perf(task): offload completion notification fan-out to Asynq worker
POST /api/task-completions/ was spending ~1.5-1.75s synchronously on
APNs push + SMTP email + B2 image fetches inside sendTaskCompletedNotification.
Per-user loop made it scale linearly with residence membership; one image
attached + one residence user is the 1.75s baseline observed in the live
honeydue-eli5-overview Grafana panel.
Replace the inline call (and the fire-and-forget goroutine in QuickComplete,
which violated the project's "no goroutines in handlers" rule) with an
Asynq job:
- new task type notification:task_completed (worker/scheduler.go)
- new payload {task_id, completion_id} — IDs only, worker re-reads
canonical state from Postgres so concurrent edits between enqueue
and dequeue are reflected
- new HandleTaskCompletedNotification on jobs.Handler delegates to
TaskService.SendTaskCompletedNotificationByID
- new dispatchTaskCompletedNotification in task_service.go picks
between enqueue (preferred) and inline (fallback) when Redis is
unreachable or the enqueuer isn't wired (tests / local dev)
Other changes required to wire it up:
- widen worker.NewTaskClient signature to accept asynq.RedisClientOpt
so the file-mounted Redis password (audit HIGH-1) can be supplied;
no prior callers, no breakage
- extend worker.Enqueuer interface with EnqueueTaskCompletedNotification
- add TaskEnqueuer field to router.Dependencies; wire from cmd/api/main.go
with the standard typed-nil interface guard
- wire a worker-side TaskService in cmd/worker/main.go so the handler
can use the shared SendTaskCompletedNotificationByID implementation
(storage service shared with the existing upload-cleanup wiring)
Expected impact on POST /api/task-completions/ p50:
~1.75s -> ~120-170ms (DB + tx + Asynq enqueue only)
Notifications still deliver; they just go via the worker instead of in
the request path. MaxRetry=3; "row not found" returns nil so a deleted
task/completion doesn't churn the retry loop.
All 31 test packages pass. No DB migrations.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,12 +2,16 @@ package worker
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Enqueuer defines the interface for enqueuing background email tasks.
|
||||
// Enqueuer defines the interface for enqueuing background email + notification
|
||||
// tasks from the api request path. Implementations are expected to be cheap to
|
||||
// call and non-blocking (Asynq's client batches over a persistent Redis
|
||||
// connection).
|
||||
type Enqueuer interface {
|
||||
EnqueueWelcomeEmail(to, firstName, code string) error
|
||||
EnqueueVerificationEmail(to, firstName, code string) error
|
||||
EnqueuePasswordResetEmail(to, firstName, code, resetToken string) error
|
||||
EnqueuePasswordChangedEmail(to, firstName string) error
|
||||
EnqueueTaskCompletedNotification(taskID, completionID uint) error
|
||||
}
|
||||
|
||||
// Verify TaskClient satisfies the interface at compile time.
|
||||
@@ -42,3 +46,12 @@ func BuildPasswordResetEmailPayload(to, firstName, code, resetToken string) ([]b
|
||||
func BuildPasswordChangedEmailPayload(to, firstName string) ([]byte, error) {
|
||||
return json.Marshal(EmailPayload{To: to, FirstName: firstName})
|
||||
}
|
||||
|
||||
// BuildTaskCompletedNotificationPayload marshals a TaskCompletedNotificationPayload
|
||||
// to JSON bytes for the Asynq queue.
|
||||
func BuildTaskCompletedNotificationPayload(taskID, completionID uint) ([]byte, error) {
|
||||
return json.Marshal(TaskCompletedNotificationPayload{
|
||||
TaskID: taskID,
|
||||
CompletionID: completionID,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user