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