Skip to content

Commit

Permalink
Spawn new Thread for the export [backend], auto-download when export …
Browse files Browse the repository at this point in the history
…done [frontend] (#1376)

Also, uses random temp export folders (to prevent concurrency issues when there are multiple simultaneous attempts to export the same project).
  • Loading branch information
imnasnainaec authored Sep 17, 2021
1 parent 5fe0ec9 commit 1f54b51
Show file tree
Hide file tree
Showing 15 changed files with 131 additions and 118 deletions.
2 changes: 1 addition & 1 deletion Backend.Tests/Controllers/LiftControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ public async Task TestDeletedWordsExportToLift()
await _wordService.Update(_projId, wordToUpdate.Id, word);
await _wordService.DeleteFrontierWord(_projId, wordToDelete.Id);

_liftController.ExportLiftFile(_projId, UserId).Wait();
_liftController.CreateLiftExportThenSignal(_projId, UserId).Wait();
var result = (FileStreamResult)_liftController.DownloadLiftFile(_projId, UserId).Result;
Assert.NotNull(result);

Expand Down
2 changes: 1 addition & 1 deletion Backend.Tests/Helper/TimeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public void TestToUtcIso8601()
}

[Test]
public void UtcNowIso8601()
public void TestUtcNowIso8601()
{
var time = Time.UtcNowIso8601();
Assert.That(time.Contains("T"));
Expand Down
49 changes: 25 additions & 24 deletions Backend/Controllers/LiftController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using BackendFramework.Helper;
using BackendFramework.Interfaces;
Expand Down Expand Up @@ -208,8 +209,7 @@ public async Task<IActionResult> ExportLiftFile(string projectId)
return await ExportLiftFile(projectId, userId);
}

