diff --git a/pages/directory-and-name-resolution.md b/pages/directory-and-name-resolution.md index 8c3250a3..eee58713 100644 --- a/pages/directory-and-name-resolution.md +++ b/pages/directory-and-name-resolution.md @@ -67,6 +67,12 @@ That location is owned by LocalDB and cannot be changed. It is derived from the Deleting an instance reclaims the system databases but leaves the logs and event files behind, so purging an instance removes both the instance and this directory. +The per run purge above only sees instances that still have a data directory. Once that directory is gone, cleared with the temp directory or by the purge itself, nothing under the data root points at the instance and it can no longer be found that way. To catch these, this directory is also swept. + +LocalDB instances are shared by everything on the machine that uses LocalDB, and nothing in an instance records what created it. So a marker file is written into the directory of each instance this library starts, and the sweep only ever reclaims marked instances. An instance created by anything else has no marker and is never removed. A marked instance is reclaimed once it has no data directory, is not running, and has been untouched for a threshold defaulting to 30 days. Configurable via the `LocalDBInstanceCleanupDays` environment variable, or `LocalDbSettings.InstanceCleanupThreshold`; set it to zero to disable the sweep. + +Instances that predate marking cannot be told apart from instances belonging to anything else. They get a single best effort pass, once per machine, which removes directories left behind by already deleted instances and orphaned instances that still carry the shrunk `model.mdf` this library leaves behind. Anything else is left alone, since there is no way to tell it apart from an instance belonging to something else, and is left for `SqlLocalDB.exe delete` to remove by hand. That the pass has run is recorded under `%LocalAppData%\LocalDb`. + ## Virus scanning exclusions diff --git a/pages/mdsource/directory-and-name-resolution.source.md b/pages/mdsource/directory-and-name-resolution.source.md index eba2dc26..949fac67 100644 --- a/pages/mdsource/directory-and-name-resolution.source.md +++ b/pages/mdsource/directory-and-name-resolution.source.md @@ -32,6 +32,12 @@ That location is owned by LocalDB and cannot be changed. It is derived from the Deleting an instance reclaims the system databases but leaves the logs and event files behind, so purging an instance removes both the instance and this directory. +The per run purge above only sees instances that still have a data directory. Once that directory is gone, cleared with the temp directory or by the purge itself, nothing under the data root points at the instance and it can no longer be found that way. To catch these, this directory is also swept. + +LocalDB instances are shared by everything on the machine that uses LocalDB, and nothing in an instance records what created it. So a marker file is written into the directory of each instance this library starts, and the sweep only ever reclaims marked instances. An instance created by anything else has no marker and is never removed. A marked instance is reclaimed once it has no data directory, is not running, and has been untouched for a threshold defaulting to 30 days. Configurable via the `LocalDBInstanceCleanupDays` environment variable, or `LocalDbSettings.InstanceCleanupThreshold`; set it to zero to disable the sweep. + +Instances that predate marking cannot be told apart from instances belonging to anything else. They get a single best effort pass, once per machine, which removes directories left behind by already deleted instances and orphaned instances that still carry the shrunk `model.mdf` this library leaves behind. Anything else is left alone, since there is no way to tell it apart from an instance belonging to something else, and is left for `SqlLocalDB.exe delete` to remove by hand. That the pass has run is recorded under `%LocalAppData%\LocalDb`. + ## Virus scanning exclusions diff --git a/readme.md b/readme.md index e5053088..c316559c 100644 --- a/readme.md +++ b/readme.md @@ -59,6 +59,7 @@ Provides a wrapper around [SqlLocalDB](https://docs.microsoft.com/en-us/sql/data * [Inputs](#inputs) * [SqlInstance Startup Flow](#sqlinstance-startup-flow) * [Clean Directory Flow](#clean-directory-flow) + * [Clean Instance Root Flow](#clean-instance-root-flow) * [Create SqlDatabase Flow](#create-sqldatabase-flow) * [Performance](#performance) * [Hardware](#hardware) @@ -353,7 +354,7 @@ flowchart TD ### Clean Directory Flow -On first access, the library scans the data root directory (`%TEMP%\LocalDb` by default) and cleans up stale database files. This prevents unbounded disk growth from old test runs. +On first access, the library scans the data root directory (`%TEMP%\LocalDb` by default) and cleans up stale database files. This prevents unbounded disk growth from old test runs. The directory LocalDB keeps for each instance is swept separately, see [Clean Instance Root Flow](#clean-instance-root-flow). ```mermaid flowchart TD @@ -366,7 +367,7 @@ flowchart TD deleteDirEmpty[Delete Directory] findNewest[Find Newest
File Write Time] checkNewest{Newest File
Older Than
6 Hours?} - stopInstance[Stop LocalDB
Instance If Running] + stopInstance[Remove Instance:
Stop, Delete, and Remove
Its LocalDB Directory] deleteAllFiles[Delete All Files] checkEmptyAfter{Directory
Empty?} deleteDirAfter[Delete Directory] @@ -397,9 +398,89 @@ Key behaviors: * **Triggered once** during `DirectoryFinder` static initialization, before any `SqlInstance` starts. * **All-or-nothing cleanup**: The newest file write time in a directory determines whether the entire instance is stale. If any file is recent, nothing is touched. This avoids partially cleaning an active instance. * **6-hour cutoff**: An instance directory is only cleaned if all its files have a last-write time older than 6 hours. - * **Stops running instances**: If stale files belong to a running LocalDB instance, the instance is stopped (via `KillProcess`) before deletion. This prevents `UnauthorizedAccessException` from locked `.mdf`/`.ldf` files. + * **Removes the instance, not only the files**: If stale files belong to a LocalDB instance, the instance is stopped (via `KillProcess`), deleted, and the directory LocalDB keeps for it removed. Stopping first prevents `UnauthorizedAccessException` from locked `.mdf`/`.ldf` files. Removing the instance along with its data means it never becomes an orphan that nothing under the data root points at. * **Empty directory cleanup**: Empty directories older than 6 hours are removed. * The instance name is derived from the directory name (e.g. `%TEMP%\LocalDb\MyTestInstance` → instance name `MyTestInstance`). + * The instance that is about to be started is treated differently: only its stale data files are removed, and the instance is left in place to be reused, since recreating one is significantly slower than restarting it. + + +### Clean Instance Root Flow + +LocalDB keeps a directory of its own for every instance at `%LocalAppData%\Microsoft\Microsoft SQL Server Local DB\Instances\InstanceName`, holding the system databases (`master`, `model`, `msdb`, `tempdb`), the error logs, and the extended event files. That location is owned by LocalDB and cannot be moved. + +The flow above only sees instances that still have a data directory. Once that directory is gone, cleared with the temp directory or by the flow above, nothing under the data root points at the instance and it is invisible there. Those orphans, and the directories left behind by instances that were already deleted, are reclaimed by this second sweep. + +LocalDB instances are shared by everything on the machine that uses LocalDB, and nothing in an instance records what created it. So a marker file is written into the directory of each instance this library starts, and the sweep only reclaims instances carrying that marker. + +```mermaid +flowchart TD + start[Start: CleanInstanceRoot] + checkEnabled{Cleanup Enabled?
threshold not zero} + checkRootExists{Instance Root
Exists?} + iterateDirs[Iterate Instance
Directories] + checkDefault{LocalDB Managed?
MSSQLLocalDB, v11.0} + checkRegistered{Backed By A
Registered Instance?} + + checkResidueOwn{Marked, Or
Backlog Pass?} + checkResidueAge{Untouched For
5 Minutes?} + deleteResidue[Delete Directory] + + checkAge{Untouched For
Threshold?
default 30 days} + checkRunning{Running?} + checkDataDir{Has A Data
Directory?} + checkMarked{Marked As Created
By This Library?} + checkBacklog{Backlog Pass?} + checkModel{model.mdf
Shrunk?} + removeInstance[Remove Instance:
Stop, Delete, and Remove
Its LocalDB Directory] + + leave[Leave Alone] + done[Done] + + start --> checkEnabled + checkEnabled -->|No| done + checkEnabled -->|Yes| checkRootExists + checkRootExists -->|No| done + checkRootExists -->|Yes| iterateDirs + iterateDirs --> checkDefault + checkDefault -->|Yes| leave + checkDefault -->|No| checkRegistered + + checkRegistered -->|No: residue| checkResidueOwn + checkResidueOwn -->|No| leave + checkResidueOwn -->|Yes| checkResidueAge + checkResidueAge -->|No| leave + checkResidueAge -->|Yes| deleteResidue + deleteResidue --> done + + checkRegistered -->|Yes| checkAge + checkAge -->|No| leave + checkAge -->|Yes| checkRunning + checkRunning -->|Yes| leave + checkRunning -->|No| checkDataDir + checkDataDir -->|Yes: handled above| leave + checkDataDir -->|No: orphan| checkMarked + checkMarked -->|Yes| removeInstance + checkMarked -->|No| checkBacklog + checkBacklog -->|No| leave + checkBacklog -->|Yes| checkModel + checkModel -->|No| leave + checkModel -->|Yes| removeInstance + + removeInstance --> done + leave --> done +``` + +Key behaviors: + + * **Triggered once** during `DirectoryFinder` static initialization, immediately after the data root cleanup above. + * **Only reclaims what it created**: an instance with no marker is never removed, so an instance belonging to anything else on the machine is left alone. The default instances LocalDB manages itself are always skipped. + * **Marked on start as well as on create**: instances that predate marking are picked up the next time they are used. + * **An instance is only an orphan if it has no data directory**: while one exists, the data root cleanup above governs when it is reclaimed, so the two never contend. + * **Running instances are skipped**: they are likely in use by another process. + * **30-day threshold** for instances, configurable via the `LocalDBInstanceCleanupDays` environment variable or `LocalDbSettings.InstanceCleanupThreshold`. Set it to zero to disable the sweep. + * **Residue uses a 5-minute threshold** instead. A directory with no registered instance behind it has no instance to race, only the window where LocalDB has created the directory but not yet registered the instance. Using the full threshold would strand any residue not old enough when the backlog pass runs, since that pass only happens once. + * **The backlog pass runs once per machine**: instances that predate marking cannot be told apart from instances belonging to anything else, so they get a single best effort pass which takes unmarked residue and unmarked orphans that still carry the shrunk `model.mdf` this library leaves behind. A fresh instance leaves `model.mdf` at 8MB, so an instance created by anything else is never matched. That the pass has run is recorded under `%LocalAppData%\LocalDb`, and only once a sweep actually completes. + * **Never fails a test run**: every directory is handled independently and any failure is logged and skipped. ### Create SqlDatabase Flow diff --git a/readme.source.md b/readme.source.md index c7938148..05a7cb7e 100644 --- a/readme.source.md +++ b/readme.source.md @@ -307,7 +307,7 @@ flowchart TD ### Clean Directory Flow -On first access, the library scans the data root directory (`%TEMP%\LocalDb` by default) and cleans up stale database files. This prevents unbounded disk growth from old test runs. +On first access, the library scans the data root directory (`%TEMP%\LocalDb` by default) and cleans up stale database files. This prevents unbounded disk growth from old test runs. The directory LocalDB keeps for each instance is swept separately, see [Clean Instance Root Flow](#clean-instance-root-flow). ```mermaid flowchart TD @@ -320,7 +320,7 @@ flowchart TD deleteDirEmpty[Delete Directory] findNewest[Find Newest
File Write Time] checkNewest{Newest File
Older Than
6 Hours?} - stopInstance[Stop LocalDB
Instance If Running] + stopInstance[Remove Instance:
Stop, Delete, and Remove
Its LocalDB Directory] deleteAllFiles[Delete All Files] checkEmptyAfter{Directory
Empty?} deleteDirAfter[Delete Directory] @@ -351,9 +351,89 @@ Key behaviors: * **Triggered once** during `DirectoryFinder` static initialization, before any `SqlInstance` starts. * **All-or-nothing cleanup**: The newest file write time in a directory determines whether the entire instance is stale. If any file is recent, nothing is touched. This avoids partially cleaning an active instance. * **6-hour cutoff**: An instance directory is only cleaned if all its files have a last-write time older than 6 hours. - * **Stops running instances**: If stale files belong to a running LocalDB instance, the instance is stopped (via `KillProcess`) before deletion. This prevents `UnauthorizedAccessException` from locked `.mdf`/`.ldf` files. + * **Removes the instance, not only the files**: If stale files belong to a LocalDB instance, the instance is stopped (via `KillProcess`), deleted, and the directory LocalDB keeps for it removed. Stopping first prevents `UnauthorizedAccessException` from locked `.mdf`/`.ldf` files. Removing the instance along with its data means it never becomes an orphan that nothing under the data root points at. * **Empty directory cleanup**: Empty directories older than 6 hours are removed. * The instance name is derived from the directory name (e.g. `%TEMP%\LocalDb\MyTestInstance` → instance name `MyTestInstance`). + * The instance that is about to be started is treated differently: only its stale data files are removed, and the instance is left in place to be reused, since recreating one is significantly slower than restarting it. + + +### Clean Instance Root Flow + +LocalDB keeps a directory of its own for every instance at `%LocalAppData%\Microsoft\Microsoft SQL Server Local DB\Instances\InstanceName`, holding the system databases (`master`, `model`, `msdb`, `tempdb`), the error logs, and the extended event files. That location is owned by LocalDB and cannot be moved. + +The flow above only sees instances that still have a data directory. Once that directory is gone, cleared with the temp directory or by the flow above, nothing under the data root points at the instance and it is invisible there. Those orphans, and the directories left behind by instances that were already deleted, are reclaimed by this second sweep. + +LocalDB instances are shared by everything on the machine that uses LocalDB, and nothing in an instance records what created it. So a marker file is written into the directory of each instance this library starts, and the sweep only reclaims instances carrying that marker. + +```mermaid +flowchart TD + start[Start: CleanInstanceRoot] + checkEnabled{Cleanup Enabled?
threshold not zero} + checkRootExists{Instance Root
Exists?} + iterateDirs[Iterate Instance
Directories] + checkDefault{LocalDB Managed?
MSSQLLocalDB, v11.0} + checkRegistered{Backed By A
Registered Instance?} + + checkResidueOwn{Marked, Or
Backlog Pass?} + checkResidueAge{Untouched For
5 Minutes?} + deleteResidue[Delete Directory] + + checkAge{Untouched For
Threshold?
default 30 days} + checkRunning{Running?} + checkDataDir{Has A Data
Directory?} + checkMarked{Marked As Created
By This Library?} + checkBacklog{Backlog Pass?} + checkModel{model.mdf
Shrunk?} + removeInstance[Remove Instance:
Stop, Delete, and Remove
Its LocalDB Directory] + + leave[Leave Alone] + done[Done] + + start --> checkEnabled + checkEnabled -->|No| done + checkEnabled -->|Yes| checkRootExists + checkRootExists -->|No| done + checkRootExists -->|Yes| iterateDirs + iterateDirs --> checkDefault + checkDefault -->|Yes| leave + checkDefault -->|No| checkRegistered + + checkRegistered -->|No: residue| checkResidueOwn + checkResidueOwn -->|No| leave + checkResidueOwn -->|Yes| checkResidueAge + checkResidueAge -->|No| leave + checkResidueAge -->|Yes| deleteResidue + deleteResidue --> done + + checkRegistered -->|Yes| checkAge + checkAge -->|No| leave + checkAge -->|Yes| checkRunning + checkRunning -->|Yes| leave + checkRunning -->|No| checkDataDir + checkDataDir -->|Yes: handled above| leave + checkDataDir -->|No: orphan| checkMarked + checkMarked -->|Yes| removeInstance + checkMarked -->|No| checkBacklog + checkBacklog -->|No| leave + checkBacklog -->|Yes| checkModel + checkModel -->|No| leave + checkModel -->|Yes| removeInstance + + removeInstance --> done + leave --> done +``` + +Key behaviors: + + * **Triggered once** during `DirectoryFinder` static initialization, immediately after the data root cleanup above. + * **Only reclaims what it created**: an instance with no marker is never removed, so an instance belonging to anything else on the machine is left alone. The default instances LocalDB manages itself are always skipped. + * **Marked on start as well as on create**: instances that predate marking are picked up the next time they are used. + * **An instance is only an orphan if it has no data directory**: while one exists, the data root cleanup above governs when it is reclaimed, so the two never contend. + * **Running instances are skipped**: they are likely in use by another process. + * **30-day threshold** for instances, configurable via the `LocalDBInstanceCleanupDays` environment variable or `LocalDbSettings.InstanceCleanupThreshold`. Set it to zero to disable the sweep. + * **Residue uses a 5-minute threshold** instead. A directory with no registered instance behind it has no instance to race, only the window where LocalDB has created the directory but not yet registered the instance. Using the full threshold would strand any residue not old enough when the backlog pass runs, since that pass only happens once. + * **The backlog pass runs once per machine**: instances that predate marking cannot be told apart from instances belonging to anything else, so they get a single best effort pass which takes unmarked residue and unmarked orphans that still carry the shrunk `model.mdf` this library leaves behind. A fresh instance leaves `model.mdf` at 8MB, so an instance created by anything else is never matched. That the pass has run is recorded under `%LocalAppData%\LocalDb`, and only once a sweep actually completes. + * **Never fails a test run**: every directory is handled independently and any failure is logged and skipped. ### Create SqlDatabase Flow diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 2565135a..ceca605d 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;CA1416;CS8632;NU1608;NU1109 - 24.1.5 + 25.0.0 preview 1.0.0 false diff --git a/src/EfClassicLocalDb/EfClassicLocalDb.csproj b/src/EfClassicLocalDb/EfClassicLocalDb.csproj index 46639ff7..9f671cee 100644 --- a/src/EfClassicLocalDb/EfClassicLocalDb.csproj +++ b/src/EfClassicLocalDb/EfClassicLocalDb.csproj @@ -17,7 +17,7 @@ - + diff --git a/src/EfLocalDb/EfLocalDb.csproj b/src/EfLocalDb/EfLocalDb.csproj index c2e0ce74..fabb6320 100644 --- a/src/EfLocalDb/EfLocalDb.csproj +++ b/src/EfLocalDb/EfLocalDb.csproj @@ -20,7 +20,7 @@ - + diff --git a/src/LocalDb.Tests/InstanceRootCleanerTests.cs b/src/LocalDb.Tests/InstanceRootCleanerTests.cs new file mode 100644 index 00000000..b6266a52 --- /dev/null +++ b/src/LocalDb.Tests/InstanceRootCleanerTests.cs @@ -0,0 +1,323 @@ +[TestFixture] +public class InstanceRootCleanerTests +{ + static readonly TimeSpan threshold = TimeSpan.FromHours(6); + + static string MakeStaleDir(string root, string name, bool marked, params string[] files) + { + var dir = Path.Combine(root, name); + Directory.CreateDirectory(dir); + var all = files.ToList(); + if (marked) + { + all.Add(".localdbwrapper"); + } + + var old = DateTime.Now.AddDays(-3); + foreach (var file in all) + { + var path = Path.Combine(dir, file); + File.WriteAllText(path, "x"); + File.SetLastWriteTime(path, old); + } + + Directory.SetCreationTime(dir, old); + return dir; + } + + [Test] + public void RemovesMarkedResidue() + { + using var instanceRoot = new TempDirectory(); + using var dataRoot = new TempDirectory(); + var dir = MakeStaleDir(instanceRoot, "MarkedResidueTest", marked: true, "error.log"); + + DirectoryCleaner.CleanInstanceRoot(instanceRoot, dataRoot, threshold, backlogPass: false); + + False(Directory.Exists(dir)); + } + + [Test] + public void LeavesUnmarkedResidueOutsideBacklogPass() + { + using var instanceRoot = new TempDirectory(); + using var dataRoot = new TempDirectory(); + var dir = MakeStaleDir(instanceRoot, "UnmarkedResidueTest", marked: false, "error.log"); + + DirectoryCleaner.CleanInstanceRoot(instanceRoot, dataRoot, threshold, backlogPass: false); + + True(Directory.Exists(dir)); + } + + [Test] + public void RemovesUnmarkedResidueInBacklogPass() + { + using var instanceRoot = new TempDirectory(); + using var dataRoot = new TempDirectory(); + var dir = MakeStaleDir(instanceRoot, "BacklogResidueTest", marked: false, "error.log"); + + DirectoryCleaner.CleanInstanceRoot(instanceRoot, dataRoot, threshold, backlogPass: true); + + False(Directory.Exists(dir)); + } + + [Test] + public void LeavesJustWrittenResidue() + { + using var instanceRoot = new TempDirectory(); + using var dataRoot = new TempDirectory(); + // guards the window where LocalDB has created the directory but not yet registered + // the instance, so it would look like residue + var dir = Path.Combine(instanceRoot, "RecentResidueTest"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, ".localdbwrapper"), ""); + + DirectoryCleaner.CleanInstanceRoot(instanceRoot, dataRoot, threshold, backlogPass: true); + + True(Directory.Exists(dir)); + } + + [Test] + public void RemovesResidueYoungerThanTheInstanceThreshold() + { + using var instanceRoot = new TempDirectory(); + using var dataRoot = new TempDirectory(); + // an hour old: well inside the instance threshold, but residue has no instance to + // race, so it must not be stranded by the backlog pass only running once + var dir = Path.Combine(instanceRoot, "YoungResidueTest"); + Directory.CreateDirectory(dir); + var file = Path.Combine(dir, "error.log"); + File.WriteAllText(file, "x"); + var hourAgo = DateTime.Now.AddHours(-1); + File.SetLastWriteTime(file, hourAgo); + Directory.SetCreationTime(dir, hourAgo); + + DirectoryCleaner.CleanInstanceRoot(instanceRoot, dataRoot, threshold, backlogPass: true); + + False(Directory.Exists(dir)); + } + + [Test] + public void LeavesDefaultInstanceDirectory() + { + using var instanceRoot = new TempDirectory(); + using var dataRoot = new TempDirectory(); + var dir = MakeStaleDir(instanceRoot, "MSSQLLocalDB", marked: true, "error.log"); + + DirectoryCleaner.CleanInstanceRoot(instanceRoot, dataRoot, threshold, backlogPass: true); + + True(Directory.Exists(dir)); + } + + [Test] + public void LeavesEverythingWhenDisabled() + { + using var instanceRoot = new TempDirectory(); + using var dataRoot = new TempDirectory(); + var dir = MakeStaleDir(instanceRoot, "DisabledCleanupTest", marked: true, "error.log"); + + DirectoryCleaner.CleanInstanceRoot(instanceRoot, dataRoot, TimeSpan.Zero, backlogPass: true); + + True(Directory.Exists(dir)); + } + + // The routing for a real instance is tested per directory rather than through + // CleanInstanceRoot, so the sweep is never run against the real instance root where it + // would process every instance on the machine. A cutoff in the future makes every + // directory count as stale, isolating the branch under test from timing. + static HashSet Registered() => + new(LocalDbApi.GetInstanceNames(), StringComparer.OrdinalIgnoreCase); + + static void Clean(string directory, string dataRoot, bool backlogPass = false) => + DirectoryCleaner.CleanInstanceDirectory( + directory, + dataRoot, + Registered(), + DateTime.Now.AddMinutes(5), + backlogPass); + + [Test] + public async Task MarksInstanceItCreates() + { + var name = "MarkedOnCreateTest"; + LocalDbApi.StopAndDelete(name); + + using var dataRoot = new TempDirectory(); + using var wrapper = new Wrapper(name, Path.Combine(dataRoot, name)); + wrapper.Start(new(2000, 1, 1), TestDbBuilder.CreateTable); + await wrapper.AwaitStart(); + + try + { + True(InstanceMarker.IsMarked(DirectoryFinder.FindInstance(name))); + } + finally + { + DirectoryCleaner.RemoveInstance(name); + } + } + + [Test] + public async Task RemovesMarkedOrphanedInstance() + { + var name = "MarkedOrphanTest"; + LocalDbApi.StopAndDelete(name); + + using var dataRoot = new TempDirectory(); + var directory = Path.Combine(dataRoot, name); + using var wrapper = new Wrapper(name, directory); + wrapper.Start(new(2000, 1, 1), TestDbBuilder.CreateTable); + await wrapper.AwaitStart(); + + var instanceDir = DirectoryFinder.FindInstance(name); + True(LocalDbApi.GetInstance(name).Exists); + + // orphan it: stop and remove the data directory + LocalDbApi.StopInstance(name, ShutdownMode.KillProcess); + Directory.Delete(directory, true); + + // marked, so reclaimed without needing the backlog pass + Clean(instanceDir, dataRoot); + + False(LocalDbApi.GetInstance(name).Exists); + False(Directory.Exists(instanceDir)); + } + + [Test] + public async Task LeavesUnmarkedOrphanedInstanceOutsideBacklogPass() + { + var name = "UnmarkedOrphanTest"; + LocalDbApi.StopAndDelete(name); + + using var dataRoot = new TempDirectory(); + var directory = Path.Combine(dataRoot, name); + using var wrapper = new Wrapper(name, directory); + wrapper.Start(new(2000, 1, 1), TestDbBuilder.CreateTable); + await wrapper.AwaitStart(); + + var instanceDir = DirectoryFinder.FindInstance(name); + LocalDbApi.StopInstance(name, ShutdownMode.KillProcess); + Directory.Delete(directory, true); + + // an instance that predates marking + File.Delete(Path.Combine(instanceDir, ".localdbwrapper")); + + try + { + Clean(instanceDir, dataRoot); + + True(LocalDbApi.GetInstance(name).Exists); + } + finally + { + DirectoryCleaner.RemoveInstance(name); + } + } + + [Test] + public async Task RemovesUnmarkedOrphanWithShrunkModelInBacklogPass() + { + var name = "BacklogOrphanTest"; + LocalDbApi.StopAndDelete(name); + + using var dataRoot = new TempDirectory(); + var directory = Path.Combine(dataRoot, name); + using var wrapper = new Wrapper(name, directory); + wrapper.Start(new(2000, 1, 1), TestDbBuilder.CreateTable); + await wrapper.AwaitStart(); + + var instanceDir = DirectoryFinder.FindInstance(name); + LocalDbApi.StopInstance(name, ShutdownMode.KillProcess); + Directory.Delete(directory, true); + File.Delete(Path.Combine(instanceDir, ".localdbwrapper")); + + // this library shrinks model when it creates an instance, so the fingerprint holds + True(InstanceMarker.HasShrunkModel(instanceDir)); + + Clean(instanceDir, dataRoot, backlogPass: true); + + False(LocalDbApi.GetInstance(name).Exists); + False(Directory.Exists(instanceDir)); + } + + [Test] + public void LeavesUnmarkedOrphanWithoutShrunkModelInBacklogPass() + { + var name = "ForeignOrphanTest"; + LocalDbApi.StopAndDelete(name); + + // created through the API alone rather than through a Wrapper, so model is never + // shrunk: this is what an instance belonging to anything else looks like + LocalDbApi.CreateInstance(name); + LocalDbApi.StartInstance(name); + LocalDbApi.StopInstance(name, ShutdownMode.KillProcess); + + var instanceDir = DirectoryFinder.FindInstance(name); + File.Delete(Path.Combine(instanceDir, ".localdbwrapper")); + False(InstanceMarker.HasShrunkModel(instanceDir)); + + using var dataRoot = new TempDirectory(); + try + { + // orphaned, stale, and the backlog pass is running, but it is not ours + Clean(instanceDir, dataRoot, backlogPass: true); + + True(LocalDbApi.GetInstance(name).Exists); + True(Directory.Exists(instanceDir)); + } + finally + { + DirectoryCleaner.RemoveInstance(name); + } + } + + [Test] + public async Task LeavesRunningInstance() + { + var name = "RunningInstanceRootTest"; + LocalDbApi.StopAndDelete(name); + + using var dataRoot = new TempDirectory(); + var directory = Path.Combine(dataRoot, name); + using var wrapper = new Wrapper(name, directory); + wrapper.Start(new(2000, 1, 1), TestDbBuilder.CreateTable); + await wrapper.AwaitStart(); + + // no data directory and treated as stale, but running: must be left alone + Directory.Delete(directory, true); + + try + { + Clean(DirectoryFinder.FindInstance(name), dataRoot, backlogPass: true); + + True(LocalDbApi.GetInstance(name).Exists); + } + finally + { + DirectoryCleaner.RemoveInstance(name); + } + } + + [Test] + public void LeavesTrackedInstanceWithDataDirectory() + { + var name = "TrackedInstanceRootTest"; + LocalDbApi.StopAndDelete(name); + LocalDbApi.CreateInstance(name); + + using var dataRoot = new TempDirectory(); + // an existing data directory means CleanRoot governs it, so the sweep leaves it + Directory.CreateDirectory(Path.Combine(dataRoot, name)); + + try + { + Clean(DirectoryFinder.FindInstance(name), dataRoot, backlogPass: true); + + True(LocalDbApi.GetInstance(name).Exists); + } + finally + { + DirectoryCleaner.RemoveInstance(name); + } + } +} diff --git a/src/LocalDb.Tests/LocalDbCleanupTests.cs b/src/LocalDb.Tests/LocalDbCleanupTests.cs deleted file mode 100644 index 08d7d2e7..00000000 --- a/src/LocalDb.Tests/LocalDbCleanupTests.cs +++ /dev/null @@ -1,88 +0,0 @@ -[TestFixture] -public class LocalDbCleanupTests -{ - static void CreateStoppedInstance(string name) - { - LocalDbApi.StopAndDelete(name); - DirectoryFinder.Delete(name); - - LocalDbApi.CreateInstance(name); - // starting materializes the directory LocalDB keeps for the instance - LocalDbApi.StartInstance(name); - LocalDbApi.StopInstance(name, ShutdownMode.KillProcess); - } - - [Test] - public void FindsInstanceWithNoDataDirectory() - { - var name = "OrphanCleanupTest"; - CreateStoppedInstance(name); - - try - { - True(LocalDbCleanup.FindOrphanInstances().Contains(name)); - } - finally - { - DirectoryCleaner.RemoveInstance(name); - } - } - - [Test] - public void SkipsInstanceWithDataDirectory() - { - var name = "OrphanCleanupWithDirectoryTest"; - CreateStoppedInstance(name); - Directory.CreateDirectory(DirectoryFinder.Find(name)); - - try - { - False(LocalDbCleanup.FindOrphanInstances().Contains(name)); - } - finally - { - DirectoryFinder.Delete(name); - DirectoryCleaner.RemoveInstance(name); - } - } - - [Test] - public void SkipsDefaultInstances() => - False(LocalDbCleanup.FindOrphanInstances().Contains("MSSQLLocalDB")); - - [Test] - public void DeletesInstanceAndDirectory() - { - var name = "OrphanCleanupDeleteTest"; - CreateStoppedInstance(name); - - var instanceDirectory = DirectoryFinder.FindInstance(name); - True(Directory.Exists(instanceDirectory)); - - // the filter keeps the sweep away from instances this test does not own - var deleted = LocalDbCleanup.DeleteOrphanInstances(_ => _ == name); - - True(deleted.Contains(name)); - False(LocalDbApi.GetInstance(name).Exists); - False(Directory.Exists(instanceDirectory)); - } - - [Test] - public void FilterKeepsUnmatchedInstances() - { - var name = "OrphanCleanupFilterTest"; - CreateStoppedInstance(name); - - try - { - var deleted = LocalDbCleanup.DeleteOrphanInstances(_ => false); - - False(deleted.Any()); - True(LocalDbApi.GetInstance(name).Exists); - } - finally - { - DirectoryCleaner.RemoveInstance(name); - } - } -} diff --git a/src/LocalDb.Tests/LocalDbSettingsTests.cs b/src/LocalDb.Tests/LocalDbSettingsTests.cs index eb938da2..ae0171e8 100644 --- a/src/LocalDb.Tests/LocalDbSettingsTests.cs +++ b/src/LocalDb.Tests/LocalDbSettingsTests.cs @@ -82,4 +82,22 @@ public void DBAutoOffline_CanBeSetProgrammatically() LocalDbSettings.DBAutoOffline = original; } } + + // the default cannot be asserted here: the module initializer sets this to zero so the + // suite does not sweep the real instance root + [Test] + public void InstanceCleanupThreshold_CanBeSetProgrammatically() + { + var original = LocalDbSettings.InstanceCleanupThreshold; + try + { + LocalDbSettings.InstanceCleanupThreshold = TimeSpan.FromDays(7); + That(LocalDbSettings.InstanceCleanupThreshold, Is.EqualTo(TimeSpan.FromDays(7))); + } + finally + { + LocalDbSettings.InstanceCleanupThreshold = original; + } + } + } diff --git a/src/LocalDb.Tests/ModuleInitializer.cs b/src/LocalDb.Tests/ModuleInitializer.cs index 3d5c6288..0a6ee6f7 100644 --- a/src/LocalDb.Tests/ModuleInitializer.cs +++ b/src/LocalDb.Tests/ModuleInitializer.cs @@ -5,6 +5,13 @@ public static void Initialize() { VerifierSettings.InitializePlugins(); LocalDbLogging.EnableVerbose(); + // do not let the test suite sweep the real instance root as a side effect: it would + // delete instances belonging to other projects on the machine. The sweep is tested + // directly instead. Must be set before DirectoryFinder initializes. + // Set as an environment variable as well as a setting, since the multi process tests + // spawn a helper exe that runs none of this and would otherwise sweep with the defaults + Environment.SetEnvironmentVariable("LocalDBInstanceCleanupDays", "0"); + LocalDbSettings.InstanceCleanupThreshold = TimeSpan.Zero; LocalDbSettings.ConnectionBuilder(_ => _.ConnectTimeout = 300); VerifierSettings.ScrubLinesContaining("filename = '"); VerifierSettings.IgnoreMember(_ => _.OwnerSID); diff --git a/src/LocalDb/DirectoryCleaner.cs b/src/LocalDb/DirectoryCleaner.cs index 2582f6a6..304289d5 100644 --- a/src/LocalDb/DirectoryCleaner.cs +++ b/src/LocalDb/DirectoryCleaner.cs @@ -78,6 +78,163 @@ static void Clean(string directory, bool deleteInstance) } } + /// + /// Reclaims instances and residue from the directory LocalDB keeps for each instance. + /// + /// Covers what cannot see: instances whose data directory is gone, so + /// nothing under the data root points at them, and directories left behind by instances that + /// were already deleted, since LocalDB does not remove the logs and event files on delete. + /// + /// + /// Only instances marked as created by this library are reclaimed, so an instance belonging to + /// anything else on the machine is never removed. additionally + /// reclaims unmarked directories that predate marking, and is run once per machine. + /// + /// + /// Only instances untouched for are processed, both to keep the + /// startup scan cheap and to avoid racing an instance that is being created or is in use. + /// Residue has no instance to race, so it uses a short threshold of its own. + /// + /// + /// + /// Whether the sweep ran to completion. False when it was disabled or could not start, so a + /// caller recording that the backlog pass has happened does not do so for a pass that never ran. + /// + public static bool CleanInstanceRoot(string instanceRoot, string dataRoot, TimeSpan threshold, bool backlogPass) + { + if (threshold <= TimeSpan.Zero || + !Directory.Exists(instanceRoot)) + { + return false; + } + + var cutoff = DateTime.Now - threshold; + + HashSet registered; + try + { + registered = new(LocalDbApi.GetInstanceNames(), StringComparer.OrdinalIgnoreCase); + } + catch (Exception exception) + { + LocalDbLogging.LogIfVerbose($"Failed to enumerate instances, skipping instance root cleanup. {exception.Message}"); + return false; + } + + foreach (var directory in Directory.EnumerateDirectories(instanceRoot)) + { + try + { + CleanInstanceDirectory(directory, dataRoot, registered, cutoff, backlogPass); + } + catch (Exception exception) + { + // cleanup must never break the test run that triggered it + LocalDbLogging.LogIfVerbose($"Failed to clean instance directory: {directory}. {exception.Message}"); + } + } + + return true; + } + + /// + /// Instances that LocalDB creates and manages itself, and that are never owned by this library. + /// + static bool IsDefaultInstance(string name) => + name == "MSSQLLocalDB" || + // automatic instances, eg "v11.0" + name.Length > 1 && name[0] == 'v' && char.IsDigit(name[1]); + + /// + /// Residue is only guarded against the window where LocalDB has created the directory for an + /// instance but not yet registered it, so it uses a short threshold of its own. Applying the + /// full threshold would strand any residue not yet old enough when the backlog pass runs, + /// since that pass only happens once. + /// + static readonly TimeSpan residueThreshold = TimeSpan.FromMinutes(5); + + internal static void CleanInstanceDirectory( + string directory, + string dataRoot, + HashSet registered, + DateTime cutoff, + bool backlogPass) + { + var name = Path.GetFileName(directory); + + // instances LocalDB creates and manages, never owned by this library + if (IsDefaultInstance(name)) + { + return; + } + + var newestWrite = NewestWrite(directory); + var marked = InstanceMarker.IsMarked(directory); + + if (!registered.Contains(name)) + { + // no instance backs this directory: the instance was already deleted and only the + // logs and event files remain. Nothing can be harmed by removing them, but an + // unmarked one is only taken in the backlog pass since it was not necessarily ours + if ((marked || backlogPass) && + newestWrite < DateTime.Now - residueThreshold) + { + LocalDbLogging.LogIfVerbose($"Removing residue directory: {name}"); + Directory.Delete(directory, true); + } + + return; + } + + // recently touched, so either in use or too new to be sure it is abandoned + if (newestWrite >= cutoff) + { + return; + } + + // likely in use by another process + if (LocalDbApi.GetInstance(name).IsRunning) + { + return; + } + + // still tracked by a data directory, so CleanRoot governs when it is reclaimed + if (Directory.Exists(Path.Combine(dataRoot, name))) + { + return; + } + + if (!marked) + { + // predates marking. Only reclaimed in the backlog pass, and only when it still + // carries the shrunk model this library leaves behind, so an instance created by + // anything else is never removed + if (!backlogPass || + !InstanceMarker.HasShrunkModel(directory)) + { + return; + } + } + + LocalDbLogging.LogIfVerbose($"Removing orphaned instance: {name}"); + RemoveInstance(name); + } + + static DateTime NewestWrite(string directory) + { + var newest = Directory.GetCreationTime(directory); + foreach (var file in Directory.EnumerateFiles(directory)) + { + var write = File.GetLastWriteTime(file); + if (write > newest) + { + newest = write; + } + } + + return newest; + } + /// /// Stops and deletes an instance, then removes the directory LocalDB keeps for it. Deleting /// the instance only reclaims the system databases: the logs and traces are left behind. diff --git a/src/LocalDb/DirectoryFinder.cs b/src/LocalDb/DirectoryFinder.cs index 20aab3c5..bb9af310 100644 --- a/src/LocalDb/DirectoryFinder.cs +++ b/src/LocalDb/DirectoryFinder.cs @@ -7,6 +7,44 @@ static DirectoryFinder() dataRoot = FindDataRoot(); var root = dataRoot; DirectoryCleaner.CleanRoot(root); + + var threshold = LocalDbSettings.InstanceCleanupThreshold; + if (threshold > TimeSpan.Zero) + { + // instances that predate marking cannot be told apart from instances belonging to + // anything else, so they get one best effort pass and are then left alone + var backlogPass = !File.Exists(backlogFlag); + var swept = DirectoryCleaner.CleanInstanceRoot(instanceRoot, root, threshold, backlogPass); + // only recorded when the pass actually ran, so a sweep that could not start does not + // consume the one chance the unattributable backlog gets + if (backlogPass && swept) + { + WriteBacklogFlag(); + } + } + } + + /// + /// Records that the one time pass over instances predating marking has been run. Persistent, + /// unlike the data directory, so the pass happens once rather than after every temp clear. + /// + static string backlogFlag = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LocalDb", + "backlog-cleaned"); + + static void WriteBacklogFlag() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(backlogFlag)!); + File.WriteAllText(backlogFlag, string.Empty); + } + catch (Exception exception) + { + // only costs a repeat of the pass on the next run + LocalDbLogging.LogIfVerbose($"Failed to write backlog flag. {exception.Message}"); + } } public static string Find(string instanceName) => Path.Combine(dataRoot, instanceName); @@ -25,7 +63,7 @@ public static void Delete(string instanceName) /// logs and traces of each instance. The location is owned by LocalDB and cannot be /// configured: it is always derived from the local application data folder. /// - static string instanceRoot = Path.Combine( + public static string instanceRoot = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Microsoft", "Microsoft SQL Server Local DB", diff --git a/src/LocalDb/InstanceMarker.cs b/src/LocalDb/InstanceMarker.cs new file mode 100644 index 00000000..80f612a1 --- /dev/null +++ b/src/LocalDb/InstanceMarker.cs @@ -0,0 +1,71 @@ +/// +/// Records which LocalDB instances were created by this library. +/// +/// LocalDB instances are shared across everything on the machine that uses LocalDB, and nothing +/// in an instance says what created it. Without a record, reclaiming an instance means guessing, +/// so a marker is written into the directory LocalDB keeps for each instance this library starts. +/// The ongoing cleanup only reclaims marked instances, and so can never remove one belonging to +/// anything else. +/// +/// +/// The marker lives in the instance directory rather than a registry of its own, so it is removed +/// with the instance and cannot drift out of sync. It survives the temp directory being cleared, +/// which is what orphans an instance in the first place. +/// +/// +static class InstanceMarker +{ + const string fileName = ".localdbwrapper"; + + /// + /// Records an instance as created by this library. Best effort: a machine where the marker + /// cannot be written still works, those instances are just never reclaimed automatically. + /// + public static void Mark(string instanceName) + { + try + { + var directory = DirectoryFinder.FindInstance(instanceName); + // LocalDB owns this directory and creates it with the instance + if (!Directory.Exists(directory)) + { + return; + } + + var marker = Path.Combine(directory, fileName); + if (!File.Exists(marker)) + { + File.WriteAllText(marker, string.Empty); + } + } + catch (Exception exception) + { + LocalDbLogging.LogIfVerbose($"Failed to mark instance: {instanceName}. {exception.Message}"); + } + } + + public static bool IsMarked(string instanceDirectory) => + File.Exists(Path.Combine(instanceDirectory, fileName)); + + /// + /// Whether model.mdf has been shrunk below the size a fresh instance has. + /// + /// Only used for the one time pass over instances that predate marking. This library shrinks + /// model when it creates an instance, and nothing else does, so a smaller model.mdf means the + /// instance was created by it. Instances left at the default size are not matched, so the + /// pass can never remove an instance belonging to anything else. + /// + /// + public static bool HasShrunkModel(string instanceDirectory) + { + var model = Path.Combine(instanceDirectory, "model.mdf"); + if (!File.Exists(model)) + { + return false; + } + + var length = new FileInfo(model).Length; + return length > 0 && + length < 8 * 1024 * 1024; + } +} diff --git a/src/LocalDb/LocalDbApi.cs b/src/LocalDb/LocalDbApi.cs index 3edce438..297377d5 100644 --- a/src/LocalDb/LocalDbApi.cs +++ b/src/LocalDb/LocalDbApi.cs @@ -164,8 +164,11 @@ public static void StopAndDelete(string instanceName, ShutdownMode mode, TimeSpa DeleteInstance(instanceName); } - public static void CreateInstance(string instanceName) => + public static void CreateInstance(string instanceName) + { createInstance(ApiVersion, instanceName, 0); + InstanceMarker.Mark(instanceName); + } public static State CreateAndStart(string instance) { @@ -192,6 +195,9 @@ public static void StartInstance(string instanceName) var size = connection.Capacity; startInstance(instanceName, 0, connection, ref size); + // also marked here, not just on create, so instances created before marking existed + // are picked up the next time they are used + InstanceMarker.Mark(instanceName); } public static void StopInstance(string instanceName, ShutdownMode mode = ShutdownMode.UseSqlShutdown) => diff --git a/src/LocalDb/LocalDbCleanup.cs b/src/LocalDb/LocalDbCleanup.cs deleted file mode 100644 index 20f56b0c..00000000 --- a/src/LocalDb/LocalDbCleanup.cs +++ /dev/null @@ -1,84 +0,0 @@ -#if EF -namespace EfLocalDb; -#else -namespace LocalDb; -#endif - -/// -/// Removes LocalDB instances left behind by previous runs. -/// -/// The automatic cleanup only knows about instances that still have a data directory. Once that -/// directory has been removed, for example by the temp directory being cleared, the instance is -/// orphaned and the system databases, logs and traces LocalDB keeps for it are never reclaimed. -/// -/// -public static class LocalDbCleanup -{ - /// - /// Instances that LocalDB creates and manages itself, and that are never owned by this library. - /// - static bool IsDefaultInstance(string name) => - name == "MSSQLLocalDB" || - // automatic instances, eg "v11.0" - name.Length > 1 && name[0] == 'v' && char.IsDigit(name[1]); - - /// - /// Finds instances that have no corresponding data directory. Running instances and the - /// LocalDB managed default instances are never treated as orphans. - /// - /// - /// Applied to each candidate name. Return false to keep an instance. Use this when the machine - /// has LocalDB instances that were not created by this library. - /// - public static IReadOnlyList FindOrphanInstances(Func? filter = null) - { - var orphans = new List(); - foreach (var name in LocalDbApi.GetInstanceNames()) - { - if (IsDefaultInstance(name)) - { - continue; - } - - if (Directory.Exists(DirectoryFinder.Find(name))) - { - continue; - } - - // an instance that is currently running is likely in use by another process - if (LocalDbApi.GetInstance(name).IsRunning) - { - continue; - } - - if (filter != null && !filter(name)) - { - continue; - } - - orphans.Add(name); - } - - return orphans; - } - - /// - /// Deletes the instances returned by and the directories - /// LocalDB keeps for them. Returns the names of the deleted instances. - /// - /// - /// Applied to each candidate name. Return false to keep an instance. Use this when the machine - /// has LocalDB instances that were not created by this library. - /// - public static IReadOnlyList DeleteOrphanInstances(Func? filter = null) - { - var orphans = FindOrphanInstances(filter); - foreach (var orphan in orphans) - { - LocalDbLogging.LogIfVerbose($"Deleting orphan instance: {orphan}"); - DirectoryCleaner.RemoveInstance(orphan); - } - - return orphans; - } -} diff --git a/src/LocalDb/Settings.cs b/src/LocalDb/Settings.cs index d1e18da5..26375b3c 100644 --- a/src/LocalDb/Settings.cs +++ b/src/LocalDb/Settings.cs @@ -37,6 +37,15 @@ internal static string BuildConnectionString(string instance, string database, b /// public static bool? DBAutoOffline { get; set; } = ResolveDBAutoOffline(); + /// + /// How long an instance directory must be untouched before automatic cleanup removes the + /// instance and the directory LocalDB keeps for it. This reclaims instances whose data + /// directory is gone, which the per run cleanup can no longer see. + /// Can be configured via the LocalDBInstanceCleanupDays environment variable. + /// Defaults to 30 days. Set to to disable. + /// + public static TimeSpan InstanceCleanupThreshold { get; set; } = ResolveInstanceCleanupThreshold(); + static ushort ResolveShutdownTimeout() { var envValue = Environment.GetEnvironmentVariable("LocalDBShutdownTimeout"); @@ -63,4 +72,20 @@ static ushort ResolveShutdownTimeout() _ => null }; } + + static TimeSpan ResolveInstanceCleanupThreshold() + { + var envValue = Environment.GetEnvironmentVariable("LocalDBInstanceCleanupDays"); + if (envValue is null) + { + return TimeSpan.FromDays(30); + } + + if (ushort.TryParse(envValue, out var days)) + { + return TimeSpan.FromDays(days); + } + + throw new ArgumentException($"Failed to parse LocalDBInstanceCleanupDays environment variable: {envValue}"); + } }