Skip to content

Commit c10a58d

Browse files
committed
Rebuild the pooled client when its health checks keep failing
The only eviction keyed on mongo.ErrClientDisconnected, which the driver returns solely after an explicit Disconnect, and nothing in the pooled path ever disconnects the cached client. So a client that could not recover was kept for the life of the process: rotate a client certificate or a CA in place and every scrape reported mongodb_up 0 until a restart, where the old per-scrape default had rebuilt and recovered on its own. Count consecutive failed health checks instead and drop the client after three, whatever the error was. One failure stays transient, since the driver reconnects its own pool and a server mid-election recovers without help. Alongside it, in the same path: - An RWMutex, an accessor and a compare-and-nil become an atomic.Pointer holding the client and its failure count. - A connectTimeoutMS of 0 in the URI is the driver's "no dial timeout", not an absent value, so the flag no longer replaces it and server selection is no longer narrowed as if the URI had said nothing. A serverSelectionTimeoutMS of 0 would leave the driver selecting forever, so it falls back to 30s and the driver is told the same number the caller waits for. - The connect budget is the sum of the two timeouts, not the larger. The driver spends them in sequence, so the maximum could expire while every phase was still inside its own limit. - MaxConnIdleTime defaults to five minutes. The driver never prunes idle connections and hands them out unchecked, so a socket dropped by a middlebox between scrapes failed the next scrape. - buildClient logs its own failure. Every scrape waiting on the flight may have given up on its deadline, leaving the cause unlogged. - buildClient recovers a panic and returns it. singleflight re-raises one on a bare goroutine, which would take the process down. - The startup connect runs only with the pool on. Without it the connect served nobody: it was made only to be disconnected. The --mongodb.connect-timeout-ms help text now says the URI wins and that 0 means 30s rather than no timeout.
1 parent 2137a80 commit c10a58d

3 files changed

Lines changed: 285 additions & 172 deletions

File tree

exporter/exporter.go

Lines changed: 77 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
4142
type 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.
5562
type Opts struct {
5663
CompatibleMode bool
@@ -97,22 +104,24 @@ type Opts struct {
97104
var (
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

102110
const (
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.
451425
func 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.
540517
func 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

586554
func 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.
597564
func connectWith(ctx context.Context, clientOpts *options.ClientOptions) (*mongo.Client, error) {
598565
client, err := mongo.Connect(ctx, clientOpts)
599566
if err != nil {

0 commit comments

Comments
 (0)