Skip to content

Commit 67a6180

Browse files
authored
exp: Materialize (#3567)
The commit introduces a MaterializationService that automatically creates persistent tables (CREATE OR REPLACE PERFETTO TABLE) for query nodes after successful execution, improving query performance for reused results. Key Changes 1. New MaterializationService (materialization_service.ts:1-122) - Manages lifecycle of materialized tables using CREATE OR REPLACE PERFETTO TABLE - Sanitizes node IDs for SQL safety (alphanumeric + underscores only) - Executes includes/preambles before creating materialized tables - Uses _exp_materialized_ prefix for table names 2. Automatic Materialization (builder.ts:380-391) - Queries are automatically materialized after successful execution - Only materializes valid queries (no errors/warnings) - Errors logged but don't block the UI 3. Cleanup Integration (explore_page.ts:247-276, explore_page.ts:319-335) - handleClearAllNodes(): Drops all materialized tables in parallel before clearing - handleDeleteNode(): Cleans up materialized table when deleting a node - Added getAllNodes() helper to traverse the node graph bidirectionally 4. UI Indicator (data_explorer.ts:114-124) - Database icon with tooltip showing materialized table name - Provides visibility into which nodes are materialized
1 parent 9884793 commit 67a6180

File tree

5 files changed

+258
-6
lines changed

5 files changed

+258
-6
lines changed

ui/src/plugins/dev.perfetto.ExplorePage/explore_page.ts

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {exportStateAsJson, importStateFromJson} from './json_handler';
2828
import {showImportWithStatementModal} from './sql_json_handler';
2929
import {registerCoreNodes} from './query_builder/core_nodes';
3030
import {nodeRegistry} from './query_builder/node_registry';
31+
import {MaterializationService} from './query_builder/materialization_service';
3132
import {HistoryManager} from './history_manager';
3233

3334
registerCoreNodes();
@@ -51,6 +52,7 @@ interface ExplorePageAttrs {
5152
}
5253

5354
export class ExplorePage implements m.ClassComponent<ExplorePageAttrs> {
55+
private materializationService?: MaterializationService;
5456
private historyManager?: HistoryManager;
5557

5658
private selectNode(attrs: ExplorePageAttrs, node: QueryNode) {
@@ -244,14 +246,70 @@ export class ExplorePage implements m.ClassComponent<ExplorePageAttrs> {
244246
}));
245247
}
246248

