Skip to content

Commit 12eda03

Browse files
authored
Merge pull request #289 from voxpupuli/perf/batch-supersession-crl-resign
Batch the supersession sweep's CRL re-sign
2 parents a2b90b9 + ea0c9cb commit 12eda03

8 files changed

Lines changed: 692 additions & 174 deletions

File tree

docs/configuration.md

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -838,16 +838,18 @@ Some things worth knowing before you rely on it:
838838
operator set the window to 0. On a CA that has never recorded a supersession
839839
each pass is a single absent-key read taking no cluster lock: the sweep rules
840840
the work out before acquiring one.
841-
- **Each due entry costs one CRL re-sign, under the shared CRL lock.** The sweep
842-
revokes entries one at a time, and every revocation is a full read, re-sign
843-
and write of the CRL. A large backlog coming due at once — after a fleet-wide
844-
outage, say — therefore drains at a rate set by CRL re-sign cost rather than
845-
by the sweep interval, and it holds the lock that every revocation on every
846-
replica needs while it does. A pass stops before its budget is spent and logs
847-
what it deferred to the next one, so a backlog that is not draining is visible
848-
rather than silent: a deferred pass raises `puppetca_supersede_failures_total`
849-
and logs `ran out of budget; deferring the rest`, and the entries it defers
850-
stay on the list. Batching those re-signs into one is a separate change.
841+
- **A pass costs one CRL re-sign, whatever the backlog.** The sweep collects
842+
every due entry and amends the CRL once — one read, one signature, one write —
843+
however many certificates come due together, and holds the shared CRL lock
844+
that every revocation on every replica needs for that single amendment rather
845+
than for one per entry. Under
846+
[`ca_key_provider: openbao`](openbao-transit.md) it is likewise one remote
847+
Transit round trip rather than one per entry. That matters most in the case
848+
the sweep used to handle worst: a large backlog coming due at once, after a
849+
fleet-wide outage or a passphrase rotation. A pass that cannot amend the CRL
850+
fails as a whole — it leaves every entry it attempted on the list, raises
851+
`puppetca_supersede_failures_total`, and the next pass retries them
852+
together.
851853
- **Revoking a subject retires its pending predecessor too.** `revoke --certname`
852854
and `DELETE /certificate_status` retire the subject's current certificate
853855
*and* anything of that subject's still inside its window, in the same call —

docs/metrics.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -311,13 +311,13 @@ sweep is not completing — check the failure counter.
311311
> that renewal but is left for the sweep to count, so one corrupt blob cannot
312312
> become a counter storm on a busy CA; a sweep pass that could not take the CRL
313313
> lock or write the list back; a predecessor a subject revocation could not
314-
> retire; and each pass that left an entry unrevoked, deferred one for want of
315-
> budget, or discarded one whose serial it could never revoke. A pass counts
314+
> retire; and each pass that left an entry unrevoked or discarded one whose
315+
> serial it could never revoke. A pass counts
316316
> once however many entries it failed on, so this is a count of bad passes
317317
> rather than of lost certificates.
318318
>
319319
> The cases differ in what you have to do about them, and the log line is what
320-
> tells them apart. `Could not revoke superseded certificate` retries on the
320+
> tells them apart. `Could not revoke superseded certificates` retries on the
321321
> next pass by itself. `failed to retire replaced certificate` and `Discarding`
322322
> are both gone for good — nothing will rediscover them, and the certificate
323323
> stays valid for its full remaining life. Retire those by serial with
@@ -330,9 +330,12 @@ sweep is not completing — check the failure counter.
330330
> A sweep that cannot read the list at all counts once per pass and omits
331331
> `puppetca_supersede_pending` rather than reporting zero, so the two signals
332332
> cannot both read clean while the list stops draining. A sweep that *can* read
333-
> it but runs out of budget counts too, and logs `ran out of budget; deferring
334-
> the rest to the next pass` — that is the shape a backlog draining slower than
335-
> it accrues takes, and the entries it defers stay on the list.
333+
> it but cannot amend the CRL counts once per pass too, not once per entry: the
334+
> re-sign covers the whole pass, so every certificate that pass attempted stays
335+
> on the list and is retried together. A pending gauge that does not fall while
336+
> this counter rises is a sweep failing outright rather than one falling behind
337+
> — the sweep no longer paces itself, so there is no longer a backlog it
338+
> declines to attempt.
336339

337340
### Client trust domains
338341

internal/ca/supersede.go

Lines changed: 258 additions & 103 deletions
Large diffs are not rendered by default.

