# Background Services in ASP.NET Core - Μέρος 2: Πρακτικά Παραδείγματα

<!--category-- ASP.NET Core, IHostedService, BackgroundService, Channels, Polly -->
<datetime class="hidden">2025-11-27T09:30</datetime>

Θεωρία είναι ένα πράγμα; κωδικός παραγωγής είναι ένα άλλο. Στο Μέρος 1 καλύψαμε τις αφαιρετικές τώρα ας δούμε πώς εφαρμόζονται σε μια πραγματική βάση κώδικα. Αυτό το άρθρο περπατά μέσα από πραγματικές υπηρεσίες υποβάθρου από αυτή την πλατφόρμα blog: παρατηρητές αρχείων με Polly retry πολιτικές, ουρές email που βασίζονται κανάλια με διακόπτες κυκλώματος, σημασιολογικοί δείκτες αναζήτησης με hash-based ανίχνευση αλλαγών, και πολλά άλλα.

# Εισαγωγή

Το [Μέρος 1](/blog/background-services-in-aspnetcore-part1), διερευνήσαμε τις θεμελιώδεις προσεγγίσεις για την εφαρμογή υπηρεσιών υποβάθρου στο ASP.NET Core. Καλύψαμε `IHostedService`, `BackgroundService`, διαχείριση κύκλου ζωής, και τις κοινές παγίδες γύρω από το χειρισμό του κλεισίματος.

Σε αυτό το άρθρο, θα εξετάσουμε πραγματικές υπηρεσίες φόντου από μια πλατφόρμα blog παραγωγής, δείχνοντας:

- Παρατηρητές συστημάτων αρχείων που συγχρονίζουν αρχεία markdown σε μια βάση δεδομένων
- ουρές email βασισμένες στο κανάλι με Polly retry πολιτικές
- Αποστολές γεγονότων Analytics που ζητούν background background
- Σημασιολογικοί δείκτες αναζήτησης με ανίχνευση αλλαγών που βασίζονται σε χασίς
- Σπασμένα χειριστήρια σύνδεσης που επικυρώνουν περιοδικά τα εξωτερικά URL
- Συντονισμός εκκίνησης μεταξύ εξαρτώμενων υπηρεσιών
- Διαχείριση κύκλου ζωής για τη διόρθωση και τη διαχείριση του κύκλου ζωής των οντοτήτων

Κάθε παράδειγμα απεικονίζει πρακτικές λύσεις σε κοινά προβλήματα που θα συναντήσετε κατά την κατασκευή υπηρεσιών φόντου παραγωγής.

[TOC]

# Παράδειγμα 1: Παρατηρητής συστήματος αρχείων με την Polly Retry

Η πρώτη υπηρεσία που θα εξετάσουμε παρακολουθεί έναν κατάλογο των θέσεων blog markdown και τις επεξεργάζεται αυτόματα όταν αλλάζουν.

## Το Πρόβλημα

Όταν ένα αρχείο markdown δημιουργείται ή τροποποιείται:

1. Διαβάστε τα περιεχόμενα του αρχείου
2. Μεταδεδομένα ανάλυσης (τίτλος, κατηγορίες, ημερομηνία δημοσίευσης)
3. Αποστολή σε HTML και εξαγωγή απλού κειμένου
4. Αποθήκευση στη βάση δεδομένων
5. Μετάφραση ενεργοποίησης σε άλλες γλώσσες
6. Δείκτης για σημασιολογική αναζήτηση

