feat(t237): import freight orders synchronously
This commit is contained in:
@@ -20,14 +20,19 @@ const (
|
||||
maxFreightItemsPerOrder = 1000
|
||||
maxFreightWindowDays = 7
|
||||
freightWatermarkOverlap = 10 * time.Minute
|
||||
orderFreightSyncTimeout = 55 * time.Second
|
||||
freightCleanupTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
type FreightService struct {
|
||||
repository FreightRepository
|
||||
source FreightSource
|
||||
clock Clock
|
||||
ids IDGenerator
|
||||
timeout time.Duration
|
||||
repository FreightRepository
|
||||
source FreightSource
|
||||
clock Clock
|
||||
ids IDGenerator
|
||||
timeout time.Duration
|
||||
orderTimeout time.Duration
|
||||
cleanupTimeout time.Duration
|
||||
orderSyncGate chan struct{}
|
||||
}
|
||||
|
||||
type FreightSourcePreflight interface {
|
||||
@@ -67,11 +72,14 @@ func NewFreightService(
|
||||
return nil, errors.New("freight service dependencies are required")
|
||||
}
|
||||
return &FreightService{
|
||||
repository: repository,
|
||||
source: source,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
timeout: timeout,
|
||||
repository: repository,
|
||||
source: source,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
timeout: timeout,
|
||||
orderTimeout: orderFreightSyncTimeout,
|
||||
cleanupTimeout: freightCleanupTimeout,
|
||||
orderSyncGate: make(chan struct{}, 1),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -105,7 +113,16 @@ func (service *FreightService) CreateOrderSync(
|
||||
fields,
|
||||
)
|
||||
}
|
||||
if err := service.ensureSource(ctx); err != nil {
|
||||
if !service.acquireOrderSync() {
|
||||
return CreateFreightSyncResult{}, freightSyncBusyError()
|
||||
}
|
||||
defer service.releaseOrderSync()
|
||||
syncCtx, cancel := context.WithTimeout(ctx, service.orderTimeout)
|
||||
defer cancel()
|
||||
if err := service.ensureSource(syncCtx); err != nil {
|
||||
if syncErr := freightContextError(syncCtx); syncErr != nil {
|
||||
return CreateFreightSyncResult{}, syncErr
|
||||
}
|
||||
return CreateFreightSyncResult{}, err
|
||||
}
|
||||
runID, err := service.ids.NewID()
|
||||
@@ -121,7 +138,7 @@ func (service *FreightService) CreateOrderSync(
|
||||
OrderNumber string `json:"order_number"`
|
||||
}{command.OrderNumber})
|
||||
run, created, err := service.repository.CreateFreightSync(
|
||||
ctx,
|
||||
syncCtx,
|
||||
domain.FreightSyncRun{
|
||||
ID: runID,
|
||||
CreatorSubject: command.CreatorSubject,
|
||||
@@ -138,10 +155,31 @@ func (service *FreightService) CreateOrderSync(
|
||||
if err != nil {
|
||||
return CreateFreightSyncResult{}, wrapRepositoryError(err)
|
||||
}
|
||||
if created {
|
||||
go service.execute(run)
|
||||
if !created {
|
||||
if run.Status != domain.FreightSyncSucceeded &&
|
||||
run.Status != domain.FreightSyncFailed {
|
||||
return CreateFreightSyncResult{}, freightSyncBusyError()
|
||||
}
|
||||
return CreateFreightSyncResult{Run: run, Replayed: true}, nil
|
||||
}
|
||||
return CreateFreightSyncResult{Run: run, Replayed: !created}, nil
|
||||
completed, err := service.executeRun(syncCtx, run)
|
||||
if err != nil {
|
||||
return CreateFreightSyncResult{}, err
|
||||
}
|
||||
return CreateFreightSyncResult{Run: completed}, nil
|
||||
}
|
||||
|
||||
func (service *FreightService) acquireOrderSync() bool {
|
||||
select {
|
||||
case service.orderSyncGate <- struct{}{}:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (service *FreightService) releaseOrderSync() {
|
||||
<-service.orderSyncGate
|
||||
}
|
||||
|
||||
func (service *FreightService) CreateDateSync(
|
||||
@@ -310,10 +348,24 @@ func (service *FreightService) ensureSource(ctx context.Context) error {
|
||||
func (service *FreightService) execute(run domain.FreightSyncRun) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), service.timeout)
|
||||
defer cancel()
|
||||
_, _ = service.executeRun(ctx, run)
|
||||
}
|
||||
|
||||
func (service *FreightService) executeRun(
|
||||
ctx context.Context,
|
||||
run domain.FreightSyncRun,
|
||||
) (domain.FreightSyncRun, error) {
|
||||
now := service.clock.Now().UTC()
|
||||
if err := service.repository.StartFreightSync(ctx, run.ID, now); err != nil {
|
||||
return
|
||||
code := freightFailureCode(ctx, "STORAGE_UNAVAILABLE")
|
||||
return domain.FreightSyncRun{}, service.finishFreightFailure(
|
||||
run.ID,
|
||||
code,
|
||||
wrapRepositoryError(err),
|
||||
)
|
||||
}
|
||||
run.Status = domain.FreightSyncRunning
|
||||
run.StartedAt = &now
|
||||
var source domain.FreightSourceBatch
|
||||
var err error
|
||||
if run.Mode == domain.FreightSyncCreatedRange {
|
||||
@@ -322,23 +374,21 @@ func (service *FreightService) execute(run domain.FreightSyncRun) {
|
||||
source, err = service.source.QueryOrder(ctx, run.OrderNumber)
|
||||
}
|
||||
if err != nil {
|
||||
_ = service.repository.FailFreightSync(
|
||||
ctx,
|
||||
code := freightFailureCode(ctx, freightSourceErrorCode(err))
|
||||
return domain.FreightSyncRun{}, service.finishFreightFailure(
|
||||
run.ID,
|
||||
freightSourceErrorCode(err),
|
||||
service.clock.Now().UTC(),
|
||||
code,
|
||||
freightExecutionError(code, err),
|
||||
)
|
||||
return
|
||||
}
|
||||
batch, err := service.normalize(source)
|
||||
if err != nil {
|
||||
_ = service.repository.FailFreightSync(
|
||||
ctx,
|
||||
code := freightFailureCode(ctx, "ERP_RESPONSE_INVALID")
|
||||
return domain.FreightSyncRun{}, service.finishFreightFailure(
|
||||
run.ID,
|
||||
"ERP_RESPONSE_INVALID",
|
||||
service.clock.Now().UTC(),
|
||||
code,
|
||||
freightExecutionError(code, domain.ErrFreightSourceProtocol),
|
||||
)
|
||||
return
|
||||
}
|
||||
finishedAt := service.clock.Now().UTC()
|
||||
if run.Mode == domain.FreightSyncCreatedRange &&
|
||||
@@ -359,13 +409,107 @@ func (service *FreightService) execute(run domain.FreightSyncRun) {
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
_ = service.repository.FailFreightSync(
|
||||
ctx,
|
||||
code := freightFailureCode(ctx, "STORAGE_UNAVAILABLE")
|
||||
return domain.FreightSyncRun{}, service.finishFreightFailure(
|
||||
run.ID,
|
||||
"STORAGE_UNAVAILABLE",
|
||||
service.clock.Now().UTC(),
|
||||
code,
|
||||
wrapRepositoryError(err),
|
||||
)
|
||||
}
|
||||
itemCount := 0
|
||||
for _, order := range batch.Orders {
|
||||
itemCount += len(order.Items)
|
||||
}
|
||||
run.Status = domain.FreightSyncSucceeded
|
||||
run.OrderCount = len(batch.Orders)
|
||||
run.ItemCount = itemCount
|
||||
run.FinishedAt = &finishedAt
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func (service *FreightService) finishFreightFailure(
|
||||
runID, code string,
|
||||
cause error,
|
||||
) error {
|
||||
timeout := service.cleanupTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = freightCleanupTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
if err := service.repository.FailFreightSync(
|
||||
ctx,
|
||||
runID,
|
||||
code,
|
||||
service.clock.Now().UTC(),
|
||||
); err != nil {
|
||||
return wrapRepositoryError(err)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
func freightFailureCode(ctx context.Context, fallback string) string {
|
||||
switch ctx.Err() {
|
||||
case context.DeadlineExceeded:
|
||||
return "FREIGHT_SYNC_TIMEOUT"
|
||||
case context.Canceled:
|
||||
return "FREIGHT_SYNC_CANCELED"
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func freightContextError(ctx context.Context) error {
|
||||
switch ctx.Err() {
|
||||
case context.DeadlineExceeded:
|
||||
return freightExecutionError(
|
||||
"FREIGHT_SYNC_TIMEOUT",
|
||||
context.DeadlineExceeded,
|
||||
)
|
||||
case context.Canceled:
|
||||
return freightExecutionError(
|
||||
"FREIGHT_SYNC_CANCELED",
|
||||
context.Canceled,
|
||||
)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func freightSyncBusyError() error {
|
||||
result := newError(
|
||||
ErrorKindConflict,
|
||||
"FREIGHT_SYNC_BUSY",
|
||||
"another freight order sync is already running",
|
||||
nil,
|
||||
)
|
||||
result.Retryable = true
|
||||
return result
|
||||
}
|
||||
|
||||
func freightExecutionError(code string, cause error) error {
|
||||
var kind ErrorKind
|
||||
var message string
|
||||
switch code {
|
||||
case "FREIGHT_SYNC_TIMEOUT":
|
||||
kind = ErrorKindUnavailable
|
||||
message = "freight order sync exceeded its time limit"
|
||||
case "FREIGHT_SYNC_CANCELED":
|
||||
kind = ErrorKindUnavailable
|
||||
message = "freight order sync was canceled"
|
||||
case "ERP_FREIGHT_NOT_FOUND":
|
||||
kind = ErrorKindNotFound
|
||||
message = "ERP freight order was not found"
|
||||
default:
|
||||
kind = ErrorKindUnavailable
|
||||
message = freightPreflightMessage(code)
|
||||
}
|
||||
result := newError(kind, code, message, cause)
|
||||
result.Retryable = code == "FREIGHT_SYNC_TIMEOUT" ||
|
||||
code == "FREIGHT_SYNC_CANCELED" ||
|
||||
code == "ERP_UNAVAILABLE" ||
|
||||
code == "OCR_SERVICE_INVALID"
|
||||
return result
|
||||
}
|
||||
|
||||
func (service *FreightService) queryCreatedRange(
|
||||
|
||||
@@ -3,6 +3,7 @@ package usecase
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -98,6 +99,176 @@ func TestCreateFreightOrderSyncStopsBeforePersistingWhenOCRIsInvalid(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFreightOrderSyncReturnsCommittedResult(t *testing.T) {
|
||||
repository := &syncTrackingRepository{}
|
||||
service, err := NewFreightService(
|
||||
repository,
|
||||
&recordingDateSource{},
|
||||
fakeClock{},
|
||||
&sequenceIDs{},
|
||||
time.Minute,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFreightService() error = %v", err)
|
||||
}
|
||||
if service.orderTimeout != 55*time.Second {
|
||||
t.Fatalf("order timeout = %s", service.orderTimeout)
|
||||
}
|
||||
result, err := service.CreateOrderSync(
|
||||
context.Background(),
|
||||
validFreightOrderSyncCommand("sync-success"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOrderSync() error = %v", err)
|
||||
}
|
||||
if result.Replayed || result.Run.Status != domain.FreightSyncSucceeded ||
|
||||
result.Run.OrderCount != 1 || result.Run.ItemCount != 1 ||
|
||||
result.Run.StartedAt == nil || result.Run.FinishedAt == nil {
|
||||
t.Fatalf("CreateOrderSync() result = %+v", result)
|
||||
}
|
||||
repository.mu.Lock()
|
||||
defer repository.mu.Unlock()
|
||||
if repository.startCalls != 1 || repository.completeCalls != 1 ||
|
||||
repository.failCalls != 0 {
|
||||
t.Fatalf(
|
||||
"start/complete/fail calls = %d/%d/%d",
|
||||
repository.startCalls,
|
||||
repository.completeCalls,
|
||||
repository.failCalls,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFreightOrderSyncTimeoutUsesLiveCleanupContext(t *testing.T) {
|
||||
repository := &syncTrackingRepository{}
|
||||
source := &blockingOrderSource{}
|
||||
service, err := NewFreightService(
|
||||
repository,
|
||||
source,
|
||||
fakeClock{},
|
||||
&sequenceIDs{},
|
||||
time.Minute,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFreightService() error = %v", err)
|
||||
}
|
||||
service.orderTimeout = 10 * time.Millisecond
|
||||
_, err = service.CreateOrderSync(
|
||||
context.Background(),
|
||||
validFreightOrderSyncCommand("sync-timeout"),
|
||||
)
|
||||
assertUsecaseError(t, err, ErrorKindUnavailable, "FREIGHT_SYNC_TIMEOUT")
|
||||
repository.mu.Lock()
|
||||
defer repository.mu.Unlock()
|
||||
if repository.failCalls != 1 ||
|
||||
repository.failCode != "FREIGHT_SYNC_TIMEOUT" ||
|
||||
repository.failContextErr != nil {
|
||||
t.Fatalf(
|
||||
"failure calls/code/context = %d/%q/%v",
|
||||
repository.failCalls,
|
||||
repository.failCode,
|
||||
repository.failContextErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFreightOrderSyncCancellationUsesLiveCleanupContext(t *testing.T) {
|
||||
repository := &syncTrackingRepository{}
|
||||
source := &blockingOrderSource{started: make(chan struct{})}
|
||||
service, err := NewFreightService(
|
||||
repository,
|
||||
source,
|
||||
fakeClock{},
|
||||
&sequenceIDs{},
|
||||
time.Minute,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFreightService() error = %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, runErr := service.CreateOrderSync(
|
||||
ctx,
|
||||
validFreightOrderSyncCommand("sync-canceled"),
|
||||
)
|
||||
result <- runErr
|
||||
}()
|
||||
select {
|
||||
case <-source.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("order sync did not reach source")
|
||||
}
|
||||
cancel()
|
||||
err = <-result
|
||||
assertUsecaseError(t, err, ErrorKindUnavailable, "FREIGHT_SYNC_CANCELED")
|
||||
repository.mu.Lock()
|
||||
defer repository.mu.Unlock()
|
||||
if repository.failCalls != 1 ||
|
||||
repository.failCode != "FREIGHT_SYNC_CANCELED" ||
|
||||
repository.failContextErr != nil {
|
||||
t.Fatalf(
|
||||
"failure calls/code/context = %d/%q/%v",
|
||||
repository.failCalls,
|
||||
repository.failCode,
|
||||
repository.failContextErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFreightOrderSyncRejectsConcurrentRequest(t *testing.T) {
|
||||
repository := &syncTrackingRepository{}
|
||||
source := &blockingOrderSource{
|
||||
started: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
service, err := NewFreightService(
|
||||
repository,
|
||||
source,
|
||||
fakeClock{},
|
||||
&sequenceIDs{},
|
||||
time.Minute,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFreightService() error = %v", err)
|
||||
}
|
||||
firstResult := make(chan error, 1)
|
||||
go func() {
|
||||
_, runErr := service.CreateOrderSync(
|
||||
context.Background(),
|
||||
validFreightOrderSyncCommand("sync-first"),
|
||||
)
|
||||
firstResult <- runErr
|
||||
}()
|
||||
select {
|
||||
case <-source.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("first order sync did not reach source")
|
||||
}
|
||||
startedAt := time.Now()
|
||||
_, err = service.CreateOrderSync(
|
||||
context.Background(),
|
||||
validFreightOrderSyncCommand("sync-second"),
|
||||
)
|
||||
assertUsecaseError(t, err, ErrorKindConflict, "FREIGHT_SYNC_BUSY")
|
||||
if time.Since(startedAt) > 100*time.Millisecond {
|
||||
t.Fatalf("busy response took %s", time.Since(startedAt))
|
||||
}
|
||||
close(source.release)
|
||||
if err := <-firstResult; err != nil {
|
||||
t.Fatalf("first CreateOrderSync() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func validFreightOrderSyncCommand(idempotencyKey string) CreateFreightSyncCommand {
|
||||
return CreateFreightSyncCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
ActorUserID: "00000000-0000-4000-8000-000000000099",
|
||||
IdempotencyKey: idempotencyKey,
|
||||
OrderNumber: "ORDER-123",
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreightDateQuerySplitsIntoSevenDayWindows(t *testing.T) {
|
||||
source := &recordingDateSource{}
|
||||
service := &FreightService{source: source}
|
||||
@@ -261,6 +432,144 @@ type recordingDateSource struct {
|
||||
ensureErr error
|
||||
}
|
||||
|
||||
type blockingOrderSource struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (source *blockingOrderSource) QueryOrder(
|
||||
ctx context.Context,
|
||||
_ string,
|
||||
) (domain.FreightSourceBatch, error) {
|
||||
if source.started != nil {
|
||||
source.once.Do(func() {
|
||||
close(source.started)
|
||||
})
|
||||
}
|
||||
if source.release != nil {
|
||||
select {
|
||||
case <-source.release:
|
||||
case <-ctx.Done():
|
||||
return domain.FreightSourceBatch{}, ctx.Err()
|
||||
}
|
||||
} else {
|
||||
<-ctx.Done()
|
||||
return domain.FreightSourceBatch{}, ctx.Err()
|
||||
}
|
||||
return validFreightSource(), nil
|
||||
}
|
||||
|
||||
func (*blockingOrderSource) QueryCreatedRange(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.FreightSourceBatch, error) {
|
||||
return domain.FreightSourceBatch{}, errors.New("unexpected date query")
|
||||
}
|
||||
|
||||
type syncTrackingRepository struct {
|
||||
mu sync.Mutex
|
||||
startCalls int
|
||||
completeCalls int
|
||||
failCalls int
|
||||
failCode string
|
||||
failContextErr error
|
||||
}
|
||||
|
||||
func (*syncTrackingRepository) CreateFreightSync(
|
||||
_ context.Context,
|
||||
run domain.FreightSyncRun,
|
||||
_, _ string,
|
||||
) (domain.FreightSyncRun, bool, error) {
|
||||
return run, true, nil
|
||||
}
|
||||
|
||||
func (repository *syncTrackingRepository) StartFreightSync(
|
||||
context.Context,
|
||||
string,
|
||||
time.Time,
|
||||
) error {
|
||||
repository.mu.Lock()
|
||||
defer repository.mu.Unlock()
|
||||
repository.startCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repository *syncTrackingRepository) CompleteFreightSync(
|
||||
context.Context,
|
||||
domain.FreightSyncRun,
|
||||
domain.FreightImportBatch,
|
||||
time.Time,
|
||||
) error {
|
||||
repository.mu.Lock()
|
||||
defer repository.mu.Unlock()
|
||||
repository.completeCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*syncTrackingRepository) CompleteFreightDateSync(
|
||||
context.Context,
|
||||
domain.FreightSyncRun,
|
||||
domain.FreightImportBatch,
|
||||
time.Time,
|
||||
time.Time,
|
||||
) error {
|
||||
return errors.New("unexpected date completion")
|
||||
}
|
||||
|
||||
func (repository *syncTrackingRepository) FailFreightSync(
|
||||
ctx context.Context,
|
||||
_ string,
|
||||
code string,
|
||||
_ time.Time,
|
||||
) error {
|
||||
repository.mu.Lock()
|
||||
defer repository.mu.Unlock()
|
||||
repository.failCalls++
|
||||
repository.failCode = code
|
||||
repository.failContextErr = ctx.Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*syncTrackingRepository) RecoverFreightSyncs(
|
||||
context.Context,
|
||||
time.Time,
|
||||
) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (*syncTrackingRepository) GetFreightSync(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.FreightSyncRun, error) {
|
||||
return domain.FreightSyncRun{}, nil
|
||||
}
|
||||
|
||||
func (*syncTrackingRepository) GetFreightSyncWatermark(
|
||||
context.Context,
|
||||
string,
|
||||
) (*domain.FreightSyncWatermark, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (*syncTrackingRepository) ListFreightOrders(
|
||||
context.Context,
|
||||
string,
|
||||
int,
|
||||
) ([]domain.FreightOrder, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (*syncTrackingRepository) GetFreightOrder(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.FreightOrderDetail, error) {
|
||||
return domain.FreightOrderDetail{}, nil
|
||||
}
|
||||
|
||||
func (source *recordingDateSource) EnsureAuthenticated(context.Context) error {
|
||||
source.ensureCalls++
|
||||
return source.ensureErr
|
||||
|
||||
Reference in New Issue
Block a user