Achtergronddiensten in ASP.NET Core - Deel 2: Praktische voorbeelden (Nederlands (Dutch))

Achtergronddiensten in ASP.NET Core - Deel 2: Praktische voorbeelden

Thursday, 27 November 2025

//

15 minute read

Theorie is een ding; productie code is een ander. In deel 1 hebben we de abstracties behandeld nu laten we zien hoe ze worden toegepast in een echte codebase. Dit artikel loopt door echte background services van deze blog platform: file watchers met Polly retry beleid, kanaal-gebaseerde e-mail wachtrijen met circuit brekers, semantische zoekindexers met hash-gebaseerde verandering detectie, en nog veel meer.

Inleiding

In Deel 1, we onderzochten de fundamentele benaderingen voor het implementeren van background services in ASP.NET Core. IHostedService, BackgroundService, lifecycle management, en de gemeenschappelijke valkuilen rond shutdown handling.

Nu is het tijd om deze patronen in actie te zien. In dit artikel zullen we echte background services van een productie blog platform onderzoeken, demonstreren:

  • Bestandssysteem watchers die markdown bestanden synchroniseren naar een database
  • Channel-gebaseerde e-mail wachtrijen met Polly opnieuw proberen beleid
  • Analytics event afzenders die background verzoeken
  • Semantische zoekindexers met hash-gebaseerde veranderingsdetectie
  • Gebroken koppelingscheckers die periodiek externe URL's valideren
  • Opstartcoördinatie tussen afhankelijke diensten
  • Scoping en beheer van de levenscyclus van entiteiten

Elk voorbeeld illustreert praktische oplossingen voor gemeenschappelijke problemen die je tegenkomt bij het bouwen van achtergronddiensten.

Voorbeeld 1: File System Watcher met Polly Retry

De eerste dienst die we bekijken horloges een directory van markdown blog berichten en automatisch verwerkt ze wanneer ze veranderen. Dit is een perfect voorbeeld van gebeurtenis-gedreven achtergrond verwerking.

Het probleem

Wanneer een markdown-bestand wordt aangemaakt of gewijzigd:

  1. De inhoud van het bestand lezen
  2. Metadata ontleden (titel, categorieën, datum publiceren)
  3. Naar HTML renderen en platte tekst uitpakken
  4. Opslaan in de database
  5. Trigger vertaling naar andere talen
  6. Index voor semantisch zoeken

De uitdaging: FileSystemWatcher gebeurtenissen kunnen vuren terwijl het bestand nog steeds wordt geschreven, waardoor IOException Als je het probeert te lezen.

De oplossing: MarkdownDirectoryWatcherService

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

Merk een paar sleutelpatronen op:

  1. IHostedService, niet AchtergrondService - We hebben controle nodig over de Wachter setup.
  2. Aparte achtergrondtaak - De StartAsync komt onmiddellijk terug, de verwerking vindt plaats in AwaitChanges
  3. Coördinatie van het opstarten - Signalen als het klaar is voor afhankelijke diensten
  4. Juiste verwijdering - Deactiveert en verwijdert de bestandssysteemwatcher in StopAsync

Problemen met het vergrendelen van bestanden met Polly

Het meest interessante is hoe we omgaan met bestanden die nog steeds worden geschreven. Polly is een .NET veerkracht bibliotheek die retry beleid, stroomonderbrekers, en nog veel meer biedt:

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

Sleutelpatronen hier:

  1. Polly retry policy - Exponentiële terugslag (500ms, 1s, 1.5s, 2s, 2.5s)
  2. Probeer alleen IOException opnieuw - Andere uitzonderingen bubbelen omhoog
  3. Geavanceerde diensten - Maak een scope per bestand om levenscyclusproblemen te vermijden
  4. Gestructureerde houtkap - Gebruiken Serilog traceeractiviteiten
  5. Cascading-bewerkingen - Opslaan → Index → Vertalen

Het retry beleid behandelt het gemeenschappelijke geval waar een teksteditor is nog steeds het bestand schrijven wanneer de verandering gebeurtenis branden.

Visuele stroom

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

Registreer het in Program.cs (alle gehoste diensten volgen dit patroon):

