@@ -25,6 +25,7 @@ import (
2525 _ "net/http/pprof"
2626 "strconv"
2727 "sync"
28+ "sync/atomic"
2829 "time"
2930
3031 "github.com/prometheus/client_golang/prometheus"
@@ -39,10 +40,8 @@ import (
3940
4041// Exporter holds Exporter methods and attributes.
4142type Exporter struct {
42- client * mongo.Client
43- // clientMu guards the client pointer only. It is never held across a command, so
44- // concurrent scrapes of this target do not wait for each other.
45- clientMu sync.RWMutex
43+ // client is the pooled client, nil until a connect has succeeded.
44+ client atomic.Pointer [pooledClient ]
4645 // clientGroup collapses concurrent attempts to build client into one connect.
4746 clientGroup singleflight.Group
4847 logger * slog.Logger
@@ -51,6 +50,14 @@ type Exporter struct {
5150 totalCollectionsCount int
5251}
5352
53+ // pooledClient is the cached client together with how many scrapes in a row have failed its
54+ // health check.
55+ type pooledClient struct {
56+ * mongo.Client
57+
58+ pingFailures atomic.Int32
59+ }
60+
5461// Opts holds new exporter options.
5562type Opts struct {
5663 CompatibleMode bool
@@ -97,22 +104,24 @@ type Opts struct {
97104var (
98105 errCannotHandleType = fmt .Errorf ("don't know how to handle data type" )
99106 errUnexpectedDataType = fmt .Errorf ("unexpected data type" )
107+ errConnectPanicked = errors .New ("cannot connect to MongoDB: connect panicked" )
100108)
101109
102110const (
103111 defaultCacheSize = 1000
104112
105- // defaultConnectTimeout bounds a connect when neither the URI nor Opts.ConnectTimeoutMS
106- // gives it a budget. The driver would otherwise be handed a server-selection timeout of
107- // 0, which it reads as no timeout at all, and an unanswering server would block a
108- // connect for the lifetime of the process.
113+ // defaultConnectTimeout stands in for a connect or server-selection timeout of zero, which
114+ // the driver reads as no timeout at all. It equals the driver's own server-selection default.
109115 defaultConnectTimeout = 30 * time .Second
110116
111- // driverServerSelectionTimeout is what the driver applies when ServerSelectionTimeout is
112- // left unset -- defaultServerSelectionTimeout in x/mongo/driver/topology. Mirrored here
113- // so the connect budget can cover a selection window the exporter deliberately leaves
114- // the driver to choose.
115- driverServerSelectionTimeout = 30 * time .Second
117+ // defaultMaxConnIdleTime closes pooled connections that sat idle this long. The driver never
118+ // prunes idle connections on its own and reuses them unchecked, so a socket a middlebox
119+ // dropped during a gap between scrapes would otherwise fail the next scrape.
120+ defaultMaxConnIdleTime = 5 * time .Minute
121+
122+ // maxConsecutivePingFailures is how many scrapes in a row may fail the pooled client's
123+ // health check before it is dropped and built anew.
124+ maxConsecutivePingFailures = 3
116125)
117126
118127// New connects to the database and returns a new Exporter instance.
@@ -132,30 +141,13 @@ func New(opts *Opts) *Exporter {
132141 lock : & sync.Mutex {},
133142 totalCollectionsCount : - 1 , // Not calculated yet. waiting the db connection.
134143 }
135- // Try initial connect. Connection will be retried with every scrape.
136- // getClient bounds the connect itself, so no deadline is imposed here.
137- go func () {
138- ctx := context .Background ()
139-
140- client , err := exp .getClient (ctx )
141- if err != nil {
142- exp .logger .Error ("Cannot connect to MongoDB" , "error" , err )
143-
144- return
145- }
146-
147- // With the global pool this client is the one every later scrape reuses. Otherwise
148- // nothing owns it, since each scrape builds its own, so leaving it connected would
149- // leak a topology, its monitoring goroutines and a connection.
150- if exp .opts .GlobalConnPool {
151- return
152- }
153-
154- err = client .Disconnect (ctx )
155- if err != nil {
156- exp .logger .Error ("Cannot disconnect client" , "error" , err )
157- }
158- }()
144+ // Warm the pool so the first scrape does not pay for the connect. getClient bounds the
145+ // attempt and buildClient logs a failure, which every scrape retries anyway.
146+ if opts .GlobalConnPool {
147+ go func () {
148+ _ , _ = exp .getClient (context .Background ())
149+ }()
150+ }
159151
160152 return exp
161153}
@@ -310,28 +302,21 @@ func (e *Exporter) getClient(ctx context.Context) (*mongo.Client, error) {
310302 return connect (ctx , e .opts )
311303 }
312304
313- client := e .cachedClient ()
314-
315- // Health-check outside the lock. Holding it across the Ping would make concurrent
316- // scrapes of this target queue behind each other, letting one slow scrape push the
317- // next past its budget.
318- if client != nil {
319- err := client .Ping (ctx , nil )
305+ if pooled := e .client .Load (); pooled != nil {
306+ err := pooled .Ping (ctx , nil )
320307 if err == nil {
321- return client , nil
308+ pooled .pingFailures .Store (0 )
309+
310+ return pooled .Client , nil
322311 }
323312
324- // A disconnected client never recovers, so forget it and let the next scrape build a
325- // new one. Every other error is transient -- an unreachable server, a scrape that ran
326- // out of time -- and the driver reconnects the pool on its own; tearing it down would
327- // only add churn while MongoDB is already struggling.
328- if errors .Is (err , mongo .ErrClientDisconnected ) {
329- e .logger .Warn ("Dropping disconnected MongoDB client, reconnecting on next scrape" )
330- e .clientMu .Lock ()
331- if e .client == client {
332- e .client = nil
333- }
334- e .clientMu .Unlock ()
313+ // One failed health check is transient -- an unreachable server, a scrape out of time --
314+ // and the driver reconnects its pool on its own. A client that keeps failing is dropped
315+ // so the next scrape builds one from scratch: that is the only way to pick up what the
316+ // driver reads once, such as rotated TLS material.
317+ if pooled .pingFailures .Add (1 ) >= maxConsecutivePingFailures && e .client .CompareAndSwap (pooled , nil ) {
318+ e .logger .Warn ("Dropping MongoDB client after repeated failed health checks, reconnecting on next scrape" , "error" , err )
319+ _ = pooled .Disconnect (ctx )
335320 }
336321
337322 return nil , fmt .Errorf ("cannot connect to MongoDB: %w" , err )
@@ -436,17 +421,6 @@ func (e *Exporter) Handler() http.Handler {
436421 })
437422}
438423
439- // cachedClient returns the pooled client, or nil if none has been built yet. The connect
440- // that fills the cache runs detached from the scrape that started it, so a caller that
441- // gave up on its deadline has no ordering against that write. This is the only safe way
442- // to read the pointer.
443- func (e * Exporter ) cachedClient () * mongo.Client {
444- e .clientMu .RLock ()
445- defer e .clientMu .RUnlock ()
446-
447- return e .client
448- }
449-
450424// GetRequestOpts makes exporter.Opts structure from request filters and default options.
451425func GetRequestOpts (filters []string , defaultOpts * Opts ) Opts {
452426 requestOpts := Opts {}
@@ -489,24 +463,25 @@ func GetRequestOpts(filters []string, defaultOpts *Opts) Opts {
489463
490464// buildClient fills the client cache and returns what is in it. It runs as a singleflight
491465// flight, so at most one of these is in progress at a time.
492- func (e * Exporter ) buildClient () (any , error ) {
466+ func (e * Exporter ) buildClient () (_ any , err error ) {
467+ // singleflight re-raises a flight's panic on a goroutine of its own, where nothing recovers
468+ // it, so a connect that panicked would take the process down rather than fail one scrape.
469+ defer func () {
470+ if r := recover (); r != nil {
471+ err = fmt .Errorf ("%w: %v" , errConnectPanicked , r )
472+ }
473+ }()
474+
493475 // singleflight retires its key once a flight returns, so a scrape that read an empty
494476 // cache and arrives after an earlier flight finished starts a new flight rather than
495- // joining the old one. Connecting again would displace a live client that nothing ever
496- // disconnects, leaving its topology, monitors and heartbeat connections running for the
497- // life of the process.
498- if cached := e .cachedClient (); cached != nil {
499- return cached , nil
477+ // joining the old one. It must not displace a live client, which nothing ever disconnects.
478+ if pooled := e .client .Load (); pooled != nil {
479+ return pooled .Client , nil
500480 }
501481
502482 // Resolved here rather than on the caller's goroutine: for mongodb+srv:// this performs
503- // SRV and TXT lookups through net.LookupSRV, which takes no context, so on the caller's
504- // goroutine a slow resolver would block the scrape past the very budget getClient's
505- // select exists to keep. It cannot be cancelled either way, but off the scrape's
506- // goroutine it can only delay the pool, not the response.
507- //
508- // The budget it returns covers whichever driver timeout runs longest, so the deadline
509- // below cannot abort an attempt that was still within a limit the operator set.
483+ // SRV and TXT lookups through net.LookupSRV, which takes no context, so a slow resolver
484+ // can only delay the pool, not a scrape's response.
510485 clientOpts , connectTimeout , err := clientOptionsFor (e .opts )
511486 if err != nil {
512487 return nil , err
@@ -517,26 +492,28 @@ func (e *Exporter) buildClient() (any, error) {
517492
518493 newClient , err := connectWith (connectCtx , clientOpts )
519494 if err != nil {
495+ // Every scrape waiting on this flight may have given up on its own deadline already, in
496+ // which case nobody else sees the cause.
497+ e .logger .Error ("MongoDB connect attempt failed" , "error" , err )
498+
520499 return nil , err
521500 }
522501
523- e .clientMu .Lock ()
524- e .client = newClient
525- e .clientMu .Unlock ()
502+ e .client .Store (& pooledClient {Client : newClient })
526503
527504 return newClient , nil
528505}
529506
530507// clientOptionsFor builds the driver options for opts and returns, alongside them, the
531- // budget one connect attempt gets. It is the single authority for that budget, so a caller
532- // that bounds the attempt with a context uses the same value the driver was given and can
533- // never cut a handshake short of what was asked for .
508+ // budget one connect attempt gets. The driver spends its connect and server-selection
509+ // timeouts in sequence, so the budget is their sum, and a caller that bounds the attempt
510+ // with it never cuts short a wait the operator configured .
534511//
535- // A connectTimeoutMS in the URI wins over --mongodb.connect-timeout-ms, being the more
536- // specific instruction; with neither usable the budget falls back to defaultConnectTimeout ,
537- // since the driver reads 0 as no timeout at all. serverSelectionTimeoutMS is left to the
538- // driver unless the URI or the flag gave a connect timeout to derive it from, so the budget
539- // accounts for the driver's default as well as the values actually set .
512+ // connectTimeoutMS in the URI wins over --mongodb.connect-timeout-ms; a zero there is the
513+ // driver's "no dial timeout" and is passed through. Server selection is taken from the URI ,
514+ // else derived from the flag's connect timeout, else the driver's default. A zero selection
515+ // timeout, or a zero flag, would mean no timeout at all and is replaced by
516+ // defaultConnectTimeout: a connect has to finish for the pool to ever fill .
540517func clientOptionsFor (opts * Opts ) (* options.ClientOptions , time.Duration , error ) {
541518 clientOpts , err := dsn_fix .ClientOptionsForDSN (opts .URI )
542519 if err != nil {
@@ -546,41 +523,32 @@ func clientOptionsFor(opts *Opts) (*options.ClientOptions, time.Duration, error)
546523 clientOpts .SetDirect (opts .DirectConnect )
547524 clientOpts .SetAppName ("mongodb_exporter" )
548525
549- // The connect timeout comes from the URI, else the flag, else the default. It may not be
550- // left at zero, which the driver reads as no timeout at all.
526+ if clientOpts .MaxConnIdleTime == nil {
527+ clientOpts .SetMaxConnIdleTime (defaultMaxConnIdleTime )
528+ }
529+
551530 connectTimeout := defaultConnectTimeout
552531 if opts .ConnectTimeoutMS > 0 {
553532 connectTimeout = time .Duration (opts .ConnectTimeoutMS ) * time .Millisecond
554533 }
555534
556- connectFromURI := clientOpts .ConnectTimeout != nil && * clientOpts . ConnectTimeout > 0
535+ connectFromURI := clientOpts .ConnectTimeout != nil
557536 if connectFromURI {
558537 connectTimeout = * clientOpts .ConnectTimeout
559538 } else {
560539 clientOpts .SetConnectTimeout (connectTimeout )
561540 }
562541
563- // Server selection is a separate limit: connectTimeoutMS bounds one socket connect,
564- // serverSelectionTimeoutMS bounds the selection loop around it, and that loop is what
565- // lets a scrape ride out an election instead of reporting mongodb_up 0. So it is
566- // derived from the connect timeout only when the URI said nothing about connecting
567- // either -- a URI carrying connectTimeoutMS alone keeps the driver's own default, as it
568- // did before this path was rewritten.
569- selectionTimeout := driverServerSelectionTimeout
542+ selectionTimeout := defaultConnectTimeout
570543 switch {
571544 case clientOpts .ServerSelectionTimeout != nil && * clientOpts .ServerSelectionTimeout > 0 :
572545 selectionTimeout = * clientOpts .ServerSelectionTimeout
573546 case ! connectFromURI :
574547 selectionTimeout = connectTimeout
575- clientOpts .SetServerSelectionTimeout (selectionTimeout )
576548 }
549+ clientOpts .SetServerSelectionTimeout (selectionTimeout )
577550
578- // The budget has to cover whichever of the two runs longest, including a selection
579- // window left for the driver to default: bounding the attempt by the connect side alone
580- // would expire the caller's deadline while selection was still legitimately running.
581- budget := max (connectTimeout , selectionTimeout )
582-
583- return clientOpts , budget , nil
551+ return clientOpts , connectTimeout + selectionTimeout , nil
584552}
585553
586554func connect (ctx context.Context , opts * Opts ) (* mongo.Client , error ) {
@@ -592,8 +560,7 @@ func connect(ctx context.Context, opts *Opts) (*mongo.Client, error) {
592560 return connectWith (ctx , clientOpts )
593561}
594562
595- // connectWith is connect for a caller that already holds resolved options, so the pooled
596- // path does not resolve the URI -- an SRV lookup among other things -- a second time.
563+ // connectWith is connect for a caller that already holds resolved options.
597564func connectWith (ctx context.Context , clientOpts * options.ClientOptions ) (* mongo.Client , error ) {
598565 client , err := mongo .Connect (ctx , clientOpts )
599566 if err != nil {
0 commit comments