diff --git a/Directory.Packages.props b/Directory.Packages.props
index 143fa8e63d..0c6f942479 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -58,6 +58,7 @@
+
diff --git a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs
index fbbb5bca73..392a8de2cb 100644
--- a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs
+++ b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Reflection;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
+using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
@@ -73,6 +74,7 @@ public static class ServiceCollectionExtensions
efCoreConfiguration = new DatabaseConfigurationOptions()
{
DatabaseType = "Jellyfin-SQLite",
+ LockingBehavior = DatabaseLockingBehaviorTypes.NoLock
};
configurationManager.SaveConfiguration("database", efCoreConfiguration);
}
@@ -85,10 +87,25 @@ public static class ServiceCollectionExtensions
serviceCollection.AddSingleton(providerFactory!);
+ switch (efCoreConfiguration.LockingBehavior)
+ {
+ case DatabaseLockingBehaviorTypes.NoLock:
+ serviceCollection.AddSingleton();
+ break;
+ case DatabaseLockingBehaviorTypes.Pessimistic:
+ serviceCollection.AddSingleton();
+ break;
+ case DatabaseLockingBehaviorTypes.Optimistic:
+ serviceCollection.AddSingleton();
+ break;
+ }
+
serviceCollection.AddPooledDbContextFactory((serviceProvider, opt) =>
{
var provider = serviceProvider.GetRequiredService();
provider.Initialise(opt);
+ var lockingBehavior = serviceProvider.GetRequiredService();
+ lockingBehavior.Initialise(opt);
});
return serviceCollection;
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index 4e898119b7..be58e2a527 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -54,7 +54,7 @@ public class PeopleRepository(IDbContextFactory dbProvider, I
public IReadOnlyList GetPeopleNames(InternalPeopleQuery filter)
{
using var context = _dbProvider.CreateDbContext();
- var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter);
+ var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter).Select(e => e.Name).Distinct();
// dbQuery = dbQuery.OrderBy(e => e.ListOrder);
if (filter.Limit > 0)
@@ -62,7 +62,7 @@ public class PeopleRepository(IDbContextFactory dbProvider, I
dbQuery = dbQuery.Take(filter.Limit);
}
- return dbQuery.Select(e => e.Name).ToArray();
+ return dbQuery.ToArray();
}
///
@@ -141,8 +141,13 @@ public class PeopleRepository(IDbContextFactory dbProvider, I
if (filter.User is not null && filter.IsFavorite.HasValue)
{
var personType = itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
- query = query
- .Where(e => context.BaseItems.Any(b => b.Type == personType && b.Name == e.Name && b.UserData!.Any(u => u.IsFavorite == filter.IsFavorite && u.UserId.Equals(filter.User.Id))));
+ var oldQuery = query;
+
+ query = context.UserData
+ .Where(u => u.Item!.Type == personType && u.IsFavorite == filter.IsFavorite && u.UserId.Equals(filter.User.Id))
+ .Join(oldQuery, e => e.Item!.Name, e => e.Name, (item, person) => person)
+ .Distinct()
+ .AsNoTracking();
}
if (!filter.ItemId.IsEmpty())
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseConfigurationOptions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseConfigurationOptions.cs
index b481a106fd..682e5019bb 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseConfigurationOptions.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseConfigurationOptions.cs
@@ -9,4 +9,10 @@ public class DatabaseConfigurationOptions
/// Gets or Sets the type of database jellyfin should use.
///
public required string DatabaseType { get; set; }
+
+ ///
+ /// Gets or Sets the kind of locking behavior jellyfin should perform. Possible options are "NoLock", "Pessimistic", "Optimistic".
+ /// Defaults to "NoLock".
+ ///
+ public DatabaseLockingBehaviorTypes LockingBehavior { get; set; }
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseLockingBehaviorTypes.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseLockingBehaviorTypes.cs
new file mode 100644
index 0000000000..3b2a55802e
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseLockingBehaviorTypes.cs
@@ -0,0 +1,22 @@
+namespace Jellyfin.Database.Implementations.DbConfiguration;
+
+///
+/// Defines all possible methods for locking database access for concurrent queries.
+///
+public enum DatabaseLockingBehaviorTypes
+{
+ ///
+ /// Defines that no explicit application level locking for reads and writes should be done and only provider specific locking should be relied on.
+ ///
+ NoLock = 0,
+
+ ///
+ /// Defines a behavior that always blocks all reads while any one write is done.
+ ///
+ Pessimistic = 1,
+
+ ///
+ /// Defines that all writes should be attempted and when fail should be retried.
+ ///
+ Optimistic = 2
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj
index 356f96fc90..28c4972d21 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj
@@ -24,6 +24,7 @@
+
all
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs
index 35ad461ec7..5163bff8b6 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs
@@ -1,9 +1,14 @@
using System;
+using System.Data.Common;
using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Entities.Security;
using Jellyfin.Database.Implementations.Interfaces;
+using Jellyfin.Database.Implementations.Locking;
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Database.Implementations;
@@ -15,7 +20,8 @@ namespace Jellyfin.Database.Implementations;
/// The database context options.
/// Logger.
/// The provider for the database engine specific operations.
-public class JellyfinDbContext(DbContextOptions options, ILogger logger, IJellyfinDatabaseProvider jellyfinDatabaseProvider) : DbContext(options)
+/// The locking behavior.
+public class JellyfinDbContext(DbContextOptions options, ILogger logger, IJellyfinDatabaseProvider jellyfinDatabaseProvider, IEntityFrameworkCoreLockingBehavior entityFrameworkCoreLocking) : DbContext(options)
{
///
/// Gets the containing the access schedules.
@@ -247,7 +253,50 @@ public class JellyfinDbContext(DbContextOptions options, ILog
public DbSet TrackMetadata => Set();*/
///
- public override int SaveChanges()
+ public override async Task SaveChangesAsync(
+ bool acceptAllChangesOnSuccess,
+ CancellationToken cancellationToken = default)
+ {
+ HandleConcurrencyToken();
+
+ try
+ {
+ var result = -1;
+ await entityFrameworkCoreLocking.OnSaveChangesAsync(this, async () =>
+ {
+ result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken).ConfigureAwait(false);
+ }).ConfigureAwait(false);
+ return result;
+ }
+ catch (Exception e)
+ {
+ logger.LogError(e, "Error trying to save changes.");
+ throw;
+ }
+ }
+
+ ///
+ public override int SaveChanges(bool acceptAllChangesOnSuccess) // SaveChanges(bool) is beeing called by SaveChanges() with default to false.
+ {
+ HandleConcurrencyToken();
+
+ try
+ {
+ var result = -1;
+ entityFrameworkCoreLocking.OnSaveChanges(this, () =>
+ {
+ result = base.SaveChanges(acceptAllChangesOnSuccess);
+ });
+ return result;
+ }
+ catch (Exception e)
+ {
+ logger.LogError(e, "Error trying to save changes.");
+ throw;
+ }
+ }
+
+ private void HandleConcurrencyToken()
{
foreach (var saveEntity in ChangeTracker.Entries()
.Where(e => e.State == EntityState.Modified)
@@ -256,16 +305,6 @@ public class JellyfinDbContext(DbContextOptions options, ILog
{
saveEntity.OnSavingChanges();
}
-
- try
- {
- return base.SaveChanges();
- }
- catch (Exception e)
- {
- logger.LogError(e, "Error trying to save changes.");
- throw;
- }
}
///
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/IEntityFrameworkCoreLockingBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/IEntityFrameworkCoreLockingBehavior.cs
new file mode 100644
index 0000000000..465c312122
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/IEntityFrameworkCoreLockingBehavior.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+
+namespace Jellyfin.Database.Implementations.Locking;
+
+///
+/// Defines a jellyfin locking behavior that can be configured.
+///
+public interface IEntityFrameworkCoreLockingBehavior
+{
+ ///
+ /// Provides access to the builder to setup any connection related locking behavior.
+ ///
+ /// The options builder.
+ void Initialise(DbContextOptionsBuilder optionsBuilder);
+
+ ///
+ /// Will be invoked when changes should be saved in the current locking behavior.
+ ///
+ /// The database context invoking the action.
+ /// Callback for performing the actual save changes.
+ void OnSaveChanges(JellyfinDbContext context, Action saveChanges);
+
+ ///
+ /// Will be invoked when changes should be saved in the current locking behavior.
+ ///
+ /// The database context invoking the action.
+ /// Callback for performing the actual save changes.
+ /// A representing the asynchronous operation.
+ Task OnSaveChangesAsync(JellyfinDbContext context, Func saveChanges);
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/NoLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/NoLockBehavior.cs
new file mode 100644
index 0000000000..3b654f4c46
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/NoLockBehavior.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Database.Implementations.Locking;
+
+///
+/// Default lock behavior. Defines no explicit application locking behavior.
+///
+public class NoLockBehavior : IEntityFrameworkCoreLockingBehavior
+{
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The Application logger.
+ public NoLockBehavior(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ ///
+ public void OnSaveChanges(JellyfinDbContext context, Action saveChanges)
+ {
+ saveChanges();
+ }
+
+ ///
+ public void Initialise(DbContextOptionsBuilder optionsBuilder)
+ {
+ _logger.LogInformation("The database locking mode has been set to: NoLock.");
+ }
+
+ ///
+ public async Task OnSaveChangesAsync(JellyfinDbContext context, Func saveChanges)
+ {
+ await saveChanges().ConfigureAwait(false);
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs
new file mode 100644
index 0000000000..9395b2e2dd
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs
@@ -0,0 +1,137 @@
+using System;
+using System.Data.Common;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.Extensions.Logging;
+using Polly;
+
+namespace Jellyfin.Database.Implementations.Locking;
+
+///
+/// Defines a locking mechanism that will retry any write operation for a few times.
+///
+public class OptimisticLockBehavior : IEntityFrameworkCoreLockingBehavior
+{
+ private readonly Policy _writePolicy;
+ private readonly AsyncPolicy _writeAsyncPolicy;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The application logger.
+ public OptimisticLockBehavior(ILogger logger)
+ {
+ TimeSpan[] sleepDurations = [
+ TimeSpan.FromMilliseconds(50),
+ TimeSpan.FromMilliseconds(50),
+ TimeSpan.FromMilliseconds(250),
+ TimeSpan.FromMilliseconds(150),
+ TimeSpan.FromMilliseconds(500),
+ TimeSpan.FromMilliseconds(500),
+ TimeSpan.FromSeconds(3)
+ ];
+ _logger = logger;
+ _writePolicy = Policy.HandleInner(e => e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase)).WaitAndRetry(sleepDurations, RetryHandle);
+ _writeAsyncPolicy = Policy.HandleInner(e => e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase)).WaitAndRetryAsync(sleepDurations, RetryHandle);
+
+ void RetryHandle(Exception exception, TimeSpan timespan, int retryNo, Context context)
+ {
+ if (retryNo < sleepDurations.Length)
+ {
+ _logger.LogWarning("Operation failed retry {RetryNo}", retryNo);
+ }
+ else
+ {
+ _logger.LogError(exception, "Operation failed retry {RetryNo}", retryNo);
+ }
+ }
+ }
+
+ ///
+ public void Initialise(DbContextOptionsBuilder optionsBuilder)
+ {
+ _logger.LogInformation("The database locking mode has been set to: Optimistic.");
+ optionsBuilder.AddInterceptors(new RetryInterceptor(_writeAsyncPolicy, _writePolicy));
+ optionsBuilder.AddInterceptors(new TransactionLockingInterceptor(_writeAsyncPolicy, _writePolicy));
+ }
+
+ ///
+ public void OnSaveChanges(JellyfinDbContext context, Action saveChanges)
+ {
+ _writePolicy.ExecuteAndCapture(saveChanges);
+ }
+
+ ///
+ public async Task OnSaveChangesAsync(JellyfinDbContext context, Func saveChanges)
+ {
+ await _writeAsyncPolicy.ExecuteAndCaptureAsync(saveChanges).ConfigureAwait(false);
+ }
+
+ private sealed class TransactionLockingInterceptor : DbTransactionInterceptor
+ {
+ private readonly AsyncPolicy _asyncRetryPolicy;
+ private readonly Policy _retryPolicy;
+
+ public TransactionLockingInterceptor(AsyncPolicy asyncRetryPolicy, Policy retryPolicy)
+ {
+ _asyncRetryPolicy = asyncRetryPolicy;
+ _retryPolicy = retryPolicy;
+ }
+
+ public override InterceptionResult TransactionStarting(DbConnection connection, TransactionStartingEventData eventData, InterceptionResult result)
+ {
+ return InterceptionResult.SuppressWithResult(_retryPolicy.Execute(() => connection.BeginTransaction(eventData.IsolationLevel)));
+ }
+
+ public override async ValueTask> TransactionStartingAsync(DbConnection connection, TransactionStartingEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default)
+ {
+ return InterceptionResult.SuppressWithResult(await _asyncRetryPolicy.ExecuteAsync(async () => await connection.BeginTransactionAsync(eventData.IsolationLevel, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false));
+ }
+ }
+
+ private sealed class RetryInterceptor : DbCommandInterceptor
+ {
+ private readonly AsyncPolicy _asyncRetryPolicy;
+ private readonly Policy _retryPolicy;
+
+ public RetryInterceptor(AsyncPolicy asyncRetryPolicy, Policy retryPolicy)
+ {
+ _asyncRetryPolicy = asyncRetryPolicy;
+ _retryPolicy = retryPolicy;
+ }
+
+ public override InterceptionResult NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult result)
+ {
+ return InterceptionResult.SuppressWithResult(_retryPolicy.Execute(command.ExecuteNonQuery));
+ }
+
+ public override async ValueTask> NonQueryExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default)
+ {
+ return InterceptionResult.SuppressWithResult(await _asyncRetryPolicy.ExecuteAsync(async () => await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false));
+ }
+
+ public override InterceptionResult