Skip to content

Commit

Permalink
Add retries for saving session data
Browse files Browse the repository at this point in the history
  • Loading branch information
0x7c13 committed Dec 6, 2023
1 parent b9d468a commit ac43c4f
Show file tree
Hide file tree
Showing 5 changed files with 165 additions and 70 deletions.
2 changes: 1 addition & 1 deletion src/Notepads/Controls/TextEditor/TextEditor.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -719,7 +719,7 @@ private async Task<TextFile> SaveContentToFileAsync(StorageFile file)
var text = TextEditorCore.GetText();
var encoding = RequestedEncoding ?? LastSavedSnapshot.Encoding;
var lineEnding = RequestedLineEnding ?? LastSavedSnapshot.LineEnding;
await FileSystemUtility.WriteToFileAsync(LineEndingUtility.ApplyLineEnding(text, lineEnding), encoding, file); // Will throw if not succeeded
await FileSystemUtility.WriteTextToFileAsync(file, LineEndingUtility.ApplyLineEnding(text, lineEnding), encoding); // Will throw if not succeeded
var newFileModifiedTime = await FileSystemUtility.GetDateModifiedAsync(file);
return new TextFile(text, encoding, lineEnding, newFileModifiedTime);
}
Expand Down
74 changes: 54 additions & 20 deletions src/Notepads/Core/SessionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,19 @@
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Windows.Storage;
using Windows.Storage.AccessCache;
using Microsoft.AppCenter.Analytics;
using System.Text.Json;
using Notepads.Controls.TextEditor;
using Notepads.Core.SessionDataModels;
using Notepads.Models;
using Notepads.Services;
using Notepads.Utilities;
using Windows.Storage;
using Windows.Storage.AccessCache;

internal class SessionManager : ISessionManager, IDisposable
internal sealed class SessionManager : ISessionManager, IDisposable
{
private static readonly TimeSpan SaveInterval = TimeSpan.FromSeconds(7);
private readonly INotepadsCore _notepadsCore;
Expand Down Expand Up @@ -61,9 +61,9 @@ public async Task<int> LoadLastSessionAsync()
return 0; // Already loaded
}

var data = await SessionUtility.GetSerializedSessionMetaDataAsync(_sessionMetaDataFileName);
bool sessionDataFileExists = await SessionUtility.IsSessionMetaDataFileExists(_sessionMetaDataFileName);

if (data == null)
if (!sessionDataFileExists)
{
return 0; // No session data found
}
Expand All @@ -72,12 +72,13 @@ public async Task<int> LoadLastSessionAsync()

