mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-01 22:29:44 +08:00
feat(server): improve indexer perf (#15512)
#### PR Dependency Tree * **PR #15512** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Search now supports generation-based indexing with embedded and remote providers. * Added automatic search reconciliation and improved handling of document, workspace, and permission changes. * Added clearer search status errors for unavailable, syncing, unready, or failed indexes. * Added Manticore Search end-to-end support and provider-specific search behavior. * **Improvements** * Search and aggregate pagination now report returned results and continuation status more accurately. * Improved permission filtering to prevent inaccessible documents from appearing in results. * Admin provider selection now consistently enables indexing. * **Documentation** * Clarified search pagination, aggregation counts, provider configuration, and end-to-end setup. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -11,5 +11,3 @@
|
||||
# MAILER_USER="noreply@toeverything.info"
|
||||
# MAILER_PASSWORD="affine"
|
||||
# MAILER_SECURE=false
|
||||
|
||||
# AFFINE_INDEXER_ENABLED=true
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Manticore Search provider E2E
|
||||
|
||||
This fixture validates the shared immutable projection contract against a real
|
||||
Manticore Search RT table. Aggregate queries remain unsupported and are validated
|
||||
through the typed fallback boundary instead.
|
||||
|
||||
Start the external dependency:
|
||||
|
||||
```bash
|
||||
docker compose -f packages/backend/server/e2e/manticore-provider/compose.yml up -d --wait
|
||||
```
|
||||
|
||||
Use a disposable PostgreSQL database and run the provider-gated E2E:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://ds:ds@localhost:55433/affine_rfc6_manticore_e2e \
|
||||
yarn workspace @affine/server prisma migrate deploy
|
||||
```
|
||||
|
||||
Configure `packages/backend/server/config.json` with:
|
||||
|
||||
```json
|
||||
{
|
||||
"indexer": {
|
||||
"enabled": true,
|
||||
"provider": {
|
||||
"type": "manticoresearch",
|
||||
"endpoint": "http://127.0.0.1:9308"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://ds:ds@localhost:55433/affine_rfc6_manticore_e2e \
|
||||
yarn af server e2e src/__tests__/e2e/indexer/manticore-provider.spec.ts
|
||||
```
|
||||
|
||||
Stop only this disposable dependency when finished:
|
||||
|
||||
```bash
|
||||
docker compose -f packages/backend/server/e2e/manticore-provider/compose.yml down -v
|
||||
```
|
||||
@@ -0,0 +1,33 @@
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
environment:
|
||||
POSTGRES_DB: affine_rfc6_manticore_e2e
|
||||
POSTGRES_USER: ds
|
||||
POSTGRES_PASSWORD: ds
|
||||
ports:
|
||||
- '55433:5432'
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U ds -d affine_rfc6_manticore_e2e']
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 30
|
||||
start_period: 5s
|
||||
|
||||
manticore:
|
||||
image: manticoresearch/manticore:29.0.2
|
||||
environment:
|
||||
searchd.listen: 'http:9308'
|
||||
searchd.network_timeout: '30'
|
||||
ports:
|
||||
- '9308:9308'
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD-SHELL',
|
||||
"wget -qO- --post-data='SHOW TABLES' 'http://127.0.0.1:9308/sql?mode=raw' >/dev/null",
|
||||
]
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 30
|
||||
start_period: 5s
|
||||
@@ -0,0 +1,39 @@
|
||||
# Remote provider E2E
|
||||
|
||||
These tests exercise the Elasticsearch-compatible provider over HTTP against a
|
||||
local OpenSearch instance. Toxiproxy is included so the lease test can inject
|
||||
provider latency without changing production code.
|
||||
|
||||
```bash
|
||||
docker compose -f packages/backend/server/e2e/remote-provider/compose.yml up -d --wait
|
||||
|
||||
DATABASE_URL=postgresql://ds:ds@localhost:55432/affine_rfc6_remote_e2e \
|
||||
yarn workspace @affine/server prisma migrate deploy
|
||||
```
|
||||
|
||||
Configure `packages/backend/server/config.json` with:
|
||||
|
||||
```json
|
||||
{
|
||||
"indexer": {
|
||||
"enabled": true,
|
||||
"provider": {
|
||||
"type": "elasticsearch",
|
||||
"endpoint": "http://127.0.0.1:8666"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://ds:ds@localhost:55432/affine_rfc6_remote_e2e \
|
||||
yarn af server e2e src/__tests__/e2e/indexer/remote-provider.spec.ts
|
||||
|
||||
docker compose -f packages/backend/server/e2e/remote-provider/compose.yml down -v
|
||||
```
|
||||
|
||||
The suite uses a disposable PostgreSQL database and creates generation-specific
|
||||
indices. It directly deletes or mutates provider rows only as a fault-injection
|
||||
fixture; production code still repairs them through the normal reconcile path.
|
||||
@@ -0,0 +1,73 @@
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
environment:
|
||||
POSTGRES_DB: affine_rfc6_remote_e2e
|
||||
POSTGRES_USER: ds
|
||||
POSTGRES_PASSWORD: ds
|
||||
ports:
|
||||
- '55432:5432'
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U ds -d affine_rfc6_remote_e2e']
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 30
|
||||
start_period: 5s
|
||||
|
||||
opensearch:
|
||||
image: opensearchproject/opensearch:2.19.1
|
||||
environment:
|
||||
discovery.type: single-node
|
||||
DISABLE_INSTALL_DEMO_CONFIG: 'true'
|
||||
DISABLE_SECURITY_PLUGIN: 'true'
|
||||
bootstrap.memory_lock: 'true'
|
||||
OPENSEARCH_JAVA_OPTS: '-Xms512m -Xmx512m'
|
||||
ulimits:
|
||||
memlock:
|
||||
soft: -1
|
||||
hard: -1
|
||||
ports:
|
||||
- '9200:9200'
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD-SHELL',
|
||||
'curl -fsS http://localhost:9200/_cluster/health >/dev/null',
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 10s
|
||||
|
||||
toxiproxy:
|
||||
image: ghcr.io/shopify/toxiproxy:2.12.0
|
||||
ports:
|
||||
- '8474:8474'
|
||||
- '8666:8666'
|
||||
|
||||
proxy-init:
|
||||
image: curlimages/curl:8.12.1
|
||||
depends_on:
|
||||
opensearch:
|
||||
condition: service_healthy
|
||||
toxiproxy:
|
||||
condition: service_started
|
||||
entrypoint: ['/bin/sh', '-ec']
|
||||
command:
|
||||
- >-
|
||||
until curl -fsS http://toxiproxy:8474/version >/dev/null; do sleep 1; done;
|
||||
curl -fsS http://toxiproxy:8474/proxies/opensearch >/dev/null 2>&1 ||
|
||||
curl -fsS -X POST http://toxiproxy:8474/proxies
|
||||
-H 'content-type: application/json'
|
||||
-d '{"name":"opensearch","listen":"0.0.0.0:8666","upstream":"opensearch:9200"}';
|
||||
tail -f /dev/null
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD-SHELL',
|
||||
'curl -fsS http://toxiproxy:8474/proxies/opensearch >/dev/null',
|
||||
]
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 30
|
||||
start_period: 2s
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type Config,
|
||||
type EventBus,
|
||||
type JobQueue,
|
||||
SearchProviderNotFound,
|
||||
SearchProviderUnavailable,
|
||||
} from '../../base';
|
||||
import { ServerFeature, type ServerService } from '../../core';
|
||||
import type { DocReader } from '../../core/doc';
|
||||
@@ -450,7 +450,7 @@ test('document tools enforce the user-selected hard scope', async t => {
|
||||
readableAc,
|
||||
{
|
||||
searchDocsByKeyword: async () => {
|
||||
throw new SearchProviderNotFound();
|
||||
throw new SearchProviderUnavailable();
|
||||
},
|
||||
} as unknown as IndexerService,
|
||||
vectorSearch,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { getCurrentUserQuery } from '@affine/graphql';
|
||||
|
||||
import { JobExecutor } from '../../../base/job/queue/executor';
|
||||
import { JobHandlerScanner } from '../../../base/job/queue/scanner';
|
||||
import { DatabaseDocReader, DocReader } from '../../../core/doc';
|
||||
import { RealtimeGateway } from '../../../core/realtime/gateway';
|
||||
import { createApp } from '../create-app';
|
||||
@@ -43,7 +42,6 @@ e2e('should init worker service', async t => {
|
||||
await withFlavor('worker', async app => {
|
||||
const res = await app.GET('/info').expect(200);
|
||||
t.is(res.body.flavor, 'worker');
|
||||
t.truthy(app.get(JobHandlerScanner).getHandler('indexer.indexDoc'));
|
||||
t.throws(() => app.get(RealtimeGateway));
|
||||
|
||||
await t.throwsAsync(app.gql({ query: getCurrentUserQuery }));
|
||||
@@ -55,7 +53,6 @@ e2e('should init allinone service with worker handlers', async t => {
|
||||
await withFlavor('allinone', async app => {
|
||||
const res = await app.GET('/info').expect(200);
|
||||
t.is(res.body.flavor, 'allinone');
|
||||
t.truthy(app.get(JobHandlerScanner).getHandler('indexer.indexDoc'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import { serverConfigQuery, ServerFeature } from '@affine/graphql';
|
||||
|
||||
import { Config } from '../../../base';
|
||||
import { BackendRuntimeProvider } from '../../../core/backend-runtime';
|
||||
import { IndexerService } from '../../../plugins/indexer';
|
||||
import { app, e2e } from '../test';
|
||||
|
||||
e2e('should indexer feature enabled by default', async t => {
|
||||
const { serverConfig } = await app.gql({ query: serverConfigQuery });
|
||||
e2e(
|
||||
'should expose the indexer feature when its projection is ready',
|
||||
async t => {
|
||||
const enabled = app.get(Config).indexer.enabled;
|
||||
if (enabled) {
|
||||
await app.get(BackendRuntimeProvider).reconcileSearchProjection(1000);
|
||||
await app.get(IndexerService).onApplicationBootstrap();
|
||||
}
|
||||
const { serverConfig } = await app.gql({ query: serverConfigQuery });
|
||||
|
||||
t.is(
|
||||
serverConfig.features.includes(ServerFeature.Indexer),
|
||||
true,
|
||||
JSON.stringify(serverConfig, null, 2)
|
||||
);
|
||||
});
|
||||
t.is(
|
||||
serverConfig.features.includes(ServerFeature.Indexer),
|
||||
enabled,
|
||||
JSON.stringify(serverConfig, null, 2)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
e2e('should comment feature enabled by default', async t => {
|
||||
const { serverConfig } = await app.gql({ query: serverConfigQuery });
|
||||
|
||||
@@ -362,7 +362,9 @@ export async function createApp(
|
||||
await app.init();
|
||||
await app.get(BackendRuntimeProvider, { strict: false }).runMigrations();
|
||||
await app.get(StorageRuntimeProvider, { strict: false }).runMigrations();
|
||||
await app.get(IndexerService, { strict: false }).onApplicationBootstrap();
|
||||
if (globalThis.env.isApi || globalThis.env.isFrontend) {
|
||||
await app.get(IndexerService, { strict: false }).onApplicationBootstrap();
|
||||
}
|
||||
} catch (error) {
|
||||
await app.close();
|
||||
throw error;
|
||||
|
||||
@@ -4,14 +4,20 @@ import {
|
||||
SearchTable,
|
||||
} from '@affine/graphql';
|
||||
|
||||
import { Config } from '../../../base';
|
||||
import { BackendRuntimeProvider } from '../../../core/backend-runtime';
|
||||
import { createDocWithMarkdown } from '../../../native';
|
||||
import { IndexerService } from '../../../plugins/indexer/service';
|
||||
import { Mockers } from '../../mocks';
|
||||
import { app, e2e } from '../test';
|
||||
|
||||
e2e('should aggregate by docId', async t => {
|
||||
const indexerE2e = app.get(Config).indexer.enabled ? e2e : e2e.skip;
|
||||
|
||||
indexerE2e('should aggregate by docId', async t => {
|
||||
const owner = await app.signup();
|
||||
const workspace = await app.create(Mockers.Workspace, { owner });
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner,
|
||||
snapshot: true,
|
||||
});
|
||||
for (const [docId, markdown] of [
|
||||
['doc-0', 'hello world\n\nhello again'],
|
||||
['doc-1', 'hello world'],
|
||||
@@ -23,7 +29,7 @@ e2e('should aggregate by docId', async t => {
|
||||
user: owner,
|
||||
blob: createDocWithMarkdown(docId, markdown, docId),
|
||||
});
|
||||
await app.get(IndexerService).indexDoc(workspace.id, docId);
|
||||
await app.get(BackendRuntimeProvider).reconcileSearchProjection(1000);
|
||||
}
|
||||
|
||||
const result = await app.gql({
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
indexerSearchDocsQuery,
|
||||
indexerSearchQuery,
|
||||
SearchQueryType,
|
||||
SearchTable,
|
||||
} from '@affine/graphql';
|
||||
|
||||
import { Config } from '../../../base';
|
||||
import { BackendRuntimeProvider } from '../../../core/backend-runtime';
|
||||
import { DocRole } from '../../../models';
|
||||
import { createDocWithMarkdown } from '../../../native';
|
||||
import { Mockers } from '../../mocks';
|
||||
import { app, e2e } from '../test';
|
||||
|
||||
const manticoreSearchEnabled =
|
||||
app.get(Config).indexer.enabled &&
|
||||
app.get(Config).indexer.provider.type === 'manticoresearch';
|
||||
const manticoreSearchE2e = manticoreSearchEnabled ? e2e : e2e.skip;
|
||||
|
||||
async function indexDocument(
|
||||
workspaceId: string,
|
||||
user: { id: string },
|
||||
docId: string,
|
||||
markdown: string,
|
||||
defaultRole = DocRole.Manager
|
||||
) {
|
||||
await app.create(Mockers.DocMeta, { workspaceId, docId, defaultRole });
|
||||
await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId,
|
||||
docId,
|
||||
user,
|
||||
blob: createDocWithMarkdown(docId, markdown, docId),
|
||||
});
|
||||
await reconcileSearch();
|
||||
}
|
||||
|
||||
async function reconcileSearch() {
|
||||
const runtime = app.get(BackendRuntimeProvider);
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
await runtime.reconcileSearchProjection(1000);
|
||||
if ((await runtime.searchStatus()).ready) return;
|
||||
}
|
||||
throw new Error('search projection did not become ready');
|
||||
}
|
||||
|
||||
async function searchPage(
|
||||
workspaceId: string,
|
||||
match: string,
|
||||
pagination: { limit: number; cursor?: string }
|
||||
) {
|
||||
const result = await app.gql({
|
||||
query: indexerSearchQuery,
|
||||
variables: {
|
||||
id: workspaceId,
|
||||
input: {
|
||||
table: SearchTable.block,
|
||||
query: {
|
||||
type: SearchQueryType.match,
|
||||
field: 'content',
|
||||
match,
|
||||
},
|
||||
options: {
|
||||
fields: ['docId', 'blockId'],
|
||||
pagination,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return result.workspace.search;
|
||||
}
|
||||
|
||||
async function searchCount(workspaceId: string, match: string) {
|
||||
return (await searchPage(workspaceId, match, { limit: 20 })).pagination.count;
|
||||
}
|
||||
|
||||
async function searchDocsCount(workspaceId: string, keyword: string) {
|
||||
const result = await app.gql({
|
||||
query: indexerSearchDocsQuery,
|
||||
variables: {
|
||||
id: workspaceId,
|
||||
input: { keyword, limit: 20 },
|
||||
},
|
||||
});
|
||||
return result.workspace.searchDocs.length;
|
||||
}
|
||||
|
||||
manticoreSearchE2e(
|
||||
'indexes and searches through the Manticore Search provider',
|
||||
async t => {
|
||||
const owner = await app.signup();
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner,
|
||||
snapshot: true,
|
||||
});
|
||||
const docId = `manticore-basic-${Date.now()}`;
|
||||
const marker = 'manticorebasicmarker';
|
||||
await indexDocument(
|
||||
workspace.id,
|
||||
owner,
|
||||
docId,
|
||||
`${marker} first block\n\nsecond block`
|
||||
);
|
||||
await indexDocument(
|
||||
workspace.id,
|
||||
owner,
|
||||
`${docId}-second`,
|
||||
`${marker} from a second document`
|
||||
);
|
||||
|
||||
t.is(await searchCount(workspace.id, marker), 2);
|
||||
const basicDocs = await app.gql({
|
||||
query: indexerSearchDocsQuery,
|
||||
variables: {
|
||||
id: workspace.id,
|
||||
input: { keyword: marker, limit: 2 },
|
||||
},
|
||||
});
|
||||
t.is(basicDocs.workspace.searchDocs.length, 2);
|
||||
await t.throwsAsync(
|
||||
app.gql({
|
||||
query: indexerSearchDocsQuery,
|
||||
variables: {
|
||||
id: workspace.id,
|
||||
input: { keyword: marker, limit: 0 },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const firstPage = await searchPage(workspace.id, marker, { limit: 1 });
|
||||
const nextCursor = firstPage.pagination.nextCursor;
|
||||
t.truthy(nextCursor);
|
||||
const secondPage = await searchPage(workspace.id, marker, {
|
||||
limit: 1,
|
||||
cursor: nextCursor ?? undefined,
|
||||
});
|
||||
t.is(secondPage.nodes.length, 1);
|
||||
t.not(
|
||||
firstPage.nodes[0]?.fields.docId[0],
|
||||
secondPage.nodes[0]?.fields.docId[0]
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
manticoreSearchE2e(
|
||||
'enforces ACL changes through the Manticore Search provider',
|
||||
async t => {
|
||||
const owner = await app.signup();
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner,
|
||||
snapshot: true,
|
||||
});
|
||||
await app.create(Mockers.TeamWorkspace, { id: workspace.id });
|
||||
const marker = `manticore-acl-${Date.now()}`;
|
||||
await indexDocument(
|
||||
workspace.id,
|
||||
owner,
|
||||
`${marker}-doc`,
|
||||
marker,
|
||||
DocRole.None
|
||||
);
|
||||
|
||||
const member = await app.signup();
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
});
|
||||
await reconcileSearch();
|
||||
t.is(await searchCount(workspace.id, marker), 0);
|
||||
t.is(await searchDocsCount(workspace.id, marker), 0);
|
||||
|
||||
await app.create(Mockers.DocUser, {
|
||||
workspaceId: workspace.id,
|
||||
docId: `${marker}-doc`,
|
||||
userId: member.id,
|
||||
type: DocRole.Reader,
|
||||
});
|
||||
await reconcileSearch();
|
||||
t.true((await searchCount(workspace.id, marker)) > 0);
|
||||
t.true((await searchDocsCount(workspace.id, marker)) > 0);
|
||||
|
||||
await app.models.docUser.delete(workspace.id, `${marker}-doc`, member.id);
|
||||
await reconcileSearch();
|
||||
t.is(await searchCount(workspace.id, marker), 0);
|
||||
t.is(await searchDocsCount(workspace.id, marker), 0);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
indexerAggregateQuery,
|
||||
SearchQueryType,
|
||||
SearchTable,
|
||||
} from '@affine/graphql';
|
||||
|
||||
import { Config } from '../../../base';
|
||||
import { BackendRuntimeProvider } from '../../../core/backend-runtime';
|
||||
import { createDocWithMarkdown } from '../../../native';
|
||||
import { Mockers } from '../../mocks';
|
||||
import { app, e2e } from '../test';
|
||||
|
||||
const indexer = app.get(Config).indexer;
|
||||
const remoteE2e =
|
||||
indexer.enabled && indexer.provider.type === 'elasticsearch' ? e2e : e2e.skip;
|
||||
|
||||
async function indexDocument(
|
||||
workspaceId: string,
|
||||
user: { id: string },
|
||||
docId: string,
|
||||
markdown: string
|
||||
) {
|
||||
await app.create(Mockers.DocMeta, { workspaceId, docId });
|
||||
await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId,
|
||||
docId,
|
||||
user,
|
||||
blob: createDocWithMarkdown(docId, markdown, docId),
|
||||
});
|
||||
const runtime = app.get(BackendRuntimeProvider);
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
await runtime.reconcileSearchProjection(1000);
|
||||
if ((await runtime.searchStatus()).ready) return;
|
||||
}
|
||||
throw new Error('search projection did not become ready');
|
||||
}
|
||||
|
||||
remoteE2e(
|
||||
'exposes remote aggregation through the GraphQL contract',
|
||||
async t => {
|
||||
const owner = await app.signup();
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner,
|
||||
snapshot: true,
|
||||
});
|
||||
const suffix = Date.now();
|
||||
for (const index of [0, 1, 2]) {
|
||||
await indexDocument(
|
||||
workspace.id,
|
||||
owner,
|
||||
`remote-aggregate-${suffix}-${index}`,
|
||||
`remote aggregate marker ${index}`
|
||||
);
|
||||
}
|
||||
|
||||
const result = await app.gql({
|
||||
query: indexerAggregateQuery,
|
||||
variables: {
|
||||
id: workspace.id,
|
||||
input: {
|
||||
table: SearchTable.block,
|
||||
query: {
|
||||
type: SearchQueryType.match,
|
||||
field: 'content',
|
||||
match: 'remote aggregate marker',
|
||||
},
|
||||
field: 'docId',
|
||||
options: {
|
||||
pagination: { limit: 2, skip: 0 },
|
||||
hits: {
|
||||
pagination: { limit: 1, skip: 0 },
|
||||
fields: ['docId', 'blockId'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.is(result.workspace.aggregate.buckets.length, 2);
|
||||
t.is(result.workspace.aggregate.pagination.count, 2);
|
||||
t.true(result.workspace.aggregate.pagination.hasMore);
|
||||
}
|
||||
);
|
||||
@@ -1,15 +1,19 @@
|
||||
import { indexerSearchDocsQuery } from '@affine/graphql';
|
||||
|
||||
import { ConfigFactory } from '../../../base';
|
||||
import { Config } from '../../../base';
|
||||
import { BackendRuntimeProvider } from '../../../core/backend-runtime';
|
||||
import { createDocWithMarkdown } from '../../../native';
|
||||
import { SearchProviderType } from '../../../plugins/indexer/config';
|
||||
import { IndexerService } from '../../../plugins/indexer/service';
|
||||
import { Mockers } from '../../mocks';
|
||||
import { app, e2e } from '../test';
|
||||
|
||||
e2e('should search docs by keyword', async t => {
|
||||
const indexerE2e = app.get(Config).indexer.enabled ? e2e : e2e.skip;
|
||||
|
||||
indexerE2e('should search docs by keyword', async t => {
|
||||
const owner = await app.signup();
|
||||
const workspace = await app.create(Mockers.Workspace, { owner });
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner,
|
||||
snapshot: true,
|
||||
});
|
||||
for (const docId of ['doc-0', 'doc-1', 'doc-2']) {
|
||||
await app.create(Mockers.DocMeta, { workspaceId: workspace.id, docId });
|
||||
await app.create(Mockers.DocSnapshot, {
|
||||
@@ -18,33 +22,26 @@ e2e('should search docs by keyword', async t => {
|
||||
user: owner,
|
||||
blob: createDocWithMarkdown(docId, `${docId} hello`, docId),
|
||||
});
|
||||
await app.get(IndexerService).indexDoc(workspace.id, docId);
|
||||
await app.get(BackendRuntimeProvider).reconcileSearchProjection(1000);
|
||||
}
|
||||
|
||||
const search = app.gql({
|
||||
query: indexerSearchDocsQuery,
|
||||
variables: { id: workspace.id, input: { keyword: 'hello', limit: 2 } },
|
||||
});
|
||||
if (
|
||||
app.get(ConfigFactory).config.indexer.provider.type ===
|
||||
SearchProviderType.Manticoresearch
|
||||
) {
|
||||
await t.throwsAsync(search, {
|
||||
message: /Invalid indexer input: unsupported_query/,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await search;
|
||||
t.is(result.workspace.searchDocs.length, 2);
|
||||
t.true(result.workspace.searchDocs.every(doc => doc.highlight.length > 0));
|
||||
});
|
||||
|
||||
e2e(
|
||||
indexerE2e(
|
||||
'should search docs by keyword failed when workspace is no permission',
|
||||
async t => {
|
||||
const owner = await app.signup();
|
||||
const workspace = await app.create(Mockers.Workspace, { owner });
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner,
|
||||
snapshot: true,
|
||||
});
|
||||
await app.signup();
|
||||
await t.throwsAsync(
|
||||
app.gql({
|
||||
|
||||
@@ -4,12 +4,15 @@ import {
|
||||
SearchTable,
|
||||
} from '@affine/graphql';
|
||||
|
||||
import { Config } from '../../../base';
|
||||
import { BackendRuntimeProvider } from '../../../core/backend-runtime';
|
||||
import { DocRole } from '../../../models';
|
||||
import { createDocWithMarkdown } from '../../../native';
|
||||
import { IndexerService } from '../../../plugins/indexer/service';
|
||||
import { Mockers } from '../../mocks';
|
||||
import { app, e2e } from '../test';
|
||||
|
||||
const indexerE2e = app.get(Config).indexer.enabled ? e2e : e2e.skip;
|
||||
|
||||
async function indexDoc(
|
||||
workspaceId: string,
|
||||
user: { id: string },
|
||||
@@ -24,12 +27,15 @@ async function indexDoc(
|
||||
user,
|
||||
blob: createDocWithMarkdown(docId, markdown, docId),
|
||||
});
|
||||
await app.get(IndexerService).indexDoc(workspaceId, docId);
|
||||
await app.get(BackendRuntimeProvider).reconcileSearchProjection(1000);
|
||||
}
|
||||
|
||||
e2e('should search with query', async t => {
|
||||
indexerE2e('should search with query', async t => {
|
||||
const owner = await app.signup();
|
||||
const workspace = await app.create(Mockers.Workspace, { owner });
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner,
|
||||
snapshot: true,
|
||||
});
|
||||
await indexDoc(
|
||||
workspace.id,
|
||||
owner,
|
||||
@@ -114,100 +120,116 @@ e2e('should search with query', async t => {
|
||||
);
|
||||
});
|
||||
|
||||
e2e('should filter no read permission docs on team workspace', async t => {
|
||||
const owner = await app.signup();
|
||||
const workspace = await app.create(Mockers.Workspace, { owner });
|
||||
await app.create(Mockers.TeamWorkspace, { id: workspace.id });
|
||||
await indexDoc(
|
||||
workspace.id,
|
||||
owner,
|
||||
'private-doc',
|
||||
'team secret searchable',
|
||||
DocRole.None
|
||||
);
|
||||
indexerE2e(
|
||||
'should filter no read permission docs on team workspace',
|
||||
async t => {
|
||||
const owner = await app.signup();
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner,
|
||||
snapshot: true,
|
||||
});
|
||||
await app.create(Mockers.TeamWorkspace, { id: workspace.id });
|
||||
await indexDoc(
|
||||
workspace.id,
|
||||
owner,
|
||||
'private-doc',
|
||||
'team secret searchable',
|
||||
DocRole.None
|
||||
);
|
||||
|
||||
const member = await app.signup();
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
});
|
||||
await app.get(IndexerService).reconcileWorkspace(workspace.id);
|
||||
const denied = await app.gql({
|
||||
query: indexerSearchQuery,
|
||||
variables: {
|
||||
id: workspace.id,
|
||||
input: {
|
||||
table: SearchTable.block,
|
||||
query: {
|
||||
type: SearchQueryType.match,
|
||||
field: 'content',
|
||||
match: 'secret',
|
||||
const member = await app.signup();
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
});
|
||||
await app.get(BackendRuntimeProvider).reconcileSearchProjection(1000);
|
||||
const denied = await app.gql({
|
||||
query: indexerSearchQuery,
|
||||
variables: {
|
||||
id: workspace.id,
|
||||
input: {
|
||||
table: SearchTable.block,
|
||||
query: {
|
||||
type: SearchQueryType.match,
|
||||
field: 'content',
|
||||
match: 'secret',
|
||||
},
|
||||
options: { fields: ['docId'], pagination: { limit: 10 } },
|
||||
},
|
||||
options: { fields: ['docId'], pagination: { limit: 10 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
t.is(denied.workspace.search.pagination.count, 0);
|
||||
});
|
||||
t.is(denied.workspace.search.pagination.count, 0);
|
||||
|
||||
await app.create(Mockers.DocUser, {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'private-doc',
|
||||
userId: member.id,
|
||||
type: DocRole.Reader,
|
||||
});
|
||||
await app.get(IndexerService).reconcileWorkspace(workspace.id);
|
||||
const allowed = await app.gql({
|
||||
query: indexerSearchQuery,
|
||||
variables: {
|
||||
id: workspace.id,
|
||||
input: {
|
||||
table: SearchTable.block,
|
||||
query: {
|
||||
type: SearchQueryType.match,
|
||||
field: 'content',
|
||||
match: 'secret',
|
||||
await app.create(Mockers.DocUser, {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'private-doc',
|
||||
userId: member.id,
|
||||
type: DocRole.Reader,
|
||||
});
|
||||
await app.get(BackendRuntimeProvider).reconcileSearchProjection(1000);
|
||||
const allowed = await app.gql({
|
||||
query: indexerSearchQuery,
|
||||
variables: {
|
||||
id: workspace.id,
|
||||
input: {
|
||||
table: SearchTable.block,
|
||||
query: {
|
||||
type: SearchQueryType.match,
|
||||
field: 'content',
|
||||
match: 'secret',
|
||||
},
|
||||
options: { fields: ['docId'], pagination: { limit: 10 } },
|
||||
},
|
||||
options: { fields: ['docId'], pagination: { limit: 10 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
t.true(allowed.workspace.search.pagination.count > 0);
|
||||
});
|
||||
t.true(allowed.workspace.search.pagination.count > 0);
|
||||
|
||||
await app.models.docUser.delete(workspace.id, 'private-doc', member.id);
|
||||
await app.get(IndexerService).reconcileWorkspace(workspace.id);
|
||||
const revoked = await app.gql({
|
||||
query: indexerSearchQuery,
|
||||
variables: {
|
||||
id: workspace.id,
|
||||
input: {
|
||||
table: SearchTable.block,
|
||||
query: {
|
||||
type: SearchQueryType.match,
|
||||
field: 'content',
|
||||
match: 'secret',
|
||||
await app.models.docUser.delete(workspace.id, 'private-doc', member.id);
|
||||
await app.get(BackendRuntimeProvider).reconcileSearchProjection(1000);
|
||||
const revoked = await app.gql({
|
||||
query: indexerSearchQuery,
|
||||
variables: {
|
||||
id: workspace.id,
|
||||
input: {
|
||||
table: SearchTable.block,
|
||||
query: {
|
||||
type: SearchQueryType.match,
|
||||
field: 'content',
|
||||
match: 'secret',
|
||||
},
|
||||
options: { fields: ['docId'], pagination: { limit: 10 } },
|
||||
},
|
||||
options: { fields: ['docId'], pagination: { limit: 10 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
t.is(revoked.workspace.search.pagination.count, 0);
|
||||
});
|
||||
});
|
||||
t.is(revoked.workspace.search.pagination.count, 0);
|
||||
}
|
||||
);
|
||||
|
||||
e2e('should return empty results when search not match any docs', async t => {
|
||||
const owner = await app.signup();
|
||||
const workspace = await app.create(Mockers.Workspace, { owner });
|
||||
await app.get(IndexerService).reconcileWorkspace(workspace.id);
|
||||
const result = await app.gql({
|
||||
query: indexerSearchQuery,
|
||||
variables: {
|
||||
id: workspace.id,
|
||||
input: {
|
||||
table: SearchTable.doc,
|
||||
query: { type: SearchQueryType.match, field: 'title', match: 'absent' },
|
||||
options: { fields: ['docId'], pagination: { limit: 10 } },
|
||||
indexerE2e(
|
||||
'should return empty results when search not match any docs',
|
||||
async t => {
|
||||
const owner = await app.signup();
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner,
|
||||
snapshot: true,
|
||||
});
|
||||
await app.get(BackendRuntimeProvider).reconcileSearchProjection(1000);
|
||||
const result = await app.gql({
|
||||
query: indexerSearchQuery,
|
||||
variables: {
|
||||
id: workspace.id,
|
||||
input: {
|
||||
table: SearchTable.doc,
|
||||
query: {
|
||||
type: SearchQueryType.match,
|
||||
field: 'title',
|
||||
match: 'absent',
|
||||
},
|
||||
options: { fields: ['docId'], pagination: { limit: 10 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
t.is(result.workspace.search.pagination.count, 0);
|
||||
t.deepEqual(result.workspace.search.nodes, []);
|
||||
});
|
||||
});
|
||||
t.is(result.workspace.search.pagination.count, 0);
|
||||
t.deepEqual(result.workspace.search.nodes, []);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -6,14 +6,18 @@ export const TEST_LOG_LEVEL: LogLevel =
|
||||
(process.env.TEST_LOG_LEVEL as LogLevel) ?? 'fatal';
|
||||
|
||||
async function flushDB(client: PrismaClient) {
|
||||
const result: { tablename: string }[] =
|
||||
await client.$queryRaw`SELECT tablename
|
||||
const result: { schemaname: string; tablename: string }[] =
|
||||
await client.$queryRaw`SELECT schemaname, tablename
|
||||
FROM pg_catalog.pg_tables
|
||||
WHERE schemaname != 'pg_catalog'
|
||||
AND schemaname != 'information_schema'`;
|
||||
const query = `TRUNCATE TABLE ${result
|
||||
.map(({ tablename }) => tablename)
|
||||
.filter(name => !name.includes('migrations'))
|
||||
.filter(({ tablename }) => !tablename.includes('migrations'))
|
||||
.map(({ schemaname, tablename }) =>
|
||||
[schemaname, tablename]
|
||||
.map(identifier => `"${identifier.replaceAll('"', '""')}"`)
|
||||
.join('.')
|
||||
)
|
||||
.join(', ')}`;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
|
||||
@@ -61,7 +61,7 @@ import { CalendarModule } from './plugins/calendar';
|
||||
import { CaptchaModule } from './plugins/captcha';
|
||||
import { CopilotModule } from './plugins/copilot';
|
||||
import { GCloudModule } from './plugins/gcloud';
|
||||
import { IndexerModule, IndexerWorkerModule } from './plugins/indexer';
|
||||
import { IndexerModule } from './plugins/indexer';
|
||||
import { LicenseModule } from './plugins/license';
|
||||
import { OAuthModule } from './plugins/oauth';
|
||||
import { PaymentModule } from './plugins/payment';
|
||||
@@ -169,9 +169,8 @@ export function buildAppModule(env: Env) {
|
||||
.use(...FunctionalityModules)
|
||||
.useIf(() => !workerOnly, RealtimeGatewayModule)
|
||||
|
||||
// online roles publish indexer events; only the worker registers consumers
|
||||
// Search API and worker runtime are separate from the queue worker application.
|
||||
.useIf(() => env.isApi || env.isFrontend, IndexerModule)
|
||||
.useIf(() => env.isWorker, IndexerWorkerModule)
|
||||
|
||||
// the worker owns doc consumers and schedulers
|
||||
.useIf(() => env.isWorker, DocJobsModule)
|
||||
|
||||
@@ -14,6 +14,7 @@ export type UserFriendlyErrorBaseType =
|
||||
| 'no_permission'
|
||||
| 'quota_exceeded'
|
||||
| 'authentication_required'
|
||||
| 'service_unavailable'
|
||||
| 'internal_server_error';
|
||||
|
||||
type ErrorArgType = 'string' | 'number' | 'boolean';
|
||||
@@ -36,6 +37,7 @@ const BaseTypeToHttpStatusMap: Record<UserFriendlyErrorBaseType, HttpStatus> = {
|
||||
no_permission: HttpStatus.FORBIDDEN,
|
||||
quota_exceeded: HttpStatus.PAYMENT_REQUIRED,
|
||||
authentication_required: HttpStatus.UNAUTHORIZED,
|
||||
service_unavailable: HttpStatus.SERVICE_UNAVAILABLE,
|
||||
internal_server_error: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
@@ -969,6 +971,25 @@ export const USER_FRIENDLY_ERRORS = {
|
||||
},
|
||||
|
||||
// indexer errors
|
||||
search_index_not_ready: {
|
||||
type: 'service_unavailable',
|
||||
args: { spaceId: 'string' },
|
||||
message: ({ spaceId }) =>
|
||||
`Search index for Space ${spaceId} is not ready yet.`,
|
||||
},
|
||||
search_permission_syncing: {
|
||||
type: 'service_unavailable',
|
||||
message: 'Search permissions are still syncing. Please try again shortly.',
|
||||
},
|
||||
search_provider_unavailable: {
|
||||
type: 'service_unavailable',
|
||||
message: 'Search provider is temporarily unavailable.',
|
||||
},
|
||||
search_index_failed: {
|
||||
type: 'service_unavailable',
|
||||
args: { diagnosticId: 'string' },
|
||||
message: 'Search index is temporarily unavailable.',
|
||||
},
|
||||
search_provider_not_found: {
|
||||
type: 'resource_not_found',
|
||||
message: 'Search provider not found.',
|
||||
|
||||
@@ -1123,6 +1123,38 @@ export class InvalidAppConfigInput extends UserFriendlyError {
|
||||
super('invalid_input', 'invalid_app_config_input', message, args);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class SearchIndexNotReadyDataType {
|
||||
@Field() spaceId!: string
|
||||
}
|
||||
|
||||
export class SearchIndexNotReady extends UserFriendlyError {
|
||||
constructor(args: SearchIndexNotReadyDataType, message?: string | ((args: SearchIndexNotReadyDataType) => string)) {
|
||||
super('service_unavailable', 'search_index_not_ready', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class SearchPermissionSyncing extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('service_unavailable', 'search_permission_syncing', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class SearchProviderUnavailable extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('service_unavailable', 'search_provider_unavailable', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class SearchIndexFailedDataType {
|
||||
@Field() diagnosticId!: string
|
||||
}
|
||||
|
||||
export class SearchIndexFailed extends UserFriendlyError {
|
||||
constructor(args: SearchIndexFailedDataType, message?: string | ((args: SearchIndexFailedDataType) => string)) {
|
||||
super('service_unavailable', 'search_index_failed', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class SearchProviderNotFound extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
@@ -1320,6 +1352,10 @@ export enum ErrorNames {
|
||||
MENTION_USER_ONESELF_DENIED,
|
||||
INVALID_APP_CONFIG,
|
||||
INVALID_APP_CONFIG_INPUT,
|
||||
SEARCH_INDEX_NOT_READY,
|
||||
SEARCH_PERMISSION_SYNCING,
|
||||
SEARCH_PROVIDER_UNAVAILABLE,
|
||||
SEARCH_INDEX_FAILED,
|
||||
SEARCH_PROVIDER_NOT_FOUND,
|
||||
INVALID_SEARCH_PROVIDER_REQUEST,
|
||||
INVALID_INDEXER_INPUT,
|
||||
@@ -1335,5 +1371,5 @@ registerEnumType(ErrorNames, {
|
||||
export const ErrorDataUnionType = createUnionType({
|
||||
name: 'ErrorDataUnion',
|
||||
types: () =>
|
||||
[GraphqlBadRequestDataType, HttpRequestErrorDataType, SsrfBlockedErrorDataType, ResponseTooLargeErrorDataType, ImageFormatNotSupportedDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CalendarProviderRequestErrorDataType, NoCopilotProviderAvailableDataType, CopilotFailedToGenerateEmbeddingDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotFailedToAddWorkspaceArtifactDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, UnsupportedServerVersionDataType, MentionUserDocAccessDeniedDataType, InvalidAppConfigDataType, InvalidAppConfigInputDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const,
|
||||
[GraphqlBadRequestDataType, HttpRequestErrorDataType, SsrfBlockedErrorDataType, ResponseTooLargeErrorDataType, ImageFormatNotSupportedDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CalendarProviderRequestErrorDataType, NoCopilotProviderAvailableDataType, CopilotFailedToGenerateEmbeddingDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotFailedToAddWorkspaceArtifactDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, UnsupportedServerVersionDataType, MentionUserDocAccessDeniedDataType, InvalidAppConfigDataType, InvalidAppConfigInputDataType, SearchIndexNotReadyDataType, SearchIndexFailedDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const,
|
||||
});
|
||||
|
||||
@@ -71,14 +71,6 @@ defineModuleConfig('job', {
|
||||
schema,
|
||||
},
|
||||
|
||||
'queues.indexer': {
|
||||
desc: 'The config for indexer job queue',
|
||||
default: {
|
||||
concurrency: 1,
|
||||
},
|
||||
schema,
|
||||
},
|
||||
|
||||
'queues.notification': {
|
||||
desc: 'The config for notification job queue',
|
||||
default: {
|
||||
|
||||
@@ -27,7 +27,6 @@ export enum Queue {
|
||||
NOTIFICATION = 'notification',
|
||||
DOC = 'doc',
|
||||
COPILOT = 'copilot',
|
||||
INDEXER = 'indexer',
|
||||
CALENDAR = 'calendar',
|
||||
BACKENDRUNTIME = 'backendRuntime',
|
||||
INVITE_ABUSE = 'inviteAbuse',
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { ServerRole } from '../../../env';
|
||||
import { Queue, QUEUES } from './def';
|
||||
|
||||
export const WORKER_QUEUES = [
|
||||
Queue.DOC,
|
||||
Queue.INDEXER,
|
||||
Queue.BACKENDRUNTIME,
|
||||
] as const;
|
||||
export const WORKER_QUEUES = [Queue.DOC, Queue.BACKENDRUNTIME] as const;
|
||||
|
||||
export function queuesForRole(role: ServerRole | undefined): Queue[] {
|
||||
switch (role) {
|
||||
|
||||
@@ -63,6 +63,7 @@ export type KnownMetricScopes =
|
||||
| 'storage'
|
||||
| 'process'
|
||||
| 'permission'
|
||||
| 'search'
|
||||
| 'workspace';
|
||||
|
||||
const metricCreators: MetricCreators = {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
BackendRuntimeEmbeddingProducer,
|
||||
BackendRuntimeEmbeddingService,
|
||||
BackendRuntimeHousekeepingJob,
|
||||
BackendRuntimeSearchJob,
|
||||
} from './job';
|
||||
import {
|
||||
BACKEND_RUNTIME_CONFIG_PATHS,
|
||||
@@ -33,7 +34,11 @@ export class BackendRuntimeProducerModule {}
|
||||
|
||||
@Module({
|
||||
imports: [BackendRuntimeModule],
|
||||
providers: [BackendRuntimeEmbeddingJob, BackendRuntimeHousekeepingJob],
|
||||
providers: [
|
||||
BackendRuntimeEmbeddingJob,
|
||||
BackendRuntimeHousekeepingJob,
|
||||
BackendRuntimeSearchJob,
|
||||
],
|
||||
})
|
||||
export class BackendRuntimeWorkerModule {}
|
||||
|
||||
@@ -42,6 +47,7 @@ export {
|
||||
BackendRuntimeEmbeddingProducer,
|
||||
BackendRuntimeEmbeddingService,
|
||||
BackendRuntimeHousekeepingJob,
|
||||
BackendRuntimeSearchJob,
|
||||
} from './job';
|
||||
export {
|
||||
BACKEND_RUNTIME_CONFIG_PATHS,
|
||||
|
||||
@@ -2,11 +2,13 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import {
|
||||
ConfigFactory,
|
||||
CopilotSelectedSourcesFailed,
|
||||
CopilotSelectedSourcesLimitExceeded,
|
||||
CopilotSelectedSourcesProcessing,
|
||||
CopilotSelectedSourcesUnavailable,
|
||||
JobQueue,
|
||||
metrics,
|
||||
OnEvent,
|
||||
OnJob,
|
||||
} from '../../base';
|
||||
@@ -30,6 +32,9 @@ declare global {
|
||||
'backendRuntime.reconcileDocumentEmbeddings': {
|
||||
workspaceId: string;
|
||||
};
|
||||
'backendRuntime.reconcileSearchProjection': {
|
||||
limit?: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,3 +262,98 @@ export class BackendRuntimeHousekeepingJob {
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BackendRuntimeSearchJob {
|
||||
constructor(
|
||||
private readonly rt: BackendRuntimeProvider,
|
||||
private readonly queue: JobQueue,
|
||||
private readonly config: ConfigFactory
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_30_SECONDS)
|
||||
async scheduleReconciliation() {
|
||||
if (!this.config.config.indexer.enabled) return;
|
||||
await this.queue.add(
|
||||
'backendRuntime.reconcileSearchProjection',
|
||||
{ limit: 100 },
|
||||
{ jobId: 'backend-runtime-search-reconciliation', removeOnFail: true }
|
||||
);
|
||||
}
|
||||
|
||||
@OnJob('backendRuntime.reconcileSearchProjection')
|
||||
async reconcileProjection({
|
||||
limit = 100,
|
||||
}: Jobs['backendRuntime.reconcileSearchProjection']) {
|
||||
if (!this.config.config.indexer.enabled) return 0;
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const reconciled = await this.rt.reconcileSearchProjection(limit);
|
||||
const status = (await this.rt.searchStatus()) as {
|
||||
ready?: boolean;
|
||||
state?: string;
|
||||
metrics?: {
|
||||
scanCursor?: number;
|
||||
scanHighWater?: number;
|
||||
pendingPublications?: number;
|
||||
gcBacklog?: number;
|
||||
providerRequests?: number;
|
||||
providerLatencyMicrosAvg?: number;
|
||||
generationGcFailures?: number;
|
||||
filterDrops?: {
|
||||
missingPublished?: number;
|
||||
projectionMismatch?: number;
|
||||
canonicalPermission?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
metrics.search.counter('reconcile_runs').add(1);
|
||||
metrics.search.gauge('reconciled_workspaces').record(reconciled);
|
||||
metrics.search.gauge('generation_ready').record(status.ready ? 1 : 0, {
|
||||
state: status.state ?? 'unknown',
|
||||
});
|
||||
metrics.search
|
||||
.histogram('reconcile_latency_ms')
|
||||
.record(performance.now() - startedAt);
|
||||
const projection = status.metrics;
|
||||
if (projection) {
|
||||
metrics.search.gauge('scan_cursor').record(projection.scanCursor ?? 0);
|
||||
metrics.search
|
||||
.gauge('scan_high_water')
|
||||
.record(projection.scanHighWater ?? 0);
|
||||
metrics.search
|
||||
.gauge('pending_publications')
|
||||
.record(projection.pendingPublications ?? 0);
|
||||
metrics.search.gauge('gc_backlog').record(projection.gcBacklog ?? 0);
|
||||
metrics.search
|
||||
.gauge('provider_requests')
|
||||
.record(projection.providerRequests ?? 0);
|
||||
metrics.search
|
||||
.gauge('provider_latency_micros_avg')
|
||||
.record(projection.providerLatencyMicrosAvg ?? 0);
|
||||
metrics.search
|
||||
.gauge('generation_gc_failures')
|
||||
.record(projection.generationGcFailures ?? 0);
|
||||
metrics.search
|
||||
.gauge('filter_drops')
|
||||
.record(projection.filterDrops?.missingPublished ?? 0, {
|
||||
reason: 'missing_published',
|
||||
});
|
||||
metrics.search
|
||||
.gauge('filter_drops')
|
||||
.record(projection.filterDrops?.projectionMismatch ?? 0, {
|
||||
reason: 'projection_mismatch',
|
||||
});
|
||||
metrics.search
|
||||
.gauge('filter_drops')
|
||||
.record(projection.filterDrops?.canonicalPermission ?? 0, {
|
||||
reason: 'canonical_permission',
|
||||
});
|
||||
}
|
||||
return reconciled;
|
||||
} catch (error) {
|
||||
metrics.search.counter('reconcile_failures').add(1);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,27 +385,9 @@ export class BackendRuntimeProvider
|
||||
);
|
||||
}
|
||||
|
||||
async indexSearchDocument(workspaceId: string, docId: string) {
|
||||
await this.measured('indexSearchDocument', runtime =>
|
||||
runtime.indexSearchDocument(workspaceId, docId)
|
||||
);
|
||||
}
|
||||
|
||||
async deleteSearchDocument(workspaceId: string, docId: string) {
|
||||
await this.measured('deleteSearchDocument', runtime =>
|
||||
runtime.deleteSearchDocument(workspaceId, docId)
|
||||
);
|
||||
}
|
||||
|
||||
async reconcileSearchWorkspace(workspaceId: string) {
|
||||
await this.measured('reconcileSearchWorkspace', runtime =>
|
||||
runtime.reconcileSearchWorkspace(workspaceId)
|
||||
);
|
||||
}
|
||||
|
||||
async deleteSearchWorkspace(workspaceId: string) {
|
||||
await this.measured('deleteSearchWorkspace', runtime =>
|
||||
runtime.deleteSearchWorkspace(workspaceId)
|
||||
async reconcileSearchProjection(limit = 100) {
|
||||
return await this.measured('reconcileSearchProjection', runtime =>
|
||||
runtime.reconcileSearchProjection(limit)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createModule } from '../../../__tests__/create-module';
|
||||
import { Mockers } from '../../../__tests__/mocks';
|
||||
import { InvalidAppConfigInput } from '../../../base';
|
||||
import { Models } from '../../../models';
|
||||
import { SearchProviderType } from '../../../plugins/indexer/config';
|
||||
import { ServerService } from '../service';
|
||||
|
||||
const module = await createModule({
|
||||
@@ -40,6 +41,19 @@ test('should update config', async t => {
|
||||
t.is(service.getConfig().server.externalUrl, newValue);
|
||||
});
|
||||
|
||||
test('should enable the selected indexer provider', async t => {
|
||||
await service.updateConfig(user.id, [
|
||||
{
|
||||
module: 'indexer',
|
||||
key: 'provider.type',
|
||||
value: SearchProviderType.Embedded,
|
||||
},
|
||||
]);
|
||||
|
||||
t.true(service.getConfig().indexer.enabled);
|
||||
t.is(service.getConfig().indexer.provider.type, SearchProviderType.Embedded);
|
||||
});
|
||||
|
||||
test('should validate config before update', async t => {
|
||||
await t.throwsAsync(
|
||||
service.updateConfig(user.id, [
|
||||
|
||||
@@ -76,15 +76,7 @@ export class ServerService implements OnApplicationBootstrap {
|
||||
const providerType = updates.find(
|
||||
update => update.module === 'indexer' && update.key === 'provider.type'
|
||||
);
|
||||
if (providerType?.value === 'embedded') {
|
||||
updates = updates.filter(update => update !== providerType);
|
||||
updates = [
|
||||
...updates.filter(
|
||||
update => !(update.module === 'indexer' && update.key === 'enabled')
|
||||
),
|
||||
{ module: 'indexer', key: 'enabled', value: false },
|
||||
];
|
||||
} else if (providerType) {
|
||||
if (providerType) {
|
||||
updates = [
|
||||
...updates.filter(
|
||||
update => !(update.module === 'indexer' && update.key === 'enabled')
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import test from 'ava';
|
||||
|
||||
import { InternalServerError } from '../../../base';
|
||||
import {
|
||||
InternalServerError,
|
||||
SearchIndexFailed,
|
||||
SearchIndexNotReady,
|
||||
SearchPermissionSyncing,
|
||||
SearchProviderUnavailable,
|
||||
SpaceAccessDenied,
|
||||
} from '../../../base';
|
||||
import { DocRole } from '../../../models';
|
||||
import { docLegacyBoundary } from '../context';
|
||||
import { PermissionContextLoader } from '../context-loader';
|
||||
@@ -23,9 +30,11 @@ function createLoader() {
|
||||
docPolicies: 0,
|
||||
docGrants: 0,
|
||||
};
|
||||
const queries: string[] = [];
|
||||
const db = {
|
||||
$queryRaw: async (strings: TemplateStringsArray) => {
|
||||
const sql = strings.join('');
|
||||
queries.push(sql);
|
||||
if (sql.includes('FROM workspace_members')) {
|
||||
calls.members += 1;
|
||||
return [{ role: 'owner', state: 'active' }];
|
||||
@@ -82,6 +91,7 @@ function createLoader() {
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
queries,
|
||||
loader: new PermissionContextLoader(db as never, createCls() as never),
|
||||
};
|
||||
}
|
||||
@@ -196,7 +206,7 @@ test('PermissionService supports anonymous preview without doc read', async t =>
|
||||
});
|
||||
|
||||
test('PermissionContextLoader reads only terminal permission tables', async t => {
|
||||
const { loader } = createLoader();
|
||||
const { loader, queries } = createLoader();
|
||||
const input = await loader.load({
|
||||
userId: 'u1',
|
||||
workspaceId: 'w1',
|
||||
@@ -212,6 +222,9 @@ test('PermissionContextLoader reads only terminal permission tables', async t =>
|
||||
t.is(input.docs?.[0]?.explicitUserRole, 'manager');
|
||||
t.is(input.docs?.[0]?.memberDefaultRole, 'manager');
|
||||
t.is(input.docs?.[1]?.publicRole, 'external');
|
||||
t.false(
|
||||
queries.some(query => /workspace_permission_|search_runtime_/i.test(query))
|
||||
);
|
||||
});
|
||||
|
||||
test('PermissionContextLoader treats missing quota state as unknown and stale', async t => {
|
||||
@@ -261,3 +274,11 @@ test('PermissionService maps native validation errors to internal errors', t =>
|
||||
|
||||
t.true(error instanceof InternalServerError);
|
||||
});
|
||||
|
||||
test('search and permission boundaries expose stable HTTP contracts', t => {
|
||||
t.is(new SearchIndexNotReady({ spaceId: 'w1' }).status, 503);
|
||||
t.is(new SearchPermissionSyncing().status, 503);
|
||||
t.is(new SearchProviderUnavailable().status, 503);
|
||||
t.is(new SearchIndexFailed({ diagnosticId: 'w1' }).status, 503);
|
||||
t.is(new SpaceAccessDenied({ spaceId: 'w1' }).status, 403);
|
||||
});
|
||||
|
||||
@@ -287,17 +287,6 @@ export class StorageRuntimeProvider
|
||||
);
|
||||
}
|
||||
|
||||
async ackDocumentCleanupEffect(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
cleanupVersion: string,
|
||||
effect: 'search' | 'copilot'
|
||||
) {
|
||||
return await this.measured('ackDocumentCleanupEffect', rt =>
|
||||
rt.ackDocumentCleanupEffect(workspaceId, docId, cleanupVersion, effect)
|
||||
);
|
||||
}
|
||||
|
||||
async planUnreferencedWorkspaceBlobs(
|
||||
workspaceId: string,
|
||||
gracePeriodDays: number,
|
||||
|
||||
@@ -13,7 +13,6 @@ interface Context {
|
||||
planUnreferencedWorkspaceBlobs: Sinon.SinonStub;
|
||||
executeBlobCleanupCandidates: Sinon.SinonStub;
|
||||
executeDocumentCleanupCandidates: Sinon.SinonStub;
|
||||
ackDocumentCleanupEffect: Sinon.SinonStub;
|
||||
};
|
||||
event: {
|
||||
emitAsync: Sinon.SinonStub;
|
||||
@@ -51,7 +50,6 @@ test.beforeEach(t => {
|
||||
planUnreferencedWorkspaceBlobs: Sinon.stub(),
|
||||
executeBlobCleanupCandidates: Sinon.stub(),
|
||||
executeDocumentCleanupCandidates: Sinon.stub(),
|
||||
ackDocumentCleanupEffect: Sinon.stub(),
|
||||
};
|
||||
t.context.event = {
|
||||
emitAsync: Sinon.stub().resolves(undefined),
|
||||
@@ -316,7 +314,7 @@ test('document projection worker drains metadata incrementally after a document
|
||||
);
|
||||
});
|
||||
|
||||
test('document cleanup dispatches stable search effects', async t => {
|
||||
test('document cleanup emits blob updates after comment object cleanup', async t => {
|
||||
t.context.runtime.executeDocumentCleanupCandidates.resolves({
|
||||
scannedCandidates: 1,
|
||||
serializationRetries: 0,
|
||||
@@ -331,22 +329,13 @@ test('document cleanup dispatches stable search effects', async t => {
|
||||
docId: 'doc-1',
|
||||
cleanupVersion: 'version-1',
|
||||
commentObjectsDone: true,
|
||||
searchDone: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await t.context.job.executeDocumentCleanupCandidates({});
|
||||
|
||||
t.true(
|
||||
t.context.queue.add.calledWith(
|
||||
'indexer.reconcileDocumentCleanup',
|
||||
Sinon.match({ docId: 'doc-1' }),
|
||||
{
|
||||
jobId: 'document-cleanup:search:workspace-1:doc-1:version-1',
|
||||
}
|
||||
)
|
||||
);
|
||||
t.false(t.context.queue.add.called);
|
||||
t.true(
|
||||
t.context.event.emitAsync.calledWith('workspace.blobs.updated', {
|
||||
workspaceId: 'workspace-1',
|
||||
@@ -354,22 +343,6 @@ test('document cleanup dispatches stable search effects', async t => {
|
||||
);
|
||||
});
|
||||
|
||||
test('document cleanup effect ack delegates to storage runtime', async t => {
|
||||
await t.context.job.ackDocumentCleanupEffect({
|
||||
workspaceId: 'workspace-1',
|
||||
docId: 'doc-1',
|
||||
cleanupVersion: 'version-1',
|
||||
effect: 'search',
|
||||
});
|
||||
|
||||
t.deepEqual(t.context.runtime.ackDocumentCleanupEffect.firstCall.args, [
|
||||
'workspace-1',
|
||||
'doc-1',
|
||||
'version-1',
|
||||
'search',
|
||||
]);
|
||||
});
|
||||
|
||||
test('blob cleanup execution sweep drains marked runs and continues by page', async t => {
|
||||
t.context.db.$queryRaw
|
||||
.onFirstCall()
|
||||
|
||||
@@ -33,12 +33,6 @@ declare global {
|
||||
gracePeriodDays?: number;
|
||||
limit?: number;
|
||||
};
|
||||
'backendRuntime.ackDocumentCleanupEffect': {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
cleanupVersion: string;
|
||||
effect: 'search' | 'copilot';
|
||||
};
|
||||
'backendRuntime.planUnreferencedWorkspaceBlobs': {
|
||||
workspaceId: string;
|
||||
gracePeriodDays?: number;
|
||||
@@ -321,11 +315,6 @@ export class StorageBlobJob {
|
||||
.counter('document_cleanup_execute_failure_total')
|
||||
.add(result.failed);
|
||||
for (const effect of result.effects) {
|
||||
if (!effect.searchDone) {
|
||||
await this.queue.add('indexer.reconcileDocumentCleanup', effect, {
|
||||
jobId: `document-cleanup:search:${effect.workspaceId}:${effect.docId}:${effect.cleanupVersion}`,
|
||||
});
|
||||
}
|
||||
if (effect.commentObjectsDone) {
|
||||
await this.event.emitAsync('workspace.blobs.updated', {
|
||||
workspaceId: effect.workspaceId,
|
||||
@@ -336,21 +325,6 @@ export class StorageBlobJob {
|
||||
return result;
|
||||
}
|
||||
|
||||
@OnJob('backendRuntime.ackDocumentCleanupEffect')
|
||||
async ackDocumentCleanupEffect({
|
||||
workspaceId,
|
||||
docId,
|
||||
cleanupVersion,
|
||||
effect,
|
||||
}: Jobs['backendRuntime.ackDocumentCleanupEffect']) {
|
||||
await this.rt.ackDocumentCleanupEffect(
|
||||
workspaceId,
|
||||
docId,
|
||||
cleanupVersion,
|
||||
effect
|
||||
);
|
||||
}
|
||||
|
||||
@OnJob('backendRuntime.planUnreferencedWorkspaceBlobs')
|
||||
async planUnreferencedWorkspaceBlobs({
|
||||
workspaceId,
|
||||
|
||||
@@ -71,7 +71,6 @@ import serverNativeModule, {
|
||||
type RuntimeVerificationTokenRecord,
|
||||
type RuntimeWorkspaceArtifact,
|
||||
type RuntimeWorkspaceInviteLinkRecord,
|
||||
type RuntimeWorkspaceStatsDailyRecalibrationResult,
|
||||
type SafeFetchRequest,
|
||||
type SafeFetchResponse,
|
||||
type StorageProviderCapabilities,
|
||||
@@ -173,7 +172,6 @@ export type {
|
||||
RuntimeVerificationTokenRecord,
|
||||
RuntimeWorkspaceArtifact,
|
||||
RuntimeWorkspaceInviteLinkRecord,
|
||||
RuntimeWorkspaceStatsDailyRecalibrationResult,
|
||||
SafeFetchRequest,
|
||||
SafeFetchResponse,
|
||||
StorageProviderCapabilities,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { SearchProviderNotFound } from '../../../base';
|
||||
import { SearchProviderUnavailable } from '../../../base';
|
||||
import { PermissionAccess } from '../../../core/permission';
|
||||
import type { DocVisibility } from '../../../core/utils/blocksuite';
|
||||
import { type DocChunkSimilarity, Models } from '../../../models';
|
||||
@@ -101,7 +101,7 @@ export class DocumentRetrievalService {
|
||||
docIds,
|
||||
})
|
||||
.catch(error => {
|
||||
if (error instanceof SearchProviderNotFound) return null;
|
||||
if (error instanceof SearchProviderUnavailable) return null;
|
||||
throw error;
|
||||
}),
|
||||
this.context.canEmbedding
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import test from 'ava';
|
||||
|
||||
import { createModule } from '../../../__tests__/create-module';
|
||||
import { JobQueue } from '../../../base';
|
||||
import { ConfigModule } from '../../../base/config';
|
||||
import { IndexerEvent } from '../event';
|
||||
import { IndexerModule } from '../index';
|
||||
import { IndexerScheduler } from '../scheduler';
|
||||
|
||||
const module = await createModule({
|
||||
imports: [
|
||||
IndexerModule,
|
||||
ConfigModule.override({
|
||||
indexer: {
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
const indexerEvent = module.get(IndexerEvent);
|
||||
const indexerScheduler = new IndexerScheduler(module.get(JobQueue));
|
||||
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('should index workspace when root snapshot is updated', async t => {
|
||||
// @ts-expect-error ignore missing fields
|
||||
await indexerEvent.indexWorkspace({
|
||||
workspaceId: 'test-workspace',
|
||||
docId: 'test-workspace',
|
||||
});
|
||||
|
||||
const { payload } = await module.queue.waitFor('indexer.indexWorkspace');
|
||||
t.is(payload.workspaceId, 'test-workspace');
|
||||
});
|
||||
|
||||
test('should not index workspace when non-root snapshot is updated', async t => {
|
||||
const count = module.queue.count('indexer.indexWorkspace');
|
||||
|
||||
// @ts-expect-error ignore missing fields
|
||||
await indexerEvent.indexWorkspace({
|
||||
workspaceId: 'test-workspace',
|
||||
docId: 'child-doc',
|
||||
});
|
||||
|
||||
t.is(module.queue.count('indexer.indexWorkspace'), count);
|
||||
});
|
||||
|
||||
test('should reindex documents after document access changes', async t => {
|
||||
await indexerEvent.reindexDocOnGrantChange({
|
||||
workspaceId: 'test-workspace',
|
||||
docId: 'test-doc',
|
||||
});
|
||||
const { payload } = await module.queue.waitFor('indexer.indexDoc');
|
||||
t.deepEqual(payload, {
|
||||
workspaceId: 'test-workspace',
|
||||
docId: 'test-doc',
|
||||
});
|
||||
});
|
||||
|
||||
test('should delete workspace', async t => {
|
||||
// @ts-expect-error ignore missing fields
|
||||
await indexerEvent.deleteUserWorkspaces({
|
||||
ownedWorkspaces: ['test-workspace'],
|
||||
});
|
||||
|
||||
const { payload } = await module.queue.waitFor('indexer.deleteWorkspace');
|
||||
t.is(payload.workspaceId, 'test-workspace');
|
||||
});
|
||||
|
||||
test('should schedule auto index workspaces', async t => {
|
||||
await indexerScheduler.autoIndexWorkspaces();
|
||||
|
||||
const { payload } = await module.queue.waitFor('indexer.autoIndexWorkspaces');
|
||||
t.is(payload.lastIndexedWorkspaceSid, undefined);
|
||||
});
|
||||
@@ -1,215 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import test from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { createModule } from '../../../__tests__/create-module';
|
||||
import { Mockers } from '../../../__tests__/mocks';
|
||||
import { JOB_SIGNAL } from '../../../base';
|
||||
import { ConfigModule } from '../../../base/config';
|
||||
import { ServerConfigModule } from '../../../core/config';
|
||||
import { DocReader } from '../../../core/doc';
|
||||
import { Models } from '../../../models';
|
||||
import { addDocToRootDoc } from '../../../native';
|
||||
import { IndexerModule, IndexerService, IndexerWorkerModule } from '../index';
|
||||
import { IndexerJob } from '../job';
|
||||
|
||||
const module = await createModule({
|
||||
imports: [
|
||||
IndexerModule,
|
||||
IndexerWorkerModule,
|
||||
ServerConfigModule,
|
||||
ConfigModule.override({
|
||||
indexer: {
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
providers: [IndexerService],
|
||||
});
|
||||
const indexerService = module.get(IndexerService);
|
||||
const indexerJob = module.get(IndexerJob);
|
||||
const models = module.get(Models);
|
||||
const docReader = module.get(DocReader);
|
||||
|
||||
const user = await module.create(Mockers.User);
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
snapshot: true,
|
||||
owner: user,
|
||||
});
|
||||
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test.afterEach.always(() => {
|
||||
Sinon.restore();
|
||||
});
|
||||
|
||||
test('should handle indexer.indexDoc job', async t => {
|
||||
const spy = Sinon.spy(indexerService, 'indexDoc');
|
||||
await indexerJob.indexDoc({
|
||||
workspaceId: workspace.id,
|
||||
docId: randomUUID(),
|
||||
});
|
||||
t.is(spy.callCount, 1);
|
||||
});
|
||||
|
||||
test('should handle indexer.deleteDoc job', async t => {
|
||||
const spy = Sinon.spy(indexerService, 'deleteDoc');
|
||||
await indexerJob.deleteDoc({
|
||||
workspaceId: workspace.id,
|
||||
docId: randomUUID(),
|
||||
});
|
||||
t.is(spy.callCount, 1);
|
||||
});
|
||||
|
||||
test('should handle indexer.indexWorkspace job', async t => {
|
||||
const spy = Sinon.stub(indexerService, 'reconcileWorkspace').resolves();
|
||||
|
||||
await indexerJob.indexWorkspace({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
t.true(spy.calledOnceWith(workspace.id));
|
||||
|
||||
// workspace should be indexed
|
||||
const ws = await models.workspace.get(workspace.id);
|
||||
t.is(ws!.indexed, true);
|
||||
});
|
||||
|
||||
test('document cleanup reconcile deletes missing search state before ack', async t => {
|
||||
const deleteSpy = Sinon.spy(indexerService, 'deleteDoc');
|
||||
const indexSpy = Sinon.spy(indexerService, 'indexDoc');
|
||||
const cleanupWorkspace = await module.create(Mockers.Workspace, {
|
||||
owner: user,
|
||||
});
|
||||
await module.create(Mockers.DocSnapshot, {
|
||||
workspaceId: cleanupWorkspace.id,
|
||||
docId: cleanupWorkspace.id,
|
||||
user,
|
||||
blob: addDocToRootDoc(Buffer.from([0, 0]), 'live-doc', 'Live'),
|
||||
});
|
||||
|
||||
await indexerJob.reconcileDocumentCleanup({
|
||||
workspaceId: cleanupWorkspace.id,
|
||||
docId: 'missing-doc',
|
||||
cleanupVersion: 'version-1',
|
||||
});
|
||||
|
||||
t.true(deleteSpy.calledOnceWith(cleanupWorkspace.id, 'missing-doc'));
|
||||
t.false(indexSpy.called);
|
||||
const { payload } = await module.queue.waitFor(
|
||||
'backendRuntime.ackDocumentCleanupEffect'
|
||||
);
|
||||
t.deepEqual(payload, {
|
||||
workspaceId: cleanupWorkspace.id,
|
||||
docId: 'missing-doc',
|
||||
cleanupVersion: 'version-1',
|
||||
effect: 'search',
|
||||
});
|
||||
});
|
||||
|
||||
test('document cleanup reconcile reindexes restored doc before ack', async t => {
|
||||
const deleteSpy = Sinon.spy(indexerService, 'deleteDoc');
|
||||
const indexSpy = Sinon.spy(indexerService, 'indexDoc');
|
||||
const cleanupWorkspace = await module.create(Mockers.Workspace, {
|
||||
owner: user,
|
||||
});
|
||||
await module.create(Mockers.DocSnapshot, {
|
||||
workspaceId: cleanupWorkspace.id,
|
||||
docId: cleanupWorkspace.id,
|
||||
user,
|
||||
blob: addDocToRootDoc(Buffer.from([0, 0]), 'restored-doc', 'Restored'),
|
||||
});
|
||||
await module.create(Mockers.DocSnapshot, {
|
||||
workspaceId: cleanupWorkspace.id,
|
||||
docId: 'restored-doc',
|
||||
user,
|
||||
});
|
||||
const getDocSpy = Sinon.spy(docReader, 'getDoc');
|
||||
|
||||
await indexerJob.reconcileDocumentCleanup({
|
||||
workspaceId: cleanupWorkspace.id,
|
||||
docId: 'restored-doc',
|
||||
cleanupVersion: 'version-2',
|
||||
});
|
||||
|
||||
t.true(indexSpy.calledOnceWith(cleanupWorkspace.id, 'restored-doc'));
|
||||
t.false(deleteSpy.called);
|
||||
t.true(getDocSpy.calledWith(cleanupWorkspace.id, cleanupWorkspace.id));
|
||||
t.true(getDocSpy.calledWith(cleanupWorkspace.id, 'restored-doc'));
|
||||
const { payload } = await module.queue.waitFor(
|
||||
'backendRuntime.ackDocumentCleanupEffect'
|
||||
);
|
||||
t.deepEqual(payload, {
|
||||
workspaceId: cleanupWorkspace.id,
|
||||
docId: 'restored-doc',
|
||||
cleanupVersion: 'version-2',
|
||||
effect: 'search',
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle indexer.deleteWorkspace job', async t => {
|
||||
const spy = Sinon.spy(indexerService, 'deleteWorkspace');
|
||||
|
||||
await indexerJob.deleteWorkspace({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
t.is(spy.callCount, 1);
|
||||
});
|
||||
|
||||
test('should handle indexer.autoIndexWorkspaces job', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
snapshot: true,
|
||||
});
|
||||
|
||||
const result = await indexerJob.autoIndexWorkspaces({
|
||||
lastIndexedWorkspaceSid: workspace.sid - 1,
|
||||
});
|
||||
t.is(result, JOB_SIGNAL.Repeat);
|
||||
|
||||
const { payload } = await module.queue.waitFor('indexer.indexWorkspace');
|
||||
t.is(payload.workspaceId, workspace.id);
|
||||
|
||||
// no new auto index job
|
||||
const count = module.queue.count('indexer.autoIndexWorkspaces');
|
||||
|
||||
await indexerJob.autoIndexWorkspaces({
|
||||
lastIndexedWorkspaceSid: workspace.sid,
|
||||
});
|
||||
|
||||
t.is(module.queue.count('indexer.autoIndexWorkspaces'), count);
|
||||
});
|
||||
|
||||
test('should not index workspace if it is not updated in 180 days', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
await module.create(Mockers.DocSnapshot, {
|
||||
user,
|
||||
workspaceId: workspace.id,
|
||||
docId: workspace.id,
|
||||
updatedAt: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000 - 1),
|
||||
});
|
||||
|
||||
const count = module.queue.count('indexer.indexWorkspace');
|
||||
|
||||
await indexerJob.autoIndexWorkspaces({
|
||||
lastIndexedWorkspaceSid: workspace.sid - 1,
|
||||
});
|
||||
|
||||
t.is(module.queue.count('indexer.indexWorkspace'), count);
|
||||
});
|
||||
|
||||
test('should not index workspace if snapshot not exists', async t => {
|
||||
// not create snapshot
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
|
||||
const count = module.queue.count('indexer.indexWorkspace');
|
||||
|
||||
await indexerJob.autoIndexWorkspaces({
|
||||
lastIndexedWorkspaceSid: workspace.sid - 1,
|
||||
});
|
||||
|
||||
t.is(module.queue.count('indexer.indexWorkspace'), count);
|
||||
});
|
||||
@@ -4,13 +4,18 @@ import Sinon from 'sinon';
|
||||
import {
|
||||
InternalServerError,
|
||||
InvalidIndexerInput,
|
||||
SearchProviderNotFound,
|
||||
SearchIndexFailed,
|
||||
SearchIndexNotReady,
|
||||
SearchPermissionSyncing,
|
||||
SearchProviderUnavailable,
|
||||
SpaceAccessDenied,
|
||||
WorkspacePermissionNotFound,
|
||||
} from '../../../base';
|
||||
import { ConfigFactory } from '../../../base/config';
|
||||
import { BackendRuntimeProvider } from '../../../core/backend-runtime';
|
||||
import { ServerService } from '../../../core/config';
|
||||
import { BackendRuntimeSearchJob } from '../../../core/backend-runtime/job';
|
||||
import { ServerFeature, ServerService } from '../../../core/config';
|
||||
import { Models } from '../../../models';
|
||||
import { IndexerResolver } from '../resolver';
|
||||
import { IndexerService } from '../service';
|
||||
import { SearchQueryType, SearchTable } from '../types';
|
||||
|
||||
@@ -18,13 +23,47 @@ test.afterEach.always(() => {
|
||||
Sinon.restore();
|
||||
});
|
||||
|
||||
function enabledServer() {
|
||||
return {
|
||||
getConfig: Sinon.stub().returns({ indexer: { enabled: true } }),
|
||||
enableFeature: Sinon.stub(),
|
||||
disableFeature: Sinon.stub(),
|
||||
};
|
||||
}
|
||||
|
||||
test('reflects native search readiness in the Node feature flag', async t => {
|
||||
const runtime = {
|
||||
searchStatus: Sinon.stub(),
|
||||
searchAuthorized: Sinon.stub().resolves({
|
||||
ok: true,
|
||||
value: { total: 0, nodes: [] },
|
||||
}),
|
||||
};
|
||||
runtime.searchStatus.onFirstCall().resolves({ ready: true });
|
||||
runtime.searchStatus.onSecondCall().resolves({ ready: false });
|
||||
runtime.searchStatus.onThirdCall().resolves({ ready: true });
|
||||
const server = enabledServer();
|
||||
const service = new IndexerService(
|
||||
runtime as unknown as BackendRuntimeProvider,
|
||||
{} as Models,
|
||||
server as unknown as ServerService
|
||||
);
|
||||
|
||||
await service.onApplicationBootstrap();
|
||||
await service.onConfigChanged({ updates: { indexer: {} } } as never);
|
||||
await service.search('actor', 'workspace', {} as never);
|
||||
|
||||
t.is(server.enableFeature.callCount, 2);
|
||||
t.true(server.disableFeature.calledOnce);
|
||||
t.is(runtime.searchStatus.callCount, 3);
|
||||
});
|
||||
|
||||
test('does not query native search when the indexer is disabled', async t => {
|
||||
const runtime = {
|
||||
searchStatus: Sinon.stub(),
|
||||
};
|
||||
const server = {
|
||||
getConfig: Sinon.stub().returns({ indexer: { enabled: false } }),
|
||||
enableFeature: Sinon.stub(),
|
||||
disableFeature: Sinon.stub(),
|
||||
};
|
||||
@@ -35,21 +74,50 @@ test('reflects native search readiness in the Node feature flag', async t => {
|
||||
);
|
||||
|
||||
await service.onApplicationBootstrap();
|
||||
await service.onConfigChanged({ updates: { indexer: {} } } as never);
|
||||
|
||||
t.true(server.enableFeature.calledOnce);
|
||||
t.true(server.disableFeature.calledOnce);
|
||||
t.is(runtime.searchStatus.callCount, 2);
|
||||
t.false(runtime.searchStatus.called);
|
||||
t.true(server.disableFeature.calledOnceWith(ServerFeature.Indexer));
|
||||
});
|
||||
|
||||
test('does not schedule or run native search reconciliation when disabled', async t => {
|
||||
const runtime = {
|
||||
reconcileSearchProjection: Sinon.stub(),
|
||||
searchStatus: Sinon.stub(),
|
||||
};
|
||||
const queue = { add: Sinon.stub() };
|
||||
const config = {
|
||||
config: { indexer: { enabled: false } },
|
||||
} as unknown as ConfigFactory;
|
||||
const job = new BackendRuntimeSearchJob(
|
||||
runtime as unknown as BackendRuntimeProvider,
|
||||
queue as never,
|
||||
config
|
||||
);
|
||||
|
||||
await job.scheduleReconciliation();
|
||||
t.is(queue.add.callCount, 0);
|
||||
t.is(await job.reconcileProjection({ limit: 100 }), 0);
|
||||
t.false(runtime.reconcileSearchProjection.called);
|
||||
t.false(runtime.searchStatus.called);
|
||||
|
||||
config.config.indexer.enabled = true;
|
||||
await job.scheduleReconciliation();
|
||||
t.deepEqual(queue.add.firstCall.args[2], {
|
||||
jobId: 'backend-runtime-search-reconciliation',
|
||||
removeOnFail: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('maps native search results and typed errors at the Node boundary', async t => {
|
||||
const runtime = {
|
||||
searchStatus: Sinon.stub().resolves({ ready: true }),
|
||||
searchAuthorized: Sinon.stub(),
|
||||
aggregateAuthorized: Sinon.stub(),
|
||||
};
|
||||
const service = new IndexerService(
|
||||
runtime as unknown as BackendRuntimeProvider,
|
||||
{} as Models,
|
||||
{} as ServerService
|
||||
enabledServer() as unknown as ServerService
|
||||
);
|
||||
const input = {
|
||||
table: SearchTable.block,
|
||||
@@ -59,7 +127,7 @@ test('maps native search results and typed errors at the Node boundary', async t
|
||||
runtime.searchAuthorized.resolves({
|
||||
ok: true,
|
||||
value: {
|
||||
total: 1,
|
||||
total: 99,
|
||||
nodes: [
|
||||
{
|
||||
id: 'node',
|
||||
@@ -85,12 +153,41 @@ test('maps native search results and typed errors at the Node boundary', async t
|
||||
markdownPreview: ['<b>hello</b>'],
|
||||
});
|
||||
|
||||
const resolver = new IndexerResolver(service, {
|
||||
user: Sinon.stub().returns({
|
||||
workspace: Sinon.stub().returns({ assert: Sinon.stub().resolves() }),
|
||||
}),
|
||||
} as never);
|
||||
const searchResult = await resolver.search(
|
||||
{ id: 'actor' } as never,
|
||||
{ id: 'workspace' } as never,
|
||||
input
|
||||
);
|
||||
t.is(searchResult.pagination.count, 1);
|
||||
|
||||
runtime.aggregateAuthorized.resolves({
|
||||
ok: true,
|
||||
value: {
|
||||
total: 99,
|
||||
hasMore: true,
|
||||
buckets: [{ key: 'doc', count: 1, hits: { nodes: [] } }],
|
||||
},
|
||||
});
|
||||
const aggregateResult = await resolver.aggregate(
|
||||
{ id: 'actor' } as never,
|
||||
{ id: 'workspace' } as never,
|
||||
{} as never
|
||||
);
|
||||
t.is(aggregateResult.pagination.count, 1);
|
||||
|
||||
for (const [errorCode, expected] of [
|
||||
['workspace_denied', SpaceAccessDenied],
|
||||
['invalid_request', InvalidIndexerInput],
|
||||
['unsupported_query', InvalidIndexerInput],
|
||||
['provider_unavailable', SearchProviderNotFound],
|
||||
['permission_unavailable', WorkspacePermissionNotFound],
|
||||
['provider_unavailable', SearchProviderUnavailable],
|
||||
['index_not_ready', SearchIndexNotReady],
|
||||
['permission_syncing', SearchPermissionSyncing],
|
||||
['index_failed', SearchIndexFailed],
|
||||
['unexpected', InternalServerError],
|
||||
] as const) {
|
||||
runtime.searchAuthorized.resolves({ ok: false, errorCode });
|
||||
@@ -102,7 +199,29 @@ test('maps native search results and typed errors at the Node boundary', async t
|
||||
});
|
||||
|
||||
test('searchDocs keeps filtering and enrichment in Node', async t => {
|
||||
const blockNode = {
|
||||
id: 'block',
|
||||
score: 1,
|
||||
fields: {
|
||||
workspace_id: ['workspace'],
|
||||
doc_id: ['doc'],
|
||||
block_id: ['block'],
|
||||
unit_id: ['unit'],
|
||||
projection_version: [1],
|
||||
source_hash: ['hash'],
|
||||
visibility: ['visible'],
|
||||
source_block_id: ['source-block'],
|
||||
flavour: ['affine:paragraph'],
|
||||
content: ['body'],
|
||||
created_at: [2_000],
|
||||
updated_at: [3_000],
|
||||
created_by_user_id: ['creator'],
|
||||
updated_by_user_id: ['updater'],
|
||||
},
|
||||
highlights: { content: ['<b>body</b>'] },
|
||||
};
|
||||
const runtime = {
|
||||
searchStatus: Sinon.stub().resolves({ ready: true }),
|
||||
aggregateAuthorized: Sinon.stub().resolves({
|
||||
ok: true,
|
||||
value: {
|
||||
@@ -113,34 +232,19 @@ test('searchDocs keeps filtering and enrichment in Node', async t => {
|
||||
key: 'doc',
|
||||
count: 1,
|
||||
hits: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'block',
|
||||
score: 1,
|
||||
fields: {
|
||||
workspace_id: ['workspace'],
|
||||
doc_id: ['doc'],
|
||||
block_id: ['block'],
|
||||
unit_id: ['unit'],
|
||||
projection_version: [1],
|
||||
source_hash: ['hash'],
|
||||
visibility: ['visible'],
|
||||
source_block_id: ['source-block'],
|
||||
flavour: ['affine:paragraph'],
|
||||
content: ['body'],
|
||||
created_at: [2_000],
|
||||
updated_at: [3_000],
|
||||
created_by_user_id: ['creator'],
|
||||
updated_by_user_id: ['updater'],
|
||||
},
|
||||
highlights: { content: ['<b>body</b>'] },
|
||||
},
|
||||
],
|
||||
nodes: [blockNode],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
searchAuthorized: Sinon.stub().resolves({
|
||||
ok: true,
|
||||
value: {
|
||||
total: 2,
|
||||
nodes: [blockNode, { ...blockNode, id: 'duplicate-block' }],
|
||||
},
|
||||
}),
|
||||
};
|
||||
const creator = { id: 'creator', name: 'Creator' };
|
||||
const updater = { id: 'updater', name: 'Updater' };
|
||||
@@ -162,9 +266,16 @@ test('searchDocs keeps filtering and enrichment in Node', async t => {
|
||||
const service = new IndexerService(
|
||||
runtime as unknown as BackendRuntimeProvider,
|
||||
models as unknown as Models,
|
||||
{} as ServerService
|
||||
enabledServer() as unknown as ServerService
|
||||
);
|
||||
|
||||
for (const limit of [0, -1]) {
|
||||
const error = await t.throwsAsync(
|
||||
service.searchDocsByKeyword('actor', 'workspace', 'body', { limit })
|
||||
);
|
||||
t.true(error instanceof InvalidIndexerInput);
|
||||
}
|
||||
t.false(runtime.aggregateAuthorized.called);
|
||||
t.deepEqual(
|
||||
await service.searchDocsByKeyword('actor', 'workspace', 'body', {
|
||||
docIds: [],
|
||||
@@ -194,4 +305,22 @@ test('searchDocs keeps filtering and enrichment in Node', async t => {
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
runtime.aggregateAuthorized.resolves({
|
||||
ok: false,
|
||||
errorCode: 'unsupported_query',
|
||||
});
|
||||
const basicDocs = await service.searchDocsByKeyword(
|
||||
'actor',
|
||||
'workspace',
|
||||
'body',
|
||||
{ limit: 5, docIds: ['doc'] }
|
||||
);
|
||||
t.is(basicDocs.length, 1);
|
||||
t.is(basicDocs[0].docId, 'doc');
|
||||
const basicRequest = runtime.searchAuthorized.firstCall.args[2];
|
||||
t.true(basicRequest.options.fields.includes('docId'));
|
||||
t.is(basicRequest.options.pagination?.limit, 20);
|
||||
t.true(JSON.stringify(basicRequest.query).includes('doc'));
|
||||
t.true(JSON.stringify(basicRequest.query).includes('workspace'));
|
||||
});
|
||||
|
||||
@@ -4,8 +4,8 @@ import { defineModuleConfig } from '../../base';
|
||||
|
||||
export enum SearchProviderType {
|
||||
Embedded = 'embedded',
|
||||
Manticoresearch = 'manticoresearch',
|
||||
Elasticsearch = 'elasticsearch',
|
||||
ManticoreSearch = 'manticoresearch',
|
||||
}
|
||||
|
||||
const SearchProviderTypeSchema = z.nativeEnum(SearchProviderType);
|
||||
@@ -21,9 +21,6 @@ declare global {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
autoIndex: {
|
||||
batchSize: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -32,18 +29,15 @@ defineModuleConfig('indexer', {
|
||||
enabled: {
|
||||
desc: 'Enable indexer plugin',
|
||||
default: false,
|
||||
env: ['AFFINE_INDEXER_ENABLED', 'boolean'],
|
||||
},
|
||||
'provider.type': {
|
||||
desc: 'Indexer search provider. Self-hosted uses the embedded provider by default; remote providers require an endpoint.',
|
||||
default: SearchProviderType.Embedded,
|
||||
shape: SearchProviderTypeSchema,
|
||||
env: ['AFFINE_INDEXER_SEARCH_PROVIDER', 'string'],
|
||||
},
|
||||
'provider.endpoint': {
|
||||
desc: 'Remote indexer endpoint. Not used by the embedded provider.',
|
||||
default: '',
|
||||
env: ['AFFINE_INDEXER_SEARCH_ENDPOINT', 'string'],
|
||||
validate: val => {
|
||||
// allow to be nullable and empty string
|
||||
if (!val) {
|
||||
@@ -54,25 +48,17 @@ defineModuleConfig('indexer', {
|
||||
},
|
||||
},
|
||||
'provider.apiKey': {
|
||||
desc: 'Indexer search service api key. Optional for elasticsearch',
|
||||
desc: 'Indexer search service api key. Optional for remote providers',
|
||||
link: 'https://www.elastic.co/guide/server/current/api-key.html',
|
||||
default: '',
|
||||
env: ['AFFINE_INDEXER_SEARCH_API_KEY', 'string'],
|
||||
},
|
||||
'provider.username': {
|
||||
desc: 'Indexer search service auth username, if not set, basic auth will be disabled. Optional for elasticsearch',
|
||||
desc: 'Indexer search service auth username, if not set, basic auth will be disabled. Optional for remote providers',
|
||||
link: 'https://www.elastic.co/guide/en/elasticsearch/reference/current/http-clients.html',
|
||||
default: '',
|
||||
env: ['AFFINE_INDEXER_SEARCH_USERNAME', 'string'],
|
||||
},
|
||||
'provider.password': {
|
||||
desc: 'Indexer search service auth password, if not set, basic auth will be disabled. Optional for elasticsearch',
|
||||
desc: 'Indexer search service auth password, if not set, basic auth will be disabled. Optional for remote providers',
|
||||
default: '',
|
||||
env: ['AFFINE_INDEXER_SEARCH_PASSWORD', 'string'],
|
||||
},
|
||||
'autoIndex.batchSize': {
|
||||
desc: 'Number of workspaces automatically indexed per batch',
|
||||
default: 10,
|
||||
shape: z.number().int().positive().max(1000),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { JobQueue, OnEvent } from '../../base';
|
||||
|
||||
@Injectable()
|
||||
export class IndexerEvent {
|
||||
constructor(private readonly queue: JobQueue) {}
|
||||
|
||||
@OnEvent('doc.grants.changed')
|
||||
async reindexDocOnGrantChange({
|
||||
workspaceId,
|
||||
docId,
|
||||
}: Events['doc.grants.changed']) {
|
||||
await this.indexDoc({ workspaceId, docId });
|
||||
}
|
||||
|
||||
@OnEvent('doc.owner.changed')
|
||||
async reindexDocOnOwnerChange({
|
||||
workspaceId,
|
||||
docId,
|
||||
}: Events['doc.owner.changed']) {
|
||||
await this.indexDoc({ workspaceId, docId });
|
||||
}
|
||||
|
||||
@OnEvent('doc.default_role.changed')
|
||||
async reindexDocOnDefaultRoleChange({
|
||||
workspaceId,
|
||||
docId,
|
||||
}: Events['doc.default_role.changed']) {
|
||||
await this.indexDoc({ workspaceId, docId });
|
||||
}
|
||||
|
||||
@OnEvent('doc.public_state.changed')
|
||||
async reindexDocOnPublicStateChange({
|
||||
workspaceId,
|
||||
docId,
|
||||
}: Events['doc.public_state.changed']) {
|
||||
await this.indexDoc({ workspaceId, docId });
|
||||
}
|
||||
|
||||
@OnEvent('doc.updated')
|
||||
async indexDoc({ workspaceId, docId }: Events['doc.updated']) {
|
||||
await this.queue.add(
|
||||
'indexer.indexDoc',
|
||||
{
|
||||
workspaceId,
|
||||
docId,
|
||||
},
|
||||
{
|
||||
jobId: `indexDoc/${workspaceId}/${docId}`,
|
||||
priority: 100,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@OnEvent('doc.snapshot.updated')
|
||||
async indexWorkspace({ workspaceId, docId }: Events['doc.snapshot.updated']) {
|
||||
if (workspaceId !== docId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.queue.add(
|
||||
'indexer.indexWorkspace',
|
||||
{ workspaceId },
|
||||
{ jobId: `indexWorkspace/${workspaceId}`, priority: 100 }
|
||||
);
|
||||
}
|
||||
|
||||
@OnEvent('user.deleted')
|
||||
async deleteUserWorkspaces(payload: Events['user.deleted']) {
|
||||
for (const workspace of payload.ownedWorkspaces) {
|
||||
await this.queue.add(
|
||||
'indexer.deleteWorkspace',
|
||||
{
|
||||
workspaceId: workspace,
|
||||
},
|
||||
{
|
||||
jobId: `deleteWorkspace/${workspace}`,
|
||||
priority: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,7 @@ import { Module } from '@nestjs/common';
|
||||
import { ServerConfigModule } from '../../core/config';
|
||||
import { DocStorageModule } from '../../core/doc';
|
||||
import { PermissionModule } from '../../core/permission';
|
||||
import { IndexerEvent } from './event';
|
||||
import { IndexerJob } from './job';
|
||||
import { IndexerResolver } from './resolver';
|
||||
import { IndexerScheduler } from './scheduler';
|
||||
import { IndexerService } from './service';
|
||||
|
||||
const INDEXER_SHARED_IMPORTS = [
|
||||
@@ -24,21 +21,9 @@ const INDEXER_SHARED_IMPORTS = [
|
||||
})
|
||||
export class IndexerServiceModule {}
|
||||
|
||||
@Module({
|
||||
imports: [IndexerServiceModule],
|
||||
providers: [IndexerEvent],
|
||||
})
|
||||
export class IndexerProducerModule {}
|
||||
|
||||
@Module({
|
||||
imports: [IndexerServiceModule, DocStorageModule, PermissionModule],
|
||||
providers: [IndexerJob, IndexerScheduler],
|
||||
})
|
||||
export class IndexerWorkerModule {}
|
||||
|
||||
@Module({
|
||||
imports: [IndexerServiceModule, DocStorageModule, PermissionModule],
|
||||
providers: [IndexerResolver, IndexerEvent],
|
||||
providers: [IndexerResolver],
|
||||
exports: [IndexerServiceModule],
|
||||
})
|
||||
export class IndexerModule {}
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Config, JOB_SIGNAL, JobQueue, OnJob } from '../../base';
|
||||
import { DocReader } from '../../core/doc';
|
||||
import { readAllDocIdsFromWorkspaceSnapshot } from '../../core/utils/blocksuite';
|
||||
import { Models } from '../../models';
|
||||
import { IndexerService } from './service';
|
||||
|
||||
declare global {
|
||||
interface Jobs {
|
||||
'indexer.indexDoc': {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
};
|
||||
'indexer.deleteDoc': {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
};
|
||||
'indexer.indexWorkspace': {
|
||||
workspaceId: string;
|
||||
};
|
||||
'indexer.deleteWorkspace': {
|
||||
workspaceId: string;
|
||||
};
|
||||
'indexer.autoIndexWorkspaces': {
|
||||
lastIndexedWorkspaceSid?: number;
|
||||
};
|
||||
'indexer.reconcileDocumentCleanup': {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
cleanupVersion: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IndexerJob {
|
||||
private readonly logger = new Logger(IndexerJob.name);
|
||||
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly service: IndexerService,
|
||||
private readonly queue: JobQueue,
|
||||
private readonly config: Config,
|
||||
private readonly doc: DocReader
|
||||
) {}
|
||||
|
||||
@OnJob('indexer.indexDoc')
|
||||
async indexDoc({ workspaceId, docId }: Jobs['indexer.indexDoc']) {
|
||||
// delete the 'indexer.deleteDoc' job from the queue
|
||||
await this.queue.remove(
|
||||
`deleteDoc/${workspaceId}/${docId}`,
|
||||
'indexer.deleteDoc'
|
||||
);
|
||||
await this.service.indexDoc(workspaceId, docId);
|
||||
await this.enqueueBlobRefProjection(workspaceId, docId);
|
||||
}
|
||||
|
||||
@OnJob('indexer.deleteDoc')
|
||||
async deleteDoc({ workspaceId, docId }: Jobs['indexer.deleteDoc']) {
|
||||
// delete the 'indexer.updateDoc' job from the queue
|
||||
await this.queue.remove(
|
||||
`indexDoc/${workspaceId}/${docId}`,
|
||||
'indexer.indexDoc'
|
||||
);
|
||||
await this.service.deleteDoc(workspaceId, docId);
|
||||
}
|
||||
|
||||
@OnJob('indexer.reconcileDocumentCleanup')
|
||||
async reconcileDocumentCleanup({
|
||||
workspaceId,
|
||||
docId,
|
||||
cleanupVersion,
|
||||
}: Jobs['indexer.reconcileDocumentCleanup']) {
|
||||
const root = await this.doc.getDoc(workspaceId, workspaceId);
|
||||
if (!root) {
|
||||
throw new Error(`workspace root ${workspaceId} not found`);
|
||||
}
|
||||
const live = readAllDocIdsFromWorkspaceSnapshot(root.bin, true).includes(
|
||||
docId
|
||||
);
|
||||
if (live) {
|
||||
if (!(await this.doc.getDoc(workspaceId, docId))) {
|
||||
throw new Error(`restored document ${workspaceId}/${docId} not found`);
|
||||
}
|
||||
await this.service.indexDoc(workspaceId, docId);
|
||||
await this.enqueueBlobRefProjection(workspaceId, docId);
|
||||
} else {
|
||||
await this.service.deleteDoc(workspaceId, docId);
|
||||
}
|
||||
await this.queue.add('backendRuntime.ackDocumentCleanupEffect', {
|
||||
workspaceId,
|
||||
docId,
|
||||
cleanupVersion,
|
||||
effect: 'search',
|
||||
});
|
||||
}
|
||||
|
||||
@OnJob('indexer.indexWorkspace')
|
||||
async indexWorkspace({ workspaceId }: Jobs['indexer.indexWorkspace']) {
|
||||
await this.queue.remove(workspaceId, 'indexer.deleteWorkspace');
|
||||
const workspace = await this.models.workspace.get(workspaceId);
|
||||
if (!workspace) {
|
||||
this.logger.warn(`workspace ${workspaceId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.service.reconcileWorkspace(workspaceId);
|
||||
if (!workspace.indexed) {
|
||||
await this.models.workspace.update(workspaceId, {
|
||||
indexed: true,
|
||||
});
|
||||
}
|
||||
this.logger.log(`reconciled workspace ${workspaceId}`);
|
||||
}
|
||||
|
||||
@OnJob('indexer.deleteWorkspace')
|
||||
async deleteWorkspace({ workspaceId }: Jobs['indexer.deleteWorkspace']) {
|
||||
await this.queue.remove(
|
||||
`indexWorkspace/${workspaceId}`,
|
||||
'indexer.indexWorkspace'
|
||||
);
|
||||
await this.service.deleteWorkspace(workspaceId);
|
||||
}
|
||||
|
||||
@OnJob('indexer.autoIndexWorkspaces')
|
||||
async autoIndexWorkspaces(payload: Jobs['indexer.autoIndexWorkspaces']) {
|
||||
const startSid = payload.lastIndexedWorkspaceSid ?? 0;
|
||||
const workspaces = await this.models.workspace.list(
|
||||
{ sid: { gt: startSid } },
|
||||
{ id: true, indexed: true, sid: true },
|
||||
this.config.indexer.autoIndex.batchSize
|
||||
);
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
// Keep the current sid value when repeating
|
||||
return JOB_SIGNAL.Repeat;
|
||||
}
|
||||
let addedCount = 0;
|
||||
for (const workspace of workspaces) {
|
||||
const snapshotMeta = await this.models.doc.getSnapshot(
|
||||
workspace.id,
|
||||
workspace.id,
|
||||
{
|
||||
select: {
|
||||
updatedAt: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
// ignore 180 days not updated workspaces
|
||||
if (
|
||||
!snapshotMeta?.updatedAt ||
|
||||
Date.now() - snapshotMeta.updatedAt.getTime() >
|
||||
180 * 24 * 60 * 60 * 1000
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
await this.queue.add(
|
||||
'indexer.indexWorkspace',
|
||||
{ workspaceId: workspace.id },
|
||||
{ jobId: `indexWorkspace/${workspace.id}` }
|
||||
);
|
||||
addedCount++;
|
||||
}
|
||||
const nextSid = workspaces[workspaces.length - 1].sid;
|
||||
this.logger.log(
|
||||
`Auto added ${addedCount} workspaces to queue, lastIndexedWorkspaceSid: ${startSid} -> ${nextSid}`
|
||||
);
|
||||
|
||||
// update the lastIndexedWorkspaceSid in the payload and repeat the job after 30 seconds
|
||||
payload.lastIndexedWorkspaceSid = nextSid;
|
||||
return JOB_SIGNAL.Repeat;
|
||||
}
|
||||
|
||||
private async enqueueBlobRefProjection(workspaceId: string, docId: string) {
|
||||
const snapshot = await this.models.doc.getSnapshot(workspaceId, docId, {
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
if (!snapshot) {
|
||||
return;
|
||||
}
|
||||
const sourceRevision = snapshot.updatedAt.getTime();
|
||||
await this.queue.add(
|
||||
'backendRuntime.projectWorkspaceDocBlobRefs',
|
||||
{ workspaceId, docId, sourceRevision },
|
||||
{
|
||||
jobId: `doc:blob-ref-projection:${workspaceId}:${docId}:${sourceRevision}`,
|
||||
priority: 100,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,8 @@ export class IndexerResolver {
|
||||
return {
|
||||
nodes: result.nodes,
|
||||
pagination: {
|
||||
count: result.total,
|
||||
hasMore: result.nodes.length > 0,
|
||||
count: result.nodes.length,
|
||||
hasMore: Boolean(result.nextCursor),
|
||||
nextCursor: result.nextCursor,
|
||||
},
|
||||
};
|
||||
@@ -56,7 +56,7 @@ export class IndexerResolver {
|
||||
return {
|
||||
buckets: result.buckets,
|
||||
pagination: {
|
||||
count: result.total,
|
||||
count: result.buckets.length,
|
||||
hasMore: result.hasMore,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ import { camelCase, mapKeys } from 'lodash-es';
|
||||
import {
|
||||
AggregateInput,
|
||||
SearchDoc,
|
||||
SearchInput,
|
||||
SearchQuery,
|
||||
SearchQueryOccur,
|
||||
SearchQueryType,
|
||||
SearchTable,
|
||||
@@ -147,6 +149,71 @@ export function buildSearchDocsInput(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBasicSearchDocsInput(
|
||||
workspaceId: string,
|
||||
keyword: string,
|
||||
options?: { limit?: number; docIds?: string[] }
|
||||
): SearchInput {
|
||||
const aggregate = buildSearchDocsInput(workspaceId, keyword, options);
|
||||
const limit = options?.limit ?? 20;
|
||||
const queries: SearchQuery[] = [
|
||||
{
|
||||
type: SearchQueryType.match as const,
|
||||
field: 'workspaceId',
|
||||
match: workspaceId,
|
||||
},
|
||||
{
|
||||
type: SearchQueryType.match as const,
|
||||
field: 'content',
|
||||
match: keyword,
|
||||
},
|
||||
];
|
||||
if (options?.docIds) {
|
||||
queries.push({
|
||||
type: SearchQueryType.boolean,
|
||||
occur: SearchQueryOccur.should,
|
||||
queries: options.docIds.map(docId => ({
|
||||
type: SearchQueryType.match as const,
|
||||
field: 'docId',
|
||||
match: docId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return {
|
||||
table: aggregate.table,
|
||||
query: {
|
||||
type: SearchQueryType.boolean,
|
||||
occur: SearchQueryOccur.must,
|
||||
queries,
|
||||
},
|
||||
options: {
|
||||
fields: ['docId', ...aggregate.options.hits.fields],
|
||||
highlights: aggregate.options.hits.highlights,
|
||||
pagination: { limit: Math.min(Math.max(limit, 1) * 4, 10_000) },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function collectBasicSearchDocs(
|
||||
result: { nodes: SearchNodeWithMeta[] },
|
||||
workspaceId: string,
|
||||
limit: number
|
||||
) {
|
||||
const seen = new Set<string>();
|
||||
const buckets: AggregateResult['buckets'] = [];
|
||||
for (const node of result.nodes) {
|
||||
const docId = node._source.docId;
|
||||
if (seen.has(docId)) continue;
|
||||
seen.add(docId);
|
||||
buckets.push({ key: docId, count: 1, hits: { nodes: [node] } });
|
||||
if (buckets.length === limit) break;
|
||||
}
|
||||
return collectSearchDocs(
|
||||
{ total: buckets.length, hasMore: false, buckets },
|
||||
workspaceId
|
||||
);
|
||||
}
|
||||
|
||||
export function collectSearchDocs(
|
||||
result: AggregateResult,
|
||||
workspaceId: string
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { JobQueue } from '../../base';
|
||||
|
||||
@Injectable()
|
||||
export class IndexerScheduler {
|
||||
constructor(private readonly queue: JobQueue) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_30_SECONDS)
|
||||
async autoIndexWorkspaces() {
|
||||
await this.queue.add(
|
||||
'indexer.autoIndexWorkspaces',
|
||||
{},
|
||||
{
|
||||
// make sure only one job is running at a time
|
||||
delay: 30 * 1000,
|
||||
jobId: 'autoIndexWorkspaces',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,20 @@ import {
|
||||
InternalServerError,
|
||||
InvalidIndexerInput,
|
||||
OnEvent,
|
||||
SearchProviderNotFound,
|
||||
SearchIndexFailed,
|
||||
SearchIndexNotReady,
|
||||
SearchPermissionSyncing,
|
||||
SearchProviderUnavailable,
|
||||
SpaceAccessDenied,
|
||||
WorkspacePermissionNotFound,
|
||||
} from '../../base';
|
||||
import { BackendRuntimeProvider } from '../../core/backend-runtime';
|
||||
import { ServerFeature, ServerService } from '../../core/config';
|
||||
import { Models } from '../../models';
|
||||
import {
|
||||
type AggregateResult,
|
||||
buildBasicSearchDocsInput,
|
||||
buildSearchDocsInput,
|
||||
collectBasicSearchDocs,
|
||||
collectSearchDocs,
|
||||
formatSearchNodes,
|
||||
type SearchNode,
|
||||
@@ -50,12 +54,17 @@ export class IndexerService implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
private async syncFeature() {
|
||||
if (!this.server.getConfig().indexer.enabled) {
|
||||
this.server.disableFeature(ServerFeature.Indexer);
|
||||
return;
|
||||
}
|
||||
const status = (await this.runtime.searchStatus()) as { ready: boolean };
|
||||
if (status.ready) this.server.enableFeature(ServerFeature.Indexer);
|
||||
else this.server.disableFeature(ServerFeature.Indexer);
|
||||
}
|
||||
|
||||
async search(actorUserId: string, workspaceId: string, input: SearchInput) {
|
||||
await this.syncFeature();
|
||||
const result = this.unwrap<SearchResult>(
|
||||
await this.runtime.searchAuthorized(actorUserId, workspaceId, input),
|
||||
workspaceId
|
||||
@@ -68,10 +77,15 @@ export class IndexerService implements OnApplicationBootstrap {
|
||||
workspaceId: string,
|
||||
input: AggregateInput
|
||||
) {
|
||||
await this.syncFeature();
|
||||
const result = this.unwrap<AggregateResult>(
|
||||
await this.runtime.aggregateAuthorized(actorUserId, workspaceId, input),
|
||||
workspaceId
|
||||
);
|
||||
return this.formatAggregate(result);
|
||||
}
|
||||
|
||||
private formatAggregate(result: AggregateResult) {
|
||||
return {
|
||||
...result,
|
||||
buckets: result.buckets.map(bucket => ({
|
||||
@@ -84,38 +98,42 @@ export class IndexerService implements OnApplicationBootstrap {
|
||||
};
|
||||
}
|
||||
|
||||
async indexDoc(workspaceId: string, docId: string) {
|
||||
await this.runtime.indexSearchDocument(workspaceId, docId);
|
||||
}
|
||||
|
||||
async deleteDoc(workspaceId: string, docId: string) {
|
||||
await this.runtime.deleteSearchDocument(workspaceId, docId);
|
||||
}
|
||||
|
||||
async reconcileWorkspace(workspaceId: string) {
|
||||
await this.runtime.reconcileSearchWorkspace(workspaceId);
|
||||
}
|
||||
|
||||
async deleteWorkspace(workspaceId: string) {
|
||||
await this.runtime.deleteSearchWorkspace(workspaceId);
|
||||
}
|
||||
|
||||
async searchDocsByKeyword(
|
||||
actorUserId: string,
|
||||
workspaceId: string,
|
||||
keyword: string,
|
||||
options?: { limit?: number; docIds?: string[] }
|
||||
): Promise<SearchDoc[]> {
|
||||
await this.syncFeature();
|
||||
if (options?.limit !== undefined && options.limit <= 0) {
|
||||
throw new InvalidIndexerInput({
|
||||
reason: 'searchDocs limit must be positive',
|
||||
});
|
||||
}
|
||||
if (options?.docIds?.length === 0) return [];
|
||||
const result = await this.aggregate(
|
||||
const aggregateOutput = await this.runtime.aggregateAuthorized(
|
||||
actorUserId,
|
||||
workspaceId,
|
||||
buildSearchDocsInput(workspaceId, keyword, options)
|
||||
);
|
||||
const { docs, missingTitles, userIds } = collectSearchDocs(
|
||||
result,
|
||||
workspaceId
|
||||
);
|
||||
const collected =
|
||||
!aggregateOutput.ok && aggregateOutput.errorCode === 'unsupported_query'
|
||||
? collectBasicSearchDocs(
|
||||
await this.search(
|
||||
actorUserId,
|
||||
workspaceId,
|
||||
buildBasicSearchDocsInput(workspaceId, keyword, options)
|
||||
),
|
||||
workspaceId,
|
||||
options?.limit ?? 20
|
||||
)
|
||||
: collectSearchDocs(
|
||||
this.formatAggregate(
|
||||
this.unwrap<AggregateResult>(aggregateOutput, workspaceId)
|
||||
),
|
||||
workspaceId
|
||||
);
|
||||
const { docs, missingTitles, userIds } = collected;
|
||||
if (missingTitles.length > 0) {
|
||||
const metas = await this.models.doc.findMetas(missingTitles, {
|
||||
select: { title: true },
|
||||
@@ -146,9 +164,15 @@ export class IndexerService implements OnApplicationBootstrap {
|
||||
case 'unsupported_query':
|
||||
throw new InvalidIndexerInput({ reason: output.errorCode });
|
||||
case 'provider_unavailable':
|
||||
throw new SearchProviderNotFound();
|
||||
case 'permission_unavailable':
|
||||
throw new WorkspacePermissionNotFound({ spaceId: workspaceId });
|
||||
throw new SearchProviderUnavailable();
|
||||
case 'index_not_ready':
|
||||
throw new SearchIndexNotReady({ spaceId: workspaceId });
|
||||
case 'permission_syncing':
|
||||
throw new SearchPermissionSyncing();
|
||||
case 'index_failed':
|
||||
throw new SearchIndexFailed({
|
||||
diagnosticId: 'search_workspace_reconcile_failed',
|
||||
});
|
||||
default:
|
||||
throw new InternalServerError();
|
||||
}
|
||||
|
||||
@@ -296,13 +296,23 @@ export class SearchNodeObjectType {
|
||||
|
||||
@ObjectType()
|
||||
export class SearchResultPagination {
|
||||
@Field(() => Int)
|
||||
@Field(() => Int, {
|
||||
description:
|
||||
'Number of results returned in this response, not a global total',
|
||||
})
|
||||
count!: number;
|
||||
|
||||
@Field(() => Boolean)
|
||||
@Field(() => Boolean, {
|
||||
description:
|
||||
'Whether the provider has more candidates; remaining visible results are not guaranteed',
|
||||
})
|
||||
hasMore!: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description:
|
||||
'Opaque provider candidate cursor; it does not guarantee complete visible-result pagination',
|
||||
})
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
@@ -326,7 +336,9 @@ export class AggregateBucketObjectType {
|
||||
@Field(() => String)
|
||||
key!: string;
|
||||
|
||||
@Field(() => Int)
|
||||
@Field(() => Int, {
|
||||
description: 'Number of returned sample hits in this bucket',
|
||||
})
|
||||
count!: number;
|
||||
|
||||
@Field(() => AggregateBucketHitsObjectType, {
|
||||
|
||||
@@ -203,6 +203,7 @@ type AggregateBucketHitsObjectType {
|
||||
}
|
||||
|
||||
type AggregateBucketObjectType {
|
||||
"""Number of returned sample hits in this bucket"""
|
||||
count: Int!
|
||||
|
||||
"""The hits object"""
|
||||
@@ -925,7 +926,7 @@ type EditorType {
|
||||
name: String!
|
||||
}
|
||||
|
||||
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CalendarProviderRequestErrorDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceArtifactDataType | CopilotFailedToGenerateEmbeddingDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderNotSupportedDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | ImageFormatNotSupportedDataType | InvalidAppConfigDataType | InvalidAppConfigInputDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidIndexerInputDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidOauthResponseDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | InvalidSearchProviderRequestDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoCopilotProviderAvailableDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | ResponseTooLargeErrorDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SsrfBlockedErrorDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedServerVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType
|
||||
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CalendarProviderRequestErrorDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceArtifactDataType | CopilotFailedToGenerateEmbeddingDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderNotSupportedDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | ImageFormatNotSupportedDataType | InvalidAppConfigDataType | InvalidAppConfigInputDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidIndexerInputDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidOauthResponseDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | InvalidSearchProviderRequestDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoCopilotProviderAvailableDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | ResponseTooLargeErrorDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SearchIndexFailedDataType | SearchIndexNotReadyDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SsrfBlockedErrorDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedServerVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType
|
||||
|
||||
enum ErrorNames {
|
||||
ACCESS_DENIED
|
||||
@@ -1050,7 +1051,11 @@ enum ErrorNames {
|
||||
RUNTIME_CONFIG_NOT_FOUND
|
||||
SAME_EMAIL_PROVIDED
|
||||
SAME_SUBSCRIPTION_RECURRING
|
||||
SEARCH_INDEX_FAILED
|
||||
SEARCH_INDEX_NOT_READY
|
||||
SEARCH_PERMISSION_SYNCING
|
||||
SEARCH_PROVIDER_NOT_FOUND
|
||||
SEARCH_PROVIDER_UNAVAILABLE
|
||||
SIGN_UP_FORBIDDEN
|
||||
SPACE_ACCESS_DENIED
|
||||
SPACE_NOT_FOUND
|
||||
@@ -2141,6 +2146,14 @@ input SearchHighlight {
|
||||
field: String!
|
||||
}
|
||||
|
||||
type SearchIndexFailedDataType {
|
||||
diagnosticId: String!
|
||||
}
|
||||
|
||||
type SearchIndexNotReadyDataType {
|
||||
spaceId: String!
|
||||
}
|
||||
|
||||
input SearchInput {
|
||||
options: SearchOptions!
|
||||
query: SearchQuery!
|
||||
@@ -2199,8 +2212,17 @@ type SearchResultObjectType {
|
||||
}
|
||||
|
||||
type SearchResultPagination {
|
||||
"""Number of results returned in this response, not a global total"""
|
||||
count: Int!
|
||||
|
||||
"""
|
||||
Whether the provider has more candidates; remaining visible results are not guaranteed
|
||||
"""
|
||||
hasMore: Boolean!
|
||||
|
||||
"""
|
||||
Opaque provider candidate cursor; it does not guarantee complete visible-result pagination
|
||||
"""
|
||||
nextCursor: String
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user