LCOV - code coverage report
Current view: top level - dashql-app/src/query - query_progress_model.ts Coverage Total Hit
Test: DashQL 6e74948 Lines: 97.3 % 183 178
Test Date: 2026-11-06 17:46:50

            Line data    Source code
       1              : import * as dashql from '@ankoh/dashql-core';
       2              : import { ProfileExecutionState } from './profile_execution_state.js';
       3              : 
       4              : /// A raw progress payload of an engine, the progress model of the query turns it into events
       5              : export type EngineProgressPayload =
       6              :     /// The `stats` of a Trino `/v1/statement` response
       7              :     | { kind: 'trino_statement_stats'; stats: object; receivedAt: number }
       8              :     /// The QueryInfo of a Trino query (`/v1/query/{id}`) with the plans of its stages
       9              :     | { kind: 'trino_query_info'; info: object; receivedAt: number }
      10              :     /// A later QueryInfo of a Trino query: the operator summaries of the stages whose tasks are done
      11              :     | { kind: 'trino_query_info_stats'; info: object; receivedAt: number }
      12              :     /// The status of a Hyper query (web HTTP API): the progress and the processed rows of the whole query
      13              :     | { kind: 'hyper_query_status'; status: HyperQueryStatus; receivedAt: number };
      14              : 
      15              : /// The status of a Hyper query, the fields of the Hyper HTTP API that describe its progress
      16              : export interface HyperQueryStatus {
      17              :     completionStatus: string;
      18              :     rowCount: number | null;
      19              :     progress: number | null;
      20              :     executionStats: { wallClockTime: number; rowsProcessed: number } | null;
      21              : }
      22              : 
      23              : /// The work units of a progress fraction, Hyper reports a fraction instead of work units
      24            1 : const HYPER_PROGRESS_UNITS = 1000;
      25              : 
      26              : /// The input events of the status of a Hyper query: the query only, Hyper reports no stages or pipelines yet
      27            1 : function hyperQueryEvents(status: HyperQueryStatus, time: number): object[] {
      28            1 :     const stats: Record<string, number> = {};
      29            1 :     if (status.executionStats != null) {
      30            1 :         stats.tuplesIn = Math.max(0, Math.round(status.executionStats.rowsProcessed));
      31            1 :         stats.wallTimeUs = Math.max(0, Math.round(status.executionStats.wallClockTime * 1000));
      32            1 :     }
      33            1 :     if (status.rowCount != null) stats.tuplesOut = Math.max(0, Math.round(status.rowCount));
      34            1 :     if (status.progress != null) {
      35              :         // A fraction, values above 1 are percentages
      36            1 :         const fraction = Math.min(1, Math.max(0, status.progress > 1 ? status.progress / 100 : status.progress));
      37            1 :         stats.workDone = Math.round(fraction * HYPER_PROGRESS_UNITS);
      38            1 :         stats.workTotal = HYPER_PROGRESS_UNITS;
      39            1 :     }
      40            1 :     const finished = status.completionStatus === 'FINISHED';
      41            1 :     return [{ type: 'updateQuery', time: Math.max(0, Math.round(time)), status: finished ? 'succeeded' : 'running', stats }];
      42            1 : }
      43              : 
      44              : /// The structured progress of a query: the profile structure (stages, the plan operators once the plan is known)
      45              : /// and its execution state
      46              : export interface QueryProfileProgress {
      47              :     structure: dashql.FlatBufferPtr<dashql.buffers.profile.QueryProfile>;
      48              :     execution: ProfileExecutionState;
      49              :     /// The recording of the payloads so far, replaying it reproduces the profile
      50              :     recording: () => ProfileRecording;
      51              : }
      52              : 
      53              : /// A recording of the progress of a query: the raw payloads of the engine and when they arrived. Replaying the
      54              : /// payloads through the adapters of the core reproduces the profile at any point in time.
      55              : export interface ProfileRecording {
      56              :     version: 1;
      57              :     payloads: RecordedPayload[];
      58              : }
      59              : 
      60              : /// A payload of a recording
      61              : export interface RecordedPayload {
      62              :     /// The milliseconds since the first payload
      63              :     elapsedMs: number;
      64              :     payload: EngineProgressPayload;
      65              : }
      66              : 
      67              : /// Replay a recording up to a point in time, returns null if nothing happened until then
      68            1 : export function replayRecording(
      69            1 :     core: dashql.DashQL,
      70            1 :     recording: ProfileRecording,
      71            1 :     untilMs: number,
      72            1 :     layout?: dashql.buffers.profile.ProfileLayoutConfigT,
      73            1 : ): QueryProfileProgress | null {
      74            1 :     const payloads = recording.payloads.filter(p => p.elapsedMs <= untilMs);
      75            1 :     if (payloads.length === 0) return null;
      76            1 :     const model = new QueryProgressModel(core, engineOf(payloads[0].payload), layout);
      77            1 :     try {
      78            1 :         let progress: QueryProfileProgress | null = null;
      79            1 :         for (const { payload } of payloads) {
      80            1 :             progress = model.apply(payload);
      81              :         }
      82            1 :         return progress;
      83            1 :     } finally {
      84            1 :         model.destroy();
      85            1 :     }
      86            1 : }
      87              : 
      88              : /// A progress model that follows the recording of the progress of a query with a layout of its own, e.g. the
      89              : /// other orientation of the plan. The progress model of the query keeps its layout, the follower applies the
      90              : /// recorded payloads again, the ones that are recorded later as they come.
      91            1 : export class QueryProgressFollower {
      92            1 :     private model: QueryProgressModel | null = null;
      93            1 :     private source: (() => ProfileRecording) | null = null;
      94            1 :     private applied = 0;
      95            1 :     private progress: QueryProfileProgress | null = null;
      96              : 
      97            1 :     public constructor(private core: dashql.DashQL, private layout: dashql.buffers.profile.ProfileLayoutConfigT) {}
      98              : 
      99              :     /// Follow the progress of a query, null before its first payload
     100            1 :     public follow(progress: QueryProfileProgress): QueryProfileProgress | null {
     101            1 :         const payloads = progress.recording().payloads;
     102              :         // The progress of another query or another replay starts over
     103            1 :         if (progress.recording !== this.source || payloads.length < this.applied) {
     104            1 :             this.reset();
     105            1 :             this.source = progress.recording;
     106            1 :         }
     107            1 :         if (payloads.length === 0) return null;
     108            1 :         this.model ??= new QueryProgressModel(this.core, engineOf(payloads[0].payload), this.layout);
     109            1 :         for (let i = this.applied; i < payloads.length; ++i) {
     110            1 :             try {
     111            1 :                 this.progress = this.model.apply(payloads[i].payload);
     112            1 :             } catch (e) {
     113              :                 // A malformed payload is skipped, like by the progress model of the query
     114            0 :                 console.warn(e);
     115            0 :             }
     116              :         }
     117            1 :         this.applied = payloads.length;
     118            1 :         return this.progress;
     119            1 :     }
     120              : 
     121            1 :     private reset() {
     122            1 :         this.model?.destroy();
     123            1 :         this.model = null;
     124            1 :         this.applied = 0;
     125            1 :         this.progress = null;
     126            1 :     }
     127              : 
     128            1 :     public destroy() {
     129            1 :         this.reset();
     130            1 :         this.source = null;
     131            1 :     }
     132            1 : }
     133              : 
     134              : /// Read a recording: a profile recording of the app, or a recording of the Trino test harness
     135              : /// (snapshots/profiles/trino/recordings/, written by //packages/trino-testing:trino_recorder)
     136            1 : export function readProfileRecording(text: string): ProfileRecording {
     137            1 :     const document = JSON.parse(text);
     138            1 :     if (document?.version === 1 && Array.isArray(document.payloads)) return document as ProfileRecording;
     139            1 :     if (Array.isArray(document?.statementResponses)) return convertTrinoHarnessRecording(document);
     140            1 :     throw new Error('The file is no profile recording');
     141            1 : }
     142              : 
     143              : /// A recording of the Trino test harness
     144              : interface TrinoHarnessRecording {
     145              :     statementResponses: { elapsedMs: number; body: { stats?: { rootStage?: unknown } } }[];
     146              :     queryInfos: { elapsedMs: number; body: { stages?: { stages?: { stageStats?: object }[] } } }[];
     147              : }
     148              : 
     149              : /// Convert a recording of the Trino test harness. Its QueryInfos are the ones the app fetches when the tasks of
     150              : /// stages are done and when the query finished. The last one carries the plans, the app requests them when the
     151              : /// stages appear, so the plans move there, without the operator summaries that arrive with the later QueryInfos.
     152            1 : function convertTrinoHarnessRecording(recording: TrinoHarnessRecording): ProfileRecording {
     153            1 :     const payloads: RecordedPayload[] = [];
     154            1 :     for (const response of recording.statementResponses) {
     155            1 :         if (response.body.stats == null) continue;
     156            1 :         payloads.push({ elapsedMs: response.elapsedMs, payload: { kind: 'trino_statement_stats', stats: response.body.stats, receivedAt: response.elapsedMs } });
     157            1 :     }
     158            1 :     for (const sample of recording.queryInfos) {
     159            1 :         payloads.push({ elapsedMs: sample.elapsedMs, payload: { kind: 'trino_query_info_stats', info: sample.body, receivedAt: sample.elapsedMs } });
     160              :     }
     161            1 :     const final = recording.queryInfos[recording.queryInfos.length - 1]?.body;
     162            1 :     const stagesAt = recording.statementResponses.find(r => r.body.stats?.rootStage != null)?.elapsedMs;
     163            1 :     if (final != null && stagesAt != null) {
     164            1 :         const stages = (final.stages?.stages ?? []).map(stage => ({
     165            1 :             ...stage,
     166            1 :             stageStats: { ...stage.stageStats, operatorSummaries: [] },
     167            1 :         }));
     168            1 :         const plans = { ...final, stages: { ...final.stages, stages } };
     169            1 :         payloads.push({ elapsedMs: stagesAt, payload: { kind: 'trino_query_info', info: plans, receivedAt: stagesAt } });
     170            1 :     }
     171            1 :     payloads.sort((a, b) => a.elapsedMs - b.elapsedMs);
     172            1 :     return { version: 1, payloads };
     173            1 : }
     174              : 
     175              : /// The engine of a payload
     176            1 : function engineOf(payload: EngineProgressPayload): dashql.buffers.profile.ProfileEngine {
     177            1 :     switch (payload.kind) {
     178            1 :         case 'trino_statement_stats':
     179            1 :         case 'trino_query_info':
     180            1 :         case 'trino_query_info_stats':
     181            1 :             return dashql.buffers.profile.ProfileEngine.TRINO;
     182            1 :         case 'hyper_query_status':
     183            1 :             return dashql.buffers.profile.ProfileEngine.HYPER;
     184            1 :     }
     185            1 : }
     186              : 
     187              : /// The progress model of one query: a core query profile that the payloads of the engine are applied to.
     188              : /// The model lives in the query executor; the query state only holds the immutable `QueryProfileProgress`.
     189            1 : export class QueryProgressModel {
     190            1 :     private profile: dashql.DashQLQueryProfile;
     191            1 :     private engine: dashql.buffers.profile.ProfileEngine;
     192            1 :     private progress: QueryProfileProgress;
     193              :     /// The latest statement stats, applied again on top of a plan that arrives later
     194            1 :     private latestStats: Extract<EngineProgressPayload, { kind: 'trino_statement_stats' }> | null = null;
     195              :     /// The payloads so far, the recording of the query
     196            1 :     private payloads: RecordedPayload[] = [];
     197            1 :     private readonly recording = (): ProfileRecording => ({ version: 1, payloads: [...this.payloads] });
     198              : 
     199            1 :     public constructor(core: dashql.DashQL, engine: dashql.buffers.profile.ProfileEngine, layout = new dashql.buffers.profile.ProfileLayoutConfigT()) {
     200            1 :         this.profile = core.createQueryProfile(layout);
     201            1 :         this.engine = engine;
     202            1 :         this.progress = { structure: this.profile.startQuery(engine), execution: ProfileExecutionState.EMPTY, recording: this.recording };
     203            1 :     }
     204              : 
     205              :     /// Apply a payload of the engine, a payload of another engine changes nothing
     206            1 :     public apply(payload: EngineProgressPayload): QueryProfileProgress {
     207            1 :         if (engineOf(payload) !== this.engine) return this.progress;
     208            1 :         const first = this.payloads[0]?.payload.receivedAt ?? payload.receivedAt;
     209            1 :         this.payloads.push({ elapsedMs: Math.max(0, payload.receivedAt - first), payload });
     210            1 :         let execution: ProfileExecutionState;
     211            1 :         switch (payload.kind) {
     212            1 :             case 'trino_statement_stats': {
     213            1 :                 this.latestStats = payload;
     214            1 :                 const events = this.profile.applyTrinoStatementStats(JSON.stringify(payload.stats), payload.receivedAt);
     215            1 :                 execution = this.progress.execution.withEvents(events.read());
     216            1 :                 break;
     217            1 :             }
     218            1 :             case 'trino_query_info': {
     219              :                 // The plan replaces the stages that the stats registered. The stats are cumulative, applying the
     220              :                 // latest ones again restores the state on the stages of the plan.
     221            1 :                 try {
     222            1 :                     this.profile.loadTrinoPlan(JSON.stringify(payload.info));
     223            1 :                 } catch (e) {
     224              :                     // Without a plan the stages stay a list
     225            1 :                     console.warn(e);
     226            1 :                     this.profile.startQuery(this.engine);
     227            1 :                 }
     228            1 :                 if (this.latestStats != null) {
     229            1 :                     this.profile.applyTrinoStatementStats(JSON.stringify(this.latestStats.stats), this.latestStats.receivedAt);
     230              :                 }
     231              :                 // The operator summaries of the stages that finished already
     232            1 :                 this.profile.applyTrinoQueryInfo(JSON.stringify(payload.info), payload.receivedAt);
     233            1 :                 execution = ProfileExecutionState.EMPTY.withEvents(this.profile.snapshotEvents().read());
     234            1 :                 break;
     235              :             }
     236            1 :             case 'hyper_query_status': {
     237            1 :                 const events = this.profile.applyEvents(JSON.stringify(hyperQueryEvents(payload.status, payload.receivedAt)));
     238            1 :                 execution = this.progress.execution.withEvents(events.read());
     239            1 :                 break;
     240            1 :             }
     241            1 :             case 'trino_query_info_stats': {
     242            1 :                 const events = this.profile.applyTrinoQueryInfo(JSON.stringify(payload.info), payload.receivedAt);
     243            1 :                 execution = this.progress.execution.withEvents(events.read());
     244            1 :                 break;
     245            1 :             }
     246            1 :         }
     247            1 :         const structure = this.profile.buffer ?? this.progress.structure;
     248            1 :         if (execution !== this.progress.execution || structure !== this.progress.structure) {
     249            1 :             this.progress = { structure, execution, recording: this.recording };
     250              :         }
     251            1 :         return this.progress;
     252            1 :     }
     253              : 
     254            1 :     public destroy() {
     255            1 :         this.profile.destroy();
     256            1 :     }
     257            1 : }
     258              : 
     259              : /// The progress models of running queries, created with the first payload of a query
     260            1 : export class QueryProgressModels {
     261            1 :     private models = new Map<number, QueryProgressModel>();
     262              : 
     263              :     /// The layout of the profiles of the queries
     264            1 :     public constructor(private layout = new dashql.buffers.profile.ProfileLayoutConfigT()) {}
     265              : 
     266              :     /// Apply a payload to the model of a query. Without core (still loading) the payload is dropped, the counters
     267              :     /// are cumulative, so the next payload catches up.
     268            1 :     public apply(core: dashql.DashQL | null, queryId: number, payload: EngineProgressPayload): QueryProfileProgress | null {
     269            1 :         let model = this.models.get(queryId);
     270            1 :         if (model == null) {
     271            1 :             if (core == null) return null;
     272            1 :             model = new QueryProgressModel(core, engineOf(payload), this.layout);
     273            1 :             this.models.set(queryId, model);
     274            1 :         }
     275            1 :         try {
     276            1 :             return model.apply(payload);
     277            1 :         } catch (e) {
     278              :             // A malformed payload must not interrupt the query
     279            0 :             console.warn(e);
     280            0 :             return null;
     281            0 :         }
     282            1 :     }
     283              : 
     284              :     /// Release the model of a query
     285            1 :     public release(queryId: number) {
     286            1 :         this.models.get(queryId)?.destroy();
     287            1 :         this.models.delete(queryId);
     288            1 :     }
     289            1 : }
        

Generated by: LCOV version 2.0-1