utils.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  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 version table with the given table */
  219. updateTable(version: string, table: MarkdownTable) {
  220. const md = this.#text;
  221. let titleStartIndex = md.indexOf(`[${version}]`);
  222. if (titleStartIndex === -1) {
  223. titleStartIndex = md.indexOf(Markdown.#UNRELEASED_VERSION);
  224. }
  225. const startIndex = titleStartIndex + md.slice(titleStartIndex).indexOf("|");
  226. const endIndex = startIndex + md.slice(startIndex).indexOf("\n\n");
  227. this.#text =
  228. md.slice(0, startIndex) + table.toString() + md.slice(endIndex + 1);
  229. }
  230. /** Bump the version to the given version */
  231. bumpVersion(newVersion: string) {
  232. newVersion = `[${newVersion}]`;
  233. if (this.#text.includes(newVersion)) {
  234. throw new Error(`Version '${newVersion}' already exists!`);
  235. }
  236. const startIndex = this.#text.indexOf(`## ${Markdown.#UNRELEASED_VERSION}`);
  237. const endIndex =
  238. startIndex + this.#text.slice(startIndex).indexOf("\n---") + 4;
  239. let unreleasedSection = this.#text.slice(startIndex, endIndex);
  240. // Update unreleased version to `newVersion`
  241. const newSection = unreleasedSection.replace(
  242. Markdown.#UNRELEASED_VERSION,
  243. newVersion
  244. );
  245. // Reset unreleased version changes
  246. unreleasedSection = unreleasedSection
  247. .split("\n")
  248. .map((line, i) => {
  249. // First 4 lines don't change
  250. if ([0, 1, 2, 3].includes(i)) return line;
  251. const regex = /\|.*\|.*\|(.*)\|/;
  252. const result = regex.exec(line);
  253. const changeStr = result?.[1];
  254. if (!changeStr) {
  255. if (line.startsWith("#")) return line;
  256. else if (line.startsWith("---")) return line + "\n";
  257. else return "";
  258. }
  259. return line.replace(changeStr, "-");
  260. })
  261. .join("\n");
  262. // Update the text
  263. this.#text =
  264. this.#text.slice(0, startIndex) +
  265. unreleasedSection +
  266. newSection +
  267. this.#text.slice(endIndex);
  268. }
  269. }
  270. /** Utility class to handle markdown table related operations */
  271. class MarkdownTable {
  272. /** Markdown rows stored as array of arrays */
  273. #rows: string[][];
  274. constructor(rows: string[][]) {
  275. this.#rows = rows;
  276. this.insert("-", "-", "-");
  277. }
  278. /** Insert a new row to the markdown table */
  279. insert(...args: string[]) {
  280. this.#rows.push(args);
  281. }
  282. /** Convert the stored rows to a markdown table */
  283. toString() {
  284. return this.#rows.reduce(
  285. (acc, row) =>
  286. acc + row.reduce((acc, cur) => `${acc} ${cur} |`, "|") + "\n",
  287. ""
  288. );
  289. }
  290. }
  291. /** Utility class to handle TOML related operations */
  292. export class Toml {
  293. /** TOML filepath */
  294. #path: string;
  295. /** TOML text */
  296. #text: string;
  297. constructor(path: string, text: string) {
  298. this.#path = path;
  299. this.#text = text;
  300. }
  301. /** Open the TOML file */
  302. static async open(tomlPath: string) {
  303. tomlPath = path.join(__dirname, tomlPath);
  304. const text = await fs.readFile(tomlPath, {
  305. encoding: "utf8",
  306. });
  307. return new Toml(tomlPath, text);
  308. }
  309. /** Save the TOML file */
  310. async save() {
  311. await fs.writeFile(this.#path, this.#text);
  312. }
  313. /** Replace the value for the given key */
  314. replaceValue(
  315. key: string,
  316. cb: (previous: string) => string,
  317. opts?: { insideQuotes: boolean }
  318. ) {
  319. this.#text = this.#text.replace(
  320. new RegExp(`${key}\\s*=\\s*${opts?.insideQuotes ? `"(.*)"` : "(.*)"}`),
  321. (line, value) => line.replace(value, cb(value))
  322. );
  323. }
  324. }
  325. /** Utility class to handle Cargo.lock file related operations */
  326. export class LockFile {
  327. /** Cargo lock file name */
  328. static #CARGO_LOCK = "Cargo.lock";
  329. /** Replace the Cargo.lock with the given version's cached lock file */
  330. static async replace(version: Version) {
  331. // Remove Cargo.lock
  332. try {
  333. await fs.rm(this.#CARGO_LOCK);
  334. } catch {}
  335. // `unreleased` version shouldn't have a cached lock file
  336. if (version !== "unreleased") {
  337. const lockFile = await fs.readFile(this.#getLockPath(version));
  338. await fs.writeFile(this.#CARGO_LOCK, lockFile);
  339. }
  340. }
  341. /** Cache the current Cargo.lock in ./locks */
  342. static async cache(version: Version) {
  343. try {
  344. await fs.rename(this.#CARGO_LOCK, this.#getLockPath(version));
  345. } catch {
  346. // Lock file doesn't exist
  347. // Run the tests to create the lock file
  348. const result = runAnchorTest();
  349. // Check failure
  350. if (result.status !== 0) {
  351. throw new Error(`Failed to create ${this.#CARGO_LOCK}`);
  352. }
  353. await this.cache(version);
  354. }
  355. }
  356. /** Get the lock file path from the given version */
  357. static #getLockPath(version: Version) {
  358. return path.join("locks", `${version}.lock`);
  359. }
  360. }
  361. /** Utility class to manage versions */
  362. export class VersionManager {
  363. /** Set the active Solana version with `solana-install init` command */
  364. static setSolanaVersion(version: Version) {
  365. const activeVersion = this.#getSolanaVersion();
  366. if (activeVersion === version) return;
  367. spawn("solana-install", ["init", version], {
  368. logOutput: true,
  369. throwOnError: { msg: `Failed to set Solana version to ${version}` },
  370. });
  371. }
  372. /** Get the active Solana version */
  373. static #getSolanaVersion() {
  374. // `solana-cli 1.14.16 (src:0fb2ffda; feat:3488713414)\n`
  375. const result = execSync("solana --version");
  376. const output = Buffer.from(result.buffer).toString();
  377. const solanaVersion = /(\d\.\d{1,3}\.\d{1,3})/.exec(output)![1].trim();
  378. return solanaVersion as Version;
  379. }
  380. }
  381. /**
  382. * Get Anchor version from the passed arguments.
  383. *
  384. * Defaults to `unreleased`.
  385. */
  386. export const getVersionFromArgs = () => {
  387. const args = process.argv;
  388. const anchorVersionArgIndex = args.indexOf(ANCHOR_VERSION_ARG);
  389. return anchorVersionArgIndex === -1
  390. ? "unreleased"
  391. : (args[anchorVersionArgIndex + 1] as Version);
  392. };
  393. /** Run `anchor test` command */
  394. export const runAnchorTest = () => {
  395. return spawn("anchor", ["test", "--skip-lint"]);
  396. };
  397. /** Spawn a blocking process */
  398. export const spawn = (
  399. cmd: string,
  400. args: string[],
  401. opts?: { logOutput?: boolean; throwOnError?: { msg: string } }
  402. ) => {
  403. const result = spawnSync(cmd, args);
  404. if (opts?.logOutput) {
  405. console.log(result.output.toString());
  406. }
  407. if (opts?.throwOnError && result.status !== 0) {
  408. throw new Error(opts.throwOnError.msg);
  409. }
  410. return result;
  411. };