try
{
var json = JsonDocument.Parse(data);
string sessionDataContent = await SessionUtility.GetSerializedSessionMetaDataAsync(_sessionMetaDataFileName);
var json = JsonDocument.Parse(sessionDataContent);
var version = json.RootElement.GetProperty("Version").GetInt32();

if (version == 1)
{
sessionData = JsonSerializer.Deserialize<NotepadsSessionDataV1>(data);
sessionData = JsonSerializer.Deserialize<NotepadsSessionDataV1>(sessionDataContent);
}
else
{
Expand All @@ -88,7 +89,28 @@ public async Task<int> LoadLastSessionAsync()
{
LoggingService.LogError($"[{nameof(SessionManager)}] Failed to load last session metadata: {ex.Message}");
Analytics.TrackEvent("SessionManager_FailedToLoadLastSession", new Dictionary<string, string>() { { "Exception", ex.Message } });

// Last session data is corrupted, clear it first
await ClearSessionDataAsync();

// Rename all backup files to indicate they are corrupted with an extension of ".txt" to avoid being deleted
foreach (StorageFile backupFile in await SessionUtility.GetAllFilesInBackupFolderAsync(_backupFolderName))
{
if (backupFile.Name.Contains(".")) continue; // Skip files with extension

try
{
await backupFile.RenameAsync(backupFile.Name + "-Corrupted.txt");
}
catch (Exception)
{
// Best effort
}
}

// TODO: Open backup folder and notify user with a special dialog
// await Launcher.LaunchFolderAsync(await SessionUtility.GetBackupFolderAsync(_backupFolderName));

return 0;
}

Expand Down Expand Up @@ -208,13 +230,22 @@ public async Task SaveSessionAsync(Action actionAfterSaving = null)
try
{
await DeleteOrphanedBackupFilesAsync(sessionData);
DeleteOrphanedTokensInFutureAccessList(sessionData);
}
catch (Exception ex)
{
Analytics.TrackEvent("SessionManager_FailedToDeleteOrphanedBackupFiles",
new Dictionary<string, string>() { { "Exception", ex.Message } });
}

try
{
DeleteOrphanedTokensInFutureAccessList(sessionData);
}
catch (Exception ex)
{
Analytics.TrackEvent("SessionManager_FailedToDeleteOrphanedTokensInFutureAccessList",
new Dictionary<string, string>() { { "Exception", ex.Message } });
}
}

stopwatch.Stop();
Expand Down Expand Up @@ -452,12 +483,13 @@ private static async Task<bool> BackupTextAsync(string text, Encoding encoding,
{
try
{
await FileSystemUtility.WriteToFileAsync(LineEndingUtility.ApplyLineEnding(text, lineEnding), encoding, file);
await FileSystemUtility.WriteTextToFileAsync(file, LineEndingUtility.ApplyLineEnding(text, lineEnding), encoding);
return true;
}
catch (Exception ex)
{
LoggingService.LogError($"[{nameof(SessionManager)}] Failed to save backup file: {ex.Message}");
Analytics.TrackEvent("SessionManager_FailedToSaveBackupFile", new Dictionary<string, string>() { { "Exception", ex.Message } });
return false;
}
}
Expand All @@ -470,18 +502,20 @@ private async Task DeleteOrphanedBackupFilesAsync(NotepadsSessionDataV1 sessionD
.Where(path => path != null)
.ToHashSet(StringComparer.OrdinalIgnoreCase);

foreach (StorageFile backupFile in await SessionUtility.GetAllBackupFilesAsync(_backupFolderName))
foreach (StorageFile backupFile in await SessionUtility.GetAllFilesInBackupFolderAsync(_backupFolderName))
{
if (!backupPaths.Contains(backupFile.Path))
if (backupPaths.Contains(backupFile.Path)) continue; // Skip files that are known to be used

// Only delete files with no extension (by contract, all backup files have no extension)
if (backupFile.Name.Contains(".")) continue;

try
{
try
{
await backupFile.DeleteAsync();
}
catch (Exception ex)
{
LoggingService.LogError($"[{nameof(SessionManager)}] Failed to delete orphaned backup file: {ex.Message}");
}
await backupFile.DeleteAsync();
}
catch (Exception ex)
{
LoggingService.LogError($"[{nameof(SessionManager)}] Failed to delete orphaned backup file: {ex.Message}");
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/Notepads/Package.appxmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="mp uap uap5 uap10 desktop4 iot2 rescap">

<Identity Name="19282JackieLiu.Notepads-Beta" Publisher="CN=40E66D07-5A3A-4954-9CA3-A1EB15ED0804" Version="1.4.9.0" />
<Identity Name="19282JackieLiu.Notepads-Beta" Publisher="CN=40E66D07-5A3A-4954-9CA3-A1EB15ED0804" Version="1.5.0.0" />
<mp:PhoneIdentity PhoneProductId="df234254-de03-48f0-80a0-11b5082ec740" PhonePublisherId="00000000-0000-0000-0000-000000000000" />

<Properties>
Expand Down
97 changes: 84 additions & 13 deletions src/Notepads/Utilities/FileSystemUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -553,11 +553,7 @@ private static Encoding FixUtf8Bom(Encoding encoding, byte[] bom)
/// Exception will be thrown if not succeeded
/// Exception should be caught and handled by caller
/// </summary>
/// <param name="text"></param>
/// <param name="encoding"></param>
/// <param name="file"></param>
/// <returns></returns>
public static async Task WriteToFileAsync(string text, Encoding encoding, StorageFile file)
public static async Task WriteTextToFileAsync(StorageFile file, string text, Encoding encoding)
{
bool usedDeferUpdates = true;

Expand Down Expand Up @@ -605,18 +601,93 @@ public static async Task WriteToFileAsync(string text, Encoding encoding, Storag
{
// Let Windows know that we're finished changing the file so the
// other app can update the remote version of the file.
FileUpdateStatus status = await CachedFileManager.CompleteUpdatesAsync(file);
if (status != FileUpdateStatus.Complete)
_ = await CachedFileManager.CompleteUpdatesAsync(file);
}
}
}

/// <summary>
/// Save text to a file with UTF-8 encoding using FileIO API with retries for retriable errors
/// </summary>
public static async Task WriteUtf8TextToFileWithRetriesAsync(StorageFile storageFile, string text, int maxRetryAttempts)
{
// Retriable errors
const int ERROR_ACCESS_DENIED = unchecked((Int32)0x80070005);
const int ERROR_SHARING_VIOLATION = unchecked((Int32)0x80070020);
const int ERROR_UNABLE_TO_REMOVE_REPLACED = unchecked((Int32)0x80070497);
const int ERROR_FAIL = unchecked((Int32)0x80004005);

bool usedDeferUpdates = true;
int retryAttempts = 0;

try
{
// Prevent updates to the remote version of the file until we
// finish making changes and call CompleteUpdatesAsync.
CachedFileManager.DeferUpdates(storageFile);
}
catch (Exception)
{
// If DeferUpdates fails, just ignore it and try to save the file anyway
usedDeferUpdates = false;
}

try
{
while (retryAttempts < maxRetryAttempts)
{
try
{
retryAttempts++;
await FileIO.WriteTextAsync(storageFile, text, Windows.Storage.Streams.UnicodeEncoding.Utf8);
break;
}
catch (Exception ex) when ((ex.HResult == ERROR_ACCESS_DENIED) ||
(ex.HResult == ERROR_SHARING_VIOLATION) ||
(ex.HResult == ERROR_UNABLE_TO_REMOVE_REPLACED) ||
(ex.HResult == ERROR_FAIL))
{
// Track FileUpdateStatus here to better understand the failed scenarios
// File name, path and content are not included to respect/protect user privacy
Analytics.TrackEvent("CachedFileManager_CompleteUpdatesAsync_Failed", new Dictionary<string, string>()
{
{ "FileUpdateStatus", nameof(status) }
});
// Delay 13ms before retrying for all retriable errors
await Task.Delay(13);
}
}
}
finally
{
if (retryAttempts > 1) // Retry attempts were used
{
// Track retry attempts to better understand the failed scenarios
// File name, path and content are not included to respect/protect user privacy
Analytics.TrackEvent("SaveSerializedSessionMetaDataAsync_RetryAttempts", new Dictionary<string, string>()
{
{ "RetryAttempts", (retryAttempts - 1).ToString() }
});
}

if (usedDeferUpdates)
{
// Let Windows know that we're finished changing the file so the
// other app can update the remote version of the file.
_ = await CachedFileManager.CompleteUpdatesAsync(storageFile);
}
}
}

public static async Task<StorageFile> GetOrCreateFileAsync(StorageFolder folder, string fileName)
{
try
{
return await folder.CreateFileAsync(fileName, CreationCollisionOption.OpenIfExists);
}
catch (Exception ex)
{
LoggingService.LogError($"[{nameof(FileSystemUtility)}] Failed to get or create file, Exception: {ex.Message}");
Analytics.TrackEvent("GetOrCreateFileAsync_Failed", new Dictionary<string, string>()
{
{ "Exception", ex.ToString() },
});
throw; // Rethrow
}
}

internal static async Task DeleteFileAsync(string filePath, StorageDeleteOption deleteOption = StorageDeleteOption.PermanentDelete)
Expand Down
60 changes: 25 additions & 35 deletions src/Notepads/Utilities/SessionUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
using System.Threading.Tasks;
using Microsoft.Toolkit.Uwp.Helpers;
using Notepads.Core;
using Notepads.Services;
using Windows.Storage;
using Microsoft.AppCenter.Analytics;

internal static class SessionUtility
{
private const string BackupFolderDefaultName = "BackupFiles";
private const string SessionMetaDataFileDefaultName = "NotepadsSessionData.json";
private const int SessionMetaDataFileWriteMaxRetryCount = 7;

private static readonly ConcurrentDictionary<INotepadsCore, ISessionManager> SessionManagers = new ConcurrentDictionary<INotepadsCore, ISessionManager>();

public static ISessionManager GetSessionManager(INotepadsCore notepadCore)
Expand All @@ -34,7 +34,9 @@ public static ISessionManager GetSessionManager(INotepadsCore notepadCore, strin
sessionMetaDataFileName = filePathPrefix + SessionMetaDataFileDefaultName;
}

sessionManager = new SessionManager(notepadCore, backupFolderName, sessionMetaDataFileName);
sessionManager = new SessionManager(notepadCore,
backupFolderName,
sessionMetaDataFileName);

if (!SessionManagers.TryAdd(notepadCore, sessionManager))
{
Expand All @@ -50,60 +52,48 @@ public static async Task<StorageFolder> GetBackupFolderAsync(string backupFolder
return await FileSystemUtility.GetOrCreateAppFolderAsync(backupFolderName);
}

public static async Task<IReadOnlyList<StorageFile>> GetAllBackupFilesAsync(string backupFolderName)
public static async Task<IReadOnlyList<StorageFile>> GetAllFilesInBackupFolderAsync(string backupFolderName)
{
StorageFolder backupFolder = await GetBackupFolderAsync(backupFolderName);
return await backupFolder.GetFilesAsync();
}

public static async Task<string> GetSerializedSessionMetaDataAsync(string sessionMetaDataFileName)
public static async Task<bool> IsSessionMetaDataFileExists(string sessionMetaDataFileName)
{
try
{
StorageFolder localFolder = ApplicationData.Current.LocalFolder;
if (await localFolder.FileExistsAsync(sessionMetaDataFileName))
{
var data = await localFolder.ReadTextFromFileAsync(sessionMetaDataFileName);
LoggingService.LogInfo($"[{nameof(SessionUtility)}] Session metadata Loaded from {localFolder.Path}");
return data;
}
}
catch (Exception ex)
{
LoggingService.LogError($"[{nameof(SessionUtility)}] Failed to get session meta data: {ex.Message}");
Analytics.TrackEvent("FailedToGetSerializedSessionMetaData", new Dictionary<string, string>()
{
{ "Exception", ex.ToString() },
{ "Message", ex.Message }
});
}
StorageFolder localFolder = ApplicationData.Current.LocalFolder;
return await localFolder.FileExistsAsync(sessionMetaDataFileName);
}

return null;
public static async Task<string> GetSerializedSessionMetaDataAsync(string sessionMetaDataFileName)
{
StorageFolder localFolder = ApplicationData.Current.LocalFolder;
return await localFolder.ReadTextFromFileAsync(sessionMetaDataFileName);
}

public static async Task SaveSerializedSessionMetaDataAsync(string serializedData, string sessionMetaDataFileName)
{
StorageFolder localFolder = ApplicationData.Current.LocalFolder;
StorageFile metaDataFile = await FileSystemUtility.GetOrCreateFileAsync(localFolder, sessionMetaDataFileName);
await FileSystemUtility.WriteUtf8TextToFileWithRetriesAsync(metaDataFile, serializedData, SessionMetaDataFileWriteMaxRetryCount);
}

public static async Task DeleteSerializedSessionMetaDataAsync(string sessionMetaDataFileName)
{
StorageFolder localFolder = ApplicationData.Current.LocalFolder;

// Attempt to delete session meta data file first in case it was not been deleted
StorageFile sessionDataFile = null;

try
{
await DeleteSerializedSessionMetaDataAsync(sessionMetaDataFileName);
sessionDataFile = await localFolder.GetFileAsync(sessionMetaDataFileName);
}
catch (Exception)
{
// ignored
}

await localFolder.WriteTextToFileAsync(serializedData, sessionMetaDataFileName, CreationCollisionOption.ReplaceExisting);
}

public static async Task DeleteSerializedSessionMetaDataAsync(string sessionMetaDataFileName)
{
StorageFolder localFolder = ApplicationData.Current.LocalFolder;
if (await localFolder.FileExistsAsync(sessionMetaDataFileName))
if (sessionDataFile != null)
{
var sessionDataFile = await localFolder.GetFileAsync(sessionMetaDataFileName);
await sessionDataFile.DeleteAsync();
}
}
Expand Down

0 comments on commit ac43c4f

Please sign in to comment.