mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-07 00:01:37 +00:00
208 lines
6.5 KiB
Go
208 lines
6.5 KiB
Go
package jobs
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// OrgNotifier raises a permission-targeted org notification (in-app feed +
|
|
// each member's enabled channels, email digest-coalesced). Satisfied by
|
|
// *notification.Service; local interface to avoid an import cycle.
|
|
type OrgNotifier interface {
|
|
NotifyOrg(ctx context.Context, orgID uuid.UUID, perm models.OrganizationPermission, exclude uuid.UUID, category models.NotificationCategory, title, body, link string, meta map[string]any, groupKey string)
|
|
}
|
|
|
|
// notifyWorkerDown tells each affected org's manage_emails members about a
|
|
// dead worker, at most once per worker incident: detection reruns every
|
|
// interval while the worker stays down, and the SetNX guard keeps that from
|
|
// re-alerting. The shared group key coalesces an org's recipients into one
|
|
// email with everyone in To.
|
|
func (s *JobsService) notifyWorkerDown(ctx context.Context, workerID uuid.UUID, orgs map[uuid.UUID]int, reassigned bool) {
|
|
if s.Notifier == nil || len(orgs) == 0 {
|
|
return
|
|
}
|
|
ok, err := s.Cache.SetNX(ctx, "worker:downnotify:"+workerID.String(), "1", 6*time.Hour).Result()
|
|
if err != nil || !ok {
|
|
return
|
|
}
|
|
for orgID, n := range orgs {
|
|
noun := fmt.Sprintf("%d of your mailboxes were", n)
|
|
if n == 1 {
|
|
noun = "One of your mailboxes was"
|
|
}
|
|
body := noun + " on a sending worker that stopped responding. "
|
|
if reassigned {
|
|
body += "They were moved to a healthy worker automatically; no action is needed."
|
|
if n == 1 {
|
|
body = noun + " on a sending worker that stopped responding. It was moved to a healthy worker automatically; no action is needed."
|
|
}
|
|
} else {
|
|
body += "Sending from them is paused until a replacement worker is available."
|
|
}
|
|
s.Notifier.NotifyOrg(ctx, orgID, models.PermManageEmails, uuid.Nil, models.NotifWorkerDowntime,
|
|
"Sending worker went offline", body, "/app/emails", nil,
|
|
"worker_down:"+workerID.String())
|
|
}
|
|
}
|
|
|
|
// StartDeadWorkerDetection periodically checks for workers whose heartbeat has
|
|
// expired and reassigns their email accounts to healthy workers.
|
|
// Runs every interval until the context is cancelled.
|
|
func (s *JobsService) StartDeadWorkerDetection(ctx context.Context, interval time.Duration) {
|
|
if s.WorkerRepo == nil {
|
|
return
|
|
}
|
|
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
detectCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
s.detectDeadWorkers(detectCtx)
|
|
cancel()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *JobsService) detectDeadWorkers(ctx context.Context) {
|
|
// Get all workers from the database
|
|
workers, err := s.WorkerRepo.GetAllActiveWorkers(ctx)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("dead worker detection: failed to list workers")
|
|
return
|
|
}
|
|
|
|
for _, w := range workers {
|
|
key := fmt.Sprintf("worker:heartbeat:%s", w.ID.String())
|
|
exists, err := s.Cache.Exists(ctx, key).Result()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if exists > 0 {
|
|
continue // Worker is alive
|
|
}
|
|
|
|
// Worker heartbeat expired - mark as stale and reassign emails
|
|
log.Warn().Str("worker_id", w.ID.String()).Msg("dead worker detected - heartbeat expired")
|
|
|
|
// Get all email accounts assigned to this worker
|
|
accountIDs, err := s.WorkerRepo.GetEmailAccountsByWorkerID(ctx, w.ID)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("worker_id", w.ID.String()).Msg("failed to get accounts for dead worker")
|
|
continue
|
|
}
|
|
|
|
if len(accountIDs) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Find a healthy replacement worker of the same tier
|
|
replacement, err := s.findHealthyWorker(ctx, w)
|
|
if err != nil || replacement == nil {
|
|
log.Warn().Str("worker_id", w.ID.String()).Msg("no healthy replacement worker found")
|
|
s.notifyWorkerDown(ctx, w.ID, s.accountOrgs(ctx, accountIDs), false)
|
|
continue
|
|
}
|
|
|
|
// Reassign accounts to the healthy worker
|
|
reassigned := 0
|
|
affectedOrgs := map[uuid.UUID]int{}
|
|
for _, accountID := range accountIDs {
|
|
if err := s.WorkerRepo.UpdateEmailAccountWorker(ctx, accountID, replacement.ID); err != nil {
|
|
log.Error().Err(err).Str("account_id", accountID.String()).Msg("failed to reassign email account")
|
|
continue
|
|
}
|
|
reassigned++
|
|
|
|
account, aerr := s.EmailRepository.GetByID(ctx, accountID)
|
|
if aerr != nil || account == nil {
|
|
continue
|
|
}
|
|
if account.OrganizationID != nil {
|
|
affectedOrgs[*account.OrganizationID]++
|
|
}
|
|
|
|
// The backend's worker reconciler loads the account onto its new
|
|
// worker with the full payload (decrypted credentials, cursors,
|
|
// sync policy). Publishing a bare ADD_EMAIL from here only made
|
|
// the worker reject it and log an error per mailbox.
|
|
}
|
|
|
|
if reassigned > 0 {
|
|
log.Info().
|
|
Str("dead_worker", w.ID.String()).
|
|
Str("replacement", replacement.ID.String()).
|
|
Int("reassigned", reassigned).
|
|
Msg("email accounts reassigned from dead worker")
|
|
|
|
// Record in admin_audit_log so the dashboard's audit viewer
|
|
// shows when and where the fleet auto-reassigned. uuid.Nil for
|
|
// admin_user_id signals "system action".
|
|
if s.AdminRepo != nil {
|
|
_ = s.AdminRepo.CreateAuditLog(ctx, &models.AdminAuditLog{
|
|
ID: uuid.New(),
|
|
AdminUserID: uuid.Nil,
|
|
Action: "auto_reassign",
|
|
TargetType: "worker",
|
|
TargetID: w.ID,
|
|
Details: map[string]any{
|
|
"replacement": replacement.ID.String(),
|
|
"accounts_reassigned": reassigned,
|
|
"reason": "heartbeat_expired",
|
|
},
|
|
IPAddress: "",
|
|
UserAgent: "system",
|
|
CreatedAt: time.Now(),
|
|
})
|
|
}
|
|
|
|
s.notifyWorkerDown(ctx, w.ID, affectedOrgs, true)
|
|
}
|
|
}
|
|
}
|
|
|
|
// accountOrgs resolves which orgs own the given accounts (org -> count).
|
|
func (s *JobsService) accountOrgs(ctx context.Context, accountIDs []uuid.UUID) map[uuid.UUID]int {
|
|
orgs := map[uuid.UUID]int{}
|
|
for _, id := range accountIDs {
|
|
if account, err := s.EmailRepository.GetByID(ctx, id); err == nil && account != nil && account.OrganizationID != nil {
|
|
orgs[*account.OrganizationID]++
|
|
}
|
|
}
|
|
return orgs
|
|
}
|
|
|
|
func (s *JobsService) findHealthyWorker(ctx context.Context, deadWorker models.Worker) (*models.Worker, error) {
|
|
// Get workers of the same tier that are alive
|
|
freeTier := deadWorker.FreeTier
|
|
workers, err := s.WorkerRepo.GetSharedWorkersByTier(ctx, freeTier)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, w := range workers {
|
|
if w.ID == deadWorker.ID {
|
|
continue
|
|
}
|
|
// Check if this worker is alive
|
|
key := fmt.Sprintf("worker:heartbeat:%s", w.ID.String())
|
|
exists, err := s.Cache.Exists(ctx, key).Result()
|
|
if err != nil || exists == 0 {
|
|
continue
|
|
}
|
|
return &w, nil
|
|
}
|
|
|
|
return nil, nil
|
|
}
|