Fix global rate limit ACME

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-07-22 17:28:35 +02:00
parent 0f7b639d2f
commit 0b577cda40
3 changed files with 388 additions and 239 deletions

View File

@@ -32,6 +32,7 @@ import (
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/crypto/cipher" "go.probo.inc/probo/pkg/crypto/cipher"
"go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/gid"
"golang.org/x/crypto/acme"
) )
const ( const (
@@ -56,16 +57,26 @@ const (
) )
type ( type (
provisionHandler struct { provisionCore struct {
pg *pg.Client pg *pg.Client
acmeService *ACMEService acmeService *ACMEService
encryptionKey cipher.EncryptionKey logger *log.Logger
tracer trace.Tracer
}
beginChallengeHandler struct {
provisionCore
cnameTarget string cnameTarget string
caaIssuerDomain string caaIssuerDomain string
resolverAddr string resolverAddr string
managedBaseDomain string managedBaseDomain string
logger *log.Logger }
tracer trace.Tracer
pollOrderHandler struct {
provisionCore
encryptionKey cipher.EncryptionKey
} }
provisioningOutcome struct { provisioningOutcome struct {
@@ -77,14 +88,14 @@ type (
) )
var ( var (
_ worker.Handler[coredata.Certificate] = (*provisionHandler)(nil) _ worker.Handler[coredata.Certificate] = (*beginChallengeHandler)(nil)
_ worker.StaleRecoverer = (*provisionHandler)(nil) _ worker.Handler[coredata.Certificate] = (*pollOrderHandler)(nil)
_ worker.StaleRecoverer = (*pollOrderHandler)(nil)
) )
func NewProvisionWorker( func NewBeginChallengeWorker(
pgClient *pg.Client, pgClient *pg.Client,
acmeService *ACMEService, acmeService *ACMEService,
encryptionKey cipher.EncryptionKey,
cnameTarget string, cnameTarget string,
caaIssuerDomain string, caaIssuerDomain string,
resolverAddr string, resolverAddr string,
@@ -92,16 +103,17 @@ func NewProvisionWorker(
logger *log.Logger, logger *log.Logger,
opts ...worker.Option, opts ...worker.Option,
) *worker.Worker[coredata.Certificate] { ) *worker.Worker[coredata.Certificate] {
h := &provisionHandler{ h := &beginChallengeHandler{
provisionCore: provisionCore{
pg: pgClient, pg: pgClient,
acmeService: acmeService, acmeService: acmeService,
encryptionKey: encryptionKey, logger: logger,
tracer: otel.Tracer(tracerName),
},
cnameTarget: cnameTarget, cnameTarget: cnameTarget,
caaIssuerDomain: caaIssuerDomain, caaIssuerDomain: caaIssuerDomain,
resolverAddr: resolverAddr, resolverAddr: resolverAddr,
managedBaseDomain: managedBaseDomain, managedBaseDomain: managedBaseDomain,
logger: logger,
tracer: otel.Tracer(tracerName),
} }
opts = append(opts, worker.WithMaxConcurrency(1)) opts = append(opts, worker.WithMaxConcurrency(1))
@@ -114,7 +126,34 @@ func NewProvisionWorker(
) )
} }
func (h *provisionHandler) Claim(ctx context.Context) (coredata.Certificate, error) { func NewPollOrderWorker(
pgClient *pg.Client,
acmeService *ACMEService,
encryptionKey cipher.EncryptionKey,
logger *log.Logger,
opts ...worker.Option,
) *worker.Worker[coredata.Certificate] {
h := &pollOrderHandler{
provisionCore: provisionCore{
pg: pgClient,
acmeService: acmeService,
logger: logger,
tracer: otel.Tracer(tracerName),
},
encryptionKey: encryptionKey,
}
opts = append(opts, worker.WithMaxConcurrency(1))
return worker.New(
"certificate-poll-worker",
h,
logger,
opts...,
)
}
func (h *beginChallengeHandler) Claim(ctx context.Context) (coredata.Certificate, error) {
if h.acmeService.InCooldown() { if h.acmeService.InCooldown() {
return coredata.Certificate{}, worker.ErrNoTask return coredata.Certificate{}, worker.ErrNoTask
} }
@@ -124,7 +163,7 @@ func (h *provisionHandler) Claim(ctx context.Context) (coredata.Certificate, err
if err := h.pg.WithTx( if err := h.pg.WithTx(
ctx, ctx,
func(ctx context.Context, tx pg.Tx) error { func(ctx context.Context, tx pg.Tx) error {
if err := certificate.LoadNextForProvisioningForUpdateSkipLocked(ctx, tx, provisioningPollLease); err != nil { if err := certificate.LoadNextForBeginChallengeForUpdateSkipLocked(ctx, tx); err != nil {
return err return err
} }
@@ -148,24 +187,10 @@ func (h *provisionHandler) Claim(ctx context.Context) (coredata.Certificate, err
return certificate, nil return certificate, nil
} }
func (h *provisionHandler) Process(ctx context.Context, certificate coredata.Certificate) error { func (h *beginChallengeHandler) Process(ctx context.Context, certificate coredata.Certificate) error {
ctx, cancel := context.WithTimeout(ctx, processTickTimeout) ctx, cancel := context.WithTimeout(ctx, processTickTimeout)
defer cancel() defer cancel()
switch certificate.Status {
case coredata.CertificateStatusPending, coredata.CertificateStatusRenewing:
return h.processBeginChallenge(ctx, certificate)
case coredata.CertificateStatusProvisioning:
return h.processPollOrder(ctx, certificate)
default:
return nil
}
}
func (h *provisionHandler) processBeginChallenge(
ctx context.Context,
certificate coredata.Certificate,
) error {
ctx, span := h.tracer.Start(ctx, "certmanager.create_order") ctx, span := h.tracer.Start(ctx, "certmanager.create_order")
defer span.End() defer span.End()
@@ -250,7 +275,7 @@ func (h *provisionHandler) processBeginChallenge(
// once any competing transaction commits; a genuinely deleted row is the only // once any competing transaction commits; a genuinely deleted row is the only
// no-op. It reports whether the row was persisted (false when the row is gone or // no-op. It reports whether the row was persisted (false when the row is gone or
// has moved on to another status). // has moved on to another status).
func (h *provisionHandler) persistChallenge( func (h *beginChallengeHandler) persistChallenge(
ctx context.Context, ctx context.Context,
certificate coredata.Certificate, certificate coredata.Certificate,
challenge *HTTPChallenge, challenge *HTTPChallenge,
@@ -299,7 +324,201 @@ func (h *provisionHandler) persistChallenge(
return persisted, nil return persisted, nil
} }
func (h *provisionHandler) processPollOrder( func (h *beginChallengeHandler) loadSkipDNSChecks(ctx context.Context, hostname string) (bool, error) {
var skip bool
err := h.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var err error
skip, err = h.skipsDNSChecks(ctx, conn, hostname)
return err
},
)
if err != nil {
return false, err
}
return skip, nil
}
func (h *beginChallengeHandler) checkDNSConfiguration(ctx context.Context, hostname string) error {
customerFQDN := hostname
if !strings.HasSuffix(customerFQDN, ".") {
customerFQDN = customerFQDN + "."
}
expectedFQDN := h.cnameTarget
if !strings.HasSuffix(expectedFQDN, ".") {
expectedFQDN = expectedFQDN + "."
}
msg := &dns.Msg{MsgHeader: dns.MsgHeader{ID: dns.ID(), RecursionDesired: true}}
msg.Question = []dns.RR{&dns.CNAME{Hdr: dns.Header{Name: customerFQDN, Class: dns.ClassINET}}}
dnsCtx, cancel := context.WithTimeout(ctx, dnsExchangeTimeout)
defer cancel()
client := dns.NewClient()
resp, _, err := client.Exchange(dnsCtx, msg, "udp", h.resolverAddr)
if err != nil {
return fmt.Errorf("cannot exchange dns message: %w", err)
}
if len(resp.Answer) == 0 {
return fmt.Errorf("no cname records found for domain %q", hostname)
}
if len(resp.Answer) > 1 {
return fmt.Errorf("multiple cname records found for domain %q", hostname)
}
resolvedRecord, ok := resp.Answer[0].(*dns.CNAME)
if !ok {
return fmt.Errorf("first answer is not a cname record for domain %q", hostname)
}
if !strings.EqualFold(expectedFQDN, resolvedRecord.Target) {
return fmt.Errorf(
"cname target mismatch: domain %q resolves to %q, expected %q",
hostname,
resolvedRecord.Target,
expectedFQDN,
)
}
return nil
}
func (h *beginChallengeHandler) checkCAARecords(ctx context.Context, hostname string) error {
fqdn := hostname
if !strings.HasSuffix(fqdn, ".") {
fqdn = fqdn + "."
}
msg := &dns.Msg{MsgHeader: dns.MsgHeader{ID: dns.ID(), RecursionDesired: true}}
msg.Question = []dns.RR{&dns.CAA{Hdr: dns.Header{Name: fqdn, Class: dns.ClassINET}}}
dnsCtx, cancel := context.WithTimeout(ctx, dnsExchangeTimeout)
defer cancel()
client := dns.NewClient()
resp, _, err := client.Exchange(
dnsCtx,
msg,
"udp",
h.resolverAddr,
)
if err != nil {
return fmt.Errorf("cannot exchange dns message for caa records: %w", err)
}
var caaRecords []*dns.CAA
for _, rr := range resp.Answer {
if caa, ok := rr.(*dns.CAA); ok {
caaRecords = append(caaRecords, caa)
}
}
if len(caaRecords) == 0 {
return nil
}
for _, caa := range caaRecords {
if caa.Tag == "issue" {
issuer, _, _ := strings.Cut(caa.Value, ";")
if strings.EqualFold(strings.TrimSpace(issuer), h.caaIssuerDomain) {
return nil
}
}
}
return fmt.Errorf(
"%w: domain %q by %q",
ErrCAANotPermitted,
hostname,
h.caaIssuerDomain,
)
}
func (h *beginChallengeHandler) skipsDNSChecks(
ctx context.Context,
conn pg.Querier,
hostname string,
) (bool, error) {
if h.managedBaseDomain == "" {
return false, nil
}
suffix := "." + h.managedBaseDomain
if hostname != h.managedBaseDomain && !strings.HasSuffix(hostname, suffix) {
return false, nil
}
domain := &coredata.CustomDomain{}
err := domain.LoadByDomain(ctx, conn, coredata.NewNoScope(), hostname)
if errors.Is(err, coredata.ErrResourceNotFound) {
return true, nil
}
if err != nil {
return false, fmt.Errorf("cannot load custom domain: %w", err)
}
return domain.Managed, nil
}
func (h *pollOrderHandler) Claim(ctx context.Context) (coredata.Certificate, error) {
// Deliberately NOT gated by acmeService.InCooldown(). A cooldown is entered
// when minting a NEW order hits the CA's rate limit (see
// beginChallengeHandler.Claim); advancing an order already in flight only
// polls/finalizes that specific order and does not mint new ones. Gating
// this claim too would stall every other tenant's in-flight provisioning
// for up to the cooldown duration just because one unrelated hostname
// tripped a limit.
var certificate coredata.Certificate
if err := h.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := certificate.LoadNextForPollOrderForUpdateSkipLocked(ctx, tx, provisioningPollLease); err != nil {
return err
}
now := time.Now()
certificate.SSLLastAttemptAt = &now
if err := certificate.Update(ctx, tx, coredata.NewNoScope()); err != nil {
return fmt.Errorf("cannot stamp certificate claim: %w", err)
}
return nil
},
); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return coredata.Certificate{}, worker.ErrNoTask
}
return coredata.Certificate{}, err
}
return certificate, nil
}
func (h *pollOrderHandler) Process(ctx context.Context, certificate coredata.Certificate) error {
ctx, cancel := context.WithTimeout(ctx, processTickTimeout)
defer cancel()
return h.processPollOrder(ctx, certificate)
}
func (h *pollOrderHandler) processPollOrder(
ctx context.Context, ctx context.Context,
certificate coredata.Certificate, certificate coredata.Certificate,
) error { ) error {
@@ -337,8 +556,12 @@ func (h *provisionHandler) processPollOrder(
case OrderPollStatusNotReady: case OrderPollStatusNotReady:
// Re-accept best-effort: if a prior tick committed the challenge but its // Re-accept best-effort: if a prior tick committed the challenge but its
// Accept never reached the CA, the order would otherwise stay pending // Accept never reached the CA, the order would otherwise stay pending
// forever since this path only polls. Re-accepting a pending challenge // forever since this path only polls. Only do this while the order is
// starts validation; one already processing is a no-op at the CA. // still PENDING: once it has moved to PROCESSING, the CA has already
// registered the Accept and rejects a second one with
// malformed/"Only pending challenges may be validated" (RFC 8555), so
// re-accepting there is not a no-op and just produces noisy failures.
if poll.Order.Status == acme.StatusPending {
if err := h.acmeService.AcceptHTTPChallenge(ctx, challenge); err != nil { if err := h.acmeService.AcceptHTTPChallenge(ctx, challenge); err != nil {
h.logger.WarnCtx( h.logger.WarnCtx(
ctx, ctx,
@@ -348,6 +571,7 @@ func (h *provisionHandler) processPollOrder(
log.Error(err), log.Error(err),
) )
} }
}
h.logger.InfoCtx( h.logger.InfoCtx(
ctx, ctx,
@@ -382,7 +606,7 @@ func (h *provisionHandler) processPollOrder(
// abandonRecoveredValidOrder clears the ACME order state and returns the row to // abandonRecoveredValidOrder clears the ACME order state and returns the row to
// PENDING so the next tick mints a fresh order (and a matching private key). // PENDING so the next tick mints a fresh order (and a matching private key).
func (h *provisionHandler) abandonRecoveredValidOrder( func (h *pollOrderHandler) abandonRecoveredValidOrder(
ctx context.Context, ctx context.Context,
span trace.Span, span trace.Span,
certificate coredata.Certificate, certificate coredata.Certificate,
@@ -427,7 +651,7 @@ func (h *provisionHandler) abandonRecoveredValidOrder(
) )
} }
func (h *provisionHandler) issueCertificate( func (h *pollOrderHandler) issueCertificate(
ctx context.Context, ctx context.Context,
certificate coredata.Certificate, certificate coredata.Certificate,
challenge *HTTPChallenge, challenge *HTTPChallenge,
@@ -520,7 +744,7 @@ func (h *provisionHandler) issueCertificate(
) )
} }
func (h *provisionHandler) persistFailure( func (h *provisionCore) persistFailure(
ctx context.Context, ctx context.Context,
certificateID gid.GID, certificateID gid.GID,
provisionErr error, provisionErr error,
@@ -591,9 +815,9 @@ func isImmediateRetryRateLimit(err error) bool {
// - Normal transient errors: ssl_retry_count increments; at maxProvisioningRetries // - Normal transient errors: ssl_retry_count increments; at maxProvisioningRetries
// the domain becomes FAILED. // the domain becomes FAILED.
// - Rate limits: never increment ssl_retry_count and never mark FAILED; the // - Rate limits: never increment ssl_retry_count and never mark FAILED; the
// in-process ACME cooldown gates Claim. When an order URL is present // in-process ACME cooldown gates the begin-challenge worker's Claim. When an
// it is preserved so the next tick resumes polling instead of minting a new // order URL is present it is preserved so the next tick resumes polling
// order. // instead of minting a new order.
// //
// DNS/CAA misconfig is intentionally non-terminal so customer DNS fixes // DNS/CAA misconfig is intentionally non-terminal so customer DNS fixes
// auto-recover on the claim backoff schedule. // auto-recover on the claim backoff schedule.
@@ -654,7 +878,7 @@ func decideProvisioningOutcome(
} }
} }
func (h *provisionHandler) RecoverStale(ctx context.Context) error { func (h *pollOrderHandler) RecoverStale(ctx context.Context) error {
return h.pg.WithTx( return h.pg.WithTx(
ctx, ctx,
func(ctx context.Context, tx pg.Tx) error { func(ctx context.Context, tx pg.Tx) error {
@@ -685,7 +909,7 @@ func (h *provisionHandler) RecoverStale(ctx context.Context) error {
) )
} }
func (h *provisionHandler) logACMEOutcome( func (h *provisionCore) logACMEOutcome(
ctx context.Context, ctx context.Context,
certificate coredata.Certificate, certificate coredata.Certificate,
phase provisionPhase, phase provisionPhase,
@@ -740,7 +964,7 @@ func (h *provisionHandler) logACMEOutcome(
) )
} }
func (h *provisionHandler) setCertificateSpanAttributes(span trace.Span, certificate coredata.Certificate) { func (h *provisionCore) setCertificateSpanAttributes(span trace.Span, certificate coredata.Certificate) {
span.SetAttributes( span.SetAttributes(
attribute.String("certificate.id", certificate.ID.String()), attribute.String("certificate.id", certificate.ID.String()),
attribute.String("certificate.hostname", certificate.Hostname), attribute.String("certificate.hostname", certificate.Hostname),
@@ -748,7 +972,7 @@ func (h *provisionHandler) setCertificateSpanAttributes(span trace.Span, certifi
) )
} }
func (h *provisionHandler) recordSpanError(span trace.Span, err error, errorCode string) { func (h *provisionCore) recordSpanError(span trace.Span, err error, errorCode string) {
if err == nil { if err == nil {
return return
} }
@@ -771,26 +995,6 @@ func (h *provisionHandler) recordSpanError(span trace.Span, err error, errorCode
) )
} }
func (h *provisionHandler) loadSkipDNSChecks(ctx context.Context, hostname string) (bool, error) {
var skip bool
err := h.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var err error
skip, err = h.skipsDNSChecks(ctx, conn, hostname)
return err
},
)
if err != nil {
return false, err
}
return skip, nil
}
func stringValue(value *string) string { func stringValue(value *string) string {
if value == nil { if value == nil {
return "" return ""
@@ -799,137 +1003,7 @@ func stringValue(value *string) string {
return *value return *value
} }
func (h *provisionHandler) checkDNSConfiguration(ctx context.Context, hostname string) error { func (h *pollOrderHandler) resetStaleCertificate(
customerFQDN := hostname
if !strings.HasSuffix(customerFQDN, ".") {
customerFQDN = customerFQDN + "."
}
expectedFQDN := h.cnameTarget
if !strings.HasSuffix(expectedFQDN, ".") {
expectedFQDN = expectedFQDN + "."
}
msg := &dns.Msg{MsgHeader: dns.MsgHeader{ID: dns.ID(), RecursionDesired: true}}
msg.Question = []dns.RR{&dns.CNAME{Hdr: dns.Header{Name: customerFQDN, Class: dns.ClassINET}}}
dnsCtx, cancel := context.WithTimeout(ctx, dnsExchangeTimeout)
defer cancel()
client := dns.NewClient()
resp, _, err := client.Exchange(dnsCtx, msg, "udp", h.resolverAddr)
if err != nil {
return fmt.Errorf("cannot exchange dns message: %w", err)
}
if len(resp.Answer) == 0 {
return fmt.Errorf("no cname records found for domain %q", hostname)
}
if len(resp.Answer) > 1 {
return fmt.Errorf("multiple cname records found for domain %q", hostname)
}
resolvedRecord, ok := resp.Answer[0].(*dns.CNAME)
if !ok {
return fmt.Errorf("first answer is not a cname record for domain %q", hostname)
}
if !strings.EqualFold(expectedFQDN, resolvedRecord.Target) {
return fmt.Errorf(
"cname target mismatch: domain %q resolves to %q, expected %q",
hostname,
resolvedRecord.Target,
expectedFQDN,
)
}
return nil
}
func (h *provisionHandler) checkCAARecords(ctx context.Context, hostname string) error {
fqdn := hostname
if !strings.HasSuffix(fqdn, ".") {
fqdn = fqdn + "."
}
msg := &dns.Msg{MsgHeader: dns.MsgHeader{ID: dns.ID(), RecursionDesired: true}}
msg.Question = []dns.RR{&dns.CAA{Hdr: dns.Header{Name: fqdn, Class: dns.ClassINET}}}
dnsCtx, cancel := context.WithTimeout(ctx, dnsExchangeTimeout)
defer cancel()
client := dns.NewClient()
resp, _, err := client.Exchange(
dnsCtx,
msg,
"udp",
h.resolverAddr,
)
if err != nil {
return fmt.Errorf("cannot exchange dns message for caa records: %w", err)
}
var caaRecords []*dns.CAA
for _, rr := range resp.Answer {
if caa, ok := rr.(*dns.CAA); ok {
caaRecords = append(caaRecords, caa)
}
}
if len(caaRecords) == 0 {
return nil
}
for _, caa := range caaRecords {
if caa.Tag == "issue" {
issuer, _, _ := strings.Cut(caa.Value, ";")
if strings.EqualFold(strings.TrimSpace(issuer), h.caaIssuerDomain) {
return nil
}
}
}
return fmt.Errorf(
"%w: domain %q by %q",
ErrCAANotPermitted,
hostname,
h.caaIssuerDomain,
)
}
func (h *provisionHandler) skipsDNSChecks(
ctx context.Context,
conn pg.Querier,
hostname string,
) (bool, error) {
if h.managedBaseDomain == "" {
return false, nil
}
suffix := "." + h.managedBaseDomain
if hostname != h.managedBaseDomain && !strings.HasSuffix(hostname, suffix) {
return false, nil
}
domain := &coredata.CustomDomain{}
err := domain.LoadByDomain(ctx, conn, coredata.NewNoScope(), hostname)
if errors.Is(err, coredata.ErrResourceNotFound) {
return true, nil
}
if err != nil {
return false, fmt.Errorf("cannot load custom domain: %w", err)
}
return domain.Managed, nil
}
func (h *provisionHandler) resetStaleCertificate(
ctx context.Context, ctx context.Context,
tx pg.Tx, tx pg.Tx,
certificate *coredata.Certificate, certificate *coredata.Certificate,

View File

@@ -38,7 +38,8 @@ type (
acmeService *ACMEService acmeService *ACMEService
encryptionKey cipher.EncryptionKey encryptionKey cipher.EncryptionKey
logger *log.Logger logger *log.Logger
provisionWorker *worker.Worker[coredata.Certificate] beginChallengeWorker *worker.Worker[coredata.Certificate]
pollOrderWorker *worker.Worker[coredata.Certificate]
renewWorker *worker.Worker[coredata.Certificate] renewWorker *worker.Worker[coredata.Certificate]
} }
@@ -75,15 +76,21 @@ func NewService(
acmeService: acmeService, acmeService: acmeService,
encryptionKey: encryptionKey, encryptionKey: encryptionKey,
logger: logger, logger: logger,
provisionWorker: NewProvisionWorker( beginChallengeWorker: NewBeginChallengeWorker(
pgClient, pgClient,
acmeService, acmeService,
encryptionKey,
cfg.CnameTarget, cfg.CnameTarget,
cfg.CAAIssuerDomain, cfg.CAAIssuerDomain,
cfg.ResolverAddr, cfg.ResolverAddr,
cfg.ManagedBaseDomain, cfg.ManagedBaseDomain,
logger.Named("provision-worker"), logger.Named("begin-challenge-worker"),
worker.WithInterval(provisionInterval),
),
pollOrderWorker: NewPollOrderWorker(
pgClient,
acmeService,
encryptionKey,
logger.Named("poll-order-worker"),
worker.WithInterval(provisionInterval), worker.WithInterval(provisionInterval),
), ),
renewWorker: NewRenewWorker( renewWorker: NewRenewWorker(
@@ -100,7 +107,13 @@ func (s *Service) Run(ctx context.Context) error {
g.Go( g.Go(
func() error { func() error {
return s.provisionWorker.Run(gctx) return s.beginChallengeWorker.Run(gctx)
},
)
g.Go(
func() error {
return s.pollOrderWorker.Run(gctx)
}, },
) )

View File

@@ -877,20 +877,15 @@ WHERE
return nil return nil
} }
func (c *Certificate) LoadNextForProvisioningForUpdateSkipLocked( // LoadNextForBeginChallengeForUpdateSkipLocked selects the next PENDING/RENEWING
ctx context.Context, // row eligible to start a new ACME order. These rows use exponential backoff
tx pg.Tx, // from ssl_last_attempt_at: 15m * 2^min(retry,5). Ordinary failures only reach
pollLease time.Duration,
) error {
// PROVISIONING rows with an open order become eligible again only after
// pollLease elapses since the last attempt. The caller sizes pollLease to
// exceed the maximum Process window: the claim's FOR UPDATE lock is released
// before Process runs, so a shorter interval would let another worker claim
// the same row while its poll/issue is still in flight. Pending/Renewing
// rows (and provisioning rows without an order) use exponential backoff from
// ssl_last_attempt_at: 15m * 2^min(retry,5). Ordinary failures only reach
// retry counts 0–2 before FAILED; higher exponents are unused by the current // retry counts 0–2 before FAILED; higher exponents are unused by the current
// failure budget but keep the SQL ceiling defensive. // failure budget but keep the SQL ceiling defensive.
func (c *Certificate) LoadNextForBeginChallengeForUpdateSkipLocked(
ctx context.Context,
tx pg.Tx,
) error {
q := ` q := `
SELECT SELECT
id, id,
@@ -915,16 +910,88 @@ WHERE
status = ANY(@statuses) status = ANY(@statuses)
AND ( AND (
ssl_last_attempt_at IS NULL ssl_last_attempt_at IS NULL
OR ( OR ssl_last_attempt_at < CURRENT_TIMESTAMP - (
INTERVAL '15 minutes' * (POWER(2, LEAST(ssl_retry_count, 5))::int)
)
)
ORDER BY
updated_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
`
rows, err := tx.Query(
ctx,
q,
pgx.StrictNamedArgs{
"statuses": []string{
string(CertificateStatusPending),
string(CertificateStatusRenewing),
},
},
)
if err != nil {
return fmt.Errorf("cannot query certificate begin-challenge queue: %w", err)
}
certificate, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Certificate])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect certificate: %w", err)
}
*c = certificate
return nil
}
// LoadNextForPollOrderForUpdateSkipLocked selects the next PROVISIONING row
// eligible to poll its ACME order. A row with an open order becomes eligible
// again only after pollLease elapses since the last attempt. The caller sizes
// pollLease to exceed the maximum Process window: the claim's FOR UPDATE lock
// is released before Process runs, so a shorter interval would let another
// worker claim the same row while its poll/issue is still in flight. A row
// without an open order is a defensive edge case (the state machine always
// pairs PROVISIONING with an order) and falls back to the same exponential
// backoff as begin-challenge rows.
func (c *Certificate) LoadNextForPollOrderForUpdateSkipLocked(
ctx context.Context,
tx pg.Tx,
pollLease time.Duration,
) error {
q := `
SELECT
id,
hostname,
http_challenge_token,
http_challenge_key_auth,
http_challenge_url,
http_order_url,
ssl_certificate,
encrypted_ssl_private_key,
ssl_certificate_chain,
status,
ssl_expires_at,
ssl_retry_count,
ssl_last_attempt_at,
provisioning_error,
created_at,
updated_at
FROM
certificates
WHERE
status = @provisioning_status status = @provisioning_status
AND http_order_url IS NOT NULL AND (
ssl_last_attempt_at IS NULL
OR (
http_order_url IS NOT NULL
AND ssl_last_attempt_at < CURRENT_TIMESTAMP - make_interval(secs => @poll_lease_seconds) AND ssl_last_attempt_at < CURRENT_TIMESTAMP - make_interval(secs => @poll_lease_seconds)
) )
OR ( OR (
NOT ( http_order_url IS NULL
status = @provisioning_status
AND http_order_url IS NOT NULL
)
AND ssl_last_attempt_at < CURRENT_TIMESTAMP - ( AND ssl_last_attempt_at < CURRENT_TIMESTAMP - (
INTERVAL '15 minutes' * (POWER(2, LEAST(ssl_retry_count, 5))::int) INTERVAL '15 minutes' * (POWER(2, LEAST(ssl_retry_count, 5))::int)
) )
@@ -940,17 +1007,12 @@ FOR UPDATE SKIP LOCKED
ctx, ctx,
q, q,
pgx.StrictNamedArgs{ pgx.StrictNamedArgs{
"statuses": []string{
string(CertificateStatusPending),
string(CertificateStatusProvisioning),
string(CertificateStatusRenewing),
},
"provisioning_status": string(CertificateStatusProvisioning), "provisioning_status": string(CertificateStatusProvisioning),
"poll_lease_seconds": pollLease.Seconds(), "poll_lease_seconds": pollLease.Seconds(),
}, },
) )
if err != nil { if err != nil {
return fmt.Errorf("cannot query certificate provisioning queue: %w", err) return fmt.Errorf("cannot query certificate poll-order queue: %w", err)
} }
certificate, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Certificate]) certificate, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Certificate])