upload photos in parallel (not all at once)

This commit is contained in:
2020-10-16 16:54:41 +00:00
committed by Stepan Usatiuk
parent c4a5faed81
commit 3bbb23dceb
4 changed files with 239 additions and 59 deletions

View File

@@ -1,4 +1,5 @@
import { Position, Toaster } from "@blueprintjs/core"; import { Position, Toaster } from "@blueprintjs/core";
import { isNumber } from "class-validator";
import { IPhotoReqJSON } from "~../../src/entity/Photo"; import { IPhotoReqJSON } from "~../../src/entity/Photo";
export const AppToaster = Toaster.create({ export const AppToaster = Toaster.create({
@@ -43,11 +44,12 @@ export function showPhotoCreateFailToast(f: File, e: string): void {
} }
export function showPhotoUploadJSONFailToast( export function showPhotoUploadJSONFailToast(
p: IPhotoReqJSON, p: IPhotoReqJSON | number,
e: string, e: string,
): void { ): void {
const photoMsg = typeof p === "number" ? p : p.hash;
AppToaster.show({ AppToaster.show({
message: `Failed to upload ${p.hash}: ${e}`, message: `Failed to upload ${photoMsg}: ${e}`,
intent: "danger", intent: "danger",
timeout: 1000, timeout: 1000,
}); });

View File

@@ -14,8 +14,12 @@ export enum PhotoTypes {
PHOTO_LOAD_SUCCESS = "PHOTO_LOAD_SUCCESS", PHOTO_LOAD_SUCCESS = "PHOTO_LOAD_SUCCESS",
PHOTO_LOAD_FAIL = "PHOTO_LOAD_FAIL", PHOTO_LOAD_FAIL = "PHOTO_LOAD_FAIL",
PHOTOS_UPLOAD_START = "PHOTOS_UPLOAD", PHOTOS_UPLOAD_START = "PHOTOS_UPLOAD",
PHOTO_CREATE_QUEUE = "PHOTO_CREATE_QUEUE",
PHOTO_CREATE_START = "PHOTO_CREATE_START",
PHOTO_CREATE_SUCCESS = "PHOTO_CREATE_SUCCESS", PHOTO_CREATE_SUCCESS = "PHOTO_CREATE_SUCCESS",
PHOTO_CREATE_FAIL = "PHOTO_CREATE_FAIL", PHOTO_CREATE_FAIL = "PHOTO_CREATE_FAIL",
PHOTO_UPLOAD_QUEUE = "PHOTO_UPLOAD_QUEUE",
PHOTO_UPLOAD_START = "PHOTO_UPLOAD_START",
PHOTO_UPLOAD_SUCCESS = "PHOTO_UPLOAD_SUCCESS", PHOTO_UPLOAD_SUCCESS = "PHOTO_UPLOAD_SUCCESS",
PHOTO_UPLOAD_FAIL = "PHOTO_UPLOAD_FAIL", PHOTO_UPLOAD_FAIL = "PHOTO_UPLOAD_FAIL",
PHOTOS_START_FETCHING_SPINNER = "PHOTOS_START_FETCHING_SPINNER", PHOTOS_START_FETCHING_SPINNER = "PHOTOS_START_FETCHING_SPINNER",
@@ -60,6 +64,28 @@ export interface IPhotosUploadStartAction extends Action {
files: FileList; files: FileList;
} }
export interface IPhotoCreateQueue extends Action {
type: PhotoTypes.PHOTO_CREATE_QUEUE;
file: File;
}
export interface IPhotoUploadQueue extends Action {
type: PhotoTypes.PHOTO_UPLOAD_QUEUE;
file: File;
id: number;
}
export interface IPhotoCreateStart extends Action {
type: PhotoTypes.PHOTO_CREATE_START;
file: File;
}
export interface IPhotoUploadStart extends Action {
type: PhotoTypes.PHOTO_UPLOAD_START;
file: File;
id: number;
}
export interface IPhotoUploadSuccessAction extends Action { export interface IPhotoUploadSuccessAction extends Action {
type: PhotoTypes.PHOTO_UPLOAD_SUCCESS; type: PhotoTypes.PHOTO_UPLOAD_SUCCESS;
photo: IPhotoReqJSON; photo: IPhotoReqJSON;
@@ -67,13 +93,14 @@ export interface IPhotoUploadSuccessAction extends Action {
export interface IPhotoUploadFailAction extends Action { export interface IPhotoUploadFailAction extends Action {
type: PhotoTypes.PHOTO_UPLOAD_FAIL; type: PhotoTypes.PHOTO_UPLOAD_FAIL;
photo: IPhotoReqJSON; photo: IPhotoReqJSON | number;
error: string; error: string;
} }
export interface IPhotoCreateSuccessAction extends Action { export interface IPhotoCreateSuccessAction extends Action {
type: PhotoTypes.PHOTO_CREATE_SUCCESS; type: PhotoTypes.PHOTO_CREATE_SUCCESS;
photo: IPhotoReqJSON; photo: IPhotoReqJSON;
file: File;
} }
export interface IPhotoCreateFailAction extends Action { export interface IPhotoCreateFailAction extends Action {
@@ -107,6 +134,22 @@ export interface IPhotosStartFetchingSpinner extends Action {
type: PhotoTypes.PHOTOS_START_FETCHING_SPINNER; type: PhotoTypes.PHOTOS_START_FETCHING_SPINNER;
} }
export function photoCreateQueue(file: File): IPhotoCreateQueue {
return { type: PhotoTypes.PHOTO_CREATE_QUEUE, file };
}
export function photoUploadQueue(file: File, id: number): IPhotoUploadQueue {
return { type: PhotoTypes.PHOTO_UPLOAD_QUEUE, file, id };
}
export function photoCreateStart(file: File): IPhotoCreateStart {
return { type: PhotoTypes.PHOTO_CREATE_START, file };
}
export function photoUploadStart(file: File, id: number): IPhotoUploadStart {
return { type: PhotoTypes.PHOTO_UPLOAD_START, file, id };
}
export function photosLoadStart(): IPhotosLoadStartAction { export function photosLoadStart(): IPhotosLoadStartAction {
return { type: PhotoTypes.PHOTOS_LOAD_START }; return { type: PhotoTypes.PHOTOS_LOAD_START };
} }
@@ -126,7 +169,7 @@ export function photoUploadSuccess(
} }
export function photoUploadFail( export function photoUploadFail(
photo: IPhotoReqJSON, photo: IPhotoReqJSON | number,
error: string, error: string,
): IPhotoUploadFailAction { ): IPhotoUploadFailAction {
showPhotoUploadJSONFailToast(photo, error); showPhotoUploadJSONFailToast(photo, error);
@@ -134,7 +177,7 @@ export function photoUploadFail(
} }
export function photoUploadFailWithFile( export function photoUploadFailWithFile(
photo: IPhotoReqJSON, photo: IPhotoReqJSON | number,
file: File, file: File,
error: string, error: string,
): IPhotoUploadFailAction { ): IPhotoUploadFailAction {
@@ -144,8 +187,9 @@ export function photoUploadFailWithFile(
export function photoCreateSuccess( export function photoCreateSuccess(
photo: IPhotoReqJSON, photo: IPhotoReqJSON,
file: File,
): IPhotoCreateSuccessAction { ): IPhotoCreateSuccessAction {
return { type: PhotoTypes.PHOTO_CREATE_SUCCESS, photo }; return { type: PhotoTypes.PHOTO_CREATE_SUCCESS, photo, file };
} }
export function photoCreateFail( export function photoCreateFail(
@@ -172,8 +216,8 @@ export function photoLoadSuccess(
return { type: PhotoTypes.PHOTO_LOAD_SUCCESS, photo }; return { type: PhotoTypes.PHOTO_LOAD_SUCCESS, photo };
} }
export function photoLoadFail(id:number,error: string): IPhotoLoadFailAction { export function photoLoadFail(id: number, error: string): IPhotoLoadFailAction {
return { type: PhotoTypes.PHOTO_LOAD_FAIL,id, error }; return { type: PhotoTypes.PHOTO_LOAD_FAIL, id, error };
} }
export function photoDeleteStart( export function photoDeleteStart(
@@ -219,4 +263,8 @@ export type PhotoAction =
| IPhotoDeleteCancelAction | IPhotoDeleteCancelAction
| IPhotoLoadFailAction | IPhotoLoadFailAction
| IPhotoLoadStartAction | IPhotoLoadStartAction
| IPhotoLoadSuccessAction; | IPhotoLoadSuccessAction
| IPhotoUploadQueue
| IPhotoCreateQueue
| IPhotoCreateStart
| IPhotoUploadStart;

View File

@@ -18,6 +18,11 @@ export interface IPhotosState {
overviewFetchingError: string | null; overviewFetchingError: string | null;
overviewFetchingSpinner: boolean; overviewFetchingSpinner: boolean;
photoCreateQueue: File[];
photosCreating: number;
photoUploadQueue: Record<number, File>;
photosUploading: number;
deleteCache: Record<number, IPhotoReqJSON>; deleteCache: Record<number, IPhotoReqJSON>;
} }
@@ -28,6 +33,11 @@ const defaultPhotosState: IPhotosState = {
overviewFetchingError: null, overviewFetchingError: null,
overviewFetchingSpinner: false, overviewFetchingSpinner: false,
photoCreateQueue: [],
photosCreating: 0,
photoUploadQueue: {},
photosUploading: 0,
photoStates: {}, photoStates: {},
deleteCache: {}, deleteCache: {},
@@ -104,31 +114,102 @@ export const photosReducer: Reducer<IPhotosState, PhotoAction> = (
photoStates, photoStates,
}; };
} }
case PhotoTypes.PHOTO_CREATE_SUCCESS: case PhotoTypes.PHOTO_CREATE_QUEUE: {
const { photoCreateQueue } = state;
return {
...state,
photoCreateQueue: [...photoCreateQueue, action.file],
};
break;
}
case PhotoTypes.PHOTO_CREATE_START: {
const { photoCreateQueue } = state;
const cleanQueue = photoCreateQueue.filter((f) => f != action.file);
return {
...state,
photosCreating: state.photosCreating + 1,
photoCreateQueue: cleanQueue,
};
break;
}
case PhotoTypes.PHOTO_UPLOAD_START: {
const newQueue = state.photoUploadQueue;
delete newQueue[action.id];
return {
...state,
photosUploading: state.photosUploading + 1,
photoUploadQueue: newQueue,
};
break;
}
case PhotoTypes.PHOTO_UPLOAD_QUEUE: {
const newQueue = state.photoUploadQueue;
newQueue[action.id] = action.file;
return {
...state,
photoUploadQueue: newQueue,
};
break;
}
case PhotoTypes.PHOTO_CREATE_SUCCESS: {
const { photoCreateQueue } = state;
const cleanQueue = photoCreateQueue.filter((f) => f != action.file);
if (state.photos) { if (state.photos) {
const photos = state.photos; const photos = state.photos;
const photosNoDup = photos.filter( const photosNoDup = photos.filter(
(p) => p.id !== action.photo.id, (p) => p.id !== action.photo.id,
); );
const updPhotos = [action.photo, ...photosNoDup]; const updPhotos = [action.photo, ...photosNoDup];
return { ...state, photos: updPhotos }; return {
...state,
photos: updPhotos,
photoCreateQueue: cleanQueue,
photosCreating: state.photosCreating - 1,
};
} else { } else {
return state; return {
...state,
photoCreateQueue: cleanQueue,
photosCreating: state.photosCreating - 1,
};
} }
case PhotoTypes.PHOTO_CREATE_FAIL: }
case PhotoTypes.PHOTO_CREATE_FAIL: {
// TODO: Handle photo create fail // TODO: Handle photo create fail
return state; const { photoCreateQueue } = state;
case PhotoTypes.PHOTO_UPLOAD_SUCCESS: const cleanQueue = photoCreateQueue.filter((f) => f != action.file);
return {
...state,
photoCreateQueue: cleanQueue,
photosCreating: state.photosCreating - 1,
};
}
case PhotoTypes.PHOTO_UPLOAD_SUCCESS: {
const newQueue = state.photoUploadQueue;
delete newQueue[action.photo.id];
if (state.photos) { if (state.photos) {
const photos = state.photos; const photos = state.photos;
const photosNoDup = photos.filter( const photosNoDup = photos.filter(
(p) => p.id !== action.photo.id, (p) => p.id !== action.photo.id,
); );
const updPhotos = [action.photo, ...photosNoDup]; const updPhotos = [action.photo, ...photosNoDup];
return { ...state, photos: updPhotos }; return {
...state,
photos: updPhotos,
photoUploadQueue: newQueue,
photosUploading: state.photosUploading - 1,
};
} else { } else {
return state; return {
...state,
photoUploadQueue: newQueue,
photosUploading: state.photosUploading - 1,
};
} }
}
case PhotoTypes.PHOTO_DELETE_START: case PhotoTypes.PHOTO_DELETE_START:
if (state.photos) { if (state.photos) {
const photos = state.photos; const photos = state.photos;
@@ -172,9 +253,20 @@ export const photosReducer: Reducer<IPhotosState, PhotoAction> = (
return { ...state, deleteCache: delCache, photos }; return { ...state, deleteCache: delCache, photos };
break; break;
} }
case PhotoTypes.PHOTO_UPLOAD_FAIL: case PhotoTypes.PHOTO_UPLOAD_FAIL: {
// TODO: Handle photo upload fail // TODO: Handle photo upload fail
return state; const newQueue = state.photoUploadQueue;
if (typeof action.photo === "number") {
delete newQueue[action.photo];
} else {
delete newQueue[action.photo.id];
}
return {
...state,
photoUploadQueue: newQueue,
photosUploading: state.photosUploading - 1,
};
}
default: default:
return state; return state;
} }

View File

@@ -9,6 +9,7 @@ import {
takeLatest, takeLatest,
takeEvery, takeEvery,
take, take,
select,
} from "redux-saga/effects"; } from "redux-saga/effects";
import * as SparkMD5 from "spark-md5"; import * as SparkMD5 from "spark-md5";
import { import {
@@ -23,6 +24,8 @@ import {
IPhotoLoadStartAction, IPhotoLoadStartAction,
IPhotosUploadStartAction, IPhotosUploadStartAction,
photoCreateFail, photoCreateFail,
photoCreateQueue,
photoCreateStart,
photoCreateSuccess, photoCreateSuccess,
photoDeleteFail, photoDeleteFail,
photoDeleteSuccess, photoDeleteSuccess,
@@ -34,6 +37,8 @@ import {
PhotoTypes, PhotoTypes,
photoUploadFail, photoUploadFail,
photoUploadFailWithFile, photoUploadFailWithFile,
photoUploadQueue,
photoUploadStart,
photoUploadSuccess, photoUploadSuccess,
} from "./actions"; } from "./actions";
import { IPhotosNewRespBody } from "~../../src/routes/photos"; import { IPhotosNewRespBody } from "~../../src/routes/photos";
@@ -160,57 +165,84 @@ function* photoLoad(action: IPhotoLoadStartAction) {
} }
} }
function* photoUpload(f: File) { function* photoCreate() {
try { const store = yield select();
const hash = yield call(computeChecksumMd5, f); const photosCreating = store.photos.photosCreating;
const size = yield call(computeSize, f); if (photosCreating < 2) {
const format = f.type; const createQueue = store.photos.photoCreateQueue as File[];
if (createQueue.length === 0) {
const { response, timeout } = yield race({
response: call(createPhoto, hash, size, format),
timeout: delay(10000),
});
if (timeout) {
yield put(photoCreateFail(f, "Timeout"));
return; return;
} }
if (response.data || response.error === "Photo already exists") { const f = createQueue[0];
const photo = (response as IPhotosNewRespBody).data; yield put(photoCreateStart(f));
yield put(photoCreateSuccess(photo)); try {
const hash = yield call(computeChecksumMd5, f);
const size = yield call(computeSize, f);
const format = f.type;
try { const { response, timeout } = yield race({
const { response, timeout } = yield race({ response: call(createPhoto, hash, size, format),
response: call(uploadPhoto, f, photo.id), timeout: delay(10000),
timeout: delay(10000), });
});
if (timeout) { if (timeout) {
yield put(photoUploadFailWithFile(photo, f, "Timeout")); yield put(photoCreateFail(f, "Timeout"));
return; return;
}
if (response.data) {
const photo = response.data;
yield put(photoUploadSuccess(photo));
} else {
yield put(
photoUploadFailWithFile(photo, f, response.error),
);
}
} catch (e) {
yield put(photoUploadFailWithFile(photo, f, "Internal error"));
} }
} else { if (response.data || response.error === "Photo already exists") {
yield put(photoCreateFail(f, response.error)); const photo = (response as IPhotosNewRespBody).data;
yield put(photoCreateSuccess(photo, f));
yield put(photoUploadQueue(f, photo.id));
} else {
yield put(photoCreateFail(f, response.error));
}
} catch (e) {
yield put(photoCreateFail(f, "Internal error"));
}
}
}
function* photoUpload() {
const store = yield select();
const photosUploading = store.photos.photosUploading;
if (photosUploading < 2) {
const createQueue = store.photos.photoUploadQueue as Record<
number,
File
>;
if (Object.keys(createQueue).length === 0) {
return;
}
const pId = parseInt(Object.keys(createQueue)[0]);
const f = createQueue[pId];
yield put(photoUploadStart(f, pId));
try {
const { response, timeout } = yield race({
response: call(uploadPhoto, f, pId),
timeout: delay(10000),
});
if (timeout) {
yield put(photoUploadFailWithFile(pId, f, "Timeout"));
return;
}
if (response.data) {
const photo = response.data;
yield put(photoUploadSuccess(photo));
} else {
yield put(photoUploadFailWithFile(pId, f, response.error));
}
} catch (e) {
yield put(photoUploadFailWithFile(pId, f, "Internal error"));
} }
} catch (e) {
yield put(photoCreateFail(f, "Internal error"));
} }
} }
function* photosUpload(action: IPhotosUploadStartAction) { function* photosUpload(action: IPhotosUploadStartAction) {
const files = Array.from(action.files); const files = Array.from(action.files);
yield all(files.map((f) => call(photoUpload, f))); for (const file of files) {
yield put(photoCreateQueue(file));
}
} }
function* photoDelete(action: IPhotoDeleteStartAction) { function* photoDelete(action: IPhotoDeleteStartAction) {
@@ -250,5 +282,11 @@ export function* photosSaga() {
takeLatest(PhotoTypes.PHOTOS_UPLOAD_START, photosUpload), takeLatest(PhotoTypes.PHOTOS_UPLOAD_START, photosUpload),
takeLatest(PhotoTypes.PHOTO_LOAD_START, photoLoad), takeLatest(PhotoTypes.PHOTO_LOAD_START, photoLoad),
takeEvery(PhotoTypes.PHOTO_DELETE_START, photoDelete), takeEvery(PhotoTypes.PHOTO_DELETE_START, photoDelete),
takeEvery(PhotoTypes.PHOTO_CREATE_QUEUE, photoCreate),
takeEvery(PhotoTypes.PHOTO_CREATE_SUCCESS, photoCreate),
takeEvery(PhotoTypes.PHOTO_CREATE_FAIL, photoCreate),
takeEvery(PhotoTypes.PHOTO_UPLOAD_QUEUE, photoUpload),
takeEvery(PhotoTypes.PHOTO_UPLOAD_SUCCESS, photoUpload),
takeEvery(PhotoTypes.PHOTO_UPLOAD_FAIL, photoUpload),
]); ]);
} }