perf(task): offload completion notification fan-out to Asynq worker
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled

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:
Trey t
2026-06-03 09:34:52 -05:00
parent e448ec66dc
commit 52bf1ff3c7
7 changed files with 280 additions and 20 deletions
+14 -1
View File
@@ -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,
})
}
+53
View File
@@ -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
}
+56 -4
View File
@@ -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
}