-
Notifications
You must be signed in to change notification settings - Fork 1.6k
[PM-25520] - Adding a transaction for mutliple repo calls #7555
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
jrmccannon
wants to merge
12
commits into
main
Choose a base branch
from
jmccannon/ac/poc-transaction
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
c0d47f0
initial work for transaction
jrmccannon 53759ec
added the transaction
jrmccannon 6c8284b
Merge branch 'refs/heads/main' into jmccannon/ac/poc-transaction
jrmccannon 9863df4
Added transaction isolation and wrapped all calls (cept sm) in transaβ¦
jrmccannon 2302757
Corrected scope.
jrmccannon b8f5a1b
Merge branch 'refs/heads/main' into jmccannon/ac/poc-transaction
jrmccannon b9de163
Added db.SaveContext
jrmccannon 4654104
commenting temporarily
jrmccannon 0f95611
Correcting ownership of EF transaction (it disposes of the context itβ¦
jrmccannon 676f98d
Merge branch 'refs/heads/main' into jmccannon/ac/poc-transaction
jrmccannon cc9df67
testing envsqlserver config
jrmccannon 4c4ed74
reverting previous change
jrmccannon File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
93 changes: 93 additions & 0 deletions
93
...arden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerConcurrencyTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| ο»Ώusing Bit.Core; | ||
| using Bit.Core.Billing.Enums; | ||
| using Bit.Core.Services; | ||
| using Bit.Infrastructure.EntityFramework.Repositories; | ||
| using Bit.IntegrationTestCommon; | ||
| using Bit.Scim.IntegrationTest.Factories; | ||
| using Bit.Scim.Models; | ||
| using Bit.Scim.Utilities; | ||
| using NSubstitute; | ||
| using Xunit; | ||
|
|
||
| namespace Bit.Scim.IntegrationTest.Controllers.v2; | ||
|
|
||
| /// <summary> | ||
| /// Verifies seat-count integrity when SCIM invite requests run concurrently. | ||
| /// Requires a real SQL Server (vault_test) β SQLite serializes writes globally and | ||
| /// cannot reproduce the read-modify-write race on Organization.Seats. | ||
| /// </summary> | ||
| public class UsersControllerConcurrencyTests | ||
| { | ||
| [Fact] | ||
| public async Task Post_ConcurrentInvites_DoNotOvershootMaxAutoscaleSeats() | ||
| { | ||
| const short startingSeats = 3; | ||
| const int availableSeats = 2; | ||
| const int concurrentInvites = 6; | ||
|
|
||
| var factory = new ScimApplicationFactory | ||
| { | ||
| TestDatabase = new SqlServerTestDatabase() | ||
| }; | ||
|
|
||
| factory.SubstituteService((IFeatureService f) => f.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) | ||
| .Returns(true)); | ||
|
|
||
| try | ||
| { | ||
| factory.ReinitializeDbForTests(factory.GetDatabaseContext()); | ||
|
|
||
| using (var setupScope = factory.Services.CreateScope()) | ||
| { | ||
| var setupContext = setupScope.ServiceProvider.GetRequiredService<DatabaseContext>(); | ||
| var org = setupContext.Organizations.Single(o => o.Id == ScimApplicationFactory.TestOrganizationId1); | ||
| org.PlanType = PlanType.EnterpriseAnnually; | ||
| org.Plan = "Enterprise (Annually)"; | ||
| org.Seats = startingSeats; | ||
| org.MaxAutoscaleSeats = startingSeats + availableSeats; | ||
| await setupContext.SaveChangesAsync(); | ||
| } | ||
|
|
||
| var inputs = Enumerable.Range(0, concurrentInvites).Select(BuildInvite).ToArray(); | ||
|
|
||
| var responses = await Task.WhenAll( | ||
| inputs.Select(input => | ||
| factory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, input))); | ||
|
|
||
| var successfulInvites = responses.Count(r => r.Response.StatusCode == StatusCodes.Status201Created); | ||
|
|
||
| using var verifyScope = factory.Services.CreateScope(); | ||
| var verifyContext = verifyScope.ServiceProvider.GetRequiredService<DatabaseContext>(); | ||
| var finalOrg = verifyContext.Organizations | ||
| .Single(o => o.Id == ScimApplicationFactory.TestOrganizationId1); | ||
| var finalActiveUserCount = verifyContext.OrganizationUsers | ||
| .Count(ou => ou.OrganizationId == ScimApplicationFactory.TestOrganizationId1 && ou.Status >= 0); | ||
|
|
||
| Assert.All(responses, r => Assert.True(r.Response.StatusCode < 500, | ||
| $"Expected non-5xx status, got {r.Response.StatusCode}")); | ||
|
|
||
| Assert.Equal(startingSeats + successfulInvites, finalOrg.Seats); | ||
|
|
||
| Assert.Equal(startingSeats + successfulInvites, finalActiveUserCount); | ||
|
|
||
| Assert.True(finalOrg.Seats <= finalOrg.MaxAutoscaleSeats, | ||
| $"Seats {finalOrg.Seats} exceeded MaxAutoscaleSeats {finalOrg.MaxAutoscaleSeats}"); | ||
| } | ||
| finally | ||
| { | ||
| await factory.DisposeAsync(); | ||
| } | ||
| } | ||
|
|
||
| private static ScimUserRequestModel BuildInvite(int i) => new() | ||
| { | ||
| DisplayName = $"Concurrent User {i}", | ||
| Emails = new List<BaseScimUserModel.EmailModel> | ||
| { | ||
| new() { Primary = true, Type = "work", Value = $"concurrent-{i}@example.com" } | ||
| }, | ||
| ExternalId = $"CONC-{i}", | ||
| Active = true, | ||
| Schemas = new List<string> { ScimConstants.Scim2SchemaUser } | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| ο»Ώusing System.Data; | ||
|
|
||
| namespace Bit.Core.Platform.Data; | ||
|
|
||
| /// <summary> | ||
| /// Manages ambient database transactions that span multiple repository calls. | ||
| /// Implementations are singleton-safe; transaction state is stored per async flow. | ||
| /// </summary> | ||
| public interface ITransactionManager | ||
| { | ||
| /// <summary> | ||
| /// Begins a new ambient transaction. All repository operations on the current | ||
| /// async flow will use the same connection and transaction until disposed. | ||
| /// Supports nesting: inner calls increment a reference count; only the | ||
| /// outermost Dispose/Commit actually affects the database. The isolation level | ||
| /// on a nested call is ignored β the inner scope joins the outer transaction. | ||
| /// </summary> | ||
| Task<ITransactionScope> BeginTransactionAsync( | ||
| IsolationLevel isolationLevel = IsolationLevel.ReadCommitted, | ||
| CancellationToken cancellationToken = default); | ||
|
|
||
| /// <summary> | ||
| /// Returns true if the current async flow has an active ambient transaction. | ||
| /// </summary> | ||
| bool HasActiveTransaction { get; } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| ο»Ώnamespace Bit.Core.Platform.Data; | ||
|
|
||
| /// <summary> | ||
| /// Represents an ambient transaction scope. Commit must be called explicitly; | ||
| /// disposing without committing triggers rollback. | ||
| /// </summary> | ||
| public interface ITransactionScope : IAsyncDisposable | ||
| { | ||
| Task CommitAsync(CancellationToken cancellationToken = default); | ||
| Task RollbackAsync(CancellationToken cancellationToken = default); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| ο»Ώnamespace Bit.Core.Platform.Data; | ||
|
|
||
| public sealed class NestedTransactionScope : ITransactionScope | ||
| { | ||
| private readonly TransactionHolder _holder; | ||
| private bool _disposed; | ||
|
|
||
| public NestedTransactionScope(TransactionHolder holder) | ||
| { | ||
| _holder = holder; | ||
| } | ||
|
|
||
| public Task CommitAsync(CancellationToken cancellationToken = default) | ||
| { | ||
| // Nested scope commit is a no-op; only the root scope commits. | ||
| return Task.CompletedTask; | ||
| } | ||
|
|
||
| public Task RollbackAsync(CancellationToken cancellationToken = default) | ||
| { | ||
| // Mark the transaction as doomed so the root scope cannot commit. | ||
| _holder.Doomed = true; | ||
| return Task.CompletedTask; | ||
| } | ||
|
|
||
| public ValueTask DisposeAsync() | ||
| { | ||
| if (_disposed) | ||
| { | ||
| return ValueTask.CompletedTask; | ||
| } | ||
|
|
||
| _disposed = true; | ||
| _holder.ReferenceCount--; | ||
| return ValueTask.CompletedTask; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| ο»Ώnamespace Bit.Core.Platform.Data; | ||
|
|
||
| public sealed class RootTransactionScope : ITransactionScope | ||
| { | ||
| private readonly TransactionHolder _holder; | ||
| private bool _disposed; | ||
|
|
||
| public RootTransactionScope(TransactionHolder holder) | ||
| { | ||
| _holder = holder; | ||
| } | ||
|
|
||
| public async Task CommitAsync(CancellationToken cancellationToken = default) | ||
| { | ||
| if (_holder.Doomed) | ||
| { | ||
| throw new InvalidOperationException( | ||
| "Cannot commit a transaction that has been marked for rollback by a nested scope."); | ||
| } | ||
|
|
||
| _holder.Committed = true; | ||
| await _holder.Transaction.CommitAsync(cancellationToken); | ||
| } | ||
|
|
||
| public async Task RollbackAsync(CancellationToken cancellationToken = default) | ||
| { | ||
| _holder.Doomed = true; | ||
| await _holder.Transaction.RollbackAsync(cancellationToken); | ||
| } | ||
|
|
||
| public async ValueTask DisposeAsync() | ||
| { | ||
| if (_disposed) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| _disposed = true; | ||
| TransactionState.Current = null; | ||
| await _holder.DisposeAsync(); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| ο»Ώusing System.Data.Common; | ||
|
|
||
| namespace Bit.Core.Platform.Data; | ||
|
|
||
| public static class TransactionState | ||
| { | ||
| private static readonly AsyncLocal<TransactionHolder?> _current = new(); | ||
|
|
||
| public static TransactionHolder? Current | ||
| { | ||
| get => _current.Value; | ||
| set => _current.Value = value; | ||
| } | ||
| } | ||
|
|
||
| public sealed class TransactionHolder : IAsyncDisposable | ||
| { | ||
| public required DbConnection Connection { get; init; } | ||
| public required DbTransaction Transaction { get; init; } | ||
| public int ReferenceCount { get; set; } = 1; | ||
| public bool Committed { get; set; } | ||
| public bool Doomed { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// True when this holder is responsible for disposing <see cref="Connection"/>. | ||
| /// EF reuses the DbContext's connection and must leave its lifetime to the scope; | ||
| /// Dapper opens its own connection and must dispose it here. | ||
| /// </summary> | ||
| public bool OwnsConnection { get; init; } = true; | ||
|
|
||
| /// <summary> | ||
| /// For EF: the DatabaseContext associated with this transaction. | ||
| /// </summary> | ||
| public object? DbContext { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// For EF: the IServiceScope that owns the DatabaseContext. | ||
| /// </summary> | ||
| public IAsyncDisposable? Scope { get; set; } | ||
|
|
||
| public async ValueTask DisposeAsync() | ||
| { | ||
| if (!Committed) | ||
| { | ||
| try | ||
| { | ||
| await Transaction.RollbackAsync(); | ||
| } | ||
| catch | ||
| { | ||
| // Best-effort rollback; connection may already be broken | ||
| } | ||
| } | ||
|
|
||
| await Transaction.DisposeAsync(); | ||
|
|
||
| if (OwnsConnection) | ||
| { | ||
| await Connection.DisposeAsync(); | ||
| } | ||
|
|
||
| if (Scope is not null) | ||
| { | ||
| await Scope.DisposeAsync(); | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
β
Serializabletransaction isolation is the strictest you can get, but it have side effects compared to default, like it can throw error if there was a concurrent write (in different transaciton). Is this by choice here ?On a side note, this changes the business logic, which i wonder, whether this PR should do.
In most cases this transaction isolation is unnecessary, in which case, it is better to begin the transaction just before the first write, not during the get.
One important note, the copy paste code trend is unavoidable and we will see other places using this isolation level for no reason, just because it was used somewhere else. If we would use serializable isolation level by choice here, i think a reasonable comment should be added explaining why it might be needed here.
@bitwarden/dept-dbops Opinions ?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The isolation is needed so that we can get the current seat count and ensure that it won't change before we complete our request.
For this specific command, multiple requests come into SCIM grab the organization at the same time and attempt to add their user. Currently, the orgservice#inviteuser method grabs resources multiple times including calling out to stripe. An optimized command for inviting users was created, but that introduced an issue where multiple requests would race to update the seat count and all set it to the same value instead of incrementing it.
3 requests come in => get Org: { Seats: 2 } and each add a seat to the org.Seat value. Each request would set org.Seat to 3 when in reality they should set it to 5 (assuming both Org.Seats are occupied). There's also the ability for an Org to set a limit (Org.MaxSeats: 4). So two should succeed and one should fail.
The SCIM protocol requires the server tell it whether the user was safely provisioned so we have to succeed or fail and can't just switch it to an asynchronous add.
With serialized, this would ensure that the seat count is updated correctly as the requests come in.
So that was the reason for this approach. I'd be interested in another way as this is very heavy handed.