O problemă de simultaneitate, Entity Framework și SQL Server

Am avut de curând acel soi de situație neplăcută, știți voi cei din branșă, din acelea ce n-au cum să se-ntâmple; cu alte cuvinte, din acelea ce-au foarte mari șanse de-a se manifesta supărător de intens și frecvent și cu umflături. La origine o problemă de acces simultan la date, unul din motivele pentru care n-ar fi trebuit să apară este simplul fapt că nu există niciun scenariu util, oficial document ori măcar suportat.

Uite mă, Costele, de-aia te-ncurci

Uite mă, Costele, de-aia te-ncurci

Cu totate acestea, operatorii au făcut-o, fiind, drept consecință, necesară o intervenție spre corectare, aplanare și reinstaurea voioșiei și, desigur, o modalitate de-a preveni disfuncționalități ulterioare.

În sine, au existat două cazuri distincte ale problemei date, ambele corespunzătoare aceluiași aspect funcțional (practic insesizabile de către un utilizator):

A) când se actualiza un set de date, respectiv
B) când se crea unul nou.

Pentru exemplificare, pe schema banală de mai jos, actualizarea însemna nu modificări ale rădăcinii R, ci adăugarea sau ștergerea unui element din lista A0 … AN (deci nici măcar actualizarea uneia din înregistrările existente, iar salvarea unui nou set însemna crearea întregului arbore, acesta fiind și cazul aflat la originea problemei.

Atenție, schemă extrem de abstractă

Atenție, schemă extrem de abstractă

Evident că rădăcina reprezintă un tabel, la fel cum evident A0 … AN sunt înregistrări într-un alt tabel depenent. Pentru cazul A, Entity Framework a fost destul de inteligent astfel încât să poată folosi o coloană marcată drept [Timestamp] în model (rowversion în SQL Server), adăugată rădăcinii R, pentru a detecta modificări de orice fel în lista A0 … AN. Deci o rezolvare ușoară.

Pentru cazul B acea coloană este complet inutilă și nu putea preveni crearea simultană a două rădăcini R. Mai mult decât atât, nici o cheie unică nu putea rezolva problema, întrucât regula rădăcinii R este: nu poate exista mai mult de una activă asociată unui operator la un moment dat, însă, desigur, pot exista oricât de multe inactive.

Soluția adoptată a fost până al urmă un trigger cu rolul de-a verifica invariantul enunțat anterior și, dacă ar fi urmată să fie încălat de tranzacția curentă, atunci tranzacția-n cauză va fi contramandată:

CREATE TRIGGER [dbo].[R_UniqueActive]
   ON  [dbo].[R]
   AFTER INSERT,UPDATE
AS 
BEGIN
    SET NOCOUNT ON;

    IF (
        SELECT COUNT(*) 
        FROM dbo.R m
        WHERE m.OperatorId = (SELECT i.OperatorId FROM inserted i) 
            AND m.StopTimestamp IS NULL
    ) > 1
    BEGIN
        ROLLBACK TRANSACTION
    END
END

Am scris o serie de teste corespunzătoare celor două cazuri și variațiilor pe seama lor și rămânea doar chestiunea informării operatorilor prin intermediul unor mesaje corespunzătoare. Din nou, abordarea este diferită în funcție de fiecare din cele două cazuri:

– dacă tranzacția eșuează din cauza faptului că Entity Framework a detectat o actualizare simultană, vom primi extrem de utilul DbUpdateConcurrencyException;
– dacă tranzacția este respinsă de trigger, atunci este generată o excepție generică, în stilul birocrațiilor stilate, singura parte utilă fiind îngropat adânc într-una din excepțiile interioare: transaction ended in trigger.

În vederea uniformizării detectării, am scris o mică extensie:

public static class DbUpdateExceptionExtensions
{
   private const string CONCURRENCEY_TRIGGER_LEVEL_MARKER = 
    "transaction ended in the trigger";

   public static bool IsConcurrencyUpdateExc( this DbUpdateException exc )
   {
      if (exc == null)
         return false;

      if (exc is DbUpdateConcurrencyException)
         return true;

      return exc.InnerException != null
         && exc.InnerException.InnerException != null
         && exc.InnerException.InnerException.Message != null
         && exc.InnerException.InnerException.Message.ToUpper()
            .Contains( CONCURRENCEY_TRIGGER_LEVEL_MARKER.ToUpper() );
   }
}

Și este scoasă la produs după cum urmează:

try
{
  //DO STUFF
}
catch (DbUpdateException exc)
   when (exc.IsConcurrencyUpdateException())
{
   //DO LESS PLEASANT STUFF
}