Атомарность видимых изменений при выполнении последовательностей действий в DataObjects.Net

Данный документ раскрывает важные особенности механизма уведомлений о происходящих действиях (т.е. событий) в процессах синхронизации парных свойств и удаления экземпляров в DataObjects.Net.

Содержание:

Проблема

1. Синхронизация парного (инверсного) отношения

2. Удаление с разрушением ссылок на удаляемый экземпляр

Решение

1. Решение с рекурсией

2. Решение с очередью

Заключение

Проблема

Практически любые действия с сущностями DataObjects.Net приводят к генерации событий. Например, действие “установка значения хранимого свойства объекта” (entity.Property = value) обычно приводит к следующим событиям (порядок может быть не совсем верен - написано по памяти):

Пролог (то, что происходит до внесения каких-либо реальных изменений)

Само действие:

Эпилог:

Т.е. событий довольно много. Но важно то, что в данном случае всю эту сложную последовательность событий можно представить в виде:

Для простоты изложения можно считать, что любое действие мы выполняем следующим образом:

try {

  Prologue();

  Action();

  Epilogue(null);

}

catch (Exception e) {

  try {

    Epilogue(e);

  } catch {}; // To avoid original exception masking

  throw;

}

Вроде-бы все замечательно. Но дело в том, что некоторые действия на самом деле являются комбинациями более простых действий. Приведу два примера:

1. Синхронизация парного (инверсного) отношения

Представим, что у нас есть такая модель:

[HierarchyRoot]

public sealed class Author: Entity

{

  [Field, Association(PairTo = “Author”)]

  EntitySet<Book> Books { get; private set; }

}

[HierarchyRoot]

public sealed class Book: Entity

{

  [Field]

  Author Author { get; set; }

}

И такой код:

var book = new Book();

var author1 = new Author();

book.Author = author1;

var author2 = new Author();

book.Author = author2; // Let’s see what happens when this line is executed

Для синхронизации парных свойств при выполнении последней строчки нам будет нужно выполнить следующие “низкоуровневые” действия (т.е. действия, которые сами по себе не приводят к синхронизации):

author1.Books.Remove(book);

author2.Books.Add(book);

book.Author = author2;

Давайте посмотрим, как все это будет выглядеть в самом простом случае, если работает концепция пролога-эпилога, описанная выше:

  1. Prologue (1): book.Author = author2;
  2. Action (1):
  1. Prologue (2): author1.Books.Remove(book);
  2. Action (2): author1.Books.Remove(book);
  3. Epilogue (2): author1.Books.Remove(book);
  4. Prologue (3): author2.Books.Add(book);
  5. Action (3): author2.Books.Add(book);
  6. Epilogue (3): author2.Books.Add(book);
  7. book.Author = author2;
  1. Epilogue (1): book.Author = author2;

То же самое - на диаграмме:

Особенность данного подхода заключается в том, что частично примененные изменения видны в прологах и эпилогах, выделенных жирным шрифтом (желтым на диаграмме).

Попробуем разобраться, к каким неожиданным последствиям это может привести.

Внимание: сценарии, рассмотренные ниже, работают в DataObjects.Net именно так, как вы этого ожидаете. Примеры, приведенные ниже, относятся ко всем ORM / BLL frameworks, имеющим схожий механизм уведомления о действиях с сущностями, но не реализующих атомарность видимых изменений состояния для подобных сценариев.

Итак, представим, что в Epilogue (3) срабатывает пользовательский код, который выполняет такой LINQ-запрос: author1.Books.Count(). Несмотря на то, что мы рассчитываем получить 1, этого не произойдёт. Вместо этого мы получим 0 - даже несмотря на то, что ORM “сбрасывает” перед запросом все накопленные в кэше изменения.

Так как коллекция Author.Books - парная к свойству Book.Author, физически ее элементы являются записями в таблице для типа  Book с заданным значением поля Author. Но так как изменения в сущности book еще не были сделаны, изменений в соответствующей таблице в БД так же еще не произошло, а следовательно, указанный выше LINQ-запрос вернет значение 0.

