UNPKG

@travetto/model-elasticsearch

Version:

Elasticsearch backing for the travetto model module, with real-time modeling support for Elasticsearch mappings.

561 lines (477 loc) 18.7 kB
import { Client, errors, estypes } from '@elastic/elasticsearch'; import { ModelCrudSupport, BulkOp, BulkResponse, ModelBulkSupport, ModelExpirySupport, ModelIndexedSupport, ModelType, ModelStorageSupport, NotFoundError, ModelRegistry, OptionalId, ModelCrudUtil, ModelIndexedUtil, ModelStorageUtil, ModelExpiryUtil, ModelBulkUtil } from '@travetto/model'; import { ShutdownManager, type DeepPartial, type Class, castTo, asFull, TypedObject, asConstructable } from '@travetto/runtime'; import { SchemaChange, BindUtil } from '@travetto/schema'; import { Injectable } from '@travetto/di'; import { ModelQuery, ModelQueryCrudSupport, ModelQueryFacetSupport, ModelQuerySupport, PageableModelQuery, Query, ValidStringFields, QueryVerifier, ModelQuerySuggestSupport, ModelQueryUtil, ModelQuerySuggestUtil, ModelQueryCrudUtil, ModelQueryFacet, } from '@travetto/model-query'; import { ElasticsearchModelConfig } from './config.ts'; import { EsBulkError } from './internal/types.ts'; import { ElasticsearchQueryUtil } from './internal/query.ts'; import { ElasticsearchSchemaUtil } from './internal/schema.ts'; import { IndexManager } from './index-manager.ts'; /** * Elasticsearch model source. */ @Injectable() export class ElasticsearchModelService implements ModelCrudSupport, ModelIndexedSupport, ModelStorageSupport, ModelBulkSupport, ModelExpirySupport, ModelQuerySupport, ModelQueryCrudSupport, ModelQuerySuggestSupport, ModelQueryFacetSupport { idSource = ModelCrudUtil.uuidSource(); client: Client; manager: IndexManager; config: ElasticsearchModelConfig; constructor(config: ElasticsearchModelConfig) { this.config = config; } /** * Directly run the search */ async execSearch<T extends ModelType>(cls: Class<T>, search: estypes.SearchRequest): Promise<estypes.SearchResponse<T>> { let query = search.query; if (query && Object.keys(query).length === 0) { query = undefined; } try { const result = await this.client.search<T>({ ...this.manager.getIdentity(cls), ...search, query }); return result; } catch (err) { if (err instanceof errors.ResponseError && err.meta.body && typeof err.meta.body === 'object' && 'error' in err.meta.body) { console.error(err.meta.body.error); } throw err; } } preUpdate(o: { id: string }): string; preUpdate(o: {}): undefined; preUpdate(o: { id?: string }): string | undefined { if ('id' in o && typeof o.id === 'string') { const id = o.id; if (!this.config.storeId) { delete o.id; } return id; } return; } postUpdate<T extends ModelType>(o: T, id?: string): T { if (!this.config.storeId) { o.id = id!; } return o; } /** * Convert _id to id */ async postLoad<T extends ModelType>(cls: Class<T>, inp: estypes.SearchHit<T> | estypes.GetGetResult<T>): Promise<T> { let item = { ...(inp._id ? { id: inp._id } : {}), ...inp._source!, }; item = await ModelCrudUtil.load(cls, item); const { expiresAt } = ModelRegistry.get(cls); if (expiresAt) { const expiry = ModelExpiryUtil.getExpiryState(cls, item); if (!expiry.expired) { return item; } throw new NotFoundError(cls, item.id); } else { return item; } } async postConstruct(this: ElasticsearchModelService): Promise<void> { this.client = new Client({ nodes: this.config.hosts, ...(this.config.options || {}), }); await this.client.cluster.health({}); this.manager = new IndexManager(this.config, this.client); await ModelStorageUtil.registerModelChangeListener(this.manager); ShutdownManager.onGracefulShutdown(() => this.client.close(), this); ModelExpiryUtil.registerCull(this); } createStorage(): Promise<void> { return this.manager.createStorage(); } deleteStorage(): Promise<void> { return this.manager.deleteStorage(); } createModel(cls: Class): Promise<void> { return this.manager.createModel(cls); } exportModel(cls: Class): Promise<string> { return this.manager.exportModel(cls); } deleteModel(cls: Class): Promise<void> { return this.manager.deleteModel(cls); } changeSchema(cls: Class, change: SchemaChange): Promise<void> { return this.manager.changeSchema(cls, change); } truncateModel(cls: Class): Promise<void> { return this.deleteByQuery(cls, {}).then(() => { }); } async get<T extends ModelType>(cls: Class<T>, id: string): Promise<T> { try { const result = await this.client.get<T>({ ...this.manager.getIdentity(cls), id }); return this.postLoad(cls, result); } catch { throw new NotFoundError(cls, id); } } async delete<T extends ModelType>(cls: Class<T>, id: string): Promise<void> { ModelCrudUtil.ensureNotSubType(cls); try { const result = await this.client.delete({ ...this.manager.getIdentity(cls), id, refresh: true, }); if (result.result === 'not_found') { throw new NotFoundError(cls, id); } } catch (err) { if (err && err instanceof errors.ResponseError && err.body && err.body.result === 'not_found') { throw new NotFoundError(cls, id); } throw err; } } async create<T extends ModelType>(cls: Class<T>, o: OptionalId<T>): Promise<T> { try { const clean = await ModelCrudUtil.preStore(cls, o, this); const id = this.preUpdate(clean); await this.client.index({ ...this.manager.getIdentity(cls), id, refresh: true, body: clean }); return this.postUpdate(clean, id); } catch (err) { console.error(err); throw err; } } async update<T extends ModelType>(cls: Class<T>, o: T): Promise<T> { ModelCrudUtil.ensureNotSubType(cls); o = await ModelCrudUtil.preStore(cls, o, this); const id = this.preUpdate(o); if (ModelRegistry.get(cls).expiresAt) { await this.get(cls, id); } await this.client.index({ ...this.manager.getIdentity(cls), id, op_type: 'index', refresh: true, body: o }); return this.postUpdate(o, id); } async upsert<T extends ModelType>(cls: Class<T>, o: OptionalId<T>): Promise<T> { ModelCrudUtil.ensureNotSubType(cls); const item = await ModelCrudUtil.preStore(cls, o, this); const id = this.preUpdate(item); await this.client.update({ ...this.manager.getIdentity(cls), id, refresh: true, body: { doc: item, doc_as_upsert: true } }); return this.postUpdate(item, id); } async updatePartial<T extends ModelType>(cls: Class<T>, data: Partial<T> & { id: string }, view?: string): Promise<T> { ModelCrudUtil.ensureNotSubType(cls); const id = data.id; const item = castTo<typeof data>(await ModelCrudUtil.prePartialUpdate(cls, data, view)); const script = ElasticsearchSchemaUtil.generateUpdateScript(item); try { await this.client.update({ ...this.manager.getIdentity(cls), id, refresh: true, body: { script } }); } catch (err) { if (err instanceof Error && /document_missing_exception/.test(err.message)) { throw new NotFoundError(cls, id); } throw err; } return this.get(cls, id); } async * list<T extends ModelType>(cls: Class<T>): AsyncIterable<T> { let search: estypes.SearchResponse<T> = await this.execSearch<T>(cls, { scroll: '2m', size: 100, query: ElasticsearchQueryUtil.getSearchQuery(cls, {}) }); while (search.hits.hits.length > 0) { for (const el of search.hits.hits) { try { yield this.postLoad(cls, el); } catch (err) { if (!(err instanceof NotFoundError)) { throw err; } } search = await this.client.scroll({ scroll_id: search._scroll_id, scroll: '2m' }); } } } async processBulk<T extends ModelType>(cls: Class<T>, operations: BulkOp<T>[]): Promise<BulkResponse<EsBulkError>> { await ModelBulkUtil.preStore(cls, operations, this); const body = operations.reduce<(T | Partial<Record<'delete' | 'create' | 'index' | 'update', { _index: string, _id?: string }>> | { doc: T })[]>((acc, op) => { const esIdent = this.manager.getIdentity(asConstructable<T>((op.upsert ?? op.delete ?? op.insert ?? op.update ?? { constructor: cls })).constructor); const ident: { _index: string, _type?: unknown } = { _index: esIdent.index }; if (op.delete) { acc.push({ delete: { ...ident, _id: op.delete.id } }); } else if (op.insert) { const id = this.preUpdate(op.insert); acc.push({ create: { ...ident, _id: id } }, castTo(op.insert)); } else if (op.upsert) { const id = this.preUpdate(op.upsert); acc.push({ index: { ...ident, _id: id } }, castTo(op.upsert)); } else if (op.update) { const id = this.preUpdate(op.update); acc.push({ update: { ...ident, _id: id } }, { doc: op.update }); } return acc; }, []); const result = await this.client.bulk({ operations: body, refresh: true }); const out: BulkResponse<EsBulkError> = { counts: { delete: 0, insert: 0, upsert: 0, update: 0, error: 0 }, insertedIds: new Map(), errors: [] }; type Count = keyof typeof out['counts']; for (let i = 0; i < result.items.length; i++) { const item = result.items[i]; const [k] = TypedObject.keys(item); const v = item[k]!; if (v.error) { out.errors.push({ reason: v.error!.reason!, type: v.error!.type }); out.counts.error += 1; } else { let sk: Count; switch (k) { case 'create': sk = 'insert'; break; case 'index': sk = operations[i].insert ? 'insert' : 'upsert'; break; case 'delete': case 'update': sk = k; break; default: { throw new Error(`Unknown response key: ${k}`); } } if (v.result === 'created') { out.insertedIds.set(i, v._id!); (operations[i].insert ?? operations[i].upsert)!.id = v._id!; } out.counts[sk] += 1; } } return out; } // Expiry deleteExpired<T extends ModelType>(cls: Class<T>): Promise<number> { return ModelQueryCrudUtil.deleteExpired(this, cls); } // Indexed async getByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: DeepPartial<T>): Promise<T> { const { key } = ModelIndexedUtil.computeIndexKey(cls, idx, body); const result = await this.execSearch<T>(cls, { query: ElasticsearchQueryUtil.getSearchQuery(cls, ElasticsearchQueryUtil.extractWhereTermQuery(cls, ModelIndexedUtil.projectIndex(cls, idx, body)) ) }); if (!result.hits.hits.length) { throw new NotFoundError(`${cls.name}: ${idx}`, key); } return this.postLoad(cls, result.hits.hits[0]); } async deleteByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: DeepPartial<T>): Promise<void> { const { key } = ModelIndexedUtil.computeIndexKey(cls, idx, body); const result = await this.client.deleteByQuery({ index: this.manager.getIdentity(cls).index, query: ElasticsearchQueryUtil.getSearchQuery(cls, ElasticsearchQueryUtil.extractWhereTermQuery(cls, ModelIndexedUtil.projectIndex(cls, idx, body)) ), refresh: true }); if (result.deleted) { return; } throw new NotFoundError(`${cls.name}: ${idx}`, key); } async upsertByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: OptionalId<T>): Promise<T> { return ModelIndexedUtil.naiveUpsert(this, cls, idx, body); } async * listByIndex<T extends ModelType>(cls: Class<T>, idx: string, body?: DeepPartial<T>): AsyncIterable<T> { const cfg = ModelRegistry.getIndex(cls, idx, ['sorted', 'unsorted']); let search = await this.execSearch<T>(cls, { scroll: '2m', size: 100, query: ElasticsearchQueryUtil.getSearchQuery(cls, ElasticsearchQueryUtil.extractWhereTermQuery(cls, ModelIndexedUtil.projectIndex(cls, idx, body, { emptySortValue: { $exists: true } })) ), sort: ElasticsearchQueryUtil.getSort(cfg.fields) }); while (search.hits.hits.length > 0) { for (const el of search.hits.hits) { try { yield this.postLoad(cls, el); } catch (err) { if (!(err instanceof NotFoundError)) { throw err; } } search = await this.client.scroll({ scroll_id: search._scroll_id, scroll: '2m' }); } } } // Query async query<T extends ModelType>(cls: Class<T>, query: PageableModelQuery<T>): Promise<T[]> { await QueryVerifier.verify(cls, query); const req = ElasticsearchQueryUtil.getSearchObject(cls, query, this.config.schemaConfig); const results = await this.execSearch(cls, req); const shouldRemoveIds = query.select && 'id' in query.select && !query.select.id; return Promise.all(results.hits.hits.map(m => this.postLoad(cls, m).then(v => { if (shouldRemoveIds) { delete castTo<OptionalId<T>>(v).id; } return v; }))); } async queryOne<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>, failOnMany: boolean = true): Promise<T> { const result = await this.query<T>(cls, { ...query, limit: failOnMany ? 2 : 1 }); return ModelQueryUtil.verifyGetSingleCounts<T>(cls, failOnMany, result, query.where); } async queryCount<T extends ModelType>(cls: Class<T>, query: Query<T>): Promise<number> { await QueryVerifier.verify(cls, query); const req = ElasticsearchQueryUtil.getSearchObject(cls, { ...query, limit: 0 }, this.config.schemaConfig); const result: number | { value: number } = (await this.execSearch(cls, req)).hits.total || { value: 0 }; return typeof result !== 'number' ? result.value : result; } // Query Crud async updateByQuery<T extends ModelType>(cls: Class<T>, data: T, query: ModelQuery<T>): Promise<T> { ModelCrudUtil.ensureNotSubType(cls); await QueryVerifier.verify(cls, query); const item = await ModelCrudUtil.preStore(cls, data, this); const id = this.preUpdate(item); const where = ModelQueryUtil.getWhereClause(cls, query.where); if (id) { where.id = id; } query.where = where; if (ModelRegistry.get(cls).expiresAt) { await this.get(cls, id); } const search = ElasticsearchQueryUtil.getSearchObject(cls, query, this.config.schemaConfig); const copy = BindUtil.bindSchemaToObject(cls, asFull<T>({}), item); try { const result = await this.client.updateByQuery({ ...this.manager.getIdentity(cls), refresh: true, query: search.query, max_docs: 1, script: ElasticsearchSchemaUtil.generateReplaceScript(castTo(copy)) }); if (result.version_conflicts || result.updated === undefined || result.updated === 0) { throw new NotFoundError(cls, id); } } catch (err) { if (err instanceof errors.ResponseError && 'version_conflicts' in err.body) { throw new NotFoundError(cls, id); } else { throw err; } } return this.postUpdate(item, id); } async deleteByQuery<T extends ModelType>(cls: Class<T>, query: ModelQuery<T> = {}): Promise<number> { await QueryVerifier.verify(cls, query); const { sort: _, ...q } = ElasticsearchQueryUtil.getSearchObject(cls, query, this.config.schemaConfig, false); const result = await this.client.deleteByQuery({ ...this.manager.getIdentity(cls), ...q, refresh: true, }); return result.deleted ?? 0; } async updatePartialByQuery<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>, data: Partial<T>): Promise<number> { await QueryVerifier.verify(cls, query); const item = await ModelCrudUtil.prePartialUpdate(cls, data); const script = ElasticsearchSchemaUtil.generateUpdateScript(item); const search = ElasticsearchQueryUtil.getSearchObject(cls, query, this.config.schemaConfig); const result = await this.client.updateByQuery({ ...this.manager.getIdentity(cls), refresh: true, query: search.query, script, }); return result.updated ?? 0; } // Query Facet async suggest<T extends ModelType>(cls: Class<T>, field: ValidStringFields<T>, prefix?: string, query?: PageableModelQuery<T>): Promise<T[]> { await QueryVerifier.verify(cls, query); const q = ModelQuerySuggestUtil.getSuggestQuery<T>(cls, field, prefix, query); const search = ElasticsearchQueryUtil.getSearchObject(cls, q); const result = await this.execSearch(cls, search); const all = await Promise.all(result.hits.hits.map(x => this.postLoad(cls, x))); return ModelQuerySuggestUtil.combineSuggestResults(cls, field, prefix, all, (x, v) => v, query && query.limit); } async suggestValues<T extends ModelType>(cls: Class<T>, field: ValidStringFields<T>, prefix?: string, query?: PageableModelQuery<T>): Promise<string[]> { await QueryVerifier.verify(cls, query); const q = ModelQuerySuggestUtil.getSuggestQuery<T>(cls, field, prefix, { select: castTo({ [field]: 1 }), ...query }); const search = ElasticsearchQueryUtil.getSearchObject(cls, q); const result = await this.execSearch(cls, search); const all = await Promise.all(result.hits.hits.map(x => castTo<T>(({ [field]: field === 'id' ? x._id : x._source![field] })))); return ModelQuerySuggestUtil.combineSuggestResults(cls, field, prefix, all, x => x, query && query.limit); } // Facet async facet<T extends ModelType>(cls: Class<T>, field: ValidStringFields<T>, query?: ModelQuery<T>): Promise<ModelQueryFacet[]> { await QueryVerifier.verify(cls, query); const q = ElasticsearchQueryUtil.getSearchObject(cls, query ?? {}, this.config.schemaConfig); const search = { body: { query: q.query ?? { ['match_all']: {} }, aggs: { [field]: { terms: { field, size: 100 } } } }, size: 0 }; const result = await this.execSearch(cls, search); const { buckets } = castTo<estypes.AggregationsStringTermsAggregate>('buckets' in result.aggregations![field] ? result.aggregations![field] : { buckets: [] }); const out = Array.isArray(buckets) ? buckets.map(b => ({ key: b.key, count: b.doc_count })) : []; return out; } }