Services in ASP.NET Core - Part 2: Практичні приклади (Українська (Ukrainian))

Services in ASP.NET Core - Part 2: Практичні приклади

Thursday, 27 November 2025

//

15 minute read

Теорія - одна річ, код виробництва - це ще одна річ. У частині 1 ми обговорювали абстракції } Тепер давайте подивимося, як їх застосовують у справжній кодовій базі даних. Ця стаття проходить через справжні фонові служби з цієї платформи блогу: спостерігачі файлів за допомогою правил Polly retrait, заснованих на каналах черг електронної пошти з переривами ланцюгів, семантичні індексатори пошуку з визначенням змін у хешах тощо.

Вступ

Вхід Частина 1Ми дослідили фундаментальні підходи до впровадження фонових служб у ядрах ASPNET. IHostedService, BackgroundService, управління життєвим циклом, і загальні пастки для того, щоб припинити роботу.

Тепер настав час побачити ці моделі в дії. демонструючи:

  • Спостереження за файловою системою, які синхронізують файли з відміткою до бази даних
  • Чергами повідомлень, заснованих на каналах, з правилами Polly для повторення
  • Аналітичні відправники подій, які надсилають запити на пакетне тло
  • Індексатори семантичних пошуків з визначенням хешів
  • Перевірки пошкоджених посилань, які періодично перевірятимуть зовнішні адреси URL
  • Розподіл координації між залежними службами
  • Керування життєвим циклом піднімання та фреймування сутностей

Кожний приклад показує практичне розв'язання загальних проблем, з якими ви зустрінетесь, коли будуватимете виробничі послуги.

Приклад 1: Наглядати за файловою системою з ввічливою спробою

Перша служба, яку ми розглянемо, стежить за каталогом дописів з блогами markdown і автоматично обробляє їх після зміни. Це чудовий приклад роботи з фоновими записами, що керують подіями.

Проблема

Після створення або зміни файла з міткою:

  1. Читати вміст файла
  2. Аналіз метаданих з відміткою (заголовок, категорій, дати оприлюднення)
  3. Відтворення HTML і видобування звичайного тексту
  4. Зберегти до бази даних
  5. Задіяти переклад іншими мовами
  6. Індекс семантичних пошуків

Проблема: FileSystemWatcher події можуть розпалюватися під час запису файла, що призводить до IOException коли намагаєшся її прочитати.

Вирішення: 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);
            }
        }
    }
}

Зауважте декілька комбінацій ключів:

  1. IHostedService, а не ApplicationService - Нам потрібен гарний контроль над налаштуванням спостерігача.
  2. Розділити фонове завдання - The StartAsync return негайно, процес виконується у AwaitChanges
  3. Координація запуску - Сигнали, коли вони готові для залежних служб
  4. Належне усуванняThe role of the transaction, in present tense - Вимикає і вилучає спостерігача файлової системи у StopAsync

Обробка питань блокування файлів за допомогою Polly

Найцікавішою частиною є те, як ми обробляємо файли, які все ще пишуться. ПолліCity in Alaska USA є бібліотекою з гнучкості NET, яка забезпечує поліс популяцію повторення, перериви електричних схем, тощо:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Шаблони ключів:

  1. Правила спроб - Експонентне повернення (500 мс, 1s, 1.5s, 2s, 2,5s)
  2. Повторно виконати лише вводуOxcoding - Інші винятки голосніше
  3. Служби з областю - Створіть область для кожного файла, щоб уникнути проблем з життєвим циклом
  4. Рекордований журналювання - Використовується Серілог дії для слідкування
  5. Обчислення операційDescription of a condition. Do not translate key words (# V1S #, # V1 #,) - Зберегти → Індекс → Перекласти

Правила повторення керують типовим випадком, коли текстовий редактор все ще пише файл під час пожежі зміни події.

Візуальний потік

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

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

Зареєструвати в Program.cs (всі служби- господаря слідують за цим шаблоном):

builder.Services.AddHostedService<MarkdownDirectoryWatcherService>();

Переглянути повну реалізацію у Mostlylucid/Blog/WatcherService/MarkdownDirectoryWatcherService.cs.

Приклад 2: Черга ел. пошти з можливістю каналу

Відсилання пошти - це класичний випадок використання фонових служб. Ви не бажаєте блокувати запит HTTP під час переговорів SMTP, отже ви вставляєте повідомлення у чергу і надсилаєте його у тло.

Проблема

Після надсилання користувачем коментар або форму контакту:

  1. Перевірити і зберегти підкорення
  2. Додати повідомлення до черги
  3. Повернути відповідь негайно (не блокувати на SMTP)
  4. Надіслати ел. пошту у фоновому режимі з логікою повторення
  5. Працювати з помилками SMTP з приємністю (зломникcrcuit)

Вирішення: emailSenderHosedService

Ця служба використовує a Channel<T> для черги і Поллі для стійкості:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Показані шаблони ключів:

  1. Канал як черга - Недоступний канал для повідомлень
  2. Перенесення правил - Повторіть спробу всередині бункера
  3. Милосердне завершення роботи - Завершити канал, скасувати ключ, зачекати на завдання
  4. Відповідність візерунка - Перемкнутися на різні типи електронної пошти
  5. Масштабоване життя - Створений один раз як одинокий, тримає довгий стан

Видимість повторних спроб і розриву електричних схем

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

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

Це демонструє шаблон виробництва:

  • Правила повторення працює з періодичними помилками (тимчасові проблеми у мережі)
  • Переривальник електричних схем запобігти крахам заскакування (якщо SMTP не працює, не перебивай його)
  • Канал надає натуральну зворотну силу (якщо ми не можемо надіслати повідомлення, черги вгору)

Приклад 3: Analicals відправник подій (Umami)

Ця служба робить черги аналітичних подій і надсилає їх до Умаміzambia_ districts. kgm Сервер аналітики. просто вогонь і забудь.

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

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

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

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

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

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

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

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

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

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

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

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

Різниця ключів від служби електронної пошти:

  1. Без правил стійкості - Аналітики не критичні, якщо вони провалюються, ми просто ведемо журнал і продовжуємо
  2. Подвійний під час циклу - Inner loop викидає всі доступні елементи для ефективності
  3. Створення обчислювальної служби - Кожен відсилання отримує нову область видимості для клієнта HTTP

Це показує, що не всі фонові служби потребують складної роботи з помилками. Для некритичної телеметрії достатньо простої лісозаготівлі.

Приклад 4: Періодична фонова робота з службами тла

Тепер давайте розглянемо служби, які виконують періодичну роботу, а не обробляють черги. BrokenLinkCheckerBackgroundService періодично перевіряє наявність посилань у блогі і отримує адреси URL archive.org для пошкоджених адрес.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Зразки продемонстровано:

  1. Основний клас FroneService - Чудово для періодичної роботи
  2. Початкова затримка - Чекати на ініціалізації інших служб
  3. Пакетне - Обробка 20 посилань за раз, не всі одночасно
  4. Обмеження швидкості - 2 секунда затримки між перевірками, щоб бути ввічливим
  5. Створення обчислювальної служби - Нова область видимості на партію
  6. Перевірка на знак скасування - Переривання раніше, якщо надійшов запит на завершення роботи

Приклад 5: Семантичний індекс пошуку з визначенням хеш- Бюджетними змінами

Індекс семантичного пошуку більш витончений, ніж re-indexes, що фактично змінилися. Це зберігає дорогі вмонтовані виклики API.

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.