LCOV - code coverage report
Current view: top level - dashql-app/src/connections/catalog - catalog_loader.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 97.2 % 288 280
Test Date: 2026-11-06 19:30:32

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

Generated by: LCOV version 2.0-1