Skip to content
This repository was archived by the owner on Mar 11, 2025. It is now read-only.

Commit ed13d94

Browse files
authored
[RND-525] - Adds support for ElasticSearch (#257)
* Clone from opensearch to create elasticsearch backend. * Code changes. Trying to fix tests. * Adds elastic/transport * Trying to fix linting problems. Working on fixing tests. * Fixes problem with number of documents. * All integration tests working fine. * Removes poc. I do not need it. * First unit tests working fine. * More unit tests now working fine. * All tests, integration and unit, working fine. * Renaming files. Code cleanup. * Some code clean up. Changes on readme file. * Adds elastic search to integration tests script. * Changes on docker-compose file. * Some changes on documentation files. * Adds SHA to docker-compose file. * Adds env values to env.example and docs folder. * Fixes name for Elasticsearch timeout var.
1 parent 5e5e469 commit ed13d94

28 files changed

+1679
-24
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@edfi:registry=https://pkgs.dev.azure.com/ed-fi-alliance/Ed-Fi-Alliance-OSS/_packaging/EdFi/npm/registry/
2+
always-auth=false
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
version: "3.8"
2+
3+
volumes:
4+
esdata01:
5+
driver: local
6+
7+
networks:
8+
default:
9+
name: elastic
10+
external: false
11+
12+
services:
13+
elasticsearch-node1:
14+
image: docker.elastic.co/elasticsearch/elasticsearch:8.8.0@sha256:9aaa38551b4d9e655c54d9dc6a1dad24ee568c41952dc8cf1d4808513cfb5f65
15+
container_name: elasticsearch-node1
16+
labels:
17+
co.elastic.logs/module: elasticsearch
18+
volumes:
19+
- esdata01:/usr/share/elasticsearch/data
20+
ports:
21+
- 9200:9200
22+
environment:
23+
- node.name=es01
24+
- cluster.name=elasticsearch-node1
25+
- discovery.type=single-node
26+
- xpack.security.enabled=false
27+
mem_limit: 2g
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Elasticsearch Backend for Meadowlark
2+
3+
:exclamation: This solution should only be used on localhost with proper firewalls around
4+
external network access to the workstation. Not appropriate for production use.
5+
6+
This Docker Compose file provisions a single node of the ElasticSearch search
7+
engine.
8+
9+
## Test dependency on older "docker-compose" versus newer "docker compose"
10+
11+
The integration tests use the testcontainers library to spin up an Elasticsearch instance. As of
12+
Feb 2023, it uses the legacy "docker-compose" command from Compose V1. If tests fail
13+
with "Error: spawn docker-compose ENOENT", you will need to either [install Compose V1
14+
standalone](https://docs.docker.com/compose/install/other/) or `alias docker-compose='docker compose'`.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Reminder - this index.ts is for on-the-fly transpile when there is no dist directory
2+
3+
// All exports from the "real" index.ts
4+
export * from './src/index';
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"name": "@edfi/meadowlark-elasticsearch-backend",
3+
"main": "dist/index.js",
4+
"version": "0.3.0-pre-36",
5+
"description": "Meadowlark backend plugin for elasticsearch",
6+
"license": "Apache-2.0",
7+
"publishConfig": {
8+
"registry": "https://pkgs.dev.azure.com/ed-fi-alliance/Ed-Fi-Alliance-OSS/_packaging/EdFi/npm/registry/"
9+
},
10+
"files": [
11+
"/dist",
12+
"/LICENSE.md",
13+
"/package.json"
14+
],
15+
"scripts": {
16+
"build": "npm run build:clean && npm run build:copy-non-ts && npm run build:dist",
17+
"build:clean": "rimraf dist",
18+
"build:dist": "tsc",
19+
"build:copy-non-ts": "copyfiles -u 1 -e \"**/*.ts\" \"src/**/*\" dist --verbose"
20+
},
21+
"dependencies": {
22+
"@edfi/meadowlark-core": "^v0.3.0-pre-35",
23+
"@edfi/meadowlark-utilities": "^v0.3.0-pre-35",
24+
"@edfi/metaed-core": "^4.0.1-dev.13",
25+
"@elastic/elasticsearch": "^8.8.0",
26+
"@elastic/transport": "^8.3.2"
27+
},
28+
"devDependencies": {
29+
"@elastic/elasticsearch-mock": "^2.0.0",
30+
"copyfiles": "^2.4.1",
31+
"dotenv": "^16.0.3",
32+
"rimraf": "^3.0.2",
33+
"testcontainers": "^9.8.0"
34+
}
35+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Licensed to the Ed-Fi Alliance under one or more agreements.
3+
// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
4+
// See the LICENSE and NOTICES files in the project root for more information.
5+
6+
import {
7+
DeleteRequest,
8+
DeleteResult,
9+
QueryRequest,
10+
QueryResult,
11+
UpdateRequest,
12+
UpdateResult,
13+
UpsertRequest,
14+
UpsertResult,
15+
} from '@edfi/meadowlark-core';
16+
import * as QueryElasticsearch from './repository/QueryElasticsearch';
17+
import * as UpdateElasticsearch from './repository/UpdateElasticsearch';
18+
import { getSharedClient, closeSharedConnection } from './repository/Db';
19+
20+
export async function queryDocuments(request: QueryRequest): Promise<QueryResult> {
21+
return QueryElasticsearch.queryDocuments(request, await getSharedClient());
22+
}
23+
24+
export async function afterDeleteDocumentById(request: DeleteRequest, result: DeleteResult): Promise<void> {
25+
return UpdateElasticsearch.afterDeleteDocumentById(request, result, await getSharedClient());
26+
}
27+
28+
export async function afterUpsertDocument(request: UpsertRequest, result: UpsertResult): Promise<void> {
29+
return UpdateElasticsearch.afterUpsertDocument(request, result, await getSharedClient());
30+
}
31+
32+
export async function afterUpdateDocumentById(request: UpdateRequest, result: UpdateResult): Promise<void> {
33+
return UpdateElasticsearch.afterUpdateDocumentById(request, result, await getSharedClient());
34+
}
35+
36+
export async function closeConnection(): Promise<void> {
37+
return closeSharedConnection();
38+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Licensed to the Ed-Fi Alliance under one or more agreements.
3+
// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
4+
// See the LICENSE and NOTICES files in the project root for more information.
5+
6+
import { QueryHandlerPlugin, Subscribe } from '@edfi/meadowlark-core';
7+
import {
8+
afterDeleteDocumentById,
9+
afterUpdateDocumentById,
10+
afterUpsertDocument,
11+
queryDocuments,
12+
closeConnection,
13+
} from './BackendFacade';
14+
15+
export function initializeQueryHandler(): QueryHandlerPlugin {
16+
return {
17+
queryDocuments,
18+
closeConnection,
19+
};
20+
}
21+
22+
export function initializeListener(subscribe: typeof Subscribe): void {
23+
subscribe.afterDeleteDocumentById(afterDeleteDocumentById);
24+
subscribe.afterUpsertDocument(afterUpsertDocument);
25+
subscribe.afterUpdateDocumentById(afterUpdateDocumentById);
26+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Licensed to the Ed-Fi Alliance under one or more agreements.
3+
// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
4+
// See the LICENSE and NOTICES files in the project root for more information.
5+
6+
import { Client, ClientOptions } from '@elastic/elasticsearch';
7+
import { Config, Logger } from '@edfi/meadowlark-utilities';
8+
import { BasicAuth } from '@elastic/transport/lib/types';
9+
10+
let singletonClient: Client | null = null;
11+
12+
const moduleName = 'elasticsearch.repository.Db';
13+
14+
export async function getElasticSearchClient(node?: string, auth?: BasicAuth, requestTimeout?: number): Promise<Client> {
15+
Logger.debug(`${moduleName}.getElasticSearchClient creating local client`, null);
16+
const clientOpts: ClientOptions = {
17+
node,
18+
auth,
19+
requestTimeout,
20+
/* Might need to setup SSL here in the future */
21+
};
22+
try {
23+
return new Client(clientOpts);
24+
} catch (e) {
25+
const masked = { ...clientOpts } as any;
26+
delete masked.auth?.password;
27+
28+
Logger.error(`${moduleName}.getElasticSearchClient error connecting with options ${JSON.stringify(masked)}`, null, e);
29+
throw e;
30+
}
31+
}
32+
33+
/**
34+
* Create and return an ElasticSearch connection object
35+
*/
36+
export async function getNewClient(): Promise<Client> {
37+
Logger.debug(`${moduleName}.getNewClient creating local client`, null);
38+
const node = Config.get<string>('ELASTICSEARCH_ENDPOINT');
39+
const auth = {
40+
username: Config.get<string>('ELASTICSEARCH_USERNAME'),
41+
password: Config.get<string>('ELASTICSEARCH_PASSWORD'),
42+
};
43+
const requestTimeout = Config.get<number>('ELASTICSEARCH_REQUEST_TIMEOUT');
44+
return getElasticSearchClient(node, auth, requestTimeout);
45+
}
46+
47+
/**
48+
* Return the shared client
49+
*/
50+
export async function getSharedClient(): Promise<Client> {
51+
if (singletonClient == null) {
52+
singletonClient = await getNewClient();
53+
}
54+
55+
return singletonClient;
56+
}
57+
58+
export async function closeSharedConnection(): Promise<void> {
59+
if (singletonClient != null) {
60+
await singletonClient.close();
61+
}
62+
singletonClient = null;
63+
Logger.info(`Elasticsearch connection: closed`, null);
64+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Licensed to the Ed-Fi Alliance under one or more agreements.
3+
// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
4+
// See the LICENSE and NOTICES files in the project root for more information.
5+
6+
import { ElasticsearchClientError, ResponseError } from '@elastic/transport/lib/errors';
7+
import { Logger } from '@edfi/meadowlark-utilities';
8+
import { QueryResult } from '@edfi/meadowlark-core';
9+
10+
export async function handleElasticSearchError(
11+
err: ElasticsearchClientError | Error,
12+
moduleName: string = '',
13+
traceId: string = '',
14+
elasticSearchRequestId: string = '',
15+
): Promise<QueryResult> {
16+
try {
17+
const elasticSearchClientError = err as ElasticsearchClientError;
18+
const documentProcessError = `ElasticSearch Error${
19+
elasticSearchRequestId ? ` processing the object '${elasticSearchRequestId}'` : ''
20+
}:`;
21+
if (elasticSearchClientError?.name !== undefined) {
22+
switch (elasticSearchClientError.name) {
23+
case 'ConfigurationError':
24+
case 'ConnectionError':
25+
case 'DeserializationError':
26+
case 'NoLivingConnectionsError':
27+
case 'NotCompatibleError':
28+
case 'ElasticSearchClientError':
29+
case 'RequestAbortedError':
30+
case 'SerializationError':
31+
case 'TimeoutError': {
32+
if (elasticSearchClientError?.message !== undefined) {
33+
Logger.error(
34+
`${moduleName} ${documentProcessError}`,
35+
traceId,
36+
`(${elasticSearchClientError.name}) - ${elasticSearchClientError.message}`,
37+
);
38+
return {
39+
response: 'QUERY_FAILURE_INVALID_QUERY',
40+
documents: [],
41+
failureMessage: elasticSearchClientError.message,
42+
};
43+
}
44+
break;
45+
}
46+
case 'ResponseError': {
47+
const responseException = err as ResponseError;
48+
if (responseException?.message !== undefined) {
49+
if (responseException.message !== 'Response Error') {
50+
let startPosition = responseException?.message?.indexOf('Reason:');
51+
const position = responseException?.message?.indexOf(' Preview');
52+
startPosition = startPosition > -1 ? startPosition : 0;
53+
if (position > -1) {
54+
responseException.message = responseException?.message?.substring(startPosition, position);
55+
} else if (startPosition !== 0) {
56+
responseException.message = responseException?.message?.substring(startPosition);
57+
}
58+
Logger.error(
59+
`${moduleName} ${documentProcessError}`,
60+
traceId,
61+
`(${elasticSearchClientError.name}) - ${elasticSearchClientError.message}`,
62+
);
63+
return { response: 'QUERY_FAILURE_INVALID_QUERY', documents: [], failureMessage: responseException.message };
64+
}
65+
if (responseException?.body !== undefined) {
66+
let responseBody = JSON.parse(responseException?.body?.toString());
67+
if (responseBody?.error?.type !== undefined) {
68+
switch (responseBody?.error?.type) {
69+
case 'IndexNotFoundException':
70+
// No object has been uploaded for the requested type
71+
Logger.warn(`${moduleName} ${documentProcessError} index not found`, traceId);
72+
return {
73+
response: 'QUERY_FAILURE_INVALID_QUERY',
74+
documents: [],
75+
failureMessage: 'IndexNotFoundException',
76+
};
77+
case 'SemanticAnalysisException':
78+
// The query term is invalid
79+
Logger.error(
80+
`${moduleName} ${documentProcessError} invalid query terms`,
81+
traceId,
82+
`(${elasticSearchClientError.name}) - ${responseBody?.error?.reason}`,
83+
);
84+
return {
85+
response: 'QUERY_FAILURE_INVALID_QUERY',
86+
documents: [],
87+
failureMessage: responseBody?.error?.details,
88+
};
89+
default:
90+
Logger.error(`${moduleName} ${documentProcessError}`, traceId, responseBody ?? err);
91+
return { response: 'UNKNOWN_FAILURE', documents: [], failureMessage: responseBody };
92+
}
93+
} else {
94+
responseBody = JSON.parse(JSON.stringify(responseException.body));
95+
Logger.error(`${moduleName} ${documentProcessError}`, traceId, responseBody ?? err);
96+
return { response: 'UNKNOWN_FAILURE', documents: [], failureMessage: responseBody };
97+
}
98+
}
99+
}
100+
break;
101+
}
102+
default: {
103+
break;
104+
}
105+
}
106+
}
107+
Logger.error(`${moduleName} UNKNOWN_FAILURE`, traceId, err);
108+
return { response: 'UNKNOWN_FAILURE', documents: [], failureMessage: err.message };
109+
} catch {
110+
Logger.error(`${moduleName} UNKNOWN_FAILURE`, traceId, err);
111+
return { response: 'UNKNOWN_FAILURE', documents: [], failureMessage: err.message };
112+
}
113+
}

0 commit comments

Comments
 (0)