Refactor delivery targets to a Target interface (closes #77)
All checks were successful
check / check (push) Successful in 5s

Each target TYPE is now an implementation of a Target interface,
dispatched from a registry in processDelivery instead of a type
switch on TargetType. Every target owns its full delivery,
including durable retries.

- Target.Deliver receives the context, the per-webhook DB, the
  Delivery, the attempt Task, and a Scheduler for durable
  re-enqueue (the existing timer + retry queue). The target makes
  one attempt, records the DeliveryResult, updates DeliveryStatus,
  and — for retry targets — decides whether to retry, computes its
  own backoff, gates with its own circuit breaker, and reschedules
  via the Scheduler.
- httpTarget and slackTarget share a retry core (retry, backoff,
  circuit breaker). database and log targets are fire-and-forget.
- Slack retry/breaker is gated on MaxRetries: 0 stays
  fire-and-forget (existing Slack targets unchanged), >0 gets
  retry + backoff + breaker on the shared core.
- The engine keeps only the worker pool, queue/channels, restart
  recovery/sweep, the recordResult/updateDeliveryStatus helpers,
  and ScheduleRetry. Recovery/sweep hand each orphaned retrying
  delivery back to its target to recompute the backoff.
- The log target logs the entire inbound webhook: full body and
  headers, method, content type, and the webhook and entrypoint
  ids (supersedes the smaller log-summary work).
- Task gains EntrypointID, populated in the webhook handler, the
  recovery-task builder, and buildEventFromTask.

Behaviour is preserved: existing delivery tests pass with their
export_test wrappers re-pointed at the new targets; new pure
Deliver tests cover the log full-content output and the gated
Slack retry path.
This commit is contained in:
2026-08-07 21:43:21 +07:00
parent 752d6beead
commit 7b1f997194
9 changed files with 1313 additions and 856 deletions

View File

@@ -1,6 +1,7 @@
package delivery_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
@@ -1652,6 +1653,179 @@ func TestProcessDelivery_RoutesToSlack(t *testing.T) {
)
}
// newLogCaptureEngine builds a test engine whose logger
// writes to the returned buffer, for inspecting log output.
func newLogCaptureEngine(
t *testing.T,
) (*delivery.Engine, *bytes.Buffer) {
t.Helper()
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(
&buf,
&slog.HandlerOptions{Level: slog.LevelDebug},
))
e := delivery.NewTestEngine(
log, &http.Client{Timeout: 5 * time.Second}, 1,
)
return e, &buf
}
// assertLogLineComplete asserts the captured log output
// carries the full inbound webhook content and ids.
func assertLogLineComplete(
t *testing.T, out string, event database.Event,
) {
t.Helper()
assert.Contains(t, out, "log-body-marker",
"log line must contain the full request body",
)
assert.Contains(t, out, "Content-Type",
"log line must contain the full request headers",
)
assert.Contains(t, out, event.EntrypointID,
"log line must contain the entrypoint id",
)
assert.Contains(t, out, event.WebhookID,
"log line must contain the webhook id",
)
assert.Contains(t, out, "application/json",
"log line must contain the content type",
)
}
func TestDeliverLog_LogsFullContent(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e, buf := newLogCaptureEngine(t)
event := seedEvent(
t, db, `{"log-body-marker":"abc123"}`,
)
dlv := seedDelivery(
t, db, event.ID, uuid.New().String(),
database.DeliveryStatusPending,
)
d := &database.Delivery{
EventID: event.ID,
TargetID: dlv.TargetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "test-log-full",
Type: database.TargetTypeLog,
},
}
d.ID = dlv.ID
e.ExportDeliverLog(db, d)
assertLogLineComplete(t, buf.String(), event)
assertDeliveryStatus(t, db, dlv.ID,
database.DeliveryStatusDelivered,
)
}
// buildSlackRetryDelivery builds a Slack delivery whose
// target is configured with retries enabled.
func buildSlackRetryDelivery(
dlv database.Delivery,
event database.Event,
targetID, cfg string,
) *database.Delivery {
d := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "test-slack-retry",
Type: database.TargetTypeSlack,
Config: cfg,
MaxRetries: 5,
},
}
d.ID = dlv.ID
return d
}
func TestDeliverSlack_WithRetries_SchedulesRetry(
t *testing.T,
) {
t.Parallel()
db := testWebhookDB(t)
ts := newStatusServer(t, http.StatusServiceUnavailable)
e := testEngine(t, 1)
targetID := uuid.New().String()
slackCfg, err := json.Marshal(
delivery.SlackTargetConfig{WebhookURL: ts.URL},
)
require.NoError(t, err)
event := seedEvent(t, db, `{"slack":"retry"}`)
dlv := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusPending,
)
d := buildSlackRetryDelivery(
dlv, event, targetID, string(slackCfg),
)
task := &delivery.Task{
DeliveryID: dlv.ID,
TargetID: targetID,
TargetType: database.TargetTypeSlack,
MaxRetries: 5,
AttemptNum: 1,
}
e.ExportProcessDelivery(context.TODO(), db, d, task)
assertDeliveryStatus(t, db, dlv.ID,
database.DeliveryStatusRetrying,
)
assertDeliveryResult(
t, db, dlv.ID, false,
http.StatusServiceUnavailable,
)
}
// newStatusServer starts a test server that always responds
// with the given status code.
func newStatusServer(
t *testing.T, code int,
) *httptest.Server {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(code)
},
))
t.Cleanup(ts.Close)
return ts
}
// readAll is a small helper to avoid importing io in
// a test handler inline.
func readAll(r interface {