mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-06 08:09:24 +00:00
171 lines
6.7 KiB
JavaScript
171 lines
6.7 KiB
JavaScript
import { appendFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { setTimeout } from "node:timers/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
import { integer } from "./partition.mjs";
|
|
import { settings } from "./runtime.mjs";
|
|
|
|
export async function coordinate(options, dependencies = {}) {
|
|
const {
|
|
repository,
|
|
runId,
|
|
attempt,
|
|
count,
|
|
token,
|
|
apiUrl = "https://api.github.com",
|
|
timeoutMs = 540000,
|
|
pollMs = 5000,
|
|
artifactGraceMs = 15000,
|
|
} = options;
|
|
if (!/^[\w.-]+\/[\w.-]+$/.test(repository)) throw new Error("Invalid repository");
|
|
for (const [name, value] of Object.entries({ runId, attempt, count })) integer(value, name);
|
|
for (const [name, value, limit] of [
|
|
["timeoutMs", timeoutMs, 540000],
|
|
["pollMs", pollMs, 5000],
|
|
["artifactGraceMs", artifactGraceMs, 15000],
|
|
]) {
|
|
if (integer(value, name) > limit) throw new Error(`${name} exceeds ${limit}`);
|
|
}
|
|
if (!token) throw new Error("Missing GitHub token");
|
|
const request = dependencies.fetch || fetch;
|
|
const now = dependencies.now || (() => performance.now());
|
|
const sleep = dependencies.sleep || setTimeout;
|
|
const log = dependencies.log || console.log;
|
|
const start = now();
|
|
const deadline = start + timeoutMs;
|
|
const path = `/repos/${repository}/actions/runs/${runId}`;
|
|
const names = Array.from({ length: count }, (_, index) => `UI unit shard ${index + 1}`);
|
|
const previousJobs = new Map();
|
|
let readySince;
|
|
let pending = "shard jobs";
|
|
let requests = 0;
|
|
|
|
async function get(endpoint) {
|
|
const remaining = deadline - now();
|
|
if (remaining <= 0) throw new Error(`Timed out waiting for ${pending}`);
|
|
requests++;
|
|
const response = await request(`${apiUrl}${endpoint}`, {
|
|
headers: {
|
|
authorization: `Bearer ${token}`,
|
|
accept: "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
signal: AbortSignal.timeout(Math.max(1, Math.ceil(Math.min(15000, remaining)))),
|
|
});
|
|
if (!response.ok)
|
|
throw new Error(`GitHub API failed: HTTP ${response.status} after ${requests} requests`);
|
|
return response.json();
|
|
}
|
|
|
|
async function pages(endpoint, field) {
|
|
const records = [];
|
|
for (let page = 1; ; page++) {
|
|
if (page > 100) throw new Error(`GitHub ${field} pagination exceeded 100 pages`);
|
|
const result = await get(`${endpoint}?per_page=100&page=${page}`);
|
|
if (!Array.isArray(result[field])) throw new Error(`Invalid GitHub ${field} response`);
|
|
records.push(...result[field]);
|
|
if (result[field].length < 100) return records;
|
|
}
|
|
}
|
|
|
|
while (now() < deadline) {
|
|
const run = await get(path);
|
|
if (run.id !== runId || run.run_attempt !== attempt) {
|
|
throw new Error("Workflow run or attempt changed while waiting");
|
|
}
|
|
if (run.status === "completed" || run.conclusion === "cancelled") {
|
|
throw new Error(`Workflow ended while waiting: ${run.conclusion}`);
|
|
}
|
|
const latest = new Map();
|
|
for (let current = attempt; current >= 1 && latest.size < count; current--) {
|
|
let jobs = previousJobs.get(current);
|
|
if (!jobs) {
|
|
jobs = await pages(`${path}/attempts/${current}/jobs`, "jobs");
|
|
if (
|
|
current < attempt &&
|
|
jobs.every((job) => !names.includes(job.name) || job.status === "completed")
|
|
) {
|
|
previousJobs.set(current, jobs);
|
|
}
|
|
}
|
|
const seen = new Set();
|
|
for (const job of jobs) {
|
|
if (!names.includes(job.name)) continue;
|
|
if (seen.has(job.name)) throw new Error(`Duplicate shard job: ${job.name}`);
|
|
seen.add(job.name);
|
|
if (!latest.has(job.name)) latest.set(job.name, { ...job, attempt: current });
|
|
}
|
|
}
|
|
const waiting = [];
|
|
for (const name of names) {
|
|
const job = latest.get(name);
|
|
if (job && job.run_id !== runId) {
|
|
throw new Error(`Wrong workflow run for ${name}`);
|
|
}
|
|
if (job?.status === "completed" && job.conclusion !== "success") {
|
|
throw new Error(`${name} ended with ${job.conclusion}; rerun failed jobs`);
|
|
}
|
|
// The coordinator's terminal result is checked by the dependent required gate.
|
|
if (name === names.at(-1)) {
|
|
if (job?.attempt === attempt && job.status === "in_progress") continue;
|
|
waiting.push(name);
|
|
continue;
|
|
}
|
|
if (!job || job.status !== "completed") waiting.push(name);
|
|
}
|
|
if (waiting.length) {
|
|
pending = waiting.join(", ");
|
|
readySince = undefined;
|
|
} else {
|
|
readySince ??= now();
|
|
const artifacts = await pages(`${path}/artifacts`, "artifacts");
|
|
const ids = [];
|
|
const missing = [];
|
|
for (let index = 1; index <= count; index++) {
|
|
const name = `ui-unit-shard-${index}-${count}`;
|
|
const matches = artifacts.filter((artifact) => artifact.name === name);
|
|
if (matches.length > 1) throw new Error(`Duplicate artifact: ${name}`);
|
|
if (!matches.length) {
|
|
missing.push(name);
|
|
continue;
|
|
}
|
|
const artifact = matches[0];
|
|
if (artifact.expired) throw new Error(`Expired artifact: ${name}; rerun all jobs`);
|
|
ids.push(integer(artifact.id, "Artifact ID"));
|
|
}
|
|
if (!missing.length) {
|
|
log(
|
|
`All shard producers succeeded; waited ${Math.ceil((now() - start) / 1000)}s with ${requests} API requests`,
|
|
);
|
|
return ids;
|
|
}
|
|
pending = `missing artifacts: ${missing.join(", ")}`;
|
|
if (now() - readySince >= artifactGraceMs) {
|
|
throw new Error(`${pending}; artifacts may have expired, rerun all jobs`);
|
|
}
|
|
}
|
|
log(`Waiting for ${pending} (${Math.ceil((now() - start) / 1000)}s elapsed)`);
|
|
await sleep(Math.min(pollMs, Math.max(0, deadline - now())));
|
|
}
|
|
throw new Error(
|
|
`Timed out waiting for ${pending}; rerun failed jobs or all jobs if artifacts expired`,
|
|
);
|
|
}
|
|
|
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const ids = await coordinate({
|
|
repository: process.env.GITHUB_REPOSITORY,
|
|
runId: integer(process.env.GITHUB_RUN_ID, "Run ID"),
|
|
attempt: integer(process.env.GITHUB_RUN_ATTEMPT, "Run attempt"),
|
|
count: settings(root).shards,
|
|
token: process.env.GITHUB_TOKEN,
|
|
apiUrl: process.env.GITHUB_API_URL,
|
|
timeoutMs: integer(process.env.UI_TEST_WAIT_TIMEOUT_MS || 540000, "Wait timeout"),
|
|
pollMs: integer(process.env.UI_TEST_POLL_INTERVAL_MS || 5000, "Poll interval"),
|
|
artifactGraceMs: integer(process.env.UI_TEST_ARTIFACT_GRACE_MS || 15000, "Artifact grace"),
|
|
});
|
|
if (!process.env.GITHUB_OUTPUT) throw new Error("Missing GitHub output path");
|
|
appendFileSync(process.env.GITHUB_OUTPUT, `artifact_ids=${ids.join(",")}\n`);
|
|
}
|