Таким образом, следующий код “упадет”, будучи выполненным внутри Epilogue (3):

Assert.AreEqual(author1.Books.Count(), author1.Books.Count);

Забавно, но на самом же деле все может быть еще запутаннее. Дело в том, что многие ORM (в т.ч. и DataObjects.Net) реализуют отложенную загрузку коллекций, и изменения в самой коллекции далеко не всегда являются поводом для полной загрузки ее состояния. Взглянем еще раз на то, что происходит с коллекцией author1.Books:

Т.е. в более сложном случае в Epilogue (3) (т.е. в событии INotifyCollectionChanged.CollectionChanged, например) могут “упасть” следующие Assert-ы:

Assert.AreEqual(1, author1.Books.Count());

Assert.AreEqual(1, author1.Books.Count);

Assert.isTrue(author1.Books.Contains(book));

Итак, видимость частично измененного состояния в некоторых случаях приводит к весьма неожиданным последствиям.

Перейдем ко второму примеру.

2. Удаление с разрушением ссылок на удаляемый экземпляр

Допустим, мы выполняем следующий код (модель - та же):

var book1 = new Book();

var book2 = new Book();

var author = new Author();

book1.Author = author;

book2.Author = author;

author.Remove(); // Let’s see what happens when this line is executed

Последовательность событий:

  1. Prologue (1): author.Remove();
  2. Action (1):
  1. Prologue (2): author.Books.Remove(book1);
  2. Action (2): author.Books.Remove(book1);
  3. Epilogue (2): author.Books.Remove(book1);
  4. Prologue (3): book1.Author = null;
  5. Action (3): book1.Author = null;
  6. Epilogue (3): book1.Author = null;
  7. Prologue (4): author.Books.Remove(book2);
  8. Action (4): author.Books.Remove(book2);
  9. Epilogue (4): author.Books.Remove(book2);
  10. Prologue (5): book2.Author = null;
  11. Action (5): book2.Author = null;
  12. Epilogue (5): book2.Author = null;
  13. author.Remove();
  1. Epilogue (1): author.Remove();

Видно, что ситуация в данном случае - полностью идентичная; разница только в том, что цепочка действий в данном случае длиннее. Если в первом случае ее длина была фиксированной, то в данном случае она зависит от количества элементов в коллекции.

Проблемы в этом случае - те же: неожиданное значение Count, неожиданное присутствие\отсутствие элемента в коллекции и т.п. Все зависит от конкретного ORM.

Решение

Нужно добиться, чтоб в подобных сценариях:

Я представляю себе два пути реализации такого решения.

1. Решение с рекурсией

Код каждого действия в этом сценарии будет примерно таким:

public void ActionX()

{

  ActionX(new RecursionBasedAtomicContext());

}

internal void ActionX(RecursionBasedAtomicContext context)

{

  Exception error = null;

  Prologue();

  try {

    context.EnqueueSideEffect(() => {

      // Important: check if side effect is still applicable

      // Side effect

    });

    context.EnqueueAction(() => {

      ActionY(context); // Internal method is called, context is passed to it

    });

    context.EnqueueAction(() => {

      ActionZ(context); // Internal method is called, context is passed to it

    });

    context.Recurse();

  }

  catch(Exception e) {

    error = e;

    throw;

  }

  finally {

    Epilogue(error);

  }

}

Методы RecursionBasedAtomicContext делают следующее:

Диаграмма:

Таким образом, мы получаем нужную нам последовательность вызовов прологов, действий с сайд-эффектом и эпилогов.

Остался еще один важный аспект - атомарность при выполнении сайд-эффектов. В случае с DataObjects.Net здесь все довольно просто - достаточно сделать метод выполнения всех сайд-эффектов (Recurse) транзакционным, как и методы Action*, поэтому любая ошибка при их выполнении приведет к откату (и блокировке) внешней или вложенной транзакции, что автоматически отменит все ее последствия.

