# Taustapalvelut ASP.NET Coressa - Osa 2: Käytännöllisiä esimerkkejä

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

Teoria on eri asia, tuotantokoodi on toinen. Ensimmäisessä osassa käsittelimme abstraktioita – nyt katsotaan, miten niitä sovelletaan todellisessa koodipohjassa. Artikkelissa käydään läpi varsinaiset taustapalvelut tältä blogialustalta: tiedostonvalvojat, joilla on Polly-retriitit, kanavapohjaiset sähköpostijonot, joissa on virtakatkaisijat, semanttiset hakuindeksaattorit, joissa on hash-pohjainen muutostunnistus, ja paljon muuta.

# Johdanto

Sisään [Osa 1](/blog/background-services-in-aspnetcore-part1)Tutkimme ASP.NET Coressa peruslähtökohtia taustapalvelujen käyttöönotossa. `IHostedService`, `BackgroundService`, elinkaaren hallinta, ja yleinen sudenkuoppa sulkukäsittelyn ympärillä.

Nyt on aika nähdä nämä kuviot käytännössä. Tässä artikkelissa tarkastelemme todellisia taustapalveluita tuotantoblogin alustalta, mikä osoittaa:

- Tiedostojärjestelmän tarkkailijat, jotka synkronoivat tiedostot tietokantaan
- Kanavapohjaiset sähköpostijonot Pollyn kanssa
- Analyyttiset tapahtumalähettäjät, jotka esittävät taustapyyntöjä
- Semanttiset hakuindeksoijat, joilla on hasispohjainen muutoshavainnointi
- Rikkinäiset linkkien tarkistajat, jotka säännöllisesti validoivat ulkoisia URL-osoitteita
- Käynnistyskoordinointi riippuvaisten palvelujen välillä
- Coping ja Entity Framework -elinkaaren hallinta

Jokainen esimerkki kuvaa käytännön ratkaisuja yhteisiin ongelmiin, joita kohtaat rakentaessasi tuotannon taustapalveluita.

[TOC]

# Esimerkki 1: Tiedostojärjestelmän valvoja Polly Retryllä

Ensimmäinen palvelu, jota tutkimme, seuraa hakemistoa markown-blogin kirjoituksista ja käsittelee ne automaattisesti niiden muuttuessa. Tämä on täydellinen esimerkki tapahtumalähtöisestä taustakäsittelystä.

## Ongelma

Kun markdown-tiedosto luodaan tai muutetaan:

1. Lue tiedostojen sisältö
2. Jäsennysmerkkien metatiedot (otsikko, luokat, julkaisupäivä)
3. Renderoida HTML:ään ja poimia pelkkää tekstiä
4. Tallenna tietokantaan
5. Aloita kääntäminen muille kielille
6. Semanttisen haun hakemisto

