From 3bb25a9accc730529cb7259d2e4439008b3209d3 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Fri, 29 Apr 2022 18:00:55 +0200 Subject: [PATCH] Initial implementation of a generic ndjson collator. Signed-off-by: Eric Peterson --- .changeset/search-eyes-of-them-all.md | 7 + .changeset/search-martha-sways.md | 5 + plugins/search-backend-node/api-report.md | 28 ++++ plugins/search-backend-node/package.json | 7 +- ...ewlineDelimitedJsonCollatorFactory.test.ts | 157 ++++++++++++++++++ .../NewlineDelimitedJsonCollatorFactory.ts | 147 ++++++++++++++++ .../src/collators/index.ts | 19 +++ plugins/search-backend-node/src/index.ts | 1 + .../src/test-utils/TestPipeline.ts | 8 +- yarn.lock | 19 +++ 10 files changed, 393 insertions(+), 5 deletions(-) create mode 100644 .changeset/search-eyes-of-them-all.md create mode 100644 .changeset/search-martha-sways.md create mode 100644 plugins/search-backend-node/src/collators/NewlineDelimitedJsonCollatorFactory.test.ts create mode 100644 plugins/search-backend-node/src/collators/NewlineDelimitedJsonCollatorFactory.ts create mode 100644 plugins/search-backend-node/src/collators/index.ts diff --git a/.changeset/search-eyes-of-them-all.md b/.changeset/search-eyes-of-them-all.md new file mode 100644 index 0000000000..4984d31877 --- /dev/null +++ b/.changeset/search-eyes-of-them-all.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-search-backend-node': patch +--- + +Introducing a `NewlineDelimitedJsonCollatorFactory`, which can be used to create search indices from newline delimited JSON files stored in external storage readable via a configured `UrlReader` instance. + +This is useful if you have an independent process periodically generating `*.ndjson` files consisting of `IndexableDocument` objects and want to be able to generate a fresh index based on the latest version of such a file. diff --git a/.changeset/search-martha-sways.md b/.changeset/search-martha-sways.md new file mode 100644 index 0000000000..10231a4205 --- /dev/null +++ b/.changeset/search-martha-sways.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-backend-node': patch +--- + +Fixed a bug that prevented `TestPipeline.withSubject` from identifying valid `Readable` subjects that were technically transform streams. diff --git a/plugins/search-backend-node/api-report.md b/plugins/search-backend-node/api-report.md index 0f379b3fc1..8c10b031a2 100644 --- a/plugins/search-backend-node/api-report.md +++ b/plugins/search-backend-node/api-report.md @@ -5,6 +5,7 @@ ```ts /// +import { Config } from '@backstage/config'; import { DocumentCollatorFactory } from '@backstage/plugin-search-common'; import { DocumentDecoratorFactory } from '@backstage/plugin-search-common'; import { DocumentTypeInfo } from '@backstage/plugin-search-common'; @@ -12,6 +13,7 @@ import { IndexableDocument } from '@backstage/plugin-search-common'; import { IndexableResultSet } from '@backstage/plugin-search-common'; import { Logger } from 'winston'; import { default as lunr_2 } from 'lunr'; +import { Permission } from '@backstage/plugin-permission-common'; import { QueryTranslator } from '@backstage/plugin-search-common'; import { Readable } from 'stream'; import { SearchEngine } from '@backstage/plugin-search-common'; @@ -19,6 +21,7 @@ import { SearchQuery } from '@backstage/plugin-search-common'; import { TaskFunction } from '@backstage/backend-tasks'; import { TaskRunner } from '@backstage/backend-tasks'; import { Transform } from 'stream'; +import { UrlReader } from '@backstage/backend-common'; import { Writable } from 'stream'; // @beta @@ -112,6 +115,31 @@ export class LunrSearchEngineIndexer extends BatchSearchEngineIndexer { initialize(): Promise; } +// @beta +export class NewlineDelimitedJsonCollatorFactory + implements DocumentCollatorFactory +{ + static fromConfig( + _config: Config, + options: NewlineDelimitedJsonCollatorFactoryOptions, + ): NewlineDelimitedJsonCollatorFactory; + // (undocumented) + getCollator(): Promise; + // (undocumented) + readonly type: string; + // (undocumented) + readonly visibilityPermission: Permission | undefined; +} + +// @beta (undocumented) +export type NewlineDelimitedJsonCollatorFactoryOptions = { + type: string; + searchPattern: string; + reader: UrlReader; + logger: Logger; + visibilityPermission?: Permission; +}; + // @beta export interface RegisterCollatorParameters { factory: DocumentCollatorFactory; diff --git a/plugins/search-backend-node/package.json b/plugins/search-backend-node/package.json index 7a0b0631c6..759879aa5f 100644 --- a/plugins/search-backend-node/package.json +++ b/plugins/search-backend-node/package.json @@ -23,19 +23,24 @@ "clean": "backstage-cli package clean" }, "dependencies": { + "@backstage/backend-common": "^0.13.6-next.0", "@backstage/backend-tasks": "^0.3.2-next.0", + "@backstage/config": "^1.0.1", "@backstage/errors": "^1.0.0", + "@backstage/plugin-permission-common": "^0.6.1", "@backstage/plugin-search-common": "^0.3.4", "@types/lunr": "^2.3.3", "lodash": "^4.17.21", "lunr": "^2.3.9", + "ndjson": "^2.0.0", "node-abort-controller": "^3.0.1", "uuid": "^8.3.2", "winston": "^3.2.1" }, "devDependencies": { "@backstage/backend-common": "^0.13.6-next.0", - "@backstage/cli": "^0.17.2-next.0" + "@backstage/cli": "^0.17.2-next.0", + "@types/ndjson": "^2.0.1" }, "files": [ "dist" diff --git a/plugins/search-backend-node/src/collators/NewlineDelimitedJsonCollatorFactory.test.ts b/plugins/search-backend-node/src/collators/NewlineDelimitedJsonCollatorFactory.test.ts new file mode 100644 index 0000000000..198995b3cf --- /dev/null +++ b/plugins/search-backend-node/src/collators/NewlineDelimitedJsonCollatorFactory.test.ts @@ -0,0 +1,157 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + getVoidLogger, + ReadUrlResponse, + UrlReader, + UrlReaders, +} from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { Readable } from 'stream'; +import { NewlineDelimitedJsonCollatorFactory } from './NewlineDelimitedJsonCollatorFactory'; +import { TestPipeline } from '../test-utils'; + +describe('DefaultCatalogCollatorFactory', () => { + const config = new ConfigReader({}); + const logger = getVoidLogger(); + + it('has expected type', () => { + const factory = NewlineDelimitedJsonCollatorFactory.fromConfig(config, { + type: 'expected-type', + searchPattern: 'test://folder/prefix-*', + logger, + reader: UrlReaders.default({ logger, config }), + }); + expect(factory.type).toBe('expected-type'); + }); + + describe('getCollator', () => { + let readable: Readable; + let reader: jest.Mocked< + UrlReader & { readUrl: jest.Mock> } + >; + let factory: NewlineDelimitedJsonCollatorFactory; + + beforeEach(async () => { + jest.clearAllMocks(); + + readable = new Readable(); + readable._read = () => {}; + reader = { + search: jest.fn(), + read: jest.fn(), + readTree: jest.fn(), + readUrl: jest.fn(), + }; + factory = NewlineDelimitedJsonCollatorFactory.fromConfig(config, { + type: 'expected-type', + searchPattern: 'test://folder/prefix-*', + logger, + reader: UrlReaders.create({ + logger, + config, + factories: [() => [{ predicate: () => true, reader }]], + }), + }); + }); + + it('throws if url reader throws an error during search', async () => { + reader.search.mockRejectedValue(new Error('Expected error')); + + await expect(() => factory.getCollator()).rejects.toThrowError( + 'Expected error', + ); + }); + + it('throws if no matching files are found', async () => { + reader.search.mockResolvedValue({ files: [], etag: '' }); + + await expect(() => factory.getCollator()).rejects.toThrowError( + 'Could not find an .ndjson file matching', + ); + }); + + it('throws if matching file is not .ndjson', async () => { + reader.search.mockResolvedValue({ + files: [{ url: 'test://folder/prefix-1.avro', content: jest.fn() }], + etag: '', + }); + reader.readUrl.mockResolvedValue({ + buffer: jest.fn(), + stream: jest.fn().mockReturnValue(readable), + }); + + await expect(() => factory.getCollator()).rejects.toThrowError( + 'Could not find an .ndjson file matching', + ); + }); + + it('gets stream using latest matched url', async () => { + reader.search.mockResolvedValue({ + files: [ + { url: 'test://folder/prefix-1.ndjson', content: jest.fn() }, + { url: 'test://folder/prefix-2.ndjson', content: jest.fn() }, + ], + etag: '', + }); + reader.readUrl.mockResolvedValue({ + buffer: jest.fn(), + stream: jest.fn().mockReturnValue(readable), + }); + + await factory.getCollator(); + + expect(reader.search).toHaveBeenCalledWith( + 'test://folder/prefix-*', + undefined, + ); + expect(reader.readUrl).toHaveBeenCalledWith( + 'test://folder/prefix-2.ndjson', + undefined, + ); + }); + + it('transforms newline delimited json into readable stream of documents', async () => { + reader.search.mockResolvedValue({ + files: [{ url: 'test://folder/prefix-1.ndjson', content: jest.fn() }], + etag: '', + }); + reader.readUrl.mockResolvedValue({ + buffer: jest.fn(), + stream: jest + .fn() + .mockReturnValue( + Readable.from( + '{"title": "Title 1", "location": "/title-1", "text": "text 1"}\n{"title": "Title 2", "location": "/title-2", "text": "text 2"}', + ), + ), + }); + + const collator = await factory.getCollator(); + const pipeline = TestPipeline.withSubject(collator); + const { documents } = await pipeline.execute(); + + expect(documents).toHaveLength(2); + expect(documents[0].title).toBe('Title 1'); + expect(documents[0].location).toBe('/title-1'); + expect(documents[0].text).toBe('text 1'); + expect(documents[1].title).toBe('Title 2'); + expect(documents[1].location).toBe('/title-2'); + expect(documents[1].text).toBe('text 2'); + }); + }); +}); diff --git a/plugins/search-backend-node/src/collators/NewlineDelimitedJsonCollatorFactory.ts b/plugins/search-backend-node/src/collators/NewlineDelimitedJsonCollatorFactory.ts new file mode 100644 index 0000000000..42c43818f1 --- /dev/null +++ b/plugins/search-backend-node/src/collators/NewlineDelimitedJsonCollatorFactory.ts @@ -0,0 +1,147 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { UrlReader } from '@backstage/backend-common'; +import { Config } from '@backstage/config'; +import { Permission } from '@backstage/plugin-permission-common'; +import { DocumentCollatorFactory } from '@backstage/plugin-search-common'; +import { parse as parseNdjson } from 'ndjson'; +import { Readable } from 'stream'; +import { Logger } from 'winston'; + +/** + * @beta + */ +export type NewlineDelimitedJsonCollatorFactoryOptions = { + type: string; + searchPattern: string; + reader: UrlReader; + logger: Logger; + visibilityPermission?: Permission; +}; + +/** + * Factory class producing a collator that can be used to index documents + * sourced from the latest newline delimited JSON file matching a given search + * pattern. "Latest" is determined by the name of the file (last alphabetically + * is considered latest). + * + * @remarks + * The reader provided must implement the `search()` method as well as the + * `readUrl` method whose response includes the `stream()` method. Naturally, + * the reader must also be configured to understand the given search pattern. + * + * @example + * Here's an example configuration using Google Cloud Storage, which would + * return the latest file under the `bucket` GCS bucket with files like + * `xyz-2021.ndjson` or `xyz-2022.ndjson`. + * ```ts + * indexBuilder.addCollator({ + * schedule, + * factory: NewlineDelimitedJsonCollatorFactory.fromConfig(env.config, { + * type: 'techdocs', + * searchPattern: 'https://storage.cloud.google.com/bucket/xyz-*', + * reader: env.reader, + * logger: env.logger, + * }) + * }); + * ``` + * + * @beta + */ +export class NewlineDelimitedJsonCollatorFactory + implements DocumentCollatorFactory +{ + /** {@inheritDoc @backstage/plugin-search-common#DocumentCollatorFactory."type"} */ + readonly type: string; + + /** {@inheritDoc @backstage/plugin-search-common#DocumentCollatorFactory.visibilityPermission} */ + public readonly visibilityPermission: Permission | undefined; + + private constructor( + type: string, + private readonly searchPattern: string, + private readonly reader: UrlReader, + private readonly logger: Logger, + visibilityPermission: Permission | undefined, + ) { + this.type = type; + this.visibilityPermission = visibilityPermission; + } + + /** + * Returns a NewlineDelimitedJsonCollatorFactory instance from configuration + * and a set of options. + */ + static fromConfig( + _config: Config, + options: NewlineDelimitedJsonCollatorFactoryOptions, + ): NewlineDelimitedJsonCollatorFactory { + return new NewlineDelimitedJsonCollatorFactory( + options.type, + options.searchPattern, + options.reader, + options.logger, + options.visibilityPermission, + ); + } + + /** + * Returns the "latest" URL for the given search pattern (e.g. the one at the + * end of the list, sorted alphabetically). + */ + private async lastUrl(): Promise { + try { + // Search for files matching the given pattern, then sort/reverse. The + // first item in the list will be the "latest" file. + this.logger.info( + `Attempting to find latest .ndjson matching ${this.searchPattern}`, + ); + const { files } = await this.reader.search(this.searchPattern); + const candidates = files + .filter(file => file.url.endsWith('.ndjson')) + .sort((a, b) => a.url.localeCompare(b.url)) + .reverse(); + + return candidates[0]?.url; + } catch (e) { + this.logger.error(`Could not search for ${this.searchPattern}`, e); + throw e; + } + } + + /** {@inheritDoc @backstage/plugin-search-common#DocumentCollatorFactory.getCollator} */ + async getCollator(): Promise { + // Search for files matching the given pattern. + const lastUrl = await this.lastUrl(); + + // Abort if no such file could be found. + if (!lastUrl) { + const noMatchingFile = `Could not find an .ndjson file matching ${this.searchPattern}`; + this.logger.error(noMatchingFile); + throw new Error(noMatchingFile); + } else { + this.logger.info(`Using latest .ndjson file ${lastUrl}`); + } + + // Use the UrlReader to try and stream the file. + const readerResponse = await this.reader.readUrl!(lastUrl); + const stream = readerResponse.stream!(); + + // Use ndjson's parser to turn the raw file into an object-mode stream. + return stream.pipe(parseNdjson()); + } +} diff --git a/plugins/search-backend-node/src/collators/index.ts b/plugins/search-backend-node/src/collators/index.ts new file mode 100644 index 0000000000..82598d2727 --- /dev/null +++ b/plugins/search-backend-node/src/collators/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type { NewlineDelimitedJsonCollatorFactoryOptions } from './NewlineDelimitedJsonCollatorFactory'; + +export { NewlineDelimitedJsonCollatorFactory } from './NewlineDelimitedJsonCollatorFactory'; diff --git a/plugins/search-backend-node/src/index.ts b/plugins/search-backend-node/src/index.ts index e5202ac640..d342bb9259 100644 --- a/plugins/search-backend-node/src/index.ts +++ b/plugins/search-backend-node/src/index.ts @@ -22,6 +22,7 @@ export { IndexBuilder } from './IndexBuilder'; export { Scheduler } from './Scheduler'; +export * from './collators'; export { LunrSearchEngine } from './engines'; export type { ConcreteLunrQuery, diff --git a/plugins/search-backend-node/src/test-utils/TestPipeline.ts b/plugins/search-backend-node/src/test-utils/TestPipeline.ts index dd90de4f38..688cab55be 100644 --- a/plugins/search-backend-node/src/test-utils/TestPipeline.ts +++ b/plugins/search-backend-node/src/test-utils/TestPipeline.ts @@ -66,14 +66,14 @@ export class TestPipeline { return new TestPipeline({ decorator: subject }); } - if (subject instanceof Readable) { - return new TestPipeline({ collator: subject }); - } - if (subject instanceof Writable) { return new TestPipeline({ indexer: subject }); } + if (subject.readable || subject instanceof Readable) { + return new TestPipeline({ collator: subject }); + } + throw new Error( 'Unknown test subject: are you passing a readable, writable, or transform stream?', ); diff --git a/yarn.lock b/yarn.lock index 3ee9833781..1df2bf6928 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6224,6 +6224,14 @@ resolved "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA== +"@types/ndjson@^2.0.1": + version "2.0.1" + resolved "https://registry.npmjs.org/@types/ndjson/-/ndjson-2.0.1.tgz#0279bc20949bfb861d69ac3de5292775b169a2d0" + integrity sha512-xSRLa/CtPjEo0plSQj+nMKjVBkYh5MeMwOXa1y//jFELdmy9AmVQgWKWQgZ+/XrNlAYxXtmKR8OHaizPgEpUEw== + dependencies: + "@types/node" "*" + "@types/through" "*" + "@types/node-fetch@^2.5.0", "@types/node-fetch@^2.5.12", "@types/node-fetch@^2.5.7": version "2.6.1" resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.1.tgz#8f127c50481db65886800ef496f20bbf15518975" @@ -18367,6 +18375,17 @@ natural-compare@^1.4.0: resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +ndjson@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/ndjson/-/ndjson-2.0.0.tgz#320ac86f6fe53f5681897349b86ac6f43bfa3a19" + integrity sha512-nGl7LRGrzugTtaFcJMhLbpzJM6XdivmbkdlaGcrk/LXg2KL/YBC6z1g70xh0/al+oFuVFP8N8kiWRucmeEH/qQ== + dependencies: + json-stringify-safe "^5.0.1" + minimist "^1.2.5" + readable-stream "^3.6.0" + split2 "^3.0.0" + through2 "^4.0.0" + negotiator@0.6.3, negotiator@^0.6.2, negotiator@^0.6.3: version "0.6.3" resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"