-
Notifications
You must be signed in to change notification settings - Fork 1.7k
[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
24
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
24 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 6777e66
Merge branch 'main' into jmccannon/ac/poc-transaction
jrmccannon e3faeca
Removed reference count as it wasn't being used anywhere. Added base …
jrmccannon 0d05e11
adding the base
jrmccannon f5167e7
Adding support for multiple dbs in scim integration tests.
jrmccannon ffde772
fixed
jrmccannon 59dc73d
Merge branch 'main' into jmccannon/ac/poc-transaction
jrmccannon dea9a01
few things
jrmccannon 45b9597
A few review changes.
jrmccannon fecb1a0
added some tests around rollback and commit
jrmccannon 923a3c6
Merge branch 'refs/heads/main' into jmccannon/ac/poc-transaction
jrmccannon d25f94a
Code review feedback
jrmccannon e40de74
added initialize method. added tests.
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
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
167 changes: 167 additions & 0 deletions
167
...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,167 @@ | ||
| using Bit.Core; | ||
| using Bit.Core.Billing.Enums; | ||
| using Bit.Core.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. | ||
| /// Runs against every supported real RDBMS (SqlServer, Postgres, MySql) when its | ||
| /// connection string is configured via BW_TEST_DATABASES__N__*. SQLite is excluded | ||
| /// because it serializes writes globally and cannot reproduce the read-modify-write | ||
| /// race on Organization.Seats. | ||
| /// </summary> | ||
| public class UsersControllerConcurrencyTests | ||
| { | ||
| private static readonly Lazy<IReadOnlyDictionary<SupportedDatabaseProviders, string>> _configuredConnections = | ||
| new(LoadConfiguredConnections); | ||
|
|
||
| [SkippableTheory] | ||
| [MemberData(nameof(DatabaseProviders))] | ||
| public async Task Post_ConcurrentInvites_DoNotOvershootMaxAutoscaleSeats( | ||
| SupportedDatabaseProviders providerType) | ||
| { | ||
| var baseConnectionString = _configuredConnections.Value.GetValueOrDefault(providerType); | ||
| Skip.If(baseConnectionString is null, | ||
| $"{providerType}: not configured (set BW_TEST_DATABASES__N__TYPE/CONNECTIONSTRING)."); | ||
|
|
||
| const short startingSeats = 3; | ||
| const int availableSeats = 2; | ||
| const int concurrentInvites = 6; | ||
|
|
||
| const string testDatabaseName = "vault_test_scim"; | ||
| ITestDatabase testDatabase = providerType switch | ||
| { | ||
| SupportedDatabaseProviders.SqlServer => new SqlServerTestDatabase(baseConnectionString!, testDatabaseName), | ||
| SupportedDatabaseProviders.Postgres => new PostgresTestDatabase(baseConnectionString!, testDatabaseName), | ||
| SupportedDatabaseProviders.MySql => new MySqlTestDatabase(baseConnectionString!, testDatabaseName), | ||
| _ => throw new InvalidOperationException($"Unsupported provider: {providerType}"), | ||
| }; | ||
|
|
||
| var factory = new ScimApplicationFactory | ||
| { | ||
| TestDatabase = testDatabase | ||
| }; | ||
|
|
||
| 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(); | ||
| } | ||
| } | ||
|
|
||
| public static IEnumerable<object?[]> DatabaseProviders() | ||
| { | ||
| yield return [SupportedDatabaseProviders.SqlServer]; | ||
| yield return [SupportedDatabaseProviders.Postgres]; | ||
| yield return [SupportedDatabaseProviders.MySql]; | ||
| } | ||
|
|
||
| private static Dictionary<SupportedDatabaseProviders, string> LoadConfiguredConnections() | ||
| { | ||
| var config = new ConfigurationBuilder() | ||
| .AddUserSecrets(typeof(Bit.Identity.Startup).Assembly, optional: true) | ||
| .AddEnvironmentVariables("BW_TEST_") | ||
| .Build(); | ||
|
|
||
| var configured = new Dictionary<SupportedDatabaseProviders, string>(); | ||
|
|
||
| // Preferred source: BW_TEST_DATABASES__N__* env vars (set by test-database.yml in CI) | ||
| for (var i = 0; ; i++) | ||
| { | ||
| var rawType = config[$"DATABASES:{i}:TYPE"]; | ||
| var connectionString = config[$"DATABASES:{i}:CONNECTIONSTRING"]; | ||
| if (rawType is null && connectionString is null) | ||
| { | ||
| break; | ||
| } | ||
| if (rawType is null || connectionString is null) | ||
| { | ||
| continue; | ||
| } | ||
| if (Enum.TryParse<SupportedDatabaseProviders>(rawType, ignoreCase: true, out var type)) | ||
| { | ||
| configured.TryAdd(type, connectionString); | ||
| } | ||
| } | ||
|
|
||
| // Fallback for local dev: Identity user secrets (globalSettings:<provider>:connectionString) | ||
| TryAddFromUserSecrets(SupportedDatabaseProviders.SqlServer, "globalSettings:sqlServer:connectionString"); | ||
| TryAddFromUserSecrets(SupportedDatabaseProviders.Postgres, "globalSettings:postgreSql:connectionString"); | ||
| TryAddFromUserSecrets(SupportedDatabaseProviders.MySql, "globalSettings:mySql:connectionString"); | ||
|
|
||
| return configured; | ||
|
|
||
| void TryAddFromUserSecrets(SupportedDatabaseProviders type, string key) | ||
| { | ||
| if (configured.ContainsKey(type)) | ||
| { | ||
| return; | ||
| } | ||
| var value = config[key]; | ||
| if (!string.IsNullOrWhiteSpace(value)) | ||
| { | ||
| configured[type] = value; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private static ScimUserRequestModel BuildInvite(int i) => new() | ||
| { | ||
| DisplayName = $"Concurrent User {i}", | ||
| Emails = [new() { Primary = true, Type = "work", Value = $"concurrent-{i}@example.com" }], | ||
| ExternalId = $"CONC-{i}", | ||
| Active = true, | ||
| Schemas = [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
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,22 @@ | ||
| 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 return a no-op scope that shares the outer | ||
| /// transaction. Only the outermost Commit/Dispose actually affects the database. | ||
| /// The isolation level on nested calls is ignored — inner scopes inherit the | ||
| /// outer transaction's isolation level. | ||
| /// </summary> | ||
| Task<ITransactionScope> BeginTransactionAsync( | ||
| IsolationLevel isolationLevel = IsolationLevel.ReadCommitted, | ||
| 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,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,36 @@ | ||
| 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.MarkDoomed(); | ||
| return Task.CompletedTask; | ||
| } | ||
|
|
||
| public ValueTask DisposeAsync() | ||
| { | ||
| if (_disposed) | ||
| { | ||
| return ValueTask.CompletedTask; | ||
| } | ||
|
|
||
| _disposed = true; | ||
| return ValueTask.CompletedTask; | ||
| } | ||
| } |
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.
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.
I agree that serializable isolation level should be avoided if possible. A couple other options are:
I'd recommend the row version column, since it requires minimal code changes and retains optimistic concurrency. The main caveat is that instead of blocking other callers upfront, each caller proceeds as normal and you deal with race conditions (if any) afterwards, like this:
Step 5 is the conflict. EF Core detects the 0-rows-affected case and throws DbUpdateConcurrencyException.
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.
I have a branch i was trying something similar with Polly in order to not just fail everything. I'll give that a try.
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.
@mkincaid-bw I would also prefer to avoid SERIALIZABLE and use an optimistic solution (even at the expense of a bit of hand-crafting), however we went down this path at @withinfocus 's direction. I would prefer Architecture's confirmation before we spend more time exploring other solutions. (Thoughts on the above suggestions @withinfocus ?)