UNPKG

@glue42/search-api

Version:

Glue42 Search API

476 lines (328 loc) 20.7 kB
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable indent */ import { Glue42Core } from "@glue42/core"; import { nanoid } from "nanoid"; import { Glue42Search } from "../../search"; import { ProviderModel } from "../models/provider"; import { AsyncSequelizer } from "../services/sequelizer"; import { GlueController } from "./glue"; import { default as CallbackRegistryFactory, CallbackRegistry } from "callback-registry"; import { operationDecoder, queryConfigDecoder, searchCancelRequestDecoder } from "../shared/decoders"; import { ActiveQuery, LegacySearchRequest, ProviderQueryDoneCommand, ProviderQueryErrorCommand, ProviderQueryResultCommand, SearchCancelRequest } from "../shared/types"; import { STALE_QUERY_TIMEOUT_MS } from "../shared/constants"; import { CLIENT_TO_PROVIDER_PROTOCOL_OPERATIONS, ProtocolProviderInfoResponse, ProtocolSearchRequest, ProtocolSearchResponse } from "../shared/protocol"; import { LimitsTracker } from "../services/limits-tracker"; import { ModelFactory } from "../services/model-factory"; import { extractErrorMsg } from "../shared/utils"; export class ProviderController { private readonly registry: CallbackRegistry = CallbackRegistryFactory(); private readonly providersModels: { [key in string]: ProviderModel } = {}; private readonly activeQueries: { [key in string]: ActiveQuery } = {}; constructor( private readonly logger: Glue42Core.Logger.API, private readonly glueController: GlueController, private readonly sequelizer: AsyncSequelizer, private readonly limitsTracker: LimitsTracker, private readonly modelsFactory: ModelFactory ) {} public async processRegisterProvider(data: { config: Glue42Search.ProviderRegistrationConfig, commandId: string }): Promise<Glue42Search.Provider> { const { config, commandId } = data; this.logger.info(`[${commandId}] enqueueing the provider registration process with config: ${JSON.stringify(config)}`); const result = await this.sequelizer.enqueue<Glue42Search.Provider>(async () => { const allProvidersInfo = await this.glueController.getAllProvidersInfo(); const allProvidersData = allProvidersInfo.flatMap((provInfo) => provInfo.info.providers); if (allProvidersData.some((providerData) => providerData && providerData.name === config.name)) { throw new Error(`Cannot register a new provider with name: ${config.name}, because there already is a provider with this name`); } await this.glueController.registerMainProviderMethod(this.handleSearchQueryRequest.bind(this)); const modelData: Glue42Search.ProviderData = { id: nanoid(10), name: config.name, interopId: this.glueController.myInteropId, appName: this.glueController.myAppName, types: config.types }; const model = this.modelsFactory.buildProviderModel(modelData, this); this.providersModels[modelData.id] = model; return model.exposeFacade(); }); this.logger.info(`[${commandId}] the provider with name: ${config.name} has been registered.`); return result; } public processProviderOnQuery(data: { callback: (query: Glue42Search.ProviderQuery) => void, id: string, commandId: string }): Glue42Search.UnsubscribeFunction { return this.registry.add(`on-search-query-${data.id}`, data.callback); } public processProviderOnQueryCancel(data: { callback: (query: { id: string; }) => void, id: string, commandId: string }): Glue42Search.UnsubscribeFunction { return this.registry.add(`on-cancel-query-${data.id}`, data.callback); } public async processProviderUnregister(data: { id: string, commandId: string }): Promise<void> { this.logger.info(`[${data.commandId}] enqueueing the provider un-registration with id: ${data.id}`); await this.sequelizer.enqueue<void>(async () => { this.cleanUpProvider(data.id, data.commandId); if (Object.keys(this.providersModels).length) { return; } await this.glueController.clearMainProviderMethod(); }); this.logger.info(`[${data.commandId}] the provider un-registration with id: ${data.id} completed`); } public async processProviderQueryDone(command: ProviderQueryDoneCommand): Promise<void> { const { commandId, identification } = command; this.activeQueries[identification.queryId]?.publisher.syncSuspendProvider(identification.providerId, commandId); await this.sequelizer.enqueue<void>(async () => { this.logger.trace(`[${commandId}] Processing a query done command with identification: ${JSON.stringify(identification)}`); const activeQuery = this.activeQueries[identification.queryId]; if (!activeQuery) { this.logger.warn(`[${commandId}] Cannot mark provider: ${identification.providerId} done with query ${identification.queryId}, because there is no active query with this id`); return; } await this.cleanUpProviderQuery(identification.queryId, identification.providerId, commandId); if (activeQuery.providersAtWork.length) { this.logger.trace(`[${commandId}] Query done command completed, but there are more providers still at work.`); return; } this.cleanUpQuery(identification.queryId, commandId); this.logger.trace(`[${commandId}] Query is completed, signalling.`); }); } public processProviderQueryError(command: ProviderQueryErrorCommand): Promise<void> { const { commandId, identification, error } = command; this.logger.warn(`[${commandId}] Processing an error sent by provider: ${identification.providerId} for query id: ${identification.queryId} -> ${error}`); this.activeQueries[identification.queryId]?.publisher.markProviderError(command); return this.processProviderQueryDone(command); } public processProviderQueryResult(command: ProviderQueryResultCommand): void { const { commandId, identification } = command; const activeQuery = this.activeQueries[identification.queryId]; if (!activeQuery) { const errorMessage = `Will not send this result to the client, because there is no active query with id ${identification.queryId}. Most likely this query was cancelled.`; this.logger.warn(`[${command}] ${errorMessage}`); throw new Error(errorMessage); } if (activeQuery.publisher.checkProviderSuspended(identification.providerId)) { const errorMessage = `Will not send this result to the client, because there is no info about this provider in the active query with id ${identification.queryId}. Most likely this query was marked as done by this provider already.`; this.logger.warn(`[${command}] ${errorMessage}`); throw new Error(errorMessage); } const requestedTypes = activeQuery.requestedTypes; if (requestedTypes && requestedTypes.every((searchType) => searchType.name !== command.result.type.name)) { const errorMessage = `Will not send this result to the client, because this result has a defined type: ${command.result.type.name} which is not in the explicitly requested list of types by the client.`; this.logger.warn(`[${command}] ${errorMessage}`); throw new Error(errorMessage); } const testResult = this.limitsTracker.testResultLimit(command); if (testResult?.maxLimitHit) { const errorMessage = `Will not process this result from provider ${command.identification.providerId}, because this provider has reached the max results limit set by the client. This provider cannot send more result, marking it as done.`; this.logger.info(errorMessage); setTimeout(() => this.processProviderQueryDone(command), 0); throw new Error(errorMessage); } if (testResult?.maxLimitPerTypeHit) { const errorMessage = `Will not process this result from provider ${command.identification.providerId}, because this provider has reached the max results limit per type as set by the client.`; this.logger.info(errorMessage); throw new Error(errorMessage); } this.logger.trace(`[${commandId}] An active query for query ${identification.queryId} was found and the provider is within limits, queueing the result`); this.limitsTracker.update(command); activeQuery.publisher.queueResult(command); this.logger.trace(`[${commandId}] The query result was queued successfully.`); } private async handleSearchQueryRequest(args: any, caller: Glue42Core.Interop.Instance): Promise<any> { const { operation } = args; const validatedOperation = operationDecoder.runWithException(operation); const commandId = nanoid(10); switch (validatedOperation) { case CLIENT_TO_PROVIDER_PROTOCOL_OPERATIONS.info: return this.handleInfoOperation({ commandId }); case CLIENT_TO_PROVIDER_PROTOCOL_OPERATIONS.search: return this.handleSearchOperation({ args, commandId }, caller); case CLIENT_TO_PROVIDER_PROTOCOL_OPERATIONS.cancel: return this.handleCancelOperation({ args, commandId }); default: throw new Error(`Unrecognized operation: ${operation}`); } } private async handleInfoOperation(request: { commandId: string }): Promise<ProtocolProviderInfoResponse> { this.logger.info(`[${request.commandId}] handling an info operation`); const allSupportedTypes: Glue42Search.SearchType[] = Object.values(this.providersModels).flatMap((providerModel) => providerModel.myProviderData.types || []); const uniqueSupportedTypes = [...new Set(allSupportedTypes)]; const hasWildCardTypeProvider = Object.values(this.providersModels).some((providerModel) => !providerModel.myProviderData.types); if (hasWildCardTypeProvider) { uniqueSupportedTypes.push({ name: "*" }); } const providers: Glue42Search.ProviderData[] = Object.values(this.providersModels).map((providerModel) => providerModel.myProviderData); const response: ProtocolProviderInfoResponse = { supportedTypes: uniqueSupportedTypes.map((supportedType) => supportedType.name), providers: providers, apiVersion: "1" }; this.logger.info(`[${request.commandId}] responding to an info operation with: ${JSON.stringify(response)}`); return response; } private async handleSearchOperation(request: { args: ProtocolSearchRequest | LegacySearchRequest, commandId: string }, caller: Glue42Core.Interop.Instance): Promise<ProtocolSearchResponse> { const commandId = request.commandId; const queryId = this.generateQueryId(); this.logger.info(`[${commandId}] Processing search operation with queryId: ${queryId} request details: ${JSON.stringify(request.args)}`); const isLegacyRequest = this.checkRequestLegacy(request.args); const validatedRequest = this.prepareRequest(request.args, isLegacyRequest, commandId); this.logger.info(`[${commandId}] Search operation with queryId: ${queryId} is validated. Creating an active query and enqueueing calling the providers.`); this.activeQueries[queryId] = { queryId, callerInstanceId: caller.instance as string, providersAtWork: [], requestedTypes: validatedRequest.types, publisher: this.modelsFactory.buildPublisher(caller.instance as string, queryId, isLegacyRequest), staleTimer: this.setClearStaleQueryTimer(queryId) }; if (validatedRequest.providerLimits) { this.limitsTracker.enableTracking(validatedRequest.providerLimits, queryId); } // needed to allow the search clients to process the query id setTimeout(() => { this.sequelizer.enqueue<void>(async () => { try { this.logger.info(`[${commandId}] Calling the providers.`); this.callProviders(validatedRequest, queryId, commandId); } catch (error) { this.logger.error(`[${commandId}] Error calling the providers: ${extractErrorMsg(error)}`); } }); }, 0); this.logger.info(`[${commandId}] Search operation with queryID: ${queryId} processed successfully.`); return { id: queryId }; } private async handleCancelOperation(request: { args: SearchCancelRequest, commandId: string }): Promise<void> { await this.sequelizer.enqueue<void>(async () => { const validation = searchCancelRequestDecoder.run(request.args); if (!validation.ok) { const errorMsg = `Cannot process a cancel request, because of validation error: ${JSON.stringify(validation.error)}`; this.logger.warn(`[${request.commandId}] ${errorMsg}`); throw new Error(errorMsg); } const validatedRequest = validation.result; const activeQuery = this.activeQueries[validatedRequest.id]; if (!activeQuery) { return; } clearTimeout(activeQuery.staleTimer); activeQuery.publisher.cancel(request.commandId); delete this.activeQueries[validatedRequest.id]; activeQuery.providersAtWork.forEach((provider) => this.registry.execute(`on-cancel-query-${provider.myProviderData.id}`, { id: validatedRequest.id })); }); } private generateQueryId(): string { const queryId = nanoid(10); if (this.activeQueries[queryId]) { return this.generateQueryId(); } return queryId; } private translateLegacySearchRequest(legacyRequest: LegacySearchRequest): Glue42Search.QueryConfig { return { search: legacyRequest.search, types: legacyRequest.types?.map<Glue42Search.SearchType>((searchType) => ({ name: searchType })), providerLimits: { maxResults: legacyRequest.limit, maxResultsPerType: legacyRequest.categoryLimit } }; } private checkRequestLegacy(searchRequest: ProtocolSearchRequest | LegacySearchRequest): boolean { return typeof (searchRequest as ProtocolSearchRequest).apiVersion === "undefined"; } private callProviders(validatedRequest: Glue42Search.QueryConfig, queryId: string, commandId: string): void { let providers: ProviderModel[] = validatedRequest.providers ? this.getFilteredProviderModels(validatedRequest.providers) : Object.values(this.providersModels); this.logger.trace(`[${commandId}] initial providers filtration yielded: ${JSON.stringify(providers.map((p) => p.myProviderData.name).join(", "))}`); providers = validatedRequest.types ? this.getFilteredProvidersBySearchTypes(providers, validatedRequest.types) : providers; this.logger.trace(`[${commandId}] search type providers filtration yielded: ${JSON.stringify(providers.map((p) => p.myProviderData.name).join(", "))}`); this.activeQueries[queryId].publisher.configureProviders(providers); this.activeQueries[queryId].providersAtWork.push(...providers); providers.forEach((provider) => this.callProvider(provider, validatedRequest, queryId, commandId)); } private callProvider(provider: ProviderModel, validatedRequest: Glue42Search.QueryConfig, queryId: string, commandId: string): void { const queryModel = this.modelsFactory.buildProviderQueryModel(validatedRequest, { queryId, providerId: provider.myProviderData.id }, this); const queryFacade = queryModel.exposeFacade(); this.logger.info(`[${commandId}] The query facade for provider: ${provider.myProviderData.id} with name ${provider.myProviderData.name} is ready, raising the event for query ID: ${queryId}.`); this.registry.execute(`on-search-query-${provider.myProviderData.id}`, queryFacade); } private getFilteredProviderModels(providers: Glue42Search.ProviderData[]): ProviderModel[] { const filtered = providers.reduce<ProviderModel[]>((providers, provider) => { if (this.providersModels[provider.id]) { providers.push(this.providersModels[provider.id]); } return providers; }, []); return filtered; } private getFilteredProvidersBySearchTypes(providers: ProviderModel[], searchTypes: Glue42Search.SearchType[]): ProviderModel[] { const filtered = providers.filter((provider) => { // a provider with no registered types should receive all queries if (!provider.myProviderData.types || !provider.myProviderData.types.length) { return true; } return provider.myProviderData.types?.some((providerSearchType) => searchTypes.some((searchType) => searchType.name === providerSearchType.name)); }); return filtered; } private setClearStaleQueryTimer(queryId: string): NodeJS.Timeout { return setTimeout(() => { const commandId = nanoid(10); this.logger.info(`[${commandId}] Stale query timer is activated for queryId: ${queryId}`); const activeQuery = this.activeQueries[queryId]; if (!activeQuery) { this.logger.info(`[${commandId}] No active query was found, this was a false activation.`); return; } this.logger.info(`[${commandId}] force-marking the query as done`); this.cleanUpQuery(queryId, commandId); this.logger.info(`[${commandId}] the stale query was cleared.`); }, STALE_QUERY_TIMEOUT_MS); } private prepareRequest(searchRequest: LegacySearchRequest | Glue42Search.QueryConfig, isLegacyRequest: boolean, commandId: string): Glue42Search.QueryConfig { const parsedRequest = isLegacyRequest ? this.translateLegacySearchRequest(searchRequest as LegacySearchRequest) : searchRequest; const validation = queryConfigDecoder.run(parsedRequest); if (!validation.ok) { const errorMsg = `Cannot process a search request, because of validation error: ${JSON.stringify(validation.error)}`; this.logger.warn(`[${commandId}] ${errorMsg}`); throw new Error(errorMsg); } const validatedRequest = validation.result; return validatedRequest; } private cleanUpQuery(queryId: string, commandId: string): void { const activeQuery = this.activeQueries[queryId]; clearTimeout(activeQuery.staleTimer); activeQuery.publisher.cleanPublisher(commandId); delete this.activeQueries[queryId]; this.limitsTracker.cleanTracking(queryId); } private cleanUpProvider(providerId: string, commandId: string): void { this.registry.clearKey(`on-search-query-${providerId}`); this.registry.clearKey(`on-cancel-query-${providerId}`); delete this.providersModels[providerId]; const queriesWithProvider = Object.values(this.activeQueries).filter((query) => !query.publisher.checkProviderSuspended(providerId)); queriesWithProvider.forEach((query) => { this.processProviderQueryDone({ identification: { queryId: query.queryId, providerId }, commandId }); }); } private async cleanUpProviderQuery(queryId: string, providerId: string, commandId: string): Promise<void> { const activeQuery = this.activeQueries[queryId]; if (!activeQuery) { this.logger.warn(`[${commandId}] Cannot clean up a provider query ${queryId} for provider ${providerId} because there is no such active query`); return; } activeQuery.providersAtWork = activeQuery.providersAtWork.filter((provider) => provider.myProviderData.id !== providerId); await activeQuery.publisher.markProviderDone(providerId, commandId); } }