DEV Community

nikosst
nikosst

Posted on

Execution Strategy στο Entity Framework Core

Τι πρόβλημα λύνει, πώς λειτουργεί, πότε το χρησιμοποιούμε και πώς το υλοποιούμε σωστά

Το πρόβλημα της “αόρατης” αποτυχίας

Όταν γράφουμε κώδικα που επικοινωνεί με μια βάση δεδομένων, ειδικά σε περιβάλλον ανάπτυξης, έχουμε την αίσθηση ότι η βάση είναι κάτι σταθερό και προβλέψιμο. Καλούμε SaveChangesAsync() και περιμένουμε ότι είτε θα επιτύχει είτε θα αποτύχει για έναν σαφή και “λογικό” λόγο: παραβίαση μοναδικού constraint, ξένο κλειδί που δεν υπάρχει, validation error, null σε non-nullable στήλη. Με άλλα λόγια, συνδέουμε την αποτυχία αποκλειστικά με το domain ή τον κώδικά μας.

Στην πραγματικότητα όμως, ιδιαίτερα σε παραγωγικά περιβάλλοντα και ακόμη περισσότερο σε cloud υποδομές, υπάρχει μια ολόκληρη κατηγορία αποτυχιών που δεν σχετίζονται ούτε με το domain ούτε με τη λογική της εφαρμογής. Σχετίζονται με την ίδια την υποδομή: το δίκτυο, τον SQL server, το cluster, τον load balancer, τη διαχείριση πόρων. Αυτές οι αποτυχίες ονομάζονται transient failures.

Ένα transient failure είναι μια αποτυχία που συμβαίνει στιγμιαία και έχει υψηλή πιθανότητα να επιλυθεί αν επαναληφθεί η ίδια πράξη λίγο αργότερα. Δεν σημαίνει ότι η πράξη ήταν λάθος. Σημαίνει ότι το περιβάλλον μέσα στο οποίο εκτελέστηκε δεν ήταν προσωρινά σε θέση να την ολοκληρώσει.

Τυπικά παραδείγματα transient failures είναι τα εξής: ένα SQL timeout επειδή ο server είχε προσωρινά υψηλό φορτίο, ένα deadlock όπου δύο transactions συγκρούστηκαν και ο SQL server αναγκάστηκε να “θυσιάσει” το ένα, μια προσωρινή απώλεια σύνδεσης λόγω δικτυακής αστάθειας, ένα failover σε cluster ή σε Azure SQL όπου η βάση μεταφέρθηκε σε άλλο κόμβο, ένα throttling σενάριο σε managed υπηρεσίες όπου το σύστημα περιορίζει προσωρινά τον ρυθμό αιτημάτων. Ακόμη και ένα στιγμιαίο reset της TCP σύνδεσης μπορεί να προκαλέσει αποτυχία σε μια κατά τα άλλα σωστή πράξη.

Το κρίσιμο σημείο είναι ότι σε όλες αυτές τις περιπτώσεις, αν εκτελέσουμε ξανά την ίδια ενέργεια, υπάρχει μεγάλη πιθανότητα να επιτύχει. Δεν αλλάξαμε τίποτα στον κώδικα. Δεν διορθώσαμε δεδομένα. Απλώς επαναλάβαμε την πράξη σε ένα σταθερότερο στιγμιότυπο της υποδομής.

Πώς καταλαβαίνουμε ότι έχουμε transient failures; Συνήθως από τη φύση των exceptions και από το μοτίβο εμφάνισής τους. Αν βλέπουμε σποραδικά timeouts που δεν αναπαράγονται σταθερά, deadlock exceptions που δεν σχετίζονται με συγκεκριμένο input, ή SQL errors που εξαφανίζονται αν ξανατρέξουμε το ίδιο request, τότε κατά πάσα πιθανότητα έχουμε transient πρόβλημα. Ειδικά σε cloud περιβάλλοντα, η τεκμηρίωση των providers (όπως ο SQL Server provider του EF Core) περιλαμβάνει συγκεκριμένους error codes που χαρακτηρίζονται ως transient. Το EF Core γνωρίζει αυτούς τους κωδικούς και μπορεί να εφαρμόσει retry μόνο σε αυτούς.

