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