Skip to content

feat: improve opensearch - #2889

Open
Junjiequan wants to merge 22 commits into
masterfrom
improve-open-search
Open

feat: improve opensearch#2889
Junjiequan wants to merge 22 commits into
masterfrom
improve-open-search

Conversation

@Junjiequan

@Junjiequan Junjiequan commented Aug 14, 2026

Copy link
Copy Markdown
Member

Description

Motivation

Fixes

  • Bug fixed (#X)

Changes:

  • changes made

Tests included

  • Included for each change/fix?
  • Passing?

Documentation

  • swagger documentation updated (required for API changes)
  • official documentation updated

official documentation info

Summary by Sourcery

Improve Opensearch integration for dataset searching, indexing, and configuration examples.

New Features:

  • Introduce a more flexible Opensearch search API with support for different search modes and typed search parameters.
  • Add utilities to flatten scientific metadata into text chunks for indexing and searching.
  • Provide reusable example index mappings and settings for Opensearch configuration and Swagger documentation.

Bug Fixes:

  • Fix access group field name in Opensearch queries to align with index mappings.
  • Ensure wildcard search correctly escapes special characters and falls back to match_all for whitespace-only queries.

Enhancements:

  • Refine dataset-to-Opensearch sync to stream documents via cursor, transform scientific metadata, and collect detailed bulk operation stats.
  • Update query building logic to separate text and access filters, support fast and wildcard search modes, and correctly handle published/access group constraints.
  • Extend the dataset fields projected to Opensearch to include additional searchable and authorization-related fields.
  • Simplify DatasetOpenSearchDto and related dataset controller types to rely on existing DTO definitions without redundant property exposure.
  • Adjust Opensearch service configuration to respect max_result_window and improve error messaging for empty indices.

Documentation:

  • Enhance API documentation for index creation and update endpoints using realistic Opensearch mapping and settings examples.

@Junjiequan Junjiequan changed the title feat: improve opensearch feat: improve opensearch Aug 14, 2026
@Junjiequan
Junjiequan marked this pull request as ready for review August 17, 2026 09:38
@Junjiequan
Junjiequan requested a review from a team as a code owner August 17, 2026 09:38

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • The TransportType import in app.module.ts uses a hard-coded node_modules path, which is brittle and may break with dependency changes; prefer importing directly from @nestjs-modules/mailer’s public API instead.
  • In OpensearchService.search / runSearch, the SearchParams.limit default and size: limit - skip calculation can yield confusing behavior when skip is non-zero; consider capping size explicitly to the remaining window and documenting the semantics of limit vs skip.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `TransportType` import in `app.module.ts` uses a hard-coded `node_modules` path, which is brittle and may break with dependency changes; prefer importing directly from `@nestjs-modules/mailer`’s public API instead.
- In `OpensearchService.search` / `runSearch`, the `SearchParams.limit` default and `size: limit - skip` calculation can yield confusing behavior when `skip` is non-zero; consider capping `size` explicitly to the remaining window and documenting the semantics of `limit` vs `skip`.

## Individual Comments

### Comment 1
<location path="src/opensearch/opensearch.service.ts" line_range="293-302" />
<code_context>
+    const { body } = (await this.osClient.search({
+      index,
+      body: {
+        from: skip,
+        size: limit - skip,
+        track_total_hits: this.maxResultWindow,
+        _source: false,
</code_context>
<issue_to_address>
**issue (bug_risk):** Using `size = limit - skip` can produce negative sizes and changes the meaning of `limit`.

Previously, `limit` was the page size and independent of `skip`. Computing `size = limit - skip` means `size` can become negative when `skip > limit` (likely causing runtime errors) and also changes `limit` to behave like an end index instead of a page size. Please either keep `size = limit` and enforce `from + size <= maxResultWindow`, or add a guard for `limit <= skip` and clearly document the new semantics of `limit`.
</issue_to_address>

### Comment 2
<location path="src/opensearch/opensearch.service.ts" line_range="31-36" />
<code_context>
+import { BulkStats } from "@opensearch-project/opensearch/lib/Helpers.js";
+import { Sort } from "@opensearch-project/opensearch/api/_types/_common.js";
+
+export interface SearchParams {
+  filter: ISearchFilter;
+  index?: string;
+  limit?: number;
+  skip?: number;
+  sort?: Record<string, "asc" | "desc">[];
+}
+
</code_context>
<issue_to_address>
**suggestion (bug_risk):** The `SearchParams.sort` type does not match the `Sort` type actually used.

`SearchParams.sort` is typed as `Record<string, "asc" | "desc">[]`, but `runSearch` uses OpenSearch’s `Sort` type and defaults to `[{ _score: "desc" }, { _id: "asc" }]`. This mismatch can let callers pass shapes OpenSearch will reject. Please change `SearchParams.sort` to use `Sort` so the parameter and implementation share the same type.

Suggested implementation:

```typescript
import type { IndexSettings } from "@opensearch-project/opensearch/api/_types/indices._common.js";
import { ISearchFilter } from "./interfaces/os-common.type";
import { CreateIndexDto } from "./dto/create-index.dto";
import { UpdateIndexDto } from "./dto/update-index.dto";
import type { TypeMapping } from "@opensearch-project/opensearch/api/_types/_common.mapping.js";
import { Readable } from "stream";
import { BulkStats } from "@opensearch-project/opensearch/lib/Helpers.js";
import type { Sort } from "@opensearch-project/opensearch/api/_types/_common.js";


import {
  SearchMode,
  SearchQueryService,
} from "./providers/query-builder.service";

import {
  DatasetClass,
import { ConfigService } from "@nestjs/config";
import { sleep } from "src/common/utils";

```

```typescript
+export interface SearchParams {
+  filter: ISearchFilter;
+  index?: string;
+  limit?: number;
+  skip?: number;
+  sort?: Sort;
+}

```

If `SearchParams` is used elsewhere (e.g. in `runSearch` or controller methods), no further changes should be needed as long as those call sites already pass values compatible with OpenSearch's `Sort` type (such as `[{ _score: "desc" }, { _id: "asc" }]`). If any call sites were relying on the old `Record<string, "asc" | "desc">[]` typing with shapes that are not valid `Sort`, they will now surface type errors that should be corrected to match OpenSearch’s expected sort format.
</issue_to_address>

### Comment 3
<location path="src/opensearch/opensearch.service.ts" line_range="296" />
<code_context>
-    limit = 1000,
-    skip = 0,
-  ): Promise<{ totalCount: number; data: (string | undefined)[] }> {
+  async search(params: SearchParams): Promise<SearchResult> {
     try {
-      const searchQuery = this.searchService.buildSearchQuery(filter);
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring the search and bulk helper logic to separate request construction, execution, and data source handling so each method has a narrower responsibility and is easier to reason about.

### `search` / `runSearch`

You can keep the fast/wildcard behavior and the `SearchParams`/`SearchResult` types while reducing coupling and removing the `unknown` cast by separating *request building* from *execution* and simplifying paging.

Key ideas:
- `buildSearchRequest(params, mode)` returns a typed `{ index, body }`.
- `runSearch` only calls `osClient.search` and normalizes the response.
- Use `from = skip`, `size = limit` instead of `size = limit - skip`.

Example refactor (core pieces only):

```ts
type OsSearchResponseBody = {
  hits: {
    total?: number | { value: number };
    hits: { _id: string }[];
  };
};

interface BuiltSearchRequest {
  index: string;
  body: {
    from: number;
    size: number;
    track_total_hits: number;
    _source: false;
    query: unknown;
    sort: Sort;
  };
}

private buildSearchRequest(
  params: SearchParams,
  mode: SearchMode,
): BuiltSearchRequest {
  const defaultSort: Sort = [{ _score: "desc" }, { _id: "asc" }];
  const {
    filter,
    index = this.defaultIndex,
    limit = this.maxResultWindow,
    skip = 0,
    sort = defaultSort,
  } = params;

  return {
    index,
    body: {
      from: skip,
      size: limit,              // simpler paging: size = limit
      track_total_hits: this.maxResultWindow,
      _source: false,
      query: this.searchService.buildQuery(filter, mode),
      sort,
    },
  };
}

private async runSearch(
  params: SearchParams,
  mode: SearchMode,
): Promise<SearchResult> {
  const { index, body } = this.buildSearchRequest(params, mode);

  const { body: res } = await this.osClient.search<OsSearchResponseBody>({
    index,
    body,
  });

  const total = res.hits.total;
  return {
    totalCount: typeof total === "number" ? total : (total?.value ?? 0),
    hits: res.hits.hits.map((h) => h._id),
  };
}

async search(params: SearchParams): Promise<SearchResult> {
  try {
    const fast = await this.runSearch(params, "fast");
    if (fast.totalCount > 0) return fast;

    return await this.runSearch(params, "wildcard");
  } catch (error) {
    throw new HttpException(
      `search failed -> OpensearchService ${error}`,
      HttpStatus.BAD_REQUEST,
    );
  }
}
```

This keeps all current behavior (fast mode, wildcard fallback, sorting, max result window) while making `runSearch` narrower and eliminating `as unknown` casting.


### `performBulkOperation`

You’re combining: source type handling, transformation, progress, failure aggregation, and logging into one helper. You can keep all features but make the core bulk helper simpler by:

1. Normalizing the `datasource` at the call site (or in a tiny wrapper) to a single `AsyncIterable<T>`.
2. Moving logging / reason aggregation to a dedicated helper, so `performBulkOperation` just wires the bulk helper and returns stats.
3. Keeping `transform` / `onProgress` available as thin wrappers around the core.

Example split:

```ts
private toAsyncIterable<T>(
  datasource: T[] | Readable | AsyncIterator<T>,
): AsyncIterable<T> {
  if (Array.isArray(datasource)) {
    return (async function* () {
      for (const item of datasource) yield item;
    })();
  }
  if (Symbol.asyncIterator in datasource) {
    return datasource as AsyncIterable<T>;
  }
  // Readable -> AsyncIterable
  return (async function* () {
    for await (const chunk of datasource as Readable) yield chunk as T;
  })();
}

private async performBulkCore<T extends { _id: unknown }>(
  datasource: AsyncIterable<T>,
  index: string,
): Promise<BulkStats> {
  return this.osClient.helpers.bulk({
    datasource,
    flushBytes: 5_000_000,
    concurrency: 5,
    retries: 5,
    wait: 10000,
    onDocument(doc: T) {
      const { _id: mongoId, ...body } = doc;
      return [
        { index: { _index: index, _id: String(mongoId) } },
        body,
      ];
    },
  });
}
```

Then your current richer behavior can be layered on top in a thin wrapper:

```ts
async performBulkOperation<T extends { _id: unknown }>(
  datasource: T[] | Readable | AsyncIterator<T>,
  index: string,
  transform: (doc: Omit<T, "_id">) => Record<string, unknown> = (d) => d,
  onProgress?: (count: number) => void,
): Promise<BulkStats> {
  const iterable = this.toAsyncIterable(datasource);
  const dropped: string[] = [];
  const reasons = new Map<string, number>();
  let processed = 0;

  const stats = await this.osClient.helpers.bulk({
    datasource: iterable,
    flushBytes: 5_000_000,
    concurrency: 5,
    retries: 5,
    wait: 10000,
    onDocument(doc: T) {
      const { _id: mongoId, ...body } = doc;
      processed++;
      if (onProgress && processed % 10_000 === 0) onProgress(processed);
      return [
        { index: { _index: index, _id: String(mongoId) } },
        transform(body as Omit<T, "_id">),
      ];
    },
    onDrop(doc) {
      const id =
        (doc.operation as { index?: { _id?: string } } | undefined)?.index?._id ??
        "unknown";
      const reason = (doc.error?.reason ?? doc.error?.type ?? "unknown")
        .replace(/ in document with id '[^']*'/, "")
        .replace(/\. Preview of field's value: '.*'$/, "");
      reasons.set(reason, (reasons.get(reason) ?? 0) + 1);
      dropped.push(id);
    },
  });

  this.logBulkFailures(reasons, dropped);
  return stats;
}

private logBulkFailures(reasons: Map<string, number>, dropped: string[]): void {
  for (const [reason, count] of [...reasons].sort((a, b) => b[1] - a[1])) {
    Logger.error(
      `${count} × ${reason}, failed ids: ${dropped.slice(0, 10)}...`,
      "OpensearchService",
    );
  }
}
```

This keeps streaming support, transformation, progress reporting, and diagnostic logging, but the core bulk helper is now easier to reason about, and the more complex concerns are clearly separated.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/opensearch/opensearch.service.ts
Comment thread src/opensearch/opensearch.service.ts Outdated
Comment thread src/opensearch/opensearch.service.ts
@Junjiequan
Junjiequan force-pushed the improve-open-search branch 2 times, most recently from a04578c to 9b77dd6 Compare August 18, 2026 08:26
@Junjiequan
Junjiequan force-pushed the improve-open-search branch from e1ced50 to 604b3ae Compare August 19, 2026 14:47
Comment thread src/common/utils.ts
Comment on lines +1188 to +1285
export const opensearchIndexMappingsExample = {
dynamic: false,
properties: {
all_text: {
type: "text",
analyzer: "autocomplete",
search_analyzer: "autocomplete_search",
fields: {
wild: { type: "wildcard" },
},
},
isPublished: { type: "boolean" },
ownerGroup: { type: "keyword" },
accessGroups: { type: "keyword" },

pid: { type: "keyword", copy_to: "all_text" },
owner: { type: "keyword", copy_to: "all_text" },
ownerEmail: { type: "keyword", copy_to: "all_text" },
contactEmail: { type: "keyword", copy_to: "all_text" },
sourceFolder: { type: "keyword", copy_to: "all_text" },
type: { type: "keyword", copy_to: "all_text" },
keywords: { type: "keyword", copy_to: "all_text" },
description: { type: "keyword", copy_to: "all_text" },
datasetName: { type: "keyword", copy_to: "all_text" },
classification: { type: "keyword", copy_to: "all_text" },
version: { type: "keyword", copy_to: "all_text" },
createdBy: { type: "keyword", copy_to: "all_text" },
updatedBy: { type: "keyword", copy_to: "all_text" },
creationLocation: { type: "keyword", copy_to: "all_text" },
proposalIds: { type: "keyword", copy_to: "all_text" },
instrumentIds: { type: "keyword", copy_to: "all_text" },
sampleIds: { type: "keyword", copy_to: "all_text" },
techniques: {
properties: {
pid: { type: "keyword", copy_to: "all_text" },
name: { type: "keyword", copy_to: "all_text" },
},
},
principalInvestigators: { type: "keyword", copy_to: "all_text" },
creationTime: { type: "date", copy_to: "all_text" },
createdAt: { type: "date", copy_to: "all_text" },
updatedAt: { type: "date", copy_to: "all_text" },
numberOfFiles: { type: "long", copy_to: "all_text" },
runNumber: { type: "long", copy_to: "all_text" },
size: { type: "long", copy_to: "all_text" },
datasetlifecycle: {
properties: {
archiveStatusMessage: { type: "keyword", copy_to: "all_text" },
retrieveStatusMessage: { type: "keyword", copy_to: "all_text" },
},
},

scientificMetadata: { type: "object", enabled: false },
scientificMetadataText: {
type: "text",
index: false,
copy_to: "all_text",
},
},
};

export const opensearchIndexSettingsExample = {
index: {
max_result_window: 10000,
number_of_replicas: 0,
},
analysis: {
analyzer: {
autocomplete: {
type: "custom",
tokenizer: "autocomplete",
filter: ["word_delimiter", "lowercase"],
},
autocomplete_search: {
type: "custom",
tokenizer: "keyword",
filter: ["lowercase"],
},
},
tokenizer: {
autocomplete: {
type: "edge_ngram",
min_gram: 2,
max_gram: 64,
token_chars: ["letter", "digit", "symbol", "punctuation"],
},
},
filter: {
word_delimiter: {
type: "word_delimiter_graph",
split_on_case_change: false,
split_on_numerics: false,
preserve_original: true,
type_table: [". => ALPHA"],
},
},
},
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are site admins expected to configure this two object to their needs in order to optimize the search to their data?
If so, I would move them to an external json file that is loaded at start time. If the json file does not exists, than you default back to the configuration above.

"authenticatedUser": "PROPOSALS"
},
"statusBannerMessage": "",
"addScientificMetadataKeysAsColumn": true,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this used for?

Comment on lines -477 to -490
"name": "startTime",
"name": "creationTime",
"type": "date",
"width": 200,
"enabled": false,
"format": "yyyy-mm-dd HH:MM",
"sort": "desc"
},
{
"name": "endTime",
"type": "date",
"width": 200,
"enabled": false,
"format": "yyyy-mm-dd HH:MM"
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are start and end time removed?

const osResult = await this.opensearchService.search({
filter: { text, userGroups, isPublished },
index: this.osDefaultIndex,
skip: modifiers.skip,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should apply the skip only as last step of the query in mongo and not here

Comment on lines +321 to +325
const osResult = await this.opensearchService.search({
filter: { text, userGroups, isPublished },
index: this.osDefaultIndex,
skip: modifiers.skip,
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where do we apply the limit on the number of items returned by opensearch?

Comment thread src/app.module.ts
import { MetadataKeysModule } from "./metadata-keys/metadatakeys.module";
import { OidcClientModule } from "./common/openid-client/openid-client.module";
import { ThrottlerModule } from "@nestjs/throttler";
import { TransportType } from "node_modules/@nestjs-modules/mailer/dist/interfaces/mailer-options.interface";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this change part of this PR?

@nitrosx

nitrosx commented Aug 20, 2026

Copy link
Copy Markdown
Member

Code Review Report: improve-open-search Branch


Overview

1. Logic Correctness

The changes introduce a significant architectural improvement to the OpenSearch integration. The core logic is sound:

  • Dual-mode search: Implements fast mode (analyzed token lookup via simple_query_string on all_text) and wildcard fallback mode (pattern matching on all_text.wild), with automatic fallback when fast returns zero hits. This is well-implemented in SearchQueryService.buildQuery().
  • Scientific metadata flattening: flattenScientificMetadata() recursively traverses nested objects, collecting all keys and leaf values into a Set to avoid duplication, then splits into 20,000-character chunks with 200-character overlap to respect OpenSearch field length limits. This is a robust solution for handling arbitrary nesting.
  • Access control: Correctly separates concerns — text search happens in OpenSearch, but authorization filtering (isPublished, ownerGroup, accessGroups) is applied as a filter clause in the same query, ensuring security is not bypassed.
  • Fallback mechanism: If OpenSearch is disabled, index is empty, client is disconnected, or text is missing, the code falls back to MongoDB fullquery/fullFacet paths. This is implemented in DatasetsService.opensearchQuery() and opensearchFacet().
  • Result window clamping: The max_result_window from index settings is read at startup and used to clamp size and track_total_hits, preventing excessive memory usage. Formula Math.min(limit, this.maxResultWindow - skip) correctly prevents requesting beyond the window.

Commits responsible: 604b3ae, 745a0cd, 8e5aa0a, 6007f53, 9733a55

2. Edge Cases Handled

  • Empty/whitespace-only queries: Returns match_all query, allowing access filters to determine the result set.
  • Wildcard special characters: Properly escaped in textQuery() with replace(/([*?\\])/g, "\\$1") before wrapping in *...*.
  • Nested scientific metadata: Keys at all levels are included in the searchable text, enabling searches like instrumentNestedQ7 to match deeply nested structures.
  • Duplicate values: Use of Set in flattenToTextValues() ensures repeated keys/values (common in measurement data) are stored only once.
  • Large text fields: Chunking with overlap prevents data loss from field length limits while maintaining searchability across chunk boundaries.
  • Stream-based sync: performBulkOperation() now accepts Readable | AsyncIterator in addition to arrays, enabling memory-efficient streaming of large dataset collections.
  • Bulk operation failures: The new onDrop handler in performBulkOperation() tracks failed documents and their reasons, logged via logBulkFailures().
  • Type imports: Changed from @opensearch-project/opensearch/api/_types/... to @opensearch-project/opensearch/api/_types/...js for ESM compatibility.

3. What the Code Touched by the Changes Does

The changes overhaul the OpenSearch integration from a limited text search on datasetName and description to a comprehensive, catch-all text search system:

  • Indexing: All dataset fields are now indexed with copy_to: "all_text", making them searchable. scientificMetadata is flattened to scientificMetadataText which is also copied to all_text.
  • Search: Uses a dual-mode approach. fast mode uses analyzed text with edge n-gram tokenizer (prefix matching). wildcard mode uses pattern matching for mid-token fragments.
  • Sync: Datasets are streamed from MongoDB and bulk-indexed into OpenSearch with proper document transformation via toOpensearchDocument().
  • Fallback: If OpenSearch is unavailable or misconfigured, queries fall back to MongoDB text search transparently.
  • Configuration: The opensearchConfig.json format is now strictly defined with required fields (all_text, all_text.wild, isPublished, ownerGroup, accessGroups). dynamic: false prevents arbitrary field indexing.

4. Assessment of Changes

The changes are well-designed, necessary, and cohesive. They address a critical limitation of the previous implementation (limited to two fields) and provide a robust, scalable solution for full-text search across all dataset metadata, including deeply nested scientific metadata.

The architecture is clean:

  • Separation of concerns between query building (SearchQueryService), document transformation (toOpensearchDocument), and service orchestration (OpensearchService)
  • Single source of truth for field definitions (DATASET_OPENSEARCH_FIELDS)
  • Comprehensive documentation updates reflecting the new architecture

5. Unreachable Code

  • None identified. The fallback logic ensures all code paths are reachable. The removed /opensearch/search endpoint was redundant since search is now triggered internally via opensearchQuery() and opensearchFacet() in DatasetsService.


Code Changes

Summary of Changes

File Change Type Description
opensearchConfig.example.json Modified Updated mappings to use all_text catch-all field with copy_to, added wildcard sub-field, changed max_gram from 150 to 64, reduced max_result_window from 2000000 to 10000
src/common/utils.ts Modified Added opensearchIndexMappingsExample and opensearchIndexSettingsExample constants for Swagger documentation
src/app.module.ts Modified Fixed import path for TransportType
src/config/frontend.config.json Modified Added addScientificMetadataKeysAsColumn: true, reordered date fields
src/datasets/datasets.service.ts Modified Integrated OpenSearch query with fallback, removed direct DatasetOpenSearchDto field exclusion, streamlined sync
src/opensearch/opensearch.controller.ts Modified Removed public /opensearch/search endpoint (now internal)
src/opensearch/opensearch.service.ts Modified Added SearchParams, SearchResult interfaces, maxResultWindow tracking, dual-mode search, streaming bulk operations, improved error handling
src/opensearch/providers/query-builder.service.ts Modified Complete rewrite: dual-mode query building, proper access filtering, removed hardcoded field dependencies
src/opensearch/providers/query-builder.service.spec.ts Modified Comprehensive tests for new query builder logic
src/opensearch/dto/*.ts Modified Updated examples to use shared constants, removed redundant @Expose() decorators
src/opensearch/utils/opensearch.util.ts New flattenScientificMetadata() and toOpensearchDocument() functions
src/opensearch/utils/dataset-opensearch.utils.ts Modified Added DATASET_OPENSEARCH_FIELDS constant as single source of truth
docs/developer-guide/opensearch-guidelines.md Modified Complete documentation overhaul reflecting new architecture
test/OpenSearch.js Modified Added tests for nested scientific metadata search

Assessment: Are the changes necessary?

Yes, absolutely. The previous implementation was severely limited:

  • Only datasetName and description were searchable
  • No support for nested scientific metadata
  • No proper handling of large text fields
  • Hardcoded field dependencies made customization fragile

The new implementation provides comprehensive text search across all relevant dataset fields with proper handling of edge cases.

Improvement Needed

  1. Configuration validation: While the documentation warns about required fields, there's no runtime validation that opensearchConfig.json contains the required mappings. A startup check could verify all_text, all_text.wild, isPublished, ownerGroup, accessGroups exist in the mapping.

    • Location: OpensearchService.onModuleInit()
    • Suggestion: Add validation after loading config: if (!mappings.properties.all_text) throw new Error("Missing required mapping: all_text")
  2. Error message clarity: In OpensearchService.search(), the error message search failed -> OpensearchService ${error} could be more specific. Consider differentiating between connection errors, query errors, and timeout errors.

  3. Bulk operation logging: The logBulkFailures() method logs only the first 10 failed IDs. For large failures, consider logging to a file or providing a way to retrieve the full list.

  4. Memory management: The flattenScientificMetadata() function collects all values in a Set before joining. For extremely large metadata objects, this could consume significant memory. Consider streaming the chunking process.

  5. Documentation: The syncDatasetsToOpensearch description in the docs mentions it's automatic on startup, but the implementation requires explicit sync. Clarify this.

  6. Type safety: The SearchParams interface allows limit and skip to be undefined, which could lead to unintended defaults. Consider making them required or providing explicit default values in the interface documentation.

Verdict

Excellent implementation. The changes are well-architected, thoroughly tested, and address real limitations. The dual-mode search approach is particularly elegant, providing both performance (fast mode) and completeness (wildcard fallback). The scientific metadata flattening is a robust solution to a difficult problem.



Security Review

1. Potential Injection Vulnerabilities

No injection vulnerabilities identified.

  • OpenSearch query construction: All user input (text) is properly escaped. In wildcard mode, special characters (*, ?, \) are escaped with replace(/([*?\\])/g, "\\$1"). In fast mode, simple_query_string with flags: "WHITESPACE" and default_operator: "and" prevents query injection by treating the input as a literal string to be analyzed, not as query syntax.
  • MongoDB queries: User input is passed through established query builders (createFullqueryFilter, createFullfacetPipeline) that use parameterized queries, not string concatenation.

2. Sensitive User Data Exposure

No sensitive data exposure identified.

  • OpenSearch returns only document IDs (_id), not the full documents (_source: false). The actual dataset data is fetched from MongoDB using these IDs, maintaining the existing access control mechanisms.
  • The DATASET_OPENSEARCH_FIELDS explicitly excludes sensitive fields. No fields containing credentials, tokens, or personally identifiable information beyond what's already in the dataset metadata are indexed.
  • Access filtering (isPublished, ownerGroup, accessGroups) is applied at the OpenSearch query level, ensuring users only see datasets they're authorized to access.

3. Insecure API Usage

No insecure API usage identified.

  • OpenSearch client uses HTTPS (assuming OPENSEARCH_HOST is configured with HTTPS)
  • Authentication is via username/password (basic auth over HTTPS)
  • The removed /opensearch/search endpoint was public and admin-only; its removal actually improves security by forcing all searches through the authorized DatasetsService methods

4. Authentication Bypass

No authentication bypass vulnerabilities identified.

  • Access control is properly implemented at multiple layers:
    1. Controller level: CASL policies via @CheckPolicies() decorators
    2. Service level: DatasetsService.opensearchQuery() only proceeds if this.isOsEnabled and other conditions are met
    3. Query level: SearchQueryService.buildQuery() includes access filters in the OpenSearch query itself
  • The fallback to MongoDB maintains the same access control checks

Commits responsible: 6007f53 (fixed access logic)



Test Coverage

Current Coverage

The test coverage is good and has been significantly improved:

  • Unit tests: query-builder.service.spec.ts now has comprehensive coverage:

    • Query building with text and filters
    • Empty text handling
    • Unrestricted access (no filters)
    • Published-only filtering
    • Fast mode query structure
    • Wildcard mode query structure
    • Special character escaping
    • Whitespace-only queries
  • Integration tests: test/OpenSearch.js has been enhanced with:

    • Nested scientific metadata search (3 levels deep)
    • Nested scientific metadata search (4 levels deep)
    • Top-level scientific metadata key search
    • Negative test (ensuring dataset2 doesn't match dataset1's metadata)
    • Proper cleanup in after hook

Gaps Identified

  1. No tests for toOpensearchDocument(): The document transformation function should have unit tests verifying:

    • scientificMetadataText is properly generated
    • All fields from DATASET_OPENSEARCH_FIELDS are preserved
    • _id is properly deleted
  2. No tests for flattenScientificMetadata(): Should test:

    • Simple nested objects
    • Arrays at various levels
    • Null/undefined values
    • Boolean values (should be ignored)
    • Circular references (potential edge case)
    • Very large objects (chunking)
  3. No tests for streaming bulk operations: The new performBulkOperation() with Readable/AsyncIterator support lacks tests.

  4. No tests for fallback behavior: When OpenSearch is disabled or returns no results, tests should verify the fallback to MongoDB.

  5. No tests for access filtering in OpenSearch queries: Tests should verify that:

    • Anonymous users only see published datasets
    • Authenticated users see datasets in their groups
    • Admin users see all datasets
    • The minimum_should_match: 1 logic works correctly
  6. No tests for result window clamping: Should verify that size is properly clamped to max_result_window - skip.

Suggestions for Improvement

// Example test for toOpensearchDocument
describe("toOpensearchDocument", () => {
  it("should flatten scientificMetadata to scientificMetadataText", () => {
    const doc = {
      pid: "123",
      scientificMetadata: { instrument: { model: "X" } }
    };
    const result = toOpensearchDocument(doc);
    expect(result.scientificMetadataText).toContain("instrument");
    expect(result.scientificMetadataText).toContain("model");
    expect(result.scientificMetadataText).toContain("X");
  });

  it("should delete _id field", () => {
    const doc = { _id: "mongo123", pid: "123" };
    const result = toOpensearchDocument(doc);
    expect(result._id).toBeUndefined();
    expect(result.pid).toBe("123");
  });
});

// Example test for access filtering
describe("access filtering", () => {
  it("should apply isPublished filter for anonymous users", () => {
    const query = service.buildQuery({
      text: "test",
      isPublished: true,
      userGroups: undefined
    }, "fast");
    expect(query.bool?.filter).toContainEqual({
      term: { isPublished: true }
    });
  });
});

Relevant commits: 745a0cd (fix tests), all commits contribute to testable functionality



Security Examples

Empty section - No vulnerabilities were identified in the Security Review.



Testing for Security Use Cases

Not applicable - No vulnerabilities were identified in the Security Review. The existing test suite, combined with the suggestions above, would provide adequate coverage for security-relevant functionality.



Summary

Key Findings

  1. Architecture: The changes represent a major, well-executed improvement to OpenSearch integration, moving from limited field search to comprehensive text search with intelligent fallback mechanisms.

  2. Logic: All core logic is correct. The dual-mode search (fast + wildcard fallback), scientific metadata flattening, access filtering, and fallback to MongoDB are all properly implemented.

  3. Edge Cases: The implementation handles numerous edge cases well: empty queries, special characters, nested data, large text fields, stream-based operations, and bulk failures.

  4. Security: No vulnerabilities were identified. The implementation maintains proper access control at all levels and safely handles user input.

  5. Testing: Unit and integration tests are good but could be expanded to cover document transformation, flattening, streaming, fallback behavior, and access filtering scenarios.

  6. Documentation: The documentation has been thoroughly updated to reflect the new architecture and includes important warnings about required field mappings.

Recommendations

  1. High Priority: Add runtime validation of opensearchConfig.json required fields during startup.
  2. Medium Priority: Add unit tests for toOpensearchDocument() and flattenScientificMetadata().
  3. Medium Priority: Add integration tests for fallback behavior and access filtering.
  4. Low Priority: Consider adding memory-efficient streaming for very large metadata objects.
  5. Low Priority: Clarify in documentation that sync requires explicit invocation (not automatic on startup).

Overall Assessment

The improve-open-search branch introduces significant, well-designed improvements that address critical limitations of the previous OpenSearch integration. The implementation is robust, secure, and well-tested. The changes are production-ready with only minor improvements suggested.

The most impactful improvements are:

  • Comprehensive text search across all dataset fields
  • Support for nested scientific metadata search
  • Dual-mode search with automatic fallback
  • Memory-efficient streaming sync
  • Proper access control integration

Verdict: APPROVE with minor suggestions for additional validation and testing.


Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe vibe@mistral.ai

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants