LCOV - code coverage report
Current view: top level - dashql-app/src/connections/hyper - hyper_log_metrics.ts Coverage Total Hit
Test: DashQL 915828b Lines: 99.6 % 285 284
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import type * as arrow from 'apache-arrow';
       2              : 
       3              : import type { EmbeddedComputeDatabase, EmbeddedConnection } from '../../platform/database/embedded_database.js';
       4              : import {
       5              :     HyperDBIncrementalExtract,
       6              :     HYPER_EXTRACT_ROW_COLUMN,
       7              :     quoteLiteral,
       8              :     type HyperDBExtractTable,
       9              :     type HyperDBExtractTransfer,
      10              :     type HyperDBSourceDatabase,
      11              : } from '../../platform/hyperdb/hyperdb_incremental_extract.js';
      12              : import {
      13              :     HYPER_LOG_RESOURCE_METRICS_TABLE,
      14              :     HyperDBLogSource,
      15              :     HyperLogUnreadableError,
      16              :     hyperLogQueryEndsTable,
      17              :     isHyperLogFlushTimeout,
      18              : } from '../../platform/hyperdb/hyperdb_log_extract.js';
      19              : import { quoteIdentifier } from '../../platform/hyperdb/hyperdb_wasm.js';
      20              : import type { Logger } from '../../platform/logger/logger.js';
      21              : import { stringifyError } from '../../platform/logger/logger.js';
      22              : import { Store } from '../../utils/store.js';
      23              : 
      24            1 : const LOG_CTX = 'hyper_log_metrics';
      25              : 
      26              : /// The polling interval of the Hyper log in milliseconds, the smallest bucket of the charts.
      27              : /// A poll reads all events since the last one, e.g. the resource usage that the embedded Hyper logs every second.
      28              : /// Every poll is about 15 queries of the monitor itself, which the extract of an embedded Hyper keeps too.
      29            1 : const DEFAULT_POLL_INTERVAL_MS = 5_000;
      30              : /// The alias of the monitoring database of a connection on the connections of its monitor
      31            1 : const MONITORING_ALIAS = 'monitoring';
      32              : /// The attempts to open an extract while reading the log times out, e.g. on a Hyper under load, and the pause between them
      33            1 : const OPEN_ATTEMPTS = 5;
      34            1 : const OPEN_RETRY_MS = 1_000;
      35              : /// The time that the extract reaches back, twice the largest window of the connection metrics
      36            1 : const RETENTION_MS = 2 * 60 * 60_000;
      37              : /// The finished queries that the extract keeps at most, e.g. after a burst of queries. The oldest are deleted first.
      38              : /// The log monitor finishes about 3 queries per second itself, about 22,000 in the retention.
      39            1 : const MAX_QUERY_ENDS = 20_000;
      40              : /// The resource samples that the extract keeps at most, the retention with a resource usage interval of 1s
      41            1 : const MAX_RESOURCE_SAMPLES = 7_200;
      42              : 
      43              : /// A query that a Hyper session finished, from a row of the query ends of the extract.
      44              : export interface HyperQueryEnd {
      45              :     /// The Hyper session that ran the query
      46              :     sessionId: string;
      47              :     /// The end of the query in milliseconds since the epoch
      48              :     endedAt: number;
      49              :     /// Did the query succeed?
      50              :     succeeded: boolean;
      51              :     /// The SQLSTATE of a failed query
      52              :     errorCode: string | null;
      53              :     /// The error message of a failed query
      54              :     errorMessage: string | null;
      55              :     /// The statement type, e.g. SELECT
      56              :     statement: string | null;
      57              :     /// The query text, truncated by Hyper
      58              :     query: string | null;
      59              :     /// The elapsed time of the query in seconds
      60              :     elapsed: number | null;
      61              :     /// The thread time of the compilation and the execution in seconds
      62              :     threadTime: number;
      63              :     /// The CPU time of the threads of the compilation and the execution in seconds
      64              :     cpuTime: number;
      65              :     /// The time that the threads of the compilation and the execution waited in seconds
      66              :     waitTime: number;
      67              :     /// The time until a worker thread picked up the query in seconds
      68              :     timeToSchedule: number | null;
      69              :     /// The peak transaction memory of the compilation or the execution in MB
      70              :     peakMemoryMb: number | null;
      71              :     /// The result rows
      72              :     rows: number | null;
      73              :     /// The rows that the compilation and the execution processed
      74              :     processedRows: number;
      75              :     /// The kind of the query from its client context, see DashQLClientContext
      76              :     queryType: string | null;
      77              :     /// Did the app run the query and not the user? E.g. a computation (SQLFrame) on a pooled compute connection, or a
      78              :     /// read of the Hyper log.
      79              :     sentByApp: boolean;
      80              : }
      81              : 
      82              : /// The client context of the queries of a connection that Hyper logs as ctx.client
      83              : interface DashQLClientContext {
      84              :     /// The fields of the app
      85              :     dashql: {
      86              :         /// The connection that the queries are attributed to, none for the queries of the log monitor
      87              :         connectionId?: string;
      88              :         /// The kind of the queries: of the user on the sessions of the connection, of computations, of the
      89              :         /// connection overview, or of the log monitor
      90              :         queryType: 'user' | 'sqlframe' | 'overview' | 'monitor';
      91              :     };
      92              : }
      93              : 
      94              : /// The client context of the queries of the user on the sessions of a connection
      95            1 : export function connectionClientContext(connectionId: string): DashQLClientContext & Record<string, unknown> {
      96            1 :     return { dashql: { connectionId, queryType: 'user' } };
      97            1 : }
      98              : 
      99              : /// The client context that attributes the queries of a computation to the connection of its table.
     100              : /// The queries run on pooled compute connections, whose sessions are shared by all connections.
     101            1 : export function sqlframeClientContext(connectionId: string): DashQLClientContext & Record<string, unknown> {
     102            1 :     return { dashql: { connectionId, queryType: 'sqlframe' } };
     103            1 : }
     104              : 
     105              : /// The client context that attributes the queries of the overview of a connection to the connection
     106            1 : export function overviewClientContext(connectionId: string): DashQLClientContext & Record<string, unknown> {
     107            1 :     return { dashql: { connectionId, queryType: 'overview' } };
     108            1 : }
     109              : 
     110              : /// The client context of the connections of the log monitor of a connection, they read the Hyper log and write the
     111              : /// extract
     112            1 : function monitorClientContext(connectionId: string): DashQLClientContext & Record<string, unknown> {
     113            1 :     return { dashql: { connectionId, queryType: 'monitor' } };
     114            1 : }
     115              : 
     116              : /// A sample of the resource usage of a Hyper process, from a row of the resource metrics of the extract.
     117              : /// Hyper writes one every log_resource_usage_interval.
     118              : export interface HyperResourceSample {
     119              :     /// The time of the sample in milliseconds since the epoch
     120              :     sampledAt: number;
     121              :     /// The physical memory of the process in MB, the size of the Wasm memory, which never shrinks
     122              :     processMemoryMb: number | null;
     123              :     /// The memory that the running queries hold in MB
     124              :     transactionMemoryMb: number | null;
     125              :     /// The memory of all memory trackers in MB, e.g. table data, temporary tables, the running queries and caches
     126              :     trackedMemoryMb: number | null;
     127              :     /// The overall load between 0 and 1
     128              :     overallLoad: number | null;
     129              :     /// The load of the scheduler between 0 and 1
     130              :     schedulerLoad: number | null;
     131              :     /// The memory load between 0 and 1
     132              :     memoryLoad: number | null;
     133              : }
     134              : 
     135              : /// The query ends with the fields of the client context of the app
     136            1 : const QUERY_ENDS_TABLE: HyperDBExtractTable = {
     137            1 :     ...hyperLogQueryEndsTable([
     138            1 :         { name: 'client_connection_id', path: ['dashql', 'connectionId'] },
     139            1 :         { name: 'client_query_type', path: ['dashql', 'queryType'] },
     140            1 :     ]),
     141            1 :     maxRows: MAX_QUERY_ENDS,
     142            1 :     maxAgeMs: RETENTION_MS,
     143            1 : };
     144              : 
     145              : /// The resource samples of the Hyper process
     146            1 : const RESOURCE_METRICS_TABLE: HyperDBExtractTable = {
     147            1 :     ...HYPER_LOG_RESOURCE_METRICS_TABLE,
     148            1 :     maxRows: MAX_RESOURCE_SAMPLES,
     149            1 :     maxAgeMs: RETENTION_MS,
     150            1 : };
     151              : 
     152              : /// The events of the extract of a connection: the query-end events that its client context names, and the
     153              : /// resource-metrics events of the Hyper process
     154            1 : function metricsSourceQuery(connectionId: string): string {
     155            1 :     return `
     156              :         SELECT * FROM log
     157              :         WHERE k = 'resource-metrics'
     158            1 :             OR (k LIKE 'query-end%' AND ctx::json->'client'->'dashql'->>'connectionId' = ${quoteLiteral(connectionId)})
     159              :     `;
     160            1 : }
     161              : 
     162              : /// The rows of an extract table that a read selects
     163              : export interface ExtractRowRange {
     164              :     /// The rows after this row number, see HYPER_EXTRACT_ROW_COLUMN
     165              :     afterRow: number;
     166              :     /// The rows logged since this time in milliseconds since the epoch
     167              :     since: number;
     168              : }
     169              : 
     170              : /// The time of a log event in milliseconds since the epoch.
     171              : /// The log writes the wall-clock time of the Hyper host as timestamp without time zone, see HyperLogState.clockOffsetMs.
     172            1 : function epochMs(clockOffsetMs: number): string {
     173            1 :     return `(extract(epoch from ts) * 1000 - ${clockOffsetMs})::double precision`;
     174            1 : }
     175              : 
     176              : /// The time of a log event in the time zone of the app, as timestamp without time zone
     177            1 : function localTimestamp(clockOffsetMs: number): string {
     178            1 :     const shiftMs = -new Date().getTimezoneOffset() * 60_000 - clockOffsetMs;
     179            1 :     return shiftMs === 0 ? 'ts' : `ts + interval '${shiftMs / 1000} seconds'`;
     180            1 : }
     181              : 
     182              : /// The predicate of the rows of a range
     183            1 : function rangePredicate(clockOffsetMs: number, range: ExtractRowRange): string {
     184            1 :     return `${HYPER_EXTRACT_ROW_COLUMN} > ${range.afterRow} AND ${epochMs(clockOffsetMs)} >= ${range.since}`;
     185            1 : }
     186              : 
     187              : /// The finished queries in the query ends of the extract of a connection, with their sent_by_app, epoch_ms (the end in
     188              : /// milliseconds since the epoch) and local_ts (the end in the time zone of the app)
     189            1 : export function queryEndsQuery(queryEnds: string, clockOffsetMs: number, range?: ExtractRowRange): string {
     190            1 :     return `
     191              :         SELECT
     192              :             *,
     193            1 :             ${epochMs(clockOffsetMs)} AS epoch_ms,
     194            1 :             ${localTimestamp(clockOffsetMs)} AS local_ts,
     195              :             client_query_type IS DISTINCT FROM 'user' AS sent_by_app
     196            1 :         FROM ${queryEnds}
     197            1 :         ${range == null ? '' : `WHERE ${rangePredicate(clockOffsetMs, range)}`}
     198              :     `;
     199            1 : }
     200              : 
     201              : /// The resource samples in the resource metrics of the extract, with their epoch_ms
     202            1 : export function resourceSamplesQuery(resourceMetrics: string, clockOffsetMs: number, range: ExtractRowRange): string {
     203            1 :     return `SELECT *, ${epochMs(clockOffsetMs)} AS epoch_ms FROM ${resourceMetrics} WHERE ${rangePredicate(clockOffsetMs, range)}`;
     204            1 : }
     205              : 
     206            1 : function nullableNumber(value: unknown): number | null {
     207            1 :     return value == null ? null : Number(value);
     208            1 : }
     209              : 
     210            1 : function nullableString(value: unknown): string | null {
     211            1 :     return value == null ? null : String(value);
     212            1 : }
     213              : 
     214              : /// The highest row number of a result of extract rows, 0 without rows
     215            1 : export function lastExtractRow(table: arrow.Table): number {
     216            1 :     const rows = table.getChild(HYPER_EXTRACT_ROW_COLUMN);
     217            1 :     let last = 0;
     218            1 :     for (let i = 0; i < table.numRows; ++i) last = Math.max(last, Number(rows?.get(i) ?? 0));
     219            1 :     return last;
     220            1 : }
     221              : 
     222              : /// Read the finished queries of a result of queryEndsQuery(), ordered by their end.
     223              : /// The log times of Hyper in Wasm have millisecond resolution, the request number orders the queries of a session that
     224              : /// end within the same millisecond.
     225            1 : export function readQueryEnds(table: arrow.Table): HyperQueryEnd[] {
     226            1 :     const queries: { query: HyperQueryEnd, request: number }[] = [];
     227            1 :     for (const row of table) {
     228            1 :         queries.push({ request: Number(row.req ?? 0), query: {
     229            1 :             sessionId: String(row.sess),
     230            1 :             endedAt: Number(row.epoch_ms),
     231            1 :             succeeded: Boolean(row.succeeded),
     232            1 :             errorCode: nullableString(row.error_code),
     233            1 :             errorMessage: nullableString(row.error_message),
     234            1 :             statement: nullableString(row.statement),
     235            1 :             query: nullableString(row.query),
     236            1 :             elapsed: nullableNumber(row.elapsed),
     237            1 :             threadTime: Number(row.thread_time ?? 0),
     238            1 :             cpuTime: Number(row.cpu_time ?? 0),
     239            1 :             waitTime: Number(row.wait_time ?? 0),
     240            1 :             timeToSchedule: nullableNumber(row.time_to_schedule),
     241            1 :             peakMemoryMb: nullableNumber(row.peak_memory_mb),
     242            1 :             rows: nullableNumber(row.rows),
     243            1 :             processedRows: Number(row.processed_rows ?? 0),
     244            1 :             queryType: nullableString(row.client_query_type),
     245            1 :             sentByApp: Boolean(row.sent_by_app),
     246            1 :         } });
     247              :     }
     248            1 :     return queries
     249            1 :         .sort((l, r) => l.query.endedAt - r.query.endedAt || l.request - r.request)
     250            1 :         .map(({ query }) => query);
     251            1 : }
     252              : 
     253              : /// Read the resource samples of a result of resourceSamplesQuery(), ordered by time
     254            1 : export function readResourceSamples(table: arrow.Table): HyperResourceSample[] {
     255            1 :     const samples: HyperResourceSample[] = [];
     256            1 :     for (const row of table) {
     257            1 :         samples.push({
     258            1 :             sampledAt: Number(row.epoch_ms),
     259            1 :             processMemoryMb: nullableNumber(row.process_memory_mb),
     260            1 :             transactionMemoryMb: nullableNumber(row.transaction_memory_mb),
     261            1 :             trackedMemoryMb: nullableNumber(row.tracked_memory_mb),
     262            1 :             overallLoad: nullableNumber(row.overall_load),
     263            1 :             schedulerLoad: nullableNumber(row.scheduler_load),
     264            1 :             memoryLoad: nullableNumber(row.memory_load),
     265            1 :         });
     266              :     }
     267            1 :     return samples.sort((l, r) => l.sampledAt - r.sampledAt);
     268            1 : }
     269              : 
     270              : /// The status of the monitor of the Hyper log of a connection
     271            1 : export enum HyperLogStatus {
     272              :     /// The log of the connection is not monitored, e.g. before the setup of the connection
     273            1 :     Closed = 'closed',
     274              :     /// The monitor opens the extract
     275            1 :     Opening = 'opening',
     276              :     /// The extract is open and polled
     277            1 :     Open = 'open',
     278              :     /// The log cannot be read, or a poll failed, see HyperLogState.error
     279            1 :     Unavailable = 'unavailable',
     280            1 : }
     281              : 
     282              : /// Why the Hyper log of a connection cannot be read
     283              : export interface HyperLogError {
     284              :     /// Does the Hyper refuse to read its log, e.g. without experimental_hyper_introspection_functions?
     285              :     /// Otherwise reading or extracting the log failed, e.g. after the Hyper was terminated.
     286              :     unreadable: boolean;
     287              :     /// The message of the error
     288              :     message: string;
     289              : }
     290              : 
     291              : /// The state of the extract of the Hyper log of a connection
     292              : export interface HyperLogState {
     293              :     /// The status of the monitor
     294              :     status: HyperLogStatus;
     295              :     /// Why the log cannot be read, set when the monitor is unavailable
     296              :     error: HyperLogError | null;
     297              :     /// The qualified table of the finished queries on the reader, see queryEndsQuery(), null until the extract is open
     298              :     queryEnds: string | null;
     299              :     /// The qualified table of the resource samples on the reader, see resourceSamplesQuery()
     300              :     resourceMetrics: string | null;
     301              :     /// The offset of the wall clock of the log from UTC in milliseconds, e.g. 7,200,000 for a Hyper in UTC+2.
     302              :     /// The log writes the wall-clock time of the Hyper host, which is not the time zone of the app for a remote Hyper.
     303              :     clockOffsetMs: number | null;
     304              :     /// The transfers that appended rows to the extract, it changes with every new row
     305              :     transfers: number;
     306              :     /// Does the Hyper log its resource usage? Null until a poll after the open tells, false while no transfer found a
     307              :     /// resource sample, e.g. without log_resource_usage_interval.
     308              :     resourceUsageLogged: boolean | null;
     309              : }
     310              : 
     311              : /// The state of a connection without an open extract
     312            1 : export const CLOSED_LOG_STATE: HyperLogState = {
     313            1 :     status: HyperLogStatus.Closed,
     314            1 :     error: null,
     315            1 :     queryEnds: null,
     316            1 :     resourceMetrics: null,
     317            1 :     clockOffsetMs: null,
     318            1 :     transfers: 0,
     319            1 :     resourceUsageLogged: null,
     320            1 : };
     321              : 
     322              : /// The open extract of a connection and its reader
     323              : interface OpenHyperLog {
     324              :     /// The extract
     325              :     extract: HyperDBIncrementalExtract;
     326              :     /// The connection that reads the extract for the overview, with the monitoring database attached
     327              :     reader: EmbeddedConnection;
     328              : }
     329              : 
     330              : /// The Hyper whose log is monitored
     331              : export interface HyperLogOrigin {
     332              :     /// The Hyper, e.g. the embedded Hyper or a channel to a remote Hyper
     333              :     database: HyperDBSourceDatabase;
     334              :     /// Do the connections to the Hyper keep their sessions? A poll then reads the log scope of its session, otherwise
     335              :     /// the whole retained log, see HyperDBLogSource.
     336              :     sessions: boolean;
     337              : }
     338              : 
     339              : /// The monitor of the log of a connection
     340              : interface HyperLogMonitor {
     341              :     /// The Hyper whose log is read
     342              :     origin: HyperLogOrigin;
     343              :     /// Opens the extract and its reader, resolves to null when the log cannot be read
     344              :     log: Promise<OpenHyperLog | null>;
     345              :     /// The last read of the reader, the reads run one after another
     346              :     reading: Promise<unknown>;
     347              : }
     348              : 
     349              : /// Options of the Hyper log metrics.
     350              : export interface HyperLogMetricsOptions {
     351              :     /// The logger
     352              :     logger: Logger;
     353              :     /// The polling interval in milliseconds
     354              :     pollIntervalMs?: number;
     355              :     /// The pause between the attempts to open an extract while reading the log times out, in milliseconds
     356              :     openRetryMs?: number;
     357              :     /// Prepare the monitoring database of a connection, e.g. create the folder of the connection, and return its path
     358              :     prepareDatabase: (connectionId: string) => Promise<string>;
     359              :     /// The embedded Hyper that holds the monitoring databases, its compute database stages the transfers
     360              :     sink: () => Promise<EmbeddedComputeDatabase>;
     361              : }
     362              : 
     363              : /// The finished queries of the connections to a Hyper and the resource samples of the Hyper, extracted from its log
     364              : /// into the monitoring database of every connection, every poll interval.
     365              : ///
     366              : /// The extract of a connection holds the queries whose client context names the connection, see DashQLClientContext:
     367              : /// the queries of the user and the app, and the reads and writes of its own log monitor. Its reader reads the
     368              : /// extract with the client context of the overview. The resource samples describe the whole Hyper process, which the
     369              : /// connections to the same Hyper share.
     370              : ///
     371              : /// The monitoring databases live in the embedded Hyper, also for a remote Hyper.
     372              : ///
     373              : /// Limits:
     374              : /// - The connections to the same Hyper monitor its log one by one, each reads the resource samples again.
     375              : /// - A Hyper without sessions, e.g. over gRPC or HTTP, is read from the watermark with every poll, which reads its whole
     376              : ///   retained log.
     377              : /// - The log files of earlier Hyper processes cannot be backfilled, the extract misses the queries while no app
     378              : ///   window monitored the connection.
     379              : /// - Resource samples are only logged with log_resource_usage_interval set.
     380              : /// - A Hyper that does not let the app read its log, e.g. without experimental_hyper_introspection_functions, leaves the
     381              : ///   monitor unavailable. The connection itself works as before, the monitor does not retry.
     382              : /// - A read of the log that times out on a Hyper under load is retried, see isHyperLogFlushTimeout(). The extract then
     383              : ///   falls behind until the reads succeed again, also without a notice in the state.
     384            1 : export class HyperLogMetrics {
     385              :     /// The states of the extracts by connection
     386            1 :     readonly state = new Store<ReadonlyMap<string, HyperLogState>>(new Map());
     387              :     /// The monitors by connection
     388            1 :     private readonly monitors = new Map<string, HyperLogMonitor>();
     389              : 
     390            1 :     constructor(private readonly options: HyperLogMetricsOptions) {}
     391              : 
     392              :     /// Extract the log of the Hyper of a connection, e.g. of the embedded Hyper. Another Hyper replaces the watched
     393              :     /// one, e.g. after the hand-over of the app to another tab. The backfill of the extract reads the retained log.
     394            1 :     watch(origin: HyperLogOrigin, connectionId: string): void {
     395            1 :         if (this.monitors.get(connectionId)?.origin.database === origin.database) return;
     396            1 :         void this.forget(connectionId);
     397            1 :         const monitor: HyperLogMonitor = { origin, log: Promise.resolve(null), reading: Promise.resolve() };
     398            1 :         this.monitors.set(connectionId, monitor);
     399            1 :         this.updateState(connectionId, { ...CLOSED_LOG_STATE, status: HyperLogStatus.Opening });
     400            1 :         monitor.log = this.open(connectionId, monitor);
     401            1 :     }
     402              : 
     403              :     /// Wait until the extract of a connection is open, false when it cannot be read
     404            1 :     async opened(connectionId: string): Promise<boolean> {
     405            1 :         const monitor = this.monitors.get(connectionId);
     406            1 :         return monitor != null && (await monitor.log) != null;
     407            1 :     }
     408              : 
     409              :     /// Read the extract of a connection on its reader, e.g. with queryEndsQuery(). The reads run one after another.
     410            1 :     async query(connectionId: string, sql: string): Promise<arrow.Table> {
     411            1 :         const monitor = this.monitors.get(connectionId);
     412            1 :         if (monitor == null) throw new Error(`the Hyper log of the connection ${connectionId} is not monitored`);
     413            1 :         const read = monitor.reading.catch(() => {}).then(async () => {
     414            1 :             const log = await monitor.log;
     415            1 :             if (log == null) throw new Error(`the Hyper log extract of the connection ${connectionId} cannot be read`);
     416            1 :             return await log.reader.query(sql);
     417            1 :         });
     418            1 :         monitor.reading = read;
     419            1 :         return await read;
     420            1 :     }
     421              : 
     422              :     /// Transfer the events that were logged since the last poll, e.g. in tests that do not wait for the interval
     423            1 :     async poll(connectionId: string): Promise<void> {
     424            1 :         const log = await this.monitors.get(connectionId)?.log;
     425            1 :         if (log == null) return;
     426            1 :         try {
     427            1 :             this.transferred(connectionId, await log.extract.poll());
     428            1 :         } catch (error) {
     429              :             // The next poll reads the same events
     430            1 :             if (!isHyperLogFlushTimeout(error)) throw error;
     431            1 :             this.options.logger.warn('Polling the Hyper log timed out', { error: stringifyError(error) }, LOG_CTX);
     432            1 :         }
     433            1 :     }
     434              : 
     435              :     /// Stop the monitors of all connections except the given ones, e.g. of deleted connections.
     436              :     /// A stopped monitor closes the monitoring database of its connection.
     437            1 :     retain(connectionIds: ReadonlySet<string>): void {
     438            1 :         for (const connectionId of [...this.monitors.keys()]) {
     439            1 :             if (!connectionIds.has(connectionId)) void this.forget(connectionId);
     440              :         }
     441              :         // The unavailable monitors were stopped already, but kept their state
     442            1 :         for (const connectionId of [...this.state.current.keys()]) {
     443            1 :             if (!connectionIds.has(connectionId)) this.updateState(connectionId, null);
     444              :         }
     445            1 :     }
     446              : 
     447              :     /// Stop the monitor of a connection and close its monitoring database
     448            1 :     async forget(connectionId: string): Promise<void> {
     449            1 :         const monitor = this.monitors.get(connectionId);
     450            1 :         if (monitor == null) return;
     451            1 :         this.monitors.delete(connectionId);
     452            1 :         this.updateState(connectionId, null);
     453            1 :         await monitor.reading.catch(() => {});
     454            1 :         const log = await monitor.log;
     455            1 :         await log?.reader.close().catch(() => {});
     456            1 :         await log?.extract.stop().catch(() => {});
     457            1 :     }
     458              : 
     459              :     /// Stop the monitors of all connections
     460            1 :     async stop(): Promise<void> {
     461            1 :         await Promise.all([...this.monitors.keys()].map(connectionId => this.forget(connectionId)));
     462            1 :     }
     463              : 
     464              :     /// Open the extract of a connection and its reader, and start polling.
     465              :     /// A failure leaves the connection unavailable with the error, e.g. for a Hyper that does not let the app read its log.
     466              :     /// A read of the log that times out is tried again first.
     467            1 :     private async open(connectionId: string, monitor: HyperLogMonitor): Promise<OpenHyperLog | null> {
     468            1 :         for (let attempt = 1; ; ++attempt) {
     469            1 :             try {
     470            1 :                 return await this.openOnce(connectionId, monitor);
     471            1 :             } catch (error) {
     472            1 :                 if (attempt < OPEN_ATTEMPTS && isHyperLogFlushTimeout(error)) {
     473            1 :                     this.options.logger.info('Opening the Hyper log extract timed out', { connectionId, attempt: String(attempt) }, LOG_CTX);
     474            1 :                     await new Promise(resolve => setTimeout(resolve, this.options.openRetryMs ?? OPEN_RETRY_MS));
     475            1 :                     if (this.monitors.get(connectionId) !== monitor) return null;
     476            1 :                     continue;
     477            1 :                 }
     478            1 :                 if (error instanceof HyperLogUnreadableError) {
     479            1 :                     this.options.logger.info('The Hyper does not let the app read its log', { error: stringifyError(error) }, LOG_CTX);
     480              :                 } else {
     481            1 :                     this.options.logger.warn('Opening the Hyper log extract failed', { error: stringifyError(error) }, LOG_CTX);
     482              :                 }
     483            1 :                 if (this.monitors.get(connectionId) === monitor) this.fail(connectionId, error);
     484            1 :                 return null;
     485            1 :             }
     486              :         }
     487            1 :     }
     488              : 
     489              :     /// Open the extract of a connection and its reader, and start polling. A failure closes them again and throws.
     490            1 :     private async openOnce(connectionId: string, monitor: HyperLogMonitor): Promise<OpenHyperLog | null> {
     491            1 :         const { origin } = monitor;
     492            1 :         const source = new HyperDBLogSource(metricsSourceQuery(connectionId), origin.sessions);
     493            1 :         let extract: HyperDBIncrementalExtract | null = null;
     494            1 :         let reader: EmbeddedConnection | null = null;
     495            1 :         let clockOffsetMs: number;
     496            1 :         try {
     497            1 :             const path = await this.options.prepareDatabase(connectionId);
     498            1 :             const sink = await this.options.sink();
     499            1 :             extract = await HyperDBIncrementalExtract.open({
     500            1 :                 sourceDatabase: origin.database,
     501            1 :                 source,
     502            1 :                 sink,
     503            1 :                 databasePath: path,
     504            1 :                 databaseAlias: MONITORING_ALIAS,
     505            1 :                 tables: [QUERY_ENDS_TABLE, RESOURCE_METRICS_TABLE],
     506            1 :                 clientContext: monitorClientContext(connectionId),
     507            1 :                 intervalMs: this.options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
     508            1 :                 onTransfer: transfer => this.transferred(connectionId, transfer),
     509            1 :                 onRebuild: reason => this.options.logger.info('Rebuilding the extract of the Hyper log', { connectionId, reason }, LOG_CTX),
     510            1 :                 onError: error => {
     511              :                     // A Hyper that was terminated, e.g. by a hand-over to another tab, fails every poll.
     512              :                     // Setting up the connection again starts a new monitor.
     513            1 :                     this.options.logger.warn('Polling the Hyper log failed', { error: stringifyError(error) }, LOG_CTX);
     514              :                     // The next poll reads the events of a read that timed out
     515            1 :                     if (isHyperLogFlushTimeout(error)) return;
     516            0 :                     if (this.monitors.get(connectionId) === monitor) this.fail(connectionId, error);
     517            1 :                 },
     518            1 :             });
     519            1 :             const clock = await origin.database.connect({ clientContext: monitorClientContext(connectionId) });
     520            1 :             try {
     521            1 :                 clockOffsetMs = await source.readClockOffset(clock);
     522            1 :             } finally {
     523            1 :                 await clock.close().catch(() => {});
     524            1 :             }
     525            1 :             reader = await sink.connect({ clientContext: overviewClientContext(connectionId) });
     526            1 :             await reader.execute(`ATTACH DATABASE ${quoteIdentifier(path)} AS ${quoteIdentifier(MONITORING_ALIAS)}`);
     527            1 :         } catch (error) {
     528            1 :             await reader?.close().catch(() => {});
     529            1 :             await extract?.stop().catch(() => {});
     530            1 :             throw error;
     531            1 :         }
     532            1 :         if (this.monitors.get(connectionId) !== monitor) {
     533            1 :             await reader.close().catch(() => {});
     534            1 :             await extract.stop().catch(() => {});
     535            1 :             return null;
     536            1 :         }
     537            1 :         this.updateState(connectionId, {
     538            1 :             status: HyperLogStatus.Open,
     539            1 :             error: null,
     540            1 :             queryEnds: extract.tableName(QUERY_ENDS_TABLE.name),
     541            1 :             resourceMetrics: extract.tableName(RESOURCE_METRICS_TABLE.name),
     542            1 :             clockOffsetMs,
     543            1 :             transfers: 1,
     544              :             // The backfill read the retained log, a Hyper that just started may not have logged a sample yet
     545            1 :             resourceUsageLogged: (extract.backfill.tables[RESOURCE_METRICS_TABLE.name] ?? 0) > 0 ? true : null,
     546            1 :         });
     547            1 :         extract.start();
     548            1 :         return { extract, reader };
     549            1 :     }
     550              : 
     551              :     /// Stop the monitor of a connection after an error, its state keeps the error until the connection is watched again
     552            1 :     private fail(connectionId: string, error: unknown): void {
     553            1 :         void this.forget(connectionId);
     554            1 :         this.updateState(connectionId, {
     555            1 :             ...CLOSED_LOG_STATE,
     556            1 :             status: HyperLogStatus.Unavailable,
     557            1 :             error: {
     558            1 :                 unreadable: error instanceof HyperLogUnreadableError,
     559            1 :                 message: error instanceof Error ? error.message : String(error),
     560            1 :             },
     561            1 :         });
     562            1 :     }
     563              : 
     564            1 :     private transferred(connectionId: string, transfer: HyperDBExtractTransfer): void {
     565            1 :         const state = this.state.current.get(connectionId);
     566            1 :         if (state == null || state.status !== HyperLogStatus.Open) return;
     567              :         // A Hyper that logs its resource usage logs a sample every interval, a poll without one marks it as not logged
     568            1 :         const resourceUsageLogged = state.resourceUsageLogged === true || (transfer.tables[RESOURCE_METRICS_TABLE.name] ?? 0) > 0;
     569            1 :         if (transfer.appended === 0 && resourceUsageLogged === state.resourceUsageLogged) return;
     570            1 :         this.updateState(connectionId, {
     571            1 :             ...state,
     572            1 :             transfers: state.transfers + (transfer.appended === 0 ? 0 : 1),
     573            1 :             resourceUsageLogged,
     574            1 :         });
     575            1 :     }
     576              : 
     577              :     /// Replace the state of a connection, null removes it
     578            1 :     private updateState(connectionId: string, state: HyperLogState | null): void {
     579            1 :         this.state.update(states => {
     580            1 :             const next = new Map(states);
     581            1 :             if (state == null) next.delete(connectionId);
     582            1 :             else next.set(connectionId, state);
     583            1 :             return next;
     584            1 :         });
     585            1 :     }
     586            1 : }
        

Generated by: LCOV version 2.0-1