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  }