78d9329cea
CRITICAL: Previous cutoffTime logic (occurred_at >= now-5min) silently dropped messages older than 5 minutes forever, contradicting Outbox Pattern's guarantee of eventual delivery for stuck messages. Changes: - DapperOutboxMessageReader: Remove cutoffTime parameter, process ALL unpublished - OutboxPollerJob: Remove cutoffTime calculation, process all messages by occurred_at - Tests: Remove cutoff scenario (no longer applicable); keep normal + max-attempts - Comments: Document monitoring approach (alert if pending > 5 min) as separate concern AGENTS.md v16.0 Checklist: ✅ Safety: No partial success (no silent data loss) ✅ Audit: Evidence tracked (all messages eventually processed) ✅ Right Way: Root cause fixed (was processing-logic bug, not test-logic bug) Test results: 2/2 passing (normal path, max-attempts DQ) Validation gate: Outbox/Inbox crash-recovery ✅ RESTORED Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
185 lines
7.4 KiB
C#
185 lines
7.4 KiB
C#
using System.Data;
|
|
using System.Data.Common;
|
|
using Dapper;
|
|
using KArtSell.BuildingBlocks.Data;
|
|
using KArtSell.BuildingBlocks.Hashing;
|
|
using KArtSell.BuildingBlocks.Reliability;
|
|
using KArtSell.BuildingBlocks.Time;
|
|
using KArtSell.Host.Jobs;
|
|
using Microsoft.Extensions.Logging;
|
|
using Npgsql;
|
|
using Xunit;
|
|
|
|
namespace KArtSell.Integration.Tests;
|
|
|
|
public sealed class OutboxPollerJobTests : IAsyncLifetime
|
|
{
|
|
private readonly string _connectionString;
|
|
private readonly NpgsqlDataSource _dataSource;
|
|
private readonly IDbConnectionFactory _connectionFactory;
|
|
private readonly IClock _clock = new SystemClock();
|
|
private readonly ILogger<OutboxPollerJob> _logger = new NoOpLogger();
|
|
|
|
public OutboxPollerJobTests()
|
|
{
|
|
_connectionString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES")
|
|
?? "Host=localhost;Port=5432;Database=kartselldb;Username=kartsell;Password=kartsell4321@!";
|
|
_dataSource = new NpgsqlDataSourceBuilder(_connectionString).Build();
|
|
_connectionFactory = new NpgsqlConnectionFactory(_dataSource);
|
|
}
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
// Verify database is accessible
|
|
await using var connection = await _dataSource.OpenConnectionAsync();
|
|
await using var cmd = connection.CreateCommand();
|
|
cmd.CommandText = "SELECT 1";
|
|
await cmd.ExecuteScalarAsync();
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
await _dataSource.DisposeAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExecuteAsync_ProcessesUnpublishedMessages_MarksAsPublished()
|
|
{
|
|
// Arrange: Insert unpublished message
|
|
var messageId = Guid.NewGuid();
|
|
var now = _clock.UtcNow;
|
|
const string payload = """{"decision_id":"test"}""";
|
|
var hash = ContentHasher.Sha256(payload);
|
|
|
|
await using (var connection = await _connectionFactory.OpenAsync(CancellationToken.None))
|
|
{
|
|
await connection.ExecuteAsync("""
|
|
insert into building_blocks.outbox_message
|
|
(message_id, event_type, schema_version, payload_json, correlation_id, occurred_at, payload_hash)
|
|
values (@MessageId, @EventType, 1, cast(@PayloadJson as jsonb), @CorrelationId, @OccurredAt, @Hash)
|
|
""",
|
|
new
|
|
{
|
|
MessageId = messageId,
|
|
EventType = "TestEvent",
|
|
PayloadJson = payload,
|
|
CorrelationId = Guid.NewGuid().ToString(),
|
|
OccurredAt = now.AddMinutes(-2),
|
|
Hash = hash
|
|
});
|
|
}
|
|
|
|
var reader = new DapperOutboxMessageReader(_connectionFactory);
|
|
var job = new OutboxPollerJob(reader, _clock, _logger);
|
|
|
|
// Act
|
|
await job.ExecuteAsync(CancellationToken.None);
|
|
|
|
// Assert: Verify published_at is set AND inbox_message is created
|
|
await using (var connection = await _connectionFactory.OpenAsync(CancellationToken.None))
|
|
{
|
|
var published = await connection.QuerySingleOrDefaultAsync<DateTime?>(
|
|
"select published_at from building_blocks.outbox_message where message_id = @MessageId",
|
|
new { MessageId = messageId });
|
|
|
|
Assert.NotNull(published);
|
|
Assert.True(published > now.AddSeconds(-5).DateTime, "published_at should be recent");
|
|
|
|
// Verify inbox_message is created
|
|
var inboxMsg = await connection.QuerySingleOrDefaultAsync<(string Consumer, Guid MessageId)?>(
|
|
"select consumer, message_id from building_blocks.inbox_message where message_id = @MessageId",
|
|
new { MessageId = messageId });
|
|
|
|
Assert.NotNull(inboxMsg);
|
|
Assert.Equal("outbox-poller", inboxMsg!.Value.Consumer);
|
|
Assert.Equal(messageId, inboxMsg.Value.MessageId);
|
|
}
|
|
|
|
// Cleanup
|
|
await using (var connection = await _connectionFactory.OpenAsync(CancellationToken.None))
|
|
{
|
|
await connection.ExecuteAsync(
|
|
"delete from building_blocks.outbox_message where message_id = @MessageId",
|
|
new { MessageId = messageId });
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExecuteAsync_SkipsMessagesExceedingMaxAttempts_LogsAsDeadLetter()
|
|
{
|
|
// Arrange: Insert message with high attempt count
|
|
var messageId = Guid.NewGuid();
|
|
var now = _clock.UtcNow;
|
|
const string payload = """{"error":"too many retries"}""";
|
|
var hash = ContentHasher.Sha256(payload);
|
|
|
|
await using (var connection = await _connectionFactory.OpenAsync(CancellationToken.None))
|
|
{
|
|
await connection.ExecuteAsync("""
|
|
insert into building_blocks.outbox_message
|
|
(message_id, event_type, schema_version, payload_json, correlation_id, occurred_at, payload_hash, attempt)
|
|
values (@MessageId, @EventType, 1, cast(@PayloadJson as jsonb), @CorrelationId, @OccurredAt, @Hash, @Attempt)
|
|
""",
|
|
new
|
|
{
|
|
MessageId = messageId,
|
|
EventType = "FailedEvent",
|
|
PayloadJson = payload,
|
|
CorrelationId = Guid.NewGuid().ToString(),
|
|
OccurredAt = now.AddMinutes(-2),
|
|
Hash = hash,
|
|
Attempt = 3
|
|
});
|
|
}
|
|
|
|
var reader = new DapperOutboxMessageReader(_connectionFactory);
|
|
var job = new OutboxPollerJob(reader, _clock, _logger);
|
|
|
|
// Act
|
|
await job.ExecuteAsync(CancellationToken.None);
|
|
|
|
// Assert: Message should NOT be published (remains unpublished) and NO inbox entry
|
|
await using (var connection = await _connectionFactory.OpenAsync(CancellationToken.None))
|
|
{
|
|
var published = await connection.QuerySingleOrDefaultAsync<DateTimeOffset?>(
|
|
"select published_at from building_blocks.outbox_message where message_id = @MessageId",
|
|
new { MessageId = messageId });
|
|
|
|
Assert.Null(published);
|
|
|
|
// Verify no inbox entry for dead-lettered message
|
|
var inboxMsg = await connection.QuerySingleOrDefaultAsync<Guid?>(
|
|
"select message_id from building_blocks.inbox_message where message_id = @MessageId",
|
|
new { MessageId = messageId });
|
|
|
|
Assert.Null(inboxMsg);
|
|
}
|
|
|
|
// Cleanup
|
|
await using (var connection = await _connectionFactory.OpenAsync(CancellationToken.None))
|
|
{
|
|
await connection.ExecuteAsync(
|
|
"delete from building_blocks.outbox_message where message_id = @MessageId",
|
|
new { MessageId = messageId });
|
|
}
|
|
}
|
|
|
|
private sealed class NoOpLogger : ILogger<OutboxPollerJob>
|
|
{
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
public bool IsEnabled(LogLevel logLevel) => false;
|
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) { }
|
|
}
|
|
}
|
|
|
|
// Database factory for tests
|
|
public sealed class NpgsqlConnectionFactory : IDbConnectionFactory
|
|
{
|
|
private readonly NpgsqlDataSource _dataSource;
|
|
|
|
public NpgsqlConnectionFactory(NpgsqlDataSource dataSource) => _dataSource = dataSource;
|
|
|
|
public async ValueTask<DbConnection> OpenAsync(CancellationToken cancellationToken)
|
|
=> await _dataSource.OpenConnectionAsync(cancellationToken);
|
|
}
|