fix: allow login during sqlite write pressure

This commit is contained in:
ShukeBta
2026-06-11 20:06:55 +08:00
parent 7274ea19b7
commit 52d75171b2
10 changed files with 225 additions and 16 deletions
+1 -1
View File
@@ -159,7 +159,7 @@ func (s *AuthService) Login(ctx context.Context, username, password string) (*Lo
return nil, ErrInvalidCredentials
}
// 签发令牌对
tokens, err := s.tokenSvc.IssuePair(ctx, u.ID, u.Role, u.Tier)
tokens, err := s.tokenSvc.IssuePairBestEffort(ctx, u.ID, u.Role, u.Tier)
if err != nil {
return nil, err
}
+73
View File
@@ -291,6 +291,79 @@ func TestLoginRetriesTransientSQLiteBusy(t *testing.T) {
}
}
func TestLoginReturnsTokensWhenSQLiteWriteLockPersists(t *testing.T) {
ctx := context.Background()
cfg := &config.Config{}
cfg.App.DataDir = t.TempDir()
cfg.Database.DBPath = filepath.Join(cfg.App.DataDir, "busy-login-degraded.db")
cfg.Database.WALMode = true
cfg.Database.BusyTimeout = 20
cfg.Database.MaxOpenConns = 4
cfg.Database.MaxIdleConns = 2
cfg.Secrets.JWTSecret = "test-secret"
log := zap.NewNop()
db, err := database.Open(cfg, log)
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
defer func() { _ = sqlDB.Close() }()
if err := database.AutoMigrate(db); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
permissions := NewPermissionService(log, repos)
auth := NewAuthService(cfg, log, repos, NewTokenService(cfg, log, repos), permissions)
hash, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
user := &model.User{
Username: "viewer",
PasswordHash: string(hash),
Role: "user",
Tier: "free",
IsActive: true,
}
if err := repos.User.Create(ctx, user); err != nil {
t.Fatal(err)
}
tx := repos.DB.Begin()
if err := tx.Exec("UPDATE users SET updated_at = updated_at WHERE username = ?", "viewer").Error; err != nil {
t.Fatal(err)
}
resp, err := auth.Login(ctx, "viewer", "password")
if err != nil {
t.Fatalf("login should return tokens while refresh token store is delayed: %v", err)
}
if resp == nil || resp.Tokens == nil || resp.Tokens.AccessToken == "" || resp.Tokens.RefreshToken == "" {
t.Fatalf("login returned incomplete token pair: %#v", resp)
}
if err := tx.Rollback().Error; err != nil {
t.Fatal(err)
}
wantHash := repository.HashToken(resp.Tokens.RefreshToken)
deadline := time.Now().Add(5 * time.Second)
for {
var count int64
if err := repos.DB.Model(&model.RefreshToken{}).Where("token_hash = ?", wantHash).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count == 1 {
return
}
if time.Now().After(deadline) {
t.Fatal("delayed refresh token store did not complete")
}
time.Sleep(100 * time.Millisecond)
}
}
func TestDefaultPermissionsAreViewerOnly(t *testing.T) {
perms := DefaultPermissions("user-1")
if !perms.CanViewDashboard || !perms.CanPlayMedia || !perms.CanExternalPlayer {
+7
View File
@@ -0,0 +1,7 @@
package service
import "github.com/ShukeBta/MediaStationGo/internal/repository"
func IsTransientDatabaseLock(err error) bool {
return repository.IsSQLiteBusyError(err)
}
+7
View File
@@ -78,6 +78,13 @@ func adminGrant(userID string) *model.UserPermission {
}
}
func FallbackPermissions(userID, role string) *model.UserPermission {
if role == "admin" {
return adminGrant(userID)
}
return DefaultPermissions(userID)
}
// Effective returns the permission set the React UI should consume.
// Admins skip the table entirely and get a synthetic all-grant row.
func (s *PermissionService) Effective(ctx context.Context, userID string) (*model.UserPermission, error) {
+75 -4
View File
@@ -25,6 +25,8 @@ const (
RefreshTokenLength = 32
)
const loginRefreshTokenStoreTimeout = 750 * time.Millisecond
// Claims 是 JWT 载荷(复制自 middleware 以避免循环导入)。
type Claims struct {
UserID string `json:"uid"`
@@ -62,6 +64,17 @@ var (
// IssuePair 为用户签发新的令牌对。
func (s *TokenService) IssuePair(ctx context.Context, userID, role, tier string) (*TokenPair, error) {
return s.issuePair(ctx, userID, role, tier, false)
}
// IssuePairBestEffort 为登录签发令牌。SQLite 被后台扫描长期写锁占用时,
// 登录不能因为 refresh token 暂时无法落库而失败:先返回可用 access token,
// 再在后台把 refresh token 补写进库。
func (s *TokenService) IssuePairBestEffort(ctx context.Context, userID, role, tier string) (*TokenPair, error) {
return s.issuePair(ctx, userID, role, tier, true)
}
func (s *TokenService) issuePair(ctx context.Context, userID, role, tier string, bestEffort bool) (*TokenPair, error) {
// 生成 Access Token
accessToken, err := s.issueAccessToken(userID, role, tier)
if err != nil {
@@ -81,11 +94,23 @@ func (s *TokenService) IssuePair(ctx context.Context, userID, role, tier string)
TokenHash: tokenHash,
ExpiresAt: time.Now().Add(RefreshTokenDuration),
}
if err := s.repo.RefreshToken.Create(ctx, rt); err != nil {
return nil, err
storeCtx := ctx
cancel := func() {}
if bestEffort {
storeCtx, cancel = context.WithTimeout(context.Background(), loginRefreshTokenStoreTimeout)
}
if err := s.repo.RefreshToken.RevokeOldestActiveByUserID(ctx, userID, s.maxActiveRefreshTokens(ctx)); err != nil {
s.log.Warn("failed to enforce refresh token session limit", zap.String("user_id", userID), zap.Error(err))
err = s.storeRefreshToken(storeCtx, rt)
cancel()
if err != nil {
if !bestEffort {
return nil, err
}
if s.log != nil {
s.log.Warn("refresh token store delayed; login will continue",
zap.String("user_id", userID),
zap.Error(err))
}
go s.storeRefreshTokenEventually(userID, tokenHash, rt.ExpiresAt)
}
return &TokenPair{
@@ -96,6 +121,52 @@ func (s *TokenService) IssuePair(ctx context.Context, userID, role, tier string)
}, nil
}
func (s *TokenService) storeRefreshToken(ctx context.Context, rt *model.RefreshToken) error {
if err := s.repo.RefreshToken.Create(ctx, rt); err != nil {
return err
}
if err := s.repo.RefreshToken.RevokeOldestActiveByUserID(ctx, rt.UserID, s.maxActiveRefreshTokens(ctx)); err != nil && s.log != nil {
s.log.Warn("failed to enforce refresh token session limit", zap.String("user_id", rt.UserID), zap.Error(err))
}
return nil
}
func (s *TokenService) storeRefreshTokenEventually(userID, tokenHash string, expiresAt time.Time) {
delay := 500 * time.Millisecond
for attempt := 1; attempt <= 30; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
err := s.storeRefreshToken(ctx, &model.RefreshToken{
UserID: userID,
TokenHash: tokenHash,
ExpiresAt: expiresAt,
})
cancel()
if err == nil {
return
}
if !repository.IsSQLiteBusyError(err) && !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
if s.log != nil {
s.log.Warn("refresh token delayed store failed permanently", zap.String("user_id", userID), zap.Error(err))
}
return
}
if s.log != nil && (attempt == 1 || attempt%10 == 0) {
s.log.Warn("refresh token delayed store still waiting",
zap.String("user_id", userID),
zap.Int("attempt", attempt),
zap.Error(err))
}
timer := time.NewTimer(delay)
<-timer.C
if delay < 10*time.Second {
delay *= 2
}
}
if s.log != nil {
s.log.Warn("refresh token delayed store gave up", zap.String("user_id", userID))
}
}
func (s *TokenService) maxActiveRefreshTokens(ctx context.Context) int {
cfg := loadBotConfig(ctx, s.repo)
if cfg.MaxLoggedClients < 1 {