اضافه کردن سعی مجدد به اجرای عملیات Migration در EF Core
اندازه‌ی قلم متن
تخمین مدت زمان مطالعه‌ی مطلب: دو دقیقه

در حین کار با بانک‌های اطلاعاتی، ممکن است Timeout رخ دهد، یا اتصال برای لحظه‌ای قطع شود و یا خطای قفل بودن جدولی مشاهده شود و امثال این‌ها. در این حالات، اعمال نتیجه‌ی عملیات Migration با شکست مواجه خواهد شد. بنابراین اضافه کردن امکان سعی مجدد عملیات شکست خورده به آن، ضروری است.


پشتیبانی توکار EF Core از سعی مجدد یک عملیات شکست خورده

EF Core بدون نیاز به کتابخانه و یا راه حل ثالثی می‌تواند یک عملیات شکست خورده را مجددا اجرا کند. برای اینکار تنها کافی است تنظیمات زیر را به پروایدر مورد استفاده اضافه کنید؛ برای مثال در مورد SQL Server به صورت زیر است:
public class Startup
{
    // Other code ...
    public IServiceProvider ConfigureServices(IServiceCollection services)
    {
        // ...
        services.AddDbContext<CatalogContext>(options =>
        {
            options.UseSqlServer(Configuration["ConnectionString"],
            sqlServerOptionsAction: sqlOptions =>
            {
                sqlOptions.EnableRetryOnFailure(
                maxRetryCount: 10,
                maxRetryDelay: TimeSpan.FromSeconds(30),
                errorNumbersToAdd: null);
            });
        });
    }
//...
}
با تنظیم پارامترهای متد EnableRetryOnFailure در اینجا، یک عملیات شکست خورده، حداکثر 10 بار با یک مکث 30 ثانیه‌ای در بین آن‌ها، تکرار می‌شود.


مشکل! EnableRetryOnFailure به عملیات Migration اعمال نمی‌شود!

بر اساس نظر تیم EF Core، تنظیمات تکرار یک عملیات شکست خورده، در زمان اعمال نتیجه‌ی عملیات Migrations ندید گرفته می‌شوند. بنابراین اگر در ابتدای اجرای برنامه قصد اجرای متد ()context.Database.Migrate را دارید تا از اعمال آخرین Migrations موجود اطمینان حاصل کنید و در این بین Timeout ای رخ دهد، این عملیات مجددا تکرار نخواهد شد و این مساله کار کردن با نگارش‌های جدید یک برنامه را غیرممکن می‌کند؛ چون برنامه‌ی جدید، بر اساس ساختار جدید بانک اطلاعاتی طراحی شده قرار است کار کند و به روز رسانی آن با شکست مواجه شده‌است.


اضافه کردن سعی مجدد در اجرای یک عملیات Migration شکست خورده با استفاده از Polly

در مورد کتابخانه‌ی Polly پیشتر در مطلب «پیاده سازی مکانیسم سعی مجدد (Retry)» بحث شده‌است. در اینجا از امکانات این کتابخانه برای سعی مجدد در اجرای عملیات شکست خورده استفاده می‌کنیم:
public static IHost MigrateDbContext<TContext>(this IHost host)  where TContext : DbContext
{
    using (var scope = host.Services.CreateScope())
    {
        var services = scope.ServiceProvider;
        var logger = services.GetRequiredService<ILogger<TContext>>();
        var context = services.GetService<TContext>();

        logger.LogInformation($"Migrating the DB associated with the context {typeof(TContext).Name}");

        var retry = Policy.Handle<Exception>().WaitAndRetry(new[]
            {
                TimeSpan.FromSeconds(5),
                TimeSpan.FromSeconds(10),
                TimeSpan.FromSeconds(15),
            });

        retry.Execute(() =>
            {
                context.Database.Migrate();
            });

        logger.LogInformation($"Migrated the DB associated with the context {typeof(TContext).Name}");
    }
    return host;
}
- هدف از این متد الحاقی فراهم آوردن روشی برای اجرای به روز رسانی ساختار بانک اطلاعاتی، در زمان آغاز برنامه‌است. به همین جهت به صورت افزونه‌ای برای IHost تعریف شده‌است.
- همچنین چون DbContext با طول عمر Scoped تعریف می‌شود، نیاز است در اینجا و خارج از طول عمر یک درخواست، این Scope را به صورت دستی ایجاد و Dispose کرد.
- در این متد با استفاده از امکانات کتابخانه‌ی Polly، در صورتیکه هر گونه خطایی رخ دهد، عملیات Migration سه بار با فواصل زمانی 5، 10 و 15 ثانیه، تکرار خواهد شد.

