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:
@@ -13,6 +13,25 @@ const (
|
||||
TypePasswordChangedEmail = "email:password_changed"
|
||||
)
|
||||
|
||||
// Task types for in-app notifications enqueued by the api request path.
|
||||
// Handlers live in internal/worker/jobs.
|
||||
const (
|
||||
// TypeTaskCompletedNotification is emitted after a task completion is
|
||||
// persisted; the worker fans out push + email to all residence users.
|
||||
// Moves the ~1-1.5s of synchronous APNs+SMTP+B2-fetch work out of the
|
||||
// POST /api/task-completions/ request path.
|
||||
TypeTaskCompletedNotification = "notification:task_completed"
|
||||
)
|
||||
|
||||
// TaskCompletedNotificationPayload carries only the IDs needed for the
|
||||
// worker to re-fetch the canonical Task + TaskCompletion rows. Keeping the
|
||||
// payload to IDs (vs. full model graphs) keeps the Redis queue cheap and
|
||||
// avoids serialising preloaded relations through JSON.
|
||||
type TaskCompletedNotificationPayload struct {
|
||||
TaskID uint `json:"task_id"`
|
||||
CompletionID uint `json:"completion_id"`
|
||||
}
|
||||
|
||||
// EmailPayload is the base payload for email tasks
|
||||
type EmailPayload struct {
|
||||
To string `json:"to"`
|
||||
@@ -43,10 +62,12 @@ type TaskClient struct {
|
||||
client *asynq.Client
|
||||
}
|
||||
|
||||
// NewTaskClient creates a new task client
|
||||
func NewTaskClient(redisAddr string) *TaskClient {
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{Addr: redisAddr})
|
||||
return &TaskClient{client: client}
|
||||
// NewTaskClient creates a new task client. Accepts a full RedisClientOpt so
|
||||
// callers can supply Addr + Password (Redis is requirepass-protected; the
|
||||
// password lives in a file-mounted secret, not the URL — see audit HIGH-1
|
||||
// in cmd/worker/main.go).
|
||||
func NewTaskClient(opt asynq.RedisClientOpt) *TaskClient {
|
||||
return &TaskClient{client: asynq.NewClient(opt)}
|
||||
}
|
||||
|
||||
// Close closes the task client
|
||||
@@ -125,3 +146,34 @@ func (c *TaskClient) EnqueuePasswordChangedEmail(to, firstName string) error {
|
||||
log.Debug().Str("to", to).Msg("Password changed email task enqueued")
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnqueueTaskCompletedNotification queues fan-out push + email delivery for a
|
||||
// completed task. The api request handler calls this so the response can
|
||||
// return ~immediately instead of waiting on APNs + SMTP + B2 image fetches.
|
||||
//
|
||||
// Queue: "default". MaxRetry: 3 (Asynq retries on handler error; notifications
|
||||
// are idempotent enough at the user-perception level that a few duplicate
|
||||
// pushes are preferable to silent drops).
|
||||
func (c *TaskClient) EnqueueTaskCompletedNotification(taskID, completionID uint) error {
|
||||
payload, err := BuildTaskCompletedNotificationPayload(taskID, completionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
task := asynq.NewTask(TypeTaskCompletedNotification, payload)
|
||||
_, err = c.client.Enqueue(task, asynq.Queue("default"), asynq.MaxRetry(3))
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Uint("task_id", taskID).
|
||||
Uint("completion_id", completionID).
|
||||
Msg("Failed to enqueue task completion notification")
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Uint("task_id", taskID).
|
||||
Uint("completion_id", completionID).
|
||||
Msg("Task completion notification enqueued")
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user