You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

任务队列中调用PDF生成时调试中断问题求助

任务队列中PDF生成导致调试会话中断问题排查

问题描述

我是一名仅有数月开发经验的初级开发者,正在为慈善机构开发公益捐赠网站项目。用户完成纪念捐赠后,系统需要生成致敬PDF并发送给指定收件人。

针对PdfService.GenerateMemorialPdf(...)的单元测试运行正常,但通过任务队列测试事件处理器时,执行到PDF生成环节(document.GeneratePdf())调试会话会突然中断,且无法捕获任何异常。若直接调用ExecuteAsync而非事件处理器的Handle()方法,测试就能正常完成,因此判断问题与任务队列的测试策略有关。

相关代码

事件处理器基类(EnqueueingEventHandler)

namespace CharityName.Application.Events;

public abstract class EnqueueingEventHandler<TEvent>(IBackgroundTaskQueue taskQueue) : IEventHandler<TEvent>
    where TEvent : IEvent
{
    public Task Handle(TEvent eventRequest, CancellationToken cancellationToken)
    {
        taskQueue.QueueBackgroundWorkItemAsync((CancellationToken) => ExecuteAsync(eventRequest, CancellationToken));
        return Task.CompletedTask;
    }

    protected abstract ValueTask ExecuteAsync(TEvent eventRequest, CancellationToken cancellationToken);
}

捐赠事件处理器实现(DonationCreatedEventHandler)

namespace CharityName.Application.Events.DonationCreatedEvent;

public class DonationCreatedEventHandler(
    ILogger<DonationCreatedEventHandler> logger,
    IPdfService pdfService,
    IEmailService emailService,
    IBackgroundTaskQueue taskQueue)
    : EnqueueingEventHandler<DonationCreatedEvent>(taskQueue)
{
    protected override async ValueTask ExecuteAsync(DonationCreatedEvent eventRequest, CancellationToken cancellationToken)
    {
        logger.LogInformation("Event {event} is being dequeued from the taskqueue", typeof(DonationCreatedEvent));
        string? pdf = null;
        if (eventRequest.DonationDto.DonationType == Domain.Enums.DonationType.Memorial)
        {
            pdf = pdfService.GenerateMemorialPdf(eventRequest.LetterDto.FullName, eventRequest.LetterDto.Message, PdfImage.heart_filled);
        }

        var res = await emailService.SendRecieptAsync(
            EmailTemplate.reciept,
            eventRequest.DonorDto.Email,
            eventRequest.DonorDto.FirstName,
            pdf,
            "Temporary");

        return;
    }
}

PDF服务实现(PdfService)

namespace CharityName.Infrastructure.Services.PdfService;

public class PdfService: IPdfService
{
    private const string BaseImagePath = "Services/PdfService/Resources/";

    public string GenerateMemorialPdf(string honoreeName, string message, PdfImage image)
    {
        // Temporary code, this does not throw
        var imagePath = BaseImagePath + image + ".png";
        if (!File.Exists(imagePath))
        {
            throw new FileNotFoundException($"Image file not found at path: {imagePath}");
        }

        var documentModel = new MemorialDocumentModel()
        {
            HonoreeName = honoreeName,
            Message = message,
            Image = new Image() { Location = BaseImagePath + image + ".png"}
        };

        var document = new MemorialDocument(documentModel);

        var pdf = document.GeneratePdf(); // <--- debug session ends here

        return Convert.ToBase64String(pdf);
    }
}

测试代码

namespace CharityName.Tests.IntegrationTests
{
    public class DonationCreatedEventHandlerTests
    {

        private readonly EmailService _emailService;
        private readonly PdfService _pdfService;
        private readonly BackgroundTaskQueue _taskQeueue;
        private readonly QueuedHostedService _queuedHostedService;
        private readonly IConfiguration _configuration;

