V13-FE-011: finalize search list layout slice
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
// generated from contracts/integrations/kbx.integrations.json; do not edit.
|
||||
namespace Kbx.Shared.Integrations.Generated;
|
||||
public sealed record KbxIntegrationDefinition(string Id,string Title,string OwnerModule,string Direction,string Transport,string Criticality,string SourceEvent,string Target,string Delivery,string Ordering,string Idempotency,int TimeoutMs,int ShortRetryAttempts,int ShortRetryBaseDelayMs,string ShortRetryBackoff,int LongRetryAttempts,IReadOnlyList<int> LongRetryScheduleSeconds,double CircuitFailureRatio,int CircuitSamplingSeconds,int CircuitMinimumThroughput,int CircuitBreakSeconds,string TerminalAction,bool UserVisible);
|
||||
public static class KbxIntegrationCatalog
|
||||
{
|
||||
public const string SourceSha256 = "607853e43ab35fdacc1adea7d5b470dfad791f6718afba215b5600c974b0642e";
|
||||
public static readonly IReadOnlyDictionary<string,KbxIntegrationDefinition> All = new Dictionary<string,KbxIntegrationDefinition>(StringComparer.Ordinal)
|
||||
{
|
||||
["integration.oms.wms.dispatch"] = new("integration.oms.wms.dispatch", "OMS → WMS 출고지시 전달", "OMS", "outbound", "outbox-http", "high", "OrderShipmentRequested", "WMS", "at-least-once", "per-aggregate", "event-id", 3000, 2, 250, "exponential", 8, new[] { 10, 30, 120, 300, 900, 1800, 3600, 7200 }, 0.5, 30, 10, 30, "operations-exception", true),
|
||||
["integration.wms.oms.picking-result"] = new("integration.wms.oms.picking-result", "WMS → OMS 피킹결과 전달", "WMS", "outbound", "outbox-http", "high", "PickingCompleted", "OMS", "at-least-once", "per-aggregate", "event-id", 3000, 2, 250, "exponential", 8, new[] { 10, 30, 120, 300, 900, 1800, 3600, 7200 }, 0.5, 30, 10, 30, "operations-exception", true),
|
||||
["integration.carrier.tracking"] = new("integration.carrier.tracking", "배송사 송장/배송상태 연계", "OMS", "outbound", "outbox-http", "medium", "TrackingSubmissionRequested", "CarrierGateway", "at-least-once", "none", "business-key", 5000, 2, 500, "exponential", 6, new[] { 30, 120, 600, 1800, 3600, 7200 }, 0.5, 60, 10, 60, "operations-exception", true)
|
||||
};
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
using Kbx.Shared.Integrations.Generated;
|
||||
|
||||
namespace Kbx.Shared.Integrations;
|
||||
|
||||
public static class KbxDurableIntegrationRetryPlanner
|
||||
{
|
||||
public static DateTimeOffset? NextAttemptAt(
|
||||
KbxIntegrationDefinition definition,
|
||||
int durableAttempt,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
if (durableAttempt < 1) throw new ArgumentOutOfRangeException(nameof(durableAttempt));
|
||||
if (durableAttempt > definition.LongRetryAttempts) return null;
|
||||
var index = durableAttempt - 1;
|
||||
if (index >= definition.LongRetryScheduleSeconds.Count) return null;
|
||||
return now.AddSeconds(definition.LongRetryScheduleSeconds[index]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Kbx.Shared.Integrations;
|
||||
|
||||
public sealed class KbxInboxReceiptStore(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<bool> TryBeginAsync(Guid tenantId, string integrationId, string messageKey, CancellationToken ct)
|
||||
{
|
||||
const string sql="""
|
||||
insert into kbx.integration_receipts(tenant_id,integration_id,message_key,received_at)
|
||||
values (@TenantId,@IntegrationId,@MessageKey,now())
|
||||
on conflict do nothing;
|
||||
""";
|
||||
await using var connection=await dataSource.OpenConnectionAsync(ct);
|
||||
return await connection.ExecuteAsync(new CommandDefinition(sql,new { TenantId=tenantId, IntegrationId=integrationId, MessageKey=messageKey },cancellationToken:ct))==1;
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
namespace Kbx.Shared.Integrations;
|
||||
|
||||
public sealed class KbxIntegrationAdapterRegistry(IEnumerable<IKbxIntegrationAdapter> adapters)
|
||||
: IKbxIntegrationAdapterRegistry
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IKbxIntegrationAdapter> _adapters =
|
||||
adapters.ToDictionary(x => x.IntegrationId, StringComparer.Ordinal);
|
||||
|
||||
public IKbxIntegrationAdapter GetRequired(string integrationId)
|
||||
=> _adapters.TryGetValue(integrationId, out var adapter)
|
||||
? adapter
|
||||
: throw new InvalidOperationException($"Integration adapter is not registered: {integrationId}");
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
|
||||
namespace Kbx.Shared.Integrations;
|
||||
|
||||
public sealed class KbxIntegrationAttemptRepository(NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<Guid> StartAsync(KbxIntegrationMessage message, int attemptNo, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
insert into kbx.integration_attempts(id, tenant_id, integration_id, message_id, aggregate_type, aggregate_id,
|
||||
aggregate_version, attempt_no, state, correlation_id, started_at)
|
||||
values (@Id,@TenantId,@IntegrationId,@MessageId,@AggregateType,@AggregateId,@AggregateVersion,@AttemptNo,'delivering',@CorrelationId,now())
|
||||
on conflict (tenant_id,integration_id,message_id,attempt_no) do update set correlation_id=excluded.correlation_id
|
||||
returning id;
|
||||
""";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
return await connection.ExecuteScalarAsync<Guid>(new CommandDefinition(sql, new {
|
||||
Id=Guid.NewGuid(), message.TenantId, message.IntegrationId, message.MessageId, message.AggregateType,
|
||||
message.AggregateId, message.AggregateVersion, AttemptNo=attemptNo, message.CorrelationId
|
||||
}, cancellationToken:ct));
|
||||
}
|
||||
|
||||
public async Task CompleteAsync(Guid id, KbxIntegrationAttemptResult result, string state, DateTimeOffset? nextRetryAt, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
update kbx.integration_attempts
|
||||
set state=@State, completed_at=now(), failure_kind=@FailureKind, failure_code=@Code, detail=@Detail,
|
||||
http_status=@HttpStatus, external_reference=@ExternalReference, next_retry_at=@NextRetryAt
|
||||
where id=@Id;
|
||||
""";
|
||||
await using var connection=await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql,new { Id=id, State=state, FailureKind=result.FailureKind?.ToString().ToLowerInvariant(), result.Code, result.Detail, result.HttpStatus, result.ExternalReference, NextRetryAt=nextRetryAt },cancellationToken:ct));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Kbx.Shared.Integrations;
|
||||
|
||||
public enum KbxIntegrationDeliveryState { Queued, Delivering, Retrying, Delivered, Failed, Suspended }
|
||||
public enum KbxIntegrationFailureKind { Transient, Permanent }
|
||||
|
||||
public sealed record KbxIntegrationMessage(
|
||||
Guid MessageId,
|
||||
Guid TenantId,
|
||||
string IntegrationId,
|
||||
string AggregateType,
|
||||
string AggregateId,
|
||||
long? AggregateVersion,
|
||||
string Payload,
|
||||
string CorrelationId,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record KbxIntegrationAttemptResult(
|
||||
bool Succeeded,
|
||||
KbxIntegrationFailureKind? FailureKind = null,
|
||||
string? Code = null,
|
||||
string? Detail = null,
|
||||
int? HttpStatus = null,
|
||||
string? ExternalReference = null);
|
||||
|
||||
public interface IKbxIntegrationAdapter
|
||||
{
|
||||
string IntegrationId { get; }
|
||||
ValueTask<KbxIntegrationAttemptResult> SendAsync(KbxIntegrationMessage message, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IKbxIntegrationAdapterRegistry
|
||||
{
|
||||
IKbxIntegrationAdapter GetRequired(string integrationId);
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
namespace Kbx.Shared.Integrations;
|
||||
|
||||
public static class KbxIntegrationFailureClassifier
|
||||
{
|
||||
public static KbxIntegrationFailureKind Classify(Exception error) => error switch
|
||||
{
|
||||
OperationCanceledException => KbxIntegrationFailureKind.Transient,
|
||||
HttpRequestException => KbxIntegrationFailureKind.Transient,
|
||||
_ => KbxIntegrationFailureKind.Permanent,
|
||||
};
|
||||
|
||||
public static KbxIntegrationFailureKind ClassifyHttp(int statusCode) => statusCode switch
|
||||
{
|
||||
408 or 429 => KbxIntegrationFailureKind.Transient,
|
||||
>= 500 and <= 599 => KbxIntegrationFailureKind.Transient,
|
||||
_ => KbxIntegrationFailureKind.Permanent,
|
||||
};
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using Kbx.Shared.Integrations.Generated;
|
||||
using Shared.Operations;
|
||||
|
||||
namespace Kbx.Shared.Integrations;
|
||||
|
||||
public sealed class KbxIntegrationOperationsProjector(OperationsProjectionWriter operations)
|
||||
{
|
||||
private const string Code = "INTEGRATION_FAILED";
|
||||
|
||||
public Task OnPermanentFailureAsync(
|
||||
KbxIntegrationMessage message,
|
||||
KbxIntegrationDefinition definition,
|
||||
KbxIntegrationAttemptResult result,
|
||||
CancellationToken ct)
|
||||
=> operations.UpsertAsync(new UpsertWorkItem(
|
||||
TenantId: message.TenantId.ToString(),
|
||||
SourceModule: definition.OwnerModule,
|
||||
SourceType: "Integration",
|
||||
SourceId: message.MessageId.ToString(),
|
||||
ReferenceNo: message.AggregateId,
|
||||
SourceScreenId: null,
|
||||
SourceVersion: message.AggregateVersion,
|
||||
Code: Code,
|
||||
Title: $"{definition.Title} 실패",
|
||||
Detail: result.Detail ?? result.Code,
|
||||
Severity: definition.Criticality == "high" ? "critical" : "warning",
|
||||
OccurredAt: DateTimeOffset.UtcNow,
|
||||
RetryActionKey: definition.Idempotency == "none" ? null : "integration.retry",
|
||||
AllowManualResolution: false,
|
||||
Context: new { message.IntegrationId, message.CorrelationId, result.Code, result.HttpStatus }), ct);
|
||||
|
||||
public Task OnDeliveredAsync(KbxIntegrationMessage message, KbxIntegrationDefinition definition, CancellationToken ct)
|
||||
=> operations.ResolveBySourceAsync(
|
||||
message.TenantId.ToString(),
|
||||
definition.OwnerModule,
|
||||
"Integration",
|
||||
message.MessageId.ToString(),
|
||||
Code,
|
||||
"외부 연계 전달 완료",
|
||||
message.AggregateVersion,
|
||||
ct);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
using Shared.Problems;
|
||||
|
||||
namespace Kbx.Shared.Integrations;
|
||||
|
||||
public static class KbxIntegrationProblemFactory
|
||||
{
|
||||
public static KbxIntegrationProblem ToProblem(KbxIntegrationAttemptResult result) => result.FailureKind switch
|
||||
{
|
||||
KbxIntegrationFailureKind.Transient => KbxIntegrationProblem.Create(
|
||||
result.Code ?? "INTEGRATION_DELAYED",
|
||||
"외부 연계가 지연되고 있습니다.",
|
||||
retryable: true,
|
||||
detail: "시스템이 자동 재처리합니다. 동일 업무를 다시 실행하지 마세요."),
|
||||
_ => KbxIntegrationProblem.Create(
|
||||
result.Code ?? "INTEGRATION_FAILED",
|
||||
"외부 연계를 완료하지 못했습니다.",
|
||||
retryable: false,
|
||||
detail: "업무 데이터는 유지됩니다. 확인 필요 항목에서 원인과 재처리 가능 여부를 확인하세요."),
|
||||
};
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Kbx.Shared.Integrations;
|
||||
|
||||
public static class KbxIntegrationRegistration
|
||||
{
|
||||
public static IServiceCollection AddKbxIntegrations(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IKbxIntegrationAdapterRegistry, KbxIntegrationAdapterRegistry>();
|
||||
services.AddScoped<KbxIntegrationAttemptRepository>();
|
||||
services.AddScoped<KbxInboxReceiptStore>();
|
||||
services.AddSingleton<KbxPollyIntegrationPipeline>();
|
||||
services.AddScoped<KbxIntegrationOperationsProjector>();
|
||||
services.AddScoped<Shared.Operations.IWorkItemActionHandler, KbxIntegrationRetryActionHandler>();
|
||||
services.AddScoped<KbxOutboxIntegrationDispatcher>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
using Shared.Operations;
|
||||
|
||||
namespace Kbx.Shared.Integrations;
|
||||
|
||||
public sealed class KbxIntegrationRetryActionHandler(NpgsqlDataSource dataSource) : IWorkItemActionHandler
|
||||
{
|
||||
public string Key => "integration.retry";
|
||||
|
||||
public async Task ExecuteAsync(string tenantId, string actorId, Guid workItemId, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct);
|
||||
var messageId = await connection.QuerySingleOrDefaultAsync<Guid?>(new CommandDefinition("""
|
||||
select source_id::uuid
|
||||
from kbx.work_items
|
||||
where id=@WorkItemId and tenant_id=@TenantId and retry_action_key='integration.retry'
|
||||
for update;
|
||||
""", new { WorkItemId = workItemId, TenantId = tenantId }, tx, cancellationToken: ct));
|
||||
if (messageId is null) { await tx.RollbackAsync(ct); return; }
|
||||
|
||||
var changed = await connection.ExecuteAsync(new CommandDefinition("""
|
||||
update kbx.integration_attempts
|
||||
set state='retrying', next_retry_at=now()
|
||||
where id = (
|
||||
select id from kbx.integration_attempts
|
||||
where message_id=@MessageId and state='failed'
|
||||
order by attempt_no desc limit 1
|
||||
);
|
||||
""", new { MessageId = messageId.Value }, tx, cancellationToken: ct));
|
||||
|
||||
if (changed == 1)
|
||||
{
|
||||
await connection.ExecuteAsync(new CommandDefinition("""
|
||||
insert into kbx.work_item_audit(id,work_item_id,tenant_id,action,actor_id,actor_name,reason,before_status,after_status)
|
||||
values(gen_random_uuid(),@WorkItemId,@TenantId,'integration-retry',@ActorId,@ActorId,'안전한 연계 재처리 예약','open','open');
|
||||
""", new { WorkItemId = workItemId, TenantId = tenantId, ActorId = actorId }, tx, cancellationToken: ct));
|
||||
}
|
||||
await tx.CommitAsync(ct);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
using Kbx.Shared.Integrations.Generated;
|
||||
|
||||
namespace Kbx.Shared.Integrations;
|
||||
|
||||
public sealed class KbxOutboxIntegrationDispatcher(
|
||||
IKbxIntegrationAdapterRegistry adapters,
|
||||
KbxIntegrationAttemptRepository attempts,
|
||||
KbxPollyIntegrationPipeline pipelineFactory,
|
||||
KbxIntegrationOperationsProjector operations)
|
||||
{
|
||||
public async Task<KbxIntegrationAttemptResult> DispatchAsync(KbxIntegrationMessage message, int durableAttempt, CancellationToken ct)
|
||||
{
|
||||
if (!KbxIntegrationCatalog.All.TryGetValue(message.IntegrationId, out var definition))
|
||||
return new(false, KbxIntegrationFailureKind.Permanent, "INTEGRATION_NOT_REGISTERED", message.IntegrationId);
|
||||
|
||||
var attemptId = await attempts.StartAsync(message, durableAttempt, ct);
|
||||
var pipeline = pipelineFactory.Build(
|
||||
definition.ShortRetryAttempts,
|
||||
TimeSpan.FromMilliseconds(definition.ShortRetryBaseDelayMs),
|
||||
TimeSpan.FromMilliseconds(definition.TimeoutMs),
|
||||
definition.CircuitFailureRatio,
|
||||
TimeSpan.FromSeconds(definition.CircuitSamplingSeconds),
|
||||
definition.CircuitMinimumThroughput,
|
||||
TimeSpan.FromSeconds(definition.CircuitBreakSeconds));
|
||||
|
||||
KbxIntegrationAttemptResult result;
|
||||
try
|
||||
{
|
||||
var adapter = adapters.GetRequired(message.IntegrationId);
|
||||
result = await pipeline.ExecuteAsync(async token => await adapter.SendAsync(message, token), ct);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
result = new(false, KbxIntegrationFailureClassifier.Classify(error), error.GetType().Name, "외부 연계 호출이 완료되지 않았습니다.");
|
||||
}
|
||||
|
||||
// A transient result is persisted and delegated to Hangfire for durable retry.
|
||||
var state = result.Succeeded ? "delivered" : result.FailureKind == KbxIntegrationFailureKind.Transient ? "retrying" : "failed";
|
||||
var next = state == "retrying" ? KbxDurableIntegrationRetryPlanner.NextAttemptAt(definition, durableAttempt, DateTimeOffset.UtcNow) : null;
|
||||
await attempts.CompleteAsync(attemptId, result, state, next, ct);
|
||||
if (result.Succeeded) await operations.OnDeliveredAsync(message, definition, ct);
|
||||
else if (result.FailureKind == KbxIntegrationFailureKind.Permanent && definition.TerminalAction == "operations-exception")
|
||||
await operations.OnPermanentFailureAsync(message, definition, result, ct);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
using Polly;
|
||||
using Polly.CircuitBreaker;
|
||||
using Polly.Retry;
|
||||
|
||||
namespace Kbx.Shared.Integrations;
|
||||
|
||||
// Polly is intentionally limited to short-lived transient resilience.
|
||||
// Persistent retries are scheduled by Hangfire from durable integration state.
|
||||
public sealed class KbxPollyIntegrationPipeline
|
||||
{
|
||||
public ResiliencePipeline<KbxIntegrationAttemptResult> Build(
|
||||
int maxRetryAttempts,
|
||||
TimeSpan baseDelay,
|
||||
TimeSpan timeout,
|
||||
double failureRatio,
|
||||
TimeSpan samplingDuration,
|
||||
int minimumThroughput,
|
||||
TimeSpan breakDuration)
|
||||
{
|
||||
return new ResiliencePipelineBuilder<KbxIntegrationAttemptResult>()
|
||||
.AddCircuitBreaker(new CircuitBreakerStrategyOptions<KbxIntegrationAttemptResult>
|
||||
{
|
||||
ShouldHandle = static args => args.Outcome switch
|
||||
{
|
||||
{ Exception: HttpRequestException } => PredicateResult.True(),
|
||||
{ Result.FailureKind: KbxIntegrationFailureKind.Transient } => PredicateResult.True(),
|
||||
_ => PredicateResult.False(),
|
||||
},
|
||||
FailureRatio = failureRatio,
|
||||
SamplingDuration = samplingDuration,
|
||||
MinimumThroughput = minimumThroughput,
|
||||
BreakDuration = breakDuration,
|
||||
})
|
||||
.AddRetry(new RetryStrategyOptions<KbxIntegrationAttemptResult>
|
||||
{
|
||||
ShouldHandle = static args => args.Outcome switch
|
||||
{
|
||||
{ Exception: HttpRequestException } => PredicateResult.True(),
|
||||
{ Result.FailureKind: KbxIntegrationFailureKind.Transient } => PredicateResult.True(),
|
||||
_ => PredicateResult.False(),
|
||||
},
|
||||
MaxRetryAttempts = maxRetryAttempts,
|
||||
Delay = baseDelay,
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
UseJitter = true,
|
||||
})
|
||||
.AddTimeout(timeout)
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using Kbx.Shared.Integrations.Generated;
|
||||
using Xunit;
|
||||
namespace Kbx.Shared.Integrations.Tests;
|
||||
public sealed class KbxDurableIntegrationRetryPlannerTests
|
||||
{
|
||||
[Fact]
|
||||
public void StopsAfterConfiguredDurableAttempts()
|
||||
{
|
||||
var d = KbxIntegrationCatalog.All["integration.oms.wms.dispatch"];
|
||||
var now = new DateTimeOffset(2026,8,8,9,0,0,TimeSpan.Zero);
|
||||
Assert.NotNull(KbxDurableIntegrationRetryPlanner.NextAttemptAt(d, 1, now));
|
||||
Assert.Null(KbxDurableIntegrationRetryPlanner.NextAttemptAt(d, d.LongRetryAttempts + 1, now));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using Kbx.Shared.Integrations;
|
||||
using Xunit;
|
||||
namespace Kbx.Shared.Integrations.Tests;
|
||||
public sealed class KbxIntegrationFailureClassifierTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(408)] [InlineData(429)] [InlineData(500)] [InlineData(503)]
|
||||
public void TransientHttpStatusesAreRetryable(int status) => Assert.Equal(KbxIntegrationFailureKind.Transient,KbxIntegrationFailureClassifier.ClassifyHttp(status));
|
||||
[Theory]
|
||||
[InlineData(400)] [InlineData(401)] [InlineData(403)] [InlineData(404)] [InlineData(422)]
|
||||
public void ClientOrDomainRejectionsArePermanent(int status) => Assert.Equal(KbxIntegrationFailureKind.Permanent,KbxIntegrationFailureClassifier.ClassifyHttp(status));
|
||||
}
|
||||
Reference in New Issue
Block a user