utils.ts 15 KB

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