Haaste: [`FileSystemWatcher`](https://learn.microsoft.com/en-us/dotnet/api/system.io.filesystemwatcher) tapahtumat voivat laueta, kun tiedostoa vielä kirjoitetaan, mikä aiheuttaa `IOException` kun sitä yrittää lukea.

## Ratkaisu: 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);
            }
        }
    }
}
```

Huomaa muutama avainkuvio:

1. **IHostedService, ei taustapalvelu** -Tarvitsemme tarkkaaja-asetusta.
2. **Erillinen taustatehtävä** - Mitä? `StartAsync` palaa heti, käsittely tapahtuu `AwaitChanges`
3. **Käynnistyskoordinointi** - Signaaleja, kun se on valmis huollettaviin palveluihin
4. **Asianmukainen hävittäminen** - Poistaa ja hävittää tiedostojärjestelmän valvojan `StopAsync`

## Tiedostolukko-ongelmien käsittely Pollyn kanssa

Kiinnostavinta on se, miten käsittelemme yhä kirjoitettavia tiedostoja. [Polly](https://github.com/App-vNext/Polly) on .NET-resistanssikirjasto, joka tarjoaa uusia toimintamalleja, virrankatkaisijoita ja paljon muuta:

```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);
    }
}
```

**Tärkeimmät kuviot tässä:**

1. **Polly yrittää uudelleen** - Exponential backoff (500ms, 1s, 1,5s, 2s, 2,5s)
2. **Kokeile vain uudelleen IO Executivea** - Muut poikkeukset pulppuavat
3. **Soveltamisalaan kuuluvat palvelut** - Luo tiedostokohtainen kattaus elinkaariongelmien välttämiseksi
4. **Strukturoitu puunkorjuu** - Käyttäytyminen [Serilog](https://serilog.net/) jäljitystoimet
5. **Ripustusoperaatiot** - Tallenna → Hakemisto → Käännä

Uusintakäytäntö käsittelee tavallista tapausta, jossa tekstieditori kirjoittaa yhä tiedostoa, kun muutostapahtuma syttyy.

## Visual Flow

```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
```

Rekisteröi se `Program.cs` (Kaikki isännöidyt palvelut noudattavat tätä kaavaa):

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

Ks. täytäntöönpano kokonaisuudessaan osoitteessa `Mostlylucid/Blog/WatcherService/MarkdownDirectoryWatcherService.cs`.

# Esimerkki 2: Kanavapohjainen sähköpostijono

Sähköpostin lähettäminen on klassinen tapa käyttää taustapalveluita. Et halua estää HTTP-pyyntöä, kun neuvottelet SMTP:stä, joten jonotat sähköpostia ja lähetät sen taustalla.

## Ongelma

Kun käyttäjä lähettää kommentti- tai yhteydenottolomakkeen:

1. Validoi ja tallenna toimitus
2. Varaa sähköposti-ilmoitus
3. Palauta vastaus välittömästi (älä estä SMTP:tä)
4. Lähetä taustalla olevia sähköposteja uudelleen logiikalla
5. Hand SMTP epäonnistuu sulavasti (katkaisin)

## Ratkaisu: EmailSenderSenderedService

Tämä palvelu käyttää [`Channel<T>`](https://learn.microsoft.com/en-us/dotnet/core/extensions/channels) jonolle ja Pollylle sietokyvyn puolesta:

```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();
    }
}
```

**Keskeisiä kuvioita on esitetty:**

1. **Kanava jonona** - Viestejä ei saa lähettää
2. **Politiikan paketointi** - Yritä uudelleen piirinkatkaisimen sisällä
3. **Armollinen sammutus** - Täysi kanava, peruuta merkki, odota tehtävää
4. **Mallien vastaavuus** - Vaihda eri sähköpostityyppejä
5. **Laajennettu käyttöikä** - Luotu kerran sinkkuna, hänellä on pitkäikäistä tilaa

## Uudelleenyrittäjä ja piirinkatkaisija visualisoitu

```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
```

Tämä osoittaa, että tuotantotaso on seuraava:

- **Uudelleenyrittämispolitiikka** Käsittelee ohimeneviä vikoja (väliaikaisia verkkokysymyksiä)
- **Virrankatkaisin** estää rystysten epäonnistumiset (jos SMTP on alhaalla, älä jatkuvasti moukaroi sitä)
- **Kanava** Tarjoaa luonnollista vastapainetta (ellemme voi lähettää, viestejä jonotetaan)

# Esimerkki 3: Analytics Event Sender (Umami)

Tämä palvelu jonottaa analytiikkatapahtumia ja lähettää ne [Umami](https://umami.is/) Analytiikkapalvelin. Se on samanlainen kuin sähköpostipalvelu, mutta yksinkertaisempi – ei uusintakäytäntöä tarvita, vain tulta ja unohtamista.

```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);
}
```

**Tärkeimmät erot sähköpostipalvelusta:**

1. **Ei häiriönsietopolitiikkaa** - Analyytikot eivät ole kriittisiä, jos se epäonnistuu, kirjaudumme ja jatkamme
2. **Kaksinkertaista silmukan aikana** - Sisäsilmukka vie kaikki käytettävissä olevat tuotteet tehokkaaksi
3. **Laajennetun palvelun luominen** - Jokainen lähetys saa uuden tähtäimen HTTP-asiakkaalle

Tämä osoittaa, että kaikki taustapalvelut eivät tarvitse monimutkaista virheiden käsittelyä. Ei-kriittinen telemetria voi riittää yksinkertaiseen kirjaukseen.

# Esimerkki 4: Satunnainen taustatyö taustapalvelun kanssa

Nyt tarkastellaan palveluja, jotka tekevät määräaikaista työtä jonojen käsittelyn sijaan. `BrokenLinkCheckerBackgroundService` Tarkista blogin linkit ajoittain ja noutaa archive.org URL-osoitteet rikkinäisille.

```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");
        }
    }
}
```

**Esitetyt kuviot:**

1. **Taustapalvelun tukikohdan luokka** - Täydellinen määräaikaisiin töihin
2. **Alkuperäinen viivästys** - Odota muiden palveluiden alustamista
3. **Leikkaus** - Prosessi 20 linkkiä kerrallaan, ei kaikkia kerralla
4. **Taajuuden rajoitus** - 2 sekunnin viive sekkien välillä kohteliaisuudesta
5. **Laajennetun palvelun luominen** - Uusi laajuus erää kohti
6. **Peruutuskyltin tarkistus** - Katkaise aikaisessa vaiheessa, jos sammutusta pyydetään

# Esimerkki 5: Semanttinen haku Hash-pohjainen muutoshavainnointi

Semanttinen hakuindeksoija on kehittyneempi – se vain uudelleen indeksoi oikeasti muuttuneet viestit. Tämä säästää kalliit upotetut API-puhelut.

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
