diff --git a/Directory.Packages.props b/Directory.Packages.props index 40c0ce4c44..f0d7468fa5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,13 +13,14 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/CoreTests/document_store_usage_tests.cs b/src/CoreTests/document_store_usage_tests.cs new file mode 100644 index 0000000000..4157bc0894 --- /dev/null +++ b/src/CoreTests/document_store_usage_tests.cs @@ -0,0 +1,202 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JasperFx; +using JasperFx.Descriptors; +using JasperFx.Events; +using Marten; +using Marten.Testing.Documents; +using Marten.Testing.Harness; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Xunit; + +namespace CoreTests; + +/// +/// Coverage for IDocumentStoreUsageSource.TryCreateUsage on Marten's +/// . Verifies the descriptor population pass — +/// first-class properties, flat OptionValues, code-generation child, and +/// per-document-type mappings with their generated DDL — drives the +/// CritterWatch Documents tab end-to-end so the operationally-interesting +/// settings reach the monitoring console accurately. +/// +public class document_store_usage_tests +{ + [Fact] + public async Task usage_carries_first_class_identity_properties() + { + using var host = await BuildHost(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "doc_usage_identity"; + opts.AutoCreateSchemaObjects = AutoCreate.None; + opts.Schema.For(); + }); + + var usage = await GetUsageAsync(host); + + usage.ShouldNotBeNull(); + usage.SubjectUri.ShouldBe(new Uri("marten://main")); + usage.StoreName.ShouldBe("Main"); + usage.DatabaseSchemaName.ShouldBe("doc_usage_identity"); + usage.AutoCreateSchemaObjects.ShouldBe(AutoCreate.None.ToString()); + usage.EnumStorage.ShouldNotBeNullOrEmpty(); + usage.Version.ShouldNotBeNullOrEmpty(); + usage.Database.ShouldNotBeNull(); + } + + [Fact] + public async Task usage_includes_a_descriptor_per_registered_document_mapping() + { + using var host = await BuildHost(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "doc_usage_mappings"; + opts.Schema.For(); + opts.Schema.For(); + opts.Schema.For(); + }); + + var usage = await GetUsageAsync(host); + + var aliases = usage.Documents.Select(d => d.Alias).ToList(); + aliases.ShouldContain("user"); + aliases.ShouldContain("issue"); + aliases.ShouldContain("target"); + + var userMapping = usage.Documents.Single(d => d.Alias == "user"); + userMapping.DocumentType.FullName.ShouldBe(typeof(User).FullName); + userMapping.DocumentType.Name.ShouldBe(nameof(User)); + userMapping.DatabaseSchemaName.ShouldBe("doc_usage_mappings"); + userMapping.IdStrategy.ShouldNotBeNullOrEmpty(); + userMapping.TenancyStyle.ShouldBe("Single"); + userMapping.DeleteStyle.ShouldBe("Remove"); + } + + [Fact] + public async Task mapping_descriptor_carries_concurrency_and_tenancy_overrides() + { + using var host = await BuildHost(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "doc_usage_overrides"; + opts.Schema.For().UseOptimisticConcurrency(true); + opts.Schema.For().MultiTenanted(); + opts.Schema.For().SoftDeleted(); + }); + + var usage = await GetUsageAsync(host); + + usage.Documents.Single(d => d.Alias == "user").UseOptimisticConcurrency.ShouldBeTrue(); + usage.Documents.Single(d => d.Alias == "issue").TenancyStyle.ShouldBe("Conjoined"); + usage.Documents.Single(d => d.Alias == "target").DeleteStyle.ShouldBe("SoftDelete"); + } + + [Fact] + public async Task mapping_descriptor_carries_full_create_table_ddl() + { + using var host = await BuildHost(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "doc_usage_ddl"; + opts.Schema.For(); + }); + + var usage = await GetUsageAsync(host); + var mapping = usage.Documents.Single(d => d.Alias == "user"); + + // The DDL field is the canonical "what schema gets applied" view — + // operators on the CritterWatch Documents tab can copy/paste this + // straight into a SQL console. It must contain the CREATE TABLE + // statement for this mapping at minimum. + mapping.Ddl.ShouldNotBeNullOrEmpty(); + mapping.Ddl.ShouldContain("CREATE", Case.Insensitive); + mapping.Ddl.ShouldContain("doc_usage_ddl.mt_doc_user", Case.Insensitive); + } + + [Fact] + public async Task usage_carries_flat_option_values_for_secondary_settings() + { + using var host = await BuildHost(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "doc_usage_flat"; + opts.CommandTimeout = 42; + opts.UpdateBatchSize = 250; + opts.Schema.For(); + }); + + var usage = await GetUsageAsync(host); + + // CommandTimeout and UpdateBatchSize are lifted onto the flat + // Properties bag (Cluster C). The bag is hand-populated, so each + // expected key must appear with the right value. + usage.PropertyFor(nameof(StoreOptions.CommandTimeout))!.RawValue.ShouldBe(42); + usage.PropertyFor(nameof(StoreOptions.UpdateBatchSize))!.RawValue.ShouldBe(250); + + // Cluster H6: HiloMaxLo lifted from HiloSequenceDefaults. + usage.PropertyFor("HiloMaxLo").ShouldNotBeNull(); + + // Cluster H7: ReadSessionPreference / WriteSessionPreference lifted + // from MultiHostSettings. + usage.PropertyFor("ReadSessionPreference").ShouldNotBeNull(); + usage.PropertyFor("WriteSessionPreference").ShouldNotBeNull(); + } + + [Fact] + public async Task usage_includes_code_generation_child_descriptor() + { + using var host = await BuildHost(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "doc_usage_codegen"; + opts.Schema.For(); + }); + + var usage = await GetUsageAsync(host); + + usage.CodeGeneration.ShouldNotBeNull(); + usage.CodeGeneration.GeneratedCodeMode.ShouldNotBeNullOrEmpty(); + } + + [Fact] + public async Task event_store_usage_includes_global_aggregates_when_present() + { + using var host = await BuildHost(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "doc_usage_global_aggs"; + // GlobalAggregates lives on the internal EventGraph implementation + // (not on the public IEventStoreOptions surface). CoreTests has + // InternalsVisibleTo, so the cast is fine here. + opts.EventGraph.GlobalAggregates.Add(typeof(User)); + }); + + var usage = await host.Services.GetRequiredService() + .TryCreateUsage(CancellationToken.None); + + usage.ShouldNotBeNull(); + usage.GlobalAggregates.ShouldContain(g => g.FullName == typeof(User).FullName); + } + + private static async Task BuildHost(Action configure) + { + return await Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddMarten(configure); + }) + .StartAsync(); + } + + private static async Task GetUsageAsync(IHost host) + { + var store = (IDocumentStoreUsageSource)host.Services.GetRequiredService(); + var usage = await store.TryCreateUsage(CancellationToken.None); + usage.ShouldNotBeNull(); + return usage!; + } +} diff --git a/src/Marten/DocumentStore.DocumentStoreUsage.cs b/src/Marten/DocumentStore.DocumentStoreUsage.cs new file mode 100644 index 0000000000..cc436946cf --- /dev/null +++ b/src/Marten/DocumentStore.DocumentStoreUsage.cs @@ -0,0 +1,146 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Descriptors; +using JasperFx.Events; +using Marten.Schema; +using Weasel.Core.Migrations; + +namespace Marten; + +public partial class DocumentStore : IDocumentStoreUsageSource +{ + /// + /// Build a snapshot of this store for + /// monitoring tools (CritterWatch). Mirrors the structure of + /// IEventStore.TryCreateUsage on the document side: hand-built + /// first-class properties for the operationally-interesting bits, flat + /// OptionValues for the secondary settings, and a per-document-type + /// for each mapping that emits + /// schema (skips structural-typed and skip-generation mappings). + /// + async Task IDocumentStoreUsageSource.TryCreateUsage(CancellationToken token) + { + var usage = new DocumentStoreUsage(Subject, this) + { + Database = await Options.Tenancy.DescribeDatabasesAsync(token).ConfigureAwait(false), + StoreName = Options.StoreName, + DatabaseSchemaName = Options.DatabaseSchemaName, + AutoCreateSchemaObjects = Options.AutoCreateSchemaObjects.ToString(), + EnumStorage = Options.EnumStorage.ToString(), + }; + + // Code-generation snapshot — wrapped as a child descriptor (rather than + // flattened) so the four obsolete-on-StoreOptions properties stay + // cohesive even after they ride out the deprecation window. +#pragma warning disable CS0618 // intentional: building a diagnostic snapshot of obsolete settings + usage.CodeGeneration = new CodeGenerationDescriptor + { + ApplicationAssembly = Options.ApplicationAssembly?.GetName().FullName, + SourceCodeWritingEnabled = Options.SourceCodeWritingEnabled, + GeneratedCodeOutputPath = Options.GeneratedCodeOutputPath, + GeneratedCodeMode = Options.GeneratedCodeMode.ToString(), + }; +#pragma warning restore CS0618 + + // Per-document-type mappings — Documents collection. Skip mappings that + // don't emit schema (structural-typed, internal-only) so the snapshot + // matches what an operator would see in the database. + foreach (var mapping in Options.Storage.DocumentMappingsWithSchema.OrderBy(x => x.Alias)) + { + usage.Documents.Add(BuildMappingDescriptor(mapping)); + } + + // Flat OptionValues lifted up onto Properties bag — populated via the + // OptionsDescription auto-property reader through the base ctor, but + // we override several to coerce non-default shapes (enums-as-strings, + // Configured/Default masking, etc.). + ApplyFlatOptionValues(usage); + + return usage; + } + + private DocumentMappingDescriptor BuildMappingDescriptor(DocumentMapping mapping) + { + var ddl = WriteSchemaCreationDdl(mapping); + + return new DocumentMappingDescriptor + { + DocumentType = TypeDescriptor.For(mapping.DocumentType), + DatabaseSchemaName = mapping.DatabaseSchemaName, + Alias = mapping.Alias, + IdStrategy = mapping.IdStrategy?.GetType().Name ?? "None", + TenancyStyle = mapping.TenancyStyle.ToString(), + DeleteStyle = mapping.DeleteStyle.ToString(), + UseOptimisticConcurrency = mapping.UseOptimisticConcurrency, + UseNumericRevisions = mapping.UseNumericRevisions, + SubClassCount = mapping.SubClasses.Count(), + PartitioningStrategy = mapping.Partitioning?.GetType().Name, + Ddl = ddl, + }; + } + + private string WriteSchemaCreationDdl(DocumentMapping mapping) + { + try + { + using var writer = new StringWriter(); + mapping.Schema.WriteFeatureCreation(Options.Advanced.Migrator, writer); + return writer.ToString(); + } + catch (Exception ex) + { + // Don't let a schema-generation hiccup poison the whole snapshot — + // worst case the operator sees an explanatory error string instead + // of DDL on this one mapping. + return $"-- Failed to generate DDL: {ex.Message}"; + } + } + + private void ApplyFlatOptionValues(DocumentStoreUsage usage) + { + // Cluster A: TenantIdStyle, DefaultTenantUsageEnabled, RlsTenantSessionSetting + usage.AddValue(nameof(Options.TenantIdStyle), Options.TenantIdStyle.ToString()); + usage.AddValue(nameof(Options.Advanced.DefaultTenantUsageEnabled), Options.Advanced.DefaultTenantUsageEnabled); + usage.AddValue( + "RlsTenantSessionSetting", + Options.RlsTenantSessionSetting != null ? "Configured" : "Default"); + + // Cluster B: NameDataLength, ApplyChangesLockId + usage.AddValue(nameof(Options.NameDataLength), Options.NameDataLength); + usage.AddValue(nameof(Options.ApplyChangesLockId), Options.ApplyChangesLockId); + + // Cluster C: CommandTimeout, UpdateBatchSize, UseStickyConnectionLifetimes + usage.AddValue(nameof(Options.CommandTimeout), Options.CommandTimeout); + usage.AddValue(nameof(Options.UpdateBatchSize), Options.UpdateBatchSize); + usage.AddValue(nameof(Options.UseStickyConnectionLifetimes), Options.UseStickyConnectionLifetimes); + + // Cluster D: DuplicatedFieldEnumStorage (lifted from Advanced) + usage.AddValue("DuplicatedFieldEnumStorage", Options.Advanced.DuplicatedFieldEnumStorage.ToString()); + + // Cluster F: OpenTelemetryTrackConnections (flattened from OpenTelemetry child), + // DisableNpgsqlLogging + usage.AddValue("OpenTelemetryTrackConnections", Options.OpenTelemetry.TrackConnections.ToString()); + usage.AddValue(nameof(Options.DisableNpgsqlLogging), Options.DisableNpgsqlLogging); + + // Cluster H6: HiloMaxLo / HiloMaxAdvanceToNextHiAttempts (lifted from + // HiloSequenceDefaults). MaxLo is the canonical chunk-size knob; + // MaxAdvanceToNextHiAttempts bounds retry behaviour during sequence + // contention. SequenceName is omitted (it's a per-document override + // that lives on individual mappings, not the store-wide default). + usage.AddValue("HiloMaxLo", Options.Advanced.HiloSequenceDefaults.MaxLo); + usage.AddValue("HiloMaxAdvanceToNextHiAttempts", Options.Advanced.HiloSequenceDefaults.MaxAdvanceToNextHiAttempts); + + // Cluster H7: ReadSessionPreference / WriteSessionPreference + // (lifted from MultiHostSettings) + usage.AddValue( + "ReadSessionPreference", + Options.Advanced.MultiHostSettings.ReadSessionPreference.ToString()); + usage.AddValue( + "WriteSessionPreference", + Options.Advanced.MultiHostSettings.WriteSessionPreference.ToString()); + } +} diff --git a/src/Marten/DocumentStore.EventStore.cs b/src/Marten/DocumentStore.EventStore.cs index ed22bb230b..3d25b0125b 100644 --- a/src/Marten/DocumentStore.EventStore.cs +++ b/src/Marten/DocumentStore.EventStore.cs @@ -376,6 +376,27 @@ async Task ISubscriptionRunner.ExecuteAsync(ISubscription subscri usage.Events.Add(descriptor); } + // DCB tag-type registrations — flatten ITagTypeRegistration onto the + // wire-friendly TagTypeDescriptor (4 strings; configuration shape only, + // no row counts). + foreach (var registration in Options.EventGraph.TagTypes) + { + usage.TagTypes.Add(new TagTypeDescriptor + { + TagType = registration.TagType.FullName ?? registration.TagType.Name, + SimpleType = registration.SimpleType.FullName ?? registration.SimpleType.Name, + TableSuffix = registration.TableSuffix, + AggregateType = registration.AggregateType?.FullName, + }); + } + + // Aggregates that live outside the multi-tenant boundary in tenanted + // setups — flat list of CLR type identities. + foreach (var aggregateType in Options.EventGraph.GlobalAggregates) + { + usage.GlobalAggregates.Add(TypeDescriptor.For(aggregateType)); + } + return usage; } diff --git a/src/Marten/Marten.csproj b/src/Marten/Marten.csproj index a834104915..a7f2bd1644 100644 --- a/src/Marten/Marten.csproj +++ b/src/Marten/Marten.csproj @@ -37,17 +37,22 @@ + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - - - - - - - + diff --git a/src/Marten/MartenServiceCollectionExtensions.cs b/src/Marten/MartenServiceCollectionExtensions.cs index 8388ba4bac..fe8ac63415 100644 --- a/src/Marten/MartenServiceCollectionExtensions.cs +++ b/src/Marten/MartenServiceCollectionExtensions.cs @@ -167,6 +167,8 @@ Func optionSource services.AddJasperFx(); services.AddSingleton(); services.AddSingleton(s => (IEventStore)s.GetRequiredService()); + services.AddSingleton(s => + (IDocumentStoreUsageSource)s.GetRequiredService()); services.AddSingleton(); services.AddSingleton(s => { @@ -290,6 +292,7 @@ public static MartenStoreExpression AddMartenStore(this IServiceCollection services.AddSingleton>(); services.AddSingleton(s => (IEventStore)s.GetRequiredService()); + services.AddSingleton(s => (IDocumentStoreUsageSource)s.GetRequiredService()); var stores = services .Where(x => !x.IsKeyedService) diff --git a/src/Marten/StoreOptions.GeneratesCode.cs b/src/Marten/StoreOptions.GeneratesCode.cs index 7a27bf61db..cdffbe6093 100644 --- a/src/Marten/StoreOptions.GeneratesCode.cs +++ b/src/Marten/StoreOptions.GeneratesCode.cs @@ -6,6 +6,7 @@ using JasperFx; using JasperFx.CodeGeneration; using JasperFx.Core; +using JasperFx.Descriptors; using Marten.Internal.CodeGeneration; using Marten.Schema; using Microsoft.Extensions.Hosting; @@ -46,6 +47,7 @@ public bool SourceCodeWritingEnabled /// Root folder where generated code should be placed. By default, this is the IHostEnvironment.ContentRootPath /// [Obsolete(PreferJasperFxMessage)] + [IgnoreDescription] public string GeneratedCodeOutputPath { get; set; } public IReadOnlyList BuildFiles() @@ -57,6 +59,7 @@ public IReadOnlyList BuildFiles() .ToList(); } + [IgnoreDescription] GenerationRules ICodeFileCollection.Rules => CreateGenerationRules(); string ICodeFileCollection.ChildNamespace { get; } = "DocumentStorage"; diff --git a/src/Marten/StoreOptions.Registration.cs b/src/Marten/StoreOptions.Registration.cs index b038a29ac9..915351c446 100644 --- a/src/Marten/StoreOptions.Registration.cs +++ b/src/Marten/StoreOptions.Registration.cs @@ -87,7 +87,7 @@ public void RegisterCompiledQueryType(Type queryType) CompiledQueryTypes.Fill(queryType); } - public class MartenAssemblyScanner +public class MartenAssemblyScanner { private readonly List _assemblies = new(); private readonly List> _eventMatchers = new(); diff --git a/src/Marten/StoreOptions.cs b/src/Marten/StoreOptions.cs index b8b15304b2..b37f03165a 100644 --- a/src/Marten/StoreOptions.cs +++ b/src/Marten/StoreOptions.cs @@ -452,6 +452,7 @@ public ITenancy Tenancy /// Registry of custom projection storage factories, keyed by aggregate document type. /// Used by EF Core projections to substitute Marten document storage with DbContext-based storage. /// + [IgnoreDescription] public Dictionary> CustomProjectionStorageProviders { get; } = new(); private int _applyChangesLockId = 4004;