utils.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. import * as fs from "fs/promises";
  2. import path from "path";
  3. import { execSync, spawnSync } from "child_process";
  4. /** Version that is used in bench data file */
  5. export type Version = "unreleased" | (`${number}.${number}.${number}` & {});
  6. /** Persistent benchmark data(mapping of `Version -> Data`) */
  7. type Bench = {
  8. [key: string]: {
  9. /**
  10. * Storing Solana version used in the release to:
  11. * - Be able to build older versions
  12. * - Adjust for the changes in platform-tools
  13. */
  14. solanaVersion: Version;
  15. /** Benchmark results for a version */
  16. result: BenchResult;
  17. };
  18. };
  19. /** Benchmark result per version */
  20. export type BenchResult = {
  21. /** Benchmark result for program binary size */
  22. binarySize: BinarySize;
  23. /** Benchmark result for compute units consumed */
  24. computeUnits: ComputeUnits;
  25. };
  26. /** `program name -> binary size` */
  27. export type BinarySize = { [programName: string]: number };
  28. /** `instruction name -> compute units consumed` */
  29. export type ComputeUnits = { [ixName: string]: number };
  30. /**
  31. * How much of a percentage difference between the current and the previous data
  32. * should be significant. Any difference above this number should be noted in
  33. * the benchmark file.
  34. */
  35. export const THRESHOLD_PERCENTAGE = 1;
  36. /** Path to the benchmark Markdown files */
  37. export const BENCH_DIR_PATH = path.join("..", "..", "bench");
  38. /** Command line argument for Anchor version */
  39. export const ANCHOR_VERSION_ARG = "--anchor-version";
  40. /** Utility class to handle benchmark data related operations */
  41. export class BenchData {
  42. /** Benchmark data filepath */
  43. static #PATH = "bench.json";
  44. /** Benchmark data */
  45. #data: Bench;
  46. constructor(data: Bench) {
  47. this.#data = data;
  48. }
  49. /** Open the benchmark data file. */
  50. static async open() {
  51. let bench: Bench;
  52. try {
  53. const benchFile = await fs.readFile(BenchData.#PATH, {
  54. encoding: "utf8",
  55. });
  56. bench = JSON.parse(benchFile);
  57. } catch {
  58. bench = {};
  59. }
  60. return new BenchData(bench);
  61. }
  62. /** Save the benchmark data file. */
  63. async save() {
  64. await fs.writeFile(BenchData.#PATH, JSON.stringify(this.#data, null, 2));
  65. }
  66. /** Get the stored results based on version. */
  67. get(version: Version) {
  68. return this.#data[version];
  69. }
  70. /** Get all versions. */
  71. getVersions() {
  72. return Object.keys(this.#data) as Version[];
  73. }
  74. /** Compare benchmark changes. */
  75. compare<K extends keyof BenchResult>({
  76. newResult,
  77. oldResult,
  78. changeCb,
  79. noChangeCb,
  80. treshold = 0,
  81. }: {
  82. /** New bench result */
  83. newResult: BenchResult[K];
  84. /** Old bench result */
  85. oldResult: BenchResult[K];
  86. /** Callback to run when there is a change(considering `threshold`) */
  87. changeCb: (args: {
  88. name: string;
  89. newValue: number | null;
  90. oldValue: number | null;
  91. }) => void;
  92. /** Callback to run when there is no change(considering `threshold`) */
  93. noChangeCb?: (args: { name: string; value: number }) => void;
  94. /** Change threshold percentage(maximum allowed difference between results) */
  95. treshold?: number;
  96. }) {
  97. let needsUpdate = false;
  98. const executeChangeCb = (...args: Parameters<typeof changeCb>) => {
  99. changeCb(...args);
  100. needsUpdate = true;
  101. };
  102. const compare = (
  103. compareFrom: BenchResult[K],
  104. compareTo: BenchResult[K],
  105. cb: (name: string, value: number) => void
  106. ) => {
  107. for (const name in compareFrom) {
  108. if (compareTo[name] === undefined) {
  109. cb(name, compareTo[name]);
  110. }
  111. }
  112. };
  113. // New key
  114. compare(newResult, oldResult, (name, value) => {
  115. console.log(`New key '${name}'`);
  116. executeChangeCb({
  117. name,
  118. newValue: value,
  119. oldValue: null,
  120. });
  121. });
  122. // Deleted key
  123. compare(oldResult, newResult, (name, value) => {
  124. console.log(`Deleted key '${name}'`);
  125. executeChangeCb({
  126. name,
  127. newValue: null,
  128. oldValue: value,
  129. });
  130. });
  131. // Compare compute units changes
  132. for (const name in newResult) {
  133. const oldValue = oldResult[name];
  134. const newValue = newResult[name];
  135. const percentage = treshold / 100;
  136. const oldMaximumAllowedDelta = oldValue * percentage;
  137. const newMaximumAllowedDelta = newValue * percentage;
  138. const delta = newValue - oldValue;
  139. const absDelta = Math.abs(delta);
  140. if (
  141. absDelta > oldMaximumAllowedDelta ||
  142. absDelta > newMaximumAllowedDelta
  143. ) {
  144. // Throw in CI
  145. if (process.env.CI) {
  146. throw new Error(
  147. [
  148. `Key '${name}' has changed more than ${treshold}% but is not saved.`,
  149. "Run `anchor test --skip-lint` in tests/bench and commit the changes.",
  150. ].join(" ")
  151. );
  152. }
  153. console.log(`'${name}' (${oldValue} -> ${newValue})`);
  154. executeChangeCb({
  155. name,
  156. newValue,
  157. oldValue,
  158. });
  159. } else {
  160. noChangeCb?.({ name, value: newValue });
  161. }
  162. }
  163. return { needsUpdate };
  164. }
  165. /** Compare and update benchmark changes. */
  166. async update(result: Partial<BenchResult>) {
  167. const resultType = Object.keys(result)[0] as keyof typeof result;
  168. const newResult = result[resultType]!;
  169. // Compare and update benchmark changes
  170. const version = getVersionFromArgs();
  171. const oldResult = this.get(version).result[resultType];
  172. const { needsUpdate } = this.compare({
  173. newResult,
  174. oldResult,
  175. changeCb: ({ name, newValue }) => {
  176. if (newValue === null) delete oldResult[name];
  177. else oldResult[name] = newValue;
  178. },
  179. treshold: THRESHOLD_PERCENTAGE,
  180. });
  181. if (needsUpdate) {
  182. console.log("Updating benchmark files...");
  183. // Save bench data file
  184. // (needs to happen before running the `sync-markdown` script)
  185. await this.save();
  186. // Only update markdown files on `unreleased` version
  187. if (version === "unreleased") {
  188. spawn("anchor", ["run", "sync-markdown"]);
  189. }
  190. }
  191. }
  192. /** Bump benchmark data version to the given version. */
  193. bumpVersion(newVersion: string) {
  194. if (this.#data[newVersion]) {
  195. throw new Error(`Version '${newVersion}' already exists!`);
  196. }
  197. const versions = this.getVersions();
  198. const unreleasedVersion = versions[versions.length - 1];
  199. // Add the new version
  200. this.#data[newVersion] = this.get(unreleasedVersion);
  201. // Delete the unreleased version
  202. delete this.#data[unreleasedVersion];
  203. // Add the new unreleased version
  204. this.#data[unreleasedVersion] = this.#data[newVersion];
  205. }
  206. /**
  207. * Loop through all of the markdown files and run the given callback before
  208. * saving the file.
  209. */
  210. static async forEachMarkdown(
  211. cb: (markdown: Markdown, fileName: string) => void
  212. ) {
  213. const fileNames = await fs.readdir(BENCH_DIR_PATH);
  214. const markdownFileNames = fileNames.filter((n) => n.endsWith(".md"));
  215. for (const fileName of markdownFileNames) {
  216. const markdown = await Markdown.open(path.join(BENCH_DIR_PATH, fileName));
  217. cb(markdown, fileName);
  218. await markdown.save();
  219. }
  220. // Format
  221. spawn("yarn", [
  222. "run",
  223. "prettier",
  224. "--write",
  225. path.join(BENCH_DIR_PATH, "*.md"),
  226. ]);
  227. }
  228. }
  229. /** Utility class to handle markdown related operations */
  230. export class Markdown {
  231. /** Unreleased version string */
  232. static #UNRELEASED_VERSION = "[Unreleased]";
  233. /** Markdown filepath */
  234. #path: string;
  235. /** Markdown text */
  236. #text: string;
  237. constructor(path: string, text: string) {
  238. this.#path = path;
  239. this.#text = text;
  240. }
  241. /** Open the markdown file. */
  242. static async open(path: string) {
  243. const text = await fs.readFile(path, { encoding: "utf8" });
  244. return new Markdown(path, text);
  245. }
  246. /** Create a markdown table. */
  247. static createTable(...args: string[]) {
  248. return new MarkdownTable([args]);
  249. }
  250. /** Save the markdown file. */
  251. async save() {
  252. await fs.writeFile(this.#path, this.#text);
  253. }
  254. /** Change the version's content with the given `solanaVersion` and `table`. */
  255. updateVersion(params: {
  256. version: Version;
  257. solanaVersion: string;
  258. table: MarkdownTable;
  259. }) {
  260. const md = this.#text;
  261. const title = `[${params.version}]`;
  262. let titleStartIndex = md.indexOf(title);
  263. if (titleStartIndex === -1) {
  264. titleStartIndex = md.indexOf(Markdown.#UNRELEASED_VERSION);
  265. }
  266. const titleContentStartIndex = titleStartIndex + title.length + 1;
  267. const tableStartIndex =
  268. titleStartIndex + md.slice(titleStartIndex).indexOf("|");
  269. const tableRowStartIndex =
  270. tableStartIndex + md.slice(tableStartIndex).indexOf("\n");
  271. const tableEndIndex =
  272. tableStartIndex + md.slice(tableStartIndex).indexOf("\n\n");
  273. this.#text =
  274. md.slice(0, titleContentStartIndex) +
  275. `Solana version: ${params.solanaVersion}\n\n` +
  276. md.slice(tableStartIndex, tableRowStartIndex - 1) +
  277. params.table.toString() +
  278. md.slice(tableEndIndex + 1);
  279. }
  280. /** Bump the version to the given version. */
  281. bumpVersion(newVersion: string) {
  282. newVersion = `[${newVersion}]`;
  283. if (this.#text.includes(newVersion)) {
  284. throw new Error(`Version '${newVersion}' already exists!`);
  285. }
  286. const startIndex = this.#text.indexOf(`## ${Markdown.#UNRELEASED_VERSION}`);
  287. const endIndex =
  288. startIndex + this.#text.slice(startIndex).indexOf("\n---") + 4;
  289. let unreleasedSection = this.#text.slice(startIndex, endIndex);
  290. // Update unreleased version to `newVersion`
  291. const newSection = unreleasedSection.replace(
  292. Markdown.#UNRELEASED_VERSION,
  293. newVersion
  294. );
  295. // Reset unreleased version changes
  296. unreleasedSection = unreleasedSection
  297. .split("\n")
  298. .map((line, i) => {
  299. // First 4 lines don't change
  300. if ([0, 1, 2, 3].includes(i)) return line;
  301. const regex = /\|.*\|.*\|(.*)\|/;
  302. const result = regex.exec(line);
  303. const changeStr = result?.[1];
  304. if (!changeStr) {
  305. if (line.startsWith("#")) return line;
  306. else if (line.startsWith("---")) return line + "\n";
  307. else return "";
  308. }
  309. return line.replace(changeStr, "-");
  310. })
  311. .join("\n");
  312. // Update the text
  313. this.#text =
  314. this.#text.slice(0, startIndex) +
  315. unreleasedSection +
  316. newSection +
  317. this.#text.slice(endIndex);
  318. }
  319. }
  320. /** Utility class to handle markdown table related operations */
  321. class MarkdownTable {
  322. /** Markdown rows stored as array of arrays */
  323. #rows: string[][];
  324. constructor(rows: string[][]) {
  325. this.#rows = rows;
  326. this.insert("-", "-", "-");
  327. }
  328. /** Insert a new row to the markdown table. */
  329. insert(...args: string[]) {
  330. this.#rows.push(args);
  331. }
  332. /** Convert the stored rows to a markdown table. */
  333. toString() {
  334. return this.#rows.reduce(
  335. (acc, row) =>
  336. acc + row.reduce((acc, cur) => `${acc} ${cur} |`, "|") + "\n",
  337. ""
  338. );
  339. }
  340. }
  341. /** Utility class to handle TOML related operations */
  342. export class Toml {
  343. /** TOML filepath */
  344. #path: string;
  345. /** TOML text */
  346. #text: string;
  347. constructor(path: string, text: string) {
  348. this.#path = path;
  349. this.#text = text;
  350. }
  351. /** Open the TOML file. */
  352. static async open(tomlPath: string) {
  353. tomlPath = path.join(__dirname, tomlPath);
  354. const text = await fs.readFile(tomlPath, {
  355. encoding: "utf8",
  356. });
  357. return new Toml(tomlPath, text);
  358. }
  359. /** Save the TOML file. */
  360. async save() {
  361. await fs.writeFile(this.#path, this.#text);
  362. }
  363. /** Replace the value for the given key. */
  364. replaceValue(
  365. key: string,
  366. cb: (previous: string) => string,
  367. opts?: { insideQuotes: boolean }
  368. ) {
  369. this.#text = this.#text.replace(
  370. new RegExp(`${key}\\s*=\\s*${opts?.insideQuotes ? `"(.*)"` : "(.*)"}`),
  371. (line, value) => line.replace(value, cb(value))
  372. );
  373. }
  374. }
  375. /** Utility class to handle Cargo.lock file related operations */
  376. export class LockFile {
  377. /** Cargo lock file name */
  378. static #CARGO_LOCK = "Cargo.lock";
  379. /** Replace the Cargo.lock with the given version's cached lock file. */
  380. static async replace(version: Version) {
  381. // Remove Cargo.lock
  382. try {
  383. await fs.rm(this.#CARGO_LOCK);
  384. } catch {}
  385. // `unreleased` version shouldn't have a cached lock file
  386. if (version !== "unreleased") {
  387. const lockFile = await fs.readFile(this.#getLockPath(version));
  388. await fs.writeFile(this.#CARGO_LOCK, lockFile);
  389. }
  390. }
  391. /** Cache the current Cargo.lock in `./locks`. */
  392. static async cache(version: Version) {
  393. try {
  394. await fs.rename(this.#CARGO_LOCK, this.#getLockPath(version));
  395. } catch {
  396. // Lock file doesn't exist
  397. // Run the tests to create the lock file
  398. const result = runAnchorTest();
  399. // Check failure
  400. if (result.status !== 0) {
  401. throw new Error(`Failed to create ${this.#CARGO_LOCK}`);
  402. }
  403. await this.cache(version);
  404. }
  405. }
  406. /** Get the lock file path from the given version. */
  407. static #getLockPath(version: Version) {
  408. return path.join("locks", `${version}.lock`);
  409. }
  410. }
  411. /** Utility class to manage versions */
  412. export class VersionManager {
  413. /** Set the active Solana version with `solana-install init` command. */
  414. static setSolanaVersion(version: Version) {
  415. const activeVersion = this.#getSolanaVersion();
  416. if (activeVersion === version) return;
  417. spawn("solana-install", ["init", version], {
  418. logOutput: true,
  419. throwOnError: { msg: `Failed to set Solana version to ${version}` },
  420. });
  421. }
  422. /** Get the active Solana version. */
  423. static #getSolanaVersion() {
  424. // `solana-cli 1.14.16 (src:0fb2ffda; feat:3488713414)\n`
  425. const result = execSync("solana --version");
  426. const output = Buffer.from(result.buffer).toString();
  427. const solanaVersion = /(\d\.\d{1,3}\.\d{1,3})/.exec(output)![1].trim();
  428. return solanaVersion as Version;
  429. }
  430. }
  431. /**
  432. * Get Anchor version from the passed arguments.
  433. *
  434. * Defaults to `unreleased`.
  435. */
  436. export const getVersionFromArgs = () => {
  437. const args = process.argv;
  438. const anchorVersionArgIndex = args.indexOf(ANCHOR_VERSION_ARG);
  439. return anchorVersionArgIndex === -1
  440. ? "unreleased"
  441. : (args[anchorVersionArgIndex + 1] as Version);
  442. };
  443. /** Run `anchor test` command. */
  444. export const runAnchorTest = () => {
  445. return spawn("anchor", ["test", "--skip-lint"]);
  446. };
  447. /** Spawn a blocking process. */
  448. export const spawn = (
  449. cmd: string,
  450. args: string[],
  451. opts?: { logOutput?: boolean; throwOnError?: { msg: string } }
  452. ) => {
  453. const result = spawnSync(cmd, args);
  454. if (opts?.logOutput) {
  455. console.log(result.output.toString());
  456. }
  457. if (opts?.throwOnError && result.status !== 0) {
  458. throw new Error(opts.throwOnError.msg);
  459. }
  460. return result;
  461. };