1

Problem

If I have 2 or more students trying to register for the last remaining seat available for a classroom lesson and they save simultaneously, entering my RegisterStudent() controller method, the only certainty I have that the parent (YogabandEvent) entity property MaxSize will be maintained is when I fetch the parent, and I check the number of registrants already added is < MaxSize and if true, I add the new registrant.

But I have a concurrency problem where if 2+ students have fetched the parent (YogabandEvent) entity at the same time, getting a MaxSize of 9, and then add a new registrant I could end up with more than MaxSize (ex. 11+). So I have a concurrency issue!

Currently, I can either fetch the parent entity including the children (Registrants) add the new child (Registrant) to the parent and save or just insert a new Registrant entity into the Registrant table directly. But with either scenario, I could end up with a situation, stated above, where more students than the MaxSize are registered.

Question

How can I restrict the child (Registrant) entities to the MaxSize in the parent (YogabandEvent) entity using EF Core?

Here is my code - some properties left out for verbosity:

public class YogabandEvent : BaseEntity
{
    public YogabandEvent() 
    {
        Registrants = new List<Registrant>();
    }

    public DateTime Date { get; set; }
    public DateTime DateUtc { get; set; }
    public MaxSize MaxSize { get; set; }

    public ICollection<Registrant> Registrants { get; set; }
 }

 public class Registrant : BaseEntity
 {
     public RegistrantType RegistrantType { get; set; }
     public int UserId { get; set; }
     public User User { get; set; }
     public int EventId { get; set; }
     public YogabandEvent Event { get; set; }
 }

 public enum MaxSize
 {
     Five = 5,
     Ten = 10,
     Fifteen = 15,
     Twenty = 20,
     ThirtyFive = 35
 }
marc_s
  • 732,580
  • 175
  • 1,330
  • 1,459
chuckd
  • 13,460
  • 29
  • 152
  • 331
  • What about solving the problem at the database server side? You could create a trigger on Registrant table that prevents inserting more rows than configured – Jesús López Jul 02 '23 at 03:25
  • not going down that route, I'm sticking with EF Core code first. I'm looking into data validation with EF, but having problems getting any type of validation to trigger when I save. Following https://learn.microsoft.com/en-us/ef/ef6/saving/validation but I think it might only be for EF 6, as I'm using EF Core 7. – chuckd Jul 02 '23 at 03:35
  • Another way to solve it is: 1) add RegistrantCount and Version property to YogabandEvent. 2) Use Version as concurrency token. 3) update RegistrantCount of YogabantEvent when adding a new registrant. This will prevents from adding two or more registrant at the same time. If you receive a concurrency error then read YogabantEvent again and retry if RegistrantCount < MaxSize – Jesús López Jul 02 '23 at 03:36
  • according to this post there is no validation done in EF Core. https://stackoverflow.com/questions/43426175/entity-framework-core-doesnt-validate-data-when-saving – chuckd Jul 02 '23 at 03:51
  • No problem, you can do it without validation, just write the code in your controller – Jesús López Jul 02 '23 at 03:52
  • Maybe you can explain in an answer with text or some sudo code for explanation? – chuckd Jul 02 '23 at 03:54
  • and what do you mean version property to YogabandEvent? – chuckd Jul 02 '23 at 03:56
  • https://dotnetcoretutorials.com/rowversion-vs-concurrencytoken-in-entityframework-efcore/?utm_content=cmp-true – Jesús López Jul 02 '23 at 03:56
  • Thinking about your solution using a ConcurrencyToken, it seems like that could become a nightmare of exceptions, if I have dozens or hundreds of people trying to register for a class at once! Do you know of any other way to enforce a child entity row count? – chuckd Jul 02 '23 at 04:47
  • I don't think you would get such a nightmare. Let me do some maths. Assuming the code to add a new registrant takes 20ms it is possible to insert 500 registrants for the same event in 1 second without exceptions if you are lucky. If you are interested you can calculate the probability to have x exceptions doing probability math. – Jesús López Jul 02 '23 at 04:58

1 Answers1

1

First thing is enabling concurrency conflict detection on YogabandEvent entity.

Here you have the official documentation for it:

https://learn.microsoft.com/en-us/ef/core/saving/concurrency?tabs=data-annotations

In this case I'm going to propose you to use a concurrency token, it is just an integer property called Version than will increment whenever YogabandEvent entity is updated.

To detect concurrency conflicts when you add a new Registrant you need to update YogabandEvent, so add a new property to YogabantEvent called RegistrantCount that you will increment when adding new registrants.

public class YogabandEvent : BaseEntity
{
    public YogabandEvent() {
        Registrants = new List<Registrant>();
    }
    public DateTime Date { get; set; }
    public DateTime DateUtc { get; set; }
    public MaxSize MaxSize { get; set; }
    [ConcurrencyCheck]
    public int Version {get; set;}
    public int RegistrantCount {get; set;}
    public ICollection<Registrant> Registrants { get; set; }
 }

In your controller write the following:

var yogabandEvent = ReadYogabantEventFromContext();
yogabandEvent.RegistrantCount++;
yogabandEvent.Version++;
var registrant = CreateNewRegistrant();
addRegistrantToContext(registrant);

while (true)
{
    if (yogabandEvent.RegistrantCount >= yogabandEvent.MaxSize) 
    {
       throw new InvalidOperationException("Event max size reached");
    }
    try 
    {
       context.SaveChanges();
       break;
    }
    catch (DbUpdateConcurrencyException) 
    {
        ReloadYogabantEvent();
        yogabandEvent.RegistrantCount++;
        yogabandEvent.Version++;
    }
}
Jesús López
  • 8,338
  • 7
  • 40
  • 66
  • Thanks for the example, but why would I need both RegistrantCount and Version? Wouldn't I need only 1 of the 2, as they do the same thing? Could you please add some explanation to your answer to show why I need both? – chuckd Jul 02 '23 at 04:53
  • The version property is for enabling concurrency conflict detection and RegistrantCount has a meaning that you check to ensure MaxSize is not execced – Jesús López Jul 02 '23 at 05:00
  • also I don't think EF Core 7 has ConcurrencyCheck, the MS site you link only shows TimeStamp or Version. – chuckd Jul 02 '23 at 05:05
  • https://learn.microsoft.com/en-us/ef/core/saving/concurrency?tabs=data-annotations#application-managed-concurrency-tokens is for EF Core – Jesús López Jul 02 '23 at 05:06
  • oh never mind, my bad!! – chuckd Jul 02 '23 at 05:08
  • How do I know if the concurrency check is created on the 'version' in the DB, I don't see anything other than the columns in my migration file and no visible changes in the table other then the 2 new added columns (version, RegistrantsCount) – chuckd Jul 02 '23 at 05:15
  • The concurrency check attribute doesn't have any effect on the database. It is managed entirely by EF. If you are using SQL Server, you can see that in action by using SQL Server Profiler. You will see that the update statement includes the version in the where clause. – Jesús López Jul 02 '23 at 05:21
  • ah ok! Thank for the help... – chuckd Jul 02 '23 at 05:23
  • Please reread the article to see how concurrency conflict detection works: https://dotnetcoretutorials.com/rowversion-vs-concurrencytoken-in-entityframework-efcore/?utm_content=cmp-true&expand_article=1 – Jesús López Jul 02 '23 at 05:23
  • doing that now. – chuckd Jul 02 '23 at 05:27