utils.ts 13 KB

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