عنوان:

‫اضافه کردن سعی مجدد به اجرای عملیات Migration در EF Core


نویسنده: وحید نصیری
تاریخ: ۱۳۹۹/۰۷/۰۸ ۱۵:۰
آدرس: www.dntips.ir
در حین کار با بانک‌های اطلاعاتی، ممکن است 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();
    });
  • وحید نصیری در ۱۴۰۵/۰۴/۰۱ ۱۱:۳۸
    تغییر رفتار EF Core 11 در قبال پروژه‌های بدون Migration

    رفتار قبلی (قدیمی)
    در نسخه‌های گذشته، اگر متد Migrate() را فراخوانی می‌کردید اما هیچ فایلی در پوشه Migrations پروژه وجود نداشت، EF Core بدون هیچ مشکلی عبور می‌کرد. صرفاً یک پیام اطلاعاتی (Information Log) ثبت می‌شد و برنامه به کار خود ادامه می‌داد.

    رفتار جدید (EF Core 11)
    از نسخه 11 به بعد، اگر این متدها فراخوانی شوند و هیچ مایگریشنی در اسمبلی پیدا نشود، EF Core به صورت پیش‌فرض یک خطا (Exception) پرتاب می‌کند و اجرای برنامه متوقف می‌شود.

    چرا این تغییر ایجاد شد؟
    فراخوانی متد Migrate() در پروژه‌ای که اصلاً مایگریشنی ندارد، معمولاً نشان‌دهنده یک اشتباه در پیکربندی (Misconfiguration) یا فراموشی توسعه‌دهنده است. EF Core برای جلوگیری از رفتارهای پیش‌بینی‌نشده و اطمینان از اینکه پایگاه داده در وضعیت درستی قرار دارد، این سکوت را شکسته و بلافاصله با یک خطای صریح، توسعه‌دهنده را آگاه می‌کند.

    نحوه مدیریت و رفع این خطا (Mitigations)
    اگر به هر دلیلی (مثلاً مدیریت دستی دیتابیس) می‌خواهید این رفتار را غیرفعال کنید، دو راه دارید:
    ۱. نادیده گرفتن خطا (Ignore):
    options.ConfigureWarnings(w => w.Ignore(RelationalEventId.MigrationsNotFound));

    ۲. تبدیل خطا به یک لاگ معمولی (Log):
    options.ConfigureWarnings(w => w.Log(RelationalEventId.MigrationsNotFound));

    نکات تکمیلی و بسیار مهم برای توسعه‌دهندگان
    اگر در حال مهاجرت به EF Core 11 هستید یا پروژه‌های بزرگ را مدیریت می‌کنید، توجه به نکات زیر می‌تواند به شما کمک کند:
    - تأثیر جدی در خط لوله CI/CD: بسیاری از تیم‌ها در ترنمنت‌های فرآیند خودکار (CI/CD Deployments)، دستور dotnet ef database update یا کد درون‌برنامه‌ای context.Database.Migrate() را اجرا می‌کنند. اگر پروژه‌ای تازه باشد و هنوز اولین Migration آن ثبت نشده باشد، این خط لوله (Pipeline) در EF Core 11 شکست خواهد خورد. بنابراین باید قبل از اولین انتشار، حتماً یک InitialCreate ثبت شده باشد.
    - ارتباط با PendingModelChangesWarning در EF 9:
    • این تغییر در ادامه سیاست‌های سخت‌گیرانه‌تر EF Core است که از نسخه ۹ شروع شد. در EF 9، اگر مدل شما تغییر می‌کرد ولی مایگریشن جدیدی نمی‌ساختید، یک اخطار دریافت می‌کردید. حالا در نسخه ۱۱، حتی اگر اصلاً مایگریشنی وجود نداشته باشد هم سیستم با خطا جلوی شما را می‌گیرد تا جلوی ناهماهنگی مدل و دیتابیس در محیط Production گرفته شود.
    - محیط‌های تست و پایگاه‌داده‌های In-Memory/EnsureCreated:
    • اگر در پروژه‌های تست خود از context.Database.EnsureCreated() استفاده می‌کنید، نیازی به نگرانی نیست. این تغییر صرفاً روی مکانیزم Migrate() اثر می‌گذارد. پیشنهاد می‌شود در پروژه‌هایی که ساختار دیتابیس از طریق مایگریشن مدیریت نمی‌شود، اصلاً متد Migrate() صدا زده نشود تا نیازی به Suppress کردن این خطا نیز نباشد.