Back to "Servizi di background in ASP.NET Core - Parte 2: Esempi pratici"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

ASP.NET Core BackgroundService Channels IHostedService Polly

Servizi di background in ASP.NET Core - Parte 2: Esempi pratici

Thursday, 27 November 2025

Teoria è una cosa; codice di produzione è un'altra. Nella Parte 1 abbiamo coperto le astrazioni ora vediamo come sono applicate in una vera base di codice. Questo articolo cammina attraverso i servizi di background reali da questa piattaforma blog: osservatori di file con politiche Polly retry, code di posta elettronica basati su canali con interruttori di circuito, semantici indexer di ricerca con rilevamento di cambiamenti basati su hash, e altro ancora.

Introduzione

Dentro Parte 1, abbiamo esplorato gli approcci fondamentali per l'implementazione di servizi di background in ASP.NET Core. IHostedService, BackgroundService, gestione del ciclo di vita, e le insidie comuni sulla gestione dello spegnimento.

Ora è il momento di vedere questi modelli in azione. In questo articolo, esamineremo i servizi di background reali da una piattaforma di produzione blog, dimostrando:

  • Osservatori di file di sistema che sincronizzano i file di markdown in un database
  • Code di posta elettronica basate su canali con politiche di riprova Polly
  • Mittenti di eventi analitici che richiedono background batch
  • Indexer di ricerca semantici con rilevamento del cambiamento basato sull'hash
  • Checker di collegamento rotti che convalidano periodicamente gli URL esterni
  • Coordinamento dell'avviamento tra i servizi dipendenti
  • Gestione del ciclo di vita del quadro di controllo e dell'entità

Ogni esempio illustra soluzioni pratiche a problemi comuni che si incontrano quando si costruiscono servizi di background di produzione.

Esempio 1: File System Watcher con Polly Retry

Il primo servizio che esamineremo controlla una directory dei post del blog markdown e li elabora automaticamente quando cambiano. Questo è un esempio perfetto di elaborazione di background guidata da eventi.

Il problema

Quando viene creato o modificato un file markdown:

  1. Leggi il contenuto del file
  2. Parse markdown metadati (titolo, categorie, data di pubblicazione)
  3. Rendere HTML ed estrarre testo semplice
  4. Salva nel database
  5. Attiva la traduzione in altre lingue
  6. Indice per la ricerca semantica

La sfida: FileSystemWatcher eventi possono sparare mentre il file è ancora in fase di scrittura, causando IOException quando provi a leggerlo.

La soluzione: 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);
            }
        }
    }
}

Notare alcuni modelli chiave:

  1. IHostedService, non BackgroundService - Abbiamo bisogno di un controllo accurato sulla configurazione del guardaroba.
  2. Attività di sfondo separata - La StartAsync ritorna immediatamente, l'elaborazione avviene in AwaitChanges
  3. Coordinamento dell'avviamento - Segnali quando è pronto per servizi dipendenti
  4. Adeguato smaltimento - Disabilita e dispone del file system watcher in StopAsync

Gestione problemi di blocco file con Polly

La parte più interessante è come gestiamo i file che sono ancora in fase di scrittura. PollyCity name (optional, probably does not need a translation) è una libreria .NET di resilienza che fornisce politiche di riprova, interruttori di circuito, e altro ancora:

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

Modelli chiave qui:

  1. Politica di ricerca e sviluppo - Backoff esponenziale (500m, 1s, 1.5s, 2s, 2.5s)
  2. Solo riprovare IOException - Altre eccezioni bubble up
  3. Servizi a campo d'applicazione - Creare un campo di applicazione per file per evitare problemi di ciclo di vita
  4. Registrazione strutturata - Usando SerilogCity name (optional, probably does not need a translation) attività di tracciamento
  5. Operazioni di cascata - Salva → Indice → Traduci

La politica di riprova gestisce il caso comune in cui un editor di testo sta ancora scrivendo il file quando l'evento di cambiamento spara.

