Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Sort, expand and filter take under 0.2 s, group by is up to 9Γ— faster, column resize is smooth and Find never freezes the table.
- Screen reader and full keyboard support.
- `Cmd/Ctrl+C` copies the filtered rows and their depth in each table, and the Call Tree, Analysis and Database toolbars copy them or export them to CSV.
- ⚑ **Call Tree and Analysis**: Bottom-Up builds 1.9Γ— faster and uses 54% less memory, and Aggregated uses 19% less, measured on a 19 MB log. ([#1225])
- ⚑ **Call Tree and Analysis**: Bottom-Up builds 1.9Γ— faster and uses 54% less memory, and no more when Analysis shows it too, and Aggregated uses 19% less, measured on a 19 MB log. ([#1225])
- ⚑ **Parser performance**: logs parse 8Γ— faster and hold 98% less memory, down from 1.1 s to 0.13 s and 1.0 GB to 23 MB on a 100 MB log. ([#1231])
- πŸ” **Find**: a more consistent and natural find experience. ([#986])
- Search matches highlight as you type.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ describe('analysis-view', () => {
jest.restoreAllMocks();
});

// A copy of its own would hold every row a second time beside the Call Tree's Bottom-Up.
it('shows the Bottom-Up rows every view of the log shares', async () => {
const shared = await MergedTree.bottomUp(logStoreFor(log));

expect(roots()).toBe(shared?.source.roots);
});

it('shows the bottom-up tree of the log, ranked by self time, frames only', () => {
expect(roots().map((row) => [row.text, row.totalSelfTime])).toEqual(
expect.arrayContaining([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { exportCsv } from '../../../../components/grid/exportCsv.js';
import { eventBus } from '#core/events/EventBus.js';
import type { LogIndex } from '#core/log/LogIndex.js';
import { LogStore, logStoreFor } from '#core/log/LogStore.js';
import { MergedTree } from '../../grid/mergedRows.js';
import { CalltreeView } from '../CalltreeView.js';

/** Which view each build made, in order. */
Expand Down Expand Up @@ -284,6 +285,15 @@ describe('calltree-view table lifetime', () => {
expect(buildSignals.map((signal) => signal?.aborted)).toEqual([true, false]);
});

// A copy of its own would hold every row a second time beside the Analysis view.
it('shows the Bottom-Up rows every view of the log shares', async () => {
await view._setViewMode('bottom-up');
await settle();
const shared = await MergedTree.bottomUp(view.logStore!); // mountView set it

expect(view.bottomUpGrid?.source?.roots).toBe(shared?.source.roots);
});

it('offers Group only in bottom up', async () => {
const picker = () => view.renderRoot.querySelector('#bottomup-groupby');
expect(picker()).toBeNull();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/*
* Copyright (c) 2026 Certinia Inc. All rights reserved.
*/
import { storeOf } from '#test-helpers/apexLog.js';
import { MergedTree } from '../mergedRows.js';

interface Row {
Expand Down Expand Up @@ -40,3 +41,55 @@ describe('MergedTree.marked', () => {
expect(tree.marked([20, 30, 99])).toEqual(new Set([2, 3]));
});
});

describe('MergedTree.bottomUp', () => {
const LOG =
'09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' +
'09:18:22.6 (2000)|METHOD_ENTRY|[2]|01p|ns.Inner.go()\n' +
'09:18:22.6 (3000)|METHOD_EXIT|[2]|ns.Inner.go()\n' +
'09:18:22.6 (4000)|METHOD_EXIT|[1]|ns.Outer.run()\n';

// The Call Tree and Analysis each built a copy, so a log open in both held its rows twice.
it('hands every caller of one store the same tree', async () => {
const { store } = await storeOf(LOG);

const [first, second] = await Promise.all([
MergedTree.bottomUp(store),
MergedTree.bottomUp(store),
]);

expect(first).not.toBeNull();
expect(second).toBe(first);
});

// A view that stops waiting must not take the tree away from one still waiting.
it('answers an aborted caller with nothing, and still builds for the rest', async () => {
const { store } = await storeOf(LOG);
const stop = new AbortController();

const aborted = MergedTree.bottomUp(store, { signal: stop.signal });
const waiting = MergedTree.bottomUp(store);
stop.abort();

expect(await aborted).toBeNull();
expect((await waiting)?.source.roots.map((row) => row.text)).toEqual(
expect.arrayContaining(['ns.Inner.go()', 'ns.Outer.run()']),
);
});

// The failure belongs to the views still waiting, not to one that left for another log.
it('keeps a failed build from an aborted caller, and builds again for the next', async () => {
const { store } = await storeOf(LOG);
const index = jest.spyOn(store, 'logIndex').mockRejectedValue(new Error('boom'));
const stop = new AbortController();

const aborted = MergedTree.bottomUp(store, { signal: stop.signal });
const waiting = MergedTree.bottomUp(store);
stop.abort();

expect(await aborted).toBeNull();
await expect(waiting).rejects.toThrow('boom');
index.mockRestore();
expect(await MergedTree.bottomUp(store)).not.toBeNull();
});
});
72 changes: 69 additions & 3 deletions log-viewer/src/features/call-tree/grid/mergedRows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,54 @@ import {
} from '../utils/Aggregation.js';
import { reportedUsageOf } from '../utils/reportedUsage.js';

/** One store's Bottom-Up build, and the callers still waiting for it. */
interface SharedBuild {
tree: Promise<MergedTree<BottomUpRow> | null>;
stop: AbortController;
waiting: number;
done: boolean;
}

const bottomUpBuilds = new WeakMap<LogStore, SharedBuild>();

function bottomUpBuild(store: LogStore): SharedBuild {
const running = bottomUpBuilds.get(store);
if (running) {
return running;
}
const stop = new AbortController();
const shared: SharedBuild = {
stop,
waiting: 0,
done: false,
tree: reportedUsageOf(store.log)
.then((usage) => buildBottomUpTree(store, { signal: stop.signal }, usage))
.then((roots) => roots && new MergedTree(roots)),
};
// A failed or stopped build is forgotten, so the next caller builds again.
const settle = (tree: MergedTree<BottomUpRow> | null): void => {
shared.done = true;
if (!tree && bottomUpBuilds.get(store) === shared) {
bottomUpBuilds.delete(store);
}
};
shared.tree.then(settle, () => settle(null));
bottomUpBuilds.set(store, shared);
return shared;
}

/** `work`, or null as soon as `signal` aborts; an error after the abort is not this caller's. */
function untilAborted<T>(work: Promise<T>, signal?: AbortSignal): Promise<T | null> {
if (!signal) {
return work;
}
return new Promise((resolve, reject) => {
const abort = (): void => resolve(null);
signal.addEventListener('abort', abort, { once: true });
work.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
});
}

/** A row of a merged view: Aggregated or Bottom-Up. */
export interface MergedRowShape<R> {
id: number;
Expand All @@ -27,13 +75,31 @@ export class MergedTree<R extends MergedRowShape<R>> {
private readonly _parents = new Map<R, R>();
private readonly _byPath = new Map<number, R>();

/** The Bottom-Up tree of the events `store` shows, or null when `signal` aborts the build. */
/**
* The Bottom-Up tree of the events `store` shows, built once per store and shared, so its
* rows must not be changed. Null as soon as `signal` aborts; the build stops once no caller waits.
*/
static async bottomUp(
store: LogStore,
options: { signal?: AbortSignal } = {},
): Promise<MergedTree<BottomUpRow> | null> {
const roots = await buildBottomUpTree(store, options, await reportedUsageOf(store.log));
return roots ? new MergedTree(roots) : null;
const { signal } = options;
if (signal?.aborted) {
return null;
}
const build = bottomUpBuild(store);
build.waiting++;
try {
return await untilAborted(build.tree, signal);
} finally {
if (--build.waiting === 0 && !build.done) {
build.stop.abort();
// Gone now, so a caller that comes next starts a build of its own, not a stopped one.
if (bottomUpBuilds.get(store) === build) {
bottomUpBuilds.delete(store);
}
}
}
}

/** The Aggregated tree of the events `store` shows, or null when `signal` aborts the build. */
Expand Down
Loading