Αντίθετα, ένα unique key violation που συμβαίνει κάθε φορά με τα ίδια δεδομένα δεν είναι transient. Ένα null reference στη βάση δεν είναι transient. Ένα constraint violation δεν θα “διορθωθεί” αν ξαναπροσπαθήσουμε. Εδώ βρίσκεται η ουσία της διάκρισης: transient σημαίνει προσωρινό και εξωτερικό ως προς τη λογική του domain.

Το Execution Strategy του EF Core δημιουργήθηκε για να αντιμετωπίσει ακριβώς αυτή την κατηγορία προβλημάτων. Δεν προσπαθεί να κρύψει λάθη στον κώδικα. Δεν αγνοεί σοβαρές εξαιρέσεις. Αναγνωρίζει συγκεκριμένα σφάλματα ως προσωρινά και επαναλαμβάνει με ελεγχόμενο και περιορισμένο τρόπο την πράξη, εφαρμόζοντας καθυστέρηση μεταξύ των προσπαθειών ώστε να δώσει χρόνο στο σύστημα να επανέλθει.

Με αυτόν τον τρόπο, η εφαρμογή αποκτά ανθεκτικότητα απέναντι σε ασταθή περιβάλλοντα χωρίς να επιβαρύνεται ο κώδικας με χειροκίνητους βρόχους επανάληψης. Το σημαντικό όμως είναι να κατανοούμε τι πρόβλημα καλύπτει: όχι τη λογική αποτυχία, αλλά την προσωρινή αστάθεια της υποδομής. Και αυτή η κατανόηση είναι το θεμέλιο για τη σωστή και υπεύθυνη χρήση του Execution Strategy.


Ενεργοποίηση

Το Execution Strategy ενεργοποιείται μέσω του provider configuration.

Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<EndysisDbContext>(options =>
        options.UseSqlServer(
            Configuration.GetConnectionString("DefaultConnection"),
            sql =>
            {
                sql.EnableRetryOnFailure(
                    4,
                    TimeSpan.FromSeconds(1),
                    Array.Empty<int>());
            }));
}

Enter fullscreen mode Exit fullscreen mode

Με αυτό λέμε στο EF Core:

«Αν προκύψει transient SQL error, επιτρέπεται να ξαναπροσπαθήσεις έως 4 φορές.»

Από αυτή τη στιγμή, το EF έχει ενεργό retry mechanism.


Πώς λειτουργεί εσωτερικά

Όταν εκτελείται μια database operation:

  1. Γίνεται η προσπάθεια.
  2. Αν επιτύχει, τελειώσαμε.
  3. Αν προκύψει exception, ο provider εξετάζει αν το error είναι transient.
  4. Αν είναι transient και δεν έχουμε εξαντλήσει τις προσπάθειες, περιμένει (backoff) και επαναλαμβάνει.
  5. Αν δεν είναι transient, το exception προωθείται άμεσα.

Σημαντικό: Δεν γίνεται retry για business errors όπως unique key violation ή null constraint.


Απλό SaveChanges

Αν έχεις αυτό:

public async Task CreateOrderAsync(Order order, CancellationToken ct)
{
    _dbContext.Orders.Add(order);
    await _dbContext.SaveChangesAsync(ct);
}

Enter fullscreen mode Exit fullscreen mode

Αν έχεις ενεργοποιήσει EnableRetryOnFailure, το EF Core μπορεί να εφαρμόσει retry αυτόματα.

Δεν χρειάζεται να γράψεις:

CreateExecutionStrategy()

Enter fullscreen mode Exit fullscreen mode

Εδώ το retry γίνεται “σιωπηρά” από τον provider.


Πότε δεν αρκεί αυτό

Αν η πράξη σου είναι σύνθετη και περιλαμβάνει πολλαπλά βήματα που πρέπει να θεωρηθούν ενιαία, όπως:

  • Πολλαπλά SaveChanges
  • Explicit transaction
  • Συνδυασμός διαφορετικών repositories

τότε η επανάληψη πρέπει να καλύπτει όλο το block.

Εδώ χρησιμοποιούμε CreateExecutionStrategy().


Transactional block με ExecutionStrategy

var strategy = _dbContext.Database.CreateExecutionStrategy();

await strategy.ExecuteAsync(async () =>
{
    await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);

    _dbContext.Orders.Add(order);
    await _dbContext.SaveChangesAsync(ct);

    _dbContext.Transactions.Add(transactionEntity);
    await _dbContext.SaveChangesAsync(ct);

    await transaction.CommitAsync(ct);
});

Enter fullscreen mode Exit fullscreen mode

