fix(storage): initialize a workspace under the lock and refuse a missing database
This commit is contained in:
1 parent
78e5faa3c4
commit
647232c447
6 files changed
+141
-6
No files matched your search
@@ -41,6 +41,42 @@ public class WorkspaceSessionTests {
|
|||||||
() => WorkspaceSession.Open(directory.FullPath));
|
() => WorkspaceSession.Open(directory.FullPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void OpenWithoutDatabaseThrowsDatabaseMissing() {
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||||
|
File.Delete(WorkspacePaths.Database(directory.FullPath));
|
||||||
|
|
||||||
|
Assert.ThrowsExactly<WorkspaceDatabaseMissingException>(
|
||||||
|
() => WorkspaceSession.Open(directory.FullPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void InitializeHoldsLockAndSeedsPreset() {
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
|
||||||
|
using WorkspaceSession session = WorkspaceSession.Initialize(directory.FullPath);
|
||||||
|
|
||||||
|
// The lock is held for the whole session, so a second initialize cannot interleave.
|
||||||
|
Assert.ThrowsExactly<WorkspaceLockException>(() => WorkspaceSession.Initialize(directory.FullPath));
|
||||||
|
|
||||||
|
// The preset columns and tags were written under the same lock.
|
||||||
|
Assert.AreEqual(3, session.Repository.GetColumns().Count);
|
||||||
|
Assert.AreEqual(8, session.Repository.GetTags().Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void InitializeExistingWorkspaceThrowsAndReleasesLock() {
|
||||||
|
using var directory = new TempDirectory();
|
||||||
|
WorkspaceInitializer.Initialize(directory.FullPath);
|
||||||
|
|
||||||
|
Assert.ThrowsExactly<InvalidOperationException>(() => WorkspaceSession.Initialize(directory.FullPath));
|
||||||
|
|
||||||
|
// The failed initialize released the lock, so the workspace can still be opened.
|
||||||
|
using WorkspaceSession session = WorkspaceSession.Open(directory.FullPath);
|
||||||
|
Assert.AreEqual(directory.FullPath, session.FolderPath);
|
||||||
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public void RepositoryIsUsableThroughSession() {
|
public void RepositoryIsUsableThroughSession() {
|
||||||
using var directory = new TempDirectory();
|
using var directory = new TempDirectory();
|
||||||
|
|||||||
@@ -45,9 +45,33 @@ public sealed class AppServices : IDisposable {
|
|||||||
return Session;
|
return Session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a fresh workspace and opens it, holding the lock throughout.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="folderPath">The folder to initialize.</param>
|
||||||
|
/// <returns>The open session.</returns>
|
||||||
|
/// <exception cref="YKanBan.Storage.WorkspaceDirectoryMissingException">The folder does not exist on disk.</exception>
|
||||||
|
/// <exception cref="YKanBan.Storage.WorkspaceLockException">Another instance already holds the lock.</exception>
|
||||||
|
public WorkspaceSession InitializeWorkspace(string folderPath) {
|
||||||
|
Session = WorkspaceSession.Initialize(folderPath);
|
||||||
|
return Session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases the workspace session, if any, and forgets it.
|
||||||
|
/// </summary>
|
||||||
|
public void CloseWorkspace() {
|
||||||
|
Session?.Dispose();
|
||||||
|
Session = null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Persists the configuration; called once at shutdown.
|
/// Persists the configuration; called once at shutdown.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
// YYC MARK: the whole app.json is rewritten unconditionally at shutdown.
|
||||||
|
// Running several instances would make the last one to exit overwrite every
|
||||||
|
// field with its own in-memory state. Multi-instance use is not supported,
|
||||||
|
// so this last-writer-wins behavior is accepted.
|
||||||
public void SaveConfiguration() => ConfigStore.Save(Config);
|
public void SaveConfiguration() => ConfigStore.Save(Config);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -55,6 +55,26 @@ public sealed class WorkspaceNotInitializedException : YKanBanException {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signals that .ykanban exists but its database file does not, so the
|
||||||
|
/// workspace data is missing; a new empty database is never created silently.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WorkspaceDatabaseMissingException : YKanBanException {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the database file that was expected to exist.
|
||||||
|
/// </summary>
|
||||||
|
public string DatabasePath { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the exception for a missing workspace database.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="databasePath">The missing database file path.</param>
|
||||||
|
public WorkspaceDatabaseMissingException(string databasePath)
|
||||||
|
: base($"The workspace database is missing: {databasePath}") {
|
||||||
|
DatabasePath = databasePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Signals that another instance already holds the workspace lock, so this
|
/// Signals that another instance already holds the workspace lock, so this
|
||||||
/// process must not touch the workspace.
|
/// process must not touch the workspace.
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ public static class WorkspaceInitializer {
|
|||||||
/// <exception cref="WorkspaceDirectoryMissingException">The folder does not exist on disk.</exception>
|
/// <exception cref="WorkspaceDirectoryMissingException">The folder does not exist on disk.</exception>
|
||||||
/// <exception cref="InvalidOperationException">The workspace database already exists.</exception>
|
/// <exception cref="InvalidOperationException">The workspace database already exists.</exception>
|
||||||
public static void Initialize(string folderPath) {
|
public static void Initialize(string folderPath) {
|
||||||
|
// YYC MARK: this class is deliberately a lock-free, functional unit; it
|
||||||
|
// does not acquire the workspace lock. Ensuring the lock is held before
|
||||||
|
// initialization is the caller's responsibility (see
|
||||||
|
// WorkspaceSession.Initialize). The schema migration and the later preset
|
||||||
|
// writes are also not one transaction, so a crash mid-initialization can
|
||||||
|
// leave a partially built database; that is accepted here.
|
||||||
if (!Directory.Exists(folderPath)) {
|
if (!Directory.Exists(folderPath)) {
|
||||||
throw new WorkspaceDirectoryMissingException(folderPath);
|
throw new WorkspaceDirectoryMissingException(folderPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,9 +38,55 @@ public sealed class WorkspaceSession : IDisposable {
|
|||||||
/// <param name="folderPath">The workspace folder to open.</param>
|
/// <param name="folderPath">The workspace folder to open.</param>
|
||||||
/// <returns>The opened session; dispose it to release the lock and connection.</returns>
|
/// <returns>The opened session; dispose it to release the lock and connection.</returns>
|
||||||
/// <exception cref="WorkspaceNotInitializedException">The folder has no .ykanban structure.</exception>
|
/// <exception cref="WorkspaceNotInitializedException">The folder has no .ykanban structure.</exception>
|
||||||
|
/// <exception cref="WorkspaceDatabaseMissingException">.ykanban exists but its database file does not.</exception>
|
||||||
/// <exception cref="WorkspaceLockException">Another instance already holds the lock.</exception>
|
/// <exception cref="WorkspaceLockException">Another instance already holds the lock.</exception>
|
||||||
public static WorkspaceSession Open(string folderPath) {
|
public static WorkspaceSession Open(string folderPath) {
|
||||||
WorkspaceLock workspaceLock = WorkspaceLock.Acquire(folderPath);
|
WorkspaceLock workspaceLock = WorkspaceLock.Acquire(folderPath);
|
||||||
|
|
||||||
|
// Opening would otherwise create an empty database and show an empty board as if nothing was lost.
|
||||||
|
string databasePath = WorkspacePaths.Database(folderPath);
|
||||||
|
if (!File.Exists(databasePath)) {
|
||||||
|
workspaceLock.Dispose();
|
||||||
|
throw new WorkspaceDatabaseMissingException(databasePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return OpenLocked(folderPath, workspaceLock);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a workspace with its preset columns and tags, then opens it.
|
||||||
|
/// The lock is taken right after .ykanban is created and is never released in
|
||||||
|
/// between, so no other instance can interleave with the initialization.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="folderPath">The existing folder to initialize.</param>
|
||||||
|
/// <returns>The opened session; dispose it to release the lock and connection.</returns>
|
||||||
|
/// <exception cref="WorkspaceDirectoryMissingException">The folder does not exist on disk.</exception>
|
||||||
|
/// <exception cref="WorkspaceLockException">Another instance holds the workspace lock.</exception>
|
||||||
|
/// <exception cref="InvalidOperationException">The workspace database already exists.</exception>
|
||||||
|
public static WorkspaceSession Initialize(string folderPath) {
|
||||||
|
// .ykanban first: the lock file lives inside it. Taking the lock here,
|
||||||
|
// before any database work, keeps two instances from initializing the same
|
||||||
|
// folder at once; WorkspaceInitializer stays a lock-free functional class.
|
||||||
|
Directory.CreateDirectory(WorkspacePaths.Root(folderPath));
|
||||||
|
WorkspaceLock workspaceLock = WorkspaceLock.Acquire(folderPath);
|
||||||
|
try {
|
||||||
|
WorkspaceInitializer.Initialize(folderPath);
|
||||||
|
WorkspacePreset.AddPresetColumns(folderPath);
|
||||||
|
WorkspacePreset.AddPresetTags(folderPath);
|
||||||
|
return OpenLocked(folderPath, workspaceLock);
|
||||||
|
} catch {
|
||||||
|
workspaceLock.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the repository under an already held lock; the lock is released if that fails.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="folderPath">The workspace folder.</param>
|
||||||
|
/// <param name="workspaceLock">The held workspace lock.</param>
|
||||||
|
/// <returns>The opened session.</returns>
|
||||||
|
private static WorkspaceSession OpenLocked(string folderPath, WorkspaceLock workspaceLock) {
|
||||||
try {
|
try {
|
||||||
var repository = new WorkspaceRepository(folderPath);
|
var repository = new WorkspaceRepository(folderPath);
|
||||||
return new WorkspaceSession(folderPath, workspaceLock, repository);
|
return new WorkspaceSession(folderPath, workspaceLock, repository);
|
||||||
|
|||||||
@@ -87,8 +87,14 @@ public sealed partial class MainWindowViewModel : ViewModelBase {
|
|||||||
/// Opens the workspace and switches the content to the normal view.
|
/// Opens the workspace and switches the content to the normal view.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="folderPath">The workspace folder to open.</param>
|
/// <param name="folderPath">The workspace folder to open.</param>
|
||||||
private void OpenWorkspace(string folderPath) {
|
private void OpenWorkspace(string folderPath) => OpenWorkspace(folderPath, _services.OpenWorkspace(folderPath));
|
||||||
WorkspaceSession session = _services.OpenWorkspace(folderPath);
|
|
||||||
|
/// <summary>
|
||||||
|
/// Switches the content to the normal view of an already opened workspace.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="folderPath">The workspace folder.</param>
|
||||||
|
/// <param name="session">The opened workspace session.</param>
|
||||||
|
private void OpenWorkspace(string folderPath, WorkspaceSession session) {
|
||||||
Content = new WorkspaceViewModel(_services, session, ShowSettings, ShowAbout);
|
Content = new WorkspaceViewModel(_services, session, ShowSettings, ShowAbout);
|
||||||
Title = $"{new DirectoryInfo(folderPath).Name} - {Resources.App_Name}";
|
Title = $"{new DirectoryInfo(folderPath).Name} - {Resources.App_Name}";
|
||||||
}
|
}
|
||||||
@@ -104,10 +110,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
WorkspaceInitializer.Initialize(page.FolderPath);
|
OpenWorkspace(page.FolderPath, _services.InitializeWorkspace(page.FolderPath));
|
||||||
WorkspacePreset.AddPresetColumns(page.FolderPath);
|
|
||||||
WorkspacePreset.AddPresetTags(page.FolderPath);
|
|
||||||
OpenWorkspace(page.FolderPath);
|
|
||||||
} catch (Exception exception) when (exception is YKanBanException or IOException or UnauthorizedAccessException) {
|
} catch (Exception exception) when (exception is YKanBanException or IOException or UnauthorizedAccessException) {
|
||||||
// Initialization failed; the page remains so the user can retry.
|
// Initialization failed; the page remains so the user can retry.
|
||||||
Debug.WriteLine(exception);
|
Debug.WriteLine(exception);
|
||||||
|
|||||||
Reference in new issue
Block a user