feat(core): use cloud indexer for search (#12899)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added enhanced error handling and user-friendly error messages in
quick search and document search menus.
  - Introduced loading state indicators for search operations.
  - Quick Search now provides explicit error feedback in the UI.

- **Improvements**
- Search and aggregation operations can now prefer remote or local
indexers based on user or system preference.
- Streamlined indexer logic for more consistent and reliable search
experiences.
- Refined error handling in messaging and synchronization layers for
improved stability.
- Enhanced error object handling in messaging for clearer error
propagation.
- Updated cloud workspace storage to always use IndexedDB locally and
CloudIndexer remotely.
- Shifted indexer operations to use synchronized indexer layer for
better consistency.
  - Simplified indexer client by consolidating storage and sync layers.
- Improved error propagation in messaging handlers by wrapping error
objects.
- Updated document search to prioritize remote indexer results by
default.

- **Bug Fixes**
- Improved robustness of search features by handling errors gracefully
and preventing potential runtime issues.

- **Style**
  - Added new styles for displaying error messages in search interfaces.

- **Chores**
- Removed the obsolete "Enable Cloud Indexer" feature flag; cloud
indexer behavior is now always enabled where applicable.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
EYHN
2025-06-25 10:55:27 +08:00
committed by GitHub
parent 6813d84deb
commit aa4874a55c
21 changed files with 366 additions and 178 deletions
+18 -2
View File
@@ -9,7 +9,11 @@ import type { PeerStorageOptions } from './types';
export type { BlobSyncState } from './blob';
export type { DocSyncDocState, DocSyncState } from './doc';
export type { IndexerDocSyncState, IndexerSyncState } from './indexer';
export type {
IndexerDocSyncState,
IndexerPreferOptions,
IndexerSyncState,
} from './indexer';
export interface SyncState {
doc?: DocSyncState;
@@ -65,7 +69,19 @@ export class Sync {
])
),
});
this.indexer = new IndexerSyncImpl(doc, indexer, indexerSync);
this.indexer = new IndexerSyncImpl(
doc,
{
local: indexer,
remotes: Object.fromEntries(
Object.entries(storages.remotes).map(([peerId, remote]) => [
peerId,
remote.get('indexer'),
])
),
},
indexerSync
);
this.state$ = this.doc.state$.pipe(map(doc => ({ doc })));
}
@@ -1,4 +1,5 @@
import { readAllDocsFromRootDoc } from '@affine/reader';
import { omit } from 'lodash-es';
import {
filter,
first,
@@ -7,21 +8,33 @@ import {
ReplaySubject,
share,
Subject,
switchMap,
throttleTime,
} from 'rxjs';
import { applyUpdate, Doc as YDoc } from 'yjs';
import {
type AggregateOptions,
type AggregateResult,
type DocStorage,
IndexerDocument,
type IndexerSchema,
type IndexerStorage,
type Query,
type SearchOptions,
type SearchResult,
} from '../../storage';
import { DummyIndexerStorage } from '../../storage/dummy/indexer';
import type { IndexerSyncStorage } from '../../storage/indexer-sync';
import { AsyncPriorityQueue } from '../../utils/async-priority-queue';
import { fromPromise } from '../../utils/from-promise';
import { takeUntilAbort } from '../../utils/take-until-abort';
import { MANUALLY_STOP, throwIfAborted } from '../../utils/throw-if-aborted';
import type { PeerStorageOptions } from '../types';
import { crawlingDocData } from './crawler';
export type IndexerPreferOptions = 'local' | 'remote';
export interface IndexerSyncState {
/**
* Number of documents currently in the indexing queue
@@ -59,6 +72,35 @@ export interface IndexerSync {
addPriority(docId: string, priority: number): () => void;
waitForCompleted(signal?: AbortSignal): Promise<void>;
waitForDocCompleted(docId: string, signal?: AbortSignal): Promise<void>;
search<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T,
query: Query<T>,
options?: O & { prefer?: IndexerPreferOptions }
): Promise<SearchResult<T, O>>;
aggregate<T extends keyof IndexerSchema, const O extends AggregateOptions<T>>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O & { prefer?: IndexerPreferOptions }
): Promise<AggregateResult<T, O>>;
search$<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T,
query: Query<T>,
options?: O & { prefer?: IndexerPreferOptions }
): Observable<SearchResult<T, O>>;
aggregate$<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O & { prefer?: IndexerPreferOptions }
): Observable<AggregateResult<T, O>>;
}
export class IndexerSyncImpl implements IndexerSync {
@@ -70,6 +112,9 @@ export class IndexerSyncImpl implements IndexerSync {
private readonly rootDocId = this.doc.spaceId;
private readonly status = new IndexerSyncStatus(this.rootDocId);
private readonly indexer: IndexerStorage;
private readonly remote?: IndexerStorage;
state$ = this.status.state$.pipe(
// throttle the state to 1 second to avoid spamming the UI
throttleTime(1000, undefined, {
@@ -106,9 +151,13 @@ export class IndexerSyncImpl implements IndexerSync {
constructor(
readonly doc: DocStorage,
readonly indexer: IndexerStorage,
readonly peers: PeerStorageOptions<IndexerStorage>,
readonly indexerSync: IndexerSyncStorage
) {}
) {
// sync feature only works on local indexer
this.indexer = this.peers.local;
this.remote = Object.values(this.peers.remotes).find(remote => !!remote);
}
start() {
if (this.abort) {
@@ -439,6 +488,116 @@ export class IndexerSyncImpl implements IndexerSync {
})
);
}
async search<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T,
query: Query<T>,
options?: O & { prefer?: IndexerPreferOptions }
): Promise<SearchResult<T, O>> {
if (
options?.prefer === 'remote' &&
this.remote &&
!(this.remote instanceof DummyIndexerStorage)
) {
await this.remote.connection.waitForConnected();
return await this.remote.search(table, query, omit(options, 'prefer'));
} else {
await this.indexer.connection.waitForConnected();
return await this.indexer.search(table, query, omit(options, 'prefer'));
}
}
async aggregate<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O & { prefer?: IndexerPreferOptions }
): Promise<AggregateResult<T, O>> {
if (
options?.prefer === 'remote' &&
this.remote &&
!(this.remote instanceof DummyIndexerStorage)
) {
await this.remote.connection.waitForConnected();
return await this.remote.aggregate(
table,
query,
field,
omit(options, 'prefer')
);
} else {
await this.indexer.connection.waitForConnected();
return await this.indexer.aggregate(
table,
query,
field,
omit(options, 'prefer')
);
}
}
search$<T extends keyof IndexerSchema, const O extends SearchOptions<T>>(
table: T,
query: Query<T>,
options?: O & { prefer?: IndexerPreferOptions }
): Observable<SearchResult<T, O>> {
if (
options?.prefer === 'remote' &&
this.remote &&
!(this.remote instanceof DummyIndexerStorage)
) {
const remote = this.remote;
return fromPromise(signal =>
remote.connection.waitForConnected(signal)
).pipe(
switchMap(() => remote.search$(table, query, omit(options, 'prefer')))
);
} else {
return fromPromise(signal =>
this.indexer.connection.waitForConnected(signal)
).pipe(
switchMap(() =>
this.indexer.search$(table, query, omit(options, 'prefer'))
)
);
}
}
aggregate$<
T extends keyof IndexerSchema,
const O extends AggregateOptions<T>,
>(
table: T,
query: Query<T>,
field: keyof IndexerSchema[T],
options?: O & { prefer?: IndexerPreferOptions }
): Observable<AggregateResult<T, O>> {
if (
options?.prefer === 'remote' &&
this.remote &&
!(this.remote instanceof DummyIndexerStorage)
) {
const remote = this.remote;
return fromPromise(signal =>
remote.connection.waitForConnected(signal)
).pipe(
switchMap(() =>
remote.aggregate$(table, query, field, omit(options, 'prefer'))
)
);
} else {
return fromPromise(signal =>
this.indexer.connection.waitForConnected(signal)
).pipe(
switchMap(() =>
this.indexer.aggregate$(table, query, field, omit(options, 'prefer'))
)
);
}
}
}
class IndexerSyncStatus {