        public DonationCreatedEventHandlerTests()
        {
            _configuration = new ConfigurationBuilder()
                .AddUserSecrets<DonationCreatedEventHandlerTests>()
                .Build();

            var brevoHttpClient = BrevoClientTestHelper.Create(_configuration);

            _emailService = new EmailService(brevoHttpClient, new Mock<ILogger<EmailService>>().Object);
            _pdfService = new PdfService();
            _taskQeueue = new BackgroundTaskQueue(100);
            _queuedHostedService = new QueuedHostedService(_taskQeueue, new Mock<ILogger<QueuedHostedService>>().Object);
            QuestPDF.Settings.License = LicenseType.Community;
        }

        [Fact]
        public async Task Test1()
        {
            // Arrange
            await _queuedHostedService.StartAsync(CancellationToken.None);

            var handler = new DonationCreatedEventHandler(
                new Mock<ILogger<DonationCreatedEventHandler>>().Object,
                _pdfService,
                _emailService,
                _taskQeueue);

            var donationDto = new DonationDto()
            {
                Amount = 5000,
                DonationType = Domain.Enums.DonationType.Memorial,
                Token = Guid.NewGuid()
            };

            var donorDto = new DonorDto()
            {
                FirstName = "John",
                LastName = "Doe",
                Email = "John.Doe@example.com"
            };

            var letterDto = new LetterDto()
            {
                LetterType = Domain.Enums.LetterType.Celebratory,
                DeliveryMethod = Domain.Enums.DeliveryMethod.email,
                LatestsDeliveryDate = DateTime.Now,
                FullName = "John Doe",
                Email = "John.Doe@example.com",
                Message = "Rest in Peace"
            };

            var @event = new DonationCreatedEvent(donationDto, donorDto, letterDto);

            // Act
            await handler.Handle(@event, CancellationToken.None);

            // Assert
            Assert.True(true); // to be changed
        }
    }
}

任务队列与托管服务补充代码

using System.Threading.Channels;
using CharityName.Application.Abstractions.Interfaces;

public class BackgroundTaskQueue : IBackgroundTaskQueue
{
    private readonly Channel<Func<CancellationToken, ValueTask>> _queue;

    public BackgroundTaskQueue(int capacity)
    {
        var options = new BoundedChannelOptions(capacity)
        {
            FullMode = BoundedChannelFullMode.Wait
        };
        _queue = Channel.CreateBounded<Func<CancellationToken, ValueTask>>(options);
    }

    public async ValueTask QueueBackgroundWorkItemAsync(
        Func<CancellationToken, ValueTask> workItem)
    {
        if (workItem == null)
        {
            throw new ArgumentNullException(nameof(workItem));
        }

        await _queue.Writer.WriteAsync(workItem);
    }

    public async ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(
        CancellationToken cancellationToken)
    {
        var workItem = await _queue.Reader.ReadAsync(cancellationToken);

        return workItem;
    }
}
using CharityName.Application.Abstractions.Interfaces;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

public class QueuedHostedService(IBackgroundTaskQueue taskQueue, 
        ILogger<QueuedHostedService> logger) : BackgroundService
{
    public IBackgroundTaskQueue TaskQueue { get; } = taskQueue;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        logger.LogInformation("Queued Hosted Service is running.");

        await BackgroundProcessing(stoppingToken);
    }

    private async Task BackgroundProcessing(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var workItem = 
                await TaskQueue.DequeueAsync(stoppingToken);

            try
            {
                await workItem(stoppingToken);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, 
                    "Error occurred executing {WorkItem}.", nameof(workItem));
            }
        }
    }

    public override async Task StopAsync(CancellationToken stoppingToken)
    {
        logger.LogInformation("Queued Hosted Service is stopping.");

        await base.StopAsync(stoppingToken);
    }
}

