fix(devices): read devices and device options through to the database
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful

The device cache was filled once at construction, so a token minted by one
replica was unknown to every other replica already running and a token revoked
on one replica stayed valid on the others until they restarted.

- drop the eager device and device options dictionaries
- read devices and device options from the database on every query
- push the device query filters and ordering into SQL
- open the request's database context only for the api key fallback
- cover both directions against real PostgreSQL with two manager instances
This commit is contained in:
2026-09-13 13:08:47 +10:00
parent 25269263ce
commit 56919b9565
7 changed files with 406 additions and 177 deletions
@@ -31,8 +31,6 @@ namespace Jellyfin.Server.Implementations.Devices
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IUserManager _userManager;
private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new();
private readonly ConcurrentDictionary<int, Device> _devices;
private readonly ConcurrentDictionary<string, DeviceOptions> _deviceOptions;
/// <summary>
/// Initializes a new instance of the <see cref="DeviceManager"/> class.
@@ -43,23 +41,6 @@ namespace Jellyfin.Server.Implementations.Devices
{
_dbProvider = dbProvider;
_userManager = userManager;
_devices = new ConcurrentDictionary<int, Device>();
_deviceOptions = new ConcurrentDictionary<string, DeviceOptions>();
using var dbContext = _dbProvider.CreateDbContext();
foreach (var device in dbContext.Devices
.OrderBy(d => d.Id)
.AsEnumerable())
{
_devices.TryAdd(device.Id, device);
}
foreach (var deviceOption in dbContext.DeviceOptions
.OrderBy(d => d.Id)
.AsEnumerable())
{
_deviceOptions.TryAdd(deviceOption.DeviceId, deviceOption);
}
}
/// <inheritdoc />
@@ -89,8 +70,6 @@ namespace Jellyfin.Server.Implementations.Devices
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
_deviceOptions[deviceId] = deviceOptions;
DeviceOptionsUpdated?.Invoke(this, new GenericEventArgs<Tuple<string, DeviceOptions>>(new Tuple<string, DeviceOptions>(deviceId, deviceOptions)));
}
@@ -102,21 +81,24 @@ namespace Jellyfin.Server.Implementations.Devices
{
dbContext.Devices.Add(device);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
_devices.TryAdd(device.Id, device);
}
return device;
}
/// <inheritdoc />
public DeviceOptionsDto? GetDeviceOptions(string deviceId)
public async Task<DeviceOptionsDto?> GetDeviceOptions(string deviceId)
{
if (_deviceOptions.TryGetValue(deviceId, out var deviceOptions))
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
return ToDeviceOptionsDto(deviceOptions);
}
var deviceOptions = await dbContext.DeviceOptions
.AsNoTracking()
.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId)
.ConfigureAwait(false);
return null;
return deviceOptions is null ? null : ToDeviceOptionsDto(deviceOptions);
}
}
/// <inheritdoc />
@@ -133,43 +115,79 @@ namespace Jellyfin.Server.Implementations.Devices
}
/// <inheritdoc />
public DeviceInfoDto? GetDevice(string id)
public async Task<DeviceInfoDto?> GetDevice(string id)
{
var device = _devices.Values.Where(d => d.DeviceId == id).OrderByDescending(d => d.DateLastActivity).FirstOrDefault();
_deviceOptions.TryGetValue(id, out var deviceOption);
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var device = await dbContext.Devices
.AsNoTracking()
.Where(d => d.DeviceId == id)
.OrderByDescending(d => d.DateLastActivity)
.FirstOrDefaultAsync()
.ConfigureAwait(false);
var deviceInfo = device is null ? null : ToDeviceInfo(device, deviceOption);
return deviceInfo is null ? null : ToDeviceInfoDto(deviceInfo);
if (device is null)
{
return null;
}
var deviceOption = await dbContext.DeviceOptions
.AsNoTracking()
.FirstOrDefaultAsync(dev => dev.DeviceId == id)
.ConfigureAwait(false);
return ToDeviceInfoDto(ToDeviceInfo(device, deviceOption));
}
}
/// <inheritdoc />
public QueryResult<Device> GetDevices(DeviceQuery query)
public async Task<QueryResult<Device>> GetDevices(DeviceQuery query)
{
IEnumerable<Device> devices = _devices.Values
.Where(device => !query.UserId.HasValue || device.UserId.Equals(query.UserId.Value))
.Where(device => query.DeviceId is null || device.DeviceId == query.DeviceId)
.Where(device => query.AccessToken is null || device.AccessToken == query.AccessToken)
.OrderBy(d => d.Id)
.ToList();
var count = devices.Count();
if (query.Skip.HasValue)
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
devices = devices.Skip(query.Skip.Value);
}
IQueryable<Device> filtered = dbContext.Devices.AsNoTracking();
if (query.Limit.HasValue && query.Limit.Value > 0)
{
devices = devices.Take(query.Limit.Value);
}
if (query.UserId.HasValue)
{
filtered = filtered.Where(device => device.UserId.Equals(query.UserId.Value));
}
return new QueryResult<Device>(query.Skip, count, devices.ToList());
if (query.DeviceId is not null)
{
filtered = filtered.Where(device => device.DeviceId == query.DeviceId);
}
if (query.AccessToken is not null)
{
filtered = filtered.Where(device => device.AccessToken == query.AccessToken);
}
// Every filter is an exact match on an indexed column and the table holds one row per
// user and device, so paging the materialised set costs less than a second round trip.
var matched = await filtered.OrderBy(d => d.Id).ToListAsync().ConfigureAwait(false);
IEnumerable<Device> devices = matched;
if (query.Skip.HasValue)
{
devices = devices.Skip(query.Skip.Value);
}
if (query.Limit.HasValue && query.Limit.Value > 0)
{
devices = devices.Take(query.Limit.Value);
}
return new QueryResult<Device>(query.Skip, matched.Count, devices.ToList());
}
}
/// <inheritdoc />
public QueryResult<DeviceInfo> GetDeviceInfos(DeviceQuery query)
public async Task<QueryResult<DeviceInfo>> GetDeviceInfos(DeviceQuery query)
{
var devices = GetDevices(query);
var devices = await GetDevices(query).ConfigureAwait(false);
return new QueryResult<DeviceInfo>(
devices.StartIndex,
@@ -178,38 +196,49 @@ namespace Jellyfin.Server.Implementations.Devices
}
/// <inheritdoc />
public QueryResult<DeviceInfoDto> GetDevicesForUser(Guid? userId)
public async Task<QueryResult<DeviceInfoDto>> GetDevicesForUser(Guid? userId)
{
IEnumerable<Device> devices = _devices.Values
.OrderByDescending(d => d.DateLastActivity)
.ThenBy(d => d.DeviceId);
if (!userId.IsNullOrEmpty())
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var user = _userManager.GetUserById(userId.Value);
if (user is null)
IEnumerable<Device> devices = await dbContext.Devices
.AsNoTracking()
.OrderByDescending(d => d.DateLastActivity)
.ThenBy(d => d.DeviceId)
.ToListAsync()
.ConfigureAwait(false);
if (!userId.IsNullOrEmpty())
{
throw new ResourceNotFoundException();
var user = _userManager.GetUserById(userId.Value);
if (user is null)
{
throw new ResourceNotFoundException();
}
devices = devices.Where(i => CanAccessDevice(user, i.DeviceId));
}
devices = devices.Where(i => CanAccessDevice(user, i.DeviceId));
var options = await dbContext.DeviceOptions
.AsNoTracking()
.ToDictionaryAsync(dev => dev.DeviceId)
.ConfigureAwait(false);
var array = devices.Select(device =>
{
options.TryGetValue(device.DeviceId, out var option);
return ToDeviceInfo(device, option);
})
.Select(ToDeviceInfoDto)
.ToArray();
return new QueryResult<DeviceInfoDto>(array);
}
var array = devices.Select(device =>
{
_deviceOptions.TryGetValue(device.DeviceId, out var option);
return ToDeviceInfo(device, option);
})
.Select(ToDeviceInfoDto)
.ToArray();
return new QueryResult<DeviceInfoDto>(array);
}
/// <inheritdoc />
public async Task DeleteDevice(Device device)
{
_devices.TryRemove(device.Id, out _);
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
@@ -229,8 +258,6 @@ namespace Jellyfin.Server.Implementations.Devices
dbContext.Devices.Update(device);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
_devices[device.Id] = device;
}
/// <inheritdoc />