Refactor delivery targets to a Target interface #77
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Part of the road to 1.0 (see #33).
Today the delivery engine dispatches by target type via methods on
Engine(deliverHTTP,deliverSlack,deliverDatabase,deliverLog) selected fromTarget.Type, rather than a polymorphic target abstraction. Per @sneak, targets should be an interface, with each target type an implementation of it.Design (per @sneak — see #77 (comment)):
Targetinterface ininternal/delivery. Each target type —http,slack,database,log— is an implementation.logTargetanddatabaseTargetare fire-and-forget (deliver once), whilehttpTargetandslackTargetretry with backoff (and the circuit breaker) as they do today.map[database.TargetType]Targetor a small factory) instead of a type switch, and remains responsible only for what is genuinely cross-target: pulling work off the queue, handing each delivery to the right target, and persisting theDeliveryResult/DeliveryStatusthe target reports.logTargetbehaviour: log the entire inbound webhook — the full request body and request headers, plus method, content type, and the entrypoint/webhook ids — not just a summary line. This supersedes the smaller #70; that work folds into this implementation.Definition of done:
Targetinterface exists, andhttp,slack,database,logare implementations of itTargetTypelogtarget logs the full inbound content and headersdocker build .greendatabaseTargetimplementationSequencing: this reshapes
internal/delivery/engine.go, so it should land after the in-flight engine work merges and before #43.No, we have fire-and-forget targets as well as retry targets. The target itself should be responsible for retries and backoff (as required). The log target should log the entire inbound webhook content and request headers etc.
Updated the design above to match:
log,database) deliver once;http/slackretry with backoff and the circuit breaker as today. The engine just dispatches via a registry and persists the result the target reports.logtarget now logs the full inbound body and headers, which supersedes #70 — that work folds in here.Because this relocates the retry loop and circuit breaker into the target implementations, it is a non-trivial rewrite of
engine.go. Before implementing, I'll post a concrete interface proposal here — the exactTargetsignature and how target-owned retries sit against the current queue/scheduler — for your sign-off. I'll sequence the rewrite after the in-flightengine.gowork (#70 / PR #76) is resolved, since it touches the same file.Design proposal (needs your sign-off before I implement)
I read the current
internal/deliveryengine. Findings, the proposed shape, and two decisions for you.How it works today
d.Target.TypeinprocessDelivery(engine.go:762).retrying, computes an exponential backoff, and re-enqueues the task viascheduleRetry(a timer that pushes onto the retry channel). Retry state lives in the DB, and on restartrecoverRetryingDeliveriesplus a 60s sweep re-enqueue orphaned retries. This durability is a real feature worth keeping.MaxRetriesof 0 is fire-and-forget.DeliveryResultrows and status updates are written by helpers (recordResult,updateDeliveryStatus) called from the per-type methods.Proposed shape
A
Targetinterface, one implementation per type, dispatched from a registry:Deliverperforms one attempt and returns anOutcomedescribing what to persist (an optionalDeliveryResult, the new status) and, if the target wants a retry, a directive with the next attempt number and backoff. The engine'sprocessDeliverycollapses to: look up the target in the registry, callDeliver, persist the result/status, and (if asked) re-enqueue the retry. The circuit-breaker map and the SSRF-safe client move into the http/slack targets; http and slack share a small core so they behave identically.databaseandlogare single-attempt.logTargetlogs the full body, full headers, method, content type, and the webhook/entrypoint ids.Decision 1 — where retry EXECUTION lives
Your instruction was that the target owns retries and backoff. Two ways to honour it:
Both give the target full ownership of the retry behaviour; they differ only in whether the durable async executor stays. My recommendation is A.
Decision 2 — Slack retry semantics
Slack today never retries and does not use the breaker; this issue lists it as a retry target. Recommendation: put Slack on the shared http core and gate retry + breaker on
MaxRetriesexactly like HTTP — a Slack target withMaxRetriesof 0 stays fire-and-forget (preserving every existing Slack target), and one configured with retries gets backoff + breaker. Alternative: keep Slack strictly single-attempt. Recommended: the gated approach.Notes
entrypoint_idinto the log needs a small additive change:Taskgains anEntrypointIDfield, populated where tasks are built.Event.Bodyis already fully resolved before dispatch, so full-body logging is available.export_test.gowrappers re-pointed (persistence moves intoprocessDelivery), plus new pure-Deliverunit tests and a log-target full-content test.Please pick A vs B (Decision 1) and the Slack semantics (Decision 2). On your sign-off I'll implement — sequenced after the in-flight
engine.gowork (PR #76) is resolved.why do you think B can't be durable? just give the database to the targets so they can do the same thing the delivery engine could, and they can own their retries. you're being obtuse here.
You're right — I over-constrained B. B does not mean a blocking sleep loop, and it does not lose durability: hand the target the per-webhook DB and the re-enqueue primitive and it does exactly what the engine does today — record the attempt, mark
retrying, schedule the next attempt — while owning the retry entirely. Scrapping the A/B framing.Corrected design:
Targetinterface; each type an implementation.Deliverreceives everything it needs to be autonomous and durable: the request context, the event, the per-webhook*gorm.DB, theDeliveryand attempt number, and aSchedulerthe engine provides for durable re-enqueue (ScheduleRetry(task, delay)— the existing timer + queue).DeliveryResultand status to the DB, decide whether to retry, compute its own backoff, gate with its own circuit breaker, and reschedule via the injectedScheduler. Fire-and-forget targets (log,database) just record success once.httpandslackown their retry + backoff + breaker.Schedulerto the target. On restart, recovery hands each orphanedretryingdelivery back to its target to recompute remaining backoff and reschedule, so the schedule stays target-owned.deliverHTTPWithRetry,handleHTTPRetry,circuitBreakerBlock,calcBackoff, and the breaker map all move wholesale intohttpTarget; the engine no longer knows anything target-specific.logTargetlogs the full body, full headers, method, content type, and the webhook/entrypoint ids.For Slack: I'll put it on the same shared core as http and gate retry/breaker on
MaxRetries(0 stays fire-and-forget, so existing Slack targets are unchanged). Say so if you'd rather it stay strictly single-attempt; otherwise that's what I'll build.Implementing this shape now. It rewrites
engine.go, so I'm treating the small log-target PR #76 as superseded — folding it into this and closing it — and branching this from currentmain.