fix(server): indexer feature (#15533)

<!--
Thank you for contributing to AFFiNE!

The PR title must follow Conventional Commits (enforced by CI):
type(scope): description e.g. fix(editor): keep selection after paste
Types: feat fix docs style refactor perf test build ci chore revert
-->

## Description

<!-- What does this PR do? Link related issues, e.g. "Closes #1234".
Screenshots or recordings are welcome for UI changes. -->

## Checklist

- [ ] I have signed the [AFFiNE Contributor License
Agreement](https://cla-assistant.io/toeverything/AFFiNE) — required
before merge; the `license/cla` check must be green ([how it
works](https://github.com/toeverything/AFFiNE/blob/canary/docs/BUILDING.md#sign-the-cla-first))
- [ ] The PR targets the `canary` branch and its title follows
[Conventional Commits](https://www.conventionalcommits.org/)
- [ ] Tests are added or updated where it makes sense
- [ ] `yarn lint` and `yarn typecheck` pass locally



#### PR Dependency Tree


* **PR #15533** 👈

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

* **Bug Fixes**
* Improved indexer feature synchronization when the indexer is enabled
or configuration changes.
* Enabled indexer-related capabilities without waiting for native search
readiness checks.
* Improved consistency across search, aggregate, document, and
application startup flows.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-08-26 22:14:17 +08:00
committed by GitHub
parent c91b1810bd
commit 612923b55c
3 changed files with 22 additions and 36 deletions
@@ -1,27 +1,18 @@
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 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 });
e2e('should expose the indexer feature when it is enabled', async t => {
const enabled = app.get(Config).indexer.enabled;
const { serverConfig } = await app.gql({ query: serverConfigQuery });
t.is(
serverConfig.features.includes(ServerFeature.Indexer),
enabled,
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 });
@@ -31,7 +31,7 @@ function enabledServer() {
};
}
test('reflects native search readiness in the Node feature flag', async t => {
test('exposes the indexer capability while its projection is building', async t => {
const runtime = {
searchStatus: Sinon.stub(),
searchAuthorized: Sinon.stub().resolves({
@@ -39,9 +39,6 @@ test('reflects native search readiness in the Node feature flag', async t => {
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,
@@ -53,9 +50,9 @@ test('reflects native search readiness in the Node feature flag', async t => {
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);
t.is(server.enableFeature.callCount, 3);
t.false(server.disableFeature.called);
t.false(runtime.searchStatus.called);
});
test('does not query native search when the indexer is disabled', async t => {
@@ -45,26 +45,24 @@ export class IndexerService implements OnApplicationBootstrap {
) {}
async onApplicationBootstrap() {
await this.syncFeature();
this.syncFeature();
}
@OnEvent('config.changed.broadcast')
async onConfigChanged({ updates }: Events['config.changed.broadcast']) {
if (updates.indexer) await this.syncFeature();
if (updates.indexer) this.syncFeature();
}
private async syncFeature() {
if (!this.server.getConfig().indexer.enabled) {
private syncFeature() {
if (this.server.getConfig().indexer.enabled) {
this.server.enableFeature(ServerFeature.Indexer);
} else {
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();
this.syncFeature();
const result = this.unwrap<SearchResult>(
await this.runtime.searchAuthorized(actorUserId, workspaceId, input),
workspaceId
@@ -77,7 +75,7 @@ export class IndexerService implements OnApplicationBootstrap {
workspaceId: string,
input: AggregateInput
) {
await this.syncFeature();
this.syncFeature();
const result = this.unwrap<AggregateResult>(
await this.runtime.aggregateAuthorized(actorUserId, workspaceId, input),
workspaceId
@@ -104,7 +102,7 @@ export class IndexerService implements OnApplicationBootstrap {
keyword: string,
options?: { limit?: number; docIds?: string[] }
): Promise<SearchDoc[]> {
await this.syncFeature();
this.syncFeature();
if (options?.limit !== undefined && options.limit <= 0) {
throw new InvalidIndexerInput({
reason: 'searchDocs limit must be positive',