feat(nbstore): add blob sync storage (#10752)

This commit is contained in:
EYHN
2025-03-14 18:05:54 +08:00
committed by GitHub
parent a2eb3fe1b2
commit 05200ad7b7
56 changed files with 1441 additions and 404 deletions
+170 -187
View File
@@ -1,213 +1,196 @@
import EventEmitter2 from 'eventemitter2';
import { difference } from 'lodash-es';
import { BehaviorSubject, type Observable } from 'rxjs';
import {
combineLatest,
map,
type Observable,
ReplaySubject,
share,
throttleTime,
} from 'rxjs';
import type { BlobRecord, BlobStorage } from '../../storage';
import { OverCapacityError } from '../../storage';
import { MANUALLY_STOP, throwIfAborted } from '../../utils/throw-if-aborted';
import type { BlobRecord, BlobStorage, BlobSyncStorage } from '../../storage';
import { MANUALLY_STOP } from '../../utils/throw-if-aborted';
import type { PeerStorageOptions } from '../types';
import { BlobSyncPeer } from './peer';
export interface BlobSyncState {
isStorageOverCapacity: boolean;
total: number;
synced: number;
uploading: number;
downloading: number;
error: number;
overCapacity: boolean;
}
export interface BlobSyncBlobState {
uploading: boolean;
downloading: boolean;
errorMessage?: string | null;
overSize: boolean;
}
export interface BlobSync {
readonly state$: Observable<BlobSyncState>;
downloadBlob(
blobId: string,
signal?: AbortSignal
): Promise<BlobRecord | null>;
uploadBlob(blob: BlobRecord, signal?: AbortSignal): Promise<void>;
fullDownload(signal?: AbortSignal): Promise<void>;
fullUpload(signal?: AbortSignal): Promise<void>;
setMaxBlobSize(size: number): void;
onReachedMaxBlobSize(cb: (byteSize: number) => void): () => void;
blobState$(blobId: string): Observable<BlobSyncBlobState>;
downloadBlob(blobId: string): Promise<void>;
uploadBlob(blob: BlobRecord): Promise<void>;
/**
* Download all blobs from a peer
* @param peerId - The peer id to download from, if not provided, all peers will be downloaded
* @param signal - The abort signal
* @returns A promise that resolves when the download is complete
*/
fullDownload(peerId?: string, signal?: AbortSignal): Promise<void>;
}
export class BlobSyncImpl implements BlobSync {
readonly state$ = new BehaviorSubject<BlobSyncState>({
isStorageOverCapacity: false,
total: Object.values(this.storages.remotes).length ? 1 : 0,
synced: 0,
});
private abort: AbortController | null = null;
private maxBlobSize: number = 1024 * 1024 * 100; // 100MB
readonly event = new EventEmitter2();
// abort all pending jobs when the sync is destroyed
private abortController = new AbortController();
private started = false;
private readonly peers: BlobSyncPeer[] = Object.entries(
this.storages.remotes
).map(
([peerId, remote]) =>
new BlobSyncPeer(peerId, this.storages.local, remote, this.blobSync)
);
constructor(readonly storages: PeerStorageOptions<BlobStorage>) {}
async downloadBlob(blobId: string, signal?: AbortSignal) {
try {
const localBlob = await this.storages.local.get(blobId, signal);
if (localBlob) {
return localBlob;
}
for (const storage of Object.values(this.storages.remotes)) {
const data = await storage.get(blobId, signal);
if (data) {
await this.storages.local.set(data, signal);
return data;
}
}
return null;
} catch (e) {
console.error('error when download blob', e);
return null;
}
}
async uploadBlob(blob: BlobRecord, signal?: AbortSignal) {
if (blob.data.length > this.maxBlobSize) {
this.event.emit('abort-large-blob', blob.data.length);
console.error('blob over limit, abort set');
}
await this.storages.local.set(blob);
await Promise.allSettled(
Object.values(this.storages.remotes).map(async remote => {
try {
return await remote.set(blob, signal);
} catch (err) {
if (err instanceof OverCapacityError) {
this.state$.next({
isStorageOverCapacity: true,
total: this.state$.value.total,
synced: this.state$.value.synced,
});
readonly state$ = combineLatest(this.peers.map(peer => peer.peerState$)).pipe(
// throttle the state to 1 second to avoid spamming the UI
throttleTime(1000),
map(allPeers =>
allPeers.length === 0
? {
uploading: 0,
downloading: 0,
error: 0,
overCapacity: false,
}
throw err;
}
: {
uploading: allPeers.reduce((acc, peer) => acc + peer.uploading, 0),
downloading: allPeers.reduce(
(acc, peer) => acc + peer.downloading,
0
),
error: allPeers.reduce((acc, peer) => acc + peer.error, 0),
overCapacity: allPeers.some(p => p.overCapacity),
}
),
share({
connector: () => new ReplaySubject(1),
})
) as Observable<BlobSyncState>;
blobState$(blobId: string) {
return combineLatest(
this.peers.map(peer => peer.blobPeerState$(blobId))
).pipe(
throttleTime(1000),
map(
peers =>
({
uploading: peers.some(p => p.uploading),
downloading: peers.some(p => p.downloading),
errorMessage: peers.find(p => p.errorMessage)?.errorMessage,
overSize: peers.some(p => p.overSize),
}) satisfies BlobSyncBlobState
),
share({
connector: () => new ReplaySubject(1),
})
);
}
async fullDownload(signal?: AbortSignal) {
throwIfAborted(signal);
constructor(
readonly storages: PeerStorageOptions<BlobStorage>,
readonly blobSync: BlobSyncStorage
) {}
await this.storages.local.connection.waitForConnected(signal);
const localList = (await this.storages.local.list(signal)).map(b => b.key);
this.state$.next({
...this.state$.value,
synced: localList.length,
});
await Promise.allSettled(
Object.entries(this.storages.remotes).map(
async ([remotePeer, remote]) => {
await remote.connection.waitForConnected(signal);
const remoteList = (await remote.list(signal)).map(b => b.key);
this.state$.next({
...this.state$.value,
total: Math.max(this.state$.value.total, remoteList.length),
});
throwIfAborted(signal);
const needDownload = difference(remoteList, localList);
for (const key of needDownload) {
try {
const data = await remote.get(key, signal);
throwIfAborted(signal);
if (data) {
await this.storages.local.set(data, signal);
this.state$.next({
...this.state$.value,
synced: this.state$.value.synced + 1,
});
throwIfAborted(signal);
}
} catch (err) {
if (err === MANUALLY_STOP) {
throw err;
}
console.error(
`error when sync ${key} from [${remotePeer}] to [local]`,
err
);
}
}
}
)
);
}
async fullUpload(signal?: AbortSignal) {
throwIfAborted(signal);
await this.storages.local.connection.waitForConnected(signal);
const localList = (await this.storages.local.list(signal)).map(b => b.key);
await Promise.allSettled(
Object.entries(this.storages.remotes).map(
async ([remotePeer, remote]) => {
await remote.connection.waitForConnected(signal);
const remoteList = (await remote.list(signal)).map(b => b.key);
throwIfAborted(signal);
const needUpload = difference(localList, remoteList);
for (const key of needUpload) {
try {
const data = await this.storages.local.get(key, signal);
throwIfAborted(signal);
if (data) {
await remote.set(data, signal);
throwIfAborted(signal);
}
} catch (err) {
if (err === MANUALLY_STOP) {
throw err;
}
console.error(
`error when sync ${key} from [local] to [${remotePeer}]`,
err
);
}
}
}
)
);
}
start() {
if (this.abort) {
this.abort.abort(MANUALLY_STOP);
}
const abort = new AbortController();
this.abort = abort;
this.fullUpload(abort.signal).catch(error => {
if (error === MANUALLY_STOP) {
downloadBlob(blobId: string) {
const signal = this.abortController.signal;
return Promise.race(
this.peers.map(p => p.downloadBlob(blobId, signal))
).catch(err => {
if (err === MANUALLY_STOP) {
return;
}
console.error('sync blob error', error);
// should never reach here, `downloadBlob()` should never throw
console.error(err);
});
}
uploadBlob(blob: BlobRecord) {
return Promise.all(
this.peers.map(p => p.uploadBlob(blob, this.abortController.signal))
).catch(err => {
if (err === MANUALLY_STOP) {
return;
}
// should never reach here, `uploadBlob()` should never throw
console.error(err);
}) as Promise<void>;
}
// start the upload loop
start() {
if (this.started) {
return;
}
this.started = true;
const signal = this.abortController.signal;
Promise.allSettled(this.peers.map(p => p.fullUploadLoop(signal))).catch(
err => {
// should never reach here
console.error(err);
}
);
}
// download all blobs from a peer
async fullDownload(
peerId?: string,
outerSignal?: AbortSignal
): Promise<void> {
return Promise.race([
Promise.all(
peerId
? [this.fullDownloadPeer(peerId)]
: this.peers.map(p => this.fullDownloadPeer(p.peerId))
),
new Promise<void>((_, reject) => {
// Reject the promise if the outer signal is aborted
// The outer signal only controls the API promise, not the actual download process
if (outerSignal?.aborted) {
reject(outerSignal.reason);
}
outerSignal?.addEventListener('abort', reason => {
reject(reason);
});
}),
]) as Promise<void>;
}
// cache the download promise for each peer
// this is used to avoid downloading the same peer multiple times
private readonly fullDownloadPromise = new Map<string, Promise<void>>();
private fullDownloadPeer(peerId: string) {
const peer = this.peers.find(p => p.peerId === peerId);
if (!peer) {
return;
}
const existing = this.fullDownloadPromise.get(peerId);
if (existing) {
return existing;
}
const promise = peer
.fullDownload(this.abortController.signal)
.finally(() => {
this.fullDownloadPromise.delete(peerId);
});
this.fullDownloadPromise.set(peerId, promise);
return promise;
}
stop() {
this.abort?.abort(MANUALLY_STOP);
this.abort = null;
}
addPriority(_id: string, _priority: number): () => void {
// TODO: implement
return () => {};
}
setMaxBlobSize(size: number): void {
this.maxBlobSize = size;
}
onReachedMaxBlobSize(cb: (byteSize: number) => void): () => void {
this.event.on('abort-large-blob', cb);
return () => {
this.event.off('abort-large-blob', cb);
};
this.abortController.abort();
this.abortController = new AbortController();
this.started = false;
}
}
@@ -0,0 +1,463 @@
import { difference } from 'lodash-es';
import { Observable, ReplaySubject, share, Subject } from 'rxjs';
import type { BlobRecord, BlobStorage } from '../../storage';
import { OverCapacityError, OverSizeError } from '../../storage';
import type { BlobSyncStorage } from '../../storage/blob-sync';
import { MANUALLY_STOP, throwIfAborted } from '../../utils/throw-if-aborted';
export interface BlobSyncPeerState {
uploading: number;
downloading: number;
error: number;
overCapacity: boolean;
}
export interface BlobSyncPeerBlobState {
uploading: boolean;
downloading: boolean;
overSize: boolean;
errorMessage?: string | null;
}
export class BlobSyncPeer {
private readonly status = new BlobSyncPeerStatus();
get peerState$() {
return this.status.peerState$;
}
blobPeerState$(blobId: string) {
return this.status.blobPeerState$(blobId);
}
constructor(
readonly peerId: string,
readonly local: BlobStorage,
readonly remote: BlobStorage,
readonly blobSync: BlobSyncStorage
) {}
private readonly downloadingPromise = new Map<string, Promise<void>>();
downloadBlob(blobId: string, signal?: AbortSignal): Promise<void> {
// if the blob is already downloading, return the existing promise
const existing = this.downloadingPromise.get(blobId);
if (existing) {
return existing;
}
const backoffRetry = {
delay: 1000,
maxDelay: 10000,
count: this.remote.isReadonly ? 1 : 5, // readonly remote storage will not retry
};
const promise = new Promise<void>((resolve, reject) => {
// mark the blob as downloading
this.status.blobDownloading(blobId);
let attempts = 0;
const attempt = async () => {
try {
throwIfAborted(signal);
const data = await this.remote.get(blobId, signal);
throwIfAborted(signal);
if (data) {
// mark the blob as uploaded to avoid uploading the same blob again
await this.blobSync.setBlobUploadedAt(
this.peerId,
blobId,
new Date()
);
await this.local.set(data, signal);
} else {
// if the blob is not found, maybe the uploader have't uploaded the blob yet, we will retry several times
attempts++;
if (attempts < backoffRetry.count) {
const waitTime = Math.min(
Math.pow(2, attempts - 1) * backoffRetry.delay,
backoffRetry.maxDelay
);
// eslint-disable-next-line @typescript-eslint/no-misused-promises
setTimeout(attempt, waitTime);
}
}
resolve();
} catch (error) {
// if we encounter any error, reject without retry
reject(error);
}
};
// eslint-disable-next-line @typescript-eslint/no-floating-promises
attempt();
})
.catch(error => {
if (error === MANUALLY_STOP) {
throw error;
}
this.status.blobError(
blobId,
error instanceof Error ? error.message : String(error)
);
})
.finally(() => {
this.status.blobDownloadFinish(blobId);
this.downloadingPromise.delete(blobId);
});
this.downloadingPromise.set(blobId, promise);
return promise;
}
uploadingPromise = new Map<string, Promise<void>>();
uploadBlob(blob: BlobRecord, signal?: AbortSignal): Promise<void> {
if (this.remote.isReadonly) {
return Promise.resolve();
}
const existing = this.uploadingPromise.get(blob.key);
if (existing) {
return existing;
}
const promise = (async () => {
// mark the blob as uploading
this.status.blobUploading(blob.key);
await this.blobSync.setBlobUploadedAt(this.peerId, blob.key, null);
try {
throwIfAborted(signal);
await this.remote.set(blob, signal);
await this.blobSync.setBlobUploadedAt(
this.peerId,
blob.key,
new Date()
);
// free the remote storage over capacity flag
this.status.remoteOverCapacityFree();
} catch (err) {
if (err === MANUALLY_STOP) {
throw err;
}
if (err instanceof OverCapacityError) {
// mark the remote storage as over capacity, this will stop the upload loop
this.status.remoteOverCapacity();
this.status.blobError(blob.key, 'Remote storage over capacity');
} else if (err instanceof OverSizeError) {
this.status.blobOverSize(blob.key);
this.status.blobError(blob.key, 'Blob size too large');
} else {
this.status.blobError(
blob.key,
err instanceof Error ? err.message : String(err)
);
}
} finally {
this.status.blobUploadFinish(blob.key);
}
})().finally(() => {
this.uploadingPromise.delete(blob.key);
});
this.uploadingPromise.set(blob.key, promise);
return promise;
}
async fullUploadLoop(signal?: AbortSignal) {
while (true) {
try {
await this.fullUpload(signal);
} catch (err) {
if (signal?.aborted) {
return;
}
// should never reach here
console.warn('Blob full upload error, retry in 15s', err);
}
// wait for 15s before next loop
await new Promise<void>(resolve => {
setTimeout(resolve, 15000);
});
if (signal?.aborted) {
return;
}
}
}
private async fullUpload(signal?: AbortSignal) {
if (this.remote.isReadonly) {
return;
}
// if the remote storage is over capacity, skip the upload loop
if (this.status.overCapacity) {
return;
}
await this.local.connection.waitForConnected(signal);
await this.remote.connection.waitForConnected(signal);
const localList = await this.local.list();
const needUpload: string[] = [];
for (const blob of localList) {
const uploadedAt = await this.blobSync.getBlobUploadedAt(
this.peerId,
blob.key
);
if (uploadedAt === null) {
needUpload.push(blob.key);
} else {
// if the blob has uploaded, we clear its error flag here.
// this ensures that the sync status seen by the user is clean.
this.status.blobErrorFree(blob.key);
}
}
if (needUpload.length === 0) {
return;
}
// mark all blobs as will upload
for (const blobKey of needUpload) {
this.status.blobWillUpload(blobKey);
}
try {
if (needUpload.length <= 3) {
// if there is only few blobs to upload, upload them one by one
// upload the blobs
for (const blobKey of needUpload) {
const data = await this.local.get(blobKey);
throwIfAborted(signal);
if (data) {
await this.uploadBlob(data, signal);
}
}
} else {
// if there are many blobs to upload, call remote list to reduce unnecessary uploads
const remoteList = new Set((await this.remote.list()).map(b => b.key));
for (const blobKey of needUpload) {
if (remoteList.has(blobKey)) {
// if the blob is already uploaded, set the blob as uploaded
await this.blobSync.setBlobUploadedAt(
this.peerId,
blobKey,
new Date()
);
// mark the blob as uploaded
this.status.blobUploadFinish(blobKey);
continue;
}
// if the blob is over size, skip it
if (this.status.overSize.has(blobKey)) {
continue;
}
const data = await this.local.get(blobKey);
throwIfAborted(signal);
if (data) {
await this.uploadBlob(data, signal);
}
}
}
} finally {
// remove all will upload flags
for (const blobKey of needUpload) {
this.status.blobWillUploadFinish(blobKey);
}
}
}
async fullDownload(signal?: AbortSignal) {
await this.local.connection.waitForConnected(signal);
await this.remote.connection.waitForConnected(signal);
const localList = (await this.local.list()).map(b => b.key);
const remoteList = (await this.remote.list()).map(b => b.key);
const needDownload = difference(remoteList, localList);
// mark all blobs as will download
for (const blobKey of needDownload) {
this.status.blobWillDownload(blobKey);
}
try {
for (const blobKey of needDownload) {
throwIfAborted(signal);
// download the blobs
await this.downloadBlob(blobKey, signal);
}
} finally {
// remove all will download flags
for (const blobKey of needDownload) {
this.status.blobWillDownloadFinish(blobKey);
}
}
}
async markBlobUploaded(blobKey: string): Promise<void> {
await this.blobSync.setBlobUploadedAt(this.peerId, blobKey, new Date());
}
}
class BlobSyncPeerStatus {
overCapacity = false;
willUpload = new Set<string>();
uploading = new Set<string>();
downloading = new Set<string>();
willDownload = new Set<string>();
error = new Map<string, string>();
overSize = new Set<string>();
peerState$ = new Observable<BlobSyncPeerState>(subscribe => {
const next = () => {
subscribe.next({
uploading: this.willUpload.union(this.uploading).size,
downloading: this.willDownload.union(this.downloading).size,
error: this.error.size,
overCapacity: this.overCapacity,
});
};
next();
const dispose = this.statusUpdatedSubject$.subscribe(() => {
next();
});
return () => {
dispose.unsubscribe();
};
}).pipe(
share({
connector: () => new ReplaySubject(1),
})
);
blobPeerState$(blobId: string) {
return new Observable<BlobSyncPeerBlobState>(subscribe => {
const next = () => {
subscribe.next({
uploading: this.willUpload.has(blobId) || this.uploading.has(blobId),
downloading:
this.willDownload.has(blobId) || this.downloading.has(blobId),
errorMessage: this.error.get(blobId) ?? null,
overSize: this.overSize.has(blobId),
});
};
next();
const dispose = this.statusUpdatedSubject$.subscribe(updatedBlobId => {
if (updatedBlobId === blobId || updatedBlobId === true) {
next();
}
});
return () => {
dispose.unsubscribe();
};
});
}
private readonly statusUpdatedSubject$ = new Subject<string | true>();
blobUploading(blobId: string) {
if (!this.uploading.has(blobId)) {
this.uploading.add(blobId);
this.statusUpdatedSubject$.next(blobId);
}
}
blobUploadFinish(blobId: string) {
let deleted = false;
deleted = this.uploading.delete(blobId) || deleted;
deleted = this.willUpload.delete(blobId) || deleted;
if (deleted) {
this.statusUpdatedSubject$.next(blobId);
}
this.blobErrorFree(blobId);
}
blobWillUpload(blobId: string) {
if (!this.willUpload.has(blobId)) {
this.willUpload.add(blobId);
this.statusUpdatedSubject$.next(blobId);
}
}
blobWillUploadFinish(blobId: string) {
const deleted = this.willUpload.delete(blobId);
if (deleted) {
this.statusUpdatedSubject$.next(blobId);
}
}
blobDownloading(blobId: string) {
if (!this.downloading.has(blobId)) {
this.downloading.add(blobId);
this.statusUpdatedSubject$.next(blobId);
}
}
blobDownloadFinish(blobId: string) {
let deleted = false;
deleted = this.willDownload.delete(blobId) || deleted;
deleted = this.downloading.delete(blobId) || deleted;
if (deleted) {
this.statusUpdatedSubject$.next(blobId);
}
this.blobErrorFree(blobId);
}
blobWillDownload(blobId: string) {
if (!this.willDownload.has(blobId)) {
this.willDownload.add(blobId);
this.statusUpdatedSubject$.next(blobId);
}
}
blobWillDownloadFinish(blobId: string) {
const deleted = this.willDownload.delete(blobId);
if (deleted) {
this.statusUpdatedSubject$.next(blobId);
}
}
blobError(blobId: string, errorMessage: string) {
this.error.set(blobId, errorMessage);
this.statusUpdatedSubject$.next(blobId);
}
remoteOverCapacity() {
if (!this.overCapacity) {
this.overCapacity = true;
this.statusUpdatedSubject$.next(true);
}
}
remoteOverCapacityFree() {
if (this.overCapacity) {
this.overCapacity = false;
this.statusUpdatedSubject$.next(true);
}
}
blobOverSize(blobId: string) {
this.overSize.add(blobId);
this.statusUpdatedSubject$.next(blobId);
}
blobErrorFree(blobId: string) {
let deleted = false;
deleted = this.error.delete(blobId) || deleted;
deleted = this.overSize.delete(blobId) || deleted;
if (deleted) {
this.statusUpdatedSubject$.next(blobId);
}
}
}
+2 -2
View File
@@ -557,10 +557,10 @@ export class DocSyncPeer {
};
this.statusUpdatedSubject$.next(true);
}
// wait for 1s before next retry
// wait for 5s before next retry
await Promise.race([
new Promise<void>(resolve => {
setTimeout(resolve, 1000);
setTimeout(resolve, 5000);
}),
new Promise((_, reject) => {
// exit if manually stopped
+13 -9
View File
@@ -24,6 +24,7 @@ export class Sync {
const doc = storages.local.get('doc');
const blob = storages.local.get('blob');
const docSync = storages.local.get('docSync');
const blobSync = storages.local.get('blobSync');
const awareness = storages.local.get('awareness');
this.doc = new DocSyncImpl(
@@ -38,15 +39,18 @@ export class Sync {
},
docSync
);
this.blob = new BlobSyncImpl({
local: blob,
remotes: Object.fromEntries(
Object.entries(storages.remotes).map(([peerId, remote]) => [
peerId,
remote.get('blob'),
])
),
});
this.blob = new BlobSyncImpl(
{
local: blob,
remotes: Object.fromEntries(
Object.entries(storages.remotes).map(([peerId, remote]) => [
peerId,
remote.get('blob'),
])
),
},
blobSync
);
this.awareness = new AwarenessSyncImpl({
local: awareness,
remotes: Object.fromEntries(