From 7ed3ea55d6121970e9b18437c56cf2a821cec7f6 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 3 Mar 2026 17:19:44 -0500 Subject: [PATCH 1/3] Add transactions to the MongoDbContext --- Backend.Tests/Mocks/MongoDbContextMock.cs | 32 ++++++++++++++++++ Backend/Contexts/MongoDbContext.cs | 40 ++++++++++++++++------- Backend/Interfaces/IMongoDbContext.cs | 17 +++++++--- Backend/Startup.cs | 3 +- 4 files changed, 74 insertions(+), 18 deletions(-) create mode 100644 Backend.Tests/Mocks/MongoDbContextMock.cs diff --git a/Backend.Tests/Mocks/MongoDbContextMock.cs b/Backend.Tests/Mocks/MongoDbContextMock.cs new file mode 100644 index 0000000000..cbc3b028a7 --- /dev/null +++ b/Backend.Tests/Mocks/MongoDbContextMock.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; +using BackendFramework.Interfaces; +using MongoDB.Driver; + +namespace Backend.Tests.Mocks; + +public class MongoDbContextMock : IMongoDbContext +{ + public IMongoDatabase Db => throw new NotSupportedException(); + public Task BeginTransaction() + { + return Task.FromResult(new MongoTransactionMock()); + } + + private sealed class MongoTransactionMock : IMongoTransaction + { + public Task CommitTransactionAsync() + { + return Task.CompletedTask; + } + + public Task AbortTransactionAsync() + { + return Task.CompletedTask; + } + + public void Dispose() + { + } + } +} diff --git a/Backend/Contexts/MongoDbContext.cs b/Backend/Contexts/MongoDbContext.cs index 7329b662b5..429396e1b8 100644 --- a/Backend/Contexts/MongoDbContext.cs +++ b/Backend/Contexts/MongoDbContext.cs @@ -1,28 +1,44 @@ -using System; -using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; using BackendFramework.Interfaces; using Microsoft.Extensions.Options; using MongoDB.Driver; -namespace BackendFramework.Contexts +namespace BackendFramework.Contexts; + +public class MongoDbContext : IMongoDbContext { - [ExcludeFromCodeCoverage] - public class MongoDbContext : IMongoDbContext + public IMongoDatabase Db { get; } + + public MongoDbContext(IOptions options) + { + var client = new MongoClient(options.Value.ConnectionString); + Db = client.GetDatabase(options.Value.CombineDatabase); + } + + public async Task BeginTransaction() { - private MongoClient _mongoClient { get; } + var session = await Db.Client.StartSessionAsync(); + session.StartTransaction(); + return new MongoTransactionWrapper(session); + } + + private class MongoTransactionWrapper(IClientSessionHandle session) : IMongoTransaction + { + private readonly IClientSessionHandle _session = session; - public IMongoDatabase Db { get; } + public Task CommitTransactionAsync() + { + return _session.CommitTransactionAsync(); + } - public MongoDbContext(IOptions options) + public Task AbortTransactionAsync() { - _mongoClient = new MongoClient(options.Value.ConnectionString); - Db = _mongoClient.GetDatabase(options.Value.CombineDatabase); + return _session.AbortTransactionAsync(); } public void Dispose() { - _mongoClient.Dispose(); - GC.SuppressFinalize(this); + _session.Dispose(); } } } diff --git a/Backend/Interfaces/IMongoDbContext.cs b/Backend/Interfaces/IMongoDbContext.cs index afd45d6a66..01827db0db 100644 --- a/Backend/Interfaces/IMongoDbContext.cs +++ b/Backend/Interfaces/IMongoDbContext.cs @@ -1,10 +1,17 @@ using System; +using System.Threading.Tasks; using MongoDB.Driver; -namespace BackendFramework.Interfaces +namespace BackendFramework.Interfaces; + +public interface IMongoDbContext +{ + IMongoDatabase Db { get; } + Task BeginTransaction(); +} + +public interface IMongoTransaction : IDisposable { - public interface IMongoDbContext : IDisposable - { - IMongoDatabase Db { get; } - } + Task CommitTransactionAsync(); + Task AbortTransactionAsync(); } diff --git a/Backend/Startup.cs b/Backend/Startup.cs index 068409c652..b66ace7d15 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -350,7 +350,8 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IHostApp // If an admin user has been created via the command line, treat that as a single action and shut the // server down so the calling script knows it's been completed successfully or unsuccessfully. - var userRepo = app.ApplicationServices.GetService(); + using var startupScope = app.ApplicationServices.CreateAsyncScope(); + var userRepo = startupScope.ServiceProvider.GetService(); if (userRepo is not null && CreateAdminUser(userRepo)) { _logger.LogInformation("Stopping application"); From e177525eb8437d0e733c0fddcb761842b5bf955f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:34:17 -0500 Subject: [PATCH 2/3] Move multi-DB operations from WordService into WordRepository (#4190) --- Backend.Tests/Mocks/WordRepositoryMock.cs | 17 ++++++++++ Backend/Interfaces/IWordRepository.cs | 2 ++ Backend/Repositories/WordRepository.cs | 39 +++++++++++++++++++++++ Backend/Services/WordService.cs | 19 ++--------- 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/Backend.Tests/Mocks/WordRepositoryMock.cs b/Backend.Tests/Mocks/WordRepositoryMock.cs index 11936493ac..56526460fa 100644 --- a/Backend.Tests/Mocks/WordRepositoryMock.cs +++ b/Backend.Tests/Mocks/WordRepositoryMock.cs @@ -163,6 +163,23 @@ public Task Add(Word word) return Task.FromResult(word); } + public Task CreateAndDeleteFrontier(Word newWord, string oldWordId) + { + newWord.Id = Guid.NewGuid().ToString(); + _words.Add(newWord.Clone()); + _frontier.Add(newWord.Clone()); + _frontier.RemoveAll(w => w.ProjectId == newWord.ProjectId && w.Id == oldWordId); + return Task.FromResult(newWord.Clone()); + } + + public Task AddAndDeleteFrontier(Word deletedWord, string wordId) + { + deletedWord.Id = Guid.NewGuid().ToString(); + _words.Add(deletedWord.Clone()); + _frontier.RemoveAll(w => w.ProjectId == deletedWord.ProjectId && w.Id == wordId); + return Task.FromResult(deletedWord.Clone()); + } + public Task CountFrontierWordsWithDomain(string projectId, string domainId) { var count = _frontier.Count( diff --git a/Backend/Interfaces/IWordRepository.cs b/Backend/Interfaces/IWordRepository.cs index 1aabed7bbd..3ee2e78e9b 100644 --- a/Backend/Interfaces/IWordRepository.cs +++ b/Backend/Interfaces/IWordRepository.cs @@ -12,6 +12,8 @@ public interface IWordRepository Task Create(Word word); Task> Create(List words); Task Add(Word word); + Task CreateAndDeleteFrontier(Word newWord, string oldWordId); + Task AddAndDeleteFrontier(Word deletedWord, string wordId); Task DeleteAllWords(string projectId); Task DeleteAllFrontierWords(string projectId); Task HasWords(string projectId); diff --git a/Backend/Repositories/WordRepository.cs b/Backend/Repositories/WordRepository.cs index 1492677079..60d204c124 100644 --- a/Backend/Repositories/WordRepository.cs +++ b/Backend/Repositories/WordRepository.cs @@ -190,6 +190,45 @@ public async Task Add(Word word) return word; } + /// + /// Adds a new to WordsCollection and Frontier, and removes the old word from Frontier. + /// + /// + /// If the Created or Modified time fields are blank, they will be automatically calculated using the current + /// time. This allows services to set or clear the values before creation to control these fields. + /// + /// The new word created. + public async Task CreateAndDeleteFrontier(Word newWord, string oldWordId) + { + using var activity = OtelService.StartActivityWithTag( + otelTagName, "creating word in WordsCollection and Frontier, deleting old word from Frontier"); + + PopulateBlankWordTimes(newWord); + await _words.InsertOneAsync(newWord); + await _frontier.InsertOneAsync(newWord); + await _frontier.FindOneAndDeleteAsync(GetProjectWordFilter(newWord.ProjectId, oldWordId)); + return newWord; + } + + /// + /// Adds a only to the WordsCollection and removes a word from the Frontier. + /// + /// + /// If the Created or Modified time fields are blank, they will be automatically calculated using the current + /// time. This allows services to set or clear the values before creation to control these fields. + /// + /// The word added. + public async Task AddAndDeleteFrontier(Word deletedWord, string wordId) + { + using var activity = OtelService.StartActivityWithTag( + otelTagName, "adding word to WordsCollection, deleting word from Frontier"); + + PopulateBlankWordTimes(deletedWord); + await _words.InsertOneAsync(deletedWord); + await _frontier.FindOneAndDeleteAsync(GetProjectWordFilter(deletedWord.ProjectId, wordId)); + return deletedWord; + } + /// Checks if Words collection for specified has any words. public async Task HasWords(string projectId) { diff --git a/Backend/Services/WordService.cs b/Backend/Services/WordService.cs index 5d55909e29..d8e23e797d 100644 --- a/Backend/Services/WordService.cs +++ b/Backend/Services/WordService.cs @@ -47,13 +47,6 @@ public async Task> Create(string userId, List words) return await _wordRepo.Create(words.Select(w => PrepEditedData(userId, w)).ToList()); } - /// Adds a new word with updated edited data. - /// The added word - private async Task Add(string userId, Word word) - { - return await _wordRepo.Add(PrepEditedData(userId, word)); - } - /// Removes audio with specified fileName from a Frontier word /// Updated word, or null if not found public async Task DeleteAudio(string projectId, string userId, string wordId, string fileName) @@ -85,10 +78,7 @@ private async Task Add(string userId, Word word) word.Accessibility = Status.Deleted; word.History.Add(wordId); - var deletedWord = await Add(userId, word); - - // Don't remove the Frontier word until the copy is successfully stored as deleted. - await _wordRepo.DeleteFrontier(projectId, wordId); + var deletedWord = await _wordRepo.AddAndDeleteFrontier(PrepEditedData(userId, word), wordId); return deletedWord.Id; } @@ -156,12 +146,7 @@ public async Task RestoreFrontierWords(string projectId, List word // only keep UsingCitationForm true if the Vernacular hasn't changed. word.UsingCitationForm &= word.Vernacular == oldWord.Vernacular; - var newWord = await Create(userId, word); - - // Don't remove the old Frontier word until the new word is successfully created. - await _wordRepo.DeleteFrontier(word.ProjectId, oldWordId); - - return newWord; + return await _wordRepo.CreateAndDeleteFrontier(PrepEditedData(userId, word), oldWordId); } /// Checks if a word being added is a duplicate of a preexisting word. From 8cd0c28a262c9791e2f7f66ca4950a619ab6bec7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:11:19 -0500 Subject: [PATCH 3/3] Add MongoDB transactions to WordRepository multi-collection operations (#4191) --- Backend.Tests/Mocks/MongoDbContextMock.cs | 2 + Backend/Contexts/MongoDbContext.cs | 2 + Backend/Interfaces/IMongoDbContext.cs | 1 + Backend/Repositories/WordRepository.cs | 77 +++++++++++++++++++---- 4 files changed, 70 insertions(+), 12 deletions(-) diff --git a/Backend.Tests/Mocks/MongoDbContextMock.cs b/Backend.Tests/Mocks/MongoDbContextMock.cs index cbc3b028a7..1b6f1ec56e 100644 --- a/Backend.Tests/Mocks/MongoDbContextMock.cs +++ b/Backend.Tests/Mocks/MongoDbContextMock.cs @@ -15,6 +15,8 @@ public Task BeginTransaction() private sealed class MongoTransactionMock : IMongoTransaction { + public IClientSessionHandle Session => null!; + public Task CommitTransactionAsync() { return Task.CompletedTask; diff --git a/Backend/Contexts/MongoDbContext.cs b/Backend/Contexts/MongoDbContext.cs index 429396e1b8..4342e234bd 100644 --- a/Backend/Contexts/MongoDbContext.cs +++ b/Backend/Contexts/MongoDbContext.cs @@ -26,6 +26,8 @@ private class MongoTransactionWrapper(IClientSessionHandle session) : IMongoTran { private readonly IClientSessionHandle _session = session; + public IClientSessionHandle Session => _session; + public Task CommitTransactionAsync() { return _session.CommitTransactionAsync(); diff --git a/Backend/Interfaces/IMongoDbContext.cs b/Backend/Interfaces/IMongoDbContext.cs index 01827db0db..f7e57b8e17 100644 --- a/Backend/Interfaces/IMongoDbContext.cs +++ b/Backend/Interfaces/IMongoDbContext.cs @@ -12,6 +12,7 @@ public interface IMongoDbContext public interface IMongoTransaction : IDisposable { + IClientSessionHandle Session { get; } Task CommitTransactionAsync(); Task AbortTransactionAsync(); } diff --git a/Backend/Repositories/WordRepository.cs b/Backend/Repositories/WordRepository.cs index 60d204c124..d4eabcd8b1 100644 --- a/Backend/Repositories/WordRepository.cs +++ b/Backend/Repositories/WordRepository.cs @@ -15,6 +15,7 @@ namespace BackendFramework.Repositories [ExcludeFromCodeCoverage] public class WordRepository(IMongoDbContext dbContext) : IWordRepository { + private readonly IMongoDbContext _dbContext = dbContext; private readonly IMongoCollection _frontier = dbContext.Db.GetCollection("FrontierCollection"); private readonly IMongoCollection _words = dbContext.Db.GetCollection("WordsCollection"); @@ -100,9 +101,19 @@ public async Task DeleteAllWords(string projectId) var filterDef = new FilterDefinitionBuilder(); var filter = filterDef.Eq(x => x.ProjectId, projectId); - var deleted = await _words.DeleteManyAsync(filter); - await _frontier.DeleteManyAsync(filter); - return deleted.DeletedCount != 0; + using var transaction = await _dbContext.BeginTransaction(); + try + { + var deleted = await _words.DeleteManyAsync(transaction.Session, filter); + await _frontier.DeleteManyAsync(transaction.Session, filter); + await transaction.CommitTransactionAsync(); + return deleted.DeletedCount != 0; + } + catch + { + await transaction.AbortTransactionAsync(); + throw; + } } /// Removes all s from the Frontier for specified @@ -146,8 +157,18 @@ public async Task Create(Word word) OtelService.StartActivityWithTag(otelTagName, "creating a word in WordsCollection and Frontier"); PopulateBlankWordTimes(word); - await _words.InsertOneAsync(word); - await AddFrontier(word); + using var transaction = await _dbContext.BeginTransaction(); + try + { + await _words.InsertOneAsync(transaction.Session, word); + await _frontier.InsertOneAsync(transaction.Session, word); + await transaction.CommitTransactionAsync(); + } + catch + { + await transaction.AbortTransactionAsync(); + throw; + } return word; } @@ -170,8 +191,18 @@ public async Task> Create(List words) { PopulateBlankWordTimes(w); } - await _words.InsertManyAsync(words); - await AddFrontier(words); + using var transaction = await _dbContext.BeginTransaction(); + try + { + await _words.InsertManyAsync(transaction.Session, words); + await _frontier.InsertManyAsync(transaction.Session, words); + await transaction.CommitTransactionAsync(); + } + catch + { + await transaction.AbortTransactionAsync(); + throw; + } return words; } @@ -204,9 +235,20 @@ public async Task CreateAndDeleteFrontier(Word newWord, string oldWordId) otelTagName, "creating word in WordsCollection and Frontier, deleting old word from Frontier"); PopulateBlankWordTimes(newWord); - await _words.InsertOneAsync(newWord); - await _frontier.InsertOneAsync(newWord); - await _frontier.FindOneAndDeleteAsync(GetProjectWordFilter(newWord.ProjectId, oldWordId)); + using var transaction = await _dbContext.BeginTransaction(); + try + { + await _words.InsertOneAsync(transaction.Session, newWord); + await _frontier.InsertOneAsync(transaction.Session, newWord); + await _frontier.FindOneAndDeleteAsync( + transaction.Session, GetProjectWordFilter(newWord.ProjectId, oldWordId)); + await transaction.CommitTransactionAsync(); + } + catch + { + await transaction.AbortTransactionAsync(); + throw; + } return newWord; } @@ -224,8 +266,19 @@ public async Task AddAndDeleteFrontier(Word deletedWord, string wordId) otelTagName, "adding word to WordsCollection, deleting word from Frontier"); PopulateBlankWordTimes(deletedWord); - await _words.InsertOneAsync(deletedWord); - await _frontier.FindOneAndDeleteAsync(GetProjectWordFilter(deletedWord.ProjectId, wordId)); + using var transaction = await _dbContext.BeginTransaction(); + try + { + await _words.InsertOneAsync(transaction.Session, deletedWord); + await _frontier.FindOneAndDeleteAsync( + transaction.Session, GetProjectWordFilter(deletedWord.ProjectId, wordId)); + await transaction.CommitTransactionAsync(); + } + catch + { + await transaction.AbortTransactionAsync(); + throw; + } return deletedWord; }