Αν υπάρξει transient failure στη μέση, ολόκληρο το block επαναλαμβάνεται.


Το πρόβλημα των side effects

Αν μέσα στο block κάνεις κάτι τέτοιο:

await _emailService.SendAsync(...);

Enter fullscreen mode Exit fullscreen mode

και υπάρξει transient error μετά, το block μπορεί να εκτελεστεί ξανά και να σταλεί δεύτερο email.

Άρα ο κανόνας είναι:

Το retry block πρέπει να περιέχει μόνο ενέργειες που είναι ασφαλείς να επαναληφθούν.

Για αποστολή email ή publish events, χρησιμοποιούμε Outbox pattern.


Clean Architecture Υλοποίηση

Σε καθαρή αρχιτεκτονική δεν θέλουμε το application layer να γνωρίζει EF λεπτομέρειες.

Application Abstraction

public interface ITransactionalExecutor
{
    Task ExecuteAsync(
        Func<CancellationToken, Task> operation,
        CancellationToken ct = default);
}

Enter fullscreen mode Exit fullscreen mode

Infrastructure Implementation

public sealed class EfCoreTransactionalExecutor<TDbContext>
    : ITransactionalExecutor
    where TDbContext : DbContext
{
    private readonly TDbContext _db;

    public EfCoreTransactionalExecutor(TDbContext db)
    {
        _db = db;
    }

    public async Task ExecuteAsync(
        Func<CancellationToken, Task> operation,
        CancellationToken ct = default)
    {
        var strategy = _db.Database.CreateExecutionStrategy();

        await strategy.ExecuteAsync(async () =>
        {
            await using var tx = await _db.Database.BeginTransactionAsync(ct);

            await operation(ct);

            await _db.SaveChangesAsync(ct);

            await tx.CommitAsync(ct);
        });
    }
}

Enter fullscreen mode Exit fullscreen mode

DI Registration

services.AddScoped<ITransactionalExecutor,
    EfCoreTransactionalExecutor<EndysisDbContext>>();

Enter fullscreen mode Exit fullscreen mode

Χρήση σε Handler

public class UpdateOrderHandler
{
    private readonly ITransactionalExecutor _executor;
    private readonly IOrdersRepository _orders;
    private readonly ITransactionsRepository _transactions;

    public UpdateOrderHandler(
        ITransactionalExecutor executor,
        IOrdersRepository orders,
        ITransactionsRepository transactions)
    {
        _executor = executor;
        _orders = orders;
        _transactions = transactions;
    }

    public async Task HandleAsync(CancellationToken ct)
    {
        await _executor.ExecuteAsync(async innerCt =>
        {
            _orders.Update(order);
            _transactions.Add(transaction);

            // κανένα SaveChanges εδώ
            // κανένα email εδώ
        }, ct);
    }
}

Enter fullscreen mode Exit fullscreen mode

Έτσι:

  • Έχουμε ένα transactional boundary.
  • Έχουμε ένα SaveChanges.
  • Έχουμε retry μόνο εκεί που χρειάζεται.
  • Τηρούμε SOLID και separation of concerns.


Τι δεν λύνει το Execution Strategy

Δεν λύνει consistency μεταξύ διαφορετικών βάσεων.
Δεν αντικαθιστά distributed transaction.
Δεν προστατεύει από λανθασμένο domain design.
Δεν κάνει το σύστημα “μαγικά” αξιόπιστο.


Να θυμάσαι

Να θυμάσαι ότι το Execution Strategy δεν είναι εργαλείο για να “κρύβεις” προβλήματα αρχιτεκτονικής. Είναι εργαλείο ανθεκτικότητας υποδομής.

Να θυμάσαι ότι το retry σημαίνει πιθανή επανάληψη κώδικα. Αν μέσα στο block υπάρχουν παρενέργειες εκτός βάσης, πρέπει να τις διαχωρίσεις.

Να θυμάσαι ότι για ένα απλό SaveChanges δεν χρειάζεται να το χρησιμοποιείς ρητά. Το EF το κάνει ήδη.

Να θυμάσαι ότι το σωστό transactional boundary είναι πιο σημαντικό από τον ίδιο τον μηχανισμό retry.

Και τέλος, να θυμάσαι ότι η ανθεκτικότητα δεν είναι τυχαία ιδιότητα ενός συστήματος. Είναι αποτέλεσμα συνειδητού σχεδιασμού.


CancellationToken στο .NET

nikosstit@gmail.com

Top comments (0)