- @groenenboomj
- @jewelkm89
- @subinz1
- @NayanNagabhushana-28
PyTorch HUD (hud.pytorch.org) currently only ingests test results from in-tree CI workflows. Out-of-tree (OOT) backends — custom accelerators, downstream device integrations — have no supported path to surface their CI results on the dashboard.
This RFC defines the HUD ingestion and display layer for OOT CI results. It builds on the cross-repository CI relay system (RFC-0050) which handles event dispatch and downstream triggering. This RFC covers:
- The write path: how downstream CI results flow from the relay's Result Handler into DynamoDB and ClickHouse
- The read path: three new HUD pages that display OOT CI health
- Storage schemas: DynamoDB table and ClickHouse table designs
- DB protection: rate limiting and payload caps
- Security: authentication at each hop, with a proposal for signed callback tokens
The complete pipeline is: Downstream CI → Result Handler → HUD API → DynamoDB → DynamoDB Stream → Replicator → ClickHouse.
Note
- Artifact storage (logs, test reports, full results) is owned and managed by each downstream organization. HUD only stores URLs and links to externally-hosted artifacts.
- This design follows the DynamoDB → ClickHouse replication pattern prescribed by PyTorch infrastructure maintainers.
With PyTorch's growing ecosystem of OOT backends (custom accelerators, partner hardware, etc.), there is increasing need for visibility into how upstream changes affect downstream projects. The relay system (RFC-0050) solves the dispatch problem — triggering downstream CI when a PyTorch PR is opened. But dispatch alone is not enough: results need to flow back and be displayed where maintainers and contributors can see them.
Without a standardized HUD integration:
- Downstream maintainers have no central place to monitor the health of their backend against PyTorch trunk.
- PyTorch maintainers have no visibility into whether a PR breaks OOT backends, even at the informational L2 level.
- PR authors cannot see OOT CI results alongside their in-tree checks without visiting each downstream repo individually.
This RFC fills that gap by defining how OOT results are ingested, stored, and displayed on HUD.
The system has four components on the write path and three views on the read path.
Write path:
- Result Handler (existing, from relay): receives callbacks from downstream CI, verifies OIDC, checks allowlist, forwards to HUD
- HUD API (
/api/oot/results): validates auth (x-hud-internal-bot), enforces payload caps, writes to DynamoDB viaUpdateItem - DynamoDB (
torchci-oot-workflow-job): mutable storage supporting the two-callback model (in_progress→completed) - ClickHouse (
default.oot_workflow_job): analytical storage, replicated automatically via the existingclickhouse-replicator-dynamoreplicator
Read path:
- Global CRCR Summary (
/crcr): cross-repo health overview for CI maintainers - Per-Backend Dashboard (
/crcr/[org]/[repo]): detailed job grid for a single downstream repo - PR View Integration (
/pr/[number]): collapsible CRCR section on existing PR pages
flowchart TD
subgraph trigger [Trigger]
PR["PR opened in\npytorch/pytorch"]
PR --> WH[webhook_handler]
WH --> DS["Downstream CI\n(OOT backend)"]
end
subgraph writePath [Write Path]
subgraph callbacks [Status Callbacks + Artifact URLs]
DS -->|"First job starts"| CB1["POST in_progress\nto Result Handler"]
DS -->|"Last job finishes"| CB2["POST completed\n+ artifact URLs\nto Result Handler"]
end
subgraph relay [Relay Server]
CB1 --> RH[result_handler]
CB2 --> RH
RH -->|"Verify OIDC\nCheck allowlist\nRate limit"| FWD["Forward to\nHUD API"]
end
subgraph hud [HUD]
FWD -->|"x-hud-internal-bot"| API["HUD API\n/api/oot/results"]
API -->|"Auth + write"| DDB[("DynamoDB\ntorchci-oot-workflow-job")]
end
subgraph replicator [Replicator]
DDB --> STREAM["DynamoDB Stream"]
STREAM --> REP["clickhouse-replicator-dynamo"]
REP --> CH[("ClickHouse\ndefault.oot_workflow_job")]
end
end
subgraph artifacts [Artifact Storage - Downstream-Owned]
DS --> ARTUP["Upload artifacts to\ndownstream-owned storage"]
ARTUP --> STORE[("Downstream Storage\n(org-managed)")]
end
subgraph readPath [Read Path - HUD Frontend]
CH --> DASH["Dashboard queries\n(workflow status, pass rates)"]
STORE -.->|"External links\n(from artifact_url in CH)"| HUDFE
DASH --> HUDFE["hud.pytorch.org\n/crcr /crcr/org/repo /pr/N"]
end
| Phase | Source | Destination | Auth | What Happens |
|---|---|---|---|---|
| Callback 1 (start) | First downstream job | Result Handler | OIDC token | POST in_progress status |
| Result Handler | HUD API | x-hud-internal-bot |
Forward {trusted, untrusted} payload |
|
| HUD API | DynamoDB | Service role | UpdateItem with in_progress status |
|
| DynamoDB | ClickHouse | Stream + replicator | Replicated to ClickHouse | |
| Callback 2 (end) | Last downstream job | Result Handler | OIDC token | POST completed status + test counts + failures + artifact URLs |
| Result Handler | HUD API | x-hud-internal-bot |
Forward {trusted, untrusted} payload |
|
| HUD API | DynamoDB | Service role | UpdateItem — merges completed fields into existing record |
|
| DynamoDB | ClickHouse | Stream + replicator | Replicated to ClickHouse (replaces in_progress row via SharedReplacingMergeTree) |
|
| Read | HUD Frontend | ClickHouse | Read-only | Dashboard queries: status, pass rates, durations |
| HUD Frontend | Downstream storage | Public URL | On-demand: external link to logs, full test results |
Following the relay's L2 design, each downstream workflow sends two callbacks:
Callback 1 — "In Progress" (from the first job in the downstream workflow):
- Minimal payload:
downstream_repo,pr_number,pytorch_head_sha,workflow_run_id,status: "in_progress",started_at - Written to DynamoDB via
UpdateItem— creates the record with initial fields - Replicated to ClickHouse — allows dashboards to show "running" indicators
Callback 2 — "Completed" (from the last job in the downstream workflow):
- Full payload: everything from Callback 1, plus
conclusion,completed_at, test summary counts, failed test details (as JSON array), artifact URLs (pointing to downstream-owned storage), environment metadata - Written to DynamoDB via
UpdateItem— merges completed fields into the existing record without clobberingin_progress-only fields (e.g.queue_time) - Replicated to ClickHouse —
SharedReplacingMergeTreereplaces thein_progressrow with thecompletedrow for the samedynamoKey
The two-callback model is why DynamoDB is the write target: the status changes from in_progress to completed, requiring an upsert. Both records are replicated to ClickHouse; SharedReplacingMergeTree handles the deduplication.
Each downstream organization owns and manages its artifact storage. PyTorch infrastructure does not provision, host, or manage any storage for OOT backend artifacts.
Downstream can use any publicly accessible storage: cloud object storage, GitHub Actions native artifacts, or any other URL-accessible location.
The only requirement is that the downstream provides publicly accessible URLs in the "completed" callback payload. HUD stores these URLs and renders them as external links.
This ensures:
- No PyTorch infra cost for artifact storage
- No access control complexity across N downstream orgs
- Full flexibility — downstream teams choose storage that fits their existing infra
| Step | Action | Failure Response |
|---|---|---|
| 1 | Verify OIDC token signature against GitHub JWKS | 401 Unauthorized |
| 2 | Check repository claim against cached allowlist |
403 Forbidden |
| 3 | Verify repo is authorized at L2 or above | 403 Forbidden |
| 4 | Per-repo rate limit check | 429 Too Many Requests |
| 5 | Forward validated payload to HUD API | — |
The relay's Result Handler receives the callback and produces a {trusted, untrusted} payload:
trusted— relay-generated fields:verified_repo(OIDC-proven identity),ci_metrics(relay-measuredqueue_time,execution_time), anddownstream_repo_level(allowlist-determined level:L1–L4)untrusted— downstream-reported data:callback_payload(the full callback body, passed through verbatim)
HUD always prefers trusted.verified_repo over anything self-reported in the body.
| Step | Action | Failure Response |
|---|---|---|
| 1 | Validate x-hud-internal-bot header via checkAuthWithApiToken() |
401 Unauthorized |
| 2 | Payload cap check (2MB max body) | 413 Payload Too Large |
| 3 | Extract and flatten record from {trusted, untrusted} payload |
400 Bad Request |
| 4 | Write to DynamoDB via UpdateItem |
500 Internal Server Error |
The HUD API endpoint (torchci/pages/api/oot/results.ts) follows the existing webhookToDynamo pattern:
import { checkAuthWithApiToken } from "lib/auth/auth";
import {
ApiError,
extractDynamoRecord,
validatePayloadSize,
writeToDynamo,
} from "lib/oot/ootUtils";
import type { NextApiRequest, NextApiResponse } from "next";
export const config = {
api: {
bodyParser: {
sizeLimit: "2mb",
},
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
// Auth: standard HUD internal-bot check (shared with DrCI, trymerge)
const auth = await checkAuthWithApiToken(req, res);
if (!auth.ok) {
return res.status(401).json({ error: "Unauthorized" });
}
// Payload size cap (safety net — relay should also enforce this)
const rawBody =
typeof req.body === "string" ? req.body : JSON.stringify(req.body);
validatePayloadSize(rawBody);
// Extract and write to DynamoDB via UpdateItem
// Schema validation is done by the relay before forwarding.
const body = typeof req.body === "string" ? JSON.parse(req.body) : req.body;
const record = extractDynamoRecord(body);
await writeToDynamo(record);
return res.status(200).json({
ok: true,
status: record.status,
dynamoKey: record.dynamoKey,
});
} catch (err: any) {
if (err instanceof ApiError) {
return res.status(err.statusCode).json({ error: err.message });
}
console.error("OOT results handler error:", err);
return res.status(500).json({ error: "Internal error writing to DynamoDB" });
}
}The relay sends a {trusted, untrusted} payload. The HUD utility library extracts and flattens fields into a DynamoDB record:
export interface RelayPayload {
trusted: {
verified_repo: string;
downstream_repo_level?: string; // "L1" | "L2" | "L3" | "L4"
ci_metrics?: {
queue_time?: number | null;
execution_time?: number | null;
};
};
untrusted: {
callback_payload: {
event_type: string;
delivery_id: string;
payload: {
pull_request?: { number: number; head?: { sha: string } };
repository?: { full_name: string };
};
workflow: {
schema_version?: string;
status: string;
conclusion?: string | null;
name: string;
url: string;
job_name?: string;
check_run_id?: string;
run_id?: string;
run_attempt?: number | string;
started_at?: string;
completed_at?: string;
test_results?: {
passed?: number;
failed?: number;
skipped?: number;
};
artifact_url?: string;
};
};
};
}The extractDynamoRecord function flattens this into a single record. It uses started_at and completed_at timestamps reported by the downstream action (wall-clock time when the callback was sent), rather than generating timestamps at HUD. Timing metrics (queue_time, execution_time) are only set when the relay provides a non-null value, preventing completed callbacks from clobbering queue_time with null:
export function extractDynamoRecord(payload: RelayPayload): OotWorkflowJobRecord {
const { trusted, untrusted } = payload;
const cb = untrusted.callback_payload;
const wf = cb.workflow;
const pr = cb.payload?.pull_request;
const jobName = wf.job_name ?? "default";
const checkRunId = wf.check_run_id ?? "unknown";
const runAttempt = Number(wf.run_attempt ?? 1) || 1;
const dynamoKey = `${trusted.verified_repo}/${cb.delivery_id}/${wf.name}/${jobName}/${checkRunId}`;
const record: OotWorkflowJobRecord = {
dynamoKey,
status: wf.status,
downstream_repo: trusted.verified_repo,
upstream_repo: cb.payload?.repository?.full_name ?? "pytorch/pytorch",
pr_number: pr?.number ?? 0,
pytorch_head_sha: pr?.head?.sha ?? "",
delivery_id: cb.delivery_id,
workflow_run_url: wf.url ?? "",
workflow_name: wf.name,
job_name: jobName,
check_run_id: checkRunId,
run_id: wf.run_id ?? "",
run_attempt: runAttempt,
};
if (trusted.downstream_repo_level) {
record.downstream_repo_level = trusted.downstream_repo_level;
}
// Only set timing metrics when the relay provides a non-null value.
if (trusted.ci_metrics?.queue_time != null) {
record.queue_time = trusted.ci_metrics.queue_time;
}
if (trusted.ci_metrics?.execution_time != null) {
record.execution_time = trusted.ci_metrics.execution_time;
}
if (wf.started_at) {
record.started_at = wf.started_at;
}
if (wf.artifact_url) {
record.artifact_url = wf.artifact_url;
}
if (wf.status === "completed") {
record.conclusion = wf.conclusion ?? undefined;
if (wf.completed_at) {
record.completed_at = wf.completed_at;
}
if (wf.test_results) {
const tr = wf.test_results;
if (typeof tr.passed === "number") record.passed_tests = tr.passed;
if (typeof tr.failed === "number") record.failed_tests = tr.failed;
if (typeof tr.skipped === "number") record.skipped_tests = tr.skipped;
// L2 action sends {passed, failed, skipped} without total — compute it
record.total_tests =
typeof tr.total === "number"
? tr.total
: (tr.passed ?? 0) + (tr.failed ?? 0) + (tr.skipped ?? 0);
}
}
return record;
}Schema validation is performed by the relay's Result Handler before forwarding to HUD. The relay validates required fields (delivery_id, workflow.status), status enum values, and conclusion presence on completed callbacks. HUD trusts that the relay has already validated the structure.
The HUD API writes the flattened workflow job record to DynamoDB using UpdateItem with SET expressions (not PutItem):
dynamoKey = `${trusted.verified_repo}/${delivery_id}/${workflow_name}/${job_name}/${check_run_id}`
- For "in_progress" callbacks: creates the record with initial fields (
status,started_at,queue_time, etc.) viaUpdateItem - For "completed" callbacks: merges
completedfields (conclusion,completed_at,execution_time, test counts, artifact URL) into the existing record viaUpdateItem— only non-null fields are set, soqueue_time(set duringin_progress) is preserved
This hop requires no new code. The existing infrastructure handles it:
- DynamoDB Streams (enabled on the table with
NEW_AND_OLD_IMAGES) captures every write clickhouse-replicator-dynamoreceives stream events and inserts bothin_progressandcompletedrecords into ClickHouse.SharedReplacingMergeTreehandles deduplication — when acompletedrecord arrives for the samedynamoKey, it replaces thein_progressrow after merges.- Replicator mapping (1 new line):
"torchci-oot-workflow-job": "default.oot_workflow_job"added toSUPPORTED_TABLESinlambda_function.py
Table: torchci-oot-workflow-job
| Field | Type | Description |
|---|---|---|
dynamoKey (hash key) |
String | {repo}/{delivery_id}/{workflow_name}/{job_name}/{check_run_id} |
status |
String | in_progress or completed |
downstream_repo |
String | Downstream repo org/name (from trusted.verified_repo) |
upstream_repo |
String | Upstream repo (e.g. pytorch/pytorch) |
pr_number |
Number | PyTorch PR number |
pytorch_head_sha |
String | PyTorch PR commit SHA |
delivery_id |
String | GitHub webhook delivery ID from L1 dispatch |
workflow_run_url |
String | Link to downstream GHA workflow run |
workflow_name |
String | Downstream workflow name (github.workflow) |
job_name |
String | Downstream job name (github.job) |
check_run_id |
String | GitHub-assigned unique ID per job execution (job.check_run_id) |
run_id |
String | GitHub workflow run ID (github.run_id), same across retries |
run_attempt |
Number | Workflow run attempt number (github.run_attempt) |
conclusion |
String | success, failure (set on "completed") |
queue_time |
Number | Relay-measured dispatch-to-in_progress time in seconds (null on retries) |
execution_time |
Number | Relay-measured in_progress-to-completed time in seconds |
started_at |
String | ISO 8601 timestamp (downstream-reported, set on "in_progress") |
completed_at |
String | ISO 8601 timestamp (downstream-reported, set on "completed") |
total_tests |
Number | Total test count (set on "completed") |
passed_tests |
Number | Passed test count |
failed_tests |
Number | Failed test count |
skipped_tests |
Number | Skipped test count |
failed_tests_json |
String | JSON array of failed/errored test details |
artifact_url |
String | URL to downstream-hosted artifacts |
environment |
String | JSON: {"sdk": "<version>", "device": "<hardware>", ...} |
downstream_repo_level |
String | Repo's relay level: L2, L3, L4 |
Table configuration:
- Partition key:
dynamoKey(String) - Billing: on-demand (pay per request)
- Streams: enabled with
NEW_AND_OLD_IMAGES
Table: default.oot_workflow_job
Based on the existing default.workflow_job schema with OOT-specific additions:
CREATE TABLE default.oot_workflow_job
(
`dynamoKey` String,
`status` String,
`downstream_repo` String COMMENT 'Downstream repo org/name, from trusted.verified_repo',
`upstream_repo` String COMMENT 'Upstream repo, typically pytorch/pytorch',
`pr_number` UInt64 COMMENT 'PyTorch PR number',
`pytorch_head_sha` String COMMENT 'PyTorch PR commit SHA',
`delivery_id` String COMMENT 'GitHub webhook delivery ID from L1 dispatch',
`workflow_run_url` String COMMENT 'Link to downstream GHA workflow run',
`workflow_name` String COMMENT 'Downstream workflow name',
`job_name` String DEFAULT '' COMMENT 'Downstream job name (github.job)',
`check_run_id` String DEFAULT '' COMMENT 'GitHub-assigned unique ID per job execution (job.check_run_id)',
`run_id` String DEFAULT '' COMMENT 'GitHub workflow run ID (github.run_id), same across retries',
`run_attempt` UInt32 DEFAULT 1 COMMENT 'Workflow run attempt number (github.run_attempt)',
`conclusion` String COMMENT 'success, failure, cancelled, timed_out (set on completed)',
`queue_time` Nullable(Float64) COMMENT 'Relay-measured dispatch-to-in_progress time in seconds',
`execution_time` Nullable(Float64) COMMENT 'Relay-measured in_progress-to-completed time in seconds',
`started_at` DateTime64(9) COMMENT 'ISO 8601 timestamp when record was created',
`completed_at` DateTime64(9) COMMENT 'ISO 8601 timestamp when job completed',
`total_tests` UInt64 DEFAULT 0,
`passed_tests` UInt64 DEFAULT 0,
`failed_tests` UInt64 DEFAULT 0,
`skipped_tests` UInt64 DEFAULT 0,
`failed_tests_json` String DEFAULT '' COMMENT 'JSON array of failed/errored test details',
`artifact_url` String DEFAULT '' COMMENT 'URL to downstream-hosted artifacts (logs, reports)',
`environment` String DEFAULT '' COMMENT 'JSON: {"cuda": "12.8", "device": "H100", ...}',
`downstream_repo_level` String DEFAULT '' COMMENT 'Relay level at dispatch time: L2, L3, L4',
`_inserted_at` DateTime MATERIALIZED now(),
`repository_full_name` String ALIAS downstream_repo COMMENT 'Alias for consistency with workflow_job queries',
`duration_seconds` Float64 ALIAS if(completed_at = toDateTime64(0, 9), 0, dateDiff(second, started_at, completed_at)),
INDEX status_index status TYPE bloom_filter GRANULARITY 1,
INDEX started_at_index started_at TYPE minmax GRANULARITY 1,
INDEX completed_at_index completed_at TYPE minmax GRANULARITY 1,
INDEX pr_number_index pr_number TYPE bloom_filter GRANULARITY 1,
INDEX downstream_repo_index downstream_repo TYPE bloom_filter GRANULARITY 1
)
ENGINE = SharedReplacingMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}')
ORDER BY (downstream_repo, delivery_id, dynamoKey)
SETTINGS index_granularity = 8192Key design decisions:
SharedReplacingMergeTreefor upsert semantics: when a "completed" callback arrives, it replaces the "in_progress" row for the samedynamoKeyrepository_full_nameas ALIAS: provides naming consistency with existingworkflow_jobqueries without storing redundant dataduration_secondsas computed ALIAS: avoids manual duration calculation- Bloom filter indexes on frequently filtered columns for efficient dashboard queries
| Storage | Retention | Mechanism |
|---|---|---|
| DynamoDB | Indefinite (low volume) | DynamoDB TTL can be added if needed |
| ClickHouse | Follows workflow_job table retention |
Same TTL/partition dropping as in-tree |
| Downstream artifacts | Downstream org's policy | Managed by downstream org |
At the relay (per-repo)
Key: oot:rate:{verified_repo}
Value: counter (atomic INCR)
TTL: 1 minute sliding window
Limit: 60 requests/minute per repo (configurable via RATE_LIMIT_PER_MIN)
Rejects at the relay before any HUD/DB traffic. First line of defense against runaway CI loops.
| Limit | Value | Enforced at |
|---|---|---|
| Max request body | 2 MB | HUD API (bodyParser.sizeLimit) |
Max failed_tests_json entries |
1,000 per request | HUD API schema validation |
Max stacktrace length |
4 KB per test | HUD API (truncate before write) |
Max message length |
1 KB per test | HUD API (truncate before write) |
| Hop | From → To | Auth Mechanism | Credential Scope |
|---|---|---|---|
| 1 | Downstream → Result Handler | OIDC token (GHA-issued, 5 min TTL) | No secrets needed by downstream |
| 2 | Result Handler → HUD API | x-hud-internal-bot header |
Standard HUD internal API token (INTERNAL_API_TOKEN) |
| 3 | HUD API → DynamoDB | Service role | Write to torchci-oot-workflow-job only |
| 4 | DynamoDB → ClickHouse | Replicator service role | Existing replicator credentials |
Security properties:
| Property | How |
|---|---|
| Downstream never gets DB/HUD credentials | OIDC only |
| Unknown repos rejected before HUD | Allowlist at relay (cached) |
| HUD only accepts internal-bot traffic | x-hud-internal-bot header checked via checkAuthWithApiToken() |
| Runaway CI caught early | Rate limit at relay |
| DB overload prevented | Payload caps |
| Artifact storage not PyTorch's burden | Each downstream org manages their own |
The relay's callback security model is based on GitHub Actions OIDC:
- Downstream workflow declares
permissions: id-token: write - GitHub's OIDC provider mints a JWT signed with RS256 (5-minute TTL)
- Token contains claims:
repository,actor,ref,run_id - Result Handler verifies the signature against GitHub's public JWKS
- Extracts
repositoryclaim asverified_repo— the only cryptographically trusted identity
What OIDC gives us:
- Zero secret management — downstream never handles API keys
- Caller identity — cryptographic proof of which repo is calling
- Short-lived — 5-minute TTL, auto-expires
What OIDC does NOT give us:
- No dispatch verification — proves who is calling, not whether they were asked to call
- No replay protection — a new OIDC token can be minted at any time
- No scope-locking — doesn't bind the callback to a specific PR or SHA
The relay separates the forwarded payload into two namespaces:
trusted— relay-generated:verified_repo(OIDC-proven) andci_metrics(relay-measured timing)untrusted— downstream-reported:callback_payload(passed through verbatim)
HUD always prefers trusted.verified_repo over anything self-reported.
The relay's forward_to_hud function handles HUD API errors:
- 4xx errors (validation, auth failures): propagated back to downstream so workflow authors see a red CI step and can fix their payload
- 5xx and network failures: retried by the relay's internal retry loop (in
hud.py). If all retries fail, the error is raised — the downstream CI step fails
The relay returns {"ok": true, "status": "<status>"} on success:
"in_progress"/"completed"— HUD received and stored the callback"ignored"— repo is not configured for L2+ features"skipped"— noHUD_API_URLconfigured (local dev)
Note: The composite callback action (
cross-repo-ci-relay-callback) does not retry independently — retries were removed (#8145) because they caused duplicate state transitions in the Redis state machine (the samecheck_run_idwould re-attempt an already-completed transition). The relay'shud.pyhandles retries internally.
To close gaps in dispatch provenance and replay prevention, we propose adding a signed callback token minted by L1 at dispatch time:
How it works:
L1 Dispatch:
1. Mint JWT: sign({delivery_id, repo, pr_number, head_sha, dispatched_at}, SECRET)
2. Store one-shot key in Redis: crcr:token:{delivery_id}:{repo} = valid (TTL 3 days)
3. Include token in client_payload → downstream receives it
L2 Callback:
1. Downstream echoes callback_token in the callback body
2. Result Handler verifies JWT signature (proves L1 minted it)
3. Checks Redis key exists (proves token hasn't been consumed)
4. Validates delivery_id + repo in token match OIDC verified_repo
5. On "completed" callback: delete Redis key (one-shot, consumed)
6. Forward to HUD
What the callback token adds:
| Concern | OIDC Only | OIDC + Callback Token |
|---|---|---|
| Dispatch provenance | Can't verify | Signed proof |
| Replay prevention | Vulnerable | One-shot Redis key |
| Fabrication blocking | Identity check only | Token locks to specific PR/SHA |
| Orphaned job detection | No way to know | Unconsumed Redis key = orphaned |
| Queue time (trusted) | Redis lookup, can miss | Embedded in token, tamper-proof |
| Per-dispatch rate limiting | Per-repo only | Exactly 2 callbacks per dispatch |
| L3/L4 merge gating | Unsafe (self-reported) | Cryptographic proof of dispatch |
Note
The callback token is not strictly required for L2 (dashboard only). However, L1 is where the token gets minted, and once L1 is deployed and downstream repos depend on the current client_payload shape, retrofitting it becomes a breaking change. We recommend adding it now as optional groundwork for L3/L4.
The relay implements a strict 3-state machine enforced via Redis. Each callback is validated against the current state before processing:
stateDiagram-v2
direction LR
[*] --> DISPATCHED: webhook sends
DISPATCHED --> IN_PROGRESS: first callback
IN_PROGRESS --> COMPLETED: completion
IN_PROGRESS --> IN_PROGRESS: ❌ duplicate
DISPATCHED --> COMPLETED: ❌ skip IN_PROGRESS
COMPLETED --> COMPLETED: ❌ duplicate
COMPLETED --> IN_PROGRESS: ❌ wrong direction
[*] --> IN_PROGRESS: ❌ no dispatch
[*] --> COMPLETED: ❌ no dispatch
State types:
DISPATCHED: Repo-level state (check_run_id=DISPATCH_CHECK_RUN_ID) — one per{delivery_id, repo}, set by the webhook handler at dispatch timeIN_PROGRESS/COMPLETED: Job-level states — independent percheck_run_id
Valid flow: DISPATCHED → IN_PROGRESS → COMPLETED (linear, no shortcuts)
Rejected: Skipping IN_PROGRESS, duplicate COMPLETED, missing DISPATCHED, or wrong-direction transitions
The state key uses check_run_id (GitHub-assigned, unique per job execution) rather than run_id:
Key: oot:state:{delivery_id}:{verified_repo}:{check_run_id}
TTL: 3 days (configurable via OOT_STATUS_TTL, default 259200s)
Reruns get a new check_run_id and are treated as separate jobs, so they don't violate the state machine.
Cross-repo overview for CI maintainers. Displays a table of all OOT backend repositories sorted by pass rate (worst first), with columns for pass rate, success/failure counts, average duration, and last run time. Includes a time range selector (24h / 7d / 30d).
Clicking a row navigates to the per-backend dashboard.
ClickHouse saved query (oot_summary):
SELECT
downstream_repo AS repo,
anyLast(downstream_repo_level) AS downstream_repo_level,
countIf(conclusion = 'success') AS successes,
countIf(conclusion = 'failure') AS failures,
count() AS total,
if(total > 0, successes / total, 0) AS pass_rate,
avg(duration_seconds) AS avg_duration_s,
max(started_at) AS last_run
FROM
default.oot_workflow_job FINAL
WHERE
started_at > now() - INTERVAL {days: UInt64} DAY
AND status = 'completed'
GROUP BY
repo
ORDER BY
pass_rate ASCFrontend implementation uses useSWR with the ClickHouse query API and renders a Material-UI table with color-coded pass rate chips (green ≥ 95%, yellow ≥ 80%, red < 80%).
Detailed CI health view for a single downstream backend. Displays:
- Header: repo name, overall health (pass rate), environment summary
- Matrix view: rows = PyTorch PRs, columns = OOT jobs. Color-coded status chips: green =
success, red =failure, yellow =cancelled/timed_out, blue =in_progress - Failure drill-down: click a red cell to see failed test details (parsed from
failed_tests_json) - External links: "View artifacts" → downstream-hosted logs; "View workflow run" → downstream GHA run URL
ClickHouse saved query (oot_backend_dashboard):
SELECT
pr_number,
pytorch_head_sha,
workflow_name,
job_name,
check_run_id,
run_id,
run_attempt,
status,
conclusion,
started_at,
completed_at,
duration_seconds,
total_tests,
passed_tests,
failed_tests,
skipped_tests,
workflow_run_url,
artifact_url,
queue_time,
execution_time
FROM
default.oot_workflow_job FINAL
WHERE
downstream_repo = {repo: String}
AND started_at > now() - INTERVAL {days: UInt64} DAY
ORDER BY
started_at DESC
LIMIT 500A collapsible "Out-of-Tree Backends" accordion added to the existing PR detail page. Only rendered when OOT results exist for the PR. Shows:
- Backend name, job name, status chip (color-coded), duration, links to run/artifacts
- Summary in the accordion header:
"3/4 passed, 1 running" in_progressruns show a spinner-style "running" chip
ClickHouse saved query (oot_pr_results):
SELECT
downstream_repo,
workflow_name,
job_name,
check_run_id,
run_id,
run_attempt,
status,
conclusion,
duration_seconds,
workflow_run_url,
artifact_url,
started_at,
queue_time,
execution_time
FROM
default.oot_workflow_job FINAL
WHERE
pr_number = {pr: UInt64}
ORDER BY
downstream_repo, started_at DESCThe OotPrSection React component is integrated into the existing PR page (torchci/pages/[repoOwner]/[repoName]/pull/[prNumber].tsx) below the existing CommitInfo section.
These samples show the actual wire format at each stage of the pipeline.
The reusable GitHub Action (cross-repo-ci-relay-callback) builds this payload from github.event.client_payload (the L1 dispatch envelope) plus a workflow dict with downstream-reported fields:
In-Progress:
{
"event_type": "pull_request",
"delivery_id": "a8f5f167-6e5b-4c3d-8a2e-4b5c6d7e8f9a",
"payload": {
"pull_request": { "number": 179565, "head": { "sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" } },
"repository": { "full_name": "pytorch/pytorch" }
},
"workflow": {
"schema_version": "1",
"status": "in_progress",
"conclusion": null,
"name": "<hardware>-ci",
"url": "https://github.com/{org}/{repo}/actions/runs/24033272679",
"job_name": "test-<hardware>-float32",
"check_run_id": "98765432100",
"run_id": "24033272679",
"run_attempt": "1",
"started_at": "2025-04-28T10:15:30Z",
"completed_at": null
}
}Completed (failure):
{
"event_type": "pull_request",
"delivery_id": "a8f5f167-6e5b-4c3d-8a2e-4b5c6d7e8f9a",
"payload": {
"pull_request": { "number": 179565, "head": { "sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" } },
"repository": { "full_name": "pytorch/pytorch" }
},
"workflow": {
"schema_version": "1",
"status": "completed",
"conclusion": "failure",
"name": "<hardware>-ci",
"url": "https://github.com/{org}/{repo}/actions/runs/24033272679",
"job_name": "test-<hardware>-float32",
"check_run_id": "98765432100",
"run_id": "24033272679",
"run_attempt": "1",
"started_at": null,
"completed_at": "2025-04-28T10:45:12Z",
"test_results": {
"passed": 8430,
"failed": 2,
"skipped": 20
},
"artifact_url": "https://{org}.github.io/ci-results/183512/"
}
}The relay wraps the callback into {trusted, untrusted} namespaces. trusted fields are relay-generated; untrusted is the raw downstream payload passed through verbatim:
In-Progress:
{
"trusted": {
"verified_repo": "{org}/{repo}",
"downstream_repo_level": "L2",
"ci_metrics": {
"queue_time": 12.345,
"execution_time": null
}
},
"untrusted": {
"callback_payload": {
"event_type": "pull_request",
"delivery_id": "a8f5f167-6e5b-4c3d-8a2e-4b5c6d7e8f9a",
"payload": {
"pull_request": { "number": 179565, "head": { "sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" } },
"repository": { "full_name": "pytorch/pytorch" }
},
"workflow": {
"schema_version": "1",
"status": "in_progress",
"conclusion": null,
"name": "<hardware>-ci",
"url": "https://github.com/{org}/{repo}/actions/runs/24033272679",
"job_name": "test-<hardware>-float32",
"check_run_id": "98765432100",
"run_id": "24033272679",
"run_attempt": "1",
"started_at": "2025-04-28T10:15:30Z"
}
}
}
}Completed (failure):
{
"trusted": {
"verified_repo": "{org}/{repo}",
"downstream_repo_level": "L2",
"ci_metrics": {
"queue_time": null,
"execution_time": 1782.0
}
},
"untrusted": {
"callback_payload": {
"event_type": "pull_request",
"delivery_id": "a8f5f167-6e5b-4c3d-8a2e-4b5c6d7e8f9a",
"payload": {
"pull_request": { "number": 179565, "head": { "sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" } },
"repository": { "full_name": "pytorch/pytorch" }
},
"workflow": {
"schema_version": "1",
"status": "completed",
"conclusion": "failure",
"name": "<hardware>-ci",
"url": "https://github.com/{org}/{repo}/actions/runs/24033272679",
"job_name": "test-<hardware>-float32",
"check_run_id": "98765432100",
"run_id": "24033272679",
"run_attempt": "1",
"completed_at": "2025-04-28T10:45:12Z",
"test_results": {
"passed": 8430,
"failed": 2,
"skipped": 20
},
"artifact_url": "https://{org}.github.io/ci-results/183512/"
}
}
}
}Note on
run_attempt: The L2 callback action sendsrun_attemptas a string (fromgithub.run_attemptenv var). The HUD API coerces it to a number viaNumber()before writing to DynamoDB.
| In-Tree | OOT (this RFC) | |
|---|---|---|
| Hops to ClickHouse | 6 (job → S3 → workflow_run → upload_stats → S3 → replicator → CH) | 4 (downstream → handler → HUD → DynamoDB → stream → replicator → CH) |
| Write target | S3 (ossci-raw-job-status) → clickhouse-replicator-s3 |
DynamoDB (torchci-oot-workflow-job) → clickhouse-replicator-dynamo |
| Mutability | Immutable (write once) | Mutable (in_progress → completed via DynamoDB upsert) |
| Artifact storage | Centralized storage (LF Foundation managed) | Downstream org's own storage (any public URL) |
| Auth model | Service roles (trusted same-org infra) | OIDC → internal token → service role |
| Rate limiting | None (relies on GHA concurrency) | Per-repo at relay |
| Schema | default.workflow_job |
default.oot_workflow_job (based on workflow_job) |
| File | Action |
|---|---|
torchci/pages/api/oot/results.ts |
New — API endpoint |
torchci/lib/oot/ootUtils.ts |
New — types, validation, extraction |
clickhouse_db_schema/default.oot_workflow_job/schema.sql |
New — ClickHouse schema |
aws/lambda/clickhouse-replicator-dynamo/lambda_function.py |
Edit — +1 line to SUPPORTED_TABLES |
torchci/pages/crcr/index.tsx |
New — global CRCR summary page |
torchci/pages/crcr/[org]/[repo].tsx |
New — per-backend dashboard |
torchci/components/oot/OotPrSection.tsx |
New — PR view OOT section |
torchci/pages/[repoOwner]/[repoName]/pull/[prNumber].tsx |
Edit — added OotPrSection |
torchci/clickhouse_queries/oot_summary/* |
New — saved query |
torchci/clickhouse_queries/oot_backend_dashboard/* |
New — saved query |
torchci/clickhouse_queries/oot_pr_results/* |
New — saved query |
- Ingestion latency: time from callback to ClickHouse availability (target: < 60s)
- OOT pass rate: per-backend success rate over 7d rolling window
- HUD API error rate: 4xx/5xx rate on
/api/oot/results - Daily callback volume: per-repo and aggregate, to validate budget thresholds
- Queue time: relay-measured dispatch-to-in_progress (identifies downstream infra bottlenecks)
- Execution time: relay-measured in_progress-to-completed (identifies test suite performance trends)
- Additional ClickHouse table: introduces a new
oot_workflow_jobtable, increasing schema maintenance surface. Mitigated by reusing the existingworkflow_jobschema pattern. - DynamoDB costs: while PAY_PER_REQUEST auto-scales, high-volume OOT backends could incur non-trivial DynamoDB costs. Mitigated by rate limiting at the relay.
- Downstream adoption cost: downstream repos must implement the two-callback model (or use the reusable GitHub Action). This is an incremental cost on top of the relay integration.
- Trust boundary: even with OIDC and allowlisting, the relay trusts downstream-reported test counts and failure details. A compromised allowlisted repo could report misleading data. Mitigated by the trusted/untrusted payload split and the proposed callback token.
-
Direct ClickHouse writes from HUD API — eliminates the DynamoDB hop but conflicts with the prescribed DynamoDB → ClickHouse pattern. ClickHouse is append-only, making the two-callback mutable status model awkward.
-
Separate
oot_cidatabase in ClickHouse — would isolate OOT data but prevents reuse of existingworkflow_jobquery patterns and frontend components. Placing the table in thedefaultdatabase (asoot_workflow_job) is consistent with other tables. -
PyTorch-managed artifact storage — PyTorch infra could provision storage buckets for downstream artifacts. Rejected because it introduces significant access control complexity for N downstream orgs and shifts storage costs to PyTorch.
-
Single callback model — one callback at workflow completion. Simpler but loses the "running" indicator on HUD, and the architecture would not match the relay's existing two-callback design.
-
Polling-based result ingestion — HUD periodically polls downstream repos for results. Rejected because it requires HUD to know about each downstream repo's API, creates polling overhead, and has higher latency.
- PyTorch in-tree CI → HUD pipeline: S3-based ingestion via
clickhouse-replicator-s3. Proven at scale but immutable (no status updates). This RFC adapts the pattern for mutable OOT data via DynamoDB. webhookToDynamo.ts: existing HUD pattern for writing webhook data to DynamoDB. The OOT API endpoint follows this pattern.- RFC-0050 (Cross-Repository CI Relay): defines the relay architecture, allowlist, OIDC auth, and downstream triggering that this RFC builds upon.
-
Downstream developers need to know:
- Their CI results will appear on
hud.pytorch.org/crcr/{org}/{repo}once they reach L2 - They should include artifact URLs in their "completed" callback for debugging links on HUD
- Test counts and failed test details are optional but recommended for richer dashboard views
- Their CI results will appear on
-
PyTorch CI maintainers need to know:
/crcrprovides a global health overview of all CRCR backends- Rate limits at the relay protect ClickHouse from OOT traffic spikes
- The replicator mapping is a single line change when adding new downstream tables
-
PR authors need to know:
- OOT CI results appear in a collapsible section on their PR page (when results exist)
- At L2, these are informational only and do not block merging
No documentation reorganization is needed. The /crcr pages are self-discoverable from HUD navigation.
-
Failed test detail storageResolved: Embedded asfailed_tests_jsonString column in both DynamoDB and ClickHouse. A separate table can be added later if needed for cross-repo flaky test detection. -
Redis TTL for relay stateResolved: 3 days (259,200 seconds), configurable viaOOT_STATUS_TTLenv var on the Lambda. -
Non-GHA CI support: OIDC auth is GHA-specific. Jenkins/Buildkite would need pre-shared API keys. Deferred to a future RFC.
-
Backfill on failureResolved: The callback handler retries HUD 5xx/network errors with exponential backoff (up to 3 retries). HUD 4xx errors propagate immediately. No dead-letter queue for now. -
Callback token adoption timeline: the signed callback token strengthens security significantly but requires coordination with L1 deployment. Can be added incrementally.
-
Silent HUD forward skipResolved:HUD_API_URLis now provisioned as a Lambda default (#8147). The relay logs and returns"skipped"when the URL is not set (local dev only). -
timed_out/cancelledconclusion support: The composite action (cross-repo-ci-relay-callback) only acceptssuccessorfailureas validconclusionvalues whenstatus=completed. Clarification of accepted values is being addressed in #8173.
The implementation follows a 6-phase rollout:
| Phase | Scope | Status |
|---|---|---|
| 1. Storage Layer | DynamoDB table, ClickHouse table, replicator mapping | Complete |
| 2. HUD API Endpoint | Types, extraction, UpdateItem write logic, POST handler |
Complete |
| 3. Relay Integration | Callback handler (OIDC, allowlist, rate limiting, state machine), composite action | Complete |
| 4. HUD Frontend Pages | ClickHouse queries, /crcr summary, /crcr/[org]/[repo] dashboard, PR page section |
Complete |
| 5. End-to-End Validation | L2 test workflow, full pipeline verification (callback → Lambda → HUD → DynamoDB → ClickHouse) | Complete |
| 6. Security Hardening | Signed callback token, token verification, orphaned job detection | Planned |
Note: The 3-state machine (
DISPATCHED → IN_PROGRESS → COMPLETED) is already implemented in Phase 3. Phase 6 covers the additional callback token for dispatch provenance and replay prevention.
- L3/L4 upstream check run management (device-based allowlist, check run creation, re-run support)
- State machine key migration:
check_run_id→{run_id}:{run_attempt} - Clarify accepted
conclusionvalues in callback action - Add CRCR CI Summary link to HUD navigation bar
- Onboard real downstream backends
Phases 1–5 are complete — all code is merged into pytorch/test-infra and the end-to-end pipeline is live. Dispatches from pytorch/crcr-test flow through the full pipeline: Downstream CI → Callback Lambda → HUD API → DynamoDB → ClickHouse → HUD pages at hud.pytorch.org/crcr.
Active work is focused on L3/L4 check run management and onboarding real downstream backends.