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