问题分析

  1. 测试生命周期不匹配:测试方法调用Handle后立即结束,没有等待后台任务队列中的PDF生成任务执行完成。测试框架会终止测试进程,导致调试会话中断,同时后台任务的异常无法被捕获。
  2. 后台线程异常处理盲区:虽然QueuedHostedService有异常捕获逻辑,但如果GeneratePdf()抛出未被CLR捕获的致命异常(如内存访问错误),会直接终止进程,调试会话也会随之结束。
  3. 工作目录差异:后台线程的工作目录可能与测试主线程不同,导致图片路径解析异常,但代码中File.Exists检查未触发,暂时排除此可能。

解决方案建议

1. 等待后台任务执行完成

修改测试代码,确保在断言前等待队列中的任务处理完毕。可以通过以下方式实现:

方式A:使用信号量监控任务完成

创建测试专用的事件处理器,在ExecuteAsync完成后触发信号:

// 测试类中新增内部类
private class TestDonationCreatedEventHandler : DonationCreatedEventHandler
{
    public ManualResetEventSlim CompletionEvent { get; } = new ManualResetEventSlim(false);

    public TestDonationCreatedEventHandler(ILogger<DonationCreatedEventHandler> logger, IPdfService pdfService, IEmailService emailService, IBackgroundTaskQueue taskQueue)
        : base(logger, pdfService, emailService, taskQueue)
    {
    }

    protected override async ValueTask ExecuteAsync(DonationCreatedEvent eventRequest, CancellationToken cancellationToken)
    {
        await base.ExecuteAsync(eventRequest, cancellationToken);
        CompletionEvent.Set();
    }
}

// 修改测试方法
[Fact]
public async Task Test1()
{
    // Arrange
    await _queuedHostedService.StartAsync(CancellationToken.None);

    var testHandler = new TestDonationCreatedEventHandler(
        new Mock<ILogger<DonationCreatedEventHandler>>().Object,
        _pdfService,
        _emailService,
        _taskQeueue);

    // ... 构造事件代码不变

    // Act
    await testHandler.Handle(@event, CancellationToken.None);
    testHandler.CompletionEvent.Wait(TimeSpan.FromSeconds(5)); // 等待任务完成,超时时间可调整

    // Assert
    // 添加实际断言,比如验证邮件发送状态或PDF生成结果
    Assert.True(testHandler.CompletionEvent.IsSet);

    // Cleanup
    await _queuedHostedService.StopAsync(CancellationToken.None);
}

方式B:检查队列状态(需修改队列暴露状态)

在BackgroundTaskQueue中添加队列数量检查的方法:

public bool IsEmpty => _queue.Reader.Count == 0;

然后在测试中循环等待队列清空:

// Act之后添加
var waitTime = DateTime.Now.AddSeconds(5);
while (!_taskQeueue.IsEmpty && DateTime.Now < waitTime)
{
    await Task.Delay(100);
}

2. 完善异常捕获与日志

确保QueuedHostedService的日志能输出详细异常信息,便于排查:

  • 替换Mock日志为真实的测试日志(如XunitLogger),或者在Mock中设置回调捕获日志:
var loggerMock = new Mock<ILogger<QueuedHostedService>>();
loggerMock.Setup(l => l.Log(
    LogLevel.Error,
    It.IsAny<EventId>(),
    It.IsAny<It.IsAnyType>(),
    It.IsAny<Exception>(),
    (Func<It.IsAnyType, Exception, string>)It.IsAny<object>()))
.Callback((LogLevel level, EventId id, object state, Exception ex, Func<object, Exception, string> formatter) =>
{
    Console.WriteLine($"Error: {ex.Message}\nStack Trace: {ex.StackTrace}");
});
_queuedHostedService = new QueuedHostedService(_taskQeueue, loggerMock.Object);

3. 验证PDF生成的线程兼容性

确认QuestPDF的GeneratePdf()方法是否支持在后台线程执行,部分PDF库可能对线程上下文有要求。可以尝试在测试主线程外单独启动线程调用该方法,验证是否会出现相同的中断问题。

内容的提问来源于stack exchange,提问作者Pontus

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.16 19:05:54