feat: add kafka admin integration

This commit is contained in:
richjohnrambo
2026-08-28 00:05:54 +08:00
parent b2e5097708
commit 5cc6eebc1a
48 changed files with 8065 additions and 55 deletions
+53
View File
@@ -2,6 +2,7 @@ package store
import (
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -42,6 +43,18 @@ func (c *configCache[T]) set(key string, value T) {
c.mu.Unlock()
}
func (c *configCache[T]) delete(key string) {
c.mu.Lock()
delete(c.items, key)
c.mu.Unlock()
}
func (c *configCache[T]) clear() {
c.mu.Lock()
c.items = make(map[string]configCacheEntry[T])
c.mu.Unlock()
}
var (
tenantByCodeCache = newConfigCache[*types.Tenant]()
tenantByIDCache = newConfigCache[*types.Tenant]()
@@ -83,3 +96,43 @@ func thirdPartyCacheKey(group string, keys ...string) string {
sort.Strings(normalized)
return group + ":" + strings.Join(normalized, ",")
}
// InvalidateTenantConfigCache clears cached tenant registry/config records. An
// empty selector clears all tenant records.
func InvalidateTenantConfigCache(code string, id types.TenantID) {
if strings.TrimSpace(code) != "" {
tenantByCodeCache.delete(strings.TrimSpace(code))
} else {
tenantByCodeCache.clear()
}
if id.IsValid() {
tenantByIDCache.delete(formatTenantIDForCache(id))
} else if strings.TrimSpace(code) == "" {
tenantByIDCache.clear()
}
}
// InvalidateRegistrationConfigCache clears cached global registration config.
func InvalidateRegistrationConfigCache() {
registrationSMSCache.clear()
}
// InvalidateThirdPartyConfigCache clears cached third-party config.
func InvalidateThirdPartyConfigCache(group string) {
if strings.TrimSpace(group) == "" {
thirdPartyConfigCache.clear()
return
}
prefix := strings.TrimSpace(group) + ":"
thirdPartyConfigCache.mu.Lock()
for key := range thirdPartyConfigCache.items {
if strings.HasPrefix(key, prefix) {
delete(thirdPartyConfigCache.items, key)
}
}
thirdPartyConfigCache.mu.Unlock()
}
func formatTenantIDForCache(id types.TenantID) string {
return strconv.FormatInt(int64(id), 10)
}
+27
View File
@@ -69,6 +69,7 @@ type groupMessageRuntime interface {
RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error
CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error)
AllowRate(tenantID types.TenantID, scope string, limit int, window time.Duration) (bool, int, time.Duration, error)
ClaimShortState(namespace, id string, ttl time.Duration) (bool, error)
}
var groupMsgRuntime groupMessageRuntime
@@ -88,6 +89,14 @@ func MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device,
return groupMsgRuntime.MarkUserOnline(tenantID, uid, sid, nodeID, device, userAgent)
}
func ClaimShortState(namespace, id string, ttl time.Duration) (bool, bool, error) {
if groupMsgRuntime == nil || namespace == "" || id == "" {
return false, false, nil
}
ok, err := groupMsgRuntime.ClaimShortState(namespace, id, ttl)
return ok, true, err
}
func RefreshUserPresence(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() || sid == "" {
return nil
@@ -169,6 +178,13 @@ func DeleteCachedObject(tenantID types.TenantID, namespace, id string) error {
return groupMsgRuntime.DeleteCachedObject(tenantID, namespace, id)
}
func InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" {
return nil
}
return groupMsgRuntime.InvalidateHotMessages(tenantID, topic, ranges)
}
func AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || member == "" {
return nil
@@ -730,6 +746,13 @@ func (r *redisGroupMessageRuntime) AllowRate(tenantID types.TenantID, scope stri
return count <= int64(limit), int(count), ttl, nil
}
func (r *redisGroupMessageRuntime) ClaimShortState(namespace, id string, ttl time.Duration) (bool, error) {
if ttl <= 0 {
ttl = time.Duration(r.cfg.ShortTTL) * time.Second
}
return r.client.SetNX(context.Background(), r.shortStateKey(namespace, id), "1", ttl).Result()
}
func (r *redisGroupMessageRuntime) hotSeqIDs(tenantID types.TenantID, topic string, opts *types.QueryOpt, limit int) ([]int64, bool, error) {
if opts != nil && len(opts.IdRanges) > 0 {
seqs := expandRanges(opts.IdRanges, limit)
@@ -851,6 +874,10 @@ func (r *redisGroupMessageRuntime) rateKey(tenantID types.TenantID, scope string
return r.key(tenantID, "rate", scope)
}
func (r *redisGroupMessageRuntime) shortStateKey(namespace, id string) string {
return r.key(types.ZeroTenantID, "short", namespace, id)
}
func (r *redisGroupMessageRuntime) cacheTTL(namespace string) time.Duration {
ttl := r.cfg.CacheTTL
if namespace == "verification" || namespace == "action_token" {
+108
View File
@@ -14,6 +14,18 @@ type HTTPInfrastructurePersistenceInterface interface {
CreateEvent(types.TenantID, *types.BusinessEvent) error
ListPendingEvents(types.TenantID, int, time.Time) ([]types.BusinessEvent, error)
MarkEventDelivered(types.TenantID, string, time.Time) error
BeginKafkaConsume(types.TenantID, *types.KafkaConsumeRecord, time.Time) (*types.KafkaConsumeRecord, bool, error)
MarkKafkaConsumeSucceeded(types.TenantID, string, string, time.Time) error
MarkKafkaConsumeFailed(types.TenantID, string, string, string, string, time.Time) (*types.KafkaConsumeRecord, error)
MarkKafkaConsumeDead(types.TenantID, *types.KafkaDeadLetter, time.Time) error
ListKafkaDeadLetters(types.TenantID, types.KafkaDeadLetterListOptions) (*types.KafkaDeadLetterPage, error)
GetKafkaDeadLetter(types.TenantID, string) (*types.KafkaDeadLetter, error)
MarkKafkaDeadLetter(types.TenantID, string, string, string, string, time.Time) error
MarkKafkaDeadLetterReplay(types.TenantID, string, string, string, time.Time) (*types.KafkaDeadLetter, error)
CreateKafkaOutbox(types.TenantID, *types.KafkaOutboxRecord) error
ListPendingKafkaOutbox(types.TenantID, int, time.Time) ([]types.KafkaOutboxRecord, error)
MarkKafkaOutboxSent(types.TenantID, string, time.Time) error
MarkKafkaOutboxFailed(types.TenantID, string, string, string, time.Time, time.Time) error
}
type httpInfrastructureMapper struct{}
@@ -79,3 +91,99 @@ func (httpInfrastructureMapper) MarkEventDelivered(tenantID types.TenantID, even
}
return infrastructure.TenantBusinessEventMarkDelivered(tenantID, eventID, now)
}
func (httpInfrastructureMapper) BeginKafkaConsume(tenantID types.TenantID, record *types.KafkaConsumeRecord, deadline time.Time) (*types.KafkaConsumeRecord, bool, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, false, err
}
return infrastructure.TenantKafkaConsumeBegin(tenantID, record, deadline)
}
func (httpInfrastructureMapper) MarkKafkaConsumeSucceeded(tenantID types.TenantID, eventID, consumerGroup string, now time.Time) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantKafkaConsumeSucceed(tenantID, eventID, consumerGroup, now)
}
func (httpInfrastructureMapper) MarkKafkaConsumeFailed(tenantID types.TenantID, eventID, consumerGroup, reason, message string, now time.Time) (*types.KafkaConsumeRecord, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, err
}
return infrastructure.TenantKafkaConsumeFail(tenantID, eventID, consumerGroup, reason, message, now)
}
func (httpInfrastructureMapper) MarkKafkaConsumeDead(tenantID types.TenantID, dead *types.KafkaDeadLetter, now time.Time) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantKafkaConsumeDead(tenantID, dead, now)
}
func (httpInfrastructureMapper) ListKafkaDeadLetters(tenantID types.TenantID, opts types.KafkaDeadLetterListOptions) (*types.KafkaDeadLetterPage, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, err
}
return infrastructure.TenantKafkaDeadLetterList(tenantID, opts)
}
func (httpInfrastructureMapper) GetKafkaDeadLetter(tenantID types.TenantID, deadID string) (*types.KafkaDeadLetter, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, err
}
return infrastructure.TenantKafkaDeadLetterGet(tenantID, deadID)
}
func (httpInfrastructureMapper) MarkKafkaDeadLetter(tenantID types.TenantID, deadID, status, resolvedBy, note string, now time.Time) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantKafkaDeadLetterMark(tenantID, deadID, status, resolvedBy, note, now)
}
func (httpInfrastructureMapper) MarkKafkaDeadLetterReplay(tenantID types.TenantID, deadID, resolvedBy, note string, now time.Time) (*types.KafkaDeadLetter, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, err
}
return infrastructure.TenantKafkaDeadLetterMarkReplay(tenantID, deadID, resolvedBy, note, now)
}
func (httpInfrastructureMapper) CreateKafkaOutbox(tenantID types.TenantID, record *types.KafkaOutboxRecord) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantKafkaOutboxCreate(tenantID, record)
}
func (httpInfrastructureMapper) ListPendingKafkaOutbox(tenantID types.TenantID, limit int, now time.Time) ([]types.KafkaOutboxRecord, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, err
}
return infrastructure.TenantKafkaOutboxListPending(tenantID, limit, now)
}
func (httpInfrastructureMapper) MarkKafkaOutboxSent(tenantID types.TenantID, eventID string, now time.Time) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantKafkaOutboxMarkSent(tenantID, eventID, now)
}
func (httpInfrastructureMapper) MarkKafkaOutboxFailed(tenantID types.TenantID, eventID, reason, message string, nextRetryAt, now time.Time) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantKafkaOutboxMarkFailed(tenantID, eventID, reason, message, nextRetryAt, now)
}
+43
View File
@@ -0,0 +1,43 @@
package store
import (
adapter "github.com/tinode/chat/server/db"
"github.com/tinode/chat/server/store/types"
)
type InternalAdminQueryPersistenceInterface interface {
Query(types.TenantID, string, string, []any, int, int) (*types.InternalAdminQueryResult, error)
QueryOne(types.TenantID, string, []any) (map[string]any, error)
}
type internalAdminQueryMapper struct{}
var InternalAdminQueries InternalAdminQueryPersistenceInterface = internalAdminQueryMapper{}
func internalAdminQueryAdapter(tenantID types.TenantID) (adapter.TenantInternalAdminQueryAdapter, error) {
business, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
queryAdp, ok := business.(adapter.TenantInternalAdminQueryAdapter)
if !ok {
return nil, types.ErrUnsupported
}
return queryAdp, nil
}
func (internalAdminQueryMapper) Query(tenantID types.TenantID, query string, countQuery string, args []any, page, perPage int) (*types.InternalAdminQueryResult, error) {
queryAdp, err := internalAdminQueryAdapter(tenantID)
if err != nil {
return nil, err
}
return queryAdp.TenantInternalAdminQuery(tenantID, query, countQuery, args, page, perPage)
}
func (internalAdminQueryMapper) QueryOne(tenantID types.TenantID, query string, args []any) (map[string]any, error) {
queryAdp, err := internalAdminQueryAdapter(tenantID)
if err != nil {
return nil, err
}
return queryAdp.TenantInternalAdminQueryOne(tenantID, query, args)
}
+60
View File
@@ -330,6 +330,36 @@ func (mr *MockTenantsPersistenceInterfaceMockRecorder) GetByCode(code interface{
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByCode", reflect.TypeOf((*MockTenantsPersistenceInterface)(nil).GetByCode), code)
}
// GetByID mocks base method.
func (m *MockTenantsPersistenceInterface) GetByID(id types.TenantID) (*types.Tenant, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetByID", id)
ret0, _ := ret[0].(*types.Tenant)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetByID indicates an expected call of GetByID.
func (mr *MockTenantsPersistenceInterfaceMockRecorder) GetByID(id interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByID", reflect.TypeOf((*MockTenantsPersistenceInterface)(nil).GetByID), id)
}
// ListActive mocks base method.
func (m *MockTenantsPersistenceInterface) ListActive(limit int) ([]types.Tenant, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListActive", limit)
ret0, _ := ret[0].([]types.Tenant)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListActive indicates an expected call of ListActive.
func (mr *MockTenantsPersistenceInterfaceMockRecorder) ListActive(limit interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListActive", reflect.TypeOf((*MockTenantsPersistenceInterface)(nil).ListActive), limit)
}
// MockUsersPersistenceInterface is a mock of UsersPersistenceInterface interface.
type MockUsersPersistenceInterface struct {
ctrl *gomock.Controller
@@ -1220,6 +1250,36 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) Save(tenantID, msg, atta
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).Save), tenantID, msg, attachmentURLs, readBySender)
}
// PreparePersist mocks base method.
func (m *MockMessagesPersistenceInterface) PreparePersist(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PreparePersist", tenantID, msg, attachmentURLs, readBySender)
ret0, _ := ret[0].(*types.MessageSaveResult)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// PreparePersist indicates an expected call of PreparePersist.
func (mr *MockMessagesPersistenceInterfaceMockRecorder) PreparePersist(tenantID, msg, attachmentURLs, readBySender interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreparePersist", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).PreparePersist), tenantID, msg, attachmentURLs, readBySender)
}
// PersistPrepared mocks base method.
func (m *MockMessagesPersistenceInterface) PersistPrepared(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PersistPrepared", tenantID, msg, attachmentURLs, readBySender)
ret0, _ := ret[0].(*types.MessageSaveResult)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// PersistPrepared indicates an expected call of PersistPrepared.
func (mr *MockMessagesPersistenceInterfaceMockRecorder) PersistPrepared(tenantID, msg, attachmentURLs, readBySender interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PersistPrepared", reflect.TypeOf((*MockMessagesPersistenceInterface)(nil).PersistPrepared), tenantID, msg, attachmentURLs, readBySender)
}
// MockDevicePersistenceInterface is a mock of DevicePersistenceInterface interface.
type MockDevicePersistenceInterface struct {
ctrl *gomock.Controller
+115 -2
View File
@@ -151,6 +151,7 @@ const (
type TenantsPersistenceInterface interface {
GetByCode(code string) (*types.Tenant, error)
GetByID(id types.TenantID) (*types.Tenant, error)
ListActive(limit int) ([]types.Tenant, error)
}
type tenantsMapper struct{}
@@ -158,6 +159,22 @@ type tenantsMapper struct{}
// Tenants is the tenant registry access object.
var Tenants TenantsPersistenceInterface
// AdminInternalConfigProvider optionally resolves hotgo-owned configuration
// through the Admin service instead of reading the hotgo database directly.
type AdminInternalConfigProvider interface {
GetTenantByCode(code string) (*types.Tenant, error)
GetTenantByID(id types.TenantID) (*types.Tenant, error)
ListActiveTenants(limit int) ([]types.Tenant, error)
RegistrationSMSEnabled() (bool, error)
ThirdPartyConfig(group string, keys ...string) (map[string]string, error)
}
var adminInternalConfigProvider AdminInternalConfigProvider
func SetAdminInternalConfigProvider(provider AdminInternalConfigProvider) {
adminInternalConfigProvider = provider
}
// RegistrationConfigPersistenceInterface exposes global registration policy.
type RegistrationConfigPersistenceInterface interface {
SMSEnabled() (bool, error)
@@ -172,6 +189,14 @@ func (registrationConfigMapper) SMSEnabled() (bool, error) {
if enabled, ok := registrationSMSCache.get("im:register_sms_enabled"); ok {
return enabled, nil
}
if adminInternalConfigProvider != nil {
if enabled, err := adminInternalConfigProvider.RegistrationSMSEnabled(); err == nil {
registrationSMSCache.set("im:register_sms_enabled", enabled)
return enabled, nil
} else {
logs.Warn.Printf("admin internal registration config fallback failed: %v", err)
}
}
configAdp, ok := adp.(adapter.RegistrationConfigAdapter)
if !ok {
return true, types.ErrUnsupported
@@ -198,6 +223,14 @@ func (thirdPartyConfigMapper) Get(group string, keys ...string) (map[string]stri
if cached, ok := thirdPartyConfigCache.get(cacheKey); ok {
return cloneStringMap(cached), nil
}
if adminInternalConfigProvider != nil {
if result, err := adminInternalConfigProvider.ThirdPartyConfig(group, keys...); err == nil {
thirdPartyConfigCache.set(cacheKey, cloneStringMap(result))
return result, nil
} else {
logs.Warn.Printf("admin internal third-party config fallback failed: group=%s err=%v", group, err)
}
}
configAdp, ok := adp.(adapter.ThirdPartyConfigAdapter)
if !ok {
return nil, types.ErrUnsupported
@@ -261,6 +294,15 @@ func (tenantsMapper) GetByCode(code string) (*types.Tenant, error) {
if tenant, ok := tenantByCodeCache.get(code); ok {
return cloneTenant(tenant), nil
}
if adminInternalConfigProvider != nil {
if tenant, err := adminInternalConfigProvider.GetTenantByCode(code); err == nil && tenant != nil {
tenantByCodeCache.set(code, cloneTenant(tenant))
tenantByIDCache.set(strconv.FormatInt(int64(tenant.ID), 10), cloneTenant(tenant))
return tenant, nil
} else if err != nil {
logs.Warn.Printf("admin internal tenant by code fallback failed: code=%s err=%v", code, err)
}
}
tenantAdp, ok := adp.(adapter.TenantAdapter)
if !ok {
return nil, types.ErrUnsupported
@@ -282,6 +324,15 @@ func (tenantsMapper) GetByID(id types.TenantID) (*types.Tenant, error) {
if tenant, ok := tenantByIDCache.get(idKey); ok {
return cloneTenant(tenant), nil
}
if adminInternalConfigProvider != nil {
if tenant, err := adminInternalConfigProvider.GetTenantByID(id); err == nil && tenant != nil {
tenantByIDCache.set(idKey, cloneTenant(tenant))
tenantByCodeCache.set(tenant.Code, cloneTenant(tenant))
return tenant, nil
} else if err != nil {
logs.Warn.Printf("admin internal tenant by id fallback failed: tenant=%d err=%v", id, err)
}
}
tenantAdp, ok := adp.(adapter.TenantAdapter)
if !ok {
return nil, types.ErrUnsupported
@@ -294,6 +345,29 @@ func (tenantsMapper) GetByID(id types.TenantID) (*types.Tenant, error) {
return tenant, err
}
func (tenantsMapper) ListActive(limit int) ([]types.Tenant, error) {
if limit <= 0 {
limit = 1000
}
if adminInternalConfigProvider != nil {
if tenants, err := adminInternalConfigProvider.ListActiveTenants(limit); err == nil {
for i := range tenants {
tenant := tenants[i]
tenantByIDCache.set(strconv.FormatInt(int64(tenant.ID), 10), cloneTenant(&tenant))
tenantByCodeCache.set(tenant.Code, cloneTenant(&tenant))
}
return tenants, nil
} else {
logs.Warn.Printf("admin internal active tenants fallback failed: err=%v", err)
}
}
tenantAdp, ok := adp.(adapter.TenantAdapter)
if !ok {
return nil, types.ErrUnsupported
}
return tenantAdp.TenantListActive(limit)
}
func tenantAuthAdapter(tenantID types.TenantID) (adapter.TenantAuthAdapter, error) {
if !tenantID.IsValid() {
return nil, types.ErrMalformed
@@ -1267,6 +1341,8 @@ func subscriptionCacheID(topic string, user types.Uid, keepDeleted bool) string
// MessagesPersistenceInterface is an interface which defines methods for persistent storage of messages.
type MessagesPersistenceInterface interface {
Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error)
PreparePersist(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error)
PersistPrepared(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error)
GetOne(tenantID types.TenantID, topic string, seq int64) (*types.Message, error)
Recall(tenantID types.TenantID, topic string, seq int64, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error)
Edit(tenantID types.TenantID, topic string, seq int64, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error)
@@ -3063,6 +3139,17 @@ func cachedBlockContains(tenantID types.TenantID, blocker, blocked types.Uid) (b
// Save message
func (messagesMapper) Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) {
saveResult, err := Messages.PreparePersist(tenantID, msg, attachmentURLs, readBySender)
if err != nil {
return nil, err
}
if saveResult != nil && saveResult.Duplicate {
return saveResult, nil
}
return Messages.PersistPrepared(tenantID, msg, attachmentURLs, readBySender)
}
func (messagesMapper) PreparePersist(tenantID types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) {
if msg == nil || msg.TenantID != tenantID {
return nil, types.ErrMalformed
}
@@ -3126,12 +3213,29 @@ func (messagesMapper) Save(tenantID types.TenantID, msg *types.Message, attachme
}
msg.InitTimes()
msg.SetUid(Store.GetUid())
// Increment topic's or user's SeqId
// Reserve topic's or user's SeqId before optional async persistence.
err = tenantAdp.TenantTopicUpdateOnMessage(tenantID, msg.Topic, msg)
if err != nil {
return nil, err
}
return &types.MessageSaveResult{SeqId: msg.SeqId}, nil
}
func (messagesMapper) PersistPrepared(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) {
if msg == nil || msg.TenantID != tenantID || msg.Topic == "" || msg.SeqId <= 0 {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
clientMsgID, err := normalizeClientMsgID(msg.Head)
if err != nil {
return nil, err
}
msg.ClientMsgID = clientMsgID
fromUid := types.ParseUid(msg.From)
isGroupMessage := types.GetTopicCat(msg.Topic) == types.TopicCatGrp
if isGroupMessage {
err = tenantAdp.TenantGroupMessageSave(tenantID, msg)
} else {
@@ -3220,6 +3324,14 @@ func (messagesMapper) Save(tenantID types.TenantID, msg *types.Message, attachme
return &types.MessageSaveResult{SeqId: msg.SeqId, MarkedReadBySender: markedReadBySender}, nil
}
func PrepareMessagePersist(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) {
return Messages.PreparePersist(tenantID, msg, attachmentURLs, readBySender)
}
func PersistPreparedMessage(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) {
return Messages.PersistPrepared(tenantID, msg, attachmentURLs, readBySender)
}
func retryTenantMessageSaveAfterSeqConflict(tenantAdp adapter.TenantBusinessAdapter, tenantID types.TenantID, msg *types.Message) error {
latest, err := tenantAdp.TenantMessageGetAll(tenantID, msg.Topic, types.ZeroUid, &types.QueryOpt{Before: 1 << 62, Limit: 1})
if err != nil {
@@ -3821,7 +3933,7 @@ func (fileMapper) LinkAttachments(tenantID types.TenantID, topic string, msgId t
if len(fids) > 0 {
userId := types.ZeroUid
if types.GetTopicCat(topic) == types.TopicCatMe {
if topic != "" && types.GetTopicCat(topic) == types.TopicCatMe {
userId = types.ParseUserId(topic)
topic = ""
}
@@ -4353,6 +4465,7 @@ func init() {
Tenants = tenantsMapper{}
RegistrationConfig = registrationConfigMapper{}
TenantEntry = tenantEntryMapper{}
InternalAdminQueries = internalAdminQueryMapper{}
Users = usersMapper{}
Topics = topicsMapper{}
Subs = subsMapper{}
+89
View File
@@ -12,6 +12,21 @@ const (
BusinessEventPending = "pending"
BusinessEventDelivered = "delivered"
KafkaConsumeProcessing = "processing"
KafkaConsumeSucceeded = "succeeded"
KafkaConsumeFailed = "failed"
KafkaConsumeDead = "dead"
KafkaDeadStatusDead = "dead"
KafkaDeadStatusPendingReplay = "pending_replay"
KafkaDeadStatusReplayed = "replayed"
KafkaDeadStatusIgnored = "ignored"
KafkaDeadStatusResolved = "resolved"
KafkaOutboxPending = "pending"
KafkaOutboxSent = "sent"
KafkaOutboxFailed = "failed"
)
// HTTPIdempotencyRecord stores the first response for one authenticated write.
@@ -46,6 +61,80 @@ type BusinessEvent struct {
UpdatedAt time.Time
}
// KafkaConsumeRecord stores idempotent consumer state for one event and group.
type KafkaConsumeRecord struct {
EventID string
ConsumerGroup string
SourceTopic string
EventType string
BizType string
BizID string
Status string
RetryCount int
LastReason string
LastError string
ProcessingDeadline *time.Time
SucceededAt *time.Time
DeadAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// KafkaDeadLetter stores a poison Kafka event for manual handling.
type KafkaDeadLetter struct {
ID int64
DeadID string
EventID string
ParentEventID string
SourceTopic string
ConsumerGroup string
EventType string
BizType string
BizID string
Envelope []byte
RetryCount int
LastReason string
LastError string
Status string
ReplayCount int
CreatedAt time.Time
UpdatedAt time.Time
}
type KafkaDeadLetterListOptions struct {
Status string
BizType string
BizID string
EventType string
Cursor string
Limit int
}
type KafkaDeadLetterPage struct {
Items []KafkaDeadLetter
NextCursor string
}
// KafkaOutboxRecord stores a Kafka event waiting for reliable publication.
type KafkaOutboxRecord struct {
ID int64
EventID string
Topic string
EventType string
BizType string
BizID string
Key string
Envelope []byte
Status string
Attempts int
NextRetryAt *time.Time
SentAt *time.Time
LastReason string
LastError string
CreatedAt time.Time
UpdatedAt time.Time
}
// StableEventID derives an idempotent event identifier from domain identity.
func StableEventID(parts ...any) string {
hash := sha256.New()
@@ -0,0 +1,8 @@
package types
type InternalAdminQueryResult struct {
Items []map[string]any
Total int64
Page int
PerPage int
}