Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -462,11 +462,8 @@ func setupControllers(mgr ctrl.Manager, blocklist *failover.Blocklist, kubeletSr
}
}

// Cost accrual is a clock-driven loop, not a reconciler, so it is added directly. It opts into
// leader election by NOT implementing LeaderElectionRunnable, which buys two things: N replicas
// would mean N times the writes, and each would book windows into its OWN cost counter, leaving
// increase() to read a series that holds only the races that replica won. The ledger itself is
// safe either way (see CostAccrual.accrue).
// Cost accrual is a clock-driven loop, not a reconciler, so it is added directly. It runs on
// the leader only (see CostAccrual.NeedLeaderElection).
if err := mgr.Add(controller.NewCostAccrual(mgr.GetClient())); err != nil {
return fmt.Errorf("unable to add the cost accrual loop: %w", err)
}
Expand Down
18 changes: 14 additions & 4 deletions internal/controller/cost_accrual.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,8 @@ const accrualWorkers = 16
// CostAccrual advances each claim's durable spend ledger on a ticker.
//
// A Runnable rather than a hook on the reconcile path: spend accrues with the CLOCK, not with
// events, and a Bound claim can sit for hours without a reconcile. Leader election is the
// manager's default for a plain Runnable and is load-bearing here — two replicas accruing the
// same fleet would double every dollar.
// events, and a Bound claim can sit for hours without a reconcile. Leader election is
// load-bearing here (see NeedLeaderElection).
//
// It lives beside the reconciler rather than in pkg/metrics because it WRITES: the ledger is a
// status field, and instrumentation that patches API objects is no longer instrumentation.
Expand All @@ -83,7 +82,18 @@ type CostAccrual struct {
now func() time.Time
}

var _ manager.Runnable = (*CostAccrual)(nil)
var (
_ manager.Runnable = (*CostAccrual)(nil)
_ manager.LeaderElectionRunnable = (*CostAccrual)(nil)
)

// NeedLeaderElection pins the loop to the leader. It matches the manager's default for a plain
// Runnable, but is stated explicitly so a change to that default cannot silently let N replicas
// accrue the same fleet: N times the writes, and each replica's cost counter would hold only the
// windows it won the race for. The ledger itself is safe either way (see CostAccrual.accrue).
func (a *CostAccrual) NeedLeaderElection() bool {
return true
}

// NewCostAccrual builds the accrual loop over the manager's client: cached reads, direct writes.
func NewCostAccrual(c client.Client) *CostAccrual {
Expand Down
6 changes: 6 additions & 0 deletions internal/controller/cost_accrual_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1045,3 +1045,9 @@ func TestCostAccrual_StartAccrues(t *testing.T) {
}
t.Fatal("Start persisted nothing after 5s of 1ms ticks")
}

func TestCostAccrualNeedsLeaderElection(t *testing.T) {
if !NewCostAccrual(nil).NeedLeaderElection() {
t.Fatal("cost accrual must run on the leader only")
}
}
Loading