Skip to content

Commit 5163e50

Browse files
SuperMarioYLclaude
andcommitted
release: v0.0.13 — scheduler leader election (HA, root-fix duplicate billing)
Lease-based leader election (internal/leader) runs the singleton billing/auto-recharge/alert scheduler on exactly one replica; scheduler made re-startable with tests; replicaCount restored to 2; LEADER_ELECTION_ENABLED toggle; coordination.k8s.io/leases RBAC added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 98a9b75 commit 5163e50

11 files changed

Lines changed: 235 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ All notable changes to the Bison project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.0.13] - 2026-06-19
9+
10+
### Added — Scheduler leader election
11+
12+
- **Lease-based leader election** (`internal/leader`) guards the singleton billing/auto-recharge/alert scheduler so it runs on exactly one api-server replica at a time. This is the root fix for the duplicate-billing risk; `apiServer.replicaCount` is restored to `2` for HA.
13+
- The scheduler is now **re-startable** (clean `Start`/`Stop` on leadership changes), with new tests covering restart and stop-before-start safety.
14+
- Toggle via `LEADER_ELECTION_ENABLED` (default on); disable for single-replica / local dev.
15+
- Added `coordination.k8s.io/leases` (get/create/update) to the api-server RBAC.
16+
817
## [0.0.12] - 2026-06-19
918

1019
### Fixed — Billing correctness & concurrency

api-server/cmd/main.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/bison/api-server/internal/config"
1515
"github.com/bison/api-server/internal/handler"
1616
"github.com/bison/api-server/internal/k8s"
17+
"github.com/bison/api-server/internal/leader"
1718
"github.com/bison/api-server/internal/middleware"
1819
"github.com/bison/api-server/internal/opencost"
1920
"github.com/bison/api-server/internal/scheduler"
@@ -300,9 +301,22 @@ func main() {
300301
IdleTimeout: 60 * time.Second,
301302
}
302303

303-
// Start scheduler
304+
// Start scheduler. When leader election is enabled the singleton scheduler
305+
// runs on exactly one replica at a time (guards against duplicate billing);
306+
// otherwise it runs directly (single-replica / local dev).
304307
ctx, cancel := context.WithCancel(context.Background())
305-
sched.Start(ctx)
308+
if cfg.LeaderElectionEnabled {
309+
go leader.Run(ctx, k8sClient.Clientset(), service.BisonNamespace,
310+
func(leaderCtx context.Context) {
311+
sched.Start(leaderCtx)
312+
<-leaderCtx.Done()
313+
sched.Stop()
314+
},
315+
func() { sched.Stop() },
316+
)
317+
} else {
318+
sched.Start(ctx)
319+
}
306320

