Skip to content

Commit f931871

Browse files
Add delivery, retry attempts, DLQ, and persistence
Enable real delivery and observability: introduce DLQ wiring, per-channel Sender abstraction, and persistence of routed payloads. Highlights: - cmd/gateway: wire a DLQ writer and Store into the router/pipeline; persist messages before delivery and update stored status after routing. - internal/config: add DLQDir config with default. - internal/audit: include Attempts in audit entries. - internal/connector/destination/retry: add SendWithAttempts to report actual attempt counts (Send delegates to it). - internal/router: implement Sender/SenderBuilder, retry-backed default builder, per-channel sender cache with invalidation, delivery handling with attempt/duration reporting, DLQ writes on terminal failures, and AggregateStatus to summarize per-message outcome. - Tests: expand router tests to cover real delivery, DLQ writing, attempt counts, sender cache behavior, and status aggregation. - web UI/types: surface new stages ('deliver','store') and status/attempts badges; add attempts to AuditEntry type. Purpose: this change wires up end-to-end delivery, records attempts for auditing/metrics, persists message state for the UI, and dead-letters permanently failing deliveries for operator inspection.
1 parent df66102 commit f931871

8 files changed

Lines changed: 481 additions & 95 deletions

File tree

cmd/gateway/main.go

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ import (
1414
"github.com/vagnercazarotto/verifhir-gateway/internal/audit"
1515
"github.com/vagnercazarotto/verifhir-gateway/internal/channel"
1616
"github.com/vagnercazarotto/verifhir-gateway/internal/config"
17+
"github.com/vagnercazarotto/verifhir-gateway/internal/connector/destination/dlq"
1718
"github.com/vagnercazarotto/verifhir-gateway/internal/ingest/mllp"
1819
"github.com/vagnercazarotto/verifhir-gateway/internal/mapping"
1920
"github.com/vagnercazarotto/verifhir-gateway/internal/model"
2021
"github.com/vagnercazarotto/verifhir-gateway/internal/parser"
2122
"github.com/vagnercazarotto/verifhir-gateway/internal/quality"
2223
"github.com/vagnercazarotto/verifhir-gateway/internal/router"
24+
"github.com/vagnercazarotto/verifhir-gateway/internal/store"
2325
"github.com/vagnercazarotto/verifhir-gateway/internal/store/sqlite"
2426
)
2527

@@ -49,9 +51,12 @@ func main() {
4951
}
5052
}
5153

52-
// Channel-aware dispatcher (Phase 3 PR 1: scaffold only — real HTTP
53-
// delivery, retry and DLQ wiring follow in subsequent PRs).
54-
rtr := router.New(reg)
54+
// Dead-letter writer for delivery failures (one shared dir for the gateway).
55+
dlqW := dlq.New(dlq.Config{Dir: cfg.DLQDir})
56+
57+
// Channel-aware dispatcher with real HTTP delivery, per-channel retry,
58+
// and DLQ on terminal failure.
59+
rtr := router.New(reg, dlqW)
5560

5661
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
5762
defer stop()
@@ -69,7 +74,7 @@ func main() {
6974
}()
7075

