52bf1ff3c7
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>
58 lines
2.2 KiB
Go
58 lines
2.2 KiB
Go
package worker
|
|
|
|
import "encoding/json"
|
|
|
|
// 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.
|
|
var _ Enqueuer = (*TaskClient)(nil)
|
|
|
|
// BuildWelcomeEmailPayload marshals a WelcomeEmailPayload to JSON bytes.
|
|
func BuildWelcomeEmailPayload(to, firstName, code string) ([]byte, error) {
|
|
return json.Marshal(WelcomeEmailPayload{
|
|
EmailPayload: EmailPayload{To: to, FirstName: firstName},
|
|
ConfirmationCode: code,
|
|
})
|
|
}
|
|
|
|
// BuildVerificationEmailPayload marshals a VerificationEmailPayload to JSON bytes.
|
|
func BuildVerificationEmailPayload(to, firstName, code string) ([]byte, error) {
|
|
return json.Marshal(VerificationEmailPayload{
|
|
EmailPayload: EmailPayload{To: to, FirstName: firstName},
|
|
Code: code,
|
|
})
|
|
}
|
|
|
|
// BuildPasswordResetEmailPayload marshals a PasswordResetEmailPayload to JSON bytes.
|
|
func BuildPasswordResetEmailPayload(to, firstName, code, resetToken string) ([]byte, error) {
|
|
return json.Marshal(PasswordResetEmailPayload{
|
|
EmailPayload: EmailPayload{To: to, FirstName: firstName},
|
|
Code: code,
|
|
ResetToken: resetToken,
|
|
})
|
|
}
|
|
|
|
// BuildPasswordChangedEmailPayload marshals an EmailPayload to JSON bytes.
|
|
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,
|
|
})
|
|
}
|