feat: complete shared database tenant sharding

This commit is contained in:
richjohnrambo
2026-09-23 02:19:07 +08:00
parent 1ec2feee47
commit e37b52c3c0
37 changed files with 946 additions and 1420 deletions
+13 -171
View File
@@ -4,203 +4,45 @@
package mysql
import (
"database/sql"
"errors"
"time"
t "github.com/tinode/chat/server/store/types"
)
func (a *adapter) routedLoginLockoutAdapter(id t.TenantID) (*adapter, error) {
if err := validTenant(id); err != nil {
return nil, err
}
if a.tenantScopedDB {
return a, nil
}
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
return tenantAdp, nil
}
type loginLockoutRow struct {
ID int64 `db:"id"`
UserID sql.NullInt64 `db:"user_id"`
AccountHash string `db:"account_hash"`
FailedAttempts int `db:"failed_attempts"`
WindowStartAt sql.NullTime `db:"window_started_at"`
LockedUntil sql.NullTime `db:"locked_until"`
LastAttemptAt sql.NullTime `db:"last_attempt_at"`
UpdatedAt sql.NullTime `db:"updated_at"`
}
func loginLockoutFromRow(id t.TenantID, row loginLockoutRow) *t.LoginLockout {
out := &t.LoginLockout{
TenantID: id,
ID: row.ID,
AccountHash: row.AccountHash,
FailedAttempts: row.FailedAttempts,
}
if row.UserID.Valid {
out.UserID = row.UserID.Int64
}
if row.WindowStartAt.Valid {
v := row.WindowStartAt.Time
out.WindowStartAt = &v
}
if row.LockedUntil.Valid {
v := row.LockedUntil.Time
out.LockedUntil = &v
}
if row.LastAttemptAt.Valid {
v := row.LastAttemptAt.Time
out.LastAttemptAt = &v
}
if row.UpdatedAt.Valid {
v := row.UpdatedAt.Time
out.UpdatedAt = &v
}
return out
}
const loginLockoutColumns = `id,user_id,account_hash,failed_attempts,window_started_at,locked_until,last_attempt_at,updated_at`
// TenantLoginLockoutGet reads the lockout counter for the given account hash.
// Login lockout counters are runtime security state and are stored in Redis by
// the store layer. The MySQL adapter keeps these methods only to satisfy older
// adapter interfaces while preventing accidental writes to im_login_lockouts.
func (a *adapter) TenantLoginLockoutGet(id t.TenantID, accountHash string) (*t.LoginLockout, error) {
tenantAdp, err := a.routedLoginLockoutAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantLoginLockoutGet(id, accountHash)
}
if accountHash == "" {
if err := validTenant(id); err != nil || accountHash == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var row loginLockoutRow
err = a.db.GetContext(ctx, &row,
`SELECT `+loginLockoutColumns+` FROM im_login_lockouts WHERE account_hash=?`, accountHash)
if errors.Is(err, sql.ErrNoRows) {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
return loginLockoutFromRow(id, row), nil
return nil, t.ErrUnsupported
}
// TenantLoginLockoutRecordFailure atomically inserts or increments the failure
// counter. windowCutoff is "now minus window seconds": an older window start
// resets the counter to 1. The updated row is returned to the caller.
func (a *adapter) TenantLoginLockoutRecordFailure(id t.TenantID, accountHash string, userID *int64, windowCutoff, now time.Time) (*t.LoginLockout, error) {
tenantAdp, err := a.routedLoginLockoutAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantLoginLockoutRecordFailure(id, accountHash, userID, windowCutoff, now)
}
if accountHash == "" || now.IsZero() {
if err := validTenant(id); err != nil || accountHash == "" || now.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rowID, err := nextSnowflakeID()
if err != nil {
return nil, err
}
// MySQL evaluates ON DUPLICATE KEY UPDATE assignments left to right, so
// failed_attempts is recomputed before window_started_at is overwritten.
_, err = a.db.ExecContext(ctx, `INSERT INTO im_login_lockouts
(id,user_id,account_hash,failed_attempts,window_started_at,last_attempt_at)
VALUES(?,?,?,1,?,?)
ON DUPLICATE KEY UPDATE
failed_attempts = IF(window_started_at IS NULL OR window_started_at < ?, 1, failed_attempts + 1),
window_started_at = IF(window_started_at IS NULL OR window_started_at < ?, ?, window_started_at),
last_attempt_at = ?,
user_id = COALESCE(user_id, ?)`,
rowID, userID, accountHash, now, now,
windowCutoff, windowCutoff, now, now, userID)
if err != nil {
return nil, err
}
var row loginLockoutRow
err = a.db.GetContext(ctx, &row,
`SELECT `+loginLockoutColumns+` FROM im_login_lockouts WHERE account_hash=?`, accountHash)
if err != nil {
return nil, err
}
return loginLockoutFromRow(id, row), nil
return nil, t.ErrUnsupported
}
// TenantLoginLockoutLock sets locked_until unless a newer lock already covers now.
func (a *adapter) TenantLoginLockoutLock(id t.TenantID, accountHash string, lockedUntil, now time.Time) error {
tenantAdp, err := a.routedLoginLockoutAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantLoginLockoutLock(id, accountHash, lockedUntil, now)
}
if accountHash == "" || lockedUntil.IsZero() || now.IsZero() {
if err := validTenant(id); err != nil || accountHash == "" || lockedUntil.IsZero() || now.IsZero() {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx,
`UPDATE im_login_lockouts SET locked_until=?, updated_at=? WHERE account_hash=? AND (locked_until IS NULL OR locked_until < ?)`,
lockedUntil, now, accountHash, now)
return err
return t.ErrUnsupported
}
// TenantLoginLockoutReset removes the counter after a successful login.
func (a *adapter) TenantLoginLockoutReset(id t.TenantID, accountHash string) error {
tenantAdp, err := a.routedLoginLockoutAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantLoginLockoutReset(id, accountHash)
}
if accountHash == "" {
if err := validTenant(id); err != nil || accountHash == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx, `DELETE FROM im_login_lockouts WHERE account_hash=?`, accountHash)
return err
return t.ErrUnsupported
}
// TenantLoginLockoutRelease clears the lock and zeroes the counter (admin unlock).
func (a *adapter) TenantLoginLockoutRelease(id t.TenantID, accountHash string, now time.Time) error {
tenantAdp, err := a.routedLoginLockoutAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantLoginLockoutRelease(id, accountHash, now)
}
if accountHash == "" || now.IsZero() {
if err := validTenant(id); err != nil || accountHash == "" || now.IsZero() {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.db.ExecContext(ctx,
`UPDATE im_login_lockouts SET failed_attempts=0, window_started_at=NULL, locked_until=NULL, updated_at=? WHERE account_hash=?`,
now, accountHash)
return err
return t.ErrUnsupported
}