feat(core): support better battery save mode (#13383)

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

* **New Features**
* Introduced a Document Summary module, enabling live and cached
document summaries with cloud revalidation.
  * Added a feature flag for enabling battery save mode.
* Added explicit pause and resume controls for sync operations,
accessible via UI events and programmatically.

* **Improvements**
* Enhanced sync and indexing logic to support pausing, resuming, and
battery save mode, with improved job prioritization.
* Updated navigation and preview components to use the new document
summary service and improved priority handling.
* Improved logging and state reporting for sync and indexing processes.
* Refined backlink handling with reactive loading states and cloud
revalidation.
* Replaced backlink and link management to use a new dedicated document
links service.
* Enhanced workspace engine to conditionally enable battery save mode
based on feature flags and workspace flavor.

* **Bug Fixes**
* Removed unnecessary debug console logs from various components for
cleaner output.

* **Refactor**
* Replaced battery save mode methods with explicit pause/resume methods
throughout the app and services.
* Modularized and streamlined document summary and sync-related code for
better maintainability.
* Restructured backlink components to improve visibility handling and
data fetching.
* Simplified and improved document backlink data fetching with retry and
loading state management.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
EYHN
2025-08-01 16:31:31 +08:00
committed by GitHub
parent 1661ab1790
commit 1ceed6c145
34 changed files with 717 additions and 286 deletions
@@ -1,7 +1,20 @@
import { Entity, LiveData } from '@toeverything/infra';
import {
catchErrorInto,
effect,
Entity,
exhaustMapWithTrailing,
fromPromise,
LiveData,
onComplete,
onStart,
smartRetry,
} from '@toeverything/infra';
import { tap } from 'rxjs';
import type { DocService } from '../../doc';
import type { DocService, DocsService } from '../../doc';
import type { DocsSearchService } from '../../docs-search';
import type { FeatureFlagService } from '../../feature-flag';
import type { WorkspaceService } from '../../workspace';
export interface Backlink {
docId: string;
@@ -17,13 +30,128 @@ export interface Backlink {
export class DocBacklinks extends Entity {
constructor(
private readonly docsSearchService: DocsSearchService,
private readonly docService: DocService
private readonly docService: DocService,
private readonly docsService: DocsService,
private readonly featureFlagService: FeatureFlagService,
private readonly workspaceService: WorkspaceService
) {
super();
}
backlinks$ = LiveData.from<Backlink[]>(
this.docsSearchService.watchRefsTo(this.docService.doc.id),
[]
backlinks$ = new LiveData<Backlink[] | undefined>(undefined);
isLoading$ = new LiveData<boolean>(false);
error$ = new LiveData<any>(undefined);
revalidateFromCloud = effect(
exhaustMapWithTrailing(() =>
fromPromise(async () => {
const searchFromCloud =
this.featureFlagService.flags.enable_battery_save_mode &&
this.workspaceService.workspace.flavour !== 'local';
const { buckets } = await this.docsSearchService.indexer.aggregate(
'block',
{
type: 'boolean',
occur: 'must',
queries: [
{
type: 'match',
field: 'refDocId',
match: this.docService.doc.id,
},
],
},
'docId',
{
hits: {
fields: [
'docId',
'blockId',
'parentBlockId',
'parentFlavour',
'additional',
'markdownPreview',
],
pagination: {
limit: 5, // the max number of backlinks to show for each doc
},
},
pagination: {
limit: 100,
},
prefer: searchFromCloud ? 'remote' : 'local',
}
);
return buckets.flatMap(bucket => {
const title =
this.docsService.list.doc$(bucket.key).value?.title$.value ?? '';
if (bucket.key === this.docService.doc.id) {
// Ignore if it is a link to the current document.
return [];
}
return bucket.hits.nodes.map(node => {
const blockId = node.fields.blockId ?? '';
const markdownPreview = node.fields.markdownPreview ?? '';
const additional =
typeof node.fields.additional === 'string'
? node.fields.additional
: node.fields.additional[0];
const additionalData: {
displayMode?: string;
noteBlockId?: string;
} = JSON.parse(additional || '{}');
const displayMode = additionalData.displayMode ?? '';
const noteBlockId = additionalData.noteBlockId ?? '';
const parentBlockId =
typeof node.fields.parentBlockId === 'string'
? node.fields.parentBlockId
: node.fields.parentBlockId[0];
const parentFlavour =
typeof node.fields.parentFlavour === 'string'
? node.fields.parentFlavour
: node.fields.parentFlavour[0];
return {
docId: bucket.key,
blockId: typeof blockId === 'string' ? blockId : blockId[0],
title: title,
markdownPreview:
typeof markdownPreview === 'string'
? markdownPreview
: markdownPreview[0],
displayMode:
typeof displayMode === 'string' ? displayMode : displayMode[0],
noteBlockId:
typeof noteBlockId === 'string' ? noteBlockId : noteBlockId[0],
parentBlockId:
typeof parentBlockId === 'string'
? parentBlockId
: parentBlockId[0],
parentFlavour:
typeof parentFlavour === 'string'
? parentFlavour
: parentFlavour[0],
};
});
});
}).pipe(
smartRetry(),
tap(backlinks => {
this.backlinks$.value = backlinks;
}),
catchErrorInto(this.error$),
onStart(() => {
this.isLoading$.value = true;
}),
onComplete(() => {
this.isLoading$.value = false;
})
)
)
);
}
@@ -2,8 +2,10 @@ import { type Framework } from '@toeverything/infra';
import { DocScope } from '../doc/scopes/doc';
import { DocService } from '../doc/services/doc';
import { DocsService } from '../doc/services/docs';
import { DocsSearchService } from '../docs-search';
import { WorkspaceScope } from '../workspace';
import { FeatureFlagService } from '../feature-flag';
import { WorkspaceScope, WorkspaceService } from '../workspace';
import { DocBacklinks } from './entities/doc-backlinks';
import { DocLinks } from './entities/doc-links';
import { DocLinksService } from './services/doc-links';
@@ -17,6 +19,12 @@ export function configureDocLinksModule(framework: Framework) {
.scope(WorkspaceScope)
.scope(DocScope)
.service(DocLinksService)
.entity(DocBacklinks, [DocsSearchService, DocService])
.entity(DocBacklinks, [
DocsSearchService,
DocService,
DocsService,
FeatureFlagService,
WorkspaceService,
])
.entity(DocLinks, [DocsSearchService, DocService]);
}
@@ -0,0 +1,25 @@
import type { Framework } from '@toeverything/infra';
import { WorkspaceServerService } from '../cloud';
import { FeatureFlagService } from '../feature-flag';
import { CacheStorage } from '../storage';
import { WorkspaceScope, WorkspaceService } from '../workspace';
import { DocSummaryService } from './services/doc-summary';
import { DocSummaryStore } from './stores/doc-summary';
export { DocSummaryService };
export function configureDocSummaryModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(DocSummaryService, [
WorkspaceService,
DocSummaryStore,
FeatureFlagService,
])
.store(DocSummaryStore, [
WorkspaceService,
WorkspaceServerService,
CacheStorage,
]);
}
@@ -0,0 +1,109 @@
import { effect, fromPromise, Service, smartRetry } from '@toeverything/infra';
import { catchError, EMPTY, Observable, type Subscription, tap } from 'rxjs';
import type { FeatureFlagService } from '../../feature-flag';
import type { WorkspaceService } from '../../workspace';
import type { DocSummaryStore } from '../stores/doc-summary';
export class DocSummaryService extends Service {
constructor(
private readonly workspaceService: WorkspaceService,
private readonly store: DocSummaryStore,
private readonly featureFlagService: FeatureFlagService
) {
super();
}
private readonly docSummaryCache = new Map<
string,
Observable<string | undefined>
>();
watchDocSummary(docId: string) {
const cached$ = this.docSummaryCache.get(docId);
if (!cached$) {
const ob$ = new Observable<string | undefined>(subscribe => {
if (
this.workspaceService.workspace.flavour === 'local' ||
this.featureFlagService.flags.enable_battery_save_mode.value === false
) {
// use local indexer
const sub = this.store
.watchDocSummaryFromIndexer(docId)
.subscribe(subscribe);
return () => sub.unsubscribe();
}
// use cache, and revalidate from cloud
const sub = this.store.watchDocSummaryCache(docId).subscribe(subscribe);
this.revalidateDocSummaryFromCloud(docId);
return () => sub.unsubscribe();
});
this.docSummaryCache.set(docId, ob$);
return ob$;
}
return cached$;
}
private readonly revalidateDocSummaryFromCloud = effect(
(source$: Observable<string>) => {
// make a lifo queue
const queue: string[] = [];
let currentTask: Subscription | undefined;
const processTask = () => {
if (currentTask) {
return;
}
const docId = queue.pop();
if (!docId) {
return;
}
currentTask = fromPromise(this.store.getDocSummaryFromCloud(docId))
.pipe(
smartRetry(),
tap(summary => {
if (summary) {
this.store.setDocSummaryCache(docId, summary).catch(error => {
console.error(error);
// ignore error
});
}
}),
catchError(error => {
console.error(error);
// ignore error
return EMPTY;
})
)
.subscribe({
complete() {
currentTask = undefined;
processTask();
},
});
};
return new Observable(subscriber => {
const sub = source$.subscribe({
next: value => {
queue.push(value);
processTask();
},
complete: () => {
subscriber.complete();
},
});
return () => {
sub.unsubscribe();
currentTask?.unsubscribe();
};
});
}
);
override dispose() {
this.revalidateDocSummaryFromCloud.unsubscribe();
super.dispose();
}
}
@@ -0,0 +1,87 @@
import { getDocSummaryQuery } from '@affine/graphql';
import { Store } from '@toeverything/infra';
import { map, Observable } from 'rxjs';
import type { WorkspaceServerService } from '../../cloud';
import type { CacheStorage } from '../../storage';
import type { WorkspaceService } from '../../workspace';
export class DocSummaryStore extends Store {
get indexer() {
return this.workspaceService.workspace.engine.indexer;
}
private readonly gql = this.workspaceServerService.server?.gql;
constructor(
private readonly workspaceService: WorkspaceService,
private readonly workspaceServerService: WorkspaceServerService,
private readonly cacheStorage: CacheStorage
) {
super();
}
async getDocSummaryFromCloud(docId: string) {
return this.gql?.({
query: getDocSummaryQuery,
variables: {
workspaceId: this.workspaceService.workspace.id,
docId,
},
}).then(res => res.workspace.doc.summary ?? '');
}
watchDocSummaryFromIndexer(docId: string) {
return new Observable<string>(subscribe => {
const undoIndexer = this.indexer.addPriority(docId, 10);
const undoSync = this.workspaceService.workspace.engine.doc.addPriority(
docId,
10
);
const sub = this.indexer
.search$(
'doc',
{
type: 'match',
field: 'docId',
match: docId,
},
{
fields: ['summary'],
pagination: {
limit: 1,
},
}
)
.pipe(
map(({ nodes }) => {
const node = nodes.at(0);
return (
(typeof node?.fields.summary === 'string'
? node?.fields.summary
: node?.fields.summary[0]) ?? ''
);
})
)
.subscribe(subscribe);
return () => {
undoIndexer();
undoSync();
sub.unsubscribe();
};
});
}
async setDocSummaryCache(docId: string, summary: string) {
return this.cacheStorage.set(
`doc-summary:${this.workspaceService.workspace.id}:${docId}`,
summary
);
}
watchDocSummaryCache(docId: string) {
return this.cacheStorage.watch<string>(
`doc-summary:${this.workspaceService.workspace.id}:${docId}`
);
}
}
@@ -34,6 +34,14 @@ export class Doc extends Entity {
this.yDoc.off('afterTransaction', handleTransactionThrottled);
handleTransactionThrottled.cancel();
});
this.disposables.push(
this.workspaceService.workspace.engine.doc.addPriority(this.id, 100)
);
this.disposables.push(
this.workspaceService.workspace.engine.indexer.addPriority(this.id, 100)
);
}
/**
@@ -4,4 +4,9 @@ import { Doc } from '../entities/doc';
export class DocService extends Service {
public readonly doc = this.framework.createEntity(Doc);
override dispose() {
this.doc.dispose();
super.dispose();
}
}
@@ -228,110 +228,6 @@ export class DocsSearchService extends Service {
);
}
watchRefsTo(docId: string) {
return this.indexer
.aggregate$(
'block',
{
type: 'boolean',
occur: 'must',
queries: [
{
type: 'match',
field: 'refDocId',
match: docId,
},
],
},
'docId',
{
hits: {
fields: [
'docId',
'blockId',
'parentBlockId',
'parentFlavour',
'additional',
'markdownPreview',
],
pagination: {
limit: 5, // the max number of backlinks to show for each doc
},
},
pagination: {
limit: 100,
},
}
)
.pipe(
switchMap(({ buckets }) => {
return fromPromise(async () => {
return buckets.flatMap(bucket => {
const title =
this.docsService.list.doc$(bucket.key).value?.title$.value ??
'';
if (bucket.key === docId) {
// Ignore if it is a link to the current document.
return [];
}
return bucket.hits.nodes.map(node => {
const blockId = node.fields.blockId ?? '';
const markdownPreview = node.fields.markdownPreview ?? '';
const additional =
typeof node.fields.additional === 'string'
? node.fields.additional
: node.fields.additional[0];
const additionalData: {
displayMode?: string;
noteBlockId?: string;
} = JSON.parse(additional || '{}');
const displayMode = additionalData.displayMode ?? '';
const noteBlockId = additionalData.noteBlockId ?? '';
const parentBlockId =
typeof node.fields.parentBlockId === 'string'
? node.fields.parentBlockId
: node.fields.parentBlockId[0];
const parentFlavour =
typeof node.fields.parentFlavour === 'string'
? node.fields.parentFlavour
: node.fields.parentFlavour[0];
return {
docId: bucket.key,
blockId: typeof blockId === 'string' ? blockId : blockId[0],
title: title,
markdownPreview:
typeof markdownPreview === 'string'
? markdownPreview
: markdownPreview[0],
displayMode:
typeof displayMode === 'string'
? displayMode
: displayMode[0],
noteBlockId:
typeof noteBlockId === 'string'
? noteBlockId
: noteBlockId[0],
parentBlockId:
typeof parentBlockId === 'string'
? parentBlockId
: parentBlockId[0],
parentFlavour:
typeof parentFlavour === 'string'
? parentFlavour
: parentFlavour[0],
};
});
});
});
})
);
}
watchDatabasesTo(docId: string) {
const DatabaseAdditionalSchema = z.object({
databaseName: z.string().optional(),
@@ -281,6 +281,13 @@ export const AFFINE_FLAGS = {
configurable: true,
defaultState: true,
},
enable_battery_save_mode: {
category: 'affine',
displayName: 'Enable Battery Save Mode (Require Restart)',
description: 'Enable battery save mode',
configurable: isCanaryBuild,
defaultState: false,
},
} satisfies { [key in string]: FlagInfo };
// oxlint-disable-next-line no-redeclare
@@ -23,6 +23,7 @@ import { configureDocModule } from './doc';
import { configureDocDisplayMetaModule } from './doc-display-meta';
import { configureDocInfoModule } from './doc-info';
import { configureDocLinksModule } from './doc-link';
import { configureDocSummaryModule } from './doc-summary';
import { configureDocsSearchModule } from './docs-search';
import { configureEditorModule } from './editor';
import { configureEditorSettingModule } from './editor-setting';
@@ -124,4 +125,5 @@ export function configureCommonModules(framework: Framework) {
configureCollectionRulesModule(framework);
configureIndexerEmbeddingModule(framework);
configureCommentModule(framework);
configureDocSummaryModule(framework);
}
@@ -337,8 +337,6 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
const isEmpty = isEmptyUpdate(localData) && isEmptyUpdate(cloudData);
console.log('isEmpty', isEmpty, localData, cloudData);
docStorage.connection.disconnect();
const info = await this.getWorkspaceInfo(id, signal);
@@ -4,6 +4,7 @@ import type {
} from '@affine/nbstore/worker/client';
import { Entity } from '@toeverything/infra';
import type { FeatureFlagService } from '../../feature-flag';
import type { NbstoreService } from '../../storage';
import { WorkspaceEngineBeforeStart } from '../events';
import type { WorkspaceService } from '../services/workspace';
@@ -17,7 +18,8 @@ export class WorkspaceEngine extends Entity<{
constructor(
private readonly workspaceService: WorkspaceService,
private readonly nbstoreService: NbstoreService
private readonly nbstoreService: NbstoreService,
private readonly featureFlagService: FeatureFlagService
) {
super();
}
@@ -61,6 +63,14 @@ export class WorkspaceEngine extends Entity<{
`workspace:${this.workspaceService.workspace.flavour}:${this.workspaceService.workspace.id}`,
this.props.engineWorkerInitOptions
);
if (
this.featureFlagService.flags.enable_battery_save_mode.value &&
this.workspaceService.workspace.flavour !== 'local'
) {
store.enableBatterySaveMode().catch(err => {
console.error('error enabling battery save mode', err);
});
}
this.client = store;
this.disposables.push(dispose);
this.eventBus.emit(WorkspaceEngineBeforeStart, this);
@@ -68,6 +78,7 @@ export class WorkspaceEngine extends Entity<{
const rootDoc = this.workspaceService.workspace.docCollection.doc;
// priority load root doc
this.doc.addPriority(rootDoc.guid, 100);
this.indexer.addPriority(rootDoc.guid, 100);
this.doc.start();
this.disposables.push(() => this.doc.stop());
@@ -62,7 +62,6 @@ export class WorkspaceProfile extends Entity<{ metadata: WorkspaceMetadata }> {
}
private setProfile(info: WorkspaceProfileInfo) {
console.log('setProfile', info, isEqual(this.profile$.value, info));
if (isEqual(this.profile$.value, info)) {
return;
}
@@ -74,7 +74,11 @@ export function configureWorkspaceModule(framework: Framework) {
.service(WorkspaceService)
.entity(Workspace, [WorkspaceScope, FeatureFlagService])
.service(WorkspaceEngineService, [WorkspaceScope])
.entity(WorkspaceEngine, [WorkspaceService, NbstoreService])
.entity(WorkspaceEngine, [
WorkspaceService,
NbstoreService,
FeatureFlagService,
])
.impl(WorkspaceLocalState, WorkspaceLocalStateImpl, [
WorkspaceService,
GlobalState,