merged from gramsrv upstream

This commit is contained in:
onysd 2026-09-01 12:06:31 +03:00
parent 79c64ee916
commit 21a0856587
651 changed files with 54774 additions and 4590 deletions

View file

@ -2,13 +2,56 @@ package rpc
import (
"context"
"errors"
"fmt"
"go.uber.org/zap"
"telesrv/internal/app/peerview"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store"
)
const (
maxPeerProjectionUsersPerBatch = 1000
maxPeerProjectionRecoveryCalls = 64
maxPeerProjectionAttemptedOwnerIDs = 16000
)
var (
ErrDurableUserProjectionIncomplete = errors.New("durable user projection is incomplete")
ErrUserProjectionCapacityRecoveryLimit = errors.New("user projection capacity recovery limit exceeded")
)
type userProjectionRecoveryBudget struct {
maxCalls int
maxItems int
calls int
attempted int
}
func (b *userProjectionRecoveryBudget) consume(items int) error {
if b == nil {
return nil
}
itemsExceeded := items < 0 || b.attempted > b.maxItems || items > b.maxItems-b.attempted
if b.calls >= b.maxCalls || itemsExceeded {
return fmt.Errorf(
"%w: calls=%d/%d attempted=%d/%d next=%d",
ErrUserProjectionCapacityRecoveryLimit,
b.calls,
b.maxCalls,
b.attempted,
b.maxItems,
items,
)
}
b.calls++
b.attempted += items
return nil
}
// viewerPeerCache 是一次 outbox/fanout 构建内的短生命周期缓存。
// 用户资料含联系人、隐私、头像和在线状态,必须按 viewerUserID 隔离,不能跨视角复用。
type viewerPeerCache struct {
@ -33,21 +76,77 @@ func newViewerPeerCache(r *Router) *viewerPeerCache {
}
func (c *viewerPeerCache) usersForIDs(ctx context.Context, viewerUserID int64, ids []int64) []domain.User {
if c == nil || c.users == nil {
return nil
}
users, err := c.users.UsersForView(ctx, viewerUserID, ids)
if err != nil && c.r != nil {
unique := uniquePeerIDs(ids)
users, err := c.resolveUsersForIDs(ctx, viewerUserID, unique)
if err != nil && c != nil && c.r != nil {
c.r.log.Warn("batch resolve users for peer projection failed",
zap.Int64("viewer_user_id", viewerUserID),
zap.Int("count", len(uniquePeerIDs(ids))),
zap.Int("count", len(unique)),
zap.Error(err),
)
}
if c.r == nil {
return users
return users
}
// usersForIDsStrict rejects resolver errors and incomplete durable envelopes.
// Callers must not merge a partial projection with raw store users while
// advancing an account/channel difference cursor or dispatching an outbox row.
func (c *viewerPeerCache) usersForIDsStrict(ctx context.Context, viewerUserID int64, ids []int64) ([]domain.User, error) {
unique := uniquePeerIDs(ids)
users, err := c.resolveUsersForIDs(ctx, viewerUserID, unique)
if err != nil {
return nil, fmt.Errorf("resolve durable users for viewer %d: %w", viewerUserID, err)
}
return c.r.withUsersPresence(users)
if missingID, missing := missingProjectedUserID(unique, users); missing {
return nil, fmt.Errorf("%w: viewer_user_id=%d missing_user_id=%d", ErrDurableUserProjectionIncomplete, viewerUserID, missingID)
}
return users, nil
}
func (c *viewerPeerCache) resolveUsersForIDs(ctx context.Context, viewerUserID int64, unique []int64) ([]domain.User, error) {
if c == nil || c.users == nil {
return nil, nil
}
budget := userProjectionRecoveryBudget{
maxCalls: maxPeerProjectionRecoveryCalls,
maxItems: maxPeerProjectionAttemptedOwnerIDs,
}
users := make([]domain.User, 0, len(unique))
for start := 0; start < len(unique); start += maxPeerProjectionUsersPerBatch {
end := start + maxPeerProjectionUsersPerBatch
if end > len(unique) {
end = len(unique)
}
batch, err := c.usersForIDBatch(ctx, viewerUserID, unique[start:end], &budget)
if err != nil {
return nil, err
}
users = append(users, batch...)
}
if c.r == nil {
return users, nil
}
return c.r.withUsersPresence(users), nil
}
func (c *viewerPeerCache) usersForIDBatch(ctx context.Context, viewerUserID int64, ids []int64, budget *userProjectionRecoveryBudget) ([]domain.User, error) {
if err := budget.consume(len(ids)); err != nil {
return nil, err
}
users, err := c.users.UsersForView(ctx, viewerUserID, ids)
if err == nil || !isSparseProjectionCapacityError(err) || len(ids) < 2 {
return users, err
}
middle := len(ids) / 2
left, leftErr := c.usersForIDBatch(ctx, viewerUserID, ids[:middle], budget)
if leftErr != nil {
return nil, leftErr
}
right, rightErr := c.usersForIDBatch(ctx, viewerUserID, ids[middle:], budget)
if rightErr != nil {
return nil, rightErr
}
return append(left, right...), nil
}
// primeUsers 把跨 viewer 一次性投影ByIDsForViewers的结果按 viewer 预热进底层 BatchCache
@ -59,6 +158,40 @@ func (c *viewerPeerCache) primeUsers(viewerUserID int64, users []domain.User) {
c.users.Prime(viewerUserID, users)
}
// primeExpectedUsers follows a caller-side completeness check. PrimeExpected
// also negative-caches omitted system-resolved IDs so later lookups cannot
// silently fall back to a scalar resolver.
func (c *viewerPeerCache) primeExpectedUsers(viewerUserID int64, expectedIDs []int64, users []domain.User) {
if c == nil || c.users == nil || viewerUserID == 0 {
return
}
c.users.PrimeExpected(viewerUserID, expectedIDs, users)
}
func missingProjectedUserID(expectedIDs []int64, users []domain.User) (int64, bool) {
found := make(map[int64]struct{}, len(users))
for _, user := range users {
if user.ID != 0 {
found[user.ID] = struct{}{}
}
}
for _, id := range uniquePeerIDs(expectedIDs) {
if _, system := domain.SystemUserByID(id); system {
continue
}
if _, ok := found[id]; !ok {
return id, true
}
}
return 0, false
}
func isSparseProjectionCapacityError(err error) bool {
return errors.Is(err, store.ErrActiveChannelMemberPairsLimit) ||
errors.Is(err, appusers.ErrBatchUsersLimit) ||
errors.Is(err, appusers.ErrBatchViewerCells)
}
func (c *viewerPeerCache) channelsForIDs(ctx context.Context, viewerUserID int64, ids []int64) []domain.Channel {
unique := uniquePeerIDs(ids)
if c == nil || c.r == nil || len(unique) == 0 || c.r.deps.Channels == nil || viewerUserID == 0 {