xref: /aosp_15_r20/external/perfetto/ui/src/core/selection_aggregation_manager.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1// Copyright (C) 2024 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
15import {AsyncLimiter} from '../base/async_limiter';
16import {isString} from '../base/object_utils';
17import {AggregateData, Column, ColumnDef, Sorting} from '../public/aggregation';
18import {AreaSelection, AreaSelectionAggregator} from '../public/selection';
19import {Engine} from '../trace_processor/engine';
20import {NUM} from '../trace_processor/query_result';
21import {raf} from './raf_scheduler';
22
23export class SelectionAggregationManager {
24  private engine: Engine;
25  private readonly limiter = new AsyncLimiter();
26  private _aggregators = new Array<AreaSelectionAggregator>();
27  private _aggregatedData = new Map<string, AggregateData>();
28  private _sorting = new Map<string, Sorting>();
29  private _currentArea: AreaSelection | undefined = undefined;
30
31  constructor(engine: Engine) {
32    this.engine = engine;
33  }
34
35  registerAggregator(aggr: AreaSelectionAggregator) {
36    this._aggregators.push(aggr);
37  }
38
39  aggregateArea(area: AreaSelection) {
40    this.limiter.schedule(async () => {
41      this._currentArea = area;
42      this._aggregatedData.clear();
43      for (const aggr of this._aggregators) {
44        const data = await this.runAggregator(aggr, area);
45        if (data !== undefined) {
46          this._aggregatedData.set(aggr.id, data);
47        }
48      }
49      raf.scheduleFullRedraw();
50    });
51  }
52
53  clear() {
54    // This is wrapped in the async limiter to make sure that an aggregateArea()
55    // followed by a clear() (e.g., because selection changes) doesn't end up
56    // with the aggregation being displayed anyways once the promise completes.
57    this.limiter.schedule(async () => {
58      this._currentArea = undefined;
59      this._aggregatedData.clear();
60      this._sorting.clear();
61      raf.scheduleFullRedraw();
62    });
63  }
64
65  getSortingPrefs(aggregatorId: string): Sorting | undefined {
66    return this._sorting.get(aggregatorId);
67  }
68
69  toggleSortingColumn(aggregatorId: string, column: string) {
70    const sorting = this._sorting.get(aggregatorId);
71
72    if (sorting === undefined || sorting.column !== column) {
73      // No sorting set for current column.
74      this._sorting.set(aggregatorId, {
75        column,
76        direction: 'DESC',
77      });
78    } else if (sorting.direction === 'DESC') {
79      // Toggle the direction if the column is currently sorted.
80      this._sorting.set(aggregatorId, {
81        column,
82        direction: 'ASC',
83      });
84    } else {
85      // If direction is currently 'ASC' toggle to no sorting.
86      this._sorting.delete(aggregatorId);
87    }
88
89    // Re-run the aggregation.
90    if (this._currentArea) {
91      this.aggregateArea(this._currentArea);
92    }
93  }
94
95  get aggregators(): ReadonlyArray<AreaSelectionAggregator> {
96    return this._aggregators;
97  }
98
99  getAggregatedData(aggregatorId: string): AggregateData | undefined {
100    return this._aggregatedData.get(aggregatorId);
101  }
102
103  private async runAggregator(
104    aggr: AreaSelectionAggregator,
105    area: AreaSelection,
106  ): Promise<AggregateData | undefined> {
107    const viewExists = await aggr.createAggregateView(this.engine, area);
108    if (!viewExists) {
109      return undefined;
110    }
111
112    const defs = aggr.getColumnDefinitions();
113    const colIds = defs.map((col) => col.columnId);
114    const sorting = this._sorting.get(aggr.id);
115    let sortClause = `${aggr.getDefaultSorting().column} ${
116      aggr.getDefaultSorting().direction
117    }`;
118    if (sorting) {
119      sortClause = `${sorting.column} ${sorting.direction}`;
120    }
121    const query = `select ${colIds} from ${aggr.id} order by ${sortClause}`;
122    const result = await this.engine.query(query);
123
124    const numRows = result.numRows();
125    const columns = defs.map((def) => columnFromColumnDef(def, numRows));
126    const columnSums = await Promise.all(
127      defs.map((def) => this.getSum(aggr.id, def)),
128    );
129    const extraData = await aggr.getExtra(this.engine, area);
130    const extra = extraData ? extraData : undefined;
131    const data: AggregateData = {
132      tabName: aggr.getTabName(),
133      columns,
134      columnSums,
135      strings: [],
136      extra,
137    };
138
139    const stringIndexes = new Map<string, number>();
140    function internString(str: string) {
141      let idx = stringIndexes.get(str);
142      if (idx !== undefined) return idx;
143      idx = data.strings.length;
144      data.strings.push(str);
145      stringIndexes.set(str, idx);
146      return idx;
147    }
148
149    const it = result.iter({});
150    for (let i = 0; it.valid(); it.next(), ++i) {
151      for (const column of data.columns) {
152        const item = it.get(column.columnId);
153        if (item === null) {
154          column.data[i] = isStringColumn(column) ? internString('NULL') : 0;
155        } else if (isString(item)) {
156          column.data[i] = internString(item);
157        } else if (item instanceof Uint8Array) {
158          column.data[i] = internString('<Binary blob>');
159        } else if (typeof item === 'bigint') {
160          // TODO(stevegolton) It would be nice to keep bigints as bigints for
161          // the purposes of aggregation, however the aggregation infrastructure
162          // is likely to be significantly reworked when we introduce EventSet,
163          // and the complexity of supporting bigints throughout the aggregation
164          // panels in its current form is not worth it. Thus, we simply
165          // convert bigints to numbers.
166          column.data[i] = Number(item);
167        } else {
168          column.data[i] = item;
169        }
170      }
171    }
172
173    return data;
174  }
175
176  private async getSum(tableName: string, def: ColumnDef): Promise<string> {
177    if (!def.sum) return '';
178    const result = await this.engine.query(
179      `select ifnull(sum(${def.columnId}), 0) as s from ${tableName}`,
180    );
181    let sum = result.firstRow({s: NUM}).s;
182    if (def.kind === 'TIMESTAMP_NS') {
183      sum = sum / 1e6;
184    }
185    return `${sum}`;
186  }
187}
188
189function columnFromColumnDef(def: ColumnDef, numRows: number): Column {
190  // TODO(hjd): The Column type should be based on the
191  // ColumnDef type or vice versa to avoid this cast.
192  return {
193    title: def.title,
194    kind: def.kind,
195    data: new def.columnConstructor(numRows),
196    columnId: def.columnId,
197  } as Column;
198}
199
200function isStringColumn(column: Column): boolean {
201  return column.kind === 'STRING' || column.kind === 'STATE';
202}
203