Η πρόκληση: [`FileSystemWatcher`](https://learn.microsoft.com/en-us/dotnet/api/system.io.filesystemwatcher) γεγονότα μπορούν να πυροδοτήσουν ενώ το αρχείο εξακολουθεί να γράφεται, προκαλώντας `IOException` Όταν προσπαθείς να το διαβάσεις.

## Η λύση: MarkdownDirectoryWatcherService

```csharp
public class MarkdownDirectoryWatcherService(
    MarkdownConfig markdownConfig,
    IServiceScopeFactory serviceScopeFactory,
    IStartupCoordinator startupCoordinator,
    ILogger<MarkdownDirectoryWatcherService> logger)
    : IHostedService
{
    private FileSystemWatcher _fileSystemWatcher;
    private Task _awaitChangeTask = Task.CompletedTask;

    public Task StartAsync(CancellationToken cancellationToken)
    {
        _fileSystemWatcher = new FileSystemWatcher
        {
            Path = markdownConfig.MarkdownPath,
            NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite |
                          NotifyFilters.CreationTime | NotifyFilters.Size,
            Filter = "*.md",
            IncludeSubdirectories = true
        };

        _fileSystemWatcher.EnableRaisingEvents = true;

        // Start background processing in a separate task
        _awaitChangeTask = Task.Run(() => AwaitChanges(cancellationToken), cancellationToken);

        logger.LogInformation("Started watching directory {Directory}", markdownConfig.MarkdownPath);

        // Signal ready - watcher is set up and listening
        startupCoordinator.SignalReady(StartupServiceNames.MarkdownDirectoryWatcher);

        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        // Proper cleanup
        _fileSystemWatcher.EnableRaisingEvents = false;
        _fileSystemWatcher.Dispose();

        logger.LogInformation("Stopped watching directory");

        return Task.CompletedTask;
    }

    private async Task AwaitChanges(CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            var fileEvent = _fileSystemWatcher.WaitForChanged(WatcherChangeTypes.All);

            if (fileEvent.ChangeType == WatcherChangeTypes.Changed ||
                fileEvent.ChangeType == WatcherChangeTypes.Created)
            {
                await OnChangedAsync(fileEvent);
            }
            else if (fileEvent.ChangeType == WatcherChangeTypes.Deleted)
            {
                await OnDeletedAsync(fileEvent);
            }
            else if (fileEvent.ChangeType == WatcherChangeTypes.Renamed)
            {
                await OnRenamedAsync(fileEvent);
            }
        }
    }
}
```

Προσέξτε μερικά βασικά σχέδια:

1. **IHostedService, όχι BackgroundService** - Χρειαζόμαστε καλό έλεγχο στο στήσιμο του παρατηρητή.
2. **Ξεχωριστό έργο φόντου** - Το `StartAsync` επιστρέφει αμέσως, η επεξεργασία συμβαίνει σε `AwaitChanges`
3. **Συντονισμός εκκίνησης** - Τα σήματα όταν είναι έτοιμα για εξαρτώμενες υπηρεσίες
4. **Κατάλληλη απόρριψη** - Απενεργοποιεί και διαθέτει τον παρατηρητή συστήματος αρχείων σε `StopAsync`

## Χειρισμός θεμάτων κλειδώματος αρχείων με την Polly

Το πιο ενδιαφέρον μέρος είναι το πώς χειριζόμαστε τα αρχεία που ακόμα γράφονται. [ΠόλυCity name (optional, probably does not need a translation)](https://github.com/App-vNext/Polly) είναι μια βιβλιοθήκη ανθεκτικότητας .NET που παρέχει retry πολιτικές, διακόπτες κυκλωμάτων, και πολλά άλλα:

```csharp
private async Task OnChangedAsync(WaitForChangedResult e)
{
    if (e.Name == null) return;

    // Serilog activity for distributed tracing
    using var activity = Log.Logger.StartActivity("Markdown File Changed {Name}", e.Name);

    // Define a retry policy for file access issues
    var retryPolicy = Policy
        .Handle<IOException>() // Only handle IO exceptions (like file in use)
        .WaitAndRetryAsync(5, retryAttempt => TimeSpan.FromMilliseconds(500 * retryAttempt),
            (exception, timeSpan, retryCount, context) =>
            {
                activity?.Activity?.SetTag("Retry Attempt", retryCount);
                logger.LogWarning(
                    "File is in use, retrying attempt {RetryCount} after {TimeSpan}",
                    retryCount, timeSpan);
            });

    try
    {
        var fileName = e.Name;
        var isTranslated = Path.GetFileNameWithoutExtension(e.Name).Contains(".");
        var language = MarkdownBaseService.EnglishLanguage;
        var directory = markdownConfig.MarkdownPath;

        if (isTranslated)
        {
            language = Path.GetFileNameWithoutExtension(e.Name).Split('.').Last();
            fileName = Path.GetFileName(fileName);
            directory = markdownConfig.MarkdownTranslatedPath;
        }

        var filePath = Path.Combine(directory, fileName);

        using var scope = serviceScopeFactory.CreateScope();
        var blogService = scope.ServiceProvider.GetRequiredService<IBlogService>();

        // Use the Polly retry policy
        await retryPolicy.ExecuteAsync(async () =>
        {
            // Read the file - might throw IOException if locked
            var markdown = await File.ReadAllTextAsync(filePath);

            var slug = Path.GetFileNameWithoutExtension(fileName);
            if (isTranslated)
            {
                slug = slug.Split('.').First();
            }

            // Save to database
            var savedModel = await blogService.SavePost(slug, language, markdown);

            activity?.Activity?.SetTag("Page Processed", savedModel.Slug);

            // Index in semantic search (only for main directory files)
            if (!e.Name.Contains(Path.DirectorySeparatorChar))
            {
                await IndexPostForSemanticSearchAsync(scope, savedModel, language);
            }

            // Trigger translation for English posts
            if (language == MarkdownBaseService.EnglishLanguage &&
                !string.IsNullOrEmpty(savedModel.Markdown))
            {
                var translateService = scope.ServiceProvider
                    .GetRequiredService<IBackgroundTranslateService>();
                await translateService.TranslateForAllLanguages(
                    new PageTranslationModel
                    {
                        OriginalFileName = filePath,
                        OriginalMarkdown = savedModel.Markdown,
                        Persist = true
                    });
            }
        });

        activity?.Complete();
    }
    catch (Exception exception)
    {
        activity?.Complete(LogEventLevel.Error, exception);
    }
}
```

**Βασικά μοτίβα εδώ:**

1. **Πολιτική επαναπροσπάθειας της Πόλλυ** - Εκτεταμένη backoff (500ms, 1s, 1,5s, 2s, 2.5s)
2. **Μόνο ξαναδοκιμάστε IO Εκτός από** - Άλλες εξαιρέσεις φούσκα επάνω
3. **Υπηρεσίες που εμπίπτουν στο πεδίο εφαρμογής** - Δημιουργία ενός πεδίου ανά αρχείο για την αποφυγή θεμάτων κύκλου ζωής
4. **Δομημένη υλοτομία** - Χρησιμοποιώντας [ΣεριλόγκCity name (optional, probably does not need a translation)](https://serilog.net/) δραστηριότητες για την ανίχνευση
5. **Πτητικές εργασίες** - Αποθήκευση → Δείκτης → Μετάφραση

Η πολιτική επαναπροσπάθεια χειρίζεται την κοινή περίπτωση όπου ένας επεξεργαστής κειμένου εξακολουθεί να γράφει το αρχείο όταν το γεγονός αλλαγής φωτιά.

## Οπτική ροή

```mermaid
graph TD
    A[File Modified] --> B[FileSystemWatcher Event]
    B --> C{File Locked?}
    C -->|Yes| D[Wait 500ms * Attempt]
    D --> E{Retry < 5?}
    E -->|Yes| C
    E -->|No| F[Log Error]
    C -->|No| G[Read File]
    G --> H[Parse Markdown]
    H --> I[Save to Database]
    I --> J{Main Directory?}
    J -->|Yes| K[Index for Search]
    J -->|No| L{English?}
    K --> L
    L -->|Yes| M[Trigger Translation]
    L -->|No| N[Complete]
    M --> N

    style A stroke:#059669,stroke-width:3px,color:#10b981
    style I stroke:#2563eb,stroke-width:3px,color:#3b82f6
    style K stroke:#7c3aed,stroke-width:3px,color:#8b5cf6
    style M stroke:#d97706,stroke-width:3px,color:#f59e0b
```

Καταχωρήστε το σε `Program.cs` (όλες οι υπηρεσίες που φιλοξενούνται ακολουθούν αυτό το πρότυπο):

```csharp
builder.Services.AddHostedService<MarkdownDirectoryWatcherService>();
```

Βλ. piλήρη εφαρογή στο `Mostlylucid/Blog/WatcherService/MarkdownDirectoryWatcherService.cs`.

# Παράδειγμα 2: Κανάλι-Βασισμένη εντολή ηλεκτρονικού ταχυδρομείου

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

## Το Πρόβλημα

Όταν ένας χρήστης υποβάλλει ένα σχόλιο ή μια φόρμα επικοινωνίας:

1. Επιβεβαιώστε και αποθηκεύστε την υποβολή
2. Αναζήτηση ειδοποίησης ηλεκτρονικού ταχυδρομείου
3. Επιστροφή απάντηση αμέσως (μην μπλοκάρετε στο SMTP)
4. Αποστολή μηνυμάτων ηλεκτρονικού ταχυδρομείου στο παρασκήνιο με τη λογική επαναπροσπαθήστε
5. Χειριστείτε τις αποτυχίες SMTP χαριτωμένα (διακόπτης κυκλώματος)

## Η λύση: EmailSenderHostedService

Αυτή η υπηρεσία χρησιμοποιεί ένα [`Channel<T>`](https://learn.microsoft.com/en-us/dotnet/core/extensions/channels) για την ουρά και την Polly για αντοχή:

```csharp
public class EmailSenderHostedService : IEmailSenderHostedService
{
    private readonly Channel<BaseEmailModel> _mailMessages =
        Channel.CreateUnbounded<BaseEmailModel>();
    private readonly CancellationTokenSource _cancellationTokenSource = new();
    private Task _sendTask = Task.CompletedTask;
    private readonly IEmailService _emailService;
    private readonly ILogger<EmailSenderHostedService> _logger;
    private readonly IAsyncPolicy _policyWrap;

    public EmailSenderHostedService(
        IEmailService emailService,
        ILogger<EmailSenderHostedService> logger)
    {
        _emailService = emailService;
        _logger = logger;

        // Retry policy: 3 attempts with exponential backoff
        var retryPolicy = Policy
            .Handle<SmtpException>()
            .WaitAndRetryAsync(3,
                attempt => TimeSpan.FromSeconds(2 * attempt),
                (exception, timeSpan, retryCount, context) =>
                {
                    logger.LogWarning(exception,
                        "Retry {RetryCount} for sending email failed", retryCount);
                });

        // Circuit breaker: open after 5 failures, stay open for 1 minute
        var circuitBreakerPolicy = Policy
            .Handle<SmtpException>()
            .CircuitBreakerAsync(
                5,
                TimeSpan.FromMinutes(1),
                onBreak: (exception, timespan) =>
                {
                    logger.LogError(
                        "Circuit broken due to too many failures. Breaking for {BreakDuration}",
                        timespan);
                },
                onReset: () =>
                {
                    logger.LogInformation("Circuit reset. Resuming email delivery.");
                },
                onHalfOpen: () =>
                {
                    logger.LogInformation("Circuit in half-open state. Testing connection...");
                });

        // Combine retry and circuit breaker
        _policyWrap = Policy.WrapAsync(retryPolicy, circuitBreakerPolicy);
    }

    public async Task SendEmailAsync(BaseEmailModel message)
    {
        await _mailMessages.Writer.WriteAsync(message);
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("Starting background e-mail delivery");
        _sendTask = DeliverAsync(_cancellationTokenSource.Token);
        return Task.CompletedTask;
    }

    public async Task StopAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("Stopping background e-mail delivery");

        // Proper shutdown sequence
        await _cancellationTokenSource.CancelAsync();
        _mailMessages.Writer.Complete(); // Critical: complete the channel

        // Wait for the background task to finish
        await Task.WhenAny(_sendTask, Task.Delay(Timeout.Infinite, cancellationToken));
    }

    private async Task DeliverAsync(CancellationToken token)
    {
        _logger.LogInformation("E-mail background delivery started");

        try
        {
            // Process items as they arrive
            while (await _mailMessages.Reader.WaitToReadAsync(token))
            {
                BaseEmailModel? message = null;
                try
                {
                    message = await _mailMessages.Reader.ReadAsync(token);

                    // Execute with retry policy and circuit breaker
                    await _policyWrap.ExecuteAsync(async () =>
                    {
                        switch (message)
                        {
                            case ContactEmailModel contactEmailModel:
                                await _emailService.SendContactEmail(contactEmailModel);
                                break;
                            case CommentEmailModel commentEmailModel:
                                await _emailService.SendCommentEmail(commentEmailModel);
                                break;
                            case ConfirmEmailModel confirmEmailModel:
                                await _emailService.SendConfirmationEmail(confirmEmailModel);
                                break;
                        }
                    });

                    _logger.LogInformation("Email from {SenderEmail} sent", message.SenderEmail);
                }
                catch (OperationCanceledException)
                {
                    break; // Shutdown requested
                }
                catch (Exception exc)
                {
                    _logger.LogError(exc,
                        "Couldn't send an e-mail from {SenderEmail}",
                        message?.SenderEmail);
                }
            }
        }
        catch (OperationCanceledException)
        {
            _logger.LogWarning("E-mail background delivery canceled");
        }

        _logger.LogInformation("E-mail background delivery stopped");
    }

    public void Dispose()
    {
        _cancellationTokenSource.Dispose();
    }
}
```

**Βασικά πρότυπα που επιδεικνύονται:**

1. **Κανάλι ως ουρά** - Χωρίς περιορισμούς κανάλι για μηνύματα
2. **Περιτύλιγμα πολιτικής** - Ξαναπροσπάθησε μέσα στον διακόπτη κυκλώματος
3. **Χαριτωμένο κλείσιμο** - Πλήρες κανάλι, ακυρώστε το κουπόνι, περιμένετε για την εργασία
4. **Ταίριασμα μοτίβου** - Ενεργοποιήστε διαφορετικούς τύπους email
5. **Καθυστερημένη διάρκεια ζωής** - Δημιουργήθηκε μια φορά ως singleton, κρατάει μακροχρόνια κατάσταση

## Οραματιστείτε ξανά και το Breaker Circuit

```mermaid
graph TD
    A[Queue Email] --> B[Write to Channel]
    B --> C[Background Task Reads]
    C --> D{Send Email}
    D -->|Success| E[Log Success]
    D -->|SmtpException| F{Retry Count < 3?}
    F -->|Yes| G[Wait 2s * Attempt]
    G --> D
    F -->|No| H{Circuit Breaker}
    H -->|< 5 Failures| I[Log Failure]
    H -->|5+ Failures| J[Open Circuit]
    J --> K[Wait 1 Minute]
    K --> L[Half-Open: Test]
    L -->|Success| M[Close Circuit]
    L -->|Failure| J
    E --> N[Continue]
    I --> N

    style A stroke:#059669,stroke-width:3px,color:#10b981
    style D stroke:#2563eb,stroke-width:3px,color:#3b82f6
    style J stroke:#dc2626,stroke-width:3px,color:#ef4444
    style M stroke:#059669,stroke-width:3px,color:#10b981
```

Αυτό δείχνει ένα μοτίβο παραγωγής:

- **Πολιτική επαναπροσπάθειας** χειρίζεται παροδικές αποτυχίες (προσωρινά ζητήματα δικτύου)
- **Διακόπτης κυκλώματος** αποτρέπει αποτυχίες κάψας (εάν SMTP είναι κάτω, μην συνεχίσετε σφυροκόπημα)
- **ΚανάλιCity name (optional, probably does not need a translation)** παρέχει φυσική πίσω πίεση (αν δεν μπορούμε να στείλουμε, μηνύματα ουρά επάνω)

# Παράδειγμα 3: Αποστολέας γεγονότων Analytics (Umami)

Αυτή η υπηρεσία ουρλιάζει analytics events και τα στέλνει σε ένα [ΟυμάμιCity name (optional, probably does not need a translation)](https://umami.is/) analytics server. Είναι παρόμοιο με την υπηρεσία ηλεκτρονικού ταχυδρομείου αλλά απλούστερη πολιτική retry που απαιτείται, απλά φωτιά και να ξεχάσετε.

```csharp
public class UmamiBackgroundSender(
    IServiceScopeFactory scopeFactory,
    ILogger<UmamiBackgroundSender> logger) : IHostedService
{
    private readonly CancellationTokenSource _cancellationTokenSource = new();
    private readonly Channel<SendBackgroundPayload> _channel =
        Channel.CreateUnbounded<SendBackgroundPayload>();
    private Task _sendTask = Task.CompletedTask;

    public Task StartAsync(CancellationToken cancellationToken)
    {
        _sendTask = SendRequest(_cancellationTokenSource.Token);
        return Task.CompletedTask;
    }

    public async Task StopAsync(CancellationToken cancellationToken)
    {
        logger.LogInformation("UmamiBackgroundSender is stopping.");

        // Standard shutdown pattern
        await _cancellationTokenSource.CancelAsync();
        _channel.Writer.Complete();

        try
        {
            await Task.WhenAny(_sendTask, Task.Delay(Timeout.Infinite, cancellationToken));
        }
        catch (OperationCanceledException)
        {
            logger.LogWarning("StopAsync operation was canceled.");
        }
    }

    public async Task TrackPageView(string url, string title, UmamiPayload? payload = null)
    {
        await using var scope = scopeFactory.CreateAsyncScope();
        var payloadService = scope.ServiceProvider.GetRequiredService<PayloadService>();
        var sendPayload = payloadService.PopulateFromPayload(payload, null);
        sendPayload.Url = url;
        sendPayload.Title = title;

        await _channel.Writer.WriteAsync(new SendBackgroundPayload("event", sendPayload));
        logger.LogInformation("Umami pageview event queued");
    }

    private async Task SendRequest(CancellationToken token)
    {
        logger.LogInformation("Umami background delivery started");

        // Double while loop: outer waits for items, inner drains all available
        while (await _channel.Reader.WaitToReadAsync(token))
        {
            while (_channel.Reader.TryRead(out var payload))
            {
                try
                {
                    using var scope = scopeFactory.CreateScope();
                    var client = scope.ServiceProvider.GetRequiredService<UmamiClient>();

                    await client.Send(payload.Payload, type: payload.EventType);

                    logger.LogInformation("Umami background event sent: {EventType}",
                        payload.EventType);
                }
                catch (OperationCanceledException)
                {
                    logger.LogWarning("Umami background delivery canceled.");
                    return;
                }
                catch (Exception ex)
                {
                    logger.LogError(ex, "Error sending Umami background event.");
                }
            }
        }
    }

    private record SendBackgroundPayload(string EventType, UmamiPayload Payload);
}
```

**Βασικές διαφορές από την υπηρεσία ηλεκτρονικού ταχυδρομείου:**

1. **Καμία πολιτική ανθεκτικότητας** - Η ανάλυση δεν είναι κρίσιμη, αν αποτύχει απλά συνδεόμαστε και συνεχίζουμε.
2. **Διπλό ενώ βρόχο** - Ο εσωτερικός βρόχος στραγγίζει όλα τα διαθέσιμα στοιχεία για την αποτελεσματικότητα
3. **Δημιουργία υπηρεσιών που εμπίπτουν στο πεδίο εφαρμογής** - Κάθε αποστολή παίρνει ένα νέο πεδίο εφαρμογής για τον πελάτη HTTP

Αυτό αποδεικνύει ότι δεν χρειάζονται όλες οι υπηρεσίες υποβάθρου περίπλοκο χειρισμό σφαλμάτων. Για τη μη-κρίσιμη τηλεμετρία, η απλή καταγραφή μπορεί να είναι αρκετή.

# Παράδειγμα 4: Περιοδική εργασία με το BackgroundService

Τώρα ας δούμε τις υπηρεσίες που κάνουν περιοδική εργασία και όχι την επεξεργασία ουρά. `BrokenLinkCheckerBackgroundService` ελέγχει blog post συνδέσεις περιοδικά και fetches αρχεία.org URL για σπασμένα αυτά.

```csharp
public class BrokenLinkCheckerBackgroundService : BackgroundService
{
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<BrokenLinkCheckerBackgroundService> _logger;
    private readonly HttpClient _httpClient;
    private readonly TimeSpan _checkInterval = TimeSpan.FromHours(1);
    private const int BatchSize = 20;

    public BrokenLinkCheckerBackgroundService(
        IServiceProvider serviceProvider,
        ILogger<BrokenLinkCheckerBackgroundService> logger,
        IHttpClientFactory httpClientFactory)
    {
        _serviceProvider = serviceProvider;
        _logger = logger;
        _httpClient = httpClientFactory.CreateClient("BrokenLinkChecker");
        _httpClient.Timeout = TimeSpan.FromSeconds(30);
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Broken Link Checker Background Service started");

        // Initial delay to let the application fully start
        await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await CheckLinksAsync(stoppingToken);
                await FetchArchiveUrlsAsync(stoppingToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error in Broken Link Checker Background Service");
            }

            _logger.LogInformation("Broken Link Checker sleeping for {Interval}", _checkInterval);
            await Task.Delay(_checkInterval, stoppingToken);
        }

        _logger.LogInformation("Broken Link Checker Background Service stopped");
    }

    private async Task CheckLinksAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("Starting link validity check");

        using var scope = _serviceProvider.CreateScope();
        var brokenLinkService = scope.ServiceProvider.GetRequiredService<IBrokenLinkService>();

        var linksToCheck = await brokenLinkService.GetLinksToCheckAsync(BatchSize, cancellationToken);
        _logger.LogInformation("Found {Count} links to check", linksToCheck.Count);

        foreach (var link in linksToCheck)
        {
            if (cancellationToken.IsCancellationRequested) break;

            try
            {
                var (statusCode, isBroken, error) = await CheckUrlAsync(link.OriginalUrl, cancellationToken);
                await brokenLinkService.UpdateLinkStatusAsync(
                    link.Id, statusCode, isBroken, error, cancellationToken);

                if (isBroken)
                {
                    _logger.LogWarning("Link is broken: {Url} (Status: {StatusCode})",
                        link.OriginalUrl, statusCode);
                }

                // Be respectful to servers
                await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error checking link: {Url}", link.OriginalUrl);
                await brokenLinkService.UpdateLinkStatusAsync(
                    link.Id, 0, true, ex.Message, cancellationToken);
            }
        }
    }

    private async Task<(int statusCode, bool isBroken, string? error)> CheckUrlAsync(
        string url,
        CancellationToken cancellationToken)
    {
        try
        {
            using var request = new HttpRequestMessage(HttpMethod.Head, url);
            request.Headers.UserAgent.ParseAdd(
                "Mozilla/5.0 (compatible; MostlylucidBot/1.0; +https://www.mostlylucid.net)");

            using var response = await _httpClient.SendAsync(
                request,
                HttpCompletionOption.ResponseHeadersRead,
                cancellationToken);

            var statusCode = (int)response.StatusCode;
            var isBroken = response.StatusCode == HttpStatusCode.NotFound ||
                          response.StatusCode == HttpStatusCode.Gone ||
                          statusCode >= 500;

            return (statusCode, isBroken, null);
        }
        catch (HttpRequestException ex)
        {
            return (0, true, ex.Message);
        }
        catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
        {
            return (0, true, "Request timed out");
        }
    }
}
```

**Μοτίβα που επιδεικνύονται:**

1. **BackgroundService base class** - Ιδανικό για περιοδική εργασία
2. **Αρχική καθυστέρηση** - Περιμένετε άλλες υπηρεσίες να ξεκινήσουν
3. **Παρτίδα** - Διαδικασία 20 συνδέσεις τη φορά, όχι όλα ταυτόχρονα
4. **Περιορισό του piοσοστού** - 2 δευτερόλεπτα καθυστέρηση μεταξύ των ελέγχων για να είναι ευγενικοί
5. **Δημιουργία υπηρεσιών που εμπίπτουν στο πεδίο εφαρμογής** - Νέο πεδίο εφαρμογής ανά παρτίδα
6. **Έλεγχος μάρκας ακύρωσης** - Διάλειμμα νωρίς αν ζητηθεί διακοπή λειτουργίας

# Παράδειγμα 5: Σημασιολογική αναζήτηση ευρετηρίαση με Hash-Based ανίχνευση αλλαγών

Ο σημασιολογικός ευρετήρας αναζήτησης είναι πιο εξελιγμένος μόνο re-indexes δημοσιεύσεις που έχουν αλλάξει πραγματικά.

csharp
public class SemanticIndexingBackgroundService : BackgroundService
{
    private readonly ILogger<SemanticIndexingBackgroundService> _logger;
    private readonly IServiceProvider _serviceProvider;
    private readonly ISemanticSearchService _semanticSearchService;
    private readonly MarkdownConfig _markdownConfig;
    private readonly SemanticSearchConfig _semanticSearchConfig;
    private readonly TimeSpan _indexInterval = TimeSpan.FromHours(1);
    private readonly TimeSpan _startupDelay = TimeSpan.FromSeconds(30);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        if (!_semanticSearchConfig.Enabled)
        {
            _logger.LogInformation("Semantic search is disabled, indexing service will not run");
            return;
        }

        _logger.LogInformation("Semantic indexing background service starting...");

        // Wait for other services to initialise
        await Task.Delay(_startupDelay, stoppingToken);

        // Initialise the semantic search service
        try
        {
            await _semanticSearchService.InitializeAsync(stoppingToken);
            _logger.LogInformation("Semantic search initialized successfully");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to initialize semantic search service");
            return;
        }

        // Initial indexing
        await IndexAllMarkdownFilesAsync(stoppingToken);

        // Periodic re-indexing to catch any changes
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await Task.Delay(_indexInterval, stoppingToken);
                await IndexAllMarkdownFilesAsync(stoppingToken);
            }
            catch (OperationCanceledException)
            {
                break;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error during periodic indexing");
            }
        }

        _logger.LogInformation("Semantic indexing background service stopped");
    }

    private async Task IndexAllMarkdownFilesAsync(CancellationToken stoppingToken)
    {
        var markdownPath = _markdownConfig.MarkdownPath;

        if (!Directory.Exists(markdownPath))
        {
            _logger.LogWarning("Markdown directory does not exist: {Path}", markdownPath);
            return;
        }

        // Get ONLY files in the main directory, NOT subdirectories
        var markdownFiles = Directory.GetFiles(markdownPath, "*.md", SearchOption.TopDirectoryOnly);

        _logger.LogInformation("Found {Count} markdown files to index in {Path}",
            markdownFiles.Length, markdownPath);

        var indexedCount = 0;
        var skippedCount = 0;
        var errorCount = 0;

        using var scope = _serviceProvider.CreateScope();
        var markdownRenderingService = scope.ServiceProvider
            .GetRequiredService<MarkdownRenderingService>();

        foreach (var filePath in markdownFiles)
        {
            if (stoppingToken.IsCancellationRequested)
                break;

            try
            {
                var result = await IndexMarkdownFileAsync(
                    filePath,
                    markdownRenderingService,
                    stoppingToken);

                if (result == IndexResult.Indexed)
                    indexedCount++;
                else if (result == IndexResult.Skipped)
                    skippedCount++;
            }
            catch (Exception ex)
            {
                errorCount++;
                _logger.LogError(ex, "Error indexing file: {FilePath}", filePath);
            }

            // Delay to avoid overwhelming the embedding service
            await Task.Delay(100, stoppingToken);
        }

        _logger.LogInformation(
            "Indexing complete: {Indexed} indexed, {Skipped} skipped (unchanged), {Errors} errors",
            indexedCount, skippedCount, errorCount);
    }

    private async Task<IndexResult> IndexMarkdownFileAsync(
        string filePath,
        MarkdownRenderingService markdownRenderingService,
        CancellationToken stoppingToken)
    {
        var fileName = Path.GetFileNameWithoutExtension(filePath);

        // Skip translated files (they have language suffix like .es.md, .fr.md)
        if (fileName.Contains('.'))
        {
            var parts = fileName.Split('.');
            if (parts.Length >= 2 && parts[^1].Length == 2)
            {
                return IndexResult.Skipped;
            }
        }

        var markdown = await File.ReadAllTextAsync(filePath, stoppingToken);
        var fileInfo = new FileInfo(filePath);

        var blogPost = markdownRenderingService.GetPageFromMarkdown(
            markdown,
            fileInfo.LastWriteTimeUtc,
            filePath);

        if (blogPost.IsHidden)
        {
            _logger.LogDebug("Skipping hidden post: {Slug}", blogPost.Slug);
            return IndexResult.Skipped;
        }

        // Compute content hash
        var contentHash = ComputeContentHash(blogPost.PlainTextContent);

        // Check if reindexing is needed
        var needsReindex = await _semanticSearchService.NeedsReindexingAsync(
            blogPost.Slug,
            MarkdownBaseService.EnglishLanguage,
            contentHash,
            stoppingToken);

        if (!needsReindex)
        {
            _logger.LogDebug("Skipping unchanged post: {Slug}", blogPost.Slug);
            return IndexResult.Skipped;
        }

        // Create document for indexing
        var document = new BlogPostDocument
        {
            Id = $"{blogPost.Slug}_{MarkdownBaseService.EnglishLanguage}",
            Slug = blogPost.Slug,
            Title = blogPost.Title,
            Content = blogPost.Plain
