Skip to content

EF Core: returning Update<T> or Store<T> saves nothing for an entity the DbContext does not track #4613

Closed

Description

@ayuksekkaya

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

  1. added a commit that references this issue on Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions