github.com/letsencrypt/boulder@v0.20251208.0/email/exporter.go (about) 1 package email 2 3 import ( 4 "context" 5 "errors" 6 "sync" 7 8 "github.com/prometheus/client_golang/prometheus" 9 "github.com/prometheus/client_golang/prometheus/promauto" 10 "golang.org/x/time/rate" 11 "google.golang.org/protobuf/types/known/emptypb" 12 13 "github.com/letsencrypt/boulder/core" 14 emailpb "github.com/letsencrypt/boulder/email/proto" 15 berrors "github.com/letsencrypt/boulder/errors" 16 blog "github.com/letsencrypt/boulder/log" 17 ) 18 19 // contactsQueueCap limits the queue size to prevent unbounded growth. This 20 // value is adjustable as needed. Each RFC 5321 email address, encoded in UTF-8, 21 // is at most 320 bytes. Storing 100,000 emails requires ~34.4 MB of memory. 22 const contactsQueueCap = 100000 23 24 var ErrQueueFull = errors.New("email-exporter queue is full") 25 26 // ExporterImpl implements the gRPC server and processes email exports. 27 type ExporterImpl struct { 28 emailpb.UnsafeExporterServer 29 30 sync.Mutex 31 drainWG sync.WaitGroup 32 // wake is used to signal workers when new emails are enqueued in toSend. 33 // The sync.Cond docs note that "For many simple use cases, users will be 34 // better off using channels." However, channels enforce FIFO ordering, 35 // while this implementation uses a LIFO queue. Making channels behave as 36 // LIFO would require extra complexity. Using a slice and broadcasting is 37 // simpler and achieves exactly what we need. 38 wake *sync.Cond 39 toSend []string 40 41 maxConcurrentRequests int 42 limiter *rate.Limiter 43 client SalesforceClient 44 emailCache *EmailCache 45 emailsHandledCounter prometheus.Counter 46 pardotErrorCounter prometheus.Counter 47 caseErrorCounter prometheus.Counter 48 log blog.Logger 49 } 50 51 var _ emailpb.ExporterServer = (*ExporterImpl)(nil) 52 53 // NewExporterImpl initializes an ExporterImpl with the given client and 54 // configuration. Both perDayLimit and maxConcurrentRequests should be 55 // distributed proportionally among instances based on their share of the daily 56 // request cap. For example, if the total daily limit is 50,000 and one instance 57 // is assigned 40% (20,000 requests), it should also receive 40% of the max 58 // concurrent requests (e.g., 2 out of 5). For more details, see: 59 // https://developer.salesforce.com/docs/marketing/pardot/guide/overview.html?q=rate%20limits 60 func NewExporterImpl(client SalesforceClient, cache *EmailCache, perDayLimit float64, maxConcurrentRequests int, stats prometheus.Registerer, logger blog.Logger) *ExporterImpl { 61 limiter := rate.NewLimiter(rate.Limit(perDayLimit/86400.0), maxConcurrentRequests) 62 63 emailsHandledCounter := promauto.With(stats).NewCounter(prometheus.CounterOpts{ 64 Name: "email_exporter_emails_handled", 65 Help: "Total number of emails handled by the email exporter", 66 }) 67 68 pardotErrorCounter := promauto.With(stats).NewCounter(prometheus.CounterOpts{ 69 Name: "email_exporter_errors", 70 Help: "Total number of Pardot API errors encountered by the email exporter", 71 }) 72 73 caseErrorCounter := promauto.With(stats).NewCounter(prometheus.CounterOpts{ 74 Name: "email_exporter_case_errors", 75 Help: "Total number of errors encountered when sending Cases to the Salesforce REST API", 76 }) 77 78 impl := &ExporterImpl{ 79 maxConcurrentRequests: maxConcurrentRequests, 80 limiter: limiter, 81 toSend: make([]string, 0, contactsQueueCap), 82 client: client, 83 emailCache: cache, 84 emailsHandledCounter: emailsHandledCounter, 85 pardotErrorCounter: pardotErrorCounter, 86 caseErrorCounter: caseErrorCounter, 87 log: logger, 88 } 89 impl.wake = sync.NewCond(&impl.Mutex) 90 91 // This metric doesn't need to be part of impl, since it computes itself 92 // each time it is scraped. 93 promauto.With(stats).NewGaugeFunc(prometheus.GaugeOpts{ 94 Name: "email_exporter_queue_length", 95 Help: "Current length of the email export queue", 96 }, func() float64 { 97 impl.Lock() 98 defer impl.Unlock() 99 return float64(len(impl.toSend)) 100 }) 101 102 return impl 103 } 104 105 // SendContacts enqueues the provided email addresses. If the queue cannot 106 // accommodate the new emails, an ErrQueueFull is returned. 107 func (impl *ExporterImpl) SendContacts(ctx context.Context, req *emailpb.SendContactsRequest) (*emptypb.Empty, error) { 108 if core.IsAnyNilOrZero(req, req.Emails) { 109 return nil, berrors.InternalServerError("Incomplete gRPC request message") 110 } 111 112 impl.Lock() 113 defer impl.Unlock() 114 115 spotsLeft := contactsQueueCap - len(impl.toSend) 116 if spotsLeft < len(req.Emails) { 117 return nil, ErrQueueFull 118 } 119 impl.toSend = append(impl.toSend, req.Emails...) 120 // Wake waiting workers to process the new emails. 121 impl.wake.Broadcast() 122 123 return &emptypb.Empty{}, nil 124 } 125 126 // SendCase immediately submits a new Case to the Salesforce REST API using the 127 // provided details. Any retries are handled internally by the SalesforceClient. 128 // The following fields are required: Origin, Subject, ContactEmail. 129 func (impl *ExporterImpl) SendCase(ctx context.Context, req *emailpb.SendCaseRequest) (*emptypb.Empty, error) { 130 if core.IsAnyNilOrZero(req, req.Origin, req.Subject, req.ContactEmail) { 131 return nil, berrors.InternalServerError("incomplete gRPC request message") 132 } 133 134 err := impl.client.SendCase(Case{ 135 Origin: req.Origin, 136 Subject: req.Subject, 137 Description: req.Description, 138 ContactEmail: req.ContactEmail, 139 Organization: req.Organization, 140 AccountId: req.AccountId, 141 RateLimitName: req.RateLimitName, 142 RateLimitTier: req.RateLimitTier, 143 UseCase: req.UseCase, 144 }) 145 if err != nil { 146 impl.caseErrorCounter.Inc() 147 return nil, berrors.InternalServerError("sending Case to the Salesforce REST API: %s", err) 148 } 149 150 return &emptypb.Empty{}, nil 151 } 152 153 // Start begins asynchronous processing of the email queue. When the parent 154 // daemonCtx is cancelled the queue will be drained and the workers will exit. 155 func (impl *ExporterImpl) Start(daemonCtx context.Context) { 156 go func() { 157 <-daemonCtx.Done() 158 // Wake waiting workers to exit. 159 impl.wake.Broadcast() 160 }() 161 162 worker := func() { 163 defer impl.drainWG.Done() 164 for { 165 impl.Lock() 166 167 for len(impl.toSend) == 0 && daemonCtx.Err() == nil { 168 // Wait for the queue to be updated or the daemon to exit. 169 impl.wake.Wait() 170 } 171 172 if len(impl.toSend) == 0 && daemonCtx.Err() != nil { 173 // No more emails to process, exit. 174 impl.Unlock() 175 return 176 } 177 178 // Dequeue and dispatch an email. 179 last := len(impl.toSend) - 1 180 email := impl.toSend[last] 181 impl.toSend = impl.toSend[:last] 182 impl.Unlock() 183 184 if !impl.emailCache.StoreIfAbsent(email) { 185 // Another worker has already processed this email. 186 continue 187 } 188 189 err := impl.limiter.Wait(daemonCtx) 190 if err != nil && !errors.Is(err, context.Canceled) { 191 impl.log.Errf("Unexpected limiter.Wait() error: %s", err) 192 continue 193 } 194 195 err = impl.client.SendContact(email) 196 if err != nil { 197 impl.emailCache.Remove(email) 198 impl.pardotErrorCounter.Inc() 199 impl.log.Errf("Sending Contact to Pardot: %s", err) 200 } else { 201 impl.emailsHandledCounter.Inc() 202 } 203 } 204 } 205 206 for range impl.maxConcurrentRequests { 207 impl.drainWG.Add(1) 208 go worker() 209 } 210 } 211 212 // Drain blocks until all workers have finished processing the email queue. 213 func (impl *ExporterImpl) Drain() { 214 impl.drainWG.Wait() 215 }