247-
handleClearAllNodes(attrs: ExplorePageAttrs) {
249+
async handleClearAllNodes(attrs: ExplorePageAttrs) {
250+
// Clean up materialized tables for all nodes
251+
if (this.materializationService !== undefined) {
252+
const allNodes = this.getAllNodes(attrs.state.rootNodes);
253+
const materialized = allNodes.filter(
254+
(node) => node.state.materialized === true,
255+
);
256+
257+
// Drop all materializations in parallel
258+
const results = await Promise.allSettled(
259+
materialized.map((node) =>
260+
this.materializationService!.dropMaterialization(node),
261+
),
262+
);
263+
264+
// Log any failures but don't block the clear operation
265+
results.forEach((result, index) => {
266+
if (result.status === 'rejected') {
267+
console.error(
268+
`Failed to drop materialization for node ${materialized[index].nodeId}:`,
269+
result.reason,
270+
);
271+
}
272+
});
273+
}
274+
248275
attrs.onStateUpdate((currentState) => ({
249276
...currentState,
250277
rootNodes: [],
251278
selectedNode: undefined,
252279
}));
253280
}
254281

282+
private getAllNodes(rootNodes: QueryNode[]): QueryNode[] {
283+
const allNodes: QueryNode[] = [];
284+
const visited = new Set<string>();
285+
const queue = [...rootNodes];
286+
287+
while (queue.length > 0) {
288+
const node = queue.shift()!;
289+
if (visited.has(node.nodeId)) {
290+
continue;
291+
}
292+
visited.add(node.nodeId);
293+
allNodes.push(node);
294+
295+
// Traverse forward edges
296+
queue.push(...node.nextNodes);
297+
298+
// Traverse backward edges
299+
if ('prevNode' in node && node.prevNode) {
300+
queue.push(node.prevNode);
301+
} else if ('prevNodes' in node) {
302+
for (const prevNode of node.prevNodes) {
303+
if (prevNode !== undefined) {
304+
queue.push(prevNode);
305+
}
306+
}
307+
}
308+
}
309+
310+
return allNodes;
311+
}
312+
255313
handleDuplicateNode(attrs: ExplorePageAttrs, node: QueryNode) {
256314
const {onStateUpdate} = attrs;
257315
onStateUpdate((currentState) => ({
@@ -260,9 +318,25 @@ export class ExplorePage implements m.ClassComponent<ExplorePageAttrs> {
260318
}));
261319
}
262320

263-
handleDeleteNode(attrs: ExplorePageAttrs, node: QueryNode) {
321+
async handleDeleteNode(attrs: ExplorePageAttrs, node: QueryNode) {
264322
const {state, onStateUpdate} = attrs;
265323

324+
// Clean up materialized table if it exists
325+
if (
326+
this.materializationService !== undefined &&
327+
node.state.materialized === true
328+
) {
329+
try {
330+
await this.materializationService.dropMaterialization(node);
331+
} catch (e) {
332+
console.error(
333+
`Failed to drop materialization for node ${node.nodeId}:`,
334+
e,
335+
);
336+
// Continue with node deletion even if materialization cleanup fails
337+
}
338+
}
339+
266340
let newRootNodes = state.rootNodes.filter((n) => n !== node);
267341
if (state.rootNodes.includes(node) && node.nextNodes.length > 0) {
268342
newRootNodes = [...newRootNodes, ...node.nextNodes];
@@ -509,6 +583,12 @@ export class ExplorePage implements m.ClassComponent<ExplorePageAttrs> {
509583
{
510584
onkeydown: (e: KeyboardEvent) => this.handleKeyDown(e, wrappedAttrs),
511585
oncreate: (vnode) => {
586+
// Initialize materialization service
587+
if (this.materializationService === undefined) {
588+
this.materializationService = new MaterializationService(
589+
attrs.trace.engine,
590+
);
591+
}
512592
(vnode.dom as HTMLElement).focus();
513593
},
514594
tabindex: 0,

ui/src/plugins/dev.perfetto.ExplorePage/query_builder/builder.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import {QueryService} from './query_service';
8282
import {findErrors, findWarnings} from './query_builder_utils';
8383
import {NodeIssues} from './node_issues';
8484
import {UIFilter} from './operations/filter';
85+
import {MaterializationService} from './materialization_service';
8586

8687
export interface BuilderAttrs {
8788
readonly trace: Trace;
@@ -131,6 +132,7 @@ export interface BuilderAttrs {
131132

132133
export class Builder implements m.ClassComponent<BuilderAttrs> {
133134
private queryService: QueryService;
135+
private materializationService: MaterializationService;
134136
private query?: Query | Error;
135137
private queryExecuted: boolean = false;
136138
private isQueryRunning: boolean = false;
@@ -143,6 +145,9 @@ export class Builder implements m.ClassComponent<BuilderAttrs> {
143145

144146
constructor({attrs}: m.Vnode<BuilderAttrs>) {
145147
this.queryService = new QueryService(attrs.trace.engine);
148+
this.materializationService = new MaterializationService(
149+
attrs.trace.engine,
150+
);
146151
}
147152

148153
view({attrs}: m.CVnode<BuilderAttrs>) {
@@ -361,7 +366,7 @@ export class Builder implements m.ClassComponent<BuilderAttrs> {
361366
return undefined;
362367
}
363368

364-
private runQuery(node: QueryNode) {
369+
private async runQuery(node: QueryNode) {
365370
if (
366371
this.query === undefined ||
367372
this.query instanceof Error ||
@@ -371,7 +376,9 @@ export class Builder implements m.ClassComponent<BuilderAttrs> {
371376
}
372377

373378
this.isQueryRunning = true;
374-
this.queryService.runQuery(queryToRun(this.query)).then((response) => {
379+
380+
try {
381+
const response = await this.queryService.runQuery(queryToRun(this.query));
375382
this.response = response;
376383
const ds = new InMemoryDataSource(this.response.rows);
377384
this.dataSource = {
@@ -411,8 +418,27 @@ export class Builder implements m.ClassComponent<BuilderAttrs> {
411418
if (node instanceof SqlSourceNode) {
412419
node.onQueryExecuted(this.response.columns);
413420
}
421+
422+
// Automatically materialize the node after successful execution
423+
if (isAQuery(this.query) && !error && !warning) {
424+
try {
425+
await this.materializationService.materializeNode(node, this.query);
426+
} catch (e) {
427+
console.error('Failed to materialize node:', e);
428+
// Don't block the UI on materialization errors
429+
}
430+
}
431+
} catch (e) {
432+
console.error('Failed to run query:', e);
433+
// Set error state on the node
434+
if (!node.state.issues) {
435+
node.state.issues = new NodeIssues();
436+
}
437+
node.state.issues.queryError =
438+
e instanceof Error ? e : new Error(String(e));
439+
} finally {
414440
this.isQueryRunning = false;
415441
m.redraw();
416-
});
442+
}
417443
}
418444
}

ui/src/plugins/dev.perfetto.ExplorePage/query_builder/data_explorer.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ import {QueryService} from './query_service';
3636
import {Intent} from '../../../widgets/common';
3737
import {Icons} from '../../../base/semantic_icons';
3838
import {MenuItem, PopupMenu} from '../../../widgets/menu';
39+
import {Icon} from '../../../widgets/icon';
40+
import {Tooltip} from '../../../widgets/tooltip';
3941

4042
import {findErrors} from './query_builder_utils';
4143
export interface DataExplorerAttrs {
@@ -109,6 +111,18 @@ export class DataExplorer implements m.ClassComponent<DataExplorerAttrs> {
109111
},
110112
});
111113

114+
// Add materialization indicator icon with tooltip
115+
const materializationIndicator =
116+
attrs.node.state.materialized && attrs.node.state.materializationTableName
117+
? m(
118+
Tooltip,
119+
{
120+
trigger: m(Icon, {icon: 'database'}),
121+
},
122+
`Materialized as ${attrs.node.state.materializationTableName}`,
123+
)
124+
: null;
125+
112126
const positionMenu = m(
113127
PopupMenu,
114128
{
@@ -124,7 +138,13 @@ export class DataExplorer implements m.ClassComponent<DataExplorerAttrs> {
124138
],
125139
);
126140

127-
return [runButton, statusIndicator, autoExecuteSwitch, positionMenu];
141+
return [
142+
runButton,
143+
statusIndicator,
144+
materializationIndicator,
145+
autoExecuteSwitch,
146+
positionMenu,
147+
];
128148
}
129149

130150
private renderContent(
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (C) 2025 The Android Open Source Project
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import {Engine} from '../../../trace_processor/engine';
16+
import {Query, QueryNode} from '../query_node';
17+
18+
/**
19+
* Service for managing materialized tables for Explore Page nodes.
20+
* Materialization creates persistent tables using CREATE OR REPLACE PERFETTO TABLE.
21+
*/
22+
export class MaterializationService {
23+
constructor(private engine: Engine) {}
24+
25+
/**
26+
* Materializes a node's query into a table.
27+
* If the table already exists, it will be replaced.
28+
*
29+
* @param node The node to materialize
30+
* @param query The validated query to materialize
31+
* @returns The name of the created materialized table
32+
*/
33+
async materializeNode(node: QueryNode, query: Query): Promise<string> {
34+
const tableName = this.getTableName(node);
35+
36+
// Build the full SQL with includes and preambles
37+
const includes = query.modules.map((c) => `INCLUDE PERFETTO MODULE ${c};`);
38+
const parts: string[] = [];
39+
if (includes.length > 0) {
40+
parts.push(includes.join('\n'));
41+
}
42+
if (query.preambles.length > 0) {
43+
parts.push(query.preambles.join('\n'));
44+
}
45+
46+
// Execute the includes and preambles first
47+
if (parts.length > 0) {
48+
const fullSql = parts.join('\n');
49+
await this.engine.query(fullSql);
50+
}
51+
52+
// Create or replace the materialized table
53+
const createTableSql = `CREATE OR REPLACE PERFETTO TABLE ${tableName} AS ${query.sql}`;
54+
await this.engine.query(createTableSql);
55+
56+
// Update node state
57+
node.state.materialized = true;
58+
node.state.materializationTableName = tableName;
59+
60+
return tableName;
61+
}
62+
63+
/**
64+
* Drops a materialized table for a node.
65+
*
66+
* @param node The node whose materialized table should be dropped
67+
*/
68+
async dropMaterialization(node: QueryNode): Promise<void> {
69+
if (!node.state.materializationTableName) {
70+
return;
71+
}
72+
73+
const tableName = node.state.materializationTableName;
74+
// Use query() not tryQuery() - we want to know if drop fails
75+
await this.engine.query(`DROP TABLE IF EXISTS ${tableName}`);
76+
77+
// Only update state if drop succeeded
78+
node.state.materialized = false;
79+
node.state.materializationTableName = undefined;
80+
}
81+
82+
/**
83+
* Generates a unique table name for a node's materialization.
84+
*
85+
* @param node The node to generate a table name for
86+
* @returns A unique table name
87+
*/
88+
private getTableName(node: QueryNode): string {
89+
// Sanitize nodeId to prevent SQL injection and ensure valid identifier
90+
// Only allow alphanumeric characters and underscores
91+
const sanitizedId = node.nodeId.replace(/[^a-zA-Z0-9_]/g, '_');
92+
93+
// Warn if sanitization changed the nodeId, as this could lead to collisions
94+
if (sanitizedId !== node.nodeId) {
95+
console.warn(
96+
`Node ID "${node.nodeId}" was sanitized to "${sanitizedId}" for table name.`,
97+
);
98+
}
99+
100+
return `_exp_materialized_${sanitizedId}`;
101+
}
102+
103+
/**
104+
* Checks if a node is currently materialized.
105+
*
106+
* @param node The node to check
107+
* @returns True if the node is materialized
108+
*/
109+
isMaterialized(node: QueryNode): boolean {
110+
return node.state.materialized ?? false;
111+
}
112+
113+
/**
114+
* Gets the materialized table name for a node if it exists.
115+
*
116+
* @param node The node to get the table name for
117+
* @returns The table name or undefined
118+
*/
119+
getMaterializedTableName(node: QueryNode): string | undefined {
120+
return node.state.materializationTableName;
121+
}
122+
}

ui/src/plugins/dev.perfetto.ExplorePage/query_node.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ export interface QueryNodeState {
9292
// If false, the user must manually click "Run" to execute queries.
9393
// Set by the node registry when the node is created.
9494
autoExecute?: boolean;
95+
96+
// Materialization state
97+
materialized?: boolean;
98+
materializationTableName?: string;
9599
}
96100

97101
export interface BaseNode {

0 commit comments

Comments
 (0)