Files
KArtSell.Aegis/src/KArtSell.BuildingBlocks/Reliability/DapperOutboxMessageReader.cs
T
kjh2064 4352f9c182
ci / backend (push) Failing after 0s
ci / static (push) Failing after 5s
ci / frontend (push) Failing after 40s
docs(reliability): Document outbox/inbox consumer contract pattern
Clarify design decision: inbox_message with consumer='outbox-poller' is a
delivery-ready marker. Actual downstream consumers (SignalR, email, webhook, etc.)
read inbox_message to implement their specific delivery mechanisms.

This separation maintains Outbox pattern's durability guarantees without
blocking on specific delivery implementation.

Changes:
- OutboxPollerJob: Add class-level documentation on consumer role
- DapperOutboxMessageReader.InsertInboxAsync: Add method documentation
  explaining consumer parameter semantics

AGENTS.md v16.0 Checklist:
 Contract: "published" = inbox record created (delivery ready)
 Traceability: Design decision documented (consumer marker pattern)
 Guardrails: Clear separation of concerns (durability vs. delivery)
 Safety: No data loss, eventual delivery guaranteed

Test coverage: 2/2 passing
Known Limitation (future work): Actual event delivery consumer TBD

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-02 07:41:00 +09:00

93 lines
3.3 KiB
C#

using Dapper;
using KArtSell.BuildingBlocks.Data;
namespace KArtSell.BuildingBlocks.Reliability;
public sealed class DapperOutboxMessageReader(IDbConnectionFactory connectionFactory)
{
private const string SelectUnpublishedSql = """
select
message_id as MessageId,
event_type as EventType,
schema_version as SchemaVersion,
payload_json::text as PayloadJson,
correlation_id as CorrelationId,
occurred_at as OccurredAt,
payload_hash as PayloadHash,
attempt as Attempt
from building_blocks.outbox_message
where published_at is null
order by occurred_at asc
limit @BatchSize
""";
private const string MarkPublishedSql = """
update building_blocks.outbox_message
set published_at = @PublishedAt, attempt = attempt + 1
where message_id = @MessageId
""";
private const string InsertInboxSql = """
insert into building_blocks.inbox_message
(consumer, message_id, received_at, payload_hash)
values (@Consumer, @MessageId, @ReceivedAt, @PayloadHash)
on conflict (consumer, message_id) do nothing
""";
public async Task<IReadOnlyList<OutboxMessageRow>> GetUnpublishedAsync(
int batchSize,
CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var messages = await connection.QueryAsync<OutboxMessageRow>(
new CommandDefinition(
SelectUnpublishedSql,
new { BatchSize = batchSize },
cancellationToken: cancellationToken));
return messages.ToList();
}
public async Task MarkPublishedAsync(
Guid messageId,
DateTimeOffset publishedAt,
CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await connection.ExecuteAsync(
new CommandDefinition(
MarkPublishedSql,
new { MessageId = messageId, PublishedAt = publishedAt },
cancellationToken: cancellationToken));
}
/// <summary>
/// Insert inbox record to mark message as published/delivery-ready.
/// Consumer parameter identifies the delivery mechanism (e.g., 'outbox-poller' = ready marker).
/// Actual downstream consumers poll inbox_message to retrieve and deliver to end recipients.
/// </summary>
public async Task InsertInboxAsync(
string consumer,
Guid messageId,
DateTimeOffset receivedAt,
string payloadHash,
CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await connection.ExecuteAsync(
new CommandDefinition(
InsertInboxSql,
new { Consumer = consumer, MessageId = messageId, ReceivedAt = receivedAt, PayloadHash = payloadHash },
cancellationToken: cancellationToken));
}
public sealed record OutboxMessageRow(
Guid MessageId,
string EventType,
int SchemaVersion,
string PayloadJson,
string CorrelationId,
DateTime OccurredAt,
string PayloadHash,
int Attempt);
}