fix(ccm,ocm): restore fixed usage polling

Remove the poll_interval config surface from CCM and OCM so both services fall back to the built-in 1h polling cadence again. Also isolate CCM credential lock mocking per test instance so the access-token refresh tests stop racing on shared global state.
This commit is contained in:
世界
2026-03-26 14:01:24 +08:00
parent 6721dff48a
commit 1774d98793
15 changed files with 41 additions and 98 deletions

View File

@@ -104,8 +104,7 @@ A single OAuth credential file. The `type` field can be omitted (defaults to `de
"tag": "pool",
"type": "balancer",
"strategy": "",
"credentials": ["a", "b"],
"poll_interval": "60s"
"credentials": ["a", "b"]
}
```
@@ -113,7 +112,6 @@ Assigns sessions to default credentials based on the selected strategy. Sessions
- `strategy`: Selection strategy. One of `least_used` `round_robin` `random` `fallback`. `least_used` will be used by default.
- `credentials`: ==Required== List of default credential tags.
- `poll_interval`: How often to poll upstream usage API. Default `60s`.
##### Fallback Strategy
@@ -122,15 +120,13 @@ Assigns sessions to default credentials based on the selected strategy. Sessions
"tag": "backup",
"type": "balancer",
"strategy": "fallback",
"credentials": ["a", "b"],
"poll_interval": "30s"
"credentials": ["a", "b"]
}
```
A balancer with `strategy: "fallback"` uses credentials in order. It falls through to the next when the current one is exhausted.
- `credentials`: ==Required== Ordered list of default credential tags.
- `poll_interval`: How often to poll upstream usage API. Default `60s`.
##### External Credential
@@ -144,8 +140,7 @@ A balancer with `strategy: "fallback"` uses credentials in order. It falls throu
"token": "",
"reverse": false,
"detour": "",
"usages_path": "",
"poll_interval": "30m"
"usages_path": ""
}
```
@@ -158,7 +153,6 @@ Proxies requests through a remote CCM instance instead of using a local OAuth cr
- `reverse`: Enable connector mode. Requires `url`. A connector dials out to `/ccm/v1/reverse` on the remote instance and cannot serve local requests directly. When `url` is set without `reverse`, the credential proxies requests through the remote instance normally and prefers an established reverse connection when one is available.
- `detour`: Outbound tag for connecting to the remote instance.
- `usages_path`: Optional usage tracking file.
- `poll_interval`: How often to poll the remote status endpoint. Default `30m`.
#### usages_path
@@ -290,7 +284,6 @@ claude
{
"tag": "pool",
"type": "balancer",
"poll_interval": "60s",
"credentials": ["a", "b"]
}
],

View File

