441 lines
12 KiB
JavaScript
441 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const TASKCLUSTER_BASE_URL =
|
|
process.env.TASKCLUSTER_PROXY_URL ||
|
|
process.env.TASKCLUSTER_ROOT_URL ||
|
|
"https://firefox-ci-tc.services.mozilla.com";
|
|
|
|
// STMO query for worker pool task data (same as workers.html dashboard)
|
|
const DATA_URL =
|
|
"https://sql.telemetry.mozilla.org/api/queries/112377/results.json?api_key=l9PzG0DxDTNPhLWSRtZm9DdUtjjW7XN5i0T85Twy";
|
|
|
|
const OUTPUT_DIR = (() => {
|
|
const idx = process.argv.findIndex(arg => arg === "--output-dir");
|
|
if (idx !== -1 && idx + 1 < process.argv.length) {
|
|
return process.argv[idx + 1];
|
|
}
|
|
return "./worker-data";
|
|
})();
|
|
|
|
let previousRunData = null;
|
|
|
|
if (!fs.existsSync(OUTPUT_DIR)) {
|
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
}
|
|
|
|
async function fetchJson(url) {
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
console.error(
|
|
`Failed to fetch ${url}: HTTP ${response.status} ${response.statusText}`
|
|
);
|
|
return null;
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
function saveJsonFile(data, filePath) {
|
|
fs.writeFileSync(filePath, JSON.stringify(data));
|
|
const stats = fs.statSync(filePath);
|
|
console.log(`Saved ${filePath} - ${Math.round(stats.size / 1024)}KB`);
|
|
}
|
|
|
|
// Fetch previous run metadata from Taskcluster
|
|
async function fetchPreviousRunData() {
|
|
try {
|
|
const taskUrl = `${TASKCLUSTER_BASE_URL}/api/queue/v1/task/${process.env.TASK_ID}`;
|
|
const taskData = await fetchJson(taskUrl);
|
|
if (!taskData) {
|
|
console.log(`Failed to fetch task info from ${taskUrl}`);
|
|
return;
|
|
}
|
|
|
|
const routes = taskData.routes || [];
|
|
const latestRoute = routes.find(
|
|
route => route.startsWith("index.") && route.includes(".latest.")
|
|
);
|
|
if (!latestRoute) {
|
|
console.log(
|
|
`No route found with 'index.' prefix and '.latest.' in name. Available routes: ${JSON.stringify(routes)}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const indexName = latestRoute.replace(/^index\./, "");
|
|
console.log(`Using index: ${indexName}`);
|
|
|
|
const artifactsUrl = `${TASKCLUSTER_BASE_URL}/api/index/v1/task/${indexName}/artifacts/public`;
|
|
|
|
const indexUrl = `${artifactsUrl}/index.json`;
|
|
console.log(`Fetching previous run data from ${indexUrl}`);
|
|
const indexData = await fetchJson(indexUrl);
|
|
if (!indexData) {
|
|
console.log(`Failed to fetch index.json from ${indexUrl}`);
|
|
return;
|
|
}
|
|
|
|
const dates = indexData.dates || [];
|
|
console.log(`Found ${dates.length} dates in previous run`);
|
|
|
|
previousRunData = {
|
|
dates: new Set(dates),
|
|
artifactsUrl,
|
|
};
|
|
|
|
console.log("Previous run metadata loaded\n");
|
|
} catch (error) {
|
|
console.log(`Error fetching previous run metadata: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// The STMO query covers the day before it ran.
|
|
function extractDateFromQuery(queryResult) {
|
|
const retrieved = queryResult.retrieved_at;
|
|
if (retrieved) {
|
|
const d = new Date(retrieved);
|
|
d.setUTCDate(d.getUTCDate() - 1);
|
|
return d.toISOString().split("T")[0];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Helper: create string table with findId function
|
|
function createStringTables(tableNames) {
|
|
const tables = {};
|
|
const maps = {};
|
|
for (const name of tableNames) {
|
|
tables[name] = [];
|
|
maps[name] = new Map();
|
|
}
|
|
|
|
function findId(tableName, value) {
|
|
if (value == null || value === "") {
|
|
return null;
|
|
}
|
|
const map = maps[tableName];
|
|
let id = map.get(value);
|
|
if (id === undefined) {
|
|
id = tables[tableName].length;
|
|
tables[tableName].push(value);
|
|
map.set(value, id);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
return { tables, findId };
|
|
}
|
|
|
|
// Sort string tables by frequency and remap all id arrays.
|
|
// idArrays maps table name to one or more arrays of indices into that table.
|
|
// Arrays may have different lengths (e.g. per-task vs per-worker).
|
|
function sortAndRemapTables(tables, idArrays) {
|
|
for (const [tableName, table] of Object.entries(tables)) {
|
|
const arrays = idArrays[tableName] || [];
|
|
const freq = new Array(table.length).fill(0);
|
|
|
|
for (const arr of arrays) {
|
|
for (let i = 0; i < arr.length; i++) {
|
|
if (arr[i] !== null) {
|
|
freq[arr[i]]++;
|
|
}
|
|
}
|
|
}
|
|
|
|
const order = table.map((val, idx) => ({ val, idx, count: freq[idx] }));
|
|
order.sort((a, b) => b.count - a.count || a.val.localeCompare(b.val));
|
|
|
|
const newTable = new Array(table.length);
|
|
const oldToNew = new Array(table.length);
|
|
for (let j = 0; j < order.length; j++) {
|
|
newTable[j] = order[j].val;
|
|
oldToNew[order[j].idx] = j;
|
|
}
|
|
|
|
tables[tableName] = newTable;
|
|
|
|
for (const arr of arrays) {
|
|
for (let i = 0; i < arr.length; i++) {
|
|
if (arr[i] !== null) {
|
|
arr[i] = oldToNew[arr[i]];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Encode both summary and full task data in a single pass over the rows.
|
|
// Returns { summary, taskData } ready to be serialized.
|
|
function encodeData(rows, date) {
|
|
const { tables, findId } = createStringTables([
|
|
"labels",
|
|
"projects",
|
|
"taskQueueIds",
|
|
"resolutions",
|
|
"workerGroups",
|
|
"workerIds",
|
|
"priorities",
|
|
"users",
|
|
"taskGroupIds",
|
|
]);
|
|
|
|
rows.sort((a, b) => Number(a.scheduled || 0) - Number(b.scheduled || 0));
|
|
|
|
const n = rows.length;
|
|
const metadata = {
|
|
date,
|
|
generatedAt: new Date().toISOString(),
|
|
taskCount: n,
|
|
};
|
|
|
|
const scheduled = new Array(n);
|
|
const started = new Array(n);
|
|
const resolved = new Array(n);
|
|
const resolutionIds = new Array(n);
|
|
const taskQueueIdIds = new Array(n);
|
|
const projectIds = new Array(n);
|
|
const taskIds = new Array(n);
|
|
const labelIds = new Array(n);
|
|
const priorityIds = new Array(n);
|
|
const taskGroupIdIds = new Array(n);
|
|
const userIds = new Array(n);
|
|
const workerIdIds = new Array(n);
|
|
const runCosts = new Array(n);
|
|
|
|
const workerGroupIds = new Array(n);
|
|
const rawProjectIds = new Array(n);
|
|
|
|
const absScheduled = new Array(n);
|
|
for (let i = 0; i < n; i++) {
|
|
absScheduled[i] = Number(rows[i].scheduled || 0);
|
|
}
|
|
|
|
// Differential compression for scheduled times
|
|
if (n > 0) {
|
|
scheduled[0] = absScheduled[0];
|
|
for (let i = 1; i < n; i++) {
|
|
scheduled[i] = absScheduled[i] - absScheduled[i - 1];
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const row = rows[i];
|
|
const absStarted = row.started ? Number(row.started) : null;
|
|
const absResolved = Number(row.resolved || 0);
|
|
|
|
started[i] = absStarted !== null ? absStarted - absScheduled[i] : null;
|
|
resolved[i] = absResolved - absScheduled[i];
|
|
|
|
const state = row.state || "";
|
|
const reason = row.reason_resolved || "";
|
|
resolutionIds[i] = findId(
|
|
"resolutions",
|
|
state === reason ? state : `${state} - ${reason}`
|
|
);
|
|
taskQueueIdIds[i] = findId("taskQueueIds", row.task_queue_id);
|
|
|
|
const runId = parseInt(row.run_id) || 0;
|
|
taskIds[i] = runId ? `${row.task_id || ""}.${runId}` : row.task_id || "";
|
|
|
|
labelIds[i] = findId("labels", row.label);
|
|
priorityIds[i] = findId("priorities", row.priority);
|
|
|
|
taskGroupIdIds[i] = findId("taskGroupIds", row.task_group_id);
|
|
rawProjectIds[i] = findId("projects", row.project);
|
|
|
|
userIds[i] = findId("users", row.created_for_user);
|
|
|
|
workerIdIds[i] = findId("workerIds", row.worker_id);
|
|
workerGroupIds[i] = findId("workerGroups", row.worker_group);
|
|
|
|
runCosts[i] =
|
|
row.run_cost != null
|
|
? Math.round(parseFloat(row.run_cost) * 1e6) / 1e6
|
|
: 0;
|
|
}
|
|
|
|
sortAndRemapTables(tables, {
|
|
labels: [labelIds],
|
|
projects: [rawProjectIds],
|
|
taskQueueIds: [taskQueueIdIds],
|
|
resolutions: [resolutionIds],
|
|
workerIds: [workerIdIds],
|
|
workerGroups: [workerGroupIds],
|
|
priorities: [priorityIds],
|
|
users: [userIds],
|
|
taskGroupIds: [taskGroupIdIds],
|
|
});
|
|
|
|
// Build per-worker and per-task-group arrays from the remapped per-task arrays.
|
|
const workerGroupForWorkerId = new Array(tables.workerIds.length).fill(null);
|
|
const projectForTaskGroup = new Array(tables.taskGroupIds.length).fill(null);
|
|
for (let i = 0; i < n; i++) {
|
|
if (workerIdIds[i] !== null) {
|
|
workerGroupForWorkerId[workerIdIds[i]] = workerGroupIds[i];
|
|
}
|
|
if (taskGroupIdIds[i] !== null && rawProjectIds[i] !== null) {
|
|
projectForTaskGroup[taskGroupIdIds[i]] = rawProjectIds[i];
|
|
}
|
|
}
|
|
|
|
// Derive per-task projectIds for the summary from the per-task-group mapping.
|
|
for (let i = 0; i < n; i++) {
|
|
const tgid = taskGroupIdIds[i];
|
|
projectIds[i] = tgid !== null ? projectForTaskGroup[tgid] : null;
|
|
}
|
|
|
|
const summary = {
|
|
metadata,
|
|
tables: {
|
|
taskQueueIds: tables.taskQueueIds,
|
|
resolutions: tables.resolutions,
|
|
projects: tables.projects,
|
|
},
|
|
tasks: {
|
|
scheduled,
|
|
started,
|
|
resolved,
|
|
resolutionIds,
|
|
taskQueueIdIds,
|
|
projectIds,
|
|
},
|
|
};
|
|
|
|
const taskData = {
|
|
metadata,
|
|
tables,
|
|
tasks: {
|
|
scheduled,
|
|
started,
|
|
resolved,
|
|
resolutionIds,
|
|
taskIds,
|
|
labelIds,
|
|
priorityIds,
|
|
taskGroupIdIds,
|
|
userIds,
|
|
taskQueueIdIds,
|
|
workerIdIds,
|
|
runCosts,
|
|
},
|
|
workerInfo: {
|
|
workerGroupIds: workerGroupForWorkerId,
|
|
},
|
|
taskGroupInfo: {
|
|
projectIds: projectForTaskGroup,
|
|
},
|
|
};
|
|
|
|
return { summary, taskData };
|
|
}
|
|
|
|
async function main() {
|
|
const scriptStartTime = Date.now();
|
|
|
|
if (process.env.TASK_ID) {
|
|
await fetchPreviousRunData();
|
|
}
|
|
|
|
// Fetch current data from STMO
|
|
console.log("Fetching worker task data from STMO...");
|
|
const stmoData = await fetchJson(DATA_URL);
|
|
if (!stmoData) {
|
|
console.error("Failed to fetch data from STMO");
|
|
process.exit(1);
|
|
}
|
|
|
|
const queryResult = stmoData.query_result;
|
|
const rows = queryResult.data.rows;
|
|
if (!rows || rows.length === 0) {
|
|
console.error("No data rows found");
|
|
process.exit(1);
|
|
}
|
|
|
|
const date = extractDateFromQuery(queryResult);
|
|
if (!date) {
|
|
console.error("Could not determine date from query result");
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`Processing ${rows.length} tasks for ${date}...`);
|
|
const { summary, taskData } = encodeData(rows, date);
|
|
|
|
saveJsonFile(summary, path.join(OUTPUT_DIR, `workers-${date}.json`));
|
|
saveJsonFile(taskData, path.join(OUTPUT_DIR, `workers-${date}-tasks.json`));
|
|
|
|
// Fetch previous run's files to build up history
|
|
if (previousRunData) {
|
|
const MAX_HISTORY_DAYS = 21;
|
|
const fetches = [];
|
|
for (const prevDate of previousRunData.dates) {
|
|
const daysDiff = Math.round(
|
|
(new Date(date) - new Date(prevDate)) / (24 * 60 * 60 * 1000)
|
|
);
|
|
if (daysDiff <= 0 || daysDiff >= MAX_HISTORY_DAYS) {
|
|
console.log(
|
|
`Skipping ${prevDate} (outside ${MAX_HISTORY_DAYS}-day window)`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
for (const suffix of ["", "-tasks"]) {
|
|
const filename = `workers-${prevDate}${suffix}.json`;
|
|
const filePath = path.join(OUTPUT_DIR, filename);
|
|
|
|
fetches.push(
|
|
fetch(`${previousRunData.artifactsUrl}/${filename}`).then(
|
|
async response => {
|
|
if (!response.ok) {
|
|
console.log(` Failed to fetch ${filename}`);
|
|
return;
|
|
}
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
fs.writeFileSync(filePath, buffer);
|
|
console.log(
|
|
`Fetched ${filename} - ${Math.round(buffer.length / 1024)}KB`
|
|
);
|
|
}
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
await Promise.all(fetches);
|
|
if (fetches.length) {
|
|
console.log(`Fetched ${fetches.length} previous files`);
|
|
}
|
|
}
|
|
|
|
// Build index.json from available summary files
|
|
const files = fs.readdirSync(OUTPUT_DIR);
|
|
const datePattern = /^workers-(\d{4}-\d{2}-\d{2})\.json$/;
|
|
const availableDates = [];
|
|
|
|
for (const file of files) {
|
|
const match = file.match(datePattern);
|
|
if (match) {
|
|
availableDates.push(match[1]);
|
|
}
|
|
}
|
|
|
|
availableDates.sort((a, b) => b.localeCompare(a));
|
|
|
|
const indexPath = path.join(OUTPUT_DIR, "index.json");
|
|
saveJsonFile({ dates: availableDates }, indexPath);
|
|
console.log(`Index file saved with ${availableDates.length} dates`);
|
|
|
|
const elapsed = ((Date.now() - scriptStartTime) / 1000).toFixed(1);
|
|
console.log(`\nDone in ${elapsed}s`);
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|