Treat an item deleted mid-save as a no-op when saving its images

This commit is contained in:
Shadowghost
2026-09-01 20:47:39 +02:00
parent c56e14d8fb
commit ccdc69e3b0
3 changed files with 100 additions and 9 deletions
@@ -176,14 +176,6 @@ public class ItemPersistenceService : IItemPersistenceService
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
if (!await context.BaseItems
.AnyAsync(bi => bi.Id == item.Id, cancellationToken)
.ConfigureAwait(false))
{
_logger.LogWarning("Unable to save ImageInfo for non existing BaseItem");
return;
}
await context.BaseItemImageInfos
.Where(e => e.ItemId == item.Id)
.ExecuteDeleteAsync(cancellationToken)
@@ -193,7 +185,26 @@ public class ItemPersistenceService : IItemPersistenceService
.AddRangeAsync(images, cancellationToken)
.ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
try
{
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException)
{
// Checking that the item exists before writing leaves a gap a scan can delete it
// through, turning the insert into a foreign key violation that fails the whole
// refresh instead of the no-op intended here. Let the insert be the check: it is the
// only point at which the answer cannot go stale. Nothing is orphaned by the delete
// above, because deleting the item cascades to its images anyway.
if (await context.BaseItems
.AnyAsync(bi => bi.Id == item.Id, cancellationToken)
.ConfigureAwait(false))
{
throw;
}
_logger.LogWarning("Unable to save ImageInfo for non existing BaseItem {ItemId}", item.Id);
}
}
}
@@ -0,0 +1,77 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Server.Implementations.Item;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Item;
public class ItemPersistenceServiceSaveImagesTests : SqliteDbTestFixture
{
private readonly ItemPersistenceService _service;
public ItemPersistenceServiceSaveImagesTests()
{
_service = new ItemPersistenceService(
CreateDbContextFactory(),
Mock.Of<IServerApplicationHost>(),
NullLogger<ItemPersistenceService>.Instance);
}
[Fact]
public async Task SaveImagesAsync_ReplacesThePreviousImages()
{
var itemId = Guid.NewGuid();
Seed(itemId);
await _service.SaveImagesAsync(CreateItem(itemId, "/first.jpg"), TestContext.Current.CancellationToken);
await _service.SaveImagesAsync(CreateItem(itemId, "/second.jpg"), TestContext.Current.CancellationToken);
using var context = CreateDbContext();
var paths = context.BaseItemImageInfos
.Where(e => e.ItemId.Equals(itemId))
.Select(e => e.Path)
.ToList();
Assert.Equal(["/second.jpg"], paths);
}
[Fact]
public async Task SaveImagesAsync_ItemDeletedFromUnderIt_IsANoOp()
{
// A scan can delete the item between the refresh reading it and the images being written. That
// must not fail the whole refresh, and must not leave the images of an item that is gone.
var itemId = Guid.NewGuid();
await _service.SaveImagesAsync(CreateItem(itemId, "/gone.jpg"), TestContext.Current.CancellationToken);
using var context = CreateDbContext();
Assert.Empty(context.BaseItemImageInfos.Where(e => e.ItemId.Equals(itemId)));
}
private static BaseItem CreateItem(Guid itemId, string imagePath)
=> new Folder
{
Id = itemId,
ImageInfos = [new ItemImageInfo { Path = imagePath, Type = ImageType.Primary }]
};
private void Seed(Guid itemId)
{
using var context = CreateDbContext();
context.BaseItems.Add(new BaseItemEntity
{
Id = itemId,
Type = "Folder",
IsFolder = true
});
context.SaveChanges();
}
}
@@ -1,4 +1,5 @@
using System;
using System.Threading;
using Emby.Server.Implementations.Data;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Locking;
@@ -58,6 +59,8 @@ public abstract class SqliteDbTestFixture : IDisposable
{
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateDbContext);
return factory.Object;
}