@@ -104,8 +104,7 @@ Claude Code OAuth 凭据文件的路径。
"tag": "pool",
"type": "balancer",
"strategy": "",
"credentials": ["a", "b"],
"poll_interval": "60s"
"credentials": ["a", "b"]
}
```
@@ -113,7 +112,6 @@ Claude Code OAuth 凭据文件的路径。
- `strategy`:选择策略。可选值:`least_used` `round_robin` `random` `fallback`。默认使用 `least_used`
- `credentials`==必填== 默认凭据标签列表。
- `poll_interval`:轮询上游使用 API 的间隔。默认 `60s`
##### 回退策略
@@ -122,15 +120,13 @@ Claude Code OAuth 凭据文件的路径。
"tag": "backup",
"type": "balancer",
"strategy": "fallback",
"credentials": ["a", "b"],
"poll_interval": "30s"
"credentials": ["a", "b"]
}
```
`strategy` 设为 `fallback` 的均衡凭据会按顺序使用凭据。当前凭据耗尽后切换到下一个。
- `credentials`==必填== 有序的默认凭据标签列表。
- `poll_interval`:轮询上游使用 API 的间隔。默认 `60s`
##### 外部凭据
@@ -144,8 +140,7 @@ Claude Code OAuth 凭据文件的路径。
"token": "",
"reverse": false,
"detour": "",
"usages_path": "",
"poll_interval": "30m"
"usages_path": ""
}
```
@@ -158,7 +153,6 @@ Claude Code OAuth 凭据文件的路径。
- `reverse`:启用连接器模式。要求设置 `url`。启用后,此凭据会主动拨出到远程实例的 `/ccm/v1/reverse`,且不能直接为本地请求提供服务。当设置了 `url` 但未启用 `reverse` 时,此凭据会正常通过远程实例转发请求,并在反向连接建立后优先使用该反向连接。
- `detour`:用于连接远程实例的出站标签。
- `usages_path`:可选的使用跟踪文件。
- `poll_interval`:轮询远程状态端点的间隔。默认 `30m`
#### usages_path
@@ -290,7 +284,6 @@ claude
{
"tag": "pool",
"type": "balancer",
"poll_interval": "60s",
"credentials": ["a", "b"]
}
],

View File

@@ -100,8 +100,7 @@ A single OAuth credential file. The `type` field can be omitted (defaults to `de
"tag": "pool",
"type": "balancer",
"strategy": "",
"credentials": ["a", "b"],
"poll_interval": "60s"
"credentials": ["a", "b"]
}
```
@@ -109,7 +108,6 @@ Assigns sessions to default credentials based on the selected strategy. Sessions
- `strategy`: Selection strategy. One of `least_used` `round_robin` `random` `fallback`. `least_used` will be used by default.
- `credentials`: ==Required== List of default credential tags.
- `poll_interval`: How often to poll upstream usage API. Default `60s`.
##### Fallback Strategy
@@ -118,15 +116,13 @@ Assigns sessions to default credentials based on the selected strategy. Sessions
"tag": "backup",
"type": "balancer",
"strategy": "fallback",
"credentials": ["a", "b"],
"poll_interval": "30s"
"credentials": ["a", "b"]
}
```
A balancer with `strategy: "fallback"` uses credentials in order. It falls through to the next when the current one is exhausted.
- `credentials`: ==Required== Ordered list of default credential tags.
- `poll_interval`: How often to poll upstream usage API. Default `60s`.
##### External Credential
@@ -140,8 +136,7 @@ A balancer with `strategy: "fallback"` uses credentials in order. It falls throu
"token": "",
"reverse": false,
"detour": "",
"usages_path": "",
"poll_interval": "30m"
"usages_path": ""
}
```
@@ -154,7 +149,6 @@ Proxies requests through a remote OCM instance instead of using a local OAuth cr
- `reverse`: Enable connector mode. Requires `url`. A connector dials out to `/ocm/v1/reverse` on the remote instance and cannot serve local requests directly. When `url` is set without `reverse`, the credential proxies requests through the remote instance normally and prefers an established reverse connection when one is available.
- `detour`: Outbound tag for connecting to the remote instance.
- `usages_path`: Optional usage tracking file.
- `poll_interval`: How often to poll the remote status endpoint. Default `30m`.
#### usages_path
@@ -342,7 +336,6 @@ codex --profile ocm
{
"tag": "pool",
"type": "balancer",
"poll_interval": "60s",
"credentials": ["a", "b"]
}
],

View File

@@ -100,8 +100,7 @@ OpenAI OAuth 凭据文件的路径。
"tag": "pool",
"type": "balancer",
"strategy": "",
"credentials": ["a", "b"],
"poll_interval": "60s"
"credentials": ["a", "b"]
}
```
@@ -109,7 +108,6 @@ OpenAI OAuth 凭据文件的路径。
- `strategy`:选择策略。可选值:`least_used` `round_robin` `random` `fallback`。默认使用 `least_used`
- `credentials`==必填== 默认凭据标签列表。
- `poll_interval`:轮询上游使用 API 的间隔。默认 `60s`
##### 回退策略
@@ -118,15 +116,13 @@ OpenAI OAuth 凭据文件的路径。
"tag": "backup",
"type": "balancer",
"strategy": "fallback",
"credentials": ["a", "b"],
"poll_interval": "30s"
"credentials": ["a", "b"]
}
```
`strategy` 设为 `fallback` 的均衡凭据会按顺序使用凭据。当前凭据耗尽后切换到下一个。
- `credentials`==必填== 有序的默认凭据标签列表。
- `poll_interval`:轮询上游使用 API 的间隔。默认 `60s`
##### 外部凭据
@@ -140,8 +136,7 @@ OpenAI OAuth 凭据文件的路径。
"token": "",
"reverse": false,
"detour": "",
"usages_path": "",
"poll_interval": "30m"
"usages_path": ""
}
```
@@ -154,7 +149,6 @@ OpenAI OAuth 凭据文件的路径。
- `reverse`:启用连接器模式。要求设置 `url`。启用后,此凭据会主动拨出到远程实例的 `/ocm/v1/reverse`,且不能直接为本地请求提供服务。当设置了 `url` 但未启用 `reverse` 时,此凭据会正常通过远程实例转发请求,并在反向连接建立后优先使用该反向连接。
- `detour`:用于连接远程实例的出站标签。
- `usages_path`:可选的使用跟踪文件。
- `poll_interval`:轮询远程状态端点的间隔。默认 `30m`
#### usages_path
@@ -343,7 +337,6 @@ codex --profile ocm
{
"tag": "pool",
"type": "balancer",
"poll_interval": "60s",
"credentials": ["a", "b"]
}
],

