123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552 |
- import * as fs from "fs/promises";
- import path from "path";
- import { execSync, spawnSync } from "child_process";
- /** Version that is used in bench data file */
- export type Version = "unreleased" | (`${number}.${number}.${number}` & {});
- /** Persistent benchmark data(mapping of `Version -> Data`) */
- type Bench = {
- [key: string]: {
- /**
- * Storing Solana version used in the release to:
- * - Be able to build older versions
- * - Adjust for the changes in platform-tools
- */
- solanaVersion: Version;
- /** Benchmark results for a version */
- result: BenchResult;
- };
- };
- /** Benchmark result per version */
- export type BenchResult = {
- /** Benchmark result for program binary size */
- binarySize: BinarySize;
- /** Benchmark result for compute units consumed */
- computeUnits: ComputeUnits;
- /** Benchmark result for stack memory usage */
- stackMemory: StackMemory;
- };
- /** `program name -> binary size` */
- export type BinarySize = { [programName: string]: number };
- /** `instruction name -> compute units consumed` */
- export type ComputeUnits = { [ixName: string]: number };
- /** `instruction name -> stack memory used` */
- export type StackMemory = { [ixName: string]: number };
- /**
- * How much of a percentage difference between the current and the previous data
- * should be significant. Any difference above this number should be noted in
- * the benchmark file.
- */
- export const THRESHOLD_PERCENTAGE = 1;
- /** Path to the benchmark Markdown files */
- export const BENCH_DIR_PATH = path.join("..", "..", "bench");
- /** Command line argument for Anchor version */
- export const ANCHOR_VERSION_ARG = "--anchor-version";
- /** Utility class to handle benchmark data related operations */
- export class BenchData {
- /** Benchmark data filepath */
- static #PATH = "bench.json";
- /** Benchmark data */
- #data: Bench;
- constructor(data: Bench) {
- this.#data = data;
- }
- /** Open the benchmark data file. */
- static async open() {
- let bench: Bench;
- try {
- const benchFile = await fs.readFile(BenchData.#PATH, {
- encoding: "utf8",
- });
- bench = JSON.parse(benchFile);
- } catch {
- bench = {};
- }
- return new BenchData(bench);
- }
- /** Save the benchmark data file. */
- async save() {
- await fs.writeFile(BenchData.#PATH, JSON.stringify(this.#data, null, 2));
- }
- /** Get the stored results based on version. */
- get(version: Version) {
- return this.#data[version];
- }
- /** Get all versions. */
- getVersions() {
- return Object.keys(this.#data) as Version[];
- }
- /** Compare benchmark changes. */
- compare<K extends keyof BenchResult>({
- newResult,
- oldResult,
- changeCb,
- noChangeCb,
- treshold = 0,
- }: {
- /** New bench result */
- newResult: BenchResult[K];
- /** Old bench result */
- oldResult: BenchResult[K];
- /** Callback to run when there is a change(considering `threshold`) */
- changeCb: (args: {
- name: string;
- newValue: number | null;
- oldValue: number | null;
- }) => void;
- /** Callback to run when there is no change(considering `threshold`) */
- noChangeCb?: (args: { name: string; value: number }) => void;
- /** Change threshold percentage(maximum allowed difference between results) */
- treshold?: number;
- }) {
- let needsUpdate = false;
- const executeChangeCb = (...args: Parameters<typeof changeCb>) => {
- changeCb(...args);
- needsUpdate = true;
- };
- const compare = (
- compareFrom: BenchResult[K],
- compareTo: BenchResult[K],
- cb: (name: string, value: number) => void
- ) => {
- for (const name in compareFrom) {
- if (compareTo[name] === undefined) {
- cb(name, compareFrom[name]);
- }
- }
- };
- // New key
- compare(newResult, oldResult, (name, value) => {
- console.log(`New key '${name}'`);
- executeChangeCb({
- name,
- newValue: value,
- oldValue: null,
- });
- });
- // Deleted key
- compare(oldResult, newResult, (name, value) => {
- console.log(`Deleted key '${name}'`);
- executeChangeCb({
- name,
- newValue: null,
- oldValue: value,
- });
- });
- // Compare compute units changes
- for (const name in newResult) {
- const oldValue = oldResult[name];
- const newValue = newResult[name];
- const percentage = treshold / 100;
- const oldMaximumAllowedDelta = oldValue * percentage;
- const newMaximumAllowedDelta = newValue * percentage;
- const delta = newValue - oldValue;
- const absDelta = Math.abs(delta);
- if (
- absDelta > oldMaximumAllowedDelta ||
- absDelta > newMaximumAllowedDelta
- ) {
- // Throw in CI
- if (process.env.CI) {
- throw new Error(
- [
- `Key '${name}' has changed more than ${treshold}% but is not saved.`,
- "Run `anchor test --skip-lint` in tests/bench and commit the changes.",
- ].join(" ")
- );
- }
- console.log(`'${name}' (${oldValue} -> ${newValue})`);
- executeChangeCb({
- name,
- newValue,
- oldValue,
- });
- } else {
- noChangeCb?.({ name, value: newValue });
- }
- }
- return { needsUpdate };
- }
- /** Compare and update benchmark changes. */
- async update(result: Partial<BenchResult>) {
- const resultType = Object.keys(result)[0] as keyof typeof result;
- const newResult = result[resultType]!;
- // Compare and update benchmark changes
- const version = getVersionFromArgs();
- const oldResult = this.get(version).result[resultType];
- const { needsUpdate } = this.compare({
- newResult,
- oldResult,
- changeCb: ({ name, newValue }) => {
- if (newValue === null) delete oldResult[name];
- else oldResult[name] = newValue;
- },
- treshold: THRESHOLD_PERCENTAGE,
- });
- if (needsUpdate) {
- console.log("Updating benchmark files...");
- // Save bench data file
- // (needs to happen before running the `sync-markdown` script)
- await this.save();
- // Only update markdown files on `unreleased` version
- if (version === "unreleased") {
- spawn("anchor", ["run", "sync-markdown"]);
- }
- }
- }
- /** Bump benchmark data version to the given version. */
- bumpVersion(newVersion: string) {
- if (this.#data[newVersion]) {
- throw new Error(`Version '${newVersion}' already exists!`);
- }
- const versions = this.getVersions();
- const unreleasedVersion = versions[versions.length - 1];
- // Add the new version
- this.#data[newVersion] = this.get(unreleasedVersion);
- // Delete the unreleased version
- delete this.#data[unreleasedVersion];
- // Add the new unreleased version
- this.#data[unreleasedVersion] = this.#data[newVersion];
- }
- /**
- * Loop through all of the markdown files and run the given callback before
- * saving the file.
- */
- static async forEachMarkdown(
- cb: (markdown: Markdown, fileName: string) => void
- ) {
- const fileNames = await fs.readdir(BENCH_DIR_PATH);
- const markdownFileNames = fileNames.filter((n) => n.endsWith(".md"));
- for (const fileName of markdownFileNames) {
- const markdown = await Markdown.open(path.join(BENCH_DIR_PATH, fileName));
- cb(markdown, fileName);
- await markdown.save();
- }
- // Format
- spawn("yarn", [
- "run",
- "prettier",
- "--write",
- path.join(BENCH_DIR_PATH, "*.md"),
- ]);
- }
- }
- /** Utility class to handle markdown related operations */
- export class Markdown {
- /** Unreleased version string */
- static #UNRELEASED_VERSION = "[Unreleased]";
- /** Markdown filepath */
- #path: string;
- /** Markdown text */
- #text: string;
- constructor(path: string, text: string) {
- this.#path = path;
- this.#text = text;
- }
- /** Open the markdown file. */
- static async open(path: string) {
- const text = await fs.readFile(path, { encoding: "utf8" });
- return new Markdown(path, text);
- }
- /** Create a markdown table. */
- static createTable(...args: string[]) {
- return new MarkdownTable([args]);
- }
- /** Save the markdown file. */
- async save() {
- await fs.writeFile(this.#path, this.#text);
- }
- /** Change the version's content with the given `solanaVersion` and `table`. */
- updateVersion(params: {
- version: Version;
- solanaVersion: string;
- table: MarkdownTable;
- }) {
- const md = this.#text;
- const title = `[${params.version}]`;
- let titleStartIndex = md.indexOf(title);
- if (titleStartIndex === -1) {
- titleStartIndex = md.indexOf(Markdown.#UNRELEASED_VERSION);
- }
- const titleContentStartIndex = titleStartIndex + title.length + 1;
- const tableStartIndex =
- titleStartIndex + md.slice(titleStartIndex).indexOf("|");
- const tableRowStartIndex =
- tableStartIndex + md.slice(tableStartIndex).indexOf("\n");
- const tableEndIndex =
- tableStartIndex + md.slice(tableStartIndex).indexOf("\n\n");
- this.#text =
- md.slice(0, titleContentStartIndex) +
- `Solana version: ${params.solanaVersion}\n\n` +
- md.slice(tableStartIndex, tableRowStartIndex - 1) +
- params.table.toString() +
- md.slice(tableEndIndex + 1);
- }
- /** Bump the version to the given version. */
- bumpVersion(newVersion: string) {
- newVersion = `[${newVersion}]`;
- if (this.#text.includes(newVersion)) {
- throw new Error(`Version '${newVersion}' already exists!`);
- }
- const startIndex = this.#text.indexOf(`## ${Markdown.#UNRELEASED_VERSION}`);
- const endIndex =
- startIndex + this.#text.slice(startIndex).indexOf("\n---") + 4;
- let unreleasedSection = this.#text.slice(startIndex, endIndex);
- // Update unreleased version to `newVersion`
- const newSection = unreleasedSection.replace(
- Markdown.#UNRELEASED_VERSION,
- newVersion
- );
- // Reset unreleased version changes
- unreleasedSection = unreleasedSection
- .split("\n")
- .map((line, i) => {
- // First 4 lines don't change
- if ([0, 1, 2, 3].includes(i)) return line;
- const regex = /\|.*\|.*\|(.*)\|/;
- const result = regex.exec(line);
- const changeStr = result?.[1];
- if (!changeStr) {
- if (line.startsWith("#")) return line;
- else if (line.startsWith("---")) return line + "\n";
- else return "";
- }
- return line.replace(changeStr, "-");
- })
- .join("\n");
- // Update the text
- this.#text =
- this.#text.slice(0, startIndex) +
- unreleasedSection +
- newSection +
- this.#text.slice(endIndex);
- }
- }
- /** Utility class to handle markdown table related operations */
- class MarkdownTable {
- /** Markdown rows stored as array of arrays */
- #rows: string[][];
- constructor(rows: string[][]) {
- this.#rows = rows;
- this.insert("-", "-", "-");
- }
- /** Insert a new row to the markdown table. */
- insert(...args: string[]) {
- this.#rows.push(args);
- }
- /** Convert the stored rows to a markdown table. */
- toString() {
- return this.#rows.reduce(
- (acc, row) =>
- acc + row.reduce((acc, cur) => `${acc} ${cur} |`, "|") + "\n",
- ""
- );
- }
- }
- /** Utility class to handle TOML related operations */
- export class Toml {
- /** TOML filepath */
- #path: string;
- /** TOML text */
- #text: string;
- constructor(path: string, text: string) {
- this.#path = path;
- this.#text = text;
- }
- /** Open the TOML file. */
- static async open(tomlPath: string) {
- tomlPath = path.join(__dirname, tomlPath);
- const text = await fs.readFile(tomlPath, {
- encoding: "utf8",
- });
- return new Toml(tomlPath, text);
- }
- /** Save the TOML file. */
- async save() {
- await fs.writeFile(this.#path, this.#text);
- }
- /** Replace the value for the given key. */
- replaceValue(
- key: string,
- cb: (previous: string) => string,
- opts?: { insideQuotes: boolean }
- ) {
- this.#text = this.#text.replace(
- new RegExp(`${key}\\s*=\\s*${opts?.insideQuotes ? `"(.*)"` : "(.*)"}`),
- (line, value) => line.replace(value, cb(value))
- );
- }
- }
- /** Utility class to handle Cargo.lock file related operations */
- export class LockFile {
- /** Cargo lock file name */
- static #CARGO_LOCK = "Cargo.lock";
- /** Replace the Cargo.lock with the given version's cached lock file. */
- static async replace(version: Version) {
- // Remove Cargo.lock
- try {
- await fs.rm(this.#CARGO_LOCK);
- } catch {}
- // `unreleased` version shouldn't have a cached lock file
- if (version !== "unreleased") {
- const lockFile = await fs.readFile(this.#getLockPath(version));
- await fs.writeFile(this.#CARGO_LOCK, lockFile);
- }
- }
- /** Cache the current Cargo.lock in `./locks`. */
- static async cache(version: Version) {
- try {
- await fs.rename(this.#CARGO_LOCK, this.#getLockPath(version));
- } catch {
- // Lock file doesn't exist
- // Run the tests to create the lock file
- const result = runAnchorTest();
- // Check failure
- if (result.status !== 0) {
- throw new Error(`Failed to create ${this.#CARGO_LOCK}`);
- }
- await this.cache(version);
- }
- }
- /** Get the lock file path from the given version. */
- static #getLockPath(version: Version) {
- return path.join("locks", `${version}.lock`);
- }
- }
- /** Utility class to manage versions */
- export class VersionManager {
- /** Set the active Solana version with `solana-install init` command. */
- static setSolanaVersion(version: Version) {
- const activeVersion = this.#getSolanaVersion();
- if (activeVersion === version) return;
- spawn("solana-install", ["init", version], {
- logOutput: true,
- throwOnError: { msg: `Failed to set Solana version to ${version}` },
- });
- }
- /** Get the active Solana version. */
- static #getSolanaVersion() {
- // `solana-cli 1.14.16 (src:0fb2ffda; feat:3488713414)\n`
- const result = execSync("solana --version");
- const output = Buffer.from(result.buffer).toString();
- const solanaVersion = /(\d\.\d{1,3}\.\d{1,3})/.exec(output)![1].trim();
- return solanaVersion as Version;
- }
- }
- /**
- * Get Anchor version from the passed arguments.
- *
- * Defaults to `unreleased`.
- */
- export const getVersionFromArgs = () => {
- const args = process.argv;
- const anchorVersionArgIndex = args.indexOf(ANCHOR_VERSION_ARG);
- return anchorVersionArgIndex === -1
- ? "unreleased"
- : (args[anchorVersionArgIndex + 1] as Version);
- };
- /** Spawn a blocking process. */
- export const spawn = (
- cmd: string,
- args: string[],
- opts?: { logOutput?: boolean; throwOnError?: { msg: string } }
- ) => {
- const result = spawnSync(cmd, args);
- if (opts?.logOutput) {
- console.log(result.output.toString());
- }
- if (opts?.throwOnError && result.status !== 0) {
- throw new Error(opts.throwOnError.msg);
- }
- return result;
- };
- /** Run `anchor test` command. */
- export const runAnchorTest = () => spawn("anchor", ["test", "--skip-lint"]);
- /** Format number with `en-US` locale. */
- export const formatNumber = (number: number) => number.toLocaleString("en-US");
|