mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 13:58:50 +08:00
feat(nbstore): add blob sync storage (#10752)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user