View File

@@ -89,16 +89,14 @@ type CCMDefaultCredentialOptions struct {
type CCMBalancerCredentialOptions struct {
Strategy string `json:"strategy,omitempty"`
Credentials badoption.Listable[string] `json:"credentials"`
PollInterval badoption.Duration `json:"poll_interval,omitempty"`
RebalanceThreshold float64 `json:"rebalance_threshold,omitempty"`
}
type CCMExternalCredentialOptions struct {
URL string `json:"url,omitempty"`
ServerOptions
Token string `json:"token"`
Reverse bool `json:"reverse,omitempty"`
Detour string `json:"detour,omitempty"`
UsagesPath string `json:"usages_path,omitempty"`
PollInterval badoption.Duration `json:"poll_interval,omitempty"`
Token string `json:"token"`
Reverse bool `json:"reverse,omitempty"`
Detour string `json:"detour,omitempty"`
UsagesPath string `json:"usages_path,omitempty"`
}

View File

@@ -88,16 +88,14 @@ type OCMDefaultCredentialOptions struct {
type OCMBalancerCredentialOptions struct {
Strategy string `json:"strategy,omitempty"`
Credentials badoption.Listable[string] `json:"credentials"`
PollInterval badoption.Duration `json:"poll_interval,omitempty"`
RebalanceThreshold float64 `json:"rebalance_threshold,omitempty"`
}
type OCMExternalCredentialOptions struct {
URL string `json:"url,omitempty"`
ServerOptions
Token string `json:"token"`
Reverse bool `json:"reverse,omitempty"`
Detour string `json:"detour,omitempty"`
UsagesPath string `json:"usages_path,omitempty"`
PollInterval badoption.Duration `json:"poll_interval,omitempty"`
Token string `json:"token"`
Reverse bool `json:"reverse,omitempty"`
Detour string `json:"detour,omitempty"`
UsagesPath string `json:"usages_path,omitempty"`
}

View File

@@ -2,7 +2,6 @@ package ccm
import (
"context"
"time"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
@@ -48,7 +47,7 @@ func buildCredentialProviders(
if err != nil {
return nil, nil, err
}
providers[credentialOption.Tag] = newBalancerProvider(subCredentials, credentialOption.BalancerOptions.Strategy, time.Duration(credentialOption.BalancerOptions.PollInterval), credentialOption.BalancerOptions.RebalanceThreshold, logger)
providers[credentialOption.Tag] = newBalancerProvider(subCredentials, credentialOption.BalancerOptions.Strategy, credentialOption.BalancerOptions.RebalanceThreshold, logger)
}
}

View File

@@ -26,8 +26,6 @@ import (
"github.com/sagernet/sing/common/observable"
)
var acquireCredentialLockFunc = acquireCredentialLock
type claudeProfileSnapshot struct {
OAuthAccount *claudeOAuthAccount
AccountType string
@@ -56,6 +54,7 @@ type defaultCredential struct {
capWeekly float64
usageTracker *AggregatedUsage
forwardHTTPClient *http.Client
acquireLock func(string) (func(), error)
logger log.ContextLogger
watcher *fswatch.Watcher
watcherRetryAt time.Time
@@ -122,6 +121,7 @@ func newDefaultCredential(ctx context.Context, tag string, options option.CCMDef
cap5h: cap5h,
capWeekly: capWeekly,
forwardHTTPClient: httpClient,
acquireLock: acquireCredentialLock,
logger: logger,
requestContext: requestContext,
cancelRequests: cancelRequests,
@@ -363,7 +363,11 @@ func (c *defaultCredential) tryRefreshCredentials(force bool) bool {
if !c.shouldAttemptRefresh(currentCredentials, force) {
return false
}
release, err := acquireCredentialLockFunc(c.configDir)
acquireLock := c.acquireLock
if acquireLock == nil {
acquireLock = acquireCredentialLock
}
release, err := acquireLock(c.configDir)
if err != nil {
c.logger.Debug("acquire credential lock for ", c.tag, ": ", err)
return false

View File

@@ -31,13 +31,9 @@ func TestGetAccessTokenReturnsExistingTokenWhenLockFails(t *testing.T) {
t.Fatal(err)
}
originalLockFunc := acquireCredentialLockFunc
acquireCredentialLockFunc = func(string) (func(), error) {
credential.acquireLock = func(string) (func(), error) {
return nil, errors.New("locked")
}
t.Cleanup(func() {
acquireCredentialLockFunc = originalLockFunc
})
token, err := credential.getAccessToken()
if err != nil {

View File

@@ -38,7 +38,6 @@ type externalCredential struct {
state credentialState
stateAccess sync.RWMutex
pollAccess sync.Mutex
pollInterval time.Duration
usageTracker *AggregatedUsage
logger log.ContextLogger
@@ -113,18 +112,12 @@ func externalCredentialReversePath(parsedURL *url.URL, endpointPath string) stri
}
func newExternalCredential(ctx context.Context, tag string, options option.CCMExternalCredentialOptions, logger log.ContextLogger) (*externalCredential, error) {
pollInterval := time.Duration(options.PollInterval)
if pollInterval <= 0 {
pollInterval = 30 * time.Minute
}
requestContext, cancelRequests := context.WithCancel(context.Background())
reverseContext, reverseCancel := context.WithCancel(context.Background())
credential := &externalCredential{
tag: tag,
token: options.Token,
pollInterval: pollInterval,
logger: logger,
requestContext: requestContext,
cancelRequests: cancelRequests,
@@ -355,9 +348,9 @@ func (c *externalCredential) markRateLimited(resetAt time.Time) {
}
func (c *externalCredential) markUpstreamRejected() {
c.logger.Warn("upstream rejected credential ", c.tag, ", marking unavailable for ", log.FormatDuration(c.pollInterval))
c.logger.Warn("upstream rejected credential ", c.tag, ", marking unavailable for ", log.FormatDuration(defaultPollInterval))
c.stateAccess.Lock()
c.state.upstreamRejectedUntil = time.Now().Add(c.pollInterval)
c.state.upstreamRejectedUntil = time.Now().Add(defaultPollInterval)
c.state.setAvailability(availabilityStateTemporarilyBlocked, availabilityReasonUpstreamRejected, c.state.upstreamRejectedUntil)
shouldInterrupt := c.checkTransitionLocked()
c.stateAccess.Unlock()

View File

@@ -112,7 +112,6 @@ type balancerProvider struct {
credentials []Credential
strategy string
roundRobinIndex atomic.Uint64
pollInterval time.Duration
rebalanceThreshold float64
sessionAccess sync.RWMutex
sessions map[string]sessionEntry
@@ -121,14 +120,10 @@ type balancerProvider struct {
logger log.ContextLogger
}
func newBalancerProvider(credentials []Credential, strategy string, pollInterval time.Duration, rebalanceThreshold float64, logger log.ContextLogger) *balancerProvider {
if pollInterval <= 0 {
pollInterval = defaultPollInterval
}
func newBalancerProvider(credentials []Credential, strategy string, rebalanceThreshold float64, logger log.ContextLogger) *balancerProvider {
return &balancerProvider{
credentials: credentials,
strategy: strategy,
pollInterval: pollInterval,
rebalanceThreshold: rebalanceThreshold,
sessions: make(map[string]sessionEntry),
credentialInterrupts: make(map[credentialInterruptKey]credentialInterruptEntry),
@@ -383,14 +378,14 @@ func (p *balancerProvider) pollIfStale() {
p.interruptAccess.Unlock()
for _, credential := range p.credentials {
if time.Since(credential.lastUpdatedTime()) > credential.pollBackoff(p.pollInterval) {
if time.Since(credential.lastUpdatedTime()) > credential.pollBackoff(defaultPollInterval) {
credential.pollUsage()
}
}
}
func (p *balancerProvider) pollCredentialIfStale(credential Credential) {
if time.Since(credential.lastUpdatedTime()) > credential.pollBackoff(p.pollInterval) {
if time.Since(credential.lastUpdatedTime()) > credential.pollBackoff(defaultPollInterval) {
credential.pollUsage()
}
}

View File

@@ -85,6 +85,7 @@ func newTestDefaultCredential(t *testing.T, credentialPath string, transport htt
cap5h: 99,
capWeekly: 99,
forwardHTTPClient: &http.Client{Transport: transport},
acquireLock: acquireCredentialLock,
logger: log.NewNOPFactory().Logger(),
requestContext: requestContext,
cancelRequests: cancelRequests,

View File

@@ -2,7 +2,6 @@ package ocm
import (
"context"
"time"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
@@ -48,7 +47,7 @@ func buildOCMCredentialProviders(
if err != nil {
return nil, nil, err
}
providers[credentialOption.Tag] = newBalancerProvider(subCredentials, credentialOption.BalancerOptions.Strategy, time.Duration(credentialOption.BalancerOptions.PollInterval), credentialOption.BalancerOptions.RebalanceThreshold, logger)
providers[credentialOption.Tag] = newBalancerProvider(subCredentials, credentialOption.BalancerOptions.Strategy, credentialOption.BalancerOptions.RebalanceThreshold, logger)
}
}

View File

@@ -40,7 +40,6 @@ type externalCredential struct {
state credentialState
stateAccess sync.RWMutex
pollAccess sync.Mutex
pollInterval time.Duration
usageTracker *AggregatedUsage
logger log.ContextLogger
@@ -131,18 +130,12 @@ func externalCredentialReversePath(parsedURL *url.URL, endpointPath string) stri
}
func newExternalCredential(ctx context.Context, tag string, options option.OCMExternalCredentialOptions, logger log.ContextLogger) (*externalCredential, error) {
pollInterval := time.Duration(options.PollInterval)
if pollInterval <= 0 {
pollInterval = 30 * time.Minute
}
requestContext, cancelRequests := context.WithCancel(context.Background())
reverseContext, reverseCancel := context.WithCancel(context.Background())
credential := &externalCredential{
tag: tag,
token: options.Token,
pollInterval: pollInterval,
logger: logger,
requestContext: requestContext,
cancelRequests: cancelRequests,
@@ -377,9 +370,9 @@ func (c *externalCredential) markRateLimited(resetAt time.Time) {
}
func (c *externalCredential) markUpstreamRejected() {
c.logger.Warn("upstream rejected credential ", c.tag, ", marking unavailable for ", log.FormatDuration(c.pollInterval))
c.logger.Warn("upstream rejected credential ", c.tag, ", marking unavailable for ", log.FormatDuration(defaultPollInterval))
c.stateAccess.Lock()
c.state.upstreamRejectedUntil = time.Now().Add(c.pollInterval)
c.state.upstreamRejectedUntil = time.Now().Add(defaultPollInterval)
c.state.setAvailability(availabilityStateTemporarilyBlocked, availabilityReasonUpstreamRejected, c.state.upstreamRejectedUntil)
shouldInterrupt := c.checkTransitionLocked()
c.stateAccess.Unlock()

View File

@@ -112,7 +112,6 @@ type balancerProvider struct {
credentials []Credential
strategy string
roundRobinIndex atomic.Uint64
pollInterval time.Duration
rebalanceThreshold float64
sessionAccess sync.RWMutex
sessions map[string]sessionEntry
@@ -125,14 +124,10 @@ func compositeCredentialSelectable(credential Credential) bool {
return !credential.ocmIsAPIKeyMode()
}
func newBalancerProvider(credentials []Credential, strategy string, pollInterval time.Duration, rebalanceThreshold float64, logger log.ContextLogger) *balancerProvider {
if pollInterval <= 0 {
pollInterval = defaultPollInterval
}
func newBalancerProvider(credentials []Credential, strategy string, rebalanceThreshold float64, logger log.ContextLogger) *balancerProvider {
return &balancerProvider{
credentials: credentials,
strategy: strategy,
pollInterval: pollInterval,
rebalanceThreshold: rebalanceThreshold,
sessions: make(map[string]sessionEntry),
credentialInterrupts: make(map[credentialInterruptKey]credentialInterruptEntry),
@@ -410,14 +405,14 @@ func (p *balancerProvider) pollIfStale() {
p.interruptAccess.Unlock()
for _, credential := range p.credentials {
if time.Since(credential.lastUpdatedTime()) > credential.pollBackoff(p.pollInterval) {
if time.Since(credential.lastUpdatedTime()) > credential.pollBackoff(defaultPollInterval) {
credential.pollUsage()
}
}
}
func (p *balancerProvider) pollCredentialIfStale(credential Credential) {
if time.Since(credential.lastUpdatedTime()) > credential.pollBackoff(p.pollInterval) {
if time.Since(credential.lastUpdatedTime()) > credential.pollBackoff(defaultPollInterval) {
credential.pollUsage()
}
}