merged from gramsrv upstream

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

View file

@ -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)

View file

@ -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
}

View 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)
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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.

View file

@ -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.

View file

@ -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 为已登录敏感操作提供作用域;登录验证码保持零值。

View file

@ -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)
}

View 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)

View 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)
}
}
}

View file

@ -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 内分配,

View 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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 {

View 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)
}
}

View file

@ -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"},

View file

@ -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]

View file

@ -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,

View file

@ -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()

View 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)
}
}

View file

@ -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
}
}
}
}

View file

@ -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()

View 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)))
}

View 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)
}
}

View file

@ -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),

View file

@ -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()

View file

@ -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]))
}
}

View file

@ -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

View 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)
}
}

View file

@ -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

View file

@ -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]++

View 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)
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)
}

View file

@ -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
}

View file

@ -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(&notifications); 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)
}
}

View file

@ -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,

View 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
}

View file

@ -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)
}
}
}

View 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))
}

View file

@ -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)
}
})
}

View file

@ -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)
}

View file

@ -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

View 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)

View 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)

View file

@ -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()

View file

@ -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(
&current.Layer,
&current.MessageID,
&current.ObservationID,
&current.ExpiresAt,
&current.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,
&current.Layer,
&current.MessageID,
&current.ObservationID,
&current.ExpiresAt,
&now,
&current.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(&current.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(&current.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(
&current.Layer,
&current.MessageID,
&current.ObservationID,
&current.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(

View 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,
&current.Layer,
&current.MessageID,
&current.ObservationID,
&current.ExpiresAt,
&current.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)

View 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)

View file

@ -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 {

View file

@ -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
}

View file

@ -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)

View file

@ -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)
}

View 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
}

View 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)
}
}
}

View 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
}

View 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,
}
}

View 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)

View 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))
}

View file

@ -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")
}
}

View file

@ -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 {

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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)
}
}

View file

@ -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

View file

@ -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()

View file

@ -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
}

View file

@ -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})

View file

@ -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)
}
}

View file

@ -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,

View file

@ -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 {

View 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
}

View file

@ -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)
}
}

View 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)
}
}

View file

@ -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 {

View 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))
}
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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

View file

@ -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})

View file

@ -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 {

View file

@ -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

View file

@ -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)
}
}

View 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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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()
}

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"telesrv/internal/domain"
)

View file

@ -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

View file

@ -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
}

View file

@ -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