builder.Services.AddHostedService<MarkdownDirectoryWatcherService>();

Zie de volledige tenuitvoerlegging op Mostlylucid/Blog/WatcherService/MarkdownDirectoryWatcherService.cs.

Voorbeeld 2: Op kanaal gebaseerde e-mailwachtrij

E-mail verzenden is een klassieke use case voor background services. U wilt een HTTP-verzoek niet blokkeren terwijl u onderhandelt over SMTP, dus u wacht de e-mail in de wachtrij en verzend het op de achtergrond.

Het probleem

Wanneer een gebruiker een commentaar of contactformulier indient:

  1. Valideren en opslaan van de indiening
  2. Wacht op een e-mailbericht
  3. Return response onmiddelijk (niet blokkeren op SMTP)
  4. E-mails versturen op de achtergrond met retry logica
  5. SMTP-storingen elegant aanpakken (circuitonderbreker)

De oplossing: EmailSenderHostedService

Deze dienst maakt gebruik van een Channel<T> voor de wachtrij en Polly voor veerkracht:

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

Sleutelpatronen gedemonstreerd:

  1. Kanaal als wachtrij - Niet geconsolideerd kanaal voor berichten
  2. Beleidsverpakking - Probeer de binnenschakelaar opnieuw
  3. Graceful shutdown - Volledig kanaal, token annuleren, wacht op taak
  4. Patroon komt overeen - Schakel verschillende e-mailtypes in
  5. Periodische levensduur - Eenmaal gemaakt als singleton, heeft een langlevende staat

Opnieuw proberen en Circuit Breaker Visualized

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

Dit demonstreert een productie-kwaliteit patroon:

  • Beleid opnieuw uitproberen het behandelen van voorbijgaande storingen (tijdelijke netwerkproblemen)
  • Circuitbreker voorkomt cascading storingen (als SMTP is uitgeschakeld, niet blijven hameren)
  • Kanaal biedt natuurlijke tegendruk (als we niet kunnen versturen, berichten wachtrij omhoog)

Voorbeeld 3: Analytics Event Sender (Umami)

Deze service wachtrij analytics evenementen en stuurt ze naar een Umami analytics server. Het is vergelijkbaar met de e-mail service, maar eenvoudiger... geen retry beleid nodig, gewoon vuur en vergeet.

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

Belangrijkste verschillen met de e-maildienst:

  1. Geen veerkrachtsbeleid - Analytics zijn niet kritisch, als het mislukt we gewoon loggen en doorgaan
  2. Dubbele tijdlus - Binnenloop afvoert alle beschikbare items voor efficiëntie
  3. Creëren van diensten met betrekking tot de reikwijdte - Elke verzenden krijgt een nieuwe scope voor de HTTP client

Dit toont aan dat niet alle background services complexe foutafhandeling nodig hebben. Voor niet-kritische telemetrie kan eenvoudig loggen voldoende zijn.

Voorbeeld 4: Periodieke achtergrond werken met AchtergrondService

Laten we nu kijken naar diensten die periodiek werk doen in plaats van het verwerken van wachtrijen. BrokenLinkCheckerBackgroundService controleert blog post links periodiek en fetches archive.org URL's voor gebroken.

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

Patronen demonstreerden:

  1. AchtergrondService basisklasse - Perfect voor periodiek werk
  2. Initiële vertraging - Wacht tot andere diensten worden geïnitialiseerd
  3. Loting - Proces 20 links tegelijk, niet allemaal tegelijk
  4. Snelheidsbeperking - 2 seconden vertraging tussen de controles om beleefd te zijn
  5. Creëren van diensten met betrekking tot de reikwijdte - Nieuw toepassingsgebied per partij
  6. Controle van het annuleringsteken - Breek vroeg als afsluiten wordt aangevraagd

Voorbeeld 5: Semantische Zoeken Indexeren met Hash-based Change Detection

De semantische zoekindexer is meer gesofistikeerd het alleen re-indexeert berichten die daadwerkelijk zijn veranderd. Dit bespaart dure inbedding API-oproepen.

csharp public class SemanticIndexingBackgroundService : BackgroundService { private readonly ILogger _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
Finding related posts...
logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.