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

View File

@@ -0,0 +1,7 @@
query getDocSummary($workspaceId: String!, $docId: String!) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
summary
}
}
}

View File

@@ -1454,6 +1454,18 @@ export const getDocDefaultRoleQuery = {
}`,
};
export const getDocSummaryQuery = {
id: 'getDocSummaryQuery' as const,
op: 'getDocSummary',
query: `query getDocSummary($workspaceId: String!, $docId: String!) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
summary
}
}
}`,
};
export const getInviteInfoQuery = {
id: 'getInviteInfoQuery' as const,
op: 'getInviteInfo',

View File

@@ -5077,6 +5077,19 @@ export type GetDocDefaultRoleQuery = {
};
};
export type GetDocSummaryQueryVariables = Exact<{
workspaceId: Scalars['String']['input'];
docId: Scalars['String']['input'];
}>;
export type GetDocSummaryQuery = {
__typename?: 'Query';
workspace: {
__typename?: 'WorkspaceType';
doc: { __typename?: 'DocType'; summary: string | null };
};
};
export type GetInviteInfoQueryVariables = Exact<{
inviteId: Scalars['String']['input'];
}>;
@@ -6432,6 +6445,11 @@ export type Queries =
variables: GetDocDefaultRoleQueryVariables;
response: GetDocDefaultRoleQuery;
}
| {
name: 'getDocSummaryQuery';
variables: GetDocSummaryQueryVariables;
response: GetDocSummaryQuery;
}
| {
name: 'getInviteInfoQuery';
variables: GetInviteInfoQueryVariables;

View File

@@ -249,7 +249,7 @@ export class DocFrontend {
while (true) {
throwIfAborted(signal);
const docId = await this.status.jobDocQueue.asyncPop(signal);
const docId = await this.status.jobDocQueue.asyncPop(undefined, signal);
const jobs = this.status.jobMap.get(docId);
this.status.jobMap.delete(docId);

View File

@@ -712,7 +712,7 @@ export class DocSyncPeer {
while (true) {
throwIfAborted(signal);
const docId = await this.status.jobDocQueue.asyncPop(signal);
const docId = await this.status.jobDocQueue.asyncPop(undefined, signal);
while (true) {
// batch process jobs for the same doc

View File

@@ -36,6 +36,8 @@ import { crawlingDocData } from './crawler';
export type IndexerPreferOptions = 'local' | 'remote';
export interface IndexerSyncState {
paused: boolean;
batterySaveMode: boolean;
/**
* Number of documents currently in the indexing queue
*/
@@ -167,6 +169,14 @@ export class IndexerSyncImpl implements IndexerSync {
this.status.disableBatterySaveMode();
}
pauseSync() {
this.status.pauseSync();
}
resumeSync() {
this.status.resumeSync();
}
start() {
if (this.abort) {
this.abort.abort(MANUALLY_STOP);
@@ -324,6 +334,7 @@ export class IndexerSyncImpl implements IndexerSync {
const docId = await this.status.acceptJob(signal);
if (docId === this.rootDocId) {
console.log('[indexer] start indexing root doc', docId);
// #region crawl root doc
for (const [docId, { title }] of this.status.docsInRootDoc) {
const existingDoc = this.status.docsInIndexer.get(docId);
@@ -401,6 +412,7 @@ export class IndexerSyncImpl implements IndexerSync {
// doc is deleted, just skip
continue;
}
console.log('[indexer] start indexing doc', docId);
const docYDoc = new YDoc({ guid: docId });
applyUpdate(docYDoc, docBin.bin);
@@ -454,6 +466,8 @@ export class IndexerSyncImpl implements IndexerSync {
// #endregion
}
console.log('[indexer] complete job', docId);
this.status.completeJob();
}
} finally {
@@ -619,10 +633,11 @@ class IndexerSyncStatus {
currentJob: string | null = null;
errorMessage: string | null = null;
statusUpdatedSubject$ = new Subject<string | true>();
batterySaveMode: {
paused: {
promise: Promise<void>;
resolve: () => void;
} | null = null;
batterySaveMode: boolean = false;
state$ = new Observable<IndexerSyncState>(subscribe => {
const next = () => {
@@ -632,6 +647,8 @@ class IndexerSyncStatus {
total: 0,
errorMessage: this.errorMessage,
completed: true,
batterySaveMode: this.batterySaveMode,
paused: this.paused !== null,
});
} else {
subscribe.next({
@@ -639,6 +656,8 @@ class IndexerSyncStatus {
total: this.docsInRootDoc.size + 1,
errorMessage: this.errorMessage,
completed: this.rootDocReady && this.jobs.length() === 0,
batterySaveMode: this.batterySaveMode,
paused: this.paused !== null,
});
}
};
@@ -697,10 +716,14 @@ class IndexerSyncStatus {
}
async acceptJob(abort?: AbortSignal) {
if (this.batterySaveMode) {
await this.batterySaveMode.promise;
if (this.paused) {
await this.paused.promise;
}
const job = await this.jobs.asyncPop(abort);
const job = await this.jobs.asyncPop(
// if battery save mode is enabled, only accept jobs with priority > 1; otherwise accept all jobs
this.batterySaveMode ? 1 : undefined,
abort
);
this.currentJob = job;
this.statusUpdatedSubject$.next(job);
return job;
@@ -728,12 +751,33 @@ class IndexerSyncStatus {
if (this.batterySaveMode) {
return;
}
this.batterySaveMode = Promise.withResolvers();
this.batterySaveMode = true;
this.statusUpdatedSubject$.next(true);
}
disableBatterySaveMode() {
this.batterySaveMode?.resolve();
this.batterySaveMode = null;
if (!this.batterySaveMode) {
return;
}
this.batterySaveMode = false;
this.statusUpdatedSubject$.next(true);
}
pauseSync() {
if (this.paused) {
return;
}
this.paused = Promise.withResolvers();
this.statusUpdatedSubject$.next(true);
}
resumeSync() {
if (!this.paused) {
return;
}
this.paused.resolve();
this.paused = null;
this.statusUpdatedSubject$.next(true);
}
reset() {
@@ -745,6 +789,8 @@ class IndexerSyncStatus {
this.rootDoc = new YDoc();
this.rootDocReady = false;
this.currentJob = null;
this.batterySaveMode = false;
this.paused = null;
this.statusUpdatedSubject$.next(true);
}
}

View File

@@ -4,8 +4,11 @@ export class AsyncPriorityQueue extends PriorityQueue {
private _resolveUpdate: (() => void) | null = null;
private _waitForUpdate: Promise<void> | null = null;
async asyncPop(abort?: AbortSignal): Promise<string> {
const update = this.pop();
async asyncPop(
minimumPriority?: number,
abort?: AbortSignal
): Promise<string> {
const update = this.pop(minimumPriority);
if (update) {
return update;
} else {
@@ -27,7 +30,7 @@ export class AsyncPriorityQueue extends PriorityQueue {
}),
]);
return this.asyncPop(abort);
return this.asyncPop(minimumPriority, abort);
}
}

View File

@@ -24,13 +24,20 @@ export class PriorityQueue {
this.priorityMap.set(id, priority);
}
pop() {
pop(minimumPriority?: number) {
const node = this.tree.max();
if (!node) {
return null;
}
if (
minimumPriority !== undefined &&
node.getValue().priority < minimumPriority
) {
return null;
}
this.tree.removeNode(node);
const { id } = node.getValue();

View File

@@ -87,17 +87,18 @@ export class StoreManagerClient {
});
}
enableBatterySaveMode() {
pause() {
this.connections.forEach(connection => {
connection.store.enableBatterySaveMode().catch(err => {
console.error('error enabling battery save mode', err);
connection.store.pauseSync().catch(err => {
console.error('error pausing', err);
});
});
}
disableBatterySaveMode() {
resume() {
this.connections.forEach(connection => {
connection.store.disableBatterySaveMode().catch(err => {
console.error('error disabling battery save mode', err);
connection.store.resumeSync().catch(err => {
console.error('error resuming', err);
});
});
}
@@ -135,6 +136,13 @@ export class StoreClient {
disableBatterySaveMode(): Promise<void> {
return this.client.call('sync.disableBatterySaveMode');
}
pauseSync() {
return this.client.call('sync.pauseSync');
}
resumeSync() {
return this.client.call('sync.resumeSync');
}
}
class WorkerDocStorage implements DocStorage {

View File

@@ -135,34 +135,44 @@ class StoreConsumer {
}
private readonly ENABLE_BATTERY_SAVE_MODE_DELAY = 1000;
private enableBatterySaveModeTimeout: NodeJS.Timeout | null = null;
private enabledBatterySaveMode = false;
private syncPauseTimeout: NodeJS.Timeout | null = null;
private syncPaused = false;
enableBatterySaveMode() {
if (this.enableBatterySaveModeTimeout || this.enabledBatterySaveMode) {
private pauseSync() {
if (this.syncPauseTimeout || this.syncPaused) {
return;
}
this.enableBatterySaveModeTimeout = setTimeout(() => {
if (!this.enabledBatterySaveMode) {
this.indexerSync.enableBatterySaveMode();
this.enabledBatterySaveMode = true;
console.log('[BatterySaveMode] enabled');
this.syncPauseTimeout = setTimeout(() => {
if (!this.syncPaused) {
this.indexerSync.pauseSync();
this.syncPaused = true;
console.log('[IndexerSync] paused');
}
}, this.ENABLE_BATTERY_SAVE_MODE_DELAY);
}
disableBatterySaveMode() {
if (this.enableBatterySaveModeTimeout) {
clearTimeout(this.enableBatterySaveModeTimeout);
this.enableBatterySaveModeTimeout = null;
private resumeSync() {
if (this.syncPauseTimeout) {
clearTimeout(this.syncPauseTimeout);
this.syncPauseTimeout = null;
}
if (this.enabledBatterySaveMode) {
this.indexerSync.disableBatterySaveMode();
this.enabledBatterySaveMode = false;
console.log('[BatterySaveMode] disabled');
if (this.syncPaused) {
this.indexerSync.resumeSync();
this.syncPaused = false;
console.log('[IndexerSync] resumed');
}
}
private enableBatterySaveMode() {
console.log('[IndexerSync] enable battery save mode');
this.indexerSync.enableBatterySaveMode();
}
private disableBatterySaveMode() {
console.log('[IndexerSync] disable battery save mode');
this.indexerSync.disableBatterySaveMode();
}
private registerHandlers(consumer: OpConsumer<WorkerOps>) {
const collectJobs = new Map<
string,
@@ -316,6 +326,8 @@ class StoreConsumer {
this.indexerSync.aggregate$(table, query, field, options),
'sync.enableBatterySaveMode': () => this.enableBatterySaveMode(),
'sync.disableBatterySaveMode': () => this.disableBatterySaveMode(),
'sync.pauseSync': () => this.pauseSync(),
'sync.resumeSync': () => this.resumeSync(),
});
}
}

View File

@@ -143,6 +143,8 @@ interface GroupedWorkerOps {
sync: {
enableBatterySaveMode: [void, void];
disableBatterySaveMode: [void, void];
pauseSync: [void, void];
resumeSync: [void, void];
};
}