Пользы от передачи собственно исключения в Prologue в данном случае мало - если оно пришло, работать с БД все равно нельзя, но это и не нужно, т.к. эта часть состояния все равно уже откачена. С другой стороны, выполнять компенсационные действия, не касающиеся БД - можно.

Важно так же помнить, что при выполнении любого сайд-эффекта нужно сначала сделать проверку на то, что его действительно все еще можно \ нужно выполнить. Дело в том, что код, вызванный в одном из Prologue, мог сделать его неприменимым (например, удалил объект, свойства которого мы запланировали поменять).

Решение с рекурсией применимо, когда достоверно известно, что количество действий в цепочке строго ограничено. В DataObjects.Net он используется в случае с парностью (посмотрите как-нибудь стек вызовов при синхронизации; мы используем SyncContext вместо RecursionBasedAtomicContext, + у нас все сделано несколько более эффективно - с учетом особенностей задачи).

2. Решение с очередью

Код каждого действия в этом случае будет примерно таким:

public void ActionX()

{

  var context = new QueueBasedAtomicContext();

  try {

    ActionX(context);
   
context.Process();

  }

  catch (Exception e) {

    context.Process(e);

  }

}

internal void ActionX(QueueBasedAtomicContext context)

{

  context.EnqueuePrologue(() => {

    // Important: the code here can be executed directly as well;

    // EnqueuePrologue must be used only to get rid of possible deep recursion

    Prologue();

    ActionY(context); // Internal method is called, context is passed to it

    ActionZ(context); // Internal method is called, context is passed to it

  });

  context.EnqueueSideEffect(() => {

    // Important: check if side effect is still applicable

    // Side effect

  });

  context.EnqueueEpilogue(error => Epilogue(error));

}

Методы QueueBasedAtomicContext делают следующее:

Диаграмма вызовов в данном случае выглядит приблизительно так:

Таким образом, и в этом случае мы получаем нужную нам последовательность вызовов прологов, действий с сайд-эффектом и эпилогов.

Важно, чтоб работа с исключениями в данном случае шла так же, как если бы вся эта цепочка сработала на стеке - т.е. первое исключение (если есть) передается в первый эпилог, если он выбрасывает исключение сам, выброшенное исключение передается по цепочке далее. Например, если результат вызова эпилога - тот же экземпляр error, его надо передать следующему эпилогу (это соответствует throw), иначе - не передавать ничего (это соответствует подавлению исключения).

Отдельная тема - возврат результата в такой цепочке вызовов. Я ее не касался, но вообще говоря, эта задача достаточно проста - например, можно передавать экземпляры типа ActionXResult ... ActionZResult через всю цепочку, перенося данные из результата потомка в результат предка в методах Action* с помощью closures.

Решение с очередью применимо практически в любом случае; его единственный минус - несколько меньшая скорость (т.к. нужно больше разных объектов в куче). DataObjects.Net использует его при удалении экземпляров - псевдо-рекурсивное разрушение ссылок на удаляемую сущность и каскадное удаление выполняются именно таким образом.

Заключение

На данный момент DataObjects.Net использует описанные механизмы в двух сценариях:

  1. Синхронизация парных свойств хранимых объектов. Здесь мы используем механизм с рекурсией, т.к. максимальная длина цепочки действий в данном случае равна 4 (“разрушить старую связь слева”, “разрушить старую связь справа”, “установить новую связь слева”, “установить новую связь справа”).
  2. Удаление хранимого объекта. Здесь используется механизм с очередью, т.к. максимальная длина списка действий ничем не ограничена.

Других сценариев,где подобное поведение было бы полезно, мы не идентифицировали. Если вы их обнаружите, мы всегда рады рассмотреть ваши предложения.

Вполне вероятно, что описанные в документе алгоритмы могут быть полезны и в других случаях, так как проблема кажется весьма общей.