0
kberntsen created
Issue reported against the framework in gh repo
https://github.com/abpframework/abp/issues/26208
Bug: Global settings unique index doesn't prevent duplicates when ProviderKey is NULL (SQL Server)
Summary
SettingManagementDbContextModelBuilderExtensions.ConfigureSettingManagement defines a unique
index on AbpSettings(Name, ProviderName, ProviderKey):
b.HasIndex(x => new { x.Name, x.ProviderName, x.ProviderKey }).IsUnique(true);
For tenant-scoped settings ( ProviderKey = <tenantId> ), this correctly prevents duplicate
rows. For global/host-scoped settings ( ProviderName = "G" , ProviderKey = NULL ), the
constraint silently fails to prevent duplicates on SQL Server, because SQL Server (like most
RDBMS unique-index implementations) treats each NULL as distinct from every other NULL for
uniqueness purposes — i.e. (Name='Foo', ProviderName='G', ProviderKey=NULL) and a second
identical row do not violate the unique constraint.
SettingManager.SetGlobalAsync / SetForTenantAsync performs a check-then-insert rather than an
atomic upsert. Under concurrent calls (e.g. two near-simultaneous requests that both persist the
same global setting before either commit), this produces two rows with the same
(Name, ProviderName, ProviderKey=NULL) and no DB-level guard stops it.
Impact
Once two duplicate global setting rows exist, every subsequent read of that setting throws,
because SettingManagementStore.SetCacheItemsAsync builds the settings cache via:
settingValues.ToDictionary(x => x.Name, x => x.Value)
ToDictionary throws ArgumentException: An item with the same key has already been added on
the duplicate Name . This is a hard, unrecoverable 500 on every request that reads that
setting's cache — in our case it broke /Account/Login for the entire application (through
ExternalProviderSettingsHelper.GetAllForHostAsync() → SettingManager.GetOrNullAsync →
SettingManagementStore.GetCacheItemAsync ), until the duplicate row was manually deleted from
the database.
Repro
1. Trigger two concurrent calls to ISettingManager.SetGlobalAsync("MyModule.MySetting", value)
for a setting that does not yet have a global row (e.g. two app-service requests racing on
first save, or a distributed deployment with two pods handling near-simultaneous requests).
2. Both calls read "no existing row", then both insert.
3. Query AbpSettings — two rows exist with identical Name / ProviderName='G' / ProviderKey=NULL .
4. Any subsequent call to read that setting (directly or via any code path that populates the
settings cache for that provider/providerKey) throws ArgumentException and surfaces as a 500.
Environment
• ABP Framework (Volo.Abp.SettingManagement / Volo.Abp.SettingManagement.EntityFrameworkCore) 10.6.0
• SQL Server (Azure SQL), EF Core provider
• .NET 10
Suggested Fix
Add a filtered unique index for the null- ProviderKey case (SQL Server HasFilter ), in addition
to the existing index, e.g.:
b.HasIndex(x => new { x.Name, x.ProviderName })
.IsUnique()
.HasFilter("[ProviderKey] IS NULL");
Alternatively/additionally, SettingManager.SetGlobalAsync / SetForTenantAsync could perform an
atomic upsert (e.g. MERGE , or catch the unique-constraint violation and retry as an update)
rather than check-then-insert, to close the race condition at its source rather than only at the
DB-constraint level.
Workaround (applied)
Manually deleted the duplicate row from AbpSettings to unblock login. Adding an app-level
filtered unique index migration (scoped to our own DbContext) as defense-in-depth until this is
fixed upstream or we adopt the fix ourselves.
Markdown supported.
Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)