refactor: event-driven delivery engine with channel notifications and timer-based retries
All checks were successful
check / check (push) Successful in 58s

Replace the polling-based delivery engine with a fully event-driven
architecture using Go channels and goroutines:

- Webhook handler notifies engine via buffered channel after creating
  delivery records, with inline event data for payloads < 16KB
- Large payloads (>= 16KB) use pointer semantics (Body *string = nil)
  and are fetched from DB on demand, keeping channel memory bounded
- Failed retry-target deliveries schedule Go timers with exponential
  backoff; timers fire into a separate retry channel when ready
- On startup, engine scans DB once to recover interrupted deliveries
  (pending processed immediately, retrying get timers for remaining
  backoff)
- DB stores delivery status for crash recovery only, not for
  inter-component communication during normal operation
- delivery.Notifier interface decouples handlers from engine; fx wires
  *Engine as Notifier

No more periodic polling. No more wasted cycles when idle.
This commit is contained in:
clawbot
2026-03-01 21:46:16 -08:00
parent 8f62fde8e9
commit 5e683af2a4
6 changed files with 404 additions and 53 deletions

View File

@@ -9,6 +9,7 @@ import (
"go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger"
@@ -25,6 +26,7 @@ type HandlersParams struct {
WebhookDBMgr *database.WebhookDBManager
Healthcheck *healthcheck.Healthcheck
Session *session.Session
Notifier delivery.Notifier
}
type Handlers struct {
@@ -34,6 +36,7 @@ type Handlers struct {
db *database.Database
dbMgr *database.WebhookDBManager
session *session.Session
notifier delivery.Notifier
templates map[string]*template.Template
}
@@ -57,6 +60,7 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
s.db = params.Database
s.dbMgr = params.WebhookDBMgr
s.session = params.Session
s.notifier = params.Notifier
// Parse all page templates once at startup
s.templates = map[string]*template.Template{

View File

@@ -12,12 +12,18 @@ import (
"go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session"
)
// noopNotifier is a no-op delivery.Notifier for tests.
type noopNotifier struct{}
func (n *noopNotifier) Notify(delivery.Notification) {}
func TestHandleIndex(t *testing.T) {
var h *Handlers
@@ -41,6 +47,7 @@ func TestHandleIndex(t *testing.T) {
database.NewWebhookDBManager,
healthcheck.New,
session.New,
func() delivery.Notifier { return &noopNotifier{} },
New,
),
fx.Populate(&h),
@@ -76,6 +83,7 @@ func TestRenderTemplate(t *testing.T) {
database.NewWebhookDBManager,
healthcheck.New,
session.New,
func() delivery.Notifier { return &noopNotifier{} },
New,
),
fx.Populate(&h),

View File

@@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
const (
@@ -117,12 +118,12 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
// Create delivery records for each active target
for i := range targets {
delivery := &database.Delivery{
dlv := &database.Delivery{
EventID: event.ID,
TargetID: targets[i].ID,
Status: database.DeliveryStatusPending,
}
if err := tx.Create(delivery).Error; err != nil {
if err := tx.Create(dlv).Error; err != nil {
tx.Rollback()
h.log.Error("failed to create delivery",
"target_id", targets[i].ID,
@@ -139,6 +140,23 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
return
}
// Notify the delivery engine with inline event data so it can
// process deliveries immediately without a DB round-trip.
// Large bodies (>= 16KB) are left nil to keep channel memory
// bounded; the engine fetches them from DB on demand.
n := delivery.Notification{
WebhookID: entrypoint.WebhookID,
EventID: event.ID,
Method: event.Method,
Headers: event.Headers,
ContentType: event.ContentType,
}
bodyStr := string(body)
if len(body) < delivery.MaxInlineBodySize {
n.Body = &bodyStr
}
h.notifier.Notify(n)
h.log.Info("webhook event created",
"event_id", event.ID,
"webhook_id", entrypoint.WebhookID,