Make link-and-sync downloads resumable

This commit is contained in:
Fedor Indutny
2025-02-13 14:09:01 -08:00
committed by GitHub
parent eecac3c1b5
commit 6330156242
4 changed files with 90 additions and 72 deletions
+21 -22
View File
@@ -11,12 +11,11 @@ import type {
BackupMediaItemType,
BackupMediaBatchResponseType,
BackupListMediaResponseType,
TransferArchiveType,
} from '../../textsecure/WebAPI';
import type { BackupCredentials } from './credentials';
import { BackupCredentialType } from '../../types/backups';
import { uploadFile } from '../../util/uploadAttachment';
import { ContinueWithoutSyncingError, RelinkRequestedError } from './errors';
import { missingCaseError } from '../../util/missingCaseError';
export type DownloadOptionsType = Readonly<{
downloadOffset: number;
@@ -24,6 +23,14 @@ export type DownloadOptionsType = Readonly<{
abortSignal?: AbortSignal;
}>;
export type EphemeralDownloadOptionsType = Readonly<{
archive: Readonly<{
cdn: number;
key: string;
}>;
}> &
DownloadOptionsType;
export class BackupAPI {
#cachedBackupInfo = new Map<
BackupCredentialType,
@@ -106,31 +113,23 @@ export class BackupAPI {
});
}
public async getTransferArchive(
abortSignal: AbortSignal
): Promise<TransferArchiveType> {
return this.#server.getTransferArchive({
abortSignal,
});
}
public async downloadEphemeral({
archive,
downloadOffset,
onProgress,
abortSignal,
}: DownloadOptionsType): Promise<Readable> {
const response = await this.#server.getTransferArchive({
abortSignal,
});
if ('error' in response) {
switch (response.error) {
case 'RELINK_REQUESTED':
throw new RelinkRequestedError();
case 'CONTINUE_WITHOUT_UPLOAD':
throw new ContinueWithoutSyncingError();
default:
throw missingCaseError(response.error);
}
}
const { cdn, key } = response;
}: EphemeralDownloadOptionsType): Promise<Readable> {
return this.#server.getEphemeralBackupStream({
cdn,
key,
cdn: archive.cdn,
key: archive.key,
downloadOffset,
onProgress,
abortSignal,
-2
View File
@@ -17,5 +17,3 @@ export class BackupProcessingError extends Error {}
export class BackupImportCanceledError extends Error {}
export class RelinkRequestedError extends Error {}
export class ContinueWithoutSyncingError extends Error {}
+63 -48
View File
@@ -53,7 +53,6 @@ import {
BackupDownloadFailedError,
BackupImportCanceledError,
BackupProcessingError,
ContinueWithoutSyncingError,
RelinkRequestedError,
UnsupportedBackupVersion,
} from './errors';
@@ -227,6 +226,7 @@ export class BackupsService {
await window.storage.remove('backupDownloadPath');
await window.storage.remove('backupEphemeralKey');
await window.storage.remove('backupTransitArchive');
await window.storage.put('isRestoredFromBackup', hasBackup);
// If the primary cancels sync on their end, then we can link without sync
@@ -577,62 +577,77 @@ export class BackupsService {
onProgress?.(InstallScreenBackupStep.Download, currentBytes, totalBytes);
};
await ensureFile(downloadPath);
if (controller.signal.aborted) {
throw new BackupImportCanceledError();
}
let stream: Readable;
try {
await ensureFile(downloadPath);
if (ephemeralKey == null) {
stream = await this.api.download({
downloadOffset,
onProgress: onDownloadProgress,
abortSignal: controller.signal,
});
} else {
let archive = window.storage.get('backupTransitArchive');
if (archive == null) {
const response = await this.api.getTransferArchive(controller.signal);
if ('error' in response) {
switch (response.error) {
case 'RELINK_REQUESTED':
throw new RelinkRequestedError();
// Primary decided to abort syncing process; continue on with no backup
case 'CONTINUE_WITHOUT_UPLOAD':
log.error(
'backups.doDownloadAndImport: primary requested to continue without syncing'
);
return false;
default:
throw missingCaseError(response.error);
}
}
archive = {
cdn: response.cdn,
key: response.key,
};
await window.storage.put('backupTransitArchive', archive);
}
stream = await this.api.downloadEphemeral({
archive,
downloadOffset,
onProgress: onDownloadProgress,
abortSignal: controller.signal,
});
}
} catch (error) {
if (controller.signal.aborted) {
throw new BackupImportCanceledError();
}
let stream: Readable;
try {
if (ephemeralKey == null) {
stream = await this.api.download({
downloadOffset,
onProgress: onDownloadProgress,
abortSignal: controller.signal,
});
} else {
stream = await this.api.downloadEphemeral({
downloadOffset,
onProgress: onDownloadProgress,
abortSignal: controller.signal,
});
}
} catch (error) {
if (controller.signal.aborted) {
throw new BackupImportCanceledError();
}
// No backup on the server
if (error instanceof HTTPError && error.code === 404) {
return false;
}
// Primary decided to abort syncing process; continue on with no backup
if (error instanceof ContinueWithoutSyncingError) {
log.error(
'backups.doDownloadAndImport: primary requested to continue without syncing'
);
return false;
}
// Primary wants to try link & sync again
if (error instanceof RelinkRequestedError) {
throw error;
}
log.error(
'backups.doDownloadAndImport: error downloading backup file',
Errors.toLogFormat(error)
);
throw new BackupDownloadFailedError();
// No backup on the server
if (error instanceof HTTPError && error.code === 404) {
return false;
}
if (controller.signal.aborted) {
throw new BackupImportCanceledError();
}
log.error(
'backups.doDownloadAndImport: error downloading backup file',
Errors.toLogFormat(error)
);
throw new BackupDownloadFailedError();
}
if (controller.signal.aborted) {
throw new BackupImportCanceledError();
}
try {
await pipeline(
stream,
createWriteStream(downloadPath, {
+6
View File
@@ -205,6 +205,12 @@ export type StorageAccessType = {
// link-and-sync backup
backupEphemeralKey: Uint8Array;
// If present - we are resuming the download of known transfer archive
backupTransitArchive: {
cdn: number;
key: string;
};
// If true Desktop message history was restored from backup
isRestoredFromBackup: boolean;