internal/ca/supersede_test.go

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -516,21 +516,26 @@ var _ = Describe("Delayed supersession", func() {
516516

517517
Describe("ReconcileSuperseded when a revocation fails", func() {
518518
// The retry half of the sweep's failure handling. The alert text tells
519-
// operators to distinguish "Could not revoke superseded certificate"
519+
// operators to distinguish "Could not revoke superseded certificates"
520520
// (recorded, will retry) from "Discarding" (gone for good), and only the
521521
// second was pinned. A change that dropped failed entries instead of
522522
// carrying them forward would turn a retryable failure into a
523523
// permanently valid credential and move no assertion.
524+
//
525+
// This is also the carry-forward case #176 had to preserve when it
526+
// batched the re-sign. Batched, the failure is all-or-nothing rather
527+
// than per entry — but where the entries end up is unchanged, which is
528+
// the part that decides whether a certificate stays tracked.
524529
It("carries the entry forward rather than dropping it, and counts the pass once", func() {
525530
first := issue("node-r")
526531
second := issue("node-s")
527532
writePending([]pendingEntry{
528533
{Serial: hexSerial(first.SerialNumber), Subject: "node-r", RevokeAt: time.Now().UTC().Add(-time.Minute)},
529534
{Serial: hexSerial(second.SerialNumber), Subject: "node-s", RevokeAt: time.Now().UTC().Add(-time.Minute)},
530535
})
531-
// An unparseable CRL makes revokeSerialLocked fail for every entry
532-
// without making any of them unrevocable — the distinction the two
533-
// arms turn on.
536+
// An unparseable CRL fails the batch's own read without making any
537+
// of its entries unrevocable — the distinction the two arms turn
538+
// on.
534539
Expect(store.UpdateCRL(ctx, []byte("not a valid CRL"))).To(Succeed())
535540
before := myCA.SupersedeFailures()
536541

@@ -720,13 +725,18 @@ var _ = Describe("Delayed supersession", func() {
720725
})
721726
})
722727

723-
// The deferral arm. Every other sweep spec runs on context.Background(), so
724-
// the reserve is never reached and this path shipped untested in round 1 —
725-
// which is how it shipped erasing the entries it claimed to defer.
726-
// ReconcileSuperseded's own WithTimeout keeps an earlier parent deadline, so
727-
// a caller deadline below the reserve reaches the branch with no sleeping.
728-
Describe("ReconcileSuperseded when the budget runs low", func() {
729-
It("leaves the entries it defers on the list rather than erasing them", func() {
728+
// This used to be the deferral arm: below the reserve the per-entry loop
729+
// retired one entry and carried the rest to the next pass, because N
730+
// entries cost N CRL re-signs and a backlog could not fit in one lock hold.
731+
// #176 collapsed those into one re-sign, so the reserve is gone and a pass
732+
// on the same deadline clears the whole backlog. The spec is kept, aimed at
733+
// the property that replaced it — every other sweep spec runs on
734+
// context.Background(), so without this one nothing exercises the sweep
735+
// with a deadline near enough to matter at all. ReconcileSuperseded's own
736+
// WithTimeout keeps an earlier parent deadline, so the short context here
737+
// really is the one the pass runs under.
738+
Describe("ReconcileSuperseded on a deadline that used to ration the pass", func() {
739+
It("drains the whole backlog rather than deferring part of it", func() {
730740
first := issue("node-y")
731741
second := issue("node-z")
732742
writePending([]pendingEntry{
@@ -735,31 +745,27 @@ var _ = Describe("Delayed supersession", func() {
735745
})
736746
before := myCA.SupersedeFailures()
737747

738-
// Below the reserve, so the loop breaks after the first entry.
748+
// Below what the old reserve check (LockTimeout/2) allowed, so the
749+
// pre-batch sweep revoked node-y here and deferred node-z.
739750
shortCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
740751
defer cancel()
741752
count, err := myCA.ReconcileSuperseded(shortCtx)
742753
Expect(err).NotTo(HaveOccurred())
743754

744-
// Always at least one: a pass that revoked nothing while entries
745-
// were due would make no progress, every time, forever.
746-
Expect(count).To(Equal(1), "a pass must attempt at least one entry even with no budget left")
747-
Expect(revoked(first.SerialNumber)).To(BeTrue(),
748-
"oldest-first: the entry superseded longest ago is the one retired")
755+
Expect(count).To(Equal(2),
756+
"one re-sign covers both entries, so a deadline that used to stop the pass "+
757+
"after the first no longer rations it")
758+
// Anchored on each serial, so a batch that lost one fails here
759+
// naming it rather than passing on a count that happens to match.
760+
Expect(revoked(first.SerialNumber)).To(BeTrue(), "node-y must be on the CRL")
761+
Expect(revoked(second.SerialNumber)).To(BeTrue(), "node-z must be on the CRL")
749762

750-
// The deferred entry is the whole point. Anchored on its serial, so
751-
// this fails for the deferral and not for some other survivor.
752-
entries := pending()
753-
Expect(entries).To(HaveLen(1),
754-
"an entry the budget deferred must still be on the list for the next pass; "+
755-
"nothing else records that it is owed a revocation")
756-
Expect(entries[0].Serial).To(Equal(hexSerial(second.SerialNumber)),
757-
"and it must be the deferred entry, not some other survivor")
758-
Expect(revoked(second.SerialNumber)).To(BeFalse())
759-
760-
Expect(myCA.SupersedeFailures()).To(Equal(before+1),
761-
"a deferred backlog must be counted, or a sweep that cannot keep up looks "+
762-
"identical to one that is keeping up")
763+
Expect(pending()).To(BeEmpty(),
764+
"an entry that is on the CRL is no longer owed a revocation, so the pass "+
765+
"must prune it")
766+
Expect(myCA.SupersedeFailures()).To(Equal(before),
767+
"a pass that left nothing unrevoked is not a failure, and counting it as one "+
768+
"would fire PuppetCASupersedeFailing on every healthy sweep")
763769
})
764770
})
765771

0 commit comments

Comments
 (0)