// These internal methods are extracted for unit testing
internal async Task<IActionResult> ExportLiftFile(string projectId, string userId)
private async Task<IActionResult> ExportLiftFile(string projectId, string userId)
{
if (!await _permissionService.HasProjectPermission(HttpContext, Permission.ImportExport))
{
Expand Down Expand Up @@ -238,35 +238,36 @@ internal async Task<IActionResult> ExportLiftFile(string projectId, string userI
// Store in-progress status for the export
_liftService.SetExportInProgress(userId, true);

try
{
// Ensure project has words
var words = await _wordRepo.GetAllWords(projectId);
if (words.Count == 0)
{
_liftService.SetExportInProgress(userId, false);
return BadRequest("No words to export.");
}

// Export the data to a zip, read into memory, and delete zip
var exportedFilepath = await CreateLiftExport(projectId);

// Store the temporary path to the exported file for user to download later.
_liftService.StoreExport(userId, exportedFilepath);
await _notifyService.Clients.All.SendAsync("DownloadReady", userId);
return Ok(projectId);
}
catch
// Ensure project has words
var words = await _wordRepo.GetAllWords(projectId);
if (words.Count == 0)
{
_liftService.SetExportInProgress(userId, false);
throw;
return BadRequest("No words to export.");
}

// Run the task without waiting for completion.
// This Task will be scheduled within the exiting Async executor thread pool efficiently.
// See: https://stackoverflow.com/a/64614779/1398841
_ = Task.Run(() => CreateLiftExportThenSignal(projectId, userId));
return Ok(projectId);
}

// These internal methods are extracted for unit testing.
internal async Task<bool> CreateLiftExportThenSignal(string projectId, string userId)
{
// Export the data to a zip, read into memory, and delete zip.
var exportedFilepath = await CreateLiftExport(projectId);

// Store the temporary path to the exported file for user to download later.
_liftService.StoreExport(userId, exportedFilepath);
await _notifyService.Clients.All.SendAsync(CombineHub.DownloadReady, userId);
return true;
}

internal async Task<string> CreateLiftExport(string projectId)
{
var exportedFilepath = await _liftService.LiftExport(projectId, _wordRepo, _projRepo);
return exportedFilepath;
return await _liftService.LiftExport(projectId, _wordRepo, _projRepo);
}

/// <summary> Downloads project data in zip file </summary>
Expand Down
1 change: 1 addition & 0 deletions Backend/Helper/CombineHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ namespace BackendFramework.Helper
{
public class CombineHub : Hub
{
public const string DownloadReady = "DownloadReady";
}
}
48 changes: 39 additions & 9 deletions Backend/Helper/FileStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,69 +27,99 @@ public enum FileType
[Serializable]
public class HomeFolderNotFoundException : Exception { }

/// <summary> Indicates an invalid input id. </summary>
[Serializable]
public class InvalidIdException : Exception { }

/// <summary>
/// Generate a path to the file name of an audio file for the Project based on the Word ID.
/// </summary>
/// <exception cref="InvalidIdException"> Throws when id invalid. </exception>
public static string GenerateAudioFilePathForWord(string projectId, string wordId)
{
if (!Sanitization.SanitizeId(projectId) || !Sanitization.SanitizeId(wordId))
{
throw new InvalidIdException();
}
return GenerateProjectFilePath(projectId, AudioPathSuffix, wordId, FileType.Audio);
}

/// <summary>
/// Generate a path to the file name of an audio file for the Project.
/// </summary>
/// <exception cref="InvalidIdException"> Throws when id invalid. </exception>
public static string GenerateAudioFilePath(string projectId, string fileName)
{
if (!Sanitization.SanitizeId(projectId))
{
throw new InvalidIdException();
}
return GenerateProjectFilePath(projectId, AudioPathSuffix, fileName);
}

/// <summary>
/// Generate a path to the directory where audio files are stored for the Project.
/// </summary>
/// <exception cref="InvalidIdException"> Throws when id invalid. </exception>
public static string GenerateAudioFileDirPath(string projectId, bool createDir = true)
{
if (!Sanitization.SanitizeId(projectId))
{
throw new InvalidIdException();
}
return GenerateProjectDirPath(projectId, AudioPathSuffix, createDir);
}

/// <summary>
/// Generate a path to the parent directory where Lift exports are stored.
/// </summary>
/// <exception cref="InvalidIdException"> Throws when id invalid. </exception>
/// <remarks> This function is not expected to be used often. </remarks>
public static string GenerateImportExtractedLocationDirPath(string projectId, bool createDir = true)
{
if (!Sanitization.SanitizeId(projectId))
{
throw new InvalidIdException();
}
return GenerateProjectDirPath(projectId, ImportExtractedLocation, createDir);
}

/// <summary>
/// Generate a path to the Lift import folder. This also stores audio files within it.
/// </summary>
/// <exception cref="InvalidIdException"> Throws when id invalid. </exception>
public static string GenerateLiftImportDirPath(string projectId, bool createDir = true)
{
if (!Sanitization.SanitizeId(projectId))
{
throw new InvalidIdException();
}
return GenerateProjectDirPath(projectId, LiftImportSuffix, createDir);
}

/// <summary>
/// Generate a path to the temporary Lift Export folder used during export.
/// </summary>
/// <remarks> This function may be removed in the future and replaced by temporary directory use. </remarks>
public static string GenerateLiftExportDirPath(string projectId, bool createDir = true)
{
return GenerateProjectDirPath(projectId, ExportDir, createDir);
}

/// <summary>
/// Generate the path to where Avatar images are stored.
/// </summary>
/// <exception cref="InvalidIdException"> Throws when id invalid. </exception>
public static string GenerateAvatarFilePath(string userId)
{
if (!Sanitization.SanitizeId(userId))
{
throw new InvalidIdException();
}
return GenerateFilePath(AvatarsDir, userId, FileType.Avatar);
}

/// <summary>
/// Get the top-level path to where all files are stored for the project.
/// </summary>
/// <exception cref="InvalidIdException"> Throws when id invalid. </exception>
public static string GetProjectDir(string projectId)
{
if (!Sanitization.SanitizeId(projectId))
{
throw new InvalidIdException();
}
return GenerateProjectDirPath(projectId, "", false);
}

Expand Down
13 changes: 4 additions & 9 deletions Backend/Services/LiftService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,15 +180,10 @@ public async Task<string> LiftExport(
var vernacularBcp47 = proj.VernacularWritingSystem.Bcp47;

// Generate the zip dir.
var exportDir = FileStorage.GenerateLiftExportDirPath(projectId);
var liftExportDir = Path.Combine(exportDir, "LiftExport");
if (Directory.Exists(liftExportDir))
{
Directory.Delete(liftExportDir, true);
}
var tempExportDir = FileOperations.GetRandomTempDir();

var projNameAsPath = Sanitization.MakeFriendlyForPath(proj.Name, "Lift");
var zipDir = Path.Combine(liftExportDir, projNameAsPath);
var zipDir = Path.Combine(tempExportDir, projNameAsPath);
Directory.CreateDirectory(zipDir);

// Add audio dir inside zip dir.
Expand Down Expand Up @@ -341,7 +336,7 @@ public async Task<string> LiftExport(
}

// Compress everything.
var destinationFileName = Path.Combine(exportDir,
var destinationFileName = Path.Combine(FileOperations.GetRandomTempDir(),
Path.Combine($"LiftExportCompressed-{proj.Id}_{DateTime.Now:yyyy-MM-dd_hh-mm-ss}.zip"));
var zipParentDir = Path.GetDirectoryName(zipDir);
if (zipParentDir is null)
Expand All @@ -351,7 +346,7 @@ public async Task<string> LiftExport(
ZipFile.CreateFromDirectory(zipParentDir, destinationFileName);

// Clean up the temporary folder structure that was compressed.
Directory.Delete(liftExportDir, true);
Directory.Delete(tempExportDir, true);

return destinationFileName;
}
Expand Down
5 changes: 2 additions & 3 deletions src/components/App/SignalRHub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default function SignalRHub() {
connection.stop();
}
setConnection(null);
if (exportState.status === ExportStatus.InProgress) {
if (exportState.status === ExportStatus.Exporting) {
const newConnection = new HubConnectionBuilder()
.withUrl(`${baseURL}/hub`)
.withAutomaticReconnect()
Expand All @@ -34,8 +34,7 @@ export default function SignalRHub() {

useEffect(() => {
if (connection) {
// The methodName must match what is used by the Backend in, e.g.,
// `_notifyService.Clients.All.SendAsync("DownloadReady", userId);`.
// The methodName must match what is in Backend/Helper/CombineHub.cs.
const methodName = "DownloadReady";
// The method is what the frontend does upon message receipt.
const method = (userId: string) => {
Expand Down
26 changes: 17 additions & 9 deletions src/components/ProjectExport/DownloadButton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { IconButton, Tooltip } from "@material-ui/core";
import { Cached, Error as ErrorIcon, GetApp } from "@material-ui/icons";
import { Cached, Error as ErrorIcon } from "@material-ui/icons";
import React, { createRef, ReactElement, useEffect, useState } from "react";
import { Translate } from "react-localize-redux";
import { useDispatch, useSelector } from "react-redux";
Expand All @@ -18,7 +18,10 @@ interface DownloadButtonProps {
colorSecondary?: boolean;
}

/** A button to show export status */
/**
* A button to show export status. This automatically initiates a download
* when a user's export is done, so there should be exactly one copy of this
* component rendered at any given time in the logged-in app. */
export default function DownloadButton(props: DownloadButtonProps) {
const exportState = useSelector(
(state: StoreState) => state.exportProjectState
Expand All @@ -36,6 +39,12 @@ export default function DownloadButton(props: DownloadButtonProps) {
}
}, [downloadLink, fileUrl]);

useEffect(() => {
if (exportState.status === ExportStatus.Success) {
download();
} // eslint-disable-next-line react-hooks/exhaustive-deps
}, [exportState.status]);

function makeExportName(projectName: string) {
return `${projectName}_${getNowDateTimeString()}.zip`;
}
Expand All @@ -60,10 +69,11 @@ export default function DownloadButton(props: DownloadButtonProps) {

function textId(): string {
switch (exportState.status) {
case ExportStatus.InProgress:
case ExportStatus.Exporting:
return "projectExport.exportInProgress";
case ExportStatus.Success:
return "projectExport.downloadReady";
case ExportStatus.Downloading:
return "projectExport.downloadInProgress";
case ExportStatus.Failure:
return "projectExport.exportFailed";
default:
Expand All @@ -73,10 +83,10 @@ export default function DownloadButton(props: DownloadButtonProps) {

function icon(): ReactElement {
switch (exportState.status) {
case ExportStatus.InProgress:
return <Cached />;
case ExportStatus.Exporting:
case ExportStatus.Downloading:
case ExportStatus.Success:
return <GetApp />;
return <Cached />;
case ExportStatus.Failure:
return <ErrorIcon />;
default:
Expand All @@ -94,8 +104,6 @@ export default function DownloadButton(props: DownloadButtonProps) {

function iconFunction(): () => void {
switch (exportState.status) {
case ExportStatus.Success:
return download;
case ExportStatus.Failure:
return reset;
default:
Expand Down
Loading

0 comments on commit 1f54b51

Please sign in to comment.