روش اعمال آن نیز به متد Main برنامه به صورت زیر است:
public static void Main(string[] args)
{
    CreateHostBuilder(args)
    .Build()
    .MigrateDbContext<AppDbContext>()
    .Run();
}
  • #
    ‫۱ سال و ۵ ماه قبل، دوشنبه ۱۵ اسفند ۱۴۰۱، ساعت ۱۶:۱۲
    یک نکته‌ی تکمیلی: مشکل تراکنش‌های صریح با EnableRetryOnFailure

    زمانیکه از EnableRetryOnFailure استفاده می‌شود، هر کوئری و هر SaveChanges، یک «عملیات قابل‌تکرار» در صورت شکست خواهد شد (که تعداد بار تکرار آن بستگی به تنظیمات آغازین این متد دارد)؛ البته این در مورد تراکنش‌های ضمنی است. اگر در برنامه، یک تراکنش صریح را با فراخوانی BeginTransaction آغاز کنیم، یعنی قصد داریم یک واحد عملیاتی جدید را خودمان به نحو صریحی مشخص کنیم. در این حالت اگر خطایی رخ دهد، نیاز است کل عملیات تراکنش یکبار دیگر تکرار شود و نه اینکه هر کوئری آن یکبار دیگر؛ که در این حالت به استثنای زیر هم خواهیم رسید:
    System.InvalidOperationException: The configured execution strategy ‘SqlServerRetryingExecutionStrategy’ 
    does not support user initiated transactions. Use the execution strategy returned by 
    ‘DbContext.Database.CreateExecutionStrategy()’ to execute all the operations in the transaction as a retriable unit.
    یا می‌توان این تنظیمات تکرار عملیات شکست خورده را حذف کرد و یا می‌توان همانطور که در متن خطا هم عنوان کرده، یک CreateExecutionStrategy جدید را سبب شد:
    public async Task<IActionResult> UpdateProduct([FromBody]CatalogItem productToUpdate)
    {
       var strategy =  _catalogContext.Database.CreateExecutionStrategy();
       await strategy.ExecuteAsync(async () =>
       {
         using (var transaction = _catalogContext.Database.BeginTransaction())
         {
            _catalogContext.CatalogItems.Update(productToUpdate);
            await _catalogContext.SaveChangesAsync();
    
            transaction.Commit();
         }
       });
    }
    در این حالت اگر مشکلی روی دهد، «عملیات قابل‌تکرار» همان کدهای async delegate تعریف شده هستند و اینبار حد و مرز آن مشخص شده‌است. اگر هم خواستید این راه حل را عمومی کنید، می‌توان از قطعه کد زیر جهت فراخوانی کدهای سفارشی داخل async delegate استفاده کرد:
    public class ResilientTransaction
    {
        private readonly DbContext _context;
        private ResilientTransaction(DbContext context) =>
            _context = context ?? throw new ArgumentNullException(nameof(context));
    
        public static ResilientTransaction New(DbContext context) => new(context);
    
        public async Task ExecuteAsync(Func<Task> action)
        {
            var strategy = _context.Database.CreateExecutionStrategy();
            await strategy.ExecuteAsync(async () =>
            {
                await using var transaction = await _context.Database.BeginTransactionAsync();
                await action();
                await transaction.CommitAsync();
            });
        }
    }
    
    // How to use it
    
    await ResilientTransaction.New(_dbContext).ExecuteAsync(async () =>
    {
          // ...
          await _dbContext.SaveChangesAsync();
    });