package postgres import ( "context" "errors" "fmt" "sync" "time" "telesrv/internal/domain" ) // ActiveChannelIDsBatchMetrics exposes bounded aggregate cold-loader signals; // owner identities never become metric labels. type ActiveChannelIDsBatchMetrics interface { ActiveChannelIDsBatch(selectors int, rows int, d time.Duration, err error) ActiveChannelIDsPending(delta int) } type ActiveChannelIDsBatchConfig struct { MaxSize int MaxWait time.Duration QueueSize int QueryTimeout time.Duration Metrics ActiveChannelIDsBatchMetrics } type activeChannelIDsSelector struct { userID int64 afterChannelID int64 limit int } type activeChannelIDsBatchRequest struct { selector activeChannelIDsSelector result chan activeChannelIDsBatchResult } type activeChannelIDsBatchResult struct { channelIDs []int64 err error } type activeChannelIDsBatchBackend interface { listActiveChannelIDPages(context.Context, []activeChannelIDsSelector) ([][]int64, error) } // ActiveChannelIDsPageBatcher combines independent readiness cache misses into // one PostgreSQL call. It is a synchronous bounded read source: failures are // returned to every selector and never fall back to one query per account. type ActiveChannelIDsPageBatcher struct { base activeChannelIDsBatchBackend cfg ActiveChannelIDsBatchConfig queue chan activeChannelIDsBatchRequest stop chan struct{} done chan struct{} cancel context.CancelFunc once sync.Once gate sync.RWMutex closed bool } func NewActiveChannelIDsPageBatcher( base *ChannelStore, cfg ActiveChannelIDsBatchConfig, ) (*ActiveChannelIDsPageBatcher, error) { if base == nil || base.db == nil { return nil, errors.New("initialize active channel IDs batcher: nil store") } return newActiveChannelIDsPageBatcher(base, cfg) } func newActiveChannelIDsPageBatcher( base activeChannelIDsBatchBackend, cfg ActiveChannelIDsBatchConfig, ) (*ActiveChannelIDsPageBatcher, error) { if base == nil { return nil, errors.New("initialize active channel IDs batcher: nil backend") } if cfg.MaxSize <= 0 || cfg.MaxSize > 4096 { return nil, fmt.Errorf("initialize active channel IDs batcher: max size %d outside [1,4096]", cfg.MaxSize) } if cfg.MaxWait <= 0 || cfg.MaxWait > time.Second { return nil, fmt.Errorf("initialize active channel IDs batcher: max wait %v outside (0,1s]", cfg.MaxWait) } if cfg.QueueSize < cfg.MaxSize || cfg.QueueSize > 1<<20 { return nil, fmt.Errorf("initialize active channel IDs batcher: queue size %d outside [%d,%d]", cfg.QueueSize, cfg.MaxSize, 1<<20) } if cfg.QueryTimeout <= 0 || cfg.QueryTimeout > 30*time.Second { return nil, fmt.Errorf("initialize active channel IDs batcher: query timeout %v outside (0,30s]", cfg.QueryTimeout) } workerCtx, cancel := context.WithCancel(context.Background()) b := &ActiveChannelIDsPageBatcher{ base: base, cfg: cfg, queue: make(chan activeChannelIDsBatchRequest, cfg.QueueSize), stop: make(chan struct{}), done: make(chan struct{}), cancel: cancel, } go b.run(workerCtx) return b, nil } func (b *ActiveChannelIDsPageBatcher) ListActiveChannelIDsForUser( ctx context.Context, userID, afterChannelID int64, limit int, ) ([]int64, error) { if userID == 0 || afterChannelID < 0 { return nil, domain.ErrChannelInvalid } if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout { limit = domain.MaxSynchronousChannelDialogFanout } if ctx == nil { ctx = context.Background() } request := activeChannelIDsBatchRequest{ selector: activeChannelIDsSelector{userID: userID, afterChannelID: afterChannelID, limit: limit}, result: make(chan activeChannelIDsBatchResult, 1), } b.gate.RLock() if b.closed { b.gate.RUnlock() return nil, context.Canceled } select { case b.queue <- request: if b.cfg.Metrics != nil { b.cfg.Metrics.ActiveChannelIDsPending(1) } case <-ctx.Done(): b.gate.RUnlock() return nil, ctx.Err() } b.gate.RUnlock() select { case result := <-request.result: return result.channelIDs, result.err case <-ctx.Done(): return nil, ctx.Err() } } func (b *ActiveChannelIDsPageBatcher) Close() { if b == nil { return } b.once.Do(func() { b.gate.Lock() b.closed = true close(b.stop) b.cancel() b.gate.Unlock() <-b.done }) } func (b *ActiveChannelIDsPageBatcher) run(ctx context.Context) { defer close(b.done) pending := make([]activeChannelIDsBatchRequest, 0, b.cfg.MaxSize) for { if len(pending) == 0 { select { case request := <-b.queue: pending = append(pending, request) case <-b.stop: b.failQueued(context.Canceled, pending) return } } if len(pending) < b.cfg.MaxSize { timer := time.NewTimer(b.cfg.MaxWait) collect: for len(pending) < b.cfg.MaxSize { select { case request := <-b.queue: pending = append(pending, request) case <-timer.C: break collect case <-b.stop: stopAndDrainTimer(timer) b.failQueued(context.Canceled, pending) return } } stopAndDrainTimer(timer) } batch, remaining := selectDistinctActiveChannelIDsBatch(pending, b.cfg.MaxSize) pending = remaining b.execute(ctx, batch) } } func selectDistinctActiveChannelIDsBatch( pending []activeChannelIDsBatchRequest, maxSize int, ) ([]activeChannelIDsBatchRequest, []activeChannelIDsBatchRequest) { batch := make([]activeChannelIDsBatchRequest, 0, min(maxSize, len(pending))) remaining := make([]activeChannelIDsBatchRequest, 0, len(pending)) seen := make(map[activeChannelIDsSelector]struct{}, min(maxSize, len(pending))) for _, request := range pending { if len(batch) >= maxSize { remaining = append(remaining, request) continue } if _, duplicate := seen[request.selector]; duplicate { remaining = append(remaining, request) continue } seen[request.selector] = struct{}{} batch = append(batch, request) } return batch, remaining } func (b *ActiveChannelIDsPageBatcher) execute(ctx context.Context, batch []activeChannelIDsBatchRequest) { if len(batch) == 0 { return } selectors := make([]activeChannelIDsSelector, len(batch)) for index, request := range batch { selectors[index] = request.selector } started := time.Now() queryCtx, cancel := context.WithTimeout(ctx, b.cfg.QueryTimeout) pages, err := b.base.listActiveChannelIDPages(queryCtx, selectors) cancel() rows := 0 if err == nil { if len(pages) != len(batch) { err = fmt.Errorf("list active channel IDs batch: result count %d, want %d", len(pages), len(batch)) } else { for _, page := range pages { rows += len(page) } } } if b.cfg.Metrics != nil { b.cfg.Metrics.ActiveChannelIDsBatch(len(batch), rows, time.Since(started), err) } for index, request := range batch { result := activeChannelIDsBatchResult{err: err} if err == nil { result.channelIDs = pages[index] } request.result <- result if b.cfg.Metrics != nil { b.cfg.Metrics.ActiveChannelIDsPending(-1) } } } func (b *ActiveChannelIDsPageBatcher) failQueued(err error, pending []activeChannelIDsBatchRequest) { for _, request := range pending { b.failRequest(request, err) } for { select { case request := <-b.queue: b.failRequest(request, err) default: return } } } func (b *ActiveChannelIDsPageBatcher) failRequest(request activeChannelIDsBatchRequest, err error) { request.result <- activeChannelIDsBatchResult{err: err} if b.cfg.Metrics != nil { b.cfg.Metrics.ActiveChannelIDsPending(-1) } } func (s *ChannelStore) listActiveChannelIDPages( ctx context.Context, selectors []activeChannelIDsSelector, ) ([][]int64, error) { pages := make([][]int64, len(selectors)) if len(selectors) == 0 { return pages, nil } userIDs := make([]int64, len(selectors)) afterChannelIDs := make([]int64, len(selectors)) limits := make([]int32, len(selectors)) seen := make(map[activeChannelIDsSelector]struct{}, len(selectors)) for index, selector := range selectors { if selector.userID == 0 || selector.afterChannelID < 0 || selector.limit <= 0 || selector.limit > domain.MaxSynchronousChannelDialogFanout { return nil, fmt.Errorf("list active channel IDs batch: invalid selector at index %d", index) } if _, duplicate := seen[selector]; duplicate { return nil, fmt.Errorf("list active channel IDs batch: duplicate selector at index %d", index) } seen[selector] = struct{}{} userIDs[index] = selector.userID afterChannelIDs[index] = selector.afterChannelID limits[index] = int32(selector.limit) } rows, err := s.db.Query(ctx, ` WITH input AS ( SELECT * FROM unnest($1::bigint[], $2::bigint[], $3::integer[]) WITH ORDINALITY AS value(user_id, after_channel_id, page_limit, ordinal) ) SELECT input.ordinal, visible.channel_id FROM input JOIN LATERAL ( SELECT channel_id FROM ( SELECT membership.channel_id FROM user_channel_member_index AS membership WHERE membership.user_id = input.user_id AND membership.status = 'active' AND NOT membership.deleted UNION SELECT mono.id FROM channels AS mono JOIN channels AS parent ON parent.id = mono.linked_monoforum_id AND NOT parent.deleted AND parent.broadcast_messages_allowed AND parent.linked_monoforum_id = mono.id WHERE mono.monoforum AND NOT mono.deleted AND ( EXISTS ( SELECT 1 FROM channel_members AS admin WHERE admin.channel_id = parent.id AND admin.user_id = input.user_id AND admin.status = 'active' AND ( admin.role = 'creator' OR ( admin.role = 'admin' AND COALESCE((admin.admin_rights->>'ManageDirectMessages')::boolean, false) ) ) ) OR EXISTS ( SELECT 1 FROM channel_messages AS message WHERE message.channel_id = mono.id AND message.saved_peer_type = 'user' AND message.saved_peer_id = input.user_id AND NOT message.deleted ) ) ) AS visible_channels WHERE channel_id > input.after_channel_id ORDER BY channel_id LIMIT input.page_limit ) AS visible ON true ORDER BY input.ordinal, visible.channel_id`, userIDs, afterChannelIDs, limits) if err != nil { return nil, fmt.Errorf("list active channel IDs batch: %w", err) } defer rows.Close() for rows.Next() { var ordinal int64 var channelID int64 if err := rows.Scan(&ordinal, &channelID); err != nil { return nil, err } if ordinal <= 0 || ordinal > int64(len(pages)) { return nil, fmt.Errorf("list active channel IDs batch: invalid ordinal %d", ordinal) } page := pages[ordinal-1] selector := selectors[ordinal-1] if channelID <= selector.afterChannelID || (len(page) > 0 && channelID <= page[len(page)-1]) || len(page) >= selector.limit { return nil, fmt.Errorf("list active channel IDs batch: invalid page row for ordinal %d", ordinal) } pages[ordinal-1] = append(page, channelID) } if err := rows.Err(); err != nil { return nil, err } return pages, nil }