• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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