Repository navigation
EF Core: returning Update<T> or Store<T> saves nothing for an entity the DbContext does not track #4613
Description
With EF Core, whether a returned storage action is saved depends on the handler's declared return type. IStorageAction<T> attaches the entity with DbContext.Update(). Update<T> and Store<T> do nothing, so an entity that is not already tracked is never saved and no error is raised. That covers an entity loaded with AsNoTracking() (or from a DbContext that defaults to no-tracking), built from the message, or taken from the request body. Store<T> of a new entity never inserts it, although the EF Core operations guide uses it to create a Todo.
Reproduction
Tested on 6.40.0 (default Eager transaction mode) against PostgreSQL 17:
using JasperFx;
using JasperFx.Resources;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Npgsql;
using Wolverine;
using Wolverine.EntityFrameworkCore;
using Wolverine.Persistence;
using Wolverine.Postgresql;
const string cs = "Host=localhost;Port=5499;Database=postgres;Username=postgres;Password=postgres";
using var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.PersistMessagesWithPostgresql(cs, "wolverine");
opts.Services.AddDbContextWithWolverineIntegration<ItemsDbContext>(o => o.UseNpgsql(cs), "wolverine");
opts.UseEntityFrameworkCoreTransactions();
opts.UseEntityFrameworkCoreWolverineManagedMigrations();
opts.Policies.AutoApplyTransactions();
opts.Services.AddResourceSetupOnStartup();
}).StartAsync();
var bus = host.MessageBus();
await using var conn = new NpgsqlConnection(cs);
await conn.OpenAsync();
async Task<object?> NameOf(Guid id) =>
await new NpgsqlCommand($"select \"Name\" from return_type_items where \"Id\" = '{id}'", conn).ExecuteScalarAsync();
Guid a = Guid.NewGuid(), b = Guid.NewGuid(), c = Guid.NewGuid(), d = Guid.NewGuid();
foreach (var id in new[] { a, b, c }) await bus.InvokeAsync(new CreateItem(id));
await bus.InvokeAsync(new RenameViaUpdate(a, "renamed"));
await bus.InvokeAsync(new RenameViaStorageAction(b, "renamed"));
await bus.InvokeAsync(new RenameViaStore(c, "renamed"));
await bus.InvokeAsync(new CreateViaStore(d, "new"));
Console.WriteLine($"Update<T> rename: name = {await NameOf(a)}");
Console.WriteLine($"IStorageAction<T> rename: name = {await NameOf(b)}");
Console.WriteLine($"Store<T> rename: name = {await NameOf(c)}");
Console.WriteLine($"Store<T> create: name = {await NameOf(d) ?? "(no row)"}");
public record CreateItem(Guid Id);
public record RenameViaUpdate(Guid Id, string Name);
public record RenameViaStorageAction(Guid Id, string Name);
public record RenameViaStore(Guid Id, string Name);
public record CreateViaStore(Guid Id, string Name);
public class Item
{
public Guid Id { get; set; }
public string Name { get; set; } = "original";
}
public class ItemsDbContext(DbContextOptions<ItemsDbContext> options) : DbContext(options)
{
public DbSet<Item> Items => Set<Item>();
protected override void OnModelCreating(ModelBuilder b) => b.Entity<Item>().ToTable("return_type_items");
}
// Every entity below is built from the message, so the DbContext does not track it
// (the same as an entity loaded with AsNoTracking)
public static class ItemHandler
{
public static Insert<Item> Handle(CreateItem cmd) => Storage.Insert(new Item { Id = cmd.Id });
public static Update<Item> Handle(RenameViaUpdate cmd) => Storage.Update(new Item { Id = cmd.Id, Name = cmd.Name });
public static IStorageAction<Item> Handle(RenameViaStorageAction cmd) => Storage.Update(new Item { Id = cmd.Id, Name = cmd.Name });
public static Store<Item> Handle(RenameViaStore cmd) => Storage.Store(new Item { Id = cmd.Id, Name = cmd.Name });
public static Store<Item> Handle(CreateViaStore cmd) => Storage.Store(new Item { Id = cmd.Id, Name = cmd.Name });
}Packages: WolverineFx.EntityFrameworkCore, WolverineFx.Postgresql, WolverineFx.RuntimeCompilation, Npgsql.EntityFrameworkCore.PostgreSQL 10.0.2, on net10.0.
Update<T> rename: name = original
IStorageAction<T> rename: name = renamed
Store<T> rename: name = original
Store<T> create: name = (no row)
Generated code for the Update<T> and IStorageAction<T> handlers (abridged):
// Update<Item>
var outgoing1 = ItemHandler.Handle(renameViaUpdate);
// No explicit update necessary with EF Core without a Version property
var result_of_SaveChangesAsync = await itemsDbContext.SaveChangesAsync(cancellation).ConfigureAwait(false);
// IStorageAction<Item>
var outgoing1 = ItemHandler.Handle(renameViaStorageAction);
await Wolverine.EntityFrameworkCore.Codegen.EfCoreStorageActionApplier.ApplyAction<Item, ItemsDbContext>(itemsDbContext, outgoing1).ConfigureAwait(false);
var result_of_SaveChangesAsync = await itemsDbContext.SaveChangesAsync(cancellation).ConfigureAwait(false);Cause
Update<T>.BuildFrame and Store<T>.BuildFrame call the provider's saga frames. For EF Core, DetermineStoreFrame forwards to DetermineUpdateFrame, which returns a CommentFrame unless the type has a Version property. That is correct for a saga the handler loaded (and so tracks), but not for an arbitrary entity. IStorageAction<T> goes through EfCoreStorageActionApplier instead, which calls context.Update() for untracked entities.
#3229 describes the intended behaviour ("Untracked (detached) entities … still go through DbContext.Update()"), but it only changed EfCoreStorageActionApplier, which an Update<T> return type never reaches.
StorageActionCompliance does not catch this for EF Core: its Update<T> tests load the entity with [Entity] (FindAsync, which tracks), and no test sends CreateTodo2.
Expected: Update<T> behaves like the same value returned as IStorageAction<T> and attaches an untracked entity with DbContext.Update(). Store<T> inserts a new entity and updates an existing one. The IStorageAction<T> path does not get Store right either: its Store case calls context.Update() ("Not really correct, but let it go"), so Storage.Store(newEntity) returned as IStorageAction<T> throws DbUpdateConcurrencyException instead of inserting.
Workaround: declare the return type as IStorageAction<T> for updates, and use Insert<T> rather than Store<T> for new entities.
Activity
GH-4611/4612/4613: make EF Core storage actions and conjoined tenancy…