Skip to content

Commit d6b24c2

Browse files
authored
OOT HUD: Add ClickHouse queries, utility library, and unit tests (Part 1/3) (#8110)
## Summary This is **Part 1 of 3** of the OOT HUD pipeline, split from #8069 per reviewer request. This PR adds the foundational data layer for the Out-of-Tree (OOT) HUD: - **ClickHouse queries** for three views: OOT summary dashboard, per-backend dashboard, and PR-level results - **Shared utility library** (`torchci/lib/oot/ootUtils.ts`) with: - Relay payload types (`RelayTrusted`, `RelayWorkflow`, `RelayCallbackPayload`, etc.) - DynamoDB record extraction and validation (`extractDynamoRecord`) - UpdateItem write logic (`writeToDynamo`) - Payload size validation (`validatePayloadSize`) - UI helpers (`conclusionColor`, `conclusionLabel`) - `ApiError` class for structured HTTP error responses - **Unit tests** (`torchci/test/ootUtils.test.ts`) covering: - Field mapping and DynamoDB key construction - Required field validation (job_name, check_run_id) - Test result computation (total from passed+failed+skipped) - Timing metric handling - Payload size validation - UI helper functions ### PR Stack 1. **This PR** — OOT HUD: Add ClickHouse queries, utility library, and unit tests (Part 1/3) 2. #8111 — OOT HUD: Add frontend components — summary, dashboard, PR section (Part 2/3) 3. #8112 — OOT HUD: Add API endpoint, PR page integration, and replicator mapping (Part 3/3) ## Test Plan - `ootUtils.test.ts` covers all utility functions - ClickHouse queries can be tested once the `oot_workflow_job` table is created (see #8105)
1 parent a2bff07 commit d6b24c2

8 files changed

Lines changed: 661 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"params": {
3+
"repo": "String",
4+
"days": "UInt64"
5+
},
6+
"tests": [
7+
{
8+
"repo": "<company>/<repo>",
9+
"days": "7"
10+
}
11+
]
12+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
SELECT
2+
upstream_repo,
3+
pr_number,
4+
pytorch_head_sha,
5+
workflow_name,
6+
job_name,
7+
check_run_id,
8+
run_id,
9+
run_attempt,
10+
status,
11+
conclusion,
12+
started_at,
13+
completed_at,
14+
duration_seconds,
15+
total_tests,
16+
passed_tests,
17+
failed_tests,
18+
skipped_tests,
19+
workflow_run_url,
20+
artifact_url,
21+
queue_time,
22+
execution_time
23+
FROM
24+
default.oot_workflow_job FINAL
25+
WHERE
26+
downstream_repo = {repo: String}
27+
AND started_at > now() - INTERVAL {days: UInt64} DAY
28+
ORDER BY
29+
started_at DESC
30+
LIMIT 500
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"params": {
3+
"pr": "UInt64"
4+
},
5+
"tests": [
6+
{
7+
"pr": "179565"
8+
}
9+
]
10+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
SELECT
2+
downstream_repo,
3+
workflow_name,
4+
job_name,
5+
check_run_id,
6+
run_id,
7+
run_attempt,
8+
status,
9+
conclusion,
10+
duration_seconds,
11+
workflow_run_url,
12+
artifact_url,
13+
started_at,
14+
queue_time,
15+
execution_time
16+
FROM
17+
default.oot_workflow_job FINAL
18+
WHERE
19+
pr_number = {pr: UInt64}
20+
ORDER BY
21+
downstream_repo, started_at DESC
22+
LIMIT 100
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"params": {
3+
"days": "UInt64"
4+
},
5+
"tests": [
6+
{
7+
"days": "7"
8+
}
9+
]
10+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
SELECT
2+
downstream_repo AS repo,
3+
anyLast(downstream_repo_level) AS downstream_repo_level,
4+
countIf(conclusion = 'success') AS successes,
5+
countIf(conclusion = 'failure') AS failures,
6+
count() AS total,
7+
if(total > 0, successes / total, 0) AS pass_rate,
8+
avg(duration_seconds) AS avg_duration_s,
9+
max(started_at) AS last_run
10+
FROM
11+
default.oot_workflow_job FINAL
12+
WHERE
13+
started_at > now() - INTERVAL {days: UInt64} DAY
14+
AND status = 'completed'
15+
GROUP BY
16+
repo
17+
ORDER BY
18+
pass_rate ASC

torchci/lib/oot/ootUtils.ts

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import { getDynamoClient } from "lib/dynamo";
2+
3+
const OOT_TABLE = "torchci-oot-workflow-job";
4+
const MAX_PAYLOAD_BYTES = 2 * 1024 * 1024; // 2MB
5+
6+
// ---- Types ----
7+
8+
export interface RelayTrusted {
9+
verified_repo: string;
10+
downstream_repo_level?: string; // "L1" | "L2" | "L3" | "L4" — relay-determined from allowlist
11+
ci_metrics?: {
12+
queue_time?: number | null;
13+
execution_time?: number | null;
14+
};
15+
}
16+
17+
export interface RelayWorkflow {
18+
schema_version?: string;
19+
status: string;
20+
conclusion?: string | null;
21+
name: string;
22+
url: string;
23+
job_name?: string;
24+
check_run_id?: string;
25+
run_id?: string;
26+
run_attempt?: number | string;
27+
started_at?: string;
28+
completed_at?: string;
29+
test_results?: {
30+
passed?: number;
31+
failed?: number;
32+
skipped?: number;
33+
total?: number;
34+
};
35+
artifact_url?: string;
36+
}
37+
38+
export interface RelayCallbackPayload {
39+
event_type: string;
40+
delivery_id: string;
41+
payload: {
42+
pull_request?: { number: number; head?: { sha: string } };
43+
repository?: { full_name: string };
44+
[key: string]: any;
45+
};
46+
workflow: RelayWorkflow;
47+
}
48+
49+
export interface RelayUntrusted {
50+
callback_payload: RelayCallbackPayload;
51+
}
52+
53+
export interface RelayPayload {
54+
trusted: RelayTrusted;
55+
untrusted: RelayUntrusted;
56+
}
57+
58+
export interface OotWorkflowJobRecord {
59+
dynamoKey: string;
60+
status: string;
61+
downstream_repo: string;
62+
upstream_repo: string;
63+
pr_number: number;
64+
pytorch_head_sha: string;
65+
delivery_id: string;
66+
workflow_run_url: string;
67+
workflow_name: string;
68+
job_name: string;
69+
check_run_id: string;
70+
run_id: string;
71+
run_attempt: number;
72+
conclusion?: string;
73+
queue_time?: number | null;
74+
execution_time?: number | null;
75+
started_at?: string;
76+
completed_at?: string;
77+
total_tests?: number;
78+
passed_tests?: number;
79+
failed_tests?: number;
80+
skipped_tests?: number;
81+
downstream_repo_level?: string;
82+
artifact_url?: string;
83+
environment?: string;
84+
}
85+
86+
// ---- Validation ----
87+
88+
export function validatePayloadSize(bodyString: string): void {
89+
if (Buffer.byteLength(bodyString, "utf-8") > MAX_PAYLOAD_BYTES) {
90+
throw new ApiError(413, "Payload exceeds 2MB limit");
91+
}
92+
}
93+
94+
// ---- Extraction ----
95+
96+
export function extractDynamoRecord(
97+
payload: RelayPayload
98+
): OotWorkflowJobRecord {
99+
const { trusted, untrusted } = payload;
100+
const cb = untrusted.callback_payload;
101+
const wf = cb.workflow;
102+
const pr = cb.payload?.pull_request;
103+
const upstreamRepo = cb.payload?.repository?.full_name ?? "pytorch/pytorch";
104+
105+
if (!wf.job_name) {
106+
throw new ApiError(400, "Missing required field: workflow.job_name");
107+
}
108+
if (wf.check_run_id == null) {
109+
throw new ApiError(400, "Missing required field: workflow.check_run_id");
110+
}
111+
const jobName = wf.job_name;
112+
const checkRunId = String(wf.check_run_id);
113+
const runAttempt = Number(wf.run_attempt ?? 1) || 1;
114+
const dynamoKey = `${trusted.verified_repo}/${cb.delivery_id}/${wf.name}/${jobName}/${checkRunId}`;
115+
116+
const record: OotWorkflowJobRecord = {
117+
dynamoKey,
118+
status: wf.status,
119+
downstream_repo: trusted.verified_repo,
120+
upstream_repo: upstreamRepo,
121+
pr_number: pr?.number ?? 0,
122+
pytorch_head_sha: pr?.head?.sha ?? "",
123+
delivery_id: cb.delivery_id,
124+
workflow_run_url: wf.url ?? "",
125+
workflow_name: wf.name,
126+
job_name: jobName,
127+
check_run_id: checkRunId,
128+
run_id: wf.run_id ?? "",
129+
run_attempt: runAttempt,
130+
};
131+
132+
if (trusted.downstream_repo_level) {
133+
record.downstream_repo_level = trusted.downstream_repo_level;
134+
}
135+
136+
// Only set timing metrics when the relay provides a non-null value.
137+
// in_progress sets queue_time; completed sets execution_time.
138+
// Using UpdateItem ensures the completed callback doesn't clobber
139+
// queue_time with null.
140+
if (trusted.ci_metrics?.queue_time != null) {
141+
record.queue_time = trusted.ci_metrics.queue_time;
142+
}
143+
if (trusted.ci_metrics?.execution_time != null) {
144+
record.execution_time = trusted.ci_metrics.execution_time;
145+
}
146+
147+
// Use downstream-reported timestamps, not HUD wall-clock time
148+
if (wf.started_at) {
149+
record.started_at = wf.started_at;
150+
}
151+
152+
if (wf.artifact_url) {
153+
record.artifact_url = wf.artifact_url;
154+
}
155+
156+
if (wf.status === "completed") {
157+
record.conclusion = wf.conclusion ?? undefined;
158+
if (wf.completed_at) {
159+
record.completed_at = wf.completed_at;
160+
}
161+
162+
if (wf.test_results) {
163+
const tr = wf.test_results;
164+
if (typeof tr.passed === "number") record.passed_tests = tr.passed;
165+
if (typeof tr.failed === "number") record.failed_tests = tr.failed;
166+
if (typeof tr.skipped === "number") record.skipped_tests = tr.skipped;
167+
record.total_tests =
168+
typeof tr.total === "number"
169+
? tr.total
170+
: (tr.passed ?? 0) + (tr.failed ?? 0) + (tr.skipped ?? 0);
171+
}
172+
}
173+
174+
return record;
175+
}
176+
177+
// ---- DynamoDB Write (UpdateItem) ----
178+
179+
export async function writeToDynamo(
180+
record: OotWorkflowJobRecord
181+
): Promise<void> {
182+
const client = getDynamoClient();
183+
184+
// Build SET expression dynamically — only set non-undefined fields.
185+
// This prevents completed callbacks from clobbering in_progress-only
186+
// fields (queue_time, started_at) with null.
187+
const expressionParts: string[] = [];
188+
const expressionValues: Record<string, any> = {};
189+
const expressionNames: Record<string, string> = {};
190+
191+
for (const [key, value] of Object.entries(record)) {
192+
if (key === "dynamoKey" || value === undefined) continue;
193+
const placeholder = `:v_${key}`;
194+
const nameAlias = `#n_${key}`;
195+
expressionParts.push(`${nameAlias} = ${placeholder}`);
196+
expressionValues[placeholder] = value;
197+
expressionNames[nameAlias] = key;
198+
}
199+
200+
await client.update({
201+
TableName: OOT_TABLE,
202+
Key: { dynamoKey: record.dynamoKey },
203+
UpdateExpression: `SET ${expressionParts.join(", ")}`,
204+
ExpressionAttributeValues: expressionValues,
205+
ExpressionAttributeNames: expressionNames,
206+
});
207+
}
208+
209+
// ---- UI Helpers ----
210+
211+
export type ChipColor = "success" | "error" | "warning" | "info" | "default";
212+
213+
export function conclusionColor(status: string, conclusion: string): ChipColor {
214+
if (status === "in_progress") return "info";
215+
switch (conclusion) {
216+
case "success":
217+
return "success";
218+
case "failure":
219+
return "error";
220+
case "cancelled":
221+
case "timed_out":
222+
return "warning";
223+
default:
224+
return "default";
225+
}
226+
}
227+
228+
export function conclusionLabel(status: string, conclusion: string): string {
229+
if (status === "in_progress") return "running";
230+
return conclusion || status;
231+
}
232+
233+
// ---- Error Helper ----
234+
235+
export class ApiError extends Error {
236+
statusCode: number;
237+
constructor(statusCode: number, message: string) {
238+
super(message);
239+
this.statusCode = statusCode;
240+
}
241+
}

0 commit comments

Comments
 (0)