LCOV - code coverage report
Current view: top level - dashql-app/src/connections/catalog - catalog_loader.tsx Coverage Total Hit
Test: DashQL d805a4b Lines: 97.3 % 292 284
Test Date: 2026-11-09 20:22:13

            Line data    Source code
       1              : import * as React from 'react';
       2              : 
       3              : import { useDynamicConnectionDispatch } from '../connection_registry.js';
       4              : import { CatalogUpdateTaskState, CatalogUpdateTaskStatus, CatalogUpdateVariant, MAX_TOUCHED_ATTEMPTS } from './catalog_update_state.js';
       5              : import { useSalesforceAPI } from '../salesforce/salesforce_connector.js';
       6              : import { HYPER_CONNECTOR, SALESFORCE_DATA_CLOUD_CONNECTOR, DBCOM_CONNECTOR, TRINO_CONNECTOR } from '../connector_info.js';
       7              : import {
       8              :     ConnectionHealth,
       9              :     CATALOG_UPDATE_CANCELLED,
      10              :     CATALOG_UPDATE_FAILED,
      11              :     CATALOG_UPDATE_PARTIALLY_SUCCEEDED,
      12              :     CATALOG_UPDATE_SUCCEEDED,
      13              :     UPDATE_CATALOG,
      14              : } from '../connection_state.js';
      15              : import { getHyperCloudAttachedDatabase, updateSalesforceCatalog } from '../salesforce/salesforce_catalog_update.js';
      16              : import { useQueryExecutor } from '../query_executor.js';
      17              : import { useLogger } from '../../platform/logger/logger_provider.js';
      18              : import { createTrace } from '../../platform/logger/trace_context.js';
      19              : import { updateInformationSchemaCatalog, updateInformationSchemaRelations } from './catalog_query_information_schema.js';
      20              : import type { RelationName } from './catalog_sql_generator.js';
      21              : import { queryTrinoCatalogScopes, trinoCatalogScopes } from '../trino/trino_catalogs.js';
      22              : import { updateHyperCatalog } from '../hyper/hyper_catalog_update.js';
      23              : import { connectionCatalogIsDurable } from '../connection_params.js';
      24              : import { ensureConnectionClientFunctions } from '../connection_client_functions.js';
      25              : import { connectorOfDetails } from '../connector_descriptor.js';
      26              : 
      27            1 : const LOG_CTX = 'catalog_loader';
      28              : 
      29              : /// The wait before asking a connection again about the relations of executed statements, per failed attempt
      30            1 : const TOUCHED_RETRY_DELAY_MS = 2000;
      31            1 : let NEXT_CATALOG_UPDATE_ID = 1;
      32              : 
      33              : /// The catalog update args
      34              : interface CatalogLoaderArgs {
      35              :     /// The relations to ask the connection about, everything if absent
      36              :     relations: RelationName[] | null;
      37              : }
      38              : /// The catalog updater function
      39              : export type CatalogLoader = (connectionId: string, args: CatalogLoaderArgs) => [number, Promise<void>];
      40              : /// A function to add a connection id to a catalog loader queue
      41              : type RefreshCatalogFn = (connectionId: string, force: boolean) => void;
      42              : /// A catalog loader queue
      43              : interface CatalogLoaderQueue { queue: Map<string, boolean>; }
      44              : 
      45              : /// The React context to propagate a active catalog loader queue function
      46            1 : const LOADER_QUEUE_FN_CTX = React.createContext<RefreshCatalogFn | null>(null);
      47              : 
      48              : /// The hook to resolve the catalog queue
      49            1 : export const useCatalogLoaderQueue = () => React.useContext(LOADER_QUEUE_FN_CTX)!;
      50              : 
      51              : export interface CatalogLoaderDependencies {
      52              :     useDynamicConnectionDispatch: typeof useDynamicConnectionDispatch;
      53              :     useQueryExecutor: typeof useQueryExecutor;
      54              :     useSalesforceAPI: typeof useSalesforceAPI;
      55              :     updateHyperCatalog: typeof updateHyperCatalog;
      56              : }
      57              : 
      58            1 : const DEFAULT_DEPENDENCIES: CatalogLoaderDependencies = {
      59            1 :     useDynamicConnectionDispatch,
      60            1 :     useQueryExecutor,
      61            1 :     useSalesforceAPI,
      62            1 :     updateHyperCatalog,
      63            1 : };
      64              : 
      65              : /// The props of the catalog loader
      66              : export interface CatalogLoaderProviderProps {
      67              :     /// The children
      68              :     children?: React.ReactElement;
      69              :     /// The hooks the loader uses, replaced in tests
      70              :     dependencies?: CatalogLoaderDependencies;
      71              :     /// Called when the catalog of a connection changed
      72              :     onCatalogDidUpdate?: (connectionId: string) => void;
      73              : }
      74              : 
      75            1 : export function CatalogLoaderProvider(props: CatalogLoaderProviderProps) {
      76            1 :     const dependencies = props.dependencies ?? DEFAULT_DEPENDENCIES;
      77            1 :     const logger = useLogger();
      78            1 :     const executor = dependencies.useQueryExecutor();
      79            1 :     const sfapi = dependencies.useSalesforceAPI();
      80              : 
      81              :     // The connection registry changes frequently, the connection map is stable.
      82              :     // This executor will depend on the map directly since it can resolve everything ad-hoc.
      83            1 :     const [connReg, connDispatch] = dependencies.useDynamicConnectionDispatch();
      84            1 :     const connMap = connReg.connections;
      85            1 :     const onCatalogDidUpdate = React.useRef(props.onCatalogDidUpdate);
      86            1 :     onCatalogDidUpdate.current = props.onCatalogDidUpdate;
      87              : 
      88              :     // The client functions of a connection come with its connector, scripts that call them are re-analyzed
      89            1 :     React.useEffect(() => {
      90            1 :         for (const connectionId of connMap.keys()) {
      91            1 :             ensureConnectionClientFunctions(connMap, connectionId, id => onCatalogDidUpdate.current?.(id), logger);
      92              :         }
      93            1 :     }, [connReg, connMap, logger]);
      94              : 
      95              :     // Maintain a queue
      96            1 :     const [queueState, setQueueState] = React.useState<CatalogLoaderQueue>(() => ({ queue: new Map() }));
      97            1 :     const enqueue = React.useCallback<RefreshCatalogFn>((connectionId: string, force: boolean) => {
      98            1 :         setQueueState(s => ({ queue: new Map(s.queue).set(connectionId, force || s.queue.get(connectionId) === true) }));
      99            1 :     }, []);
     100              : 
     101              :     // Execute a query with pre-allocated query id
     102            1 :     const updateImpl = React.useCallback(async (connectionId: string, args: CatalogLoaderArgs, updateId: number): Promise<void> => {
     103              :         // Each catalog update gets its own trace so failures don't borrow
     104              :         // whatever trace happens to be on a shared stack.
     105            1 :         const traced = logger.withTrace(createTrace());
     106              : 
     107            1 :         const conn = connMap.get(connectionId);
     108            1 :         if (!conn) {
     109            1 :             traced.warn("Failed to resolve connection", { connectionId }, LOG_CTX);
     110            1 :             throw new Error(`couldn't find a connection with id ${connectionId}`);
     111            1 :         }
     112            1 :         if (!executor) {
     113            1 :             traced.warn("Query executor not configured", { connectionId }, LOG_CTX);
     114            1 :             throw new Error(`couldn't find trino executor`);
     115            1 :         }
     116              : 
     117            1 :         traced.debug("Updating catalog", { connectionId }, LOG_CTX);
     118              : 
     119            1 :         const relations = args.relations;
     120              : 
     121              :         // Accept the query and clear the request
     122            1 :         const abortController = new AbortController();
     123            1 :         const now = new Date();
     124            1 :         const initialState: CatalogUpdateTaskState = {
     125            1 :             taskId: updateId,
     126            1 :             taskVariant: relations != null ? CatalogUpdateVariant.RELATION_REFRESH : CatalogUpdateVariant.FULL_CATALOG_REFRESH,
     127            1 :             status: CatalogUpdateTaskStatus.STARTED,
     128            1 :             cancellation: abortController,
     129            1 :             queries: [],
     130            1 :             error: null,
     131            1 :             startedAt: now,
     132            1 :             finishedAt: null,
     133            1 :             touchedRelations: relations ?? [],
     134            1 :         };
     135            1 :         connDispatch(connectionId, {
     136            1 :             type: UPDATE_CATALOG,
     137            1 :             value: [updateId, initialState],
     138            1 :         });
     139              : 
     140              :         // Emit a heartbeat log every few seconds so long-running updates
     141              :         // don't look like a silent hang between "Starting" and "Updated".
     142            1 :         const updateStartMs = performance.now();
     143            1 :         const heartbeat = setInterval(() => {
     144            0 :             const elapsedMs = performance.now() - updateStartMs;
     145            0 :             traced.info("Catalog update in progress", {
     146            0 :                 connectionId,
     147            0 :                 "updateId": updateId.toString(),
     148            0 :                 "elapsedMs": elapsedMs.toFixed(0),
     149            0 :             }, LOG_CTX);
     150            1 :         }, 5000);
     151              : 
     152              :         // Update the catalog
     153            1 :         const versionBefore = conn.catalog.version();
     154            1 :         try {
     155            1 :             const batchOptions = {
     156            1 :                 abortSignal: abortController.signal,
     157            1 :                 isCurrent: () => connMap.get(connectionId)?.catalog === conn.catalog,
     158            1 :             };
     159            1 :             let partialError: Error | null = null;
     160            1 :             switch (conn.details.type) {
     161              :                 // Update the catalog by querying the information_schema
     162            1 :                 case TRINO_CONNECTOR: {
     163              :                     // Trino folds the names of relations to lower case
     164            1 :                     if (relations != null) {
     165            1 :                         const folded = relations.map(r => ({ databaseName: r.databaseName.toLowerCase(), schemaName: r.schemaName.toLowerCase(), tableName: r.tableName.toLowerCase() }));
     166            1 :                         await updateInformationSchemaRelations(connectionId, connDispatch, updateId, folded, executor, conn.catalog, conn.catalogRelationEntryId, batchOptions);
     167            1 :                         break;
     168            1 :                     }
     169            1 :                     const setupParams = conn.details.value.proto.setupParams;
     170            1 :                     let scopes = setupParams ? trinoCatalogScopes(setupParams) : [];
     171              :                     // Without configured catalogs, read all catalogs of the server
     172            1 :                     if (scopes.length === 0) {
     173            1 :                         scopes = await queryTrinoCatalogScopes(connectionId, connDispatch, updateId, executor, abortController.signal);
     174              :                     }
     175            1 :                     await updateInformationSchemaCatalog(connectionId, connDispatch, updateId, scopes, executor, conn.catalog, conn.catalogRelationEntryId, batchOptions);
     176            1 :                     break;
     177            1 :                 }
     178            1 :                 case HYPER_CONNECTOR: {
     179            1 :                     const result = await dependencies.updateHyperCatalog(
     180            1 :                         traced,
     181            1 :                         connectionId,
     182            1 :                         connDispatch,
     183            1 :                         updateId,
     184            1 :                         (conn.details.value.proto.setupParams?.attachedDatabases ?? []).map(database => ({
     185            0 :                             path: database.path ?? '',
     186            0 :                             alias: database.alias,
     187            1 :                         })),
     188            1 :                         executor,
     189            1 :                         conn.catalog,
     190            1 :                         conn.instance,
     191            1 :                         conn.catalogRelationEntryId,
     192            1 :                         conn.catalogFunctionScript,
     193            1 :                         conn.details.value.proto.setupParams?.protocol === 'WASM',
     194            1 :                         abortController.signal,
     195            1 :                         batchOptions,
     196            1 :                         relations,
     197            1 :                     );
     198            1 :                     if (result.failures.length > 0) {
     199            1 :                         partialError = new Error(result.failures
     200            1 :                             .map(failure => `${failure.database}: ${failure.error.message}`)
     201            1 :                             .join('; '));
     202            1 :                         traced.warn('Partially updated Hyper catalog', {
     203            1 :                             connectionId,
     204            1 :                             updated: result.updatedDatabases.join(', '),
     205            1 :                             failed: result.failures.map(failure => failure.database).join(', '),
     206            1 :                         }, LOG_CTX);
     207            1 :                     }
     208            1 :                     break;
     209            1 :                 }
     210              :                 // Update the catalog by querying the _hyper_catalog of the attached Hyper Cloud database
     211            1 :                 case DBCOM_CONNECTOR:
     212            1 :                     await dependencies.updateHyperCatalog(
     213            1 :                         traced,
     214            1 :                         connectionId,
     215            1 :                         connDispatch,
     216            1 :                         updateId,
     217            1 :                         [getHyperCloudAttachedDatabase(conn.details.value.proto.setupParams)],
     218            1 :                         executor,
     219            1 :                         conn.catalog,
     220            1 :                         conn.instance,
     221            1 :                         conn.catalogRelationEntryId,
     222            1 :                         conn.catalogFunctionScript,
     223            1 :                         false,
     224            1 :                         abortController.signal,
     225            1 :                         batchOptions,
     226            1 :                         relations,
     227            1 :                     );
     228            1 :                     break;
     229              :                 // Update the catalog by querying the Salesforce Metadata Service
     230            1 :                 case SALESFORCE_DATA_CLOUD_CONNECTOR:
     231            1 :                     await updateSalesforceCatalog(
     232            1 :                         traced,
     233            1 :                         conn.details.value,
     234            1 :                         conn.catalog,
     235            1 :                         conn.instance,
     236            1 :                         conn.catalogRelationEntryId,
     237            1 :                         conn.catalogFunctionScript,
     238            1 :                         sfapi,
     239            1 :                         abortController,
     240            1 :                         batchOptions,
     241            1 :                     );
     242              :                     break;
     243            1 :             }
     244            1 :             traced.debug("Updated catalog", { connectionId }, LOG_CTX);
     245              : 
     246              :             // Mark the update successful
     247            1 :             if (partialError == null) {
     248            1 :                 connDispatch(connectionId, {
     249            1 :                     type: CATALOG_UPDATE_SUCCEEDED,
     250            1 :                     value: [updateId, conn.catalog.version() !== versionBefore],
     251            1 :                 });
     252              :             } else {
     253            1 :                 connDispatch(connectionId, {
     254            1 :                     type: CATALOG_UPDATE_PARTIALLY_SUCCEEDED,
     255            1 :                     value: [updateId, partialError],
     256            1 :                 });
     257              :             }
     258              :             // Announce the new catalog
     259            1 :             onCatalogDidUpdate.current?.(connectionId);
     260              : 
     261            1 :         } catch (e: any) {
     262            1 :             if (e?.name === 'AbortError') {
     263            1 :                 traced.info("Cancelled catalog update", { connectionId, "error": e?.message ?? String(e) }, LOG_CTX);
     264            1 :                 connDispatch(connectionId, {
     265            1 :                     type: CATALOG_UPDATE_CANCELLED,
     266            1 :                     value: [updateId, e],
     267            1 :                 });
     268            1 :             } else {
     269            1 :                 traced.error("Failed to update catalog", { connectionId, "error": e?.message ?? String(e) }, LOG_CTX);
     270            1 :                 connDispatch(connectionId, {
     271            1 :                     type: CATALOG_UPDATE_FAILED,
     272            1 :                     value: [updateId, e],
     273            1 :                 });
     274            1 :             }
     275            1 :         } finally {
     276            1 :             clearInterval(heartbeat);
     277            1 :             const totalMs = performance.now() - updateStartMs;
     278            1 :             traced.info("Finished catalog update", {
     279            1 :                 connectionId,
     280            1 :                 "updateId": updateId.toString(),
     281            1 :                 "durationMs": totalMs.toFixed(0),
     282            1 :             }, LOG_CTX);
     283            1 :         }
     284            1 :     }, [connMap, sfapi, executor, dependencies.updateHyperCatalog]);
     285              : 
     286              :     // Allocate the next query id and start the execution
     287            1 :     const update = React.useCallback<CatalogLoader>((connectionId: string, args: CatalogLoaderArgs): [number, Promise<void>] => {
     288            1 :         const updateId = NEXT_CATALOG_UPDATE_ID++;
     289            1 :         const execution = updateImpl(connectionId, args, updateId);
     290            1 :         return [updateId, execution];
     291            1 :     }, [updateImpl]);
     292              : 
     293              :     // A full refresh only runs on user input and for the initial description of a connection: the catalog of a
     294              :     // connection whose database doesn't outlive the app is not restored from storage, it is described whenever the
     295              :     // connection comes online. A connection that has neither a stored nor a refreshed catalog is described when it
     296              :     // first comes online. Everything else asks the connection about single relations.
     297            1 :     const onlineRef = React.useRef(new Set<string>());
     298            1 :     React.useEffect(() => {
     299            1 :         const online = onlineRef.current;
     300            1 :         for (const [connectionId, conn] of connMap) {
     301            1 :             if (conn.connectionHealth !== ConnectionHealth.ONLINE) {
     302            1 :                 online.delete(connectionId);
     303            1 :             } else if (!online.has(connectionId)) {
     304            1 :                 online.add(connectionId);
     305            1 :                 if (!connectionCatalogIsDurable(conn.params)) {
     306            1 :                     enqueue(connectionId, true);
     307            1 :                 } else if (!conn.catalogUpdates.described) {
     308            1 :                     enqueue(connectionId, false);
     309              :                 }
     310            1 :             }
     311              :         }
     312            1 :         for (const connectionId of [...online]) {
     313            1 :             if (!connMap.has(connectionId)) online.delete(connectionId);
     314              :         }
     315            1 :     }, [connReg, connMap, enqueue]);
     316              : 
     317              :     // When the last update of a connection finished and the timer of the next attempt
     318            1 :     const lastFinishedAt = React.useRef(new Map<string, number>());
     319            1 :     const retryTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
     320            1 :     React.useEffect(() => () => {
     321            1 :         if (retryTimer.current != null) clearTimeout(retryTimer.current);
     322            1 :         retryTimer.current = null;
     323            1 :     }, []);
     324              : 
     325              :     // Subscribe the queue
     326            1 :     const updatesInProgress = React.useRef<Set<string> | null>(null);
     327            1 :     React.useEffect(() => {
     328            1 :         const inProgress = updatesInProgress.current ?? new Set();
     329            1 :         updatesInProgress.current = inProgress;
     330              : 
     331              :         // Helper to perform the catalog update
     332            1 :         const doUpdate = async (connectionId: string, relations: RelationName[] | null) => {
     333            1 :             inProgress.add(connectionId);
     334            1 :             try {
     335            1 :                 logger.info("Starting catalog update", { connectionId, relations: String(relations?.length ?? 'all') }, LOG_CTX);
     336              :                 // Otherwise start the catalog update
     337            1 :                 const [_updateId, updatePromise] = update(connectionId, { relations });
     338              :                 // Await the update
     339            1 :                 await updatePromise;
     340            1 :             } catch (e: any) {
     341            1 :                 logger.warn("Catalog update failed", { connectionId, "error": e?.message ?? String(e) }, LOG_CTX);
     342            1 :             } finally {
     343            1 :                 inProgress.delete(connectionId);
     344            1 :                 lastFinishedAt.current.set(connectionId, Date.now());
     345              :                 // Pending requests must be reconsidered even if the registry does not change.
     346            1 :                 setQueueState(s => ({ ...s }));
     347            1 :             }
     348            1 :         };
     349              : 
     350              :         // Ask the connections about the relations of executed statements, when they have room for another query.
     351              :         // A refresh that runs may have read the relations too early, this update follows it and corrects them.
     352            1 :         for (const [connectionId, conn] of connReg.connections) {
     353            1 :             const { touchedRelations: relations, touchedFailures: failures, tasksRunning } = conn.catalogUpdates;
     354            1 :             if (relations.length === 0 || inProgress.has(connectionId) || tasksRunning.size > 0) continue;
     355              :             // A requested refresh goes first, the lookup follows it
     356            1 :             if (queueState.queue.has(connectionId)) continue;
     357              :             // Relations that a restored catalog still has to ask about wait for the connection
     358            1 :             if (conn.connectionHealth !== ConnectionHealth.ONLINE) continue;
     359            1 :             if (conn.queriesActive.size >= connectorOfDetails(conn.details).maxConcurrentQueries(conn.details.value)) continue;
     360              :             // The next statement or a manual refresh tries again
     361            1 :             if (failures >= MAX_TOUCHED_ATTEMPTS) continue;
     362            1 :             if (failures > 0) {
     363            1 :                 const wait = (lastFinishedAt.current.get(connectionId) ?? 0) + TOUCHED_RETRY_DELAY_MS * failures - Date.now();
     364            1 :                 if (wait > 0) {
     365            1 :                     if (retryTimer.current == null) {
     366            1 :                         retryTimer.current = setTimeout(() => {
     367            1 :                             retryTimer.current = null;
     368            1 :                             setQueueState(s => ({ ...s }));
     369            1 :                         }, wait);
     370              :                     }
     371            1 :                     continue;
     372            1 :                 }
     373            1 :             }
     374            1 :             doUpdate(connectionId, relations);
     375            1 :         }
     376              : 
     377            1 :         const processed: string[] = [];
     378            1 :         for (const [connectionId, force] of queueState.queue) {
     379            1 :             logger.debug("Received catalog update request", { connectionId }, LOG_CTX);
     380              : 
     381              :             // Find the connection
     382            1 :             const connState = connReg.connections.get(connectionId);
     383            1 :             if (!connState) {
     384            1 :                 logger.warn("Failed to resolve connection", { connectionId }, LOG_CTX);
     385            1 :                 processed.push(connectionId);
     386            1 :                 continue;
     387            1 :             }
     388              : 
     389              :             // Has a current catalog update running?
     390              :             // Skip auto-updates, but let explicit user refreshes supersede
     391              :             // the in-flight ones by aborting them first.
     392            1 :             if (connState.catalogUpdates.tasksRunning.size > 0) {
     393            1 :                 if (!force) {
     394            1 :                     logger.info("Skipping redundant catalog update", { connectionId }, LOG_CTX);
     395            1 :                     continue;
     396            1 :                 }
     397            1 :                 for (const task of connState.catalogUpdates.tasksRunning.values()) {
     398            1 :                     if (!task.cancellation.signal.aborted) {
     399            1 :                         task.cancellation.abort(new DOMException('Superseded by forced catalog refresh', 'AbortError'));
     400              :                     }
     401              :                 }
     402            1 :             }
     403              : 
     404              :             // Keep forced requests queued until the cancelled execution settles.
     405            1 :             if (inProgress.has(connectionId)) {
     406            1 :                 continue;
     407              :             }
     408              : 
     409              :             // A refresh described the connection meanwhile
     410            1 :             if (!force && connState.catalogUpdates.described) {
     411            1 :                 processed.push(connectionId);
     412            1 :                 continue;
     413            1 :             }
     414              : 
     415              :             // Perform the catalog update
     416            1 :             doUpdate(connectionId, null);
     417            1 :             processed.push(connectionId);
     418            1 :         }
     419              : 
     420              :         // No processed?
     421            1 :         if (processed.length == 0) {
     422            1 :             return;
     423              :         }
     424              : 
     425              :         // Remove all processed ids from the queue
     426            1 :         setQueueState(s => {
     427            1 :             const queue = new Map(s.queue);
     428            1 :             for (const connectionId of processed) {
     429            1 :                 queue.delete(connectionId);
     430              :             }
     431            1 :             return { ...s, queue };
     432            1 :         });
     433            1 :     }, [queueState, connReg, update, logger]);
     434              : 
     435            1 :     return (
     436            1 :         <LOADER_QUEUE_FN_CTX.Provider value={enqueue}>
     437            1 :             {props.children}
     438            1 :         </LOADER_QUEUE_FN_CTX.Provider>
     439              :     );
     440            1 : }
        

Generated by: LCOV version 2.0-1