// Copyright (C) 2024 The Android Open Source Project // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use size file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import {AsyncLimiter} from '../../base/async_limiter'; import {AsyncDisposableStack} from '../../base/disposable_stack'; import {Size2D} from '../../base/geom'; import {Duration, Time, TimeSpan, duration, time} from '../../base/time'; import {TimeScale} from '../../base/time_scale'; import {TraceImpl} from '../../core/trace_impl'; import {LONG, NUM} from '../../trace_processor/query_result'; import {escapeSearchQuery} from '../../trace_processor/query_utils'; import {createVirtualTable} from '../../trace_processor/sql_utils'; import {calculateResolution} from './resolution'; interface SearchSummary { tsStarts: BigInt64Array; tsEnds: BigInt64Array; count: Uint8Array; } /** * This component is drawn on top of the timeline and creates small yellow * rectangles that highlight the time span of search results (similarly to what * Chrome does on the scrollbar when you Ctrl+F and type a search term). * It reacts to changes in SearchManager and queries the quantized ranges of the * search results. */ export class SearchOverviewTrack implements AsyncDisposable { private readonly trash = new AsyncDisposableStack(); private readonly trace: TraceImpl; private readonly limiter = new AsyncLimiter(); private initialized = false; private previousResolution: duration | undefined; private previousSpan: TimeSpan | undefined; private previousSearchGeneration = 0; private searchSummary: SearchSummary | undefined; constructor(trace: TraceImpl) { this.trace = trace; } render(ctx: CanvasRenderingContext2D, size: Size2D) { this.maybeUpdate(size); this.renderSearchOverview(ctx, size); } private async initialize() { const engine = this.trace.engine; this.trash.use( await createVirtualTable(engine, 'search_summary_window', 'window'), ); this.trash.use( await createVirtualTable( engine, 'search_summary_sched_span', 'span_join(sched PARTITIONED cpu, search_summary_window)', ), ); this.trash.use( await createVirtualTable( engine, 'search_summary_slice_span', 'span_join(slice PARTITIONED track_id, search_summary_window)', ), ); } private async update( search: string, start: time, end: time, resolution: duration, ): Promise { if (!this.initialized) { this.initialized = true; await this.initialize(); } const searchLiteral = escapeSearchQuery(search); const resolutionScalingFactor = 10n; const quantum = resolution * resolutionScalingFactor; start = Time.quantFloor(start, quantum); const windowDur = Duration.max(Time.diff(end, start), 1n); const engine = this.trace.engine; await engine.query(`update search_summary_window set window_start=${start}, window_dur=${windowDur}, quantum=${quantum} where rowid = 0;`); const utidRes = await engine.query(`select utid from thread join process using(upid) where thread.name glob ${searchLiteral} or process.name glob ${searchLiteral}`); const utids = []; for (const it = utidRes.iter({utid: NUM}); it.valid(); it.next()) { utids.push(it.utid); } const res = await engine.query(` select (quantum_ts * ${quantum} + ${start}) as tsStart, ((quantum_ts+1) * ${quantum} + ${start}) as tsEnd, min(count(*), 255) as count from ( select quantum_ts from search_summary_sched_span where utid in (${utids.join(',')}) union all select quantum_ts from search_summary_slice_span where name glob ${searchLiteral} ) group by quantum_ts order by quantum_ts;`); const numRows = res.numRows(); const summary: SearchSummary = { tsStarts: new BigInt64Array(numRows), tsEnds: new BigInt64Array(numRows), count: new Uint8Array(numRows), }; const it = res.iter({tsStart: LONG, tsEnd: LONG, count: NUM}); for (let row = 0; it.valid(); it.next(), ++row) { summary.tsStarts[row] = it.tsStart; summary.tsEnds[row] = it.tsEnd; summary.count[row] = it.count; } return summary; } private maybeUpdate(size: Size2D) { const searchManager = this.trace.search; const timeline = this.trace.timeline; if (!searchManager.hasResults) { return; } const newSpan = timeline.visibleWindow; const newSearchGeneration = searchManager.searchGeneration; const newResolution = calculateResolution(newSpan, size.width); const newTimeSpan = newSpan.toTimeSpan(); if ( this.previousSpan?.containsSpan(newTimeSpan.start, newTimeSpan.end) && this.previousResolution === newResolution && this.previousSearchGeneration === newSearchGeneration ) { return; } // TODO(hjd): We should restrict this to the start of the trace but // that is not easily available here. // N.B. Timestamps can be negative. const {start, end} = newTimeSpan.pad(newTimeSpan.duration); this.previousSpan = new TimeSpan(start, end); this.previousResolution = newResolution; this.previousSearchGeneration = newSearchGeneration; const search = searchManager.searchText; if (search === '') { this.searchSummary = { tsStarts: new BigInt64Array(0), tsEnds: new BigInt64Array(0), count: new Uint8Array(0), }; return; } this.limiter.schedule(async () => { const summary = await this.update( searchManager.searchText, start, end, newResolution, ); this.searchSummary = summary; }); } private renderSearchOverview( ctx: CanvasRenderingContext2D, size: Size2D, ): void { const visibleWindow = this.trace.timeline.visibleWindow; const timescale = new TimeScale(visibleWindow, { left: 0, right: size.width, }); if (!this.searchSummary) return; for (let i = 0; i < this.searchSummary.tsStarts.length; i++) { const tStart = Time.fromRaw(this.searchSummary.tsStarts[i]); const tEnd = Time.fromRaw(this.searchSummary.tsEnds[i]); if (!visibleWindow.overlaps(tStart, tEnd)) { continue; } const rectStart = Math.max(timescale.timeToPx(tStart), 0); const rectEnd = timescale.timeToPx(tEnd); ctx.fillStyle = '#ffe263'; ctx.fillRect( Math.floor(rectStart), 0, Math.ceil(rectEnd - rectStart), size.height, ); } const results = this.trace.search.searchResults; if (results === undefined) { return; } const index = this.trace.search.resultIndex; if (index !== -1 && index < results.tses.length) { const start = results.tses[index]; if (start !== -1n) { const triangleStart = Math.max( timescale.timeToPx(Time.fromRaw(start)), 0, ); ctx.fillStyle = '#000'; ctx.beginPath(); ctx.moveTo(triangleStart, size.height); ctx.lineTo(triangleStart - 3, 0); ctx.lineTo(triangleStart + 3, 0); ctx.lineTo(triangleStart, size.height); ctx.fill(); ctx.closePath(); } } ctx.restore(); } async [Symbol.asyncDispose](): Promise { return await this.trash.asyncDispose(); } }