fix: reduce persistence lag and retry audit deadlocks

This commit is contained in:
richjohnrambo
2026-09-27 16:33:05 +08:00
parent 704c65a7a4
commit d96f661f30
8 changed files with 185 additions and 18 deletions
@@ -26,6 +26,7 @@ CREATE TABLE IF NOT EXISTS im_user_login_logs (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), PRIMARY KEY (id),
INDEX idx_im_user_login_tenant_time (tenant_id, login_at DESC, id DESC), INDEX idx_im_user_login_tenant_time (tenant_id, login_at DESC, id DESC),
INDEX idx_im_user_login_session_close (tenant_id, session_id, logout_at),
INDEX idx_im_user_login_user_time (user_id, login_at DESC, id DESC), INDEX idx_im_user_login_user_time (user_id, login_at DESC, id DESC),
INDEX idx_im_user_login_ip_time (ip, login_at DESC, id DESC), INDEX idx_im_user_login_ip_time (ip, login_at DESC, id DESC),
INDEX idx_im_user_login_device_time (device_id(64), login_at DESC), INDEX idx_im_user_login_device_time (device_id(64), login_at DESC),
+2 -2
View File
@@ -4671,7 +4671,7 @@ func (a *adapter) FileUserHasAccess(fid string, uid t.Uid) (bool, error) {
return a.db.GetContext(ctx, &allowed, query, args...) return a.db.GetContext(ctx, &allowed, query, args...)
} }
err := run() err := run()
for attempt := 0; isRetryableMySQLReadErr(err) && attempt < 2; attempt++ { for attempt := 0; isRetryableMySQLError(err) && attempt < 2; attempt++ {
time.Sleep(time.Duration(attempt+1) * 25 * time.Millisecond) time.Sleep(time.Duration(attempt+1) * 25 * time.Millisecond)
err = run() err = run()
} }
@@ -4813,7 +4813,7 @@ func isIDOutOfRange(err error) bool {
return ok && myerr.Number == 1264 && strings.Contains(strings.ToLower(myerr.Message), "id") return ok && myerr.Number == 1264 && strings.Contains(strings.ToLower(myerr.Message), "id")
} }
func isRetryableMySQLReadErr(err error) bool { func isRetryableMySQLError(err error) bool {
if err == nil { if err == nil {
return false return false
} }
@@ -17,6 +17,7 @@ func ensureIMAdminGovernanceTables(exec sqlExecutor) error {
request_id VARCHAR(64) NOT NULL DEFAULT '', login_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), request_id VARCHAR(64) NOT NULL DEFAULT '', login_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
logout_at DATETIME(3), metadata JSON, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), logout_at DATETIME(3), metadata JSON, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), INDEX idx_im_user_login_tenant_time(tenant_id, login_at DESC, id DESC), PRIMARY KEY(id), INDEX idx_im_user_login_tenant_time(tenant_id, login_at DESC, id DESC),
INDEX idx_im_user_login_session_close(tenant_id, session_id, logout_at),
INDEX idx_im_user_login_user_time(user_id, login_at DESC, id DESC), INDEX idx_im_user_login_user_time(user_id, login_at DESC, id DESC),
INDEX idx_im_user_login_ip_time(ip, login_at DESC, id DESC), INDEX idx_im_user_login_ip_time(ip, login_at DESC, id DESC),
INDEX idx_im_user_login_device_time(device_id(64), login_at DESC), INDEX idx_im_user_login_device_time(device_id(64), login_at DESC),
@@ -53,5 +54,9 @@ func ensureIMAdminGovernanceTables(exec sqlExecutor) error {
return err return err
} }
} }
if err := execIgnoreMySQLError(exec,
`ALTER TABLE im_user_login_logs ADD INDEX idx_im_user_login_session_close(tenant_id,session_id,logout_at)`, 1061); err != nil {
return err
}
return nil return nil
} }
@@ -13,8 +13,8 @@ func TestEnsureIMAdminGovernanceTablesDefinesLoginAuditTenantScope(t *testing.T)
if err := ensureIMAdminGovernanceTables(exec); err != nil { if err := ensureIMAdminGovernanceTables(exec); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(exec.statements) != 3 { if len(exec.statements) != 4 {
t.Fatalf("created %d admin governance tables, want 3", len(exec.statements)) t.Fatalf("executed %d admin governance schema statements, want 4", len(exec.statements))
} }
joined := strings.ToLower(strings.Join(exec.statements, "\n")) joined := strings.ToLower(strings.Join(exec.statements, "\n"))
for _, table := range []string{"im_user_login_logs", "im_message_admin_actions", "im_user_ip_whitelists"} { for _, table := range []string{"im_user_login_logs", "im_message_admin_actions", "im_user_ip_whitelists"} {
@@ -38,4 +38,8 @@ func TestEnsureIMAdminGovernanceTablesDefinesLoginAuditTenantScope(t *testing.T)
if end < 0 || !strings.Contains(joined[start:start+end], "tenant_id bigint not null") { if end < 0 || !strings.Contains(joined[start:start+end], "tenant_id bigint not null") {
t.Fatal("im_user_login_logs must declare tenant_id") t.Fatal("im_user_login_logs must declare tenant_id")
} }
if !strings.Contains(joined, "idx_im_user_login_session_close(tenant_id, session_id, logout_at)") &&
!strings.Contains(joined, "idx_im_user_login_session_close(tenant_id,session_id,logout_at)") {
t.Fatal("im_user_login_logs must index tenant-scoped session close updates")
}
} }
+12 -1
View File
@@ -93,7 +93,18 @@ func (a *adapter) TenantLoginAuditClose(id t.TenantID, sessionID string, at time
if tenantAdp != a { if tenantAdp != a {
return tenantAdp.TenantLoginAuditClose(id, sessionID, at) return tenantAdp.TenantLoginAuditClose(id, sessionID, at)
} }
_, err = a.db.Exec("UPDATE im_user_login_logs SET logout_at=? WHERE tenant_id=? AND session_id=? AND logout_at IS NULL", at, int64(id), sessionID) return retryMySQLDeadlock(func() error {
_, execErr := a.db.Exec("UPDATE im_user_login_logs SET logout_at=? WHERE tenant_id=? AND session_id=? AND logout_at IS NULL", at, int64(id), sessionID)
return execErr
}, time.Sleep)
}
func retryMySQLDeadlock(operation func() error, sleep func(time.Duration)) error {
err := operation()
for attempt := 1; isRetryableMySQLError(err) && attempt < 4; attempt++ {
sleep(time.Duration(attempt) * 25 * time.Millisecond)
err = operation()
}
return err return err
} }
@@ -3,9 +3,13 @@
package mysql package mysql
import ( import (
"errors"
"os" "os"
"strings" "strings"
"testing" "testing"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
) )
func TestLoginAuditQueriesAreTenantScoped(t *testing.T) { func TestLoginAuditQueriesAreTenantScoped(t *testing.T) {
@@ -24,3 +28,48 @@ func TestLoginAuditQueriesAreTenantScoped(t *testing.T) {
} }
} }
} }
func TestRetryMySQLDeadlockEventuallySucceeds(t *testing.T) {
calls := 0
var delays []time.Duration
err := retryMySQLDeadlock(func() error {
calls++
if calls < 3 {
return &mysqldriver.MySQLError{Number: 1213, SQLState: [5]byte{'4', '0', '0', '0', '1'}}
}
return nil
}, func(delay time.Duration) { delays = append(delays, delay) })
if err != nil {
t.Fatal(err)
}
if calls != 3 {
t.Fatalf("operation calls=%d want=3", calls)
}
want := []time.Duration{25 * time.Millisecond, 50 * time.Millisecond}
if len(delays) != len(want) || delays[0] != want[0] || delays[1] != want[1] {
t.Fatalf("retry delays=%v want=%v", delays, want)
}
}
func TestRetryMySQLDeadlockStopsOnNonRetryableError(t *testing.T) {
want := errors.New("invalid update")
calls := 0
err := retryMySQLDeadlock(func() error {
calls++
return want
}, func(time.Duration) { t.Fatal("non-retryable error must not sleep") })
if !errors.Is(err, want) || calls != 1 {
t.Fatalf("err=%v calls=%d want=%v/1", err, calls, want)
}
}
func TestRetryMySQLDeadlockStopsAfterFourAttempts(t *testing.T) {
calls := 0
err := retryMySQLDeadlock(func() error {
calls++
return &mysqldriver.MySQLError{Number: 1205}
}, func(time.Duration) {})
if err == nil || calls != 4 {
t.Fatalf("err=%v calls=%d want non-nil/4", err, calls)
}
}
+42
View File
@@ -0,0 +1,42 @@
package store
import (
"testing"
"github.com/tinode/chat/server/store/types"
)
func TestAggregatePreparedMessageBatchUpdatesKeepsLatestSeq(t *testing.T) {
user1 := types.Uid(1)
user2 := types.Uid(2)
items := []PreparedMessageBatchItem{
{Message: &types.Message{Topic: "p2p-b", From: user1.String(), SeqId: 3}, ReadBySender: true},
{Message: &types.Message{Topic: "p2p-a", From: user1.String(), SeqId: 2}, ReadBySender: true},
{Message: &types.Message{Topic: "p2p-a", From: user2.String(), SeqId: 5}, ReadBySender: true},
{Message: &types.Message{Topic: "p2p-a", From: user1.String(), SeqId: 6}, ReadBySender: true},
{Message: &types.Message{Topic: "p2p-b", From: user1.String(), SeqId: 7}, ReadBySender: false},
{},
}
topics, reads := aggregatePreparedMessageBatchUpdates(items)
if len(topics) != 2 || topics[0].topic != "p2p-a" || topics[0].message.SeqId != 6 ||
topics[1].topic != "p2p-b" || topics[1].message.SeqId != 7 {
t.Fatalf("unexpected topic updates: %+v", topics)
}
if len(reads) != 3 {
t.Fatalf("read updates=%d want=3: %+v", len(reads), reads)
}
got := make(map[string]int64, len(reads))
for _, update := range reads {
got[update.topic+":"+update.from.String()] = update.message.SeqId
}
for key, want := range map[string]int64{
"p2p-a:" + user1.String(): 6,
"p2p-a:" + user2.String(): 5,
"p2p-b:" + user1.String(): 3,
} {
if got[key] != want {
t.Fatalf("read update %s=%d want=%d; all=%v", key, got[key], want, got)
}
}
}
+68 -13
View File
@@ -3682,6 +3682,58 @@ type PreparedMessageBatchItem struct {
ReadBySender bool ReadBySender bool
} }
type preparedMessageBatchTopicUpdate struct {
topic string
message *types.Message
}
type preparedMessageBatchReadUpdate struct {
topic string
from types.Uid
message *types.Message
}
func aggregatePreparedMessageBatchUpdates(items []PreparedMessageBatchItem) ([]preparedMessageBatchTopicUpdate, []preparedMessageBatchReadUpdate) {
topics := make(map[string]*types.Message)
reads := make(map[string]preparedMessageBatchReadUpdate)
for _, item := range items {
msg := item.Message
if msg == nil {
continue
}
if previous := topics[msg.Topic]; previous == nil || msg.SeqId > previous.SeqId {
topics[msg.Topic] = msg
}
from := types.ParseUid(msg.From)
if !item.ReadBySender || from.IsZero() {
continue
}
key := msg.Topic + "\x00" + from.String()
if previous, ok := reads[key]; !ok || msg.SeqId > previous.message.SeqId {
reads[key] = preparedMessageBatchReadUpdate{topic: msg.Topic, from: from, message: msg}
}
}
topicKeys := make([]string, 0, len(topics))
for topic := range topics {
topicKeys = append(topicKeys, topic)
}
sort.Strings(topicKeys)
topicUpdates := make([]preparedMessageBatchTopicUpdate, 0, len(topicKeys))
for _, topic := range topicKeys {
topicUpdates = append(topicUpdates, preparedMessageBatchTopicUpdate{topic: topic, message: topics[topic]})
}
readKeys := make([]string, 0, len(reads))
for key := range reads {
readKeys = append(readKeys, key)
}
sort.Strings(readKeys)
readUpdates := make([]preparedMessageBatchReadUpdate, 0, len(readKeys))
for _, key := range readKeys {
readUpdates = append(readUpdates, reads[key])
}
return topicUpdates, readUpdates
}
func PersistPreparedMessageBatch(tenantID types.TenantID, items []PreparedMessageBatchItem) error { func PersistPreparedMessageBatch(tenantID types.TenantID, items []PreparedMessageBatchItem) error {
if len(items) == 0 { if len(items) == 0 {
return nil return nil
@@ -3725,14 +3777,17 @@ func PersistPreparedMessageBatch(tenantID types.TenantID, items []PreparedMessag
if err != nil { if err != nil {
return err return err
} }
for _, item := range items { topicUpdates, readUpdates := aggregatePreparedMessageBatchUpdates(items)
msg := item.Message for _, update := range topicUpdates {
from := types.ParseUid(msg.From) if err = tenantAdp.TenantTopicUpdateOnMessage(tenantID, update.topic, update.message); err != nil {
if err = tenantAdp.TenantTopicUpdateOnMessage(tenantID, msg.Topic, msg); err != nil {
return err return err
} }
invalidateTopicCache(tenantID, msg.Topic) invalidateTopicCache(tenantID, update.topic)
if isGroup && groupMsgRuntime != nil { }
if isGroup && groupMsgRuntime != nil {
for _, item := range items {
msg := item.Message
from := types.ParseUid(msg.From)
if msg.ClientMsgID != "" { if msg.ClientMsgID != "" {
_ = groupMsgRuntime.MarkDone(tenantID, msg.Topic, from, msg.ClientMsgID, msg.SeqId, msg.Uid().String()) _ = groupMsgRuntime.MarkDone(tenantID, msg.Topic, from, msg.ClientMsgID, msg.SeqId, msg.Uid().String())
} }
@@ -3741,13 +3796,13 @@ func PersistPreparedMessageBatch(tenantID types.TenantID, items []PreparedMessag
}) })
_ = groupMsgRuntime.CacheHotMessage(tenantID, msg) _ = groupMsgRuntime.CacheHotMessage(tenantID, msg)
} }
if item.ReadBySender && !from.IsZero() { }
if updateErr := tenantAdp.TenantSubsUpdate(tenantID, msg.Topic, from, map[string]any{ for _, update := range readUpdates {
"RecvSeqId": msg.SeqId, "ReadSeqId": msg.SeqId, if updateErr := tenantAdp.TenantSubsUpdate(tenantID, update.topic, update.from, map[string]any{
}); updateErr == nil { "RecvSeqId": update.message.SeqId, "ReadSeqId": update.message.SeqId,
invalidateSubscriptionCache(tenantID, msg.Topic, from) }); updateErr == nil {
DeleteCachedUserUnreadCount(tenantID, from) invalidateSubscriptionCache(tenantID, update.topic, update.from)
} DeleteCachedUserUnreadCount(tenantID, update.from)
} }
} }
return nil return nil