Changelog
The changes in each release of Tenantry Pro 0.5, newest first.
0.5.0 - 2026-10-03
Upgrading from 0.4
0.5 reshapes the public API once, before 1.0, and runs on Tenantry Core 0.5: update the Core calls first, with
Core's changelog ("Upgrading from 0.4"). Registration code needs no Tenantry.Pro using directive any more; code
that uses Pro's types needs using Tenantry.Pro; (and using Tenantry.Pro.EfCore; or
using Tenantry.Pro.AspNetCore; for theirs). The Breaking entries below give the details.
| 0.4 | 0.5 |
|---|---|
packages Tenantry.Pro.EfCore.SqlServer, .Npgsql and .MySql | Tenantry.Pro.EfCore, which works through your context's own EF Core provider |
package Tenantry.Pro.HealthChecks | Tenantry.Pro.EfCore |
namespaces Tenantry.Pro.BackgroundServices, .Exceptions, .Licensing, .Lifecycle and .Strategies.* | Tenantry.Pro |
namespaces Tenantry.Pro.EfCore.Audit, .Migrations and .Strategies.* | Tenantry.Pro.EfCore |
namespaces Tenantry.Pro.AspNetCore.Telemetry and .Telemetry.Extensions | Tenantry.Pro.AspNetCore |
pro.WithLicence(key), the Tenantry:Licence setting | pro.UseLicenseKey(key), the Tenantry:License setting (Tenantry__License) |
pro.UseDatabasePerTenant(…) and DatabasePerTenantOptions | Core's tenant.UseConnectionStrings(…) and tenant.AddDbContextPerTenantDatabase<TContext>(…) |
DatabasePerTenantOptions.CacheConnectionStrings and CacheDuration | pro.CacheConnectionStrings(o => o.Duration = …) |
connection-string encryption (UseDataProtectionEncryption(), ConnectionStringEncryptionOptions) | removed |
ITenantLifecycleManager<TKey>, pro.AddLifecycleManagement(…), TenantLifecycleOptions | ITenantProvisioner<TKey> (always registered), pro.ConfigureProvisioning(o => …), TenantProvisioningOptions |
a seeder registered in the service collection, with SeedAsync(tenant, scopedProvider, ct) | pro.AddSeeder<T>(), with SeedAsync(tenant, ct) and its services in the constructor; a seeder that is only in the service collection is no longer run |
pro.UseMixedMode(o => o.GetStrategyForTenant = …), TenantStrategy | pro.UseMixedMode(o => o.GetIsolation = …), TenantIsolation |
pro.AddDatabaseProvisioning(), pro.AddSchemaProvisioning(o => o.ConnectionString = …), DatabaseProvisioningService<TKey>, SchemaProvisioningService<TKey> | pro.AddDatabaseProvisioning<TContext>(), pro.AddSchemaProvisioning<TContext>(); provision with ITenantProvisioner<TKey> |
pro.AddSchemaPerTenantCaching(), options.AddSchemaPerTenantCaching<TKey>(sp), ISchemaNameResolver<TKey>, HasDefaultSchema(…) in OnModelCreating | pro.UseSchemaPerTenant(o => o.GetSchemaName = …) and options.UseTenantry() |
pro.WithMigrationOrchestration<TKey, TContext>(factory, runAtStartup, failStartupOnMigrationError), MigrationOrchestratorService<TKey, TContext>, MigrationStatusTracker<TKey, TContext> | pro.AddMigrations<TContext>(o => o.OnStartup = …), ITenantMigrationRunner<TKey> |
AddTenantryDatabaseCheck<TKey>(…), AddTenantryMigrationCheck<TKey, TContext>(factory), TenantHealthCheckOptions.ConnectionFactory | AddTenantDatabaseCheck<TContext>(), AddTenantMigrationCheck<TContext>(), through the application's context |
options.UseAuditLogging(sp), AuditOptions.ExcludeTypes | pro.AddAuditLogging() for every context with options.UseTenantry(); Exclude<T>(), ExcludeProperty<T>(…), ShouldAudit |
TenantBackgroundService<TKey>.ExecuteForTenantAsync(tenant, scopedProvider, ct), the constructor's storeAccessor | ExecuteForTenantAsync(scope, ct), the constructor's ITenantLookup<TKey> tenantLookup |
pro.AddTenantMetrics() and the tenantry.requests.* instruments | the tenant.id tag on ASP.NET Core's http.server.request.duration (see Changed for dashboards) |
AddHangfireTenantFilter(), app.UseTenantryHangfire() | pro.AddHangfirePropagation(), config.UseTenantry(sp) in AddHangfire |
AddMassTransitTenantFilters(), x.AddTenantryConsumeFilter(), cfg.UseTenantryPro(ctx) | pro.AddMassTransitPropagation(), cfg.UseTenantry(context) |
AddQuartzTenantScope() | pro.AddQuartzPropagation(), q.UseTenantry() in AddQuartz |
AddRebusTenantSteps(), o.UseTenantryPro(sp) | pro.AddRebusPropagation(), o.UseTenantry(sp) |
MissingTenantBehavior in the integrations' propagation options | TenantPropagationBehavior (Allow, Warn, Skip, Reject) |
Added
- Audit entries say who made the change and what made it:
AuditEntry.Actor,CorrelationIdandData, from anIAuditContextProvider(by default no actor, and the current trace's id; register your own to name the user, as the audit logging guide shows for ASP.NET Core), and EF Core's name for the entity type,EntityType. WithAuditOptions.OnStoreFailure = AuditStoreFailureBehavior.Throw, a store that fails to write the entries of changes already saved makesSaveChanges, or the commit, throw anAuditStoreExceptionholding the entries (by default the failure is logged, as before). - Migrating several databases or schemas at once:
pro.AddMigrations<TContext>(o => o.MaxConcurrency = 8)(1 by default), which also boundsGetStatusAsync.ITenantMigrationRunner<TKey>.MigrateAllAsync(IProgress<MigrationResult<TKey>>, …)reports each one's result as it completes, with every tenant whose data is there. To know which tenants share a database or schema before migrating it, a run now creates each tenant's context first, so the first tenant's context of each is created twice. - Migrations for schema per tenant:
pro.AddMigrations<TContext>()applies migrations generated without a schema (asdotnet ef migrations addgenerates them, with no tenant) to each tenant's schema, with a migration history table in that schema, and gives the snapshot the tenant's schema for EF Core 9+'s pending-changes check. Tested on SQL Server and PostgreSQL; SQL inmigrationBuilder.Sql(…)is applied as written. See Changed forAddMigrations. app.RunTenantMigrationsIfRequestedAsync(args)(anIHostextension): with the argumentmigrate-tenants, migrates every tenant and returns the exit code (1 if any database or schema failed), so a deployment step is one line.- Provisioning steps of your own:
ITenantProvisioningStep<TKey>, added withpro.AddProvisioningStep<T>(), runs for each new tenant in a scope for it, after Tenantry.Pro's own steps; itsAppliesTocan skip a tenant, for example by its isolation in mixed mode. See Changed for the provisioning API. IConnectionStringCache<TKey>.InvalidateAll(), to drop every tenant's cached connection string after rotating credentials.- Jobs and messages for a tenant by name, whichever tenant is current:
jobs.ForTenant(tenantId).Enqueue(…)for Hangfire (anyEnqueue,ScheduleorContinueJobWith),Publish(message, context => context.SetTenant(tenantId))orSend(…)for MassTransit, andbus.Send(message, new Dictionary<string, string>().WithTenant(tenantId))(orPublish,Defer) for Rebus. A tenant given this way wins over the current one: Hangfire's filter and Rebus's step add the current tenant only to a job or message that carries none, and MassTransit runs the callback after its filters. - Recurring work for each tenant:
recurringJobs.AddOrUpdateForEachTenant<T>(id, job => …, cron)for Hangfire, whose runs enqueue the job once for each tenant in the store, andnew JobDataMap().ForEachTenant()for a Quartz.NET job, which then, whenever it fires without a tenant, schedules a run for each tenant with the firing trigger's data and priority. Each tenant's job runs, and fails, on its own; Hangfire's recurring jobs otherwise run without a tenant. - MassTransit routing slips (Courier):
cfg.UseTenantry(context)adds Tenantry's execute and compensate activity filters, so each activity executes and compensates as the routing slip's tenant, which it passes on. They ran without it. - Jobs and messages name their tenant in logs and traces: while a Hangfire job, Quartz.NET job, MassTransit consumer
or activity, or Rebus handler runs as its tenant, a log scope with
TenantIdis open, and its trace span is taggedtenant.id(for a MassTransit consumer, the message's receive span, of which the consumer's is a child). - CI builds every
csharp block in the README and docs against the packed packages (`scripts/check-doc-snippets.sh`), so a guide can no longer show code that does not compile; a block that is not meant to compile is markedcsharp no-compile. - An
.editorconfigshared with Tenantry core, checked in CI withdotnet format --verify-no-changes. SchemaPerTenantOptions<TKey>.MaxCachedSchemas, how many schemas' compiled models each context type keeps (500 by default), andMaxCompiledQueriesPerSchema, room for each schema's compiled queries (100 by default), set inpro.UseSchemaPerTenant(o => …). See Fixed.- CI starts every sample in Development, where the host validates its registrations, against SQL Server,
MySQL and PostgreSQL in containers and with a licence signed for the run, and checks each does what its
README says (
scripts/smoke-samples.cs). Each package has a conformance test: its features registered throughUsePro, scopes and every registration validated, every Tenantry service resolved in a tenant's scope, the host started (licence check included). A weekly lane builds and tests Pro against Tenantry Core'smaster(scripts/build-against-local-core.sh --test).
Fixed
-
AuditEntry.PrimaryKeyshowed a byte array key asSystem.Byte[], and a date key without its fractional seconds, so entries of different rows could not be told apart, and wrote other values in the server's culture, so a decimal key's comma read as a separator. A byte array is now written in hexadecimal (0x0102), a date or time to the tick (2026-10-02T13:04:05.1230000, aDateTimewithout its kind, so an inserted row's key matches its later entries'), a strongly typed id (a value-converted key that cannot be formatted in the invariant culture) as the value it is stored as, and every other value in the invariant culture. -
Audit entries left out the values of complex properties (
ComplexProperty, and EF Core 10'sComplexCollection): an update ofAddress.Citywas saved and recorded with no old or new values. They are now recorded by path (Address.City), and a complex collection whole, as a list of its elements' values, when it changes.ExcludePropertyleaves out a complex property, or, named on the complex type, one of its properties (it no longer requires a class, so a struct complex type can be named). -
Audit entries held the entity's own instances of mutable values, such as a byte array, so changing one after
SaveChanges, before the transaction committed, changed the entry but not the saved row. Each value is now copied when the save starts, as EF Core copies it to detect changes, and an array is copied too. -
The API reference repeated a type parameter's variance in its constraints (
where TKey : IEquatable<in TKey>), which is not C#. -
With a
Guidorinttenant key, a Hangfire job, MassTransit or Rebus message or audit entry created without a tenant carried the key's default (00000000-…,0) as its tenant: without a tenant,ITenantContext.CurrentTenantIdis the key's default, not null. They now carry no tenant. -
Tenantry.Pro.MassTransitfailed on MassTransit 8.1 and later:AddTenantryConsumeFilterthrewMissingMethodExceptionat startup, because MassTransit 8.1 changed the endpoint-callback delegate it uses and the package was built against 8.0. It now requires MassTransit 8.1 or later 8.x. (Found by the sample smoke check; the tests ran on 8.0.) -
Quartz jobs whose constructor takes a scoped service that reads the tenant (a
DbContextchoosing its connection, say) got it without a tenant: Quartz's DI job factory creates the job, and its scoped dependencies, before the tenant scope opened. The job is now created inside the scope, and returned to Quartz's factory before the scope closes; withSkipit is not created at all. A constructor that throws now fails that run of the job, which Quartz retries on the next firing, instead of putting its triggers in theErrorstate. -
Rebus: a message whose tenant could not be resolved under
Reject, or whose tenant lookup threw, was never retried or moved to the error queue, but redelivered for ever: the incoming step ran in front of Rebus's retry step. It now runs just before Rebus deserializes the message, and a pipeline without that step fails to start rather than skip the tenant step. Data bus hydration and decryption, which Rebus runs before deserializing, now run outside the tenant scope. The tenant lookup is cancelled when the bus stops, and log messages name the message id. -
MassTransit: an endpoint configured with
cfg.ReceiveEndpoint(…)consumed messages without their tenant, becauseAddTenantryConsumeFilterreached only the endpointsConfigureEndpointsconfigured.cfg.UseTenantry(context)covers every endpoint, consumers, sagas and handlers alike, and the consume filter runs inside MassTransit's message retry, so a rejected message is retried like a consumer that throws. -
MassTransit: a batch consumer (
IConsumer<Batch<T>>) could consume a batch whose messages carried different tenants as one of those tenants. A batch is consumed as its messages' tenant only when they all carry it; one that mixes tenants fails, and the guide shows how to group batches by tenant. -
A tenant id carried by a job or message was formatted and parsed with the current culture, so an
intorlongid could fail to round trip between cultures that write a minus sign differently. Both use the invariant culture. -
The Hangfire guide said a job skipped by the
Skippolicy is marked succeeded; Hangfire deletes it ("Canceled by filter"). -
PeriodicTenantBackgroundServicestopped for good (and, by default, stopped the host) when a sweep failed as a whole, for example because the tenant store could not be read. The failure is now logged, and the next sweep runs on schedule. -
Schema per tenant: EF Core's cache holds the models of about 40 schemas (about 100 on EF Core 8), and about 500 compiled queries, which EF Core compiles again for each schema's model; so with more tenants in use it evicted and compiled them again as tenants took turns. Each context type now has its own cache with room for
MaxCachedSchemasschemas andMaxCompiledQueriesPerSchemaqueries each, and the models are keyed on the schema name rather than the tenant id, so tenants that share a schema share a model. The documentation said EF Core's model cache never evicts. -
Schema per tenant on a pooled context (
AddDbContextPool,AddPooledDbContextFactory, Core's pooled database-per-tenant registration) queried the first tenant's schema for every tenant: a pooled context keeps the model it was first built with. Schema per tenant now refuses a pooled context: creating one throwsInvalidOperationException. -
Audit logging resolved
IAuditStoreonce, into the singleton interceptor: a scoped store, such as one that saves through aDbContext, failed scope validation, or was kept for the application's lifetime with its context. The store is now resolved for each save from the saving context's scope, or from a scope created for the save when the context has none (pooled, from anIDbContextFactory, or created by hand), so a scoped store works everywhere and a transient one is disposed. -
Every package's README said "Licensed under the Apache License 2.0". The packages now carry their own README for customers (
eng/package-readme.md): the commercial licence, the feed, links to tenantry.dev and the support address.Tenantry.Pro.AspNetCore's description and the docs no longer claim per-request licence enforcement, which does not exist (the key is checked once, at startup). Security reports go to support@tenantry.dev rather than the private repository. -
The getting-started guide, the README and most guides left out the using directives their code needs, and showed APIs from packages they did not name (a Hangfire memory storage, RabbitMQ).
-
The
HangfireJobssample could not start: it registered no tenant store and never resolved a tenant, so no job carried one. It now runs with in-memory tenants and job storage, and its job depends on a scoped service that reads the tenant. -
The MassTransit guide and the
Skipdocumentation said it acknowledges and drops a message; MassTransit moves it to the endpoint's_skippedqueue, and whatSkipdoes is up to each host. The Hangfire guide now says recurring jobs run without a tenant; see Added for one that runs for each tenant. -
MigrationResult.AppliedMigrationslists what the run applied. It listed the migrations pending when the run started, so of two runners migrating the same tenant at once, the one that waited for EF Core's lock and applied nothing reported them too; and it was always empty when a run failed, even after committing some. It now lists the migrations whose row in the migration history this run wrote and committed (from EF Core's diagnostic events): each once when an execution strategy retries, never one another runner applied, including one it waited for, failed on or skipped on a retry, and on failure those committed before it. -
Reading migration status (
GetStatusAsync, wasMigrationStatusTracker's) no longer fails for every tenant when one tenant's database cannot be read. That entry has the newErrorset, andIsUpToDateis false;GetTenantStatusAsyncreturns such an entry instead of throwing. Cancellation still throws. -
Provisioning the same tenant twice at once (a double submit, a redelivered message) no longer fails with "already exists" in the
CreateDatabaseorCreateSchemastep: a run whoseCREATE DATABASEfails waits for the database another run created to come online, and a run whoseCREATE SCHEMAfails checks for the schema again. -
The database health check reports the registration's failure status instead of always
Unhealthy, and checks up toTenantHealthCheckOptions.MaxConcurrencydatabases at a time (default 8) instead of one after another, so unreachable tenants no longer each add a full timeout in turn. -
A failed migration was logged twice, once as it failed and again in the run's summary. It is logged once, and the summary of a run with failures is a warning.
-
Creating several tenants' contexts at once in a new application, as the tenant health checks do, could fail on Oracle's
MySql.EntityFrameworkCore, which builds its type mappings, shared by every context, the first time without a lock (seen in CI). Tenantry.Pro now works through tenants one at a time until one tenant's context has been created and used, then goes on several at once. Two such runs at once in one new process can still meet there. -
AuditEntry.TenantIdand the tenant health checks' data keys (tenant:{id}) format the tenant id with the invariant culture, as job and message headers and traces do. They used the machine's culture, so for a key type whose text depends on it they could differ from the id elsewhere. -
ProvisionAsync,MigrateTenantAsyncandGetTenantStatusAsyncthrowArgumentExceptionfor an id Tenantry reserves for "no tenant" (the key type's default,Guid.Emptyor0, or an empty string), before anything runs, as Tenantry Core'sRunInScopeAsyncdoes. Provisioning such a tenant reported success when no steps were registered and a failed step otherwise, and the migration runner asked the store for it and threwTenantNotFoundException. Every Pro package now applies the same rules for formatting tenant ids and refusing the reserved ones.
Changed
-
Breaking: Tenantry.Pro follows Tenantry Core 0.5's API (see Core's changelog, "Upgrading from 0.4"):
AddTenantryis the one entry point for every host (AddTenantryCoreis gone), Core's types are in theTenantrynamespace and its registration methods need nousing,ITenantContextSetter<TKey>.Usemakes a tenant current (wasITenantScope.BeginScope),ITenantScopeFactorycreates anITenantScope<TKey>(wasITenantServiceScope), singletons read tenants throughITenantLookup<TKey>(wasITenantStoreAccessor), and a tenant's connection string comes fromITenantConnectionStringProvider<TKey>.Getor, for the current tenant,CurrentTenantConnectionString<TKey>.Get(wasITenantConnectionStringResolver.Resolve). Core'stenant.AddDbContextPerTenantDatabase<TContext>(…), pooled or not, aftertenant.UseConnectionStrings(…), replaces Core'sAddTenantDbContextPoolfor a database per tenant. -
Breaking: the propagation options of the Hangfire, MassTransit, Quartz and Rebus integrations take
Tenantry.Pro.TenantPropagationBehavior(Allow,Warn,Skip,Reject), in place of Core'sMissingTenantBehavior, which is now EF Core's and has noSkip. -
Breaking:
UsePropasses anIProBuilder<TKey>, a public interface inTenantry.Pro; the builder class it replaces (Tenantry.Pro.Internal.ProBuilder<TKey>) is internal and onlyUsePro, which registers the licence check, creates it.UseSchemaPerTenantandUseMixedModeare extension methods like the other features. A feature that takes a type parameter of its own can register throughIProBuilder.Add(IProRegistration), which applies it with the builder's key type, so its callers need not repeat the key type. -
Breaking: Tenantry.Pro's types are in the
Tenantry.Pronamespace (they were inTenantry.Pro.BackgroundServices,.Exceptions,.Licensing,.Lifecycleand.Strategies.*), and Tenantry.Pro.AspNetCore's inTenantry.Pro.AspNetCore(were.Telemetryand.Telemetry.Extensions). Registration methods (UsePro, the builder's andAddTenantMetrics) are inMicrosoft.Extensions.DependencyInjection, andUseTenantryMetricsinMicrosoft.AspNetCore.Builder, so they need nousing. -
Breaking:
UseProreads the licence key from theTenantry:Licensesetting (theTenantry__Licenseenvironment variable) when the application starts, so configuration added after the services, such as a test host's, reaches it;pro.UseLicenseKey(key)sets it in code instead.pro.WithLicence(key)and theTenantry:Licencesetting are gone, andLicenseOptionsis internal. Code identifiers use American spelling. -
Breaking: a licence key must name the key it was signed with (
kid, the key's RFC 7638 thumbprint), and carry the audiencetenantry-pro(aud) and the licence format1(ver), as keys issued by tenantry.dev now do. A key in the earlier format names none of them and no longer validates; no customer was issued one, and support@tenantry.dev replaces any that turns up. Pro finds the public key by itskid, so a future signing key can be added without invalidating keys already issued; a key in a newer format than the package reads asks for an update. -
Breaking: a database per tenant is Tenantry Core's:
pro.UseDatabasePerTenant(…)andDatabasePerTenantOptionsare gone. Set the connection strings withtenant.UseConnectionStrings(…)and register the context withtenant.AddDbContextPerTenantDatabase<TContext>(…).pro.CacheConnectionStrings(o => o.Duration = …)caches them (wasCacheConnectionStringsandCacheDuration): it wraps the providerUseConnectionStringsregisters, whether called before or afterUsePro, or one of your own registered beforeUsePro. The application fails to start if there is none, or if the duration is not positive, and warns at startup if a provider registered later took the cache's place.IConnectionStringCache<TKey>is always registered and hasInvalidateAll(); a connection string read while it is being invalidated is no longer cached afterwards. -
Breaking: tenant provisioning.
ITenantLifecycleManager<TKey>isITenantProvisioner<TKey>, whichUseProalways registers, as a singleton (pro.AddLifecycleManagement(…)is gone;pro.ConfigureProvisioning(o => …)setsTenantProvisioningOptions, wasTenantLifecycleOptions). It runsITenantProvisioningStep<TKey>s: Pro's own first (creating the database or schema, then migrations), then the steps and seeders added withpro.AddProvisioningStep<T>()andpro.AddSeeder<T>(), in the order they were added. Each step is resolved from a new scope for the tenant and gets the tenant, so it no longer reads it from the store again, and, in mixed mode, its isolation; itsAppliesTocan skip a tenant.TenantProvisioningResultlists every step's outcome (Steps:Succeeded,Failed,SkippedorNotRun) in place ofCompletedUpToand theTenantProvisioningStepenum.ITenantSeeder<TKey>.SeedAsync(tenant, ct)no longer takes a service provider: a seeder takes the services it needs, such as theDbContext, in its constructor; and every seeder added runs (only the first registered one ran). Add seeders withpro.AddSeeder<T>(): a seeder registered only in the service collection (AddScoped<ITenantSeeder<TKey>, T>(), as 0.4 documented) is no longer run.ITenantInfrastructureProvisionerandITenantMigratorare gone:AddDatabaseProvisioning,AddSchemaProvisioningandAddMigrations(see below) add steps. Cancelling stops provisioning before the next step, and a step that fails after the cancellation is reported asOperationCanceledException. -
MigrateTenantAsyncandGetTenantStatusAsyncthrow Core'sTenantNotFoundExceptionfor a tenant the store does not return. It derives fromInvalidOperationException, which they threw before, so existing handlers still catch it. -
Breaking: mixed mode is honoured by provisioning.
pro.UseMixedMode(o => o.GetIsolation = …)returns each tenant'sTenantIsolation(Shared,SchemaorDatabase; wasGetStrategyForTenantandTenantStrategy). Creating a database applies only toDatabasetenants, creating a schema only toSchematenants, and migrations, which go to each distinct database or schema, to both (see below).MixedStrategyResolveris gone: each tenant's connection string comes from theUseConnectionStringsdelegate. -
Breaking: per-tenant request metrics are ASP.NET Core's.
pro.AddTenantMetrics()andapp.UseTenantryMetrics()add atenant.idtag to ASP.NET Core'shttp.server.request.duration(a histogram in seconds, on theMicrosoft.AspNetCore.Hostingmeter, already tagged with the route, method, status code anderror.type) instead of recording Tenantry.Pro's own instruments; see Removed. For dashboards:tenantry.requests.countis the histogram's count,tenantry.requests.duration(milliseconds) the histogram, andtenantry.requests.errorsthe requests witherror.typeor a5xxhttp.response.status_code.tenantry.requests.activehas no per-tenant replacement: ASP.NET Core recordshttp.server.active_requestsbefore the tenant is known. A request without a tenant has notenant.idtag (it wasunknown).TenantMetricsOptions<TKey>.GetTagValuesets the tag for each tenant, or leaves it off, to bound the number of series;ExcludePathsandAdditionalTagsare gone (on ASP.NET Core 9 and later,DisableHttpMetrics()leaves an endpoint out of the metric).UseTenantryMetricswithoutAddTenantMetricssays so. -
Breaking:
TenantBackgroundService<TKey>.ExecuteForTenantAsynctakes the tenant'sITenantScope<TKey>(scope.Tenant,scope.ServiceProvider) in place of the tenant and a service provider, and the base class has a protectedLogger, the logger passed to its constructor. Its constructor, andPeriodicTenantBackgroundService<TKey>'s, take anITenantLookup<TKey>namedtenantLookup(wasstoreAccessor). -
Breaking: provisioning works through your context's own EF Core provider, so the provider packages are gone (see Removed).
pro.AddDatabaseProvisioning<TContext>()andpro.AddSchemaProvisioning<TContext>(), inTenantry.Pro.EfCore, replace theirAddDatabaseProvisioning()andAddSchemaProvisioning(o => o.ConnectionString = …): the step resolvesTContextin the tenant's scope, so it connects as the application does, and creates the database through EF Core's database creator, or the schema with EF Core's migrations SQL (SQL Server and PostgreSQL; another provider fails the step withNotSupportedException).o.CreateContextgives the step a context of its own, with credentials allowed to create databases or schemas. A run whose create fails but finds the database once it waits (up to 30 seconds) succeeds and logs the failure as a warning.DatabaseProvisioningService<TKey>,SchemaProvisioningService<TKey>, their base classes,SchemaProvisioningOptions.ConnectionStringandProvisionAsync(tenantId)are gone: provision withITenantProvisioner<TKey>. The methods take the context type, so they return the builder without its key type: call them after the others in a chain. -
Breaking: schema per tenant needs nothing in the context.
pro.UseSchemaPerTenant(o => o.GetSchemaName = …)is inTenantry.Pro.EfCore, and every context that usesUseTenantry()gets the current tenant's schema as its default schema, afterOnModelCreating, with a compiled model per schema.pro.AddSchemaPerTenantCaching(),options.AddSchemaPerTenantCaching(sp),ISchemaNameResolver<TKey>and the publicTenantModelCacheKeyFactory<TKey>are gone: replaceoptions.AddSchemaPerTenantCaching<TKey>(sp)withoptions.UseTenantry()(a context without it gets no tenant schema, and every tenant would use the database's default), and removepro.AddSchemaPerTenantCaching(),ISchemaNameResolver<TKey>and theHasDefaultSchema(…)call inOnModelCreating.SchemaPerTenantOptions<TKey>is inTenantry.Pro.EfCoreand has the cache limits (wasSchemaPerTenantCachingOptions); options that are not valid,GetSchemaNamemissing included, stop the application from starting. In mixed modeGetSchemaNameis called only forSchematenants, whose contexts get their schema; the others keep the database's default. Without a current tenant, such as indotnet ef, the model has no default schema, so migrations are generated without one. A schema name that is empty, has control characters, or is longer than the database allows (128 characters on SQL Server, 63 bytes on PostgreSQL, which would otherwise shorten it) fails the tenant's queries withInvalidOperationException. Each context type's model cache belongs to an EF Core internal service provider of the context type's own, which every application in a process shares, so a test suite that starts many hosts does not reach EF Core's limit of 20 internal service providers; an application with more than about 20 such context types would.UseMemoryCache, orReplaceServiceofIModelCacheKeyFactoryorIMemoryCache, on such a context's options throwsInvalidOperationException, since either would undo the cache or let tenants share a model. -
Breaking: Tenantry.Pro.EfCore's provisioning, schema-per-tenant, migration, health-check and audit types are in
Tenantry.Pro.EfCore(they were in.Strategies.DatabasePerTenant,.Strategies.SchemaPerTenant,.Migrations,.Auditand.Extensions), its builder and health-check methods (UseSchemaPerTenant,AddDatabaseProvisioning,AddSchemaProvisioning,AddMigrations,AddAuditLogging,AddTenantDatabaseCheck,AddTenantMigrationCheck) inMicrosoft.Extensions.DependencyInjection, andRunTenantMigrationsIfRequestedAsyncinMicrosoft.Extensions.Hosting, so they need nousing. -
Breaking: migrations.
pro.AddMigrations<TContext>(o => …)replacespro.WithMigrationOrchestration<TKey, TContext>(factory, runAtStartup, failStartupOnMigrationError), andITenantMigrationRunner<TKey>(MigrateAllAsync,MigrateTenantAsync,GetStatusAsync,GetTenantStatusAsync) replacesMigrationOrchestratorService<TKey, TContext>andMigrationStatusTracker<TKey, TContext>. The context comes from the application's registration in each tenant's scope (itsIDbContextFactory<TContext>when it cannot be created there, as with only an asynchronous connection string), so there is no factory from a connection string;o.CreateContextcreates one of its own, for other credentials. One runner migrates every context added, in the order added, and tenants whose context has the same connection string, default schema and migration history table are migrated once: a result or status entry is per context and database or schema, with itsTenantIds,ContextType,DatabaseandSchema(it had oneTenantId),MigrateTenantAsyncreturns a report, andGetTenantStatusAsynca list of entries, one per context (it returned one entry).o.OnStartup = StartupMigrations.LogFailuresorFailOnErrorreplacesrunAtStartupandfailStartupOnMigrationError. Every context added is aMigrationsprovisioning step; in mixed mode it applies toDatabaseandSchematenants (onlyDatabasebefore), and the shared database is migrated with the other tenants.UseConnectionStringsis no longer required. -
Breaking: the health checks are in
Tenantry.Pro.EfCore(see Removed).AddTenantDatabaseCheck<TContext>()andAddTenantMigrationCheck<TContext>()replaceAddTenantryDatabaseCheck<TKey>(…)andAddTenantryMigrationCheck<TKey, TContext>(factory): they go through the application's context in each tenant's scope, soTenantHealthCheckOptions.ConnectionFactoryis gone, and read each distinct database (or, for migrations, database and schema) once. They take the standardname,failureStatus(Degraded by default),tags,timeout(30 seconds by default) andconfigurearguments, which replaceTenantHealthCheckOptions.FailureStatusandTags; the default names aretenant-databasesandtenant-migrations(weretenantry-databasesandtenantry-migrations).DatabaseTimeout(wasConnectionTimeout) andMaxConcurrencyapply to both checks, and a check reports its last result forCacheDuration(30 seconds by default), so frequent polls do not each reach every tenant database. A tenant's entry readsreachableorunreachable: …(washealthyorunhealthy: …). They needUsePro: without it they report their failure status, saying so. -
Breaking: audit logging.
pro.AddAuditLogging()audits every context that usesUseTenantry(), which adds the audit interceptor after Tenantry's own, sooptions.UseAuditLogging(sp)andoptions.UseAuditLogging<TKey>(sp)are gone, and a new tenant-owned entity is always recorded with itsTenantId. A context that does not useUseTenantry()is not audited, nor are the saves the store makes. Entries reach the store once their changes are committed (AuditOptions.Timing,AuditTiming.AfterCommitby default): those of changes saved in a transaction (Database.BeginTransaction, shared with other contexts throughUseTransactionor not, aTransactionScope, or an enlisted transaction) when it commits, through whichever context, and none if it rolls back, or for changes rolled back to a savepoint. They were passed at the end of eachSaveChanges, so a transaction rolled back later still had its entries. A store failure is logged, as before, and the store gets a token that is never cancelled, so a save cancelled once its changes are committed keeps their entries (cancellation was caught and logged as a store failure). WithAuditTiming.InTransactionthe store is called at the end of eachSaveChanges, inside its transaction, to write through the same connection and transaction, and its failure or cancellation is thrown fromSaveChanges.IAuditStore.SaveAsynctakes the context that saved the changes first; the store comes from a scope of its own when the saving context's scope is gone by the time the transaction commits.AuditOptions.ExcludeTypesis replaced byExclude<T>(), which also leaves out the types derived fromT(it matched the mapped type exactly) and the entities an excluded type owns,ExcludeProperty<T>(x => x.Property)and aShouldAuditpredicate, each applied before an entity's values are read (they were copied first).AuditEntryhas a requiredEntityType, so code that creates entries (a store's tests, say) sets it, and the default store's log message names the actor. See Added for who made the change. -
Breaking:
ILicenseGuardis internal: the licensed operations check the licence themselves. -
Breaking: the Hangfire, MassTransit, Quartz.NET and Rebus integrations are each added with one builder method and wired with one call on the host library's configuration:
pro.AddHangfirePropagation()andconfig.UseTenantry(sp)inAddHangfire((sp, config) => …)(wereAddHangfireTenantFilter()andapp.UseTenantryHangfire());pro.AddMassTransitPropagation()andcfg.UseTenantry(context)(wereAddMassTransitTenantFilters(),x.AddTenantryConsumeFilter()andcfg.UseTenantryPro(ctx));pro.AddQuartzPropagation()andq.UseTenantry()inAddQuartz(wasAddQuartzTenantScope(), which had to come afterAddQuartz; the two now come in either order);pro.AddRebusPropagation()ando.UseTenantry(sp)(wereAddRebusTenantSteps()ando.UseTenantryPro(sp)). The application fails to start if an integration was added but its host side never ran, which left its jobs or messages without their tenant, or, with several MassTransit buses (MultiBus) or Rebus buses (Rebus.ServiceProvider'sAddRebus(…, key: …)), if it did not run for each. The filters, steps and job factory are internal and need no key type, so the generic overloads are gone, and so areTenantContextPropagation,TenantPropagationOutcomeandTenantPropagationDecision. The job parameter, header and job data key isTenantPropagation.HeaderName, with the same value,tenantry-tenant-id(TenantPublishFilter<TKey>.HeaderKey,TenantOutgoingStep<TKey>.HeaderKeyandTenantJobData.TenantIdKeyare gone). The host-side methods are in the host libraries' namespaces (Hangfire,MassTransit,Quartz,Rebus.Config, withWithTenantinQuartz) and the builder methods inMicrosoft.Extensions.DependencyInjection, so they need nousing.Tenantry.Pro.Hangfireno longer needs the ASP.NET Core shared framework, so a worker service can use it. Quartz.NET'sWithTenantrefuses the key type's default value (Guid.Empty,0) and an empty string, which Tenantry reserves for no tenant. -
Breaking: a job or message that carries a tenant the store does not have, or an id that is not a valid tenant id, follows a setting of its own,
TenantPropagationOptions.OnUnresolvedTenant, which isRejectby default: it fails, so the host's retry and error handling take over (withTenantNotFoundExceptionfor a tenant the store does not have). It ran without a tenant, with a warning, as a job or message that carries no tenant still does (OnMissingTenant,Warnby default). -
Every log message has an event id that does not change between versions, listed in
docs/telemetry.md: 30xx the licence, 31xx provisioning, 32xx background services, 33xx connection strings, 34xx jobs and messages (the integrations' messages, which also name the job or message), and Tenantry.Pro.EfCore's 4xxx (provisioning databases and schemas, migrations, health checks, audit). Alert on the licence errors, on 4103 and 4113 (migration runs with failures) and on 4302 (lost audit entries). -
The provisioning steps that create databases and schemas use the application's
IDbContextFactory<TContext>when the context cannot be created in the tenant's scope, as migrations do, so Tenantry core's asynchronous connection strings work there too. -
The licence check is one internal service, which the startup check and the guarded operations share. A key that is not valid is logged once, with the reason.
-
Tenantry.Prodepends onMicrosoft.Extensions.DependencyInjection.Abstractions, not the DI container. -
Only the packages that use
Tenantry.Pro's internals (Tenantry.Pro.EfCoreand the Hangfire, MassTransit, Quartz.NET and Rebus packages) depend on exactly their own release of it;Tenantry.Pro.AspNetCoretakes it up to the next minor.Microsoft.Extensions.Diagnostics.HealthCheckstakes 10.0.0 or later on .NET 10, as the otherMicrosoft.Extensionspackages do. -
Breaking: the Hangfire and Rebus packages no longer depend on
Newtonsoft.Json, which they referenced only to raise a vulnerable version Hangfire and Rebus allow.Tenantry.Pro.Rebusneeds Rebus 8.4 or later, whose .NET build requires a fixed version itself (earlier 8.x releases allow a vulnerableNewtonsoft.JsonorSystem.Text.Json). Hangfire allows the vulnerable 11.0.1: an application using Hangfire referencesNewtonsoft.Json13.0.1 or later itself (the Hangfire guide says so). -
The database health check reports
Degradedby default when a tenant database is unreachable (thefailureStatusargument; it wasUnhealthy): it is a monitoring check, and one tenant's outage is not the application's. ASP.NET Core answersDegradedwith200, so a monitor that reads only the status code should map it to503on the monitoring endpoint, as the guide shows. -
The health checks are for monitoring, not liveness or readiness probes: one unreachable tenant database fails a probe on every replica at once. The guide recommended the
tenantry-tagged checks for readiness; it now shows separate liveness, readiness and monitoring endpoints, and how to protect the monitoring one (the per-tenant data lists tenant ids and provider error messages). TheTenantHealthCheckssample maps/health/liveand/health/tenants. -
The docs say the tenant store must list every tenant, suspended ones included:
MigrateTenantAsynclooks tenants up there, and migration runs, migration status and the health checks cover only the tenants it lists. Refuse suspended tenants with an access validator, and check the status in background jobs, which Tenantry does not do. Keep suspended tenants' databases online. -
The docs now cover: EF Core 9 applying all pending migrations in one transaction; concurrent runners on PostgreSQL with EF Core 10+, where EF Core releases its lock between migrations, so two runners can each apply some and one fails; the permission to create databases that provisioning needs, and provisioning from a separate process (which must grant the application access); EF Core's
Migratecreating a missing database; and reading the logs or the migration status after a cancelled run. -
The samples are named
Tenantry.Pro.Samples.<Name>, for their folder, project and namespace, as Tenantry Core's areTenantry.Samples.<Name>. The Hangfire, MassTransit, Quartz.NET and Rebus samples declared their types in those libraries' own namespaces:Hangfire.BackgroundJobsis nowHangfireJobs,MassTransit.MessagingMassTransitMessaging,Quartz.SchedulingQuartzSchedulingandRebus.MessagingRebusMessaging.HealthChecks, the root namespace of the AspNetCore.HealthChecks packages, isTenantHealthChecks, andSchemaPerTenant.NpgsqlisSchemaPerTenantPostgreSql; the others drop the dot (DatabasePerTenant.MySqlisDatabasePerTenantMySql). -
The assemblies carry their PDBs, so stack traces from Tenantry.Pro code show file names and line numbers. There are no symbol packages any more.
-
The packages have an icon, the project URL (tenantry.dev) and a copyright notice, and their descriptions match what each package holds. Packing checks that each target framework's assembly keeps the API of the lower ones (package validation).
-
A GitHub release's notes are its section of this changelog, and the release publishes this changelog with its docs, for the Changelog page of the site's docs.
Removed
- Breaking:
Tenantry.Pro.HealthChecks: its checks are inTenantry.Pro.EfCore(see Changed). ReferenceTenantry.Pro.EfCoreinstead. - Breaking:
WithMigrationOrchestration,MigrationOrchestratorService<TKey, TContext>,MigrationStatusTracker<TKey, TContext>and theFunc<string, TContext>they registered; see Changed. - Breaking:
Tenantry.Pro.EfCore.SqlServer,Tenantry.Pro.EfCore.NpgsqlandTenantry.Pro.EfCore.MySql:Tenantry.Pro.EfCoreprovisions with whichever EF Core provider your context uses (see Changed). ReferenceTenantry.Pro.EfCoreinstead. Tenantry.Pro no longer depends onMicrosoft.Data.SqlClient,Npgsql,MySqlConnectoror theAzure.IdentityandMicrosoft.Identity.Clientversions it pinned for SqlClient. - Breaking: the
Tenantry.Prometer (TenantryMeter) and itstenantry.requests.*instruments, which repeated ASP.NET Core's request metrics under other names and units; see Changed for thetenant.idtag that replaces them. Instruments of your own belong on a meter of your own. - Breaking: connection-string encryption:
ConnectionStringEncryptionOptions,ConnectionStringEncryptionMode,IConnectionStringProtector, the AES protector andTenantry.Pro.AspNetCore'sUseDataProtectionEncryption(). It encrypted only the in-process cache, with a key held by the same process, and every read handed the plain connection string to EF Core and the ADO.NET pool, so it protected nothing.
Earlier releases are in the full changelog.