perf: Serialize concurrent certificate generation (#210)

* perf: Serialize concurrent certificate generation

* fix: Code review fixes

* fix: Code review fixes
This commit is contained in:
Abhisek Datta
2026-04-08 23:30:46 +05:30
committed by GitHub
parent a128a60982
commit e72ff6aeaf
4 changed files with 121 additions and 13 deletions
+86
View File
@@ -3,13 +3,16 @@ package certmanager
import (
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateCA(t *testing.T) {
@@ -202,6 +205,45 @@ func TestNewCertificateManagerWithNilCA(t *testing.T) {
}
}
func TestConcurrentCertGeneration(t *testing.T) {
config := DefaultCertManagerConfig()
ca, err := GenerateCA(config)
require.NoError(t, err)
cm, err := NewCertificateManagerWithCA(ca, config)
require.NoError(t, err)
hostname := "registry.npmjs.org"
const goroutines = 50
certs := make([]*Certificate, goroutines)
errs := make([]error, goroutines)
var wg sync.WaitGroup
wg.Add(goroutines)
// Launch many goroutines requesting the same hostname concurrently.
// Singleflight should ensure only one RSA key generation happens.
for i := 0; i < goroutines; i++ {
go func(idx int) {
defer wg.Done()
certs[idx], errs[idx] = cm.GenerateCertForHost(hostname)
}(i)
}
wg.Wait()
for i := 0; i < goroutines; i++ {
require.NoError(t, errs[i], "goroutine %d should not error", i)
require.NotNil(t, certs[i], "goroutine %d should get a certificate", i)
}
// All goroutines should receive the same cached certificate.
for i := 1; i < goroutines; i++ {
assert.Equal(t, certs[0].X509Cert.SerialNumber, certs[i].X509Cert.SerialNumber,
"goroutine %d should get the same certificate as goroutine 0", i)
}
}
func BenchmarkGenerateCA(b *testing.B) {
config := DefaultCertManagerConfig()
@@ -227,6 +269,50 @@ func BenchmarkGenerateHostCert(b *testing.B) {
}
}
func BenchmarkGenerateHostCertUncached(b *testing.B) {
config := DefaultCertManagerConfig()
ca, err := GenerateCA(config)
require.NoError(b, err, "Failed to generate CA")
cm, err := NewCertificateManagerWithCA(ca, config)
require.NoError(b, err, "Failed to create certificate manager")
b.ResetTimer()
for i := 0; i < b.N; i++ {
hostname := fmt.Sprintf("host-%d.example.com", i)
_, err := cm.GenerateCertForHost(hostname)
assert.NoError(b, err, "Failed to generate host certificate")
}
}
func BenchmarkGetTLSConfig(b *testing.B) {
config := DefaultCertManagerConfig()
ca, err := GenerateCA(config)
require.NoError(b, err, "Failed to generate CA")
cm, err := NewCertificateManagerWithCA(ca, config)
require.NoError(b, err, "Failed to create certificate manager")
b.Run("Uncached", func(b *testing.B) {
for i := 0; i < b.N; i++ {
hostname := fmt.Sprintf("host-%d.example.com", i)
_, err := cm.GetTLSConfig(hostname)
assert.NoError(b, err, "Failed to get TLS config")
}
})
b.Run("Cached", func(b *testing.B) {
_, err := cm.GetTLSConfig("cached.example.com")
assert.NoError(b, err)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := cm.GetTLSConfig("cached.example.com")
assert.NoError(b, err, "Failed to get TLS config")
}
})
}
func BenchmarkCacheOperations(b *testing.B) {
cache := NewInMemoryCache()
cert := &Certificate{
+32 -13
View File
@@ -15,13 +15,15 @@ import (
"time"
"github.com/safedep/dry/log"
"golang.org/x/sync/singleflight"
)
const (
maxSystemCABundleBytes int64 = 10 * 1024 * 1024
goosDarwin = "darwin"
goosLinux = "linux"
goosWindows = "windows"
maxSystemCABundleBytes int64 = 10 * 1024 * 1024
certExpiryThreshold time.Duration = 1 * time.Hour
goosDarwin = "darwin"
goosLinux = "linux"
goosWindows = "windows"
)
// certManager implements the CertificateManager interface
@@ -29,6 +31,7 @@ type certManager struct {
ca *Certificate
cache CertificateCache
config CertManagerConfig
group singleflight.Group
}
// NewCertificateManagerWithCA creates a new certificate manager with an existing CA certificate
@@ -51,7 +54,7 @@ func NewCertificateManagerWithCA(ca *Certificate, config CertManagerConfig) (Cer
ca = parsedCA
}
if ca.IsExpired(1 * time.Hour) {
if ca.IsExpired(certExpiryThreshold) {
return nil, fmt.Errorf("CA certificate is expired")
}
@@ -67,23 +70,39 @@ func (cm *certManager) GetCA() (*Certificate, error) {
return cm.ca, nil
}
// GenerateCertForHost creates a certificate for the given hostname
// Uses caching to avoid regeneration
// GenerateCertForHost creates a certificate for the given hostname.
// Uses caching and singleflight to ensure only one goroutine generates
// a certificate for a given hostname at a time, preventing CPU starvation
// when many concurrent CONNECT requests arrive for the same host.
func (cm *certManager) GenerateCertForHost(hostname string) (*Certificate, error) {
if cached, found := cm.cache.Get(hostname); found {
if !cached.IsExpired(1 * time.Hour) {
if !cached.IsExpired(certExpiryThreshold) {
return cached, nil
}
}
cert, err := cm.generateHostCert(hostname)
result, err, _ := cm.group.Do(hostname, func() (interface{}, error) {
// Re-check cache: another goroutine in a previous singleflight
// group may have populated it while we were waiting.
if cached, found := cm.cache.Get(hostname); found {
if !cached.IsExpired(certExpiryThreshold) {
return cached, nil
}
}
cert, err := cm.generateHostCert(hostname)
if err != nil {
return nil, fmt.Errorf("failed to generate certificate for %s: %w", hostname, err)
}
cm.cache.Set(hostname, cert)
return cert, nil
})
if err != nil {
return nil, fmt.Errorf("failed to generate certificate for %s: %w", hostname, err)
return nil, err
}
cm.cache.Set(hostname, cert)
return cert, nil
return result.(*Certificate), nil
}
// GetTLSConfig returns a tls.Config for the given hostname