blob: 4c283702c0c3abc1bba4c412412a4b191a3d269d [file]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this 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 m from 'mithril';
import {InMemoryDataSource} from '../../components/widgets/datagrid/in_memory_data_source';
import {bigTraceSettingsStorage} from '../settings/bigtrace_settings_storage';
import {getBigtraceEndpoint} from '../settings/endpoint_storage';
import type {SettingFilter} from '../settings/settings_types';
import {effectiveQueryColumns} from '../settings/trace_selection_state';
import {BigtraceAsyncDataSource} from './bigtrace_async_data_source';
import {
BigtraceQueryClient,
type ExecuteOptions,
QueryCancelledError,
QueryNotFoundError,
} from './bigtrace_query_client';
import {forwardAbort} from './abort_utils';
import {
isoToEpochMs,
snapshotSettingsToFilters,
type RawQueryExecution,
} from './query_history_storage';
import {queryStore, TERMINAL_STATUSES} from './query_store';
import {
disabledSettingsFromSnapshot,
effectiveTabSettings,
makeQueryResponse,
} from '../pages/query_tabs_state';
import type {BigTraceEditorTab} from '../pages/query_tabs_state';
import {PollingController} from './polling_controller';
interface QueryRunnerCallbacks {
readonly onHistoryChanged: () => void;
readonly redraw?: () => void;
readonly markDirty?: () => void;
}
// One instance per QueryPage; owns dispatch / cancel for each tab.
// Polling is delegated to PollingController.
export class QueryRunner {
private readonly poller: PollingController;
constructor(private readonly cb: QueryRunnerCallbacks) {
this.poller = new PollingController({
redraw: () => this.redraw(),
onHistoryChanged: cb.onHistoryChanged,
});
}
// Run `query` on `tab`. Aborts any in-flight query on the tab first.
async run(tab: BigTraceEditorTab, query: string): Promise<void> {
if (!query) return;
tab.activeRequest?.abort();
tab.isLoading = true;
tab.queryResult = undefined;
tab.lastProcessedRows = 0;
tab.clientStartTime = Date.now();
this.cb.markDirty?.();
this.redraw();
this.cb.onHistoryChanged();
const endpoint = getBigtraceEndpoint();
if (endpoint.trim() === '') {
tab.queryResult = makeQueryResponse(query, {
error: 'Set the BigTrace Endpoint in Settings before running queries.',
});
tab.dataSource = new InMemoryDataSource([]);
tab.isLoading = false;
this.redraw();
return;
}
await bigTraceSettingsStorage.loadSettings();
// Global defaults overridden by per-tab values, minus per-tab-disabled
// settings. Same helper the trace grid uses, so /trace_metadata and
// /execute_* stay in sync.
const settings = effectiveTabSettings(tab);
// Per-tab snapshot is the trace-selection source of truth: what the user
// staged here (seeded from globals at tab creation, then edited). Globals
// aren't re-read at run time.
const traceFilters = tab.traceFilters;
const traceOrderBy = tab.traceOrderBy;
const queryClient = new BigtraceQueryClient(endpoint);
tab.queryClient = queryClient;
const requestController = new AbortController();
const cancelForward = forwardAbort(tab.lifecycle.signal, requestController);
tab.activeRequest = requestController;
// null → use the schema's defaultVisible columns; an explicit list (incl.
// []) ships as-is; a schema-fetch failure attaches nothing.
let traceMetadataColumns: readonly string[] =
tab.traceMetadataColumns ?? [];
if (tab.traceMetadataColumns === null) {
try {
const schema = await queryClient.listTraceMetadataSchema(
settings,
requestController.signal,
);
traceMetadataColumns = effectiveQueryColumns(null, schema.columns);
} catch {
traceMetadataColumns = [];
}
}
const executeOptions: ExecuteOptions = {
traceFilters,
traceMetadataColumns,
traceOrderBy,
};
const wallStartMs = performance.now();
try {
if (tab.materialize) {
await this.runAsync(
tab,
query,
queryClient,
settings,
executeOptions,
requestController.signal,
wallStartMs,
);
} else {
await this.runSync(
tab,
query,
queryClient,
settings,
executeOptions,
requestController.signal,
wallStartMs,
);
}
} catch (e) {
if (e instanceof QueryCancelledError) {
tab.isLoading = false;
this.redraw();
return;
}
tab.queryResult = makeQueryResponse(query, {
error: e instanceof Error ? e.message : String(e),
durationMs: performance.now() - wallStartMs,
});
tab.isLoading = false;
} finally {
cancelForward();
tab.activeRequest = undefined;
}
if (tab.queryResult !== undefined && !tab.materialize) {
tab.dataSource = new InMemoryDataSource(tab.queryResult.rows);
tab.isLoading = false;
this.cb.onHistoryChanged();
}
this.redraw();
}
// Aborts the local request and, for materialized queries, the remote run too.
async cancel(tab: BigTraceEditorTab): Promise<void> {
this.redraw();
const queryUuid = tab.queryUuid;
tab.activeRequest?.abort();
this.poller.stop(tab);
if (tab.execution && tab.execution.status === 'IN_PROGRESS') {
tab.execution.status = 'CANCELLED';
tab.execution.endTime = Date.now();
}
if (tab.materialize && queryUuid && tab.queryClient) {
try {
await tab.queryClient.cancelQuery(queryUuid);
} catch (e) {
console.error(`Failed to cancel query ${queryUuid} on backend:`, e);
}
}
tab.activeRequest = undefined;
tab.isLoading = false;
this.cb.onHistoryChanged();
this.redraw();
}
// Pick up a tab whose `queryUuid` was set externally (e.g. history click).
async resumeFromHistory(
tab: BigTraceEditorTab,
fallbackQuery: string,
): Promise<void> {
if (!tab.queryUuid) return;
const queryClient = new BigtraceQueryClient(getBigtraceEndpoint());
tab.queryClient = queryClient;
// The disabled-set reconstruction below needs the global registry (the
// complement of the snapshot against every categoried setting). Cached, so
// a no-op once loaded.
await bigTraceSettingsStorage.loadSettings();
if (
!tab.dataSource ||
(tab.materialize && !(tab.dataSource instanceof BigtraceAsyncDataSource))
) {
tab.dataSource = tab.materialize
? new BigtraceAsyncDataSource(
tab.queryUuid,
queryClient,
() => tab.execution?.processedRows ?? 0,
tab.lifecycle.signal,
)
: new InMemoryDataSource([]);
}
let details: RawQueryExecution;
try {
details = await queryClient.getQueryExecution(
tab.queryUuid,
tab.lifecycle.signal,
);
} catch (e) {
if (e instanceof QueryNotFoundError) {
this.poller.dropStaleQueryUuid(tab);
return;
}
console.error('Failed to fetch query details on open:', e);
this.poller.start(tab);
return;
}
if (!tab.execution) return;
const exec = tab.execution;
exec.status = details.status ?? 'N/A';
exec.processedRows = details.processedRows ?? 0;
exec.processedTraces = details.processedTraces ?? 0;
exec.totalTraces = details.totalTraces ?? 0;
if (details.limit !== undefined) tab.limit = details.limit;
// Restore the submit-time snapshot so the settings bar reflects what this
// query ran with (only the full GET echoes it; the list endpoint omits it).
// `settings` arrives camelCase (settingId); convert to SettingFilter[].
const snapshotSettings = snapshotSettingsToFilters(details.settings);
tab.querySettings = snapshotSettings;
// Reconstruct the per-tab disabled set: the snapshot lists exactly the
// settings active for this run, so any categoried global absent from it was
// disabled at submit time. Skip empty snapshots — there "nothing disabled"
// and "no snapshot" are indistinguishable, so keep the default (nothing
// disabled) rather than blanking every toggle.
if (snapshotSettings.length > 0) {
tab.disabledSettings = disabledSettingsFromSnapshot(
snapshotSettings.map((s) => s.settingId),
bigTraceSettingsStorage
.buildSettingFilters({includeDisabled: true})
.map((s) => s.settingId),
);
}
if (Array.isArray(details.traceFilters)) {
tab.traceFilters = details.traceFilters;
}
if (Array.isArray(details.traceMetadataColumns)) {
tab.traceMetadataColumns = details.traceMetadataColumns;
}
if (typeof details.traceOrderBy === 'string') {
tab.traceOrderBy = details.traceOrderBy;
}
this.cb.markDirty?.();
tab.editorText = details.perfettoSql || fallbackQuery;
const startMs = isoToEpochMs(details.startTime);
if (startMs !== undefined) exec.startTime = startMs;
const isTerminal = TERMINAL_STATUSES.has(exec.status);
if (isTerminal) {
const endMs = isoToEpochMs(details.endTime);
if (endMs !== undefined) exec.endTime = endMs;
}
tab.isLoading = !isTerminal;
const durationMs =
exec.endTime !== undefined && exec.startTime !== undefined
? exec.endTime - exec.startTime
: 0;
if (!tab.queryResult) {
tab.queryResult = makeQueryResponse(tab.editorText, {
totalRowCount: exec.processedRows,
durationMs,
statementWithOutputCount: 1,
});
} else {
if (tab.materialize) {
tab.queryResult.totalRowCount = exec.processedRows;
}
tab.queryResult.lastStatementSql = tab.editorText;
tab.queryResult.query = tab.editorText;
}
if (!isTerminal) {
this.poller.start(tab);
} else if (
(exec.status === 'SUCCESS' || exec.status === 'CANCELLED') &&
tab.dataSource instanceof BigtraceAsyncDataSource &&
(details.tableName ?? '') !== '' &&
exec.processedRows > 0
) {
await tab.dataSource.ensureResultsLoaded();
} else if (exec.status === 'FAILED') {
tab.queryResult.error =
details.errorMessage ??
'Query failed without a specific error message.';
}
this.redraw();
}
// For external callers (e.g. resumeFromHistory fallback).
startPolling(tab: BigTraceEditorTab): void {
this.poller.start(tab);
}
// ----- Internals -----
private redraw(): void {
(this.cb.redraw ?? m.redraw)();
}
private async runAsync(
tab: BigTraceEditorTab,
query: string,
client: BigtraceQueryClient,
settings: ReadonlyArray<SettingFilter>,
options: ExecuteOptions,
signal: AbortSignal,
wallStartMs: number,
): Promise<void> {
const data = await client.executeAsync(
query,
tab.limit,
settings,
signal,
options,
);
if (data.queryUuid === undefined || data.queryUuid === '') {
throw new Error('Backend did not return a queryUuid for async execute');
}
tab.queryUuid = data.queryUuid;
tab.execution = queryStore.getOrCreate(tab.queryUuid, {
perfettoSql: query,
});
try {
const details = await client.getQueryExecution(
tab.queryUuid,
tab.lifecycle.signal,
);
const serverStartMs = isoToEpochMs(details?.startTime);
if (serverStartMs !== undefined) {
queryStore.update(tab.queryUuid, {startTime: serverStartMs});
}
} catch (e) {
console.error('Failed to fetch query details after executeAsync:', e);
}
this.poller.start(tab);
tab.dataSource = new BigtraceAsyncDataSource(
tab.queryUuid,
client,
() => tab.execution?.processedRows ?? 0,
tab.lifecycle.signal,
);
tab.queryResult = makeQueryResponse(query, {
durationMs: performance.now() - wallStartMs,
});
}
private async runSync(
tab: BigTraceEditorTab,
query: string,
client: BigtraceQueryClient,
settings: ReadonlyArray<SettingFilter>,
options: ExecuteOptions,
signal: AbortSignal,
wallStartMs: number,
): Promise<void> {
const result = await client.executeSync(
query,
tab.limit,
settings,
signal,
options,
);
if (result.queryUuid === undefined || result.queryUuid === '') {
throw new Error('Backend did not return a queryUuid for sync execute');
}
tab.queryUuid = result.queryUuid;
tab.execution = queryStore.getOrCreate(tab.queryUuid, {
perfettoSql: query,
});
tab.queryResult = makeQueryResponse(query, {
rows: [...result.rows],
columns: [...result.columns],
totalRowCount: result.rows.length,
durationMs: performance.now() - wallStartMs,
statementWithOutputCount: 1,
});
queryStore.update(tab.queryUuid, {
processedRows: result.rows.length,
});
tab.isLoading = false;
}
}