Initial commit

This commit is contained in:
zavolo
2026-06-05 00:53:35 +03:00
commit e59e4424a4
7686 changed files with 352420 additions and 0 deletions
@@ -0,0 +1,26 @@
using Microsoft.Extensions.Configuration;
using MongoDB.Driver;
namespace MyTelegram.EventFlow.MongoDB;
public class
DefaultEventStoreMongoDbContext(IConfiguration configuration) : IMongoDbContext
{
private IMongoDatabase? _database;
public IMongoDatabase GetDatabase()
{
if (_database == null)
{
var connectionString = configuration.GetConnectionString(GetConnectionStringName());
var databaseName = configuration.GetValue<string>(GetKeyOfDatabaseNameInConfiguration());
var client = new MongoClient(connectionString);
_database = client.GetDatabase(databaseName);
}
return _database;
}
protected virtual string GetConnectionStringName() => "Default";
protected virtual string GetKeyOfDatabaseNameInConfiguration() => "App:DatabaseName";
}
@@ -0,0 +1,33 @@
using Microsoft.Extensions.Configuration;
using MongoDB.Driver;
namespace MyTelegram.EventFlow.MongoDB;
public class
DefaultReadModelMongoDbContext(IConfiguration configuration) : IMongoDbContext
{
private IMongoDatabase? _database;
public IMongoDatabase GetDatabase()
{
if (_database == null)
{
var connectionString = configuration.GetConnectionString(GetConnectionStringName());
var databaseName = configuration.GetValue<string>(GetKeyOfDatabaseNameInConfiguration());
// Configure MongoClient with proper read settings to avoid stale data
var settings = MongoClientSettings.FromConnectionString(connectionString);
settings.ReadConcern = ReadConcern.Local; // Read latest data without waiting for replication
settings.ReadPreference = ReadPreference.Primary; // Always read from primary node
settings.RetryReads = true; // Retry failed reads
var client = new MongoClient(settings);
_database = client.GetDatabase(databaseName);
}
return _database;
}
protected virtual string GetConnectionStringName() => "Default";
protected virtual string GetKeyOfDatabaseNameInConfiguration() => "App:ReadModelDatabaseName";
}
@@ -0,0 +1,159 @@
using EventFlow;
using EventFlow.Aggregates;
using EventFlow.Core;
using EventFlow.Extensions;
using EventFlow.MongoDB.EventStore;
using EventFlow.MongoDB.ReadStores;
using EventFlow.ReadStores;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using MongoDB.Driver;
using MyTelegram.EventFlow.MongoDB.ReadStores;
using MyTelegram.EventFlow.ReadStores;
namespace MyTelegram.EventFlow.MongoDB.Extensions;
public static class MyMongoDbOptionsExtensions
{
public static void AddEventStoreMongoDbContext<TMongoDbContext>(this IServiceCollection services) where TMongoDbContext : class, IMongoDbContext
{
BsonSerializer.TryRegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
services.TryAddSingleton<TMongoDbContext>();
services.TryAddSingleton<IMongoDatabase>(f => f.GetRequiredService<TMongoDbContext>().GetDatabase());
}
public static void AddEventStoreMongoDbContext(this IServiceCollection services)
{
services.AddEventStoreMongoDbContext<DefaultEventStoreMongoDbContext>();
}
public static void AddReadModelMongoDbContext<TMongoDbContext>(this IServiceCollection services)
where TMongoDbContext : class, IMongoDbContext
{
BsonSerializer.TryRegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
services.TryAddSingleton<TMongoDbContext>();
services.TryAddSingleton<IMongoDbContext, TMongoDbContext>();
services.TryAddSingleton<IQueryOnlyReadModelDescriptionProvider, QueryOnlyReadModelDescriptionProvider>();
services.TryAddSingleton<IReadModelDescriptionProvider, ReadModelDescriptionProvider>();
services.TryAddSingleton<IMongoDbEventSequenceStore, MongoDbEventSequenceStore>();
}
public static void AddReadModelMongoDbContext(this IServiceCollection services)
{
services.AddReadModelMongoDbContext<DefaultReadModelMongoDbContext>();
}
public static IEventFlowOptions UseMongoDbReadModel<TAggregate, TIdentity, TReadModel>(
this IEventFlowOptions eventFlowOptions)
where TReadModel : class, IMongoDbReadModel
where TIdentity : IIdentity
where TAggregate : IAggregateRoot<TIdentity>
{
eventFlowOptions.ServiceCollection
//.AddTransient<IMongoDbReadModelStore<TReadModel>, MyMongoDbReadModelStore<TReadModel>>()
.AddTransient<IMyMongoDbReadModelStore<TReadModel>, MyMongoDbReadModelStore<TReadModel>>()
.AddTransient<IQueryOnlyReadModelStore<TReadModel>, MongoDbQueryOnlyReadModelStore<TReadModel>>()
;
eventFlowOptions.ServiceCollection.AddTransient<IReadModelStore<TReadModel>>(f =>
f.GetRequiredService<IMyMongoDbReadModelStore<TReadModel>>());
#pragma warning disable CS0618
eventFlowOptions.UseReadStoreFor<TAggregate, TIdentity, IMyMongoDbReadModelStore<TReadModel>, TReadModel>();
#pragma warning restore CS0618
return eventFlowOptions;
}
public static IEventFlowOptions UseMongoDbReadModel<TReadModel, TReadModelLocator>(
this IEventFlowOptions eventFlowOptions)
where TReadModel : class, IMongoDbReadModel
where TReadModelLocator : IReadModelLocator
{
eventFlowOptions.ServiceCollection
//.AddTransient<IMongoDbReadModelStore<TReadModel>, MyMongoDbReadModelStore<TReadModel>>()
.AddTransient<IMyMongoDbReadModelStore<TReadModel>, MyMongoDbReadModelStore<TReadModel>>()
.AddTransient<IQueryOnlyReadModelStore<TReadModel>, MongoDbQueryOnlyReadModelStore<TReadModel>>()
;
eventFlowOptions.ServiceCollection.AddTransient<IReadModelStore<TReadModel>>(f =>
f.GetRequiredService<IMyMongoDbReadModelStore<TReadModel>>());
eventFlowOptions.UseReadStoreFor<IMyMongoDbReadModelStore<TReadModel>, TReadModel, TReadModelLocator>();
return eventFlowOptions;
}
private static void AddMongoDbStoreServices<TReadModel, TDbContext>(this IServiceCollection services)
where TReadModel : class, IQueryOnlyReadModel
where TDbContext : IMongoDbContext
{
//services.AddTransient<IMongoDbReadModelStore<TReadModel>, MyMongoDbReadModelStore<TReadModel, TDbContext>>()
services
.AddTransient<IQueryOnlyReadModelStore<TReadModel>, MongoDbQueryOnlyReadModelStore<TReadModel, TDbContext>>();
}
public static IEventFlowOptions UseMongoDbReadModel<TAggregate, TIdentity, TReadModel, TDbContext>(
this IEventFlowOptions eventFlowOptions)
where TReadModel : class, IMongoDbReadModel
where TIdentity : IIdentity
where TAggregate : IAggregateRoot<TIdentity>
where TDbContext : class, IMongoDbContext
{
eventFlowOptions.ServiceCollection
.AddTransient<IMongoDbReadModelStore<TReadModel>, MyMongoDbReadModelStore<TReadModel, TDbContext>>()
.AddTransient<IMyMongoDbReadModelStore<TReadModel>,
MyMongoDbReadModelStore<TReadModel, TDbContext>>()
.AddTransient<IMyMongoDbReadModelStore<TReadModel>, MyMongoDbReadModelStore<TReadModel, TDbContext>>()
.AddTransient<IQueryOnlyReadModelStore<TReadModel>, MongoDbQueryOnlyReadModelStore<TReadModel, TDbContext>>()
;
eventFlowOptions.ServiceCollection.AddTransient<IReadModelStore<TReadModel>>(f =>
f.GetRequiredService<IMyMongoDbReadModelStore<TReadModel>>());
//eventFlowOptions.UseReadStoreFor<IMongoDbReadModelStore<TReadModel>, TReadModel>();
#pragma warning disable CS0618
eventFlowOptions
.UseReadStoreFor<TAggregate, TIdentity, IMongoDbReadModelStore<TReadModel>, TReadModel>();
#pragma warning restore CS0618
return eventFlowOptions;
}
public static IEventFlowOptions UseMongoDbReadModelWithContext<TReadModel, TMongoDbContext>(
this IEventFlowOptions eventFlowOptions)
where TReadModel : class, IMongoDbReadModel
where TMongoDbContext : class, IMongoDbContext
{
eventFlowOptions.ServiceCollection
.AddTransient<IMongoDbReadModelStore<TReadModel>, MyMongoDbReadModelStore<TReadModel>>()
.AddTransient<IMyMongoDbReadModelStore<TReadModel>,
MyMongoDbReadModelStore<TReadModel, TMongoDbContext>>()
.AddTransient<IReadModelStore<TReadModel>>(f =>
f.GetRequiredService<IMyMongoDbReadModelStore<TReadModel>>())
;
eventFlowOptions.UseReadStoreFor<IMongoDbReadModelStore<TReadModel>, TReadModel>();
return eventFlowOptions;
}
public static IEventFlowOptions UseMongoDbReadModelWithContext<TReadModel, TReadModelLocator, TMongoDbContext>(
this IEventFlowOptions eventFlowOptions)
where TReadModel : class, IMongoDbReadModel
where TMongoDbContext : class, IMongoDbContext
where TReadModelLocator : IReadModelLocator
{
eventFlowOptions.ServiceCollection
.AddTransient<IMongoDbReadModelStore<TReadModel>, MyMongoDbReadModelStore<TReadModel>>()
.AddTransient<IMyMongoDbReadModelStore<TReadModel>,
MyMongoDbReadModelStore<TReadModel, TMongoDbContext>>()
.AddTransient<IReadModelStore<TReadModel>>(f =>
f.GetRequiredService<IMyMongoDbReadModelStore<TReadModel>>())
;
eventFlowOptions.UseReadStoreFor<IMongoDbReadModelStore<TReadModel>, TReadModel, TReadModelLocator>();
return eventFlowOptions;
}
}
@@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait />
</Weavers>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
<xs:element name="Weavers">
<xs:complexType>
<xs:all>
<xs:element name="ConfigureAwait" minOccurs="0" maxOccurs="1">
<xs:complexType>
<xs:attribute name="ContinueOnCapturedContext" type="xs:boolean" />
</xs:complexType>
</xs:element>
</xs:all>
<xs:attribute name="VerifyAssembly" type="xs:boolean">
<xs:annotation>
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="VerifyIgnoreCodes" type="xs:string">
<xs:annotation>
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="GenerateXsd" type="xs:boolean">
<xs:annotation>
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:schema>
@@ -0,0 +1,8 @@
using MongoDB.Driver;
namespace MyTelegram.EventFlow.MongoDB;
public interface IMongoDbContext
{
IMongoDatabase GetDatabase();
}
@@ -0,0 +1,6 @@
namespace MyTelegram.EventFlow.MongoDB;
public interface IMongoDbIndexesCreator
{
Task CreateAllIndexesAsync();
}
@@ -0,0 +1,23 @@
using System.Linq.Expressions;
using EventFlow.MongoDB.ReadStores;
using EventFlow.ReadStores;
using MongoDB.Driver;
namespace MyTelegram.EventFlow.MongoDB;
public interface IMyMongoDbReadModelStore<TReadModel> : IMongoDbReadModelStore<TReadModel> where TReadModel : class, IReadModel
{
Task<IAsyncCursor<TResult>> FindAsync<TResult>(
Expression<Func<TReadModel, bool>> filter,
FindOptions<TReadModel, TResult>? options = null,
CancellationToken cancellationToken = default);
Task<long> CountAsync(Expression<Func<TReadModel, bool>>? filter = null, CancellationToken cancellationToken = default);
Task<IAggregateFluent<TResult>> AggregateAsync<TResult, TKey>(
Expression<Func<TReadModel, bool>> filter,
Expression<Func<TReadModel, TKey>> id,
Expression<Func<IGrouping<TKey, TReadModel>, TResult>> group,
AggregateOptions? options = null,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,8 @@
using MongoDB.Driver;
namespace MyTelegram.EventFlow.MongoDB;
public class MongoDbContext(IMongoDatabase mongoDatabase) : IMongoDbContext
{
public IMongoDatabase GetDatabase() => mongoDatabase;
}
@@ -0,0 +1,44 @@
using EventFlow.MongoDB.EventStore;
using EventFlow.MongoDB.ReadStores;
using EventFlow.MongoDB.ValueObjects;
using MongoDB.Driver;
using System.Linq.Expressions;
namespace MyTelegram.EventFlow.MongoDB;
public abstract class MongoDbIndexesCreatorBase(
IMongoDatabase database,
IReadModelDescriptionProvider descriptionProvider,
IMongoDbEventPersistenceInitializer eventPersistenceInitializer)
: IMongoDbIndexesCreator
{
public async Task CreateAllIndexesAsync()
{
eventPersistenceInitializer.Initialize();
var snapShotCollectionName = "snapShots";
await CreateIndexAsync<MongoDbSnapshotDataModel>(p => p.AggregateId, snapShotCollectionName);
await CreateIndexAsync<MongoDbSnapshotDataModel>(p => p.AggregateName, snapShotCollectionName);
await CreateIndexAsync<MongoDbSnapshotDataModel>(p => p.AggregateSequenceNumber, snapShotCollectionName);
await CreateAllIndexesCoreAsync();
}
protected abstract Task CreateAllIndexesCoreAsync();
protected async Task CreateIndexAsync<TReadModel>(Expression<Func<TReadModel, object>> field)
where TReadModel : IMongoDbReadModel
{
var indexDefine = Builders<TReadModel>.IndexKeys.Ascending(field);
var collectionName = descriptionProvider.GetReadModelDescription<TReadModel>().RootCollectionName;
await database.GetCollection<TReadModel>(collectionName.Value).Indexes
.CreateOneAsync(new CreateIndexModel<TReadModel>(indexDefine));
}
protected async Task CreateIndexAsync<TSnapshot>(Expression<Func<TSnapshot, object>> field,
string collectionName)
{
var indexDefine = Builders<TSnapshot>.IndexKeys.Ascending(field);
await database.GetCollection<TSnapshot>(collectionName).Indexes
.CreateOneAsync(new CreateIndexModel<TSnapshot>(indexDefine));
}
}
@@ -0,0 +1,337 @@
using EventFlow.Aggregates;
using EventFlow.Core;
using EventFlow.Core.RetryStrategies;
using EventFlow.Extensions;
using EventFlow.MongoDB.ReadStores;
using EventFlow.ReadStores;
using EventFlow.Exceptions;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using System.Linq.Expressions;
namespace MyTelegram.EventFlow.MongoDB;
public class MyMongoDbReadModelStore<TReadModel>(
ILogger<MongoDbReadModelStore<TReadModel>> logger,
IReadModelDescriptionProvider readModelDescriptionProvider,
ITransientFaultHandler<IOptimisticConcurrencyRetryStrategy> transientFaultHandler,
IMongoDbContext mongoDbContext)
:
MyMongoDbReadModelStore<TReadModel, IMongoDbContext>(logger, readModelDescriptionProvider,
transientFaultHandler, mongoDbContext)
where TReadModel : class, IMongoDbReadModel;
public class MyMongoDbReadModelStore<TReadModel, TDbContext> : IMyMongoDbReadModelStore<TReadModel>
where TReadModel : class, IMongoDbReadModel
where TDbContext : IMongoDbContext
{
private readonly ILogger<MongoDbReadModelStore<TReadModel>> _logger;
private readonly IReadModelDescriptionProvider _readModelDescriptionProvider;
private readonly ITransientFaultHandler<IOptimisticConcurrencyRetryStrategy> _transientFaultHandler;
private readonly TDbContext _dbContext;
private readonly IMongoDatabase _database;
public MyMongoDbReadModelStore(
ILogger<MongoDbReadModelStore<TReadModel>> logger,
IReadModelDescriptionProvider readModelDescriptionProvider,
ITransientFaultHandler<IOptimisticConcurrencyRetryStrategy> transientFaultHandler,
TDbContext dbContext)
{
_logger = logger;
_readModelDescriptionProvider = readModelDescriptionProvider;
_transientFaultHandler = transientFaultHandler;
_dbContext = dbContext;
_database = dbContext.GetDatabase();
}
private IMongoDatabase GetDatabase() => _database;
public async Task UpdateAsync(
IReadOnlyCollection<ReadModelUpdate> readModelUpdates,
IReadModelContextFactory readModelContextFactory,
Func<IReadModelContext, IReadOnlyCollection<IDomainEvent>, ReadModelEnvelope<TReadModel>, CancellationToken, Task<ReadModelUpdateResult<TReadModel>>> updateReadModel,
CancellationToken cancellationToken)
{
_logger.LogTrace(
"UpdateAsync called with {Count} read model updates for type '{ReadModel}'",
readModelUpdates.Count,
typeof(TReadModel).PrettyPrint());
foreach (var readModelUpdate in readModelUpdates)
{
_logger.LogTrace(
"Processing read model update for '{ReadModelId}' with {EventCount} domain events",
readModelUpdate.ReadModelId,
readModelUpdate.DomainEvents.Count);
// Retry logic with reload - each retry will fetch fresh data from MongoDB
await _transientFaultHandler.TryAsync(
async c =>
{
await UpdateReadModelAsync(
readModelUpdate.ReadModelId,
readModelUpdate,
readModelContextFactory,
updateReadModel,
c);
return 0; // Success
},
Label.Named("mongodb-read-model-update"),
cancellationToken)
.ConfigureAwait(false);
}
}
private async Task UpdateReadModelAsync(
string readModelId,
ReadModelUpdate readModelUpdate,
IReadModelContextFactory readModelContextFactory,
Func<IReadModelContext, IReadOnlyCollection<IDomainEvent>, ReadModelEnvelope<TReadModel>, CancellationToken, Task<ReadModelUpdateResult<TReadModel>>> updateReadModel,
CancellationToken cancellationToken)
{
var readModelDescription = _readModelDescriptionProvider.GetReadModelDescription<TReadModel>();
var collection = GetDatabase().GetCollection<TReadModel>(readModelDescription.RootCollectionName.Value);
try
{
// Try to get existing read model - ALWAYS reload from database to get fresh version
var filter = Builders<TReadModel>.Filter.Eq("_id", readModelId);
var existingReadModel = await collection.Find(filter).FirstOrDefaultAsync(cancellationToken);
_logger.LogTrace(
"Loaded read model '{ReadModelId}' from MongoDB - Exists: {Exists}, Version: {Version}",
readModelId,
existingReadModel != null,
existingReadModel?.Version);
ReadModelEnvelope<TReadModel> readModelEnvelope;
long? originalVersion = existingReadModel?.Version; // Save original version BEFORE modification
if (existingReadModel == null)
{
// Create new read model
_logger.LogTrace(
"Creating new read model '{ReadModelId}' of type '{ReadModel}'",
readModelId,
typeof(TReadModel).PrettyPrint());
readModelEnvelope = ReadModelEnvelope<TReadModel>.With(readModelId, default(TReadModel));
}
else
{
// Update existing read model
_logger.LogTrace(
"Updating existing read model '{ReadModelId}' of type '{ReadModel}' with version '{Version}'",
readModelId,
typeof(TReadModel).PrettyPrint(),
existingReadModel.Version);
readModelEnvelope = ReadModelEnvelope<TReadModel>.With(readModelId, existingReadModel);
}
_logger.LogTrace(
"Before applying events - ReadModel '{ReadModelId}' envelope Version: '{EnvelopeVersion}', IsNew: {IsNew}",
readModelId,
readModelEnvelope.ReadModel?.Version,
existingReadModel == null);
var readModelContext = readModelContextFactory.Create(readModelId, isNew: existingReadModel == null);
var result = await updateReadModel(readModelContext, readModelUpdate.DomainEvents, readModelEnvelope, cancellationToken);
if (!result.IsModified)
{
_logger.LogTrace(
"Read model '{ReadModelId}' was not modified",
readModelId);
return;
}
_logger.LogTrace(
"After applying {EventCount} events: ReadModel '{ReadModelId}' new version will be '{NewVersion}', was '{OldVersion}'",
readModelUpdate.DomainEvents.Count,
readModelId,
result.Envelope.ReadModel?.Version,
readModelEnvelope.ReadModel?.Version);
// Save the updated read model
if (existingReadModel == null)
{
// Insert new document
try
{
await collection.InsertOneAsync(result.Envelope.ReadModel, cancellationToken: cancellationToken);
_logger.LogTrace(
"Inserted new read model '{ReadModelId}' with version '{Version}'",
readModelId,
result.Envelope.ReadModel.Version);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
// Another process inserted it concurrently, throw exception to retry with reload
_logger.LogWarning(
ex,
"Duplicate key error when inserting read model '{ReadModelId}'. Another process created it concurrently. Throwing OptimisticConcurrencyException for retry.",
readModelId);
// Add a small delay to reduce contention on retry
await Task.Delay(50, cancellationToken);
throw new OptimisticConcurrencyException($"Read model '{readModelId}' created by another process", ex);
}
}
else
{
// Update existing document with optimistic concurrency check
_logger.LogTrace(
"Preparing to update - originalVersion: {OriginalVersion}, existingReadModel.Version (after Apply): {ExistingVersion}, result.Envelope.ReadModel.Version: {ResultVersion}, Are they same object: {SameObject}",
originalVersion,
existingReadModel.Version,
result.Envelope.ReadModel.Version,
ReferenceEquals(existingReadModel, result.Envelope.ReadModel));
// В фильтр берём originalVersion (зафиксированную до вызова Apply), а не existingReadModel.Version, которую Apply уже изменил
var updateFilter = Builders<TReadModel>.Filter.And(
Builders<TReadModel>.Filter.Eq("_id", readModelId),
Builders<TReadModel>.Filter.Eq(r => r.Version, originalVersion));
var replaceResult = await collection.ReplaceOneAsync(
updateFilter,
result.Envelope.ReadModel,
new ReplaceOptions { IsUpsert = false },
cancellationToken);
if (replaceResult.MatchedCount == 0)
{
// Document was modified by another process - reload and retry
var currentInDb = await collection.Find(Builders<TReadModel>.Filter.Eq("_id", readModelId)).FirstOrDefaultAsync(cancellationToken);
_logger.LogWarning(
"Optimistic concurrency conflict for read model '{ReadModelId}'. We loaded version '{LoadedVersion}' and tried to save version '{NewVersion}', but database now has version '{CurrentVersion}'. Throwing OptimisticConcurrencyException to reload and retry. Events count: {EventsCount}",
readModelId,
originalVersion, // Use the original version we tried to match
result.Envelope.ReadModel.Version,
currentInDb?.Version,
readModelUpdate.DomainEvents.Count);
// Add a small delay to reduce contention on retry
await Task.Delay(50, cancellationToken);
throw new OptimisticConcurrencyException($"Read model '{readModelId}' updated by another process");
}
_logger.LogTrace(
"Updated read model '{ReadModelId}' from version '{OldVersion}' to '{NewVersion}'",
readModelId,
originalVersion,
result.Envelope.ReadModel.Version);
}
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
_logger.LogWarning(
ex,
"Duplicate key error for read model '{ReadModelId}'. Another process likely modified it concurrently. Throwing OptimisticConcurrencyException for retry.",
readModelId);
throw new OptimisticConcurrencyException($"Read model '{readModelId}' updated by another process", ex);
}
}
public Task DeleteAsync(string id, CancellationToken cancellationToken)
{
var readModelDescription = _readModelDescriptionProvider.GetReadModelDescription<TReadModel>();
var collection = GetDatabase().GetCollection<TReadModel>(readModelDescription.RootCollectionName.Value);
var filter = Builders<TReadModel>.Filter.Eq("_id", id);
return collection.DeleteOneAsync(filter, cancellationToken);
}
public async Task<ReadModelEnvelope<TReadModel>> GetAsync(string id, CancellationToken cancellationToken)
{
var readModelDescription = _readModelDescriptionProvider.GetReadModelDescription<TReadModel>();
var collection = GetDatabase().GetCollection<TReadModel>(readModelDescription.RootCollectionName.Value);
var filter = Builders<TReadModel>.Filter.Eq("_id", id);
var readModel = await collection.Find(filter).FirstOrDefaultAsync(cancellationToken);
return readModel == null
? ReadModelEnvelope<TReadModel>.Empty(id)
: ReadModelEnvelope<TReadModel>.With(id, readModel);
}
public Task<IAggregateFluent<TResult>> AggregateAsync<TResult, TKey>(
Expression<Func<TReadModel, bool>> filter,
Expression<Func<TReadModel, TKey>> id,
Expression<Func<IGrouping<TKey, TReadModel>, TResult>> group,
AggregateOptions? options = null,
CancellationToken cancellationToken = default)
{
var readModelDescription = _readModelDescriptionProvider.GetReadModelDescription<TReadModel>();
var collection = GetDatabase().GetCollection<TReadModel>(readModelDescription.RootCollectionName.Value);
return Task.FromResult(collection.Aggregate()
.Match(filter)
.Group(id, group))
;
}
public async Task<IAsyncCursor<TResult>> FindAsync<TResult>(Expression<Func<TReadModel, bool>> filter, FindOptions<TReadModel, TResult>? options = null, CancellationToken cancellationToken = default)
{
var readModelDescription = _readModelDescriptionProvider.GetReadModelDescription<TReadModel>();
var collection = GetDatabase().GetCollection<TReadModel>(readModelDescription.RootCollectionName.Value);
_logger.LogTrace(
"Finding read model '{ReadModel}' with expression '{Filter}' from collection '{RootCollectionName}'",
typeof(TReadModel).PrettyPrint(),
filter,
readModelDescription.RootCollectionName);
return await collection.FindAsync(filter, options, cancellationToken);
}
public Task<long> CountAsync(Expression<Func<TReadModel, bool>>? filter = null, CancellationToken cancellationToken = default)
{
var readModelDescription = _readModelDescriptionProvider.GetReadModelDescription<TReadModel>();
var collection = GetDatabase().GetCollection<TReadModel>(readModelDescription.RootCollectionName.Value);
return collection.CountDocumentsAsync(filter, cancellationToken: cancellationToken);
}
public async Task<IAsyncCursor<TReadModel>> FindAsync(
Expression<Func<TReadModel, bool>> filter,
FindOptions<TReadModel, TReadModel>? options = null,
CancellationToken cancellationToken = default)
{
var readModelDescription = _readModelDescriptionProvider.GetReadModelDescription<TReadModel>();
var collection = GetDatabase().GetCollection<TReadModel>(readModelDescription.RootCollectionName.Value);
_logger.LogTrace(
"Finding read model '{ReadModel}' with expression '{Filter}' from collection '{RootCollectionName}'",
typeof(TReadModel).PrettyPrint(),
filter,
readModelDescription.RootCollectionName);
return await collection.FindAsync(filter, options, cancellationToken);
}
public IQueryable<TReadModel> AsQueryable()
{
var readModelDescription = _readModelDescriptionProvider.GetReadModelDescription<TReadModel>();
var collection = GetDatabase().GetCollection<TReadModel>(readModelDescription.RootCollectionName.Value);
return collection.AsQueryable();
}
public Task DeleteAllAsync(CancellationToken cancellationToken)
{
var readModelDescription = _readModelDescriptionProvider.GetReadModelDescription<TReadModel>();
var collection = GetDatabase().GetCollection<TReadModel>(readModelDescription.RootCollectionName.Value);
_logger.LogWarning(
"Deleting all documents from collection '{CollectionName}'",
readModelDescription.RootCollectionName);
return collection.DeleteManyAsync(Builders<TReadModel>.Filter.Empty, cancellationToken);
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<None Include="FodyWeavers.xsd" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="EventFlow.MongoDB" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<ProjectReference Include="..\MyTelegram.EventFlow\MyTelegram.EventFlow.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Fody">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,10 @@
using EventFlow.MongoDB.ValueObjects;
using EventFlow.ReadStores;
namespace MyTelegram.EventFlow.MongoDB.ReadStores;
public interface IQueryOnlyReadModelDescriptionProvider
{
ReadModelDescription GetReadModelDescription<TReadModel>()
where TReadModel : IReadModel;
}
@@ -0,0 +1,130 @@
using EventFlow.Extensions;
using EventFlow.ReadStores;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using MyTelegram.EventFlow.ReadStores;
using System.Linq.Expressions;
namespace MyTelegram.EventFlow.MongoDB.ReadStores;
public class MongoDbQueryOnlyReadModelStore<TReadModel>(
IQueryOnlyReadModelDescriptionProvider readModelDescriptionProvider,
IMongoDbContext dbContext,
ILogger<MongoDbQueryOnlyReadModelStore<TReadModel, IMongoDbContext>> logger)
: MongoDbQueryOnlyReadModelStore<TReadModel, IMongoDbContext>(readModelDescriptionProvider, dbContext,
logger)
where TReadModel : class, IReadModel;
public class MongoDbQueryOnlyReadModelStore<TReadModel, TDbContext>(
IQueryOnlyReadModelDescriptionProvider readModelDescriptionProvider,
TDbContext dbContext,
ILogger<MongoDbQueryOnlyReadModelStore<TReadModel, TDbContext>> logger)
: IQueryOnlyReadModelStore<TReadModel>
where TReadModel : class, IReadModel
where TDbContext : IMongoDbContext
{
public IQueryable<TReadModel> GetAll()
{
var readModelDescription = readModelDescriptionProvider.GetReadModelDescription<TReadModel>();
var collection = GetDatabase().GetCollection<TReadModel>(readModelDescription.RootCollectionName.Value);
return collection.AsQueryable();
}
public Task<IReadOnlyCollection<TReadModel>> FindAsync(Expression<Func<TReadModel, bool>> filter, int skip = 0,
int limit = 0,
SortOptions<TReadModel>? sort = null,
CancellationToken cancellationToken = default)
{
return FindAsync(filter, p => p, skip, limit, sort, cancellationToken);
}
public async Task<IReadOnlyCollection<TResult>> FindAsync<TResult>(Expression<Func<TReadModel, bool>> filter,
Expression<Func<TReadModel, TResult>> createResult, int skip = 0,
int limit = 0, SortOptions<TReadModel>? sort = null, CancellationToken cancellationToken = default)
{
var findOptions = CreateFindOptions(createResult, skip, limit, sort);
var cursor = await FindCoreAsync(filter, findOptions, cancellationToken);
return await cursor.ToListAsync(cancellationToken);
}
public async Task<TReadModel?> FirstOrDefaultAsync(Expression<Func<TReadModel, bool>> filter,
CancellationToken cancellationToken = default)
{
var cursor = await FindCoreAsync<TReadModel>(filter, cancellationToken: cancellationToken);
return await cursor.FirstOrDefaultAsync(cancellationToken);
}
public async Task<TResult?> FirstOrDefaultAsync<TResult>(Expression<Func<TReadModel, bool>> filter,
Expression<Func<TReadModel, TResult>> createResult,
SortOptions<TReadModel>? sort = null,
CancellationToken cancellationToken = default)
{
var findOptions = CreateFindOptions(createResult, 0, 0, sort);
var cursor = await FindCoreAsync(filter, findOptions, cancellationToken);
return await cursor.FirstOrDefaultAsync(cancellationToken);
}
public Task<long> CountAsync(Expression<Func<TReadModel, bool>> filter,
CancellationToken cancellationToken = default)
{
var readModelDescription = readModelDescriptionProvider.GetReadModelDescription<TReadModel>();
var collection = GetDatabase().GetCollection<TReadModel>(readModelDescription.RootCollectionName.Value);
return collection.CountDocumentsAsync(filter, cancellationToken: cancellationToken);
}
private static FindOptions<T1, T2> CreateFindOptions<T1, T2>(
Expression<Func<T1, T2>> createResult,
int skip = 0,
int limit = 0, SortOptions<T1>? sort = null)
{
var findOptions = new FindOptions<T1, T2>
{
Projection = Builders<T1>.Projection.Expression(createResult)
};
if (skip > 0)
{
findOptions.Skip = skip;
}
if (limit > 0)
{
findOptions.Limit = limit;
}
if (sort != null)
{
findOptions.Sort = sort.SortType switch
{
SortType.None or SortType.Ascending => Builders<T1>.Sort.Ascending(sort.Sort),
SortType.Descending => Builders<T1>.Sort.Descending(sort.Sort),
_ => throw new ArgumentOutOfRangeException()
};
}
return findOptions;
}
private async Task<IAsyncCursor<TResult>> FindCoreAsync<TResult>(Expression<Func<TReadModel, bool>> filter,
FindOptions<TReadModel, TResult>? options = null, CancellationToken cancellationToken = default)
{
var readModelDescription = readModelDescriptionProvider.GetReadModelDescription<TReadModel>();
var collection = GetDatabase().GetCollection<TReadModel>(readModelDescription.RootCollectionName.Value);
logger.LogTrace(
"Finding read model '{ReadModel}' with expression '{Filter}' from collection '{RootCollectionName}'",
typeof(TReadModel).PrettyPrint(),
filter,
readModelDescription.RootCollectionName);
return await collection.FindAsync(filter, options, cancellationToken);
}
private IMongoDatabase GetDatabase()
{
return dbContext.GetDatabase();
}
}
@@ -0,0 +1,33 @@
using System.Collections.Concurrent;
using System.Reflection;
using EventFlow.Extensions;
using EventFlow.MongoDB.ReadStores.Attributes;
using EventFlow.MongoDB.ValueObjects;
using EventFlow.ReadStores;
namespace MyTelegram.EventFlow.MongoDB.ReadStores;
public class QueryOnlyReadModelDescriptionProvider : IQueryOnlyReadModelDescriptionProvider
{
private static readonly ConcurrentDictionary<Type, ReadModelDescription> CollectionNames = new();
public ReadModelDescription GetReadModelDescription<TReadModel>() where TReadModel : IReadModel
{
var name = typeof(TReadModel).PrettyPrint().ToLowerInvariant();
if (typeof(TReadModel).IsInterface && name.StartsWith("i"))
{
name = name[1..];
}
return CollectionNames.GetOrAdd(
typeof(TReadModel),
t =>
{
var collectionType = t.GetTypeInfo().GetCustomAttribute<MongoDbCollectionNameAttribute>();
var indexName = collectionType == null
? $"eventflow-{name}"
: collectionType.CollectionName;
return new ReadModelDescription(new RootCollectionName(indexName));
});
}
}