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:
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/treytartt/honeydue-api/internal/push"
|
||||
"github.com/treytartt/honeydue-api/internal/repositories"
|
||||
"github.com/treytartt/honeydue-api/internal/services"
|
||||
"github.com/treytartt/honeydue-api/internal/worker"
|
||||
)
|
||||
|
||||
// Task types
|
||||
@@ -41,6 +42,7 @@ type Handler struct {
|
||||
notificationService NotificationSender
|
||||
onboardingService OnboardingEmailSender
|
||||
uploadService *services.UploadService
|
||||
taskService *services.TaskService
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
@@ -51,6 +53,14 @@ func (h *Handler) SetUploadService(us *services.UploadService) {
|
||||
h.uploadService = us
|
||||
}
|
||||
|
||||
// SetTaskService wires the api-side TaskService so HandleTaskCompletedNotification
|
||||
// can re-use the same SendTaskCompletedNotificationByID logic the inline path
|
||||
// used to call. Required for the task-completed notification job; without it
|
||||
// the handler logs a warning and no-ops (notifications silently dropped).
|
||||
func (h *Handler) SetTaskService(ts *services.TaskService) {
|
||||
h.taskService = ts
|
||||
}
|
||||
|
||||
// NewHandler creates a new job handler
|
||||
func NewHandler(db *gorm.DB, pushClient *push.Client, emailService *services.EmailService, notificationService *services.NotificationService, cfg *config.Config) *Handler {
|
||||
h := &Handler{
|
||||
@@ -677,3 +687,46 @@ func (h *Handler) HandleUploadCleanup(ctx context.Context, task *asynq.Task) err
|
||||
log.Info().Int("reaped", reaped).Msg("Pending uploads cleanup completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleTaskCompletedNotification fans out push + email notifications for a
|
||||
// completed task. Enqueued by the api request handler (POST
|
||||
// /api/task-completions/) so the synchronous chain of APNs + SMTP + B2 image
|
||||
// fetches happens here instead of in the user-facing request path.
|
||||
//
|
||||
// The payload only carries IDs; canonical state is re-read from Postgres so
|
||||
// the worker reflects any concurrent edits to the Task or Completion that
|
||||
// happened between enqueue and dequeue.
|
||||
//
|
||||
// Asynq retries on returned error; we return nil for "row not found" cases
|
||||
// (task or completion got deleted before the job ran) so retries don't
|
||||
// loop forever on a permanent miss.
|
||||
func (h *Handler) HandleTaskCompletedNotification(ctx context.Context, t *asynq.Task) error {
|
||||
var p worker.TaskCompletedNotificationPayload
|
||||
if err := json.Unmarshal(t.Payload(), &p); err != nil {
|
||||
return fmt.Errorf("unmarshal task_completed_notification payload: %w", err)
|
||||
}
|
||||
|
||||
if h.taskService == nil {
|
||||
log.Warn().
|
||||
Uint("task_id", p.TaskID).
|
||||
Uint("completion_id", p.CompletionID).
|
||||
Msg("task_completed_notification handler invoked without TaskService wired — dropping job")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Uint("task_id", p.TaskID).
|
||||
Uint("completion_id", p.CompletionID).
|
||||
Msg("Processing task completion notification")
|
||||
|
||||
if err := h.taskService.SendTaskCompletedNotificationByID(ctx, p.TaskID, p.CompletionID); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Uint("task_id", p.TaskID).
|
||||
Uint("completion_id", p.CompletionID).
|
||||
Msg("Failed to deliver task completion notification")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user