7176
// MLLP listener (blocks until ctx cancelled)
72-
mllpSrv := mllp.New(cfg.MLLPAddr, makePipeline(rtr))
77+
mllpSrv := mllp.New(cfg.MLLPAddr, makePipeline(rtr, st))
7378
if err := mllpSrv.ListenAndServe(ctx); err != nil {
7479
log.Printf("[gateway] mllp: %v", err)
7580
}
@@ -87,8 +92,10 @@ func main() {
8792
// processing chain and emits one structured audit log line per stage.
8893
//
8994
// Routing is delegated to rtr, which fans the payload out across the
90-
// channels currently registered (see internal/router).
91-
func makePipeline(rtr *router.Router) func(model.HL7Message) error {
95+
// channels currently registered (see internal/router). Persistence is
96+
// delegated to st: every message is saved before routing and the aggregated
97+
// delivery outcome is recorded after the router returns.
98+
func makePipeline(rtr *router.Router, st store.Store) func(model.HL7Message) error {
9299
return func(msg model.HL7Message) error {
93100
audit.Log(audit.Entry{
94101
MessageID: msg.ID,
@@ -142,15 +149,45 @@ func makePipeline(rtr *router.Router) func(model.HL7Message) error {
142149
Findings: len(report.Findings),
143150
})
144151

152+
payload := model.RoutedPayload{Resource: resource, Quality: report}
153+
154+
// Persist as pending before delivery so the message is visible in
155+
// the UI even if routing crashes or the gateway restarts mid-flight.
156+
ctx := context.Background()
157+
t = time.Now()
158+
if err := st.Save(ctx, payload); err != nil {
159+
audit.Log(audit.Entry{
160+
MessageID: msg.ID,
161+
Stage: "store",
162+
DurationMs: time.Since(t).Milliseconds(),
163+
Status: "error",
164+
Error: err.Error(),
165+
})
166+
// Continue: persistence failure must not block delivery.
167+
}
168+
145169
t = time.Now()
146-
rtr.Route(context.Background(), model.RoutedPayload{Resource: resource, Quality: report})
170+
decisions := rtr.Route(ctx, payload)
147171
audit.Log(audit.Entry{
148172
MessageID: msg.ID,
149173
Stage: "route",
150174
DurationMs: time.Since(t).Milliseconds(),
151175
Status: "ok",
152176
})
153177

178+
// Reflect the aggregated delivery outcome on the stored record.
179+
status, attempts, lastErr := router.AggregateStatus(decisions)
180+
t = time.Now()
181+
if err := st.UpdateStatus(ctx, msg.ID, status, attempts, lastErr); err != nil {
182+
audit.Log(audit.Entry{
183+
MessageID: msg.ID,
184+
Stage: "store",
185+
DurationMs: time.Since(t).Milliseconds(),
186+
Status: "error",
187+
Error: err.Error(),
188+
})
189+
}
190+
154191
return nil
155192
}
156193
}

internal/audit/logger.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ type Entry struct {
3535
ChannelID string `json:"channel_id,omitempty"`
3636
// DestURL is the resolved target URL for the delivery attempt.
3737
DestURL string `json:"dest_url,omitempty"`
38+
// Attempts is the number of delivery tries actually executed.
39+
Attempts int `json:"attempts,omitempty"`
3840
}
3941

4042
// Log writes e as a single JSON line to the audit sink.

internal/config/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ type Config struct {
1111
DBPath string // SQLite file path; ignored when DATABASE_URL is set
1212
ChannelsFile string // optional YAML file with channel definitions
1313
AuditDir string // directory for daily .jsonl audit files
14+
DLQDir string // directory for dead-letter JSON files
1415
}
1516

1617
func Load() Config {
@@ -34,11 +35,17 @@ func Load() Config {
3435
auditDir = ".local/audit"
3536
}
3637

38+
dlqDir := os.Getenv("GATEWAY_DLQ_DIR")
39+
if dlqDir == "" {
40+
dlqDir = ".local/dlq"
41+
}
42+
3743
return Config{
3844
HTTPPort: port,
3945
MLLPAddr: mllp,
4046
DBPath: dbPath,
4147
ChannelsFile: os.Getenv("GATEWAY_CHANNELS_FILE"),
4248
AuditDir: auditDir,
49+
DLQDir: dlqDir,
4350
}
4451
}

internal/connector/destination/retry/retryer.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,25 @@ func (r *Retryer) SetSleep(fn func(ctx context.Context, d time.Duration) error)
6767
// It returns the last error if all attempts are exhausted, or the context
6868
// error if the context is cancelled between retries.
6969
func (r *Retryer) Send(ctx context.Context, payload model.RoutedPayload) error {
70+
_, err := r.SendWithAttempts(ctx, payload)
71+
return err
72+
}
73+
74+
// SendWithAttempts is like Send but also returns the number of attempts that
75+
// were actually executed (1-based). Callers that need to record the attempt
76+
// count for auditing or metrics should prefer this method.
77+
//
78+
// On success the returned count reflects the attempt that succeeded
79+
// (e.g. 1 if the first try worked). On failure or context cancellation it
80+
// reflects the last attempt that ran.
81+
func (r *Retryer) SendWithAttempts(ctx context.Context, payload model.RoutedPayload) (int, error) {
7082
backoff := r.cfg.InitialBackoff
7183
var lastErr error
7284

7385
for attempt := 0; attempt < r.cfg.MaxAttempts; attempt++ {
7486
lastErr = r.inner.Send(ctx, payload)
7587
if lastErr == nil {
76-
return nil
88+
return attempt + 1, nil
7789
}
7890

7991
// Last attempt — do not sleep.
@@ -82,12 +94,12 @@ func (r *Retryer) Send(ctx context.Context, payload model.RoutedPayload) error {
8294
}
8395

8496
if err := r.sleep(ctx, backoff); err != nil {
85-
return fmt.Errorf("retry: context cancelled after %d attempt(s): %w", attempt+1, err)
97+
return attempt + 1, fmt.Errorf("retry: context cancelled after %d attempt(s): %w", attempt+1, err)
8698
}
8799
backoff = time.Duration(float64(backoff) * r.cfg.Multiplier)
88100
}
89101

90-
return fmt.Errorf("retry: all %d attempt(s) failed: %w", r.cfg.MaxAttempts, lastErr)
102+
return r.cfg.MaxAttempts, fmt.Errorf("retry: all %d attempt(s) failed: %w", r.cfg.MaxAttempts, lastErr)
91103
}
92104

93105
func sleepWithContext(ctx context.Context, d time.Duration) error {

0 commit comments

Comments
 (0)