Probe hosts for health instead of risking the shared connection
The health check shares each host's libvirt connection with the reconciles, so it must not be able to take that connection away from them. - A timed-out check no longer drops the link. The check goes through the new hostPool.probe, which abandons its RPCs when the context ends and leaves the connection alone, so an image upload or a Create running on the same link is not cut off. A link that really died is closed by the keepalive. One abandoned probe per host may stay waiting; until it returns, the next check reports Timeout at once (errProbeBusy). - Waiting for a host's connection slot now ends with the caller's context. hostEntry used a mutex, so a check could wait out a reconcile redialing a dead host — minutes — past its own timeout. The slot is a one-slot channel and acquire bounds only the wait, never the uncontended take. - --provider-check-timeout defaults to 0 and derives min(15s, interval). The flat 15s default contradicted the validation that the timeout may not exceed the interval, so --provider-check-interval=12s alone failed to start. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
libvirt "github.com/digitalocean/go-libvirt"
|
||||
@@ -102,9 +103,16 @@ type hostPool struct {
|
||||
entries map[string]*hostEntry
|
||||
}
|
||||
|
||||
// hostEntry is one host's slot in the pool. lock is a one-slot semaphore
|
||||
// rather than a mutex so that waiting for it can end with the caller's
|
||||
// context: whoever holds it may be dialing a dead host for as long as the
|
||||
// dial takes.
|
||||
type hostEntry struct {
|
||||
mu sync.Mutex
|
||||
lock chan struct{}
|
||||
client *hostClient
|
||||
// probing is set while a probe's fn runs, including one whose caller
|
||||
// has already given up on it.
|
||||
probing atomic.Bool
|
||||
}
|
||||
|
||||
func newHostPool(dial hostDialer) *hostPool {
|
||||
@@ -116,16 +124,37 @@ func (p *hostPool) entry(name string) *hostEntry {
|
||||
defer p.mu.Unlock()
|
||||
e, ok := p.entries[name]
|
||||
if !ok {
|
||||
e = &hostEntry{}
|
||||
e = &hostEntry{lock: make(chan struct{}, 1)}
|
||||
p.entries[name] = e
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// acquire takes e.lock. Only waiting is bounded by ctx: a free slot is
|
||||
// taken even when ctx has already ended, so an uncontended caller behaves
|
||||
// as it did with a mutex, and a contended one gives up with ctx's error.
|
||||
func (e *hostEntry) acquire(ctx context.Context) error {
|
||||
select {
|
||||
case e.lock <- struct{}{}:
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case e.lock <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (e *hostEntry) release() { <-e.lock }
|
||||
|
||||
func (p *hostPool) get(ctx context.Context, log logr.Logger, host provider.KVMHost) (*hostClient, error) {
|
||||
e := p.entry(host.Name)
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if err := e.acquire(ctx); err != nil {
|
||||
return nil, fmt.Errorf("waiting for the connection to host %s (%s): %w", host.Name, host.Address, err)
|
||||
}
|
||||
defer e.release()
|
||||
if e.client != nil {
|
||||
if e.client.alive() {
|
||||
return e.client, nil
|
||||
@@ -149,8 +178,8 @@ func (p *hostPool) get(ctx context.Context, log logr.Logger, host provider.KVMHo
|
||||
// connection that was already replaced by a redial is left alone.
|
||||
func (p *hostPool) drop(host string, c *hostClient) {
|
||||
e := p.entry(host)
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.lock <- struct{}{}
|
||||
defer e.release()
|
||||
if e.client == c {
|
||||
e.client = nil
|
||||
}
|
||||
@@ -173,19 +202,61 @@ func (p *hostPool) call(ctx context.Context, log logr.Logger, host provider.KVMH
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
api := c.api
|
||||
if log.V(1).Enabled() {
|
||||
api = &tracingAPI{inner: c.api, log: log}
|
||||
}
|
||||
err = awaitOrCancel(ctx, func() error { return fn(api) }, func() {
|
||||
err = awaitOrCancel(ctx, func() error { return fn(traced(c, log)) }, func() {
|
||||
log.V(1).Info("libvirt connection dropped", "reason", "context cancelled", "error", ctx.Err().Error())
|
||||
p.drop(host.Name, c)
|
||||
})
|
||||
p.dropOnTransportError(ctx, log, host, c, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// errProbeBusy refuses a probe while the previous one on the same host has
|
||||
// not come back yet.
|
||||
var errProbeBusy = errors.New("the previous check of this host is still waiting for libvirt to answer")
|
||||
|
||||
// probe is call for the health check, which must never cost the reconciles
|
||||
// their connection: when ctx ends first, fn is abandoned and the connection
|
||||
// left alone. A link that is really dead is closed by the keepalive, which
|
||||
// ends fn too. At most one probe per host runs at a time, an abandoned one
|
||||
// included, so a daemon that stopped answering cannot pile up goroutines;
|
||||
// until it answers, further probes get errProbeBusy.
|
||||
func (p *hostPool) probe(ctx context.Context, log logr.Logger, host provider.KVMHost, fn func(api libvirtAPI) error) error {
|
||||
log = log.WithName("libvirt")
|
||||
e := p.entry(host.Name)
|
||||
if !e.probing.CompareAndSwap(false, true) {
|
||||
return errProbeBusy
|
||||
}
|
||||
c, err := p.get(ctx, log, host)
|
||||
if err != nil {
|
||||
e.probing.Store(false)
|
||||
return err
|
||||
}
|
||||
err = awaitOrCancel(ctx, func() error {
|
||||
defer e.probing.Store(false)
|
||||
return fn(traced(c, log))
|
||||
}, func() {
|
||||
log.V(1).Info("libvirt probe abandoned, connection kept", "error", ctx.Err().Error())
|
||||
})
|
||||
p.dropOnTransportError(ctx, log, host, c, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// traced returns c's API, logging every RPC when log has V(1) enabled.
|
||||
func traced(c *hostClient, log logr.Logger) libvirtAPI {
|
||||
if log.V(1).Enabled() {
|
||||
return &tracingAPI{inner: c.api, log: log}
|
||||
}
|
||||
return c.api
|
||||
}
|
||||
|
||||
// dropOnTransportError drops c when err is evidence against the link:
|
||||
// neither a libvirt error, nor a verdict already classified, nor the
|
||||
// caller's own context ending.
|
||||
func (p *hostPool) dropOnTransportError(ctx context.Context, log logr.Logger, host provider.KVMHost, c *hostClient, err error) {
|
||||
if err != nil && !isLibvirtError(err) && !isClassified(err) && !errors.Is(err, ctx.Err()) {
|
||||
log.V(1).Info("libvirt connection dropped", "reason", "transport error", "error", err.Error())
|
||||
p.drop(host.Name, c)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// awaitOrCancel runs fn in the background and returns its result, unless ctx
|
||||
|
||||
Reference in New Issue
Block a user