LCOV - code coverage report
Current view: top level - dashql-app/src/connections/hyper/platform - web_hyperdb_http_client.ts Coverage Total Hit
Test: DashQL 915828b Lines: 91.7 % 217 199
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as arrow from 'apache-arrow';
       2              : import * as pb from "../../../proto.js";
       3              : import * as connection from '@ankoh/dashql-jsonschema/connection.js';
       4              : 
       5              : import {
       6              :     HyperDatabaseChannel,
       7              :     HyperDatabaseClient,
       8              :     HyperDatabaseConnectionContext,
       9              :     HyperQueryResultStream,
      10              : } from '../hyperdb_grpc_client.js';
      11              : import {
      12              :     HyperDatabaseHttpClient,
      13              :     HyperHttpAuthProvider,
      14              :     HyperHttpError,
      15              :     mergeQueryStatus,
      16              :     QueryStatus,
      17              : } from '../hyperdb_http_client.js';
      18              : import { fromHttpErrorResponse, HyperQueryError } from '../hyper_error_info.js';
      19              : import {
      20              :     createQueryResponseStreamMetrics,
      21              :     QueryExecutionMetrics,
      22              :     QueryExecutionProgress,
      23              :     QueryExecutionResponseStream,
      24              :     QueryExecutionStatus,
      25              : } from '../../../query/query_execution_state.js';
      26              : import { AsyncConsumer } from '../../../utils/async_consumer.js';
      27              : import { AsyncValue } from '../../../utils/async_value.js';
      28              : import { HttpClient } from '../../../platform/http/http_client.js';
      29              : import { Logger } from '../../../platform/logger/logger.js';
      30              : import { yieldToBrowser } from '../../../utils/yield_to_browser.js';
      31              : 
      32            1 : const LOG_CTX = "web_hyperdb_http_client";
      33              : 
      34              : /// Convert a HyperHttpError (v3 JSON error body) into a structured HyperQueryError.
      35              : /// Other errors are rethrown unchanged.
      36            1 : function rethrowAsHyperQueryError(e: unknown): never {
      37            1 :     if (e instanceof HyperHttpError && e.errorResponse) {
      38            1 :         throw new HyperQueryError(fromHttpErrorResponse(e.errorResponse), LOG_CTX);
      39              :     }
      40            1 :     throw e;
      41            1 : }
      42              : 
      43            1 : const CHUNK_POLL_DELAY_MS = 250;
      44              : export const DEFAULT_PARALLEL_CHUNKS = 4;
      45            1 : const STREAM_YIELD_INTERVAL = 8;
      46              : 
      47              : /// Bytes-reader that feeds Arrow IPC stream chunks from v3 HTTP into an AsyncRecordBatchStreamReader.
      48              : /// Chunks are prefetched in parallel up to `parallelChunks`, but yielded in-order so that the
      49              : /// Arrow stream framing stays valid.
      50            1 : class WebHyperResultReader implements AsyncIterator<Uint8Array>, AsyncIterable<Uint8Array> {
      51            1 :     httpClient: HyperDatabaseHttpClient;
      52            1 :     logger: Logger;
      53            1 :     queryId: string;
      54              :     /// The initial response body from POST /api/v3/query (already the first/only chunk when RESULTS_PRODUCED).
      55            1 :     initialBytes: Uint8Array | null;
      56              :     /// Current status (from POST or last poll / chunk response).
      57            1 :     status: QueryStatus;
      58              :     /// Maximum number of in-flight chunk fetches.
      59            1 :     parallelChunks: number;
      60              :     /// Index of the next chunk to enqueue for prefetch.
      61            1 :     nextPrefetchIndex: number;
      62              :     /// FIFO of in-flight chunk byte Promises; the head is the next chunk to return.
      63            1 :     inflight: Array<Promise<Uint8Array>>;
      64              :     /// Has the Arrow schema prefix already been emitted to the reader (via POST body or a prior chunk)?
      65            1 :     schemaEmitted: boolean;
      66              :     /// Public reader state.
      67            1 :     currentStatus: QueryExecutionStatus;
      68            1 :     metrics: QueryExecutionMetrics;
      69            1 :     metadata: Map<string, string>;
      70              :     /// Callback invoked whenever a new QueryStatus is observed.
      71            1 :     progressCallback: ((progress: QueryExecutionProgress) => void) | null;
      72            1 :     abort: AbortSignal | undefined;
      73              : 
      74            1 :     constructor(httpClient: HyperDatabaseHttpClient, logger: Logger, queryId: string, initialStatus: QueryStatus, initialBytes: Uint8Array | null, parallelChunks: number) {
      75            1 :         this.httpClient = httpClient;
      76            1 :         this.logger = logger;
      77            1 :         this.queryId = queryId;
      78            1 :         this.initialBytes = initialBytes;
      79            1 :         this.status = initialStatus;
      80            1 :         this.parallelChunks = Math.max(1, parallelChunks);
      81            1 :         this.nextPrefetchIndex = initialBytes && initialBytes.byteLength > 0 ? 1 : 0;
      82            1 :         this.inflight = [];
      83            1 :         this.schemaEmitted = !!(initialBytes && initialBytes.byteLength > 0);
      84            1 :         this.currentStatus = QueryExecutionStatus.RUNNING;
      85            1 :         this.metrics = createQueryResponseStreamMetrics();
      86            1 :         this.metadata = new Map();
      87            1 :         this.progressCallback = null;
      88            1 :         this.abort = undefined;
      89            1 :         if (initialBytes) {
      90            1 :             this.metrics.totalDataBytesReceived += initialBytes.byteLength;
      91              :         }
      92            1 :     }
      93              : 
      94            1 :     emitProgress(): void {
      95            1 :         if (!this.progressCallback) return;
      96            1 :         const stats = this.status.executionStats;
      97            1 :         this.progressCallback({
      98            1 :             isQueued: null,
      99            1 :             progressPercent: this.status.progress,
     100            1 :             resultRows: this.status.rowCount,
     101            1 :             rowsProcessed: stats?.rowsProcessed ?? null,
     102            1 :             executionTimeMs: stats?.wallClockTime ?? null,
     103            1 :             metrics: { ...this.metrics },
     104            1 :             engineProgress: { kind: 'hyper_query_status', status: this.status, receivedAt: Date.now() },
     105            1 :         });
     106            1 :     }
     107              : 
     108            1 :     private updateStatus(status: QueryStatus): void {
     109            1 :         this.status = mergeQueryStatus(this.status, status);
     110            1 :         this.emitProgress();
     111            1 :     }
     112              : 
     113            1 :     private isCompleted(): boolean {
     114            1 :         return this.status.completionStatus === "RESULTS_PRODUCED" || this.status.completionStatus === "FINISHED";
     115            1 :     }
     116              : 
     117            1 :     private fillPrefetchWindow(): void {
     118            1 :         const chunkCount = this.status.chunkCount ?? 0;
     119            1 :         while (this.inflight.length < this.parallelChunks && this.nextPrefetchIndex < chunkCount) {
     120            1 :             const chunkId = this.nextPrefetchIndex++;
     121            1 :             const omitSchema = this.schemaEmitted;
     122            1 :             this.schemaEmitted = true;
     123            1 :             this.inflight.push(this.fetchChunk(chunkId, omitSchema));
     124            1 :         }
     125            1 :     }
     126              : 
     127            1 :     private async fetchChunk(chunkId: number, omitSchema: boolean): Promise<Uint8Array> {
     128            1 :         const { status, response } = await this.httpClient.getQueryChunk({
     129            1 :             queryId: this.queryId,
     130            1 :             chunkId,
     131            1 :             omitSchema,
     132            1 :         }, undefined, this.abort).catch(rethrowAsHyperQueryError);
     133            1 :         if (status) {
     134            1 :             this.updateStatus(status);
     135              :         }
     136            1 :         const body = await response.arrayBuffer();
     137            1 :         return new Uint8Array(body);
     138            1 :     }
     139              : 
     140            1 :     async next(): Promise<IteratorResult<Uint8Array>> {
     141            1 :         if (this.initialBytes && this.initialBytes.byteLength > 0) {
     142            1 :             const value = this.initialBytes;
     143            1 :             this.initialBytes = null;
     144            1 :             this.fillPrefetchWindow();
     145            1 :             return { done: false, value };
     146            1 :         }
     147            1 :         this.initialBytes = null;
     148              : 
     149            1 :         while (true) {
     150            1 :             this.fillPrefetchWindow();
     151              : 
     152            1 :             if (this.inflight.length > 0) {
     153            1 :                 const bytes = await this.inflight.shift()!;
     154              :                 // Top up the window as soon as one slot frees.
     155            1 :                 this.fillPrefetchWindow();
     156            1 :                 if (bytes.byteLength > 0) {
     157            1 :                     this.metrics.totalDataBytesReceived += bytes.byteLength;
     158            1 :                     return { done: false, value: bytes };
     159            1 :                 }
     160            0 :                 continue;
     161            0 :             }
     162              : 
     163            1 :             if (this.isCompleted()) {
     164            1 :                 return { done: true, value: null };
     165              :             }
     166              : 
     167              :             // No chunks available yet and query is still running — poll for progress.
     168            0 :             this.updateStatus(await this.httpClient.getQueryStatus({
     169            0 :                 queryId: this.queryId,
     170            0 :                 waitTimeMs: CHUNK_POLL_DELAY_MS,
     171            0 :             }, this.abort).catch(rethrowAsHyperQueryError));
     172            0 :         }
     173            1 :     }
     174              : 
     175            1 :     [Symbol.asyncIterator]() {
     176            1 :         return this;
     177            1 :     }
     178            1 : }
     179              : 
     180              : /// A web HyperDatabaseChannel backed by the v3 HTTP API.
     181            1 : export class WebHyperQueryResultStream implements QueryExecutionResponseStream {
     182            1 :     reader: WebHyperResultReader;
     183            1 :     resultSchema: AsyncValue<arrow.Schema<any>, Error>;
     184              : 
     185            1 :     constructor(reader: WebHyperResultReader) {
     186            1 :         this.reader = reader;
     187            1 :         this.resultSchema = new AsyncValue();
     188            1 :     }
     189              : 
     190            1 :     async cancel(): Promise<void> {
     191            1 :         await this.reader.httpClient.cancelQuery(this.reader.queryId);
     192            1 :     }
     193              : 
     194            0 :     getMetadata(): Map<string, string> {
     195            0 :         return this.reader.metadata;
     196            0 :     }
     197            1 :     getMetrics(): QueryExecutionMetrics {
     198            1 :         return this.reader.metrics;
     199            1 :     }
     200            0 :     getStatus(): QueryExecutionStatus {
     201            0 :         return this.reader.currentStatus;
     202            0 :     }
     203            0 :     getSchema(): Promise<arrow.Schema> {
     204            0 :         return this.resultSchema.getValue();
     205            0 :     }
     206              : 
     207            1 :     async produce(batches: AsyncConsumer<QueryExecutionResponseStream, arrow.RecordBatch>, progress: AsyncConsumer<QueryExecutionResponseStream, QueryExecutionProgress>, abort?: AbortSignal): Promise<void> {
     208            1 :         this.reader.abort = abort;
     209            1 :         this.reader.progressCallback = (update: QueryExecutionProgress) => {
     210            1 :             progress.resolve(this, update);
     211            1 :         };
     212              :         // Report the status of the query response
     213            1 :         this.reader.emitProgress();
     214            1 :         const arrowReader = await arrow.AsyncRecordBatchStreamReader.from(this.reader);
     215            1 :         abort?.throwIfAborted();
     216              : 
     217            1 :         await arrowReader.open();
     218            1 :         abort?.throwIfAborted();
     219            1 :         this.resultSchema.resolve(arrowReader.schema);
     220              : 
     221            1 :         let batchesRead = 0;
     222            1 :         while (true) {
     223            1 :             const iter = await arrowReader.next();
     224            1 :             abort?.throwIfAborted();
     225            1 :             if (iter.done) {
     226            1 :                 if (iter.value !== undefined) {
     227            0 :                     batches.resolve(this, iter.value);
     228              :                 }
     229            1 :                 break;
     230            1 :             }
     231            1 :             batches.resolve(this, iter.value);
     232            1 :             if (++batchesRead % STREAM_YIELD_INTERVAL === 0) await yieldToBrowser();
     233            1 :         }
     234            1 :     }
     235            1 : }
     236              : 
     237            1 : class WebHyperDatabaseChannel implements HyperDatabaseChannel {
     238            1 :     httpClient: HyperDatabaseHttpClient;
     239            1 :     connection: HyperDatabaseConnectionContext;
     240            1 :     logger: Logger;
     241            1 :     parallelChunks: number;
     242              : 
     243            1 :     constructor(httpClient: HyperDatabaseHttpClient, connection: HyperDatabaseConnectionContext, logger: Logger, parallelChunks: number) {
     244            1 :         this.httpClient = httpClient;
     245            1 :         this.connection = connection;
     246            1 :         this.logger = logger;
     247            1 :         this.parallelChunks = parallelChunks;
     248            1 :     }
     249              : 
     250            1 :     async executeQuery(params: pb.salesforce_hyperdb_grpc_v1.pb.QueryParam, abort?: AbortSignal): Promise<HyperQueryResultStream> {
     251            1 :         const sql = params.query;
     252            1 :         const queryParameters = this.connection.getQueryParameters();
     253            1 :         const settings = Object.keys(queryParameters).length > 0 ? queryParameters : undefined;
     254            1 :         const attachedDatabases = this.connection.getAttachedDatabases();
     255            1 :         const databases = attachedDatabases.length > 0 ? attachedDatabases.map(db => ({ path: db.path, alias: db.alias || undefined })) : undefined;
     256            1 :         const clientContext = this.connection.getClientContext?.();
     257            1 :         const headers = clientContext == null ? undefined : { externalClientContext: JSON.stringify(clientContext) };
     258            1 :         const { status, response } = await this.httpClient
     259            1 :             .executeQuery({ sql, settings, databases }, headers, abort)
     260            1 :             .catch(rethrowAsHyperQueryError);
     261            1 :         if (!status || !status.queryId) {
     262            1 :             throw new Error("v3 query response missing status header");
     263              :         }
     264            1 :         const bodyBuffer = await response.arrayBuffer();
     265            1 :         const bytes = new Uint8Array(bodyBuffer);
     266            1 :         const reader = new WebHyperResultReader(this.httpClient, this.logger, status.queryId, status, bytes.byteLength > 0 ? bytes : null, this.parallelChunks);
     267            1 :         return new WebHyperQueryResultStream(reader);
     268            1 :     }
     269              : 
     270            0 :     async close(): Promise<void> { }
     271            1 : }
     272              : 
     273              : /// HyperHttpAuthProvider that delegates to the connection context's getRequestMetadata.
     274            1 : class ContextAuthProvider implements HyperHttpAuthProvider {
     275            1 :     context: HyperDatabaseConnectionContext;
     276            1 :     constructor(context: HyperDatabaseConnectionContext) {
     277            1 :         this.context = context;
     278            1 :     }
     279            1 :     async getAuthHeaders(): Promise<Record<string, string>> {
     280            1 :         return await this.context.getRequestMetadata();
     281            1 :     }
     282            1 : }
     283              : 
     284              : export interface WebHyperDatabaseClientConfig {
     285              :     /// Maximum number of chunk fetches in flight per query result stream.
     286              :     parallelChunks?: number;
     287              : }
     288              : 
     289            1 : export class WebHyperDatabaseClient implements HyperDatabaseClient {
     290            1 :     httpClient: HttpClient;
     291            1 :     logger: Logger;
     292            1 :     parallelChunks: number;
     293              : 
     294            1 :     constructor(httpClient: HttpClient, logger: Logger, config: WebHyperDatabaseClientConfig = {}) {
     295            1 :         this.httpClient = httpClient;
     296            1 :         this.logger = logger;
     297            1 :         this.parallelChunks = Math.max(1, config.parallelChunks ?? DEFAULT_PARALLEL_CHUNKS);
     298            1 :     }
     299              : 
     300            1 :     async connect(hyperArgs: connection.HyperConnectionParams, context: HyperDatabaseConnectionContext): Promise<HyperDatabaseChannel> {
     301            1 :         if (!hyperArgs.endpoint) {
     302            1 :             throw new Error("missing hyper endpoint");
     303              :         }
     304            1 :         const endpointUrl = new URL(hyperArgs.endpoint);
     305            1 :         const auth = new ContextAuthProvider(context);
     306            1 :         const client = new HyperDatabaseHttpClient(this.httpClient, endpointUrl, auth, this.logger);
     307            1 :         return new WebHyperDatabaseChannel(client, context, this.logger, this.parallelChunks);
     308            1 :     }
     309            1 : }
        

Generated by: LCOV version 2.0-1