merged from gramsrv upstream
This commit is contained in:
parent
79c64ee916
commit
21a0856587
651 changed files with 54774 additions and 4590 deletions
|
|
@ -78,6 +78,7 @@ type SavedMusicStore interface {
|
|||
|
||||
// BusinessAutomationStore persists account-local Telegram Business settings.
|
||||
type BusinessAutomationStore interface {
|
||||
HasBusinessAutomation(ctx context.Context, userID int64) (bool, error)
|
||||
GetBusinessProfile(ctx context.Context, userID int64) (domain.BusinessProfile, bool, error)
|
||||
SaveBusinessProfile(ctx context.Context, profile domain.BusinessProfile) error
|
||||
ListBusinessChatLinks(ctx context.Context, ownerUserID int64) ([]domain.BusinessChatLink, error)
|
||||
|
|
|
|||
|
|
@ -7,9 +7,8 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// AccountLifecycleStore owns the atomic boundary between tombstoning a user,
|
||||
// purging private account state, revoking authorizations and enqueueing
|
||||
// non-pts updateUser notifications.
|
||||
// AccountLifecycleStore owns the durable account-deletion decision and logical
|
||||
// tombstone boundary.
|
||||
type AccountLifecycleStore interface {
|
||||
AccountDeletionSnapshot(ctx context.Context, userID int64) (domain.AccountDeletionSnapshot, bool, error)
|
||||
ScheduleAccountDeletion(ctx context.Context, req domain.ScheduleAccountDeletion) (domain.AccountDeletionRequest, bool, error)
|
||||
|
|
@ -17,6 +16,4 @@ type AccountLifecycleStore interface {
|
|||
ExecuteAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error)
|
||||
CancelAccountDeletion(ctx context.Context, userID int64, digest [32]byte, now time.Time) ([]domain.Authorization, error)
|
||||
DueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionCandidate, error)
|
||||
ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error)
|
||||
CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error
|
||||
}
|
||||
|
|
|
|||
28
internal/store/active_channel_ids_cache.go
Normal file
28
internal/store/active_channel_ids_cache.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package store
|
||||
|
||||
import "context"
|
||||
|
||||
// ActiveChannelIDsPageKey identifies one immutable page of the durable
|
||||
// owner-scoped active membership read model. Generation is the exact
|
||||
// channel_active_memberships hash, or the documented missing-generation
|
||||
// sentinel used before an owner has any membership row.
|
||||
type ActiveChannelIDsPageKey struct {
|
||||
UserID int64
|
||||
Generation int64
|
||||
AfterChannelID int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
// ActiveChannelIDsPageCache is a rebuildable shared L2. Redis errors are
|
||||
// returned so callers cannot silently turn an outage into a PostgreSQL
|
||||
// stampede.
|
||||
type ActiveChannelIDsPageCache interface {
|
||||
GetActiveChannelIDsPage(context.Context, ActiveChannelIDsPageKey) ([]int64, bool, error)
|
||||
PutActiveChannelIDsPage(context.Context, ActiveChannelIDsPageKey, []int64) error
|
||||
}
|
||||
|
||||
// ActiveChannelIDsPageLoader is the bounded authoritative cold source used
|
||||
// only after a shared-cache miss.
|
||||
type ActiveChannelIDsPageLoader interface {
|
||||
ListActiveChannelIDsForUser(context.Context, int64, int64, int) ([]int64, error)
|
||||
}
|
||||
|
|
@ -5,10 +5,24 @@ import "context"
|
|||
// BoxIDAllocator 分配用户视角 message box id。box_id 允许空洞,但不能回退。
|
||||
type BoxIDAllocator interface {
|
||||
NextBoxID(ctx context.Context, userID int64) (int, error)
|
||||
// NextBoxIDs allocates one monotonic id for every distinct user in one
|
||||
// backend batch. Implementations reject invalid users before allocation and
|
||||
// must not turn this operation into a per-user network loop.
|
||||
NextBoxIDs(ctx context.Context, userIDs []int64) (map[int64]int, error)
|
||||
CurrentBoxID(ctx context.Context, userID int64) (int, error)
|
||||
}
|
||||
|
||||
// DistributedBoxIDAllocator marks an allocator whose per-owner reservations
|
||||
// remain atomic across processes and are safe before a PostgreSQL transaction.
|
||||
// The private-send microbatch path requires this capability; local and test
|
||||
// allocators stay on the single-command transaction path.
|
||||
type DistributedBoxIDAllocator interface {
|
||||
BoxIDAllocator
|
||||
DistributedBoxIDAllocation()
|
||||
}
|
||||
|
||||
// CounterSource 用于 Redis 计数器冷启动时从 PostgreSQL durable log 恢复当前值。
|
||||
type CounterSource interface {
|
||||
Current(ctx context.Context, userID int64) (int, error)
|
||||
CurrentBatch(ctx context.Context, userIDs []int64) (map[int64]int, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,17 @@ type AuthKeyClientInfo struct {
|
|||
AppVersion string
|
||||
}
|
||||
|
||||
// AuthKeyBindingKeys is the authoritative pair used to verify one
|
||||
// auth.bindTempAuthKey proof. Stores load and activity-touch both requested
|
||||
// rows in one database statement so orphan collection cannot split proof
|
||||
// validation across two independent leases.
|
||||
type AuthKeyBindingKeys struct {
|
||||
Temporary AuthKeyData
|
||||
TemporaryFound bool
|
||||
Permanent AuthKeyData
|
||||
PermanentFound bool
|
||||
}
|
||||
|
||||
// MergeAuthKeyLayerObservations resolves the inherited default when a raw
|
||||
// temporary key is bound to its permanent identity. Positive observation IDs
|
||||
// are globally ordered durable evidence. Equal positive IDs must describe the
|
||||
|
|
@ -104,13 +115,21 @@ func MergeAuthKeyLayerObservations(
|
|||
type AuthKeyStore interface {
|
||||
// Save 保存一条 auth key 记录;同 ID 重试只能保持 key body 与协议类型/寿命不变。
|
||||
Save(ctx context.Context, k AuthKeyData) error
|
||||
// Get 按 auth_key_id 查询;不存在时 found=false。
|
||||
// Get 按 auth_key_id 查询并刷新 durable orphan-activity lease;不存在时
|
||||
// found=false。只用于 physical connection 首次取得 key 或其它确需建立
|
||||
// 新 lease 的边界。
|
||||
Get(ctx context.Context, id [8]byte) (data AuthKeyData, found bool, err error)
|
||||
// Revalidate 在 activation claim 已可见后重新读取权威 row,但不重复刷新
|
||||
// last_used_at。首次 Get 与 active-key heartbeat 已负责跨实例 orphan lease。
|
||||
Revalidate(ctx context.Context, id [8]byte) (data AuthKeyData, found bool, err error)
|
||||
// LoadBindingKeys 在一个权威调用中取得并 touch temp/permanent proof key。
|
||||
LoadBindingKeys(ctx context.Context, tempID, permID [8]byte) (AuthKeyBindingKeys, error)
|
||||
// UpdateClientInfo 合并更新 auth key 的客户端协商元数据。目标 key 不存在时
|
||||
// 必须返回 ErrAuthKeyNotFound,禁止把缺失 primary 当成成功后继续更新 mirror。
|
||||
// 空字段不覆盖已有值,layer/api_id 为 0 时不覆盖。
|
||||
UpdateClientInfo(ctx context.Context, id [8]byte, info AuthKeyClientInfo) error
|
||||
// Delete 删除一条 auth key 记录(destroy_auth_key)。不存在时静默成功。
|
||||
// 连接层每帧按 auth_key_id 回查本接口,删除后该 key 的入站帧立即失效。
|
||||
// SessionManager/control fabric 负责 fence active connection;activation
|
||||
// claim 仍通过 Revalidate 关闭首次取得与注册之间的竞态。
|
||||
Delete(ctx context.Context, id [8]byte) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,9 +40,12 @@ type AuthKeySessionLayer struct {
|
|||
// processes and restarts. AdvanceSessionLayer never replaces a live row with a
|
||||
// lower msg_id. applied is true only for an insert, a strictly newer msg_id, or
|
||||
// replacement of an expired row; duplicate/older evidence returns the current
|
||||
// row with applied=false. A successful advance and the auth-key-wide default
|
||||
// update share one store transaction and one globally ordered ObservationID;
|
||||
// callers must not persist the default in a second best-effort write.
|
||||
// row with applied=false. A strictly newer selector at the same Layer advances
|
||||
// only the exact-session msg_id/expiry and retains its ObservationID because no
|
||||
// shared profile generation changed. Inserts, expiry replacements and Layer
|
||||
// changes update the auth-key-wide default in the same transaction and allocate
|
||||
// one globally ordered ObservationID; callers must not persist the default in a
|
||||
// second best-effort write.
|
||||
// AdvanceSessionLayer derives ExpiresAt from a fresh client msg_id at the store
|
||||
// boundary; no caller-controlled retention duration is accepted.
|
||||
type AuthKeySessionLayerStore interface {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@ package store
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
var ErrAuthorizationStateChanged = errors.New("authorization state changed")
|
||||
|
||||
// AuthorizationStore 持久化设备授权(auth_key ↔ user 绑定)。实现见 store/memory(测试替身)、store/postgres。
|
||||
type AuthorizationStore interface {
|
||||
Bind(ctx context.Context, a domain.Authorization) error
|
||||
|
|
@ -16,8 +19,9 @@ type AuthorizationStore interface {
|
|||
Delete(ctx context.Context, authKeyID [8]byte) error
|
||||
DeleteByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error)
|
||||
DeleteByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error)
|
||||
// MarkPasswordPassed 清除 auth_key 的 password_pending 标记,使其转为完全授权(两步验证通过后调用)。
|
||||
MarkPasswordPassed(ctx context.Context, authKeyID [8]byte) error
|
||||
// MarkPasswordPassed 仅在 auth_key 仍属于 expectedUserID 且仍为
|
||||
// password_pending 时提升为完全授权,避免旧用户的密码 proof 提升重绑后的账号。
|
||||
MarkPasswordPassed(ctx context.Context, authKeyID [8]byte, expectedUserID int64) error
|
||||
}
|
||||
|
||||
// AuthKeyAuthorityLinker is an optional in-process store-composition boundary.
|
||||
|
|
|
|||
|
|
@ -2,11 +2,18 @@ package store
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// MaxActiveChannelMemberPairs bounds every exact channel-membership store
|
||||
// request independently of caller-side privacy projection admission.
|
||||
const MaxActiveChannelMemberPairs = 65536
|
||||
|
||||
var ErrActiveChannelMemberPairsLimit = errors.New("active channel membership pair limit exceeded")
|
||||
|
||||
// ChannelStore persists Telegram channels/supergroups and their single-copy messages.
|
||||
type ChannelStore interface {
|
||||
CreateChannel(ctx context.Context, req domain.CreateChannelRequest) (domain.CreateChannelResult, error)
|
||||
|
|
@ -189,6 +196,10 @@ type ChannelStore interface {
|
|||
ListActiveChannelMembers(ctx context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error)
|
||||
ListChannelInviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error)
|
||||
FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
|
||||
// FilterActiveChannelMemberPairs intersects only the supplied channel->user
|
||||
// edges. Implementations must keep this as one bounded batch rather than
|
||||
// widening it into channels x users or issuing one query per channel.
|
||||
FilterActiveChannelMemberPairs(ctx context.Context, userIDsByChannel map[int64][]int64) (map[int64][]int64, error)
|
||||
// FilterChannelMessageAudienceIDs authoritatively intersects a bounded online
|
||||
// candidate set with users allowed to receive channel message-box updates:
|
||||
// active members plus non-banned public-channel preview subscribers.
|
||||
|
|
|
|||
|
|
@ -38,9 +38,10 @@ func LoginCodeChannelTakeable(channel string) bool {
|
|||
}
|
||||
|
||||
// PhoneCodeVersionCurrent is the only version accepted by the atomic login
|
||||
// state machine. Version zero is the pre-state-machine shape and deliberately
|
||||
// fails closed instead of being normalized on read.
|
||||
const PhoneCodeVersionCurrent = 1
|
||||
// state machine. Version 2 binds every scope to an E.164 canonical identity;
|
||||
// version 1 records are invalidated across rollout instead of being normalized
|
||||
// on read and accidentally authorizing a different phone owner.
|
||||
const PhoneCodeVersionCurrent = 2
|
||||
|
||||
// PhoneCode 是一条验证码记录(与某次 sendCode 的 phone_code_hash 或邮箱验证键关联)。
|
||||
// Purpose/UserID/AuthKeyID/SessionID 为已登录敏感操作提供作用域;登录验证码保持零值。
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ type ContactStore interface {
|
|||
Get(ctx context.Context, userID, contactUserID int64) (domain.Contact, bool, error)
|
||||
GetMany(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.Contact, error)
|
||||
GetReverseContacts(ctx context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error)
|
||||
ContactProjectionForViewers(ctx context.Context, viewerUserIDs, contactUserIDs []int64) (domain.ContactProjectionBatch, error)
|
||||
Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error)
|
||||
UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error)
|
||||
UpdateNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, bool, error)
|
||||
|
|
@ -24,3 +25,19 @@ type ContactStore interface {
|
|||
IsBlocked(ctx context.Context, userID, blockedUserID int64) (bool, error)
|
||||
ListBlocked(ctx context.Context, userID int64, offset, limit int) (domain.BlockedContactList, error)
|
||||
}
|
||||
|
||||
// SparseContactProjectionStore reads only the explicitly requested
|
||||
// viewer->contact pairs. Unlike ContactProjectionForViewers, the two dimensions
|
||||
// are not crossed: a target listed for one viewer is never read for another
|
||||
// viewer unless that pair is also present in contactUserIDsByViewer.
|
||||
type SparseContactProjectionStore interface {
|
||||
ContactProjectionForViewerUserIDs(ctx context.Context, contactUserIDsByViewer map[int64][]int64) (domain.ContactProjectionBatch, error)
|
||||
}
|
||||
|
||||
// SparseReverseContactStore reads only explicitly requested owner->viewer
|
||||
// relationship pairs. It is the database-facing primitive behind batched
|
||||
// privacy projection: unlike GetReverseContacts it can combine different
|
||||
// viewers in one query without broadening the request into a cross product.
|
||||
type SparseReverseContactStore interface {
|
||||
GetReverseContactsForViewerUserIDs(ctx context.Context, viewerUserIDsByOwner map[int64][]int64) (map[int64]map[int64]domain.Contact, error)
|
||||
}
|
||||
|
|
|
|||
318
internal/store/contact_reverse_batch.go
Normal file
318
internal/store/contact_reverse_batch.go
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// ReverseContactBatchConfig bounds synchronous cross-request batching of the
|
||||
// exact owner->viewer relationship facts used by privacy projection. MaxPairs
|
||||
// limits the union sent to one database query; QueueSize limits accepted RPC
|
||||
// requests rather than individual pairs.
|
||||
type ReverseContactBatchConfig struct {
|
||||
MaxPairs int
|
||||
MaxWait time.Duration
|
||||
QueueSize int
|
||||
QueryTimeout time.Duration
|
||||
}
|
||||
|
||||
type reverseContactPair struct {
|
||||
ownerUserID int64
|
||||
viewerUserID int64
|
||||
}
|
||||
|
||||
type reverseContactBatchRequest struct {
|
||||
ctx context.Context
|
||||
viewerUserID int64
|
||||
ownerUserIDs []int64
|
||||
result chan reverseContactBatchResult
|
||||
}
|
||||
|
||||
type reverseContactBatchResult struct {
|
||||
contacts map[int64]domain.Contact
|
||||
err error
|
||||
}
|
||||
|
||||
// BatchedReverseContactStore preserves ContactStore while replacing
|
||||
// GetReverseContacts with an exact-pair batch coordinator. The embedded base
|
||||
// remains authoritative for writes and all other reads. There is deliberately
|
||||
// no direct-query fallback: overload and shared-query failures stay visible to
|
||||
// callers instead of recreating the PostgreSQL connection storm this layer is
|
||||
// intended to prevent.
|
||||
type BatchedReverseContactStore struct {
|
||||
ContactStore
|
||||
sparse SparseReverseContactStore
|
||||
cfg ReverseContactBatchConfig
|
||||
|
||||
queue chan reverseContactBatchRequest
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
cancel context.CancelFunc
|
||||
once sync.Once
|
||||
gate sync.RWMutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewBatchedReverseContactStore(base ContactStore, cfg ReverseContactBatchConfig) (*BatchedReverseContactStore, error) {
|
||||
if base == nil {
|
||||
return nil, errors.New("initialize reverse-contact batcher: nil store")
|
||||
}
|
||||
sparse, ok := base.(SparseReverseContactStore)
|
||||
if !ok {
|
||||
return nil, errors.New("initialize reverse-contact batcher: store does not support sparse reverse reads")
|
||||
}
|
||||
if cfg.MaxPairs <= 0 || cfg.MaxPairs > 1<<16 {
|
||||
return nil, fmt.Errorf("initialize reverse-contact batcher: max pairs %d outside [1,65536]", cfg.MaxPairs)
|
||||
}
|
||||
if cfg.MaxWait <= 0 || cfg.MaxWait > 10*time.Millisecond {
|
||||
return nil, fmt.Errorf("initialize reverse-contact batcher: max wait %v outside (0,10ms]", cfg.MaxWait)
|
||||
}
|
||||
if cfg.QueueSize <= 0 || cfg.QueueSize > 1<<20 {
|
||||
return nil, fmt.Errorf("initialize reverse-contact batcher: queue size %d outside [1,1048576]", cfg.QueueSize)
|
||||
}
|
||||
if cfg.QueryTimeout <= 0 || cfg.QueryTimeout > 30*time.Second {
|
||||
return nil, fmt.Errorf("initialize reverse-contact batcher: query timeout %v outside (0,30s]", cfg.QueryTimeout)
|
||||
}
|
||||
workerCtx, cancel := context.WithCancel(context.Background())
|
||||
s := &BatchedReverseContactStore{
|
||||
ContactStore: base,
|
||||
sparse: sparse,
|
||||
cfg: cfg,
|
||||
queue: make(chan reverseContactBatchRequest, cfg.QueueSize),
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
cancel: cancel,
|
||||
}
|
||||
go s.run(workerCtx)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *BatchedReverseContactStore) GetReverseContacts(
|
||||
ctx context.Context,
|
||||
viewerUserID int64,
|
||||
ownerUserIDs []int64,
|
||||
) (map[int64]domain.Contact, error) {
|
||||
out := make(map[int64]domain.Contact, len(ownerUserIDs))
|
||||
if viewerUserID == 0 || len(ownerUserIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
owners := canonicalPositiveInt64(ownerUserIDs)
|
||||
for start := 0; start < len(owners); start += s.cfg.MaxPairs {
|
||||
end := start + s.cfg.MaxPairs
|
||||
if end > len(owners) {
|
||||
end = len(owners)
|
||||
}
|
||||
loaded, err := s.readChunk(ctx, viewerUserID, owners[start:end])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for ownerID, contact := range loaded {
|
||||
out[ownerID] = contact
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ContactProjectionForViewerUserIDs preserves the optional sparse projection
|
||||
// capability through this wrapper so the outer contact cache does not fall
|
||||
// back to a dense viewers x targets query.
|
||||
func (s *BatchedReverseContactStore) ContactProjectionForViewerUserIDs(
|
||||
ctx context.Context,
|
||||
requested map[int64][]int64,
|
||||
) (domain.ContactProjectionBatch, error) {
|
||||
projection, ok := s.ContactStore.(SparseContactProjectionStore)
|
||||
if !ok {
|
||||
return domain.ContactProjectionBatch{}, errors.New("contact store does not support sparse projection")
|
||||
}
|
||||
return projection.ContactProjectionForViewerUserIDs(ctx, requested)
|
||||
}
|
||||
|
||||
func (s *BatchedReverseContactStore) readChunk(
|
||||
ctx context.Context,
|
||||
viewerUserID int64,
|
||||
ownerUserIDs []int64,
|
||||
) (map[int64]domain.Contact, error) {
|
||||
request := reverseContactBatchRequest{
|
||||
ctx: ctx,
|
||||
viewerUserID: viewerUserID,
|
||||
ownerUserIDs: append([]int64(nil), ownerUserIDs...),
|
||||
result: make(chan reverseContactBatchResult, 1),
|
||||
}
|
||||
s.gate.RLock()
|
||||
if s.closed {
|
||||
s.gate.RUnlock()
|
||||
return nil, context.Canceled
|
||||
}
|
||||
select {
|
||||
case s.queue <- request:
|
||||
case <-ctx.Done():
|
||||
s.gate.RUnlock()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
s.gate.RUnlock()
|
||||
|
||||
select {
|
||||
case result := <-request.result:
|
||||
return result.contacts, result.err
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BatchedReverseContactStore) Close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.once.Do(func() {
|
||||
s.gate.Lock()
|
||||
s.closed = true
|
||||
close(s.stop)
|
||||
s.cancel()
|
||||
s.gate.Unlock()
|
||||
<-s.done
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BatchedReverseContactStore) run(ctx context.Context) {
|
||||
defer close(s.done)
|
||||
var carry *reverseContactBatchRequest
|
||||
for {
|
||||
batch := make([]reverseContactBatchRequest, 0, 32)
|
||||
pairCount := 0
|
||||
if carry != nil {
|
||||
batch = append(batch, *carry)
|
||||
pairCount = len(carry.ownerUserIDs)
|
||||
carry = nil
|
||||
} else {
|
||||
select {
|
||||
case request := <-s.queue:
|
||||
batch = append(batch, request)
|
||||
pairCount = len(request.ownerUserIDs)
|
||||
case <-s.stop:
|
||||
s.failQueued(context.Canceled, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
timer := time.NewTimer(s.cfg.MaxWait)
|
||||
collect:
|
||||
for pairCount < s.cfg.MaxPairs {
|
||||
select {
|
||||
case request := <-s.queue:
|
||||
if pairCount+len(request.ownerUserIDs) > s.cfg.MaxPairs {
|
||||
carry = &request
|
||||
break collect
|
||||
}
|
||||
batch = append(batch, request)
|
||||
pairCount += len(request.ownerUserIDs)
|
||||
case <-timer.C:
|
||||
break collect
|
||||
case <-s.stop:
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
if carry != nil {
|
||||
batch = append(batch, *carry)
|
||||
carry = nil
|
||||
}
|
||||
s.failQueued(context.Canceled, batch)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
s.execute(ctx, batch)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BatchedReverseContactStore) execute(ctx context.Context, batch []reverseContactBatchRequest) {
|
||||
active := batch[:0]
|
||||
seen := make(map[reverseContactPair]struct{})
|
||||
requested := make(map[int64][]int64)
|
||||
for _, request := range batch {
|
||||
if err := request.ctx.Err(); err != nil {
|
||||
request.result <- reverseContactBatchResult{err: err}
|
||||
continue
|
||||
}
|
||||
active = append(active, request)
|
||||
for _, ownerID := range request.ownerUserIDs {
|
||||
pair := reverseContactPair{ownerUserID: ownerID, viewerUserID: request.viewerUserID}
|
||||
if _, duplicate := seen[pair]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[pair] = struct{}{}
|
||||
requested[ownerID] = append(requested[ownerID], request.viewerUserID)
|
||||
}
|
||||
}
|
||||
if len(active) == 0 {
|
||||
return
|
||||
}
|
||||
queryCtx, cancel := context.WithTimeout(ctx, s.cfg.QueryTimeout)
|
||||
loaded, err := s.sparse.GetReverseContactsForViewerUserIDs(queryCtx, requested)
|
||||
cancel()
|
||||
if err != nil {
|
||||
for _, request := range active {
|
||||
request.result <- reverseContactBatchResult{err: err}
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, request := range active {
|
||||
contacts := make(map[int64]domain.Contact, len(request.ownerUserIDs))
|
||||
for _, ownerID := range request.ownerUserIDs {
|
||||
if contact, found := loaded[ownerID][request.viewerUserID]; found {
|
||||
contacts[ownerID] = contact
|
||||
}
|
||||
}
|
||||
request.result <- reverseContactBatchResult{contacts: contacts}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BatchedReverseContactStore) failQueued(err error, pending []reverseContactBatchRequest) {
|
||||
for _, request := range pending {
|
||||
request.result <- reverseContactBatchResult{err: err}
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case request := <-s.queue:
|
||||
request.result <- reverseContactBatchResult{err: err}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalPositiveInt64(values []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
out := make([]int64, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value == 0 {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[value]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out
|
||||
}
|
||||
|
||||
var _ ContactStore = (*BatchedReverseContactStore)(nil)
|
||||
var _ SparseContactProjectionStore = (*BatchedReverseContactStore)(nil)
|
||||
146
internal/store/contact_reverse_batch_test.go
Normal file
146
internal/store/contact_reverse_batch_test.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type recordingSparseReverseStore struct {
|
||||
store.ContactStore
|
||||
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
pairCount int
|
||||
}
|
||||
|
||||
func (s *recordingSparseReverseStore) GetReverseContactsForViewerUserIDs(
|
||||
ctx context.Context,
|
||||
requested map[int64][]int64,
|
||||
) (map[int64]map[int64]domain.Contact, error) {
|
||||
out := make(map[int64]map[int64]domain.Contact, len(requested))
|
||||
pairs := 0
|
||||
for ownerID, viewerIDs := range requested {
|
||||
for _, viewerID := range viewerIDs {
|
||||
pairs++
|
||||
contact, found, err := s.ContactStore.Get(ctx, ownerID, viewerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
if out[ownerID] == nil {
|
||||
out[ownerID] = make(map[int64]domain.Contact)
|
||||
}
|
||||
out[ownerID][viewerID] = contact
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.calls++
|
||||
s.pairCount += pairs
|
||||
s.mu.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *recordingSparseReverseStore) stats() (int, int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.calls, s.pairCount
|
||||
}
|
||||
|
||||
func TestBatchedReverseContactStoreCombinesExactPairs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewContactStore()
|
||||
const requestCount = 32
|
||||
for index := 0; index < requestCount; index++ {
|
||||
viewerID := int64(10_000 + index)
|
||||
ownerID := int64(20_000 + index)
|
||||
if _, err := base.Upsert(ctx, ownerID, domain.ContactInput{
|
||||
ContactUserID: viewerID,
|
||||
FirstName: "viewer",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if index%2 == 0 {
|
||||
if _, err := base.SetCloseFriends(ctx, ownerID, []int64{viewerID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
recording := &recordingSparseReverseStore{ContactStore: base}
|
||||
batched, err := store.NewBatchedReverseContactStore(recording, store.ReverseContactBatchConfig{
|
||||
MaxPairs: 128, MaxWait: 10 * time.Millisecond, QueueSize: 64, QueryTimeout: time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(batched.Close)
|
||||
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, requestCount)
|
||||
var wg sync.WaitGroup
|
||||
for index := 0; index < requestCount; index++ {
|
||||
index := index
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
viewerID := int64(10_000 + index)
|
||||
ownerID := int64(20_000 + index)
|
||||
contacts, getErr := batched.GetReverseContacts(ctx, viewerID, []int64{ownerID, 99_999, ownerID})
|
||||
if getErr != nil {
|
||||
errs <- getErr
|
||||
return
|
||||
}
|
||||
contact, found := contacts[ownerID]
|
||||
if !found || contact.User.ID != viewerID || contact.CloseFriend != (index%2 == 0) {
|
||||
errs <- errors.New("batched reverse-contact result mismatch")
|
||||
}
|
||||
if _, found := contacts[99_999]; found {
|
||||
errs <- errors.New("negative reverse-contact pair returned a value")
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls, pairs := recording.stats()
|
||||
if calls <= 0 || calls > 4 {
|
||||
t.Fatalf("sparse reverse calls = %d, want 1..4 for %d concurrent requests", calls, requestCount)
|
||||
}
|
||||
// Every request contributes exactly one positive and one negative pair;
|
||||
// duplicate owner ids must be canonicalized before queue admission.
|
||||
if pairs != requestCount*2 {
|
||||
t.Fatalf("queried pairs = %d, want %d exact pairs", pairs, requestCount*2)
|
||||
}
|
||||
|
||||
batched.Close()
|
||||
if _, err := batched.GetReverseContacts(ctx, 10_000, []int64{20_000}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("GetReverseContacts after close err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBatchedReverseContactStoreRejectsInvalidConfig(t *testing.T) {
|
||||
base := &recordingSparseReverseStore{ContactStore: memory.NewContactStore()}
|
||||
for _, cfg := range []store.ReverseContactBatchConfig{
|
||||
{},
|
||||
{MaxPairs: 1, MaxWait: 11 * time.Millisecond, QueueSize: 1, QueryTimeout: time.Second},
|
||||
{MaxPairs: 1, MaxWait: time.Microsecond, QueueSize: 0, QueryTimeout: time.Second},
|
||||
{MaxPairs: 1, MaxWait: time.Microsecond, QueueSize: 1, QueryTimeout: 31 * time.Second},
|
||||
} {
|
||||
batcher, err := store.NewBatchedReverseContactStore(base, cfg)
|
||||
if err == nil {
|
||||
batcher.Close()
|
||||
t.Fatalf("invalid config accepted: %+v", cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ type DialogStore interface {
|
|||
GetDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (domain.DialogDraft, bool, error)
|
||||
DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error)
|
||||
ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error)
|
||||
ListDraftsByPeers(ctx context.Context, userID int64, peers []domain.Peer) ([]domain.DialogDraft, error)
|
||||
ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error)
|
||||
MarkRead(ctx context.Context, userID int64, peer domain.Peer, maxID int) (domain.ReadHistoryResult, error)
|
||||
// SetPinned 置顶/取消置顶一条会话;order 在会话当前 folder 内分配,
|
||||
|
|
|
|||
39
internal/store/dialog_list_snapshot_cache.go
Normal file
39
internal/store/dialog_list_snapshot_cache.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// DialogListSnapshotCacheKey identifies one all-built-in-folders owner header
|
||||
// base. OwnerHash is the durable dialog_owner generation and is part of the
|
||||
// shared cache key; shared channel generations are validated from Value.
|
||||
type DialogListSnapshotCacheKey struct {
|
||||
UserID int64
|
||||
OwnerHash int64
|
||||
}
|
||||
|
||||
// DialogListSnapshotCacheValue is a domain-only materialized owner projection.
|
||||
// It contains owner dialog facts and private top-message/peer payloads covered
|
||||
// by dialog_owner plus the recorded channel_base dependency hash. Shared
|
||||
// channel rows/top messages are hydrated through their channel-keyed caches so
|
||||
// they are not duplicated once per member. Cloud drafts are owner-scoped and
|
||||
// covered by dialog_owner, so they are materialized with Dialogs; presence,
|
||||
// privacy, notify settings and TL values remain response-time overlays.
|
||||
type DialogListSnapshotCacheValue struct {
|
||||
DependencyHash int64
|
||||
Dialogs []domain.Dialog
|
||||
Messages []domain.Message
|
||||
Users []domain.User
|
||||
State domain.UpdateState
|
||||
ArchiveSummary *domain.DialogArchiveSummary
|
||||
}
|
||||
|
||||
// DialogListSnapshotCache is a rebuildable shared L2. Transport, decode and
|
||||
// write failures are returned to the caller so production does not silently
|
||||
// stampede PostgreSQL through an unversioned fallback.
|
||||
type DialogListSnapshotCache interface {
|
||||
GetDialogListSnapshot(context.Context, DialogListSnapshotCacheKey) (DialogListSnapshotCacheValue, bool, error)
|
||||
PutDialogListSnapshot(context.Context, DialogListSnapshotCacheKey, DialogListSnapshotCacheValue) error
|
||||
}
|
||||
|
|
@ -79,6 +79,23 @@ func (s *AuthKeyStore) Get(_ context.Context, id [8]byte) (store.AuthKeyData, bo
|
|||
return k, ok, nil
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) Revalidate(ctx context.Context, id [8]byte) (store.AuthKeyData, bool, error) {
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) LoadBindingKeys(_ context.Context, tempID, permID [8]byte) (store.AuthKeyBindingKeys, error) {
|
||||
s.state.mu.RLock()
|
||||
temp, tempFound := s.state.keys[tempID]
|
||||
perm, permFound := s.state.keys[permID]
|
||||
s.state.mu.RUnlock()
|
||||
return store.AuthKeyBindingKeys{
|
||||
Temporary: temp,
|
||||
TemporaryFound: tempFound,
|
||||
Permanent: perm,
|
||||
PermanentFound: permFound,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) UpdateClientInfo(_ context.Context, id [8]byte, info store.AuthKeyClientInfo) error {
|
||||
s.state.mu.Lock()
|
||||
k, ok := s.state.keys[id]
|
||||
|
|
@ -198,19 +215,24 @@ func NewTempAuthKeyBindingStore(authKeys *AuthKeyStore) *TempAuthKeyBindingStore
|
|||
return &TempAuthKeyBindingStore{state: authKeys.state}
|
||||
}
|
||||
|
||||
func (s *TempAuthKeyBindingStore) Save(_ context.Context, b domain.TempAuthKeyBinding) error {
|
||||
func (s *TempAuthKeyBindingStore) Save(ctx context.Context, b domain.TempAuthKeyBinding) error {
|
||||
_, err := s.SaveWithState(ctx, b)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TempAuthKeyBindingStore) SaveWithState(_ context.Context, b domain.TempAuthKeyBinding) (domain.TempAuthKeyBindingResult, error) {
|
||||
b.EncryptedMessage = append([]byte(nil), b.EncryptedMessage...)
|
||||
s.state.mu.Lock()
|
||||
defer s.state.mu.Unlock()
|
||||
if current, ok := s.state.bindings[b.TempAuthKeyID]; ok && current.PermAuthKeyID != b.PermAuthKeyID {
|
||||
return store.ErrTempAuthKeyAlreadyBound
|
||||
return domain.TempAuthKeyBindingResult{}, store.ErrTempAuthKeyAlreadyBound
|
||||
}
|
||||
temp, tempFound := s.state.keys[b.TempAuthKeyID]
|
||||
var permID [8]byte
|
||||
binary.LittleEndian.PutUint64(permID[:], uint64(b.PermAuthKeyID))
|
||||
perm, permFound := s.state.keys[permID]
|
||||
if !tempFound || !permFound || temp.ExpiresAt <= 0 || perm.ExpiresAt != 0 || b.ExpiresAt != temp.ExpiresAt {
|
||||
return store.ErrAuthKeyBindingInvalid
|
||||
return domain.TempAuthKeyBindingResult{}, store.ErrAuthKeyBindingInvalid
|
||||
}
|
||||
// Binding and Layer-default normalization are one state transition. Exact
|
||||
// session evidence remains keyed by the raw temp key; only the inherited
|
||||
|
|
@ -220,7 +242,7 @@ func (s *TempAuthKeyBindingStore) Save(_ context.Context, b domain.TempAuthKeyBi
|
|||
perm.Layer, perm.LayerObservationID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return domain.TempAuthKeyBindingResult{}, err
|
||||
}
|
||||
temp.Layer, temp.LayerObservationID = layer, observationID
|
||||
perm.Layer, perm.LayerObservationID = layer, observationID
|
||||
|
|
@ -228,7 +250,7 @@ func (s *TempAuthKeyBindingStore) Save(_ context.Context, b domain.TempAuthKeyBi
|
|||
s.state.keys[permID] = perm
|
||||
s.state.bindings[b.TempAuthKeyID] = b
|
||||
s.state.mirrorAuthorizationLayersLocked([][8]byte{b.TempAuthKeyID, permID}, layer)
|
||||
return nil
|
||||
return domain.TempAuthKeyBindingResult{Layer: layer, LayerObservationID: observationID}, nil
|
||||
}
|
||||
|
||||
func (s *TempAuthKeyBindingStore) GetByTemp(_ context.Context, tempAuthKeyID [8]byte) (domain.TempAuthKeyBinding, bool, error) {
|
||||
|
|
@ -319,9 +341,9 @@ func (s *AuthorizationStore) Bind(_ context.Context, a domain.Authorization) err
|
|||
if a.Hash == 0 {
|
||||
a.Hash = int64(binary.LittleEndian.Uint64(a.AuthKeyID[:]))
|
||||
}
|
||||
if a.CreatedAt.IsZero() {
|
||||
a.CreatedAt = now
|
||||
}
|
||||
// Bind is an explicit login boundary. Metadata-only refreshes use
|
||||
// UpdateClientInfo and must not reset the session age.
|
||||
a.CreatedAt = now
|
||||
a.ActiveAt = now
|
||||
s.linkMu.RLock()
|
||||
if s.authKeys != nil {
|
||||
|
|
@ -353,9 +375,6 @@ func (s *AuthorizationStore) Bind(_ context.Context, a domain.Authorization) err
|
|||
}
|
||||
|
||||
func (s *AuthorizationStore) bindLocked(a domain.Authorization) {
|
||||
if existing, ok := s.m[a.AuthKeyID]; ok && !existing.CreatedAt.IsZero() {
|
||||
a.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
s.m[a.AuthKeyID] = a
|
||||
}
|
||||
|
||||
|
|
@ -419,14 +438,18 @@ func mergeAuthorizationClientInfo(a *domain.Authorization, info domain.AuthKeyCl
|
|||
a.ActiveAt = time.Now()
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) MarkPasswordPassed(_ context.Context, id [8]byte) error {
|
||||
func (s *AuthorizationStore) MarkPasswordPassed(_ context.Context, id [8]byte, expectedUserID int64) error {
|
||||
s.mu.Lock()
|
||||
if a, ok := s.m[id]; ok {
|
||||
a.PasswordPending = false
|
||||
a.ActiveAt = time.Now()
|
||||
s.m[id] = a
|
||||
defer s.mu.Unlock()
|
||||
a, ok := s.m[id]
|
||||
if !ok || expectedUserID == 0 || a.UserID != expectedUserID || !a.PasswordPending {
|
||||
return store.ErrAuthorizationStateChanged
|
||||
}
|
||||
s.mu.Unlock()
|
||||
now := time.Now()
|
||||
a.PasswordPending = false
|
||||
a.CreatedAt = now
|
||||
a.ActiveAt = now
|
||||
s.m[id] = a
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -80,6 +80,15 @@ func (s *AuthKeyStore) AdvanceSessionLayer(
|
|||
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeyBindingInvalid
|
||||
}
|
||||
}
|
||||
if found && now.Before(current.ExpiresAt) && layer == current.Layer {
|
||||
current.MessageID = msgID
|
||||
current.ExpiresAt = expiresAt
|
||||
stored := current
|
||||
stored.SharedDefault = false
|
||||
s.state.sessionLayers[key] = stored
|
||||
current.SharedDefault = s.state.sessionLayerIsSharedDefaultLocked(rawAuthKeyID, current)
|
||||
return current, true, nil
|
||||
}
|
||||
if s.state.nextLayerObservation == math.MaxInt64 {
|
||||
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeySessionLayerInvalid
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,9 @@ func TestAuthKeySessionLayerOrdersRestartEvidenceAndBindingDefaults(t *testing.T
|
|||
}
|
||||
now := time.Now().UTC()
|
||||
firstMsgID := authKeySessionLayerTestMsgID(now, 1)
|
||||
newerMsgID := authKeySessionLayerTestMsgID(now, 2)
|
||||
otherMsgID := authKeySessionLayerTestMsgID(now, 3)
|
||||
sameLayerMsgID := authKeySessionLayerTestMsgID(now, 2)
|
||||
newerMsgID := authKeySessionLayerTestMsgID(now, 3)
|
||||
otherMsgID := authKeySessionLayerTestMsgID(now, 4)
|
||||
|
||||
first, applied, err := keys.AdvanceSessionLayer(ctx, temp, 10, 220, firstMsgID)
|
||||
if err != nil || !applied || !first.SharedDefault || first.ObservationID <= 0 {
|
||||
|
|
@ -48,6 +49,17 @@ func TestAuthKeySessionLayerOrdersRestartEvidenceAndBindingDefaults(t *testing.T
|
|||
t.Fatalf("bound default %x = (%+v,%v,%v)", id, got, found, err)
|
||||
}
|
||||
}
|
||||
sameLayer, applied, err := keys.AdvanceSessionLayer(ctx, temp, 10, 220, sameLayerMsgID)
|
||||
if err != nil || !applied || !sameLayer.SharedDefault || sameLayer.MessageID != sameLayerMsgID ||
|
||||
sameLayer.ObservationID != first.ObservationID {
|
||||
t.Fatalf("same-Layer high-water advance = (%+v,%v,%v)", sameLayer, applied, err)
|
||||
}
|
||||
for _, id := range [][8]byte{temp, perm} {
|
||||
got, found, err := keys.Get(ctx, id)
|
||||
if err != nil || !found || got.Layer != 220 || got.LayerObservationID != first.ObservationID {
|
||||
t.Fatalf("same-Layer default rewrite %x = (%+v,%v,%v)", id, got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
newer, applied, err := keys.AdvanceSessionLayer(ctx, temp, 10, 227, newerMsgID)
|
||||
if err != nil || !applied || !newer.SharedDefault || newer.ObservationID <= first.ObservationID {
|
||||
|
|
|
|||
63
internal/store/memory/authorization_test.go
Normal file
63
internal/store/memory/authorization_test.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestAuthorizationLoginAgeAndPasswordPromotionCAS(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
auths := NewAuthorizationStore()
|
||||
key := [8]byte{0x91}
|
||||
old := time.Now().Add(-48 * time.Hour)
|
||||
|
||||
if err := auths.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: key, UserID: 101, CreatedAt: old,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, found, err := auths.ByAuthKey(ctx, key)
|
||||
if err != nil || !found || !first.CreatedAt.After(old) {
|
||||
t.Fatalf("first login authorization=%+v found=%v err=%v", first, found, err)
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: key, UserID: 101}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, _, _ := auths.ByAuthKey(ctx, key)
|
||||
if !second.CreatedAt.After(first.CreatedAt) {
|
||||
t.Fatalf("same-owner login kept created_at=%v, want newer than %v", second.CreatedAt, first.CreatedAt)
|
||||
}
|
||||
|
||||
if err := auths.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: key, UserID: 101, PasswordPending: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := auths.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: key, UserID: 202, PasswordPending: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pendingB, _, _ := auths.ByAuthKey(ctx, key)
|
||||
if err := auths.MarkPasswordPassed(ctx, key, 101); !errors.Is(err, store.ErrAuthorizationStateChanged) {
|
||||
t.Fatalf("stale A proof err=%v, want state changed", err)
|
||||
}
|
||||
stillPendingB, _, _ := auths.ByAuthKey(ctx, key)
|
||||
if stillPendingB.UserID != 202 || !stillPendingB.PasswordPending {
|
||||
t.Fatalf("stale A proof changed B authorization: %+v", stillPendingB)
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
if err := auths.MarkPasswordPassed(ctx, key, 202); err != nil {
|
||||
t.Fatalf("promote B: %v", err)
|
||||
}
|
||||
passedB, _, _ := auths.ByAuthKey(ctx, key)
|
||||
if passedB.PasswordPending || !passedB.CreatedAt.After(pendingB.CreatedAt) {
|
||||
t.Fatalf("promoted B authorization=%+v, want fresh fully-authorized session", passedB)
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +62,10 @@ func NewBotStore(users *UserStore) *BotStore {
|
|||
s.byID[domain.BotFatherUserID] = botFatherSeedProfile()
|
||||
s.byID[domain.StickersBotUserID] = stickersSeedProfile()
|
||||
s.byID[domain.ChatBotUserID] = chatBotSeedProfile()
|
||||
s.byID[domain.GifBotUserID] = domain.BotProfile{
|
||||
BotUserID: domain.GifBotUserID, OwnerUserID: domain.GifBotUserID,
|
||||
Description: "Search the server-curated GIF catalog.", InlinePlaceholder: "Search GIFs",
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
|
|
@ -89,7 +93,7 @@ func stickersSeedProfile() domain.BotProfile {
|
|||
return domain.BotProfile{
|
||||
BotUserID: domain.StickersBotUserID,
|
||||
OwnerUserID: domain.StickersBotUserID,
|
||||
Description: "Create custom sticker and emoji packs for telesrv.",
|
||||
Description: domain.StickersBotDescription(),
|
||||
Commands: []domain.BotCommand{
|
||||
{Command: "start", Description: "start the sticker pack assistant"},
|
||||
{Command: "help", Description: "show help"},
|
||||
|
|
@ -108,7 +112,7 @@ func chatBotSeedProfile() domain.BotProfile {
|
|||
return domain.BotProfile{
|
||||
BotUserID: domain.ChatBotUserID,
|
||||
OwnerUserID: domain.ChatBotUserID,
|
||||
Description: "Chat with the configured telesrv AI provider.",
|
||||
Description: domain.ChatBotDescription(),
|
||||
Commands: []domain.BotCommand{
|
||||
{Command: "start", Description: "start chatting"},
|
||||
{Command: "help", Description: "show help"},
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *PasswordStore) HasBusinessAutomation(_ context.Context, userID int64) (bool, error) {
|
||||
s.mu.RLock()
|
||||
profile, hasProfile := s.businessProfiles[userID]
|
||||
bot, hasBot := s.connectedBusinessBots[userID]
|
||||
s.mu.RUnlock()
|
||||
return hasProfile && (profile.Greeting != nil || profile.Away != nil) || hasBot && bot.BotUserID != 0, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) GetBusinessProfile(_ context.Context, userID int64) (domain.BusinessProfile, bool, error) {
|
||||
s.mu.RLock()
|
||||
profile, ok := s.businessProfiles[userID]
|
||||
|
|
|
|||
|
|
@ -56,6 +56,13 @@ func (s *ChannelStore) CreateChannel(_ context.Context, req domain.CreateChannel
|
|||
AdminRights: domain.CreatorChannelAdminRights(),
|
||||
}
|
||||
s.channels[channelID] = channel
|
||||
// Channel clients initialize an unknown message box at PTS 1. Reserve that
|
||||
// state without emitting an event so the real create service message is 2/1.
|
||||
s.ptsSeq[channelID] = domain.InitialChannelPts
|
||||
s.retention[channelID] = domain.ChannelUpdateRetentionCheckpoint{
|
||||
ChannelID: channelID,
|
||||
RetainedThroughPts: domain.InitialChannelPts,
|
||||
}
|
||||
s.invites[inviteHash] = domain.ChannelInvite{
|
||||
ChannelID: channelID,
|
||||
InviteID: inviteID,
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@ package memory
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) GetParticipants(_ context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
|
||||
|
|
@ -892,6 +895,45 @@ func (s *ChannelStore) FilterActiveChannelMemberIDs(_ context.Context, channelID
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) FilterActiveChannelMemberPairs(_ context.Context, userIDsByChannel map[int64][]int64) (map[int64][]int64, error) {
|
||||
requested := make(map[int64][]int64)
|
||||
seen := make(map[[2]int64]struct{})
|
||||
for channelID, userIDs := range userIDsByChannel {
|
||||
if channelID == 0 {
|
||||
continue
|
||||
}
|
||||
for _, userID := range userIDs {
|
||||
if userID == 0 {
|
||||
continue
|
||||
}
|
||||
pair := [2]int64{channelID, userID}
|
||||
if _, ok := seen[pair]; ok {
|
||||
continue
|
||||
}
|
||||
if len(seen) >= store.MaxActiveChannelMemberPairs {
|
||||
return nil, fmt.Errorf("%w: maximum %d", store.ErrActiveChannelMemberPairsLimit, store.MaxActiveChannelMemberPairs)
|
||||
}
|
||||
seen[pair] = struct{}{}
|
||||
requested[channelID] = append(requested[channelID], userID)
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make(map[int64][]int64, len(requested))
|
||||
for channelID, userIDs := range requested {
|
||||
members := s.members[channelID]
|
||||
for _, userID := range userIDs {
|
||||
member, ok := members[userID]
|
||||
if ok && member.Status == domain.ChannelMemberActive {
|
||||
out[channelID] = append(out[channelID], userID)
|
||||
}
|
||||
}
|
||||
sort.Slice(out[channelID], func(i, j int) bool { return out[channelID][i] < out[channelID][j] })
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) FilterChannelMessageAudienceIDs(_ context.Context, channelID int64, userIDs []int64) ([]int64, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
|
|
|||
60
internal/store/memory/channel_members_sparse_test.go
Normal file
60
internal/store/memory/channel_members_sparse_test.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestFilterActiveChannelMemberPairsKeepsExactEdges(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channels := NewChannelStore()
|
||||
first, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 1,
|
||||
MemberUserIDs: []int64{11, 12},
|
||||
Title: "first",
|
||||
Megagroup: true,
|
||||
Date: 1700000000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel(first): %v", err)
|
||||
}
|
||||
second, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 2,
|
||||
MemberUserIDs: []int64{11, 12},
|
||||
Title: "second",
|
||||
Megagroup: true,
|
||||
Date: 1700000001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel(second): %v", err)
|
||||
}
|
||||
|
||||
got, err := channels.FilterActiveChannelMemberPairs(ctx, map[int64][]int64{
|
||||
first.Channel.ID: {11},
|
||||
second.Channel.ID: {12},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FilterActiveChannelMemberPairs: %v", err)
|
||||
}
|
||||
if len(got[first.Channel.ID]) != 1 || got[first.Channel.ID][0] != 11 {
|
||||
t.Fatalf("first channel result = %+v, want [11]", got[first.Channel.ID])
|
||||
}
|
||||
if len(got[second.Channel.ID]) != 1 || got[second.Channel.ID][0] != 12 {
|
||||
t.Fatalf("second channel result = %+v, want [12]", got[second.Channel.ID])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterActiveChannelMemberPairsRejectsOverLimit(t *testing.T) {
|
||||
userIDs := make([]int64, store.MaxActiveChannelMemberPairs+1)
|
||||
for i := range userIDs {
|
||||
userIDs[i] = int64(i + 1)
|
||||
}
|
||||
_, err := NewChannelStore().FilterActiveChannelMemberPairs(context.Background(), map[int64][]int64{1: userIDs})
|
||||
if !errors.Is(err, store.ErrActiveChannelMemberPairsLimit) {
|
||||
t.Fatalf("FilterActiveChannelMemberPairs error = %v, want ErrActiveChannelMemberPairsLimit", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package memory
|
|||
import (
|
||||
"context"
|
||||
"telesrv/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) GetChannelMessageViews(_ context.Context, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error) {
|
||||
|
|
@ -43,16 +44,25 @@ func (s *ChannelStore) GetChannelMessageViews(_ context.Context, req domain.Chan
|
|||
s.msgViews[req.ChannelID] = make(map[int]int)
|
||||
}
|
||||
if s.msgViewers[req.ChannelID] == nil {
|
||||
s.msgViewers[req.ChannelID] = make(map[int]map[int64]struct{})
|
||||
s.msgViewers[req.ChannelID] = make(map[int]map[int64]int)
|
||||
}
|
||||
viewedAt := req.Date
|
||||
if viewedAt <= 0 {
|
||||
viewedAt = int(time.Now().Unix())
|
||||
}
|
||||
for id := range visible {
|
||||
if req.Increment {
|
||||
if s.msgViewers[req.ChannelID][id] == nil {
|
||||
s.msgViewers[req.ChannelID][id] = make(map[int64]struct{})
|
||||
s.msgViewers[req.ChannelID][id] = make(map[int64]int)
|
||||
}
|
||||
if _, seen := s.msgViewers[req.ChannelID][id][req.UserID]; !seen {
|
||||
s.msgViewers[req.ChannelID][id][req.UserID] = struct{}{}
|
||||
s.msgViewers[req.ChannelID][id][req.UserID] = viewedAt
|
||||
s.msgViews[req.ChannelID][id]++
|
||||
if idx, ok := s.findMessageIndexLocked(req.ChannelID, id); ok {
|
||||
msg := s.messages[req.ChannelID][idx]
|
||||
msg.ViewsCount = s.msgViews[req.ChannelID][id]
|
||||
s.messages[req.ChannelID][idx] = msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ func (s *ChannelStore) SetChannelEmojiStatusAdmin(_ context.Context, channelID i
|
|||
}
|
||||
|
||||
func (s *ChannelStore) SetChannelPhotoAdmin(_ context.Context, channelID int64, photo domain.Photo) (domain.Channel, error) {
|
||||
if channelID == 0 {
|
||||
if channelID == 0 || photo.ID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
|
|
|
|||
392
internal/store/memory/channel_stats.go
Normal file
392
internal/store/memory/channel_stats.go
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) GetChannelStats(_ context.Context, req domain.ChannelStatsRequest) (domain.ChannelStats, error) {
|
||||
if req.ViewerUserID == 0 || req.ChannelID == 0 || !req.Period.Valid() {
|
||||
return domain.ChannelStats{}, domain.ErrChannelInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, _, err := s.statsAdminChannelLocked(req.ViewerUserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelStats{}, err
|
||||
}
|
||||
|
||||
stats := domain.ChannelStats{Channel: cloneChannel(channel), Period: req.Period}
|
||||
days, dayIndex := newMemoryStatsDays(req.Period)
|
||||
prevMin := req.Period.PreviousMinDate()
|
||||
var currentMessages, previousMessages int
|
||||
var currentViews, previousViews int
|
||||
currentPosters := make(map[int64]struct{})
|
||||
previousPosters := make(map[int64]struct{})
|
||||
currentMessageDay := make(map[int]int)
|
||||
previousMessageIDs := make(map[int]struct{})
|
||||
currentMessageIDs := make(map[int]struct{})
|
||||
top := make(map[int64]struct{ messages, chars int })
|
||||
|
||||
for _, member := range s.members[req.ChannelID] {
|
||||
if memoryStatsMemberActiveAt(member, req.Period.MaxDate-1) {
|
||||
stats.Members.Current++
|
||||
}
|
||||
if memoryStatsMemberActiveAt(member, req.Period.MinDate-1) {
|
||||
stats.Members.Previous++
|
||||
}
|
||||
if i, ok := dayIndex[memoryStatsDay(member.JoinedAt)]; ok && member.JoinedAt >= req.Period.MinDate && member.JoinedAt < req.Period.MaxDate {
|
||||
days[i].NewMembers++
|
||||
}
|
||||
}
|
||||
for i := range days {
|
||||
at := days[i].Date + 86400 - 1
|
||||
if at >= req.Period.MaxDate {
|
||||
at = req.Period.MaxDate - 1
|
||||
}
|
||||
for _, member := range s.members[req.ChannelID] {
|
||||
if memoryStatsMemberActiveAt(member, at) {
|
||||
days[i].Members++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, msg := range s.messages[req.ChannelID] {
|
||||
if msg.Deleted || msg.Action != nil {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case msg.Date >= req.Period.MinDate && msg.Date < req.Period.MaxDate:
|
||||
currentMessages++
|
||||
currentViews += msg.ViewsCount
|
||||
currentPosters[msg.SenderUserID] = struct{}{}
|
||||
currentMessageIDs[msg.ID] = struct{}{}
|
||||
if i, ok := dayIndex[memoryStatsDay(msg.Date)]; ok {
|
||||
currentMessageDay[msg.ID] = i
|
||||
days[i].Messages++
|
||||
days[i].Views += msg.ViewsCount
|
||||
}
|
||||
entry := top[msg.SenderUserID]
|
||||
entry.messages++
|
||||
entry.chars += utf8.RuneCountInString(msg.Body)
|
||||
top[msg.SenderUserID] = entry
|
||||
case msg.Date >= prevMin && msg.Date < req.Period.MinDate:
|
||||
previousMessages++
|
||||
previousViews += msg.ViewsCount
|
||||
previousPosters[msg.SenderUserID] = struct{}{}
|
||||
previousMessageIDs[msg.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
currentViewerIDs := make(map[int64]struct{})
|
||||
previousViewerIDs := make(map[int64]struct{})
|
||||
dayViewerIDs := make(map[int]map[int64]struct{}, len(days))
|
||||
for _, viewers := range s.msgViewers[req.ChannelID] {
|
||||
for userID, viewedAt := range viewers {
|
||||
switch {
|
||||
case viewedAt >= req.Period.MinDate && viewedAt < req.Period.MaxDate:
|
||||
currentViewerIDs[userID] = struct{}{}
|
||||
if i, ok := dayIndex[memoryStatsDay(viewedAt)]; ok {
|
||||
if dayViewerIDs[i] == nil {
|
||||
dayViewerIDs[i] = make(map[int64]struct{})
|
||||
}
|
||||
dayViewerIDs[i][userID] = struct{}{}
|
||||
}
|
||||
case viewedAt >= prevMin && viewedAt < req.Period.MinDate:
|
||||
previousViewerIDs[userID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var currentReactions, previousReactions int
|
||||
for messageID, byUser := range s.reactions[req.ChannelID] {
|
||||
_, current := currentMessageIDs[messageID]
|
||||
_, previous := previousMessageIDs[messageID]
|
||||
for _, rows := range byUser {
|
||||
for _, row := range rows {
|
||||
if current {
|
||||
currentReactions++
|
||||
if i, ok := currentMessageDay[messageID]; ok {
|
||||
days[i].Reactions++
|
||||
addMemoryStatsReaction(&days[i], row.Reaction)
|
||||
}
|
||||
} else if previous {
|
||||
previousReactions++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
forwardCounts := s.publicForwardCountsLocked(req.ChannelID)
|
||||
currentShares, previousShares := 0, 0
|
||||
for messageID, count := range forwardCounts {
|
||||
if _, ok := currentMessageIDs[messageID]; ok {
|
||||
currentShares += count
|
||||
if i, ok := currentMessageDay[messageID]; ok {
|
||||
days[i].Shares += count
|
||||
}
|
||||
} else if _, ok := previousMessageIDs[messageID]; ok {
|
||||
previousShares += count
|
||||
}
|
||||
}
|
||||
|
||||
for i := range days {
|
||||
days[i].Viewers = len(dayViewerIDs[i])
|
||||
posters := make(map[int64]struct{})
|
||||
start, end := days[i].Date, days[i].Date+86400
|
||||
for _, msg := range s.messages[req.ChannelID] {
|
||||
if !msg.Deleted && msg.Action == nil && msg.Date >= start && msg.Date < end {
|
||||
posters[msg.SenderUserID] = struct{}{}
|
||||
}
|
||||
}
|
||||
days[i].Posters = len(posters)
|
||||
sort.Slice(days[i].ByReaction, func(a, b int) bool {
|
||||
return days[i].ByReaction[a].Reaction.Key() < days[i].ByReaction[b].Reaction.Key()
|
||||
})
|
||||
}
|
||||
|
||||
stats.Messages = domain.StatsValueAndPrev{Current: float64(currentMessages), Previous: float64(previousMessages)}
|
||||
stats.Viewers = domain.StatsValueAndPrev{Current: float64(len(currentViewerIDs)), Previous: float64(len(previousViewerIDs))}
|
||||
stats.Posters = domain.StatsValueAndPrev{Current: float64(len(currentPosters)), Previous: float64(len(previousPosters))}
|
||||
stats.ViewsPerPost = memoryStatsAverage(currentViews, currentMessages, previousViews, previousMessages)
|
||||
stats.SharesPerPost = memoryStatsAverage(currentShares, currentMessages, previousShares, previousMessages)
|
||||
stats.ReactionsPerPost = memoryStatsAverage(currentReactions, currentMessages, previousReactions, previousMessages)
|
||||
stats.Days = days
|
||||
|
||||
for userID, entry := range top {
|
||||
if userID == 0 || entry.messages == 0 {
|
||||
continue
|
||||
}
|
||||
stats.TopPosters = append(stats.TopPosters, domain.ChannelStatsTopPoster{
|
||||
UserID: userID, Messages: entry.messages, AvgChars: entry.chars / entry.messages,
|
||||
})
|
||||
}
|
||||
sort.Slice(stats.TopPosters, func(i, j int) bool {
|
||||
if stats.TopPosters[i].Messages != stats.TopPosters[j].Messages {
|
||||
return stats.TopPosters[i].Messages > stats.TopPosters[j].Messages
|
||||
}
|
||||
return stats.TopPosters[i].UserID < stats.TopPosters[j].UserID
|
||||
})
|
||||
if len(stats.TopPosters) > domain.MaxChannelStatsTopPosters {
|
||||
stats.TopPosters = stats.TopPosters[:domain.MaxChannelStatsTopPosters]
|
||||
}
|
||||
|
||||
messages := append([]domain.ChannelMessage(nil), s.messages[req.ChannelID]...)
|
||||
sort.Slice(messages, func(i, j int) bool {
|
||||
if messages[i].Date != messages[j].Date {
|
||||
return messages[i].Date > messages[j].Date
|
||||
}
|
||||
return messages[i].ID > messages[j].ID
|
||||
})
|
||||
for _, msg := range messages {
|
||||
if msg.Deleted || msg.Action != nil {
|
||||
continue
|
||||
}
|
||||
stats.RecentPosts = append(stats.RecentPosts, domain.ChannelStatsRecentPost{
|
||||
MessageID: msg.ID,
|
||||
Views: msg.ViewsCount,
|
||||
Forwards: forwardCounts[msg.ID],
|
||||
Reactions: memoryStatsReactionCount(s.reactions[req.ChannelID][msg.ID]),
|
||||
})
|
||||
if len(stats.RecentPosts) == domain.MaxChannelStatsRecentPosts {
|
||||
break
|
||||
}
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetChannelMessageStats(_ context.Context, req domain.ChannelMessageStatsRequest) (domain.ChannelMessageStats, error) {
|
||||
if req.ViewerUserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
|
||||
req.MessageID > domain.MaxMessageBoxID || !req.Period.Valid() {
|
||||
return domain.ChannelMessageStats{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
channel, _, err := s.statsAdminChannelLocked(req.ViewerUserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessageStats{}, err
|
||||
}
|
||||
message, ok := s.findMessageLocked(req.ChannelID, req.MessageID)
|
||||
if !ok || message.Deleted {
|
||||
return domain.ChannelMessageStats{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
days, dayIndex := newMemoryStatsDays(req.Period)
|
||||
for _, viewedAt := range s.msgViewers[req.ChannelID][req.MessageID] {
|
||||
if i, ok := dayIndex[memoryStatsDay(viewedAt)]; ok && viewedAt >= req.Period.MinDate && viewedAt < req.Period.MaxDate {
|
||||
days[i].Views++
|
||||
}
|
||||
}
|
||||
for _, rows := range s.reactions[req.ChannelID][req.MessageID] {
|
||||
for _, row := range rows {
|
||||
if i, ok := dayIndex[memoryStatsDay(row.Date)]; ok && row.Date >= req.Period.MinDate && row.Date < req.Period.MaxDate {
|
||||
days[i].Reactions++
|
||||
addMemoryStatsReaction(&days[i], row.Reaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range days {
|
||||
sort.Slice(days[i].ByReaction, func(a, b int) bool {
|
||||
return days[i].ByReaction[a].Reaction.Key() < days[i].ByReaction[b].Reaction.Key()
|
||||
})
|
||||
}
|
||||
return domain.ChannelMessageStats{
|
||||
Channel: cloneChannel(channel), Message: cloneChannelMessage(message), Period: req.Period, Days: days,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListChannelMessagePublicForwards(_ context.Context, req domain.ChannelMessagePublicForwardListRequest) (domain.ChannelMessagePublicForwardList, error) {
|
||||
if req.ViewerUserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
|
||||
req.MessageID > domain.MaxMessageBoxID || req.Limit <= 0 || req.Limit > domain.MaxChannelMessagePublicForwards {
|
||||
return domain.ChannelMessagePublicForwardList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
cursor, err := domain.ParseChannelMessagePublicForwardCursor(req.Offset)
|
||||
if err != nil {
|
||||
return domain.ChannelMessagePublicForwardList{}, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if _, _, err := s.statsAdminChannelLocked(req.ViewerUserID, req.ChannelID); err != nil {
|
||||
return domain.ChannelMessagePublicForwardList{}, err
|
||||
}
|
||||
source, ok := s.findMessageLocked(req.ChannelID, req.MessageID)
|
||||
if !ok || source.Deleted {
|
||||
return domain.ChannelMessagePublicForwardList{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
all := make([]domain.ChannelMessage, 0)
|
||||
for channelID, channel := range s.channels {
|
||||
if !memoryStatsPublicChannel(channel) {
|
||||
continue
|
||||
}
|
||||
for _, msg := range s.messages[channelID] {
|
||||
if memoryStatsForwardsPost(msg, req.ChannelID, req.MessageID) {
|
||||
all = append(all, cloneChannelMessage(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return memoryStatsForwardBefore(all[i], all[j]) })
|
||||
page := make([]domain.ChannelMessage, 0, req.Limit)
|
||||
for _, msg := range all {
|
||||
if cursor.Date != 0 && !memoryStatsForwardAfterCursor(msg, cursor) {
|
||||
continue
|
||||
}
|
||||
page = append(page, msg)
|
||||
if len(page) == req.Limit+1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
next := ""
|
||||
if len(page) > req.Limit {
|
||||
page = page[:req.Limit]
|
||||
next = domain.FormatChannelMessagePublicForwardCursor(page[len(page)-1])
|
||||
}
|
||||
return domain.ChannelMessagePublicForwardList{Count: len(all), Messages: page, NextOffset: next}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) statsAdminChannelLocked(userID, channelID int64) (domain.Channel, domain.ChannelMember, error) {
|
||||
channel, member, err := s.channelAndMemberLocked(userID, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, domain.ChannelMember{}, err
|
||||
}
|
||||
if member.Role != domain.ChannelRoleCreator && member.Role != domain.ChannelRoleAdmin {
|
||||
return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
return channel, member, nil
|
||||
}
|
||||
|
||||
func newMemoryStatsDays(period domain.StatsPeriod) ([]domain.ChannelStatsDay, map[int]int) {
|
||||
start := memoryStatsDay(period.MinDate)
|
||||
end := memoryStatsDay(period.MaxDate - 1)
|
||||
days := make([]domain.ChannelStatsDay, 0, (end-start)/86400+1)
|
||||
index := make(map[int]int)
|
||||
for date := start; date <= end; date += 86400 {
|
||||
index[date] = len(days)
|
||||
days = append(days, domain.ChannelStatsDay{Date: date})
|
||||
}
|
||||
return days, index
|
||||
}
|
||||
|
||||
func memoryStatsDay(date int) int {
|
||||
if date <= 0 {
|
||||
return 0
|
||||
}
|
||||
return date - date%86400
|
||||
}
|
||||
|
||||
func memoryStatsMemberActiveAt(member domain.ChannelMember, at int) bool {
|
||||
return member.JoinedAt > 0 && member.JoinedAt <= at && (member.LeftAt == 0 || member.LeftAt > at)
|
||||
}
|
||||
|
||||
func memoryStatsAverage(current, currentCount, previous, previousCount int) domain.StatsValueAndPrev {
|
||||
var out domain.StatsValueAndPrev
|
||||
if currentCount > 0 {
|
||||
out.Current = float64(current) / float64(currentCount)
|
||||
}
|
||||
if previousCount > 0 {
|
||||
out.Previous = float64(previous) / float64(previousCount)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func addMemoryStatsReaction(day *domain.ChannelStatsDay, reaction domain.MessageReaction) {
|
||||
for i := range day.ByReaction {
|
||||
if day.ByReaction[i].Reaction.Key() == reaction.Key() {
|
||||
day.ByReaction[i].Count++
|
||||
return
|
||||
}
|
||||
}
|
||||
day.ByReaction = append(day.ByReaction, domain.StatsReactionCount{Reaction: reaction, Count: 1})
|
||||
}
|
||||
|
||||
func memoryStatsReactionCount(byUser map[int64][]domain.ChannelMessagePeerReaction) int {
|
||||
count := 0
|
||||
for _, rows := range byUser {
|
||||
count += len(rows)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (s *ChannelStore) publicForwardCountsLocked(sourceChannelID int64) map[int]int {
|
||||
counts := make(map[int]int)
|
||||
for channelID, channel := range s.channels {
|
||||
if !memoryStatsPublicChannel(channel) {
|
||||
continue
|
||||
}
|
||||
for _, msg := range s.messages[channelID] {
|
||||
if msg.Deleted || msg.Forward == nil || msg.Forward.From.Type != domain.PeerTypeChannel ||
|
||||
msg.Forward.From.ID != sourceChannelID || msg.Forward.ChannelPost <= 0 {
|
||||
continue
|
||||
}
|
||||
counts[msg.Forward.ChannelPost]++
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func memoryStatsPublicChannel(channel domain.Channel) bool {
|
||||
return !channel.Deleted && strings.TrimSpace(channel.Username) != "" && (channel.Broadcast || channel.Megagroup)
|
||||
}
|
||||
|
||||
func memoryStatsForwardsPost(msg domain.ChannelMessage, channelID int64, messageID int) bool {
|
||||
return !msg.Deleted && msg.Forward != nil && msg.Forward.From.Type == domain.PeerTypeChannel &&
|
||||
msg.Forward.From.ID == channelID && msg.Forward.ChannelPost == messageID
|
||||
}
|
||||
|
||||
func memoryStatsForwardBefore(a, b domain.ChannelMessage) bool {
|
||||
if a.Date != b.Date {
|
||||
return a.Date > b.Date
|
||||
}
|
||||
if a.ChannelID != b.ChannelID {
|
||||
return a.ChannelID < b.ChannelID
|
||||
}
|
||||
return a.ID > b.ID
|
||||
}
|
||||
|
||||
func memoryStatsForwardAfterCursor(msg domain.ChannelMessage, cursor domain.ChannelMessagePublicForwardCursor) bool {
|
||||
return msg.Date < cursor.Date ||
|
||||
(msg.Date == cursor.Date && (msg.ChannelID > cursor.ChannelID ||
|
||||
(msg.ChannelID == cursor.ChannelID && msg.ID < cursor.MessageID)))
|
||||
}
|
||||
133
internal/store/memory/channel_stats_test.go
Normal file
133
internal/store/memory/channel_stats_test.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestChannelStatsUseDurableFactsAndPagePublicForwards(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
const owner, viewer int64 = 1, 2
|
||||
period := domain.StatsPeriod{MinDate: 1_700_006_400, MaxDate: 1_700_611_200}
|
||||
|
||||
source, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner,
|
||||
Title: "stats source",
|
||||
Broadcast: true,
|
||||
MemberUserIDs: []int64{viewer},
|
||||
Date: period.MinDate - 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create source: %v", err)
|
||||
}
|
||||
previous, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: owner, ChannelID: source.Channel.ID, RandomID: 100, Message: "previous", Date: period.MinDate - 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send previous: %v", err)
|
||||
}
|
||||
_ = previous
|
||||
post, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: owner, ChannelID: source.Channel.ID, RandomID: 101, Message: "current post", Date: period.MinDate + 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send current: %v", err)
|
||||
}
|
||||
if _, err := store.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{
|
||||
UserID: viewer, ChannelID: source.Channel.ID, IDs: []int{post.Message.ID}, Increment: true, Date: period.MinDate + 20,
|
||||
}); err != nil {
|
||||
t.Fatalf("increment view: %v", err)
|
||||
}
|
||||
reaction := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "👍"}
|
||||
if _, err := store.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{
|
||||
UserID: viewer, ChannelID: source.Channel.ID, MessageID: post.Message.ID, Reactions: []domain.MessageReaction{reaction}, Date: period.MinDate + 30,
|
||||
}); err != nil {
|
||||
t.Fatalf("react: %v", err)
|
||||
}
|
||||
|
||||
publicCreated, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner, Title: "public destination", Broadcast: true, Date: period.MinDate + 40,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create public destination: %v", err)
|
||||
}
|
||||
publicChannel, err := store.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{
|
||||
UserID: owner, ChannelID: publicCreated.Channel.ID, Username: "stats_forward_memory",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("make public destination: %v", err)
|
||||
}
|
||||
privateChannel, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner, Title: "private destination", Broadcast: true, Date: period.MinDate + 40,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create private destination: %v", err)
|
||||
}
|
||||
forward := &domain.MessageForward{
|
||||
From: domain.Peer{Type: domain.PeerTypeChannel, ID: source.Channel.ID}, Date: post.Message.Date, ChannelPost: post.Message.ID,
|
||||
}
|
||||
for i, date := range []int{period.MinDate + 50, period.MinDate + 60} {
|
||||
if _, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: owner, ChannelID: publicChannel.ID, RandomID: int64(200 + i), Message: "public forward", Forward: forward, Date: date,
|
||||
}); err != nil {
|
||||
t.Fatalf("send public forward %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: owner, ChannelID: privateChannel.Channel.ID, RandomID: 300, Message: "private forward", Forward: forward, Date: period.MinDate + 70,
|
||||
}); err != nil {
|
||||
t.Fatalf("send private forward: %v", err)
|
||||
}
|
||||
|
||||
stats, err := store.GetChannelStats(ctx, domain.ChannelStatsRequest{
|
||||
ViewerUserID: owner, ChannelID: source.Channel.ID, Period: period,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get stats: %v", err)
|
||||
}
|
||||
if stats.Members.Current != 2 || stats.Messages.Current != 1 || stats.Messages.Previous != 1 ||
|
||||
stats.Viewers.Current != 1 || stats.Posters.Current != 1 || stats.ViewsPerPost.Current != 1 ||
|
||||
stats.SharesPerPost.Current != 2 || stats.ReactionsPerPost.Current != 1 {
|
||||
t.Fatalf("stats = %+v, want durable current/previous aggregates", stats)
|
||||
}
|
||||
if len(stats.Days) == 0 || len(stats.Days[0].ByReaction) != 1 || stats.Days[0].Shares != 2 {
|
||||
t.Fatalf("stats days = %+v, want view/share/reaction buckets", stats.Days)
|
||||
}
|
||||
messageStats, err := store.GetChannelMessageStats(ctx, domain.ChannelMessageStatsRequest{
|
||||
ViewerUserID: owner, ChannelID: source.Channel.ID, MessageID: post.Message.ID, Period: period,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get message stats: %v", err)
|
||||
}
|
||||
if len(messageStats.Days) == 0 || messageStats.Days[0].Views != 1 || messageStats.Days[0].Reactions != 1 {
|
||||
t.Fatalf("message stats days = %+v, want one view and reaction", messageStats.Days)
|
||||
}
|
||||
|
||||
first, err := store.ListChannelMessagePublicForwards(ctx, domain.ChannelMessagePublicForwardListRequest{
|
||||
ViewerUserID: owner, ChannelID: source.Channel.ID, MessageID: post.Message.ID, Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list first forward page: %v", err)
|
||||
}
|
||||
if first.Count != 2 || len(first.Messages) != 1 || first.NextOffset == "" || first.Messages[0].ChannelID != publicChannel.ID {
|
||||
t.Fatalf("first forward page = %+v, want one of two public forwards", first)
|
||||
}
|
||||
second, err := store.ListChannelMessagePublicForwards(ctx, domain.ChannelMessagePublicForwardListRequest{
|
||||
ViewerUserID: owner, ChannelID: source.Channel.ID, MessageID: post.Message.ID, Offset: first.NextOffset, Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list second forward page: %v", err)
|
||||
}
|
||||
if second.Count != 2 || len(second.Messages) != 1 || second.Messages[0].ID == first.Messages[0].ID || second.NextOffset != "" {
|
||||
t.Fatalf("second forward page = %+v, want remaining public forward", second)
|
||||
}
|
||||
if _, err := store.ListChannelMessagePublicForwards(ctx, domain.ChannelMessagePublicForwardListRequest{
|
||||
ViewerUserID: owner, ChannelID: source.Channel.ID, MessageID: post.Message.ID, Offset: "bad", Limit: 1,
|
||||
}); !errors.Is(err, domain.ErrStatsOffsetInvalid) {
|
||||
t.Fatalf("invalid cursor err = %v, want ErrStatsOffsetInvalid", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -63,20 +63,23 @@ func (w channelReadWatermark) advance(userID int64, maxID int) channelReadWaterm
|
|||
|
||||
// ChannelStore is an in-memory channel/supergroup store for tests and local development.
|
||||
type ChannelStore struct {
|
||||
mu sync.RWMutex
|
||||
nextID int64
|
||||
nextHash int64
|
||||
channels map[int64]domain.Channel
|
||||
members map[int64]map[int64]domain.ChannelMember
|
||||
dialogs map[int64]map[int64]domain.ChannelDialog
|
||||
topics map[int64]map[int]domain.ChannelForumTopic
|
||||
messages map[int64][]domain.ChannelMessage
|
||||
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
|
||||
top map[int64]map[string]domain.TopMessageReaction
|
||||
recent map[int64]map[string]domain.RecentMessageReaction
|
||||
mentions map[int64]map[int64]map[int]memoryMention
|
||||
msgViews map[int64]map[int]int
|
||||
msgViewers map[int64]map[int]map[int64]struct{}
|
||||
mu sync.RWMutex
|
||||
nextID int64
|
||||
nextHash int64
|
||||
channels map[int64]domain.Channel
|
||||
members map[int64]map[int64]domain.ChannelMember
|
||||
dialogs map[int64]map[int64]domain.ChannelDialog
|
||||
topics map[int64]map[int]domain.ChannelForumTopic
|
||||
messages map[int64][]domain.ChannelMessage
|
||||
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
|
||||
top map[int64]map[string]domain.TopMessageReaction
|
||||
recent map[int64]map[string]domain.RecentMessageReaction
|
||||
mentions map[int64]map[int64]map[int]memoryMention
|
||||
msgViews map[int64]map[int]int
|
||||
// msgViewers stores the first durable view time for each unique viewer.
|
||||
// Keeping the timestamp (instead of only a set membership bit) lets stats
|
||||
// produce real event-time graphs while preserving idempotent view counts.
|
||||
msgViewers map[int64]map[int]map[int64]int
|
||||
events map[int64][]domain.ChannelUpdateEvent
|
||||
retention map[int64]domain.ChannelUpdateRetentionCheckpoint
|
||||
// historyClearDates is the no-PTS recovery timestamp for a future
|
||||
|
|
@ -136,7 +139,7 @@ func NewChannelStore() *ChannelStore {
|
|||
recent: make(map[int64]map[string]domain.RecentMessageReaction),
|
||||
mentions: make(map[int64]map[int64]map[int]memoryMention),
|
||||
msgViews: make(map[int64]map[int]int),
|
||||
msgViewers: make(map[int64]map[int]map[int64]struct{}),
|
||||
msgViewers: make(map[int64]map[int]map[int64]int),
|
||||
events: make(map[int64][]domain.ChannelUpdateEvent),
|
||||
retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint),
|
||||
historyClearDates: make(map[int64]map[int64]int),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,46 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestChannelCreateInitialPtsBaseline(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 1,
|
||||
Title: "initial pts",
|
||||
Megagroup: true,
|
||||
Date: 1_700_000_080,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
if created.Channel.Pts != domain.FirstChannelEventPts || created.Message.Pts != domain.FirstChannelEventPts ||
|
||||
created.Event.Pts != domain.FirstChannelEventPts || created.Event.PtsCount != 1 {
|
||||
t.Fatalf("create result = channel:%+v message:%+v event:%+v, want first event 2/1", created.Channel, created.Message, created.Event)
|
||||
}
|
||||
checkpoint := store.retention[created.Channel.ID]
|
||||
if checkpoint.RetainedThroughPts != domain.InitialChannelPts || checkpoint.LatestPts != domain.FirstChannelEventPts {
|
||||
t.Fatalf("checkpoint = %+v, want floor/latest 1/2", checkpoint)
|
||||
}
|
||||
fromBaseline, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
UserID: 1, ChannelID: created.Channel.ID, Pts: domain.InitialChannelPts, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("difference from baseline: %v", err)
|
||||
}
|
||||
if fromBaseline.TooLong || len(fromBaseline.Events) != 1 || fromBaseline.Events[0].Pts != domain.FirstChannelEventPts {
|
||||
t.Fatalf("difference from baseline = %+v, want create event at pts=2", fromBaseline)
|
||||
}
|
||||
fromZero, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
UserID: 1, ChannelID: created.Channel.ID, Pts: 0, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("difference from zero: %v", err)
|
||||
}
|
||||
if !fromZero.TooLong || fromZero.Pts != domain.FirstChannelEventPts || len(fromZero.NewMessages) != 1 {
|
||||
t.Fatalf("difference from zero = %+v, want complete snapshot at pts=2", fromZero)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelCreateCreatesPermanentInviteAndHasLink(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ func TestDeleteExpiredChannelUpdateEventsIsBoundedMemory(t *testing.T) {
|
|||
t.Fatalf("deleted = %d, want bounded batch 2", deleted)
|
||||
}
|
||||
checkpoint := store.retention[created.Channel.ID]
|
||||
if checkpoint.RetainedThroughPts != 2 || len(store.events[created.Channel.ID]) != 2 {
|
||||
t.Fatalf("after bounded prune checkpoint=%+v events=%d, want floor=2 and 2 rows", checkpoint, len(store.events[created.Channel.ID]))
|
||||
if checkpoint.RetainedThroughPts != 3 || len(store.events[created.Channel.ID]) != 2 {
|
||||
t.Fatalf("after bounded prune checkpoint=%+v events=%d, want floor=3 and 2 rows", checkpoint, len(store.events[created.Channel.ID]))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,122 @@ func (s *ContactStore) GetReverseContacts(_ context.Context, userID int64, owner
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) ContactProjectionForViewers(_ context.Context, viewerUserIDs, contactUserIDs []int64) (domain.ContactProjectionBatch, error) {
|
||||
out := domain.ContactProjectionBatch{
|
||||
Contacts: make(map[int64]map[int64]domain.Contact, len(viewerUserIDs)),
|
||||
PersonalPhotos: make(map[int64]map[int64]domain.ProfilePhotoRef, len(viewerUserIDs)),
|
||||
}
|
||||
if len(viewerUserIDs) == 0 || len(contactUserIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
viewers := make(map[int64]struct{}, len(viewerUserIDs))
|
||||
for _, id := range viewerUserIDs {
|
||||
if id != 0 {
|
||||
viewers[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
targets := make(map[int64]struct{}, len(contactUserIDs))
|
||||
for _, id := range contactUserIDs {
|
||||
if id != 0 {
|
||||
targets[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(viewers) == 0 || len(targets) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for viewerID := range viewers {
|
||||
list := s.m[viewerID]
|
||||
for _, contact := range list.Contacts {
|
||||
targetID := contact.User.ID
|
||||
if _, ok := targets[targetID]; !ok {
|
||||
continue
|
||||
}
|
||||
if out.Contacts[viewerID] == nil {
|
||||
out.Contacts[viewerID] = make(map[int64]domain.Contact, len(targets))
|
||||
}
|
||||
out.Contacts[viewerID][targetID] = domain.Contact{
|
||||
User: domain.User{ID: targetID},
|
||||
FirstName: contact.FirstName,
|
||||
LastName: contact.LastName,
|
||||
Phone: contact.Phone,
|
||||
Note: contact.Note,
|
||||
NoteEntities: append([]domain.MessageEntity(nil), contact.NoteEntities...),
|
||||
Mutual: contact.Mutual || contact.User.Mutual,
|
||||
CloseFriend: contact.CloseFriend || contact.User.CloseFriend,
|
||||
}
|
||||
if contact.User.PhotoID == 0 {
|
||||
continue
|
||||
}
|
||||
if out.PersonalPhotos[viewerID] == nil {
|
||||
out.PersonalPhotos[viewerID] = make(map[int64]domain.ProfilePhotoRef, len(targets))
|
||||
}
|
||||
out.PersonalPhotos[viewerID][targetID] = cloneProfilePhotoRef(domain.ProfilePhotoRef{
|
||||
PhotoID: contact.User.PhotoID,
|
||||
DCID: contact.User.PhotoDCID,
|
||||
Stripped: contact.User.PhotoStripped,
|
||||
Personal: true,
|
||||
HasVideo: contact.User.PhotoHasVideo,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) ContactProjectionForViewerUserIDs(_ context.Context, contactUserIDsByViewer map[int64][]int64) (domain.ContactProjectionBatch, error) {
|
||||
out := domain.ContactProjectionBatch{
|
||||
Contacts: make(map[int64]map[int64]domain.Contact, len(contactUserIDsByViewer)),
|
||||
PersonalPhotos: make(map[int64]map[int64]domain.ProfilePhotoRef, len(contactUserIDsByViewer)),
|
||||
}
|
||||
if len(contactUserIDsByViewer) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for viewerID, contactUserIDs := range contactUserIDsByViewer {
|
||||
if viewerID == 0 || len(contactUserIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
want := make(map[int64]struct{}, len(contactUserIDs))
|
||||
for _, id := range contactUserIDs {
|
||||
if id != 0 {
|
||||
want[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, contact := range s.m[viewerID].Contacts {
|
||||
targetID := contact.User.ID
|
||||
if _, ok := want[targetID]; !ok {
|
||||
continue
|
||||
}
|
||||
if out.Contacts[viewerID] == nil {
|
||||
out.Contacts[viewerID] = make(map[int64]domain.Contact, len(want))
|
||||
}
|
||||
out.Contacts[viewerID][targetID] = domain.Contact{
|
||||
User: domain.User{ID: targetID},
|
||||
FirstName: contact.FirstName,
|
||||
LastName: contact.LastName,
|
||||
Phone: contact.Phone,
|
||||
Note: contact.Note,
|
||||
NoteEntities: append([]domain.MessageEntity(nil), contact.NoteEntities...),
|
||||
Mutual: contact.Mutual || contact.User.Mutual,
|
||||
CloseFriend: contact.CloseFriend || contact.User.CloseFriend,
|
||||
}
|
||||
if contact.User.PhotoID == 0 {
|
||||
continue
|
||||
}
|
||||
if out.PersonalPhotos[viewerID] == nil {
|
||||
out.PersonalPhotos[viewerID] = make(map[int64]domain.ProfilePhotoRef, len(want))
|
||||
}
|
||||
out.PersonalPhotos[viewerID][targetID] = cloneProfilePhotoRef(domain.ProfilePhotoRef{
|
||||
PhotoID: contact.User.PhotoID, DCID: contact.User.PhotoDCID,
|
||||
Stripped: contact.User.PhotoStripped, Personal: true, HasVideo: contact.User.PhotoHasVideo,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) Upsert(_ context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
|
||||
contact := domain.Contact{
|
||||
User: domain.User{
|
||||
|
|
@ -391,9 +507,15 @@ func cloneContacts(contacts []domain.Contact) []domain.Contact {
|
|||
|
||||
func cloneContact(contact domain.Contact) domain.Contact {
|
||||
contact.NoteEntities = append([]domain.MessageEntity(nil), contact.NoteEntities...)
|
||||
contact.User.PhotoStripped = append([]byte(nil), contact.User.PhotoStripped...)
|
||||
return contact
|
||||
}
|
||||
|
||||
func cloneProfilePhotoRef(ref domain.ProfilePhotoRef) domain.ProfilePhotoRef {
|
||||
ref.Stripped = append([]byte(nil), ref.Stripped...)
|
||||
return ref
|
||||
}
|
||||
|
||||
func contactListHash(contacts []domain.Contact) int64 {
|
||||
if len(contacts) == 0 {
|
||||
return 0
|
||||
|
|
|
|||
92
internal/store/memory/contacts_sparse_test.go
Normal file
92
internal/store/memory/contacts_sparse_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestContactProjectionForViewerUserIDsDoesNotCrossPairs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
contacts := NewContactStore()
|
||||
const (
|
||||
viewerA = int64(11)
|
||||
viewerB = int64(12)
|
||||
ownerA = int64(21)
|
||||
ownerB = int64(22)
|
||||
)
|
||||
for _, row := range []struct {
|
||||
viewer int64
|
||||
owner int64
|
||||
name string
|
||||
photo int64
|
||||
}{
|
||||
{viewerA, ownerA, "A expected", 101},
|
||||
{viewerA, ownerB, "B cross", 102},
|
||||
{viewerB, ownerA, "A cross", 103},
|
||||
{viewerB, ownerB, "B expected", 104},
|
||||
} {
|
||||
if _, err := contacts.Upsert(ctx, row.viewer, domain.ContactInput{
|
||||
ContactUserID: row.owner,
|
||||
FirstName: row.name,
|
||||
Phone: "known-phone",
|
||||
Note: "private note",
|
||||
NoteEntities: []domain.MessageEntity{{
|
||||
Type: domain.MessageEntityBold, Length: 7,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found, err := contacts.SetPersonalPhoto(ctx, row.viewer, row.owner, row.photo, 1); err != nil || !found {
|
||||
t.Fatalf("SetPersonalPhoto %d->%d: found=%v err=%v", row.viewer, row.owner, found, err)
|
||||
}
|
||||
}
|
||||
got, err := contacts.ContactProjectionForViewerUserIDs(ctx, map[int64][]int64{
|
||||
viewerA: {ownerA},
|
||||
viewerB: {ownerB},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Contacts[viewerA]) != 1 || got.Contacts[viewerA][ownerA].FirstName != "A expected" {
|
||||
t.Fatalf("viewer A contacts = %+v", got.Contacts[viewerA])
|
||||
}
|
||||
contactA := got.Contacts[viewerA][ownerA]
|
||||
if !reflect.DeepEqual(contactA.User, domain.User{ID: ownerA}) {
|
||||
t.Fatalf("viewer A sparse projection retained base user data: %+v", contactA.User)
|
||||
}
|
||||
if contactA.Phone != "known-phone" || contactA.Note != "private note" || len(contactA.NoteEntities) != 1 || contactA.NoteEntities[0].Length != 7 {
|
||||
t.Fatalf("viewer A sparse overlay = %+v", contactA)
|
||||
}
|
||||
if len(got.Contacts[viewerB]) != 1 || got.Contacts[viewerB][ownerB].FirstName != "B expected" {
|
||||
t.Fatalf("viewer B contacts = %+v", got.Contacts[viewerB])
|
||||
}
|
||||
if _, ok := got.Contacts[viewerA][ownerB]; ok {
|
||||
t.Fatal("viewer A unexpectedly received viewer B's requested owner")
|
||||
}
|
||||
if _, ok := got.Contacts[viewerB][ownerA]; ok {
|
||||
t.Fatal("viewer B unexpectedly received viewer A's requested owner")
|
||||
}
|
||||
if len(got.PersonalPhotos[viewerA]) != 1 || got.PersonalPhotos[viewerA][ownerA].PhotoID != 101 {
|
||||
t.Fatalf("viewer A personal photos = %+v", got.PersonalPhotos[viewerA])
|
||||
}
|
||||
if len(got.PersonalPhotos[viewerB]) != 1 || got.PersonalPhotos[viewerB][ownerB].PhotoID != 104 {
|
||||
t.Fatalf("viewer B personal photos = %+v", got.PersonalPhotos[viewerB])
|
||||
}
|
||||
|
||||
// Returned overlay slices are caller-owned, and the personal photo remains
|
||||
// in its dedicated projection map rather than leaking through Contact.User.
|
||||
contactA.NoteEntities[0].Length = 99
|
||||
gotAgain, err := contacts.ContactProjectionForViewerUserIDs(ctx, map[int64][]int64{viewerA: {ownerA}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotAgain.Contacts[viewerA][ownerA].NoteEntities[0].Length != 7 {
|
||||
t.Fatalf("sparse overlay shared NoteEntities with caller: %+v", gotAgain.Contacts[viewerA][ownerA])
|
||||
}
|
||||
if !reflect.DeepEqual(gotAgain.Contacts[viewerA][ownerA].User, domain.User{ID: ownerA}) {
|
||||
t.Fatalf("sparse projection reintroduced base user data: %+v", gotAgain.Contacts[viewerA][ownerA].User)
|
||||
}
|
||||
}
|
||||
|
|
@ -98,6 +98,39 @@ func (s *DialogStore) ListByPeers(_ context.Context, userID int64, peers []domai
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// ListPrivateDialogPeerIDs returns the bounded private-dialog peer set used by
|
||||
// presence fan-out. Keep this narrow read available in the in-memory
|
||||
// production-shaped fake as well: callers must not fall back to hydrating a
|
||||
// complete dialog page when a store implementation lacks the optimized path.
|
||||
func (s *DialogStore) ListPrivateDialogPeerIDs(_ context.Context, userID int64, limit int) ([]int64, error) {
|
||||
s.mu.RLock()
|
||||
dialogs := cloneDialogs(s.m[userID].Dialogs)
|
||||
s.mu.RUnlock()
|
||||
|
||||
sort.SliceStable(dialogs, func(i, j int) bool {
|
||||
return dialogLess(dialogs[i], dialogs[j])
|
||||
})
|
||||
if limit <= 0 || limit > 4096 {
|
||||
limit = 4096
|
||||
}
|
||||
out := make([]int64, 0, min(limit, len(dialogs)))
|
||||
seen := make(map[int64]struct{}, min(limit, len(dialogs)))
|
||||
for _, dialog := range dialogs {
|
||||
if dialog.Peer.Type != domain.PeerTypeUser || dialog.Peer.ID == 0 || dialog.Peer.ID == userID {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[dialog.Peer.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[dialog.Peer.ID] = struct{}{}
|
||||
out = append(out, dialog.Peer.ID)
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveList 保存一份用户会话列表,供测试和本地替身使用。
|
||||
func (s *DialogStore) SaveList(_ context.Context, userID int64, list domain.DialogList) error {
|
||||
list.Dialogs = cloneDialogs(list.Dialogs)
|
||||
|
|
@ -217,6 +250,27 @@ func (s *DialogStore) ListDrafts(_ context.Context, userID int64, limit int) ([]
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) ListDraftsByPeers(_ context.Context, userID int64, peers []domain.Peer) ([]domain.DialogDraft, error) {
|
||||
s.mu.RLock()
|
||||
items := s.drafts[userID]
|
||||
out := make([]domain.DialogDraft, 0, len(peers))
|
||||
seen := make(map[domain.Peer]struct{}, len(peers))
|
||||
for _, peer := range peers {
|
||||
if peer.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[peer]; ok {
|
||||
continue
|
||||
}
|
||||
seen[peer] = struct{}{}
|
||||
if draft, ok := items[draftKey(peer, 0)]; ok {
|
||||
out = append(out, cloneDialogDraft(draft))
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *DialogStore) ClearDrafts(_ context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
|
||||
if limit <= 0 || limit > domain.MaxDialogDraftsPerUser {
|
||||
limit = domain.MaxDialogDraftsPerUser
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package memory
|
|||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
@ -29,6 +30,38 @@ func mediaCategoryMatches(media *domain.MessageMedia, entities []domain.MessageE
|
|||
return false
|
||||
}
|
||||
|
||||
func mediaSearchCommonMatches(id, date int, body string, reply *domain.MessageReply, req domain.MediaSearchRequest) bool {
|
||||
if req.Query != "" && !strings.Contains(strings.ToLower(body), strings.ToLower(req.Query)) {
|
||||
return false
|
||||
}
|
||||
if req.MinDate > 0 && date <= req.MinDate {
|
||||
return false
|
||||
}
|
||||
if req.MaxDate > 0 && date >= req.MaxDate {
|
||||
return false
|
||||
}
|
||||
if req.TopMsgID != 0 && id != req.TopMsgID && (reply == nil || reply.TopMessageID != req.TopMsgID) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func savedMessageHasAnyTag(tags []domain.MessageReaction, wanted []domain.MessageReaction) bool {
|
||||
if len(wanted) == 0 {
|
||||
return true
|
||||
}
|
||||
have := make(map[string]struct{}, len(tags))
|
||||
for _, reaction := range tags {
|
||||
have[reaction.Key()] = struct{}{}
|
||||
}
|
||||
for _, reaction := range wanted {
|
||||
if _, ok := have[reaction.Key()]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pageMediaIDs 把全部匹配 id 按 newest-first 分页(返回本页 id + 满足 max/min 的总数)。
|
||||
func pageMediaIDs(ids []int, req domain.MediaSearchRequest) ([]int, int) {
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(ids)))
|
||||
|
|
@ -80,7 +113,19 @@ func (s *MessageStore) SearchPrivateMedia(ctx context.Context, ownerUserID, peer
|
|||
s.mu.RLock()
|
||||
matched := make([]int, 0, len(s.m[ownerUserID]))
|
||||
for _, msg := range s.m[ownerUserID] {
|
||||
if msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID != peerID {
|
||||
if msg.Deleted || msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID != peerID {
|
||||
continue
|
||||
}
|
||||
if req.SenderUserID != 0 && (msg.From.Type != domain.PeerTypeUser || msg.From.ID != req.SenderUserID) {
|
||||
continue
|
||||
}
|
||||
if !mediaSearchCommonMatches(msg.ID, msg.Date, msg.Body, msg.ReplyTo, req) {
|
||||
continue
|
||||
}
|
||||
if req.SavedPeer.ID != 0 && msg.SavedPeer != req.SavedPeer {
|
||||
continue
|
||||
}
|
||||
if !savedMessageHasAnyTag(s.savedMessageTags[ownerUserID][msg.ID], req.SavedReactions) {
|
||||
continue
|
||||
}
|
||||
if mediaCategoryMatches(msg.Media, msg.Entities, set) {
|
||||
|
|
@ -110,7 +155,7 @@ func (s *MessageStore) CountPrivateMediaCategories(_ context.Context, ownerUserI
|
|||
defer s.mu.RUnlock()
|
||||
out := domain.MediaCategoryCounts{}
|
||||
for _, msg := range s.m[ownerUserID] {
|
||||
if msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID != peerID {
|
||||
if msg.Deleted || msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID != peerID {
|
||||
continue
|
||||
}
|
||||
for _, category := range domain.ClassifyMediaCategories(msg.Media, msg.Entities) {
|
||||
|
|
@ -129,7 +174,7 @@ func (s *ChannelStore) SearchChannelMedia(ctx context.Context, viewerUserID, cha
|
|||
return domain.ChannelHistory{}, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
_, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
s.mu.RUnlock()
|
||||
return domain.ChannelHistory{}, err
|
||||
|
|
@ -139,6 +184,20 @@ func (s *ChannelStore) SearchChannelMedia(ctx context.Context, viewerUserID, cha
|
|||
if msg.Deleted || msg.ID <= member.AvailableMinID {
|
||||
continue
|
||||
}
|
||||
if channel.Monoforum {
|
||||
if member.CanManageDirectMessages() && msg.SavedPeer.ID != 0 {
|
||||
continue
|
||||
}
|
||||
if !member.CanManageDirectMessages() && msg.SavedPeer != (domain.Peer{Type: domain.PeerTypeUser, ID: viewerUserID}) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if req.SenderUserID != 0 && msg.SenderUserID != req.SenderUserID {
|
||||
continue
|
||||
}
|
||||
if !mediaSearchCommonMatches(msg.ID, msg.Date, msg.Body, msg.ReplyTo, req) {
|
||||
continue
|
||||
}
|
||||
if mediaCategoryMatches(msg.Media, msg.Entities, set) {
|
||||
matched = append(matched, msg.ID)
|
||||
}
|
||||
|
|
@ -165,7 +224,7 @@ func (s *ChannelStore) CountChannelMediaCategories(_ context.Context, viewerUser
|
|||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
_, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.MediaCategoryCounts{}, err
|
||||
}
|
||||
|
|
@ -174,6 +233,14 @@ func (s *ChannelStore) CountChannelMediaCategories(_ context.Context, viewerUser
|
|||
if msg.Deleted || msg.ID <= member.AvailableMinID {
|
||||
continue
|
||||
}
|
||||
if channel.Monoforum {
|
||||
if member.CanManageDirectMessages() && msg.SavedPeer.ID != 0 {
|
||||
continue
|
||||
}
|
||||
if !member.CanManageDirectMessages() && msg.SavedPeer != (domain.Peer{Type: domain.PeerTypeUser, ID: viewerUserID}) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
for _, category := range domain.ClassifyMediaCategories(msg.Media, msg.Entities) {
|
||||
if category != domain.MediaCategoryNone {
|
||||
out[category]++
|
||||
|
|
|
|||
92
internal/store/memory/media_search_test.go
Normal file
92
internal/store/memory/media_search_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func testPhotoMedia(id int64) *domain.MessageMedia {
|
||||
return &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindPhoto,
|
||||
Photo: &domain.Photo{ID: id, AccessHash: id + 100},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateMediaSearchCombinesQuerySenderAndDate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMessageStore()
|
||||
const alice, bob = int64(1001), int64(1002)
|
||||
send := func(sender, recipient, randomID int64, body string, date int) {
|
||||
t.Helper()
|
||||
if _, err := store.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: sender, RecipientUserID: recipient, RandomID: randomID,
|
||||
Message: body, Media: testPhotoMedia(randomID), Date: date,
|
||||
}); err != nil {
|
||||
t.Fatalf("send private media: %v", err)
|
||||
}
|
||||
}
|
||||
send(alice, bob, 1, "needle outside date", 100)
|
||||
send(alice, bob, 2, "needle wanted", 200)
|
||||
send(bob, alice, 3, "needle wrong sender", 210)
|
||||
send(alice, bob, 4, "other text", 220)
|
||||
|
||||
got, err := store.SearchPrivateMedia(ctx, bob, alice, domain.MediaSearchRequest{
|
||||
Categories: []domain.MediaCategory{domain.MediaCategoryPhoto},
|
||||
Query: "NEEDLE",
|
||||
SenderUserID: alice,
|
||||
MinDate: 150,
|
||||
MaxDate: 205,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("search private media: %v", err)
|
||||
}
|
||||
if got.Count != 1 || len(got.Messages) != 1 || got.Messages[0].Body != "needle wanted" {
|
||||
t.Fatalf("combined private media = count %d messages %+v", got.Count, got.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelMediaSearchCombinesQuerySenderAndDate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 1,
|
||||
Title: "combined media",
|
||||
Megagroup: true,
|
||||
MemberUserIDs: []int64{2},
|
||||
Date: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
send := func(sender, randomID int64, body string, date int) {
|
||||
t.Helper()
|
||||
if _, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: sender, ChannelID: created.Channel.ID, RandomID: randomID,
|
||||
Message: body, Media: testPhotoMedia(randomID), Date: date,
|
||||
}); err != nil {
|
||||
t.Fatalf("send channel media: %v", err)
|
||||
}
|
||||
}
|
||||
send(1, 11, "needle outside date", 110)
|
||||
send(2, 12, "needle wrong sender", 210)
|
||||
send(1, 13, "needle wanted", 220)
|
||||
send(1, 14, "other text", 230)
|
||||
|
||||
got, err := store.SearchChannelMedia(ctx, 1, created.Channel.ID, domain.MediaSearchRequest{
|
||||
Categories: []domain.MediaCategory{domain.MediaCategoryPhoto},
|
||||
Query: "NEEDLE",
|
||||
SenderUserID: 1,
|
||||
MinDate: 200,
|
||||
MaxDate: 225,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("search channel media: %v", err)
|
||||
}
|
||||
if got.Count != 1 || len(got.Messages) != 1 || got.Messages[0].Body != "needle wanted" {
|
||||
t.Fatalf("combined channel media = count %d messages %+v", got.Count, got.Messages)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,14 +2,13 @@ package memory
|
|||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// PhoneChangeStore 是测试用内存实现。用户唯一性在 UserStore 锁内维护;事件写入
|
||||
// 共享 UpdateEventStore 后可由 updates.getDifference 重放。
|
||||
// PhoneChangeStore 是测试用内存实现。用户唯一性在 UserStore 锁内维护;
|
||||
// updateUserPhone 无 PTS,所以不向 UpdateEventStore 写 durable event。
|
||||
type PhoneChangeStore struct {
|
||||
users *UserStore
|
||||
events store.UpdateEventStore
|
||||
|
|
@ -19,9 +18,7 @@ func NewPhoneChangeStore(users *UserStore, events store.UpdateEventStore) *Phone
|
|||
return &PhoneChangeStore{users: users, events: events}
|
||||
}
|
||||
|
||||
func (*PhoneChangeStore) UsesReliableDispatch() bool { return false }
|
||||
|
||||
func (s *PhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChangeRequest) (domain.PhoneChangeResult, error) {
|
||||
func (s *PhoneChangeStore) ChangePhone(_ context.Context, req domain.PhoneChangeRequest) (domain.PhoneChangeResult, error) {
|
||||
if s == nil || s.users == nil || req.UserID == 0 || !domain.ValidPhone(req.Phone) {
|
||||
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberInvalid
|
||||
}
|
||||
|
|
@ -41,37 +38,8 @@ func (s *PhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChan
|
|||
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied
|
||||
}
|
||||
}
|
||||
currentPhone := u.Phone
|
||||
currentSignupEmail := u.SignupEmail
|
||||
u.Phone = req.Phone
|
||||
if req.SignupEmail != "" {
|
||||
u.SignupEmail = req.SignupEmail
|
||||
}
|
||||
s.users.byID[req.UserID] = u
|
||||
|
||||
date := req.Date
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
event := domain.UpdateEvent{
|
||||
UserID: req.UserID,
|
||||
Type: domain.UpdateEventUserPhone,
|
||||
Date: date,
|
||||
Phone: req.Phone,
|
||||
PtsCount: 1,
|
||||
}
|
||||
if s.events != nil {
|
||||
var err error
|
||||
event, err = s.events.AppendAllocated(ctx, req.UserID, event)
|
||||
if err != nil {
|
||||
// 保持内存替身与 PG 的 user+event 原子可见语义。
|
||||
u.Phone = currentPhone
|
||||
u.SignupEmail = currentSignupEmail
|
||||
s.users.byID[req.UserID] = u
|
||||
s.users.mu.Unlock()
|
||||
return domain.PhoneChangeResult{}, err
|
||||
}
|
||||
}
|
||||
s.users.mu.Unlock()
|
||||
return domain.PhoneChangeResult{User: u, Event: event, Changed: true}, nil
|
||||
return domain.PhoneChangeResult{User: u, Changed: true}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,13 +28,13 @@ func cloneSecretChat(c domain.SecretChat) domain.SecretChat {
|
|||
}
|
||||
|
||||
func (s *SecretChatStore) CreateSecretChat(_ context.Context, chat domain.SecretChat) error {
|
||||
if chat.ID == 0 {
|
||||
return domain.ErrSecretChatNotFound
|
||||
if chat.ID == 0 || chat.ID != int(chat.RandomID) {
|
||||
return domain.ErrSecretChatRandomIDDuplicate
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, exists := s.chats[chat.ID]; exists {
|
||||
return domain.ErrSecretChatIDConflict
|
||||
return domain.ErrSecretChatRandomIDDuplicate
|
||||
}
|
||||
if chat.State == "" {
|
||||
chat.State = domain.SecretChatStateRequested
|
||||
|
|
@ -53,18 +53,6 @@ func (s *SecretChatStore) GetSecretChat(_ context.Context, chatID int) (domain.S
|
|||
return cloneSecretChat(c), true, nil
|
||||
}
|
||||
|
||||
func (s *SecretChatStore) GetByAdminRandom(_ context.Context, adminAuthKeyID int64, randomID int32) (domain.SecretChat, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// 仅返回非终态匹配(与部分唯一索引 WHERE state <> 'discarded' 一致)。
|
||||
for _, c := range s.chats {
|
||||
if c.AdminAuthKeyID == adminAuthKeyID && c.RandomID == randomID && !c.Terminal() {
|
||||
return cloneSecretChat(c), true, nil
|
||||
}
|
||||
}
|
||||
return domain.SecretChat{}, false, nil
|
||||
}
|
||||
|
||||
func (s *SecretChatStore) AcceptSecretChat(_ context.Context, chatID int, participantAuthKeyID int64, gb []byte, keyFingerprint int64) (domain.SecretChat, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
@ -126,18 +114,6 @@ func (s *SecretChatStore) ListActiveSecretChatsByAuthKey(_ context.Context, auth
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *SecretChatStore) MaxSecretChatID(_ context.Context) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
max := 0
|
||||
for id := range s.chats {
|
||||
if id > max {
|
||||
max = id
|
||||
}
|
||||
}
|
||||
return max, nil
|
||||
}
|
||||
|
||||
// EncryptedQueueStore 是 store.EncryptedQueueStore 的进程内实现。
|
||||
type EncryptedQueueStore struct {
|
||||
mu sync.Mutex
|
||||
|
|
|
|||
|
|
@ -549,6 +549,52 @@ func (s *StoryStore) GetPeerStoryProjections(_ context.Context, viewerUserID int
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StoryStore) ActiveStoryPeerExpirations(_ context.Context, peers []domain.Peer, now int) (map[domain.Peer]int, error) {
|
||||
if len(peers) > domain.MaxStoryIDs {
|
||||
return nil, domain.ErrStoryIDInvalid
|
||||
}
|
||||
requested := make(map[domain.Peer]struct{}, len(peers))
|
||||
for _, peer := range peers {
|
||||
if err := validateStoryPeer(peer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
requested[peer] = struct{}{}
|
||||
}
|
||||
out := make(map[domain.Peer]int, len(peers))
|
||||
s.mu.RLock()
|
||||
for _, story := range s.stories {
|
||||
if _, ok := requested[story.Owner]; !ok || !story.Active(now) {
|
||||
continue
|
||||
}
|
||||
if story.ExpireDate > out[story.Owner] {
|
||||
out[story.Owner] = story.ExpireDate
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StoryStore) ListHiddenStoryPeers(_ context.Context, viewerUserID int64) ([]domain.Peer, error) {
|
||||
if viewerUserID == 0 {
|
||||
return nil, domain.ErrStoryPeerInvalid
|
||||
}
|
||||
out := make([]domain.Peer, 0)
|
||||
s.mu.RLock()
|
||||
for key, hidden := range s.hidden {
|
||||
if key.viewerID == viewerUserID && hidden {
|
||||
out = append(out, domain.Peer{Type: key.peerType, ID: key.peerID})
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Type != out[j].Type {
|
||||
return out[i].Type < out[j].Type
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StoryStore) MarkRead(_ context.Context, viewerUserID int64, peer domain.Peer, maxID, date int) (domain.StoryReadResult, error) {
|
||||
if viewerUserID == 0 {
|
||||
return domain.StoryReadResult{}, domain.ErrStoryPeerInvalid
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import (
|
|||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"telesrv/internal/domain"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// UserStore 是 store.UserStore 的内存实现。ID 与 PG identity 使用同一业务起点。
|
||||
|
|
@ -18,11 +20,11 @@ type UserStore struct {
|
|||
usernameRegistry *CollectibleUsernameStore
|
||||
}
|
||||
|
||||
// NewUserStore 创建内存 UserStore。内置系统账号(777000 / BotFather / Stickers / ChatBot)
|
||||
// NewUserStore 创建内存 UserStore。内置系统账号
|
||||
// 预置进表,与 postgres 的迁移种子保持双 store 行为一致。
|
||||
func NewUserStore() *UserStore {
|
||||
s := &UserStore{byID: make(map[int64]domain.User), nextID: domain.UserIDSequenceBase}
|
||||
for _, id := range []int64{domain.OfficialSystemUserID, domain.BotFatherUserID, domain.StickersBotUserID, domain.ChatBotUserID, domain.GifBotUserID} {
|
||||
for _, id := range domain.SystemUserIDs() {
|
||||
if u, ok := domain.SystemUserByID(id); ok {
|
||||
s.byID[u.ID] = u
|
||||
}
|
||||
|
|
@ -453,6 +455,24 @@ func (s *UserStore) UpdateLastSeen(_ context.Context, userID int64, lastSeenAt i
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *UserStore) UpdateLastSeenBatch(ctx context.Context, updates []store.UserLastSeenUpdate) error {
|
||||
latest := make(map[int64]int, len(updates))
|
||||
for _, update := range updates {
|
||||
if update.UserID == 0 || update.LastSeenAt <= 0 {
|
||||
continue
|
||||
}
|
||||
if current := latest[update.UserID]; update.LastSeenAt > current {
|
||||
latest[update.UserID] = update.LastSeenAt
|
||||
}
|
||||
}
|
||||
for userID, lastSeenAt := range latest {
|
||||
if err := s.UpdateLastSeen(ctx, userID, lastSeenAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func userMatchesSearch(u domain.User, query, phoneQuery string) bool {
|
||||
if phoneQuery != "" && strings.HasPrefix(u.Phone, phoneQuery) {
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -232,7 +232,9 @@ func (s *PasswordStore) GetAccountSettings(ctx context.Context, userID int64) (d
|
|||
row := s.db.QueryRow(ctx, `
|
||||
SELECT archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders,
|
||||
hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button,
|
||||
noncontact_peers_paid_stars, account_ttl_days, sensitive_content_enabled, contact_signup_silent
|
||||
noncontact_peers_paid_stars, disallow_unlimited_stargifts, disallow_limited_stargifts,
|
||||
disallow_unique_stargifts, disallow_premium_gifts, disallow_stargifts_from_channels,
|
||||
account_ttl_days, sensitive_content_enabled, contact_signup_silent
|
||||
FROM account_settings
|
||||
WHERE user_id = $1`, userID)
|
||||
settings := domain.DefaultAccountSettings()
|
||||
|
|
@ -240,7 +242,11 @@ WHERE user_id = $1`, userID)
|
|||
if err := row.Scan(
|
||||
&gp.ArchiveAndMuteNewNoncontactPeers, &gp.KeepArchivedUnmuted, &gp.KeepArchivedFolders,
|
||||
&gp.HideReadMarks, &gp.NewNoncontactPeersRequirePremium, &gp.DisplayGiftsButton,
|
||||
&gp.NoncontactPeersPaidStars, &settings.AccountTTLDays, &settings.SensitiveContentEnabled, &settings.ContactSignUpSilent,
|
||||
&gp.NoncontactPeersPaidStars,
|
||||
&gp.DisallowedGifts.UnlimitedStargifts, &gp.DisallowedGifts.LimitedStargifts,
|
||||
&gp.DisallowedGifts.UniqueStargifts, &gp.DisallowedGifts.PremiumGifts,
|
||||
&gp.DisallowedGifts.StargiftsFromChannel,
|
||||
&settings.AccountTTLDays, &settings.SensitiveContentEnabled, &settings.ContactSignUpSilent,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.AccountSettings{}, false, nil
|
||||
|
|
@ -258,7 +264,9 @@ func (s *PasswordStore) GetAccountSettingsBatch(ctx context.Context, userIDs []i
|
|||
rows, err := s.db.Query(ctx, `
|
||||
SELECT user_id, archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders,
|
||||
hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button,
|
||||
noncontact_peers_paid_stars, account_ttl_days, sensitive_content_enabled, contact_signup_silent
|
||||
noncontact_peers_paid_stars, disallow_unlimited_stargifts, disallow_limited_stargifts,
|
||||
disallow_unique_stargifts, disallow_premium_gifts, disallow_stargifts_from_channels,
|
||||
account_ttl_days, sensitive_content_enabled, contact_signup_silent
|
||||
FROM account_settings
|
||||
WHERE user_id = ANY($1::bigint[])`, userIDs)
|
||||
if err != nil {
|
||||
|
|
@ -273,7 +281,11 @@ WHERE user_id = ANY($1::bigint[])`, userIDs)
|
|||
&userID,
|
||||
&gp.ArchiveAndMuteNewNoncontactPeers, &gp.KeepArchivedUnmuted, &gp.KeepArchivedFolders,
|
||||
&gp.HideReadMarks, &gp.NewNoncontactPeersRequirePremium, &gp.DisplayGiftsButton,
|
||||
&gp.NoncontactPeersPaidStars, &settings.AccountTTLDays, &settings.SensitiveContentEnabled, &settings.ContactSignUpSilent,
|
||||
&gp.NoncontactPeersPaidStars,
|
||||
&gp.DisallowedGifts.UnlimitedStargifts, &gp.DisallowedGifts.LimitedStargifts,
|
||||
&gp.DisallowedGifts.UniqueStargifts, &gp.DisallowedGifts.PremiumGifts,
|
||||
&gp.DisallowedGifts.StargiftsFromChannel,
|
||||
&settings.AccountTTLDays, &settings.SensitiveContentEnabled, &settings.ContactSignUpSilent,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan account settings batch: %w", err)
|
||||
}
|
||||
|
|
@ -291,8 +303,10 @@ func (s *PasswordStore) SaveAccountSettings(ctx context.Context, userID int64, s
|
|||
INSERT INTO account_settings (
|
||||
user_id, archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders,
|
||||
hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button,
|
||||
noncontact_peers_paid_stars, account_ttl_days, sensitive_content_enabled, contact_signup_silent
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||
noncontact_peers_paid_stars, disallow_unlimited_stargifts, disallow_limited_stargifts,
|
||||
disallow_unique_stargifts, disallow_premium_gifts, disallow_stargifts_from_channels,
|
||||
account_ttl_days, sensitive_content_enabled, contact_signup_silent
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
archive_and_mute_new_noncontact_peers = EXCLUDED.archive_and_mute_new_noncontact_peers,
|
||||
keep_archived_unmuted = EXCLUDED.keep_archived_unmuted,
|
||||
|
|
@ -301,6 +315,11 @@ ON CONFLICT (user_id) DO UPDATE SET
|
|||
new_noncontact_peers_require_premium = EXCLUDED.new_noncontact_peers_require_premium,
|
||||
display_gifts_button = EXCLUDED.display_gifts_button,
|
||||
noncontact_peers_paid_stars = EXCLUDED.noncontact_peers_paid_stars,
|
||||
disallow_unlimited_stargifts = EXCLUDED.disallow_unlimited_stargifts,
|
||||
disallow_limited_stargifts = EXCLUDED.disallow_limited_stargifts,
|
||||
disallow_unique_stargifts = EXCLUDED.disallow_unique_stargifts,
|
||||
disallow_premium_gifts = EXCLUDED.disallow_premium_gifts,
|
||||
disallow_stargifts_from_channels = EXCLUDED.disallow_stargifts_from_channels,
|
||||
account_ttl_days = EXCLUDED.account_ttl_days,
|
||||
sensitive_content_enabled = EXCLUDED.sensitive_content_enabled,
|
||||
contact_signup_silent = EXCLUDED.contact_signup_silent,
|
||||
|
|
@ -308,7 +327,11 @@ ON CONFLICT (user_id) DO UPDATE SET
|
|||
userID,
|
||||
gp.ArchiveAndMuteNewNoncontactPeers, gp.KeepArchivedUnmuted, gp.KeepArchivedFolders,
|
||||
gp.HideReadMarks, gp.NewNoncontactPeersRequirePremium, gp.DisplayGiftsButton,
|
||||
gp.NoncontactPeersPaidStars, settings.NormalizedTTLDays(), settings.SensitiveContentEnabled, settings.ContactSignUpSilent,
|
||||
gp.NoncontactPeersPaidStars,
|
||||
gp.DisallowedGifts.UnlimitedStargifts, gp.DisallowedGifts.LimitedStargifts,
|
||||
gp.DisallowedGifts.UniqueStargifts, gp.DisallowedGifts.PremiumGifts,
|
||||
gp.DisallowedGifts.StargiftsFromChannel,
|
||||
settings.NormalizedTTLDays(), settings.SensitiveContentEnabled, settings.ContactSignUpSilent,
|
||||
); err != nil {
|
||||
return fmt.Errorf("save account settings: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import (
|
|||
)
|
||||
|
||||
// AccountLifecycleStore is the PostgreSQL implementation of the unified
|
||||
// account tombstone, delayed deletion and deletion notification boundary.
|
||||
// account tombstone and delayed deletion boundary.
|
||||
type AccountLifecycleStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
|
@ -157,19 +157,15 @@ func (s *AccountLifecycleStore) ExecuteAccountDeletion(ctx context.Context, user
|
|||
if !due {
|
||||
return domain.AccountDeletionResult{User: u, Changed: false}, nil
|
||||
}
|
||||
if err := enqueueAccountDeletionNotifications(ctx, tx, userID); err != nil {
|
||||
return domain.AccountDeletionResult{}, err
|
||||
}
|
||||
if err := settleDeletedAccountFinancialState(ctx, tx, userID, now); err != nil {
|
||||
return domain.AccountDeletionResult{}, err
|
||||
}
|
||||
// Human account deletion is deliberately a short logical tombstone boundary.
|
||||
// Relationships, history, memberships, settings and financial rows remain
|
||||
// attached to the stable user id; reads project that id as Deleted Account.
|
||||
// The physical cleanup helpers remain available only to the separate bot-
|
||||
// deletion boundary, whose lifecycle semantics are intentionally different.
|
||||
revoked, err := revokeByUserExceptTx(ctx, tx, userID, 0)
|
||||
if err != nil {
|
||||
return domain.AccountDeletionResult{}, fmt.Errorf("revoke deleted account authorizations: %w", err)
|
||||
}
|
||||
if err := purgeDeletedAccountPrivateState(ctx, tx, userID, now); err != nil {
|
||||
return domain.AccountDeletionResult{}, err
|
||||
}
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, "", ""); err != nil {
|
||||
return domain.AccountDeletionResult{}, fmt.Errorf("release deleted account username: %w", err)
|
||||
}
|
||||
|
|
@ -281,45 +277,6 @@ SELECT user_id, source, due_at FROM dedup ORDER BY due_at, user_id LIMIT $2`, no
|
|||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *AccountLifecycleStore) ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error) {
|
||||
if s == nil || s.pool == nil || limit <= 0 || lease <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH claim AS (
|
||||
SELECT id FROM account_deletion_notifications
|
||||
WHERE (status = 'pending' AND next_attempt_at <= $1)
|
||||
OR (status = 'dispatching' AND lease_until <= $1)
|
||||
ORDER BY next_attempt_at, id FOR UPDATE SKIP LOCKED LIMIT $2
|
||||
)
|
||||
UPDATE account_deletion_notifications n
|
||||
SET status = 'dispatching', attempts = attempts + 1, lease_until = $3, updated_at = $1
|
||||
FROM claim WHERE n.id = claim.id
|
||||
RETURNING n.id, n.target_user_id, n.deleted_user_id, n.attempts`, now, limit, now.Add(lease))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claim account deletion notifications: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.AccountDeletionNotification, 0)
|
||||
for rows.Next() {
|
||||
var n domain.AccountDeletionNotification
|
||||
if err := rows.Scan(&n.ID, &n.TargetUserID, &n.DeletedUserID, &n.Attempts); err != nil {
|
||||
return nil, fmt.Errorf("scan account deletion notification: %w", err)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *AccountLifecycleStore) CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE account_deletion_notifications
|
||||
SET status = 'delivered', lease_until = NULL, last_error = '', updated_at = $2 WHERE id = $1`, id, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete account deletion notification: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type accountDeletionRowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
|
@ -448,38 +405,6 @@ func truncateUTF8Bytes(value string, maxBytes int) string {
|
|||
return value[:cut]
|
||||
}
|
||||
|
||||
func enqueueAccountDeletionNotifications(ctx context.Context, tx pgx.Tx, userID int64) error {
|
||||
const maxAccountDeletionNotificationAudience = 4096
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO account_deletion_notifications (target_user_id, deleted_user_id)
|
||||
SELECT audience.user_id, $1
|
||||
FROM (
|
||||
SELECT user_id
|
||||
FROM (
|
||||
SELECT contact_user_id AS user_id, 0 AS priority, 0 AS activity
|
||||
FROM contacts WHERE user_id = $1
|
||||
UNION ALL
|
||||
SELECT user_id, 0, 0 FROM contacts WHERE contact_user_id = $1
|
||||
UNION ALL
|
||||
SELECT peer_id, 1, top_message_date
|
||||
FROM dialogs WHERE user_id = $1 AND peer_type = 'user'
|
||||
UNION ALL
|
||||
SELECT user_id, 1, top_message_date
|
||||
FROM dialogs WHERE peer_type = 'user' AND peer_id = $1
|
||||
) candidates
|
||||
GROUP BY user_id
|
||||
ORDER BY min(priority), max(activity) DESC, user_id
|
||||
LIMIT $2
|
||||
) audience
|
||||
JOIN users u ON u.id = audience.user_id
|
||||
WHERE audience.user_id <> $1 AND u.deleted_at IS NULL
|
||||
ON CONFLICT (target_user_id, deleted_user_id) DO NOTHING`, userID, maxAccountDeletionNotificationAudience)
|
||||
if err != nil {
|
||||
return fmt.Errorf("enqueue account deletion notifications: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func revokeOneAuthorizationTx(ctx context.Context, tx pgx.Tx, userID int64, authKeyID [8]byte) ([]domain.Authorization, error) {
|
||||
id := authKeyIDToInt64(authKeyID)
|
||||
if id == 0 {
|
||||
|
|
@ -514,10 +439,16 @@ FROM authorizations WHERE auth_key_id = $1 AND user_id = $2 FOR UPDATE`, id, use
|
|||
return []domain.Authorization{a}, nil
|
||||
}
|
||||
|
||||
func purgeDeletedAccountPrivateState(ctx context.Context, tx pgx.Tx, userID int64, now time.Time) error {
|
||||
func purgeDeletedBotPrivateState(ctx context.Context, tx pgx.Tx, userID int64, now time.Time) error {
|
||||
// Leave shared private_messages/channel_messages and immutable transaction
|
||||
// ledgers intact. Only the deleted user's private projections and settings are
|
||||
// removed; other users continue to reference the tombstone sender.
|
||||
// The purge can empty the durable delivery lane. Fence concurrent appends
|
||||
// before deleting its outbox/head/event facts so no committed task becomes
|
||||
// undiscoverable during the account lifecycle transition.
|
||||
if err := lockDispatchOutboxLanesExclusive(ctx, tx, []int64{userID}); err != nil {
|
||||
return fmt.Errorf("lock deleted bot dispatch lane: %w", err)
|
||||
}
|
||||
statements := []string{
|
||||
`DELETE FROM account_privacy_rules WHERE owner_user_id = $1`,
|
||||
`DELETE FROM account_reaction_settings WHERE user_id = $1`,
|
||||
|
|
@ -580,6 +511,7 @@ func purgeDeletedAccountPrivateState(ctx context.Context, tx pgx.Tx, userID int6
|
|||
`DELETE FROM group_call_invites WHERE inviter_user_id = $1 OR invitee_user_id = $1`,
|
||||
`DELETE FROM channel_boost_slots WHERE user_id = $1`,
|
||||
`DELETE FROM channel_invite_importers WHERE user_id = $1`,
|
||||
`DELETE FROM welcome_message_deliveries WHERE target_user_id = $1`,
|
||||
`DELETE FROM channel_topic_read WHERE user_id = $1`,
|
||||
`DELETE FROM channel_unread_mentions WHERE user_id = $1`,
|
||||
`DELETE FROM channel_unread_mention_index WHERE user_id = $1`,
|
||||
|
|
@ -621,121 +553,3 @@ WHERE admin_user_id = $1 OR participant_user_id = $1`, userID); err != nil {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func settleDeletedAccountFinancialState(ctx context.Context, tx pgx.Tx, userID int64, now time.Time) error {
|
||||
nowUnix := int(now.Unix())
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id, buyer_user_id, currency, amount
|
||||
FROM star_gift_offers
|
||||
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND status = 'pending'
|
||||
ORDER BY id FOR UPDATE`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lock deleted account gift offers: %w", err)
|
||||
}
|
||||
type offer struct {
|
||||
id, buyer, amount int64
|
||||
currency string
|
||||
}
|
||||
offers := make([]offer, 0)
|
||||
for rows.Next() {
|
||||
var o offer
|
||||
if err := rows.Scan(&o.id, &o.buyer, &o.currency, &o.amount); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("scan deleted account gift offer: %w", err)
|
||||
}
|
||||
offers = append(offers, o)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
for _, o := range offers {
|
||||
var balance int64
|
||||
if o.currency == "XTR" {
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO stars_balances (user_id, balance) VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO UPDATE SET balance = stars_balances.balance + EXCLUDED.balance, updated_at = now()
|
||||
RETURNING balance`, o.buyer, o.amount).Scan(&balance); err != nil {
|
||||
return fmt.Errorf("refund deleted account stars offer: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO stars_transactions
|
||||
(user_id, peer_type, peer_id, amount, reason, title, description, date)
|
||||
VALUES ($1, 'user', $2, $3, 'gift_offer_refund_account_deleted', 'Gift offer refunded', '', $4)`, o.buyer, userID, o.amount, nowUnix); err != nil {
|
||||
return fmt.Errorf("record deleted account stars refund: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO ton_balances (user_id, balance_nanoton) VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO UPDATE SET balance_nanoton = ton_balances.balance_nanoton + EXCLUDED.balance_nanoton, updated_at = now()
|
||||
RETURNING balance_nanoton`, o.buyer, o.amount).Scan(&balance); err != nil {
|
||||
return fmt.Errorf("refund deleted account TON offer: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions
|
||||
(user_id, amount_nanoton, reason, peer_type, peer_id, date)
|
||||
VALUES ($1, $2, 'gift_offer_refund_account_deleted', 'user', $3, $4)`, o.buyer, o.amount, userID, nowUnix); err != nil {
|
||||
return fmt.Errorf("record deleted account TON refund: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE star_gift_offers
|
||||
SET status = 'cancelled', resolved_at = $2, balance_after = $3
|
||||
WHERE id = $1 AND status = 'pending'`, o.id, nowUnix, balance); err != nil {
|
||||
return fmt.Errorf("cancel deleted account gift offer: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE star_gift_offers
|
||||
SET status = 'cancelled', resolved_at = $2, balance_after = 0
|
||||
WHERE buyer_user_id = $1 AND status = 'pending'`, userID, nowUnix); err != nil {
|
||||
return fmt.Errorf("cancel deleted buyer gift offers: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE star_gift_withdrawal_requests
|
||||
SET status = 'failed', completed_at = $2 WHERE owner_user_id = $1 AND status = 'pending'`, userID, nowUnix); err != nil {
|
||||
return fmt.Errorf("fail deleted account withdrawals: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE star_gift_auction_bids SET active = false, version = version + 1
|
||||
WHERE bidder_user_id = $1 AND active = true`, userID); err != nil {
|
||||
return fmt.Errorf("deactivate deleted account auction bids: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts
|
||||
SET burned = true, owner_name = '', updated_at = $2
|
||||
WHERE owner_peer_type = 'user' AND owner_peer_id = $1`, userID, now); err != nil {
|
||||
return fmt.Errorf("burn deleted account unique gifts: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts
|
||||
SET lifecycle_status = 'burned', unsaved = true, pinned_order = 0
|
||||
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND unique_gift_id IS NOT NULL`, userID); err != nil {
|
||||
return fmt.Errorf("burn deleted account saved gifts: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM peer_star_gifts
|
||||
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND unique_gift_id IS NULL`, userID); err != nil {
|
||||
return fmt.Errorf("delete deleted account regular gifts: %w", err)
|
||||
}
|
||||
var stars int64
|
||||
if err := tx.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id = $1 FOR UPDATE`, userID).Scan(&stars); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Errorf("lock deleted account stars balance: %w", err)
|
||||
}
|
||||
if stars != 0 {
|
||||
if _, err := tx.Exec(ctx, `UPDATE stars_balances SET balance = 0, updated_at = $2 WHERE user_id = $1`, userID, now); err != nil {
|
||||
return fmt.Errorf("zero deleted account stars: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO stars_transactions
|
||||
(user_id, peer_type, peer_id, amount, reason, title, description, date)
|
||||
VALUES ($1, 'user', $1, $2, 'account_deleted', 'Account deleted', '', $3)`, userID, -stars, nowUnix); err != nil {
|
||||
return fmt.Errorf("record deleted account stars clearing: %w", err)
|
||||
}
|
||||
}
|
||||
var ton int64
|
||||
if err := tx.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id = $1 FOR UPDATE`, userID).Scan(&ton); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Errorf("lock deleted account TON balance: %w", err)
|
||||
}
|
||||
if ton != 0 {
|
||||
if _, err := tx.Exec(ctx, `UPDATE ton_balances SET balance_nanoton = 0, updated_at = $2 WHERE user_id = $1`, userID, now); err != nil {
|
||||
return fmt.Errorf("zero deleted account TON: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions
|
||||
(user_id, amount_nanoton, reason, date) VALUES ($1, $2, 'account_deleted', $3)`, userID, -ton, nowUnix); err != nil {
|
||||
return fmt.Errorf("record deleted account TON clearing: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,15 @@ func TestAccountLifecycleScheduleCancelAndTombstonePostgres(t *testing.T) {
|
|||
users := NewUserStore(pool)
|
||||
deleted := createTestUser(t, ctx, users, fmt.Sprintf("15571%d", nonce), "Delete", "Me")
|
||||
peer := createTestUser(t, ctx, users, fmt.Sprintf("15572%d", nonce), "Keep", "Peer")
|
||||
var channelID int64
|
||||
var collectiblePhoneID int64
|
||||
t.Cleanup(func() {
|
||||
if channelID != 0 {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM channels WHERE id = $1`, channelID)
|
||||
}
|
||||
if collectiblePhoneID != 0 {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM collectible_phones WHERE id = $1`, collectiblePhoneID)
|
||||
}
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM stars_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM ton_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM stars_balances WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||
|
|
@ -33,6 +41,11 @@ func TestAccountLifecycleScheduleCancelAndTombstonePostgres(t *testing.T) {
|
|||
_, _ = pool.Exec(ctx, `DELETE FROM private_messages WHERE sender_user_id = ANY($1) OR recipient_user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1)`, []int64{deleted.ID, peer.ID})
|
||||
})
|
||||
deletedUsername := fmt.Sprintf("deleteme%d", nonce)
|
||||
deleted, err := users.UpdateUsername(ctx, deleted.ID, deletedUsername)
|
||||
if err != nil {
|
||||
t.Fatalf("set deleted user username: %v", err)
|
||||
}
|
||||
|
||||
authOne := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 1)
|
||||
authTwo := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 2)
|
||||
|
|
@ -55,6 +68,36 @@ VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err !=
|
|||
if _, err := pool.Exec(ctx, `INSERT INTO ton_balances (user_id, balance_nanoton) VALUES ($1, 100)`, deleted.ID); err != nil {
|
||||
t.Fatalf("insert TON balance: %v", err)
|
||||
}
|
||||
collectiblePhone := fmt.Sprintf("888%010d", nonce%10_000_000_000)
|
||||
if err := pool.QueryRow(ctx, `INSERT INTO collectible_phones
|
||||
(phone, tier, status, owner_user_id, purchase_date, currency, amount, created_at, updated_at)
|
||||
VALUES ($1, 'standard', 'owned', $2, $3, 'XTR', 100, $3, $3)
|
||||
RETURNING id`, collectiblePhone, deleted.ID, time.Now().UTC()).Scan(&collectiblePhoneID); err != nil {
|
||||
t.Fatalf("insert collectible phone: %v", err)
|
||||
}
|
||||
createdChannel, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: deleted.ID,
|
||||
Title: "Retained deletion membership",
|
||||
Megagroup: true,
|
||||
Date: int(time.Now().Unix()),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create retained channel membership: %v", err)
|
||||
}
|
||||
channelID = createdChannel.Channel.ID
|
||||
var contactVersionBefore, channelParticipantsVersionBefore int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT COALESCE((SELECT version FROM read_model_versions
|
||||
WHERE model = 'contact_account' AND owner_user_id = $1
|
||||
AND peer_type = 'user' AND peer_id = $1), 0)`, peer.ID).Scan(&contactVersionBefore); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT COALESCE((SELECT version FROM read_model_versions
|
||||
WHERE model = 'channel_participants' AND owner_user_id = 0
|
||||
AND peer_type = 'channel' AND peer_id = $1), 0)`, channelID).Scan(&channelParticipantsVersionBefore); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lifecycle := NewAccountLifecycleStore(pool)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
|
|
@ -80,6 +123,15 @@ VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err !=
|
|||
if _, found, err := NewAuthKeyStore(pool).Get(ctx, authTwo); err != nil || !found {
|
||||
t.Fatalf("other auth key after cancel found=%v err=%v, want retained", found, err)
|
||||
}
|
||||
digestTwo := sha256.Sum256([]byte("confirm-two"))
|
||||
pendingBeforeDelete, created, err := lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{
|
||||
UserID: deleted.ID, RequesterAuthKeyID: authTwo, Reason: "Delete account",
|
||||
ConfirmHashDigest: digestTwo, ServiceMessage: "tg://confirmphone?phone=hidden&hash=confirm-two",
|
||||
RequestedAt: now.Add(time.Minute), ExecuteAt: now.Add(7 * 24 * time.Hour),
|
||||
})
|
||||
if err != nil || !created {
|
||||
t.Fatalf("schedule deletion before tombstone = %+v created=%v err=%v", pendingBeforeDelete, created, err)
|
||||
}
|
||||
|
||||
result, err := lifecycle.ExecuteAccountDeletion(ctx, deleted.ID, domain.AccountDeletionManual, "manual", now.Add(2*time.Minute))
|
||||
if err != nil {
|
||||
|
|
@ -91,9 +143,48 @@ VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err !=
|
|||
if _, found, err := users.ByPhone(ctx, deleted.Phone); err != nil || found {
|
||||
t.Fatalf("released phone found=%v err=%v", found, err)
|
||||
}
|
||||
if _, found, err := users.ByUsername(ctx, deletedUsername); err != nil || found {
|
||||
t.Fatalf("released username found=%v err=%v", found, err)
|
||||
}
|
||||
if tombstone, found, err := users.ByID(ctx, deleted.ID); err != nil || !found || !tombstone.Deleted || tombstone.FirstName != "" {
|
||||
t.Fatalf("tombstone = %+v found=%v err=%v", tombstone, found, err)
|
||||
}
|
||||
if _, found, err := NewAuthorizationStore(pool).ByAuthKey(ctx, authTwo); err != nil || found {
|
||||
t.Fatalf("authorization after tombstone found=%v err=%v, want revoked", found, err)
|
||||
}
|
||||
if _, found, err := NewAuthKeyStore(pool).Get(ctx, authTwo); err != nil || !found {
|
||||
t.Fatalf("permanent protocol auth key after tombstone found=%v err=%v, want retained", found, err)
|
||||
}
|
||||
var requestState string
|
||||
if err := pool.QueryRow(ctx, `SELECT state FROM account_deletion_requests WHERE id = $1`, pendingBeforeDelete.ID).Scan(&requestState); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requestState != "executed" {
|
||||
t.Fatalf("pending request state after tombstone = %q, want executed", requestState)
|
||||
}
|
||||
var deletedVersion, contactVersionAfter, channelParticipantsVersionAfter int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT version FROM read_model_versions
|
||||
WHERE model = 'user_deleted' AND owner_user_id = $1
|
||||
AND peer_type = 'user' AND peer_id = $1`, deleted.ID).Scan(&deletedVersion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT COALESCE((SELECT version FROM read_model_versions
|
||||
WHERE model = 'contact_account' AND owner_user_id = $1
|
||||
AND peer_type = 'user' AND peer_id = $1), 0)`, peer.ID).Scan(&contactVersionAfter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT COALESCE((SELECT version FROM read_model_versions
|
||||
WHERE model = 'channel_participants' AND owner_user_id = 0
|
||||
AND peer_type = 'channel' AND peer_id = $1), 0)`, channelID).Scan(&channelParticipantsVersionAfter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deletedVersion < 1 || contactVersionAfter != contactVersionBefore || channelParticipantsVersionAfter != channelParticipantsVersionBefore {
|
||||
t.Fatalf("logical-delete read-model fanout deleted=%d contact=%d->%d channel=%d->%d",
|
||||
deletedVersion, contactVersionBefore, contactVersionAfter, channelParticipantsVersionBefore, channelParticipantsVersionAfter)
|
||||
}
|
||||
if _, err := users.UpdateProfile(ctx, deleted.ID, "Resurrected", "", ""); err == nil {
|
||||
t.Fatal("deleted account profile mutation unexpectedly succeeded")
|
||||
}
|
||||
|
|
@ -105,7 +196,10 @@ VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err !=
|
|||
if err != nil || len(history.Messages) != 1 || history.Messages[0].Body != "keep shared history" || history.Messages[0].From.ID != deleted.ID {
|
||||
t.Fatalf("peer history after deletion = %+v err=%v", history, err)
|
||||
}
|
||||
var peerBoxes, settings, contacts, notifications int
|
||||
var ownerBoxes, peerBoxes, settings, contacts, notifications int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE owner_user_id = $1`, deleted.ID).Scan(&ownerBoxes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE owner_user_id = $1 AND from_user_id = $2`, peer.ID, deleted.ID).Scan(&peerBoxes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -118,8 +212,13 @@ VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err !=
|
|||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM account_deletion_notifications WHERE target_user_id = $1 AND deleted_user_id = $2`, peer.ID, deleted.ID).Scan(¬ifications); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if peerBoxes != 1 || settings != 0 || contacts != 0 || notifications != 1 {
|
||||
t.Fatalf("post-delete state peerBoxes=%d settings=%d contacts=%d notifications=%d", peerBoxes, settings, contacts, notifications)
|
||||
var memberStatus string
|
||||
if err := pool.QueryRow(ctx, `SELECT status FROM channel_members WHERE channel_id = $1 AND user_id = $2`, channelID, deleted.ID).Scan(&memberStatus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ownerBoxes == 0 || peerBoxes != 1 || settings != 1 || contacts != 1 || notifications != 0 || memberStatus != "active" {
|
||||
t.Fatalf("logical-delete retained state ownerBoxes=%d peerBoxes=%d settings=%d contacts=%d notifications=%d memberStatus=%q",
|
||||
ownerBoxes, peerBoxes, settings, contacts, notifications, memberStatus)
|
||||
}
|
||||
var stars, ton, starClear, tonClear int64
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id = $1`, deleted.ID).Scan(&stars); err != nil {
|
||||
|
|
@ -134,8 +233,16 @@ VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err !=
|
|||
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount_nanoton), 0) FROM ton_transactions WHERE user_id = $1 AND reason = 'account_deleted'`, deleted.ID).Scan(&tonClear); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stars != 0 || ton != 0 || starClear != -50 || tonClear != -100 {
|
||||
t.Fatalf("financial clearing stars=%d ton=%d star_tx=%d ton_tx=%d", stars, ton, starClear, tonClear)
|
||||
if stars != 50 || ton != 100 || starClear != 0 || tonClear != 0 {
|
||||
t.Fatalf("logical-delete retained finances stars=%d ton=%d star_tx=%d ton_tx=%d", stars, ton, starClear, tonClear)
|
||||
}
|
||||
var collectibleStatus string
|
||||
var collectibleOwner int64
|
||||
if err := pool.QueryRow(ctx, `SELECT status, owner_user_id FROM collectible_phones WHERE id = $1`, collectiblePhoneID).Scan(&collectibleStatus, &collectibleOwner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if collectibleStatus != "owned" || collectibleOwner != deleted.ID {
|
||||
t.Fatalf("logical-delete collectible phone status=%q owner=%d, want owned by tombstone %d", collectibleStatus, collectibleOwner, deleted.ID)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ func TestAccountSettingsRoundTripPostgres(t *testing.T) {
|
|||
HideReadMarks: true,
|
||||
DisplayGiftsButton: true,
|
||||
NoncontactPeersPaidStars: 75,
|
||||
DisallowedGifts: domain.DisallowedGifts{
|
||||
UnlimitedStargifts: true,
|
||||
PremiumGifts: true,
|
||||
},
|
||||
},
|
||||
AccountTTLDays: 30,
|
||||
SensitiveContentEnabled: true,
|
||||
|
|
|
|||
379
internal/store/postgres/active_channel_ids_batch.go
Normal file
379
internal/store/postgres/active_channel_ids_batch.go
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestChannelStoreListActiveChannelIDPagesPreservesSelectorOrdinality(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
owner, err := users.Create(ctx, domain.User{AccessHash: 901, Phone: "+1887" + suffix + "01", FirstName: "BatchOwner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
member, err := users.Create(ctx, domain.User{AccessHash: 902, Phone: "+1887" + suffix + "02", FirstName: "BatchMember"})
|
||||
if err != nil {
|
||||
t.Fatalf("create member: %v", err)
|
||||
}
|
||||
channels := NewChannelStore(pool)
|
||||
first, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID, Title: "Batch First " + suffix, Megagroup: true,
|
||||
MemberUserIDs: []int64{member.ID}, Date: 1700007010,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create first channel: %v", err)
|
||||
}
|
||||
second, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID, Title: "Batch Second " + suffix, Megagroup: true, Date: 1700007011,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create second channel: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", []int64{first.Channel.ID, second.Channel.ID})
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID})
|
||||
})
|
||||
selectors := []activeChannelIDsSelector{
|
||||
{userID: owner.ID, afterChannelID: 0, limit: 1},
|
||||
{userID: member.ID, afterChannelID: 0, limit: 1000},
|
||||
{userID: owner.ID, afterChannelID: first.Channel.ID, limit: 1000},
|
||||
}
|
||||
pages, err := channels.listActiveChannelIDPages(ctx, selectors)
|
||||
if err != nil {
|
||||
t.Fatalf("list batch: %v", err)
|
||||
}
|
||||
for index, selector := range selectors {
|
||||
want, err := channels.ListActiveChannelIDsForUser(ctx, selector.userID, selector.afterChannelID, selector.limit)
|
||||
if err != nil {
|
||||
t.Fatalf("list direct selector %d: %v", index, err)
|
||||
}
|
||||
if !slices.Equal(pages[index], want) {
|
||||
t.Fatalf("page %d = %v, want %v", index, pages[index], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
148
internal/store/postgres/active_channel_ids_batch_test.go
Normal file
148
internal/store/postgres/active_channel_ids_batch_test.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSelectDistinctActiveChannelIDsBatchDefersDuplicate(t *testing.T) {
|
||||
selector := activeChannelIDsSelector{userID: 1, limit: 1000}
|
||||
first := activeChannelIDsBatchRequest{selector: selector}
|
||||
duplicate := activeChannelIDsBatchRequest{selector: selector}
|
||||
other := activeChannelIDsBatchRequest{selector: activeChannelIDsSelector{userID: 2, limit: 1000}}
|
||||
batch, remaining := selectDistinctActiveChannelIDsBatch([]activeChannelIDsBatchRequest{first, duplicate, other}, 3)
|
||||
if len(batch) != 2 || batch[0].selector.userID != 1 || batch[1].selector.userID != 2 {
|
||||
t.Fatalf("batch = %#v", batch)
|
||||
}
|
||||
if len(remaining) != 1 || remaining[0].selector != selector {
|
||||
t.Fatalf("remaining = %#v", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveChannelIDsPageBatcherCoalescesSelectors(t *testing.T) {
|
||||
const count = 32
|
||||
backend := &fakeActiveChannelIDsBatchBackend{}
|
||||
batcher, err := newActiveChannelIDsPageBatcher(backend, ActiveChannelIDsBatchConfig{
|
||||
MaxSize: count, MaxWait: 100 * time.Millisecond, QueueSize: count * 2, QueryTimeout: time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(batcher.Close)
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, count)
|
||||
var wg sync.WaitGroup
|
||||
for index := range count {
|
||||
index := index
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
got, err := batcher.ListActiveChannelIDsForUser(context.Background(), int64(index+1), 0, 1000)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
} else if !slices.Equal(got, []int64{int64(index + 1)}) {
|
||||
errs <- errors.New("unexpected active channel IDs page")
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if backend.calls.Load() != 1 || backend.inputs.Load() != count {
|
||||
t.Fatalf("backend calls=%d inputs=%d", backend.calls.Load(), backend.inputs.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveChannelIDsPageBatcherCapacityAndShutdownAreExplicit(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
backend := &fakeActiveChannelIDsBatchBackend{started: started, block: true}
|
||||
metrics := &fakeActiveChannelIDsBatchMetrics{}
|
||||
batcher, err := newActiveChannelIDsPageBatcher(backend, ActiveChannelIDsBatchConfig{
|
||||
MaxSize: 1, MaxWait: time.Millisecond, QueueSize: 1, QueryTimeout: time.Second, Metrics: metrics,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results := make(chan error, 2)
|
||||
go func() {
|
||||
_, err := batcher.ListActiveChannelIDsForUser(context.Background(), 1, 0, 1000)
|
||||
results <- err
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("first batch did not start")
|
||||
}
|
||||
go func() {
|
||||
_, err := batcher.ListActiveChannelIDsForUser(context.Background(), 2, 0, 1000)
|
||||
results <- err
|
||||
}()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for metrics.pending.Load() != 2 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := batcher.ListActiveChannelIDsForUser(ctx, 3, 0, 1000); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("capacity wait err = %v", err)
|
||||
}
|
||||
batcher.Close()
|
||||
for range 2 {
|
||||
if err := <-results; !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("shutdown result = %v", err)
|
||||
}
|
||||
}
|
||||
if metrics.pending.Load() != 0 {
|
||||
t.Fatalf("pending = %d", metrics.pending.Load())
|
||||
}
|
||||
if _, err := batcher.ListActiveChannelIDsForUser(context.Background(), 4, 0, 1000); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("post-close err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeActiveChannelIDsBatchBackend struct {
|
||||
calls atomic.Int64
|
||||
inputs atomic.Int64
|
||||
started chan struct{}
|
||||
block bool
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (f *fakeActiveChannelIDsBatchBackend) listActiveChannelIDPages(
|
||||
ctx context.Context,
|
||||
selectors []activeChannelIDsSelector,
|
||||
) ([][]int64, error) {
|
||||
f.calls.Add(1)
|
||||
f.inputs.Add(int64(len(selectors)))
|
||||
if f.started != nil {
|
||||
f.once.Do(func() { close(f.started) })
|
||||
}
|
||||
if f.block {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
pages := make([][]int64, len(selectors))
|
||||
for index, selector := range selectors {
|
||||
pages[index] = []int64{selector.userID}
|
||||
}
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
type fakeActiveChannelIDsBatchMetrics struct {
|
||||
pending atomic.Int64
|
||||
}
|
||||
|
||||
func (*fakeActiveChannelIDsBatchMetrics) ActiveChannelIDsBatch(int, int, time.Duration, error) {}
|
||||
|
||||
func (m *fakeActiveChannelIDsBatchMetrics) ActiveChannelIDsPending(delta int) {
|
||||
m.pending.Add(int64(delta))
|
||||
}
|
||||
|
|
@ -39,22 +39,15 @@ func TestAuthIdentitySelectorRetriesUncommittedFirstBindSnapshotPostgres(t *test
|
|||
if err := advanceConn.QueryRow(ctx, `SELECT pg_backend_pid()`).Scan(&advancePID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
barrier := newAuthStoreQueryBarrier(advanceConn, "auth_identity_hint", "")
|
||||
msgID := authKeySessionLayerTestMsgID(time.Now().UTC(), 1)
|
||||
type advanceResult struct {
|
||||
value store.AuthKeySessionLayer
|
||||
applied bool
|
||||
err error
|
||||
}
|
||||
result := make(chan advanceResult, 1)
|
||||
go func() {
|
||||
value, applied, err := NewAuthKeyStore(barrier).AdvanceSessionLayer(ctx, temp, 8703, 227, msgID)
|
||||
result <- advanceResult{value: value, applied: applied, err: err}
|
||||
}()
|
||||
<-barrier.observed
|
||||
|
||||
// The selector already read "unbound". Stage a committed binding behind
|
||||
// its statement snapshot while retaining P/raw row locks in the outer tx.
|
||||
// Stage the first binding but do not commit it. Save holds the permanent
|
||||
// identity gate and raw row, so the selector sees the old unbound hint and
|
||||
// then waits on the raw row inside the server-side advance function.
|
||||
bindTx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -63,7 +56,11 @@ func TestAuthIdentitySelectorRetriesUncommittedFirstBindSnapshotPostgres(t *test
|
|||
if err := NewTempAuthKeyBindingStore(bindTx).Save(ctx, binding); err != nil {
|
||||
t.Fatalf("stage first bind: %v", err)
|
||||
}
|
||||
close(barrier.release)
|
||||
result := make(chan advanceResult, 1)
|
||||
go func() {
|
||||
value, applied, err := NewAuthKeyStore(advanceConn).AdvanceSessionLayer(ctx, temp, 8703, 227, msgID)
|
||||
result <- advanceResult{value: value, applied: applied, err: err}
|
||||
}()
|
||||
waitForPostgresBackendLockWait(t, ctx, pool, advancePID)
|
||||
if err := bindTx.Commit(ctx); err != nil {
|
||||
t.Fatalf("commit first bind: %v", err)
|
||||
|
|
@ -166,11 +163,22 @@ func TestAuthIdentitySelectorSerializesWithPermanentRevocationAndDeletePostgres(
|
|||
if err := <-opResult; err != nil {
|
||||
t.Fatalf("%s error = %v", op, err)
|
||||
}
|
||||
assertRevokeTestNoAuthorization(t, ctx, auths, perm)
|
||||
if op == "revoke" {
|
||||
// Remote authorization revocation deliberately preserves protocol
|
||||
// keys and their binding so reconnect reaches the RPC authorization
|
||||
// gate and receives AUTH_KEY_UNREGISTERED rather than transport -404.
|
||||
assertRevokeTestPresentAuthKey(t, ctx, keys, temp)
|
||||
assertRevokeTestPresentAuthKey(t, ctx, keys, perm)
|
||||
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || !found {
|
||||
t.Fatalf("binding after revoke found=%v err=%v, want present", found, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
assertTempIdentityAuthKeyMissing(t, ctx, keys, temp)
|
||||
assertTempIdentityAuthKeyMissing(t, ctx, keys, perm)
|
||||
assertRevokeTestNoAuthorization(t, ctx, auths, perm)
|
||||
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || found {
|
||||
t.Fatalf("binding after %s found=%v err=%v", op, found, err)
|
||||
t.Fatalf("binding after delete found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -446,7 +446,7 @@ INSERT INTO public.secret_chats (
|
|||
) VALUES (
|
||||
860086, 86, 87,
|
||||
$1, $2, $3, $4,
|
||||
'waiting', 86, 1
|
||||
'waiting', 860086, 1
|
||||
)`, adminUserID, adminAuthKeyID, participantUserID, participantAuthKeyID); err != nil {
|
||||
t.Fatalf("insert temporary-key secret chat fixture: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,11 +54,104 @@ WHERE auth_keys.body = EXCLUDED.body
|
|||
// 完成,GC 的 cutoff/final predicate 会看到新水位并跳过。这样连接不会在“读到旧 key、尚未
|
||||
// 注册进 SessionManager”的窗口被后台清理。
|
||||
func (s *AuthKeyStore) Get(ctx context.Context, id [8]byte) (store.AuthKeyData, bool, error) {
|
||||
data, err := scanAuthKeyData(s.db.QueryRow(ctx, `
|
||||
UPDATE auth_keys
|
||||
SET last_used_at = now()
|
||||
WHERE auth_key_id = $1
|
||||
RETURNING auth_key_id, body, server_salt, created_at,
|
||||
expires_at, layer, layer_observation_id,
|
||||
device_model, platform, system_version, api_id, app_version
|
||||
`, authKeyIDToInt64(id)))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return store.AuthKeyData{}, false, nil
|
||||
}
|
||||
return store.AuthKeyData{}, false, fmt.Errorf("get auth key: %w", err)
|
||||
}
|
||||
if data.ID != id {
|
||||
return store.AuthKeyData{}, false, fmt.Errorf("get auth key returned id %x, want %x", data.ID, id)
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
// Revalidate reads the immutable key/protocol tuple after an activation claim
|
||||
// is visible. It deliberately does not touch last_used_at: the physical
|
||||
// connection's initial Get already established the orphan lease and the claim
|
||||
// is now the local delete/revoke serialization boundary.
|
||||
func (s *AuthKeyStore) Revalidate(ctx context.Context, id [8]byte) (store.AuthKeyData, bool, error) {
|
||||
data, err := scanAuthKeyData(s.db.QueryRow(ctx, `
|
||||
SELECT auth_key_id, body, server_salt, created_at,
|
||||
expires_at, layer, layer_observation_id,
|
||||
device_model, platform, system_version, api_id, app_version
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = $1
|
||||
`, authKeyIDToInt64(id)))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return store.AuthKeyData{}, false, nil
|
||||
}
|
||||
return store.AuthKeyData{}, false, fmt.Errorf("revalidate auth key: %w", err)
|
||||
}
|
||||
if data.ID != id {
|
||||
return store.AuthKeyData{}, false, fmt.Errorf("revalidate auth key returned id %x, want %x", data.ID, id)
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
// LoadBindingKeys touches and returns both cryptographic proof keys in one
|
||||
// statement. Missing rows remain explicit in the result so the application can
|
||||
// preserve its temp-rotation versus invalid-encrypted-proof error split.
|
||||
func (s *AuthKeyStore) LoadBindingKeys(ctx context.Context, tempID, permID [8]byte) (store.AuthKeyBindingKeys, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
UPDATE auth_keys
|
||||
SET last_used_at = now()
|
||||
WHERE auth_key_id = ANY($1::bigint[])
|
||||
RETURNING auth_key_id, body, server_salt, created_at,
|
||||
expires_at, layer, layer_observation_id,
|
||||
device_model, platform, system_version, api_id, app_version
|
||||
`, []int64{authKeyIDToInt64(tempID), authKeyIDToInt64(permID)})
|
||||
if err != nil {
|
||||
return store.AuthKeyBindingKeys{}, fmt.Errorf("load auth key binding pair: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var result store.AuthKeyBindingKeys
|
||||
for rows.Next() {
|
||||
data, scanErr := scanAuthKeyData(rows)
|
||||
if scanErr != nil {
|
||||
return store.AuthKeyBindingKeys{}, fmt.Errorf("scan auth key binding pair: %w", scanErr)
|
||||
}
|
||||
switch data.ID {
|
||||
case tempID:
|
||||
result.Temporary = data
|
||||
result.TemporaryFound = true
|
||||
case permID:
|
||||
result.Permanent = data
|
||||
result.PermanentFound = true
|
||||
default:
|
||||
return store.AuthKeyBindingKeys{}, fmt.Errorf("load auth key binding pair returned unexpected id %x", data.ID)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return store.AuthKeyBindingKeys{}, fmt.Errorf("iterate auth key binding pair: %w", err)
|
||||
}
|
||||
if tempID == permID && result.TemporaryFound {
|
||||
result.Permanent = result.Temporary
|
||||
result.PermanentFound = true
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type authKeyDataScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanAuthKeyData(row authKeyDataScanner) (store.AuthKeyData, error) {
|
||||
var (
|
||||
storedID int64
|
||||
body []byte
|
||||
serverSalt int64
|
||||
expiresAt int
|
||||
createdAt pgtype.Timestamptz
|
||||
expiresAt int
|
||||
layer int
|
||||
layerObservationID int64
|
||||
deviceModel string
|
||||
|
|
@ -67,25 +160,18 @@ func (s *AuthKeyStore) Get(ctx context.Context, id [8]byte) (store.AuthKeyData,
|
|||
apiID int
|
||||
appVersion string
|
||||
)
|
||||
err := s.db.QueryRow(ctx, `
|
||||
UPDATE auth_keys
|
||||
SET last_used_at = now()
|
||||
WHERE auth_key_id = $1
|
||||
RETURNING auth_key_id, body, server_salt, created_at,
|
||||
expires_at, layer, layer_observation_id,
|
||||
device_model, platform, system_version, api_id, app_version
|
||||
`, authKeyIDToInt64(id)).Scan(new(int64), &body, &serverSalt, &createdAt, &expiresAt, &layer, &layerObservationID, &deviceModel, &platform, &systemVersion, &apiID, &appVersion)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return store.AuthKeyData{}, false, nil
|
||||
}
|
||||
return store.AuthKeyData{}, false, fmt.Errorf("get auth key: %w", err)
|
||||
if err := row.Scan(
|
||||
&storedID, &body, &serverSalt, &createdAt,
|
||||
&expiresAt, &layer, &layerObservationID,
|
||||
&deviceModel, &platform, &systemVersion, &apiID, &appVersion,
|
||||
); err != nil {
|
||||
return store.AuthKeyData{}, err
|
||||
}
|
||||
if len(body) != len(store.AuthKeyData{}.Value) {
|
||||
return store.AuthKeyData{}, false, fmt.Errorf("auth key body length = %d, want 256", len(body))
|
||||
return store.AuthKeyData{}, fmt.Errorf("auth key body length = %d, want 256", len(body))
|
||||
}
|
||||
data := store.AuthKeyData{
|
||||
ID: id,
|
||||
ID: authKeyIDFromInt64(storedID),
|
||||
ServerSalt: serverSalt,
|
||||
ExpiresAt: expiresAt,
|
||||
Layer: layer,
|
||||
|
|
@ -100,7 +186,7 @@ RETURNING auth_key_id, body, server_salt, created_at,
|
|||
if createdAt.Valid {
|
||||
data.CreatedAt = createdAt.Time.Unix()
|
||||
}
|
||||
return data, true, nil
|
||||
return data, nil
|
||||
}
|
||||
|
||||
const activeAuthKeyHeartbeatBatch = 4096
|
||||
|
|
|
|||
344
internal/store/postgres/authkey_get_batch.go
Normal file
344
internal/store/postgres/authkey_get_batch.go
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// AuthKeyGetBatchConfig bounds the synchronous first-frame auth-key lookup.
|
||||
// Every accepted Get waits until its durable last_used_at touch has completed;
|
||||
// this is not an asynchronous activity update.
|
||||
type AuthKeyGetBatchConfig struct {
|
||||
MaxSize int
|
||||
MaxWait time.Duration
|
||||
QueueSize int
|
||||
QueryTimeout time.Duration
|
||||
}
|
||||
|
||||
type authKeyGetBatchRequest struct {
|
||||
ctx context.Context
|
||||
id [8]byte
|
||||
result chan authKeyGetBatchResult
|
||||
}
|
||||
|
||||
type authKeyGetBatchResult struct {
|
||||
data store.AuthKeyData
|
||||
found bool
|
||||
err error
|
||||
}
|
||||
|
||||
// BatchedAuthKeyStore preserves store.AuthKeyStore semantics while combining
|
||||
// contemporaneous first-frame Get calls into one PostgreSQL UPDATE ...
|
||||
// RETURNING statement. Save/revalidate/bind/client-info/delete remain direct
|
||||
// authority operations on the base store.
|
||||
type BatchedAuthKeyStore struct {
|
||||
base *AuthKeyStore
|
||||
cfg AuthKeyGetBatchConfig
|
||||
|
||||
touchQueue chan authKeyGetBatchRequest
|
||||
revalidateQueue chan authKeyGetBatchRequest
|
||||
stop chan struct{}
|
||||
cancel context.CancelFunc
|
||||
once sync.Once
|
||||
workers sync.WaitGroup
|
||||
gate sync.RWMutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewBatchedAuthKeyStore(base *AuthKeyStore, cfg AuthKeyGetBatchConfig) (*BatchedAuthKeyStore, error) {
|
||||
if base == nil || base.db == nil {
|
||||
return nil, errors.New("initialize auth-key get batcher: nil store")
|
||||
}
|
||||
if cfg.MaxSize <= 0 || cfg.MaxSize > 4096 {
|
||||
return nil, fmt.Errorf("initialize auth-key get batcher: max size %d outside [1,4096]", cfg.MaxSize)
|
||||
}
|
||||
if cfg.MaxWait <= 0 || cfg.MaxWait > 10*time.Millisecond {
|
||||
return nil, fmt.Errorf("initialize auth-key get batcher: max wait %v outside (0,10ms]", cfg.MaxWait)
|
||||
}
|
||||
if cfg.QueueSize < cfg.MaxSize || cfg.QueueSize > 1<<20 {
|
||||
return nil, fmt.Errorf("initialize auth-key get 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 auth-key get batcher: query timeout %v outside (0,30s]", cfg.QueryTimeout)
|
||||
}
|
||||
workerCtx, cancel := context.WithCancel(context.Background())
|
||||
s := &BatchedAuthKeyStore{
|
||||
base: base, cfg: cfg,
|
||||
touchQueue: make(chan authKeyGetBatchRequest, cfg.QueueSize),
|
||||
revalidateQueue: make(chan authKeyGetBatchRequest, cfg.QueueSize),
|
||||
stop: make(chan struct{}), cancel: cancel,
|
||||
}
|
||||
s.workers.Add(2)
|
||||
go s.run(workerCtx, s.touchQueue, true)
|
||||
go s.run(workerCtx, s.revalidateQueue, false)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *BatchedAuthKeyStore) Save(ctx context.Context, key store.AuthKeyData) error {
|
||||
return s.base.Save(ctx, key)
|
||||
}
|
||||
|
||||
func (s *BatchedAuthKeyStore) Get(ctx context.Context, id [8]byte) (store.AuthKeyData, bool, error) {
|
||||
return s.lookup(ctx, id, s.touchQueue, true)
|
||||
}
|
||||
|
||||
func (s *BatchedAuthKeyStore) lookup(
|
||||
ctx context.Context,
|
||||
id [8]byte,
|
||||
queue chan authKeyGetBatchRequest,
|
||||
waitDefinitive bool,
|
||||
) (store.AuthKeyData, bool, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
request := authKeyGetBatchRequest{ctx: ctx, id: id, result: make(chan authKeyGetBatchResult, 1)}
|
||||
s.gate.RLock()
|
||||
if s.closed {
|
||||
s.gate.RUnlock()
|
||||
return store.AuthKeyData{}, false, context.Canceled
|
||||
}
|
||||
select {
|
||||
case queue <- request:
|
||||
case <-ctx.Done():
|
||||
s.gate.RUnlock()
|
||||
return store.AuthKeyData{}, false, ctx.Err()
|
||||
}
|
||||
s.gate.RUnlock()
|
||||
|
||||
if waitDefinitive {
|
||||
// Get owns a durable activity touch. Once admitted, wait for its
|
||||
// definitive result even if the transport context is canceled, so a
|
||||
// submitted write is never left as unobserved best effort.
|
||||
result := <-request.result
|
||||
return result.data, result.found, result.err
|
||||
}
|
||||
select {
|
||||
case result := <-request.result:
|
||||
return result.data, result.found, result.err
|
||||
case <-ctx.Done():
|
||||
return store.AuthKeyData{}, false, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BatchedAuthKeyStore) Revalidate(ctx context.Context, id [8]byte) (store.AuthKeyData, bool, error) {
|
||||
return s.lookup(ctx, id, s.revalidateQueue, false)
|
||||
}
|
||||
|
||||
func (s *BatchedAuthKeyStore) LoadBindingKeys(ctx context.Context, tempID, permID [8]byte) (store.AuthKeyBindingKeys, error) {
|
||||
return s.base.LoadBindingKeys(ctx, tempID, permID)
|
||||
}
|
||||
|
||||
func (s *BatchedAuthKeyStore) UpdateClientInfo(ctx context.Context, id [8]byte, info store.AuthKeyClientInfo) error {
|
||||
return s.base.UpdateClientInfo(ctx, id, info)
|
||||
}
|
||||
|
||||
func (s *BatchedAuthKeyStore) Delete(ctx context.Context, id [8]byte) error {
|
||||
return s.base.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (s *BatchedAuthKeyStore) Close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.once.Do(func() {
|
||||
s.gate.Lock()
|
||||
s.closed = true
|
||||
close(s.stop)
|
||||
s.cancel()
|
||||
s.gate.Unlock()
|
||||
s.workers.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BatchedAuthKeyStore) run(
|
||||
ctx context.Context,
|
||||
queue chan authKeyGetBatchRequest,
|
||||
touch bool,
|
||||
) {
|
||||
defer s.workers.Done()
|
||||
pending := make([]authKeyGetBatchRequest, 0, s.cfg.MaxSize)
|
||||
for {
|
||||
if len(pending) == 0 {
|
||||
select {
|
||||
case request := <-queue:
|
||||
pending = append(pending, request)
|
||||
case <-s.stop:
|
||||
failAuthKeyGetQueued(queue, context.Canceled, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(pending) < s.cfg.MaxSize {
|
||||
timer := time.NewTimer(s.cfg.MaxWait)
|
||||
collect:
|
||||
for len(pending) < s.cfg.MaxSize {
|
||||
select {
|
||||
case request := <-queue:
|
||||
pending = append(pending, request)
|
||||
case <-timer.C:
|
||||
break collect
|
||||
case <-s.stop:
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
failAuthKeyGetQueued(queue, context.Canceled, pending)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
batch := append([]authKeyGetBatchRequest(nil), pending...)
|
||||
pending = pending[:0]
|
||||
s.execute(ctx, batch, touch)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BatchedAuthKeyStore) execute(ctx context.Context, batch []authKeyGetBatchRequest, touch bool) {
|
||||
active := batch[:0]
|
||||
ids := make([][8]byte, 0, len(batch))
|
||||
seen := make(map[[8]byte]struct{}, len(batch))
|
||||
for _, request := range batch {
|
||||
if err := request.ctx.Err(); err != nil {
|
||||
request.result <- authKeyGetBatchResult{err: err}
|
||||
continue
|
||||
}
|
||||
active = append(active, request)
|
||||
if _, duplicate := seen[request.id]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[request.id] = struct{}{}
|
||||
ids = append(ids, request.id)
|
||||
}
|
||||
if len(active) == 0 {
|
||||
return
|
||||
}
|
||||
queryCtx, cancel := context.WithTimeout(ctx, s.cfg.QueryTimeout)
|
||||
var (
|
||||
loaded map[[8]byte]store.AuthKeyData
|
||||
err error
|
||||
)
|
||||
if touch {
|
||||
loaded, err = s.base.getManyAndTouch(queryCtx, ids)
|
||||
} else {
|
||||
loaded, err = s.base.getMany(queryCtx, ids)
|
||||
}
|
||||
cancel()
|
||||
if err != nil {
|
||||
for _, request := range active {
|
||||
request.result <- authKeyGetBatchResult{err: err}
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, request := range active {
|
||||
data, found := loaded[request.id]
|
||||
request.result <- authKeyGetBatchResult{data: data, found: found}
|
||||
}
|
||||
}
|
||||
|
||||
func failAuthKeyGetQueued(queue chan authKeyGetBatchRequest, err error, pending []authKeyGetBatchRequest) {
|
||||
for _, request := range pending {
|
||||
request.result <- authKeyGetBatchResult{err: err}
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case request := <-queue:
|
||||
request.result <- authKeyGetBatchResult{err: err}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) getManyAndTouch(ctx context.Context, ids [][8]byte) (map[[8]byte]store.AuthKeyData, error) {
|
||||
if len(ids) == 0 {
|
||||
return map[[8]byte]store.AuthKeyData{}, nil
|
||||
}
|
||||
keyIDs, requested := authKeyBatchIDs(ids)
|
||||
rows, err := s.db.Query(ctx, `
|
||||
/* auth_key_get_batch */
|
||||
UPDATE auth_keys
|
||||
SET last_used_at = now()
|
||||
WHERE auth_key_id = ANY($1::bigint[])
|
||||
RETURNING auth_key_id, body, server_salt, created_at,
|
||||
expires_at, layer, layer_observation_id,
|
||||
device_model, platform, system_version, api_id, app_version
|
||||
`, keyIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("batch get auth keys: %w", err)
|
||||
}
|
||||
return scanAuthKeyBatch(rows, requested, "batched auth key")
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) getMany(ctx context.Context, ids [][8]byte) (map[[8]byte]store.AuthKeyData, error) {
|
||||
if len(ids) == 0 {
|
||||
return map[[8]byte]store.AuthKeyData{}, nil
|
||||
}
|
||||
keyIDs, requested := authKeyBatchIDs(ids)
|
||||
rows, err := s.db.Query(ctx, `
|
||||
/* auth_key_revalidate_batch */
|
||||
SELECT auth_key_id, body, server_salt, created_at,
|
||||
expires_at, layer, layer_observation_id,
|
||||
device_model, platform, system_version, api_id, app_version
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = ANY($1::bigint[])
|
||||
`, keyIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("batch revalidate auth keys: %w", err)
|
||||
}
|
||||
return scanAuthKeyBatch(rows, requested, "revalidated auth key")
|
||||
}
|
||||
|
||||
func authKeyBatchIDs(ids [][8]byte) ([]int64, map[[8]byte]struct{}) {
|
||||
keyIDs := make([]int64, 0, len(ids))
|
||||
requested := make(map[[8]byte]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if _, duplicate := requested[id]; duplicate {
|
||||
continue
|
||||
}
|
||||
requested[id] = struct{}{}
|
||||
keyIDs = append(keyIDs, authKeyIDToInt64(id))
|
||||
}
|
||||
return keyIDs, requested
|
||||
}
|
||||
|
||||
func scanAuthKeyBatch(
|
||||
rows interface {
|
||||
Next() bool
|
||||
Scan(...any) error
|
||||
Err() error
|
||||
Close()
|
||||
},
|
||||
requested map[[8]byte]struct{},
|
||||
operation string,
|
||||
) (map[[8]byte]store.AuthKeyData, error) {
|
||||
defer rows.Close()
|
||||
out := make(map[[8]byte]store.AuthKeyData, len(requested))
|
||||
for rows.Next() {
|
||||
data, scanErr := scanAuthKeyData(rows)
|
||||
if scanErr != nil {
|
||||
return nil, fmt.Errorf("scan %s: %w", operation, scanErr)
|
||||
}
|
||||
if _, expected := requested[data.ID]; !expected {
|
||||
return nil, fmt.Errorf("%s returned unexpected id %x", operation, data.ID)
|
||||
}
|
||||
out[data.ID] = data
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate %s: %w", operation, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var _ store.AuthKeyStore = (*BatchedAuthKeyStore)(nil)
|
||||
189
internal/store/postgres/authkey_get_batch_test.go
Normal file
189
internal/store/postgres/authkey_get_batch_test.go
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
func TestBatchedAuthKeyStorePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
const keyCount = 32
|
||||
keys := NewAuthKeyStore(pool)
|
||||
ids := make([][8]byte, 0, keyCount)
|
||||
old := time.Now().Add(-time.Hour)
|
||||
for index := 0; index < keyCount; index++ {
|
||||
id := randomLayerTestAuthKeyID(t)
|
||||
data := store.AuthKeyData{ID: id, ServerSalt: int64(index + 1)}
|
||||
data.Value[0] = byte(index + 1)
|
||||
if err := keys.Save(ctx, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE auth_keys SET last_used_at = $2 WHERE auth_key_id = $1`, authKeyIDToInt64(id), old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, id := range ids {
|
||||
_ = keys.Delete(ctx, id)
|
||||
}
|
||||
})
|
||||
|
||||
counted := &authKeyGetCountingDB{db: pool}
|
||||
batcher, err := NewBatchedAuthKeyStore(NewAuthKeyStore(counted), AuthKeyGetBatchConfig{
|
||||
MaxSize: keyCount, MaxWait: 10 * time.Millisecond,
|
||||
QueueSize: keyCount * 2, QueryTimeout: 5 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(batcher.Close)
|
||||
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, keyCount)
|
||||
var wg sync.WaitGroup
|
||||
for index, id := range ids {
|
||||
index, id := index, id
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
data, found, getErr := batcher.Get(ctx, id)
|
||||
if getErr != nil {
|
||||
errs <- getErr
|
||||
return
|
||||
}
|
||||
if !found || data.ID != id || data.ServerSalt != int64(index+1) || data.Value[0] != byte(index+1) {
|
||||
errs <- errors.New("batched auth-key result mismatch")
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls := counted.batchQueries.Load(); calls <= 0 || calls > 4 {
|
||||
t.Fatalf("batch SQL calls = %d, want 1..4 for %d concurrent keys", calls, keyCount)
|
||||
}
|
||||
for _, id := range ids {
|
||||
var touched time.Time
|
||||
if err := pool.QueryRow(ctx, `SELECT last_used_at FROM auth_keys WHERE auth_key_id = $1`, authKeyIDToInt64(id)).Scan(&touched); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !touched.After(old) {
|
||||
t.Fatalf("auth key %x was not touched: %v", id, touched)
|
||||
}
|
||||
}
|
||||
|
||||
readMarker := time.Now().Add(-2 * time.Hour).Truncate(time.Microsecond)
|
||||
for _, id := range ids {
|
||||
if _, err := pool.Exec(ctx, `UPDATE auth_keys SET last_used_at = $2 WHERE auth_key_id = $1`, authKeyIDToInt64(id), readMarker); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
errs = make(chan error, keyCount)
|
||||
start = make(chan struct{})
|
||||
for _, id := range ids {
|
||||
id := id
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
data, found, getErr := batcher.Revalidate(ctx, id)
|
||||
if getErr != nil || !found || data.ID != id {
|
||||
errs <- errors.New("batched auth-key revalidate mismatch")
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls := counted.revalidateQueries.Load(); calls <= 0 || calls > 4 {
|
||||
t.Fatalf("revalidate SQL calls = %d, want 1..4 for %d concurrent keys", calls, keyCount)
|
||||
}
|
||||
for _, id := range ids {
|
||||
var lastUsed time.Time
|
||||
if err := pool.QueryRow(ctx, `SELECT last_used_at FROM auth_keys WHERE auth_key_id = $1`, authKeyIDToInt64(id)).Scan(&lastUsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !lastUsed.Equal(readMarker) {
|
||||
t.Fatalf("revalidate touched auth key %x: got %v want %v", id, lastUsed, readMarker)
|
||||
}
|
||||
}
|
||||
|
||||
missing := randomLayerTestAuthKeyID(t)
|
||||
if _, found, err := batcher.Get(ctx, missing); err != nil || found {
|
||||
t.Fatalf("missing Get = found %v err %v", found, err)
|
||||
}
|
||||
batcher.Close()
|
||||
if _, _, err := batcher.Get(ctx, ids[0]); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Get after close err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBatchedAuthKeyStoreRejectsInvalidConfig(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
base := NewAuthKeyStore(pool)
|
||||
for _, cfg := range []AuthKeyGetBatchConfig{
|
||||
{},
|
||||
{MaxSize: 1, MaxWait: 11 * time.Millisecond, QueueSize: 1, QueryTimeout: time.Second},
|
||||
{MaxSize: 2, MaxWait: time.Microsecond, QueueSize: 1, QueryTimeout: time.Second},
|
||||
{MaxSize: 1, MaxWait: time.Microsecond, QueueSize: 1, QueryTimeout: 31 * time.Second},
|
||||
} {
|
||||
if batcher, err := NewBatchedAuthKeyStore(base, cfg); err == nil {
|
||||
batcher.Close()
|
||||
t.Fatalf("invalid config accepted: %+v", cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type authKeyGetCountingDB struct {
|
||||
db sqlcgen.DBTX
|
||||
batchQueries atomic.Int64
|
||||
revalidateQueries atomic.Int64
|
||||
}
|
||||
|
||||
func (db *authKeyGetCountingDB) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
|
||||
return db.db.Exec(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func (db *authKeyGetCountingDB) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
|
||||
if strings.Contains(sql, "auth_key_get_batch") {
|
||||
db.batchQueries.Add(1)
|
||||
}
|
||||
if strings.Contains(sql, "auth_key_revalidate_batch") {
|
||||
db.revalidateQueries.Add(1)
|
||||
}
|
||||
return db.db.Query(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func (db *authKeyGetCountingDB) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
|
||||
return db.db.QueryRow(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func (db *authKeyGetCountingDB) Begin(ctx context.Context) (pgx.Tx, error) {
|
||||
beginner, ok := db.db.(txBeginner)
|
||||
if !ok {
|
||||
return nil, errors.New("counted database does not support transactions")
|
||||
}
|
||||
return beginner.Begin(ctx)
|
||||
}
|
||||
|
||||
var _ sqlcgen.DBTX = (*authKeyGetCountingDB)(nil)
|
||||
|
|
@ -7,7 +7,10 @@ import (
|
|||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"telesrv/internal/store"
|
||||
|
|
@ -94,6 +97,80 @@ func TestAuthKeyStoreRoundTrip(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAuthKeyStoreSeparatesActivationRevalidationAndBindingPairTouchPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore(pool)
|
||||
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, int(time.Now().Add(time.Hour).Unix()))
|
||||
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
old := time.Now().Add(-48 * time.Hour).UTC().Truncate(time.Microsecond)
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
UPDATE auth_keys SET last_used_at = $2
|
||||
WHERE auth_key_id = ANY($1::bigint[])`,
|
||||
[]int64{authKeyIDToInt64(temp), authKeyIDToInt64(perm)}, old,
|
||||
); err != nil {
|
||||
t.Fatalf("seed old auth-key activity: %v", err)
|
||||
}
|
||||
got, found, err := keys.Revalidate(ctx, temp)
|
||||
if err != nil || !found || got.ID != temp {
|
||||
t.Fatalf("revalidate temp auth key = (%+v,%v,%v)", got, found, err)
|
||||
}
|
||||
var revalidatedAt time.Time
|
||||
if err := pool.QueryRow(ctx, `SELECT last_used_at FROM auth_keys WHERE auth_key_id = $1`, authKeyIDToInt64(temp)).Scan(&revalidatedAt); err != nil {
|
||||
t.Fatalf("read activity after revalidate: %v", err)
|
||||
}
|
||||
if !revalidatedAt.Equal(old) {
|
||||
t.Fatalf("activation revalidate touched last_used_at: got %s want %s", revalidatedAt, old)
|
||||
}
|
||||
|
||||
counter := &authKeyStatementCounter{Pool: pool}
|
||||
pair, err := NewAuthKeyStore(counter).LoadBindingKeys(ctx, temp, perm)
|
||||
if err != nil {
|
||||
t.Fatalf("load binding keys: %v", err)
|
||||
}
|
||||
if counter.statements != 1 {
|
||||
t.Fatalf("binding key load statements = %d, want 1", counter.statements)
|
||||
}
|
||||
if !pair.TemporaryFound || pair.Temporary.ID != temp ||
|
||||
!pair.PermanentFound || pair.Permanent.ID != perm {
|
||||
t.Fatalf("binding key pair = %+v", pair)
|
||||
}
|
||||
var touched int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT count(*)::int
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = ANY($1::bigint[])
|
||||
AND last_used_at > $2`,
|
||||
[]int64{authKeyIDToInt64(temp), authKeyIDToInt64(perm)}, old,
|
||||
).Scan(&touched); err != nil {
|
||||
t.Fatalf("read paired activity: %v", err)
|
||||
}
|
||||
if touched != 2 {
|
||||
t.Fatalf("binding key rows touched = %d, want 2", touched)
|
||||
}
|
||||
}
|
||||
|
||||
type authKeyStatementCounter struct {
|
||||
*pgxpool.Pool
|
||||
statements int
|
||||
}
|
||||
|
||||
func (c *authKeyStatementCounter) Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) {
|
||||
c.statements++
|
||||
return c.Pool.Exec(ctx, sql, arguments...)
|
||||
}
|
||||
|
||||
func (c *authKeyStatementCounter) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
|
||||
c.statements++
|
||||
return c.Pool.Query(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func (c *authKeyStatementCounter) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
|
||||
c.statements++
|
||||
return c.Pool.QueryRow(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func TestAuthKeyStoreClientInfoRoundTrip(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
|
|
|
|||
|
|
@ -66,6 +66,26 @@ func (s *AuthKeyStore) AdvanceSessionLayer(
|
|||
if layer <= 0 || !validMessageID {
|
||||
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeySessionLayerInvalid
|
||||
}
|
||||
current, advanced, err := s.tryAdvanceSessionLayerSameLayer(
|
||||
ctx, authKeyIDToInt64(rawAuthKeyID), sessionID, layer, msgID, expiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, err
|
||||
}
|
||||
if advanced {
|
||||
return current, true, nil
|
||||
}
|
||||
return s.advanceSessionLayerFull(ctx, rawAuthKeyID, sessionID, layer, msgID, expiresAt)
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) advanceSessionLayerFull(
|
||||
ctx context.Context,
|
||||
rawAuthKeyID [8]byte,
|
||||
sessionID int64,
|
||||
layer int,
|
||||
msgID int64,
|
||||
expiresAt time.Time,
|
||||
) (store.AuthKeySessionLayer, bool, error) {
|
||||
var (
|
||||
current store.AuthKeySessionLayer
|
||||
applied bool
|
||||
|
|
@ -83,6 +103,81 @@ func (s *AuthKeyStore) AdvanceSessionLayer(
|
|||
return current, applied, nil
|
||||
}
|
||||
|
||||
// tryAdvanceSessionLayerSameLayer is the common invokeWithLayer path once an
|
||||
// exact session has established its profile generation. It keeps the durable
|
||||
// msg_id high-water mark exact while avoiding the identity gate, observation
|
||||
// allocation and shared-default rewrites that are only needed when the Layer
|
||||
// itself changes. The identity CTE admits only a structurally valid raw/bound
|
||||
// key; every miss falls through to the full locked state machine.
|
||||
func (s *AuthKeyStore) tryAdvanceSessionLayerSameLayer(
|
||||
ctx context.Context,
|
||||
rawID int64,
|
||||
sessionID int64,
|
||||
layer int,
|
||||
msgID int64,
|
||||
expiresAt time.Time,
|
||||
) (store.AuthKeySessionLayer, bool, error) {
|
||||
var current store.AuthKeySessionLayer
|
||||
err := s.db.QueryRow(ctx, `
|
||||
WITH identity AS MATERIALIZED (
|
||||
SELECT raw.auth_key_id,
|
||||
defaults.layer AS default_layer,
|
||||
defaults.layer_observation_id AS default_observation_id
|
||||
FROM auth_keys AS raw
|
||||
LEFT JOIN temp_auth_key_bindings AS binding
|
||||
ON binding.temp_auth_key_id = raw.auth_key_id
|
||||
JOIN auth_keys AS defaults
|
||||
ON defaults.auth_key_id = COALESCE(binding.perm_auth_key_id, raw.auth_key_id)
|
||||
WHERE raw.auth_key_id = $1
|
||||
AND (
|
||||
binding.temp_auth_key_id IS NULL
|
||||
OR (raw.expires_at > 0 AND defaults.expires_at = 0)
|
||||
)
|
||||
), advanced AS (
|
||||
UPDATE auth_key_session_layers AS evidence
|
||||
SET msg_id = $4,
|
||||
expires_at = $5
|
||||
FROM identity
|
||||
WHERE evidence.raw_auth_key_id = $1
|
||||
AND evidence.session_id = $2
|
||||
AND evidence.layer = $3
|
||||
AND evidence.msg_id < $4
|
||||
AND evidence.expires_at > now()
|
||||
AND $3 > 0
|
||||
AND $4 > 0
|
||||
AND $4 % 4 = 0
|
||||
AND ($4 & 4294967295) <> 0
|
||||
AND $5 > now()
|
||||
AND $5 - interval '301 seconds' <= now() + interval '30 seconds'
|
||||
RETURNING evidence.layer,
|
||||
evidence.msg_id,
|
||||
evidence.observation_id,
|
||||
evidence.expires_at
|
||||
)
|
||||
SELECT advanced.layer,
|
||||
advanced.msg_id,
|
||||
advanced.observation_id,
|
||||
advanced.expires_at,
|
||||
identity.default_layer = advanced.layer
|
||||
AND identity.default_observation_id = advanced.observation_id
|
||||
FROM advanced
|
||||
CROSS JOIN identity
|
||||
`, rawID, sessionID, layer, msgID, expiresAt).Scan(
|
||||
¤t.Layer,
|
||||
¤t.MessageID,
|
||||
¤t.ObservationID,
|
||||
¤t.ExpiresAt,
|
||||
¤t.SharedDefault,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return store.AuthKeySessionLayer{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("advance same-Layer auth key session watermark: %w", err)
|
||||
}
|
||||
return current, true, nil
|
||||
}
|
||||
|
||||
func advanceSessionLayerTx(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
|
|
@ -92,109 +187,48 @@ func advanceSessionLayerTx(
|
|||
msgID int64,
|
||||
expiresAt time.Time,
|
||||
) (store.AuthKeySessionLayer, bool, error) {
|
||||
_, permID, _, err := lockRawAuthKeyInIdentityOrder(ctx, tx, rawID)
|
||||
if err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, err
|
||||
}
|
||||
var (
|
||||
status string
|
||||
current store.AuthKeySessionLayer
|
||||
now time.Time
|
||||
applied bool
|
||||
)
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT layer, msg_id, observation_id, expires_at, now()
|
||||
FROM auth_key_session_layers
|
||||
WHERE raw_auth_key_id = $1 AND session_id = $2
|
||||
FOR UPDATE
|
||||
`, rawID, sessionID).Scan(
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT advance_status,
|
||||
current_layer,
|
||||
current_msg_id,
|
||||
current_observation_id,
|
||||
current_expires_at,
|
||||
shared_default,
|
||||
applied
|
||||
FROM public.telesrv_advance_auth_session_layer($1, $2, $3, $4, $5)
|
||||
`, rawID, sessionID, layer, msgID, expiresAt).Scan(
|
||||
&status,
|
||||
¤t.Layer,
|
||||
¤t.MessageID,
|
||||
¤t.ObservationID,
|
||||
¤t.ExpiresAt,
|
||||
&now,
|
||||
¤t.SharedDefault,
|
||||
&applied,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
if err := tx.QueryRow(ctx, `SELECT now()`).Scan(&now); err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("read session layer database time: %w", err)
|
||||
}
|
||||
current = store.AuthKeySessionLayer{}
|
||||
} else if err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("lock auth key session layer: %w", err)
|
||||
if err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("advance auth key session layer: %w", err)
|
||||
}
|
||||
if _, fresh := store.AuthKeySessionLayerEvidenceFresh(now, msgID); !fresh {
|
||||
switch status {
|
||||
case "ok":
|
||||
return current, applied, nil
|
||||
case "identity_changed":
|
||||
return store.AuthKeySessionLayer{}, false, errAuthIdentityChanged
|
||||
case "auth_key_not_found":
|
||||
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeyNotFound
|
||||
case "binding_invalid":
|
||||
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeyBindingInvalid
|
||||
case "evidence_invalid":
|
||||
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeySessionLayerInvalid
|
||||
case "conflict":
|
||||
return current, false, store.ErrAuthKeySessionLayerConflict
|
||||
default:
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("advance auth key session layer: unknown database status %q", status)
|
||||
}
|
||||
if current.MessageID != 0 && now.Before(current.ExpiresAt) {
|
||||
switch {
|
||||
case msgID < current.MessageID:
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT layer = $2 AND layer_observation_id = $3
|
||||
FROM auth_keys WHERE auth_key_id = $1
|
||||
`, permID, current.Layer, current.ObservationID).Scan(¤t.SharedDefault); err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("compare older session layer with shared default: %w", err)
|
||||
}
|
||||
return current, false, nil
|
||||
case msgID == current.MessageID:
|
||||
if layer != current.Layer {
|
||||
return current, false, store.ErrAuthKeySessionLayerConflict
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT layer = $2 AND layer_observation_id = $3
|
||||
FROM auth_keys WHERE auth_key_id = $1
|
||||
`, permID, current.Layer, current.ObservationID).Scan(¤t.SharedDefault); err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("compare duplicate session layer with shared default: %w", err)
|
||||
}
|
||||
return current, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
var observationID int64
|
||||
if err := tx.QueryRow(ctx, `SELECT nextval('auth_key_layer_observation_seq')`).Scan(&observationID); err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("allocate auth key layer observation: %w", err)
|
||||
}
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO auth_key_session_layers (
|
||||
raw_auth_key_id, session_id, layer, msg_id, observation_id, expires_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (raw_auth_key_id, session_id) DO UPDATE SET
|
||||
layer = EXCLUDED.layer,
|
||||
msg_id = EXCLUDED.msg_id,
|
||||
observation_id = EXCLUDED.observation_id,
|
||||
expires_at = EXCLUDED.expires_at
|
||||
RETURNING layer, msg_id, observation_id, expires_at
|
||||
`, rawID, sessionID, layer, msgID, observationID, expiresAt).Scan(
|
||||
¤t.Layer,
|
||||
¤t.MessageID,
|
||||
¤t.ObservationID,
|
||||
¤t.ExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("upsert auth key session layer: %w", err)
|
||||
}
|
||||
keyIDs := []int64{rawID}
|
||||
if permID != rawID {
|
||||
keyIDs = append(keyIDs, permID)
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE auth_keys
|
||||
SET layer = $2, layer_observation_id = $3
|
||||
WHERE auth_key_id = ANY($1::bigint[])
|
||||
AND layer_observation_id < $3
|
||||
`, keyIDs, layer, observationID)
|
||||
if err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("publish auth key session layer defaults: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != int64(len(keyIDs)) {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("publish auth key session layer defaults: updated %d of %d locked keys", tag.RowsAffected(), len(keyIDs))
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE authorizations
|
||||
SET layer = $2
|
||||
WHERE auth_key_id = ANY($1::bigint[])
|
||||
`, keyIDs, layer); err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, fmt.Errorf("mirror auth key session layer defaults: %w", err)
|
||||
}
|
||||
current.SharedDefault = true
|
||||
return current, true, nil
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) DeleteSessionLayer(
|
||||
|
|
|
|||
408
internal/store/postgres/authkey_session_layer_batch.go
Normal file
408
internal/store/postgres/authkey_session_layer_batch.go
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// AuthKeySessionLayerBatchConfig bounds the synchronous cross-session batch.
|
||||
// A batch never contains the same raw auth-key/session identity twice and a
|
||||
// caller does not return until its batch has committed or failed.
|
||||
type AuthKeySessionLayerBatchConfig struct {
|
||||
MaxSize int
|
||||
MaxWait time.Duration
|
||||
QueueSize int
|
||||
QueryTimeout time.Duration
|
||||
}
|
||||
|
||||
type authKeySessionLayerBatchKey struct {
|
||||
rawAuthKeyID [8]byte
|
||||
sessionID int64
|
||||
}
|
||||
|
||||
type authKeySessionLayerBatchRequest struct {
|
||||
ctx context.Context
|
||||
rawAuthKeyID [8]byte
|
||||
sessionID int64
|
||||
layer int
|
||||
msgID int64
|
||||
expiresAt time.Time
|
||||
result chan authKeySessionLayerBatchResult
|
||||
}
|
||||
|
||||
type authKeySessionLayerBatchResult struct {
|
||||
current store.AuthKeySessionLayer
|
||||
fast bool
|
||||
err error
|
||||
}
|
||||
|
||||
// BatchedAuthKeySessionLayerStore preserves AuthKeySessionLayerStore semantics
|
||||
// while combining contemporaneous same-Layer fast attempts for distinct
|
||||
// sessions into one PostgreSQL statement. A miss is resolved synchronously by
|
||||
// the original full identity transaction before the caller returns.
|
||||
type BatchedAuthKeySessionLayerStore struct {
|
||||
base *AuthKeyStore
|
||||
cfg AuthKeySessionLayerBatchConfig
|
||||
queue chan authKeySessionLayerBatchRequest
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
cancel context.CancelFunc
|
||||
once sync.Once
|
||||
gate sync.RWMutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewBatchedAuthKeySessionLayerStore(
|
||||
base *AuthKeyStore,
|
||||
cfg AuthKeySessionLayerBatchConfig,
|
||||
) (*BatchedAuthKeySessionLayerStore, error) {
|
||||
if base == nil || base.db == nil {
|
||||
return nil, errors.New("initialize auth key session Layer batcher: nil store")
|
||||
}
|
||||
if cfg.MaxSize <= 0 || cfg.MaxSize > 4096 {
|
||||
return nil, fmt.Errorf("initialize auth key session Layer batcher: max size %d outside [1,4096]", cfg.MaxSize)
|
||||
}
|
||||
if cfg.MaxWait <= 0 || cfg.MaxWait > 10*time.Millisecond {
|
||||
return nil, fmt.Errorf("initialize auth key session Layer batcher: max wait %v outside (0,10ms]", cfg.MaxWait)
|
||||
}
|
||||
if cfg.QueueSize < cfg.MaxSize || cfg.QueueSize > 1<<20 {
|
||||
return nil, fmt.Errorf("initialize auth key session Layer 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 auth key session Layer batcher: query timeout %v outside (0,30s]", cfg.QueryTimeout)
|
||||
}
|
||||
workerCtx, cancel := context.WithCancel(context.Background())
|
||||
s := &BatchedAuthKeySessionLayerStore{
|
||||
base: base, cfg: cfg,
|
||||
queue: make(chan authKeySessionLayerBatchRequest, cfg.QueueSize),
|
||||
stop: make(chan struct{}), done: make(chan struct{}), cancel: cancel,
|
||||
}
|
||||
go s.run(workerCtx)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *BatchedAuthKeySessionLayerStore) GetSessionLayer(
|
||||
ctx context.Context,
|
||||
rawAuthKeyID [8]byte,
|
||||
sessionID int64,
|
||||
) (store.AuthKeySessionLayer, bool, error) {
|
||||
return s.base.GetSessionLayer(ctx, rawAuthKeyID, sessionID)
|
||||
}
|
||||
|
||||
func (s *BatchedAuthKeySessionLayerStore) AdvanceSessionLayer(
|
||||
ctx context.Context,
|
||||
rawAuthKeyID [8]byte,
|
||||
sessionID int64,
|
||||
layer int,
|
||||
msgID int64,
|
||||
) (store.AuthKeySessionLayer, bool, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
expiresAt, validMessageID := store.AuthKeySessionLayerExpiry(msgID)
|
||||
if layer <= 0 || !validMessageID {
|
||||
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeySessionLayerInvalid
|
||||
}
|
||||
request := authKeySessionLayerBatchRequest{
|
||||
ctx: ctx, rawAuthKeyID: rawAuthKeyID, sessionID: sessionID,
|
||||
layer: layer, msgID: msgID, expiresAt: expiresAt,
|
||||
result: make(chan authKeySessionLayerBatchResult, 1),
|
||||
}
|
||||
s.gate.RLock()
|
||||
if s.closed {
|
||||
s.gate.RUnlock()
|
||||
return store.AuthKeySessionLayer{}, false, context.Canceled
|
||||
}
|
||||
select {
|
||||
case s.queue <- request:
|
||||
case <-ctx.Done():
|
||||
s.gate.RUnlock()
|
||||
return store.AuthKeySessionLayer{}, false, ctx.Err()
|
||||
}
|
||||
s.gate.RUnlock()
|
||||
|
||||
// Once accepted by the bounded queue, wait for the worker's definitive
|
||||
// commit/error. This prevents a canceled caller from turning the submitted
|
||||
// selector into an unobserved asynchronous best-effort write.
|
||||
result := <-request.result
|
||||
if result.err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, result.err
|
||||
}
|
||||
if result.fast {
|
||||
return result.current, true, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return store.AuthKeySessionLayer{}, false, err
|
||||
}
|
||||
return s.base.advanceSessionLayerFull(ctx, rawAuthKeyID, sessionID, layer, msgID, expiresAt)
|
||||
}
|
||||
|
||||
func (s *BatchedAuthKeySessionLayerStore) DeleteSessionLayer(
|
||||
ctx context.Context,
|
||||
rawAuthKeyID [8]byte,
|
||||
sessionID int64,
|
||||
) (bool, error) {
|
||||
return s.base.DeleteSessionLayer(ctx, rawAuthKeyID, sessionID)
|
||||
}
|
||||
|
||||
func (s *BatchedAuthKeySessionLayerStore) DeleteExpiredSessionLayers(ctx context.Context, limit int) (int, error) {
|
||||
return s.base.DeleteExpiredSessionLayers(ctx, limit)
|
||||
}
|
||||
|
||||
func (s *BatchedAuthKeySessionLayerStore) Close() {
|
||||
s.once.Do(func() {
|
||||
s.gate.Lock()
|
||||
s.closed = true
|
||||
close(s.stop)
|
||||
s.cancel()
|
||||
s.gate.Unlock()
|
||||
<-s.done
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BatchedAuthKeySessionLayerStore) run(ctx context.Context) {
|
||||
defer close(s.done)
|
||||
pending := make([]authKeySessionLayerBatchRequest, 0, s.cfg.MaxSize)
|
||||
for {
|
||||
if len(pending) == 0 {
|
||||
select {
|
||||
case request := <-s.queue:
|
||||
pending = append(pending, request)
|
||||
case <-s.stop:
|
||||
s.failQueued(context.Canceled, pending)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(pending) < s.cfg.MaxSize {
|
||||
timer := time.NewTimer(s.cfg.MaxWait)
|
||||
collect:
|
||||
for len(pending) < s.cfg.MaxSize {
|
||||
select {
|
||||
case request := <-s.queue:
|
||||
pending = append(pending, request)
|
||||
case <-timer.C:
|
||||
break collect
|
||||
case <-s.stop:
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
s.failQueued(context.Canceled, pending)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
batch, remaining := selectDistinctLayerAdvanceBatch(pending, s.cfg.MaxSize)
|
||||
pending = remaining
|
||||
s.execute(ctx, batch)
|
||||
}
|
||||
}
|
||||
|
||||
func selectDistinctLayerAdvanceBatch(
|
||||
pending []authKeySessionLayerBatchRequest,
|
||||
maxSize int,
|
||||
) ([]authKeySessionLayerBatchRequest, []authKeySessionLayerBatchRequest) {
|
||||
batch := make([]authKeySessionLayerBatchRequest, 0, min(maxSize, len(pending)))
|
||||
remaining := make([]authKeySessionLayerBatchRequest, 0, len(pending))
|
||||
seen := make(map[authKeySessionLayerBatchKey]struct{}, min(maxSize, len(pending)))
|
||||
for _, request := range pending {
|
||||
if len(batch) >= maxSize {
|
||||
remaining = append(remaining, request)
|
||||
continue
|
||||
}
|
||||
key := authKeySessionLayerBatchKey{rawAuthKeyID: request.rawAuthKeyID, sessionID: request.sessionID}
|
||||
if _, exists := seen[key]; exists {
|
||||
remaining = append(remaining, request)
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
batch = append(batch, request)
|
||||
}
|
||||
return batch, remaining
|
||||
}
|
||||
|
||||
func (s *BatchedAuthKeySessionLayerStore) execute(ctx context.Context, batch []authKeySessionLayerBatchRequest) {
|
||||
active := batch[:0]
|
||||
for _, request := range batch {
|
||||
if err := request.ctx.Err(); err != nil {
|
||||
request.result <- authKeySessionLayerBatchResult{err: err}
|
||||
continue
|
||||
}
|
||||
active = append(active, request)
|
||||
}
|
||||
if len(active) == 0 {
|
||||
return
|
||||
}
|
||||
queryCtx, cancel := context.WithTimeout(ctx, s.cfg.QueryTimeout)
|
||||
results, err := s.base.tryAdvanceSessionLayersSameLayer(queryCtx, active)
|
||||
cancel()
|
||||
if err != nil {
|
||||
for _, request := range active {
|
||||
request.result <- authKeySessionLayerBatchResult{err: err}
|
||||
}
|
||||
return
|
||||
}
|
||||
for index, request := range active {
|
||||
request.result <- results[index]
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BatchedAuthKeySessionLayerStore) failQueued(err error, pending []authKeySessionLayerBatchRequest) {
|
||||
for _, request := range pending {
|
||||
request.result <- authKeySessionLayerBatchResult{err: err}
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case request := <-s.queue:
|
||||
request.result <- authKeySessionLayerBatchResult{err: err}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) tryAdvanceSessionLayersSameLayer(
|
||||
ctx context.Context,
|
||||
requests []authKeySessionLayerBatchRequest,
|
||||
) ([]authKeySessionLayerBatchResult, error) {
|
||||
results := make([]authKeySessionLayerBatchResult, len(requests))
|
||||
if len(requests) == 0 {
|
||||
return results, nil
|
||||
}
|
||||
rawIDs := make([]int64, len(requests))
|
||||
sessionIDs := make([]int64, len(requests))
|
||||
layers := make([]int32, len(requests))
|
||||
msgIDs := make([]int64, len(requests))
|
||||
expiresAts := make([]time.Time, len(requests))
|
||||
seen := make(map[authKeySessionLayerBatchKey]struct{}, len(requests))
|
||||
for index, request := range requests {
|
||||
key := authKeySessionLayerBatchKey{rawAuthKeyID: request.rawAuthKeyID, sessionID: request.sessionID}
|
||||
if _, duplicate := seen[key]; duplicate {
|
||||
return nil, fmt.Errorf("advance same-Layer auth key session batch: duplicate identity at index %d", index)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
rawIDs[index] = authKeyIDToInt64(request.rawAuthKeyID)
|
||||
sessionIDs[index] = request.sessionID
|
||||
layers[index] = int32(request.layer)
|
||||
msgIDs[index] = request.msgID
|
||||
expiresAts[index] = request.expiresAt
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
WITH input AS (
|
||||
SELECT *
|
||||
FROM unnest(
|
||||
$1::bigint[],
|
||||
$2::bigint[],
|
||||
$3::integer[],
|
||||
$4::bigint[],
|
||||
$5::timestamptz[]
|
||||
) WITH ORDINALITY AS value(raw_id, session_id, layer, msg_id, expires_at, ordinal)
|
||||
), identity AS MATERIALIZED (
|
||||
SELECT input.*,
|
||||
defaults.layer AS default_layer,
|
||||
defaults.layer_observation_id AS default_observation_id
|
||||
FROM input
|
||||
JOIN auth_keys AS raw
|
||||
ON raw.auth_key_id = input.raw_id
|
||||
LEFT JOIN temp_auth_key_bindings AS binding
|
||||
ON binding.temp_auth_key_id = raw.auth_key_id
|
||||
JOIN auth_keys AS defaults
|
||||
ON defaults.auth_key_id = COALESCE(binding.perm_auth_key_id, raw.auth_key_id)
|
||||
WHERE binding.temp_auth_key_id IS NULL
|
||||
OR (raw.expires_at > 0 AND defaults.expires_at = 0)
|
||||
), candidates AS MATERIALIZED (
|
||||
SELECT identity.ordinal,
|
||||
identity.msg_id,
|
||||
identity.expires_at,
|
||||
identity.default_layer,
|
||||
identity.default_observation_id,
|
||||
evidence.raw_auth_key_id,
|
||||
evidence.session_id,
|
||||
evidence.layer,
|
||||
evidence.observation_id
|
||||
FROM identity
|
||||
JOIN auth_key_session_layers AS evidence
|
||||
ON evidence.raw_auth_key_id = identity.raw_id
|
||||
AND evidence.session_id = identity.session_id
|
||||
WHERE evidence.layer = identity.layer
|
||||
AND evidence.msg_id < identity.msg_id
|
||||
AND evidence.expires_at > now()
|
||||
AND identity.layer > 0
|
||||
AND identity.msg_id > 0
|
||||
AND identity.msg_id % 4 = 0
|
||||
AND (identity.msg_id & 4294967295) <> 0
|
||||
AND identity.expires_at > now()
|
||||
AND identity.expires_at - interval '301 seconds' <= now() + interval '30 seconds'
|
||||
ORDER BY evidence.raw_auth_key_id, evidence.session_id
|
||||
FOR UPDATE OF evidence
|
||||
), advanced AS (
|
||||
UPDATE auth_key_session_layers AS evidence
|
||||
SET msg_id = candidates.msg_id,
|
||||
expires_at = candidates.expires_at
|
||||
FROM candidates
|
||||
WHERE evidence.raw_auth_key_id = candidates.raw_auth_key_id
|
||||
AND evidence.session_id = candidates.session_id
|
||||
RETURNING candidates.ordinal,
|
||||
candidates.default_layer,
|
||||
candidates.default_observation_id,
|
||||
evidence.layer,
|
||||
evidence.msg_id,
|
||||
evidence.observation_id,
|
||||
evidence.expires_at
|
||||
)
|
||||
SELECT ordinal,
|
||||
layer,
|
||||
msg_id,
|
||||
observation_id,
|
||||
expires_at,
|
||||
default_layer = layer AND default_observation_id = observation_id
|
||||
FROM advanced
|
||||
ORDER BY ordinal
|
||||
`, rawIDs, sessionIDs, layers, msgIDs, expiresAts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("advance same-Layer auth key session batch: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var (
|
||||
ordinal int64
|
||||
current store.AuthKeySessionLayer
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&ordinal,
|
||||
¤t.Layer,
|
||||
¤t.MessageID,
|
||||
¤t.ObservationID,
|
||||
¤t.ExpiresAt,
|
||||
¤t.SharedDefault,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan same-Layer auth key session batch: %w", err)
|
||||
}
|
||||
index := int(ordinal - 1)
|
||||
if index < 0 || index >= len(results) || results[index].fast {
|
||||
return nil, fmt.Errorf("advance same-Layer auth key session batch: invalid ordinal %d", ordinal)
|
||||
}
|
||||
results[index] = authKeySessionLayerBatchResult{current: current, fast: true}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("advance same-Layer auth key session batch rows: %w", err)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
var _ store.AuthKeySessionLayerStore = (*BatchedAuthKeySessionLayerStore)(nil)
|
||||
159
internal/store/postgres/authkey_session_layer_batch_test.go
Normal file
159
internal/store/postgres/authkey_session_layer_batch_test.go
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
func TestSelectDistinctLayerAdvanceBatchDefersSameSession(t *testing.T) {
|
||||
first := authKeySessionLayerBatchRequest{rawAuthKeyID: [8]byte{1}, sessionID: 7}
|
||||
duplicate := authKeySessionLayerBatchRequest{rawAuthKeyID: [8]byte{1}, sessionID: 7}
|
||||
other := authKeySessionLayerBatchRequest{rawAuthKeyID: [8]byte{1}, sessionID: 8}
|
||||
batch, remaining := selectDistinctLayerAdvanceBatch(
|
||||
[]authKeySessionLayerBatchRequest{first, duplicate, other},
|
||||
3,
|
||||
)
|
||||
if len(batch) != 2 || batch[0].sessionID != 7 || batch[1].sessionID != 8 {
|
||||
t.Fatalf("batch = %#v", batch)
|
||||
}
|
||||
if len(remaining) != 1 || remaining[0].sessionID != 7 {
|
||||
t.Fatalf("remaining = %#v", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchedAuthKeySessionLayerStorePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
const accountCount = 32
|
||||
now := time.Now().UTC()
|
||||
keys := NewAuthKeyStore(pool)
|
||||
type seeded struct {
|
||||
id [8]byte
|
||||
sessionID int64
|
||||
observationID int64
|
||||
msgID int64
|
||||
}
|
||||
seededKeys := make([]seeded, 0, accountCount)
|
||||
for index := 0; index < accountCount; index++ {
|
||||
id := randomLayerTestAuthKeyID(t)
|
||||
sessionID := int64(91000 + index)
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstMsgID := authKeySessionLayerTestMsgID(now, uint32(index+1))
|
||||
first, applied, err := keys.AdvanceSessionLayer(ctx, id, sessionID, 227, firstMsgID)
|
||||
if err != nil || !applied || first.ObservationID <= 0 {
|
||||
t.Fatalf("seed %d = (%+v,%v,%v)", index, first, applied, err)
|
||||
}
|
||||
seededKeys = append(seededKeys, seeded{
|
||||
id: id, sessionID: sessionID, observationID: first.ObservationID,
|
||||
msgID: authKeySessionLayerTestMsgID(now, uint32(accountCount+index+1)),
|
||||
})
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, item := range seededKeys {
|
||||
_ = keys.Delete(ctx, item.id)
|
||||
}
|
||||
})
|
||||
|
||||
counted := &layerBatchCountingDB{db: pool}
|
||||
batchedBase := NewAuthKeyStore(counted)
|
||||
batcher, err := NewBatchedAuthKeySessionLayerStore(batchedBase, AuthKeySessionLayerBatchConfig{
|
||||
MaxSize: accountCount, MaxWait: 10 * time.Millisecond,
|
||||
QueueSize: accountCount * 2, QueryTimeout: 5 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(batcher.Close)
|
||||
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, accountCount)
|
||||
var wg sync.WaitGroup
|
||||
for _, item := range seededKeys {
|
||||
item := item
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
current, applied, err := batcher.AdvanceSessionLayer(ctx, item.id, item.sessionID, 227, item.msgID)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if !applied || current.MessageID != item.msgID || current.ObservationID != item.observationID {
|
||||
errs <- errors.New("same-Layer batch changed durable generation or failed to advance")
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls := counted.batchQueries.Load(); calls <= 0 || calls > 4 {
|
||||
t.Fatalf("batch SQL calls = %d, want 1..4 for %d concurrent sessions", calls, accountCount)
|
||||
}
|
||||
for _, item := range seededKeys {
|
||||
current, found, err := keys.GetSessionLayer(ctx, item.id, item.sessionID)
|
||||
if err != nil || !found || current.MessageID != item.msgID || current.ObservationID != item.observationID {
|
||||
t.Fatalf("durable result %x/%d = (%+v,%v,%v)", item.id, item.sessionID, current, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A fast miss must synchronously execute the original full state machine,
|
||||
// rather than treating a successful batch statement as success for every row.
|
||||
missingSession := int64(99001)
|
||||
missingMsgID := authKeySessionLayerTestMsgID(now, 1000)
|
||||
created, applied, err := batcher.AdvanceSessionLayer(ctx, seededKeys[0].id, missingSession, 225, missingMsgID)
|
||||
if err != nil || !applied || created.Layer != 225 || created.MessageID != missingMsgID || created.ObservationID <= 0 {
|
||||
t.Fatalf("batch miss full fallback = (%+v,%v,%v)", created, applied, err)
|
||||
}
|
||||
|
||||
batcher.Close()
|
||||
if _, _, err := batcher.AdvanceSessionLayer(ctx, seededKeys[0].id, missingSession, 225, missingMsgID); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("advance after close err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type layerBatchCountingDB struct {
|
||||
db sqlcgen.DBTX
|
||||
batchQueries atomic.Int64
|
||||
}
|
||||
|
||||
func (db *layerBatchCountingDB) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
|
||||
return db.db.Exec(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func (db *layerBatchCountingDB) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
|
||||
if strings.Contains(sql, "WITH input AS") && strings.Contains(sql, "candidates AS MATERIALIZED") {
|
||||
db.batchQueries.Add(1)
|
||||
}
|
||||
return db.db.Query(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func (db *layerBatchCountingDB) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
|
||||
return db.db.QueryRow(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func (db *layerBatchCountingDB) Begin(ctx context.Context) (pgx.Tx, error) {
|
||||
beginner, ok := db.db.(txBeginner)
|
||||
if !ok {
|
||||
return nil, errors.New("counted database does not support transactions")
|
||||
}
|
||||
return beginner.Begin(ctx)
|
||||
}
|
||||
|
||||
var _ sqlcgen.DBTX = (*layerBatchCountingDB)(nil)
|
||||
|
|
@ -37,9 +37,10 @@ func TestAuthKeySessionLayerTransactionAndRestartPostgres(t *testing.T) {
|
|||
}
|
||||
now := time.Now().UTC()
|
||||
firstMsgID := authKeySessionLayerTestMsgID(now, 1)
|
||||
newerMsgID := authKeySessionLayerTestMsgID(now, 2)
|
||||
concurrentLowMsgID := authKeySessionLayerTestMsgID(now, 3)
|
||||
concurrentHighMsgID := authKeySessionLayerTestMsgID(now, 4)
|
||||
sameLayerMsgID := authKeySessionLayerTestMsgID(now, 2)
|
||||
newerMsgID := authKeySessionLayerTestMsgID(now, 3)
|
||||
concurrentLowMsgID := authKeySessionLayerTestMsgID(now, 4)
|
||||
concurrentHighMsgID := authKeySessionLayerTestMsgID(now, 5)
|
||||
for _, invalidMsgID := range []int64{
|
||||
authKeySessionLayerTestMsgID(now.Add(-302*time.Second), 1),
|
||||
authKeySessionLayerTestMsgID(now.Add(31*time.Second), 1),
|
||||
|
|
@ -72,6 +73,24 @@ func TestAuthKeySessionLayerTransactionAndRestartPostgres(t *testing.T) {
|
|||
t.Fatalf("bound default %x = (%+v,%v,%v)", id, got, found, err)
|
||||
}
|
||||
}
|
||||
futureSameLayerMsgID := authKeySessionLayerTestMsgID(now.Add(31*time.Second), 1)
|
||||
if _, _, err := NewAuthKeyStore(pool).AdvanceSessionLayer(ctx, temp, sessionID, 220, futureSameLayerMsgID); !errors.Is(err, store.ErrAuthKeySessionLayerInvalid) {
|
||||
t.Fatalf("future same-Layer fast advance err = %v", err)
|
||||
}
|
||||
if got, found, err := NewAuthKeyStore(pool).GetSessionLayer(ctx, temp, sessionID); err != nil || !found || got.MessageID != firstMsgID || got.ObservationID != first.ObservationID {
|
||||
t.Fatalf("rejected future same-Layer advance changed row = (%+v,%v,%v)", got, found, err)
|
||||
}
|
||||
sameLayer, applied, err := NewAuthKeyStore(pool).AdvanceSessionLayer(ctx, temp, sessionID, 220, sameLayerMsgID)
|
||||
if err != nil || !applied || !sameLayer.SharedDefault || sameLayer.MessageID != sameLayerMsgID ||
|
||||
sameLayer.ObservationID != first.ObservationID {
|
||||
t.Fatalf("same-Layer high-water advance = (%+v,%v,%v)", sameLayer, applied, err)
|
||||
}
|
||||
for _, id := range [][8]byte{temp, perm} {
|
||||
got, found, err := NewAuthKeyStore(pool).Get(ctx, id)
|
||||
if err != nil || !found || got.Layer != 220 || got.LayerObservationID != first.ObservationID {
|
||||
t.Fatalf("same-Layer default rewrite %x = (%+v,%v,%v)", id, got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
newer, applied, err := NewAuthKeyStore(pool).AdvanceSessionLayer(ctx, temp, sessionID, 227, newerMsgID)
|
||||
if err != nil || !applied || !newer.SharedDefault || newer.ObservationID <= first.ObservationID {
|
||||
|
|
@ -124,6 +143,27 @@ func TestAuthKeySessionLayerTransactionAndRestartPostgres(t *testing.T) {
|
|||
t.Fatalf("transactional shared default %x = (%+v,%v,%v)", id, got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Expiry ends the old row's ordering authority. A still-fresh selector with
|
||||
// a lower msg_id may replace it and must publish one new shared observation.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
UPDATE auth_key_session_layers
|
||||
SET expires_at = now() - interval '1 second'
|
||||
WHERE raw_auth_key_id = $1 AND session_id = $2
|
||||
`, authKeyIDToInt64(temp), sessionID); err != nil {
|
||||
t.Fatalf("expire session Layer row: %v", err)
|
||||
}
|
||||
replacement, applied, err := restarted.AdvanceSessionLayer(ctx, temp, sessionID, 225, firstMsgID)
|
||||
if err != nil || !applied || replacement.Layer != 225 || replacement.MessageID != firstMsgID ||
|
||||
!replacement.SharedDefault || replacement.ObservationID <= current.ObservationID {
|
||||
t.Fatalf("expired-row replacement = (%+v,%v,%v)", replacement, applied, err)
|
||||
}
|
||||
for _, id := range [][8]byte{temp, perm} {
|
||||
got, found, err := restarted.Get(ctx, id)
|
||||
if err != nil || !found || got.Layer != 225 || got.LayerObservationID != replacement.ObservationID {
|
||||
t.Fatalf("replacement shared default %x = (%+v,%v,%v)", id, got, found, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func authKeySessionLayerTestMsgID(at time.Time, order uint32) int64 {
|
||||
|
|
|
|||
|
|
@ -40,17 +40,37 @@ func (s *AuthorizationStore) Bind(ctx context.Context, a domain.Authorization) e
|
|||
|
||||
// bindAuthorization 把 auth_key→user 绑定和设备 update baseline 作为同一个状态边界提交。
|
||||
//
|
||||
// 锁顺序固定为:auth_keys 母行 → 目标 user_update_watermarks →
|
||||
// user_update_retention → 目标 update_states。前两个 user 锁与
|
||||
// pruneConfirmedUserPrefixTx 一致,使新授权的 observed baseline 和 retained floor 不会
|
||||
// 交叉提交成静默空洞。母行锁又能在首次 authorization 尚不存在时串行化同一
|
||||
// raw auth key 的并发登录/换号。
|
||||
// 锁顺序固定为:目标 user advisory/row → auth_keys 母行 →
|
||||
// user_update_watermarks → user_update_retention → 目标 update_states。其中 watermark
|
||||
// 与 retention 两个 row lock 的顺序和 pruneConfirmedUserPrefixTx 一致,使新授权的
|
||||
// observed baseline 和 retained floor 不会交叉提交成静默空洞。母行锁又能在首次
|
||||
// authorization 尚不存在时串行化同一
|
||||
// raw auth key 的并发登录/换号。user 锁与账号 tombstone 使用同一顺序;因此 Bind
|
||||
// 要么先提交并被随后删除事务撤销,要么等删除提交后看见 tombstone 并拒绝,不能在
|
||||
// 删除事务枚举 authorization 之后重新绑定账号。
|
||||
func bindAuthorization(ctx context.Context, db sqlcgen.DBTX, a domain.Authorization) error {
|
||||
keyID := authKeyIDToInt64(a.AuthKeyID)
|
||||
tx, ok := db.(pgx.Tx)
|
||||
if !ok {
|
||||
return fmt.Errorf("bind authorization requires a transaction")
|
||||
}
|
||||
if err := lockUsersForUpdate(ctx, tx, a.UserID); err != nil {
|
||||
return fmt.Errorf("lock authorization user: %w", err)
|
||||
}
|
||||
var active bool
|
||||
if err := db.QueryRow(ctx, `
|
||||
SELECT deleted_at IS NULL
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
FOR UPDATE`, a.UserID).Scan(&active); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ErrUserNotFound
|
||||
}
|
||||
return fmt.Errorf("lock authorization user row: %w", err)
|
||||
}
|
||||
if !active {
|
||||
return domain.ErrAccountDeleted
|
||||
}
|
||||
if err := lockPermanentAuthIdentities(ctx, tx, []int64{keyID}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -160,6 +180,7 @@ ON CONFLICT (auth_key_id) DO UPDATE SET
|
|||
app_version = EXCLUDED.app_version,
|
||||
ip = EXCLUDED.ip,
|
||||
password_pending = EXCLUDED.password_pending,
|
||||
created_at = now(),
|
||||
active_at = now()`,
|
||||
keyID, a.UserID, a.Hash, int32(authLayer), a.DeviceModel, a.Platform, a.SystemVersion, int32(a.APIID), a.AppVersion, a.IP, a.PasswordPending,
|
||||
); err != nil {
|
||||
|
|
@ -204,12 +225,19 @@ WHERE auth_key_id = $1`,
|
|||
return nil
|
||||
}
|
||||
|
||||
// MarkPasswordPassed 在两步验证通过后清除 password_pending,使 auth_key 转为完全授权。
|
||||
func (s *AuthorizationStore) MarkPasswordPassed(ctx context.Context, id [8]byte) error {
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
UPDATE authorizations SET password_pending = false, active_at = now() WHERE auth_key_id = $1`, authKeyIDToInt64(id)); err != nil {
|
||||
// MarkPasswordPassed atomically promotes only the pending identity whose
|
||||
// password was just verified. A concurrent cross-user Bind must not let A's
|
||||
// proof clear B's password_pending flag.
|
||||
func (s *AuthorizationStore) MarkPasswordPassed(ctx context.Context, id [8]byte, expectedUserID int64) error {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE authorizations SET password_pending = false, created_at = now(), active_at = now()
|
||||
WHERE auth_key_id = $1 AND user_id = $2 AND password_pending`, authKeyIDToInt64(id), expectedUserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark authorization password passed: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return store.ErrAuthorizationStateChanged
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package postgres
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -159,6 +160,99 @@ func TestAuthorizationStoreUpdateClientInfoMergesPostgres(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAuthorizationStoreLoginAndPasswordCompletionRefreshSessionAgePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
userA := createRevokeTestUser(t, ctx, pool, "session-age-a")
|
||||
userB := createRevokeTestUser(t, ctx, pool, "session-age-b")
|
||||
keys := NewAuthKeyStore(pool)
|
||||
auths := NewAuthorizationStore(pool)
|
||||
key := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
|
||||
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: key, UserID: userA, Hash: 9251}); err != nil {
|
||||
t.Fatalf("bind initial authorization: %v", err)
|
||||
}
|
||||
var oldCreatedAt time.Time
|
||||
if err := pool.QueryRow(ctx, `UPDATE authorizations SET created_at=now()-interval '48 hours'
|
||||
WHERE auth_key_id=$1 RETURNING created_at`, authKeyIDToInt64(key)).Scan(&oldCreatedAt); err != nil {
|
||||
t.Fatalf("backdate initial authorization: %v", err)
|
||||
}
|
||||
|
||||
// Bind is an explicit login boundary, not a metadata refresh. Even a login
|
||||
// to the same account must start a new withdrawal freshness window.
|
||||
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: key, UserID: userA, Hash: 9252}); err != nil {
|
||||
t.Fatalf("rebind same owner: %v", err)
|
||||
}
|
||||
assertFreshAuthorizationCreatedAt(t, ctx, pool, key, userA, oldCreatedAt)
|
||||
|
||||
if _, err := pool.Exec(ctx, `UPDATE authorizations SET created_at=now()-interval '48 hours'
|
||||
WHERE auth_key_id=$1`, authKeyIDToInt64(key)); err != nil {
|
||||
t.Fatalf("backdate authorization before owner change: %v", err)
|
||||
}
|
||||
if err := auths.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: key, UserID: userB, Hash: 9253, PasswordPending: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("bind new owner pending password: %v", err)
|
||||
}
|
||||
assertFreshAuthorizationCreatedAt(t, ctx, pool, key, userB, oldCreatedAt)
|
||||
|
||||
// A pending login may wait longer than 24 hours before auth.checkPassword.
|
||||
// Full authorization starts only when that proof succeeds, so its age must
|
||||
// be reset here rather than inheriting the pending row's old timestamp.
|
||||
if _, err := pool.Exec(ctx, `UPDATE authorizations SET created_at=now()-interval '48 hours'
|
||||
WHERE auth_key_id=$1`, authKeyIDToInt64(key)); err != nil {
|
||||
t.Fatalf("backdate pending authorization: %v", err)
|
||||
}
|
||||
if err := auths.MarkPasswordPassed(ctx, key, userB); err != nil {
|
||||
t.Fatalf("mark password passed: %v", err)
|
||||
}
|
||||
got, found, err := auths.ByAuthKey(ctx, key)
|
||||
if err != nil || !found || got.PasswordPending {
|
||||
t.Fatalf("completed password authorization = %+v found=%v err=%v", got, found, err)
|
||||
}
|
||||
assertFreshAuthorizationCreatedAt(t, ctx, pool, key, userB, oldCreatedAt)
|
||||
|
||||
// Model the exact proof/promote race: A's password was verified, then the
|
||||
// same auth key was rebound to B in password_pending state before promotion.
|
||||
// A's proof must not promote B or turn Router's stale A identity into a cache
|
||||
// fact for this key.
|
||||
raceKey := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
if err := auths.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: raceKey, UserID: userA, Hash: 9254, PasswordPending: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("bind proof owner A: %v", err)
|
||||
}
|
||||
if err := auths.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: raceKey, UserID: userB, Hash: 9255, PasswordPending: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("rebind pending owner B: %v", err)
|
||||
}
|
||||
if err := auths.MarkPasswordPassed(ctx, raceKey, userA); !errors.Is(err, store.ErrAuthorizationStateChanged) {
|
||||
t.Fatalf("stale A proof promotion err=%v, want authorization state changed", err)
|
||||
}
|
||||
raced, found, err := auths.ByAuthKey(ctx, raceKey)
|
||||
if err != nil || !found || raced.UserID != userB || !raced.PasswordPending {
|
||||
t.Fatalf("authorization after stale A proof = %+v found=%v err=%v, want pending B", raced, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFreshAuthorizationCreatedAt(t *testing.T, ctx context.Context, pool *pgxpool.Pool, key [8]byte, userID int64, old time.Time) {
|
||||
t.Helper()
|
||||
var (
|
||||
actualUserID int64
|
||||
createdAt time.Time
|
||||
fresh bool
|
||||
)
|
||||
if err := pool.QueryRow(ctx, `SELECT user_id,created_at,created_at > now()-interval '1 minute'
|
||||
FROM authorizations WHERE auth_key_id=$1`, authKeyIDToInt64(key)).Scan(&actualUserID, &createdAt, &fresh); err != nil {
|
||||
t.Fatalf("read refreshed authorization: %v", err)
|
||||
}
|
||||
if actualUserID != userID || !fresh || !createdAt.After(old) {
|
||||
t.Fatalf("authorization session age user=%d created_at=%v fresh=%v, want user=%d newer than %v",
|
||||
actualUserID, createdAt, fresh, userID, old)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizationStoreRevokeByHashConcurrentTempBindKeepsProtocolIdentityPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
|
|
@ -258,9 +352,10 @@ func TestAuthorizationStoreRevokeByHashSkipsKeyTransferredAfterCandidateReadPost
|
|||
t.Fatalf("save temp binding before owner transfer: %v", err)
|
||||
}
|
||||
|
||||
// Bind B performs the auth_keys-first ownership change inside an open
|
||||
// transaction. Its uncommitted row is invisible to A's candidate lookup, but
|
||||
// the parent FOR UPDATE lock is the deterministic barrier for revocation.
|
||||
// Bind B locks its target user before performing the auth-key ownership change
|
||||
// inside an open transaction. Its uncommitted row is invisible to A's candidate
|
||||
// lookup, while the parent auth-key FOR UPDATE lock remains the deterministic
|
||||
// barrier for revocation.
|
||||
bindB, err := pool.Begin(testCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin B bind transaction: %v", err)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const tombstoneAuthorizationTestUserSQL = `
|
||||
UPDATE users SET
|
||||
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
|
||||
verified = false, support = false, last_seen_at = 0,
|
||||
premium_expires_at = NULL, emoji_status_document_id = 0, emoji_status_until = 0,
|
||||
emoji_status_collectible_id = NULL, emoji_status_collectible = '{}'::jsonb,
|
||||
color_set = false, color = 0, color_background_emoji_id = 0,
|
||||
profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0,
|
||||
birthday_day = 0, birthday_month = 0, birthday_year = 0, personal_channel_id = 0,
|
||||
deleted_at = $2, deletion_source = 'manual', deletion_reason = '',
|
||||
account_delete_at = NULL, updated_at = $2
|
||||
WHERE id = $1 AND deleted_at IS NULL`
|
||||
|
||||
func TestAuthorizationStoreBindRejectsTombstonePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
userID := createRevokeTestUser(t, ctx, pool, "bind-tombstone")
|
||||
key := saveTempIdentityTestAuthKey(t, ctx, pool, NewAuthKeyStore(pool), 0)
|
||||
|
||||
if _, err := pool.Exec(ctx, tombstoneAuthorizationTestUserSQL, userID, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("tombstone user: %v", err)
|
||||
}
|
||||
err := NewAuthorizationStore(pool).Bind(ctx, domain.Authorization{AuthKeyID: key, UserID: userID})
|
||||
if !errors.Is(err, domain.ErrAccountDeleted) {
|
||||
t.Fatalf("Bind tombstone err = %v, want ErrAccountDeleted", err)
|
||||
}
|
||||
assertRevokeTestNoAuthorization(t, ctx, NewAuthorizationStore(pool), key)
|
||||
assertRevokeTestTableCount(t, ctx, pool, "update_states", "auth_key_id", authKeyIDToInt64(key), 0)
|
||||
}
|
||||
|
||||
func TestAuthorizationStoreBindWaitsForTombstoneThenRejectsPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
testCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
userID := createRevokeTestUser(t, testCtx, pool, "bind-tombstone-race")
|
||||
key := saveTempIdentityTestAuthKey(t, testCtx, pool, NewAuthKeyStore(pool), 0)
|
||||
|
||||
deleteTx, err := pool.Begin(testCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin tombstone transaction: %v", err)
|
||||
}
|
||||
defer func() { _ = deleteTx.Rollback(context.Background()) }()
|
||||
if err := lockUsersForUpdate(testCtx, deleteTx, userID); err != nil {
|
||||
t.Fatalf("lock tombstone user: %v", err)
|
||||
}
|
||||
if _, err := deleteTx.Exec(testCtx, tombstoneAuthorizationTestUserSQL, userID, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("stage tombstone: %v", err)
|
||||
}
|
||||
|
||||
bindConn, err := pool.Acquire(testCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire bind connection: %v", err)
|
||||
}
|
||||
t.Cleanup(bindConn.Release)
|
||||
var bindPID int
|
||||
if err := bindConn.QueryRow(testCtx, "SELECT pg_backend_pid()").Scan(&bindPID); err != nil {
|
||||
t.Fatalf("get bind backend pid: %v", err)
|
||||
}
|
||||
bindResult := make(chan error, 1)
|
||||
go func() {
|
||||
bindResult <- NewAuthorizationStore(bindConn).Bind(testCtx, domain.Authorization{AuthKeyID: key, UserID: userID})
|
||||
}()
|
||||
waitForPostgresBackendLockWait(t, testCtx, pool, bindPID)
|
||||
|
||||
if err := deleteTx.Commit(testCtx); err != nil {
|
||||
t.Fatalf("commit tombstone: %v", err)
|
||||
}
|
||||
select {
|
||||
case err := <-bindResult:
|
||||
if !errors.Is(err, domain.ErrAccountDeleted) {
|
||||
t.Fatalf("Bind after tombstone lock err = %v, want ErrAccountDeleted", err)
|
||||
}
|
||||
case <-testCtx.Done():
|
||||
t.Fatalf("Bind did not finish after tombstone commit: %v", testCtx.Err())
|
||||
}
|
||||
assertRevokeTestNoAuthorization(t, testCtx, NewAuthorizationStore(pool), key)
|
||||
assertRevokeTestTableCount(t, testCtx, pool, "update_states", "auth_key_id", authKeyIDToInt64(key), 0)
|
||||
}
|
||||
111
internal/store/postgres/blob_migration.go
Normal file
111
internal/store/postgres/blob_migration.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// BlobMigrationObject is one immutable content-addressed object referenced by
|
||||
// one or more logical file locations on the same permanent backend.
|
||||
type BlobMigrationObject struct {
|
||||
ObjectKey string
|
||||
Size int64
|
||||
SHA256 []byte
|
||||
LocationRows int64
|
||||
}
|
||||
|
||||
// ListBlobMigrationObjects keyset-pages distinct objects. Inconsistent size or
|
||||
// digest metadata for one content key is returned as an error, never guessed.
|
||||
func (s *MediaStore) ListBlobMigrationObjects(
|
||||
ctx context.Context,
|
||||
backend domain.MediaBackend,
|
||||
afterObjectKey string,
|
||||
limit int,
|
||||
) ([]BlobMigrationObject, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT
|
||||
object_key,
|
||||
min(size)::bigint,
|
||||
count(DISTINCT size)::bigint,
|
||||
min(encode(sha256, 'hex')),
|
||||
count(DISTINCT encode(sha256, 'hex'))::bigint,
|
||||
count(*)::bigint
|
||||
FROM file_blobs
|
||||
WHERE backend = $1 AND object_key > $2
|
||||
GROUP BY object_key
|
||||
ORDER BY object_key
|
||||
LIMIT $3`, string(backend), afterObjectKey, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list %s blob migration objects: %w", backend, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
objects := make([]BlobMigrationObject, 0, limit)
|
||||
for rows.Next() {
|
||||
var (
|
||||
object BlobMigrationObject
|
||||
sizeVariants int64
|
||||
digestHex string
|
||||
digestVariants int64
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&object.ObjectKey,
|
||||
&object.Size,
|
||||
&sizeVariants,
|
||||
&digestHex,
|
||||
&digestVariants,
|
||||
&object.LocationRows,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan blob migration object: %w", err)
|
||||
}
|
||||
if sizeVariants != 1 || digestVariants != 1 {
|
||||
return nil, fmt.Errorf("blob %q has inconsistent persisted size or SHA-256 metadata", object.ObjectKey)
|
||||
}
|
||||
digest, err := hex.DecodeString(digestHex)
|
||||
if err != nil || len(digest) != 32 {
|
||||
return nil, fmt.Errorf("blob %q has invalid persisted SHA-256 metadata", object.ObjectKey)
|
||||
}
|
||||
if object.ObjectKey != digestHex {
|
||||
return nil, fmt.Errorf("blob %q object key does not match persisted SHA-256 %q", object.ObjectKey, digestHex)
|
||||
}
|
||||
object.SHA256 = digest
|
||||
objects = append(objects, object)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate blob migration objects: %w", err)
|
||||
}
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
// MoveFileBlobBackendForObject atomically relabels every logical location for a
|
||||
// verified immutable object. It refuses partial/racing changes.
|
||||
func (s *MediaStore) MoveFileBlobBackendForObject(
|
||||
ctx context.Context,
|
||||
from domain.MediaBackend,
|
||||
to domain.MediaBackend,
|
||||
objectKey string,
|
||||
expectedRows int64,
|
||||
) error {
|
||||
if expectedRows <= 0 {
|
||||
return fmt.Errorf("blob %q expected row count must be positive", objectKey)
|
||||
}
|
||||
result, err := s.db.Exec(ctx, `
|
||||
UPDATE file_blobs
|
||||
SET backend = $1
|
||||
WHERE backend = $2 AND object_key = $3`, string(to), string(from), objectKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("move blob %q metadata from %s to %s: %w", objectKey, from, to, err)
|
||||
}
|
||||
if result.RowsAffected() != expectedRows {
|
||||
return fmt.Errorf(
|
||||
"move blob %q metadata changed %d rows, want %d",
|
||||
objectKey, result.RowsAffected(), expectedRows,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
95
internal/store/postgres/blob_storage_integration_test.go
Normal file
95
internal/store/postgres/blob_storage_integration_test.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestBlobStorageAdvisoryLock(t *testing.T) {
|
||||
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
runtimeLock, err := AcquireBlobRuntimeLock(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire runtime lock: %v", err)
|
||||
}
|
||||
if _, err := AcquireBlobMigrationLock(ctx, dsn); err == nil {
|
||||
t.Fatal("exclusive migration lock acquired while runtime shared lock was held")
|
||||
}
|
||||
if err := runtimeLock.Close(); err != nil {
|
||||
t.Fatalf("close runtime lock: %v", err)
|
||||
}
|
||||
migrationLock, err := AcquireBlobMigrationLock(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire migration lock after runtime stopped: %v", err)
|
||||
}
|
||||
if err := migrationLock.Close(); err != nil {
|
||||
t.Fatalf("close migration lock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobMigrationMetadataRoundTrip(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
media := NewMediaStore(pool)
|
||||
uniqueBefore, err := media.UniqueFileBlobBytes(ctx, domain.MediaBackendLocalFS)
|
||||
if err != nil {
|
||||
t.Fatalf("unique blob bytes before insert: %v", err)
|
||||
}
|
||||
suffix := time.Now().UnixNano()
|
||||
first := postgresTestBlob("blob-migration:first:"+time.Unix(0, suffix).Format("150405.000000000"), "shared-migration", 4096, "application/octet-stream")
|
||||
second := first
|
||||
second.LocationKey = "blob-migration:second:" + time.Unix(0, suffix).Format("150405.000000000")
|
||||
for _, blob := range []domain.FileBlob{first, second} {
|
||||
if err := media.PutFileBlob(ctx, blob); err != nil {
|
||||
t.Fatalf("put %s: %v", blob.LocationKey, err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), "DELETE FROM file_blobs WHERE location_key = ANY($1::text[])", []string{first.LocationKey, second.LocationKey})
|
||||
})
|
||||
|
||||
counts, err := media.FileBlobBackendCounts(ctx)
|
||||
if err != nil || counts[domain.MediaBackendLocalFS] < 2 {
|
||||
t.Fatalf("backend counts=%v err=%v", counts, err)
|
||||
}
|
||||
uniqueBytes, err := media.UniqueFileBlobBytes(ctx, domain.MediaBackendLocalFS)
|
||||
if err != nil {
|
||||
t.Fatalf("unique blob bytes: %v", err)
|
||||
}
|
||||
if uniqueBytes-uniqueBefore != first.Size {
|
||||
t.Fatalf("unique blob byte delta=%d, want shared object counted once as %d", uniqueBytes-uniqueBefore, first.Size)
|
||||
}
|
||||
objects, err := media.ListBlobMigrationObjects(ctx, domain.MediaBackendLocalFS, first.ObjectKey[:len(first.ObjectKey)-1], 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list migration objects: %v", err)
|
||||
}
|
||||
var found *BlobMigrationObject
|
||||
for i := range objects {
|
||||
if objects[i].ObjectKey == first.ObjectKey {
|
||||
found = &objects[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil || found.LocationRows != 2 || found.Size != first.Size {
|
||||
t.Fatalf("migration object=%+v", found)
|
||||
}
|
||||
if err := media.MoveFileBlobBackendForObject(
|
||||
ctx, domain.MediaBackendLocalFS, domain.MediaBackendS3,
|
||||
found.ObjectKey, found.LocationRows,
|
||||
); err != nil {
|
||||
t.Fatalf("move backend: %v", err)
|
||||
}
|
||||
for _, key := range []string{first.LocationKey, second.LocationKey} {
|
||||
blob, ok, err := media.GetFileBlob(ctx, key)
|
||||
if err != nil || !ok || blob.Backend != domain.MediaBackendS3 {
|
||||
t.Fatalf("get %s backend=%q ok=%v err=%v", key, blob.Backend, ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
79
internal/store/postgres/blob_storage_lock.go
Normal file
79
internal/store/postgres/blob_storage_lock.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// blobStorageAdvisoryLockKey is the signed int64 encoding of "telesrvb". Every
|
||||
// running server holds a shared session lock; the offline migration tool needs
|
||||
// the exclusive form, which proves no server using this database is active.
|
||||
const blobStorageAdvisoryLockKey int64 = 0x74656c6573727662
|
||||
|
||||
type BlobStorageLock struct {
|
||||
conn *pgx.Conn
|
||||
shared bool
|
||||
}
|
||||
|
||||
func AcquireBlobRuntimeLock(ctx context.Context, dsn string) (*BlobStorageLock, error) {
|
||||
return acquireBlobStorageLock(ctx, dsn, true)
|
||||
}
|
||||
|
||||
func AcquireBlobMigrationLock(ctx context.Context, dsn string) (*BlobStorageLock, error) {
|
||||
return acquireBlobStorageLock(ctx, dsn, false)
|
||||
}
|
||||
|
||||
func acquireBlobStorageLock(ctx context.Context, dsn string, shared bool) (*BlobStorageLock, error) {
|
||||
conn, err := pgx.Connect(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect for blob storage lock: %w", err)
|
||||
}
|
||||
query := "SELECT pg_try_advisory_lock($1)"
|
||||
kind := "exclusive migration"
|
||||
if shared {
|
||||
query = "SELECT pg_try_advisory_lock_shared($1)"
|
||||
kind = "shared runtime"
|
||||
}
|
||||
var acquired bool
|
||||
if err := conn.QueryRow(ctx, query, blobStorageAdvisoryLockKey).Scan(&acquired); err != nil {
|
||||
_ = conn.Close(context.Background())
|
||||
return nil, fmt.Errorf("acquire %s blob storage lock: %w", kind, err)
|
||||
}
|
||||
if !acquired {
|
||||
_ = conn.Close(context.Background())
|
||||
if shared {
|
||||
return nil, fmt.Errorf("blob migration lock is active; wait for the offline migration to finish before starting telesrv")
|
||||
}
|
||||
return nil, fmt.Errorf("one or more telesrv processes are active; stop every process using this PostgreSQL database before migrating blobs")
|
||||
}
|
||||
return &BlobStorageLock{conn: conn, shared: shared}, nil
|
||||
}
|
||||
|
||||
func (l *BlobStorageLock) Close() error {
|
||||
if l == nil || l.conn == nil {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
query := "SELECT pg_advisory_unlock($1)"
|
||||
if l.shared {
|
||||
query = "SELECT pg_advisory_unlock_shared($1)"
|
||||
}
|
||||
var unlocked bool
|
||||
err := l.conn.QueryRow(ctx, query, blobStorageAdvisoryLockKey).Scan(&unlocked)
|
||||
closeErr := l.conn.Close(ctx)
|
||||
l.conn = nil
|
||||
if err != nil {
|
||||
return fmt.Errorf("release blob storage lock: %w", err)
|
||||
}
|
||||
if !unlocked {
|
||||
return fmt.Errorf("blob storage advisory lock was not held by its session")
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close blob storage lock connection: %w", closeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
25
internal/store/postgres/blob_test_helpers_test.go
Normal file
25
internal/store/postgres/blob_test_helpers_test.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func postgresTestBlob(locationKey, label string, size int64, mimeType string) domain.FileBlob {
|
||||
data := make([]byte, size)
|
||||
seed := sha256.Sum256([]byte(label))
|
||||
for i := range data {
|
||||
data[i] = seed[i%len(seed)]
|
||||
}
|
||||
digest := sha256.Sum256(data)
|
||||
return domain.FileBlob{
|
||||
LocationKey: locationKey,
|
||||
Backend: domain.MediaBackendLocalFS,
|
||||
ObjectKey: hex.EncodeToString(digest[:]),
|
||||
Size: size,
|
||||
SHA256: append([]byte(nil), digest[:]...),
|
||||
MimeType: mimeType,
|
||||
}
|
||||
}
|
||||
376
internal/store/postgres/bootstrap_update_job_batch.go
Normal file
376
internal/store/postgres/bootstrap_update_job_batch.go
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// BootstrapReadyBatchMetrics exposes only bounded aggregate signals. Selector
|
||||
// identities are deliberately excluded from metrics.
|
||||
type BootstrapReadyBatchMetrics interface {
|
||||
BootstrapReadyBatch(inputs int, matched int, d time.Duration, err error)
|
||||
BootstrapReadyPending(delta int)
|
||||
}
|
||||
|
||||
type BootstrapReadyBatchConfig struct {
|
||||
MaxSize int
|
||||
MaxWait time.Duration
|
||||
QueueSize int
|
||||
QueryTimeout time.Duration
|
||||
Metrics BootstrapReadyBatchMetrics
|
||||
}
|
||||
|
||||
type bootstrapReadyBatchKey struct {
|
||||
userID int64
|
||||
authKeyID [8]byte
|
||||
}
|
||||
|
||||
type bootstrapReadyBatchRequest struct {
|
||||
userID int64
|
||||
authKeyID [8]byte
|
||||
sessionID int64
|
||||
result chan bootstrapReadyBatchResult
|
||||
}
|
||||
|
||||
type bootstrapReadyBatchResult struct {
|
||||
matched int
|
||||
err error
|
||||
}
|
||||
|
||||
type bootstrapReadyBatchBackend interface {
|
||||
store.BootstrapUpdateJobStore
|
||||
markReadyForSessions(context.Context, []bootstrapReadyBatchRequest) ([]int, error)
|
||||
}
|
||||
|
||||
// BatchedBootstrapUpdateJobStore preserves the synchronous post-response
|
||||
// delivery fence while combining independent readiness selectors into one
|
||||
// PostgreSQL statement. Once accepted, a selector waits for a definitive
|
||||
// commit/error; it is never converted into an unobserved background write.
|
||||
type BatchedBootstrapUpdateJobStore struct {
|
||||
base bootstrapReadyBatchBackend
|
||||
cfg BootstrapReadyBatchConfig
|
||||
queue chan bootstrapReadyBatchRequest
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
cancel context.CancelFunc
|
||||
once sync.Once
|
||||
gate sync.RWMutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewBatchedBootstrapUpdateJobStore(
|
||||
base *BootstrapUpdateJobStore,
|
||||
cfg BootstrapReadyBatchConfig,
|
||||
) (*BatchedBootstrapUpdateJobStore, error) {
|
||||
if base == nil || base.db == nil {
|
||||
return nil, errors.New("initialize bootstrap readiness batcher: nil store")
|
||||
}
|
||||
return newBatchedBootstrapUpdateJobStore(base, cfg)
|
||||
}
|
||||
|
||||
func newBatchedBootstrapUpdateJobStore(
|
||||
base bootstrapReadyBatchBackend,
|
||||
cfg BootstrapReadyBatchConfig,
|
||||
) (*BatchedBootstrapUpdateJobStore, error) {
|
||||
if base == nil {
|
||||
return nil, errors.New("initialize bootstrap readiness batcher: nil backend")
|
||||
}
|
||||
if cfg.MaxSize <= 0 || cfg.MaxSize > 4096 {
|
||||
return nil, fmt.Errorf("initialize bootstrap readiness batcher: max size %d outside [1,4096]", cfg.MaxSize)
|
||||
}
|
||||
if cfg.MaxWait <= 0 || cfg.MaxWait > time.Second {
|
||||
return nil, fmt.Errorf("initialize bootstrap readiness batcher: max wait %v outside (0,1s]", cfg.MaxWait)
|
||||
}
|
||||
if cfg.QueueSize < cfg.MaxSize || cfg.QueueSize > 1<<20 {
|
||||
return nil, fmt.Errorf("initialize bootstrap readiness 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 bootstrap readiness batcher: query timeout %v outside (0,30s]", cfg.QueryTimeout)
|
||||
}
|
||||
workerCtx, cancel := context.WithCancel(context.Background())
|
||||
s := &BatchedBootstrapUpdateJobStore{
|
||||
base: base, cfg: cfg,
|
||||
queue: make(chan bootstrapReadyBatchRequest, cfg.QueueSize),
|
||||
stop: make(chan struct{}), done: make(chan struct{}), cancel: cancel,
|
||||
}
|
||||
go s.run(workerCtx)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *BatchedBootstrapUpdateJobStore) EnqueueLoginMessage(
|
||||
ctx context.Context,
|
||||
job domain.BootstrapUpdateJob,
|
||||
) (domain.BootstrapUpdateJob, error) {
|
||||
return s.base.EnqueueLoginMessage(ctx, job)
|
||||
}
|
||||
|
||||
func (s *BatchedBootstrapUpdateJobStore) MarkReadyForSession(
|
||||
ctx context.Context,
|
||||
userID int64,
|
||||
authKeyID [8]byte,
|
||||
sessionID int64,
|
||||
) (int, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
request := bootstrapReadyBatchRequest{
|
||||
userID: userID, authKeyID: authKeyID, sessionID: sessionID,
|
||||
result: make(chan bootstrapReadyBatchResult, 1),
|
||||
}
|
||||
s.gate.RLock()
|
||||
if s.closed {
|
||||
s.gate.RUnlock()
|
||||
return 0, context.Canceled
|
||||
}
|
||||
select {
|
||||
case s.queue <- request:
|
||||
if s.cfg.Metrics != nil {
|
||||
s.cfg.Metrics.BootstrapReadyPending(1)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
s.gate.RUnlock()
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
s.gate.RUnlock()
|
||||
|
||||
// Accepted work ignores later caller cancellation and waits for the worker's
|
||||
// definitive result. This prevents a physically delivered baseline from
|
||||
// leaving an unknown asynchronous readiness mutation behind.
|
||||
result := <-request.result
|
||||
return result.matched, result.err
|
||||
}
|
||||
|
||||
func (s *BatchedBootstrapUpdateJobStore) ClaimReady(
|
||||
ctx context.Context,
|
||||
limit int,
|
||||
leaseTimeout time.Duration,
|
||||
) ([]domain.BootstrapUpdateJob, error) {
|
||||
return s.base.ClaimReady(ctx, limit, leaseTimeout)
|
||||
}
|
||||
|
||||
func (s *BatchedBootstrapUpdateJobStore) MarkPublished(ctx context.Context, id int64) error {
|
||||
return s.base.MarkPublished(ctx, id)
|
||||
}
|
||||
|
||||
func (s *BatchedBootstrapUpdateJobStore) MarkFailed(ctx context.Context, id int64, lastError string) error {
|
||||
return s.base.MarkFailed(ctx, id, lastError)
|
||||
}
|
||||
|
||||
func (s *BatchedBootstrapUpdateJobStore) Close() {
|
||||
s.once.Do(func() {
|
||||
s.gate.Lock()
|
||||
s.closed = true
|
||||
close(s.stop)
|
||||
s.cancel()
|
||||
s.gate.Unlock()
|
||||
<-s.done
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BatchedBootstrapUpdateJobStore) run(ctx context.Context) {
|
||||
defer close(s.done)
|
||||
pending := make([]bootstrapReadyBatchRequest, 0, s.cfg.MaxSize)
|
||||
for {
|
||||
if len(pending) == 0 {
|
||||
select {
|
||||
case request := <-s.queue:
|
||||
pending = append(pending, request)
|
||||
case <-s.stop:
|
||||
s.failQueued(context.Canceled, pending)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(pending) < s.cfg.MaxSize {
|
||||
timer := time.NewTimer(s.cfg.MaxWait)
|
||||
collect:
|
||||
for len(pending) < s.cfg.MaxSize {
|
||||
select {
|
||||
case request := <-s.queue:
|
||||
pending = append(pending, request)
|
||||
case <-timer.C:
|
||||
break collect
|
||||
case <-s.stop:
|
||||
stopAndDrainTimer(timer)
|
||||
s.failQueued(context.Canceled, pending)
|
||||
return
|
||||
}
|
||||
}
|
||||
stopAndDrainTimer(timer)
|
||||
}
|
||||
|
||||
batch, remaining := selectDistinctBootstrapReadyBatch(pending, s.cfg.MaxSize)
|
||||
pending = remaining
|
||||
s.execute(ctx, batch)
|
||||
}
|
||||
}
|
||||
|
||||
func stopAndDrainTimer(timer *time.Timer) {
|
||||
if timer != nil && !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func selectDistinctBootstrapReadyBatch(
|
||||
pending []bootstrapReadyBatchRequest,
|
||||
maxSize int,
|
||||
) ([]bootstrapReadyBatchRequest, []bootstrapReadyBatchRequest) {
|
||||
batch := make([]bootstrapReadyBatchRequest, 0, min(maxSize, len(pending)))
|
||||
remaining := make([]bootstrapReadyBatchRequest, 0, len(pending))
|
||||
seen := make(map[bootstrapReadyBatchKey]struct{}, min(maxSize, len(pending)))
|
||||
for _, request := range pending {
|
||||
if len(batch) >= maxSize {
|
||||
remaining = append(remaining, request)
|
||||
continue
|
||||
}
|
||||
key := bootstrapReadyBatchKey{userID: request.userID, authKeyID: request.authKeyID}
|
||||
if _, exists := seen[key]; exists {
|
||||
remaining = append(remaining, request)
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
batch = append(batch, request)
|
||||
}
|
||||
return batch, remaining
|
||||
}
|
||||
|
||||
func (s *BatchedBootstrapUpdateJobStore) execute(ctx context.Context, batch []bootstrapReadyBatchRequest) {
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
started := time.Now()
|
||||
queryCtx, cancel := context.WithTimeout(ctx, s.cfg.QueryTimeout)
|
||||
results, err := s.base.markReadyForSessions(queryCtx, batch)
|
||||
cancel()
|
||||
matched := 0
|
||||
if err == nil {
|
||||
if len(results) != len(batch) {
|
||||
err = fmt.Errorf("mark bootstrap readiness batch: result count %d, want %d", len(results), len(batch))
|
||||
} else {
|
||||
for _, count := range results {
|
||||
matched += count
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.cfg.Metrics != nil {
|
||||
s.cfg.Metrics.BootstrapReadyBatch(len(batch), matched, time.Since(started), err)
|
||||
}
|
||||
for index, request := range batch {
|
||||
result := bootstrapReadyBatchResult{err: err}
|
||||
if err == nil {
|
||||
result.matched = results[index]
|
||||
}
|
||||
request.result <- result
|
||||
if s.cfg.Metrics != nil {
|
||||
s.cfg.Metrics.BootstrapReadyPending(-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BatchedBootstrapUpdateJobStore) failQueued(err error, pending []bootstrapReadyBatchRequest) {
|
||||
for _, request := range pending {
|
||||
s.failRequest(request, err)
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case request := <-s.queue:
|
||||
s.failRequest(request, err)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BatchedBootstrapUpdateJobStore) failRequest(request bootstrapReadyBatchRequest, err error) {
|
||||
request.result <- bootstrapReadyBatchResult{err: err}
|
||||
if s.cfg.Metrics != nil {
|
||||
s.cfg.Metrics.BootstrapReadyPending(-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BootstrapUpdateJobStore) markReadyForSessions(
|
||||
ctx context.Context,
|
||||
requests []bootstrapReadyBatchRequest,
|
||||
) ([]int, error) {
|
||||
results := make([]int, len(requests))
|
||||
if len(requests) == 0 {
|
||||
return results, nil
|
||||
}
|
||||
userIDs := make([]int64, len(requests))
|
||||
authKeyIDs := make([]int64, len(requests))
|
||||
sessionIDs := make([]int64, len(requests))
|
||||
seen := make(map[bootstrapReadyBatchKey]struct{}, len(requests))
|
||||
for index, request := range requests {
|
||||
key := bootstrapReadyBatchKey{userID: request.userID, authKeyID: request.authKeyID}
|
||||
if _, duplicate := seen[key]; duplicate {
|
||||
return nil, fmt.Errorf("mark bootstrap readiness batch: duplicate fence at index %d", index)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
userIDs[index] = request.userID
|
||||
authKeyIDs[index] = authKeyIDToInt64(request.authKeyID)
|
||||
sessionIDs[index] = request.sessionID
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
WITH input AS (
|
||||
SELECT *
|
||||
FROM unnest(
|
||||
$1::bigint[],
|
||||
$2::bigint[],
|
||||
$3::bigint[]
|
||||
) WITH ORDINALITY AS value(user_id, auth_key_id, session_id, ordinal)
|
||||
), candidates AS MATERIALIZED (
|
||||
SELECT input.ordinal,
|
||||
input.session_id,
|
||||
jobs.id
|
||||
FROM input
|
||||
JOIN bootstrap_update_jobs AS jobs
|
||||
ON jobs.user_id = input.user_id
|
||||
AND jobs.auth_key_id = input.auth_key_id
|
||||
AND jobs.status = 'pending'
|
||||
ORDER BY jobs.id, input.ordinal
|
||||
FOR UPDATE OF jobs
|
||||
), updated AS (
|
||||
UPDATE bootstrap_update_jobs AS jobs
|
||||
SET status = 'ready',
|
||||
session_id = candidates.session_id,
|
||||
ready_at = now(),
|
||||
updated_at = now()
|
||||
FROM candidates
|
||||
WHERE jobs.id = candidates.id
|
||||
RETURNING candidates.ordinal
|
||||
)
|
||||
SELECT ordinal, count(*)::bigint
|
||||
FROM updated
|
||||
GROUP BY ordinal
|
||||
ORDER BY ordinal`, userIDs, authKeyIDs, sessionIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mark bootstrap readiness batch: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var ordinal, count int64
|
||||
if err := rows.Scan(&ordinal, &count); err != nil {
|
||||
return nil, fmt.Errorf("scan bootstrap readiness batch: %w", err)
|
||||
}
|
||||
index := int(ordinal - 1)
|
||||
if index < 0 || index >= len(results) || results[index] != 0 || count <= 0 {
|
||||
return nil, fmt.Errorf("mark bootstrap readiness batch: invalid ordinal/count %d/%d", ordinal, count)
|
||||
}
|
||||
results[index] = int(count)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("mark bootstrap readiness batch rows: %w", err)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
var _ store.BootstrapUpdateJobStore = (*BatchedBootstrapUpdateJobStore)(nil)
|
||||
172
internal/store/postgres/bootstrap_update_job_batch_test.go
Normal file
172
internal/store/postgres/bootstrap_update_job_batch_test.go
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestSelectDistinctBootstrapReadyBatchDefersSameFence(t *testing.T) {
|
||||
first := bootstrapReadyBatchRequest{userID: 1, authKeyID: [8]byte{1}, sessionID: 10}
|
||||
duplicate := bootstrapReadyBatchRequest{userID: 1, authKeyID: [8]byte{1}, sessionID: 11}
|
||||
other := bootstrapReadyBatchRequest{userID: 1, authKeyID: [8]byte{2}, sessionID: 12}
|
||||
batch, remaining := selectDistinctBootstrapReadyBatch(
|
||||
[]bootstrapReadyBatchRequest{first, duplicate, other},
|
||||
3,
|
||||
)
|
||||
if len(batch) != 2 || batch[0].sessionID != 10 || batch[1].sessionID != 12 {
|
||||
t.Fatalf("batch = %#v", batch)
|
||||
}
|
||||
if len(remaining) != 1 || remaining[0].sessionID != 11 {
|
||||
t.Fatalf("remaining = %#v", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchedBootstrapUpdateJobStoreCoalescesSynchronousSelectors(t *testing.T) {
|
||||
const count = 16
|
||||
backend := &fakeBootstrapReadyBackend{}
|
||||
batcher, err := newBatchedBootstrapUpdateJobStore(backend, BootstrapReadyBatchConfig{
|
||||
MaxSize: count, MaxWait: 100 * time.Millisecond,
|
||||
QueueSize: count * 2, QueryTimeout: time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(batcher.Close)
|
||||
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, count)
|
||||
var wg sync.WaitGroup
|
||||
for index := 0; index < count; index++ {
|
||||
index := index
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
matched, err := batcher.MarkReadyForSession(
|
||||
context.Background(), int64(index+1), [8]byte{byte(index + 1)}, int64(index+100),
|
||||
)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
} else if matched != 0 {
|
||||
errs <- errors.New("unexpected bootstrap readiness match")
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls := backend.calls.Load(); calls != 1 {
|
||||
t.Fatalf("batch calls = %d, want 1", calls)
|
||||
}
|
||||
if inputs := backend.inputs.Load(); inputs != count {
|
||||
t.Fatalf("batch inputs = %d, want %d", inputs, count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchedBootstrapUpdateJobStoreCapacityAndShutdownAreExplicit(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
backend := &fakeBootstrapReadyBackend{started: started, block: true}
|
||||
metrics := &fakeBootstrapReadyMetrics{}
|
||||
batcher, err := newBatchedBootstrapUpdateJobStore(backend, BootstrapReadyBatchConfig{
|
||||
MaxSize: 1, MaxWait: time.Millisecond, QueueSize: 1, QueryTimeout: time.Second, Metrics: metrics,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
results := make(chan error, 2)
|
||||
go func() {
|
||||
_, err := batcher.MarkReadyForSession(context.Background(), 1, [8]byte{1}, 1)
|
||||
results <- err
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("first batch did not start")
|
||||
}
|
||||
go func() {
|
||||
_, err := batcher.MarkReadyForSession(context.Background(), 2, [8]byte{2}, 2)
|
||||
results <- err
|
||||
}()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for metrics.pending.Load() != 2 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if metrics.pending.Load() != 2 {
|
||||
t.Fatalf("pending = %d, want 2", metrics.pending.Load())
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := batcher.MarkReadyForSession(ctx, 3, [8]byte{3}, 3); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("capacity wait err = %v, want deadline exceeded", err)
|
||||
}
|
||||
batcher.Close()
|
||||
for range 2 {
|
||||
if err := <-results; !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("shutdown result = %v, want canceled", err)
|
||||
}
|
||||
}
|
||||
if pending := metrics.pending.Load(); pending != 0 {
|
||||
t.Fatalf("pending after shutdown = %d", pending)
|
||||
}
|
||||
if _, err := batcher.MarkReadyForSession(context.Background(), 4, [8]byte{4}, 4); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("mark after close err = %v, want canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeBootstrapReadyBackend struct {
|
||||
calls atomic.Int64
|
||||
inputs atomic.Int64
|
||||
started chan struct{}
|
||||
block bool
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (s *fakeBootstrapReadyBackend) markReadyForSessions(ctx context.Context, requests []bootstrapReadyBatchRequest) ([]int, error) {
|
||||
s.calls.Add(1)
|
||||
s.inputs.Add(int64(len(requests)))
|
||||
if s.started != nil {
|
||||
s.once.Do(func() { close(s.started) })
|
||||
}
|
||||
if s.block {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return make([]int, len(requests)), nil
|
||||
}
|
||||
|
||||
func (*fakeBootstrapReadyBackend) EnqueueLoginMessage(context.Context, domain.BootstrapUpdateJob) (domain.BootstrapUpdateJob, error) {
|
||||
return domain.BootstrapUpdateJob{}, nil
|
||||
}
|
||||
|
||||
func (*fakeBootstrapReadyBackend) MarkReadyForSession(context.Context, int64, [8]byte, int64) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (*fakeBootstrapReadyBackend) ClaimReady(context.Context, int, time.Duration) ([]domain.BootstrapUpdateJob, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (*fakeBootstrapReadyBackend) MarkPublished(context.Context, int64) error { return nil }
|
||||
|
||||
func (*fakeBootstrapReadyBackend) MarkFailed(context.Context, int64, string) error { return nil }
|
||||
|
||||
type fakeBootstrapReadyMetrics struct {
|
||||
pending atomic.Int64
|
||||
}
|
||||
|
||||
func (*fakeBootstrapReadyMetrics) BootstrapReadyBatch(int, int, time.Duration, error) {}
|
||||
|
||||
func (m *fakeBootstrapReadyMetrics) BootstrapReadyPending(delta int) {
|
||||
m.pending.Add(int64(delta))
|
||||
}
|
||||
|
|
@ -51,3 +51,60 @@ func TestBootstrapUpdateJobPostgresSameAuthKeyReconnectTakesOverPendingSession(t
|
|||
t.Fatalf("bootstrap status/session = %s/%d, want ready/%d", status, sessionID, newSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapUpdateJobPostgresMarksReadinessBatchByOrdinal(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
user := createLoginCodeDeliveryTestUser(t, ctx, pool, "bootstrap-batch")
|
||||
messages := NewMessageStore(pool)
|
||||
bootstrap := NewBootstrapUpdateJobStore(pool)
|
||||
authKeyID := [8]byte{2, 4, 6, 8}
|
||||
for index := 0; index < 2; index++ {
|
||||
msg, err := messages.Create(ctx, domain.Message{
|
||||
OwnerUserID: user.ID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
Date: int(time.Now().Unix()) + index,
|
||||
Body: "Login code batch",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create bootstrap message %d: %v", index, err)
|
||||
}
|
||||
if _, err := bootstrap.EnqueueLoginMessage(ctx, domain.BootstrapUpdateJob{
|
||||
Kind: domain.BootstrapUpdateJobLoginMessage, UserID: user.ID,
|
||||
AuthKeyID: authKeyID, SessionID: int64(100 + index), MessageBoxID: msg.ID,
|
||||
}); err != nil {
|
||||
t.Fatalf("enqueue bootstrap %d: %v", index, err)
|
||||
}
|
||||
}
|
||||
|
||||
results, err := bootstrap.markReadyForSessions(ctx, []bootstrapReadyBatchRequest{
|
||||
{userID: user.ID + 1, authKeyID: authKeyID, sessionID: 700},
|
||||
{userID: user.ID, authKeyID: [8]byte{9}, sessionID: 701},
|
||||
{userID: user.ID, authKeyID: authKeyID, sessionID: 702},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 3 || results[0] != 0 || results[1] != 0 || results[2] != 2 {
|
||||
t.Fatalf("batch results = %#v, want [0 0 2]", results)
|
||||
}
|
||||
var count int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT count(*)
|
||||
FROM bootstrap_update_jobs
|
||||
WHERE user_id = $1 AND auth_key_id = $2 AND status = 'ready' AND session_id = $3`,
|
||||
user.ID, authKeyIDToInt64(authKeyID), int64(702)).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("ready jobs = %d, want 2", count)
|
||||
}
|
||||
|
||||
if _, err := bootstrap.markReadyForSessions(ctx, []bootstrapReadyBatchRequest{
|
||||
{userID: user.ID, authKeyID: authKeyID, sessionID: 1},
|
||||
{userID: user.ID, authKeyID: authKeyID, sessionID: 2},
|
||||
}); err == nil {
|
||||
t.Fatal("duplicate fence accepted in one batch")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,13 +130,10 @@ func (s *BotStore) DeleteBotAccount(ctx context.Context, botUserID int64) (domai
|
|||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := enqueueAccountDeletionNotifications(ctx, tx, botUserID); err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if _, err := revokeByUserExceptTx(ctx, tx, botUserID, 0); err != nil {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: revoke sessions: %w", err)
|
||||
}
|
||||
if err := purgeDeletedAccountPrivateState(ctx, tx, botUserID, now); err != nil {
|
||||
if err := purgeDeletedBotPrivateState(ctx, tx, botUserID, now); err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, botUserID, "", ""); err != nil {
|
||||
|
|
|
|||
|
|
@ -74,11 +74,11 @@ const (
|
|||
maxVerificationIconNameBytes = 512
|
||||
maxVerifierCompanyBytes = 512
|
||||
// Rune-counting domain limits use their worst-case UTF-8 byte size in SQL.
|
||||
// The final generated description may be longer than the 70-rune custom input.
|
||||
maxVerifierDescriptionBytes = 280
|
||||
// The final generated description may be longer than the custom-input limit.
|
||||
maxVerifierDescriptionBytes = 4 * domain.MaxCustomVerificationDescriptionLength
|
||||
maxVerifierGrantReasonBytes = 4096
|
||||
maxCustomVerificationDescriptionBytes = 4096
|
||||
maxCustomVerificationInputBytes = 280
|
||||
maxCustomVerificationInputBytes = 4 * domain.MaxCustomVerificationDescriptionLength
|
||||
maxCustomVerificationTitleBytes = 1024
|
||||
maxCustomVerificationUsernameBytes = 64
|
||||
maxCustomVerificationReasonBytes = 16384
|
||||
|
|
|
|||
|
|
@ -1006,7 +1006,7 @@ func TestCustomVerificationRequestQueuePostgres(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestBotVerificationDescriptionsAcceptEmojiPostgres pins the app-configured
|
||||
// 70-rune custom-description limit against UTF-8 byte constraints.
|
||||
// configured custom-description limit against UTF-8 byte constraints.
|
||||
func TestBotVerificationDescriptionsAcceptEmojiPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
|
|
|
|||
|
|
@ -13,6 +13,25 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func (s *PasswordStore) HasBusinessAutomation(ctx context.Context, userID int64) (bool, error) {
|
||||
var exists bool
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_business_profiles
|
||||
WHERE user_id = $1
|
||||
AND (greeting_message <> '{}'::jsonb OR away_message <> '{}'::jsonb)
|
||||
UNION ALL
|
||||
SELECT 1
|
||||
FROM business_connected_bots
|
||||
WHERE owner_user_id = $1
|
||||
)`, userID).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check business automation: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) GetBusinessProfile(ctx context.Context, userID int64) (domain.BusinessProfile, bool, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
|
|
|
|||
|
|
@ -296,6 +296,46 @@ func TestBusinessStoresRoundTrip(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestHasBusinessAutomationUsesConfiguredGreetingOrAwayState(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
owner, err := NewUserStore(pool).Create(ctx, domain.User{
|
||||
AccessHash: 51,
|
||||
Phone: "+1999" + suffix + "01",
|
||||
FirstName: "AutomationOwner",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
|
||||
})
|
||||
|
||||
business := NewPasswordStore(pool)
|
||||
if got, err := business.HasBusinessAutomation(ctx, owner.ID); err != nil || got {
|
||||
t.Fatalf("empty HasBusinessAutomation = %v, %v; want false, nil", got, err)
|
||||
}
|
||||
if err := business.SaveBusinessProfile(ctx, domain.BusinessProfile{
|
||||
UserID: owner.ID,
|
||||
Intro: &domain.BusinessIntro{Title: "profile only"},
|
||||
}); err != nil {
|
||||
t.Fatalf("save non-automation profile: %v", err)
|
||||
}
|
||||
if got, err := business.HasBusinessAutomation(ctx, owner.ID); err != nil || got {
|
||||
t.Fatalf("profile-only HasBusinessAutomation = %v, %v; want false, nil", got, err)
|
||||
}
|
||||
if err := business.SaveBusinessProfile(ctx, domain.BusinessProfile{
|
||||
UserID: owner.ID,
|
||||
Greeting: &domain.BusinessGreetingMessage{ShortcutID: 7},
|
||||
}); err != nil {
|
||||
t.Fatalf("save greeting profile: %v", err)
|
||||
}
|
||||
if got, err := business.HasBusinessAutomation(ctx, owner.ID); err != nil || !got {
|
||||
t.Fatalf("greeting HasBusinessAutomation = %v, %v; want true, nil", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func randomSuffix(t *testing.T) string {
|
||||
t.Helper()
|
||||
var b [4]byte
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestChannelActiveMembershipGenerationCoversMonoforumVisibility(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
owner, err := users.Create(ctx, domain.User{AccessHash: 971, Phone: "+1886" + suffix + "01", FirstName: "MonoVersionOwner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
subscriber, err := users.Create(ctx, domain.User{AccessHash: 972, Phone: "+1886" + suffix + "02", FirstName: "MonoVersionSubscriber"})
|
||||
if err != nil {
|
||||
t.Fatalf("create subscriber: %v", err)
|
||||
}
|
||||
channels := NewChannelStore(pool)
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID, Title: "Mono Version " + suffix, Broadcast: true, Date: 1700007110,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
enabled, err := channels.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enable monoforum: %v", err)
|
||||
}
|
||||
monoID := enabled.Channel.LinkedMonoforumID
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", []int64{created.Channel.ID, monoID})
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, subscriber.ID})
|
||||
})
|
||||
version := func(userID int64) int64 {
|
||||
t.Helper()
|
||||
var value int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT COALESCE((
|
||||
SELECT version
|
||||
FROM read_model_versions
|
||||
WHERE model = 'channel_active_memberships'
|
||||
AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1
|
||||
), 0)`, userID).Scan(&value); err != nil {
|
||||
t.Fatalf("read generation for %d: %v", userID, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
beforeSend := version(subscriber.ID)
|
||||
sent, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||
MonoforumID: monoID, SenderUserID: subscriber.ID,
|
||||
SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: subscriber.ID},
|
||||
RandomID: 7711, Message: "visibility", Date: 1700007111,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send monoforum message: %v", err)
|
||||
}
|
||||
if after := version(subscriber.ID); after <= beforeSend {
|
||||
t.Fatalf("subscriber generation after send = %d, want > %d", after, beforeSend)
|
||||
}
|
||||
active, err := channels.ListActiveChannelIDsForUser(ctx, subscriber.ID, 0, 1000)
|
||||
if err != nil {
|
||||
t.Fatalf("list subscriber active IDs: %v", err)
|
||||
}
|
||||
if !containsInt64(active, monoID) {
|
||||
t.Fatalf("subscriber active IDs = %v, want monoforum %d", active, monoID)
|
||||
}
|
||||
|
||||
beforeDelete := version(subscriber.ID)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
UPDATE channel_messages
|
||||
SET deleted = true
|
||||
WHERE channel_id = $1 AND id = $2`, monoID, sent.Message.ID); err != nil {
|
||||
t.Fatalf("delete saved-peer message: %v", err)
|
||||
}
|
||||
if after := version(subscriber.ID); after <= beforeDelete {
|
||||
t.Fatalf("subscriber generation after delete = %d, want > %d", after, beforeDelete)
|
||||
}
|
||||
active, err = channels.ListActiveChannelIDsForUser(ctx, subscriber.ID, 0, 1000)
|
||||
if err != nil {
|
||||
t.Fatalf("list subscriber active IDs after delete: %v", err)
|
||||
}
|
||||
if containsInt64(active, monoID) {
|
||||
t.Fatalf("subscriber active IDs after last message delete = %v, monoforum remained", active)
|
||||
}
|
||||
|
||||
beforeRights := version(owner.ID)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
UPDATE channel_members
|
||||
SET admin_rights = admin_rights || '{"ManageDirectMessages": true}'::jsonb,
|
||||
updated_at = now()
|
||||
WHERE channel_id = $1 AND user_id = $2`, created.Channel.ID, owner.ID); err != nil {
|
||||
t.Fatalf("update manager rights: %v", err)
|
||||
}
|
||||
if after := version(owner.ID); after <= beforeRights {
|
||||
t.Fatalf("manager generation after rights = %d, want > %d", after, beforeRights)
|
||||
}
|
||||
|
||||
beforeToggleOwner := version(owner.ID)
|
||||
beforeToggleSubscriber := version(subscriber.ID)
|
||||
if _, err := channels.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, false); err != nil {
|
||||
t.Fatalf("disable monoforum: %v", err)
|
||||
}
|
||||
if after := version(owner.ID); after <= beforeToggleOwner {
|
||||
t.Fatalf("manager generation after disable = %d, want > %d", after, beforeToggleOwner)
|
||||
}
|
||||
// The subscriber no longer has a live message, so it is intentionally not
|
||||
// part of the toggle fan-out. A previous deleted row cannot manufacture a
|
||||
// new active-page dependency.
|
||||
if after := version(subscriber.ID); after != beforeToggleSubscriber {
|
||||
t.Fatalf("deleted-only subscriber generation after disable = %d, want %d", after, beforeToggleSubscriber)
|
||||
}
|
||||
}
|
||||
|
|
@ -115,7 +115,9 @@ func (s *ChannelStore) CreateChannel(ctx context.Context, req domain.CreateChann
|
|||
if err != nil {
|
||||
return domain.CreateChannelResult{}, fmt.Errorf("allocate channel message id: %w", err)
|
||||
}
|
||||
pts := 1
|
||||
// PTS 1 is the empty channel message-box baseline. The create service
|
||||
// message is the first real event, so its post-event state is 2.
|
||||
pts := domain.FirstChannelEventPts
|
||||
channel := domain.Channel{
|
||||
ID: channelID,
|
||||
AccessHash: accessHash,
|
||||
|
|
@ -167,6 +169,13 @@ func (s *ChannelStore) CreateChannel(ctx context.Context, req domain.CreateChann
|
|||
if err := insertChannelEventTx(ctx, tx, event); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE channel_update_checkpoints
|
||||
SET retained_through_pts = $2,
|
||||
updated_at = now()
|
||||
WHERE channel_id = $1`, channelID, domain.InitialChannelPts); err != nil {
|
||||
return domain.CreateChannelResult{}, fmt.Errorf("initialize channel pts baseline: %w", err)
|
||||
}
|
||||
for _, member := range members {
|
||||
readMax := 0
|
||||
if member.UserID == req.CreatorUserID {
|
||||
|
|
@ -301,6 +310,13 @@ func (s *ChannelStore) ResolveChannel(ctx context.Context, viewerUserID, channel
|
|||
return view, nil
|
||||
}
|
||||
|
||||
// AuthoritativeResolveChannelCache declares that ResolveChannel is already
|
||||
// protected by ChannelRowCache + ChannelMemberCache. Both consume exact
|
||||
// channel_base/channel_member invalidations, reject stale in-flight writes by
|
||||
// epoch, and flush after listener reconnect. The app layer must therefore not
|
||||
// place a second read_model_versions gate in front of this store path.
|
||||
func (*ChannelStore) AuthoritativeResolveChannelCache() {}
|
||||
|
||||
func (s *ChannelStore) GetChannels(ctx context.Context, viewerUserID int64, channelIDs []int64) ([]domain.ChannelView, error) {
|
||||
if viewerUserID == 0 || len(channelIDs) == 0 {
|
||||
return nil, nil
|
||||
|
|
@ -573,19 +589,6 @@ WHERE id = $1`, channel.ID, participants, admins, kicked, banned); err != nil {
|
|||
return channel, nil
|
||||
}
|
||||
|
||||
func addPeerRef(peer domain.Peer, currentChannelID int64, userRefs, channelRefs map[int64]struct{}) {
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
if peer.ID != 0 {
|
||||
userRefs[peer.ID] = struct{}{}
|
||||
}
|
||||
case domain.PeerTypeChannel:
|
||||
if peer.ID != 0 && peer.ID != currentChannelID {
|
||||
channelRefs[peer.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mapKeysInt64(items map[int64]struct{}) []int64 {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -7,6 +7,74 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestChannelCreateInitialPtsBaselinePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
owner := createTestUser(t, ctx, users, "+1887"+suffix+"80", "PtsOwner", "")
|
||||
var channelID int64
|
||||
t.Cleanup(func() {
|
||||
if channelID != 0 {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
|
||||
}
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
|
||||
})
|
||||
|
||||
channels := NewChannelStore(pool)
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
Title: "Initial pts " + suffix,
|
||||
Megagroup: true,
|
||||
Date: 1_700_001_180,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
channelID = created.Channel.ID
|
||||
if created.Channel.Pts != domain.FirstChannelEventPts || created.Message.Pts != domain.FirstChannelEventPts ||
|
||||
created.Event.Pts != domain.FirstChannelEventPts || created.Event.PtsCount != 1 {
|
||||
t.Fatalf("create result = channel:%+v message:%+v event:%+v, want first event 2/1", created.Channel, created.Message, created.Event)
|
||||
}
|
||||
|
||||
var channelPts, messagePts, eventPts, eventPtsCount, retainedFloor, latestPts int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT c.pts, m.pts, e.pts, e.pts_count, cp.retained_through_pts, cp.latest_pts
|
||||
FROM channels c
|
||||
JOIN channel_messages m ON m.channel_id = c.id AND m.id = c.top_message_id
|
||||
JOIN channel_update_events e ON e.channel_id = c.id AND e.message_id = m.id
|
||||
JOIN channel_update_checkpoints cp ON cp.channel_id = c.id
|
||||
WHERE c.id = $1`, channelID).Scan(
|
||||
&channelPts, &messagePts, &eventPts, &eventPtsCount, &retainedFloor, &latestPts,
|
||||
); err != nil {
|
||||
t.Fatalf("read persisted initial pts: %v", err)
|
||||
}
|
||||
if channelPts != domain.FirstChannelEventPts || messagePts != domain.FirstChannelEventPts ||
|
||||
eventPts != domain.FirstChannelEventPts || eventPtsCount != 1 ||
|
||||
retainedFloor != domain.InitialChannelPts || latestPts != domain.FirstChannelEventPts {
|
||||
t.Fatalf("persisted pts = channel:%d message:%d event:%d/%d checkpoint:%d/%d, want 2/2/2/1/1/2",
|
||||
channelPts, messagePts, eventPts, eventPtsCount, retainedFloor, latestPts)
|
||||
}
|
||||
fromBaseline, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
UserID: owner.ID, ChannelID: channelID, Pts: domain.InitialChannelPts, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("difference from baseline: %v", err)
|
||||
}
|
||||
if fromBaseline.TooLong || len(fromBaseline.Events) != 1 || fromBaseline.Events[0].Pts != domain.FirstChannelEventPts {
|
||||
t.Fatalf("difference from baseline = %+v, want create event at pts=2", fromBaseline)
|
||||
}
|
||||
fromZero, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
UserID: owner.ID, ChannelID: channelID, Pts: 0, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("difference from zero: %v", err)
|
||||
}
|
||||
if !fromZero.TooLong || fromZero.Pts != domain.FirstChannelEventPts || len(fromZero.NewMessages) != 1 {
|
||||
t.Fatalf("difference from zero = %+v, want complete snapshot at pts=2", fromZero)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelStoreGetChannelsBatchesVisibleAndPublicPreview(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package postgres
|
|||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
|
|
@ -12,6 +13,14 @@ type channelDialogCacheKey struct {
|
|||
channelID int64
|
||||
}
|
||||
|
||||
type channelDialogCacheEntry struct {
|
||||
dialog domain.ChannelDialog
|
||||
listVisible bool
|
||||
topMentioned bool
|
||||
topMediaUnread bool
|
||||
topUnreadProjected bool
|
||||
}
|
||||
|
||||
// ChannelDialogCache 缓存 viewer 作用域的频道 dialog 投影,由统一缓存原语
|
||||
// readmodelcache.Cache 承载(LRU 单条驱逐 / epoch 守卫 / singleflight 内建)。
|
||||
//
|
||||
|
|
@ -19,39 +28,59 @@ type channelDialogCacheKey struct {
|
|||
// dialog_light(viewer,channel) 三个 read model;ReadModelChangeListener 在写侧 NOTIFY 时
|
||||
// 失效对应键,重连时 flush。warm-from-list 经 put 回填(含 DefaultSendAs)。
|
||||
type ChannelDialogCache struct {
|
||||
cache *readmodelcache.Cache[channelDialogCacheKey, domain.ChannelDialog]
|
||||
cache *readmodelcache.Cache[channelDialogCacheKey, channelDialogCacheEntry]
|
||||
|
||||
indexMu sync.Mutex
|
||||
channelKeys map[int64]map[channelDialogCacheKey]struct{}
|
||||
}
|
||||
|
||||
func NewChannelDialogCache(max int) *ChannelDialogCache {
|
||||
cache := readmodelcache.New[channelDialogCacheKey, domain.ChannelDialog](readmodelcache.Config[channelDialogCacheKey, domain.ChannelDialog]{
|
||||
c := &ChannelDialogCache{channelKeys: make(map[int64]map[channelDialogCacheKey]struct{})}
|
||||
cache := readmodelcache.New[channelDialogCacheKey, channelDialogCacheEntry](readmodelcache.Config[channelDialogCacheKey, channelDialogCacheEntry]{
|
||||
MaxEntries: max,
|
||||
Clone: cloneChannelDialog,
|
||||
Clone: cloneChannelDialogCacheEntry,
|
||||
OnStore: c.indexEntry,
|
||||
OnRemove: c.unindexEntry,
|
||||
})
|
||||
if cache == nil {
|
||||
return nil
|
||||
}
|
||||
return &ChannelDialogCache{cache: cache}
|
||||
c.cache = cache
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *ChannelDialogCache) get(userID, channelID int64) (domain.ChannelDialog, bool) {
|
||||
if c == nil || userID == 0 || channelID == 0 {
|
||||
return domain.ChannelDialog{}, false
|
||||
}
|
||||
return c.cache.Peek(channelDialogCacheKey{userID: userID, channelID: channelID})
|
||||
entry, ok := c.cache.Peek(channelDialogCacheKey{userID: userID, channelID: channelID})
|
||||
return entry.dialog, ok
|
||||
}
|
||||
|
||||
func (c *ChannelDialogCache) getListProjection(userID, channelID int64) (channelDialogCacheEntry, bool) {
|
||||
if c == nil || userID == 0 || channelID == 0 {
|
||||
return channelDialogCacheEntry{}, false
|
||||
}
|
||||
entry, ok := c.cache.Peek(channelDialogCacheKey{userID: userID, channelID: channelID})
|
||||
return entry, ok && entry.listVisible
|
||||
}
|
||||
|
||||
func (c *ChannelDialogCache) getOrLoad(ctx context.Context, userID, channelID int64, load func() (domain.ChannelDialog, error)) (domain.ChannelDialog, error) {
|
||||
if c == nil || userID == 0 || channelID == 0 {
|
||||
return load()
|
||||
}
|
||||
return c.cache.GetOrLoad(ctx, channelDialogCacheKey{userID: userID, channelID: channelID}, load)
|
||||
entry, err := c.cache.GetOrLoad(ctx, channelDialogCacheKey{userID: userID, channelID: channelID}, func() (channelDialogCacheEntry, error) {
|
||||
dialog, err := load()
|
||||
return channelDialogCacheEntry{dialog: dialog}, err
|
||||
})
|
||||
return entry.dialog, err
|
||||
}
|
||||
|
||||
func (c *ChannelDialogCache) put(dialog domain.ChannelDialog) {
|
||||
if c == nil || dialog.UserID == 0 || dialog.ChannelID == 0 {
|
||||
return
|
||||
}
|
||||
c.cache.Store(channelDialogCacheKey{userID: dialog.UserID, channelID: dialog.ChannelID}, dialog)
|
||||
c.cache.Store(channelDialogCacheKey{userID: dialog.UserID, channelID: dialog.ChannelID}, channelDialogCacheEntry{dialog: dialog})
|
||||
}
|
||||
|
||||
// cacheEpoch 在「列表暖写回」前快照 epoch;配合 putIfEpoch 堵住 warm-vs-invalidation
|
||||
|
|
@ -67,7 +96,30 @@ func (c *ChannelDialogCache) putIfEpoch(dialog domain.ChannelDialog, loadEpoch u
|
|||
if c == nil || dialog.UserID == 0 || dialog.ChannelID == 0 {
|
||||
return
|
||||
}
|
||||
c.cache.StoreIfEpoch(channelDialogCacheKey{userID: dialog.UserID, channelID: dialog.ChannelID}, dialog, loadEpoch)
|
||||
c.cache.StoreIfEpoch(channelDialogCacheKey{userID: dialog.UserID, channelID: dialog.ChannelID}, channelDialogCacheEntry{dialog: dialog}, loadEpoch)
|
||||
}
|
||||
|
||||
func (c *ChannelDialogCache) putListProjectionIfEpoch(
|
||||
dialog domain.ChannelDialog,
|
||||
topMentioned bool,
|
||||
topMediaUnread bool,
|
||||
topUnreadProjected bool,
|
||||
loadEpoch uint64,
|
||||
) {
|
||||
if c == nil || dialog.UserID == 0 || dialog.ChannelID == 0 {
|
||||
return
|
||||
}
|
||||
c.cache.StoreIfEpoch(
|
||||
channelDialogCacheKey{userID: dialog.UserID, channelID: dialog.ChannelID},
|
||||
channelDialogCacheEntry{
|
||||
dialog: dialog,
|
||||
listVisible: true,
|
||||
topMentioned: topMentioned,
|
||||
topMediaUnread: topMediaUnread,
|
||||
topUnreadProjected: topUnreadProjected,
|
||||
},
|
||||
loadEpoch,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *ChannelDialogCache) delete(userID, channelID int64) {
|
||||
|
|
@ -81,7 +133,14 @@ func (c *ChannelDialogCache) deleteChannel(channelID int64) {
|
|||
if c == nil || channelID == 0 {
|
||||
return
|
||||
}
|
||||
c.cache.InvalidateWhere(func(k channelDialogCacheKey) bool { return k.channelID == channelID })
|
||||
c.indexMu.Lock()
|
||||
indexed := c.channelKeys[channelID]
|
||||
keys := make([]channelDialogCacheKey, 0, len(indexed))
|
||||
for key := range indexed {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
c.indexMu.Unlock()
|
||||
c.cache.Invalidate(keys...)
|
||||
}
|
||||
|
||||
func (c *ChannelDialogCache) flush() {
|
||||
|
|
@ -89,12 +148,36 @@ func (c *ChannelDialogCache) flush() {
|
|||
return
|
||||
}
|
||||
c.cache.Flush()
|
||||
c.indexMu.Lock()
|
||||
c.channelKeys = make(map[int64]map[channelDialogCacheKey]struct{})
|
||||
c.indexMu.Unlock()
|
||||
}
|
||||
|
||||
func cloneChannelDialog(dialog domain.ChannelDialog) domain.ChannelDialog {
|
||||
if dialog.DefaultSendAs != nil {
|
||||
peer := *dialog.DefaultSendAs
|
||||
dialog.DefaultSendAs = &peer
|
||||
func (c *ChannelDialogCache) indexEntry(key channelDialogCacheKey, _ channelDialogCacheEntry) {
|
||||
c.indexMu.Lock()
|
||||
keys := c.channelKeys[key.channelID]
|
||||
if keys == nil {
|
||||
keys = make(map[channelDialogCacheKey]struct{})
|
||||
c.channelKeys[key.channelID] = keys
|
||||
}
|
||||
return dialog
|
||||
keys[key] = struct{}{}
|
||||
c.indexMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *ChannelDialogCache) unindexEntry(key channelDialogCacheKey, _ channelDialogCacheEntry) {
|
||||
c.indexMu.Lock()
|
||||
keys := c.channelKeys[key.channelID]
|
||||
delete(keys, key)
|
||||
if len(keys) == 0 {
|
||||
delete(c.channelKeys, key.channelID)
|
||||
}
|
||||
c.indexMu.Unlock()
|
||||
}
|
||||
|
||||
func cloneChannelDialogCacheEntry(entry channelDialogCacheEntry) channelDialogCacheEntry {
|
||||
if entry.dialog.DefaultSendAs != nil {
|
||||
peer := *entry.dialog.DefaultSendAs
|
||||
entry.dialog.DefaultSendAs = &peer
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,26 @@ func TestChannelDialogCachePutGetDeleteFlushAndClone(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChannelDialogCacheSeparatesAuthoritativeListProjection(t *testing.T) {
|
||||
c := NewChannelDialogCache(16)
|
||||
dialog := domain.ChannelDialog{UserID: 10, ChannelID: 20, TopMessageID: 9}
|
||||
c.put(dialog)
|
||||
if _, ok := c.getListProjection(10, 20); ok {
|
||||
t.Fatal("single-channel cache entry without active-list proof must not enter getDialogs")
|
||||
}
|
||||
epoch := c.cacheEpoch()
|
||||
c.putListProjectionIfEpoch(dialog, true, true, true, epoch)
|
||||
entry, ok := c.getListProjection(10, 20)
|
||||
if !ok || !entry.listVisible || !entry.topMentioned || !entry.topMediaUnread || !entry.topUnreadProjected {
|
||||
t.Fatalf("list projection = %+v,%v", entry, ok)
|
||||
}
|
||||
entry.dialog.TopMessageID = 99
|
||||
again, ok := c.getListProjection(10, 20)
|
||||
if !ok || again.dialog.TopMessageID != 9 {
|
||||
t.Fatalf("list projection clone isolation = %+v,%v", again, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDialogCacheDeleteChannelAndCap(t *testing.T) {
|
||||
c := NewChannelDialogCache(2)
|
||||
c.put(domain.ChannelDialog{UserID: 1, ChannelID: 10, TopMessageID: 1})
|
||||
|
|
|
|||
|
|
@ -96,7 +96,8 @@ func TestChannelStoreListDialogsWarmsDialogCache(t *testing.T) {
|
|||
})
|
||||
|
||||
cache := NewChannelDialogCache(16)
|
||||
channels := NewChannelStore(pool, WithChannelDialogCache(cache))
|
||||
memberCache := NewChannelMemberCache(16)
|
||||
channels := NewChannelStore(pool, WithChannelDialogCache(cache), WithChannelMemberCache(memberCache))
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
Title: "Dialog Warm " + suffix,
|
||||
|
|
@ -127,6 +128,80 @@ func TestChannelStoreListDialogsWarmsDialogCache(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChannelStoreMaterializedSnapshotWarmsExactDialogCache(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
|
||||
users := NewUserStore(pool)
|
||||
owner, err := users.Create(ctx, domain.User{
|
||||
AccessHash: 37,
|
||||
Phone: "+1777" + suffix + "14",
|
||||
FirstName: "SnapshotWarmOwner",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
var channelID int64
|
||||
t.Cleanup(func() {
|
||||
if channelID != 0 {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
|
||||
}
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
|
||||
})
|
||||
|
||||
cache := NewChannelDialogCache(16)
|
||||
memberCache := NewChannelMemberCache(16)
|
||||
channels := NewChannelStore(pool, WithChannelDialogCache(cache), WithChannelMemberCache(memberCache))
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
Title: "Snapshot Warm " + suffix,
|
||||
Megagroup: true,
|
||||
Date: 1700000326,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
channelID = created.Channel.ID
|
||||
if _, err := pool.Exec(ctx, `
|
||||
UPDATE channel_dialogs
|
||||
SET default_send_as_peer_type = 'channel', default_send_as_peer_id = $2
|
||||
WHERE user_id = $1 AND channel_id = $2`, owner.ID, channelID); err != nil {
|
||||
t.Fatalf("seed default send as: %v", err)
|
||||
}
|
||||
|
||||
ownerSnapshot, err := channels.ListAllBuiltinChannelDialogSnapshot(ctx, owner.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list materialized owner snapshot: %v", err)
|
||||
}
|
||||
if len(ownerSnapshot.Dialogs) != 1 || ownerSnapshot.Dialogs[0].DefaultSendAs == nil ||
|
||||
ownerSnapshot.Dialogs[0].DefaultSendAs.Type != domain.PeerTypeChannel ||
|
||||
ownerSnapshot.Dialogs[0].DefaultSendAs.ID != channelID ||
|
||||
ownerSnapshot.Dialogs[0].ChannelMember == nil ||
|
||||
ownerSnapshot.Dialogs[0].ChannelMember.UserID != owner.ID ||
|
||||
ownerSnapshot.Dialogs[0].ChannelMember.ChannelID != channelID ||
|
||||
ownerSnapshot.Dialogs[0].ChannelMember.Status != domain.ChannelMemberActive {
|
||||
t.Fatalf("owner snapshot default send as = %+v", ownerSnapshot.Dialogs)
|
||||
}
|
||||
if _, ok := cache.get(owner.ID, channelID); ok {
|
||||
t.Fatal("owner snapshot scan alone must not warm cache before shared hydration")
|
||||
}
|
||||
if _, err := channels.HydrateChannelDialogSnapshot(ctx, owner.ID, ownerSnapshot.Dialogs); err != nil {
|
||||
t.Fatalf("hydrate materialized owner snapshot: %v", err)
|
||||
}
|
||||
cached, ok := cache.get(owner.ID, channelID)
|
||||
if !ok || cached.TopMessageID != ownerSnapshot.Dialogs[0].TopMessage ||
|
||||
cached.DefaultSendAs == nil || cached.DefaultSendAs.Type != domain.PeerTypeChannel ||
|
||||
cached.DefaultSendAs.ID != channelID {
|
||||
t.Fatalf("exact warmed dialog = %+v ok=%v", cached, ok)
|
||||
}
|
||||
warmedMember, ok := memberCache.get(channelID, owner.ID)
|
||||
if !ok || warmedMember.ChannelID != channelID || warmedMember.UserID != owner.ID ||
|
||||
warmedMember.Status != domain.ChannelMemberActive || warmedMember.Role != domain.ChannelRoleCreator {
|
||||
t.Fatalf("exact warmed member = %+v ok=%v", warmedMember, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelStoreListDialogsScansChannelWallpaper(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
|
|
@ -315,6 +390,18 @@ FROM unnest($1::bigint[]) AS t(id)`, ids, owner.ID); err != nil {
|
|||
}
|
||||
|
||||
channels := NewChannelStore(pool)
|
||||
headers, err := channels.ListChannelDialogSnapshotHeaders(ctx, owner.ID, domain.DialogFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("list channel dialog snapshot headers: %v", err)
|
||||
}
|
||||
if len(headers.Dialogs) != count || headers.Count != count || len(headers.Messages) != 0 || len(headers.Channels) != 0 {
|
||||
t.Fatalf("snapshot headers dialogs=%d count=%d messages=%d channels=%d, want %d lightweight headers",
|
||||
len(headers.Dialogs), headers.Count, len(headers.Messages), len(headers.Channels), count)
|
||||
}
|
||||
if headers.Dialogs[0].Peer.ID != ids[len(ids)-1] || headers.Dialogs[len(headers.Dialogs)-1].Peer.ID != ids[0] {
|
||||
t.Fatalf("snapshot header bounds = %d..%d, want %d..%d",
|
||||
headers.Dialogs[0].Peer.ID, headers.Dialogs[len(headers.Dialogs)-1].Peer.ID, ids[len(ids)-1], ids[0])
|
||||
}
|
||||
var cursor domain.Dialog
|
||||
var sixth domain.ChannelDialogList
|
||||
for page := 0; page < 6; page++ {
|
||||
|
|
@ -332,6 +419,9 @@ FROM unnest($1::bigint[]) AS t(id)`, ids, owner.ID); err != nil {
|
|||
if len(got.Dialogs) == 0 {
|
||||
t.Fatalf("page %d unexpectedly empty after cursor %+v", page+1, cursor)
|
||||
}
|
||||
if page < 5 && got.Count <= len(got.Dialogs) {
|
||||
t.Fatalf("page %d count = %d dialogs = %d, want bounded has-more signal", page+1, got.Count, len(got.Dialogs))
|
||||
}
|
||||
cursor = got.Dialogs[len(got.Dialogs)-1]
|
||||
if page == 5 {
|
||||
sixth = got
|
||||
|
|
@ -423,4 +513,31 @@ VALUES ($1, $2, $3, 1, 1700000500)`, owner.ID, archivedID, domain.DialogArchiveF
|
|||
if len(archive.Dialogs) != 1 || archive.Dialogs[0].Peer.ID != archivedID {
|
||||
t.Fatalf("archive dialogs = %+v, want archived channel beyond first query window", archive.Dialogs)
|
||||
}
|
||||
archiveHeaders, err := NewChannelStore(pool).ListChannelDialogSnapshotHeaders(ctx, owner.ID, domain.DialogFilter{
|
||||
HasFolderID: true,
|
||||
FolderID: domain.DialogArchiveFolderID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list archive channel snapshot headers: %v", err)
|
||||
}
|
||||
if len(archiveHeaders.Dialogs) != 1 || archiveHeaders.Dialogs[0].Peer.ID != archivedID {
|
||||
t.Fatalf("archive snapshot headers = %+v, want archived channel", archiveHeaders.Dialogs)
|
||||
}
|
||||
allHeaders, err := NewChannelStore(pool).ListAllBuiltinChannelDialogSnapshot(ctx, owner.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list all built-in channel snapshot headers: %v", err)
|
||||
}
|
||||
if len(allHeaders.Dialogs) != count {
|
||||
t.Fatalf("all built-in snapshot headers = %d, want %d", len(allHeaders.Dialogs), count)
|
||||
}
|
||||
foundArchived := false
|
||||
for _, dialog := range allHeaders.Dialogs {
|
||||
if dialog.Peer.ID == archivedID {
|
||||
foundArchived = dialog.FolderID == domain.DialogArchiveFolderID
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundArchived {
|
||||
t.Fatalf("all built-in snapshot did not retain archived channel %d", archivedID)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ func TestChannelDialogTopMessageCarriesMentionFlags(t *testing.T) {
|
|||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID})
|
||||
})
|
||||
|
||||
channels := NewChannelStore(pool)
|
||||
channels := NewChannelStore(pool, WithChannelTopMessageCache(NewChannelTopMessageCache(32)))
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
Title: "MentionDialog " + suffix,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package postgres
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
|
@ -13,9 +14,13 @@ import (
|
|||
)
|
||||
|
||||
type channelDialogListItem struct {
|
||||
channel domain.Channel
|
||||
dialog domain.Dialog
|
||||
defaultSendAs *domain.Peer
|
||||
channel domain.Channel
|
||||
dialog domain.Dialog
|
||||
defaultSendAs *domain.Peer
|
||||
topMentioned bool
|
||||
topMediaUnread bool
|
||||
topUnreadProjected bool
|
||||
listCacheable bool
|
||||
}
|
||||
|
||||
func channelDialogVisibleTopIDSQL() string {
|
||||
|
|
@ -39,6 +44,10 @@ END`
|
|||
}
|
||||
|
||||
func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) {
|
||||
return s.listChannelDialogs(ctx, viewerUserID, filter)
|
||||
}
|
||||
|
||||
func (s *ChannelStore) listChannelDialogs(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) {
|
||||
if viewerUserID == 0 {
|
||||
return domain.ChannelDialogList{}, nil
|
||||
}
|
||||
|
|
@ -59,6 +68,21 @@ func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int6
|
|||
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
|
||||
args := []any{viewerUserID, channelIDs}
|
||||
where := []string{"m.user_id = $1", "m.channel_id = ANY($2::bigint[])", "m.status = 'active'"}
|
||||
from := `FROM channel_members m
|
||||
JOIN channels c ON c.id = m.channel_id AND c.id = ANY($2::bigint[]) AND NOT c.deleted
|
||||
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.channel_id = ANY($2::bigint[]) AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
|
||||
LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id`
|
||||
// archive 与 pinned 集合必然有显式 channel_dialogs 行。以该 owner 索引为入口,
|
||||
// 避免 archive summary(limit=1) 和 getPinnedDialogs 为一个通常为空/很小的集合
|
||||
// 仍扫描账号全部 active memberships。保留 $2 的 typed no-op,后续动态条件的
|
||||
// placeholder 编号无需分叉;monoforum 仍在主查询后按原权限路径合并。
|
||||
if (filter.HasFolderID && filter.FolderID == domain.DialogArchiveFolderID) || filter.PinnedOnly {
|
||||
from = `FROM channel_dialogs d
|
||||
JOIN channel_members m ON m.user_id = d.user_id AND m.channel_id = d.channel_id AND m.status = 'active'
|
||||
JOIN channels c ON c.id = d.channel_id AND NOT c.deleted
|
||||
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = d.channel_id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted`
|
||||
where = []string{"d.user_id = $1", "cardinality($2::bigint[]) >= 0"}
|
||||
}
|
||||
if filter.HasFolderID && filter.FolderID < domain.DialogCustomFolderMinID {
|
||||
args = append(args, filter.FolderID)
|
||||
where = append(where, fmt.Sprintf("COALESCE(d.folder_id, 0) = $%d", len(args)))
|
||||
|
|
@ -138,7 +162,13 @@ func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int6
|
|||
where = append(where, "false")
|
||||
}
|
||||
}
|
||||
args = append(args, channelDialogQueryLimit)
|
||||
// 只多取一行作为 has-more 证据。旧实现无视 RPC limit 固定读取 500 个完整
|
||||
// channel + viewer dialog,并对每行动态派生 unread;getDialogs(limit=100)
|
||||
// 因而最多水合 5 倍对象,archive summary(limit=1) 最坏放大 500 倍。
|
||||
// 所有 folder/offset 条件已经在 SQL LIMIT 前完成,monoforum 结果也会在下方
|
||||
// 合并排序,所以每个来源取 limit+1 足以构造正确的合并页和继续分页信号。
|
||||
queryLimit := limit + 1
|
||||
args = append(args, queryLimit)
|
||||
limitArg := fmt.Sprintf("$%d", len(args))
|
||||
// 暖写回的 epoch 守卫:在加载前快照,写回时若期间收到失效(epoch 变更)则拒绝陈旧投影,
|
||||
// 避免 scan→put 窗口内的并发失效被裸 Store 覆盖回(@角标/未读 lost-update)。
|
||||
|
|
@ -162,10 +192,7 @@ SELECT `+channelColumns+`,
|
|||
d.default_send_as_peer_id,
|
||||
m.history_clear_anchor_id,
|
||||
m.history_clear_anchor_date
|
||||
FROM channel_members m
|
||||
JOIN channels c ON c.id = m.channel_id AND c.id = ANY($2::bigint[]) AND NOT c.deleted
|
||||
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.channel_id = ANY($2::bigint[]) AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
|
||||
LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id
|
||||
`+from+`
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY COALESCE(d.pinned, false) DESC,
|
||||
COALESCE(d.pinned_order, 0) DESC,
|
||||
|
|
@ -243,7 +270,7 @@ LIMIT `+limitArg, args...)
|
|||
// default_send_as,否则 getFullChannel/getSendAs 命中暖缓存会丢失「以频道发言」默认值。
|
||||
cd := channelDialogFromDialog(viewerUserID, item.dialog)
|
||||
cd.DefaultSendAs = item.defaultSendAs
|
||||
s.dialogCache.putIfEpoch(cd, dialogCacheEpoch)
|
||||
s.dialogCache.putListProjectionIfEpoch(cd, false, false, false, dialogCacheEpoch)
|
||||
}
|
||||
out.Dialogs = append(out.Dialogs, item.dialog)
|
||||
out.Channels = append(out.Channels, item.channel)
|
||||
|
|
@ -251,13 +278,308 @@ LIMIT `+limitArg, args...)
|
|||
// getDialogs 的 top message 必须按 viewer 补 mentioned/media_unread 与
|
||||
// reactions:TDesktop 把它先入缓存且不被后续 difference/getHistory 的
|
||||
// 完整版覆盖,缺标志会让客户端永不上报 contents-read,@ 角标重启回潮。
|
||||
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil {
|
||||
if err := s.populateChannelDialogTopMessageReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages, false); err != nil {
|
||||
return domain.ChannelDialogList{}, err
|
||||
}
|
||||
projectChannelDialogHistoryClearMessages(out.Dialogs, out.Messages)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListChannelDialogSnapshotHeaders builds the bounded owner-specific ordering
|
||||
// index used by app pagination. It intentionally excludes channel metadata and
|
||||
// top-message payloads; those are hydrated per page through versioned peer read
|
||||
// models, so one owner snapshot remains lightweight and shared channel facts do
|
||||
// not get duplicated for every online account.
|
||||
func (s *ChannelStore) ListChannelDialogSnapshotHeaders(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) {
|
||||
if viewerUserID == 0 {
|
||||
return domain.ChannelDialogList{}, nil
|
||||
}
|
||||
if filter.Folder != nil || (filter.HasFolderID && filter.FolderID >= domain.DialogCustomFolderMinID) ||
|
||||
filter.OffsetDate != 0 || filter.OffsetID != 0 || filter.HasOffsetPeer {
|
||||
return domain.ChannelDialogList{}, errors.New("channel dialog snapshot headers require an offset-free built-in folder")
|
||||
}
|
||||
channelIDs, hasBroadcastAdmin, err := s.listActiveChannelDialogCandidateIDs(ctx, viewerUserID, false)
|
||||
if err != nil {
|
||||
return domain.ChannelDialogList{}, err
|
||||
}
|
||||
if len(channelIDs) == 0 {
|
||||
return domain.ChannelDialogList{}, nil
|
||||
}
|
||||
visibleTopID := channelDialogVisibleTopIDSQL()
|
||||
visibleTopDate := channelDialogVisibleTopDateSQL("COALESCE(top_msg.message_date, d.top_message_date, c.date)", "0")
|
||||
args := []any{viewerUserID}
|
||||
where := []string{"i.user_id = $1", "i.status = 'active'", "NOT i.deleted", "m.status = 'active'"}
|
||||
from := `FROM user_channel_member_index i
|
||||
JOIN channel_members m ON m.user_id = i.user_id AND m.channel_id = i.channel_id
|
||||
JOIN channels c ON c.id = i.channel_id AND NOT c.deleted
|
||||
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = i.channel_id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
|
||||
LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id`
|
||||
if (filter.HasFolderID && filter.FolderID == domain.DialogArchiveFolderID) || filter.PinnedOnly {
|
||||
from = `FROM channel_dialogs d
|
||||
JOIN user_channel_member_index i ON i.user_id = d.user_id AND i.channel_id = d.channel_id
|
||||
JOIN channel_members m ON m.user_id = i.user_id AND m.channel_id = i.channel_id
|
||||
JOIN channels c ON c.id = d.channel_id AND NOT c.deleted
|
||||
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = d.channel_id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted`
|
||||
where = []string{"d.user_id = $1", "i.status = 'active'", "NOT i.deleted", "m.status = 'active'"}
|
||||
}
|
||||
if filter.HasFolderID {
|
||||
args = append(args, filter.FolderID)
|
||||
where = append(where, fmt.Sprintf("COALESCE(d.folder_id, 0) = $%d", len(args)))
|
||||
} else {
|
||||
where = append(where, "COALESCE(d.folder_id, 0) = 0")
|
||||
}
|
||||
if filter.PinnedOnly {
|
||||
where = append(where, "COALESCE(d.pinned, false)")
|
||||
}
|
||||
if filter.ExcludePinned {
|
||||
where = append(where, "NOT COALESCE(d.pinned, false)")
|
||||
}
|
||||
args = append(args, channelDialogCandidateLimit)
|
||||
rows, err := s.db.Query(ctx, `
|
||||
WITH dependency_peers AS MATERIALIZED (
|
||||
SELECT i.channel_id AS id
|
||||
FROM user_channel_member_index i
|
||||
WHERE i.user_id = $1
|
||||
AND i.status = 'active'
|
||||
AND NOT i.deleted
|
||||
UNION
|
||||
SELECT parent.linked_monoforum_id
|
||||
FROM user_channel_member_index i
|
||||
JOIN channels parent ON parent.id = i.channel_id
|
||||
WHERE i.user_id = $1
|
||||
AND i.status = 'active'
|
||||
AND NOT i.deleted
|
||||
AND parent.linked_monoforum_id <> 0
|
||||
),
|
||||
dependency AS MATERIALIZED (
|
||||
SELECT COALESCE(bit_xor(v.hash), 0)::bigint AS hash
|
||||
FROM read_model_versions v
|
||||
JOIN dependency_peers peer ON peer.id = v.peer_id
|
||||
WHERE v.peer_type = 'channel'
|
||||
AND (
|
||||
(v.model = 'channel_base' AND v.owner_user_id = 0)
|
||||
OR
|
||||
(v.model IN ('channel_member', 'dialog_light') AND v.owner_user_id = $1)
|
||||
)
|
||||
)
|
||||
SELECT c.id,
|
||||
`+visibleTopID+`,
|
||||
`+visibleTopDate+`,
|
||||
COALESCE(d.folder_id, 0),
|
||||
COALESCE(d.pinned, false),
|
||||
COALESCE(d.pinned_order, 0),
|
||||
dependency.hash
|
||||
`+from+`
|
||||
CROSS JOIN dependency
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY COALESCE(d.pinned, false) DESC,
|
||||
COALESCE(d.pinned_order, 0) DESC,
|
||||
`+visibleTopDate+` DESC,
|
||||
`+visibleTopID+` DESC,
|
||||
c.id DESC
|
||||
LIMIT $`+fmt.Sprint(len(args)), args...)
|
||||
if err != nil {
|
||||
return domain.ChannelDialogList{}, fmt.Errorf("list channel dialog snapshot headers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
dialogs := make([]domain.Dialog, 0, minInt(len(channelIDs), 1024))
|
||||
seenChannels := make(map[int64]struct{}, len(channelIDs))
|
||||
var dependencyHash int64
|
||||
for rows.Next() {
|
||||
var dialog domain.Dialog
|
||||
var channelID int64
|
||||
if err := rows.Scan(
|
||||
&channelID,
|
||||
&dialog.TopMessage,
|
||||
&dialog.TopMessageDate,
|
||||
&dialog.FolderID,
|
||||
&dialog.Pinned,
|
||||
&dialog.PinnedOrder,
|
||||
&dependencyHash,
|
||||
); err != nil {
|
||||
return domain.ChannelDialogList{}, err
|
||||
}
|
||||
dialog.Peer = domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}
|
||||
dialogs = append(dialogs, dialog)
|
||||
seenChannels[channelID] = struct{}{}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.ChannelDialogList{}, err
|
||||
}
|
||||
if hasBroadcastAdmin {
|
||||
items, err := s.listMonoforumAdminDialogItems(ctx, viewerUserID, channelIDs, filter, seenChannels)
|
||||
if err != nil {
|
||||
return domain.ChannelDialogList{}, err
|
||||
}
|
||||
for _, item := range items {
|
||||
dialogs = append(dialogs, item.dialog)
|
||||
}
|
||||
}
|
||||
if len(dialogs) > channelDialogCandidateLimit {
|
||||
return domain.ChannelDialogList{}, fmt.Errorf("channel dialog snapshot exceeds %d entries", channelDialogCandidateLimit)
|
||||
}
|
||||
sort.SliceStable(dialogs, func(i, j int) bool {
|
||||
if dialogs[i].Pinned != dialogs[j].Pinned {
|
||||
return dialogs[i].Pinned
|
||||
}
|
||||
if dialogs[i].PinnedOrder != dialogs[j].PinnedOrder {
|
||||
return dialogs[i].PinnedOrder > dialogs[j].PinnedOrder
|
||||
}
|
||||
if dialogs[i].TopMessageDate != dialogs[j].TopMessageDate {
|
||||
return dialogs[i].TopMessageDate > dialogs[j].TopMessageDate
|
||||
}
|
||||
if dialogs[i].TopMessage != dialogs[j].TopMessage {
|
||||
return dialogs[i].TopMessage > dialogs[j].TopMessage
|
||||
}
|
||||
return dialogs[i].Peer.ID > dialogs[j].Peer.ID
|
||||
})
|
||||
return domain.ChannelDialogList{
|
||||
Dialogs: dialogs,
|
||||
Count: len(dialogs),
|
||||
Hash: mixDialogListDependencyHash(dialogListHash(dialogs), dependencyHash),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListAllBuiltinChannelDialogSnapshot loads every owner-varying dialog fact
|
||||
// needed to derive main/archive/pinned pages in one bounded scan. Shared
|
||||
// channel rows and top-message payloads remain channel-keyed response overlays.
|
||||
func (s *ChannelStore) ListAllBuiltinChannelDialogSnapshot(ctx context.Context, viewerUserID int64) (domain.ChannelDialogList, error) {
|
||||
if viewerUserID == 0 {
|
||||
return domain.ChannelDialogList{}, nil
|
||||
}
|
||||
visibleTopID := channelDialogVisibleTopIDSQL()
|
||||
visibleTopDate := channelDialogVisibleTopDateSQL("COALESCE(top_msg.message_date, d.top_message_date, c.date)", "0")
|
||||
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
|
||||
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT c.id,
|
||||
`+visibleTopID+`,
|
||||
`+visibleTopDate+`,
|
||||
COALESCE(d.folder_id, 0),
|
||||
`+visibleReadInbox+`,
|
||||
LEAST(GREATEST(c.top_message_id, 0), GREATEST(COALESCE(d.read_outbox_max_id, 0), m.read_outbox_max_id, CASE WHEN c.read_inbox_top1_user_id = m.user_id THEN c.read_inbox_top2 ELSE c.read_inbox_top1 END)),
|
||||
`+visibleUnreadCount+`,
|
||||
COALESCE(d.pinned, false),
|
||||
COALESCE(d.pinned_order, 0),
|
||||
COALESCE(d.unread_mark, m.unread_mark),
|
||||
COALESCE(d.unread_mentions_count, 0),
|
||||
COALESCE(d.unread_reactions_count, 0),
|
||||
COALESCE(d.view_forum_as_messages, false),
|
||||
COALESCE(d.has_scheduled, false),
|
||||
d.default_send_as_peer_type,
|
||||
d.default_send_as_peer_id,
|
||||
m.history_clear_anchor_id,
|
||||
m.history_clear_anchor_date,
|
||||
top_unread.message_id IS NOT NULL,
|
||||
COALESCE(top_unread.unread, false),
|
||||
m.user_id,
|
||||
m.inviter_user_id,
|
||||
m.role,
|
||||
m.status,
|
||||
m.joined_at,
|
||||
m.left_at,
|
||||
m.admin_rights::text,
|
||||
m.banned_rights::text,
|
||||
m.rank,
|
||||
m.available_min_id,
|
||||
m.available_min_pts,
|
||||
m.read_inbox_max_id,
|
||||
m.read_outbox_max_id,
|
||||
m.unread_mark,
|
||||
m.slowmode_last_send_date,
|
||||
bool_or(i.broadcast AND i.role IN ('creator', 'admin')) OVER ()
|
||||
FROM user_channel_member_index AS i
|
||||
JOIN channel_members AS m
|
||||
ON m.user_id = i.user_id
|
||||
AND m.channel_id = i.channel_id
|
||||
AND m.status = 'active'
|
||||
JOIN channels AS c
|
||||
ON c.id = i.channel_id
|
||||
AND NOT c.deleted
|
||||
LEFT JOIN channel_messages AS top_msg
|
||||
ON top_msg.channel_id = i.channel_id
|
||||
AND top_msg.id = c.top_message_id
|
||||
AND NOT top_msg.deleted
|
||||
LEFT JOIN channel_dialogs AS d
|
||||
ON d.user_id = i.user_id
|
||||
AND d.channel_id = i.channel_id
|
||||
LEFT JOIN channel_unread_mentions AS top_unread
|
||||
ON top_unread.user_id = i.user_id
|
||||
AND top_unread.channel_id = i.channel_id
|
||||
AND top_unread.message_id = `+visibleTopID+`
|
||||
WHERE i.user_id = $1
|
||||
AND i.status = 'active'
|
||||
AND NOT i.deleted
|
||||
AND COALESCE(d.folder_id, 0) IN (0, 1)
|
||||
ORDER BY COALESCE(d.pinned, false) DESC,
|
||||
COALESCE(d.pinned_order, 0) DESC,
|
||||
`+visibleTopDate+` DESC,
|
||||
`+visibleTopID+` DESC,
|
||||
c.id DESC
|
||||
LIMIT $2`, viewerUserID, channelDialogCandidateLimit+1)
|
||||
if err != nil {
|
||||
return domain.ChannelDialogList{}, fmt.Errorf("list all built-in channel dialog snapshot: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
dialogs := make([]domain.Dialog, 0, 128)
|
||||
parentChannelIDs := make([]int64, 0, 128)
|
||||
seenChannels := make(map[int64]struct{}, 128)
|
||||
hasBroadcastAdmin := false
|
||||
for rows.Next() {
|
||||
var values channelDialogProjectionValues
|
||||
var rowHasBroadcastAdmin bool
|
||||
destinations := append(values.scanDestinations(), &rowHasBroadcastAdmin)
|
||||
if err := rows.Scan(destinations...); err != nil {
|
||||
return domain.ChannelDialogList{}, fmt.Errorf("scan all built-in channel dialog snapshot: %w", err)
|
||||
}
|
||||
channelID, dialog, defaultSendAs, _, _ := values.result()
|
||||
dialog.DefaultSendAs = defaultSendAs
|
||||
dialogs = append(dialogs, dialog)
|
||||
parentChannelIDs = append(parentChannelIDs, channelID)
|
||||
seenChannels[channelID] = struct{}{}
|
||||
hasBroadcastAdmin = hasBroadcastAdmin || rowHasBroadcastAdmin
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.ChannelDialogList{}, fmt.Errorf("list all built-in channel dialog snapshot rows: %w", err)
|
||||
}
|
||||
if len(dialogs) > channelDialogCandidateLimit {
|
||||
return domain.ChannelDialogList{}, fmt.Errorf("channel dialog snapshot exceeds %d entries", channelDialogCandidateLimit)
|
||||
}
|
||||
if hasBroadcastAdmin {
|
||||
items, err := s.listMonoforumAdminDialogItems(
|
||||
ctx, viewerUserID, parentChannelIDs, domain.DialogFilter{}, seenChannels,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.ChannelDialogList{}, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.dialog.FolderID == domain.DialogMainFolderID || item.dialog.FolderID == domain.DialogArchiveFolderID {
|
||||
item.dialog.DefaultSendAs = item.defaultSendAs
|
||||
dialogs = append(dialogs, item.dialog)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(dialogs) > channelDialogCandidateLimit {
|
||||
return domain.ChannelDialogList{}, fmt.Errorf("channel dialog snapshot exceeds %d entries", channelDialogCandidateLimit)
|
||||
}
|
||||
sort.SliceStable(dialogs, func(i, j int) bool {
|
||||
if dialogs[i].Pinned != dialogs[j].Pinned {
|
||||
return dialogs[i].Pinned
|
||||
}
|
||||
if dialogs[i].PinnedOrder != dialogs[j].PinnedOrder {
|
||||
return dialogs[i].PinnedOrder > dialogs[j].PinnedOrder
|
||||
}
|
||||
if dialogs[i].TopMessageDate != dialogs[j].TopMessageDate {
|
||||
return dialogs[i].TopMessageDate > dialogs[j].TopMessageDate
|
||||
}
|
||||
if dialogs[i].TopMessage != dialogs[j].TopMessage {
|
||||
return dialogs[i].TopMessage > dialogs[j].TopMessage
|
||||
}
|
||||
return dialogs[i].Peer.ID > dialogs[j].Peer.ID
|
||||
})
|
||||
return domain.ChannelDialogList{Dialogs: dialogs, Count: len(dialogs)}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) listMonoforumAdminDialogItems(ctx context.Context, viewerUserID int64, parentChannelIDs []int64, filter domain.DialogFilter, seen map[int64]struct{}) ([]channelDialogListItem, error) {
|
||||
if viewerUserID == 0 || len(parentChannelIDs) == 0 {
|
||||
return nil, nil
|
||||
|
|
@ -331,7 +653,7 @@ type channelMessageLookupKey struct {
|
|||
|
||||
func (s *ChannelStore) channelDialogTopMessages(ctx context.Context, db sqlcgen.DBTX, dialogs []domain.Dialog) (map[channelMessageLookupKey]domain.ChannelMessage, error) {
|
||||
seen := make(map[channelMessageLookupKey]struct{}, len(dialogs))
|
||||
idsByChannel := make(map[int64][]int, len(dialogs))
|
||||
keys := make([]channelMessageLookupKey, 0, len(dialogs))
|
||||
for _, dialog := range dialogs {
|
||||
if dialog.Peer.Type != domain.PeerTypeChannel || dialog.Peer.ID == 0 || dialog.TopMessage <= 0 {
|
||||
continue
|
||||
|
|
@ -341,10 +663,55 @@ func (s *ChannelStore) channelDialogTopMessages(ctx context.Context, db sqlcgen.
|
|||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
idsByChannel[dialog.Peer.ID] = append(idsByChannel[dialog.Peer.ID], dialog.TopMessage)
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
load := func(ctx context.Context, missing []channelMessageLookupKey) (map[channelMessageLookupKey]domain.ChannelMessage, error) {
|
||||
return loadChannelDialogTopMessages(ctx, db, missing)
|
||||
}
|
||||
var (
|
||||
out map[channelMessageLookupKey]domain.ChannelMessage
|
||||
err error
|
||||
)
|
||||
if s.topMessageCacheActive(db) {
|
||||
out, err = s.topMsgCache.getOrLoadBatch(ctx, keys, load)
|
||||
} else {
|
||||
out, err = load(ctx, keys)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// History-clear anchors are owner-local projections and must never enter
|
||||
// the shared cache. Apply them to the cloned result after cache hydration.
|
||||
for _, dialog := range dialogs {
|
||||
if dialog.Peer.Type != domain.PeerTypeChannel ||
|
||||
dialog.TopMessage <= 0 ||
|
||||
dialog.TopMessage != dialog.HistoryClearAnchorID {
|
||||
continue
|
||||
}
|
||||
key := channelMessageLookupKey{channelID: dialog.Peer.ID, id: dialog.TopMessage}
|
||||
out[key] = domain.ProjectChannelHistoryClearMessage(
|
||||
out[key],
|
||||
dialog.Peer.ID,
|
||||
dialog.HistoryClearAnchorID,
|
||||
dialog.HistoryClearAnchorDate,
|
||||
)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadChannelDialogTopMessages(ctx context.Context, db sqlcgen.DBTX, keys []channelMessageLookupKey) (map[channelMessageLookupKey]domain.ChannelMessage, error) {
|
||||
idsByChannel := make(map[int64][]int, len(keys))
|
||||
for _, key := range keys {
|
||||
if key.channelID == 0 || key.id <= 0 {
|
||||
continue
|
||||
}
|
||||
idsByChannel[key.channelID] = append(idsByChannel[key.channelID], key.id)
|
||||
}
|
||||
if len(idsByChannel) == 0 {
|
||||
return nil, nil
|
||||
return map[channelMessageLookupKey]domain.ChannelMessage{}, nil
|
||||
}
|
||||
channelIDs := make([]int64, 0, len(idsByChannel))
|
||||
for channelID := range idsByChannel {
|
||||
|
|
@ -371,7 +738,7 @@ WHERE `+where.String(), args...)
|
|||
return nil, fmt.Errorf("list channel dialog top messages: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[channelMessageLookupKey]domain.ChannelMessage, len(seen))
|
||||
out := make(map[channelMessageLookupKey]domain.ChannelMessage, len(keys))
|
||||
for rows.Next() {
|
||||
msg, err := scanChannelMessage(rows)
|
||||
if err != nil {
|
||||
|
|
@ -382,25 +749,14 @@ WHERE `+where.String(), args...)
|
|||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("scan channel dialog top messages: %w", err)
|
||||
}
|
||||
for _, dialog := range dialogs {
|
||||
if dialog.Peer.Type != domain.PeerTypeChannel ||
|
||||
dialog.TopMessage <= 0 ||
|
||||
dialog.TopMessage != dialog.HistoryClearAnchorID {
|
||||
continue
|
||||
}
|
||||
key := channelMessageLookupKey{channelID: dialog.Peer.ID, id: dialog.TopMessage}
|
||||
out[key] = domain.ProjectChannelHistoryClearMessage(
|
||||
out[key],
|
||||
dialog.Peer.ID,
|
||||
dialog.HistoryClearAnchorID,
|
||||
dialog.HistoryClearAnchorDate,
|
||||
)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64, channelIDs []int64) (domain.ChannelDialogList, error) {
|
||||
out := domain.ChannelDialogList{}
|
||||
if viewerUserID == 0 || len(channelIDs) == 0 {
|
||||
return domain.ChannelDialogList{}, nil
|
||||
}
|
||||
orderedIDs := make([]int64, 0, len(channelIDs))
|
||||
seen := make(map[int64]struct{}, len(channelIDs))
|
||||
for _, channelID := range channelIDs {
|
||||
if channelID == 0 {
|
||||
|
|
@ -410,6 +766,79 @@ func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64
|
|||
continue
|
||||
}
|
||||
seen[channelID] = struct{}{}
|
||||
orderedIDs = append(orderedIDs, channelID)
|
||||
}
|
||||
if len(orderedIDs) == 0 {
|
||||
return domain.ChannelDialogList{}, nil
|
||||
}
|
||||
itemsByID := make(map[int64]channelDialogListItem, len(orderedIDs))
|
||||
loadedIDs := make([]int64, 0, len(orderedIDs))
|
||||
misses := make([]int64, 0, len(orderedIDs))
|
||||
dialogCacheActive := s.dialogCacheActive(s.db)
|
||||
memberCacheActive := s.memberCacheActive(s.db)
|
||||
var dialogCacheEpoch uint64
|
||||
var memberCacheEpoch uint64
|
||||
if dialogCacheActive {
|
||||
dialogCacheEpoch = s.dialogCache.cacheEpoch()
|
||||
}
|
||||
if memberCacheActive {
|
||||
memberCacheEpoch = s.memberCache.cacheEpoch()
|
||||
}
|
||||
for _, channelID := range orderedIDs {
|
||||
if dialogCacheActive {
|
||||
if cached, ok := s.dialogCache.getListProjection(viewerUserID, channelID); ok {
|
||||
itemsByID[channelID] = channelDialogListItem{
|
||||
dialog: channelDialogToDialog(cached.dialog, 0),
|
||||
defaultSendAs: cached.dialog.DefaultSendAs,
|
||||
topMentioned: cached.topMentioned,
|
||||
topMediaUnread: cached.topMediaUnread,
|
||||
topUnreadProjected: cached.topUnreadProjected,
|
||||
listCacheable: true,
|
||||
}
|
||||
loadedIDs = append(loadedIDs, channelID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
misses = append(misses, channelID)
|
||||
}
|
||||
if len(misses) > 0 {
|
||||
loaded, err := s.loadChannelDialogListItems(ctx, viewerUserID, misses)
|
||||
if err != nil {
|
||||
return domain.ChannelDialogList{}, err
|
||||
}
|
||||
for _, channelID := range misses {
|
||||
if item, ok := loaded[channelID]; ok {
|
||||
itemsByID[channelID] = item
|
||||
loadedIDs = append(loadedIDs, channelID)
|
||||
}
|
||||
}
|
||||
}
|
||||
channelsByID, err := s.channelsByIDs(ctx, s.db, loadedIDs)
|
||||
if err != nil {
|
||||
return domain.ChannelDialogList{}, err
|
||||
}
|
||||
for _, channelID := range loadedIDs {
|
||||
item := itemsByID[channelID]
|
||||
channel := channelsByID[channelID]
|
||||
if channel.ID == 0 {
|
||||
// The shared row was deleted after the owner-state snapshot. Treat
|
||||
// it as absent instead of returning a half-hydrated dialog.
|
||||
delete(itemsByID, channelID)
|
||||
continue
|
||||
}
|
||||
item.channel = channel
|
||||
item.dialog.Pts = channel.Pts
|
||||
itemsByID[channelID] = item
|
||||
}
|
||||
|
||||
out := domain.ChannelDialogList{}
|
||||
// A requested monoforum preview can be absent from channel_members. Keep the
|
||||
// rare admission path exact, but only pay its per-peer checks for IDs missed
|
||||
// by the normal batch query.
|
||||
for _, channelID := range orderedIDs {
|
||||
if _, ok := itemsByID[channelID]; ok {
|
||||
continue
|
||||
}
|
||||
channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID)
|
||||
synthetic := false
|
||||
if err != nil {
|
||||
|
|
@ -451,31 +880,248 @@ func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64
|
|||
return domain.ChannelDialogList{}, err
|
||||
}
|
||||
}
|
||||
msg, _ := s.getChannelMessage(ctx, s.db, channelID, dialog.TopMessageID)
|
||||
if dialog.TopMessageID > 0 && dialog.TopMessageID == dialog.HistoryClearAnchorID {
|
||||
msg = domain.ProjectChannelHistoryClearMessage(
|
||||
msg,
|
||||
channelID,
|
||||
dialog.HistoryClearAnchorID,
|
||||
dialog.HistoryClearAnchorDate,
|
||||
)
|
||||
itemsByID[channelID] = channelDialogListItem{
|
||||
channel: channel,
|
||||
dialog: channelDialogToDialog(dialog, channel.Pts),
|
||||
defaultSendAs: dialog.DefaultSendAs,
|
||||
listCacheable: !synthetic,
|
||||
}
|
||||
}
|
||||
|
||||
dialogs := make([]domain.Dialog, 0, len(itemsByID))
|
||||
for _, channelID := range orderedIDs {
|
||||
if item, ok := itemsByID[channelID]; ok {
|
||||
item.dialog.TopMessageMentioned = item.topMentioned
|
||||
item.dialog.TopMessageMediaUnread = item.topMediaUnread
|
||||
item.dialog.TopMessageUnreadProjected = item.topUnreadProjected
|
||||
itemsByID[channelID] = item
|
||||
dialogs = append(dialogs, item.dialog)
|
||||
}
|
||||
}
|
||||
topMessages, err := s.channelDialogTopMessages(ctx, s.db, dialogs)
|
||||
if err != nil {
|
||||
return domain.ChannelDialogList{}, err
|
||||
}
|
||||
allTopUnreadProjected := true
|
||||
for _, channelID := range orderedIDs {
|
||||
item, ok := itemsByID[channelID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
msg := topMessages[channelMessageLookupKey{channelID: channelID, id: item.dialog.TopMessage}]
|
||||
if msg.ID != 0 {
|
||||
dialog.TopMessageDate = msg.Date
|
||||
if item.topUnreadProjected {
|
||||
msg.Mentioned = item.topMentioned
|
||||
msg.MediaUnread = item.topMediaUnread
|
||||
} else {
|
||||
allTopUnreadProjected = false
|
||||
}
|
||||
item.dialog.TopMessageDate = msg.Date
|
||||
out.Messages = append(out.Messages, msg)
|
||||
}
|
||||
out.Dialogs = append(out.Dialogs, channelDialogToDialog(dialog, channel.Pts))
|
||||
out.Channels = append(out.Channels, channel)
|
||||
if dialogCacheActive && item.listCacheable {
|
||||
cached := channelDialogFromDialog(viewerUserID, item.dialog)
|
||||
cached.DefaultSendAs = item.defaultSendAs
|
||||
s.dialogCache.putListProjectionIfEpoch(
|
||||
cached,
|
||||
item.topMentioned,
|
||||
item.topMediaUnread,
|
||||
item.topUnreadProjected,
|
||||
dialogCacheEpoch,
|
||||
)
|
||||
}
|
||||
if memberCacheActive && item.dialog.ChannelMember != nil {
|
||||
s.memberCache.putIfEpoch(*item.dialog.ChannelMember, memberCacheEpoch)
|
||||
}
|
||||
out.Dialogs = append(out.Dialogs, item.dialog)
|
||||
out.Channels = append(out.Channels, item.channel)
|
||||
}
|
||||
out.Count = len(out.Dialogs)
|
||||
// 与 ListChannelDialogs 同因:top message 按 viewer 补未读标志与 reactions。
|
||||
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil {
|
||||
if err := s.populateChannelDialogTopMessageReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages, allTopUnreadProjected); err != nil {
|
||||
return domain.ChannelDialogList{}, err
|
||||
}
|
||||
projectChannelDialogHistoryClearMessages(out.Dialogs, out.Messages)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// HydrateChannelDialogSnapshot attaches viewer-independent channel rows/top
|
||||
// messages and the small viewer reaction overlay to already materialized
|
||||
// owner dialog facts. It deliberately does not read channel_members or
|
||||
// channel_dialogs: dialog_owner + channel_base generations protecting the
|
||||
// caller's snapshot are the authority for those fields.
|
||||
func (s *ChannelStore) HydrateChannelDialogSnapshot(
|
||||
ctx context.Context,
|
||||
viewerUserID int64,
|
||||
dialogs []domain.Dialog,
|
||||
) (domain.ChannelDialogList, error) {
|
||||
if viewerUserID == 0 || len(dialogs) == 0 {
|
||||
return domain.ChannelDialogList{}, nil
|
||||
}
|
||||
ordered := make([]domain.Dialog, 0, len(dialogs))
|
||||
ids := make([]int64, 0, len(dialogs))
|
||||
seen := make(map[int64]struct{}, len(dialogs))
|
||||
for _, dialog := range dialogs {
|
||||
if dialog.Peer.Type != domain.PeerTypeChannel || dialog.Peer.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[dialog.Peer.ID]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[dialog.Peer.ID] = struct{}{}
|
||||
ordered = append(ordered, dialog)
|
||||
ids = append(ids, dialog.Peer.ID)
|
||||
}
|
||||
if len(ordered) == 0 {
|
||||
return domain.ChannelDialogList{}, nil
|
||||
}
|
||||
dialogCacheActive := s.dialogCacheActive(s.db)
|
||||
memberCacheActive := s.memberCacheActive(s.db)
|
||||
var dialogCacheEpoch uint64
|
||||
var memberCacheEpoch uint64
|
||||
if dialogCacheActive {
|
||||
// Snapshot the epoch before any shared hydration. A concurrent
|
||||
// dialog_light/channel_member/channel_base invalidation must prevent
|
||||
// the owner projection from being written back after it became stale.
|
||||
dialogCacheEpoch = s.dialogCache.cacheEpoch()
|
||||
}
|
||||
if memberCacheActive {
|
||||
memberCacheEpoch = s.memberCache.cacheEpoch()
|
||||
}
|
||||
channelsByID, err := s.channelsByIDs(ctx, s.db, ids)
|
||||
if err != nil {
|
||||
return domain.ChannelDialogList{}, err
|
||||
}
|
||||
for index := range ordered {
|
||||
channel := channelsByID[ordered[index].Peer.ID]
|
||||
if channel.ID == 0 {
|
||||
return domain.ChannelDialogList{}, fmt.Errorf("hydrate channel dialog snapshot %d: %w", ordered[index].Peer.ID, domain.ErrChannelInvalid)
|
||||
}
|
||||
ordered[index].Pts = channel.Pts
|
||||
}
|
||||
topMessages, err := s.channelDialogTopMessages(ctx, s.db, ordered)
|
||||
if err != nil {
|
||||
return domain.ChannelDialogList{}, err
|
||||
}
|
||||
out := domain.ChannelDialogList{Dialogs: make([]domain.Dialog, 0, len(ordered))}
|
||||
allTopUnreadProjected := true
|
||||
for _, dialog := range ordered {
|
||||
channel := channelsByID[dialog.Peer.ID]
|
||||
message := topMessages[channelMessageLookupKey{channelID: dialog.Peer.ID, id: dialog.TopMessage}]
|
||||
if message.ID != 0 {
|
||||
if dialog.TopMessageUnreadProjected {
|
||||
message.Mentioned = dialog.TopMessageMentioned
|
||||
message.MediaUnread = dialog.TopMessageMediaUnread
|
||||
} else {
|
||||
allTopUnreadProjected = false
|
||||
}
|
||||
dialog.TopMessageDate = message.Date
|
||||
out.Messages = append(out.Messages, message)
|
||||
}
|
||||
if dialogCacheActive {
|
||||
cached := channelDialogFromDialog(viewerUserID, dialog)
|
||||
cached.DefaultSendAs = clonePeer(dialog.DefaultSendAs)
|
||||
s.dialogCache.putListProjectionIfEpoch(
|
||||
cached,
|
||||
dialog.TopMessageMentioned,
|
||||
dialog.TopMessageMediaUnread,
|
||||
dialog.TopMessageUnreadProjected,
|
||||
dialogCacheEpoch,
|
||||
)
|
||||
}
|
||||
if memberCacheActive && dialog.ChannelMember != nil {
|
||||
s.memberCache.putIfEpoch(*dialog.ChannelMember, memberCacheEpoch)
|
||||
}
|
||||
out.Dialogs = append(out.Dialogs, dialog)
|
||||
out.Channels = append(out.Channels, channel)
|
||||
}
|
||||
out.Count = len(out.Dialogs)
|
||||
if err := s.populateChannelDialogTopMessageReactions(
|
||||
ctx, s.db, viewerUserID, out.Channels, out.Messages, allTopUnreadProjected,
|
||||
); err != nil {
|
||||
return domain.ChannelDialogList{}, err
|
||||
}
|
||||
projectChannelDialogHistoryClearMessages(out.Dialogs, out.Messages)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) loadChannelDialogListItems(ctx context.Context, viewerUserID int64, channelIDs []int64) (map[int64]channelDialogListItem, error) {
|
||||
visibleTopID := channelDialogVisibleTopIDSQL()
|
||||
visibleTopDate := channelDialogVisibleTopDateSQL("COALESCE(top_msg.message_date, d.top_message_date, c.date)", "0")
|
||||
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
|
||||
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT c.id,
|
||||
`+visibleTopID+`,
|
||||
`+visibleTopDate+`,
|
||||
COALESCE(d.folder_id, 0),
|
||||
`+visibleReadInbox+`,
|
||||
LEAST(GREATEST(c.top_message_id, 0), GREATEST(COALESCE(d.read_outbox_max_id, 0), m.read_outbox_max_id, CASE WHEN c.read_inbox_top1_user_id = m.user_id THEN c.read_inbox_top2 ELSE c.read_inbox_top1 END)),
|
||||
`+visibleUnreadCount+`,
|
||||
COALESCE(d.pinned, false),
|
||||
COALESCE(d.pinned_order, 0),
|
||||
COALESCE(d.unread_mark, m.unread_mark),
|
||||
COALESCE(d.unread_mentions_count, 0),
|
||||
COALESCE(d.unread_reactions_count, 0),
|
||||
COALESCE(d.view_forum_as_messages, false),
|
||||
COALESCE(d.has_scheduled, false),
|
||||
d.default_send_as_peer_type,
|
||||
d.default_send_as_peer_id,
|
||||
m.history_clear_anchor_id,
|
||||
m.history_clear_anchor_date,
|
||||
top_unread.message_id IS NOT NULL,
|
||||
COALESCE(top_unread.unread, false),
|
||||
m.user_id,
|
||||
m.inviter_user_id,
|
||||
m.role,
|
||||
m.status,
|
||||
m.joined_at,
|
||||
m.left_at,
|
||||
m.admin_rights::text,
|
||||
m.banned_rights::text,
|
||||
m.rank,
|
||||
m.available_min_id,
|
||||
m.available_min_pts,
|
||||
m.read_inbox_max_id,
|
||||
m.read_outbox_max_id,
|
||||
m.unread_mark,
|
||||
m.slowmode_last_send_date
|
||||
FROM channel_members m
|
||||
JOIN channels c ON c.id = m.channel_id AND NOT c.deleted
|
||||
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
|
||||
LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id
|
||||
LEFT JOIN channel_unread_mentions top_unread
|
||||
ON top_unread.user_id = m.user_id
|
||||
AND top_unread.channel_id = m.channel_id
|
||||
AND top_unread.message_id = `+visibleTopID+`
|
||||
WHERE m.user_id = $1
|
||||
AND m.channel_id = ANY($2::bigint[])
|
||||
AND m.status = 'active'`, viewerUserID, channelIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("batch get channel dialogs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
itemsByID := make(map[int64]channelDialogListItem, len(channelIDs))
|
||||
for rows.Next() {
|
||||
channelID, dialog, defaultSendAs, topMentioned, topMediaUnread, err := scanChannelDialogProjectionRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
itemsByID[channelID] = channelDialogListItem{
|
||||
dialog: dialog,
|
||||
defaultSendAs: defaultSendAs,
|
||||
topMentioned: topMentioned,
|
||||
topMediaUnread: topMediaUnread,
|
||||
topUnreadProjected: true,
|
||||
listCacheable: true,
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return itemsByID, nil
|
||||
}
|
||||
|
||||
func projectChannelDialogHistoryClearMessages(dialogs []domain.Dialog, messages []domain.ChannelMessage) {
|
||||
anchors := make(map[channelMessageLookupKey]domain.Dialog)
|
||||
for _, dialog := range dialogs {
|
||||
|
|
@ -1325,6 +1971,106 @@ func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.
|
|||
return ch, dialog, defaultSendAs, nil
|
||||
}
|
||||
|
||||
type channelDialogProjectionValues struct {
|
||||
channelID int64
|
||||
topID, topDate, folderID, readInbox, readOutbox int
|
||||
unreadCount, pinnedOrder, unreadMentions, unreadReactions int
|
||||
historyClearAnchorID, historyClearAnchorDate int
|
||||
pinned, unreadMark, viewForumAsMessages, hasScheduled bool
|
||||
defaultSendAsType sql.NullString
|
||||
defaultSendAsID sql.NullInt64
|
||||
topMentioned, topMediaUnread bool
|
||||
memberUserID, memberInviterUserID int64
|
||||
memberRole, memberStatus string
|
||||
memberJoinedAt, memberLeftAt int
|
||||
memberAdminRights, memberBannedRights, memberRank string
|
||||
memberAvailableMinID, memberAvailableMinPts int
|
||||
memberReadInboxMaxID, memberReadOutboxMaxID int
|
||||
memberUnreadMark bool
|
||||
memberSlowmodeLastSendDate int
|
||||
}
|
||||
|
||||
func (v *channelDialogProjectionValues) scanDestinations() []any {
|
||||
return []any{
|
||||
&v.channelID,
|
||||
&v.topID, &v.topDate,
|
||||
&v.folderID, &v.readInbox, &v.readOutbox, &v.unreadCount, &v.pinned, &v.pinnedOrder, &v.unreadMark, &v.unreadMentions, &v.unreadReactions, &v.viewForumAsMessages, &v.hasScheduled,
|
||||
&v.defaultSendAsType, &v.defaultSendAsID,
|
||||
&v.historyClearAnchorID, &v.historyClearAnchorDate,
|
||||
&v.topMentioned, &v.topMediaUnread,
|
||||
&v.memberUserID, &v.memberInviterUserID,
|
||||
&v.memberRole, &v.memberStatus,
|
||||
&v.memberJoinedAt, &v.memberLeftAt,
|
||||
&v.memberAdminRights, &v.memberBannedRights, &v.memberRank,
|
||||
&v.memberAvailableMinID, &v.memberAvailableMinPts,
|
||||
&v.memberReadInboxMaxID, &v.memberReadOutboxMaxID,
|
||||
&v.memberUnreadMark, &v.memberSlowmodeLastSendDate,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *channelDialogProjectionValues) result() (int64, domain.Dialog, *domain.Peer, bool, bool) {
|
||||
dialog := domain.Dialog{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: v.channelID},
|
||||
FolderID: v.folderID,
|
||||
TopMessage: v.topID,
|
||||
TopMessageDate: v.topDate,
|
||||
HistoryClearAnchorID: v.historyClearAnchorID,
|
||||
HistoryClearAnchorDate: v.historyClearAnchorDate,
|
||||
ReadInboxMaxID: v.readInbox,
|
||||
ReadOutboxMaxID: v.readOutbox,
|
||||
UnreadCount: v.unreadCount,
|
||||
UnreadMentions: v.unreadMentions,
|
||||
UnreadReactions: v.unreadReactions,
|
||||
Pinned: v.pinned,
|
||||
PinnedOrder: v.pinnedOrder,
|
||||
UnreadMark: v.unreadMark,
|
||||
ViewForumAsMessages: v.viewForumAsMessages,
|
||||
HasScheduled: v.hasScheduled,
|
||||
TopMessageMentioned: v.topMentioned,
|
||||
TopMessageMediaUnread: v.topMediaUnread,
|
||||
TopMessageUnreadProjected: true,
|
||||
}
|
||||
var defaultSendAs *domain.Peer
|
||||
if v.defaultSendAsType.Valid && v.defaultSendAsID.Valid && v.defaultSendAsID.Int64 != 0 {
|
||||
defaultSendAs = &domain.Peer{Type: domain.PeerType(v.defaultSendAsType.String), ID: v.defaultSendAsID.Int64}
|
||||
}
|
||||
member := domain.ChannelMember{
|
||||
ChannelID: v.channelID,
|
||||
UserID: v.memberUserID,
|
||||
InviterUserID: v.memberInviterUserID,
|
||||
Role: domain.ChannelMemberRole(v.memberRole),
|
||||
Status: domain.ChannelMemberStatus(v.memberStatus),
|
||||
JoinedAt: v.memberJoinedAt,
|
||||
LeftAt: v.memberLeftAt,
|
||||
Rank: v.memberRank,
|
||||
AvailableMinID: v.memberAvailableMinID,
|
||||
AvailableMinPts: v.memberAvailableMinPts,
|
||||
HistoryClearAnchorID: v.historyClearAnchorID,
|
||||
HistoryClearAnchorDate: v.historyClearAnchorDate,
|
||||
ReadInboxMaxID: v.memberReadInboxMaxID,
|
||||
ReadOutboxMaxID: v.memberReadOutboxMaxID,
|
||||
UnreadMark: v.memberUnreadMark,
|
||||
SlowmodeLastSendDate: v.memberSlowmodeLastSendDate,
|
||||
}
|
||||
_ = json.Unmarshal([]byte(v.memberAdminRights), &member.AdminRights)
|
||||
_ = json.Unmarshal([]byte(v.memberBannedRights), &member.BannedRights)
|
||||
dialog.ChannelMember = &member
|
||||
return v.channelID, dialog, defaultSendAs, v.topMentioned, v.topMediaUnread
|
||||
}
|
||||
|
||||
// scanChannelDialogProjectionRow scans only owner-varying channel dialog
|
||||
// state. Shared channel metadata is hydrated separately through ChannelRowCache
|
||||
// so a page containing the same channel for many online owners does not decode
|
||||
// and transfer the wide channels row repeatedly.
|
||||
func scanChannelDialogProjectionRow(row rowScanner) (int64, domain.Dialog, *domain.Peer, bool, bool, error) {
|
||||
var values channelDialogProjectionValues
|
||||
if err := row.Scan(values.scanDestinations()...); err != nil {
|
||||
return 0, domain.Dialog{}, nil, false, false, err
|
||||
}
|
||||
channelID, dialog, defaultSendAs, topMentioned, topMediaUnread := values.result()
|
||||
return channelID, dialog, defaultSendAs, topMentioned, topMediaUnread, nil
|
||||
}
|
||||
|
||||
func channelDialogToDialog(dialog domain.ChannelDialog, channelPts int) domain.Dialog {
|
||||
return domain.Dialog{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
|
||||
|
|
@ -1343,6 +2089,7 @@ func channelDialogToDialog(dialog domain.ChannelDialog, channelPts int) domain.D
|
|||
UnreadMark: dialog.UnreadMark,
|
||||
ViewForumAsMessages: dialog.ViewForumAsMessages,
|
||||
HasScheduled: dialog.HasScheduled,
|
||||
DefaultSendAs: clonePeer(dialog.DefaultSendAs),
|
||||
Pts: channelPts,
|
||||
}
|
||||
}
|
||||
|
|
@ -1366,9 +2113,18 @@ func channelDialogFromDialog(userID int64, dialog domain.Dialog) domain.ChannelD
|
|||
UnreadMark: dialog.UnreadMark,
|
||||
ViewForumAsMessages: dialog.ViewForumAsMessages,
|
||||
HasScheduled: dialog.HasScheduled,
|
||||
DefaultSendAs: clonePeer(dialog.DefaultSendAs),
|
||||
}
|
||||
}
|
||||
|
||||
func clonePeer(peer *domain.Peer) *domain.Peer {
|
||||
if peer == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *peer
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func channelDialogMatchesFilter(dialog domain.Dialog, channel domain.Channel, filter domain.DialogFilter) bool {
|
||||
if filter.HasFolderID {
|
||||
if filter.FolderID < domain.DialogCustomFolderMinID {
|
||||
|
|
|
|||
174
internal/store/postgres/channel_difference_cache.go
Normal file
174
internal/store/postgres/channel_difference_cache.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
)
|
||||
|
||||
type channelDifferenceBaseKey struct {
|
||||
channelID int64
|
||||
requestPts int
|
||||
capturedPts int
|
||||
capturedTopID int
|
||||
limit int
|
||||
}
|
||||
|
||||
// channelDifferenceBase contains only viewer-independent durable facts. Access,
|
||||
// available-min, monoforum visibility, unread flags and dialog state are applied
|
||||
// after the cache lookup by ListChannelDifference.
|
||||
type channelDifferenceBase struct {
|
||||
retainedThroughPts int
|
||||
lastPts int
|
||||
tooLong bool
|
||||
events []domain.ChannelUpdateEvent
|
||||
messages []domain.ChannelMessage
|
||||
// mentionCandidateIDs is a viewer-independent sparse gate sourced from
|
||||
// channel_unread_mention_index. When candidatesKnown is true, messages not
|
||||
// present in this set cannot have a viewer mention overlay and must not cause
|
||||
// a channel_unread_mentions query.
|
||||
mentionCandidateIDs map[int]struct{}
|
||||
candidatesKnown bool
|
||||
}
|
||||
|
||||
type ChannelDifferenceCacheSnapshot struct {
|
||||
Entries int
|
||||
Weight int64
|
||||
Hits uint64
|
||||
Misses uint64
|
||||
Loads uint64
|
||||
LoadErrors uint64
|
||||
}
|
||||
|
||||
// ChannelDifferenceBaseCache deduplicates immutable channel event/message pages
|
||||
// shared by many viewers catching up from the same cursor. It never stores a
|
||||
// permission decision or a final ChannelDifference response.
|
||||
type ChannelDifferenceBaseCache struct {
|
||||
cache *readmodelcache.Cache[channelDifferenceBaseKey, channelDifferenceBase]
|
||||
|
||||
hits atomic.Uint64
|
||||
misses atomic.Uint64
|
||||
loads atomic.Uint64
|
||||
loadErrors atomic.Uint64
|
||||
}
|
||||
|
||||
func NewChannelDifferenceBaseCache(maxEntries int, maxWeight int64, ttl time.Duration) *ChannelDifferenceBaseCache {
|
||||
cache := readmodelcache.New[channelDifferenceBaseKey, channelDifferenceBase](readmodelcache.Config[channelDifferenceBaseKey, channelDifferenceBase]{
|
||||
MaxEntries: maxEntries,
|
||||
MaxWeight: maxWeight,
|
||||
TTL: ttl,
|
||||
Clone: cloneChannelDifferenceBase,
|
||||
Weight: channelDifferenceBaseWeight,
|
||||
KeyString: func(key channelDifferenceBaseKey) string {
|
||||
return strconv.FormatInt(key.channelID, 10) + ":" +
|
||||
strconv.Itoa(key.requestPts) + ":" + strconv.Itoa(key.capturedPts) + ":" +
|
||||
strconv.Itoa(key.capturedTopID) + ":" + strconv.Itoa(key.limit)
|
||||
},
|
||||
})
|
||||
if cache == nil {
|
||||
return nil
|
||||
}
|
||||
return &ChannelDifferenceBaseCache{cache: cache}
|
||||
}
|
||||
|
||||
func (c *ChannelDifferenceBaseCache) getOrLoad(
|
||||
ctx context.Context,
|
||||
key channelDifferenceBaseKey,
|
||||
load func() (channelDifferenceBase, error),
|
||||
) (channelDifferenceBase, error) {
|
||||
if c == nil {
|
||||
return load()
|
||||
}
|
||||
if _, ok := c.cache.Peek(key); ok {
|
||||
c.hits.Add(1)
|
||||
} else {
|
||||
c.misses.Add(1)
|
||||
}
|
||||
return c.cache.GetOrLoad(ctx, key, func() (channelDifferenceBase, error) {
|
||||
c.loads.Add(1)
|
||||
value, err := load()
|
||||
if err != nil {
|
||||
c.loadErrors.Add(1)
|
||||
}
|
||||
return value, err
|
||||
})
|
||||
}
|
||||
|
||||
func (c *ChannelDifferenceBaseCache) deleteChannel(channelID int64) {
|
||||
if c == nil || channelID == 0 {
|
||||
return
|
||||
}
|
||||
c.cache.InvalidateWhere(func(key channelDifferenceBaseKey) bool { return key.channelID == channelID })
|
||||
}
|
||||
|
||||
func (c *ChannelDifferenceBaseCache) flush() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.cache.Flush()
|
||||
}
|
||||
|
||||
func (c *ChannelDifferenceBaseCache) Snapshot() ChannelDifferenceCacheSnapshot {
|
||||
if c == nil {
|
||||
return ChannelDifferenceCacheSnapshot{}
|
||||
}
|
||||
return ChannelDifferenceCacheSnapshot{
|
||||
Entries: c.cache.Len(),
|
||||
Weight: c.cache.Weight(),
|
||||
Hits: c.hits.Load(),
|
||||
Misses: c.misses.Load(),
|
||||
Loads: c.loads.Load(),
|
||||
LoadErrors: c.loadErrors.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneChannelDifferenceBase(base channelDifferenceBase) channelDifferenceBase {
|
||||
candidates := base.mentionCandidateIDs
|
||||
base.events = append([]domain.ChannelUpdateEvent(nil), base.events...)
|
||||
for i := range base.events {
|
||||
base.events[i].MessageIDs = append([]int(nil), base.events[i].MessageIDs...)
|
||||
base.events[i].UserIDs = append([]int64(nil), base.events[i].UserIDs...)
|
||||
base.events[i].Message = cloneChannelTopMessage(base.events[i].Message)
|
||||
}
|
||||
base.messages = append([]domain.ChannelMessage(nil), base.messages...)
|
||||
for i := range base.messages {
|
||||
base.messages[i] = cloneChannelTopMessage(base.messages[i])
|
||||
}
|
||||
if base.mentionCandidateIDs != nil {
|
||||
base.mentionCandidateIDs = make(map[int]struct{}, len(base.mentionCandidateIDs))
|
||||
for id := range candidates {
|
||||
base.mentionCandidateIDs[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func channelDifferenceBaseWeight(base channelDifferenceBase) int64 {
|
||||
weight := int64(96 + len(base.events)*192 + len(base.messages)*192 + len(base.mentionCandidateIDs)*16)
|
||||
for _, event := range base.events {
|
||||
weight += int64(len(event.MessageIDs)*8 + len(event.UserIDs)*8)
|
||||
weight += channelDifferenceMessageWeight(event.Message)
|
||||
}
|
||||
for _, message := range base.messages {
|
||||
weight += channelDifferenceMessageWeight(message)
|
||||
}
|
||||
return weight
|
||||
}
|
||||
|
||||
func channelDifferenceMessageWeight(message domain.ChannelMessage) int64 {
|
||||
if message.ID == 0 {
|
||||
return 0
|
||||
}
|
||||
weight := int64(len(message.Body) + len(message.PostAuthor) + len(message.Entities)*48)
|
||||
if message.RichMessage != nil {
|
||||
weight += int64(len(message.RichMessage.Blocks) + len(message.RichMessage.BotAPIProjection))
|
||||
}
|
||||
if message.Action != nil {
|
||||
weight += int64(len(message.Action.Title) + len(message.Action.UserIDs)*8 + len(message.Action.TodoItems)*64)
|
||||
}
|
||||
return weight
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestChannelDifferenceBaseLoaderRejectsChangedStableCutPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
owner, err := NewUserStore(pool).Create(ctx, domain.User{
|
||||
AccessHash: 721, Phone: "+1993" + suffix + "01", FirstName: "DiffCutOwner",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var channelID int64
|
||||
t.Cleanup(func() {
|
||||
if channelID != 0 {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
|
||||
}
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
|
||||
})
|
||||
channels := NewChannelStore(pool)
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID, Title: "Difference Cut " + suffix, Megagroup: true, Date: 1701000200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channelID = created.Channel.ID
|
||||
first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: owner.ID, ChannelID: channelID, RandomID: 1701000201, Message: "first cut", Date: 1701000201,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
captured, member, _, err := channels.getChannelForViewer(ctx, pool, owner.ID, channelID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: owner.ID, ChannelID: channelID, RandomID: 1701000202, Message: "future cut", Date: 1701000202,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = channels.loadChannelDifferenceBase(ctx, captured, member, owner.ID, created.Channel.Pts, 100, true)
|
||||
if !errors.Is(err, errChannelDifferenceCutChanged) {
|
||||
t.Fatalf("load against captured pts %d after new event = %v, want stable-cut retry", first.Event.Pts, err)
|
||||
}
|
||||
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
UserID: owner.ID, ChannelID: channelID, Pts: created.Channel.Pts, Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !diff.Final || len(diff.Events) != 2 || diff.Events[0].Pts != first.Event.Pts {
|
||||
t.Fatalf("retried difference = %+v, want both stable events", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDifferenceBaseCacheSharesDurablePageAcrossViewersPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
owner, err := users.Create(ctx, domain.User{AccessHash: 701, Phone: "+1991" + suffix + "01", FirstName: "DiffCacheOwner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
memberA, err := users.Create(ctx, domain.User{AccessHash: 702, Phone: "+1991" + suffix + "02", FirstName: "DiffCacheA"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
memberB, err := users.Create(ctx, domain.User{AccessHash: 703, Phone: "+1991" + suffix + "03", FirstName: "DiffCacheB"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var channelID int64
|
||||
t.Cleanup(func() {
|
||||
if channelID != 0 {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
|
||||
}
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, memberA.ID, memberB.ID})
|
||||
})
|
||||
|
||||
cache := NewChannelDifferenceBaseCache(32, 8<<20, time.Minute)
|
||||
channels := NewChannelStore(pool, WithChannelDifferenceBaseCache(cache))
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
Title: "Shared Difference " + suffix,
|
||||
Megagroup: true,
|
||||
MemberUserIDs: []int64{memberA.ID, memberB.ID},
|
||||
Date: 1701000000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channelID = created.Channel.ID
|
||||
sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: owner.ID, ChannelID: channelID, RandomID: 1701000001,
|
||||
Message: "shared immutable page", MentionUserIDs: []int64{memberA.ID}, Date: 1701000001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
request := func(userID int64) domain.ChannelDifference {
|
||||
t.Helper()
|
||||
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
UserID: userID, ChannelID: channelID, Pts: created.Channel.Pts, Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.NewMessages) != 1 || diff.NewMessages[0].Body != "shared immutable page" {
|
||||
t.Fatalf("difference for %d = %+v", userID, diff)
|
||||
}
|
||||
if diff.Self.UserID != userID || diff.Dialog.UserID != userID {
|
||||
t.Fatalf("viewer overlay crossed accounts: self=%d dialog=%d want=%d", diff.Self.UserID, diff.Dialog.UserID, userID)
|
||||
}
|
||||
return diff
|
||||
}
|
||||
first := request(memberA.ID)
|
||||
if !first.NewMessages[0].Mentioned {
|
||||
t.Fatalf("member A mention overlay missing: %+v", first.NewMessages[0])
|
||||
}
|
||||
second := request(memberB.ID)
|
||||
if second.NewMessages[0].Mentioned || second.NewMessages[0].MediaUnread {
|
||||
t.Fatalf("member B received member A mention overlay: %+v", second.NewMessages[0])
|
||||
}
|
||||
snapshot := cache.Snapshot()
|
||||
if snapshot.Loads != 1 || snapshot.Entries != 1 || snapshot.Hits < 1 {
|
||||
t.Fatalf("shared base snapshot = %+v, want one load and a hit", snapshot)
|
||||
}
|
||||
first.NewMessages[0].Body = "caller mutation"
|
||||
third := request(memberA.ID)
|
||||
if third.NewMessages[0].Body != "shared immutable page" {
|
||||
t.Fatalf("caller mutation leaked into cache: %+v", third.NewMessages[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDifferenceRetentionInvalidatesSharedBasePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
owner, err := NewUserStore(pool).Create(ctx, domain.User{
|
||||
AccessHash: 711, Phone: "+1992" + suffix + "01", FirstName: "DiffRetentionOwner",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var channelID int64
|
||||
t.Cleanup(func() {
|
||||
if channelID != 0 {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
|
||||
}
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
|
||||
})
|
||||
|
||||
cache := NewChannelDifferenceBaseCache(32, 8<<20, time.Minute)
|
||||
channels := NewChannelStore(pool, WithChannelDifferenceBaseCache(cache))
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID, Title: "Difference Retention " + suffix, Megagroup: true, Date: 1701000100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channelID = created.Channel.ID
|
||||
first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: owner.ID, ChannelID: channelID, RandomID: 1701000101, Message: "first", Date: 1701000101,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
UserID: owner.ID, ChannelID: channelID, Pts: created.Channel.Pts, Limit: 100,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cache.Snapshot().Entries != 1 {
|
||||
t.Fatalf("entries before prune = %d, want 1", cache.Snapshot().Entries)
|
||||
}
|
||||
pruned, err := channels.PruneChannelUpdateEvents(ctx, channelID, first.Event.Pts, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pruned.Deleted == 0 || cache.Snapshot().Entries != 0 {
|
||||
t.Fatalf("prune/cache = %+v/%+v, want deletion and immediate invalidation", pruned, cache.Snapshot())
|
||||
}
|
||||
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
UserID: owner.ID, ChannelID: channelID, Pts: created.Channel.Pts, Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !diff.TooLong || diff.Pts != first.Event.Pts {
|
||||
t.Fatalf("difference after retained floor = %+v, want tooLong at pts %d", diff, first.Event.Pts)
|
||||
}
|
||||
}
|
||||
147
internal/store/postgres/channel_difference_cache_test.go
Normal file
147
internal/store/postgres/channel_difference_cache_test.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestChannelDifferenceBaseCacheSingleflightAndCloneIsolation(t *testing.T) {
|
||||
cache := NewChannelDifferenceBaseCache(16, 1<<20, time.Minute)
|
||||
key := channelDifferenceBaseKey{channelID: 7, requestPts: 10, capturedPts: 11, capturedTopID: 3, limit: 100}
|
||||
var loads atomic.Int32
|
||||
load := func() (channelDifferenceBase, error) {
|
||||
loads.Add(1)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
return channelDifferenceBase{
|
||||
lastPts: 11,
|
||||
candidatesKnown: true,
|
||||
mentionCandidateIDs: map[int]struct{}{3: {}},
|
||||
events: []domain.ChannelUpdateEvent{{
|
||||
ChannelID: 7,
|
||||
Pts: 11,
|
||||
PtsCount: 1,
|
||||
Type: domain.ChannelUpdateNewMessage,
|
||||
MessageIDs: []int{3},
|
||||
Message: domain.ChannelMessage{
|
||||
ChannelID: 7,
|
||||
ID: 3,
|
||||
Body: "immutable",
|
||||
Entities: []domain.MessageEntity{{Offset: 1}},
|
||||
},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
const callers = 64
|
||||
values := make([]channelDifferenceBase, callers)
|
||||
errs := make([]error, callers)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(callers)
|
||||
for i := range callers {
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
values[i], errs[i] = cache.getOrLoad(context.Background(), key, load)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("caller %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if loads.Load() != 1 {
|
||||
t.Fatalf("loads = %d, want 1", loads.Load())
|
||||
}
|
||||
values[0].events[0].MessageIDs[0] = 99
|
||||
values[0].events[0].Message.Entities[0].Offset = 99
|
||||
delete(values[0].mentionCandidateIDs, 3)
|
||||
got, err := cache.getOrLoad(context.Background(), key, load)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.events[0].MessageIDs[0] != 3 || got.events[0].Message.Entities[0].Offset != 1 {
|
||||
t.Fatalf("cached value was aliased: %+v", got.events[0])
|
||||
}
|
||||
if _, ok := got.mentionCandidateIDs[3]; !ok {
|
||||
t.Fatalf("cached mention candidates were aliased: %+v", got.mentionCandidateIDs)
|
||||
}
|
||||
snapshot := cache.Snapshot()
|
||||
if snapshot.Entries != 1 || snapshot.Loads != 1 || snapshot.Hits == 0 || snapshot.Weight <= 0 {
|
||||
t.Fatalf("snapshot = %+v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDifferenceBaseCacheSeparatesCutsAndInvalidatesChannel(t *testing.T) {
|
||||
cache := NewChannelDifferenceBaseCache(16, 1<<20, time.Minute)
|
||||
var loads atomic.Int32
|
||||
load := func() (channelDifferenceBase, error) {
|
||||
loads.Add(1)
|
||||
return channelDifferenceBase{lastPts: 2}, nil
|
||||
}
|
||||
keys := []channelDifferenceBaseKey{
|
||||
{channelID: 9, requestPts: 1, capturedPts: 2, capturedTopID: 1, limit: 100},
|
||||
{channelID: 9, requestPts: 1, capturedPts: 3, capturedTopID: 2, limit: 100},
|
||||
{channelID: 10, requestPts: 1, capturedPts: 2, capturedTopID: 1, limit: 100},
|
||||
}
|
||||
for _, key := range keys {
|
||||
if _, err := cache.getOrLoad(context.Background(), key, load); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if loads.Load() != 3 || cache.Snapshot().Entries != 3 {
|
||||
t.Fatalf("loads/entries = %d/%d, want 3/3", loads.Load(), cache.Snapshot().Entries)
|
||||
}
|
||||
cache.deleteChannel(9)
|
||||
if cache.Snapshot().Entries != 1 {
|
||||
t.Fatalf("entries after channel invalidation = %d, want 1", cache.Snapshot().Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDifferenceBaseCacheDoesNotCacheErrors(t *testing.T) {
|
||||
cache := NewChannelDifferenceBaseCache(4, 1<<20, time.Minute)
|
||||
key := channelDifferenceBaseKey{channelID: 12, requestPts: 1, capturedPts: 2, limit: 100}
|
||||
want := errors.New("load failed")
|
||||
for range 2 {
|
||||
if _, err := cache.getOrLoad(context.Background(), key, func() (channelDifferenceBase, error) {
|
||||
return channelDifferenceBase{}, want
|
||||
}); !errors.Is(err, want) {
|
||||
t.Fatalf("err = %v, want %v", err, want)
|
||||
}
|
||||
}
|
||||
snapshot := cache.Snapshot()
|
||||
if snapshot.Entries != 0 || snapshot.Loads != 2 || snapshot.LoadErrors != 2 {
|
||||
t.Fatalf("snapshot = %+v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDifferenceUnreadFlagsSkipDatabaseWithoutMentionCandidates(t *testing.T) {
|
||||
messages := []domain.ChannelMessage{{ChannelID: 12, ID: 7}}
|
||||
base := channelDifferenceBase{candidatesKnown: true, mentionCandidateIDs: map[int]struct{}{}}
|
||||
if err := populateChannelDifferenceUnreadFlags(context.Background(), nil, 99, messages, base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if messages[0].Mentioned || messages[0].MediaUnread {
|
||||
t.Fatalf("empty candidate gate changed message flags: %+v", messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadModelListenerInvalidatesChannelDifferenceBase(t *testing.T) {
|
||||
cache := NewChannelDifferenceBaseCache(4, 1<<20, time.Minute)
|
||||
key := channelDifferenceBaseKey{channelID: 14, requestPts: 1, capturedPts: 2, limit: 100}
|
||||
if _, err := cache.getOrLoad(context.Background(), key, func() (channelDifferenceBase, error) {
|
||||
return channelDifferenceBase{lastPts: 2}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener := NewReadModelChangeListener("", ReadModelCacheSet{ChannelDifferences: cache}, nil)
|
||||
listener.handlePayload(`{"model":"channel_difference_base","peer_type":"channel","peer_id":14}`)
|
||||
if cache.Snapshot().Entries != 0 {
|
||||
t.Fatalf("entries after retention invalidation = %d, want 0", cache.Snapshot().Entries)
|
||||
}
|
||||
}
|
||||
|
|
@ -132,6 +132,14 @@ func (s *ChannelStore) DeleteChannel(ctx context.Context, req domain.DeleteChann
|
|||
linkedMono = &mono
|
||||
}
|
||||
}
|
||||
if err := deleteChannelWelcomeMessageDeliveriesTx(ctx, tx, channel.ID); err != nil {
|
||||
return domain.DeleteChannelResult{}, err
|
||||
}
|
||||
if linkedMono != nil {
|
||||
if err := deleteChannelWelcomeMessageDeliveriesTx(ctx, tx, linkedMono.ID); err != nil {
|
||||
return domain.DeleteChannelResult{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.DeleteChannelResult{}, fmt.Errorf("commit delete channel: %w", err)
|
||||
}
|
||||
|
|
@ -556,6 +564,27 @@ func listChannelsByIDs(ctx context.Context, db sqlcgen.DBTX, ids []int64) ([]dom
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// channelsByIDs hydrates viewer-independent channel rows in one batch and
|
||||
// reuses them across owners. The per-viewer member/dialog state stays outside
|
||||
// this cache and is read by its own owner-scoped query.
|
||||
func (s *ChannelStore) channelsByIDs(ctx context.Context, db sqlcgen.DBTX, ids []int64) (map[int64]domain.Channel, error) {
|
||||
load := func(ctx context.Context, missing []int64) (map[int64]domain.Channel, error) {
|
||||
channels, err := listChannelsByIDs(ctx, db, missing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[int64]domain.Channel, len(channels))
|
||||
for _, channel := range channels {
|
||||
out[channel.ID] = channel
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if s.cacheActive(db) {
|
||||
return s.rowCache.getOrLoadBatch(ctx, ids, load)
|
||||
}
|
||||
return load(ctx, ids)
|
||||
}
|
||||
|
||||
func listChannelsByIDsInOrder(ctx context.Context, db sqlcgen.DBTX, ids []int64) ([]domain.Channel, error) {
|
||||
channels, err := listChannelsByIDs(ctx, db, ids)
|
||||
if err != nil {
|
||||
|
|
@ -789,7 +818,33 @@ func (s *ChannelStore) resolveChannelReply(ctx context.Context, db sqlcgen.DBTX,
|
|||
peer = channelPeer
|
||||
}
|
||||
if peer != channelPeer {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
// inputReplyToMessage.reply_to_peer_id may deliberately reference a
|
||||
// message from another dialog (the official clients expose this as
|
||||
// "Reply in another chat"). Keep that source pair intact instead of
|
||||
// resolving it as a destination-channel thread reply.
|
||||
if req.ReplyTo.MessageID <= 0 {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
var exists bool
|
||||
if err := db.QueryRow(ctx, `SELECT EXISTS (
|
||||
SELECT 1 FROM message_boxes
|
||||
WHERE owner_user_id=$1 AND peer_type='user' AND peer_id=$2 AND box_id=$3 AND NOT deleted
|
||||
)`, req.UserID, peer.ID, req.ReplyTo.MessageID).Scan(&exists); err != nil || !exists {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
case domain.PeerTypeChannel:
|
||||
target, err := s.getChannelMessage(ctx, db, peer.ID, req.ReplyTo.MessageID)
|
||||
if err != nil || target.Deleted {
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
default:
|
||||
return nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
reply := cloneMessageReply(req.ReplyTo)
|
||||
reply.Peer = peer
|
||||
return reply, nil
|
||||
}
|
||||
if req.ReplyTo.MessageID == 0 {
|
||||
if req.ReplyTo.TopMessageID <= 0 || !channel.Forum {
|
||||
|
|
|
|||
130
internal/store/postgres/channel_invite_batch_integration_test.go
Normal file
130
internal/store/postgres/channel_invite_batch_integration_test.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestChannelStoreInviteBatchAdvancesDistinctReadModelsOncePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
|
||||
owner, err := users.Create(ctx, domain.User{
|
||||
AccessHash: 960001,
|
||||
Phone: "+1960" + suffix + "00",
|
||||
FirstName: "BatchInviteOwner",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
members := make([]domain.User, 8)
|
||||
userIDs := make([]int64, len(members))
|
||||
for i := range members {
|
||||
members[i], err = users.Create(ctx, domain.User{
|
||||
AccessHash: int64(960100 + i),
|
||||
Phone: fmt.Sprintf("+1960%s%02d", suffix, i+1),
|
||||
FirstName: fmt.Sprintf("BatchInvite%02d", i+1),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create member %d: %v", i, err)
|
||||
}
|
||||
// Deliberately reverse the input. The store must establish one canonical
|
||||
// lock/write order independent of the request order.
|
||||
userIDs[len(members)-1-i] = members[i].ID
|
||||
}
|
||||
allUserIDs := append([]int64{owner.ID}, userIDs...)
|
||||
var channelID int64
|
||||
t.Cleanup(func() {
|
||||
if channelID != 0 {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM channels WHERE id = $1`, channelID)
|
||||
}
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1::bigint[])`, allUserIDs)
|
||||
})
|
||||
|
||||
channels := NewChannelStore(pool)
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
Title: "Batch Invite " + suffix,
|
||||
Megagroup: true,
|
||||
Date: 1700019600,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
channelID = created.Channel.ID
|
||||
|
||||
version := func(model string, ownerID int64, peerType string, peerID int64) int64 {
|
||||
t.Helper()
|
||||
var got int64
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT version
|
||||
FROM read_model_versions
|
||||
WHERE model=$1 AND owner_user_id=$2 AND peer_type=$3 AND peer_id=$4`, model, ownerID, peerType, peerID).Scan(&got)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return got
|
||||
}
|
||||
participantsBefore := version("channel_participants", 0, "channel", channelID)
|
||||
dialogOwnerBefore := make(map[int64]int64, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
dialogOwnerBefore[userID] = version("dialog_owner", userID, "user", userID)
|
||||
}
|
||||
|
||||
invited, err := channels.InviteToChannel(ctx, channelID, owner.ID, userIDs, 1700019601)
|
||||
if err != nil {
|
||||
t.Fatalf("batch invite: %v", err)
|
||||
}
|
||||
if len(invited.Members) != len(userIDs) {
|
||||
t.Fatalf("invited members = %d, want %d", len(invited.Members), len(userIDs))
|
||||
}
|
||||
if len(invited.Recipients) != 0 {
|
||||
t.Fatalf("durable invite recipients = %v, want realtime audience derived from session fabric", invited.Recipients)
|
||||
}
|
||||
if invited.Event.Pts != created.Channel.Pts+1 || invited.Event.PtsCount != 1 || invited.Channel.Pts != invited.Event.Pts {
|
||||
t.Fatalf("invite pts=(event:%d/%d channel:%d), want one slot after %d", invited.Event.Pts, invited.Event.PtsCount, invited.Channel.Pts, created.Channel.Pts)
|
||||
}
|
||||
if invited.Message.Action == nil || invited.Message.Action.Type != domain.ChannelActionChatAddUser || len(invited.Message.Action.UserIDs) != len(userIDs) {
|
||||
t.Fatalf("invite service action = %+v, want all invited users", invited.Message.Action)
|
||||
}
|
||||
|
||||
if got := version("channel_participants", 0, "channel", channelID); got != participantsBefore+1 {
|
||||
t.Fatalf("channel participants version = %d, want %d", got, participantsBefore+1)
|
||||
}
|
||||
for _, userID := range userIDs {
|
||||
if got := version("channel_member", userID, "channel", channelID); got != 1 {
|
||||
t.Errorf("channel_member version user %d = %d, want 1", userID, got)
|
||||
}
|
||||
if got := version("dialog_light", userID, "channel", channelID); got != 1 {
|
||||
t.Errorf("dialog_light version user %d = %d, want 1", userID, got)
|
||||
}
|
||||
if got := version("channel_active_memberships", userID, "user", userID); got != 1 {
|
||||
t.Errorf("active memberships version user %d = %d, want 1", userID, got)
|
||||
}
|
||||
if got := version("dialog_owner", userID, "user", userID); got != dialogOwnerBefore[userID]+1 {
|
||||
t.Errorf("dialog_owner version user %d = %d, want %d", userID, got, dialogOwnerBefore[userID]+1)
|
||||
}
|
||||
}
|
||||
|
||||
var memberRows, indexRows, dialogRows, adminRows int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_members WHERE channel_id=$1 AND user_id=ANY($2::bigint[]) AND status='active'`, channelID, userIDs).Scan(&memberRows); err != nil {
|
||||
t.Fatalf("count member rows: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM user_channel_member_index WHERE channel_id=$1 AND user_id=ANY($2::bigint[]) AND status='active'`, channelID, userIDs).Scan(&indexRows); err != nil {
|
||||
t.Fatalf("count membership indexes: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_dialogs WHERE channel_id=$1 AND user_id=ANY($2::bigint[]) AND unread_count=1 AND unread_reactions_count=0`, channelID, userIDs).Scan(&dialogRows); err != nil {
|
||||
t.Fatalf("count dialog rows: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_admin_log_events WHERE channel_id=$1 AND event_type='participant_invite'`, channelID).Scan(&adminRows); err != nil {
|
||||
t.Fatalf("count invite admin logs: %v", err)
|
||||
}
|
||||
if memberRows != len(userIDs) || indexRows != len(userIDs) || dialogRows != len(userIDs) || adminRows != len(userIDs) {
|
||||
t.Fatalf("batch rows member/index/dialog/admin = %d/%d/%d/%d, want %d each", memberRows, indexRows, dialogRows, adminRows, len(userIDs))
|
||||
}
|
||||
}
|
||||
|
|
@ -222,6 +222,9 @@ WHERE channel_id = $1 AND user_id = $2`, channel.ID, userID, member.ReadInboxMax
|
|||
if err := refreshChannelUnreadReactionsCountTx(ctx, tx, userID, channel.ID); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if err := enqueueWelcomeMessageDeliveriesTx(ctx, tx, channel.ID, []domain.ChannelMember{member}); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
return domain.CreateChannelResult{Channel: channel, Members: []domain.ChannelMember{member}, Message: msg, Event: event}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ package postgres
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
|
|
@ -39,13 +39,18 @@ func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUs
|
|||
date = nowUnix()
|
||||
}
|
||||
requested := uniqueChannelUserIDs(userIDs, 0)
|
||||
sort.Slice(requested, func(i, j int) bool { return requested[i] < requested[j] })
|
||||
inviteOne := len(requested) == 1
|
||||
canRestoreKicked := canBanChannelUsers(inviter)
|
||||
invitedIDs := make([]int64, 0, len(requested))
|
||||
members := make([]domain.ChannelMember, 0, len(requested))
|
||||
restoredKicked := 0
|
||||
existingMembers, err := channelMembersForUpdateBatchTx(ctx, tx, channelID, requested)
|
||||
if err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
for _, userID := range requested {
|
||||
if existing, err := s.getChannelMember(ctx, tx, channelID, userID); err == nil {
|
||||
if existing, ok := existingMembers[userID]; ok {
|
||||
if existing.Status == domain.ChannelMemberActive {
|
||||
if inviteOne {
|
||||
return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant
|
||||
|
|
@ -63,8 +68,6 @@ func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUs
|
|||
restoredKicked++
|
||||
}
|
||||
}
|
||||
} else if !errors.Is(err, domain.ErrChannelPrivate) {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
member := domain.ChannelMember{
|
||||
ChannelID: channelID,
|
||||
|
|
@ -77,22 +80,19 @@ func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUs
|
|||
AvailableMinPts: channelInitialAvailableMinPts(channel),
|
||||
ReadInboxMaxID: channel.TopMessageID,
|
||||
}
|
||||
if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
|
||||
ChannelID: channelID,
|
||||
UserID: inviterUserID,
|
||||
Date: date,
|
||||
Type: domain.ChannelAdminLogParticipantInvite,
|
||||
Participant: &member,
|
||||
}); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
members = append(members, member)
|
||||
invitedIDs = append(invitedIDs, userID)
|
||||
}
|
||||
if len(members) > 0 {
|
||||
if err := enableChannelMembershipBatchTx(ctx, tx); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if err := upsertChannelMembersBatchTx(ctx, tx, channel, members); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if err := insertChannelInviteAdminLogsBatchTx(ctx, tx, channelID, inviterUserID, date, members); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE channels SET participants_count = participants_count + $2, kicked_count = GREATEST(kicked_count - $3, 0), updated_at = now() WHERE id = $1`, channelID, len(members), restoredKicked); err != nil {
|
||||
return domain.CreateChannelResult{}, fmt.Errorf("update channel participants: %w", err)
|
||||
}
|
||||
|
|
@ -112,21 +112,24 @@ func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUs
|
|||
channel.TopMessageID = msg.ID
|
||||
channel.Pts = event.Pts
|
||||
}
|
||||
for _, member := range members {
|
||||
if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
// 被重新拉入群也是重进:按新 available_min_id 重算未读 reaction 计数清幽灵角标。
|
||||
if err := refreshChannelUnreadReactionsCountTx(ctx, tx, member.UserID, channel.ID); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if err := upsertChannelDialogsBatchTx(ctx, tx, channel, msg, members); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
// 被重新拉入群也是重进:按新 available_min_id 集合重算未读 reaction 计数清幽灵角标。
|
||||
if err := refreshChannelUnreadReactionsCountsBatchTx(ctx, tx, channel.ID, invitedIDs); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if err := enqueueWelcomeMessageDeliveriesTx(ctx, tx, channel.ID, members); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if err := bumpChannelMembershipReadModelsBatchTx(ctx, tx, channel.ID, invitedIDs); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.CreateChannelResult{}, fmt.Errorf("commit invite channel: %w", err)
|
||||
}
|
||||
committed = true
|
||||
recipients, _ := s.ListActiveChannelMemberIDs(ctx, inviterUserID, channelID, 0)
|
||||
return domain.CreateChannelResult{Channel: channel, Members: members, Message: msg, Event: event, Recipients: recipients}, nil
|
||||
return domain.CreateChannelResult{Channel: channel, Members: members, Message: msg, Event: event}, nil
|
||||
}
|
||||
|
||||
func canInviteToChannel(channel domain.Channel, member domain.ChannelMember) bool {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChan
|
|||
return domain.EditChannelAdminResult{}, domain.ErrChannelAdminRequired
|
||||
}
|
||||
previous, err := s.getChannelMember(ctx, tx, req.ChannelID, req.MemberID)
|
||||
membershipActivated := err != nil && errors.Is(err, domain.ErrChannelPrivate)
|
||||
if err != nil {
|
||||
if !errors.Is(err, domain.ErrChannelPrivate) {
|
||||
return domain.EditChannelAdminResult{}, err
|
||||
|
|
@ -52,6 +53,9 @@ func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChan
|
|||
ReadInboxMaxID: channel.TopMessageID,
|
||||
}
|
||||
}
|
||||
if previous.Status != domain.ChannelMemberActive {
|
||||
membershipActivated = true
|
||||
}
|
||||
if previous.Role == domain.ChannelRoleCreator {
|
||||
if req.MemberID != req.UserID || channel.CreatorUserID != req.UserID || actor.Role != domain.ChannelRoleCreator {
|
||||
return domain.EditChannelAdminResult{}, domain.ErrChannelUserCreator
|
||||
|
|
@ -100,6 +104,7 @@ func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChan
|
|||
member.Rank = req.Rank
|
||||
}
|
||||
if previous.Status != domain.ChannelMemberActive {
|
||||
member.JoinedAt = req.Date
|
||||
if minPts := channelInitialAvailableMinPts(channel); minPts > member.AvailableMinPts {
|
||||
member.AvailableMinPts = minPts
|
||||
}
|
||||
|
|
@ -137,6 +142,11 @@ func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChan
|
|||
if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil {
|
||||
return domain.EditChannelAdminResult{}, err
|
||||
}
|
||||
if membershipActivated {
|
||||
if err := enqueueWelcomeMessageDeliveriesTx(ctx, tx, channel.ID, []domain.ChannelMember{member}); err != nil {
|
||||
return domain.EditChannelAdminResult{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.EditChannelAdminResult{}, fmt.Errorf("commit edit channel admin: %w", err)
|
||||
}
|
||||
|
|
@ -449,6 +459,11 @@ func (s *ChannelStore) EditChannelBanned(ctx context.Context, req domain.EditCha
|
|||
return domain.EditChannelBannedResult{}, err
|
||||
}
|
||||
}
|
||||
if previous.Status == domain.ChannelMemberActive && member.Status != domain.ChannelMemberActive {
|
||||
if err := deleteWelcomeMessageDeliveriesTx(ctx, tx, req.ChannelID, []int64{req.Participant.ID}); err != nil {
|
||||
return domain.EditChannelBannedResult{}, err
|
||||
}
|
||||
}
|
||||
var serviceMsg domain.ChannelMessage
|
||||
var serviceEvent domain.ChannelUpdateEvent
|
||||
if channel.Megagroup && previous.Status == domain.ChannelMemberActive && member.Status == domain.ChannelMemberKicked {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,27 @@ func (c *ChannelMemberCache) put(member domain.ChannelMember) {
|
|||
c.cache.Store(channelMemberCacheKey{channelID: member.ChannelID, userID: member.UserID}, member)
|
||||
}
|
||||
|
||||
func (c *ChannelMemberCache) cacheEpoch() uint64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return c.cache.LoadEpoch()
|
||||
}
|
||||
|
||||
// putIfEpoch prevents a materialized owner snapshot that raced a membership
|
||||
// invalidation from restoring stale access rights after the listener advanced
|
||||
// the cache epoch.
|
||||
func (c *ChannelMemberCache) putIfEpoch(member domain.ChannelMember, loadEpoch uint64) {
|
||||
if c == nil || member.ChannelID == 0 || member.UserID == 0 {
|
||||
return
|
||||
}
|
||||
c.cache.StoreIfEpoch(
|
||||
channelMemberCacheKey{channelID: member.ChannelID, userID: member.UserID},
|
||||
member,
|
||||
loadEpoch,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *ChannelMemberCache) delete(channelID, userID int64) {
|
||||
if c == nil || channelID == 0 || userID == 0 {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -55,6 +55,29 @@ func TestChannelMemberCachePutGetDeleteFlush(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChannelMemberCachePutIfEpochRejectsStaleSnapshot(t *testing.T) {
|
||||
c := NewChannelMemberCache(16)
|
||||
epoch := c.cacheEpoch()
|
||||
c.delete(10, 20)
|
||||
c.putIfEpoch(domain.ChannelMember{
|
||||
ChannelID: 10,
|
||||
UserID: 20,
|
||||
Status: domain.ChannelMemberActive,
|
||||
}, epoch)
|
||||
if _, ok := c.get(10, 20); ok {
|
||||
t.Fatal("stale materialized membership restored after invalidation")
|
||||
}
|
||||
freshEpoch := c.cacheEpoch()
|
||||
c.putIfEpoch(domain.ChannelMember{
|
||||
ChannelID: 10,
|
||||
UserID: 20,
|
||||
Status: domain.ChannelMemberActive,
|
||||
}, freshEpoch)
|
||||
if member, ok := c.get(10, 20); !ok || member.Status != domain.ChannelMemberActive {
|
||||
t.Fatalf("fresh materialized membership = %+v ok=%v", member, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelMemberCacheDeleteChannelAndCap(t *testing.T) {
|
||||
c := NewChannelMemberCache(2)
|
||||
c.put(domain.ChannelMember{ChannelID: 1, UserID: 10})
|
||||
|
|
|
|||
|
|
@ -126,6 +126,9 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, userID, member.ReadInboxMaxI
|
|||
if err := refreshChannelUnreadReactionsCountTx(ctx, tx, userID, channelID); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if err := enqueueWelcomeMessageDeliveriesTx(ctx, tx, channelID, []domain.ChannelMember{member}); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.CreateChannelResult{}, fmt.Errorf("commit join channel: %w", err)
|
||||
}
|
||||
|
|
@ -242,6 +245,9 @@ WHERE id = $1`, channelID, channel.CreatorUserID, adminsDelta); err != nil {
|
|||
if err := clearChannelMentionsForUserTx(ctx, tx, channelID, userID); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
if err := deleteWelcomeMessageDeliveriesTx(ctx, tx, channelID, []int64{userID}); err != nil {
|
||||
return domain.CreateChannelResult{}, err
|
||||
}
|
||||
var msg domain.ChannelMessage
|
||||
var event domain.ChannelUpdateEvent
|
||||
if channel.Megagroup {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) GetParticipants(ctx context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
|
||||
|
|
@ -364,6 +365,70 @@ ORDER BY user_id`, channelID, candidates[start:end])
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) FilterActiveChannelMemberPairs(ctx context.Context, userIDsByChannel map[int64][]int64) (map[int64][]int64, error) {
|
||||
channelIDs, userIDs, err := flattenActiveChannelMemberPairs(userIDsByChannel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[int64][]int64)
|
||||
if len(channelIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
WITH requested(channel_id, user_id) AS (
|
||||
SELECT * FROM unnest($1::bigint[], $2::bigint[])
|
||||
)
|
||||
SELECT r.channel_id, r.user_id
|
||||
FROM requested r
|
||||
JOIN channel_members m
|
||||
ON m.channel_id = r.channel_id
|
||||
AND m.user_id = r.user_id
|
||||
WHERE m.status = 'active'
|
||||
ORDER BY r.channel_id, r.user_id`, channelIDs, userIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("filter active channel member pairs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var channelID, userID int64
|
||||
if err := rows.Scan(&channelID, &userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[channelID] = append(out[channelID], userID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func flattenActiveChannelMemberPairs(userIDsByChannel map[int64][]int64) ([]int64, []int64, error) {
|
||||
channelIDs := make([]int64, 0)
|
||||
userIDs := make([]int64, 0)
|
||||
seen := make(map[[2]int64]struct{})
|
||||
for channelID, candidates := range userIDsByChannel {
|
||||
if channelID == 0 {
|
||||
continue
|
||||
}
|
||||
for _, userID := range candidates {
|
||||
if userID == 0 {
|
||||
continue
|
||||
}
|
||||
pair := [2]int64{channelID, userID}
|
||||
if _, ok := seen[pair]; ok {
|
||||
continue
|
||||
}
|
||||
if len(seen) >= store.MaxActiveChannelMemberPairs {
|
||||
return nil, nil, fmt.Errorf("%w: maximum %d", store.ErrActiveChannelMemberPairsLimit, store.MaxActiveChannelMemberPairs)
|
||||
}
|
||||
seen[pair] = struct{}{}
|
||||
channelIDs = append(channelIDs, channelID)
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
}
|
||||
return channelIDs, userIDs, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) FilterChannelMessageAudienceIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) {
|
||||
if channelID == 0 || len(userIDs) == 0 {
|
||||
return nil, nil
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestFilterActiveChannelMemberPairsPostgresKeepsExactEdges(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
owner := createTestUser(t, ctx, users, "+1911"+suffix+"01", "Pair", "Owner")
|
||||
memberA := createTestUser(t, ctx, users, "+1911"+suffix+"02", "Pair", "A")
|
||||
memberB := createTestUser(t, ctx, users, "+1911"+suffix+"03", "Pair", "B")
|
||||
userIDs := []int64{owner.ID, memberA.ID, memberB.ID}
|
||||
var channelIDs []int64
|
||||
t.Cleanup(func() {
|
||||
if len(channelIDs) > 0 {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs)
|
||||
}
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", userIDs)
|
||||
})
|
||||
|
||||
channels := NewChannelStore(pool)
|
||||
first, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
MemberUserIDs: []int64{memberA.ID, memberB.ID},
|
||||
Title: "pair first " + suffix,
|
||||
Megagroup: true,
|
||||
Date: 1700000000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel(first): %v", err)
|
||||
}
|
||||
channelIDs = append(channelIDs, first.Channel.ID)
|
||||
second, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID,
|
||||
MemberUserIDs: []int64{memberA.ID, memberB.ID},
|
||||
Title: "pair second " + suffix,
|
||||
Megagroup: true,
|
||||
Date: 1700000001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel(second): %v", err)
|
||||
}
|
||||
channelIDs = append(channelIDs, second.Channel.ID)
|
||||
|
||||
got, err := channels.FilterActiveChannelMemberPairs(ctx, map[int64][]int64{
|
||||
first.Channel.ID: {memberA.ID},
|
||||
second.Channel.ID: {memberB.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FilterActiveChannelMemberPairs: %v", err)
|
||||
}
|
||||
if len(got[first.Channel.ID]) != 1 || got[first.Channel.ID][0] != memberA.ID {
|
||||
t.Fatalf("first channel result = %+v, want [%d]", got[first.Channel.ID], memberA.ID)
|
||||
}
|
||||
if len(got[second.Channel.ID]) != 1 || got[second.Channel.ID][0] != memberB.ID {
|
||||
t.Fatalf("second channel result = %+v, want [%d]", got[second.Channel.ID], memberB.ID)
|
||||
}
|
||||
}
|
||||
274
internal/store/postgres/channel_membership_batch.go
Normal file
274
internal/store/postgres/channel_membership_batch.go
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func channelMembersForUpdateBatchTx(ctx context.Context, tx pgx.Tx, channelID int64, userIDs []int64) (map[int64]domain.ChannelMember, error) {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at,
|
||||
admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts,
|
||||
history_clear_anchor_id, history_clear_anchor_date,
|
||||
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
|
||||
FROM channel_members
|
||||
WHERE channel_id = $1 AND user_id = ANY($2::bigint[])
|
||||
ORDER BY user_id
|
||||
FOR UPDATE`, channelID, userIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lock channel invite members: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[int64]domain.ChannelMember, len(userIDs))
|
||||
for rows.Next() {
|
||||
member, err := scanChannelMember(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[member.UserID] = member
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("lock channel invite members: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func enableChannelMembershipBatchTx(ctx context.Context, tx pgx.Tx) error {
|
||||
if _, err := tx.Exec(ctx, `SELECT set_config('telesrv.membership_batch_mode', 'on', true)`); err != nil {
|
||||
return fmt.Errorf("enable channel membership batch invalidation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertChannelMembersBatchTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, members []domain.ChannelMember) error {
|
||||
if len(members) == 0 {
|
||||
return nil
|
||||
}
|
||||
userIDs := make([]int64, len(members))
|
||||
inviterIDs := make([]int64, len(members))
|
||||
joinedAt := make([]int32, len(members))
|
||||
availableMinIDs := make([]int32, len(members))
|
||||
availableMinPts := make([]int32, len(members))
|
||||
readInboxMaxIDs := make([]int32, len(members))
|
||||
for i, member := range members {
|
||||
userIDs[i] = member.UserID
|
||||
inviterIDs[i] = member.InviterUserID
|
||||
joinedAt[i] = int32(member.JoinedAt)
|
||||
availableMinIDs[i] = int32(member.AvailableMinID)
|
||||
availableMinPts[i] = int32(member.AvailableMinPts)
|
||||
readInboxMaxIDs[i] = int32(member.ReadInboxMaxID)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
WITH input AS MATERIALIZED (
|
||||
SELECT user_id, inviter_user_id, joined_at, available_min_id, available_min_pts, read_inbox_max_id
|
||||
FROM unnest(
|
||||
$2::bigint[], $3::bigint[], $4::integer[], $5::integer[], $6::integer[], $7::integer[]
|
||||
) AS value(user_id, inviter_user_id, joined_at, available_min_id, available_min_pts, read_inbox_max_id)
|
||||
)
|
||||
INSERT INTO channel_members (
|
||||
channel_id, user_id, inviter_user_id, role, status, joined_at, left_at,
|
||||
admin_rights, banned_rights, rank, available_min_id, available_min_pts,
|
||||
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
|
||||
)
|
||||
SELECT $1, user_id, inviter_user_id, 'member', 'active', joined_at, 0,
|
||||
'{}'::jsonb, '{}'::jsonb, '', available_min_id, available_min_pts,
|
||||
read_inbox_max_id, 0, false, 0
|
||||
FROM input
|
||||
ORDER BY user_id
|
||||
ON CONFLICT (channel_id, user_id) DO UPDATE SET
|
||||
inviter_user_id = EXCLUDED.inviter_user_id,
|
||||
role = EXCLUDED.role,
|
||||
status = EXCLUDED.status,
|
||||
joined_at = EXCLUDED.joined_at,
|
||||
left_at = EXCLUDED.left_at,
|
||||
admin_rights = EXCLUDED.admin_rights,
|
||||
banned_rights = EXCLUDED.banned_rights,
|
||||
rank = EXCLUDED.rank,
|
||||
available_min_id = GREATEST(channel_members.available_min_id, EXCLUDED.available_min_id),
|
||||
available_min_pts = GREATEST(channel_members.available_min_pts, EXCLUDED.available_min_pts),
|
||||
read_inbox_max_id = GREATEST(channel_members.read_inbox_max_id, EXCLUDED.read_inbox_max_id),
|
||||
updated_at = now()`, channel.ID, userIDs, inviterIDs, joinedAt, availableMinIDs, availableMinPts, readInboxMaxIDs); err != nil {
|
||||
return fmt.Errorf("batch upsert channel members: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
WITH input AS MATERIALIZED (
|
||||
SELECT user_id
|
||||
FROM unnest($2::bigint[]) AS value(user_id)
|
||||
)
|
||||
INSERT INTO user_channel_member_index (
|
||||
user_id, channel_id, status, megagroup, broadcast, deleted,
|
||||
role, left_at, forum, public_username, can_pin_messages
|
||||
)
|
||||
SELECT user_id, $1, 'active', $3, $4, $5, 'member', 0, $6, $7, false
|
||||
FROM input
|
||||
ORDER BY user_id
|
||||
ON CONFLICT (user_id, channel_id) DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
megagroup = EXCLUDED.megagroup,
|
||||
broadcast = EXCLUDED.broadcast,
|
||||
deleted = EXCLUDED.deleted,
|
||||
role = EXCLUDED.role,
|
||||
left_at = EXCLUDED.left_at,
|
||||
forum = EXCLUDED.forum,
|
||||
public_username = EXCLUDED.public_username,
|
||||
can_pin_messages = EXCLUDED.can_pin_messages,
|
||||
updated_at = now()`, channel.ID, userIDs, channel.Megagroup, channel.Broadcast, channel.Deleted, channel.Forum, channel.Username != ""); err != nil {
|
||||
return fmt.Errorf("batch upsert user channel member index: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertChannelInviteAdminLogsBatchTx(ctx context.Context, tx pgx.Tx, channelID, inviterUserID int64, date int, members []domain.ChannelMember) error {
|
||||
if len(members) == 0 {
|
||||
return nil
|
||||
}
|
||||
type row struct {
|
||||
Ordinal int `json:"ordinal"`
|
||||
Participant domain.ChannelMember `json:"participant"`
|
||||
}
|
||||
input := make([]row, len(members))
|
||||
for i, member := range members {
|
||||
input[i] = row{Ordinal: i + 1, Participant: member}
|
||||
}
|
||||
payload, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal channel invite admin logs: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
WITH input AS MATERIALIZED (
|
||||
SELECT ordinal, participant
|
||||
FROM jsonb_to_recordset($5::jsonb) AS value(ordinal integer, participant jsonb)
|
||||
), allocated AS MATERIALIZED (
|
||||
UPDATE channels
|
||||
SET admin_log_seq = admin_log_seq + $4, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING admin_log_seq
|
||||
)
|
||||
INSERT INTO channel_admin_log_events (
|
||||
channel_id, id, actor_user_id, event_date, event_type, participant, query
|
||||
)
|
||||
SELECT $1, allocated.admin_log_seq - $4 + input.ordinal, $2, $3,
|
||||
'participant_invite', input.participant, ''
|
||||
FROM input
|
||||
CROSS JOIN allocated
|
||||
ORDER BY input.ordinal`, channelID, inviterUserID, date, len(members), string(payload)); err != nil {
|
||||
return fmt.Errorf("batch insert channel invite admin logs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertChannelDialogsBatchTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, top domain.ChannelMessage, members []domain.ChannelMember) error {
|
||||
if len(members) == 0 {
|
||||
return nil
|
||||
}
|
||||
topDate := top.Date
|
||||
if topDate == 0 {
|
||||
topDate = channel.Date
|
||||
}
|
||||
userIDs := make([]int64, len(members))
|
||||
readInboxMaxIDs := make([]int32, len(members))
|
||||
readOutboxMaxIDs := make([]int32, len(members))
|
||||
for i, member := range members {
|
||||
userIDs[i] = member.UserID
|
||||
readInboxMaxIDs[i] = int32(member.ReadInboxMaxID)
|
||||
readOutboxMaxIDs[i] = int32(member.ReadOutboxMaxID)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
WITH input AS MATERIALIZED (
|
||||
SELECT user_id, read_inbox_max_id, read_outbox_max_id
|
||||
FROM unnest($4::bigint[], $5::integer[], $6::integer[])
|
||||
AS value(user_id, read_inbox_max_id, read_outbox_max_id)
|
||||
)
|
||||
INSERT INTO channel_dialogs (
|
||||
user_id, channel_id, top_message_id, top_message_date,
|
||||
read_inbox_max_id, read_outbox_max_id, unread_count, unread_mark
|
||||
)
|
||||
SELECT user_id, $1, $2, $3, read_inbox_max_id, read_outbox_max_id, 0, false
|
||||
FROM input
|
||||
ORDER BY user_id
|
||||
ON CONFLICT (user_id, channel_id) DO UPDATE SET
|
||||
top_message_id = GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id),
|
||||
top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date),
|
||||
read_inbox_max_id = GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id),
|
||||
read_outbox_max_id = GREATEST(channel_dialogs.read_outbox_max_id, EXCLUDED.read_outbox_max_id),
|
||||
unread_mark = false,
|
||||
updated_at = now()`, channel.ID, channel.TopMessageID, topDate, userIDs, readInboxMaxIDs, readOutboxMaxIDs); err != nil {
|
||||
return fmt.Errorf("batch upsert channel dialogs: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE channel_dialogs AS dialog
|
||||
SET unread_count = (
|
||||
SELECT COUNT(*)::int
|
||||
FROM (
|
||||
SELECT 1
|
||||
FROM channel_messages AS message
|
||||
WHERE message.channel_id = dialog.channel_id
|
||||
AND message.id > dialog.read_inbox_max_id
|
||||
AND message.id <= dialog.top_message_id
|
||||
AND message.sender_user_id <> dialog.user_id
|
||||
AND NOT message.deleted
|
||||
LIMIT $3
|
||||
) AS capped
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE dialog.channel_id = $1
|
||||
AND dialog.user_id = ANY($2::bigint[])`, channel.ID, userIDs, domain.MaxDialogUnreadCount); err != nil {
|
||||
return fmt.Errorf("batch refresh channel dialog unread count: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func refreshChannelUnreadReactionsCountsBatchTx(ctx context.Context, tx pgx.Tx, channelID int64, userIDs []int64) error {
|
||||
if len(userIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
WITH input AS MATERIALIZED (
|
||||
SELECT user_id FROM unnest($2::bigint[]) AS value(user_id)
|
||||
), counts AS MATERIALIZED (
|
||||
SELECT input.user_id,
|
||||
(
|
||||
SELECT COUNT(DISTINCT reaction.message_id)::int
|
||||
FROM channel_message_reactions AS reaction
|
||||
JOIN channel_messages AS message
|
||||
ON message.channel_id = reaction.channel_id AND message.id = reaction.message_id
|
||||
JOIN channel_members AS member
|
||||
ON member.channel_id = reaction.channel_id AND member.user_id = input.user_id
|
||||
WHERE reaction.sender_user_id = input.user_id
|
||||
AND reaction.channel_id = $1
|
||||
AND reaction.unread
|
||||
AND reaction.reacted_user_id <> input.user_id
|
||||
AND message.id > member.available_min_id
|
||||
AND NOT message.deleted
|
||||
AND member.status = 'active'
|
||||
AND NOT COALESCE((member.banned_rights->>'ViewMessages')::boolean, false)
|
||||
) AS count
|
||||
FROM input
|
||||
)
|
||||
INSERT INTO channel_dialogs (user_id, channel_id, unread_reactions_count)
|
||||
SELECT user_id, $1, count
|
||||
FROM counts
|
||||
ORDER BY user_id
|
||||
ON CONFLICT (user_id, channel_id) DO UPDATE SET
|
||||
unread_reactions_count = EXCLUDED.unread_reactions_count,
|
||||
updated_at = now()`, channelID, userIDs); err != nil {
|
||||
return fmt.Errorf("batch refresh channel unread reactions count: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bumpChannelMembershipReadModelsBatchTx(ctx context.Context, tx pgx.Tx, channelID int64, userIDs []int64) error {
|
||||
if len(userIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `SELECT public.telesrv_bump_channel_membership_read_models($1, $2::bigint[])`, channelID, userIDs); err != nil {
|
||||
return fmt.Errorf("batch bump channel membership read models: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -163,32 +163,6 @@ func channelMessageReplyFromColumns(reply *domain.MessageReply, msgID int, peerT
|
|||
return out
|
||||
}
|
||||
|
||||
func collectChannelMessageRefs(msg domain.ChannelMessage, currentChannelID int64, userRefs, channelRefs map[int64]struct{}) {
|
||||
if msg.SenderUserID != 0 {
|
||||
userRefs[msg.SenderUserID] = struct{}{}
|
||||
}
|
||||
addPeerRef(msg.From, currentChannelID, userRefs, channelRefs)
|
||||
if msg.SendAs != nil {
|
||||
addPeerRef(*msg.SendAs, currentChannelID, userRefs, channelRefs)
|
||||
}
|
||||
if msg.Forward != nil {
|
||||
addPeerRef(msg.Forward.From, currentChannelID, userRefs, channelRefs)
|
||||
}
|
||||
if msg.ViaBotID != 0 {
|
||||
userRefs[msg.ViaBotID] = struct{}{}
|
||||
}
|
||||
if msg.ReplyTo != nil {
|
||||
addPeerRef(msg.ReplyTo.Peer, currentChannelID, userRefs, channelRefs)
|
||||
}
|
||||
if msg.Action != nil {
|
||||
for _, id := range msg.Action.UserIDs {
|
||||
if id != 0 {
|
||||
userRefs[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type pgChannelMessageIDAllocator struct {
|
||||
db sqlcgen.DBTX
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1003,7 +1003,7 @@ WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, maxID, req.D
|
|||
}
|
||||
msg, _ := s.getChannelMessage(ctx, tx, req.ChannelID, channel.TopMessageID)
|
||||
if changed {
|
||||
outboxUpdates, err = advanceChannelReadOutboxTx(ctx, tx, channel, msg, req.UserID, previous, maxID)
|
||||
outboxUpdates, err = advanceChannelReadOutboxTx(ctx, tx, channel.ID, req.UserID, previous, maxID)
|
||||
if err != nil {
|
||||
return domain.ReadChannelHistoryResult{}, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,18 @@ func emptyChannelMessageReactions(channel domain.Channel) domain.ChannelMessageR
|
|||
}
|
||||
|
||||
func (s *ChannelStore) populateChannelMessagesReactions(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, channels []domain.Channel, messages []domain.ChannelMessage) error {
|
||||
return s.populateChannelMessagesReactionsWhere(ctx, db, viewerUserID, channels, messages, nil, false)
|
||||
}
|
||||
|
||||
func (s *ChannelStore) populateChannelMessagesReactionsWhere(
|
||||
ctx context.Context,
|
||||
db sqlcgen.DBTX,
|
||||
viewerUserID int64,
|
||||
channels []domain.Channel,
|
||||
messages []domain.ChannelMessage,
|
||||
reactionEligible func(domain.ChannelMessage) bool,
|
||||
unreadAlreadyProjected bool,
|
||||
) error {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -53,8 +65,10 @@ func (s *ChannelStore) populateChannelMessagesReactions(ctx context.Context, db
|
|||
if err := s.populateChannelMessagesPolls(ctx, db, viewerUserID, messages); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := populateChannelMessageUnreadFlags(ctx, db, viewerUserID, messages); err != nil {
|
||||
return err
|
||||
if !unreadAlreadyProjected {
|
||||
if err := populateChannelMessageUnreadFlags(ctx, db, viewerUserID, messages); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
channelsByID := make(map[int64]domain.Channel, len(channels))
|
||||
for _, ch := range channels {
|
||||
|
|
@ -68,6 +82,9 @@ func (s *ChannelStore) populateChannelMessagesReactions(ctx context.Context, db
|
|||
if messages[i].ChannelID == 0 || messages[i].ID <= 0 {
|
||||
continue
|
||||
}
|
||||
if reactionEligible != nil && !reactionEligible(messages[i]) {
|
||||
continue
|
||||
}
|
||||
key := channelReactionMessageKey{channelID: messages[i].ChannelID, messageID: messages[i].ID}
|
||||
if _, ok := indexes[key]; !ok {
|
||||
idsByChannel[messages[i].ChannelID] = append(idsByChannel[messages[i].ChannelID], int32(messages[i].ID))
|
||||
|
|
@ -202,6 +219,30 @@ ORDER BY channel_id ASC, message_id ASC, reaction_date DESC, reacted_user_id DES
|
|||
return nil
|
||||
}
|
||||
|
||||
// populateChannelDialogTopMessageReactions keeps poll and unread-mention
|
||||
// enrichment exact for every message, but uses the shared top-message
|
||||
// existence cache to avoid querying three reaction tables when no reaction row
|
||||
// can possibly contribute to the viewer projection.
|
||||
func (s *ChannelStore) populateChannelDialogTopMessageReactions(
|
||||
ctx context.Context,
|
||||
db sqlcgen.DBTX,
|
||||
viewerUserID int64,
|
||||
channels []domain.Channel,
|
||||
messages []domain.ChannelMessage,
|
||||
unreadAlreadyProjected bool,
|
||||
) error {
|
||||
if !s.topMessageCacheActive(db) || len(messages) == 0 {
|
||||
return s.populateChannelMessagesReactions(ctx, db, viewerUserID, channels, messages)
|
||||
}
|
||||
presence, err := s.topMsgCache.reactionPresenceFor(ctx, db, messages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load channel top reaction presence: %w", err)
|
||||
}
|
||||
return s.populateChannelMessagesReactionsWhere(ctx, db, viewerUserID, channels, messages, func(msg domain.ChannelMessage) bool {
|
||||
return presence[channelMessageLookupKey{channelID: msg.ChannelID, id: msg.ID}].any()
|
||||
}, unreadAlreadyProjected)
|
||||
}
|
||||
|
||||
func channelReactionOffset(row domain.ChannelMessagePeerReaction) string {
|
||||
return strconv.Itoa(row.Date) + ":" + strconv.FormatInt(row.UserID, 10) + ":" + string(row.Reaction.Type) + ":" + row.Reaction.Value()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,66 @@ package postgres
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestChannelTopReactionPresenceNegativeCacheInvalidatesOnReaction(t *testing.T) {
|
||||
env := newReactionPolicyTestEnv(t, false)
|
||||
ctx := context.Background()
|
||||
topCache := NewChannelTopMessageCache(32)
|
||||
env.channels.topMsgCache = topCache
|
||||
key := channelMessageLookupKey{channelID: env.channelID, id: env.messageID}
|
||||
|
||||
// Observe listener readiness through a sentinel flush before warming the
|
||||
// negative reaction-presence entry.
|
||||
topCache.reactionPresence.Store(key, channelTopReactionPresence{Normal: true})
|
||||
lctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
listener := NewReadModelChangeListener(os.Getenv("TELESRV_TEST_POSTGRES_DSN"), ReadModelCacheSet{
|
||||
ChannelTopMessages: topCache,
|
||||
}, nil)
|
||||
go listener.Run(lctx)
|
||||
if !waitUntil(2*time.Second, func() bool {
|
||||
_, ok := topCache.reactionPresence.Peek(key)
|
||||
return !ok
|
||||
}) {
|
||||
t.Fatal("read-model listener did not flush reaction sentinel")
|
||||
}
|
||||
|
||||
before, err := env.channels.GetChannelDialogs(ctx, env.ownerID, []int64{env.channelID})
|
||||
if err != nil {
|
||||
t.Fatalf("warm no-reaction dialog: %v", err)
|
||||
}
|
||||
if len(before.Messages) != 1 || before.Messages[0].Reactions != nil {
|
||||
t.Fatalf("before reaction messages = %+v", before.Messages)
|
||||
}
|
||||
if presence, ok := topCache.reactionPresence.Peek(key); !ok || presence.any() {
|
||||
t.Fatalf("negative presence not cached: ok=%v value=%+v", ok, presence)
|
||||
}
|
||||
|
||||
if _, err := env.react(t, env.memberID, "U0001f44d"); err != nil {
|
||||
t.Fatalf("add reaction: %v", err)
|
||||
}
|
||||
if !waitUntil(3*time.Second, func() bool {
|
||||
_, ok := topCache.reactionPresence.Peek(key)
|
||||
return !ok
|
||||
}) {
|
||||
t.Fatal("reaction write did not invalidate negative presence")
|
||||
}
|
||||
|
||||
after, err := env.channels.GetChannelDialogs(ctx, env.ownerID, []int64{env.channelID})
|
||||
if err != nil {
|
||||
t.Fatalf("dialog after reaction: %v", err)
|
||||
}
|
||||
if len(after.Messages) != 1 || after.Messages[0].Reactions == nil || len(after.Messages[0].Reactions.Results) != 1 || after.Messages[0].Reactions.Results[0].Count != 1 {
|
||||
t.Fatalf("after reaction messages = %+v", after.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
type reactionPolicyTestEnv struct {
|
||||
channels *ChannelStore
|
||||
channelID int64
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package postgres
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"sort"
|
||||
|
|
@ -359,7 +358,7 @@ func (s *ChannelStore) ReadChannelHistory(ctx context.Context, req domain.ReadCh
|
|||
return domain.ReadChannelHistoryResult{}, lastErr
|
||||
}
|
||||
|
||||
func advanceChannelReadOutboxTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, top domain.ChannelMessage, readerUserID int64, previous, maxID int) ([]domain.ChannelReadOutboxUpdate, error) {
|
||||
func advanceChannelReadOutboxTx(ctx context.Context, tx pgx.Tx, channelID, readerUserID int64, previous, maxID int) ([]domain.ChannelReadOutboxUpdate, error) {
|
||||
if maxID <= previous {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -368,7 +367,7 @@ func advanceChannelReadOutboxTx(ctx context.Context, tx pgx.Tx, channel domain.C
|
|||
lowerID = maxID - domain.MaxChannelReadOutboxScanMessages
|
||||
}
|
||||
rows, err := tx.Query(ctx, `
|
||||
WITH latest_sender_messages AS (
|
||||
WITH latest_sender_messages AS MATERIALIZED (
|
||||
SELECT sender_user_id, MAX(id) AS max_id
|
||||
FROM channel_messages
|
||||
WHERE channel_id = $1
|
||||
|
|
@ -379,52 +378,35 @@ WITH latest_sender_messages AS (
|
|||
GROUP BY sender_user_id
|
||||
ORDER BY max_id DESC
|
||||
LIMIT $5
|
||||
), updated AS (
|
||||
UPDATE channel_members AS member
|
||||
SET read_outbox_max_id = GREATEST(member.read_outbox_max_id, latest.max_id),
|
||||
updated_at = now()
|
||||
FROM latest_sender_messages AS latest
|
||||
WHERE member.channel_id = $1
|
||||
AND member.user_id = latest.sender_user_id
|
||||
AND member.status = 'active'
|
||||
AND member.read_outbox_max_id < latest.max_id
|
||||
RETURNING member.user_id, member.read_outbox_max_id
|
||||
)
|
||||
SELECT sender_user_id, max_id
|
||||
FROM latest_sender_messages
|
||||
ORDER BY sender_user_id ASC`, channel.ID, lowerID, maxID, readerUserID, domain.MaxChannelReadOutboxFanout)
|
||||
SELECT user_id, read_outbox_max_id
|
||||
FROM updated
|
||||
ORDER BY user_id ASC`, channelID, lowerID, maxID, readerUserID, domain.MaxChannelReadOutboxFanout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list channel read outbox senders: %w", err)
|
||||
return nil, fmt.Errorf("advance channel sender read outbox: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
type candidate struct {
|
||||
userID int64
|
||||
maxID int
|
||||
}
|
||||
candidates := make([]candidate, 0, domain.MaxChannelReadOutboxFanout)
|
||||
out := make([]domain.ChannelReadOutboxUpdate, 0, domain.MaxChannelReadOutboxFanout)
|
||||
for rows.Next() {
|
||||
var item candidate
|
||||
if err := rows.Scan(&item.userID, &item.maxID); err != nil {
|
||||
var item domain.ChannelReadOutboxUpdate
|
||||
if err := rows.Scan(&item.UserID, &item.MaxID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidates = append(candidates, item)
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]domain.ChannelReadOutboxUpdate, 0, len(candidates))
|
||||
for _, item := range candidates {
|
||||
var readOutboxMaxID, readInboxMaxID int
|
||||
err := tx.QueryRow(ctx, `
|
||||
UPDATE channel_members
|
||||
SET read_outbox_max_id = GREATEST(read_outbox_max_id, $3),
|
||||
updated_at = now()
|
||||
WHERE channel_id = $1
|
||||
AND user_id = $2
|
||||
AND status = 'active'
|
||||
AND read_outbox_max_id < $3
|
||||
RETURNING read_outbox_max_id, read_inbox_max_id`, channel.ID, item.userID, item.maxID).Scan(&readOutboxMaxID, &readInboxMaxID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update channel sender read outbox: %w", err)
|
||||
}
|
||||
if err := upsertChannelDialogTx(ctx, tx, item.userID, channel, top, readInboxMaxID, readOutboxMaxID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, domain.ChannelReadOutboxUpdate{UserID: item.userID, MaxID: readOutboxMaxID})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,93 @@ package postgres
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/observability/dbtrace"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestChannelStoreReadHistorySenderFanoutUsesConstantStatements(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
baseCtx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
reader, err := users.Create(baseCtx, domain.User{
|
||||
AccessHash: 351, Phone: "+1776" + suffix + "00", FirstName: "SetReader",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create reader: %v", err)
|
||||
}
|
||||
const senderCount = 12
|
||||
senders := make([]domain.User, 0, senderCount)
|
||||
userIDs := []int64{reader.ID}
|
||||
for i := 0; i < senderCount; i++ {
|
||||
sender, createErr := users.Create(baseCtx, domain.User{
|
||||
AccessHash: int64(352 + i), Phone: fmt.Sprintf("+1776%s%02d", suffix, i+1), FirstName: "SetSender",
|
||||
})
|
||||
if createErr != nil {
|
||||
t.Fatalf("create sender %d: %v", i, createErr)
|
||||
}
|
||||
senders = append(senders, sender)
|
||||
userIDs = append(userIDs, sender.ID)
|
||||
}
|
||||
var channelID int64
|
||||
t.Cleanup(func() {
|
||||
if channelID != 0 {
|
||||
_, _ = pool.Exec(baseCtx, "DELETE FROM channels WHERE id = $1", channelID)
|
||||
}
|
||||
_, _ = pool.Exec(baseCtx, "DELETE FROM users WHERE id = ANY($1::bigint[])", userIDs)
|
||||
})
|
||||
|
||||
channels := NewChannelStore(pool)
|
||||
created, err := channels.CreateChannel(baseCtx, domain.CreateChannelRequest{
|
||||
CreatorUserID: reader.ID, Title: "Set Read Outbox " + suffix, Megagroup: true,
|
||||
MemberUserIDs: userIDs[1:], Date: 1700000300,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
channelID = created.Channel.ID
|
||||
topID := 0
|
||||
for i, sender := range senders {
|
||||
sent, sendErr := channels.SendChannelMessage(baseCtx, domain.SendChannelMessageRequest{
|
||||
UserID: sender.ID, ChannelID: channelID, RandomID: int64(936000 + i),
|
||||
Message: "set-based channel read outbox", Date: 1700000310 + i,
|
||||
})
|
||||
if sendErr != nil {
|
||||
t.Fatalf("send %d: %v", i, sendErr)
|
||||
}
|
||||
topID = sent.Message.ID
|
||||
}
|
||||
|
||||
ctx, stats := dbtrace.WithStats(baseCtx)
|
||||
read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
|
||||
UserID: reader.ID, ChannelID: channelID, MaxID: topID, Date: 1700000400,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("read channel history: %v", err)
|
||||
}
|
||||
if len(read.OutboxUpdates) != senderCount {
|
||||
t.Fatalf("outbox updates = %d, want %d: %+v", len(read.OutboxUpdates), senderCount, read.OutboxUpdates)
|
||||
}
|
||||
if snapshot := stats.Snapshot(); snapshot.Errors != 0 || snapshot.Queries > 16 {
|
||||
t.Fatalf("read-history query stats = %+v, want constant <=16 queries for %d senders", snapshot, senderCount)
|
||||
}
|
||||
for i, sender := range senders {
|
||||
var readOutbox int
|
||||
if err := pool.QueryRow(baseCtx, `
|
||||
SELECT read_outbox_max_id FROM channel_members
|
||||
WHERE channel_id=$1 AND user_id=$2`, channelID, sender.ID).Scan(&readOutbox); err != nil {
|
||||
t.Fatalf("load sender %d read outbox: %v", i, err)
|
||||
}
|
||||
if readOutbox <= 0 || readOutbox > topID {
|
||||
t.Fatalf("sender %d read outbox = %d, want 1..%d", i, readOutbox, topID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelStoreReadOutboxDoesNotRegressSenderDialogUnread(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue