Skip to content

NullReferenceException in SortAndRemoveLoopEdges after a persist fails inside an entity constructor (NonTransactionalReads) #479

Open

Description

@selimhaddioui

Version

Xtensive.Orm.SqlServer 7.2.1 and 7.2.2, SQL Server (LocalDB 16), .NET 8.

Session options: LegacyProfile | NonTransactionalReads | AutoSaveChanges.

What happens

  1. In a transaction, many entities are pending, and one of them has a value too long for its column.
  2. Still in that transaction, the constructor of another entity runs a query. The query triggers an automatic persist, which fails (String or binary data would be truncated), so the constructor throws.
  3. The transaction is rolled back.
  4. In a new transaction of the same session, any query throws:
System.NullReferenceException: Object reference not set to an instance of an object.
   at Xtensive.Orm.Internals.SortingPersistActionGenerator.SortAndRemoveLoopEdges(IEnumerable`1 entityStates, Boolean rollbackDifferenceBeforeSort)
   at Xtensive.Orm.Internals.SortingPersistActionGenerator.GetDeleteSequence(IEnumerable`1 entityStates)+MoveNext()
   at Xtensive.Orm.Internals.SortingPersistActionGenerator.GetPersistSequence(EntityChangeRegistry registry)+MoveNext()
   at Xtensive.Orm.Providers.Persister.Persist(EntityChangeRegistry registry, CommandProcessor processor)
   at Xtensive.Orm.Providers.SqlSessionHandler.Persist(EntityChangeRegistry registry, Boolean allowPartialExecution)
   at Xtensive.Orm.Session.Persist(PersistReason reason, Boolean isAsync, CancellationToken token)
   at Xtensive.Orm.Session.Persist(PersistReason reason)

Expected: the session is usable again after the rollback.

The session stays broken: every later persist throws the same exception.

Reproduction

A console app referencing only Xtensive.Orm.SqlServer 7.2.1. Run it with an empty database.

using System;
using System.Linq;
using Xtensive.Orm;
using Xtensive.Orm.Configuration;

[HierarchyRoot]
public class Holder : Entity
{
  [Field, Key] public int Id { get; private set; }
  [Field(Length = 10)] public string Short { get; set; }
  [Field] public Built Built { get; set; }
}

[HierarchyRoot]
public class Built : Entity
{
  [Field, Key] public int Id { get; private set; }
  [Field] public Holder Holder { get; set; }

  public Built()
  {
    // Any query inside the constructor triggers the automatic persist.
    _ = Session.Query.All<Holder>().Count();
  }
}

public static class Program
{
  public static void Main()
  {
    var configuration = new DomainConfiguration("sqlserver", @"Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=XtRepro;Integrated Security=True")
    {
      UpgradeMode = DomainUpgradeMode.Recreate,
    };
    configuration.Types.Register(typeof(Holder).Assembly);
    configuration.Sessions.Add(new SessionConfiguration(WellKnown.Sessions.Default)
    {
      Options = SessionOptions.LegacyProfile | SessionOptions.NonTransactionalReads | SessionOptions.AutoSaveChanges,
    });
    using var domain = Domain.Build(configuration);

    using var session = domain.OpenSession();
    using (var transaction = session.OpenTransaction())
    {
      for (var i = 0; i < 30; i++)
        _ = new Holder { Short = "ok" };
      _ = new Holder { Short = new string('x', 11) };
      for (var i = 0; i < 30; i++)
        _ = new Holder { Short = "ok" };
      try
      {
        _ = new Built();
      }
      catch (StorageException)
      {
        // Expected: the persist failed inside Built's constructor. The transaction is rolled back.
      }
    }

    using (var transaction = session.OpenTransaction())
    {
      _ = session.Query.All<Holder>().Count(); // NullReferenceException
    }
  }
}

What we observed while narrowing it down:

Variant Result
As above, 7.2.1 or 7.2.2 NullReferenceException
Without NonTransactionalReads No error
Without the two reference fields No error
Without the 30 entities before the bad one No error: the failing insert is deferred and runs with the query, so persistingIsFailed stays false

After the rollback, EntityChangeRegistry still holds Built in its Removed set, with no tuple. That entity was never inserted.

Why, as far as we can read the code

  • Session.Persist (Session.Persist.cs, the finally around line 182): without NonTransactionalEntityStates, a failed persist still marks the New states as Synchronized and clears EntityChangeRegistry.
  • Entity.SystemInitializationError (Entity.cs, around line 612): Built's constructor failed, so its state is set to Removed and invalidated. Built is no longer in the New set, so EntityChangeRegistry.Register adds it to the Removed set.
  • Session.RollbackTransaction (Session.Transactions.cs, around line 315): the registry is cleared only if (!persistingIsFailed || !Configuration.Supports(SessionOptions.NonTransactionalReads)). The persist failed and the session has NonTransactionalReads, so the Removed state survives the rollback.
  • The next persist builds the delete sequence. SortAndRemoveLoopEdges reads the references of that invalidated state and throws.

The two conditions disagree: the persist decides on NonTransactionalEntityStates, the rollback on NonTransactionalReads. A session with NonTransactionalReads only falls between them.

Possible fixes

  • In RollbackTransaction, test NonTransactionalEntityStates like Persist does.
  • Or, in SystemInitializationError, drop from the registry a state that was never persisted, instead of registering it as Removed.

Activity

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