// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ // ┃ Copyright (c) 2017, the Perspective Authors. ┃ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ // ┃ This file is part of the Perspective library, distributed under the terms ┃ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { test, expect } from "@perspective-dev/test"; import perspective from "./perspective_client"; import * as fs from "node:fs"; import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const arr = fs.readFileSync( require.resolve("superstore-arrow/superstore.lz4.arrow"), ).buffer; /** * Run a function in a loop, comparing before-and-after wasm heap for leaks. * Emscripten allocates in pages, and Perspective is hard coded to start at * 16777216b, so rather than check `end - start`, we just test that any * allocation ever occurs. * @param {*} test * @param {*} num_iterations */ async function leak_test(test, num_iterations = 10_000) { // warmup await test(); await test(); // TODO Playwright uses the same host instance so this may have grown by // the time the suite runs. Could fix with a nod eagent (and test in other // browsers). const start = (await perspective.system_info()).heap_size; const start_used = (await perspective.system_info()).used_size; for (var i = 0; i < num_iterations; i++) { await test(); } const final_used = (await perspective.system_info()).used_size; expect((await perspective.system_info()).heap_size).toEqual(start); expect(Number(final_used) / Number(start_used)).toBeGreaterThanOrEqual(0.8); expect(Number(final_used) / Number(start_used)).toBeLessThanOrEqual(1.5); } /** * Given columns a (int), b (float), c (string) and d (datetime), * generate expressions that use all columns and scalar values. */ function generate_expressions() { const expressions = { "concat('abcd', \"c\", 'efg')": "concat('abcd', \"c\", 'efg')", }; for (const op of ["+", "-", "*", "/", "^", "%"]) { expressions[`("a" ${op} "b") + ${Math.floor(Math.random() * 100)}`] = `("a" ${op} "b") + ${Math.floor(Math.random() * 100)}`; } for (const fn of ["sqrt", "log10", "deg2rad"]) { expressions[`${fn}("b")`] = `${fn}("b")`; } for (const fn of ["upper", "lower", "length"]) { expressions[`${fn}("c")`] = `${fn}("c")`; } for (const unit of ["m", "D"]) { expressions[`bucket("d", '${unit}')`] = `bucket("d", '${unit}')`; } const rand = Object.keys(expressions)[ Math.floor(Math.random() * Object.keys(expressions).length) ]; return { [rand]: expressions[rand] }; } test.describe("leaks", function () { test.describe("view", function () { test.describe("1-sided", function () { test("to_json does not leak", async () => { test.setTimeout(60000); const table = await perspective.table(arr.slice()); const view = await table.view({ group_by: ["State"] }); await leak_test(async function () { let json = await view.to_json(); expect(json.length).toEqual(50); }); await view.delete(); await table.delete(); }); test.skip("OG - to_columns_string does not leak", async () => { const table = await perspective.table(arr.slice()); const view = await table.view({ group_by: ["State"] }); await leak_test(async function () { let json = await view.to_columns_string(); expect(json.length).toEqual(6722); }); await view.delete(); await table.delete(); }); // The length of this string changed due to // some of the trailing fractional digits in the floats. test("to_columns_string does not leak", async () => { const table = await perspective.table(arr.slice()); const view = await table.view({ group_by: ["State"] }); await leak_test(async function () { let json = await view.to_columns_string(); expect(json.length).toEqual(6669); }); await view.delete(); await table.delete(); }); }); }); test.describe("table", function () { test("update does not leak", async () => { const table = await perspective.table( { x: "integer", y: "string" }, { index: "x" }, ); let count = 0; const view = await table.view(); view.on_update(function () { count += 1; }); await table.update([{ x: 1, y: "TestTestTest" }]); await leak_test(async function () { table.update([{ x: 1, y: "TestTestTest" }]); expect(await table.size()).toEqual(1); }); // await table.update([{ x: 1, y: "TestTestTest" }]); expect(count).toBeGreaterThan(0); await view.delete(); await table.delete(); }); test.skip("csv loading does not leak", async () => { const table = await perspective.table(arr.slice()); const view = await table.view(); const csv = await view.to_csv({ end_row: 10 }); await view.delete(); await table.delete(); await leak_test(async function () { const table = await perspective.table(csv); expect(await table.size()).toEqual(10); await table.delete(); }); }); }); test.describe("expression columns", function () { test("0 sided does not leak", async () => { test.setTimeout(60000); const table = await perspective.table({ a: [1, 2, 3, 4], b: [1.5, 2.5, 3.5, 4.5], c: ["a", "b", "c", "d"], d: [new Date(), new Date(), new Date(), new Date()], }); const expressions = generate_expressions(); await leak_test(async () => { const view = await table.view({ expressions, }); const expression_schema = await view.expression_schema(); expect(Object.keys(expression_schema).length).toEqual( Object.keys(expressions).length, ); await view.delete(); }); await table.delete(); }); /** * Because the expression vocab and the regex cache is per-table and * not per-view, we should be able to leak test the table creation * and view creation. */ test.skip("0 sided regex does not leak", async () => { const expressions = { "match(\"a\", '.{1}')": "match(\"a\", '.{1}')", "match_all(\"a\", '[a-z]{1}')": "match_all(\"a\", '[a-z]{1}')", "search(\"a\", '.')": "search(\"a\", '.')", }; await leak_test(async () => { const table = await perspective.table({ a: "abcdefghijklmnopqrstuvwxyz".split(""), }); const view = await table.view({ expressions, }); const expression_schema = await view.expression_schema(); expect(Object.keys(expression_schema).length).toEqual( Object.keys(expressions).length, ); await view.delete(); await table.delete(); }); }); test.skip("0 sided string does not leak", async () => { const table = await perspective.table({ a: "abcdefghijklmnopqrstuvwxyz".split(""), }); const expressions = { "var x := 'abcdefghijklmnopqrstuvwxyz'; concat(\"a\", x, 'abc')": "var x := 'abcdefghijklmnopqrstuvwxyz'; concat(\"a\", x, 'abc')", "var x := 'abcdefghijklmnopqrstuvwxyz'; var y := 'defhijklmnopqrst'; concat(\"a\", x, 'abc', y)": "var x := 'abcdefghijklmnopqrstuvwxyz'; var y := 'defhijklmnopqrst'; concat(\"a\", x, 'abc', y)", }; await leak_test(async () => { const view = await table.view({ expressions, }); const expression_schema = await view.expression_schema(); expect(Object.keys(expression_schema).length).toEqual( Object.keys(expressions).length, ); await view.delete(); }); await table.delete(); }); test("1 sided does not leak", async () => { const table = await perspective.table({ a: [1, 2, 3, 4], b: [1.5, 2.5, 3.5, 4.5], c: ["a", "b", "c", "d"], d: [new Date(), new Date(), new Date(), new Date()], }); const columns = ["a", "b", "c", "d"]; const expressions = generate_expressions(); await leak_test(async () => { const view = await table.view({ group_by: [ columns[Math.floor(Math.random() * columns.length)], ], expressions, }); const expression_schema = await view.expression_schema(); expect(Object.keys(expression_schema).length).toEqual( Object.keys(expressions).length, ); await view.delete(); }, 3000); await table.delete(); }); test("2 sided does not leak", async () => { const table = await perspective.table({ a: [1, 2, 3, 4], b: [1.5, 2.5, 3.5, 4.5], c: ["a", "b", "c", "d"], d: [new Date(), new Date(), new Date(), new Date()], }); const columns = ["a", "b", "c", "d"]; const expressions = generate_expressions(); await leak_test(async () => { const view = await table.view({ group_by: [ columns[Math.floor(Math.random() * columns.length)], ], split_by: [ columns[Math.floor(Math.random() * columns.length)], ], expressions, }); const expression_schema = await view.expression_schema(); expect(Object.keys(expression_schema).length).toEqual( Object.keys(expressions).length, ); await view.delete(); }, 3000); await table.delete(); }); }); });