From 5e9ad634b7f450d48e88018362efa4077b2cdc20 Mon Sep 17 00:00:00 2001 From: EYHN Date: Thu, 17 Apr 2025 13:16:18 +0000 Subject: [PATCH] feat(nbstore): optimize search performance (#11778) now we can debug indexeddb performance by devtool ![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/75cf686f-9bc4-4db3-86ac-12bba83e8d83.png) --- .../src/impls/idb/indexer/data-struct.ts | 52 ++++++++++++++++-- .../common/nbstore/src/utils/shallow-equal.ts | 34 ++++++++++++ .../docs-search/services/docs-search.ts | 4 +- .../src/modules/quicksearch/impls/docs.ts | 6 ++- .../src/modules/search-menu/services/index.ts | 53 +++++++++---------- 5 files changed, 116 insertions(+), 33 deletions(-) create mode 100644 packages/common/nbstore/src/utils/shallow-equal.ts diff --git a/packages/common/nbstore/src/impls/idb/indexer/data-struct.ts b/packages/common/nbstore/src/impls/idb/indexer/data-struct.ts index ed6d84791e..40a1edd5f2 100644 --- a/packages/common/nbstore/src/impls/idb/indexer/data-struct.ts +++ b/packages/common/nbstore/src/impls/idb/indexer/data-struct.ts @@ -10,6 +10,7 @@ import { type SearchOptions, type SearchResult, } from '../../../storage'; +import { shallowEqual } from '../../../utils/shallow-equal'; import type { DocStorageSchema } from '../schema'; import { highlighter } from './highlighter'; import { @@ -33,6 +34,8 @@ export type DataStructROTransaction = IDBPTransaction< 'readonly' | 'readwrite' >; +let debugMarkCount = 0; + export class DataStruct { database: IDBPDatabase = null as any; invertedIndex = new Map>(); @@ -82,6 +85,7 @@ export class DataStruct { table: keyof IndexerSchema, document: IndexerDocument ) { + using _ = await this.measure(`update`); const existsNid = await trx .objectStore('indexerRecords') .index('id') @@ -119,6 +123,7 @@ export class DataStruct { if (!iidx) { continue; } + using _ = await this.measure(`insert[${typeInfo.type}]`); await iidx.insert(trx, nid, values); } } @@ -129,6 +134,7 @@ export class DataStruct { table: keyof IndexerSchema, document: IndexerDocument ) { + using _ = await this.measure(`insert`); const existsNid = await trx .objectStore('indexerRecords') .index('id') @@ -160,6 +166,7 @@ export class DataStruct { if (!iidx) { continue; } + using _ = await this.measure(`insert[${typeInfo.type}]`); await iidx.insert(trx, nid, values); } } @@ -183,6 +190,7 @@ export class DataStruct { table: keyof IndexerSchema, id: string ) { + using _ = await this.measure(`delete`); const nid = await trx .objectStore('indexerRecords') .index('id') @@ -195,11 +203,12 @@ export class DataStruct { } } - async deleteByQuery( + private async deleteByQuery( trx: DataStructRWTransaction, table: keyof IndexerSchema, query: Query ) { + using _ = await this.measure(`deleteByQuery`); const match = await this.queryRaw(trx, table, query); for (const nid of match.scores.keys()) { @@ -215,6 +224,7 @@ export class DataStruct { inserts: IndexerDocument[], updates: IndexerDocument[] ) { + using _ = await this.measure(`batchWrite`); for (const query of deleteByQueries) { await this.deleteByQuery(trx, table, query); } @@ -248,8 +258,14 @@ export class DataStruct { async queryRaw( trx: DataStructROTransaction, table: keyof IndexerSchema, - query: Query + query: Query, + cache: QueryCache = new QueryCache() ): Promise { + const cached = cache.get(query); + if (cached) { + return cached; + } + using _ = await this.measure(`query[${query.type}]`); if (query.type === 'match') { const iidx = this.invertedIndex.get(table)?.get(query.field as string); if (!iidx) { @@ -259,7 +275,7 @@ export class DataStruct { } else if (query.type === 'boolean') { const weights = []; for (const q of query.queries) { - weights.push(await this.queryRaw(trx, table, q)); + weights.push(await this.queryRaw(trx, table, q, cache)); } if (query.occur === 'must') { return weights.reduce((acc, w) => acc.and(w)); @@ -272,7 +288,9 @@ export class DataStruct { } else if (query.type === 'all') { return await this.matchAll(trx, table); } else if (query.type === 'boost') { - return (await this.queryRaw(trx, table, query.query)).boost(query.boost); + return (await this.queryRaw(trx, table, query.query, cache)).boost( + query.boost + ); } else if (query.type === 'exists') { const iidx = this.invertedIndex.get(table)?.get(query.field as string); if (!iidx) { @@ -490,4 +508,30 @@ export class DataStruct { return node; } + + async measure(name: string) { + const count = debugMarkCount++; + performance.mark(`${name}Start(${count})`); + return { + [Symbol.dispose]: () => { + performance.mark(`${name}End(${count})`); + performance.measure( + `${name}`, + `${name}Start(${count})`, + `${name}End(${count})` + ); + }, + }; + } +} +class QueryCache { + private readonly cache: [Query, Match][] = []; + + get(query: Query) { + return this.cache.find(q => shallowEqual(q[0], query))?.[1]; + } + + set(query: Query, match: Match) { + this.cache.push([query, match]); + } } diff --git a/packages/common/nbstore/src/utils/shallow-equal.ts b/packages/common/nbstore/src/utils/shallow-equal.ts new file mode 100644 index 0000000000..b9d4a9d7a3 --- /dev/null +++ b/packages/common/nbstore/src/utils/shallow-equal.ts @@ -0,0 +1,34 @@ +// credit: https://github.com/facebook/fbjs/blob/main/packages/fbjs/src/core/shallowEqual.js +export function shallowEqual(objA: any, objB: any) { + if (Object.is(objA, objB)) { + return true; + } + + if ( + typeof objA !== 'object' || + objA === null || + typeof objB !== 'object' || + objB === null + ) { + return false; + } + + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + + if (keysA.length !== keysB.length) { + return false; + } + + // Test for A's keys different from B. + for (const key of keysA) { + if ( + !Object.prototype.hasOwnProperty.call(objB, key) || + !Object.is(objA[key], objB[key]) + ) { + return false; + } + } + + return true; +} diff --git a/packages/frontend/core/src/modules/docs-search/services/docs-search.ts b/packages/frontend/core/src/modules/docs-search/services/docs-search.ts index 742c568dd1..e6c3c21b43 100644 --- a/packages/frontend/core/src/modules/docs-search/services/docs-search.ts +++ b/packages/frontend/core/src/modules/docs-search/services/docs-search.ts @@ -52,7 +52,9 @@ export class DocsSearchService extends Service { occur: 'should', queries: [ { - type: 'all', + type: 'match', + field: 'content', + match: query, }, { type: 'boost', diff --git a/packages/frontend/core/src/modules/quicksearch/impls/docs.ts b/packages/frontend/core/src/modules/quicksearch/impls/docs.ts index c162913d69..8cc5bb791f 100644 --- a/packages/frontend/core/src/modules/quicksearch/impls/docs.ts +++ b/packages/frontend/core/src/modules/quicksearch/impls/docs.ts @@ -6,7 +6,7 @@ import { onStart, } from '@toeverything/infra'; import { truncate } from 'lodash-es'; -import { EMPTY, map, mergeMap, of, switchMap } from 'rxjs'; +import { EMPTY, map, mergeMap, of, switchMap, throttleTime } from 'rxjs'; import type { DocRecord, DocsService } from '../../doc'; import type { DocDisplayMetaService } from '../../doc-display-meta'; @@ -50,6 +50,10 @@ export class DocsQuickSearchSession items$ = new LiveData[]>([]); query = effect( + throttleTime(1000, undefined, { + leading: false, + trailing: true, + }), switchMap((query: string) => { let out; if (!query) { diff --git a/packages/frontend/core/src/modules/search-menu/services/index.ts b/packages/frontend/core/src/modules/search-menu/services/index.ts index b90d9ea47b..a59b15c085 100644 --- a/packages/frontend/core/src/modules/search-menu/services/index.ts +++ b/packages/frontend/core/src/modules/search-menu/services/index.ts @@ -143,43 +143,42 @@ export class SearchMenuService extends Service { // only search docs by title, excluding blocks private searchDocs$(query: string) { return this.docsSearch.indexer - .aggregate$( + .search$( 'doc', { - type: 'boolean', - occur: 'must', - queries: [ + type: 'match', + field: 'title', + match: query, + }, + { + fields: ['docId', 'title'], + pagination: { + limit: 1, + }, + highlights: [ { - type: 'match', field: 'title', - match: query, + before: ``, + end: '', }, ], - }, - 'docId', - { - hits: { - fields: ['docId', 'title'], - pagination: { - limit: 1, - }, - highlights: [ - { - field: 'title', - before: ``, - end: '', - }, - ], - }, } ) .pipe( - map(({ buckets }) => - buckets.map(bucket => { + map(({ nodes }) => + nodes.map(node => { + const id = + typeof node.fields.docId === 'string' + ? node.fields.docId + : node.fields.docId[0]; + const title = + typeof node.fields.title === 'string' + ? node.fields.title + : node.fields.title[0]; return { - id: bucket.key, - title: bucket.hits.nodes[0].fields.title, - highlights: bucket.hits.nodes[0].highlights.title[0], + id, + title, + highlights: node.highlights.title[0], }; }) )