307321
// Start server in goroutine
308322
go func() {

api-server/internal/config/config.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ type Config struct {
2424

2525
// Feature toggles
2626
CapsuleEnabled bool
27+
28+
// LeaderElectionEnabled gates the singleton scheduler behind a Kubernetes
29+
// lease so it runs on exactly one replica. Disable for single-replica or
30+
// out-of-cluster development.
31+
LeaderElectionEnabled bool
2732
}
2833

2934
// Load reads configuration from environment variables
@@ -35,9 +40,10 @@ func Load() (*Config, error) {
3540
AdminUsername: "admin",
3641
AdminPassword: "admin",
3742
JWTSecret: "bison-secret-key-change-in-production",
38-
OpenCostURL: "",
39-
PrometheusURL: "",
40-
CapsuleEnabled: true,
43+
OpenCostURL: "",
44+
PrometheusURL: "",
45+
CapsuleEnabled: true,
46+
LeaderElectionEnabled: true,
4147
}
4248

4349
if port := os.Getenv("PORT"); port != "" {
@@ -78,6 +84,9 @@ func Load() (*Config, error) {
7884
if capsuleEnabled := os.Getenv("CAPSULE_ENABLED"); capsuleEnabled == "false" {
7985
cfg.CapsuleEnabled = false
8086
}
87+
if le := os.Getenv("LEADER_ELECTION_ENABLED"); le == "false" {
88+
cfg.LeaderElectionEnabled = false
89+
}
8190

8291
return cfg, nil
8392
}

api-server/internal/k8s/client.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ func NewClientWithInterfaces(clientset kubernetes.Interface, dynamicClient dynam
3737
}
3838
}
3939

40+
// Clientset exposes the underlying typed client (used e.g. for leader election).
41+
func (c *Client) Clientset() kubernetes.Interface {
42+
return c.clientset
43+
}
44+
4045
// NewClient creates a new Kubernetes client
4146
func NewClient() (*Client, error) {
4247
var config *rest.Config
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Package leader provides Kubernetes lease-based leader election so that
2+
// singleton background work (the billing/auto-recharge/alert scheduler) runs on
3+
// exactly one api-server replica at a time, even when scaled horizontally.
4+
package leader
5+
6+
import (
7+
"context"
8+
"os"
9+
"time"
10+
11+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
"k8s.io/client-go/kubernetes"
13+
"k8s.io/client-go/tools/leaderelection"
14+
"k8s.io/client-go/tools/leaderelection/resourcelock"
15+
16+
"github.com/bison/api-server/pkg/logger"
17+
)
18+
19+
// LeaseName is the coordination.k8s.io Lease used to elect the scheduler leader.
20+
const LeaseName = "bison-scheduler"
21+
22+
// identity returns a per-process identity. In Kubernetes the pod name is the
23+
// hostname, which is unique per replica; POD_NAME overrides it when set.
24+
func identity() string {
25+
if v := os.Getenv("POD_NAME"); v != "" {
26+
return v
27+
}
28+
if h, err := os.Hostname(); err == nil && h != "" {
29+
return h
30+
}
31+
return "bison-api"
32+
}
33+
34+
func namespace(def string) string {
35+
if v := os.Getenv("POD_NAMESPACE"); v != "" {
36+
return v
37+
}
38+
return def
39+
}
40+
41+
// Run blocks running leader election until ctx is cancelled.
42+
//
43+
// onStarted is invoked (in its own goroutine) with a context that is cancelled
44+
// when leadership is lost or ctx is cancelled; it should start the leader-only
45+
// work and return promptly when its context is done. onStopped is invoked when
46+
// leadership is lost. The scheduler must be re-startable, since leadership can be
47+
// re-acquired after a transient loss.
48+
func Run(ctx context.Context, clientset kubernetes.Interface, ns string, onStarted func(context.Context), onStopped func()) {
49+
id := identity()
50+
leaseNS := namespace(ns)
51+
52+
lock := &resourcelock.LeaseLock{
53+
LeaseMeta: metav1.ObjectMeta{Name: LeaseName, Namespace: leaseNS},
54+
Client: clientset.CoordinationV1(),
55+
LockConfig: resourcelock.ResourceLockConfig{Identity: id},
56+
}
57+
58+
logger.Info("Starting leader election", "identity", id, "namespace", leaseNS, "lease", LeaseName)
59+
60+
config := leaderelection.LeaderElectionConfig{
61+
Lock: lock,
62+
ReleaseOnCancel: true,
63+
LeaseDuration: 15 * time.Second,
64+
RenewDeadline: 10 * time.Second,
65+
RetryPeriod: 2 * time.Second,
66+
Callbacks: leaderelection.LeaderCallbacks{
67+
OnStartedLeading: func(leaderCtx context.Context) {
68+
logger.Info("Acquired scheduler leadership", "identity", id)
69+
onStarted(leaderCtx)
70+
},
71+
OnStoppedLeading: func() {
72+
logger.Warn("Lost scheduler leadership", "identity", id)
73+
onStopped()
74+
},
75+
OnNewLeader: func(current string) {
76+
if current != id {
77+
logger.Info("Observed scheduler leader", "leader", current)
78+
}
79+
},
80+
},
81+
}
82+
83+
// RunOrDie returns when ctx is cancelled or leadership is lost. Loop so a
84+
// transient loss leads to re-election rather than permanently idle.
85+
for {
86+
select {
87+
case <-ctx.Done():
88+
return
89+
default:
90+
}
91+
leaderelection.RunOrDie(ctx, config)
92+
select {
93+
case <-ctx.Done():
94+
return
95+
case <-time.After(2 * time.Second):
96+
}
97+
}
98+
}

api-server/internal/scheduler/scheduler.go

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ type Scheduler struct {
1919
executions []service.TaskExecution
2020
executionsMu sync.RWMutex
2121

22-
stopCh chan struct{}
23-
wg sync.WaitGroup
22+
mu sync.Mutex
23+
started bool
24+
stopCh chan struct{}
25+
wg sync.WaitGroup
2426
}
2527

2628
// NewScheduler creates a new Scheduler
@@ -34,31 +36,41 @@ func NewScheduler(
3436
balanceSvc: balanceSvc,
3537
alertSvc: alertSvc,
3638
executions: make([]service.TaskExecution, 0),
37-
stopCh: make(chan struct{}),
3839
}
3940
}
4041

41-
// Start starts all scheduled tasks
42+
// Start starts all scheduled tasks. It is idempotent and re-startable: calling
43+
// Start after a Stop (e.g. when leadership is re-acquired) spins up a fresh set
44+
// of tasks against a new stop channel.
4245
func (s *Scheduler) Start(ctx context.Context) {
46+
s.mu.Lock()
47+
defer s.mu.Unlock()
48+
if s.started {
49+
return
50+
}
51+
s.started = true
52+
s.stopCh = make(chan struct{})
4353
logger.Info("Starting scheduler")
4454

45-
// Start billing task (every hour)
46-
s.wg.Add(1)
55+
s.wg.Add(3)
4756
go s.runBillingTask(ctx)
48-
49-
// Start auto-recharge task (every hour)
50-
s.wg.Add(1)
5157
go s.runAutoRechargeTask(ctx)
52-
53-
// Start alert check task (every 15 minutes)
54-
s.wg.Add(1)
5558
go s.runAlertTask(ctx)
5659
}
5760

58-
// Stop stops all scheduled tasks
61+
// Stop stops all scheduled tasks and waits for them to exit. Safe to call when
62+
// not started.
5963
func (s *Scheduler) Stop() {
60-
logger.Info("Stopping scheduler")
64+
s.mu.Lock()
65+
if !s.started {
66+
s.mu.Unlock()
67+
return
68+
}
69+
s.started = false
6170
close(s.stopCh)
71+
s.mu.Unlock()
72+
73+
logger.Info("Stopping scheduler")
6274
s.wg.Wait()
6375
}
6476

@@ -101,14 +113,14 @@ func (s *Scheduler) safeExecute(name string, fn func()) {
101113

102114
// sleepWithJitter waits a random duration in [0, max) to desynchronize task
103115
// firing across replicas, returning false if the scheduler is stopped meanwhile.
104-
func (s *Scheduler) sleepWithJitter(max time.Duration) bool {
116+
func (s *Scheduler) sleepWithJitter(stopCh <-chan struct{}, max time.Duration) bool {
105117
if max <= 0 {
106118
return true
107119
}
108120
timer := time.NewTimer(time.Duration(rand.Int63n(int64(max))))
109121
defer timer.Stop()
110122
select {
111-
case <-s.stopCh:
123+
case <-stopCh:
112124
return false
113125
case <-timer.C:
114126
return true
@@ -117,8 +129,9 @@ func (s *Scheduler) sleepWithJitter(max time.Duration) bool {
117129

118130
func (s *Scheduler) runBillingTask(ctx context.Context) {
119131
defer s.wg.Done()
132+
stopCh := s.stopCh
120133

121-
if !s.sleepWithJitter(60 * time.Second) {
134+
if !s.sleepWithJitter(stopCh, 60*time.Second) {
122135
return
123136
}
124137

@@ -127,7 +140,7 @@ func (s *Scheduler) runBillingTask(ctx context.Context) {
127140

128141
for {
129142
select {
130-
case <-s.stopCh:
143+
case <-stopCh:
131144
return
132145
case <-ticker.C:
133146
s.safeExecute("billing", func() { s.executeBillingTask(ctx) })
@@ -161,8 +174,9 @@ func (s *Scheduler) executeBillingTask(ctx context.Context) {
161174

162175
func (s *Scheduler) runAutoRechargeTask(ctx context.Context) {
163176
defer s.wg.Done()
177+
stopCh := s.stopCh
164178

165-
if !s.sleepWithJitter(60 * time.Second) {
179+
if !s.sleepWithJitter(stopCh, 60*time.Second) {
166180
return
167181
}
168182

@@ -171,7 +185,7 @@ func (s *Scheduler) runAutoRechargeTask(ctx context.Context) {
171185

172186
for {
173187
select {
174-
case <-s.stopCh:
188+
case <-stopCh:
175189
return
176190
case <-ticker.C:
177191
s.safeExecute("auto_recharge", func() { s.executeAutoRechargeTask(ctx) })
@@ -205,8 +219,9 @@ func (s *Scheduler) executeAutoRechargeTask(ctx context.Context) {
205219

206220
func (s *Scheduler) runAlertTask(ctx context.Context) {
207221
defer s.wg.Done()
222+
stopCh := s.stopCh
208223

209-
if !s.sleepWithJitter(30 * time.Second) {
224+
if !s.sleepWithJitter(stopCh, 30*time.Second) {
210225
return
211226
}
212227

@@ -215,7 +230,7 @@ func (s *Scheduler) runAlertTask(ctx context.Context) {
215230

216231
for {
217232
select {
218-
case <-s.stopCh:
233+
case <-stopCh:
219234
return
220235
case <-ticker.C:
221236
s.safeExecute("alert_check", func() { s.executeAlertTask(ctx) })
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package scheduler
2+
3+
import (
4+
"context"
5+
"os"
6+
"testing"
7+
"time"
8+
9+
"github.com/bison/api-server/pkg/logger"
10+
)
11+
12+
func TestMain(m *testing.M) {
13+
logger.Init(false)
14+
os.Exit(m.Run())
15+
}
16+
17+
// TestSchedulerRestartable verifies the scheduler can be stopped and started
18+
// again (required for leader-election re-acquisition) and that Start/Stop are
19+
// idempotent and do not deadlock.
20+
func TestSchedulerRestartable(t *testing.T) {
21+
s := NewScheduler(nil, nil, nil)
22+
ctx := context.Background()
23+
24+
done := make(chan struct{})
25+
go func() {
26+
s.Start(ctx)
27+
s.Start(ctx) // idempotent: second Start is a no-op
28+
s.Stop()
29+
s.Stop() // idempotent: second Stop is a no-op
30+
s.Start(ctx) // re-startable after Stop
31+
s.Stop()
32+
close(done)
33+
}()
34+
35+
select {
36+
case <-done:
37+
case <-time.After(5 * time.Second):
38+
t.Fatal("Start/Stop deadlocked")
39+
}
40+
}
41+
42+
func TestStopBeforeStartIsSafe(t *testing.T) {
43+
s := NewScheduler(nil, nil, nil)
44+
s.Stop() // must not panic or block when never started
45+
}

deploy/charts/bison/Chart.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ apiVersion: v2
22
name: bison
33
description: Bison - GPU 资源计费平台,基于 Capsule 多租户 + OpenCost 成本追踪
44
type: application
5-
version: 0.0.12
6-
appVersion: "0.0.12"
5+
version: 0.0.13
6+
appVersion: "0.0.13"
77
keywords:
88
- gpu
99
- billing

0 commit comments

Comments
 (0)