refactor(nbstore): improve doc state management (#12359)

Move the `waitForSynced` method from `frontend` to `nbstore worker` to make the wait more reliable

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

- **New Features**
  - Added explicit tracking of document updating state to indicate when data is being applied or saved.
  - Introduced new methods to wait for update and synchronization completion with abort support.

- **Improvements**
  - Applied throttling with leading and trailing emissions to state observables for smoother UI updates.
  - Refined synchronization waiting logic for clearer separation between update completion and sync completion.
  - Removed throttling in workspace selector component for more immediate state feedback.
  - Updated import and clipper services to use the new synchronization waiting methods.
  - Simplified asynchronous waiting logic in indexer synchronization methods.

- **Bug Fixes**
  - Enhanced accuracy and reliability of document update and sync status indicators.

- **Tests**
  - Increased wait timeout in avatar selection test to improve stability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
EYHN
2025-05-20 07:33:20 +00:00
parent 151f499154
commit 59ef4b227b
10 changed files with 157 additions and 167 deletions
+72 -96
View File
@@ -1,13 +1,16 @@
import { groupBy } from 'lodash-es';
import { nanoid } from 'nanoid';
import type { Subscription } from 'rxjs';
import {
combineLatest,
filter,
first,
lastValueFrom,
map,
Observable,
ReplaySubject,
share,
Subject,
throttleTime,
} from 'rxjs';
import {
applyUpdate,
@@ -22,6 +25,7 @@ import type { DocRecord, DocStorage } from '../storage';
import type { DocSync } from '../sync/doc';
import { AsyncPriorityQueue } from '../utils/async-priority-queue';
import { isEmptyUpdate } from '../utils/is-empty-update';
import { takeUntilAbort } from '../utils/take-until-abort';
import { MANUALLY_STOP, throwIfAborted } from '../utils/throw-if-aborted';
const NBSTORE_ORIGIN = 'nbstore-frontend';
@@ -86,6 +90,10 @@ export type DocFrontendState = {
* number of docs that have been loaded to yjs doc instance
*/
loaded: number;
/**
* some data is being applied to yjs doc instance, or some data is being saved to local doc storage
*/
updating: boolean;
/**
* number of docs that are syncing with remote peers
*/
@@ -128,7 +136,7 @@ export class DocFrontend {
readonly options: DocFrontendOptions = {}
) {}
docState$(docId: string): Observable<DocFrontendDocState> {
private _docState$(docId: string): Observable<DocFrontendDocState> {
const frontendState$ = new Observable<{
ready: boolean;
loaded: boolean;
@@ -160,24 +168,38 @@ export class DocFrontend {
);
}
state$ = combineLatest([
new Observable<{ total: number; loaded: number }>(subscriber => {
const next = () => {
subscriber.next({
total: this.status.docs.size,
loaded: this.status.connectedDocs.size,
});
};
next();
return this.statusUpdatedSubject$.subscribe(() => {
docState$(docId: string): Observable<DocFrontendDocState> {
return this._docState$(docId).pipe(
throttleTime(1000, undefined, {
trailing: true,
leading: true,
})
);
}
private readonly _state$ = combineLatest([
new Observable<{ total: number; loaded: number; updating: boolean }>(
subscriber => {
const next = () => {
subscriber.next({
total: this.status.docs.size,
loaded: this.status.connectedDocs.size,
updating:
this.status.jobMap.size > 0 || this.status.currentJob !== null,
});
};
next();
});
}),
return this.statusUpdatedSubject$.subscribe(() => {
next();
});
}
),
this.sync.state$,
]).pipe(
map(([frontend, sync]) => ({
total: sync.total ?? frontend.total,
loaded: frontend.loaded,
updating: frontend.updating,
syncing: sync.syncing,
synced: sync.synced,
syncRetrying: sync.retrying,
@@ -188,6 +210,13 @@ export class DocFrontend {
})
) satisfies Observable<DocFrontendState>;
state$ = this._state$.pipe(
throttleTime(1000, undefined, {
leading: true,
trailing: true,
})
);
start() {
if (this.abort.signal.aborted) {
throw new Error('doc frontend can only start once');
@@ -463,96 +492,43 @@ ${changedList}
return merge(updates.filter(bin => !isEmptyUpdate(bin)));
}
async waitForSynced(abort?: AbortSignal) {
let sub: Subscription | undefined = undefined;
return Promise.race([
new Promise<void>(resolve => {
sub = this.state$?.subscribe(status => {
if (status.synced) {
resolve();
}
});
}),
new Promise<void>((_, reject) => {
if (abort?.aborted) {
reject(abort?.reason);
}
abort?.addEventListener('abort', () => {
reject(abort.reason);
});
}),
]).finally(() => {
sub?.unsubscribe();
});
async waitForUpdated(docId?: string, abort?: AbortSignal) {
const source$: Observable<DocFrontendDocState | DocFrontendState> = docId
? this._docState$(docId)
: this._state$;
await lastValueFrom(
source$.pipe(
filter(status => !status.updating),
takeUntilAbort(abort),
first()
)
);
return;
}
async waitForDocLoaded(docId: string, abort?: AbortSignal) {
let sub: Subscription | undefined = undefined;
return Promise.race([
new Promise<void>(resolve => {
sub = this.docState$(docId).subscribe(state => {
if (state.loaded) {
resolve();
}
});
}),
new Promise<void>((_, reject) => {
if (abort?.aborted) {
reject(abort?.reason);
}
abort?.addEventListener('abort', () => {
reject(abort.reason);
});
}),
]).finally(() => {
sub?.unsubscribe();
});
await lastValueFrom(
this._docState$(docId).pipe(
filter(state => state.loaded),
takeUntilAbort(abort),
first()
)
);
}
async waitForDocSynced(docId: string, abort?: AbortSignal) {
let sub: Subscription | undefined = undefined;
return Promise.race([
new Promise<void>(resolve => {
sub = this.docState$(docId).subscribe(state => {
if (state.synced && !state.updating) {
resolve();
}
});
}),
new Promise<void>((_, reject) => {
if (abort?.aborted) {
reject(abort?.reason);
}
abort?.addEventListener('abort', () => {
reject(abort.reason);
});
}),
]).finally(() => {
sub?.unsubscribe();
});
async waitForSynced(docId?: string, abort?: AbortSignal) {
await this.waitForUpdated(docId, abort);
await this.sync.waitForSynced(docId, abort);
}
async waitForDocReady(docId: string, abort?: AbortSignal) {
let sub: Subscription | undefined = undefined;
return Promise.race([
new Promise<void>(resolve => {
sub = this.docState$(docId).subscribe(state => {
if (state.ready) {
resolve();
}
});
}),
new Promise<void>((_, reject) => {
if (abort?.aborted) {
reject(abort?.reason);
}
abort?.addEventListener('abort', () => {
reject(abort.reason);
});
}),
]).finally(() => {
sub?.unsubscribe();
});
await lastValueFrom(
this._docState$(docId).pipe(
filter(state => state.ready),
takeUntilAbort(abort),
first()
)
);
}
async resetSync() {