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 15import {STR, STR_NULL} from '../../trace_processor/query_result'; 16import {Engine} from '../../trace_processor/engine'; 17import {escapeQuery} from '../../trace_processor/query_utils'; 18import {generateSqlWithInternalLayout} from '../../components/sql_utils/layout'; 19import {rows} from './utils'; 20 21/** A model of the scroll timeline. */ 22export interface ScrollTimelineModel { 23 /** 24 * The name of the SQL table which contains information about the slices in 25 * {@link scroll_timeline_track#ScrollTimelineTrack}. 26 * 27 * The table has the following columns: 28 * 29 * id (LONG): Unique ID of the slice (monotonically increasing). Note that 30 * it cannot joined with any tables in Chrome's tracing stdlib. 31 * ts (TIMESTAMP): Start timestamp of the slice. 32 * dur (DURATION): Duration of the slice. 33 * depth (LONG): Depth of the slice on the track. 34 * name (STRING): Title of the slice. 35 * classification (LONG): Classification of a scroll update for the purposes 36 * of trace visualization. Guaranteed to be one of the values in 37 * {@link ScrollUpdateClassification}. 38 * scroll_update_id (LONG): ID of the `chrome_scroll_update_info` row that 39 * this slice corresponds to. Can be joined with 40 * `chrome_scroll_update_info.id`. In general, multiple rows in `tableName` 41 * correspond to a single row in `chrome_scroll_update_info`. 42 * 43 * Note: The table contains both: 44 * 45 * 1. Parent slices for the entire scroll updates (e.g. 'Janky Scroll 46 * Update') and 47 * 2. Child slices for the individual stages of the scroll update (e.g. 48 * 'GenerationToBrowserMain'). 49 */ 50 readonly tableName: string; 51 52 /** 53 * A unique identifier of the associated 54 * {@link scroll_timeline_track#ScrollTimelineTrack}. 55 */ 56 readonly trackUri: string; 57 58 /** Definitions of the stages of a scroll. */ 59 readonly stepTemplates: readonly StepTemplate[]; 60} 61 62/** 63 * Definition of a stage of a scroll retrieved from the 64 * `chrome_scroll_update_info_step_templates` table. 65 */ 66export interface StepTemplate { 67 // The name of a stage of a scroll. 68 // WARNING: This could be an arbitrary string so it MUST BE ESCAPED before 69 // using in an SQL query. 70 readonly stepName: string; 71 // The name of the column in `chrome_scroll_update_info` which contains the 72 // timestamp of the step. If not null, this is guaranteed to be a valid column 73 // name, i.e. it's safe to use inline in an SQL query without any additional 74 // sanitization. 75 readonly tsColumnName: string | null; 76 // The name of the column in `chrome_scroll_update_info` which contains the 77 // duration of the step. Null if the stage doesn't have a duration. If not 78 // null, this is guaranteed to be a valid column name, i.e. it's safe to use 79 // inline in an SQL query without any additional sanitization. 80 readonly durColumnName: string | null; 81} 82 83/** 84 * Classification of a scroll update for the purposes of trace visualization. 85 * 86 * If a scroll update matches multiple classifications (e.g. janky and 87 * inertial), it should be classified with the highest-priority one (e.g. 88 * janky). With the exception of `DEFAULT` and `STEP`, the values are sorted in 89 * the order of descending priority (i.e. `JANKY` has the highest priority). 90 */ 91export enum ScrollUpdateClassification { 92 // None of the other classifications apply. 93 DEFAULT = 0, 94 95 // The corresponding frame was janky. 96 // See `chrome_scroll_update_input_info.is_janky`. 97 JANKY = 1, 98 99 // The input was coalesced into an earlier input's frame. 100 // See `chrome_scroll_update_input_info.is_first_scroll_update_in_frame`. 101 COALESCED = 2, 102 103 // It's the first scroll update in a scroll. 104 // Note: A first scroll update can never be janky. 105 // See `chrome_scroll_update_input_info.is_first_scroll_update_in_scroll`. 106 FIRST_SCROLL_UPDATE_IN_FRAME = 3, 107 108 // The corresponding scroll was inertial (i.e. a fling). 109 INERTIAL = 4, 110 111 // Sentinel value for slices which represent sub-steps of a scroll update. 112 STEP = -1, 113} 114 115export async function createScrollTimelineModel( 116 engine: Engine, 117 tableName: string, 118 trackUri: string, 119): Promise<ScrollTimelineModel> { 120 const stepTemplates = Object.freeze(await queryStepTemplates(engine)); 121 createTable(engine, tableName, stepTemplates); 122 return {tableName, trackUri, stepTemplates}; 123} 124 125/** 126 * Creates a Perfetto table named `tableName` representing the slices of a 127 * {@link scroll_timeline_track#ScrollTimelineTrack} for a given trace. 128 */ 129async function createTable( 130 engine: Engine, 131 tableName: string, 132 stepTemplates: readonly StepTemplate[], 133): Promise<void> { 134 // TODO: b/383549233 - Set ts+dur of each scroll update directly based on 135 // our knowledge of the scrolling pipeline (as opposed to aggregating over 136 // scroll_steps). 137 await engine.query( 138 `INCLUDE PERFETTO MODULE chrome.chrome_scrolls; 139 CREATE PERFETTO TABLE ${tableName} AS 140 WITH 141 -- Unpivot all ts+dur columns into rows. Each row corresponds to a step 142 -- of a particular scroll update. Some of the rows might have null 143 -- ts/dur values, which will be filtered out in unordered_slices. 144 -- |scroll_steps| = |chrome_scroll_update_info| * |stepTemplates| 145 scroll_steps AS (${stepTemplates 146 .map( 147 (step) => ` 148 SELECT 149 id AS scroll_update_id, 150 ${step.tsColumnName ?? 'NULL'} AS ts, 151 ${step.durColumnName ?? 'NULL'} AS dur, 152 ${escapeQuery(step.stepName)} AS name 153 FROM chrome_scroll_update_info`, 154 ) 155 .join(' UNION ALL ')}), 156 -- For each scroll update, find its ts+dur by aggregating over all steps 157 -- within the scroll update. We're basically trying to find MIN(COL1_ts, 158 -- COL2_ts, ..., COLn_ts) and MAX(COL1_ts, COL2_ts, ..., COLn_ts) from 159 -- all the various ts columns in chrome_scroll_update_info. The 160 -- difficulty is that some of those columns might be null, which is 161 -- better handled by the aggregate MIN/MAX functions (which ignore null 162 -- values) than the scalar MIN/MAX functions (which return null if any 163 -- argument is null). Furthermore, using a COALESCE function with so 164 -- many arguments (COL1_ts, COL2_ts, ..., COLn_ts) seems to cause 165 -- out-of-memory crashes. 166 scroll_update_bounds AS ( 167 SELECT 168 scroll_update_id, 169 MIN(ts) AS ts, 170 MAX(ts) - MIN(ts) AS dur 171 FROM scroll_steps 172 GROUP BY scroll_update_id 173 ), 174 -- Now that we know the ts+dur of all scroll updates, we can lay them 175 -- out efficiently (i.e. assign depths to them to avoid overlaps). 176 scroll_update_layouts AS ( 177 ${generateSqlWithInternalLayout({ 178 columns: ['scroll_update_id', 'ts', 'dur'], 179 source: 'scroll_update_bounds', 180 ts: 'ts', 181 dur: 'dur', 182 // Filter out scroll updates with no timestamps. See b/388756942. 183 whereClause: 'ts IS NOT NULL AND dur IS NOT NULL', 184 })} 185 ), 186 -- We interleave the top-level scroll update slices (at even depths) and 187 -- their constituent step slices (at odd depths). 188 unordered_slices AS ( 189 SELECT 190 scroll_update_layouts.ts, 191 scroll_update_layouts.dur, 192 2 * scroll_update_layouts.depth AS depth, 193 -- Combine all applicable scroll update classifications into the 194 -- name. For example, if a scroll update is both janky and inertial, 195 -- its name will be name 'Janky Inertial Scroll Update'. 196 CONCAT_WS( 197 ' ', 198 IIF(chrome_scroll_update_info.is_janky, 'Janky', NULL), 199 IIF( 200 chrome_scroll_update_info.is_first_scroll_update_in_scroll, 201 'First', 202 NULL 203 ), 204 IIF( 205 NOT chrome_scroll_update_info.is_first_scroll_update_in_frame, 206 'Coalesced', 207 NULL 208 ), 209 IIF(chrome_scroll_update_info.is_inertial, 'Inertial', NULL), 210 'Scroll Update' 211 ) AS name, 212 -- Pick the highest-priority applicable scroll update 213 -- classification. For example, if a scroll update is both janky and 214 -- inertial, classify it as janky. 215 CASE 216 WHEN chrome_scroll_update_info.is_janky 217 THEN ${ScrollUpdateClassification.JANKY} 218 WHEN chrome_scroll_update_info.is_first_scroll_update_in_scroll 219 THEN ${ScrollUpdateClassification.FIRST_SCROLL_UPDATE_IN_FRAME} 220 WHEN NOT chrome_scroll_update_info.is_first_scroll_update_in_frame 221 THEN ${ScrollUpdateClassification.COALESCED} 222 WHEN chrome_scroll_update_info.is_inertial 223 THEN ${ScrollUpdateClassification.INERTIAL} 224 ELSE ${ScrollUpdateClassification.DEFAULT} 225 END AS classification, 226 scroll_update_layouts.scroll_update_id 227 FROM scroll_update_layouts 228 JOIN chrome_scroll_update_info 229 ON scroll_update_layouts.scroll_update_id 230 = chrome_scroll_update_info.id 231 UNION ALL 232 SELECT 233 scroll_steps.ts, 234 MAX(scroll_steps.dur, 0) AS dur, 235 2 * scroll_update_layouts.depth + 1 AS depth, 236 scroll_steps.name, 237 ${ScrollUpdateClassification.STEP} AS classification, 238 scroll_update_layouts.scroll_update_id 239 FROM scroll_steps 240 JOIN scroll_update_layouts USING(scroll_update_id) 241 WHERE scroll_steps.ts IS NOT NULL AND scroll_steps.dur IS NOT NULL 242 ) 243 -- Finally, we sort all slices chronologically and assign them 244 -- monotonically increasing IDs. Note that we cannot reuse 245 -- chrome_scroll_update_info.id (not even for the top-level scroll update 246 -- slices) because Perfetto slice IDs must be 32-bit unsigned integers. 247 SELECT 248 ROW_NUMBER() OVER (ORDER BY ts ASC) AS id, 249 * 250 FROM unordered_slices 251 ORDER BY ts ASC`, 252 ); 253} 254 255/** 256 * Queries scroll step templates from 257 * `chrome_scroll_update_info_step_templates`. 258 * 259 * This function sanitizes the column names `StepTemplate.ts_column_name` and 260 * `StepTemplate.dur_column_name`. Unless null, the returned column names are 261 * guaranteed to be valid column names of `chrome_scroll_update_info`. 262 */ 263async function queryStepTemplates(engine: Engine): Promise<StepTemplate[]> { 264 // Use a set for faster lookups. 265 const columnNames = new Set( 266 await queryChromeScrollUpdateInfoColumnNames(engine), 267 ); 268 const stepTemplatesResult = await engine.query(` 269 INCLUDE PERFETTO MODULE chrome.chrome_scrolls; 270 SELECT 271 step_name, 272 ts_column_name, 273 dur_column_name 274 FROM chrome_scroll_update_info_step_templates;`); 275 return rows(stepTemplatesResult, { 276 step_name: STR, 277 ts_column_name: STR_NULL, 278 dur_column_name: STR_NULL, 279 }).map( 280 // We defensively verify that the column names actually exist in the 281 // `chrome_scroll_update_info` table. We do this because we cannot update 282 // the `chrome_scroll_update_info` table and this plugin atomically 283 // (`chrome_scroll_update_info` is a part of the Chrome tracing stdlib, 284 // whose source of truth is in the Chromium repository). 285 (row) => ({ 286 stepName: row.step_name, 287 tsColumnName: checkColumnNameIsValidOrReturnNull( 288 row.ts_column_name, 289 columnNames, 290 'Invalid ts_column_name in chrome_scroll_update_info_step_templates', 291 ), 292 durColumnName: checkColumnNameIsValidOrReturnNull( 293 row.dur_column_name, 294 columnNames, 295 'Invalid dur_column_name in chrome_scroll_update_info_step_templates', 296 ), 297 }), 298 ); 299} 300 301/** Returns the names of columns of the `chrome_scroll_update_info` table. */ 302async function queryChromeScrollUpdateInfoColumnNames( 303 engine: Engine, 304): Promise<string[]> { 305 // See https://www.sqlite.org/pragma.html#pragfunc and 306 // https://www.sqlite.org/pragma.html#pragma_table_info for more information 307 // about `pragma_table_info`. 308 const columnNamesResult = await engine.query(` 309 INCLUDE PERFETTO MODULE chrome.chrome_scrolls; 310 SELECT name FROM pragma_table_info('chrome_scroll_update_info');`); 311 return rows(columnNamesResult, {name: STR}).map((row) => row.name); 312} 313 314/** 315 * If `allowedColumnNames` contains `columnName`, returns `columnName`. 316 * Otherwise, returns null. 317 */ 318function checkColumnNameIsValidOrReturnNull( 319 columnName: string | null, 320 allowedColumnNames: Set<string>, 321 errorMessagePrefix: string, 322): string | null { 323 if (columnName == null || allowedColumnNames.has(columnName)) { 324 return columnName; 325 } else { 326 console.error( 327 `${errorMessagePrefix}: ${columnName} 328 (allowed column names: ${Array.from(allowedColumnNames).join(', ')})`, 329 ); 330 return null; 331 } 332} 333