@glue42/search-api
Version:
Glue42 Search API
476 lines (328 loc) • 20.7 kB
text/typescript
/* 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);
}
}