//go:build mysql // +build mysql package mysql import ( "context" "database/sql" "encoding/json" "strconv" "strings" "time" "github.com/jmoiron/sqlx" "github.com/tinode/chat/server/store" t "github.com/tinode/chat/server/store/types" ) func (a *adapter) infrastructureAdapter(id t.TenantID) (*adapter, error) { if !a.tenantScopedDB { return a.routedTenantAdapter(id) } if err := validTenant(id); err != nil { return nil, err } return a, nil } func (a *adapter) TenantHTTPIdempotencyBegin(id t.TenantID, record *t.HTTPIdempotencyRecord) (*t.HTTPIdempotencyRecord, bool, error) { tenantAdp, err := a.infrastructureAdapter(id) if err != nil { return nil, false, err } if record == nil || record.UserID.IsZero() || record.Method == "" || record.Route == "" || record.Key == "" || record.BodyHash == "" { return nil, false, t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantHTTPIdempotencyBegin(id, record) } now := t.TimeNow() if record.ExpiresAt.IsZero() { record.ExpiresAt = now.Add(24 * time.Hour) } _, err = a.db.Exec(`INSERT INTO im_http_idempotency (user_id,method,route,idempotency_key,body_hash,state,response_status,response_body,expires_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, decodeUID(record.UserID), record.Method, record.Route, record.Key, record.BodyHash, t.HTTPIdempotencyProcessing, 0, nil, record.ExpiresAt, now, now) if err == nil { record.State = t.HTTPIdempotencyProcessing record.CreatedAt = now record.UpdatedAt = now return record, true, nil } if !isDupe(err) { return nil, false, err } existing, err := a.httpIdempotencyGet(record.UserID, record.Method, record.Route, record.Key) if err == nil && !existing.ExpiresAt.After(now) { res, deleteErr := a.db.Exec(`DELETE FROM im_http_idempotency WHERE user_id=? AND method=? AND route=? AND idempotency_key=? AND expires_at<=?`, decodeUID(record.UserID), record.Method, record.Route, record.Key, now) if deleteErr != nil { return nil, false, deleteErr } if deleted, _ := res.RowsAffected(); deleted > 0 { return a.TenantHTTPIdempotencyBegin(id, record) } current, getErr := a.httpIdempotencyGet(record.UserID, record.Method, record.Route, record.Key) return current, false, getErr } return existing, false, err } func (a *adapter) TenantHTTPIdempotencyComplete(id t.TenantID, record *t.HTTPIdempotencyRecord) error { tenantAdp, err := a.infrastructureAdapter(id) if err != nil { return err } if record == nil || record.UserID.IsZero() { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantHTTPIdempotencyComplete(id, record) } now := t.TimeNow() res, err := a.db.Exec(`UPDATE im_http_idempotency SET state=?,response_status=?,response_body=?,updated_at=? WHERE user_id=? AND method=? AND route=? AND idempotency_key=? AND body_hash=? AND state=?`, t.HTTPIdempotencyCompleted, record.ResponseStatus, record.ResponseBody, now, decodeUID(record.UserID), record.Method, record.Route, record.Key, record.BodyHash, t.HTTPIdempotencyProcessing) if err != nil { return err } if count, _ := res.RowsAffected(); count == 0 { return t.ErrNotFound } return nil } func (a *adapter) TenantHTTPIdempotencyAbort(id t.TenantID, record *t.HTTPIdempotencyRecord) error { tenantAdp, err := a.infrastructureAdapter(id) if err != nil { return err } if record == nil || record.UserID.IsZero() { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantHTTPIdempotencyAbort(id, record) } _, err = a.db.Exec(`DELETE FROM im_http_idempotency WHERE user_id=? AND method=? AND route=? AND idempotency_key=? AND body_hash=? AND state=?`, decodeUID(record.UserID), record.Method, record.Route, record.Key, record.BodyHash, t.HTTPIdempotencyProcessing) return err } func (a *adapter) httpIdempotencyGet(uid t.Uid, method, route, key string) (*t.HTTPIdempotencyRecord, error) { row := struct { UserID int64 `db:"user_id"` Method string Route string Key string `db:"idempotency_key"` BodyHash string `db:"body_hash"` State string ResponseStatus int `db:"response_status"` ResponseBody []byte `db:"response_body"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` ExpiresAt time.Time `db:"expires_at"` }{} err := a.db.Get(&row, `SELECT user_id,method,route,idempotency_key,body_hash,state,response_status,response_body,created_at,updated_at,expires_at FROM im_http_idempotency WHERE user_id=? AND method=? AND route=? AND idempotency_key=?`, decodeUID(uid), method, route, key) if err == sql.ErrNoRows { return nil, t.ErrNotFound } if err != nil { return nil, err } return &t.HTTPIdempotencyRecord{UserID: store.EncodeUid(row.UserID), Method: row.Method, Route: row.Route, Key: row.Key, BodyHash: row.BodyHash, State: row.State, ResponseStatus: row.ResponseStatus, ResponseBody: row.ResponseBody, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, ExpiresAt: row.ExpiresAt}, nil } func (a *adapter) TenantBusinessEventCreate(id t.TenantID, event *t.BusinessEvent) error { tenantAdp, err := a.infrastructureAdapter(id) if err != nil { return err } if event == nil || event.EventID == "" || event.Target == "" || event.EventType == "" || (event.TargetType != "user" && event.TargetType != "topic") { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantBusinessEventCreate(id, event) } now := t.TimeNow() _, err = a.db.Exec(`INSERT INTO im_business_events_outbox (event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, event.EventID, event.TargetType, event.Target, decodeUID(event.ActorUserID), event.EventType, event.Payload, t.BusinessEventPending, now, now) return err } func (a *adapter) TenantBusinessEventListPending(id t.TenantID, limit int, now time.Time) ([]t.BusinessEvent, error) { tenantAdp, err := a.infrastructureAdapter(id) if err != nil { return nil, err } if tenantAdp != a { return tenantAdp.TenantBusinessEventListPending(id, limit, now) } if limit <= 0 || limit > 500 { limit = 100 } rows, err := a.db.Queryx(`SELECT * FROM im_business_events_outbox WHERE status=? AND (next_retry_at IS NULL OR next_retry_at<=?) ORDER BY id LIMIT ?`, t.BusinessEventPending, now, limit) if err != nil { return nil, err } defer rows.Close() events := make([]t.BusinessEvent, 0, limit) for rows.Next() { var row struct { ID int64 EventID string `db:"event_id"` TargetType string `db:"target_type"` Target string ActorUserID int64 `db:"actor_user_id"` EventType string `db:"event_type"` Payload []byte Status string Attempts int NextRetryAt *time.Time `db:"next_retry_at"` DeliveredAt *time.Time `db:"delivered_at"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } if err = rows.StructScan(&row); err != nil { return nil, err } events = append(events, t.BusinessEvent{ID: row.ID, EventID: row.EventID, TargetType: row.TargetType, Target: row.Target, ActorUserID: store.EncodeUid(row.ActorUserID), EventType: row.EventType, Payload: row.Payload, Status: row.Status, Attempts: row.Attempts, NextRetryAt: row.NextRetryAt, DeliveredAt: row.DeliveredAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}) } return events, rows.Err() } func (a *adapter) TenantBusinessEventMarkDelivered(id t.TenantID, eventID string, now time.Time) error { tenantAdp, err := a.infrastructureAdapter(id) if err != nil { return err } if eventID == "" { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantBusinessEventMarkDelivered(id, eventID, now) } res, err := a.db.Exec(`UPDATE im_business_events_outbox SET status=?,delivered_at=?,attempts=attempts+1,updated_at=? WHERE event_id=? AND status=?`, t.BusinessEventDelivered, now, now, eventID, t.BusinessEventPending) if err != nil { return err } if count, _ := res.RowsAffected(); count == 0 { return t.ErrNotFound } return nil } func (a *adapter) TenantKafkaConsumeBegin(id t.TenantID, record *t.KafkaConsumeRecord, deadline time.Time) (*t.KafkaConsumeRecord, bool, error) { tenantAdp, err := a.infrastructureAdapter(id) if err != nil { return nil, false, err } if record == nil || record.EventID == "" || record.ConsumerGroup == "" || record.SourceTopic == "" { return nil, false, t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantKafkaConsumeBegin(id, record, deadline) } now := t.TimeNow() _, err = a.db.Exec(`INSERT INTO im_kafka_consume_log (event_id,consumer_group,source_topic,event_type,biz_type,biz_id,status,processing_deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, record.EventID, record.ConsumerGroup, record.SourceTopic, record.EventType, record.BizType, record.BizID, t.KafkaConsumeProcessing, deadline, now, now) if err == nil { record.Status = t.KafkaConsumeProcessing record.ProcessingDeadline = &deadline record.CreatedAt = now record.UpdatedAt = now return record, true, nil } if !isDupe(err) { return nil, false, err } existing, err := a.kafkaConsumeGet(record.EventID, record.ConsumerGroup) if err != nil { return nil, false, err } if existing.Status == t.KafkaConsumeSucceeded || existing.Status == t.KafkaConsumeDead { return existing, false, nil } if existing.Status == t.KafkaConsumeProcessing && existing.ProcessingDeadline != nil && existing.ProcessingDeadline.After(now) { return existing, false, nil } res, err := a.db.Exec(`UPDATE im_kafka_consume_log SET status=?,source_topic=?,event_type=?,biz_type=?,biz_id=?, processing_deadline=?,updated_at=? WHERE event_id=? AND consumer_group=? AND status<>?`, t.KafkaConsumeProcessing, record.SourceTopic, record.EventType, record.BizType, record.BizID, deadline, now, record.EventID, record.ConsumerGroup, t.KafkaConsumeSucceeded) if err != nil { return nil, false, err } if affected, _ := res.RowsAffected(); affected == 0 { current, getErr := a.kafkaConsumeGet(record.EventID, record.ConsumerGroup) return current, false, getErr } current, err := a.kafkaConsumeGet(record.EventID, record.ConsumerGroup) return current, err == nil, err } func (a *adapter) TenantKafkaConsumeSucceed(id t.TenantID, eventID, consumerGroup string, now time.Time) error { tenantAdp, err := a.infrastructureAdapter(id) if err != nil { return err } if eventID == "" || consumerGroup == "" { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantKafkaConsumeSucceed(id, eventID, consumerGroup, now) } res, err := a.db.Exec(`UPDATE im_kafka_consume_log SET status=?,succeeded_at=?,processing_deadline=NULL,updated_at=? WHERE event_id=? AND consumer_group=?`, t.KafkaConsumeSucceeded, now, now, eventID, consumerGroup) if err != nil { return err } if affected, _ := res.RowsAffected(); affected == 0 { return t.ErrNotFound } return nil } func (a *adapter) TenantKafkaConsumeFail(id t.TenantID, eventID, consumerGroup, reason, message string, now time.Time) (*t.KafkaConsumeRecord, error) { tenantAdp, err := a.infrastructureAdapter(id) if err != nil { return nil, err } if eventID == "" || consumerGroup == "" { return nil, t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantKafkaConsumeFail(id, eventID, consumerGroup, reason, message, now) } _, err = a.db.Exec(`UPDATE im_kafka_consume_log SET status=?,retry_count=retry_count+1,last_reason=?, last_error=?,processing_deadline=NULL,updated_at=? WHERE event_id=? AND consumer_group=?`, t.KafkaConsumeFailed, truncateKafkaString(reason, 64), truncateKafkaString(message, 1000), now, eventID, consumerGroup) if err != nil { return nil, err } return a.kafkaConsumeGet(eventID, consumerGroup) } func (a *adapter) TenantKafkaConsumeDead(id t.TenantID, dead *t.KafkaDeadLetter, now time.Time) error { tenantAdp, err := a.infrastructureAdapter(id) if err != nil { return err } if dead == nil || dead.DeadID == "" || dead.EventID == "" || dead.ConsumerGroup == "" || dead.SourceTopic == "" || len(dead.Envelope) == 0 { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantKafkaConsumeDead(id, dead, now) } tx, err := a.db.BeginTxx(context.Background(), nil) if err != nil { return err } defer tx.Rollback() if _, err = tx.Exec(`INSERT INTO im_kafka_dead_letters (dead_id,event_id,parent_event_id,source_topic,consumer_group,event_type,biz_type,biz_id,envelope, retry_count,last_reason,last_error,status,replay_count,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE last_reason=VALUES(last_reason),last_error=VALUES(last_error),updated_at=VALUES(updated_at)`, dead.DeadID, dead.EventID, dead.ParentEventID, dead.SourceTopic, dead.ConsumerGroup, dead.EventType, dead.BizType, dead.BizID, dead.Envelope, dead.RetryCount, truncateKafkaString(dead.LastReason, 64), truncateKafkaString(dead.LastError, 1000), t.KafkaDeadStatusDead, dead.ReplayCount, now, now); err != nil { return err } if _, err = tx.Exec(`UPDATE im_kafka_consume_log SET status=?,dead_at=?,processing_deadline=NULL,last_reason=?, last_error=?,updated_at=? WHERE event_id=? AND consumer_group=?`, t.KafkaConsumeDead, now, truncateKafkaString(dead.LastReason, 64), truncateKafkaString(dead.LastError, 1000), now, dead.EventID, dead.ConsumerGroup); err != nil { return err } return tx.Commit() } func (a *adapter) TenantKafkaDeadLetterList(id t.TenantID, opts t.KafkaDeadLetterListOptions) (*t.KafkaDeadLetterPage, error) { tenantAdp, err := a.infrastructureAdapter(id) if err != nil { return nil, err } if tenantAdp != a { return tenantAdp.TenantKafkaDeadLetterList(id, opts) } limit := opts.Limit if limit <= 0 || limit > 200 { limit = 50 } where := []string{"1=1"} args := []any{} if opts.Status != "" { where = append(where, "status=?") args = append(args, opts.Status) } if opts.BizType != "" { where = append(where, "biz_type=?") args = append(args, opts.BizType) } if opts.BizID != "" { where = append(where, "biz_id=?") args = append(args, opts.BizID) } if opts.EventType != "" { where = append(where, "event_type=?") args = append(args, opts.EventType) } if opts.Cursor != "" { where = append(where, "id?`, t.KafkaOutboxSent, now, now, eventID, t.KafkaOutboxSent) if err != nil { return err } if affected, _ := res.RowsAffected(); affected == 0 { return t.ErrNotFound } return nil } func (a *adapter) TenantKafkaOutboxMarkFailed(id t.TenantID, eventID, reason, message string, nextRetryAt, now time.Time) error { tenantAdp, err := a.infrastructureAdapter(id) if err != nil { return err } if eventID == "" { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantKafkaOutboxMarkFailed(id, eventID, reason, message, nextRetryAt, now) } res, err := a.db.Exec(`UPDATE im_kafka_outbox SET status=?,attempts=attempts+1,next_retry_at=?, last_reason=?,last_error=?,updated_at=? WHERE event_id=? AND status<>?`, t.KafkaOutboxFailed, nextRetryAt, truncateKafkaString(reason, 64), truncateKafkaString(message, 1000), now, eventID, t.KafkaOutboxSent) if err != nil { return err } if affected, _ := res.RowsAffected(); affected == 0 { return t.ErrNotFound } return nil } func scanKafkaDeadLetter(rows *sqlx.Rows) (*t.KafkaDeadLetter, error) { var row struct { ID int64 `db:"id"` DeadID string `db:"dead_id"` EventID string `db:"event_id"` ParentEventID string `db:"parent_event_id"` SourceTopic string `db:"source_topic"` ConsumerGroup string `db:"consumer_group"` EventType string `db:"event_type"` BizType string `db:"biz_type"` BizID string `db:"biz_id"` Envelope []byte `db:"envelope"` RetryCount int `db:"retry_count"` LastReason string `db:"last_reason"` LastError string `db:"last_error"` Status string `db:"status"` ReplayCount int `db:"replay_count"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } if err := rows.StructScan(&row); err != nil { return nil, err } return &t.KafkaDeadLetter{ ID: row.ID, DeadID: row.DeadID, EventID: row.EventID, ParentEventID: row.ParentEventID, SourceTopic: row.SourceTopic, ConsumerGroup: row.ConsumerGroup, EventType: row.EventType, BizType: row.BizType, BizID: row.BizID, Envelope: row.Envelope, RetryCount: row.RetryCount, LastReason: row.LastReason, LastError: row.LastError, Status: row.Status, ReplayCount: row.ReplayCount, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, }, nil } func (a *adapter) kafkaConsumeGet(eventID, consumerGroup string) (*t.KafkaConsumeRecord, error) { row := struct { EventID string `db:"event_id"` ConsumerGroup string `db:"consumer_group"` SourceTopic string `db:"source_topic"` EventType string `db:"event_type"` BizType string `db:"biz_type"` BizID string `db:"biz_id"` Status string `db:"status"` RetryCount int `db:"retry_count"` LastReason string `db:"last_reason"` LastError string `db:"last_error"` ProcessingDeadline *time.Time `db:"processing_deadline"` SucceededAt *time.Time `db:"succeeded_at"` DeadAt *time.Time `db:"dead_at"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` }{} err := a.db.Get(&row, `SELECT event_id,consumer_group,source_topic,event_type,biz_type,biz_id,status,retry_count, last_reason,last_error,processing_deadline,succeeded_at,dead_at,created_at,updated_at FROM im_kafka_consume_log WHERE event_id=? AND consumer_group=?`, eventID, consumerGroup) if err == sql.ErrNoRows { return nil, t.ErrNotFound } if err != nil { return nil, err } return &t.KafkaConsumeRecord{EventID: row.EventID, ConsumerGroup: row.ConsumerGroup, SourceTopic: row.SourceTopic, EventType: row.EventType, BizType: row.BizType, BizID: row.BizID, Status: row.Status, RetryCount: row.RetryCount, LastReason: row.LastReason, LastError: row.LastError, ProcessingDeadline: row.ProcessingDeadline, SucceededAt: row.SucceededAt, DeadAt: row.DeadAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}, nil } func truncateKafkaString(value string, limit int) string { if limit <= 0 { return "" } runes := []rune(value) if len(runes) <= limit { return value } return string(runes[:limit]) } func insertBusinessEventTx(tx *sqlx.Tx, eventID, targetType, target string, actor t.Uid, eventType string, payload any) error { encoded, err := json.Marshal(payload) if err != nil { return err } now := t.TimeNow() _, err = tx.Exec(`INSERT INTO im_business_events_outbox (event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, eventID, targetType, target, decodeUID(actor), eventType, encoded, t.BusinessEventPending, now, now) return err } func insertSystemNotificationTx(tx *sqlx.Tx, uid t.Uid, eventID, eventType, title, summary string, action any) error { encoded, err := json.Marshal(action) if err != nil { return err } now := t.TimeNow() _, err = tx.Exec(`INSERT INTO im_system_notifications (user_id,event_id,event_type,title,summary,action,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_type=VALUES(event_type),title=VALUES(title), summary=VALUES(summary),action=VALUES(action),updated_at=VALUES(updated_at)`, decodeUID(uid), eventID, eventType, title, summary, encoded, now, now) return err }