Flusso visivo

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

Registralo in Program.cs (tutti i servizi ospitati seguono questo schema):

builder.Services.AddHostedService<MarkdownDirectoryWatcherService>();

Si veda l'attuazione completa all'indirizzo: http:europa.eu.int/comm/environment/index_en.htm Mostlylucid/Blog/WatcherService/MarkdownDirectoryWatcherService.cs.

Esempio 2: Coda di posta elettronica basata su canali

L'invio di email è un classico caso d'uso per i servizi di background. Non si desidera bloccare una richiesta HTTP mentre si negozia SMTP, quindi si coda l'email e inviarlo in background.

Il problema

Quando un utente invia un commento o un modulo di contatto:

  1. Convalidare e memorizzare la presentazione
  2. In coda una notifica di posta elettronica
  3. Risposta di ritorno immediatamente (non bloccare su SMTP)
  4. Invia messaggi di posta elettronica in background con logica di riprova
  5. Gestire con grazia i guasti SMTP (interruttore di circuito)

La soluzione: EmailSenderHostedService

Questo servizio utilizza un Channel<T> per la coda e Polly per la resilienza:

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

Modelli chiave dimostrati:

  1. Canale come coda - Canale senza restrizioni per i messaggi
  2. Imballaggio delle politiche - Riprova all'interno dell'interruttore
  3. Spegnimento grazioso - Canale completo, cancellazione token, attesa attività
  4. Corrispondenza motivo - Attiva diversi tipi di email
  5. Durata utile - Creato una volta come singleton, detiene stato di lunga durata

Riprova e Interruttore di circuito visualizzato

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

Ciò dimostra uno schema di produzione:

  • Riprovare la politica gestisce i guasti transitori (problemi di rete temporanei)
  • Interruttore previene i guasti a cascata (se SMTP è giù, non continuare a martellare)
  • Canale fornisce contropressione naturale (se non possiamo inviare, messaggi in coda)

Esempio 3: Mittente di eventi analitici (Umami)

Questo servizio mette in coda gli eventi di analytics e li invia ad un UmamiCity name (optional, probably does not need a translation) server analytics. E 'simile al servizio di posta elettronica, ma più semplice policy di riprova necessario, basta sparare e dimenticare.

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

Le differenze chiave dal servizio di posta elettronica:

  1. Nessuna politica di resilienza - L'analisi non è critica, se fallisce registriamo e continuiamo
  2. Raddoppia mentre loop - Loop interno drena tutti gli articoli disponibili per l'efficienza
  3. Creazione di servizi a portata di mano - Ogni invio ottiene un nuovo campo di applicazione per il client HTTP

Ciò dimostra che non tutti i servizi di background necessitano di una gestione complessa degli errori. Per la telemetria non critica, una semplice registrazione potrebbe essere sufficiente.

Esempio 4: lavoro periodico di background con backgroundService

Ora guardiamo a servizi che fanno lavoro periodico piuttosto che elaborare code. BrokenLinkCheckerBackgroundService controlla periodicamente i link del post del blog e recupera gli URL di archive.org per quelli rotti.

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

Patterns ha dimostrato:

  1. Classe base BackgroundService - Perfetto per lavori periodici
  2. Ritardo iniziale - Aspettare che altri servizi inizializzino
  3. Lotto - Processo 20 collegamenti alla volta, non tutti in una volta
  4. Limitazioni dei tassi - 2 secondi di ritardo tra i controlli per essere educati
  5. Creazione di servizi a portata di mano - Nuovo campo d'applicazione per lotto
  6. Controllo token di annullamento - Rompere in anticipo se lo spegnimento richiesto

Esempio 5: Ricerca semantica Indicizzazione con rilevamento dei cambiamenti basati su Hash

L'indexer di ricerca semantico è più sofisticato solo re-indexes post che hanno effettivamente cambiato. Ciò consente di risparmiare costose chiamate API incorporando.

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
logo

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