Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@
<PackageVersion Include="FluentAssertions" Version="6.12.0" />
<PackageVersion Include="FSharp.Core" Version="9.0.100" />
<PackageVersion Include="FSharp.SystemTextJson" Version="1.3.13" />
<PackageVersion Include="JasperFx" Version="1.28.2" />
<PackageVersion Include="JasperFx.Events" Version="1.31.1" />
<PackageVersion Include="JasperFx" Version="1.29.0" />
<PackageVersion Include="JasperFx.Events" Version="1.33.1" />
<PackageVersion Include="JasperFx.Events.SourceGenerator" Version="1.5.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageVersion>
<PackageVersion Include="JasperFx.RuntimeCompiler" Version="4.5.0" />
<PackageVersion Include="JasperFx.SourceGeneration" Version="1.1.0" />
<PackageVersion Include="Jil" Version="3.0.0-alpha2" />
<PackageVersion Include="Lamar" Version="7.1.1" />
<PackageVersion Include="Lamar.Microsoft.DependencyInjection" Version="15.0.0" />
Expand Down
202 changes: 202 additions & 0 deletions src/CoreTests/document_store_usage_tests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Coverage for <c>IDocumentStoreUsageSource.TryCreateUsage</c> on Marten's
/// <see cref="DocumentStore"/>. 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.
/// </summary>
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<User>();
});

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<User>();
opts.Schema.For<Issue>();
opts.Schema.For<Target>();
});

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<User>().UseOptimisticConcurrency(true);
opts.Schema.For<Issue>().MultiTenanted();
opts.Schema.For<Target>().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<User>();
});

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<User>();
});

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<User>();
});

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<IEventStore>()
.TryCreateUsage(CancellationToken.None);

usage.ShouldNotBeNull();
usage.GlobalAggregates.ShouldContain(g => g.FullName == typeof(User).FullName);
}

private static async Task<IHost> BuildHost(Action<StoreOptions> configure)
{
return await Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddMarten(configure);
})
.StartAsync();
}

private static async Task<DocumentStoreUsage> GetUsageAsync(IHost host)
{
var store = (IDocumentStoreUsageSource)host.Services.GetRequiredService<IDocumentStore>();
var usage = await store.TryCreateUsage(CancellationToken.None);
usage.ShouldNotBeNull();
return usage!;
}
}
146 changes: 146 additions & 0 deletions src/Marten/DocumentStore.DocumentStoreUsage.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Build a <see cref="DocumentStoreUsage"/> snapshot of this store for
/// monitoring tools (CritterWatch). Mirrors the structure of
/// <c>IEventStore.TryCreateUsage</c> 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
/// <see cref="DocumentMappingDescriptor"/> for each mapping that emits
/// schema (skips structural-typed and skip-generation mappings).
/// </summary>
async Task<DocumentStoreUsage?> 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());
}
}
21 changes: 21 additions & 0 deletions src/Marten/DocumentStore.EventStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,27 @@ async Task ISubscriptionRunner<ISubscription>.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;
}

Expand Down
Loading
Loading