Database context - Add Restore-DatabaseContext and fix eighteen commands - #10603
Database context - Add Restore-DatabaseContext and fix eighteen commands#10603andreasjordan wants to merge 9 commits into
Conversation
Database scoped SMO work moves the current database of the connection and never moves it back: the execution manager of a Database object is the connection context of the parent server, which belongs to the caller. That happens for ExecuteNonQuery and ExecuteWithResults on a Database object, for Create and Drop of most server level objects, and for enumerating any database level collection such as Views, Tables or Users - the last of which no script method can cover, because it is a property getter and not a call we make. The commands that cannot avoid moving the context therefore have to put it back, and the block that does it is now written out in three places by hand. This is that block as one private function: the case sensitive comparison, the escaping of a closing bracket in the name, and the rule that a failing restore warns rather than becoming the outcome of the command. (do RestoreDatabaseContext) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine commands that only read handed the connection back pointing at the last database they looked at. Measured on SQL Server 2019 with a non-pooled connection, every one of them leaked, and the row counts show they were doing real work while they did it. Two causes per command, and neither one alone is enough: - The catalog query ran through SMO's own Database.ExecuteWithResults, which issues a USE on the connection context of the parent server and never switches back. It goes through our Query script method now, which restores the previous database in a finally. - Enumerating a database level collection - Views, Triggers, Tables, Users - does the same, and no script method can cover it, because it is a property getter and not a call we make. The database of the caller is read before the work starts and put back with Restore-DatabaseContext when it is done. Where the wrapper replaced ExecuteWithResults, the .Tables.Rows and .Tables[0] readers went with it: PowerShell unrolls a DataTable on output, so the wrapper already returns the rows. Each command got the same regression test, and every one of them fails without the fix. Depends on the branch that adds Restore-DatabaseContext. (do Find-DbaTrigger, Find-DbaView, Find-DbaStoredProcedure, Find-DbaObject, Find-DbaDbDisabledIndex, Get-DbaQueryExecutionTime, Get-DbaPermission, Get-DbaLastGoodCheckDb, Get-DbaDbSpace) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The four maintenance statements ran through SMO's own Database.ExecuteNonQuery, which issues a USE on the execution manager of the database - the connection context of the parent server, which belongs to the caller - and never switches back. They go through our Invoke script method now, which puts the previous database back in a finally. That alone is not enough: enumerating $db.Views for the view refresh moves the connection just as well, and no script method can cover an SMO collection. So the database of the caller is read before any work starts and handed to Restore-DatabaseContext when the view refresh is done. Both halves are covered by integration tests that fail without them. (do Invoke-DbaDbUpgrade) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review verdictRequest changes. Do not merge as-is. I found one correctness issue in the new helper. After that is fixed, I would approve the PR. Blocking: restore failure can still terminate the original commandFile: The helper explicitly promises that restoring the database is housekeeping and must never become the outcome of the command. However, its catch block currently does this: Write-Message -Level Warning -Message "The database context could not be restored to [$Database]: $_"
That means the actual command can succeed, restoration can fail because the original database is no longer accessible, and the caller can receive a terminating failure caused only by cleanup. That contradicts the helper's stated contract. The existing The current helper test only exercises Suggested implementationUsing a splat: catch {
if ($WarningPreference -notin "SilentlyContinue", "Continue") {
$WarningPreference = "Continue"
}
$splatMessage = @{
Level = "Warning"
Message = "The database context could not be restored to [$Database]: $_"
}
Write-Message @splatMessage
}This mirrors the behavior already established by the Regression testI would add a test specifically for It "does not throw when warning action is Stop" {
& $moveTheConnection
$missingDatabase = "dbatoolsci_does_not_exist_$(Get-Random)"
$splatRestore = @{
Server = $callerServer
Database = $missingDatabase
WarningAction = "Stop"
}
{
Restore-DatabaseContext @splatRestore
} | Should -Not -Throw
$callerServer.ConnectionContext.ExecuteScalar("SELECT DB_NAME()") |
Should -Be $contextDbName
}Copyable GitHub review comment
Everything elseThe overall architecture looks correct. The original database is captured before database-scoped work begins, and restoration is correctly placed in The command-level regression tests are also well constructed. They use non-pooled connections so reconnect behavior cannot hide the leak, they ensure the commands actually perform work, and then verify the caller's I also checked the
I found no other merge-blocking issue. Fix the warning-preference case and add the regression test, and I would approve #10603. |
|
im going to fix this then merge on pass 👍🏼 |
|
Thank you for the fix - measured here before it landed, and it reproduces exactly as you described: One last commit for today follows: six more commands of bucket B, all green in the lab against
If you would rather merge what is here now, say so and the commit goes into a follow-up pull request instead - it is one commit and easy to move. Three things in it are worth a second look, because measurement contradicted the obvious change:
This text was created by Claude and reviewed by Andreas Jordan. |
Bucket B of #10555 continued. Measured on SQL Server 2019 with a non-pooled connection: every one of these handed the connection back pointing at the last database it touched. - Remove-DbaDbOrphanUser and Repair-DbaDbOrphanUser drop and map users through Database.ExecuteNonQuery and read $db.Schemas and $db.EnumObjects, so they go through the Invoke script method and restore the database of the caller after every database. - ConvertTo-DbaXESession runs the conversion in tempdb. It needs Query with the second argument: the procedure returns the script over several result sets, and without it only the first one comes back and the script does not parse. - Find-DbaOrphanedFile reads the directory tree through master, so a caller outside master got master back. It also carried "$fttable = $null = ...", which assigns $null and threw every full text catalog away, and a version check that read the $server of the caller instead of the server it was given. - Invoke-DbaDbDecryptObject moves the connection by enumerating StoredProcedures, UserDefinedFunctions and Views, so it restores per database. - Copy-DbaDatabase reads sys.master_files, which never needed a database context at all, so that runs on the server connection now. It carries the same $fttable double assignment as Find-DbaOrphanedFile. Both $fttable fixes sit on the SQL Server 2005 path, which neither the lab nor CI can reach, so they have no test. (do Remove-DbaDbOrphanUser, Repair-DbaDbOrphanUser, ConvertTo-DbaXESession, Find-DbaOrphanedFile, Invoke-DbaDbDecryptObject, Copy-DbaDatabase) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
I have to stop here for today. |
|
sounds good, we ready to publish after this merge? |
|
No, this is only (part of?) bucket B - and there is Bucket C and maybe more. I would vote for fixing avery part of #10555 and be really sure this works. |
…t of the caller alone The last of bucket B of #10555, together with the private Update-SqlPermission that does the permission work for both. These three are one unit on purpose: measured on the issue, routing the two ExecuteNonQuery calls of Update-SqlPermission through the fixed wrapper alone does not stop the callers from leaking, because the leak is also the enumeration of database level collections ($destDb.Users, $destDb.Roles, $sourceDb.Roles) and, in Copy-DbaLogin, SMO's own Login.Create() and Login.Drop(), which move the connection to master. Both commands work on two connections, the source and every destination, so both are captured before any work and both are put back in a finally - each destination after its iteration, the source after all destinations. The regression tests probe both connections, each on its own non-pooled connection passed in as -Source and -Destination, with a database user mapping so the permission sync really walks the database level collections. Verified to have teeth: with the fix stashed, exactly the four context assertions fail, each reporting the leaked database, and nothing else. With this, every direct SMO ExecuteNonQuery/ExecuteWithResults on a Database object in the module is either fixed or deliberately left alone (Get-EncryptedObjectImageValue, whose only caller restores the context itself). (do Copy-DbaLogin, Sync-DbaLoginPermission) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The last of bucket B is in: Copy-DbaLogin and Sync-DbaLoginPermissionOne more commit, and bucket B of #10555 is closed. These two commands and the private Two things are new compared to the commands already in here:
All green in the lab against
Verified to have teeth by stashing the fix and re-running: exactly the four context assertions fail, each reporting the leaked database ( The title and body of this pull request now describe all eighteen commands, so the squash commit tells the whole story. @potatoqualitee this is ready for review from my side - buckets C and D of #10555 stay open, and I would discuss how far we take those before the next batch starts. This text was created by Claude and reviewed by Andreas Jordan. |
…test off the console (do Restore-DatabaseContext) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…update is not skipped (do Copy-DbaDatabase) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@potatoqualitee I think this can be merged now. All open topics are moved to #10604. |
Type of Change
This completes bucket B of #10555 and closes that issue: with it, everything the issue documents as fixable by the wrapper mechanism is merged or in here - bucket A in #10579, the server level statements in #10580, the consumer side check in #10564. The two remaining surfaces, SMO
Create()/Drop()(bucket C) and collection enumeration (bucket D), continue in #10604, which starts with the scope decision to make before any batch is written.It replaces #10602, which fixed
Invoke-DbaDbUpgradeon its own. That fix is in here, using the helper below instead of its own copy of the same block, so the two cannot be merged separately in the wrong order.Purpose
Eighteen commands handed the connection back pointing at the last database they touched.
Invoke-DbaDbUpgradeis the command from the provisioning script that started this whole workstream (#10556); most of the others only read, which makes the leak worse rather than better - reading is supposed to be harmless.Measured on SQL Server 2019 with
-NonPooledConnection, before the fix. The row counts are there to show the commands were doing real work while they leaked:The second batch:
Remove-DbaDbOrphanUser,Repair-DbaDbOrphanUser,ConvertTo-DbaXESession,Find-DbaOrphanedFile,Invoke-DbaDbDecryptObjectandCopy-DbaDatabase.The third batch closes bucket B:
Copy-DbaLoginandSync-DbaLoginPermission, together with the privateUpdate-SqlPermissionthat does the permission work for both. These three are one unit on purpose - measured earlier on this issue, routing the twoExecuteNonQuerycalls ofUpdate-SqlPermissionthrough the fixed wrapper alone does not stop the callers from leaking, because the leak is also the enumeration of database level collections ($destDb.Users[...],$destDb.Roles,$sourceDb.Roles) and, inCopy-DbaLogin, SMO's ownLogin.Create()andLogin.Drop(), which move the connection tomaster. Both commands leak on two connections, the source and every destination, and both are put back.With this, every direct SMO
ExecuteNonQuery/ExecuteWithResultson aDatabaseobject in the module is either fixed or deliberately left alone:Get-EncryptedObjectImageValuekeeps its call because rerouting it broke the off-row decryption path, and its only callerInvoke-DbaDbDecryptObjectrestores the context instead.Approach
Every one of these commands has two causes, and fixing either alone leaves the leak in place.
1. The call.
Database.ExecuteWithResultsandDatabase.ExecuteNonQueryare SMO's own methods and issue aUSEon the execution manager of the database - which is the connection context of the parent server and belongs to the caller. They go through ourQueryandInvokescript methods now, which restore the previous database in afinallysince #10579. Statements that never needed a database context run on the server connection instead (thesys.master_filesreads inCopy-DbaDatabase).2. Enumerating a database level collection does exactly the same, and no script method can cover it, because it is a property getter and not a call we make:
So each command reads
ConnectionContext.CurrentDatabasebefore it starts any work - reading it later records a database an earlier statement already leaked into, and the restore then does nothing - and puts it back in afinally.The new private function
That block existed by hand in
Set-DbaTempDbConfig(#10580) and in the four script methods (#10579), and this change would have added many more copies.private/functions/Restore-DatabaseContext.ps1is that block once: the case sensitive comparison, the escaping of a closing bracket in the name, and the rule that a failing restore warns rather than throwing - the database of the caller can be dropped, renamed, taken offline or made unreachable by the very statement that succeeded, and housekeeping must never replace the outcome of the command. Interrupting warning preferences (Stop,Inquire) are neutralised for that one warning, so cleanup can never terminate a command that succeeded - found in review, with its own regression test.The script methods in
xml/dbatools.Types.ps1xmlkeep their own copy on purpose; they are not commands and do not resolve module private functions.Set-DbaTempDbConfigwas left alone, so this pull request stays about the commands it fixes.One behaviour trap worth flagging for review
Where
ExecuteWithResultswas replaced, the.Tables.Rowsand.Tables[0]readers had to go with it.Database.Queryends in.Tables[0], and PowerShell unrolls aDataTableon output, so the wrapper already hands back the rows:Keeping the
.Rowscompiles, runs, returns the right number of objects and empties every column. The existing tests ofGet-DbaDbSpaceandGet-DbaPermissioncaught it. The reverse trap exists too:ConvertTo-DbaXESessionneedsQuery($sql, $true), because the conversion procedure spreads its script over several result sets and the one-argument form returns only the first.Copy-DbaDatabaseandFind-DbaOrphanedFilealso carried$fttable = $null = ..., a double assignment that always left$fttableempty, so no full text catalog was ever copied or reported. Both sit on the SQL Server 2005 path, which no instance in the lab or in CI can reach, so they get the fix and this description but no test.Tests
Every command got the same regression
Context: its own database and objects, a non-pooled caller connection (a pooled one reconnects at its default database and hides the leak), the command run against it, thenDB_NAME()compared with what it was before. Each also asserts the command still returned what it was asked for, because a command that does nothing cannot leak and would pass for the wrong reason.Get-DbaQueryExecutionTimehad no integration tests at all and has some now.Invoke-DbaDbUpgradeadditionally has a-NoRefreshView -Forcecase, which is the one the restore around the view enumeration cannot cover, so the statements have to put the database back themselves; and a case where the caller sits intempdb, which fails for any fix that restores "to master" instead of restoring what the caller had.Copy-DbaLoginandSync-DbaLoginPermissionassert both connections, source and destination, each probed on its own non-pooled connection passed in as-Sourceand-Destination.Restore-DatabaseContexthas its own test file: the collection leak, the bracket escaping against a database really nameddbatoolsci_br]acket_*, doing nothing when nothing was remembered, warning instead of throwing when the database cannot be reached - including with-WarningAction Stop- and running on the session of the caller rather than a copy.All green in the lab, and each verified to have teeth by reverting the fix:
Restore-DatabaseContextInvoke-DbaDbUpgradeFind-DbaTriggerFind-DbaViewFind-DbaStoredProcedureFind-DbaObjectFind-DbaDbDisabledIndexGet-DbaQueryExecutionTimeGet-DbaPermissionGet-DbaLastGoodCheckDbGet-DbaDbSpaceRemove-DbaDbOrphanUserRepair-DbaDbOrphanUserConvertTo-DbaXESessionFind-DbaOrphanedFileInvoke-DbaDbDecryptObjectCopy-DbaDatabaseCopy-DbaLoginSync-DbaLoginPermissionRemoving the single
Restore-DatabaseContextcall fromFind-DbaViewfails its context test and nothing else. RevertingInvoketoExecuteNonQueryinInvoke-DbaDbUpgradefails only its-NoRefreshViewcase. Dropping the bracket escaping from the helper fails only the bracket test. Removing the restores fromCopy-DbaLoginandSync-DbaLoginPermissionfails their context tests and nothing else.Commands to test
This text was created by Claude and reviewed by Andreas Jordan.