feat(nbstore): add upload function to blob frontend (#11247)
This commit is contained in:
@@ -23,7 +23,9 @@ export class BlobFrontend {
|
|||||||
}
|
}
|
||||||
await lock[Symbol.asyncDispose]();
|
await lock[Symbol.asyncDispose]();
|
||||||
|
|
||||||
await this.sync.downloadBlob(blobId);
|
await this.sync.downloadBlob(blobId).catch(() => {
|
||||||
|
// ignore the error as it has already been recorded in the sync status
|
||||||
|
});
|
||||||
return await this.storage.get(blobId);
|
return await this.storage.get(blobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,14 +42,31 @@ export class BlobFrontend {
|
|||||||
|
|
||||||
// We don't wait for the upload to complete,
|
// We don't wait for the upload to complete,
|
||||||
// as the upload process runs asynchronously in the background
|
// as the upload process runs asynchronously in the background
|
||||||
this.sync.uploadBlob(blob).catch(err => {
|
this.sync.uploadBlob(blob, true /* force upload */).catch(() => {
|
||||||
// never reach here
|
// ignore the error as it has already been recorded in the sync status
|
||||||
console.error(err);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads a blob to the peer. Do nothing if the blob has already been uploaded.
|
||||||
|
*
|
||||||
|
* @returns Always resolves to true when successful
|
||||||
|
*
|
||||||
|
* @throws This method will throw an error if the blob is not found locally, if the upload is aborted, or if it fails due to storage limitations.
|
||||||
|
*/
|
||||||
|
async upload(blobIdOrRecord: string | BlobRecord): Promise<boolean> {
|
||||||
|
const blob =
|
||||||
|
typeof blobIdOrRecord === 'string'
|
||||||
|
? await this.storage.get(blobIdOrRecord)
|
||||||
|
: blobIdOrRecord;
|
||||||
|
if (!blob) {
|
||||||
|
throw new Error(`Blob ${blobIdOrRecord} not found`);
|
||||||
|
}
|
||||||
|
return this.sync.uploadBlob(blob, false);
|
||||||
|
}
|
||||||
|
|
||||||
fullDownload(peerId?: string, signal?: AbortSignal) {
|
fullDownload(peerId?: string, signal?: AbortSignal) {
|
||||||
return this.sync.fullDownload(peerId, signal);
|
return this.sync.fullDownload(peerId, signal);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
|
|
||||||
import type { BlobRecord, BlobStorage, BlobSyncStorage } from '../../storage';
|
import type { BlobRecord, BlobStorage, BlobSyncStorage } from '../../storage';
|
||||||
import { MANUALLY_STOP } from '../../utils/throw-if-aborted';
|
|
||||||
import type { PeerStorageOptions } from '../types';
|
import type { PeerStorageOptions } from '../types';
|
||||||
import { BlobSyncPeer } from './peer';
|
import { BlobSyncPeer } from './peer';
|
||||||
|
|
||||||
@@ -29,13 +28,30 @@ export interface BlobSyncBlobState {
|
|||||||
export interface BlobSync {
|
export interface BlobSync {
|
||||||
readonly state$: Observable<BlobSyncState>;
|
readonly state$: Observable<BlobSyncState>;
|
||||||
blobState$(blobId: string): Observable<BlobSyncBlobState>;
|
blobState$(blobId: string): Observable<BlobSyncBlobState>;
|
||||||
downloadBlob(blobId: string): Promise<void>;
|
/**
|
||||||
uploadBlob(blob: BlobRecord): Promise<void>;
|
* Downloads a blob from all peers
|
||||||
|
* @param blobId - The blob ID to download
|
||||||
|
* @returns A promise that resolves to true when the download is complete from any peer, false if no peer has the blob
|
||||||
|
*
|
||||||
|
* @throws This method will throw an error if the download is aborted or fails due to network issues.
|
||||||
|
*/
|
||||||
|
downloadBlob(blobId: string): Promise<boolean>;
|
||||||
|
/**
|
||||||
|
* Upload a blob to all peers
|
||||||
|
* @param blob - The blob to upload
|
||||||
|
* @param force - Whether to force upload the blob, even if it has already been uploaded
|
||||||
|
* @returns A promise that resolves when the upload is complete, should always resolve to true
|
||||||
|
*
|
||||||
|
* @throws This method will throw an error if the upload is aborted or fails due to storage limitations.
|
||||||
|
*/
|
||||||
|
uploadBlob(blob: BlobRecord, force?: boolean): Promise<true>;
|
||||||
/**
|
/**
|
||||||
* Download all blobs from a peer
|
* Download all blobs from a peer
|
||||||
* @param peerId - The peer id to download from, if not provided, all peers will be downloaded
|
* @param peerId - The peer id to download from, if not provided, all peers will be downloaded
|
||||||
* @param signal - The abort signal
|
* @param signal - The abort signal
|
||||||
* @returns A promise that resolves when the download is complete
|
* @returns A promise that resolves when the download is complete
|
||||||
|
*
|
||||||
|
* @throws This method will never throw an error, but the promise will reject if the signal is aborted.
|
||||||
*/
|
*/
|
||||||
fullDownload(peerId?: string, signal?: AbortSignal): Promise<void>;
|
fullDownload(peerId?: string, signal?: AbortSignal): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -102,15 +118,15 @@ export class BlobSyncImpl implements BlobSync {
|
|||||||
readonly blobSync: BlobSyncStorage
|
readonly blobSync: BlobSyncStorage
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
downloadBlob(blobId: string): Promise<void> {
|
downloadBlob(blobId: string): Promise<boolean> {
|
||||||
const signal = this.abortController.signal;
|
const signal = this.abortController.signal;
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<boolean>((resolve, reject) => {
|
||||||
let completed = 0;
|
let completed = 0;
|
||||||
const totalPeers = this.peers.length;
|
const totalPeers = this.peers.length;
|
||||||
|
|
||||||
if (totalPeers === 0) {
|
if (totalPeers === 0) {
|
||||||
resolve();
|
resolve(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,35 +138,29 @@ export class BlobSyncImpl implements BlobSync {
|
|||||||
.then(result => {
|
.then(result => {
|
||||||
if (result === true) {
|
if (result === true) {
|
||||||
// resolve if the peer has success
|
// resolve if the peer has success
|
||||||
resolve();
|
resolve(true);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
// should never throw
|
|
||||||
// unless the signal is aborted
|
|
||||||
reject(err);
|
reject(err);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
completed++;
|
completed++;
|
||||||
if (completed === totalPeers) {
|
if (completed === totalPeers) {
|
||||||
// resolve if all peers finish
|
// resolve if all peers finish
|
||||||
resolve();
|
resolve(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadBlob(blob: BlobRecord) {
|
uploadBlob(blob: BlobRecord, force = false): Promise<true> {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
this.peers.map(p => p.uploadBlob(blob, this.abortController.signal))
|
this.peers.map(p =>
|
||||||
).catch(err => {
|
p.uploadBlob(blob, force, this.abortController.signal)
|
||||||
if (err === MANUALLY_STOP) {
|
)
|
||||||
return;
|
).then(() => true as const);
|
||||||
}
|
|
||||||
// should never reach here, `uploadBlob()` should never throw
|
|
||||||
console.error(err);
|
|
||||||
}) as Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// start the upload loop
|
// start the upload loop
|
||||||
|
|||||||
@@ -41,10 +41,12 @@ export class BlobSyncPeer {
|
|||||||
private readonly downloadingPromise = new Map<string, Promise<boolean>>();
|
private readonly downloadingPromise = new Map<string, Promise<boolean>>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads a blob from the peer with retry logic
|
* Downloads a blob from the peer with exponential backoff retry logic
|
||||||
* @returns true if the blob is downloaded successfully, false if the blob is not found or encounters an error
|
* @param blobId - The ID of the blob to download
|
||||||
|
* @param signal - Optional AbortSignal to cancel the download
|
||||||
|
* @returns true if the blob is downloaded successfully, false if the blob is not found after retries
|
||||||
*
|
*
|
||||||
* @throws This method will never throw (errors are saved to the sync status) unless the signal is aborted
|
* @throws This method will throw an error if the download operation fails due to network issues or is aborted
|
||||||
*/
|
*/
|
||||||
downloadBlob(blobId: string, signal?: AbortSignal): Promise<boolean> {
|
downloadBlob(blobId: string, signal?: AbortSignal): Promise<boolean> {
|
||||||
// if the blob is already downloading, return the existing promise
|
// if the blob is already downloading, return the existing promise
|
||||||
@@ -111,7 +113,7 @@ export class BlobSyncPeer {
|
|||||||
blobId,
|
blobId,
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
);
|
);
|
||||||
return false;
|
throw error;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.status.blobDownloadFinish(blobId);
|
this.status.blobDownloadFinish(blobId);
|
||||||
@@ -122,11 +124,35 @@ export class BlobSyncPeer {
|
|||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadingPromise = new Map<string, Promise<void>>();
|
uploadingPromise = new Map<string, Promise<true>>();
|
||||||
|
|
||||||
uploadBlob(blob: BlobRecord, signal?: AbortSignal): Promise<void> {
|
/**
|
||||||
|
* Upload a blob to the peer
|
||||||
|
* @param blob - The blob to upload
|
||||||
|
* @param force - Whether to force upload the blob, even if it has already been uploaded
|
||||||
|
* @param signal - The abort signal
|
||||||
|
* @returns The promise should always resolve to true when the upload is complete.
|
||||||
|
*
|
||||||
|
* @throws This method will throw an error if the upload is aborted or fails due to storage limitations.
|
||||||
|
*/
|
||||||
|
async uploadBlob(
|
||||||
|
blob: BlobRecord,
|
||||||
|
force = false,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<true> {
|
||||||
if (this.remote.isReadonly) {
|
if (this.remote.isReadonly) {
|
||||||
return Promise.resolve();
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force) {
|
||||||
|
// if the blob has been uploaded, skip the upload
|
||||||
|
const uploadedAt = await this.blobSync.getBlobUploadedAt(
|
||||||
|
this.peerId,
|
||||||
|
blob.key
|
||||||
|
);
|
||||||
|
if (uploadedAt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = this.uploadingPromise.get(blob.key);
|
const existing = this.uploadingPromise.get(blob.key);
|
||||||
@@ -149,6 +175,7 @@ export class BlobSyncPeer {
|
|||||||
|
|
||||||
// free the remote storage over capacity flag
|
// free the remote storage over capacity flag
|
||||||
this.status.remoteOverCapacityFree();
|
this.status.remoteOverCapacityFree();
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err === MANUALLY_STOP) {
|
if (err === MANUALLY_STOP) {
|
||||||
throw err;
|
throw err;
|
||||||
@@ -166,12 +193,13 @@ export class BlobSyncPeer {
|
|||||||
err instanceof Error ? err.message : String(err)
|
err instanceof Error ? err.message : String(err)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
this.status.blobUploadFinish(blob.key);
|
this.status.blobUploadFinish(blob.key);
|
||||||
}
|
}
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
this.uploadingPromise.delete(blob.key);
|
this.uploadingPromise.delete(blob.key);
|
||||||
});
|
}) as Promise<true>;
|
||||||
|
|
||||||
this.uploadingPromise.set(blob.key, promise);
|
this.uploadingPromise.set(blob.key, promise);
|
||||||
return promise;
|
return promise;
|
||||||
@@ -246,7 +274,14 @@ export class BlobSyncPeer {
|
|||||||
const data = await this.local.get(blobKey);
|
const data = await this.local.get(blobKey);
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
if (data) {
|
if (data) {
|
||||||
await this.uploadBlob(data, signal);
|
try {
|
||||||
|
await this.uploadBlob(data, false, signal);
|
||||||
|
} catch (err) {
|
||||||
|
if (err === MANUALLY_STOP) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// ignore the error as it has already been recorded in the sync status
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -275,7 +310,14 @@ export class BlobSyncPeer {
|
|||||||
const data = await this.local.get(blobKey);
|
const data = await this.local.get(blobKey);
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
if (data) {
|
if (data) {
|
||||||
await this.uploadBlob(data, signal);
|
try {
|
||||||
|
await this.uploadBlob(data, false, signal);
|
||||||
|
} catch (err) {
|
||||||
|
if (err === MANUALLY_STOP) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// ignore the error as it has already been recorded in the sync status
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,7 +347,14 @@ export class BlobSyncPeer {
|
|||||||
for (const blobKey of needDownload) {
|
for (const blobKey of needDownload) {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
// download the blobs
|
// download the blobs
|
||||||
await this.downloadBlob(blobKey, signal);
|
try {
|
||||||
|
await this.downloadBlob(blobKey, signal);
|
||||||
|
} catch (err) {
|
||||||
|
if (err === MANUALLY_STOP) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// ignore the error as it has already been recorded in the sync status
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// remove all will download flags
|
// remove all will download flags
|
||||||
|
|||||||
@@ -238,10 +238,10 @@ class WorkerBlobSync implements BlobSync {
|
|||||||
return this.client.ob$('blobSync.blobState', blobId);
|
return this.client.ob$('blobSync.blobState', blobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadBlob(blobId: string): Promise<void> {
|
downloadBlob(blobId: string): Promise<boolean> {
|
||||||
return this.client.call('blobSync.downloadBlob', blobId);
|
return this.client.call('blobSync.downloadBlob', blobId);
|
||||||
}
|
}
|
||||||
uploadBlob(blob: BlobRecord): Promise<void> {
|
uploadBlob(blob: BlobRecord): Promise<true> {
|
||||||
return this.client.call('blobSync.uploadBlob', blob);
|
return this.client.call('blobSync.uploadBlob', blob);
|
||||||
}
|
}
|
||||||
fullDownload(peerId?: string, signal?: AbortSignal): Promise<void> {
|
fullDownload(peerId?: string, signal?: AbortSignal): Promise<void> {
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ interface GroupedWorkerOps {
|
|||||||
blobSync: {
|
blobSync: {
|
||||||
state: [void, BlobSyncState];
|
state: [void, BlobSyncState];
|
||||||
blobState: [string, BlobSyncBlobState];
|
blobState: [string, BlobSyncBlobState];
|
||||||
downloadBlob: [string, void];
|
downloadBlob: [string, boolean];
|
||||||
uploadBlob: [BlobRecord, void];
|
uploadBlob: [BlobRecord, true];
|
||||||
fullDownload: [string | null, void];
|
fullDownload: [string | null, void];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user