diff --git a/.build.ps1 b/.build.ps1 index baa79f6..366dd24 100644 --- a/.build.ps1 +++ b/.build.ps1 @@ -220,12 +220,22 @@ task PushNuGet NuGet, { Clean # Synopsis: Make and push the PSGallery package. -task PushPSGallery Package, Version, { - assert ($TargetFramework -eq 'netstandard2.0') - $NuGetApiKey = Read-Host NuGetApiKey - Publish-Module -Path z/tools/$ModuleName -NuGetApiKey $NuGetApiKey -}, -Clean +task PushPSGallery @( + if ($TargetFramework -eq 'netstandard2.0') { + 'Package' + 'Version' + { + $NuGetApiKey = Read-Host NuGetApiKey + Publish-Module -Path z/tools/$ModuleName -NuGetApiKey $NuGetApiKey + } + 'Clean' + } + else { + { + Invoke-Build PushPSGallery -TargetFramework netstandard2.0 + } + } +) # Synopsis: Test synopsis of each cmdlet and warn about unexpected. task TestHelpSynopsis { diff --git a/Module/en-US/Mdbc.dll-Help.ps1 b/Module/en-US/Mdbc.dll-Help.ps1 index fd951ef..0caa642 100644 --- a/Module/en-US/Mdbc.dll-Help.ps1 +++ b/Module/en-US/Mdbc.dll-Help.ps1 @@ -312,7 +312,12 @@ $AClient = @{ $ASession = @{ parameters = @{ - Session = 'Specifies the client session which executes the command.' + Session = @' +Specifies the client session which invokes the command. + +If it is omitted then the cmdlet is invoked in the current default session, +either its own or the transaction session created by Use-MdbcTransaction. +'@ } } @@ -1156,3 +1161,56 @@ Tells to ignore document elements that do not match the properties. '@ } } + +### Use-MdbcTransaction +Merge-Helps $AClient @{ + command = 'Use-MdbcTransaction' + synopsis = 'Invokes the script with a transaction.' + description = @' +The cmdlet starts a transaction session and invokes the specified script. The +script calls data cmdlets and either succeeds or fails. The cmdlet commits or +aborts the transaction accordingly. + +The transaction session is default for cmdlets with the parameter Session. +For the script the session is exposed as the automatic variable $Session. + +Nested calls are allowed but transactions and sessions are independent. +In particular, they may not see changes made in parent or nested calls. +'@ + parameters = @{ + Script = @' +Specifies the script to be invoked with a transaction session. +'@ + } + + outputs = @( + @{ + type = '[object]' + description = 'Output of the specified script.' + } + ) + + examples = @( + @{ + code = { + # add several documents using a transaction + $documents = ... + Use-MdbcTransaction { + $documents | Add-MdbcData + } + } + } + @{ + code = { + # move a document using a transaction + $c1 = Get-MdbcCollection MyData1 + $c2 = Get-MdbcCollection MyData2 + Use-MdbcTransaction { + # get and remove from MyData1 | add to MyData2 + Get-MdbcData @{_id = 1} -Remove -Collection $c1 | + Add-MdbcData -Collection $c2 + } + } + } + ) +} diff --git a/Src/Commands/AbstractSessionCommand.cs b/Src/Commands/AbstractSessionCommand.cs index 11cf428..142b959 100644 --- a/Src/Commands/AbstractSessionCommand.cs +++ b/Src/Commands/AbstractSessionCommand.cs @@ -2,9 +2,9 @@ // Copyright (c) Roman Kuzmin // http://www.apache.org/licenses/LICENSE-2.0 -using MongoDB.Bson; using MongoDB.Driver; using System; +using System.Collections.Generic; using System.Management.Automation; namespace Mdbc.Commands @@ -15,6 +15,13 @@ public abstract class AbstractSessionCommand : Abstract, IDisposable bool _disposed; IClientSessionHandle _Session; + //! ThreadStatic and `= new Stack()` fails in Split-Pipeline + [ThreadStatic] + static Stack _DefaultSessions_; + static Stack DefaultSessions { get { return _DefaultSessions_ ?? (_DefaultSessions_ = new Stack()); } } + internal static void PushDefaultSession(IClientSessionHandle session) { DefaultSessions.Push(session); } + internal static IClientSessionHandle PopDefaultSession() { return DefaultSessions.Pop(); } + [Parameter] public IClientSessionHandle Session { @@ -22,8 +29,17 @@ public IClientSessionHandle Session { if (_Session == null) { - _Session = MyClient.StartSession(); - _dispose = true; + if (DefaultSessions.Count == 0) + { + // temporary session + _Session = MyClient.StartSession(); + _dispose = true; + } + else + { + // the current default session + _Session = DefaultSessions.Peek(); + } } return _Session; } diff --git a/Src/CollectionExt.cs b/Src/Commands/CollectionExt.cs similarity index 97% rename from Src/CollectionExt.cs rename to Src/Commands/CollectionExt.cs index 9ed6d7b..59c8a81 100644 --- a/Src/CollectionExt.cs +++ b/Src/Commands/CollectionExt.cs @@ -6,14 +6,14 @@ using MongoDB.Driver; using System.Collections.Generic; -namespace Mdbc +namespace Mdbc.Commands { static class CollectionExt { public static long MyCount(this IMongoCollection collection, IClientSessionHandle session, FilterDefinition filter, long skip, long first) { if (skip <= 0 && first <= 0) - return collection.CountDocuments(filter); + return collection.CountDocuments(session, filter); var options = new CountOptions(); if (skip > 0) diff --git a/Src/Commands/UseTransactionCommand.cs b/Src/Commands/UseTransactionCommand.cs new file mode 100644 index 0000000..f8553b9 --- /dev/null +++ b/Src/Commands/UseTransactionCommand.cs @@ -0,0 +1,48 @@ + +// Copyright (c) Roman Kuzmin +// http://www.apache.org/licenses/LICENSE-2.0 + +using MongoDB.Driver; +using System; +using System.Collections.Generic; +using System.Management.Automation; + +namespace Mdbc.Commands +{ + [Cmdlet(VerbsOther.Use, "MdbcTransaction"), OutputType(typeof(IMongoDatabase))] + public sealed class UseTransactionCommand : AbstractClientCommand + { + [Parameter(Position = 0, Mandatory = true)] + public ScriptBlock Script { get; set; } + + protected override void BeginProcessing() + { + var session = Client.StartSession(); + AbstractSessionCommand.PushDefaultSession(session); + try + { + session.StartTransaction(); + try + { + var vars = new List() { new PSVariable("Session", session) }; + var result = Script.InvokeWithContext(null, vars); + foreach (var item in result) + WriteObject(item); + + session.CommitTransaction(); + } + catch (RuntimeException exn) + { + var text = $"{exn.Message}{Environment.NewLine}{exn.ErrorRecord.InvocationInfo.PositionMessage}"; + var exn2 = new RuntimeException(text, exn); + WriteException(exn2, null); + } + } + finally + { + AbstractSessionCommand.PopDefaultSession(); + session.Dispose(); + } + } + } +} diff --git a/Src/GlobalSuppressions.cs b/Src/GlobalSuppressions.cs index e36d7c5..d2aca33 100644 --- a/Src/GlobalSuppressions.cs +++ b/Src/GlobalSuppressions.cs @@ -3,4 +3,4 @@ [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "rk", Scope = "namespaceanddescendants", Target = "Mdbc.Commands")] [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1825:Avoid zero-length array allocations.", Justification = "", Scope = "member", Target = "~F:Mdbc.MyDocument.EmptyArray")] [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1010:Collections should implement generic interface", Justification = "", Scope = "type", Target = "~T:Mdbc.Collection")] -[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1010:Collections should implement generic interface", Justification = "", Scope = "type", Target = "~T:Mdbc.Dictionary")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1010:Collections should implement generic interface", Justification = "", Scope = "type", Target = "~T:Mdbc.Dictionary")] \ No newline at end of file diff --git a/Src/Mdbc.csproj b/Src/Mdbc.csproj index 7615bdf..ec58a34 100644 --- a/Src/Mdbc.csproj +++ b/Src/Mdbc.csproj @@ -16,7 +16,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Tests/About.test.ps1 b/Tests/About.test.ps1 index 0dfd501..a245530 100644 --- a/Tests/About.test.ps1 +++ b/Tests/About.test.ps1 @@ -127,5 +127,6 @@ task PublicTypes { 'RenameCollectionCommand' 'SetDataCommand' 'UpdateDataCommand' + 'UseTransactionCommand' ) } diff --git a/Tests/MongoFiles.test.ps1 b/Tests/MongoFiles.test.ps1 index 9f2a33f..c633d0c 100644 --- a/Tests/MongoFiles.test.ps1 +++ b/Tests/MongoFiles.test.ps1 @@ -141,11 +141,11 @@ task Get-MongoFile Update-MongoFiles, { $r = @(Get-MongoFile -CollectionName test 'collectionext|documentinput' | Sort-Object) $r equals 3 $r.Count - assert ($r[0] -clike '*\Mdbc\Src\CollectionExt.cs') + assert ($r[0] -clike '*\Mdbc\Src\Commands\CollectionExt.cs') assert ($r[1] -clike '*\Mdbc\Src\DocumentInput.cs') assert ($r[2] -clike '*\Mdbc\Tests\DocumentInput.test.ps1') $r = @(Get-MongoFile -CollectionName test 'CollectionExt.cs' -Name) equals 1 $r.Count - assert ($r[0] -clike '*\Mdbc\Src\CollectionExt.cs') + assert ($r[0] -clike '*\Mdbc\Src\Commands\CollectionExt.cs') } diff --git a/Tests/Session.test.ps1 b/Tests/Session.test.ps1 deleted file mode 100644 index 936923b..0000000 --- a/Tests/Session.test.ps1 +++ /dev/null @@ -1,27 +0,0 @@ - -Import-Module Mdbc - -task Basic { - Connect-Mdbc -NewCollection - $ses = $Client.StartSession() - try { - @{_id = 71} | Add-MdbcData -Session $ses - $r = Get-MdbcData -Session $ses - equals $r._id 71 - - Set-MdbcData @{} @{p1 = 71} -Session $ses - $r = Get-MdbcData -Session $ses - equals $r.p1 71 - - Update-MdbcData @{} @{'$set' = @{p1 = 72}} -Session $ses - $r = Get-MdbcData -Session $ses - equals $r.p1 72 - - Remove-MdbcData @{} -Session $ses - $r = Get-MdbcData -Count -Session $ses - equals $r 0L - } - finally { - $ses.Dispose() - } -} diff --git a/Tests/Transaction.test.ps1 b/Tests/Transaction.test.ps1 new file mode 100644 index 0000000..232585c --- /dev/null +++ b/Tests/Transaction.test.ps1 @@ -0,0 +1,314 @@ +<# +.Synopsis + Tests transactions and sessions. +#> + +. ./Zoo.ps1 + +# Synopsis: How to use manual and block transactions. +task manual-and-block-transactions { + Connect-Mdbc . test + $Collection = Get-MdbcCollectionNew test + + $session1 = $Client.StartSession() + try { + $session1.StartTransaction() + @{_id = 1} | Add-MdbcData -Session $session1 + $session1.CommitTransaction() + } + finally { + $session1.Dispose() + } + + $session1 = $Client.StartSession() + try { + $session1.StartTransaction() + @{_id = 2} | Add-MdbcData -Session $session1 + $session1.AbortTransaction() + } + finally { + $session1.Dispose() + } + + Use-MdbcTransaction { + @{_id = 3} | Add-MdbcData + } + + Use-MdbcTransaction -ErrorAction 2 { + @{_id = 4} | Add-MdbcData + throw 'oops' + } + + Get-MdbcData +} + +# Synopsis: Move a document with a transaction. +task move-document { + Connect-Mdbc . test + $c1 = Get-MdbcCollectionNew test1 + $c2 = Get-MdbcCollectionNew test2 + @{_id = 33} | Add-MdbcData -Collection $c1 + + Use-MdbcTransaction -ErrorAction 0 -ErrorVariable err { + Get-MdbcData @{_id = 33} -Remove -Collection $c1 | + Add-MdbcData -Collection $c2 + + equals (Get-MdbcData -Count -Collection $c1) 0L + equals "$(Get-MdbcData -Collection $c2)" '{ "_id" : 33 }' + + throw 'oops' + } + assert ("$err" -like "oops*At*") + equals (Get-MdbcData -Count -Collection $c1) 1L + equals (Get-MdbcData -Count -Collection $c2) 0L + + Use-MdbcTransaction -ErrorAction 0 -ErrorVariable err { + Get-MdbcData @{_id = 33} -Remove -Collection $c1 | + Add-MdbcData -Collection $c2 + + equals (Get-MdbcData -Count -Collection $c1) 0L + equals "$(Get-MdbcData -Collection $c2)" '{ "_id" : 33 }' + } + equals (Get-MdbcData -Count -Collection $c1) 0L + equals "$(Get-MdbcData -Collection $c2)" '{ "_id" : 33 }' + + Remove-MdbcCollection test1 + Remove-MdbcCollection test2 +} + +# Synopsis: Can use another session in a block transaction. +task use-another-session-in-transaction { + Connect-Mdbc . test + + $session1 = $Client.StartSession() + try { + $c1 = Get-MdbcCollectionNew test1 + $c2 = Get-MdbcCollectionNew test2 + @{_id = 33} | Add-MdbcData -Collection $c1 + + Use-MdbcTransaction { + # move the doc from one collection to another + Get-MdbcData @{_id = 33} -Remove -Collection $c1 | + Add-MdbcData -Collection $c2 + + # test in transaction session, moved + equals "$(Get-MdbcData -Collection $c1)" '' + equals "$(Get-MdbcData -Collection $c2)" '{ "_id" : 33 }' + + # test in session 1, not moved + equals "$(Get-MdbcData -Collection $c1 -Session $session1)" '{ "_id" : 33 }' + equals "$(Get-MdbcData -Collection $c2 -Session $session1)" '' + } + } + finally { + $session1.Dispose() + } + + Remove-MdbcCollection test1 + Remove-MdbcCollection test2 +} + +# Synopsis: Show not just Use-MdbcTransaction position but the actual error, too. +task show-inner-error-position { + Connect-Mdbc . + Use-MdbcTransaction -ErrorAction 2 -ErrorVariable err { + throw 'oops' + } + assert ("$err" -like "oops*At $BuildFile*throw 'oops'*") +} + +# Synopsis: Transactions are not really nested. +task nested-manual-transactions { + Connect-Mdbc . test test + + function Test-Nested([switch]$Commit1, [switch]$Commit2) { + $Collection = Get-MdbcCollectionNew test + $session1 = $Client.StartSession() + try { + $session1.StartTransaction() + @{_id = 1} | Add-MdbcData -Session $session1 + + $session2 = $Client.StartSession() + try { + $session2.StartTransaction() + @{_id = 2} | Add-MdbcData -Session $session2 + + if ($Commit2) { + $session2.CommitTransaction() + } + } + finally { + $session2.Dispose() + } + + if ($Commit1) { + $session1.CommitTransaction() + } + } + finally { + $session1.Dispose() + } + } + + Test-Nested + equals "$(Get-MdbcData)" '' + + Test-Nested -Commit1 + equals "$(Get-MdbcData)" '{ "_id" : 1 }' + + Test-Nested -Commit2 + equals "$(Get-MdbcData)" '{ "_id" : 2 }' + + Test-Nested -Commit1 -Commit2 + equals "$(Get-MdbcData)" '{ "_id" : 1 } { "_id" : 2 }' +} + +# Synopsis: Nested block transactions are allowed but they are independent. +task nested-block-transactions { + Connect-Mdbc . test + $Collection = Get-MdbcCollectionNew test + + # This function calls Use-MdbcTransaction with nested calls. + function Invoke-Job1 { + Use-MdbcTransaction { + # add data + @{_id = 1} | Add-MdbcData + + # call nested, provide the current session if needed + Invoke-Job2 $Session + + #! this session cannot see nested changes + $r = Get-MdbcData + equals "$r" '{ "_id" : 1 }' + } + } + + #! Do not call the parameter "Session", Use-MdbcTransaction sets its own. + function Invoke-Job2($ParentSession) { + Use-MdbcTransaction { + #! this session cannot see parent changes without -Session + $r = Get-MdbcData + equals "$r" '' + + # that is why, when needed, we use the session parameter + $r = Get-MdbcData -Session $ParentSession + equals "$r" '{ "_id" : 1 }' + + # ok, add some data + @{_id = 2} | Add-MdbcData + } + } + + Invoke-Job1 + $r = Get-MdbcData + equals "$r" '{ "_id" : 1 } { "_id" : 2 }' +} + +# Synopsis: Cannot start a session transaction when another is in progress. +task start-transaction-twice { + Connect-Mdbc . + try { + $session1 = $Client.StartSession() + $session1.StartTransaction() + $session1.StartTransaction() + throw + } + catch { + $_ + assert ("$_" -like '*Transaction already in progress.*') + } +} + +# Synopsis: Simulate a conflict of two transactions. +task conflicting-transactions { + # init counter + Connect-Mdbc . test test -NewCollection + @{_id = 1; n = 1} | Add-MdbcData + + $session1 = $Client.StartSession() + $session2 = $Client.StartSession() + try { + $session1.StartTransaction() + $session2.StartTransaction() + + # inc counter in 1 + $r1 = Get-MdbcData -Session $session1 @{_id = 1} -Update @{'$inc' = @{n = 1}} -New + equals $r1.n 2 + + # inc counter in 2 -> error + try { + Get-MdbcData -Session $session2 @{_id = 1} -Update @{'$inc' = @{n = 1}} -New + throw + } + catch { + equals "$_" "Command findAndModify failed: WriteConflict." + } + + # commit 2 -> error + try { + $session2.CommitTransaction() + throw + } + catch { + assert ("$_" -match 'Command commitTransaction failed: Transaction \d+ has been aborted') + } + + # commit 1 + $session1.CommitTransaction() + } + finally { + $session2.Dispose() + $session1.Dispose() + } + + # inc worked once + $r = Get-MdbcData + equals "$r" '{ "_id" : 1, "n" : 2 }' +} + +# Synopsis: Without transactions sessions see other changes. +task sessions-without-transactions { + Connect-Mdbc -NewCollection + $session1 = $Client.StartSession() + $session2 = $Client.StartSession() + try { + @{_id = 1} | Add-MdbcData -Session $session1 + @{_id = 2} | Add-MdbcData -Session $session2 + + $r = Get-MdbcData + equals "$r" '{ "_id" : 1 } { "_id" : 2 }' + + $r = Get-MdbcData -Session $session1 + equals "$r" '{ "_id" : 1 } { "_id" : 2 }' + + $r = Get-MdbcData -Session $session2 + equals "$r" '{ "_id" : 1 } { "_id" : 2 }' + } + finally { + $session2.Dispose() + $session1.Dispose() + } +} + +# Synopsis: Script of Use-MdbcTransaction may output whatever. +task block-output { + Connect-Mdbc . test + $Collection = Get-MdbcCollectionNew test + + # nothing + $r = Use-MdbcTransaction {} + equals $r $null + + # one object + $r = Use-MdbcTransaction {1} + equals $r 1 + + # many objects + $r = Use-MdbcTransaction {1; 2} + equals "$r" '1 2' + + # null on abort, even if something was written before failure + $r = Use-MdbcTransaction -ErrorAction 0 -ErrorVariable err {1; throw 'oops'; 2} + equals $r $null + assert ("$err" -like "oops*throw 'oops'*") +} diff --git a/Tests/Zoo.ps1 b/Tests/Zoo.ps1 index 0b8b747..e2992c7 100644 --- a/Tests/Zoo.ps1 +++ b/Tests/Zoo.ps1 @@ -13,6 +13,13 @@ $ErrorPipeline = [Mdbc.Api]::TextParameterPipeline $ErrorSet = [Mdbc.Api]::TextParameterSet $ErrorUpdate = [Mdbc.Api]::TextParameterUpdate +function Get-MdbcCollectionNew($Name, $Database=$Database) { + $Collection = Get-MdbcCollection $Name -NewCollection + Add-MdbcData @{_id = 1} + Remove-MdbcData @{} + $Collection +} + function Zoo1 { class T { $Version = [version]'0.0' } [T]::new()