merged from gramsrv upstream
|
|
@ -19,6 +19,22 @@ import (
|
|||
|
||||
const accountDeletionDelay = 7 * 24 * time.Hour
|
||||
|
||||
func (s *Service) RevenueWithdrawalPasswordState(ctx context.Context, userID int64) (domain.RevenueWithdrawalPasswordState, error) {
|
||||
if s == nil || s.lifecycle == nil || userID == 0 {
|
||||
return domain.RevenueWithdrawalPasswordState{}, fmt.Errorf("revenue withdrawal password state is unavailable")
|
||||
}
|
||||
snapshot, found, err := s.lifecycle.AccountDeletionSnapshot(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.RevenueWithdrawalPasswordState{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.RevenueWithdrawalPasswordState{}, domain.ErrUserNotFound
|
||||
}
|
||||
return domain.RevenueWithdrawalPasswordState{
|
||||
HasPassword: snapshot.HasPassword, PasswordChangedAt: snapshot.PasswordUpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteAccount implements the official 2FA deletion decision. A supplied and
|
||||
// valid SRP proof always deletes immediately. Without a proof, an account whose
|
||||
// password is older than seven days and which was active during the last seven
|
||||
|
|
@ -345,17 +361,3 @@ func (s *Service) SweepDueAccountDeletions(ctx context.Context, now time.Time, l
|
|||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error) {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.lifecycle.ClaimAccountDeletionNotifications(ctx, now, limit, lease)
|
||||
}
|
||||
|
||||
func (s *Service) CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error {
|
||||
if s == nil || s.lifecycle == nil {
|
||||
return nil
|
||||
}
|
||||
return s.lifecycle.CompleteAccountDeletionNotification(ctx, id, now)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,9 +141,3 @@ func (f *fakeAccountLifecycleStore) CancelAccountDeletion(_ context.Context, use
|
|||
func (*fakeAccountLifecycleStore) DueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionCandidate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*fakeAccountLifecycleStore) ClaimAccountDeletionNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountDeletionNotification, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*fakeAccountLifecycleStore) CompleteAccountDeletionNotification(context.Context, int64, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,18 +15,6 @@ import (
|
|||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
type reliablePhoneChangeDispatcher interface {
|
||||
UsesReliableDispatch() bool
|
||||
}
|
||||
|
||||
func (s *Service) PhoneChangeUsesReliableDispatch() bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
reporter, ok := s.phoneChanges.(reliablePhoneChangeDispatcher)
|
||||
return ok && reporter.UsesReliableDispatch()
|
||||
}
|
||||
|
||||
// SendChangePhoneCode 创建只允许当前 user + perm auth_key 消费的改号验证码。
|
||||
// CodeStore 会按 purpose+user+auth_key+phone 原子轮换:同一作用域的新请求
|
||||
// 立即使旧 hash 失效,避免 Android 返回重进页面时留下并行有效验证码。
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ func TestPhoneChangeScopesCodeAndPersistsDurableEvent(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("change phone after session reconnect: %v", err)
|
||||
}
|
||||
if !result.Changed || result.User.Phone != "15550012002" || result.Event.Type != domain.UpdateEventUserPhone || result.Event.Phone != "15550012002" || result.Event.Pts != 1 {
|
||||
if !result.Changed || result.User.Phone != "15550012002" {
|
||||
t.Fatalf("change result = %+v", result)
|
||||
}
|
||||
if got := f.changes.lastRequest().ExcludeAuthKeyID; got != rawAuthKeyID {
|
||||
|
|
@ -160,7 +160,7 @@ func TestPhoneChangeScopesCodeAndPersistsDurableEvent(t *testing.T) {
|
|||
t.Fatalf("new phone resolves to %+v found=%v", got, found)
|
||||
}
|
||||
events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10)
|
||||
if err != nil || len(events) != 1 || events[0].Type != domain.UpdateEventUserPhone || events[0].Phone != "15550012002" {
|
||||
if err != nil || len(events) != 0 {
|
||||
t.Fatalf("durable events = %+v err=%v", events, err)
|
||||
}
|
||||
if _, found, _ := f.codes.Get(f.ctx, hash); found {
|
||||
|
|
@ -194,6 +194,26 @@ func TestPhoneChangeRejectsOccupiedAndCrossAuthCode(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPhoneChangeRejectsOccupiedNationalTrunkVariant(t *testing.T) {
|
||||
f := newPhoneChangeFixture(t)
|
||||
occupied, err := f.users.Create(f.ctx, domain.User{AccessHash: 103, Phone: "989981679461", FirstName: "Iran"})
|
||||
if err != nil {
|
||||
t.Fatalf("create occupied user: %v", err)
|
||||
}
|
||||
if _, _, err := f.service.SendChangePhoneCode(
|
||||
f.ctx,
|
||||
f.user.ID,
|
||||
f.authKeyID,
|
||||
77,
|
||||
"+98 0998 167 9461",
|
||||
); !errors.Is(err, domain.ErrPhoneNumberOccupied) {
|
||||
t.Fatalf("occupied trunk variant err = %v", err)
|
||||
}
|
||||
if got, found, err := f.users.ByPhone(f.ctx, "989981679461"); err != nil || !found || got.ID != occupied.ID {
|
||||
t.Fatalf("canonical owner user=%+v found=%v err=%v", got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneChangeWrongCodeExhaustsAttempts(t *testing.T) {
|
||||
f := newPhoneChangeFixture(t)
|
||||
hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012005")
|
||||
|
|
@ -233,12 +253,12 @@ func TestPhoneChangeNewSendInvalidatesPreviousHash(t *testing.T) {
|
|||
t.Fatalf("new hash change: %v", err)
|
||||
}
|
||||
events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10)
|
||||
if err != nil || len(events) != 1 || events[0].Type != domain.UpdateEventUserPhone {
|
||||
if err != nil || len(events) != 0 {
|
||||
t.Fatalf("events = %+v err=%v", events, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneChangeConcurrentReplayAppendsOneEvent(t *testing.T) {
|
||||
func TestPhoneChangeConcurrentReplayChangesOnceWithoutPTSEvent(t *testing.T) {
|
||||
f := newPhoneChangeFixture(t)
|
||||
hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012007")
|
||||
if err != nil {
|
||||
|
|
@ -273,7 +293,7 @@ func TestPhoneChangeConcurrentReplayAppendsOneEvent(t *testing.T) {
|
|||
t.Fatalf("successes=%d expired=%d", successes, expired)
|
||||
}
|
||||
events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10)
|
||||
if err != nil || len(events) != 1 || events[0].Pts != 1 {
|
||||
if err != nil || len(events) != 0 {
|
||||
t.Fatalf("events = %+v err=%v", events, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1053,6 +1053,12 @@ func (s *Service) SetLoginEmail(ctx context.Context, userID int64, email string)
|
|||
return s.passwords.Save(ctx, userID, settings)
|
||||
}
|
||||
|
||||
// ValidLoginEmail exposes the same normalization/shape gate to trusted
|
||||
// administrative dry-runs without duplicating the address policy.
|
||||
func (s *Service) ValidLoginEmail(email string) bool {
|
||||
return validLoginEmail(normalizeLoginEmail(email))
|
||||
}
|
||||
|
||||
// LoginEmail 返回已登录用户的登录邮箱原始地址(用于 verifyEmail 回显 emailVerified.email)。
|
||||
func (s *Service) LoginEmail(ctx context.Context, userID int64) (string, bool, error) {
|
||||
if s == nil || s.passwords == nil || userID == 0 {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,32 @@ func TestWebhookPhoneLoginUsesRandomSMSCode(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestWebhookPhoneLoginCanonicalizesNationalTrunkBeforeOTP(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sender := &captureOTPSender{}
|
||||
svc := NewService(
|
||||
memory.NewUserStore(),
|
||||
memory.NewAuthorizationStore(),
|
||||
memory.NewCodeStore(),
|
||||
nil,
|
||||
nil,
|
||||
"fixed-code-must-not-leak",
|
||||
WithPhoneCodeDelivery(sender, 6),
|
||||
)
|
||||
|
||||
hash, err := svc.SendCode(ctx, "+98 0998 167 9461")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
if len(sender.requests) != 1 || sender.requests[0].Recipient != "989981679461" {
|
||||
t.Fatalf("OTP requests = %+v, want canonical Iran recipient", sender.requests)
|
||||
}
|
||||
_, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, "989981679461", hash, sender.requests[0].Code)
|
||||
if err != nil || !needSignUp {
|
||||
t.Fatalf("SignIn canonical variant needSignUp=%v err=%v", needSignUp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookExistingAccountRejectionKeepsDurableAppCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
|
|
|
|||
|
|
@ -64,7 +64,10 @@ func validPhone(phone string) bool {
|
|||
}
|
||||
|
||||
func systemUserLoginForbidden(u domain.User) bool {
|
||||
return domain.IsSystemUserID(u.ID)
|
||||
// Login-facing callers deliberately use the same non-enumerating error for
|
||||
// reserved identities and irreversible tombstones. The durable authorization
|
||||
// store repeats the deleted check under the user lock to close the TOCTOU gap.
|
||||
return u.Deleted || domain.IsSystemUserID(u.ID)
|
||||
}
|
||||
|
||||
func systemLoginPhoneForbidden(phone string) bool {
|
||||
|
|
@ -270,12 +273,14 @@ func NewService(users store.UserStore, auths store.AuthorizationStore, codes sto
|
|||
}
|
||||
|
||||
// BindTempAuthKey 校验并记录 TDesktop PFS temp→perm auth key 绑定。
|
||||
func (s *Service) BindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) error {
|
||||
func (s *Service) BindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (domain.TempAuthKeyBindingResult, error) {
|
||||
var validated domain.TempAuthKeyBindingResult
|
||||
if s.authKeys != nil {
|
||||
inner, protocolExpiresAt, err := s.validateBindTempAuthKey(ctx, sessionID, binding)
|
||||
inner, protocolExpiresAt, result, err := s.validateBindTempAuthKey(ctx, sessionID, binding)
|
||||
if err != nil {
|
||||
return err
|
||||
return domain.TempAuthKeyBindingResult{}, err
|
||||
}
|
||||
validated = result
|
||||
binding.TempSessionID = inner.TempSessionID
|
||||
// The bind request's expires_at is a signed client assertion. TDesktop
|
||||
// intentionally adds a small grace interval, while Android derives its
|
||||
|
|
@ -287,21 +292,22 @@ func (s *Service) BindTempAuthKey(ctx context.Context, sessionID int64, binding
|
|||
// The edge may admit the frame immediately before the temporary key's
|
||||
// absolute boundary and the encrypted proof may cross it. This is a temp-key
|
||||
// rotation condition, never a destructive permanent-key proof failure.
|
||||
return ErrTempAuthKeyEmpty
|
||||
return domain.TempAuthKeyBindingResult{}, ErrTempAuthKeyEmpty
|
||||
}
|
||||
if s.tempKeys == nil {
|
||||
return nil
|
||||
return validated, nil
|
||||
}
|
||||
if err := s.tempKeys.Save(ctx, binding); err != nil {
|
||||
result, err := s.tempKeys.SaveWithState(ctx, binding)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrTempAuthKeyAlreadyBound) {
|
||||
return ErrTempAuthKeyAlreadyBound
|
||||
return domain.TempAuthKeyBindingResult{}, ErrTempAuthKeyAlreadyBound
|
||||
}
|
||||
if errors.Is(err, store.ErrAuthKeyBindingInvalid) {
|
||||
return s.classifyBindingStoreInvalid(ctx, binding)
|
||||
return domain.TempAuthKeyBindingResult{}, s.classifyBindingStoreInvalid(ctx, binding)
|
||||
}
|
||||
return err
|
||||
return domain.TempAuthKeyBindingResult{}, err
|
||||
}
|
||||
return nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ResolveAuthKey 将已绑定的 temp auth_key 解析为对应 perm auth_key。
|
||||
|
|
@ -323,7 +329,7 @@ func (s *Service) ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byt
|
|||
|
||||
// UserID 返回 auth_key 当前绑定的用户。未登录、或两步验证未完成时 found=false。
|
||||
func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) {
|
||||
if s == nil || s.auths == nil {
|
||||
if s == nil || s.auths == nil || s.users == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
a, found, err := s.auths.ByAuthKey(ctx, authKeyID)
|
||||
|
|
@ -334,7 +340,13 @@ func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, e
|
|||
// 两步验证未完成:业务鉴权视为未登录,仅允许 auth.checkPassword 继续。
|
||||
return 0, false, nil
|
||||
}
|
||||
if domain.IsSystemUserID(a.UserID) {
|
||||
u, userFound, err := s.users.ByID(ctx, a.UserID)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if !userFound || systemUserLoginForbidden(u) {
|
||||
// A stale row can exist only after an interrupted/legacy path. Fail closed
|
||||
// before it reaches the Router auth cache and retire it opportunistically.
|
||||
_ = s.auths.Delete(ctx, authKeyID)
|
||||
return 0, false, nil
|
||||
}
|
||||
|
|
@ -344,14 +356,18 @@ func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, e
|
|||
// PendingPasswordUserID 返回处于"待两步验证"状态的 auth_key 对应的用户。
|
||||
// UserID 对 password_pending 的 auth_key 返回未登录,auth.checkPassword 借此仍能定位待验证用户。
|
||||
func (s *Service) PendingPasswordUserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) {
|
||||
if s == nil || s.auths == nil {
|
||||
if s == nil || s.auths == nil || s.users == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
a, found, err := s.auths.ByAuthKey(ctx, authKeyID)
|
||||
if err != nil || !found || !a.PasswordPending {
|
||||
return 0, false, err
|
||||
}
|
||||
if domain.IsSystemUserID(a.UserID) {
|
||||
u, userFound, err := s.users.ByID(ctx, a.UserID)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if !userFound || systemUserLoginForbidden(u) {
|
||||
_ = s.auths.Delete(ctx, authKeyID)
|
||||
return 0, false, nil
|
||||
}
|
||||
|
|
@ -359,13 +375,24 @@ func (s *Service) PendingPasswordUserID(ctx context.Context, authKeyID [8]byte)
|
|||
}
|
||||
|
||||
// CompletePasswordSignIn 在两步验证通过后清除 password_pending,使 auth_key 转为完全授权。
|
||||
func (s *Service) CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte) error {
|
||||
func (s *Service) CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte, expectedUserID int64) error {
|
||||
if s == nil || s.auths == nil {
|
||||
return nil
|
||||
}
|
||||
if err := s.auths.MarkPasswordPassed(ctx, authKeyID); err != nil {
|
||||
if expectedUserID == 0 {
|
||||
return store.ErrAuthorizationStateChanged
|
||||
}
|
||||
if err := s.auths.MarkPasswordPassed(ctx, authKeyID, expectedUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
// Revalidate the active account after the CAS promotion before Router caches
|
||||
// or binds the session. Account deletion can still linearize immediately
|
||||
// after the update and must leave the caller unauthorized.
|
||||
if userID, found, err := s.UserID(ctx, authKeyID); err != nil {
|
||||
return err
|
||||
} else if !found || userID != expectedUserID {
|
||||
return ErrSystemUserLoginForbidden
|
||||
}
|
||||
// This is where a 2FA account's sign-in actually finishes — finishSignIn
|
||||
// deliberately skipped the welcome message while password_pending.
|
||||
if a, found, err := s.auths.ByAuthKey(ctx, authKeyID); err == nil && found {
|
||||
|
|
@ -1423,7 +1450,11 @@ func (s *Service) AuthKeyClientInfo(ctx context.Context, authKeyID [8]byte) (dom
|
|||
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
|
||||
return domain.AuthKeyClientInfo{}, false, nil
|
||||
}
|
||||
key, found, err := s.authKeys.Get(ctx, authKeyID)
|
||||
// Client metadata is a read-only projection. The physical connection's
|
||||
// first-frame Get and active-key heartbeat already own the durable orphan
|
||||
// lease, so this path must not turn every init/profile read into another
|
||||
// last_used_at write.
|
||||
key, found, err := s.authKeys.Revalidate(ctx, authKeyID)
|
||||
if err != nil || !found {
|
||||
return domain.AuthKeyClientInfo{}, found, err
|
||||
}
|
||||
|
|
@ -1499,6 +1530,16 @@ func (s *Service) ResetAuthorizations(ctx context.Context, userID int64, keepAut
|
|||
}
|
||||
|
||||
func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error {
|
||||
if s == nil || s.users == nil || s.auths == nil || userID == 0 {
|
||||
return ErrSystemUserLoginForbidden
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || systemUserLoginForbidden(u) {
|
||||
return ErrSystemUserLoginForbidden
|
||||
}
|
||||
if s.authKeys != nil {
|
||||
key, found, err := s.authKeys.Get(ctx, auth.AuthKeyID)
|
||||
if err != nil {
|
||||
|
|
@ -1519,6 +1560,9 @@ func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID in
|
|||
if errors.Is(err, store.ErrAuthKeyNotPermanent) {
|
||||
return ErrAuthKeyPermEmpty
|
||||
}
|
||||
if errors.Is(err, domain.ErrAccountDeleted) || errors.Is(err, domain.ErrUserNotFound) {
|
||||
return ErrSystemUserLoginForbidden
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
|
@ -1535,17 +1579,19 @@ func (s *Service) passwordNeeded(ctx context.Context, userID int64) (bool, error
|
|||
return found && settings.HasPassword, nil
|
||||
}
|
||||
|
||||
const loginMessageTpl = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
|
||||
func loginMessageTemplate() string {
|
||||
return `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
|
||||
|
||||
This code can be used to log in to your ` + branding.ProductName + ` account. We never ask it for anything else.
|
||||
|
||||
If you didn't request this code by trying to log in on another device, simply ignore this message.`
|
||||
}
|
||||
|
||||
func (s *Service) recordLoginMessage(ctx context.Context, userID int64, code string) (domain.Message, error) {
|
||||
if s.messages == nil || s.dialogs == nil {
|
||||
return domain.Message{}, nil
|
||||
}
|
||||
body := fmt.Sprintf(loginMessageTpl, code)
|
||||
body := fmt.Sprintf(loginMessageTemplate(), code)
|
||||
codeOffset := len("Login code: ")
|
||||
msg, err := s.messages.Create(ctx, domain.Message{
|
||||
OwnerUserID: userID,
|
||||
|
|
@ -1595,53 +1641,58 @@ func (s *Service) recordWelcomeMessage(ctx context.Context, u domain.User) {
|
|||
})
|
||||
}
|
||||
|
||||
func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, int, error) {
|
||||
func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, int, domain.TempAuthKeyBindingResult, error) {
|
||||
if binding.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrExpiresAtInvalid
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrExpiresAtInvalid
|
||||
}
|
||||
temp, found, err := s.authKeys.Get(ctx, binding.TempAuthKeyID)
|
||||
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
|
||||
pair, err := s.authKeys.LoadBindingKeys(ctx, binding.TempAuthKeyID, permID)
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, err
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, err
|
||||
}
|
||||
// expires_at in auth.bindTempAuthKey is client-supplied and must only attest
|
||||
// to a still-live binding. It may never create or reclassify a protocol key;
|
||||
// the caller normalizes durable retention to this handshake-authoritative
|
||||
// temp.ExpiresAt instead of trusting the client value.
|
||||
if !found || temp.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrTempAuthKeyEmpty
|
||||
if !pair.TemporaryFound || pair.Temporary.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrTempAuthKeyEmpty
|
||||
}
|
||||
|
||||
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
|
||||
perm, found, err := s.authKeys.Get(ctx, permID)
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, err
|
||||
}
|
||||
if !found || perm.ExpiresAt != 0 {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
|
||||
if !pair.PermanentFound || pair.Permanent.ExpiresAt != 0 {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
|
||||
inner, err := decryptBindAuthKeyInner(perm, binding.EncryptedMessage)
|
||||
inner, err := decryptBindAuthKeyInner(pair.Permanent, binding.EncryptedMessage)
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
if inner.Nonce != binding.Nonce ||
|
||||
inner.TempAuthKeyID != authKeyIDInt64(binding.TempAuthKeyID) ||
|
||||
inner.PermAuthKeyID != binding.PermAuthKeyID ||
|
||||
inner.TempSessionID != sessionID ||
|
||||
inner.ExpiresAt != binding.ExpiresAt {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrEncryptedMessageInvalid
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrEncryptedMessageInvalid
|
||||
}
|
||||
if temp.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, ErrTempAuthKeyEmpty
|
||||
if pair.Temporary.ExpiresAt <= int(time.Now().Unix()) {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, ErrTempAuthKeyEmpty
|
||||
}
|
||||
return inner, temp.ExpiresAt, nil
|
||||
layer, observationID, err := store.MergeAuthKeyLayerObservations(
|
||||
pair.Temporary.Layer, pair.Temporary.LayerObservationID,
|
||||
pair.Permanent.Layer, pair.Permanent.LayerObservationID,
|
||||
)
|
||||
if err != nil {
|
||||
return mtcrypto.BindAuthKeyInner{}, 0, domain.TempAuthKeyBindingResult{}, err
|
||||
}
|
||||
return inner, pair.Temporary.ExpiresAt, domain.TempAuthKeyBindingResult{
|
||||
Layer: layer, LayerObservationID: observationID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) classifyBindingStoreInvalid(ctx context.Context, binding domain.TempAuthKeyBinding) error {
|
||||
if s == nil || s.authKeys == nil {
|
||||
return ErrEncryptedMessageInvalid
|
||||
}
|
||||
temp, found, err := s.authKeys.Get(ctx, binding.TempAuthKeyID)
|
||||
temp, found, err := s.authKeys.Revalidate(ctx, binding.TempAuthKeyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
|
|||
t.Fatalf("encrypt bind message: %v", err)
|
||||
}
|
||||
|
||||
err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
|
||||
_, err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
Nonce: nonce,
|
||||
|
|
@ -59,7 +59,7 @@ func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
|
|||
t.Fatalf("BindTempAuthKey valid message: %v", err)
|
||||
}
|
||||
|
||||
err = svc.BindTempAuthKey(ctx, sessionID+1, domain.TempAuthKeyBinding{
|
||||
_, err = svc.BindTempAuthKey(ctx, sessionID+1, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
Nonce: nonce,
|
||||
|
|
@ -89,7 +89,7 @@ func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("encrypt extended bind message: %v", err)
|
||||
}
|
||||
err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
|
||||
_, err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
Nonce: nonce,
|
||||
|
|
@ -120,17 +120,17 @@ func TestBindTempAuthKeyClassifiesExpiryWithoutDestroyingPermanentKey(t *testing
|
|||
PermAuthKeyID: permKey.IntID(),
|
||||
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
|
||||
}
|
||||
if err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrTempAuthKeyEmpty) {
|
||||
if _, err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrTempAuthKeyEmpty) {
|
||||
t.Fatalf("expired protocol temp key err = %v, want ErrTempAuthKeyEmpty", err)
|
||||
}
|
||||
|
||||
request.TempAuthKeyID = testAuthKey(0x33).ID
|
||||
if err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrTempAuthKeyEmpty) {
|
||||
if _, err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrTempAuthKeyEmpty) {
|
||||
t.Fatalf("missing protocol temp key err = %v, want ErrTempAuthKeyEmpty", err)
|
||||
}
|
||||
|
||||
request.ExpiresAt = int(time.Now().Add(-time.Second).Unix())
|
||||
if err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrExpiresAtInvalid) {
|
||||
if _, err := svc.BindTempAuthKey(ctx, 1001, request); !errors.Is(err, ErrExpiresAtInvalid) {
|
||||
t.Fatalf("expired request proof err = %v, want ErrExpiresAtInvalid", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -346,6 +346,59 @@ func TestAuthorizationBindRejectsTemporaryProtocolKey(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDeletedUserCannotCrossAuthorizationBoundaries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
deleted, err := users.Create(ctx, domain.User{
|
||||
Deleted: true,
|
||||
DeletedAt: time.Now().Unix(),
|
||||
DeletionSource: domain.AccountDeletionManual,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create deleted user: %v", err)
|
||||
}
|
||||
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345")
|
||||
|
||||
passkeyAuthKeyID := [8]byte{0x91}
|
||||
if _, err := svc.BindVerifiedLogin(ctx, domain.Authorization{AuthKeyID: passkeyAuthKeyID}, deleted.ID); !errors.Is(err, ErrSystemUserLoginForbidden) {
|
||||
t.Fatalf("BindVerifiedLogin deleted user err = %v, want ErrSystemUserLoginForbidden", err)
|
||||
}
|
||||
if _, found, err := authz.ByAuthKey(ctx, passkeyAuthKeyID); err != nil || found {
|
||||
t.Fatalf("deleted passkey authorization found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
|
||||
qrAuthKeyID := [8]byte{0x92}
|
||||
if _, err := svc.AcceptLoginToken(ctx, domain.Authorization{AuthKeyID: qrAuthKeyID}, deleted.ID); !errors.Is(err, ErrSystemUserLoginForbidden) {
|
||||
t.Fatalf("AcceptLoginToken deleted user err = %v, want ErrSystemUserLoginForbidden", err)
|
||||
}
|
||||
if _, found, err := authz.ByAuthKey(ctx, qrAuthKeyID); err != nil || found {
|
||||
t.Fatalf("deleted QR authorization found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
|
||||
staleAuthKeyID := [8]byte{0x93}
|
||||
if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: staleAuthKeyID, UserID: deleted.ID}); err != nil {
|
||||
t.Fatalf("seed stale authorization: %v", err)
|
||||
}
|
||||
if userID, found, err := svc.UserID(ctx, staleAuthKeyID); err != nil || found || userID != 0 {
|
||||
t.Fatalf("UserID stale tombstone = %d found=%v err=%v, want unauthorized", userID, found, err)
|
||||
}
|
||||
if _, found, err := authz.ByAuthKey(ctx, staleAuthKeyID); err != nil || found {
|
||||
t.Fatalf("stale tombstone authorization found=%v err=%v, want retired", found, err)
|
||||
}
|
||||
|
||||
pendingAuthKeyID := [8]byte{0x94}
|
||||
if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: pendingAuthKeyID, UserID: deleted.ID, PasswordPending: true}); err != nil {
|
||||
t.Fatalf("seed stale pending authorization: %v", err)
|
||||
}
|
||||
if err := svc.CompletePasswordSignIn(ctx, pendingAuthKeyID, deleted.ID); !errors.Is(err, ErrSystemUserLoginForbidden) {
|
||||
t.Fatalf("CompletePasswordSignIn deleted user err = %v, want ErrSystemUserLoginForbidden", err)
|
||||
}
|
||||
if _, found, err := authz.ByAuthKey(ctx, pendingAuthKeyID); err != nil || found {
|
||||
t.Fatalf("stale pending tombstone authorization found=%v err=%v, want retired", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneCodeAcceptsTDesktopDigitsOnlySignIn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
|
|
@ -377,6 +430,100 @@ func TestPhoneCodeAcceptsTDesktopDigitsOnlySignIn(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestVirtual888PhoneRegistersAndSignsIn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
delivery := &captureLoginCodeDelivery{}
|
||||
svc := NewService(
|
||||
users,
|
||||
authz,
|
||||
memory.NewCodeStore(),
|
||||
nil,
|
||||
nil,
|
||||
"12345",
|
||||
WithLoginCodeDelivery(delivery),
|
||||
)
|
||||
const (
|
||||
formatted = "+888 12-34"
|
||||
canonical = "8881234"
|
||||
)
|
||||
|
||||
firstHash, err := svc.SendCode(ctx, formatted)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode virtual phone: %v", err)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, canonical, firstHash, "12345")
|
||||
created, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: [8]byte{0x54}}, formatted, firstHash, "Virtual", "User")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp virtual phone: %v", err)
|
||||
}
|
||||
if created.Phone != canonical {
|
||||
t.Fatalf("created phone = %q, want %q", created.Phone, canonical)
|
||||
}
|
||||
|
||||
secondHash, err := svc.SendCode(ctx, canonical)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode existing virtual phone: %v", err)
|
||||
}
|
||||
signedIn, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: [8]byte{0x55}}, formatted, secondHash, "12345")
|
||||
if err != nil {
|
||||
t.Fatalf("SignIn virtual phone: %v", err)
|
||||
}
|
||||
if needSignUp || signedIn.ID != created.ID {
|
||||
t.Fatalf("SignIn user=%d needSignUp=%v, want existing user %d", signedIn.ID, needSignUp, created.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIranNationalTrunkVariantsShareOneAccountIdentity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
delivery := &captureLoginCodeDelivery{}
|
||||
svc := NewService(
|
||||
users,
|
||||
authz,
|
||||
memory.NewCodeStore(),
|
||||
nil,
|
||||
nil,
|
||||
"12345",
|
||||
WithLoginCodeDelivery(delivery),
|
||||
)
|
||||
const (
|
||||
withNationalTrunk = "+98 0998 167 9461"
|
||||
international = "989981679461"
|
||||
canonical = "989981679461"
|
||||
)
|
||||
|
||||
firstHash, err := svc.SendCode(ctx, withNationalTrunk)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode trunk variant: %v", err)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, international, firstHash, "12345")
|
||||
created, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: [8]byte{1}}, international, firstHash, "Iran", "User")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp international variant: %v", err)
|
||||
}
|
||||
if created.Phone != canonical {
|
||||
t.Fatalf("created phone = %q, want %q", created.Phone, canonical)
|
||||
}
|
||||
|
||||
secondHash, err := svc.SendCode(ctx, international)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode existing international variant: %v", err)
|
||||
}
|
||||
signedIn, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: [8]byte{2}}, withNationalTrunk, secondHash, "12345")
|
||||
if err != nil {
|
||||
t.Fatalf("SignIn trunk variant: %v", err)
|
||||
}
|
||||
if needSignUp || signedIn.ID != created.ID {
|
||||
t.Fatalf("SignIn user=%d needSignUp=%v, want existing user %d", signedIn.ID, needSignUp, created.ID)
|
||||
}
|
||||
if got, found, err := users.ByPhone(ctx, canonical); err != nil || !found || got.ID != created.ID {
|
||||
t.Fatalf("canonical lookup user=%+v found=%v err=%v", got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func verifyCodeForSignUp(t *testing.T, svc *Service, phone, hash, code string) {
|
||||
t.Helper()
|
||||
got, msg, needSignUp, err := svc.SignIn(context.Background(), domain.Authorization{}, phone, hash, code)
|
||||
|
|
@ -809,7 +956,10 @@ func TestSignInExistingTwoFactorAccountNeedsPassword(t *testing.T) {
|
|||
t.Fatalf("PendingPasswordUserID = %d pending=%v err=%v, want %d", pendingUID, pending, err, u.ID)
|
||||
}
|
||||
// 两步验证通过后转为完全授权。
|
||||
if err := svc.CompletePasswordSignIn(ctx, key); err != nil {
|
||||
if err := svc.CompletePasswordSignIn(ctx, key, 0); !errors.Is(err, store.ErrAuthorizationStateChanged) {
|
||||
t.Fatalf("CompletePasswordSignIn without expected user err=%v, want authorization state changed", err)
|
||||
}
|
||||
if err := svc.CompletePasswordSignIn(ctx, key, u.ID); err != nil {
|
||||
t.Fatalf("CompletePasswordSignIn: %v", err)
|
||||
}
|
||||
bound, found, err = svc.UserID(ctx, key)
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ func TestTwoFactorSignInDefersWelcomeMessageUntilPasswordCompletes(t *testing.T)
|
|||
t.Fatalf("welcome message fired before password check completed: %+v", pending.Messages[0])
|
||||
}
|
||||
|
||||
if err := svc.CompletePasswordSignIn(ctx, key); err != nil {
|
||||
if err := svc.CompletePasswordSignIn(ctx, key, u.ID); err != nil {
|
||||
t.Fatalf("CompletePasswordSignIn: %v", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ const (
|
|||
maxTelegramLoginCommandsPerMessage = 32
|
||||
)
|
||||
|
||||
const botFatherHelpText = `I can help you create and manage ` + branding.ProductName + ` bots.
|
||||
func botFatherHelpText() string {
|
||||
return `I can help you create and manage ` + branding.ProductName + ` bots.
|
||||
|
||||
You can control me by sending these commands:
|
||||
|
||||
|
|
@ -73,6 +74,7 @@ You can control me by sending these commands:
|
|||
/done - finish the active Telegram Login configuration
|
||||
/cancel - cancel the current operation
|
||||
/help - show this message`
|
||||
}
|
||||
|
||||
// botReply 是内置 service bot 的一条回复。ReplyMarkup 为可选 inline keyboard
|
||||
// 快照(@verifybot 的按钮式对话使用);落库前经 domain.ValidateReplyMarkup 校验。
|
||||
|
|
@ -80,6 +82,7 @@ type botReply struct {
|
|||
Text string
|
||||
Entities []domain.MessageEntity
|
||||
ReplyMarkup *domain.MessageReplyMarkup
|
||||
Media *domain.MessageMedia
|
||||
}
|
||||
|
||||
// HandlesBot 报告该收件人是否为内置应答 bot(messages.BotResponder 实现)。
|
||||
|
|
@ -105,7 +108,7 @@ func (s *Service) HandlesBot(botUserID int64) bool {
|
|||
// OnPrivateMessage 处理投递给内置 bot 的私聊消息(messages.BotResponder 实现)。
|
||||
// msg 是 bot 视角的收件 box 行。回复异步生成(不占用户 sendMessage 的 RPC
|
||||
// goroutine——官方 bot 回复本就异步到达),失败只记日志,绝不影响用户消息本身。
|
||||
func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg domain.Message) {
|
||||
func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg domain.Message, session domain.ClientSessionMetadata) {
|
||||
if s == nil || s.messages == nil || !s.HandlesBot(botUserID) {
|
||||
return
|
||||
}
|
||||
|
|
@ -163,7 +166,7 @@ func (s *Service) serviceBotRecipientBlocked(ctx context.Context, botUserID, use
|
|||
}
|
||||
|
||||
func (s *Service) sendServiceBotReplyResult(ctx context.Context, botUserID, userID int64, reply botReply) (domain.SendPrivateTextResult, bool) {
|
||||
if s == nil || s.messages == nil || reply.Text == "" {
|
||||
if s == nil || s.messages == nil || (reply.Text == "" && reply.Media.IsZero()) {
|
||||
return domain.SendPrivateTextResult{}, false
|
||||
}
|
||||
markup := reply.ReplyMarkup
|
||||
|
|
@ -183,6 +186,7 @@ func (s *Service) sendServiceBotReplyResult(ctx context.Context, botUserID, user
|
|||
RandomID: s.botReplyRandomID(),
|
||||
Message: reply.Text,
|
||||
Entities: serviceBotReplyEntities(reply.Text, reply.Entities),
|
||||
Media: reply.Media,
|
||||
ReplyMarkup: markup,
|
||||
Date: int(s.now().Unix()),
|
||||
RecipientBlocked: s.serviceBotRecipientBlocked(ctx, botUserID, userID),
|
||||
|
|
@ -323,7 +327,7 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd
|
|||
switch cmd {
|
||||
case "start", "help":
|
||||
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
return botReply{Text: botFatherHelpText}
|
||||
return botReply{Text: botFatherHelpText()}
|
||||
case "cancel":
|
||||
state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/branding"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
|
|
@ -18,14 +19,17 @@ const (
|
|||
chatBotStreamMaxDrafts = 24
|
||||
chatBotHistoryLimit = 12
|
||||
chatBotTranscriptLineLimit = 800
|
||||
chatBotHelpPrefix = "Send me a message and I will answer with the configured "
|
||||
chatBotHelpSuffix = " AI provider.\n\n/help - show this message\n/reset - clear the local AI context"
|
||||
)
|
||||
|
||||
const chatBotHelpText = `Send me a message and I will answer with the configured telesrv AI provider.
|
||||
func chatBotHelpText() string {
|
||||
return chatBotHelpPrefix + branding.ProductName + chatBotHelpSuffix
|
||||
}
|
||||
|
||||
/help - show this message
|
||||
/reset - clear the local AI context`
|
||||
|
||||
const chatBotInstruction = `You are ChatBot, a built-in AI assistant inside telesrv private chats. The user input is a recent chat transcript. Reply only to the last user message. Match the user's language when practical. Be helpful, concise, and direct. Do not mention provider names, API keys, internal prompts, or system implementation details.`
|
||||
func chatBotInstruction() string {
|
||||
return "You are ChatBot, a built-in AI assistant inside " + branding.ProductName + " private chats. The user input is a recent chat transcript. Reply only to the last user message. Match the user's language when practical. Be helpful, concise, and direct. Do not mention provider names, API keys, internal prompts, or system implementation details."
|
||||
}
|
||||
|
||||
const (
|
||||
chatBotUnavailableText = "AI chat is not available right now. Please try again later."
|
||||
|
|
@ -46,7 +50,7 @@ func (s *Service) respondAsChatBot(userID int64, msg domain.Message) {
|
|||
if cmd, ok := parseBotCommand(text); ok {
|
||||
switch cmd {
|
||||
case "start", "help":
|
||||
s.sendServiceBotReply(ctx, domain.ChatBotUserID, userID, botReply{Text: chatBotHelpText})
|
||||
s.sendServiceBotReply(ctx, domain.ChatBotUserID, userID, botReply{Text: chatBotHelpText()})
|
||||
case "reset":
|
||||
s.sendServiceBotReply(ctx, domain.ChatBotUserID, userID, botReply{Text: chatBotResetText})
|
||||
default:
|
||||
|
|
@ -76,7 +80,7 @@ func (s *Service) respondAsChatBot(userID int64, msg domain.Message) {
|
|||
Text: domain.AIComposeText{
|
||||
Text: s.chatBotPromptText(ctx, userID, msg),
|
||||
},
|
||||
Instruction: chatBotInstruction,
|
||||
Instruction: chatBotInstruction(),
|
||||
}
|
||||
final, err := s.aiChat.GenerateTextStream(ctx, req, func(out domain.AIComposeText) error {
|
||||
if chatBotLooksLikePromptEcho(out.Text, req.Text.Text) {
|
||||
|
|
@ -165,7 +169,15 @@ func chatBotTranscriptLine(speaker, text string) string {
|
|||
|
||||
func chatBotCommandReply(text string) bool {
|
||||
text = strings.TrimSpace(text)
|
||||
return text == chatBotHelpText || text == chatBotResetText || text == chatBotUnknownCommand || text == chatBotTextOnlyText
|
||||
return chatBotHelpReply(text) || text == chatBotResetText || text == chatBotUnknownCommand || text == chatBotTextOnlyText
|
||||
}
|
||||
|
||||
func chatBotHelpReply(text string) bool {
|
||||
if !strings.HasPrefix(text, chatBotHelpPrefix) || !strings.HasSuffix(text, chatBotHelpSuffix) {
|
||||
return false
|
||||
}
|
||||
brand := strings.TrimSuffix(strings.TrimPrefix(text, chatBotHelpPrefix), chatBotHelpSuffix)
|
||||
return strings.TrimSpace(brand) != ""
|
||||
}
|
||||
|
||||
func chatBotLooksLikePromptEcho(text, prompt string) bool {
|
||||
|
|
|
|||
|
|
@ -128,6 +128,19 @@ func TestChatBotSystemSeedAndCommands(t *testing.T) {
|
|||
assertReplyEntityText(t, reply, domain.MessageEntityBotCommand, "/help")
|
||||
}
|
||||
|
||||
func TestChatBotCommandReplyRecognizesHelpFromPreviousBrand(t *testing.T) {
|
||||
oldHelp := chatBotHelpPrefix + "Previous Product" + chatBotHelpSuffix
|
||||
if !chatBotCommandReply(oldHelp) {
|
||||
t.Fatal("help reply from previous product brand should be excluded from AI history")
|
||||
}
|
||||
if chatBotCommandReply(chatBotHelpPrefix + chatBotHelpSuffix) {
|
||||
t.Fatal("help-shaped text without a product name should not be classified as a bot command reply")
|
||||
}
|
||||
if chatBotCommandReply("ordinary assistant reply") {
|
||||
t.Fatal("ordinary assistant reply should remain in AI history")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatBotStreamsByTypingDraftThenFinalMessage(t *testing.T) {
|
||||
ai := &fakeChatAI{
|
||||
chunks: []string{"Hel", "Hello from AI"},
|
||||
|
|
|
|||
41
internal/app/bots/gifbot_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package bots
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type gifCatalogTestSource struct {
|
||||
entries []domain.GifCatalogEntry
|
||||
docs []domain.Document
|
||||
}
|
||||
|
||||
func (s gifCatalogTestSource) ListGifCatalog(context.Context, bool) ([]domain.GifCatalogEntry, error) {
|
||||
return s.entries, nil
|
||||
}
|
||||
func (s gifCatalogTestSource) GetDocuments(context.Context, []int64) ([]domain.Document, error) {
|
||||
return s.docs, nil
|
||||
}
|
||||
|
||||
func TestGifBotRanksMatchesAndReturnsPlayableDocuments(t *testing.T) {
|
||||
doc := domain.Document{ID: 9, MimeType: "video/mp4", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrAnimated}, {Kind: domain.DocAttrVideo, W: 320, H: 240, Duration: 1}}}
|
||||
svc := NewService(nil, nil, nil, WithGifCatalogSource(gifCatalogTestSource{
|
||||
entries: []domain.GifCatalogEntry{{ID: 1, Title: "Dog", DocumentID: 9}, {ID: 2, Title: "Cat wave", DocumentID: 9}}, docs: []domain.Document{doc},
|
||||
}))
|
||||
got, handled, err := svc.OnInlineQuery(context.Background(), domain.GifBotUserID, 42, "cat", "")
|
||||
if err != nil || !handled || got.QueryID != 0 || len(got.Results) != 2 {
|
||||
t.Fatalf("OnInlineQuery = %+v,%v,%v", got, handled, err)
|
||||
}
|
||||
if got.Results[0].ID != "2" || got.Results[0].Media == nil || got.Results[0].Media.Document.ID != 9 {
|
||||
t.Fatalf("ranked results = %+v", got.Results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGifBotFailsFastOnMissingDocument(t *testing.T) {
|
||||
svc := NewService(nil, nil, nil, WithGifCatalogSource(gifCatalogTestSource{entries: []domain.GifCatalogEntry{{ID: 1, Title: "Missing", DocumentID: 99}}}))
|
||||
if _, handled, err := svc.OnInlineQuery(context.Background(), domain.GifBotUserID, 42, "", ""); !handled || err == nil {
|
||||
t.Fatalf("handled=%v err=%v", handled, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import (
|
|||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/branding"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/links"
|
||||
)
|
||||
|
|
@ -44,18 +45,17 @@ const (
|
|||
stickersBotCreatedListPageLimit = 20
|
||||
)
|
||||
|
||||
const stickersBotHelpText = `I can help you create sticker and custom emoji packs for telesrv.
|
||||
|
||||
Send /newpack to create a sticker pack.
|
||||
Send /newemoji to create a custom emoji pack.
|
||||
Send /addsticker to add an item to one of your packs.
|
||||
Send /delsticker to remove an item from one of your packs.
|
||||
|
||||
Send a sticker/custom emoji, or upload a TGS, Lottie JSON, WebP, WebM, or MP4 file as a document. Send /publish when your pack is ready, then choose a short name for the link.
|
||||
|
||||
/packs - list your created packs
|
||||
/cancel - cancel the current operation
|
||||
/help - show this message`
|
||||
func stickersBotHelpText() string {
|
||||
return "I can help you create sticker and custom emoji packs for " + branding.ProductName + ".\n\n" +
|
||||
"Send /newpack to create a sticker pack.\n" +
|
||||
"Send /newemoji to create a custom emoji pack.\n" +
|
||||
"Send /addsticker to add an item to one of your packs.\n" +
|
||||
"Send /delsticker to remove an item from one of your packs.\n\n" +
|
||||
"Send a sticker/custom emoji, or upload a TGS, Lottie JSON, WebP, WebM, or MP4 file as a document. Send /publish when your pack is ready, then choose a short name for the link.\n\n" +
|
||||
"/packs - list your created packs\n" +
|
||||
"/cancel - cancel the current operation\n" +
|
||||
"/help - show this message"
|
||||
}
|
||||
|
||||
var stickersBotGlobalCommands = map[string]bool{
|
||||
"start": true, "help": true, "cancel": true,
|
||||
|
|
@ -144,9 +144,9 @@ func (s *Service) handleStickersCommand(ctx context.Context, userID int64, cmd s
|
|||
switch cmd {
|
||||
case "start":
|
||||
_ = s.bots.DeleteBotChatState(ctx, domain.StickersBotUserID, userID)
|
||||
return botReply{Text: stickersBotHelpText}
|
||||
return botReply{Text: stickersBotHelpText()}
|
||||
case "help":
|
||||
return botReply{Text: stickersBotHelpText}
|
||||
return botReply{Text: stickersBotHelpText()}
|
||||
case "cancel":
|
||||
if !found {
|
||||
return botReply{Text: "No active pack to cancel."}
|
||||
|
|
@ -197,9 +197,9 @@ func (s *Service) startStickersEditFlow(ctx context.Context, userID int64, cmd s
|
|||
return internalReply()
|
||||
}
|
||||
if cmd == stickersBotCmdDel {
|
||||
return botReply{Text: "Send the short name or telesrv link of the pack you want to edit. Use /packs to see your packs."}
|
||||
return botReply{Text: "Send the short name or " + branding.ProductName + " link of the pack you want to edit. Use /packs to see your packs."}
|
||||
}
|
||||
return botReply{Text: "Send the short name or telesrv link of the pack you want to add to. Use /packs to see your packs."}
|
||||
return botReply{Text: "Send the short name or " + branding.ProductName + " link of the pack you want to add to. Use /packs to see your packs."}
|
||||
}
|
||||
|
||||
func (s *Service) startStickersFlow(ctx context.Context, userID int64, cmd string, kind domain.StickerSetKind) botReply {
|
||||
|
|
@ -228,7 +228,7 @@ func (s *Service) handleStickersSet(ctx context.Context, state domain.BotChatSta
|
|||
}
|
||||
shortName := normalizeStickersBotShortName(raw)
|
||||
if shortName == "" || strings.HasPrefix(shortName, "/") {
|
||||
return botReply{Text: "Send the pack short name or telesrv link. Use /packs to list your packs, or /cancel."}
|
||||
return botReply{Text: "Send the pack short name or " + branding.ProductName + " link. Use /packs to list your packs, or /cancel."}
|
||||
}
|
||||
set, _, found, err := s.stickers.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: shortName})
|
||||
if err != nil {
|
||||
|
|
@ -556,7 +556,7 @@ func (s *Service) listStickersBotPacks(ctx context.Context, userID int64) botRep
|
|||
func stickersBotStepPrompt(state domain.BotChatState) botReply {
|
||||
switch state.Step {
|
||||
case stickersBotStepSet:
|
||||
return botReply{Text: "Send the pack short name or telesrv link, or /cancel."}
|
||||
return botReply{Text: "Send the pack short name or " + branding.ProductName + " link, or /cancel."}
|
||||
case stickersBotStepTitle:
|
||||
return botReply{Text: "Send a title for this pack, or /cancel."}
|
||||
case stickersBotStepDocument:
|
||||
|
|
|
|||
|
|
@ -194,13 +194,16 @@ const (
|
|||
|
||||
// verifierBotWhatText is the part of /start that is true whether or not an
|
||||
// operator has activated this bot, so it is said first and unconditionally.
|
||||
const verifierBotWhatText = `I hand out THIRD-PARTY verification.
|
||||
func verifierBotWhatText() string {
|
||||
return `I hand out THIRD-PARTY verification.
|
||||
|
||||
A third-party mark is a verifier's own icon, shown right before the name of a bot, a channel or an account, plus one line of description in its profile. It means "this verifier vouches for this peer" -- nothing more.
|
||||
|
||||
It is NOT the official ` + branding.ProductName + ` checkmark. The platform badge is granted by the platform itself (@verifybot collects those applications); a third-party mark is granted by the company running a verifier bot. The two are stored, shown and taken away separately, and neither one implies the other.`
|
||||
}
|
||||
|
||||
const verifierBotHelpText = `I am a verifier bot. I grant third-party marks: my icon before the name of your bot, channel or account, plus a description in its profile. This is not the official ` + branding.ProductName + ` checkmark.
|
||||
func verifierBotHelpText() string {
|
||||
return `I am a verifier bot. I grant third-party marks: my icon before the name of your bot, channel or account, plus a description in its profile. This is not the official ` + branding.ProductName + ` checkmark.
|
||||
|
||||
/start - what a third-party mark is and who grants it
|
||||
/verify - apply for the mark
|
||||
|
|
@ -210,6 +213,7 @@ const verifierBotHelpText = `I am a verifier bot. I grant third-party marks: my
|
|||
/help - show this message
|
||||
|
||||
I do not decide anything: I collect the application, an operator grants or refuses the mark, and I message you here with the outcome.`
|
||||
}
|
||||
|
||||
const (
|
||||
verifierBotIdleText = `I only hand out third-party verification marks. Send /verify to apply, /status to see where your applications stand, /revoke to remove a mark, or /help to see what I understand.`
|
||||
|
|
@ -363,7 +367,7 @@ func (s *Service) handleVerifier(ctx context.Context, userID int64, body string)
|
|||
|
||||
func (s *Service) handleVerifierCommand(ctx context.Context, userID int64, cmd string, state domain.BotChatState, found bool) botReply {
|
||||
if cmd == "help" {
|
||||
return botReply{Text: verifierBotHelpText}
|
||||
return botReply{Text: verifierBotHelpText()}
|
||||
}
|
||||
if s.customVerification == nil {
|
||||
return botReply{Text: verifierUnavailableText}
|
||||
|
|
@ -386,7 +390,7 @@ func (s *Service) handleVerifierCommand(ctx context.Context, userID int64, cmd s
|
|||
case "cancel":
|
||||
return s.cancelVerifierDialog(ctx, userID, state, found)
|
||||
default:
|
||||
return botReply{Text: verifierBotHelpText}
|
||||
return botReply{Text: verifierBotHelpText()}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -532,7 +536,7 @@ func (s *Service) verifierIntro(ctx context.Context, userID int64, state domain.
|
|||
settings, refusal, ok := s.verifierSettings(ctx, userID)
|
||||
if !ok {
|
||||
// No keyboard, no state write: there is nothing for the applicant to press.
|
||||
return botReply{Text: verifierJoin(verifierBotWhatText, refusal.Text)}
|
||||
return botReply{Text: verifierJoin(verifierBotWhatText(), refusal.Text)}
|
||||
}
|
||||
state.Step = verifierStepIntro
|
||||
markup := s.verifierOptionKeyboard(&state, [][]verifierOption{{
|
||||
|
|
@ -544,7 +548,7 @@ func (s *Service) verifierIntro(ctx context.Context, userID int64, state domain.
|
|||
live := fmt.Sprintf("Verifier: %s\n\nThe mark I would put on your peer:\n%s\n\nTap the button below, or send /verify, to apply. An operator reads every application and decides; I only collect it. Send /help for the rest of my commands.",
|
||||
verifierTruncate(strings.TrimSpace(settings.CompanyName), domain.MaxVerifierCompanyLength),
|
||||
verifierDescriptionLine(settings))
|
||||
return botReply{Text: verifierJoin(verifierBotWhatText, live), ReplyMarkup: markup}
|
||||
return botReply{Text: verifierJoin(verifierBotWhatText(), live), ReplyMarkup: markup}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -431,7 +431,7 @@ func TestVerifierBotHiddenByThirdPartyVerificationFlag(t *testing.T) {
|
|||
svc.OnPrivateMessage(context.Background(), domain.VerifierBotUserID, domain.Message{
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
|
||||
Body: "/start",
|
||||
})
|
||||
}, domain.ClientSessionMetadata{})
|
||||
if replies := verifierReplies(t, messages, owner.ID); len(replies) != 0 {
|
||||
t.Fatalf("hidden @verifierbot replied: %+v", replies)
|
||||
}
|
||||
|
|
@ -811,7 +811,7 @@ func TestVerifierBotHelpAndIdleText(t *testing.T) {
|
|||
// /help answers even with no verification service wired at all: it describes the
|
||||
// bot rather than reading any state.
|
||||
help := sendToVerifierBot(t, svc, messages, owner.ID, "/help")
|
||||
if help.Body != verifierBotHelpText {
|
||||
if help.Body != verifierBotHelpText() {
|
||||
t.Fatalf("/help = %q", help.Body)
|
||||
}
|
||||
for _, want := range []string{"/start", "/verify", "/status", "/revoke", "/help", "not the official"} {
|
||||
|
|
@ -846,7 +846,7 @@ func TestVerifierBotGlobalCommandsWorkMidStep(t *testing.T) {
|
|||
pressVerifierButton(t, svc, owner.ID, latestVerifierReply(t, messages, owner.ID), "@examplenews")
|
||||
|
||||
// /help and /status in the middle of the reason step answer and keep the step.
|
||||
if help := sendToVerifierBot(t, svc, messages, owner.ID, "/help"); help.Body != verifierBotHelpText {
|
||||
if help := sendToVerifierBot(t, svc, messages, owner.ID, "/help"); help.Body != verifierBotHelpText() {
|
||||
t.Fatalf("/help mid-step = %q", help.Body)
|
||||
}
|
||||
if status := sendToVerifierBot(t, svc, messages, owner.ID, "/status"); status.Body != verifierNoRequestsText {
|
||||
|
|
|
|||
|
|
@ -114,7 +114,8 @@ const (
|
|||
verifyChoiceBlockedPrefix = "no:"
|
||||
)
|
||||
|
||||
const verifyBotStartText = `I collect applications for official ` + branding.ProductName + ` verification: the badge shown next to the name of a channel, supergroup or bot whose identity has been confirmed.
|
||||
func verifyBotStartText() string {
|
||||
return `I collect applications for official ` + branding.ProductName + ` verification: the badge shown next to the name of a channel, supergroup or bot whose identity has been confirmed.
|
||||
|
||||
Before you apply, check that the subject of the application:
|
||||
- is a channel, supergroup or bot with a public @username;
|
||||
|
|
@ -125,8 +126,10 @@ Before you apply, check that the subject of the application:
|
|||
This badge is never sold and never granted automatically. A person reads every application, and I message you here with the decision.
|
||||
|
||||
Tap the button below, or send /new, to start. Send /help for the full list of commands.`
|
||||
}
|
||||
|
||||
const verifyBotHelpText = `I collect official ` + branding.ProductName + ` verification applications.
|
||||
func verifyBotHelpText() string {
|
||||
return `I collect official ` + branding.ProductName + ` verification applications.
|
||||
|
||||
/new - file a verification application
|
||||
/status - list your applications and their status
|
||||
|
|
@ -134,6 +137,7 @@ const verifyBotHelpText = `I collect official ` + branding.ProductName + ` verif
|
|||
/help - show this message
|
||||
|
||||
One application asks for: the subject, a category, a description, the official website, optional social links, links to independent press coverage, and an optional comment for the reviewers. You can send /cancel at any point, and /status any time after filing.`
|
||||
}
|
||||
|
||||
const verifyBotIdleText = `I only collect official verification applications. Send /new to file one, /status to check the ones you filed, or /help to see what I understand.`
|
||||
|
||||
|
|
@ -310,7 +314,7 @@ func (s *Service) handleVerify(ctx context.Context, userID int64, body string) b
|
|||
|
||||
func (s *Service) handleVerifyCommand(ctx context.Context, userID int64, cmd string, state domain.BotChatState, found bool) botReply {
|
||||
if cmd == "help" {
|
||||
return botReply{Text: verifyBotHelpText}
|
||||
return botReply{Text: verifyBotHelpText()}
|
||||
}
|
||||
if s.verification == nil {
|
||||
return botReply{Text: verifyUnavailableText}
|
||||
|
|
@ -328,7 +332,7 @@ func (s *Service) handleVerifyCommand(ctx context.Context, userID int64, cmd str
|
|||
case "cancel":
|
||||
return s.cancelVerifyApplication(ctx, userID, state, found)
|
||||
default:
|
||||
return botReply{Text: verifyBotHelpText}
|
||||
return botReply{Text: verifyBotHelpText()}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -458,7 +462,7 @@ func (s *Service) verifyIntro(ctx context.Context, userID int64, state domain.Bo
|
|||
if !s.saveVerifyState(ctx, state) {
|
||||
return internalReply()
|
||||
}
|
||||
return botReply{Text: verifyBotStartText, ReplyMarkup: markup}
|
||||
return botReply{Text: verifyBotStartText(), ReplyMarkup: markup}
|
||||
}
|
||||
|
||||
// startVerifyApplication is /new and the Apply button. An applicant has at most
|
||||
|
|
|
|||
|
|
@ -679,7 +679,7 @@ func TestVerifyBotGlobalCommandsWorkMidStep(t *testing.T) {
|
|||
|
||||
// /help in the middle of the description step answers help and keeps the step.
|
||||
help := sendToVerifyBot(t, svc, messages, owner.ID, "/help")
|
||||
if help.Body != verifyBotHelpText {
|
||||
if help.Body != verifyBotHelpText() {
|
||||
t.Fatalf("/help mid-step = %q", help.Body)
|
||||
}
|
||||
status := sendToVerifyBot(t, svc, messages, owner.ID, "/status")
|
||||
|
|
@ -855,7 +855,7 @@ func TestVerifyBotWithoutServiceReportsUnavailable(t *testing.T) {
|
|||
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/new"); reply.Body != verifyUnavailableText {
|
||||
t.Fatalf("/new without a verification service = %q", reply.Body)
|
||||
}
|
||||
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/help"); reply.Body != verifyBotHelpText {
|
||||
if reply := sendToVerifyBot(t, svc, messages, owner.ID, "/help"); reply.Body != verifyBotHelpText() {
|
||||
t.Fatalf("/help without a verification service = %q", reply.Body)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,19 +2,31 @@ package channels
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/app/readmodel"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultActiveChannelIDsReadModelTTL = 24 * time.Hour
|
||||
activeChannelIDsReadModelMaxEntries = 8192
|
||||
activeChannelIDsReadModelMaxEntries = 32768
|
||||
activeChannelIDsNoVersionHash = -1
|
||||
activeChannelIDsStableCutAttempts = 2
|
||||
)
|
||||
|
||||
var errActiveChannelIDsGenerationChanged = errors.New("active channel IDs generation changed")
|
||||
|
||||
// ActiveChannelIDsReadModelMetrics records bounded shared-cache outcomes.
|
||||
// User IDs and page selectors are deliberately excluded.
|
||||
type ActiveChannelIDsReadModelMetrics interface {
|
||||
ActiveChannelIDsCache(outcome string)
|
||||
}
|
||||
|
||||
type activeChannelIDsCacheKey struct {
|
||||
userID int64
|
||||
afterChannelID int64
|
||||
|
|
@ -27,13 +39,16 @@ type activeChannelIDsReadModelCache struct {
|
|||
cache *readmodelcache.Cache[activeChannelIDsCacheKey, []int64]
|
||||
}
|
||||
|
||||
func newActiveChannelIDsReadModelCache(ttl time.Duration) *activeChannelIDsReadModelCache {
|
||||
func newActiveChannelIDsReadModelCache(maxEntries int, ttl time.Duration) *activeChannelIDsReadModelCache {
|
||||
if maxEntries <= 0 {
|
||||
maxEntries = activeChannelIDsReadModelMaxEntries
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = defaultActiveChannelIDsReadModelTTL
|
||||
}
|
||||
return &activeChannelIDsReadModelCache{
|
||||
cache: readmodelcache.New[activeChannelIDsCacheKey, []int64](readmodelcache.Config[activeChannelIDsCacheKey, []int64]{
|
||||
MaxEntries: activeChannelIDsReadModelMaxEntries,
|
||||
MaxEntries: maxEntries,
|
||||
TTL: ttl,
|
||||
Clone: cloneInt64s,
|
||||
}),
|
||||
|
|
@ -67,20 +82,114 @@ func (c *activeChannelIDsReadModelCache) invalidateUsers(userIDs ...int64) {
|
|||
}
|
||||
|
||||
func (s *Service) cachedActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error) {
|
||||
if s.activeIDsCache == nil || s.versions == nil {
|
||||
return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
|
||||
if s.activeIDsShared == nil {
|
||||
if s.activeIDsCache == nil || s.versions == nil {
|
||||
return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
|
||||
}
|
||||
hash, err := s.activeChannelIDsGeneration(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := activeChannelIDsCacheKey{userID: userID, afterChannelID: afterChannelID, limit: limit}
|
||||
return s.activeIDsCache.getOrLoad(ctx, key, hash, func() ([]int64, error) {
|
||||
return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
|
||||
})
|
||||
}
|
||||
if s.activeIDsCache == nil || s.versions == nil || s.activeIDsLoader == nil {
|
||||
return nil, errors.New("shared active channel IDs read model is incompletely configured")
|
||||
}
|
||||
key := activeChannelIDsCacheKey{userID: userID, afterChannelID: afterChannelID, limit: limit}
|
||||
for attempt := 0; attempt < activeChannelIDsStableCutAttempts; attempt++ {
|
||||
generation, err := s.activeChannelIDsGeneration(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channelIDs, err := s.activeIDsCache.getOrLoad(ctx, key, generation, func() ([]int64, error) {
|
||||
return s.loadSharedActiveChannelIDsPage(ctx, key, generation)
|
||||
})
|
||||
if errors.Is(err, errActiveChannelIDsGenerationChanged) {
|
||||
s.recordActiveChannelIDsCache("generation_retry")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentGeneration, err := s.activeChannelIDsGeneration(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentGeneration != generation {
|
||||
s.recordActiveChannelIDsCache("generation_retry")
|
||||
s.activeIDsCache.invalidateUsers(userID)
|
||||
continue
|
||||
}
|
||||
s.recordActiveChannelIDsCache("served")
|
||||
return channelIDs, nil
|
||||
}
|
||||
return nil, errActiveChannelIDsGenerationChanged
|
||||
}
|
||||
|
||||
func (s *Service) activeChannelIDsGeneration(ctx context.Context, userID int64) (int64, error) {
|
||||
if s == nil || s.versions == nil {
|
||||
return 0, errors.New("active channel IDs read model requires durable versions")
|
||||
}
|
||||
hash, ok, err := s.versions.ReadModelHash(ctx, readmodel.ModelChannelActiveIDs, userID, domain.PeerTypeUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return 0, err
|
||||
}
|
||||
if !ok || hash == 0 {
|
||||
hash = activeChannelIDsNoVersionHash
|
||||
return activeChannelIDsNoVersionHash, nil
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadSharedActiveChannelIDsPage(
|
||||
ctx context.Context,
|
||||
key activeChannelIDsCacheKey,
|
||||
generation int64,
|
||||
) ([]int64, error) {
|
||||
sharedKey := store.ActiveChannelIDsPageKey{
|
||||
UserID: key.userID, Generation: generation,
|
||||
AfterChannelID: key.afterChannelID, Limit: key.limit,
|
||||
}
|
||||
channelIDs, found, err := s.activeIDsShared.GetActiveChannelIDsPage(ctx, sharedKey)
|
||||
if err != nil {
|
||||
s.recordActiveChannelIDsCache("read_error")
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
s.recordActiveChannelIDsCache("hit")
|
||||
return channelIDs, nil
|
||||
}
|
||||
s.recordActiveChannelIDsCache("miss")
|
||||
channelIDs, err = s.activeIDsLoader.ListActiveChannelIDsForUser(
|
||||
ctx, key.userID, key.afterChannelID, key.limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if generation == activeChannelIDsNoVersionHash && len(channelIDs) != 0 {
|
||||
return nil, fmt.Errorf("active channel IDs generation missing for non-empty owner %d", key.userID)
|
||||
}
|
||||
currentGeneration, err := s.activeChannelIDsGeneration(ctx, key.userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentGeneration != generation {
|
||||
return nil, errActiveChannelIDsGenerationChanged
|
||||
}
|
||||
if err := s.activeIDsShared.PutActiveChannelIDsPage(ctx, sharedKey, channelIDs); err != nil {
|
||||
s.recordActiveChannelIDsCache("write_error")
|
||||
return nil, err
|
||||
}
|
||||
s.recordActiveChannelIDsCache("fill")
|
||||
return channelIDs, nil
|
||||
}
|
||||
|
||||
func (s *Service) recordActiveChannelIDsCache(outcome string) {
|
||||
if s != nil && s.activeIDsMetrics != nil {
|
||||
s.activeIDsMetrics.ActiveChannelIDsCache(outcome)
|
||||
}
|
||||
key := activeChannelIDsCacheKey{userID: userID, afterChannelID: afterChannelID, limit: limit}
|
||||
return s.activeIDsCache.getOrLoad(ctx, key, hash, func() ([]int64, error) {
|
||||
return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
|
||||
})
|
||||
}
|
||||
|
||||
func cloneInt64s(in []int64) []int64 {
|
||||
|
|
|
|||
261
internal/app/channels/active_ids_shared_test.go
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type fakeActiveChannelIDsPageCache struct {
|
||||
mu sync.Mutex
|
||||
values map[store.ActiveChannelIDsPageKey][]int64
|
||||
getErr error
|
||||
putErr error
|
||||
gets int
|
||||
puts int
|
||||
}
|
||||
|
||||
func (f *fakeActiveChannelIDsPageCache) GetActiveChannelIDsPage(
|
||||
_ context.Context,
|
||||
key store.ActiveChannelIDsPageKey,
|
||||
) ([]int64, bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.gets++
|
||||
if f.getErr != nil {
|
||||
return nil, false, f.getErr
|
||||
}
|
||||
value, found := f.values[key]
|
||||
return append([]int64(nil), value...), found, nil
|
||||
}
|
||||
|
||||
func (f *fakeActiveChannelIDsPageCache) PutActiveChannelIDsPage(
|
||||
_ context.Context,
|
||||
key store.ActiveChannelIDsPageKey,
|
||||
value []int64,
|
||||
) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.puts++
|
||||
if f.putErr != nil {
|
||||
return f.putErr
|
||||
}
|
||||
if f.values == nil {
|
||||
f.values = make(map[store.ActiveChannelIDsPageKey][]int64)
|
||||
}
|
||||
f.values[key] = append([]int64(nil), value...)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeActiveChannelIDsLoader struct {
|
||||
mu sync.Mutex
|
||||
values []int64
|
||||
err error
|
||||
calls int
|
||||
onLoad func()
|
||||
}
|
||||
|
||||
func (f *fakeActiveChannelIDsLoader) ListActiveChannelIDsForUser(
|
||||
_ context.Context,
|
||||
_, _ int64,
|
||||
_ int,
|
||||
) ([]int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls++
|
||||
if f.onLoad != nil {
|
||||
f.onLoad()
|
||||
}
|
||||
return append([]int64(nil), f.values...), f.err
|
||||
}
|
||||
|
||||
type fakeActiveChannelIDsMetrics struct {
|
||||
mu sync.Mutex
|
||||
outcomes map[string]int
|
||||
}
|
||||
|
||||
type mutableReadModelVersions struct {
|
||||
mu sync.Mutex
|
||||
hashes map[store.ReadModelKey]int64
|
||||
}
|
||||
|
||||
func (m *mutableReadModelVersions) ReadModelHash(
|
||||
_ context.Context,
|
||||
model string,
|
||||
ownerUserID int64,
|
||||
peerType domain.PeerType,
|
||||
peerID int64,
|
||||
) (int64, bool, error) {
|
||||
key := store.ReadModelKey{Model: model, OwnerUserID: ownerUserID, PeerType: peerType, PeerID: peerID}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
hash := m.hashes[key]
|
||||
return hash, hash != 0, nil
|
||||
}
|
||||
|
||||
func (m *mutableReadModelVersions) ReadModelHashes(
|
||||
_ context.Context,
|
||||
keys []store.ReadModelKey,
|
||||
) (map[store.ReadModelKey]int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make(map[store.ReadModelKey]int64, len(keys))
|
||||
for _, key := range keys {
|
||||
out[key] = m.hashes[key]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *mutableReadModelVersions) set(key store.ReadModelKey, hash int64) {
|
||||
m.mu.Lock()
|
||||
m.hashes[key] = hash
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (f *fakeActiveChannelIDsMetrics) ActiveChannelIDsCache(outcome string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.outcomes == nil {
|
||||
f.outcomes = make(map[string]int)
|
||||
}
|
||||
f.outcomes[outcome]++
|
||||
}
|
||||
|
||||
func TestActiveChannelIDsSharedPageSurvivesServiceRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
key := store.ReadModelKey{Model: "channel_active_memberships", OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}
|
||||
versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{key: 501}}
|
||||
shared := &fakeActiveChannelIDsPageCache{}
|
||||
firstLoader := &fakeActiveChannelIDsLoader{values: []int64{11, 12}}
|
||||
firstMetrics := &fakeActiveChannelIDsMetrics{}
|
||||
first := NewService(memory.NewChannelStore(),
|
||||
WithReadModelVersions(versions),
|
||||
WithActiveChannelIDsReadModel(shared, firstLoader, 32, 0, firstMetrics),
|
||||
)
|
||||
got, err := first.ActiveChannelIDsForUser(ctx, ownerID, 0, 1000)
|
||||
if err != nil || !slices.Equal(got, []int64{11, 12}) {
|
||||
t.Fatalf("first page = %v err=%v", got, err)
|
||||
}
|
||||
if firstLoader.calls != 1 || shared.puts != 1 || firstMetrics.outcomes["miss"] != 1 || firstMetrics.outcomes["fill"] != 1 {
|
||||
t.Fatalf("first load calls=%d puts=%d metrics=%v", firstLoader.calls, shared.puts, firstMetrics.outcomes)
|
||||
}
|
||||
|
||||
secondLoader := &fakeActiveChannelIDsLoader{err: errors.New("cold loader must not run")}
|
||||
secondMetrics := &fakeActiveChannelIDsMetrics{}
|
||||
second := NewService(memory.NewChannelStore(),
|
||||
WithReadModelVersions(versions),
|
||||
WithActiveChannelIDsReadModel(shared, secondLoader, 32, 0, secondMetrics),
|
||||
)
|
||||
got, err = second.ActiveChannelIDsForUser(ctx, ownerID, 0, 1000)
|
||||
if err != nil || !slices.Equal(got, []int64{11, 12}) {
|
||||
t.Fatalf("restart page = %v err=%v", got, err)
|
||||
}
|
||||
if secondLoader.calls != 0 || secondMetrics.outcomes["hit"] != 1 || secondMetrics.outcomes["served"] != 1 {
|
||||
t.Fatalf("restart loader=%d metrics=%v", secondLoader.calls, secondMetrics.outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveChannelIDsSharedPageFailsClosedOnRedisError(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
key := store.ReadModelKey{Model: "channel_active_memberships", OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}
|
||||
versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{key: 601}}
|
||||
shared := &fakeActiveChannelIDsPageCache{getErr: errors.New("redis down")}
|
||||
loader := &fakeActiveChannelIDsLoader{values: []int64{11}}
|
||||
service := NewService(memory.NewChannelStore(),
|
||||
WithReadModelVersions(versions),
|
||||
WithActiveChannelIDsReadModel(shared, loader, 32, 0, nil),
|
||||
)
|
||||
if _, err := service.ActiveChannelIDsForUser(context.Background(), ownerID, 0, 1000); err == nil {
|
||||
t.Fatal("Redis error was silently bypassed")
|
||||
}
|
||||
if loader.calls != 0 {
|
||||
t.Fatalf("cold loader calls = %d, want 0", loader.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveChannelIDsSharedPageRetriesGenerationChange(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
key := store.ReadModelKey{Model: "channel_active_memberships", OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}
|
||||
versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{key: 701}}
|
||||
shared := &fakeActiveChannelIDsPageCache{}
|
||||
loader := &fakeActiveChannelIDsLoader{values: []int64{11}}
|
||||
loader.onLoad = func() {
|
||||
if loader.calls == 1 {
|
||||
versions.hashes[key] = 702
|
||||
loader.values = []int64{11, 12}
|
||||
}
|
||||
}
|
||||
metrics := &fakeActiveChannelIDsMetrics{}
|
||||
service := NewService(memory.NewChannelStore(),
|
||||
WithReadModelVersions(versions),
|
||||
WithActiveChannelIDsReadModel(shared, loader, 32, 0, metrics),
|
||||
)
|
||||
got, err := service.ActiveChannelIDsForUser(ctx, ownerID, 0, 1000)
|
||||
if err != nil || !slices.Equal(got, []int64{11, 12}) {
|
||||
t.Fatalf("page = %v err=%v", got, err)
|
||||
}
|
||||
if loader.calls != 2 || shared.puts != 1 || metrics.outcomes["generation_retry"] != 1 {
|
||||
t.Fatalf("loader=%d puts=%d metrics=%v", loader.calls, shared.puts, metrics.outcomes)
|
||||
}
|
||||
oldKey := store.ActiveChannelIDsPageKey{UserID: ownerID, Generation: 701, AfterChannelID: 0, Limit: 1000}
|
||||
if _, found := shared.values[oldKey]; found {
|
||||
t.Fatal("generation-raced page was stored under old key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveChannelIDsSharedMissingGenerationOnlyCachesEmpty(t *testing.T) {
|
||||
shared := &fakeActiveChannelIDsPageCache{}
|
||||
loader := &fakeActiveChannelIDsLoader{values: []int64{11}}
|
||||
service := NewService(memory.NewChannelStore(),
|
||||
WithReadModelVersions(&fakeReadModelVersions{}),
|
||||
WithActiveChannelIDsReadModel(shared, loader, 32, 0, nil),
|
||||
)
|
||||
if _, err := service.ActiveChannelIDsForUser(context.Background(), 1001, 0, 1000); err == nil {
|
||||
t.Fatal("non-empty page without durable generation accepted")
|
||||
}
|
||||
if shared.puts != 0 {
|
||||
t.Fatalf("shared puts = %d, want 0", shared.puts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveChannelIDsLocalWriteInvalidatesCachedGenerationBeforeNotify(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
versionKey := store.ReadModelKey{Model: "channel_active_memberships", OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}
|
||||
baseVersions := &mutableReadModelVersions{hashes: map[store.ReadModelKey]int64{versionKey: 801}}
|
||||
cachedVersions := store.NewCachedReadModelVersionStore(baseVersions, 0, 32)
|
||||
oldPageKey := store.ActiveChannelIDsPageKey{UserID: ownerID, Generation: 801, AfterChannelID: 0, Limit: 1000}
|
||||
shared := &fakeActiveChannelIDsPageCache{values: map[store.ActiveChannelIDsPageKey][]int64{oldPageKey: {11}}}
|
||||
loader := &fakeActiveChannelIDsLoader{values: []int64{11, 12}}
|
||||
service := NewService(memory.NewChannelStore(),
|
||||
WithReadModelVersions(cachedVersions),
|
||||
WithActiveChannelIDsReadModel(shared, loader, 32, 0, nil),
|
||||
)
|
||||
first, err := service.ActiveChannelIDsForUser(ctx, ownerID, 0, 1000)
|
||||
if err != nil || !slices.Equal(first, []int64{11}) {
|
||||
t.Fatalf("first = %v err=%v", first, err)
|
||||
}
|
||||
baseVersions.set(versionKey, 802)
|
||||
// Simulate the synchronous post-commit app hook before PostgreSQL NOTIFY is
|
||||
// delivered to this process.
|
||||
service.invalidateActiveChannelIDs(ownerID)
|
||||
second, err := service.ActiveChannelIDsForUser(ctx, ownerID, 0, 1000)
|
||||
if err != nil || !slices.Equal(second, []int64{11, 12}) {
|
||||
t.Fatalf("after local invalidation = %v err=%v", second, err)
|
||||
}
|
||||
if loader.calls != 1 {
|
||||
t.Fatalf("cold loader calls = %d, want 1 for new generation", loader.calls)
|
||||
}
|
||||
newPageKey := store.ActiveChannelIDsPageKey{UserID: ownerID, Generation: 802, AfterChannelID: 0, Limit: 1000}
|
||||
if !slices.Equal(shared.values[newPageKey], []int64{11, 12}) {
|
||||
t.Fatalf("new generation page = %v", shared.values[newPageKey])
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,14 @@ type channelResolveReadModelCache struct {
|
|||
cache *readmodelcache.Cache[channelViewCacheKey, domain.ChannelView]
|
||||
}
|
||||
|
||||
// authoritativeResolveChannelCache marks a store whose ResolveChannel path is
|
||||
// already guarded by exact channel/member invalidation and reconnect flushes.
|
||||
// Wrapping that path in a second version-token cache adds no freshness boundary
|
||||
// and turns every process-cold access check into a read_model_versions query.
|
||||
type authoritativeResolveChannelCache interface {
|
||||
AuthoritativeResolveChannelCache()
|
||||
}
|
||||
|
||||
func newChannelResolveReadModelCache(ttl time.Duration) *channelResolveReadModelCache {
|
||||
if ttl <= 0 {
|
||||
ttl = defaultChannelResolveReadModelTTL
|
||||
|
|
@ -41,6 +49,9 @@ func (c *channelResolveReadModelCache) getOrLoad(ctx context.Context, key channe
|
|||
}
|
||||
|
||||
func (s *Service) cachedResolveChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) {
|
||||
if _, ok := s.channels.(authoritativeResolveChannelCache); ok {
|
||||
return s.channels.ResolveChannel(ctx, userID, channelID)
|
||||
}
|
||||
if s.resolveCache == nil || s.versions == nil {
|
||||
return s.channels.ResolveChannel(ctx, userID, channelID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/app/readmodel"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
|
@ -22,6 +24,9 @@ type Service struct {
|
|||
mediaCountCache *mediaCountReadModelCache
|
||||
participantCache *participantsReadModelCache
|
||||
activeIDsCache *activeChannelIDsReadModelCache
|
||||
activeIDsShared store.ActiveChannelIDsPageCache
|
||||
activeIDsLoader store.ActiveChannelIDsPageLoader
|
||||
activeIDsMetrics ActiveChannelIDsReadModelMetrics
|
||||
botMemberIDsCache *activeBotMemberIDsCache
|
||||
// reserved blocks the self-service UpdateUsername (not AdminSetUsername)
|
||||
// from claiming a config.ReservedUsernames entry -- see
|
||||
|
|
@ -35,6 +40,15 @@ type SendPermissionChecker interface {
|
|||
CanSendMessages(ctx context.Context, userID int64) error
|
||||
}
|
||||
|
||||
// channelStatsStore is an optional capability kept out of the broad
|
||||
// store.ChannelStore contract. It lets focused test stores stay small while
|
||||
// both production backends expose the complete bounded stats read model.
|
||||
type channelStatsStore interface {
|
||||
GetChannelStats(ctx context.Context, req domain.ChannelStatsRequest) (domain.ChannelStats, error)
|
||||
GetChannelMessageStats(ctx context.Context, req domain.ChannelMessageStatsRequest) (domain.ChannelMessageStats, error)
|
||||
ListChannelMessagePublicForwards(ctx context.Context, req domain.ChannelMessagePublicForwardListRequest) (domain.ChannelMessagePublicForwardList, error)
|
||||
}
|
||||
|
||||
// NewService creates a channel service.
|
||||
func NewService(channels store.ChannelStore, opts ...Option) *Service {
|
||||
s := &Service{
|
||||
|
|
@ -43,7 +57,7 @@ func NewService(channels store.ChannelStore, opts ...Option) *Service {
|
|||
resolveCache: newChannelResolveReadModelCache(defaultChannelResolveReadModelTTL),
|
||||
mediaCountCache: newMediaCountReadModelCache(defaultMediaCountReadModelTTL),
|
||||
participantCache: newParticipantsReadModelCache(defaultParticipantsReadModelTTL),
|
||||
activeIDsCache: newActiveChannelIDsReadModelCache(defaultActiveChannelIDsReadModelTTL),
|
||||
activeIDsCache: newActiveChannelIDsReadModelCache(0, defaultActiveChannelIDsReadModelTTL),
|
||||
botMemberIDsCache: newActiveBotMemberIDsCache(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
|
|
@ -66,6 +80,25 @@ func WithReadModelVersions(v store.ReadModelVersionStore) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithActiveChannelIDsReadModel installs the production shared readiness page
|
||||
// cache and its bounded authoritative cold loader. Supplying the shared cache
|
||||
// without either durable versions or a loader is a configuration error at read
|
||||
// time; the service never silently falls back to per-session PostgreSQL reads.
|
||||
func WithActiveChannelIDsReadModel(
|
||||
shared store.ActiveChannelIDsPageCache,
|
||||
loader store.ActiveChannelIDsPageLoader,
|
||||
maxEntries int,
|
||||
ttl time.Duration,
|
||||
metrics ActiveChannelIDsReadModelMetrics,
|
||||
) Option {
|
||||
return func(s *Service) {
|
||||
s.activeIDsShared = shared
|
||||
s.activeIDsLoader = loader
|
||||
s.activeIDsMetrics = metrics
|
||||
s.activeIDsCache = newActiveChannelIDsReadModelCache(maxEntries, ttl)
|
||||
}
|
||||
}
|
||||
|
||||
func WithSendPermissionChecker(c SendPermissionChecker) Option {
|
||||
return func(s *Service) {
|
||||
s.sendGate = c
|
||||
|
|
@ -202,6 +235,51 @@ func (s *Service) CountChannelMediaCategories(ctx context.Context, userID, chann
|
|||
return s.cachedChannelMediaCounts(ctx, userID, channelID)
|
||||
}
|
||||
|
||||
// GetStats returns bounded aggregates derived from durable channel facts.
|
||||
func (s *Service) GetStats(ctx context.Context, userID int64, req domain.ChannelStatsRequest) (domain.ChannelStats, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || !req.Period.Valid() {
|
||||
return domain.ChannelStats{}, domain.ErrChannelInvalid
|
||||
}
|
||||
provider, ok := s.channels.(channelStatsStore)
|
||||
if !ok {
|
||||
return domain.ChannelStats{}, domain.ErrChannelInvalid
|
||||
}
|
||||
req.ViewerUserID = userID
|
||||
return provider.GetChannelStats(ctx, req)
|
||||
}
|
||||
|
||||
// GetMessageStats returns view/reaction event buckets for one exact post.
|
||||
func (s *Service) GetMessageStats(ctx context.Context, userID int64, req domain.ChannelMessageStatsRequest) (domain.ChannelMessageStats, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
|
||||
req.MessageID > domain.MaxMessageBoxID || !req.Period.Valid() {
|
||||
return domain.ChannelMessageStats{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
provider, ok := s.channels.(channelStatsStore)
|
||||
if !ok {
|
||||
return domain.ChannelMessageStats{}, domain.ErrChannelInvalid
|
||||
}
|
||||
req.ViewerUserID = userID
|
||||
return provider.GetChannelMessageStats(ctx, req)
|
||||
}
|
||||
|
||||
// ListMessagePublicForwards returns only public destination posts with a
|
||||
// validated seek cursor; private forwards never cross this boundary.
|
||||
func (s *Service) ListMessagePublicForwards(ctx context.Context, userID int64, req domain.ChannelMessagePublicForwardListRequest) (domain.ChannelMessagePublicForwardList, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
|
||||
req.MessageID > domain.MaxMessageBoxID || req.Limit <= 0 || req.Limit > domain.MaxChannelMessagePublicForwards {
|
||||
return domain.ChannelMessagePublicForwardList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if _, err := domain.ParseChannelMessagePublicForwardCursor(req.Offset); err != nil {
|
||||
return domain.ChannelMessagePublicForwardList{}, err
|
||||
}
|
||||
provider, ok := s.channels.(channelStatsStore)
|
||||
if !ok {
|
||||
return domain.ChannelMessagePublicForwardList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
req.ViewerUserID = userID
|
||||
return provider.ListChannelMessagePublicForwards(ctx, req)
|
||||
}
|
||||
|
||||
// GetChannels returns channel data personalized for userID, ordered by the first occurrence in channelIDs.
|
||||
func (s *Service) GetChannels(ctx context.Context, userID int64, channelIDs []int64) ([]domain.ChannelView, error) {
|
||||
if s == nil || s.channels == nil || userID == 0 {
|
||||
|
|
@ -584,7 +662,7 @@ func (s *Service) AdminSetEmojiStatus(ctx context.Context, channelID int64, stat
|
|||
// AdminSetPhoto force-sets a channel's avatar through the admin path (no
|
||||
// permission checks, no "changed photo" service message).
|
||||
func (s *Service) AdminSetPhoto(ctx context.Context, channelID int64, photo domain.Photo) (domain.Channel, error) {
|
||||
if s == nil || s.channels == nil || channelID == 0 {
|
||||
if s == nil || s.channels == nil || channelID == 0 || photo.ID == 0 {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.SetChannelPhotoAdmin(ctx, channelID, photo)
|
||||
|
|
@ -1968,7 +2046,15 @@ func (s *Service) SendMonoforumMessage(ctx context.Context, req domain.SendMonof
|
|||
if err := s.ensureCanSend(ctx, req.SenderUserID); err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
return s.channels.SendMonoforumMessage(ctx, req)
|
||||
result, err := s.channels.SendMonoforumMessage(ctx, req)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
// The saved-peer owner gains (or refreshes) monoforum readiness visibility.
|
||||
// Evict locally at commit return; migration 20260901000022 advances the durable token
|
||||
// and NOTIFY handles every other process.
|
||||
s.invalidateActiveChannelIDs(req.SavedPeer.ID)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListMonoforumHistory 拉取某订阅者在频道私信(monoforum)内的历史。
|
||||
|
|
@ -2375,7 +2461,20 @@ func activeMembershipUserIDsFromMembers(primary int64, members []domain.ChannelM
|
|||
}
|
||||
|
||||
func (s *Service) invalidateActiveChannelIDs(userIDs ...int64) {
|
||||
if s == nil || s.activeIDsCache == nil {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if versionCache, ok := s.versions.(store.ReadModelVersionCache); ok {
|
||||
for _, userID := range uniqueNonZero(userIDs) {
|
||||
versionCache.InvalidateReadModel(store.ReadModelKey{
|
||||
Model: readmodel.ModelChannelActiveIDs,
|
||||
OwnerUserID: userID,
|
||||
PeerType: domain.PeerTypeUser,
|
||||
PeerID: userID,
|
||||
})
|
||||
}
|
||||
}
|
||||
if s.activeIDsCache == nil {
|
||||
return
|
||||
}
|
||||
s.activeIDsCache.invalidateUsers(userIDs...)
|
||||
|
|
|
|||
|
|
@ -155,6 +155,12 @@ type countingChannelStore struct {
|
|||
resolveStartOnce sync.Once
|
||||
}
|
||||
|
||||
type authoritativeCountingChannelStore struct {
|
||||
*countingChannelStore
|
||||
}
|
||||
|
||||
func (*authoritativeCountingChannelStore) AuthoritativeResolveChannelCache() {}
|
||||
|
||||
func (s *countingChannelStore) GetChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) {
|
||||
s.getChannelCalls++
|
||||
return s.ChannelStore.GetChannel(ctx, viewerUserID, channelID)
|
||||
|
|
@ -198,6 +204,20 @@ type fakeReadModelVersions struct {
|
|||
hashes map[store.ReadModelKey]int64
|
||||
}
|
||||
|
||||
type countingReadModelVersions struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (v *countingReadModelVersions) ReadModelHash(context.Context, string, int64, domain.PeerType, int64) (int64, bool, error) {
|
||||
v.calls++
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
func (v *countingReadModelVersions) ReadModelHashes(context.Context, []store.ReadModelKey) (map[store.ReadModelKey]int64, error) {
|
||||
v.calls++
|
||||
return map[store.ReadModelKey]int64{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeReadModelVersions) ReadModelHash(_ context.Context, model string, ownerUserID int64, peerType domain.PeerType, peerID int64) (int64, bool, error) {
|
||||
hash := f.hashes[store.ReadModelKey{Model: model, OwnerUserID: ownerUserID, PeerType: peerType, PeerID: peerID}]
|
||||
return hash, hash != 0, nil
|
||||
|
|
@ -331,6 +351,39 @@ func TestResolveChannelCachesAccessViewByCompositeReadModelHash(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResolveChannelDelegatesToAuthoritativeStoreCacheWithoutVersionRead(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
base := &countingChannelStore{ChannelStore: memory.NewChannelStore()}
|
||||
created, err := base.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: ownerID, Title: "Store-owned Resolve", Megagroup: true, Date: 1700004105,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
versions := &countingReadModelVersions{}
|
||||
service := NewService(
|
||||
&authoritativeCountingChannelStore{countingChannelStore: base},
|
||||
WithReadModelVersions(versions),
|
||||
)
|
||||
|
||||
for range 2 {
|
||||
view, resolveErr := service.ResolveChannel(ctx, ownerID, created.Channel.ID)
|
||||
if resolveErr != nil {
|
||||
t.Fatalf("ResolveChannel: %v", resolveErr)
|
||||
}
|
||||
if view.Channel.ID != created.Channel.ID || view.Self.UserID != ownerID {
|
||||
t.Fatalf("resolve view = %+v", view)
|
||||
}
|
||||
}
|
||||
if base.resolveChannelCalls != 2 {
|
||||
t.Fatalf("authoritative store calls = %d, want 2", base.resolveChannelCalls)
|
||||
}
|
||||
if versions.calls != 0 {
|
||||
t.Fatalf("read-model version calls = %d, want 0", versions.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveChannelIDsForUserCachesPageByReadModelHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
|
|
@ -872,8 +925,8 @@ func TestCreateChatCreatesMegagroupWithChannelPts(t *testing.T) {
|
|||
if !created.Channel.Megagroup || created.Channel.Broadcast {
|
||||
t.Fatalf("channel flags = megagroup:%v broadcast:%v, want megagroup only", created.Channel.Megagroup, created.Channel.Broadcast)
|
||||
}
|
||||
if created.Channel.Pts != 1 || created.Message.ID != 1 || created.Event.PtsCount != 1 {
|
||||
t.Fatalf("created pts/message/event = %+v/%+v/%+v, want initial pts=1 message id=1", created.Channel, created.Message, created.Event)
|
||||
if created.Channel.Pts != domain.FirstChannelEventPts || created.Message.ID != 1 || created.Event.Pts != domain.FirstChannelEventPts || created.Event.PtsCount != 1 {
|
||||
t.Fatalf("created pts/message/event = %+v/%+v/%+v, want initial event pts=2 message id=1", created.Channel, created.Message, created.Event)
|
||||
}
|
||||
if created.Message.Action == nil || created.Message.Action.Type != domain.ChannelActionCreate {
|
||||
t.Fatalf("create service action = %+v, want channel create", created.Message.Action)
|
||||
|
|
@ -889,8 +942,8 @@ func TestCreateChatCreatesMegagroupWithChannelPts(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
if sent.Message.ID != 2 || sent.Message.Pts != 2 || sent.Event.Pts != 2 || sent.Event.PtsCount != 1 {
|
||||
t.Fatalf("sent = %+v event=%+v, want message id/pts=2", sent.Message, sent.Event)
|
||||
if sent.Message.ID != 2 || sent.Message.Pts != 3 || sent.Event.Pts != 3 || sent.Event.PtsCount != 1 {
|
||||
t.Fatalf("sent = %+v event=%+v, want message id=2 pts=3", sent.Message, sent.Event)
|
||||
}
|
||||
if sent.Message.ViaBotID != 1003 || sent.Event.Message.ViaBotID != 1003 {
|
||||
t.Fatalf("sent via_bot_id = msg %d event %d, want 1003", sent.Message.ViaBotID, sent.Event.Message.ViaBotID)
|
||||
|
|
@ -924,12 +977,12 @@ func TestCreateChatCreatesMegagroupWithChannelPts(t *testing.T) {
|
|||
t.Fatalf("history via_bot_id = %d, want 1003", history.Messages[0].ViaBotID)
|
||||
}
|
||||
|
||||
diff, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: 1, Limit: 10})
|
||||
diff, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: created.Event.Pts, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if !diff.Final || diff.Pts != 2 || len(diff.NewMessages) != 1 || diff.NewMessages[0].Body != "hello" {
|
||||
t.Fatalf("diff = %+v, want single new channel message at pts=2", diff)
|
||||
if !diff.Final || diff.Pts != 3 || len(diff.NewMessages) != 1 || diff.NewMessages[0].Body != "hello" {
|
||||
t.Fatalf("diff = %+v, want single new channel message at pts=3", diff)
|
||||
}
|
||||
if diff.NewMessages[0].ViaBotID != 1003 {
|
||||
t.Fatalf("diff via_bot_id = %d, want 1003", diff.NewMessages[0].ViaBotID)
|
||||
|
|
@ -2116,8 +2169,8 @@ func TestChannelEditDeleteAndLocalClearUseChannelPts(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("EditMessage: %v", err)
|
||||
}
|
||||
if edited.Event.Type != domain.ChannelUpdateEditMessage || edited.Event.Pts != 4 || edited.Event.PtsCount != 1 {
|
||||
t.Fatalf("edit event = %+v, want channel edit pts=4 count=1", edited.Event)
|
||||
if edited.Event.Type != domain.ChannelUpdateEditMessage || edited.Event.Pts != 5 || edited.Event.PtsCount != 1 {
|
||||
t.Fatalf("edit event = %+v, want channel edit pts=5 count=1", edited.Event)
|
||||
}
|
||||
duplicate, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 2, Message: "two", Date: 13})
|
||||
if err != nil {
|
||||
|
|
@ -2135,14 +2188,14 @@ func TestChannelEditDeleteAndLocalClearUseChannelPts(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("DeleteMessages: %v", err)
|
||||
}
|
||||
if deleted.Event.Type != domain.ChannelUpdateDeleteMessages || deleted.Event.Pts != 6 || deleted.Event.PtsCount != 2 {
|
||||
if deleted.Event.Type != domain.ChannelUpdateDeleteMessages || deleted.Event.Pts != 7 || deleted.Event.PtsCount != 2 {
|
||||
t.Fatalf("delete event = %+v, want pts advanced by deleted id count", deleted.Event)
|
||||
}
|
||||
diff, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: 3, Limit: 10})
|
||||
diff, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: second.Event.Pts, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDifference: %v", err)
|
||||
}
|
||||
if len(diff.OtherUpdates) != 2 || diff.OtherUpdates[1].Type != domain.ChannelUpdateDeleteMessages || diff.Pts != 6 {
|
||||
if len(diff.OtherUpdates) != 2 || diff.OtherUpdates[1].Type != domain.ChannelUpdateDeleteMessages || diff.Pts != 7 {
|
||||
t.Fatalf("diff after edit/delete = %+v, want edit then delete through channel pts", diff)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -133,10 +133,17 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con
|
|||
if input.FirstName == "" && input.LastName == "" {
|
||||
return domain.Contact{}, ErrContactNameEmpty
|
||||
}
|
||||
// Android 的 contacts.addContact 会提交带 "+" 前缀的号码(TDesktop 传纯数字或空),
|
||||
// 归一成纯数字。空串表示客户端只按 user id 添加联系人,必须原样保留;
|
||||
// Android 的 contacts.addContact 会提交带 "+" 前缀的号码(TDesktop 传纯数字或空)。
|
||||
// 可解析的完整号码写成与账号相同的 E.164 identity;无法解析的本地名片号码只
|
||||
// 保留展示 digits,绝不能拿它做账号选择。空串表示客户端只按 user id 添加联系人,必须原样保留;
|
||||
// TL 明确允许省略号码,服务端不得从 target 全局资料反向补出隐私号码。
|
||||
input.Phone = digitsOnly(input.Phone)
|
||||
if input.Phone != "" {
|
||||
if canonical := domain.NormalizePhone(input.Phone); canonical != "" {
|
||||
input.Phone = canonical
|
||||
} else {
|
||||
input.Phone = digitsOnly(input.Phone)
|
||||
}
|
||||
}
|
||||
if s.users != nil {
|
||||
_, found, err := s.users.ByID(ctx, input.ContactUserID)
|
||||
if err != nil {
|
||||
|
|
@ -222,7 +229,7 @@ func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []dom
|
|||
phones := make([]string, 0, len(inputs))
|
||||
seenPhones := make(map[string]struct{}, len(inputs))
|
||||
for _, input := range inputs {
|
||||
phone := normalizePhone(input.Phone)
|
||||
phone := domain.NormalizePhone(input.Phone)
|
||||
if phone == "" {
|
||||
continue
|
||||
}
|
||||
|
|
@ -354,7 +361,7 @@ func (s *Service) Search(ctx context.Context, userID int64, query string, limit
|
|||
}
|
||||
phoneQuery := ""
|
||||
if isPhoneSearchQuery(query) {
|
||||
phoneQuery = normalizePhone(query)
|
||||
phoneQuery = normalizePhoneQuery(query)
|
||||
}
|
||||
res, err := s.users.Search(ctx, userID, query, phoneQuery, limit)
|
||||
if err != nil {
|
||||
|
|
@ -686,7 +693,7 @@ func digitsOnly(phone string) string {
|
|||
return b.String()
|
||||
}
|
||||
|
||||
func normalizePhone(phone string) string {
|
||||
func normalizePhoneQuery(phone string) string {
|
||||
if !utf8.ValidString(phone) {
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,36 @@ func TestImportContactsBatchesPhonesAndDedupesUpserts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestImportContactsResolvesNationalTrunkVariantToCanonicalUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
contactsStore := memory.NewContactStore()
|
||||
owner, err := users.Create(ctx, domain.User{Phone: "15551234567", FirstName: "Owner"})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
target, err := users.Create(ctx, domain.User{Phone: "989981679461", FirstName: "Iran"})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
svc := NewService(contactsStore, users)
|
||||
|
||||
res, err := svc.ImportContacts(ctx, owner.ID, []domain.ContactInput{{
|
||||
ClientID: 98,
|
||||
Phone: "+98 0998 167 9461",
|
||||
FirstName: "Saved",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("ImportContacts: %v", err)
|
||||
}
|
||||
if len(res.Imported) != 1 || res.Imported[0].UserID != target.ID {
|
||||
t.Fatalf("imported = %+v, want target %d", res.Imported, target.ID)
|
||||
}
|
||||
if len(res.Contacts) != 1 || res.Contacts[0].Phone != "989981679461" {
|
||||
t.Fatalf("contacts = %+v, want one canonical contact", res.Contacts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddContactWithoutPhoneDoesNotBackfillTargetPhone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
|
|
|
|||
153
internal/app/dialogs/draft_read_model_cache.go
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
package dialogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultDialogDraftReadModelTTL = 24 * time.Hour
|
||||
defaultDialogDraftReadModelMaxEntries = 1000000
|
||||
defaultDialogDraftReadModelMaxBytes int64 = 256 << 20
|
||||
)
|
||||
|
||||
type dialogDraftCacheEntry struct {
|
||||
draft domain.DialogDraft
|
||||
found bool
|
||||
}
|
||||
|
||||
type dialogDraftReadModelCache struct {
|
||||
cache *readmodelcache.Cache[dialogPeerCacheKey, dialogDraftCacheEntry]
|
||||
}
|
||||
|
||||
func newDialogDraftReadModelCache(maxEntries int, maxBytes int64, ttl time.Duration) *dialogDraftReadModelCache {
|
||||
if ttl <= 0 {
|
||||
ttl = defaultDialogDraftReadModelTTL
|
||||
}
|
||||
return &dialogDraftReadModelCache{cache: readmodelcache.New[dialogPeerCacheKey, dialogDraftCacheEntry](readmodelcache.Config[dialogPeerCacheKey, dialogDraftCacheEntry]{
|
||||
MaxEntries: maxEntries,
|
||||
MaxWeight: maxBytes,
|
||||
Weight: dialogDraftEntryApproxBytes,
|
||||
TTL: ttl,
|
||||
Clone: cloneDialogDraftCacheEntry,
|
||||
})}
|
||||
}
|
||||
|
||||
func (s *Service) dialogDraftsReadModel(ctx context.Context, userID int64, peers []domain.Peer) (map[domain.Peer]dialogDraftCacheEntry, error) {
|
||||
out := make(map[domain.Peer]dialogDraftCacheEntry, len(peers))
|
||||
if s == nil || s.dialogs == nil || userID == 0 || len(peers) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
unique := uniqueDialogPeers(peers)
|
||||
if len(unique) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
keys := make([]dialogPeerCacheKey, 0, len(unique))
|
||||
for _, peer := range unique {
|
||||
keys = append(keys, dialogPeerCacheKey{userID: userID, peer: peer})
|
||||
}
|
||||
hashes := map[domain.Peer]int64{}
|
||||
if s.versions != nil {
|
||||
var err error
|
||||
hashes, err = s.dialogHashes(ctx, userID, unique)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var cache *readmodelcache.Cache[dialogPeerCacheKey, dialogDraftCacheEntry]
|
||||
if s.draftCache != nil {
|
||||
cache = s.draftCache.cache
|
||||
}
|
||||
loaded, err := cache.GetOrLoadBatch(ctx, keys,
|
||||
func(key dialogPeerCacheKey) (int64, bool) {
|
||||
hash := hashes[key.peer]
|
||||
return hash, s.versions != nil && hash != 0
|
||||
},
|
||||
func(ctx context.Context, missing []dialogPeerCacheKey) (map[dialogPeerCacheKey]dialogDraftCacheEntry, error) {
|
||||
requested := make([]domain.Peer, 0, len(missing))
|
||||
for _, key := range missing {
|
||||
requested = append(requested, key.peer)
|
||||
}
|
||||
drafts, err := s.dialogs.ListDraftsByPeers(ctx, userID, requested)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make(map[dialogPeerCacheKey]dialogDraftCacheEntry, len(missing))
|
||||
for _, key := range missing {
|
||||
entries[key] = dialogDraftCacheEntry{}
|
||||
}
|
||||
for _, draft := range drafts {
|
||||
if draft.TopMessageID != 0 {
|
||||
continue
|
||||
}
|
||||
key := dialogPeerCacheKey{userID: userID, peer: draft.Peer}
|
||||
if _, ok := entries[key]; ok {
|
||||
entries[key] = dialogDraftCacheEntry{draft: cloneDraft(draft), found: true}
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, entry := range loaded {
|
||||
out[key.peer] = entry
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func uniqueDialogPeers(peers []domain.Peer) []domain.Peer {
|
||||
out := make([]domain.Peer, 0, len(peers))
|
||||
seen := make(map[domain.Peer]struct{}, len(peers))
|
||||
for _, peer := range peers {
|
||||
if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[peer]; ok {
|
||||
continue
|
||||
}
|
||||
seen[peer] = struct{}{}
|
||||
out = append(out, peer)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *dialogDraftReadModelCache) invalidate(key dialogPeerCacheKey) {
|
||||
if c != nil {
|
||||
c.cache.Invalidate(key)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *dialogDraftReadModelCache) flush() {
|
||||
if c != nil {
|
||||
c.cache.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func cloneDialogDraftCacheEntry(entry dialogDraftCacheEntry) dialogDraftCacheEntry {
|
||||
if entry.found {
|
||||
entry.draft = cloneDraft(entry.draft)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func dialogDraftEntryApproxBytes(entry dialogDraftCacheEntry) int64 {
|
||||
if !entry.found {
|
||||
return 64
|
||||
}
|
||||
draft := entry.draft
|
||||
weight := int64(256 + len(draft.Message) + len(draft.Entities)*64)
|
||||
if draft.ReplyTo != nil {
|
||||
weight += int64(128 + len(draft.ReplyTo.QuoteText) + len(draft.ReplyTo.QuoteEntities)*64)
|
||||
}
|
||||
if draft.WebPage != nil {
|
||||
weight += int64(64 + len(draft.WebPage.URL))
|
||||
}
|
||||
if draft.RichMessage != nil {
|
||||
weight += int64(len(draft.RichMessage.Blocks) + len(draft.RichMessage.BotAPIProjection) + len(draft.RichMessage.Photos)*256 + len(draft.RichMessage.Documents)*256)
|
||||
}
|
||||
return weight
|
||||
}
|
||||
442
internal/app/dialogs/list_snapshot_cache.go
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
package dialogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"hash/fnv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/app/readmodel"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
)
|
||||
|
||||
const (
|
||||
dialogListSnapshotTTL = 5 * time.Minute
|
||||
dialogListSnapshotMaxEntries = 10000
|
||||
dialogListSnapshotMaxHeaders = 1000000
|
||||
dialogListSnapshotLoadLimit = 10000
|
||||
)
|
||||
|
||||
type dialogListSnapshotKey struct {
|
||||
userID int64
|
||||
}
|
||||
|
||||
type dialogListSnapshot struct {
|
||||
dialogs []domain.Dialog
|
||||
messages []domain.Message
|
||||
users []domain.User
|
||||
hash int64
|
||||
state domain.UpdateState
|
||||
archive *domain.DialogArchiveSummary
|
||||
channelIDs []int64
|
||||
ownerHash int64
|
||||
dependencyHash int64
|
||||
}
|
||||
|
||||
type dialogListSnapshotCache struct {
|
||||
cache *readmodelcache.Cache[dialogListSnapshotKey, *dialogListSnapshot]
|
||||
|
||||
indexMu sync.Mutex
|
||||
channelKeys map[int64]map[dialogListSnapshotKey]struct{}
|
||||
keyChannels map[dialogListSnapshotKey][]int64
|
||||
}
|
||||
|
||||
func newDialogListSnapshotCache(maxEntries int, maxHeaders int64, ttl time.Duration) *dialogListSnapshotCache {
|
||||
if maxEntries <= 0 {
|
||||
maxEntries = dialogListSnapshotMaxEntries
|
||||
}
|
||||
if maxHeaders <= 0 {
|
||||
maxHeaders = dialogListSnapshotMaxHeaders
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = dialogListSnapshotTTL
|
||||
}
|
||||
c := &dialogListSnapshotCache{
|
||||
channelKeys: make(map[int64]map[dialogListSnapshotKey]struct{}),
|
||||
keyChannels: make(map[dialogListSnapshotKey][]int64),
|
||||
}
|
||||
c.cache = readmodelcache.New[dialogListSnapshotKey, *dialogListSnapshot](readmodelcache.Config[dialogListSnapshotKey, *dialogListSnapshot]{
|
||||
MaxEntries: maxEntries,
|
||||
MaxWeight: maxHeaders,
|
||||
Weight: func(snap *dialogListSnapshot) int64 {
|
||||
if snap == nil {
|
||||
return 1
|
||||
}
|
||||
// The historical knob is expressed in header-equivalent units. A
|
||||
// materialized message/channel is wider than an ordering header, so
|
||||
// charge conservative multiples and keep the old global bound useful.
|
||||
memberProjections := 0
|
||||
for _, dialog := range snap.dialogs {
|
||||
if dialog.ChannelMember != nil {
|
||||
memberProjections++
|
||||
}
|
||||
}
|
||||
weight := len(snap.dialogs) + memberProjections*2 + len(snap.messages)*4 + len(snap.users)*2
|
||||
if weight < 1 {
|
||||
return 1
|
||||
}
|
||||
return int64(weight)
|
||||
},
|
||||
TTL: ttl,
|
||||
OnStore: c.indexSnapshot,
|
||||
OnRemove: c.unindexSnapshot,
|
||||
})
|
||||
return c
|
||||
}
|
||||
|
||||
func dialogSnapshotKey(userID int64, filter domain.DialogFilter) (dialogListSnapshotKey, bool) {
|
||||
if userID == 0 || filter.Folder != nil {
|
||||
return dialogListSnapshotKey{}, false
|
||||
}
|
||||
if filter.HasFolderID {
|
||||
if filter.FolderID != domain.DialogMainFolderID && filter.FolderID != domain.DialogArchiveFolderID {
|
||||
return dialogListSnapshotKey{}, false
|
||||
}
|
||||
}
|
||||
return dialogListSnapshotKey{userID: userID}, true
|
||||
}
|
||||
|
||||
func (c *dialogListSnapshotCache) getOrLoad(ctx context.Context, key dialogListSnapshotKey, load func() (*dialogListSnapshot, error)) (*dialogListSnapshot, error) {
|
||||
if c == nil || c.cache == nil {
|
||||
return load()
|
||||
}
|
||||
return c.cache.GetOrLoad(ctx, key, load)
|
||||
}
|
||||
|
||||
func (c *dialogListSnapshotCache) getOrLoadVersioned(
|
||||
ctx context.Context,
|
||||
key dialogListSnapshotKey,
|
||||
ownerHash int64,
|
||||
load func() (*dialogListSnapshot, error),
|
||||
) (*dialogListSnapshot, error) {
|
||||
if c == nil || c.cache == nil {
|
||||
return load()
|
||||
}
|
||||
return c.cache.GetOrLoadVersioned(ctx, key, ownerHash, load)
|
||||
}
|
||||
|
||||
func (c *dialogListSnapshotCache) invalidateOwner(userID int64) {
|
||||
if c == nil || c.cache == nil || userID == 0 {
|
||||
return
|
||||
}
|
||||
c.cache.InvalidateWhere(func(key dialogListSnapshotKey) bool { return key.userID == userID })
|
||||
}
|
||||
|
||||
func (c *dialogListSnapshotCache) invalidateChannel(channelID int64) {
|
||||
if c == nil || c.cache == nil || channelID == 0 {
|
||||
return
|
||||
}
|
||||
c.indexMu.Lock()
|
||||
indexed := c.channelKeys[channelID]
|
||||
keys := make([]dialogListSnapshotKey, 0, len(indexed))
|
||||
for key := range indexed {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
c.indexMu.Unlock()
|
||||
c.cache.Invalidate(keys...)
|
||||
}
|
||||
|
||||
func (c *dialogListSnapshotCache) flush() {
|
||||
if c != nil && c.cache != nil {
|
||||
c.cache.Flush()
|
||||
c.indexMu.Lock()
|
||||
c.channelKeys = make(map[int64]map[dialogListSnapshotKey]struct{})
|
||||
c.keyChannels = make(map[dialogListSnapshotKey][]int64)
|
||||
c.indexMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *dialogListSnapshotCache) indexSnapshot(key dialogListSnapshotKey, snap *dialogListSnapshot) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.indexMu.Lock()
|
||||
defer c.indexMu.Unlock()
|
||||
c.unindexSnapshotLocked(key)
|
||||
if snap == nil || len(snap.channelIDs) == 0 {
|
||||
return
|
||||
}
|
||||
ids := append([]int64(nil), snap.channelIDs...)
|
||||
c.keyChannels[key] = ids
|
||||
for _, channelID := range ids {
|
||||
keys := c.channelKeys[channelID]
|
||||
if keys == nil {
|
||||
keys = make(map[dialogListSnapshotKey]struct{})
|
||||
c.channelKeys[channelID] = keys
|
||||
}
|
||||
keys[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *dialogListSnapshotCache) unindexSnapshot(key dialogListSnapshotKey, _ *dialogListSnapshot) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.indexMu.Lock()
|
||||
c.unindexSnapshotLocked(key)
|
||||
c.indexMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *dialogListSnapshotCache) unindexSnapshotLocked(key dialogListSnapshotKey) {
|
||||
for _, channelID := range c.keyChannels[key] {
|
||||
keys := c.channelKeys[channelID]
|
||||
delete(keys, key)
|
||||
if len(keys) == 0 {
|
||||
delete(c.channelKeys, channelID)
|
||||
}
|
||||
}
|
||||
delete(c.keyChannels, key)
|
||||
}
|
||||
|
||||
func newDialogListSnapshot(list domain.DialogList) *dialogListSnapshot {
|
||||
channelIDs := make([]int64, 0, len(list.Dialogs))
|
||||
seen := make(map[int64]struct{}, len(list.Dialogs))
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.Peer.Type != domain.PeerTypeChannel || dialog.Peer.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[dialog.Peer.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[dialog.Peer.ID] = struct{}{}
|
||||
channelIDs = append(channelIDs, dialog.Peer.ID)
|
||||
}
|
||||
archive := cloneDialogArchiveSummary(list.ArchiveSummary)
|
||||
structuralHash := dialogOwnerSnapshotStructuralHash(list.Dialogs, list.Hash)
|
||||
return &dialogListSnapshot{
|
||||
dialogs: cloneDialogSlice(list.Dialogs),
|
||||
messages: cloneDialogMessages(list.Messages),
|
||||
users: cloneDialogUsers(list.Users),
|
||||
hash: dialogHashWithDrafts(structuralHash, list.Dialogs),
|
||||
state: list.State,
|
||||
archive: archive,
|
||||
channelIDs: channelIDs,
|
||||
}
|
||||
}
|
||||
|
||||
func dialogListSnapshotPageHeaders(snap *dialogListSnapshot, filter domain.DialogFilter) domain.DialogList {
|
||||
if snap == nil {
|
||||
return domain.DialogList{}
|
||||
}
|
||||
dialogs := dialogListSnapshotVariant(snap.dialogs, filter)
|
||||
start := dialogSnapshotPageStart(dialogs, filter)
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
end := start + limit
|
||||
if end > len(dialogs) {
|
||||
end = len(dialogs)
|
||||
}
|
||||
if start > end {
|
||||
start = end
|
||||
}
|
||||
hash := readmodel.MixHashes(snap.hash, dialogSnapshotVariantIdentity(filter))
|
||||
if snap.ownerHash != 0 && snap.dependencyHash != 0 {
|
||||
hash = readmodel.MixHashes(hash, snap.ownerHash, snap.dependencyHash)
|
||||
}
|
||||
out := domain.DialogList{Count: len(dialogs), Hash: hash, State: snap.state}
|
||||
out.Dialogs = cloneDialogSlice(dialogs[start:end])
|
||||
payloadPeers := make([]domain.Peer, 0, len(out.Dialogs)+1)
|
||||
for _, dialog := range out.Dialogs {
|
||||
payloadPeers = append(payloadPeers, dialog.Peer)
|
||||
}
|
||||
if dialogSnapshotIncludesArchiveSummary(filter) && snap.archive != nil {
|
||||
if !filter.PinnedOnly || snap.archive.Pinned {
|
||||
summary := *snap.archive
|
||||
out.ArchiveSummary = &summary
|
||||
if summary.TopPeer.ID != 0 {
|
||||
payloadPeers = append(payloadPeers, summary.TopPeer)
|
||||
}
|
||||
}
|
||||
}
|
||||
appendDialogSnapshotPayload(snap, payloadPeers, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func appendDialogSnapshotPayload(snap *dialogListSnapshot, peers []domain.Peer, out *domain.DialogList) {
|
||||
if snap == nil || out == nil || len(peers) == 0 {
|
||||
return
|
||||
}
|
||||
keep := make(map[domain.Peer]struct{}, len(peers))
|
||||
for _, peer := range peers {
|
||||
if peer.Type != "" && peer.ID != 0 {
|
||||
keep[peer] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, msg := range snap.messages {
|
||||
if _, ok := keep[msg.Peer]; ok {
|
||||
out.Messages = append(out.Messages, cloneMessageForDialogCache(msg))
|
||||
}
|
||||
}
|
||||
userIDs := make(map[int64]struct{}, len(keep))
|
||||
for peer := range keep {
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
userIDs[peer.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, user := range snap.users {
|
||||
if _, ok := userIDs[user.ID]; ok {
|
||||
out.Users = append(out.Users, cloneDialogUser(user))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dialogOwnerSnapshotStructuralHash(dialogs []domain.Dialog, provided int64) int64 {
|
||||
if provided != 0 {
|
||||
return provided
|
||||
}
|
||||
h := fnv.New64a()
|
||||
var buf [96]byte
|
||||
for _, dialog := range dialogs {
|
||||
clear(buf[:])
|
||||
binary.LittleEndian.PutUint64(buf[:8], uint64(dialog.Peer.ID))
|
||||
binary.LittleEndian.PutUint32(buf[8:12], uint32(dialog.FolderID))
|
||||
binary.LittleEndian.PutUint32(buf[12:16], uint32(dialog.TopMessage))
|
||||
binary.LittleEndian.PutUint32(buf[16:20], uint32(dialog.TopMessageDate))
|
||||
binary.LittleEndian.PutUint32(buf[20:24], uint32(dialog.ReadInboxMaxID))
|
||||
binary.LittleEndian.PutUint32(buf[24:28], uint32(dialog.ReadOutboxMaxID))
|
||||
binary.LittleEndian.PutUint32(buf[28:32], uint32(dialog.UnreadCount))
|
||||
binary.LittleEndian.PutUint32(buf[32:36], uint32(dialog.UnreadMentions))
|
||||
binary.LittleEndian.PutUint32(buf[36:40], uint32(dialog.UnreadReactions))
|
||||
binary.LittleEndian.PutUint32(buf[40:44], uint32(dialog.PinnedOrder))
|
||||
if dialog.Pinned {
|
||||
buf[44] = 1
|
||||
} else {
|
||||
buf[44] = 0
|
||||
}
|
||||
if dialog.UnreadMark {
|
||||
buf[45] = 1
|
||||
} else {
|
||||
buf[45] = 0
|
||||
}
|
||||
if dialog.PeerSettingsBarHidden {
|
||||
buf[46] = 1
|
||||
} else {
|
||||
buf[46] = 0
|
||||
}
|
||||
buf[47] = byte(len(dialog.Peer.Type))
|
||||
binary.LittleEndian.PutUint32(buf[48:52], uint32(dialog.HistoryClearAnchorID))
|
||||
binary.LittleEndian.PutUint32(buf[52:56], uint32(dialog.HistoryClearAnchorDate))
|
||||
binary.LittleEndian.PutUint32(buf[56:60], uint32(dialog.TTLPeriod))
|
||||
binary.LittleEndian.PutUint32(buf[60:64], uint32(dialog.Pts))
|
||||
if dialog.ChannelLeft {
|
||||
buf[64] = 1
|
||||
}
|
||||
if dialog.HasScheduled {
|
||||
buf[65] = 1
|
||||
}
|
||||
if dialog.ViewForumAsMessages {
|
||||
buf[66] = 1
|
||||
}
|
||||
if dialog.TopMessageMentioned {
|
||||
buf[67] = 1
|
||||
}
|
||||
if dialog.TopMessageMediaUnread {
|
||||
buf[68] = 1
|
||||
}
|
||||
if dialog.TopMessageUnreadProjected {
|
||||
buf[69] = 1
|
||||
}
|
||||
if dialog.DefaultSendAs != nil {
|
||||
binary.LittleEndian.PutUint64(buf[72:80], uint64(dialog.DefaultSendAs.ID))
|
||||
buf[80] = byte(len(dialog.DefaultSendAs.Type))
|
||||
}
|
||||
_, _ = h.Write(buf[:])
|
||||
_, _ = h.Write([]byte(dialog.Peer.Type))
|
||||
_, _ = h.Write([]byte(dialog.ThemeEmoticon))
|
||||
if dialog.DefaultSendAs != nil {
|
||||
_, _ = h.Write([]byte(dialog.DefaultSendAs.Type))
|
||||
}
|
||||
}
|
||||
sum := int64(h.Sum64() & 0x7fffffffffffffff)
|
||||
if sum == 0 {
|
||||
return 1
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
func dialogListSnapshotVariant(dialogs []domain.Dialog, filter domain.DialogFilter) []domain.Dialog {
|
||||
folderID := domain.DialogMainFolderID
|
||||
if filter.HasFolderID {
|
||||
folderID = filter.FolderID
|
||||
}
|
||||
out := make([]domain.Dialog, 0, len(dialogs))
|
||||
for _, dialog := range dialogs {
|
||||
if dialog.FolderID != folderID || filter.PinnedOnly && !dialog.Pinned || filter.ExcludePinned && dialog.Pinned {
|
||||
continue
|
||||
}
|
||||
out = append(out, dialog)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dialogSnapshotVariantIdentity(filter domain.DialogFilter) int64 {
|
||||
folderID := domain.DialogMainFolderID
|
||||
if filter.HasFolderID {
|
||||
folderID = filter.FolderID
|
||||
}
|
||||
identity := int64(folderID + 1)
|
||||
if filter.PinnedOnly {
|
||||
identity |= 1 << 8
|
||||
}
|
||||
if filter.ExcludePinned {
|
||||
identity |= 1 << 9
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
func dialogSnapshotIncludesArchiveSummary(filter domain.DialogFilter) bool {
|
||||
if filter.HasFolderID && filter.FolderID != domain.DialogMainFolderID {
|
||||
return false
|
||||
}
|
||||
if filter.ExcludePinned {
|
||||
return false
|
||||
}
|
||||
return filter.OffsetID == 0 && filter.OffsetDate == 0 && !filter.HasOffsetPeer
|
||||
}
|
||||
|
||||
func dialogSnapshotPageStart(dialogs []domain.Dialog, filter domain.DialogFilter) int {
|
||||
if filter.OffsetID == 0 && filter.OffsetDate == 0 && !filter.HasOffsetPeer {
|
||||
return 0
|
||||
}
|
||||
if filter.HasOffsetPeer {
|
||||
for i, dialog := range dialogs {
|
||||
if dialog.Peer == filter.OffsetPeer &&
|
||||
(filter.OffsetID == 0 || dialog.TopMessage == filter.OffsetID) &&
|
||||
(filter.OffsetDate == 0 || dialog.TopMessageDate == filter.OffsetDate) {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, dialog := range dialogs {
|
||||
if dialogAfterSnapshotOffset(dialog, filter) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(dialogs)
|
||||
}
|
||||
|
||||
func dialogAfterSnapshotOffset(dialog domain.Dialog, filter domain.DialogFilter) bool {
|
||||
if filter.OffsetDate > 0 {
|
||||
if dialog.TopMessageDate != filter.OffsetDate {
|
||||
return dialog.TopMessageDate < filter.OffsetDate
|
||||
}
|
||||
if filter.OffsetID <= 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if filter.OffsetID > 0 {
|
||||
if dialog.TopMessage != filter.OffsetID {
|
||||
return dialog.TopMessage < filter.OffsetID
|
||||
}
|
||||
if filter.HasOffsetPeer {
|
||||
return dialog.Peer.ID < filter.OffsetPeer.ID
|
||||
}
|
||||
return false
|
||||
}
|
||||
return filter.HasOffsetPeer && dialog.Peer != filter.OffsetPeer
|
||||
}
|
||||
43
internal/app/dialogs/list_snapshot_cache_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package dialogs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestDialogOwnerSnapshotStructuralHashCoversMaterializedOwnerFacts(t *testing.T) {
|
||||
base := domain.Dialog{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 10},
|
||||
TopMessage: 20,
|
||||
TopMessageDate: 30,
|
||||
Pts: 40,
|
||||
HistoryClearAnchorID: 50,
|
||||
HistoryClearAnchorDate: 60,
|
||||
TopMessageUnreadProjected: true,
|
||||
DefaultSendAs: &domain.Peer{Type: domain.PeerTypeChannel, ID: 70},
|
||||
}
|
||||
wantDifferent := []domain.Dialog{
|
||||
func() domain.Dialog { out := cloneDialog(base); out.Pts++; return out }(),
|
||||
func() domain.Dialog { out := cloneDialog(base); out.HistoryClearAnchorID++; return out }(),
|
||||
func() domain.Dialog { out := cloneDialog(base); out.TopMessageUnreadProjected = false; return out }(),
|
||||
func() domain.Dialog { out := cloneDialog(base); out.DefaultSendAs.ID++; return out }(),
|
||||
}
|
||||
baseHash := dialogOwnerSnapshotStructuralHash([]domain.Dialog{base}, 0)
|
||||
for index, changed := range wantDifferent {
|
||||
if got := dialogOwnerSnapshotStructuralHash([]domain.Dialog{changed}, 0); got == baseHash {
|
||||
t.Fatalf("materialized owner fact case %d did not change structural hash", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogListSnapshotHashCoversMaterializedDraft(t *testing.T) {
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 10}
|
||||
without := newDialogListSnapshot(domain.DialogList{Dialogs: []domain.Dialog{{Peer: peer}}})
|
||||
with := newDialogListSnapshot(domain.DialogList{Dialogs: []domain.Dialog{{
|
||||
Peer: peer, Draft: &domain.DialogDraft{Peer: peer, Date: 20, Message: "draft"},
|
||||
}}})
|
||||
if without.hash == with.hash {
|
||||
t.Fatalf("draft did not change snapshot hash: %d", without.hash)
|
||||
}
|
||||
}
|
||||
|
|
@ -11,11 +11,10 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
dialogLightReadModel = readmodel.ModelDialogLight
|
||||
channelBaseReadModel = readmodel.ModelChannelBase
|
||||
channelMemberReadModel = readmodel.ModelChannelMember
|
||||
defaultDialogPeerReadModelTTL = 24 * time.Hour
|
||||
dialogPeerReadModelMaxEntries = 8192
|
||||
dialogLightReadModel = readmodel.ModelDialogLight
|
||||
defaultDialogPeerReadModelTTL = 24 * time.Hour
|
||||
defaultDialogPeerReadModelMaxEntries = 500000
|
||||
defaultDialogPeerReadModelMaxBytes int64 = 256 << 20
|
||||
)
|
||||
|
||||
type dialogPeerCacheKey struct {
|
||||
|
|
@ -32,12 +31,18 @@ type dialogPeerReadModelCache struct {
|
|||
}
|
||||
|
||||
func newDialogPeerReadModelCache(ttl time.Duration) *dialogPeerReadModelCache {
|
||||
return newDialogPeerReadModelCacheWithLimits(defaultDialogPeerReadModelMaxEntries, defaultDialogPeerReadModelMaxBytes, ttl)
|
||||
}
|
||||
|
||||
func newDialogPeerReadModelCacheWithLimits(maxEntries int, maxBytes int64, ttl time.Duration) *dialogPeerReadModelCache {
|
||||
if ttl <= 0 {
|
||||
ttl = defaultDialogPeerReadModelTTL
|
||||
}
|
||||
return &dialogPeerReadModelCache{
|
||||
cache: readmodelcache.New[dialogPeerCacheKey, domain.DialogList](readmodelcache.Config[dialogPeerCacheKey, domain.DialogList]{
|
||||
MaxEntries: dialogPeerReadModelMaxEntries,
|
||||
MaxEntries: maxEntries,
|
||||
MaxWeight: maxBytes,
|
||||
Weight: dialogPeerListApproxBytes,
|
||||
TTL: ttl,
|
||||
Clone: cloneDialogList,
|
||||
}),
|
||||
|
|
@ -52,7 +57,7 @@ func (s *Service) userPeerDialogsReadModel(ctx context.Context, userID int64, pe
|
|||
if len(unique) == 0 {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
return s.cachedPeerDialogsReadModel(ctx, userID, unique, s.userDialogHashes, s.loadUserPeerDialogs)
|
||||
return s.cachedPeerDialogsReadModel(ctx, userID, unique, s.dialogHashes, s.loadUserPeerDialogs)
|
||||
}
|
||||
|
||||
func (s *Service) channelPeerDialogsReadModel(ctx context.Context, userID int64, channelIDs []int64) (domain.DialogList, error) {
|
||||
|
|
@ -63,7 +68,7 @@ func (s *Service) channelPeerDialogsReadModel(ctx context.Context, userID int64,
|
|||
if len(unique) == 0 {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
return s.cachedPeerDialogsReadModel(ctx, userID, unique, s.channelDialogHashes, s.loadChannelPeerDialogsByPeers)
|
||||
return s.loadChannelPeerDialogsByPeers(ctx, userID, unique)
|
||||
}
|
||||
|
||||
func (s *Service) cachedPeerDialogsReadModel(
|
||||
|
|
@ -73,20 +78,20 @@ func (s *Service) cachedPeerDialogsReadModel(
|
|||
hashesFor func(context.Context, int64, []domain.Peer) (map[domain.Peer]int64, error),
|
||||
load func(context.Context, int64, []domain.Peer) (domain.DialogList, error),
|
||||
) (domain.DialogList, error) {
|
||||
if s.peerCache == nil || s.versions == nil {
|
||||
if s.privatePeerCache == nil || s.versions == nil {
|
||||
return load(ctx, userID, peers)
|
||||
}
|
||||
hashes, err := hashesFor(ctx, userID, peers)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
loadEpoch := s.peerCache.cacheEpoch()
|
||||
loadEpoch := s.privatePeerCache.cacheEpoch()
|
||||
var out domain.DialogList
|
||||
misses := make([]domain.Peer, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
hash := hashes[peer]
|
||||
if hash != 0 {
|
||||
if cached, ok := s.peerCache.lookup(dialogPeerCacheKey{userID: userID, peer: peer}, hash); ok {
|
||||
if cached, ok := s.privatePeerCache.lookup(dialogPeerCacheKey{userID: userID, peer: peer}, hash); ok {
|
||||
out = mergeDialogLists(out, cached)
|
||||
continue
|
||||
}
|
||||
|
|
@ -108,7 +113,7 @@ func (s *Service) cachedPeerDialogsReadModel(
|
|||
}
|
||||
peerList := dialogListForPeer(list, peer)
|
||||
peerList.Hash = hash
|
||||
s.peerCache.putIfEpoch(dialogPeerCacheKey{userID: userID, peer: peer}, peerList, hash, loadEpoch)
|
||||
s.privatePeerCache.putIfEpoch(dialogPeerCacheKey{userID: userID, peer: peer}, peerList, hash, loadEpoch)
|
||||
}
|
||||
if len(out.Dialogs) > 0 || len(out.Messages) > 0 || len(out.ChannelMessages) > 0 || len(out.Users) > 0 || len(out.Channels) > 0 {
|
||||
return mergeDialogLists(out, list), nil
|
||||
|
|
@ -124,11 +129,8 @@ func (s *Service) loadUserPeerDialogs(ctx context.Context, userID int64, peers [
|
|||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
if err := s.attachDrafts(ctx, userID, &list); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
if err := s.projectDialogUsers(ctx, userID, &list); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
for i := range list.Dialogs {
|
||||
list.Dialogs[i].Draft = nil
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
|
@ -150,16 +152,10 @@ func (s *Service) loadChannelPeerDialogsByPeers(ctx context.Context, userID int6
|
|||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
if err := s.attachDrafts(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
if err := s.projectDialogUsers(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) userDialogHashes(ctx context.Context, userID int64, peers []domain.Peer) (map[domain.Peer]int64, error) {
|
||||
func (s *Service) dialogHashes(ctx context.Context, userID int64, peers []domain.Peer) (map[domain.Peer]int64, error) {
|
||||
keys := make([]store.ReadModelKey, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
keys = append(keys, store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID})
|
||||
|
|
@ -175,32 +171,6 @@ func (s *Service) userDialogHashes(ctx context.Context, userID int64, peers []do
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) channelDialogHashes(ctx context.Context, userID int64, peers []domain.Peer) (map[domain.Peer]int64, error) {
|
||||
keys := make([]store.ReadModelKey, 0, len(peers)*3)
|
||||
for _, peer := range peers {
|
||||
keys = append(keys,
|
||||
store.ReadModelKey{Model: channelBaseReadModel, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID},
|
||||
store.ReadModelKey{Model: channelMemberReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID},
|
||||
store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID},
|
||||
)
|
||||
}
|
||||
rows, err := s.versions.ReadModelHashes(ctx, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[domain.Peer]int64, len(peers))
|
||||
for _, peer := range peers {
|
||||
base := rows[store.ReadModelKey{Model: channelBaseReadModel, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}]
|
||||
if base == 0 {
|
||||
continue
|
||||
}
|
||||
member := rows[store.ReadModelKey{Model: channelMemberReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID}]
|
||||
dialog := rows[store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID}]
|
||||
out[peer] = readmodel.MixHashes(base, member, dialog)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// lookup 命中且版本(值自带 DialogList.Hash)匹配才返回;原语已在返回边界 clone。
|
||||
func (c *dialogPeerReadModelCache) lookup(key dialogPeerCacheKey, currentHash int64) (domain.DialogList, bool) {
|
||||
if c == nil {
|
||||
|
|
@ -246,23 +216,79 @@ func (s *Service) InvalidateDialog(userID int64, peer domain.Peer) {
|
|||
if s == nil || userID == 0 {
|
||||
return
|
||||
}
|
||||
s.invalidateDialogListHashes(userID)
|
||||
if s.peerCache == nil || peer.Type == "" || peer.ID == 0 {
|
||||
s.InvalidateDialogOwner(userID)
|
||||
if s.draftCache != nil && peer.Type != "" && peer.ID != 0 {
|
||||
s.draftCache.invalidate(dialogPeerCacheKey{userID: userID, peer: peer})
|
||||
}
|
||||
if s.privatePeerCache != nil && peer.Type == domain.PeerTypeUser && peer.ID != 0 {
|
||||
s.privatePeerCache.invalidate(dialogPeerCacheKey{userID: userID, peer: peer})
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateDialogOwner invalidates owner-list L1 state without inventing an
|
||||
// exact peer. Redis L2 values are version-addressed and validated, so old keys
|
||||
// expire naturally rather than requiring a global/key-pattern delete.
|
||||
func (s *Service) InvalidateDialogOwner(userID int64) {
|
||||
if s == nil || userID == 0 {
|
||||
return
|
||||
}
|
||||
s.peerCache.invalidate(dialogPeerCacheKey{userID: userID, peer: peer})
|
||||
s.invalidateDialogListHashes(userID)
|
||||
if s.listCache != nil {
|
||||
s.listCache.invalidateOwner(userID)
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateDialogListsForChannel invalidates only local bounded owner
|
||||
// snapshots that actually contain the changed shared channel. The listener
|
||||
// invokes it for channel_base; reconnect flush remains the missed-NOTIFY guard.
|
||||
func (s *Service) InvalidateDialogListsForChannel(channelID int64) {
|
||||
if s != nil && s.listCache != nil {
|
||||
s.listCache.invalidateChannel(channelID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) FlushReadModelCache() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.peerCache != nil {
|
||||
s.peerCache.flush()
|
||||
if s.privatePeerCache != nil {
|
||||
s.privatePeerCache.flush()
|
||||
}
|
||||
if s.draftCache != nil {
|
||||
s.draftCache.flush()
|
||||
}
|
||||
if s.listHashCache != nil {
|
||||
s.listHashCache.flush()
|
||||
}
|
||||
if s.listCache != nil {
|
||||
s.listCache.flush()
|
||||
}
|
||||
}
|
||||
|
||||
func dialogPeerListApproxBytes(list domain.DialogList) int64 {
|
||||
weight := int64(256 + len(list.Dialogs)*256 + len(list.Messages)*512 + len(list.Users)*512)
|
||||
for _, dialog := range list.Dialogs {
|
||||
weight += int64(len(dialog.ThemeEmoticon))
|
||||
}
|
||||
for _, msg := range list.Messages {
|
||||
weight += int64(len(msg.Body) + len(msg.Entities)*64)
|
||||
if msg.ReplyTo != nil {
|
||||
weight += int64(128 + len(msg.ReplyTo.QuoteText) + len(msg.ReplyTo.QuoteEntities)*64)
|
||||
}
|
||||
if msg.Forward != nil {
|
||||
weight += int64(96 + len(msg.Forward.FromName))
|
||||
}
|
||||
if msg.RichMessage != nil {
|
||||
weight += int64(len(msg.RichMessage.Blocks) + len(msg.RichMessage.BotAPIProjection) + len(msg.RichMessage.Photos)*256 + len(msg.RichMessage.Documents)*256)
|
||||
}
|
||||
}
|
||||
for _, user := range list.Users {
|
||||
weight += int64(len(user.Phone) + len(user.FirstName) + len(user.LastName) + len(user.About) + len(user.Username) + len(user.PhotoStripped))
|
||||
}
|
||||
if weight < 1 {
|
||||
return 1
|
||||
}
|
||||
return weight
|
||||
}
|
||||
|
||||
func (s *Service) invalidateDialogListHashes(userID int64) {
|
||||
|
|
@ -373,9 +399,22 @@ func cloneDialogList(in domain.DialogList) domain.DialogList {
|
|||
in.ChannelMessages = cloneDialogChannelMessages(in.ChannelMessages)
|
||||
in.Users = cloneDialogUsers(in.Users)
|
||||
in.Channels = cloneDialogChannels(in.Channels)
|
||||
in.ArchiveSummary = cloneDialogArchiveSummary(in.ArchiveSummary)
|
||||
return in
|
||||
}
|
||||
|
||||
func cloneDialogArchiveSummary(in *domain.DialogArchiveSummary) *domain.DialogArchiveSummary {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
if in.TopDialog != nil {
|
||||
dialog := cloneDialog(*in.TopDialog)
|
||||
out.TopDialog = &dialog
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
func cloneDialogSlice(in []domain.Dialog) []domain.Dialog {
|
||||
out := make([]domain.Dialog, len(in))
|
||||
for i := range in {
|
||||
|
|
@ -385,6 +424,14 @@ func cloneDialogSlice(in []domain.Dialog) []domain.Dialog {
|
|||
}
|
||||
|
||||
func cloneDialog(in domain.Dialog) domain.Dialog {
|
||||
if in.DefaultSendAs != nil {
|
||||
peer := *in.DefaultSendAs
|
||||
in.DefaultSendAs = &peer
|
||||
}
|
||||
if in.ChannelMember != nil {
|
||||
member := *in.ChannelMember
|
||||
in.ChannelMember = &member
|
||||
}
|
||||
if in.Draft != nil {
|
||||
draft := cloneDraft(*in.Draft)
|
||||
in.Draft = &draft
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"hash/fnv"
|
||||
"reflect"
|
||||
"sort"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/app/userprojection"
|
||||
|
|
@ -19,17 +20,20 @@ type PremiumChecker func(ctx context.Context, userID int64) bool
|
|||
|
||||
// Service 提供会话列表查询。
|
||||
type Service struct {
|
||||
dialogs store.DialogStore
|
||||
channels store.ChannelStore
|
||||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
premium PremiumChecker
|
||||
projector *userprojection.Projector
|
||||
versions store.ReadModelVersionStore
|
||||
peerCache *dialogPeerReadModelCache
|
||||
listHashCache *dialogListHashCache
|
||||
dialogs store.DialogStore
|
||||
channels store.ChannelStore
|
||||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
premium PremiumChecker
|
||||
projector *userprojection.Projector
|
||||
versions store.ReadModelVersionStore
|
||||
privatePeerCache *dialogPeerReadModelCache
|
||||
draftCache *dialogDraftReadModelCache
|
||||
listHashCache *dialogListHashCache
|
||||
listCache *dialogListSnapshotCache
|
||||
sharedListCache store.DialogListSnapshotCache
|
||||
}
|
||||
|
||||
// Option adjusts optional dialogs service dependencies.
|
||||
|
|
@ -64,12 +68,41 @@ func WithReadModelVersions(v store.ReadModelVersionStore) Option {
|
|||
return func(s *Service) { s.versions = v }
|
||||
}
|
||||
|
||||
// WithDialogHydrationCaches configures the bounded structural-private-peer and
|
||||
// cloud-draft working sets. Channel structure has its own store-level cache and
|
||||
// is deliberately not duplicated here.
|
||||
func WithDialogHydrationCaches(privateMaxEntries int, privateMaxBytes int64, draftMaxEntries int, draftMaxBytes int64) Option {
|
||||
return func(s *Service) {
|
||||
s.privatePeerCache = newDialogPeerReadModelCacheWithLimits(privateMaxEntries, privateMaxBytes, defaultDialogPeerReadModelTTL)
|
||||
s.draftCache = newDialogDraftReadModelCache(draftMaxEntries, draftMaxBytes, defaultDialogDraftReadModelTTL)
|
||||
}
|
||||
}
|
||||
|
||||
// WithDialogListSnapshotCache configures the bounded materialized owner working
|
||||
// set. maxHeaders is retained as the configuration/API name and measures
|
||||
// header-equivalent weighted units, so high-membership owners cannot turn
|
||||
// maxEntries into an unbounded heap commitment.
|
||||
func WithDialogListSnapshotCache(maxEntries int, maxHeaders int64, ttl time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
s.listCache = newDialogListSnapshotCache(maxEntries, maxHeaders, ttl)
|
||||
}
|
||||
}
|
||||
|
||||
// WithSharedDialogListSnapshotCache installs the production Redis L2 for
|
||||
// process-cold materialized owner restoration. Errors are propagated; production
|
||||
// never silently replaces Redis failure with a full PostgreSQL scan.
|
||||
func WithSharedDialogListSnapshotCache(cache store.DialogListSnapshotCache) Option {
|
||||
return func(s *Service) { s.sharedListCache = cache }
|
||||
}
|
||||
|
||||
// NewService 创建 dialogs 服务。
|
||||
func NewService(dialogs store.DialogStore, channels ...store.ChannelStore) *Service {
|
||||
s := &Service{
|
||||
dialogs: dialogs,
|
||||
peerCache: newDialogPeerReadModelCache(defaultDialogPeerReadModelTTL),
|
||||
listHashCache: newDialogListHashCache(defaultDialogListHashCacheTTL),
|
||||
dialogs: dialogs,
|
||||
privatePeerCache: newDialogPeerReadModelCache(defaultDialogPeerReadModelTTL),
|
||||
draftCache: newDialogDraftReadModelCache(defaultDialogDraftReadModelMaxEntries, defaultDialogDraftReadModelMaxBytes, defaultDialogDraftReadModelTTL),
|
||||
listHashCache: newDialogListHashCache(defaultDialogListHashCacheTTL),
|
||||
listCache: newDialogListSnapshotCache(0, 0, 0),
|
||||
}
|
||||
if len(channels) > 0 {
|
||||
s.channels = channels[0]
|
||||
|
|
@ -129,19 +162,403 @@ func (s *Service) getDialogs(ctx context.Context, userID int64, filter domain.Di
|
|||
}
|
||||
filter.Folder = &folder
|
||||
}
|
||||
if filter.Limit <= 0 || filter.Limit > 100 {
|
||||
filter.Limit = 100
|
||||
}
|
||||
if !lightweight {
|
||||
if key, ok := dialogSnapshotKey(userID, filter); ok && s.supportsDialogListSnapshot() {
|
||||
listHashEpoch := s.listHashCache.cacheEpoch()
|
||||
if s.sharedListCache != nil {
|
||||
page, err := s.stableDialogSnapshotPage(ctx, key, filter)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
s.rememberDialogListHash(userID, filter, page, listHashEpoch)
|
||||
return page, nil
|
||||
}
|
||||
snap, err := s.listCache.getOrLoad(ctx, key, func() (*dialogListSnapshot, error) {
|
||||
return s.loadDialogListSnapshot(ctx, key)
|
||||
})
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
page, err := s.hydrateDialogSnapshotPage(ctx, userID, dialogListSnapshotPageHeaders(snap, filter))
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
s.rememberDialogListHash(userID, filter, page, listHashEpoch)
|
||||
return page, nil
|
||||
}
|
||||
}
|
||||
return s.loadDialogs(ctx, userID, filter, lightweight)
|
||||
}
|
||||
|
||||
const dialogListSnapshotStableReadAttempts = 4
|
||||
|
||||
func (s *Service) stableDialogSnapshotPage(
|
||||
ctx context.Context,
|
||||
key dialogListSnapshotKey,
|
||||
filter domain.DialogFilter,
|
||||
) (domain.DialogList, error) {
|
||||
for attempt := 0; attempt < dialogListSnapshotStableReadAttempts; attempt++ {
|
||||
ownerHash, err := s.dialogOwnerHash(ctx, key.userID)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
snap, err := s.listCache.getOrLoadVersioned(ctx, key, ownerHash, func() (*dialogListSnapshot, error) {
|
||||
return s.loadDialogListSnapshotAtOwnerHash(ctx, key, ownerHash)
|
||||
})
|
||||
if errors.Is(err, errDialogListSnapshotGenerationChanged) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
|
||||
page, hydrateErr := s.hydrateDialogSnapshotPage(ctx, key.userID, dialogListSnapshotPageHeaders(snap, filter))
|
||||
currentOwnerHash, hashErr := s.dialogOwnerHash(ctx, key.userID)
|
||||
if hashErr != nil {
|
||||
return domain.DialogList{}, hashErr
|
||||
}
|
||||
if currentOwnerHash != ownerHash {
|
||||
continue
|
||||
}
|
||||
if hydrateErr != nil {
|
||||
return domain.DialogList{}, hydrateErr
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
return domain.DialogList{}, errDialogListSnapshotGenerationChanged
|
||||
}
|
||||
|
||||
type dialogListSnapshotStore interface {
|
||||
ListAllBuiltinDialogSnapshotHeaders(context.Context, int64) (domain.DialogList, error)
|
||||
}
|
||||
|
||||
type channelDialogListSnapshotStore interface {
|
||||
ListAllBuiltinChannelDialogSnapshot(context.Context, int64) (domain.ChannelDialogList, error)
|
||||
}
|
||||
|
||||
type channelDialogSnapshotHydrator interface {
|
||||
HydrateChannelDialogSnapshot(context.Context, int64, []domain.Dialog) (domain.ChannelDialogList, error)
|
||||
}
|
||||
|
||||
type privateDialogPeerIDStore interface {
|
||||
ListPrivateDialogPeerIDs(context.Context, int64, int) ([]int64, error)
|
||||
}
|
||||
|
||||
// PrivateDialogPeerIDs returns the bounded private-peer candidate set used by
|
||||
// transient presence fan-out without entering the full dialogs projection.
|
||||
// A process-cold server first tries the version-addressed shared owner snapshot:
|
||||
// its private dialog headers are fully covered by dialog_owner and can be
|
||||
// sorted into the same narrow result without another PostgreSQL acquisition.
|
||||
func (s *Service) PrivateDialogPeerIDs(ctx context.Context, userID int64, limit int) ([]int64, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
privateStore, ok := s.dialogs.(privateDialogPeerIDStore)
|
||||
if !ok {
|
||||
return nil, errors.New("dialog store does not provide private peer candidates")
|
||||
}
|
||||
if limit <= 0 || limit > 4096 {
|
||||
limit = 4096
|
||||
}
|
||||
if s.sharedListCache == nil || s.versions == nil {
|
||||
return privateStore.ListPrivateDialogPeerIDs(ctx, userID, limit)
|
||||
}
|
||||
for attempt := 0; attempt < dialogListSnapshotStableReadAttempts; attempt++ {
|
||||
ownerHash, err := s.dialogOwnerHash(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, found, err := s.sharedListCache.GetDialogListSnapshot(
|
||||
ctx,
|
||||
store.DialogListSnapshotCacheKey{UserID: userID, OwnerHash: ownerHash},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ids []int64
|
||||
if found {
|
||||
ids = privateDialogPeerIDsFromDialogs(value.Dialogs, userID, limit)
|
||||
} else {
|
||||
ids, err = privateStore.ListPrivateDialogPeerIDs(ctx, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
currentOwnerHash, err := s.dialogOwnerHash(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentOwnerHash == ownerHash {
|
||||
return ids, nil
|
||||
}
|
||||
}
|
||||
return nil, errDialogListSnapshotGenerationChanged
|
||||
}
|
||||
|
||||
func privateDialogPeerIDsFromDialogs(dialogs []domain.Dialog, userID int64, limit int) []int64 {
|
||||
candidates := make([]domain.Dialog, 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 _, duplicate := seen[dialog.Peer.ID]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[dialog.Peer.ID] = struct{}{}
|
||||
candidates = append(candidates, dialog)
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].TopMessageDate != candidates[j].TopMessageDate {
|
||||
return candidates[i].TopMessageDate > candidates[j].TopMessageDate
|
||||
}
|
||||
if candidates[i].TopMessage != candidates[j].TopMessage {
|
||||
return candidates[i].TopMessage > candidates[j].TopMessage
|
||||
}
|
||||
return candidates[i].Peer.ID > candidates[j].Peer.ID
|
||||
})
|
||||
if len(candidates) > limit {
|
||||
candidates = candidates[:limit]
|
||||
}
|
||||
ids := make([]int64, len(candidates))
|
||||
for index := range candidates {
|
||||
ids[index] = candidates[index].Peer.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (s *Service) supportsDialogListSnapshot() bool {
|
||||
if s == nil || s.listCache == nil {
|
||||
return false
|
||||
}
|
||||
if s.dialogs != nil {
|
||||
if _, ok := s.dialogs.(dialogListSnapshotStore); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if s.channels != nil {
|
||||
if _, ok := s.channels.(channelDialogListSnapshotStore); !ok {
|
||||
return false
|
||||
}
|
||||
if _, ok := s.channels.(channelDialogSnapshotHydrator); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) loadDialogOwnerSnapshotHeaders(ctx context.Context, userID int64) (domain.DialogList, error) {
|
||||
var out domain.DialogList
|
||||
if s.dialogs != nil {
|
||||
headers, err := s.dialogs.(dialogListSnapshotStore).ListAllBuiltinDialogSnapshotHeaders(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
peers := make([]domain.Peer, 0, len(headers.Dialogs))
|
||||
for _, dialog := range headers.Dialogs {
|
||||
if dialog.Peer.Type == domain.PeerTypeUser && dialog.Peer.ID != 0 {
|
||||
peers = append(peers, dialog.Peer)
|
||||
}
|
||||
}
|
||||
materialized := headers
|
||||
if len(peers) > 0 {
|
||||
materialized, err = s.dialogs.ListByPeers(ctx, userID, peers)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
materialized = orderMaterializedDialogList(headers, materialized)
|
||||
}
|
||||
out = mergeDialogLists(out, materialized)
|
||||
}
|
||||
if s.channels != nil {
|
||||
materialized, err := s.channels.(channelDialogListSnapshotStore).ListAllBuiltinChannelDialogSnapshot(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
out = mergeChannelDialogs(out, materialized)
|
||||
}
|
||||
sortDialogList(out.Dialogs)
|
||||
out.Count = len(out.Dialogs)
|
||||
if err := s.attachArchiveSummaryFromOwnerHeaders(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
if err := s.attachOwnerSnapshotDrafts(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// attachOwnerSnapshotDrafts materializes the complete bounded cloud-draft set
|
||||
// once under the dialog_owner stable-read fence. Draft writes bump
|
||||
// dialog_light and its aggregate dialog_owner generation, so storing these
|
||||
// overlays in the version-addressed owner snapshot is exact: page reads no
|
||||
// longer need one ListDraftsByPeers query apiece, and a concurrent draft write
|
||||
// forces the snapshot materialization retry before publication.
|
||||
func (s *Service) attachOwnerSnapshotDrafts(ctx context.Context, userID int64, out *domain.DialogList) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 || out == nil || len(out.Dialogs) == 0 {
|
||||
return nil
|
||||
}
|
||||
drafts, err := s.dialogs.ListDrafts(ctx, userID, domain.MaxDialogDraftsPerUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byPeer := make(map[domain.Peer]domain.DialogDraft, len(drafts))
|
||||
for _, draft := range drafts {
|
||||
if draft.TopMessageID == 0 && draft.Peer.Type != "" && draft.Peer.ID != 0 {
|
||||
byPeer[draft.Peer] = cloneDraft(draft)
|
||||
}
|
||||
}
|
||||
for index := range out.Dialogs {
|
||||
out.Dialogs[index].Draft = nil
|
||||
if draft, found := byPeer[out.Dialogs[index].Peer]; found {
|
||||
draft := cloneDraft(draft)
|
||||
out.Dialogs[index].Draft = &draft
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) attachArchiveSummaryFromOwnerHeaders(ctx context.Context, userID int64, out *domain.DialogList) error {
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
var top domain.Dialog
|
||||
for _, dialog := range out.Dialogs {
|
||||
if dialog.FolderID == domain.DialogArchiveFolderID {
|
||||
top = dialog
|
||||
break
|
||||
}
|
||||
}
|
||||
if top.Peer.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
unreadPeers, unreadMessages := 0, 0
|
||||
if s.dialogs != nil {
|
||||
peers, messages, err := s.dialogs.CountArchiveUnread(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unreadPeers += peers
|
||||
unreadMessages += messages
|
||||
}
|
||||
if s.channels != nil {
|
||||
peers, messages, err := s.channels.CountChannelArchiveUnread(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unreadPeers += peers
|
||||
unreadMessages += messages
|
||||
}
|
||||
archivePinned := true
|
||||
if s.dialogs != nil {
|
||||
pinned, err := s.dialogs.ArchivePinned(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
archivePinned = pinned
|
||||
}
|
||||
out.ArchiveSummary = &domain.DialogArchiveSummary{
|
||||
TopPeer: top.Peer, TopMessage: top.TopMessage,
|
||||
TopDialog: cloneDialogPtr(top),
|
||||
UnreadPeersCount: unreadPeers, UnreadMessagesCount: unreadMessages,
|
||||
Pinned: archivePinned,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) hydrateDialogSnapshotPage(ctx context.Context, userID int64, headers domain.DialogList) (domain.DialogList, error) {
|
||||
hydrated := cloneDialogList(headers)
|
||||
if s.channels != nil {
|
||||
channelDialogs := make([]domain.Dialog, 0, len(hydrated.Dialogs)+1)
|
||||
present := make(map[domain.Peer]struct{}, len(hydrated.Dialogs))
|
||||
for _, dialog := range hydrated.Dialogs {
|
||||
present[dialog.Peer] = struct{}{}
|
||||
if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID != 0 {
|
||||
channelDialogs = append(channelDialogs, dialog)
|
||||
}
|
||||
}
|
||||
if hydrated.ArchiveSummary != nil && hydrated.ArchiveSummary.TopDialog != nil {
|
||||
top := *hydrated.ArchiveSummary.TopDialog
|
||||
if top.Peer.Type == domain.PeerTypeChannel && top.Peer.ID != 0 {
|
||||
if _, ok := present[top.Peer]; !ok {
|
||||
channelDialogs = append(channelDialogs, top)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(channelDialogs) > 0 {
|
||||
projection, err := s.channels.(channelDialogSnapshotHydrator).HydrateChannelDialogSnapshot(ctx, userID, channelDialogs)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
byPeer := make(map[domain.Peer]domain.Dialog, len(projection.Dialogs))
|
||||
for _, dialog := range projection.Dialogs {
|
||||
byPeer[dialog.Peer] = dialog
|
||||
}
|
||||
for index := range hydrated.Dialogs {
|
||||
if dialog, ok := byPeer[hydrated.Dialogs[index].Peer]; ok {
|
||||
hydrated.Dialogs[index] = dialog
|
||||
}
|
||||
}
|
||||
hydrated.ChannelMessages = append(hydrated.ChannelMessages, projection.Messages...)
|
||||
hydrated.Channels = append(hydrated.Channels, projection.Channels...)
|
||||
hydrated.Users = append(hydrated.Users, projection.Users...)
|
||||
}
|
||||
}
|
||||
// Drafts are part of the version-addressed owner snapshot. Re-reading them
|
||||
// per page would discard that materialization and recreate a PostgreSQL
|
||||
// acquisition for every messages.getDialogs cursor.
|
||||
if err := s.projectDialogUsers(ctx, userID, &hydrated); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
return hydrated, nil
|
||||
}
|
||||
|
||||
func orderMaterializedDialogList(headers, materialized domain.DialogList) domain.DialogList {
|
||||
materialized.Dialogs = orderMaterializedDialogs(headers.Dialogs, materialized.Dialogs)
|
||||
materialized.Count = len(materialized.Dialogs)
|
||||
return materialized
|
||||
}
|
||||
|
||||
func orderMaterializedDialogs(headers, materialized []domain.Dialog) []domain.Dialog {
|
||||
byPeer := make(map[domain.Peer]domain.Dialog, len(materialized))
|
||||
for _, dialog := range materialized {
|
||||
byPeer[dialog.Peer] = dialog
|
||||
}
|
||||
ordered := make([]domain.Dialog, 0, len(headers))
|
||||
for _, header := range headers {
|
||||
if dialog, ok := byPeer[header.Peer]; ok {
|
||||
ordered = append(ordered, dialog)
|
||||
continue
|
||||
}
|
||||
// Keep the authoritative header so a concurrent disappearance remains
|
||||
// visible to the generation/dependency guard instead of silently
|
||||
// shrinking a page while the read model is being materialized.
|
||||
ordered = append(ordered, header)
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func (s *Service) loadDialogs(ctx context.Context, userID int64, filter domain.DialogFilter, lightweight bool) (domain.DialogList, error) {
|
||||
// 在加载任何会话状态前快照 list-hash epoch:若加载/投影期间发生 dialog_light 写失效,
|
||||
// rememberDialogListHash 会据此拒绝写回 stale hash,避免后续 getDialogs 误返 NotModified。
|
||||
listHashEpoch := s.listHashCache.cacheEpoch()
|
||||
var out domain.DialogList
|
||||
if s.dialogs != nil {
|
||||
list, err := s.dialogs.ListByUser(ctx, userID, filter)
|
||||
var list domain.DialogList
|
||||
var err error
|
||||
list, err = s.dialogs.ListByUser(ctx, userID, filter)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
out = mergeDialogLists(out, list)
|
||||
}
|
||||
if s.channels != nil {
|
||||
list, err := s.channels.ListChannelDialogs(ctx, userID, filter)
|
||||
var list domain.ChannelDialogList
|
||||
var err error
|
||||
list, err = s.channels.ListChannelDialogs(ctx, userID, filter)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
|
|
@ -246,6 +663,7 @@ func (s *Service) attachArchiveSummary(ctx context.Context, userID int64, filter
|
|||
out.ArchiveSummary = &domain.DialogArchiveSummary{
|
||||
TopPeer: topDialog.Peer,
|
||||
TopMessage: topDialog.TopMessage,
|
||||
TopDialog: cloneDialogPtr(topDialog),
|
||||
UnreadPeersCount: unreadPeers,
|
||||
UnreadMessagesCount: unreadMessages,
|
||||
Pinned: archivePinned,
|
||||
|
|
@ -259,6 +677,11 @@ func (s *Service) attachArchiveSummary(ctx context.Context, userID int64, filter
|
|||
return nil
|
||||
}
|
||||
|
||||
func cloneDialogPtr(dialog domain.Dialog) *domain.Dialog {
|
||||
clone := cloneDialog(dialog)
|
||||
return &clone
|
||||
}
|
||||
|
||||
// GetPeerDialogs 返回指定 peer 的会话摘要。缺失的 peer 由 store 按空会话占位返回。
|
||||
func (s *Service) GetPeerDialogs(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
|
||||
if s == nil || userID == 0 || len(peers) == 0 {
|
||||
|
|
@ -292,6 +715,12 @@ func (s *Service) GetPeerDialogs(ctx context.Context, userID int64, peers []doma
|
|||
}
|
||||
out = mergeDialogLists(out, channelOut)
|
||||
}
|
||||
if err := s.attachDrafts(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
if err := s.projectDialogUsers(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
|
@ -836,30 +1265,24 @@ func (s *Service) attachDrafts(ctx context.Context, userID int64, list *domain.D
|
|||
if s == nil || s.dialogs == nil || userID == 0 || list == nil || len(list.Dialogs) == 0 {
|
||||
return nil
|
||||
}
|
||||
drafts, err := s.dialogs.ListDrafts(ctx, userID, domain.MaxDialogDraftsPerUser)
|
||||
peers := make([]domain.Peer, 0, len(list.Dialogs))
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.Peer.ID != 0 {
|
||||
peers = append(peers, dialog.Peer)
|
||||
}
|
||||
}
|
||||
drafts, err := s.dialogDraftsReadModel(ctx, userID, peers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(drafts) == 0 {
|
||||
return nil
|
||||
}
|
||||
byPeer := make(map[domain.Peer]domain.DialogDraft, len(drafts))
|
||||
for _, draft := range drafts {
|
||||
if draft.TopMessageID != 0 {
|
||||
continue
|
||||
}
|
||||
byPeer[draft.Peer] = cloneDraft(draft)
|
||||
}
|
||||
if len(byPeer) == 0 {
|
||||
return nil
|
||||
}
|
||||
attached := false
|
||||
for i := range list.Dialogs {
|
||||
draft, ok := byPeer[list.Dialogs[i].Peer]
|
||||
if !ok {
|
||||
list.Dialogs[i].Draft = nil
|
||||
draft, ok := drafts[list.Dialogs[i].Peer]
|
||||
if !ok || !draft.found {
|
||||
continue
|
||||
}
|
||||
d := cloneDraft(draft)
|
||||
d := cloneDraft(draft.draft)
|
||||
list.Dialogs[i].Draft = &d
|
||||
attached = true
|
||||
}
|
||||
|
|
@ -996,7 +1419,8 @@ func writeDraftRichHash(h interface{ Write([]byte) (int, error) }, buf []byte, r
|
|||
binary.LittleEndian.PutUint64(buf[2:10], uint64(len(rich.Blocks)))
|
||||
binary.LittleEndian.PutUint64(buf[10:18], uint64(len(rich.Photos)))
|
||||
binary.LittleEndian.PutUint64(buf[18:26], uint64(len(rich.Documents)))
|
||||
_, _ = h.Write(buf[:26])
|
||||
binary.LittleEndian.PutUint64(buf[26:34], uint64(rich.EffectiveBlocksLayer()))
|
||||
_, _ = h.Write(buf[:34])
|
||||
_, _ = h.Write(rich.Blocks)
|
||||
for _, photo := range rich.Photos {
|
||||
binary.LittleEndian.PutUint64(buf[:8], uint64(photo.ID))
|
||||
|
|
|
|||
|
|
@ -14,10 +14,241 @@ import (
|
|||
|
||||
type countingDialogStore struct {
|
||||
store.DialogStore
|
||||
listByUserCalls int
|
||||
listByPeersCalls int
|
||||
listByPeersBatches [][]domain.Peer
|
||||
listDraftsCalls int
|
||||
listByUserCalls int
|
||||
listByPeersCalls int
|
||||
listByPeersBatches [][]domain.Peer
|
||||
listDraftsByPeersCalls int
|
||||
listDraftsByPeersBatches [][]domain.Peer
|
||||
listDraftsByPeersErr error
|
||||
}
|
||||
|
||||
type snapshotDialogStore struct {
|
||||
store.DialogStore
|
||||
list domain.DialogList
|
||||
snapshotCalls int
|
||||
listByPeersCalls int
|
||||
listDraftsCalls int
|
||||
privatePeerCalls int
|
||||
onListByPeers func()
|
||||
onListDrafts func()
|
||||
}
|
||||
|
||||
func (s *snapshotDialogStore) ListAllBuiltinDialogSnapshotHeaders(_ context.Context, _ int64) (domain.DialogList, error) {
|
||||
s.snapshotCalls++
|
||||
return cloneDialogList(s.list), nil
|
||||
}
|
||||
|
||||
func (s *snapshotDialogStore) ListByPeers(_ context.Context, _ int64, peers []domain.Peer) (domain.DialogList, error) {
|
||||
s.listByPeersCalls++
|
||||
if s.onListByPeers != nil {
|
||||
fn := s.onListByPeers
|
||||
s.onListByPeers = nil
|
||||
fn()
|
||||
}
|
||||
wanted := make(map[domain.Peer]struct{}, len(peers))
|
||||
for _, peer := range peers {
|
||||
wanted[peer] = struct{}{}
|
||||
}
|
||||
out := domain.DialogList{}
|
||||
for _, dialog := range s.list.Dialogs {
|
||||
if _, ok := wanted[dialog.Peer]; ok {
|
||||
out.Dialogs = append(out.Dialogs, cloneDialog(dialog))
|
||||
}
|
||||
}
|
||||
for _, message := range s.list.Messages {
|
||||
if _, ok := wanted[message.Peer]; ok {
|
||||
out.Messages = append(out.Messages, cloneMessageForDialogCache(message))
|
||||
}
|
||||
}
|
||||
for _, user := range s.list.Users {
|
||||
if _, ok := wanted[domain.Peer{Type: domain.PeerTypeUser, ID: user.ID}]; ok {
|
||||
out.Users = append(out.Users, cloneDialogUser(user))
|
||||
}
|
||||
}
|
||||
out.Count = len(out.Dialogs)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *snapshotDialogStore) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
|
||||
s.listDraftsCalls++
|
||||
drafts, err := s.DialogStore.ListDrafts(ctx, userID, limit)
|
||||
if s.onListDrafts != nil {
|
||||
fn := s.onListDrafts
|
||||
s.onListDrafts = nil
|
||||
fn()
|
||||
}
|
||||
return drafts, err
|
||||
}
|
||||
|
||||
func (s *snapshotDialogStore) ListPrivateDialogPeerIDs(ctx context.Context, userID int64, limit int) ([]int64, error) {
|
||||
s.privatePeerCalls++
|
||||
return s.DialogStore.(privateDialogPeerIDStore).ListPrivateDialogPeerIDs(ctx, userID, limit)
|
||||
}
|
||||
|
||||
func TestGetDialogsSnapshotReusesOwnerProjectionAcrossCursorPages(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
peers := []domain.Peer{
|
||||
{Type: domain.PeerTypeUser, ID: 2003},
|
||||
{Type: domain.PeerTypeUser, ID: 2002},
|
||||
{Type: domain.PeerTypeUser, ID: 2001},
|
||||
}
|
||||
base := memory.NewDialogStore()
|
||||
snapshots := &snapshotDialogStore{DialogStore: base, list: domain.DialogList{
|
||||
Dialogs: []domain.Dialog{
|
||||
{Peer: peers[0], TopMessage: 30, TopMessageDate: 300},
|
||||
{Peer: peers[1], TopMessage: 20, TopMessageDate: 200},
|
||||
{Peer: peers[2], TopMessage: 10, TopMessageDate: 100},
|
||||
},
|
||||
Messages: []domain.Message{
|
||||
{ID: 30, Peer: peers[0], From: peers[0], Date: 300, Body: "first"},
|
||||
{ID: 20, Peer: peers[1], From: peers[1], Date: 200, Body: "second"},
|
||||
{ID: 10, Peer: peers[2], From: peers[2], Date: 100, Body: "third"},
|
||||
},
|
||||
Users: []domain.User{{ID: 2003}, {ID: 2002}, {ID: 2001}},
|
||||
Count: 3,
|
||||
Hash: 77,
|
||||
}}
|
||||
service := NewService(snapshots)
|
||||
first, err := service.GetDialogs(ctx, ownerID, domain.DialogFilter{ExcludePinned: true, Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("first page: %v", err)
|
||||
}
|
||||
if len(first.Dialogs) != 1 || first.Dialogs[0].Peer != peers[0] || len(first.Messages) != 1 || len(first.Users) != 1 || first.Count != 3 {
|
||||
t.Fatalf("first page = %+v, messages=%d users=%d count=%d", first.Dialogs, len(first.Messages), len(first.Users), first.Count)
|
||||
}
|
||||
second, err := service.GetDialogs(ctx, ownerID, domain.DialogFilter{
|
||||
ExcludePinned: true,
|
||||
Limit: 1,
|
||||
OffsetDate: first.Dialogs[0].TopMessageDate,
|
||||
OffsetID: first.Dialogs[0].TopMessage,
|
||||
HasOffsetPeer: true,
|
||||
OffsetPeer: first.Dialogs[0].Peer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second page: %v", err)
|
||||
}
|
||||
if len(second.Dialogs) != 1 || second.Dialogs[0].Peer != peers[1] || len(second.Messages) != 1 || len(second.Users) != 1 || second.Count != 3 {
|
||||
t.Fatalf("second page = %+v, messages=%d users=%d count=%d", second.Dialogs, len(second.Messages), len(second.Users), second.Count)
|
||||
}
|
||||
if snapshots.snapshotCalls != 1 {
|
||||
t.Fatalf("snapshot calls = %d, want one owner load across pages", snapshots.snapshotCalls)
|
||||
}
|
||||
|
||||
service.InvalidateDialog(ownerID, peers[0])
|
||||
if _, err := service.GetDialogs(ctx, ownerID, domain.DialogFilter{ExcludePinned: true, Limit: 1}); err != nil {
|
||||
t.Fatalf("reload after owner invalidation: %v", err)
|
||||
}
|
||||
if snapshots.snapshotCalls != 2 {
|
||||
t.Fatalf("snapshot calls after invalidation = %d, want 2", snapshots.snapshotCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDialogsSnapshotMaterializesDraftsOnceAcrossCursorPages(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1101
|
||||
firstPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2101}
|
||||
secondPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2102}
|
||||
base := memory.NewDialogStore()
|
||||
if err := base.SaveDraft(ctx, ownerID, domain.DialogDraft{
|
||||
Peer: secondPeer, Date: 22, Message: "second-page draft",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshots := &snapshotDialogStore{DialogStore: base, list: domain.DialogList{
|
||||
Dialogs: []domain.Dialog{
|
||||
{Peer: firstPeer, TopMessage: 2, TopMessageDate: 20},
|
||||
{Peer: secondPeer, TopMessage: 1, TopMessageDate: 10},
|
||||
},
|
||||
Users: []domain.User{{ID: firstPeer.ID}, {ID: secondPeer.ID}},
|
||||
}}
|
||||
service := NewService(snapshots)
|
||||
first, err := service.GetDialogs(ctx, ownerID, domain.DialogFilter{ExcludePinned: true, Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := service.GetDialogs(ctx, ownerID, domain.DialogFilter{
|
||||
ExcludePinned: true, Limit: 1,
|
||||
OffsetDate: first.Dialogs[0].TopMessageDate, OffsetID: first.Dialogs[0].TopMessage,
|
||||
HasOffsetPeer: true, OffsetPeer: first.Dialogs[0].Peer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(second.Dialogs) != 1 || second.Dialogs[0].Draft == nil || second.Dialogs[0].Draft.Message != "second-page draft" {
|
||||
t.Fatalf("second page draft = %+v", second.Dialogs)
|
||||
}
|
||||
if snapshots.snapshotCalls != 1 || snapshots.listDraftsCalls != 1 {
|
||||
t.Fatalf("snapshot/draft loads = %d/%d, want 1/1 across pages", snapshots.snapshotCalls, snapshots.listDraftsCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogSnapshotChannelDependencyInvalidatesOwnerProjection(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 77}
|
||||
service := NewService(memory.NewDialogStore())
|
||||
key, ok := dialogSnapshotKey(ownerID, domain.DialogFilter{ExcludePinned: true})
|
||||
if !ok {
|
||||
t.Fatal("standard main-folder snapshot key was rejected")
|
||||
}
|
||||
service.listCache.cache.Store(key, newDialogListSnapshot(domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{Peer: channelPeer, TopMessage: 1, TopMessageDate: 10}},
|
||||
Channels: []domain.Channel{{ID: channelPeer.ID, Title: "before"}},
|
||||
Count: 1,
|
||||
}))
|
||||
service.InvalidateDialogListsForChannel(channelPeer.ID)
|
||||
if got := service.listCache.cache.Len(); got != 0 {
|
||||
t.Fatalf("snapshot cache entries = %d, want channel dependency invalidation", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogSnapshotCacheBoundsAggregateHeaderWeight(t *testing.T) {
|
||||
cache := newDialogListSnapshotCache(10, 3, time.Hour)
|
||||
first := dialogListSnapshotKey{userID: 1001}
|
||||
second := dialogListSnapshotKey{userID: 1002}
|
||||
cache.cache.Store(first, newDialogListSnapshot(domain.DialogList{Dialogs: []domain.Dialog{
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 1}},
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 2}},
|
||||
}}))
|
||||
cache.cache.Store(second, newDialogListSnapshot(domain.DialogList{Dialogs: []domain.Dialog{
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3}},
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 4}},
|
||||
}}))
|
||||
if _, ok := cache.cache.Peek(first); ok {
|
||||
t.Fatal("old owner snapshot should be evicted by aggregate header budget")
|
||||
}
|
||||
if _, ok := cache.cache.Peek(second); !ok {
|
||||
t.Fatal("new owner snapshot should remain within aggregate header budget")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogSnapshotDependencyIndexTracksLRUEvictionAndReplacement(t *testing.T) {
|
||||
cache := newDialogListSnapshotCache(1, 10, time.Hour)
|
||||
first := dialogListSnapshotKey{userID: 1001}
|
||||
second := dialogListSnapshotKey{userID: 1002}
|
||||
cache.cache.Store(first, newDialogListSnapshot(domain.DialogList{Dialogs: []domain.Dialog{
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 7}},
|
||||
}}))
|
||||
cache.cache.Store(second, newDialogListSnapshot(domain.DialogList{Dialogs: []domain.Dialog{
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 8}},
|
||||
}}))
|
||||
cache.indexMu.Lock()
|
||||
_, staleEvicted := cache.channelKeys[7]
|
||||
_, retained := cache.channelKeys[8]
|
||||
cache.indexMu.Unlock()
|
||||
if staleEvicted || !retained {
|
||||
t.Fatalf("dependency index after LRU eviction: channel7=%v channel8=%v", staleEvicted, retained)
|
||||
}
|
||||
cache.cache.Store(second, newDialogListSnapshot(domain.DialogList{Dialogs: []domain.Dialog{
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 9}},
|
||||
}}))
|
||||
cache.indexMu.Lock()
|
||||
_, staleReplaced := cache.channelKeys[8]
|
||||
_, replaced := cache.channelKeys[9]
|
||||
cache.indexMu.Unlock()
|
||||
if staleReplaced || !replaced {
|
||||
t.Fatalf("dependency index after replacement: channel8=%v channel9=%v", staleReplaced, replaced)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *countingDialogStore) ListByUser(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
|
||||
|
|
@ -31,9 +262,13 @@ func (s *countingDialogStore) ListByPeers(ctx context.Context, userID int64, pee
|
|||
return s.DialogStore.ListByPeers(ctx, userID, peers)
|
||||
}
|
||||
|
||||
func (s *countingDialogStore) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
|
||||
s.listDraftsCalls++
|
||||
return s.DialogStore.ListDrafts(ctx, userID, limit)
|
||||
func (s *countingDialogStore) ListDraftsByPeers(ctx context.Context, userID int64, peers []domain.Peer) ([]domain.DialogDraft, error) {
|
||||
s.listDraftsByPeersCalls++
|
||||
s.listDraftsByPeersBatches = append(s.listDraftsByPeersBatches, append([]domain.Peer(nil), peers...))
|
||||
if s.listDraftsByPeersErr != nil {
|
||||
return nil, s.listDraftsByPeersErr
|
||||
}
|
||||
return s.DialogStore.ListDraftsByPeers(ctx, userID, peers)
|
||||
}
|
||||
|
||||
type fakeDialogReadModelVersions struct {
|
||||
|
|
@ -179,6 +414,51 @@ func TestSaveDraftNoopsWhenOnlyDateChanges(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetDialogsLoadsDraftsOnlyForCurrentPage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
firstPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
secondPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1003}
|
||||
base := memory.NewDialogStore()
|
||||
if err := base.SaveList(ctx, ownerID, domain.DialogList{
|
||||
Dialogs: []domain.Dialog{
|
||||
{Peer: firstPeer, TopMessage: 11, TopMessageDate: 200},
|
||||
{Peer: secondPeer, TopMessage: 12, TopMessageDate: 100},
|
||||
},
|
||||
Messages: []domain.Message{
|
||||
{ID: 11, OwnerUserID: ownerID, Peer: firstPeer, From: firstPeer, Date: 200, Body: "first"},
|
||||
{ID: 12, OwnerUserID: ownerID, Peer: secondPeer, From: secondPeer, Date: 100, Body: "second"},
|
||||
},
|
||||
Users: []domain.User{
|
||||
{ID: firstPeer.ID, FirstName: "First"},
|
||||
{ID: secondPeer.ID, FirstName: "Second"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveList: %v", err)
|
||||
}
|
||||
if err := base.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: firstPeer, Date: 201, Message: "first draft"}); err != nil {
|
||||
t.Fatalf("save first draft: %v", err)
|
||||
}
|
||||
if err := base.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: secondPeer, Date: 202, Message: "second draft"}); err != nil {
|
||||
t.Fatalf("save second draft: %v", err)
|
||||
}
|
||||
counting := &countingDialogStore{DialogStore: base}
|
||||
|
||||
list, err := NewService(counting).GetDialogs(ctx, ownerID, domain.DialogFilter{Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs: %v", err)
|
||||
}
|
||||
if len(list.Dialogs) != 1 || list.Dialogs[0].Peer != firstPeer || list.Dialogs[0].Draft == nil || list.Dialogs[0].Draft.Message != "first draft" {
|
||||
t.Fatalf("dialogs = %+v, want first page with its draft", list.Dialogs)
|
||||
}
|
||||
if counting.listDraftsByPeersCalls != 1 || len(counting.listDraftsByPeersBatches) != 1 {
|
||||
t.Fatalf("ListDraftsByPeers calls/batches = %d/%d, want 1/1", counting.listDraftsByPeersCalls, len(counting.listDraftsByPeersBatches))
|
||||
}
|
||||
if got := counting.listDraftsByPeersBatches[0]; len(got) != 1 || got[0] != firstPeer {
|
||||
t.Fatalf("draft peer batch = %+v, want current-page peer %+v", got, firstPeer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPeerDialogsCachesPrivatePeerReadModelByHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
|
|
@ -226,16 +506,19 @@ func TestGetPeerDialogsCachesPrivatePeerReadModelByHash(t *testing.T) {
|
|||
if len(second.Dialogs) != 1 || second.Dialogs[0].TopMessage != 7 {
|
||||
t.Fatalf("second dialog = %+v, want cached top message", second.Dialogs)
|
||||
}
|
||||
if counting.listByPeersCalls != 1 || counting.listDraftsCalls != 1 {
|
||||
t.Fatalf("store calls ListByPeers/ListDrafts = %d/%d, want 1/1 after cache hit", counting.listByPeersCalls, counting.listDraftsCalls)
|
||||
if counting.listByPeersCalls != 1 || counting.listDraftsByPeersCalls != 1 {
|
||||
t.Fatalf("store calls ListByPeers/ListDraftsByPeers = %d/%d, want 1/1 after cache hit", counting.listByPeersCalls, counting.listDraftsByPeersCalls)
|
||||
}
|
||||
if got := counting.listDraftsByPeersBatches[0]; len(got) != 1 || got[0] != peer {
|
||||
t.Fatalf("draft peer batch = %+v, want only %+v", got, peer)
|
||||
}
|
||||
|
||||
versions.hashes[store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}] = 202
|
||||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
|
||||
t.Fatalf("third GetPeerDialogs after hash bump: %v", err)
|
||||
}
|
||||
if counting.listByPeersCalls != 2 || counting.listDraftsCalls != 2 {
|
||||
t.Fatalf("store calls after hash bump = %d/%d, want 2/2", counting.listByPeersCalls, counting.listDraftsCalls)
|
||||
if counting.listByPeersCalls != 2 || counting.listDraftsByPeersCalls != 2 {
|
||||
t.Fatalf("store calls after hash bump = %d/%d, want 2/2", counting.listByPeersCalls, counting.listDraftsByPeersCalls)
|
||||
}
|
||||
|
||||
if _, err := dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 72, Message: "new draft"}); err != nil {
|
||||
|
|
@ -244,8 +527,8 @@ func TestGetPeerDialogsCachesPrivatePeerReadModelByHash(t *testing.T) {
|
|||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
|
||||
t.Fatalf("GetPeerDialogs after service invalidation: %v", err)
|
||||
}
|
||||
if counting.listByPeersCalls != 3 || counting.listDraftsCalls != 3 {
|
||||
t.Fatalf("store calls after explicit invalidation = %d/%d, want 3/3", counting.listByPeersCalls, counting.listDraftsCalls)
|
||||
if counting.listByPeersCalls != 3 || counting.listDraftsByPeersCalls != 3 {
|
||||
t.Fatalf("store calls after explicit invalidation = %d/%d, want 3/3", counting.listByPeersCalls, counting.listDraftsByPeersCalls)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -295,7 +578,7 @@ func TestGetPeerDialogsReloadsOnlyReadModelCacheMisses(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetPeerDialogsCachesChannelPeerReadModelByCompositeHash(t *testing.T) {
|
||||
func TestGetPeerDialogsUsesStoreChannelProjectionAndVersionedDraftCache(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
dialogStore := &countingDialogStore{DialogStore: memory.NewDialogStore()}
|
||||
|
|
@ -320,9 +603,7 @@ func TestGetPeerDialogsCachesChannelPeerReadModelByCompositeHash(t *testing.T) {
|
|||
}
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: channelBaseReadModel, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}: 11,
|
||||
{Model: channelMemberReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 22,
|
||||
{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 33,
|
||||
{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 33,
|
||||
}}
|
||||
dialogs := NewService(dialogStore, channelStore).Configure(WithReadModelVersions(versions))
|
||||
|
||||
|
|
@ -339,18 +620,18 @@ func TestGetPeerDialogsCachesChannelPeerReadModelByCompositeHash(t *testing.T) {
|
|||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
|
||||
t.Fatalf("second GetPeerDialogs: %v", err)
|
||||
}
|
||||
if channelStore.getChannelDialogsCalls != 1 || dialogStore.listDraftsCalls != 1 {
|
||||
t.Fatalf("store calls GetChannelDialogs/ListDrafts = %d/%d, want 1/1 after channel cache hit",
|
||||
channelStore.getChannelDialogsCalls, dialogStore.listDraftsCalls)
|
||||
if channelStore.getChannelDialogsCalls != 2 || dialogStore.listDraftsByPeersCalls != 1 {
|
||||
t.Fatalf("store calls GetChannelDialogs/ListDraftsByPeers = %d/%d, want 2/1 without duplicate Service channel cache",
|
||||
channelStore.getChannelDialogsCalls, dialogStore.listDraftsByPeersCalls)
|
||||
}
|
||||
|
||||
versions.hashes[store.ReadModelKey{Model: channelMemberReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}] = 44
|
||||
versions.hashes[store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}] = 44
|
||||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
|
||||
t.Fatalf("third GetPeerDialogs after member hash bump: %v", err)
|
||||
t.Fatalf("third GetPeerDialogs after dialog hash bump: %v", err)
|
||||
}
|
||||
if channelStore.getChannelDialogsCalls != 2 || dialogStore.listDraftsCalls != 2 {
|
||||
t.Fatalf("store calls after member hash bump = %d/%d, want 2/2",
|
||||
channelStore.getChannelDialogsCalls, dialogStore.listDraftsCalls)
|
||||
if channelStore.getChannelDialogsCalls != 3 || dialogStore.listDraftsByPeersCalls != 2 {
|
||||
t.Fatalf("store calls after dialog hash bump = %d/%d, want 3/2",
|
||||
channelStore.getChannelDialogsCalls, dialogStore.listDraftsByPeersCalls)
|
||||
}
|
||||
|
||||
if _, err := dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 1700003220, Message: "channel draft"}); err != nil {
|
||||
|
|
@ -359,9 +640,121 @@ func TestGetPeerDialogsCachesChannelPeerReadModelByCompositeHash(t *testing.T) {
|
|||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
|
||||
t.Fatalf("GetPeerDialogs after draft invalidation: %v", err)
|
||||
}
|
||||
if channelStore.getChannelDialogsCalls != 3 || dialogStore.listDraftsCalls != 3 {
|
||||
t.Fatalf("store calls after draft invalidation = %d/%d, want 3/3",
|
||||
channelStore.getChannelDialogsCalls, dialogStore.listDraftsCalls)
|
||||
if channelStore.getChannelDialogsCalls != 4 || dialogStore.listDraftsByPeersCalls != 3 {
|
||||
t.Fatalf("store calls after draft invalidation = %d/%d, want 4/3",
|
||||
channelStore.getChannelDialogsCalls, dialogStore.listDraftsByPeersCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHydrationDoesNotEvictPrivatePeerStructure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
privatePeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
base := memory.NewDialogStore()
|
||||
if err := base.SaveList(ctx, ownerID, domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{Peer: privatePeer, TopMessage: 7, TopMessageDate: 70}},
|
||||
Messages: []domain.Message{{ID: 7, OwnerUserID: ownerID, Peer: privatePeer, From: privatePeer, Body: "private"}},
|
||||
Users: []domain.User{{ID: privatePeer.ID}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveList: %v", err)
|
||||
}
|
||||
dialogStore := &countingDialogStore{DialogStore: base}
|
||||
channelStore := &countingDialogChannelStore{ChannelStore: memory.NewChannelStore()}
|
||||
channels := appchannels.NewService(channelStore)
|
||||
channelPeers := make([]domain.Peer, 0, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
created, err := channels.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{Title: "channel", Megagroup: true, Date: 100 + i})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel(%d): %v", i, err)
|
||||
}
|
||||
channelPeers = append(channelPeers, domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID})
|
||||
}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: privatePeer.Type, PeerID: privatePeer.ID}: 1,
|
||||
}}
|
||||
dialogs := NewService(dialogStore, channelStore).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithDialogHydrationCaches(1, 1<<20, 10, 1<<20),
|
||||
)
|
||||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{privatePeer}); err != nil {
|
||||
t.Fatalf("first private hydration: %v", err)
|
||||
}
|
||||
for _, peer := range channelPeers {
|
||||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
|
||||
t.Fatalf("channel hydration %+v: %v", peer, err)
|
||||
}
|
||||
}
|
||||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{privatePeer}); err != nil {
|
||||
t.Fatalf("second private hydration: %v", err)
|
||||
}
|
||||
if dialogStore.listByPeersCalls != 1 {
|
||||
t.Fatalf("private ListByPeers calls = %d, want 1 after channel churn", dialogStore.listByPeersCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivatePeerCacheReprojectsCurrentViewerUserFacts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
base := memory.NewDialogStore()
|
||||
if err := base.SaveList(ctx, ownerID, domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 7}},
|
||||
Users: []domain.User{{ID: peer.ID, FirstName: "Peer"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveList: %v", err)
|
||||
}
|
||||
counting := &countingDialogStore{DialogStore: base}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 101,
|
||||
}}
|
||||
photos := dialogProfilePhotos{peer.ID: {PhotoID: 1}}
|
||||
dialogs := NewService(counting).Configure(WithReadModelVersions(versions), WithPhotoProvider(photos))
|
||||
first, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer})
|
||||
if err != nil {
|
||||
t.Fatalf("first GetPeerDialogs: %v", err)
|
||||
}
|
||||
photos[peer.ID] = domain.ProfilePhotoRef{PhotoID: 2}
|
||||
second, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer})
|
||||
if err != nil {
|
||||
t.Fatalf("second GetPeerDialogs: %v", err)
|
||||
}
|
||||
if len(first.Users) != 1 || first.Users[0].PhotoID != 1 || len(second.Users) != 1 || second.Users[0].PhotoID != 2 {
|
||||
t.Fatalf("projected photos first/second = %+v/%+v, want 1/2", first.Users, second.Users)
|
||||
}
|
||||
if counting.listByPeersCalls != 1 {
|
||||
t.Fatalf("ListByPeers calls = %d, want one structural load", counting.listByPeersCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftReadErrorIsNotCachedAsNegative(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
base := memory.NewDialogStore()
|
||||
if err := base.SaveList(ctx, ownerID, domain.DialogList{Dialogs: []domain.Dialog{{Peer: peer}}}); err != nil {
|
||||
t.Fatalf("SaveList: %v", err)
|
||||
}
|
||||
if err := base.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Message: "recovered"}); err != nil {
|
||||
t.Fatalf("SaveDraft: %v", err)
|
||||
}
|
||||
counting := &countingDialogStore{DialogStore: base, listDraftsByPeersErr: errors.New("temporary draft read failure")}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 101,
|
||||
}}
|
||||
dialogs := NewService(counting).Configure(WithReadModelVersions(versions))
|
||||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err == nil {
|
||||
t.Fatal("first GetPeerDialogs error = nil, want draft read failure")
|
||||
}
|
||||
counting.listDraftsByPeersErr = nil
|
||||
got, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer})
|
||||
if err != nil {
|
||||
t.Fatalf("recovered GetPeerDialogs: %v", err)
|
||||
}
|
||||
if len(got.Dialogs) != 1 || got.Dialogs[0].Draft == nil || got.Dialogs[0].Draft.Message != "recovered" {
|
||||
t.Fatalf("recovered dialogs = %+v", got.Dialogs)
|
||||
}
|
||||
if counting.listDraftsByPeersCalls != 2 {
|
||||
t.Fatalf("ListDraftsByPeers calls = %d, want retry after error", counting.listDraftsByPeersCalls)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
208
internal/app/dialogs/shared_list_snapshot.go
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
package dialogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
|
||||
"telesrv/internal/app/readmodel"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const dialogListSnapshotMaterializeAttempts = 2
|
||||
|
||||
var errDialogListSnapshotGenerationChanged = errors.New("dialog list snapshot owner generation changed")
|
||||
|
||||
func (s *Service) loadDialogListSnapshot(
|
||||
ctx context.Context,
|
||||
key dialogListSnapshotKey,
|
||||
) (*dialogListSnapshot, error) {
|
||||
if s.sharedListCache == nil {
|
||||
list, err := s.loadDialogOwnerSnapshotHeaders(ctx, key.userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newDialogListSnapshot(list), nil
|
||||
}
|
||||
if s.versions == nil {
|
||||
return nil, errors.New("shared dialog list snapshot requires durable read-model versions")
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < dialogListSnapshotMaterializeAttempts; attempt++ {
|
||||
ownerHash, err := s.dialogOwnerHash(ctx, key.userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap, err := s.loadDialogListSnapshotAtOwnerHash(ctx, key, ownerHash)
|
||||
if errors.Is(err, errDialogListSnapshotGenerationChanged) {
|
||||
continue
|
||||
}
|
||||
return snap, err
|
||||
}
|
||||
return nil, errDialogListSnapshotGenerationChanged
|
||||
}
|
||||
|
||||
func (s *Service) loadDialogListSnapshotAtOwnerHash(
|
||||
ctx context.Context,
|
||||
key dialogListSnapshotKey,
|
||||
ownerHash int64,
|
||||
) (*dialogListSnapshot, error) {
|
||||
if s.sharedListCache == nil {
|
||||
list, err := s.loadDialogOwnerSnapshotHeaders(ctx, key.userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newDialogListSnapshot(list), nil
|
||||
}
|
||||
if s.versions == nil {
|
||||
return nil, errors.New("shared dialog list snapshot requires durable read-model versions")
|
||||
}
|
||||
if ownerHash == 0 {
|
||||
return nil, errors.New("dialog_owner read-model generation missing")
|
||||
}
|
||||
|
||||
sharedKey := sharedDialogListSnapshotKey(key, ownerHash)
|
||||
cached, found, err := s.sharedListCache.GetDialogListSnapshot(ctx, sharedKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
snap := dialogListSnapshotFromShared(cached)
|
||||
snap.ownerHash = ownerHash
|
||||
dependencyHash, err := s.dialogListSnapshotDependencyHash(ctx, ownerHash, snap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dependencyHash == cached.DependencyHash {
|
||||
return snap, nil
|
||||
}
|
||||
}
|
||||
|
||||
list, err := s.loadDialogOwnerSnapshotHeaders(ctx, key.userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap := newDialogListSnapshot(list)
|
||||
currentOwnerHash, err := s.dialogOwnerHash(ctx, key.userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentOwnerHash != ownerHash {
|
||||
return nil, errDialogListSnapshotGenerationChanged
|
||||
}
|
||||
dependencyHash, err := s.dialogListSnapshotDependencyHash(ctx, ownerHash, snap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap.ownerHash = ownerHash
|
||||
snap.dependencyHash = dependencyHash
|
||||
value := sharedDialogListSnapshotValue(snap, dependencyHash)
|
||||
if err := s.sharedListCache.PutDialogListSnapshot(ctx, sharedKey, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
func (s *Service) dialogOwnerHash(ctx context.Context, userID int64) (int64, error) {
|
||||
hash, found, err := s.versions.ReadModelHash(
|
||||
ctx, readmodel.ModelDialogOwner, userID, domain.PeerTypeUser, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !found || hash == 0 {
|
||||
return 0, errors.New("dialog_owner read-model generation missing")
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func (s *Service) dialogListSnapshotDependencyHash(
|
||||
ctx context.Context,
|
||||
ownerHash int64,
|
||||
snap *dialogListSnapshot,
|
||||
) (int64, error) {
|
||||
peers := dialogListSnapshotPeers(snap)
|
||||
keys := make([]store.ReadModelKey, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
if peer.Type == domain.PeerTypeChannel {
|
||||
keys = append(keys, store.ReadModelKey{
|
||||
Model: readmodel.ModelChannelBase, PeerType: peer.Type, PeerID: peer.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
hashes, err := s.versions.ReadModelHashes(ctx, keys)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
values := make([]int64, 0, len(keys)+1)
|
||||
values = append(values, ownerHash)
|
||||
for _, key := range keys {
|
||||
hash := hashes[key]
|
||||
if hash == 0 {
|
||||
return 0, errors.New("dialog snapshot dependency generation missing")
|
||||
}
|
||||
values = append(values, hash)
|
||||
}
|
||||
return readmodel.MixHashes(values...), nil
|
||||
}
|
||||
|
||||
func dialogListSnapshotPeers(snap *dialogListSnapshot) []domain.Peer {
|
||||
if snap == nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[domain.Peer]struct{}, len(snap.dialogs)+1)
|
||||
peers := make([]domain.Peer, 0, len(snap.dialogs)+1)
|
||||
appendPeer := func(peer domain.Peer) {
|
||||
if peer.Type == "" || peer.ID == 0 {
|
||||
return
|
||||
}
|
||||
if _, found := seen[peer]; found {
|
||||
return
|
||||
}
|
||||
seen[peer] = struct{}{}
|
||||
peers = append(peers, peer)
|
||||
}
|
||||
for _, dialog := range snap.dialogs {
|
||||
appendPeer(dialog.Peer)
|
||||
}
|
||||
if snap.archive != nil {
|
||||
appendPeer(snap.archive.TopPeer)
|
||||
}
|
||||
sort.Slice(peers, func(i, j int) bool {
|
||||
if peers[i].Type != peers[j].Type {
|
||||
return peers[i].Type < peers[j].Type
|
||||
}
|
||||
return peers[i].ID < peers[j].ID
|
||||
})
|
||||
return peers
|
||||
}
|
||||
|
||||
func sharedDialogListSnapshotKey(key dialogListSnapshotKey, ownerHash int64) store.DialogListSnapshotCacheKey {
|
||||
return store.DialogListSnapshotCacheKey{
|
||||
UserID: key.userID, OwnerHash: ownerHash,
|
||||
}
|
||||
}
|
||||
|
||||
func sharedDialogListSnapshotValue(snap *dialogListSnapshot, dependencyHash int64) store.DialogListSnapshotCacheValue {
|
||||
value := store.DialogListSnapshotCacheValue{DependencyHash: dependencyHash}
|
||||
if snap == nil {
|
||||
return value
|
||||
}
|
||||
value.Dialogs = cloneDialogSlice(snap.dialogs)
|
||||
value.Messages = cloneDialogMessages(snap.messages)
|
||||
value.Users = cloneDialogUsers(snap.users)
|
||||
value.State = snap.state
|
||||
value.ArchiveSummary = cloneDialogArchiveSummary(snap.archive)
|
||||
return value
|
||||
}
|
||||
|
||||
func dialogListSnapshotFromShared(value store.DialogListSnapshotCacheValue) *dialogListSnapshot {
|
||||
list := domain.DialogList{
|
||||
Dialogs: value.Dialogs, Messages: value.Messages, Users: value.Users,
|
||||
State: value.State, ArchiveSummary: value.ArchiveSummary,
|
||||
}
|
||||
snap := newDialogListSnapshot(list)
|
||||
snap.dependencyHash = value.DependencyHash
|
||||
return snap
|
||||
}
|
||||
445
internal/app/dialogs/shared_list_snapshot_test.go
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
package dialogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/app/readmodel"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type fakeSharedDialogListSnapshotCache struct {
|
||||
value store.DialogListSnapshotCacheValue
|
||||
found bool
|
||||
getErr error
|
||||
putErr error
|
||||
getCalls int
|
||||
putCalls int
|
||||
putKey store.DialogListSnapshotCacheKey
|
||||
putValue store.DialogListSnapshotCacheValue
|
||||
}
|
||||
|
||||
func (f *fakeSharedDialogListSnapshotCache) GetDialogListSnapshot(
|
||||
_ context.Context,
|
||||
_ store.DialogListSnapshotCacheKey,
|
||||
) (store.DialogListSnapshotCacheValue, bool, error) {
|
||||
f.getCalls++
|
||||
return f.value, f.found, f.getErr
|
||||
}
|
||||
|
||||
func (f *fakeSharedDialogListSnapshotCache) PutDialogListSnapshot(
|
||||
_ context.Context,
|
||||
key store.DialogListSnapshotCacheKey,
|
||||
value store.DialogListSnapshotCacheValue,
|
||||
) error {
|
||||
f.putCalls++
|
||||
f.putKey = key
|
||||
f.putValue = value
|
||||
return f.putErr
|
||||
}
|
||||
|
||||
func TestSharedDialogListSnapshotHitAvoidsAuthoritativeHeaderScan(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}: 11,
|
||||
}}
|
||||
shared := &fakeSharedDialogListSnapshotCache{
|
||||
found: true,
|
||||
value: store.DialogListSnapshotCacheValue{
|
||||
DependencyHash: readmodel.MixHashes(11),
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 7, TopMessageDate: 70}},
|
||||
},
|
||||
}
|
||||
authoritative := &snapshotDialogStore{DialogStore: memory.NewDialogStore()}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(shared),
|
||||
)
|
||||
|
||||
snap, err := service.loadDialogListSnapshot(context.Background(), dialogListSnapshotKey{userID: ownerID})
|
||||
if err != nil {
|
||||
t.Fatalf("load shared snapshot: %v", err)
|
||||
}
|
||||
if authoritative.snapshotCalls != 0 || shared.getCalls != 1 || shared.putCalls != 0 {
|
||||
t.Fatalf("calls header/get/put = %d/%d/%d, want 0/1/0",
|
||||
authoritative.snapshotCalls, shared.getCalls, shared.putCalls)
|
||||
}
|
||||
if snap == nil || len(snap.dialogs) != 1 || snap.dialogs[0].Peer != peer {
|
||||
t.Fatalf("snapshot = %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateDialogPeerIDsUsesVersionedSharedOwnerSnapshot(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
newer := domain.Peer{Type: domain.PeerTypeUser, ID: 1004}
|
||||
older := domain.Peer{Type: domain.PeerTypeUser, ID: 1003}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}: 41,
|
||||
}}
|
||||
shared := &fakeSharedDialogListSnapshotCache{found: true, value: store.DialogListSnapshotCacheValue{
|
||||
DependencyHash: readmodel.MixHashes(41),
|
||||
Dialogs: []domain.Dialog{
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID}, TopMessageDate: 999},
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 2001}, TopMessageDate: 998},
|
||||
{Peer: older, TopMessage: 9, TopMessageDate: 10},
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, TopMessage: 1, TopMessageDate: 20},
|
||||
{Peer: newer, TopMessage: 3, TopMessageDate: 20},
|
||||
},
|
||||
}}
|
||||
authoritative := &snapshotDialogStore{DialogStore: memory.NewDialogStore()}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(shared),
|
||||
)
|
||||
|
||||
ids, err := service.PrivateDialogPeerIDs(context.Background(), ownerID, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) != 2 || ids[0] != newer.ID || ids[1] != 1002 {
|
||||
t.Fatalf("private peer ids = %v, want [%d 1002]", ids, newer.ID)
|
||||
}
|
||||
if authoritative.privatePeerCalls != 0 || shared.getCalls != 1 {
|
||||
t.Fatalf("authoritative/shared calls = %d/%d, want 0/1", authoritative.privatePeerCalls, shared.getCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateDialogPeerIDsCacheMissUsesStableNarrowStoreRead(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
base := memory.NewDialogStore()
|
||||
if err := base.SaveList(ctx, ownerID, domain.DialogList{Dialogs: []domain.Dialog{{
|
||||
Peer: peer, TopMessage: 7, TopMessageDate: 70,
|
||||
}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}: 42,
|
||||
}}
|
||||
shared := &fakeSharedDialogListSnapshotCache{}
|
||||
authoritative := &snapshotDialogStore{DialogStore: base}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(shared),
|
||||
)
|
||||
|
||||
ids, err := service.PrivateDialogPeerIDs(ctx, ownerID, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) != 1 || ids[0] != peer.ID || authoritative.privatePeerCalls != 1 || shared.getCalls != 1 {
|
||||
t.Fatalf("ids/calls = %v/%d/%d, want [%d]/1/1", ids, authoritative.privatePeerCalls, shared.getCalls, peer.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedDialogListSnapshotHitServesMaterializedPageWithoutPeerHydration(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}: 12,
|
||||
}}
|
||||
shared := &fakeSharedDialogListSnapshotCache{
|
||||
found: true,
|
||||
value: store.DialogListSnapshotCacheValue{
|
||||
DependencyHash: readmodel.MixHashes(12),
|
||||
Dialogs: []domain.Dialog{{
|
||||
Peer: peer, TopMessage: 7, TopMessageDate: 70,
|
||||
Draft: &domain.DialogDraft{Peer: peer, Date: 71, Message: "materialized draft"},
|
||||
}},
|
||||
Messages: []domain.Message{{ID: 7, Peer: peer, From: peer, Date: 70, Body: "materialized"}},
|
||||
Users: []domain.User{{ID: peer.ID, FirstName: "cached"}},
|
||||
},
|
||||
}
|
||||
authoritative := &snapshotDialogStore{DialogStore: memory.NewDialogStore()}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(shared),
|
||||
)
|
||||
|
||||
page, err := service.GetDialogs(context.Background(), ownerID, domain.DialogFilter{Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("get materialized shared page: %v", err)
|
||||
}
|
||||
if authoritative.snapshotCalls != 0 || authoritative.listByPeersCalls != 0 || authoritative.listDraftsCalls != 0 {
|
||||
t.Fatalf("authoritative snapshot/peer/draft calls = %d/%d/%d, want 0/0/0",
|
||||
authoritative.snapshotCalls, authoritative.listByPeersCalls, authoritative.listDraftsCalls)
|
||||
}
|
||||
if len(page.Dialogs) != 1 || len(page.Messages) != 1 || page.Messages[0].Body != "materialized" ||
|
||||
len(page.Users) != 1 || page.Users[0].ID != peer.ID || page.Dialogs[0].Draft == nil ||
|
||||
page.Dialogs[0].Draft.Message != "materialized draft" {
|
||||
t.Fatalf("materialized page = dialogs:%+v messages:%+v users:%+v", page.Dialogs, page.Messages, page.Users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedDialogListSnapshotDependencyMismatchRebuildsAndPublishes(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}: 31,
|
||||
}}
|
||||
shared := &fakeSharedDialogListSnapshotCache{
|
||||
found: true,
|
||||
value: store.DialogListSnapshotCacheValue{
|
||||
DependencyHash: 999,
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 1}},
|
||||
},
|
||||
}
|
||||
authoritative := &snapshotDialogStore{DialogStore: memory.NewDialogStore(), list: domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 8, TopMessageDate: 80}}, Count: 1,
|
||||
}}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(shared),
|
||||
)
|
||||
|
||||
snap, err := service.loadDialogListSnapshot(context.Background(), dialogListSnapshotKey{userID: ownerID})
|
||||
if err != nil {
|
||||
t.Fatalf("rebuild shared snapshot: %v", err)
|
||||
}
|
||||
if authoritative.snapshotCalls != 1 || shared.putCalls != 1 {
|
||||
t.Fatalf("calls header/put = %d/%d, want 1/1", authoritative.snapshotCalls, shared.putCalls)
|
||||
}
|
||||
if shared.putKey.OwnerHash != 31 || shared.putValue.DependencyHash != readmodel.MixHashes(31) {
|
||||
t.Fatalf("published key/value = %+v/%+v", shared.putKey, shared.putValue)
|
||||
}
|
||||
if snap == nil || len(snap.dialogs) != 1 || snap.dialogs[0].TopMessage != 8 {
|
||||
t.Fatalf("rebuilt snapshot = %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedDialogListSnapshotValidatesSharedChannelGeneration(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 2001}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}: 61,
|
||||
{Model: readmodel.ModelChannelBase, PeerType: peer.Type, PeerID: peer.ID}: 71,
|
||||
}}
|
||||
shared := &fakeSharedDialogListSnapshotCache{
|
||||
found: true,
|
||||
value: store.DialogListSnapshotCacheValue{
|
||||
DependencyHash: readmodel.MixHashes(61, 70),
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 1}},
|
||||
},
|
||||
}
|
||||
authoritative := &snapshotDialogStore{DialogStore: memory.NewDialogStore(), list: domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 2}}, Count: 1,
|
||||
}}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(shared),
|
||||
)
|
||||
|
||||
_, err := service.loadDialogListSnapshot(context.Background(), dialogListSnapshotKey{userID: ownerID})
|
||||
if err != nil {
|
||||
t.Fatalf("rebuild after channel generation change: %v", err)
|
||||
}
|
||||
if authoritative.snapshotCalls != 1 || shared.putCalls != 1 ||
|
||||
shared.putValue.DependencyHash != readmodel.MixHashes(61, 71) {
|
||||
t.Fatalf("calls/header dependency = %d/%d/%d, want 1/1/%d",
|
||||
authoritative.snapshotCalls, shared.putCalls, shared.putValue.DependencyHash,
|
||||
readmodel.MixHashes(61, 71))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedDialogListSnapshotRedisErrorFailsClosed(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}: 51,
|
||||
}}
|
||||
shared := &fakeSharedDialogListSnapshotCache{getErr: errors.New("redis unavailable")}
|
||||
authoritative := &snapshotDialogStore{DialogStore: memory.NewDialogStore()}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(shared),
|
||||
)
|
||||
|
||||
_, err := service.loadDialogListSnapshot(context.Background(), dialogListSnapshotKey{userID: ownerID})
|
||||
if err == nil || authoritative.snapshotCalls != 0 || shared.putCalls != 0 {
|
||||
t.Fatalf("err=%v header_calls=%d put_calls=%d, want fail-closed before PostgreSQL scan",
|
||||
err, authoritative.snapshotCalls, shared.putCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDialogsL1RejectsOldOwnerGenerationBeforeHydration(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
ownerKey := store.ReadModelKey{
|
||||
Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID,
|
||||
PeerType: domain.PeerTypeUser, PeerID: ownerID,
|
||||
}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{ownerKey: 11}}
|
||||
shared := &fakeSharedDialogListSnapshotCache{}
|
||||
authoritative := &snapshotDialogStore{DialogStore: memory.NewDialogStore(), list: domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 7, TopMessageDate: 70}},
|
||||
Users: []domain.User{{ID: peer.ID}},
|
||||
Count: 1,
|
||||
}}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(shared),
|
||||
)
|
||||
filter := domain.DialogFilter{ExcludePinned: true, Limit: 100}
|
||||
first, err := service.GetDialogs(context.Background(), ownerID, filter)
|
||||
if err != nil || len(first.Dialogs) != 1 {
|
||||
t.Fatalf("first GetDialogs = dialogs:%d err:%v", len(first.Dialogs), err)
|
||||
}
|
||||
|
||||
authoritative.list = domain.DialogList{}
|
||||
versions.hashes[ownerKey] = 12
|
||||
second, err := service.GetDialogs(context.Background(), ownerID, filter)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs after owner generation advance: %v", err)
|
||||
}
|
||||
if len(second.Dialogs) != 0 || authoritative.snapshotCalls != 2 {
|
||||
t.Fatalf("second dialogs/snapshot calls = %d/%d, want 0/2", len(second.Dialogs), authoritative.snapshotCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDialogsRetriesWhenOwnerGenerationChangesDuringHydration(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
ownerKey := store.ReadModelKey{
|
||||
Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID,
|
||||
PeerType: domain.PeerTypeUser, PeerID: ownerID,
|
||||
}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{ownerKey: 21}}
|
||||
authoritative := &snapshotDialogStore{DialogStore: memory.NewDialogStore(), list: domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 7, TopMessageDate: 70}},
|
||||
Users: []domain.User{{ID: peer.ID}},
|
||||
Count: 1,
|
||||
}}
|
||||
authoritative.onListByPeers = func() {
|
||||
authoritative.list = domain.DialogList{}
|
||||
versions.hashes[ownerKey] = 22
|
||||
}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(&fakeSharedDialogListSnapshotCache{}),
|
||||
)
|
||||
|
||||
list, err := service.GetDialogs(context.Background(), ownerID, domain.DialogFilter{ExcludePinned: true, Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs across owner generation change: %v", err)
|
||||
}
|
||||
if len(list.Dialogs) != 0 || authoritative.snapshotCalls != 2 {
|
||||
t.Fatalf("dialogs/snapshot calls = %d/%d, want stable empty generation and 2 loads", len(list.Dialogs), authoritative.snapshotCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDialogsRetriesWhenDraftChangesDuringOwnerSnapshot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
ownerKey := store.ReadModelKey{
|
||||
Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID,
|
||||
PeerType: domain.PeerTypeUser, PeerID: ownerID,
|
||||
}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{ownerKey: 31}}
|
||||
base := memory.NewDialogStore()
|
||||
if err := base.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 1, Message: "old"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authoritative := &snapshotDialogStore{DialogStore: base, list: domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 7, TopMessageDate: 70}},
|
||||
Users: []domain.User{{ID: peer.ID}},
|
||||
Count: 1,
|
||||
}}
|
||||
authoritative.onListDrafts = func() {
|
||||
if err := base.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 2, Message: "new"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions.hashes[ownerKey] = 32
|
||||
}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(&fakeSharedDialogListSnapshotCache{}),
|
||||
)
|
||||
|
||||
list, err := service.GetDialogs(ctx, ownerID, domain.DialogFilter{ExcludePinned: true, Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs across draft generation change: %v", err)
|
||||
}
|
||||
if len(list.Dialogs) != 1 || list.Dialogs[0].Draft == nil || list.Dialogs[0].Draft.Message != "new" {
|
||||
t.Fatalf("stable draft snapshot = %+v", list.Dialogs)
|
||||
}
|
||||
if authoritative.snapshotCalls != 2 || authoritative.listDraftsCalls != 2 {
|
||||
t.Fatalf("snapshot/draft loads = %d/%d, want 2/2 after generation retry",
|
||||
authoritative.snapshotCalls, authoritative.listDraftsCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerBaseSnapshotDerivesBuiltInFolderVariantsOnce(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
mainPinned := domain.Peer{Type: domain.PeerTypeUser, ID: 2001}
|
||||
mainRegular := domain.Peer{Type: domain.PeerTypeUser, ID: 2002}
|
||||
archived := domain.Peer{Type: domain.PeerTypeUser, ID: 2003}
|
||||
ownerKey := store.ReadModelKey{
|
||||
Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID,
|
||||
PeerType: domain.PeerTypeUser, PeerID: ownerID,
|
||||
}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{ownerKey: 81}}
|
||||
authoritative := &snapshotDialogStore{DialogStore: memory.NewDialogStore(), list: domain.DialogList{
|
||||
Dialogs: []domain.Dialog{
|
||||
{Peer: mainPinned, FolderID: domain.DialogMainFolderID, TopMessage: 30, TopMessageDate: 300, Pinned: true, PinnedOrder: 1},
|
||||
{Peer: mainRegular, FolderID: domain.DialogMainFolderID, TopMessage: 20, TopMessageDate: 200},
|
||||
{Peer: archived, FolderID: domain.DialogArchiveFolderID, TopMessage: 10, TopMessageDate: 100},
|
||||
},
|
||||
Users: []domain.User{{ID: mainPinned.ID}, {ID: mainRegular.ID}, {ID: archived.ID}},
|
||||
}}
|
||||
shared := &fakeSharedDialogListSnapshotCache{}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(shared),
|
||||
)
|
||||
|
||||
main, err := service.GetDialogs(context.Background(), ownerID, domain.DialogFilter{Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
excludePinned, err := service.GetDialogs(context.Background(), ownerID, domain.DialogFilter{ExcludePinned: true, Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pinned, err := service.GetDialogs(context.Background(), ownerID, domain.DialogFilter{PinnedOnly: true, Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive, err := service.GetDialogs(context.Background(), ownerID, domain.DialogFilter{
|
||||
HasFolderID: true, FolderID: domain.DialogArchiveFolderID, Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicitMain, err := service.GetDialogs(context.Background(), ownerID, domain.DialogFilter{
|
||||
HasFolderID: true, FolderID: domain.DialogMainFolderID, Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if authoritative.snapshotCalls != 1 || authoritative.listByPeersCalls != 1 || shared.getCalls != 1 || shared.putCalls != 1 {
|
||||
t.Fatalf("base/peer/get/put calls = %d/%d/%d/%d, want 1/1/1/1",
|
||||
authoritative.snapshotCalls, authoritative.listByPeersCalls, shared.getCalls, shared.putCalls)
|
||||
}
|
||||
if len(main.Dialogs) != 2 || main.Dialogs[0].Peer != mainPinned || main.Dialogs[1].Peer != mainRegular || main.ArchiveSummary == nil || main.ArchiveSummary.TopPeer != archived {
|
||||
t.Fatalf("main variant = %+v", main)
|
||||
}
|
||||
if len(excludePinned.Dialogs) != 1 || excludePinned.Dialogs[0].Peer != mainRegular || excludePinned.ArchiveSummary != nil {
|
||||
t.Fatalf("exclude-pinned variant = %+v", excludePinned)
|
||||
}
|
||||
if len(pinned.Dialogs) != 1 || pinned.Dialogs[0].Peer != mainPinned || pinned.ArchiveSummary == nil {
|
||||
t.Fatalf("pinned variant = %+v", pinned)
|
||||
}
|
||||
if len(archive.Dialogs) != 1 || archive.Dialogs[0].Peer != archived || archive.ArchiveSummary != nil {
|
||||
t.Fatalf("archive variant = %+v", archive)
|
||||
}
|
||||
if explicitMain.Hash != main.Hash || main.Hash == 0 || excludePinned.Hash == main.Hash || pinned.Hash == main.Hash || archive.Hash == main.Hash {
|
||||
t.Fatalf("variant hashes main=%d explicit=%d exclude=%d pinned=%d archive=%d",
|
||||
main.Hash, explicitMain.Hash, excludePinned.Hash, pinned.Hash, archive.Hash)
|
||||
}
|
||||
}
|
||||
|
|
@ -69,7 +69,7 @@ func TestGetFileCachesMetadataAndSmallBlobBytes(t *testing.T) {
|
|||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
media := newFakeMediaStore()
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:42", ObjectKey: objectKey, Size: 10, MimeType: "application/octet-stream"}); err != nil {
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:42", Backend: domain.MediaBackendLocalFS, ObjectKey: objectKey, Size: 10, MimeType: "application/octet-stream"}); err != nil {
|
||||
t.Fatalf("put blob: %v", err)
|
||||
}
|
||||
counting := &countingMediaStore{fakeMediaStore: media}
|
||||
|
|
@ -121,7 +121,7 @@ func TestGetFileLogsCacheHitMiss(t *testing.T) {
|
|||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
media := newFakeMediaStore()
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:log", ObjectKey: objectKey, Size: 10, MimeType: "application/octet-stream"}); err != nil {
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:log", Backend: domain.MediaBackendLocalFS, ObjectKey: objectKey, Size: 10, MimeType: "application/octet-stream"}); err != nil {
|
||||
t.Fatalf("put blob: %v", err)
|
||||
}
|
||||
blobs := &countingBlobBackend{BlobBackend: local}
|
||||
|
|
@ -176,6 +176,7 @@ func TestGetFileDoesNotByteCacheLargeBlob(t *testing.T) {
|
|||
media := newFakeMediaStore()
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: "doc:large",
|
||||
Backend: domain.MediaBackendLocalFS,
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(content)),
|
||||
MimeType: "application/octet-stream",
|
||||
|
|
@ -199,6 +200,33 @@ func TestGetFileDoesNotByteCacheLargeBlob(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetFileRejectsStoredBackendMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
local, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("local fs: %v", err)
|
||||
}
|
||||
objectKey, err := local.Put(ctx, []byte("must-not-fallback"))
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
media := newFakeMediaStore()
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: "doc:mismatch",
|
||||
Backend: domain.MediaBackendS3,
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len("must-not-fallback")),
|
||||
}); err != nil {
|
||||
t.Fatalf("put blob: %v", err)
|
||||
}
|
||||
svc := NewService(media, local, 2)
|
||||
if _, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{
|
||||
LocationKey: "doc:mismatch", Limit: 128 << 10,
|
||||
}); err == nil || found {
|
||||
t.Fatalf("mismatched backend found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarmCachesPreloadsStickerSetAndSmallBlobs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
local, err := NewLocalFS(t.TempDir())
|
||||
|
|
@ -227,10 +255,10 @@ func TestWarmCachesPreloadsStickerSetAndSmallBlobs(t *testing.T) {
|
|||
if err := media.PutDocument(ctx, doc); err != nil {
|
||||
t.Fatalf("put doc: %v", err)
|
||||
}
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100", ObjectKey: mainKey, Size: 7, MimeType: doc.MimeType}); err != nil {
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100", Backend: domain.MediaBackendLocalFS, ObjectKey: mainKey, Size: 7, MimeType: doc.MimeType}); err != nil {
|
||||
t.Fatalf("put main blob: %v", err)
|
||||
}
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", ObjectKey: thumbKey, Size: 5, MimeType: "image/jpeg"}); err != nil {
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", Backend: domain.MediaBackendLocalFS, ObjectKey: thumbKey, Size: 5, MimeType: "image/jpeg"}); err != nil {
|
||||
t.Fatalf("put thumb blob: %v", err)
|
||||
}
|
||||
set := domain.StickerSet{
|
||||
|
|
|
|||
|
|
@ -549,7 +549,7 @@ func TestCreateAvatarVideoMarkupFallsBackToVideoFirstFrame(t *testing.T) {
|
|||
assertAvatarImageSize(t, svc, photo.ID, "a", 160, 160, "image/jpeg")
|
||||
}
|
||||
|
||||
func TestCreateAvatarVideoMarkupRejectsSyntheticPreviewAndFallsBackToVideo(t *testing.T) {
|
||||
func TestCreateAvatarVideoMarkupRejectsDegeneratePreviewAndFallsBackToVideo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
media := newFakeMediaStore()
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
|
|
@ -562,7 +562,7 @@ func TestCreateAvatarVideoMarkupRejectsSyntheticPreviewAndFallsBackToVideo(t *te
|
|||
MimeType: "application/x-tgsticker",
|
||||
Thumbs: []domain.PhotoSize{{
|
||||
Kind: domain.PhotoSizeKindCached, Type: "m", W: 1, H: 1,
|
||||
Bytes: append([]byte(nil), seedSyntheticTGStickerPreviewThumbPNG...),
|
||||
Bytes: testJPEG(t, 1, 1),
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("PutDocument: %v", err)
|
||||
|
|
@ -581,14 +581,14 @@ func TestCreateAvatarVideoMarkupRejectsSyntheticPreviewAndFallsBackToVideo(t *te
|
|||
t.Fatalf("CreateAvatarVideoMarkupFromUpload: %v", err)
|
||||
}
|
||||
if thumbnailer.calls != 1 {
|
||||
t.Fatalf("thumbnailer calls = %d, want synthetic preview rejected and video fallback used", thumbnailer.calls)
|
||||
t.Fatalf("thumbnailer calls = %d, want degenerate preview rejected and video fallback used", thumbnailer.calls)
|
||||
}
|
||||
chunk, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: fmt.Sprintf("photo:%d:c", photo.ID), Limit: 1 << 20})
|
||||
if err != nil || !found {
|
||||
t.Fatalf("avatar c blob found=%v err=%v", found, err)
|
||||
}
|
||||
if !bytes.Equal(chunk.Bytes, frame) {
|
||||
t.Fatal("avatar still did not use extracted video frame after rejecting synthetic preview")
|
||||
t.Fatal("avatar still did not use extracted video frame after rejecting degenerate preview")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
|
@ -485,6 +481,9 @@ func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDi
|
|||
thumbs = append(thumbs, ps)
|
||||
}
|
||||
doc.Thumbs = thumbs
|
||||
if data, ok := seedBundledDocumentPreview(doc.ID); ok {
|
||||
doc.Thumbs = appendSeedBundledDocumentPreview(doc.Thumbs, data)
|
||||
}
|
||||
if existingFound {
|
||||
doc.Thumbs = mergeSeedDocumentThumbs(existing.Thumbs, doc.Thumbs)
|
||||
}
|
||||
|
|
@ -492,10 +491,6 @@ func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDi
|
|||
return domain.Document{}, err
|
||||
}
|
||||
|
||||
if err := s.ensureTGStickerPreviewThumb(ctx, &doc, stats); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
|
||||
if err := s.media.PutDocument(ctx, doc); err != nil {
|
||||
return domain.Document{}, err
|
||||
}
|
||||
|
|
@ -503,6 +498,27 @@ func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDi
|
|||
return doc, nil
|
||||
}
|
||||
|
||||
func appendSeedBundledDocumentPreview(thumbs []domain.PhotoSize, data []byte) []domain.PhotoSize {
|
||||
for _, thumb := range thumbs {
|
||||
if thumb.Type == seedBundledDocumentThumbType && seedPhotoSizePreviewTier(thumb) >= 4 {
|
||||
return thumbs
|
||||
}
|
||||
}
|
||||
out := thumbs[:0]
|
||||
for _, thumb := range thumbs {
|
||||
if thumb.Type != seedBundledDocumentThumbType {
|
||||
out = append(out, thumb)
|
||||
}
|
||||
}
|
||||
return append(out, domain.PhotoSize{
|
||||
Kind: domain.PhotoSizeKindCached,
|
||||
Type: seedBundledDocumentThumbType,
|
||||
W: 128,
|
||||
H: 128,
|
||||
Bytes: append([]byte(nil), data...),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) prewarmSmallBlob(objectKey string, data []byte) {
|
||||
if len(data) > 0 && len(data) <= blobBytesCacheMaxEntryBytes {
|
||||
s.byteCache.put(objectKey, data)
|
||||
|
|
@ -520,10 +536,8 @@ var seedTrailingDigits = regexp.MustCompile(`(\d{6,})`)
|
|||
var seedThumbMarker = regexp.MustCompile(`_thumb\d+_`)
|
||||
|
||||
const seedInlineCachedDocumentThumbMaxBytes = 32 * 1024
|
||||
const seedSyntheticDocumentThumbType = "m"
|
||||
|
||||
var seedThumbType = regexp.MustCompile(`PhotoSize_type([a-z])`)
|
||||
var seedSyntheticTGStickerPreviewThumbPNG = makeSeedSyntheticTGStickerPreviewThumbPNG()
|
||||
|
||||
func scanSeedDir(dir string) (seedDirIndex, error) {
|
||||
idx := seedDirIndex{main: map[int64]string{}, thumb: map[int64]map[string]string{}}
|
||||
|
|
@ -758,23 +772,7 @@ func mergeSeedDocumentThumbs(existing, incoming []domain.PhotoSize) []domain.Pho
|
|||
out = append(out, thumb)
|
||||
}
|
||||
|
||||
hasRealPreview := false
|
||||
for _, thumb := range out {
|
||||
if !seedSyntheticTGStickerPreviewThumb(thumb) && seedPhotoSizePreviewTier(thumb) > 1 {
|
||||
hasRealPreview = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRealPreview {
|
||||
return out
|
||||
}
|
||||
filtered := out[:0]
|
||||
for _, thumb := range out {
|
||||
if !seedSyntheticTGStickerPreviewThumb(thumb) {
|
||||
filtered = append(filtered, thumb)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
return out
|
||||
}
|
||||
|
||||
func seedDocumentThumbByType(thumbs []domain.PhotoSize, typ string) (domain.PhotoSize, bool) {
|
||||
|
|
@ -800,9 +798,6 @@ func seedPhotoSizeBetter(a, b domain.PhotoSize) bool {
|
|||
}
|
||||
|
||||
func seedPhotoSizePreviewTier(thumb domain.PhotoSize) int {
|
||||
if seedSyntheticTGStickerPreviewThumb(thumb) {
|
||||
return 0
|
||||
}
|
||||
switch thumb.Kind {
|
||||
case domain.PhotoSizeKindCached:
|
||||
if len(thumb.Bytes) > 0 && thumb.W > 0 && thumb.H > 0 {
|
||||
|
|
@ -820,13 +815,6 @@ func seedPhotoSizePreviewTier(thumb domain.PhotoSize) int {
|
|||
return 1
|
||||
}
|
||||
|
||||
func seedSyntheticTGStickerPreviewThumb(thumb domain.PhotoSize) bool {
|
||||
return thumb.Kind == domain.PhotoSizeKindCached &&
|
||||
thumb.Type == seedSyntheticDocumentThumbType &&
|
||||
thumb.W == 1 && thumb.H == 1 &&
|
||||
bytes.Equal(thumb.Bytes, seedSyntheticTGStickerPreviewThumbPNG)
|
||||
}
|
||||
|
||||
// ensureSeedCachedThumbBlobs keeps the RPC conversion invariant: document cached
|
||||
// previews are exposed as downloadable PhotoSize entries, so every advertised type
|
||||
// must have a matching blob even when the source JSON carried the bytes inline.
|
||||
|
|
@ -866,43 +854,6 @@ func (s *Service) ensureSeedCachedThumbBlobs(ctx context.Context, doc domain.Doc
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureTGStickerPreviewThumb(ctx context.Context, doc *domain.Document, stats *SeedStats) error {
|
||||
if !seedDocumentNeedsSyntheticTGStickerPreviewThumb(*doc) {
|
||||
return nil
|
||||
}
|
||||
if s.blobs == nil {
|
||||
return fmt.Errorf("blob backend not configured for synthetic sticker preview thumb")
|
||||
}
|
||||
data := seedSyntheticTGStickerPreviewThumbPNG
|
||||
objectKey, err := s.blobs.Put(ctx, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
|
||||
LocationKey: fmt.Sprintf("doc:%d:%s", doc.ID, seedSyntheticDocumentThumbType),
|
||||
Backend: domain.MediaBackend(s.blobs.Name()),
|
||||
ObjectKey: objectKey,
|
||||
Size: int64(len(data)),
|
||||
MimeType: "image/png",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
doc.Thumbs = append(doc.Thumbs, domain.PhotoSize{
|
||||
Kind: domain.PhotoSizeKindCached,
|
||||
Type: seedSyntheticDocumentThumbType,
|
||||
W: 1,
|
||||
H: 1,
|
||||
Bytes: append([]byte(nil), data...),
|
||||
})
|
||||
s.prewarmSmallBlob(objectKey, data)
|
||||
stats.Blobs++
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedDocumentNeedsSyntheticTGStickerPreviewThumb(doc domain.Document) bool {
|
||||
return doc.MimeType == "application/x-tgsticker" && len(doc.Thumbs) == 0
|
||||
}
|
||||
|
||||
func seedDocumentHasAttribute(attrs []domain.DocumentAttribute, kind domain.DocumentAttributeKind) bool {
|
||||
for _, attr := range attrs {
|
||||
if attr.Kind == kind {
|
||||
|
|
@ -912,14 +863,6 @@ func seedDocumentHasAttribute(attrs []domain.DocumentAttribute, kind domain.Docu
|
|||
return false
|
||||
}
|
||||
|
||||
func makeSeedSyntheticTGStickerPreviewThumbPNG() []byte {
|
||||
var buf bytes.Buffer
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
img.Set(0, 0, color.NRGBA{})
|
||||
_ = png.Encode(&buf, img)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func seedThumbMimeType(data []byte) string {
|
||||
switch {
|
||||
case len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' &&
|
||||
|
|
@ -980,9 +923,6 @@ func (s *Service) documentsNeedSeedRepair(ctx context.Context, ids []int64) (boo
|
|||
return false, err
|
||||
}
|
||||
for _, doc := range docs {
|
||||
if seedDocumentNeedsSyntheticTGStickerPreviewThumb(doc) {
|
||||
return true, nil
|
||||
}
|
||||
for _, thumb := range doc.Thumbs {
|
||||
if thumb.Kind == domain.PhotoSizeKindDefault && thumb.Size > 0 && thumb.Size <= seedInlineCachedDocumentThumbMaxBytes {
|
||||
return true, nil
|
||||
|
|
|
|||
|
|
@ -106,13 +106,12 @@ func seedDocumentJSONLocationKeys(dj seedDocumentJSON, index seedDirIndex) []str
|
|||
keys = append(keys, fmt.Sprintf("doc:%d:%s", dj.ID, ps.Type))
|
||||
}
|
||||
}
|
||||
if _, ok := seedBundledDocumentPreview(dj.ID); ok {
|
||||
keys = append(keys, fmt.Sprintf("doc:%d:%s", dj.ID, seedBundledDocumentThumbType))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func seedDocumentJSONNeedsSyntheticTGStickerPreviewThumb(dj seedDocumentJSON) bool {
|
||||
return dj.MimeType == "application/x-tgsticker" && len(dj.Thumbs) == 0
|
||||
}
|
||||
|
||||
func (s *Service) seedDocumentJSONsReady(ctx context.Context, docs []seedDocumentJSON, index seedDirIndex) (bool, error) {
|
||||
expected := make(map[int64]seedDocumentJSON, len(docs))
|
||||
ids := make([]int64, 0, len(docs))
|
||||
|
|
@ -152,27 +151,11 @@ func (s *Service) seedDocumentJSONsReady(ctx context.Context, docs []seedDocumen
|
|||
if doc.DCID != s.dc || doc.MimeType != dj.MimeType || doc.Size != dj.Size {
|
||||
return false, nil
|
||||
}
|
||||
// A catalog without its own thumbnail may share this document with a richer
|
||||
// catalog. Readiness follows the preview that is actually stored instead of
|
||||
// demanding the synthetic "m" key and repeatedly downgrading that richer
|
||||
// document on every import.
|
||||
if seedDocumentJSONNeedsSyntheticTGStickerPreviewThumb(dj) {
|
||||
if len(doc.Thumbs) == 0 {
|
||||
if _, bundled := seedBundledDocumentPreview(dj.ID); bundled {
|
||||
thumb, ok := seedDocumentThumbByType(doc.Thumbs, seedBundledDocumentThumbType)
|
||||
if !ok || seedPhotoSizePreviewTier(thumb) < 4 {
|
||||
return false, nil
|
||||
}
|
||||
for _, thumb := range doc.Thumbs {
|
||||
switch thumb.Kind {
|
||||
case domain.PhotoSizeKindDefault, domain.PhotoSizeKindProgressive, domain.PhotoSizeKindCached:
|
||||
if thumb.Type == "" {
|
||||
return false, nil
|
||||
}
|
||||
key := fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type)
|
||||
if _, seen := seenLocationKeys[key]; !seen {
|
||||
seenLocationKeys[key] = struct{}{}
|
||||
locationKeys = append(locationKeys, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
delete(expected, doc.ID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
|
@ -463,8 +464,8 @@ func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {
|
|||
svc := NewService(media, blobs, 2)
|
||||
if stats, err := svc.SeedMedia(context.Background(), seedDir, 0); err != nil {
|
||||
t.Fatalf("initial seed: %v", err)
|
||||
} else if stats.Reactions != 1 || stats.Blobs != 3 {
|
||||
t.Fatalf("initial stats = %+v, want one reaction and three blobs", stats)
|
||||
} else if stats.Reactions != 1 || stats.Blobs != 2 {
|
||||
t.Fatalf("initial stats = %+v, want one reaction and two document blobs", stats)
|
||||
}
|
||||
chunk, ok, err := svc.GetFile(context.Background(), domain.FileDownloadRequest{LocationKey: "doc:2222222", Offset: 0, Limit: 4})
|
||||
if err != nil || !ok {
|
||||
|
|
@ -486,7 +487,7 @@ func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {
|
|||
t.Fatalf("repair seed: %v", err)
|
||||
}
|
||||
if stats.Reactions != 1 || stats.Blobs != 2 || stats.Skipped {
|
||||
t.Fatalf("repair stats = %+v, want two missing/revalidated main blobs without rewriting intact preview", stats)
|
||||
t.Fatalf("repair stats = %+v, want two revalidated main document blobs", stats)
|
||||
}
|
||||
if _, ok, _ := media.GetFileBlob(context.Background(), "doc:2222222"); !ok {
|
||||
t.Fatal("missing reaction blob was not repaired")
|
||||
|
|
@ -496,10 +497,10 @@ func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSeedCustomEmojiTGSWithoutThumbGetsSyntheticPreview(t *testing.T) {
|
||||
func TestSeedStatusPackTGSWithoutExportedThumbUsesBundledPreview(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
seedDir := t.TempDir()
|
||||
const sourceID int64 = 4444444
|
||||
const sourceID int64 = 5247133031235329609
|
||||
writeStatusPackWithoutThumbSeed(t, seedDir, sourceID, 17)
|
||||
|
||||
media := newFakeMediaStore()
|
||||
|
|
@ -513,7 +514,7 @@ func TestSeedCustomEmojiTGSWithoutThumbGetsSyntheticPreview(t *testing.T) {
|
|||
t.Fatalf("seed media: %v", err)
|
||||
}
|
||||
if stats.StickerSets != 1 || stats.Documents != 1 || stats.Blobs != 2 || stats.Skipped {
|
||||
t.Fatalf("stats = %+v, want one set, one doc, main blob plus synthetic preview", stats)
|
||||
t.Fatalf("stats = %+v, want one set, one doc, main blob plus bundled preview", stats)
|
||||
}
|
||||
|
||||
set, ok, err := media.GetStickerSetByShortName(ctx, "StatusPack")
|
||||
|
|
@ -529,21 +530,52 @@ func TestSeedCustomEmojiTGSWithoutThumbGetsSyntheticPreview(t *testing.T) {
|
|||
}
|
||||
thumb, ok := findCachedThumb(doc.Thumbs)
|
||||
if !ok {
|
||||
t.Fatalf("document thumbs = %+v, want synthetic cached preview", doc.Thumbs)
|
||||
t.Fatalf("document thumbs = %+v, want bundled cached preview", doc.Thumbs)
|
||||
}
|
||||
if thumb.Type != seedSyntheticDocumentThumbType || thumb.W != 1 || thumb.H != 1 || len(thumb.Bytes) == 0 {
|
||||
t.Fatalf("synthetic thumb = %+v, want 1x1 cached %q thumb", thumb, seedSyntheticDocumentThumbType)
|
||||
want, ok := seedBundledDocumentPreview(sourceID)
|
||||
if !ok {
|
||||
t.Fatal("bundled StatusPack preview missing")
|
||||
}
|
||||
if thumb.Type != seedBundledDocumentThumbType || thumb.W != 128 || thumb.H != 128 || !bytes.Equal(thumb.Bytes, want) {
|
||||
t.Fatalf("bundled thumb = %+v, want visible 128x128 cached %q preview", thumb, seedBundledDocumentThumbType)
|
||||
}
|
||||
blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("synthetic thumb blob ok=%v err=%v", ok, err)
|
||||
t.Fatalf("bundled thumb blob ok=%v err=%v", ok, err)
|
||||
}
|
||||
if blob.MimeType != "image/png" {
|
||||
t.Fatalf("synthetic thumb blob mime = %q, want image/png", blob.MimeType)
|
||||
t.Fatalf("bundled thumb blob mime = %q, want image/png", blob.MimeType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedMediaRepairsCustomEmojiTGSWithoutThumb(t *testing.T) {
|
||||
func TestBundledStatusPackPreviewsAreVisibleTransparentPNGs(t *testing.T) {
|
||||
if len(seedBundledDocumentPreviews) != 11 {
|
||||
t.Fatalf("bundled StatusPack previews = %d, want 11", len(seedBundledDocumentPreviews))
|
||||
}
|
||||
for documentID, data := range seedBundledDocumentPreviews {
|
||||
img, err := png.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("decode bundled preview %d: %v", documentID, err)
|
||||
}
|
||||
if bounds := img.Bounds(); bounds.Dx() != 128 || bounds.Dy() != 128 {
|
||||
t.Fatalf("bundled preview %d bounds = %v, want 128x128", documentID, bounds)
|
||||
}
|
||||
visible := false
|
||||
transparent := false
|
||||
for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ {
|
||||
for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ {
|
||||
_, _, _, alpha := img.At(x, y).RGBA()
|
||||
visible = visible || alpha != 0
|
||||
transparent = transparent || alpha != 0xffff
|
||||
}
|
||||
}
|
||||
if !visible || !transparent {
|
||||
t.Fatalf("bundled preview %d visible=%v transparent=%v", documentID, visible, transparent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedMediaDoesNotInventPreviewForUnknownTGS(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
seedDir := t.TempDir()
|
||||
const sourceID int64 = 5555555
|
||||
|
|
@ -586,15 +618,15 @@ func TestSeedMediaRepairsCustomEmojiTGSWithoutThumb(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("repair seed: %v", err)
|
||||
}
|
||||
if stats.StickerSets != 1 || stats.Documents != 1 || stats.Blobs != 2 || stats.Skipped {
|
||||
t.Fatalf("repair stats = %+v, want forced reimport", stats)
|
||||
if stats.StickerSets != 1 || stats.Documents != 1 || stats.Blobs != 1 || stats.Skipped {
|
||||
t.Fatalf("reimport stats = %+v, want main document blob only", stats)
|
||||
}
|
||||
doc, ok, err := media.GetDocument(ctx, sourceID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("repaired document ok=%v err=%v", ok, err)
|
||||
}
|
||||
if _, ok := findCachedThumb(doc.Thumbs); !ok {
|
||||
t.Fatalf("repaired document thumbs = %+v, want synthetic cached preview", doc.Thumbs)
|
||||
if len(doc.Thumbs) != 0 {
|
||||
t.Fatalf("document thumbs = %+v, want no invented preview for unknown TGS", doc.Thumbs)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -614,8 +646,8 @@ func TestSeedMediaSkipsUnchangedEffectsDocuments(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("first seed: %v", err)
|
||||
}
|
||||
if first.Effects != 1 || first.Documents != 1 || first.Blobs != 2 {
|
||||
t.Fatalf("first stats = %+v, want one imported effect document with main plus synthetic preview blobs", first)
|
||||
if first.Effects != 1 || first.Documents != 1 || first.Blobs != 1 {
|
||||
t.Fatalf("first stats = %+v, want one imported effect document with its main blob", first)
|
||||
}
|
||||
|
||||
second, err := svc.SeedMedia(ctx, seedDir, 0)
|
||||
|
|
@ -665,7 +697,7 @@ func TestSeedEffectsDoesNotDowngradeSharedStickerPreview(t *testing.T) {
|
|||
if !ok {
|
||||
t.Fatalf("shared document thumbs = %+v, want real cached preview", doc.Thumbs)
|
||||
}
|
||||
if thumb.W != 128 || thumb.H != 128 || !bytes.Equal(thumb.Bytes, realThumb) || seedSyntheticTGStickerPreviewThumb(thumb) {
|
||||
if thumb.W != 128 || thumb.H != 128 || !bytes.Equal(thumb.Bytes, realThumb) {
|
||||
t.Fatalf("shared preview = %+v, want original 128x128 catalog thumbnail", thumb)
|
||||
}
|
||||
blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d:m", sourceID))
|
||||
|
|
@ -692,55 +724,6 @@ func TestSeedEffectsDoesNotDowngradeSharedStickerPreview(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSeedMediaMigratesSyntheticStickerPreviewToExportedThumbnail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
seedDir := t.TempDir()
|
||||
const sourceID int64 = 8888888
|
||||
realThumb := writeStatusPackWithThumbSeed(t, seedDir, sourceID, 31)
|
||||
|
||||
media := newFakeMediaStore()
|
||||
if err := media.PutDocument(ctx, domain.Document{
|
||||
ID: sourceID,
|
||||
MimeType: "application/x-tgsticker",
|
||||
Thumbs: []domain.PhotoSize{{
|
||||
Kind: domain.PhotoSizeKindCached, Type: seedSyntheticDocumentThumbType,
|
||||
W: 1, H: 1, Bytes: append([]byte(nil), seedSyntheticTGStickerPreviewThumbPNG...),
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("put stale document: %v", err)
|
||||
}
|
||||
if err := media.PutStickerSet(ctx, domain.StickerSet{
|
||||
ID: 773947703670341676, AccessHash: 1, ShortName: "StatusPack", Title: "Status Pack",
|
||||
Hash: 31, Kind: domain.StickerSetKindEmoji, Emojis: true, DocumentIDs: []int64{sourceID},
|
||||
}); err != nil {
|
||||
t.Fatalf("put stale sticker set: %v", err)
|
||||
}
|
||||
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("local fs: %v", err)
|
||||
}
|
||||
svc := NewService(media, blobs, 2)
|
||||
stats, err := svc.SeedMedia(ctx, seedDir, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("migration seed: %v", err)
|
||||
}
|
||||
if stats.StickerSets != 1 || stats.Documents != 1 {
|
||||
t.Fatalf("migration stats = %+v, want forced sticker document rebuild", stats)
|
||||
}
|
||||
doc, ok, err := media.GetDocument(ctx, sourceID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("migrated document ok=%v err=%v", ok, err)
|
||||
}
|
||||
thumb, ok := findCachedThumb(doc.Thumbs)
|
||||
if !ok || thumb.W != 128 || thumb.H != 128 || !bytes.Equal(thumb.Bytes, realThumb) {
|
||||
t.Fatalf("migrated thumbs = %+v, want exported 128x128 preview", doc.Thumbs)
|
||||
}
|
||||
if state, ok, err := media.GetSeedState(ctx, seedStickerPreviewStateKey); err != nil || !ok || state == "" {
|
||||
t.Fatalf("preview migration state = %q ok=%v err=%v", state, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedMediaFromRealExport(t *testing.T) {
|
||||
seedDir := os.Getenv("TELESRV_REAL_STICKER_SEED_DIR")
|
||||
if seedDir == "" {
|
||||
|
|
@ -841,7 +824,7 @@ func TestSeedMediaFromRealExport(t *testing.T) {
|
|||
t.Fatalf("sample sticker thumb mime = %q, want %q", blob.MimeType, want)
|
||||
}
|
||||
if !hasPathThumb(doc.Thumbs) {
|
||||
t.Logf("sample sticker document has no exported PhotoPathSize placeholder; synthetic cached preview is present: %+v", doc.Thumbs)
|
||||
t.Logf("sample sticker document has no exported PhotoPathSize placeholder; cached preview is present: %+v", doc.Thumbs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
46
internal/app/files/statuspack_previews.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const seedBundledDocumentThumbType = "m"
|
||||
|
||||
// StatusPack is exported without document thumbnails, while Android uses a
|
||||
// non-empty thumbs vector to recognize application/x-tgsticker documents as
|
||||
// animated custom emoji. Keep real, visible first-frame previews with the
|
||||
// server's default media assets instead of inventing transparent metadata.
|
||||
//
|
||||
//go:embed statuspack_previews/*.png
|
||||
var statusPackPreviewFS embed.FS
|
||||
|
||||
var seedBundledDocumentPreviews = map[int64][]byte{
|
||||
5244508282231465075: mustReadStatusPackPreview(5244508282231465075),
|
||||
5246743378917334735: mustReadStatusPackPreview(5246743378917334735),
|
||||
5246772116543512028: mustReadStatusPackPreview(5246772116543512028),
|
||||
5246828303305678732: mustReadStatusPackPreview(5246828303305678732),
|
||||
5246842176050046092: mustReadStatusPackPreview(5246842176050046092),
|
||||
5246960163096632543: mustReadStatusPackPreview(5246960163096632543),
|
||||
5247100325059370738: mustReadStatusPackPreview(5247100325059370738),
|
||||
5247133031235329609: mustReadStatusPackPreview(5247133031235329609),
|
||||
5247176827016847212: mustReadStatusPackPreview(5247176827016847212),
|
||||
5247209275494769660: mustReadStatusPackPreview(5247209275494769660),
|
||||
5249273776079640466: mustReadStatusPackPreview(5249273776079640466),
|
||||
}
|
||||
|
||||
func mustReadStatusPackPreview(documentID int64) []byte {
|
||||
data, err := statusPackPreviewFS.ReadFile(fmt.Sprintf("statuspack_previews/%d.png", documentID))
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("read bundled StatusPack preview %d: %v", documentID, err))
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func seedBundledDocumentPreview(documentID int64) ([]byte, bool) {
|
||||
data, ok := seedBundledDocumentPreviews[documentID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return append([]byte(nil), data...), true
|
||||
}
|
||||
BIN
internal/app/files/statuspack_previews/5244508282231465075.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
internal/app/files/statuspack_previews/5246743378917334735.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
internal/app/files/statuspack_previews/5246772116543512028.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
internal/app/files/statuspack_previews/5246828303305678732.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
internal/app/files/statuspack_previews/5246842176050046092.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
internal/app/files/statuspack_previews/5246960163096632543.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
internal/app/files/statuspack_previews/5247100325059370738.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
internal/app/files/statuspack_previews/5247133031235329609.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
internal/app/files/statuspack_previews/5247176827016847212.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
internal/app/files/statuspack_previews/5247209275494769660.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
internal/app/files/statuspack_previews/5249273776079640466.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
|
|
@ -94,6 +94,47 @@ func isWebPData(data []byte) bool {
|
|||
return len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP"
|
||||
}
|
||||
|
||||
// ValidateAdminAddStickerToSet is a pure check (no store writes), used by a
|
||||
// dry-run preview before AdminAddStickerToSet actually mutates the pack.
|
||||
func (s *Service) ValidateAdminAddStickerToSet(ctx context.Context, setID int64, emoji string) error {
|
||||
set, _, found, err := s.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: setID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || set.ID == 0 || set.Deleted || set.Kind == domain.StickerSetKindSystem {
|
||||
return domain.ErrStickerSetInvalid
|
||||
}
|
||||
if len(set.DocumentIDs) >= domain.MaxStickerSetItems {
|
||||
return domain.ErrStickerSetTooMuch
|
||||
}
|
||||
return validateStickerEmoji(strings.TrimSpace(emoji))
|
||||
}
|
||||
|
||||
// ValidateAdminCreateStickerSet is a pure check (no store writes), used by a
|
||||
// dry-run preview before AdminCreateStickerSet actually creates the pack.
|
||||
func (s *Service) ValidateAdminCreateStickerSet(ctx context.Context, title, shortName, emoji string, kind domain.StickerSetKind) error {
|
||||
if err := validateStickerSetTitle(strings.TrimSpace(title)); err != nil {
|
||||
return err
|
||||
}
|
||||
if kind != domain.StickerSetKindEmoji && kind != domain.StickerSetKindMasks && kind != "" {
|
||||
return domain.ErrStickerSetTypeInvalid
|
||||
}
|
||||
normalizedShortName := normalizeStickerSetShortName(shortName)
|
||||
if normalizedShortName != "" {
|
||||
if err := validateStickerSetShortName(normalizedShortName); err != nil {
|
||||
return err
|
||||
}
|
||||
available, err := s.media.StickerSetShortNameAvailable(ctx, normalizedShortName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !available {
|
||||
return domain.ErrStickerSetShortNameOccupied
|
||||
}
|
||||
}
|
||||
return validateStickerEmoji(strings.TrimSpace(emoji))
|
||||
}
|
||||
|
||||
// AdminAddStickerToSet appends an already-materialized document (from
|
||||
// AdminUploadStickerMaterial) to an existing pack with no ownership check —
|
||||
// same convention as AdminSetStickerSetArchived and friends.
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ func (s *Service) AdminSetStickerSetArchived(ctx context.Context, setID int64, a
|
|||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !found {
|
||||
if !found || set.Deleted {
|
||||
return false, domain.ErrStickerSetInvalid
|
||||
}
|
||||
if set.Archived == archived {
|
||||
|
|
@ -163,7 +163,7 @@ func (s *Service) AdminSetStickerSetSortOrder(ctx context.Context, setID int64,
|
|||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !found {
|
||||
if !found || set.Deleted {
|
||||
return false, domain.ErrStickerSetInvalid
|
||||
}
|
||||
if set.SortOrder == order {
|
||||
|
|
@ -188,7 +188,7 @@ func (s *Service) AdminRenameStickerSet(ctx context.Context, setID int64, title
|
|||
if err != nil {
|
||||
return domain.StickerSet{}, err
|
||||
}
|
||||
if !found {
|
||||
if !found || set.Deleted {
|
||||
return domain.StickerSet{}, domain.ErrStickerSetInvalid
|
||||
}
|
||||
set.Title = title
|
||||
|
|
@ -203,15 +203,15 @@ func (s *Service) AdminRenameStickerSet(ctx context.Context, setID int64, title
|
|||
// AdminDeleteStickerSet deletes (soft-delete) a set with no ownership check;
|
||||
// see AdminSetStickerSetArchived for why that's needed here. Safe to bypass
|
||||
// ownership for: sticker_sets has no incoming foreign keys, so there's no
|
||||
// cascade to worry about (unlike star gifts, which have ~15 dependent
|
||||
// tables). Seed-imported sets will reappear on next restart if their source
|
||||
// files are still under data/sticker-seed — this only removes the DB row.
|
||||
// cascade to worry about. Seed-imported sets will reappear on next restart
|
||||
// if their source files are still under data/sticker-seed — this only
|
||||
// removes the DB row.
|
||||
func (s *Service) AdminDeleteStickerSet(ctx context.Context, setID int64) (domain.StickerSetKind, error) {
|
||||
set, found, err := s.media.GetStickerSetByID(ctx, setID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !found {
|
||||
if !found || set.Deleted {
|
||||
return "", domain.ErrStickerSetInvalid
|
||||
}
|
||||
if err := s.media.AdminDeleteStickerSet(ctx, setID); err != nil {
|
||||
|
|
|
|||
|
|
@ -138,6 +138,50 @@ func TestManageStickerSetRejectsNonCreator(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestValidateAdminStickerSetUploadPreconditions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fullIDs := make([]int64, domain.MaxStickerSetItems)
|
||||
for i := range fullIDs {
|
||||
fullIDs[i] = int64(i + 1)
|
||||
}
|
||||
media := &fakeMediaStore{
|
||||
docs: map[int64]domain.Document{},
|
||||
sets: map[int64]domain.StickerSet{
|
||||
10: {ID: 10, Kind: domain.StickerSetKindEmoji, DocumentIDs: fullIDs},
|
||||
20: {ID: 20, Kind: domain.StickerSetKindSystem, DocumentIDs: []int64{1}},
|
||||
30: {ID: 30, Kind: domain.StickerSetKindEmoji, DocumentIDs: []int64{1}},
|
||||
40: {ID: 40, Kind: domain.StickerSetKindEmoji, Deleted: true, DocumentIDs: []int64{1}},
|
||||
},
|
||||
}
|
||||
svc := NewService(media, nil, 2)
|
||||
|
||||
if err := svc.ValidateAdminAddStickerToSet(ctx, 10, "🙂"); !errors.Is(err, domain.ErrStickerSetTooMuch) {
|
||||
t.Fatalf("full pack validation err = %v, want ErrStickerSetTooMuch", err)
|
||||
}
|
||||
for _, setID := range []int64{20, 40, 999} {
|
||||
if err := svc.ValidateAdminAddStickerToSet(ctx, setID, "🙂"); !errors.Is(err, domain.ErrStickerSetInvalid) {
|
||||
t.Fatalf("set %d validation err = %v, want ErrStickerSetInvalid", setID, err)
|
||||
}
|
||||
}
|
||||
if err := svc.ValidateAdminAddStickerToSet(ctx, 30, ""); !errors.Is(err, domain.ErrStickerSetEmojiInvalid) {
|
||||
t.Fatalf("empty emoji validation err = %v, want ErrStickerSetEmojiInvalid", err)
|
||||
}
|
||||
if err := svc.ValidateAdminAddStickerToSet(ctx, 30, "🙂"); err != nil {
|
||||
t.Fatalf("editable pack validation: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.ValidateAdminCreateStickerSet(ctx, "New Emoji", "new_emoji", "🙂", domain.StickerSetKindEmoji); err != nil {
|
||||
t.Fatalf("create validation: %v", err)
|
||||
}
|
||||
if err := svc.ValidateAdminCreateStickerSet(ctx, "New Emoji", "new_emoji", "🙂", domain.StickerSetKindSystem); !errors.Is(err, domain.ErrStickerSetTypeInvalid) {
|
||||
t.Fatalf("system create validation err = %v, want ErrStickerSetTypeInvalid", err)
|
||||
}
|
||||
media.sets[50] = domain.StickerSet{ID: 50, ShortName: "occupied_name"}
|
||||
if err := svc.ValidateAdminCreateStickerSet(ctx, "New Emoji", "occupied_name", "🙂", domain.StickerSetKindEmoji); !errors.Is(err, domain.ErrStickerSetShortNameOccupied) {
|
||||
t.Fatalf("occupied name validation err = %v, want ErrStickerSetShortNameOccupied", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddStickerToSetAcceptsUploadedMaterial(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
media := &fakeMediaStore{
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ func (f *webpageFetcher) fetch(ctx context.Context, rawURL, accept string) ([]by
|
|||
if err != nil {
|
||||
// SSRF 拦截(dial Control 返回的 terminal)经 url.Error 传上来,errors.Is 仍能识别;
|
||||
// 其余 dial/超时错误是瞬时。
|
||||
return nil, "", fmt.Errorf("%w: %v", ErrWebPagePreviewInvalid, err)
|
||||
return nil, "", fmt.Errorf("%w: %w", ErrWebPagePreviewInvalid, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
|
|
@ -293,7 +293,7 @@ func (f *webpageFetcher) resolve(ctx context.Context, s *Service, normalizedURL
|
|||
if err != nil {
|
||||
// 终态失败(SSRF/4xx/非法 URL)→ 负缓存为空预览,避免重复按键/发送重打 PG+外网。
|
||||
// 瞬时失败(5xx/超时/dial/限速)→ 上抛 error,GetOrLoad 不缓存、可重试。
|
||||
if errors.Is(err, errWebPageTerminal) {
|
||||
if isTerminalWebPageFetchError(err) {
|
||||
return emptyWebPage(normalizedURL, urlHash), nil
|
||||
}
|
||||
return domain.MessageWebPage{}, err
|
||||
|
|
@ -316,6 +316,14 @@ func (f *webpageFetcher) resolve(ctx context.Context, s *Service, normalizedURL
|
|||
return page, nil
|
||||
}
|
||||
|
||||
func isTerminalWebPageFetchError(err error) bool {
|
||||
if errors.Is(err, errWebPageTerminal) {
|
||||
return true
|
||||
}
|
||||
var dnsErr *net.DNSError
|
||||
return errors.As(err, &dnsErr) && dnsErr.IsNotFound
|
||||
}
|
||||
|
||||
// fetchImage 抓取并铸造预览图(best-effort)。解码前按尺寸拦截解压炸弹;非图片/失败丢弃。
|
||||
func (f *webpageFetcher) fetchImage(ctx context.Context, s *Service, imageURL string) (domain.Photo, bool) {
|
||||
data, _, err := f.fetch(ctx, imageURL, acceptImage)
|
||||
|
|
|
|||
|
|
@ -201,7 +201,8 @@ func TestResolveWebPageNonHTMLIsEmpty(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestResolveWebPageSSRFBlocksLoopback 验证生产配置(allowPrivate=false)拦截指向 loopback 的 URL。
|
||||
// TestResolveWebPageSSRFBlocksLoopback 验证生产配置(allowPrivate=false)拦截指向 loopback 的 URL,
|
||||
// 并把该确定性失败收敛为空预览。
|
||||
func TestResolveWebPageSSRFBlocksLoopback(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
|
@ -210,8 +211,12 @@ func TestResolveWebPageSSRFBlocksLoopback(t *testing.T) {
|
|||
defer srv.Close()
|
||||
|
||||
svc := newWebpageTestService(t, false) // 生产口径:禁 loopback
|
||||
if _, err := svc.ResolveWebPage(context.Background(), srv.URL+"/x"); err == nil {
|
||||
t.Fatalf("expected SSRF guard to block loopback fetch")
|
||||
page, err := svc.ResolveWebPage(context.Background(), srv.URL+"/x")
|
||||
if err != nil {
|
||||
t.Fatalf("SSRF guard should resolve as terminal-empty: %v", err)
|
||||
}
|
||||
if page.State != domain.MessageWebPageStateEmpty {
|
||||
t.Fatalf("state = %q, want empty", page.State)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,10 +64,10 @@ const tdesktopClient = "tdesktop"
|
|||
//
|
||||
// WebK directly calls Array.some on fragment_prefixes while rendering user profiles,
|
||||
// so this compatibility key must always remain an array, even when it is empty.
|
||||
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stargifts_pinned_to_top_limit":6,"giveaway_gifts_purchase_available":true,"giveaway_boosts_per_premium":4,"giveaway_countries_max":10,"giveaway_add_peers_max":10,"giveaway_period_max":604800,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"bot_verification_description_length_limit":70,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000,"gif_search_username":"gif"`
|
||||
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","fragment_prefixes":["888"],"forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","ephemeral_welcome_messages_max":5,"upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stars_purchase_blocked":false,"stargifts_blocked":false,"stargifts_pinned_to_top_limit":6,"giveaway_gifts_purchase_available":true,"giveaway_boosts_per_premium":4,"giveaway_countries_max":10,"giveaway_add_peers_max":10,"giveaway_period_max":604800,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"bot_verification_description_length_limit":128,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000,"gif_search_username":"gif"`
|
||||
const tdesktopNoForwardsAppConfig = `,"no_forwards_request_expire_period":86400`
|
||||
|
||||
const defaultAppConfigHash = 28 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||
const defaultAppConfigHash = 30 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||
|
||||
// Service 提供客户端启动配置与国家区号目录。
|
||||
//
|
||||
|
|
@ -131,7 +131,10 @@ func WithEmailSignupPhonePrefixes(prefixes []string) Option {
|
|||
|
||||
// NewService 创建 help 服务。
|
||||
func NewService(appConfigs store.AppConfigStore, countries store.CountryStore, opts ...Option) *Service {
|
||||
s := &Service{appConfigs: appConfigs, countries: countries}
|
||||
s := &Service{
|
||||
appConfigs: appConfigs,
|
||||
countries: countries,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(s)
|
||||
|
|
|
|||
|
|
@ -72,6 +72,10 @@ func (s *Service) prepareBusinessAutomation(ctx context.Context, req domain.Send
|
|||
if !s.shouldConsiderBusinessAutomation(req) {
|
||||
return businessAutomationContext{}, false
|
||||
}
|
||||
hasAutomation, err := s.business.store.HasBusinessAutomation(ctx, req.RecipientUserID)
|
||||
if err != nil || !hasAutomation {
|
||||
return businessAutomationContext{}, false
|
||||
}
|
||||
out := businessAutomationContext{
|
||||
ownerUserID: req.RecipientUserID,
|
||||
customerUserID: req.SenderUserID,
|
||||
|
|
|
|||
|
|
@ -11,17 +11,22 @@ import (
|
|||
|
||||
// Service 提供消息历史、搜索与已读业务。
|
||||
type Service struct {
|
||||
messages store.MessageStore
|
||||
dialogs store.DialogStore
|
||||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
versions store.ReadModelVersionStore
|
||||
projector *userprojection.Projector
|
||||
botResponder BotResponder
|
||||
sendGate SendPermissionChecker
|
||||
business *businessAutomationConfig
|
||||
messages store.MessageStore
|
||||
dialogs store.DialogStore
|
||||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
versions store.ReadModelVersionStore
|
||||
projector *userprojection.Projector
|
||||
// viewerProjectionComplete is true only when every viewer-scoped user
|
||||
// overlay used by the shared RPC Users service is configured here too.
|
||||
// A partially configured service may still project the dependencies it has,
|
||||
// but RPC must not trust that partial envelope as authoritative.
|
||||
viewerProjectionComplete bool
|
||||
botResponder BotResponder
|
||||
sendGate SendPermissionChecker
|
||||
business *businessAutomationConfig
|
||||
|
||||
privateMediaCountCache *privateMediaCountReadModelCache
|
||||
}
|
||||
|
|
@ -37,7 +42,7 @@ type BotResponder interface {
|
|||
// HandlesBot 报告 botUserID 是否为该 responder 负责的内置 bot。
|
||||
HandlesBot(botUserID int64) bool
|
||||
// OnPrivateMessage 处理一条投递给内置 bot 的消息;msg 为 bot 视角收件 box 行。
|
||||
OnPrivateMessage(ctx context.Context, botUserID int64, msg domain.Message)
|
||||
OnPrivateMessage(ctx context.Context, botUserID int64, msg domain.Message, session domain.ClientSessionMetadata)
|
||||
}
|
||||
|
||||
// Option adjusts optional message service dependencies.
|
||||
|
|
@ -92,9 +97,18 @@ func NewService(messages store.MessageStore, dialogs store.DialogStore, opts ...
|
|||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
)
|
||||
s.viewerProjectionComplete = s.contacts != nil && s.photos != nil && s.privacy != nil && s.freezes != nil
|
||||
return s
|
||||
}
|
||||
|
||||
// ProjectsMessageUsersForViewer reports that history/search results returned by
|
||||
// this service have already passed through the viewer-specific user projection
|
||||
// boundary. RPC may reuse that envelope and resolve only nested message refs;
|
||||
// raw stores and test doubles do not implicitly gain this trust marker.
|
||||
func (s *Service) ProjectsMessageUsersForViewer() bool {
|
||||
return s != nil && s.viewerProjectionComplete
|
||||
}
|
||||
|
||||
// SendPrivateText 发送一条私聊文本消息。
|
||||
func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) {
|
||||
if s == nil || s.messages == nil || userID == 0 {
|
||||
|
|
@ -140,7 +154,7 @@ func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain.
|
|||
// 兜错,不回传失败。bot 自己发出的消息不触发(SenderUserID 不会是内置 bot
|
||||
// 的对话对象集合里关心的方向——hook 只看收件人)。
|
||||
if err == nil && !res.Duplicate && req.BusinessAutomationKind == "" && s.botResponder != nil && s.botResponder.HandlesBot(req.RecipientUserID) {
|
||||
s.botResponder.OnPrivateMessage(ctx, req.RecipientUserID, res.RecipientMessage)
|
||||
s.botResponder.OnPrivateMessage(ctx, req.RecipientUserID, res.RecipientMessage, req.OriginClientSession)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
|
@ -349,7 +363,11 @@ func (s *Service) SearchPrivateMedia(ctx context.Context, userID, peerID int64,
|
|||
if s == nil || s.messages == nil || userID == 0 || peerID == 0 {
|
||||
return domain.MessageList{}, nil
|
||||
}
|
||||
return s.messages.SearchPrivateMedia(ctx, userID, peerID, req)
|
||||
list, err := s.messages.SearchPrivateMedia(ctx, userID, peerID, req)
|
||||
if err != nil {
|
||||
return domain.MessageList{}, err
|
||||
}
|
||||
return s.projectMessageUsers(ctx, userID, list)
|
||||
}
|
||||
|
||||
// CountPrivateMediaCategories 返回某私聊会话按基础媒体类别聚合的精确计数。
|
||||
|
|
|
|||
|
|
@ -104,6 +104,9 @@ func TestServiceProjectsMessageUsersForViewerContacts(t *testing.T) {
|
|||
friendID: {PhotoID: 9101, DCID: 2, Stripped: []byte{5, 6}},
|
||||
strangerID: {PhotoID: 9102, DCID: 4},
|
||||
}))
|
||||
if svc.ProjectsMessageUsersForViewer() {
|
||||
t.Fatal("partially configured message projector must not claim a complete viewer envelope")
|
||||
}
|
||||
|
||||
list, err := svc.GetHistory(ctx, ownerID, domain.MessageFilter{Limit: 10})
|
||||
if err != nil {
|
||||
|
|
@ -127,6 +130,62 @@ func TestServiceProjectsMessageUsersForViewerContacts(t *testing.T) {
|
|||
if self.Phone != "15550000001" {
|
||||
t.Fatalf("self phone = %q, want preserved", self.Phone)
|
||||
}
|
||||
|
||||
media, err := svc.SearchPrivateMedia(ctx, ownerID, friendID, domain.MediaSearchRequest{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("SearchPrivateMedia: %v", err)
|
||||
}
|
||||
mediaFriend := findUser(t, media.Users, friendID)
|
||||
if !mediaFriend.Contact || mediaFriend.FirstName != "Remark" || mediaFriend.Phone != "15550000002" || mediaFriend.PhotoID != 9101 {
|
||||
t.Fatalf("shared-media friend projection = %+v, want the same viewer projection as history", mediaFriend)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMarksOnlyFullyConfiguredViewerProjectionComplete(t *testing.T) {
|
||||
store := projectionMessageStore{}
|
||||
svc := NewService(store, nil,
|
||||
WithContactStore(memory.NewContactStore()),
|
||||
WithPhotoProvider(messageProfilePhotos{}),
|
||||
WithPrivacyEvaluator(messageProjectionPrivacy{}),
|
||||
WithAccountFreezeProvider(messageProjectionFreezes{}),
|
||||
)
|
||||
if !svc.ProjectsMessageUsersForViewer() {
|
||||
t.Fatal("fully configured message projector must advertise a complete viewer envelope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendPrivateTextWithoutBusinessAutomationSkipsDialogAndContactReads(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 2001
|
||||
const customerID int64 = 2002
|
||||
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
business := &countingBusinessAutomationStore{BusinessAutomationStore: memory.NewPasswordStore()}
|
||||
countingDialogs := &countingBusinessDialogStore{DialogStore: dialogs}
|
||||
countingContacts := &countingBusinessContactStore{ContactStore: memory.NewContactStore()}
|
||||
svc := NewService(
|
||||
messages,
|
||||
countingDialogs,
|
||||
WithBusinessAutomation(business),
|
||||
WithContactStore(countingContacts),
|
||||
)
|
||||
|
||||
if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: customerID,
|
||||
RecipientUserID: ownerID,
|
||||
RandomID: 9001,
|
||||
Message: "ordinary message",
|
||||
Date: 1_700_000_000,
|
||||
}); err != nil {
|
||||
t.Fatalf("SendPrivateText: %v", err)
|
||||
}
|
||||
if business.hasCalls != 1 {
|
||||
t.Fatalf("HasBusinessAutomation calls = %d, want one lightweight gate", business.hasCalls)
|
||||
}
|
||||
if countingDialogs.listByPeersCalls != 0 || countingContacts.getCalls != 0 {
|
||||
t.Fatalf("business detail reads dialogs/contacts = %d/%d, want 0/0 without automation", countingDialogs.listByPeersCalls, countingContacts.getCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessAutomationGreetingSendsQuickReplyWithoutLoop(t *testing.T) {
|
||||
|
|
@ -567,6 +626,36 @@ type staticBusinessAutomationProvider struct {
|
|||
message string
|
||||
}
|
||||
|
||||
type countingBusinessAutomationStore struct {
|
||||
store.BusinessAutomationStore
|
||||
hasCalls int
|
||||
}
|
||||
|
||||
func (s *countingBusinessAutomationStore) HasBusinessAutomation(ctx context.Context, userID int64) (bool, error) {
|
||||
s.hasCalls++
|
||||
return s.BusinessAutomationStore.HasBusinessAutomation(ctx, userID)
|
||||
}
|
||||
|
||||
type countingBusinessDialogStore struct {
|
||||
store.DialogStore
|
||||
listByPeersCalls int
|
||||
}
|
||||
|
||||
func (s *countingBusinessDialogStore) ListByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
|
||||
s.listByPeersCalls++
|
||||
return s.DialogStore.ListByPeers(ctx, userID, peers)
|
||||
}
|
||||
|
||||
type countingBusinessContactStore struct {
|
||||
store.ContactStore
|
||||
getCalls int
|
||||
}
|
||||
|
||||
func (s *countingBusinessContactStore) Get(ctx context.Context, userID, contactUserID int64) (domain.Contact, bool, error) {
|
||||
s.getCalls++
|
||||
return s.ContactStore.Get(ctx, userID, contactUserID)
|
||||
}
|
||||
|
||||
func (p staticBusinessAutomationProvider) BusinessAutomationReplies(context.Context, BusinessAutomationReplyInput) ([]domain.QuickReplyMessage, error) {
|
||||
return []domain.QuickReplyMessage{{ID: 1, Message: p.message}}, nil
|
||||
}
|
||||
|
|
@ -751,9 +840,21 @@ func (s projectionMessageStore) ListByUser(context.Context, int64, domain.Messag
|
|||
}
|
||||
|
||||
func (s projectionMessageStore) SearchPrivateMedia(context.Context, int64, int64, domain.MediaSearchRequest) (domain.MessageList, error) {
|
||||
return domain.MessageList{}, nil
|
||||
return s.list, nil
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) CountPrivateMediaCategories(context.Context, int64, int64) (domain.MediaCategoryCounts, error) {
|
||||
return domain.MediaCategoryCounts{}, nil
|
||||
}
|
||||
|
||||
type messageProjectionPrivacy struct{}
|
||||
|
||||
func (messageProjectionPrivacy) CanSee(context.Context, int64, int64, domain.PrivacyKey) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
type messageProjectionFreezes struct{}
|
||||
|
||||
func (messageProjectionFreezes) AccountFreezes(context.Context, []int64) (map[int64]domain.AccountFreeze, error) {
|
||||
return map[int64]domain.AccountFreeze{}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ type moderationAccountDeleter interface {
|
|||
ExecuteAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error)
|
||||
}
|
||||
|
||||
type moderationAccountDeletionNotifier interface {
|
||||
NotifyModerationAccountDeletion(ctx context.Context, result domain.AccountDeletionResult)
|
||||
}
|
||||
|
||||
type moderationAppealLinkIssuer interface {
|
||||
IssueAppealLink(ctx context.Context, caseID, appellantUserID int64, expiresAt, now time.Time) (string, error)
|
||||
}
|
||||
|
|
@ -44,6 +48,7 @@ type ActionExecutor struct {
|
|||
channels moderationChannelDeleter
|
||||
channelNotifier moderationChannelDeleteNotifier
|
||||
accounts moderationAccountDeleter
|
||||
accountNotifier moderationAccountDeletionNotifier
|
||||
appealLinks moderationAppealLinkIssuer
|
||||
publicBaseURL string
|
||||
now func() time.Time
|
||||
|
|
@ -51,6 +56,15 @@ type ActionExecutor struct {
|
|||
|
||||
type ActionExecutorOption func(*ActionExecutor)
|
||||
|
||||
// WithAccountDeletionNotifier installs the post-commit runtime boundary for a
|
||||
// moderation deletion. The deleter owns the durable tombstone transaction; the
|
||||
// notifier retires the returned authorizations from live RPC sessions/caches.
|
||||
func WithAccountDeletionNotifier(notifier moderationAccountDeletionNotifier) ActionExecutorOption {
|
||||
return func(executor *ActionExecutor) {
|
||||
executor.accountNotifier = notifier
|
||||
}
|
||||
}
|
||||
|
||||
func WithAppealLinks(issuer moderationAppealLinkIssuer, publicBaseURL string) ActionExecutorOption {
|
||||
return func(executor *ActionExecutor) {
|
||||
executor.appealLinks = issuer
|
||||
|
|
@ -206,17 +220,26 @@ func (e *ActionExecutor) Execute(ctx context.Context, detail domain.ModerationCa
|
|||
}
|
||||
return nil
|
||||
case domain.ModerationActionDeleteAccount:
|
||||
if e.accounts == nil || detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
// The tombstone and live-session revocation are one application-level
|
||||
// outcome. Refuse to commit the durable half if this process cannot run the
|
||||
// post-commit half; otherwise an already-bound session keeps its cached user.
|
||||
if e.accounts == nil || e.accountNotifier == nil || detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
if err := decodeStrictActionPayload(action.Payload, &struct{}{}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := e.accounts.ExecuteAccountDeletion(
|
||||
result, err := e.accounts.ExecuteAccountDeletion(
|
||||
ctx, detail.Case.Target.ID, domain.AccountDeletionManual,
|
||||
fmt.Sprintf("moderation case %d", detail.Case.ID), e.now().UTC(),
|
||||
)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Changed && e.accountNotifier != nil {
|
||||
e.accountNotifier.NotifyModerationAccountDeletion(ctx, result)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,25 @@ type captureModerationAdmin struct {
|
|||
frozen []admin.SetAccountFrozenRequest
|
||||
}
|
||||
|
||||
type captureModerationAccountDeleter struct {
|
||||
result domain.AccountDeletionResult
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (d *captureModerationAccountDeleter) ExecuteAccountDeletion(context.Context, int64, domain.AccountDeletionSource, string, time.Time) (domain.AccountDeletionResult, error) {
|
||||
d.calls++
|
||||
return d.result, d.err
|
||||
}
|
||||
|
||||
type captureModerationAccountDeletionNotifier struct {
|
||||
results []domain.AccountDeletionResult
|
||||
}
|
||||
|
||||
func (n *captureModerationAccountDeletionNotifier) NotifyModerationAccountDeletion(_ context.Context, result domain.AccountDeletionResult) {
|
||||
n.results = append(n.results, result)
|
||||
}
|
||||
|
||||
func (a *captureModerationAdmin) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) {
|
||||
a.frozen = append(a.frozen, req)
|
||||
return admin.CommandResult{}, nil
|
||||
|
|
@ -323,3 +342,46 @@ func TestActionExecutorFreezeDefaultsAndBoundsAppealLink(t *testing.T) {
|
|||
t.Fatalf("link expiry=%v want=%v", issuer.expiresAt, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionExecutorNotifiesCommittedAccountDeletion(t *testing.T) {
|
||||
revoked := domain.Authorization{AuthKeyID: [8]byte{7}, UserID: 20}
|
||||
result := domain.AccountDeletionResult{
|
||||
User: domain.User{ID: 20, Deleted: true},
|
||||
Changed: true,
|
||||
RevokedAuthorizations: []domain.Authorization{revoked},
|
||||
}
|
||||
accounts := &captureModerationAccountDeleter{result: result}
|
||||
notifier := &captureModerationAccountDeletionNotifier{}
|
||||
executor := NewActionExecutor(nil, nil, nil, accounts, WithAccountDeletionNotifier(notifier))
|
||||
detail := domain.ModerationCaseDetail{
|
||||
Case: domain.ModerationCase{ID: 10, Target: domain.Peer{Type: domain.PeerTypeUser, ID: 20}},
|
||||
Decisions: []domain.ModerationDecision{{ID: 30, Actor: "reviewer"}},
|
||||
}
|
||||
action := domain.ModerationAction{
|
||||
CaseID: 10, DecisionID: 30, Kind: domain.ModerationActionDeleteAccount,
|
||||
Payload: []byte(`{}`), CommandID: "delete-account:000",
|
||||
}
|
||||
if err := executor.Execute(context.Background(), detail, action); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if accounts.calls != 1 || len(notifier.results) != 1 {
|
||||
t.Fatalf("delete calls=%d notifications=%d, want 1/1", accounts.calls, len(notifier.results))
|
||||
}
|
||||
got := notifier.results[0]
|
||||
if !got.Changed || got.User.ID != result.User.ID || len(got.RevokedAuthorizations) != 1 || got.RevokedAuthorizations[0].AuthKeyID != revoked.AuthKeyID {
|
||||
t.Fatalf("notification result=%+v, want committed deletion", got)
|
||||
}
|
||||
|
||||
accounts.result = domain.AccountDeletionResult{User: result.User, Changed: false}
|
||||
if err := executor.Execute(context.Background(), detail, action); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(notifier.results) != 1 {
|
||||
t.Fatalf("notifications after unchanged deletion=%d, want 1", len(notifier.results))
|
||||
}
|
||||
|
||||
withoutNotifier := NewActionExecutor(nil, nil, nil, accounts)
|
||||
if err := withoutNotifier.Execute(context.Background(), detail, action); !errors.Is(err, domain.ErrModerationActionInvalid) {
|
||||
t.Fatalf("delete without runtime notifier err=%v, want ErrModerationActionInvalid", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,6 +103,29 @@ func (c *BatchCache) Prime(viewerUserID int64, users []domain.User) {
|
|||
}
|
||||
}
|
||||
|
||||
// PrimeExpected preheats one viewer with the complete result of a bounded batch
|
||||
// projection. IDs omitted by the resolver are negative-cached as missing, so a
|
||||
// later fan-out builder cannot silently fall back to a per-viewer ByIDs query.
|
||||
// System users remain locally synthesised even when the resolver omits them.
|
||||
func (c *BatchCache) PrimeExpected(viewerUserID int64, expectedIDs []int64, users []domain.User) {
|
||||
if c == nil || viewerUserID == 0 {
|
||||
return
|
||||
}
|
||||
c.Prime(viewerUserID, users)
|
||||
byID := c.viewerUsers(viewerUserID)
|
||||
missing := c.viewerMissing(viewerUserID)
|
||||
for _, id := range uniqueIDs(expectedIDs) {
|
||||
if _, ok := byID[id]; ok {
|
||||
continue
|
||||
}
|
||||
if system, ok := domain.SystemUserByID(id); ok {
|
||||
byID[id] = system
|
||||
continue
|
||||
}
|
||||
missing[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *BatchCache) viewerUsers(viewerUserID int64) map[int64]domain.User {
|
||||
if byID, ok := c.byViewer[viewerUserID]; ok {
|
||||
return byID
|
||||
|
|
|
|||
|
|
@ -86,6 +86,42 @@ func TestBatchCachePrimeServesWithoutResolver(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBatchCachePrimeExpectedNegativeCachesOmittedUsers(t *testing.T) {
|
||||
resolver := &captureUserResolver{
|
||||
users: map[int64]domain.User{
|
||||
1000000002: {ID: 1000000002, FirstName: "must not be loaded"},
|
||||
},
|
||||
}
|
||||
cache := NewBatchCache(resolver)
|
||||
const viewer = int64(1000000003)
|
||||
cache.PrimeExpected(viewer,
|
||||
[]int64{1000000001, 1000000002, domain.OfficialSystemUserID},
|
||||
[]domain.User{{ID: 1000000001, FirstName: "Primed"}},
|
||||
)
|
||||
|
||||
got, err := cache.UsersForView(context.Background(), viewer,
|
||||
[]int64{1000000001, 1000000002, domain.OfficialSystemUserID})
|
||||
if err != nil {
|
||||
t.Fatalf("UsersForView: %v", err)
|
||||
}
|
||||
if len(resolver.calls) != 0 {
|
||||
t.Fatalf("resolver calls = %+v, want none after complete batch preheat", resolver.calls)
|
||||
}
|
||||
byID := make(map[int64]domain.User, len(got))
|
||||
for _, user := range got {
|
||||
byID[user.ID] = user
|
||||
}
|
||||
if byID[1000000001].FirstName != "Primed" {
|
||||
t.Fatalf("primed user = %+v", byID[1000000001])
|
||||
}
|
||||
if _, ok := byID[1000000002]; ok {
|
||||
t.Fatalf("omitted user unexpectedly resolved: %+v", byID[1000000002])
|
||||
}
|
||||
if _, ok := byID[domain.OfficialSystemUserID]; !ok {
|
||||
t.Fatalf("system user missing from local synthesis: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type resolverCall struct {
|
||||
viewerUserID int64
|
||||
ids []int64
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
"fmt"
|
||||
)
|
||||
|
||||
// DHConfigVersion 是 messages.getDhConfig 的静态版本号。p/g 是编译期常量,
|
||||
// DHConfigVersion 是 messages.getDhConfig 的服务端配置版本。p/g 是编译期常量,
|
||||
// 客户端缓存命中(请求 version 相同)时只回 dhConfigNotModified{random}。
|
||||
//
|
||||
// ⚠ 提高此值会让所有客户端在下一次 messages.getDhConfig(每次拨打/接听通话都会调用)
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ package privacy
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -15,6 +17,10 @@ const (
|
|||
|
||||
privacyViewerFactsMaxEntries = 8192
|
||||
privacyMembershipMaxEntries = 65536
|
||||
// A single legal cold batch must not replace the complete long-lived pair
|
||||
// cache. Large projections still use one exact store batch, but bypass LRU
|
||||
// admission and return the loaded facts directly.
|
||||
privacyMembershipBatchAdmissionMaxPairs = privacyMembershipMaxEntries / 4
|
||||
)
|
||||
|
||||
// baseUserProvider returns viewer-independent user facts through the users read
|
||||
|
|
@ -24,10 +30,11 @@ type baseUserProvider interface {
|
|||
PrivacyBaseUsers(ctx context.Context, userIDs []int64) ([]domain.User, error)
|
||||
}
|
||||
|
||||
// channelMembershipProvider is the cold loader behind the bounded membership
|
||||
// read model. Privacy evaluation never calls it for a warm (chat,user) pair.
|
||||
// channelMembershipProvider is the exact-pair cold loader behind the bounded
|
||||
// membership read model. Cache-admitted batches load only misses; oversized
|
||||
// non-admitted batches reload once without polluting the long-lived LRU.
|
||||
type channelMembershipProvider interface {
|
||||
FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
|
||||
FilterActiveChannelMemberPairs(ctx context.Context, userIDsByChannel map[int64][]int64) (map[int64][]int64, error)
|
||||
}
|
||||
|
||||
type viewerFacts struct {
|
||||
|
|
@ -153,40 +160,30 @@ func (s *Service) loadMembershipFacts(ctx context.Context, chatIDs, viewerUserID
|
|||
if len(chats) == 0 || len(viewers) == 0 {
|
||||
return map[membershipKey]bool{}, nil
|
||||
}
|
||||
if !activeChannelMembershipPairsAllowed(0, len(chats), len(viewers)) {
|
||||
return nil, activeChannelMembershipPairLimitError()
|
||||
}
|
||||
keys := make([]membershipKey, 0, len(chats)*len(viewers))
|
||||
for _, chatID := range chats {
|
||||
for _, viewerID := range viewers {
|
||||
keys = append(keys, membershipKey{ChatID: chatID, UserID: viewerID})
|
||||
}
|
||||
}
|
||||
loadMissing := func(ctx context.Context, missing []membershipKey) (map[membershipKey]bool, error) {
|
||||
out := make(map[membershipKey]bool, len(missing))
|
||||
byChat := make(map[int64][]int64)
|
||||
for _, key := range missing {
|
||||
out[key] = false // negative cache: not an active member.
|
||||
byChat[key.ChatID] = append(byChat[key.ChatID], key.UserID)
|
||||
}
|
||||
if s == nil || s.memberships == nil {
|
||||
return out, nil
|
||||
}
|
||||
for chatID, userIDs := range byChat {
|
||||
active, err := s.memberships.FilterActiveChannelMemberIDs(ctx, chatID, userIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, userID := range active {
|
||||
out[membershipKey{ChatID: chatID, UserID: userID}] = true
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
return s.loadMembershipFactsForKeys(ctx, keys)
|
||||
}
|
||||
|
||||
func activeChannelMembershipPairsAllowed(current, channelCount, userCount int) bool {
|
||||
if current < 0 || channelCount < 0 || userCount < 0 || current > store.MaxActiveChannelMemberPairs {
|
||||
return false
|
||||
}
|
||||
if s == nil || s.membershipFacts == nil {
|
||||
return loadMissing(ctx, keys)
|
||||
if channelCount == 0 || userCount == 0 {
|
||||
return true
|
||||
}
|
||||
return s.membershipFacts.GetOrLoadBatch(ctx, keys,
|
||||
func(membershipKey) (int64, bool) { return 0, true },
|
||||
loadMissing,
|
||||
)
|
||||
return channelCount <= (store.MaxActiveChannelMemberPairs-current)/userCount
|
||||
}
|
||||
|
||||
func activeChannelMembershipPairLimitError() error {
|
||||
return fmt.Errorf("%w: maximum %d", store.ErrActiveChannelMemberPairsLimit, store.MaxActiveChannelMemberPairs)
|
||||
}
|
||||
|
||||
func applyViewerFacts(ctx *domain.PrivacyContext, facts viewerFacts, now int64) {
|
||||
|
|
|
|||
|
|
@ -346,8 +346,8 @@ func (s *Service) ViewerIsPremium(ctx context.Context, viewerUserID int64) (bool
|
|||
}
|
||||
|
||||
// CanSeeMatrix 批量评估 owners × viewers × keys 的可见性矩阵,结果等价于逐 (owner,viewer,key)
|
||||
// 调 CanSee,但只用一次 ListPrivacyRules + 每 owner 一次 GetMany(owner,viewers) + 内存 Evaluate
|
||||
// (把 fan-out 投影从 O(viewer) 次 privacy 查询降到 O(owner))。返回 map[owner]map[viewer]map[key]bool。
|
||||
// 调 CanSee。生产 contact store 通过一次 exact owner->viewer pair batch 读取联系人关系;仅不支持
|
||||
// sparse projection 的测试/替代实现按 owner 回退 GetMany。返回 map[owner]map[viewer]map[key]bool。
|
||||
func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs []int64, keys []domain.PrivacyKey) (map[int64]map[int64]map[domain.PrivacyKey]bool, error) {
|
||||
out := make(map[int64]map[int64]map[domain.PrivacyKey]bool, len(ownerUserIDs))
|
||||
if len(ownerUserIDs) == 0 || len(viewerUserIDs) == 0 || len(keys) == 0 {
|
||||
|
|
@ -408,11 +408,29 @@ func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs
|
|||
return nil, err
|
||||
}
|
||||
}
|
||||
var contactsByOwner map[int64]map[int64]domain.Contact
|
||||
useSparseContacts := false
|
||||
if s != nil && s.contacts != nil {
|
||||
if loader, ok := s.contacts.(store.SparseContactProjectionStore); ok {
|
||||
requested := make(map[int64][]int64, len(owners))
|
||||
for _, owner := range owners {
|
||||
requested[owner] = viewers
|
||||
}
|
||||
batch, err := loader.ContactProjectionForViewerUserIDs(ctx, requested)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contactsByOwner = batch.Contacts
|
||||
useSparseContacts = true
|
||||
}
|
||||
}
|
||||
now := s.now().Unix()
|
||||
for _, owner := range owners {
|
||||
// owner 的联系人中哪些是本批 viewer(= privacy 的 ViewerIsContact,对应 contacts.Get(owner,viewer))。
|
||||
var ownerContacts map[int64]domain.Contact
|
||||
if s != nil && s.contacts != nil {
|
||||
if useSparseContacts {
|
||||
ownerContacts = contactsByOwner[owner]
|
||||
} else if s != nil && s.contacts != nil {
|
||||
var err error
|
||||
ownerContacts, err = s.contacts.GetMany(ctx, owner, viewers)
|
||||
if err != nil {
|
||||
|
|
|
|||
232
internal/app/privacy/service_sparse.go
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
package privacy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// CanSeeForViewerUserIDs evaluates only the requested viewer->owner pairs.
|
||||
// contactsByOwner must contain the inverse owner->viewer contact rows prefetched
|
||||
// by the caller; accepting them here lets user projection share one sparse
|
||||
// contact read for contact overlays, personal photos, and privacy relations.
|
||||
func (s *Service) CanSeeForViewerUserIDs(
|
||||
ctx context.Context,
|
||||
ownerUserIDsByViewer map[int64][]int64,
|
||||
keys []domain.PrivacyKey,
|
||||
contactsByOwner map[int64]map[int64]domain.Contact,
|
||||
) (map[int64]map[int64]map[domain.PrivacyKey]bool, error) {
|
||||
out := make(map[int64]map[int64]map[domain.PrivacyKey]bool)
|
||||
if len(ownerUserIDsByViewer) == 0 || len(keys) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
for _, key := range keys {
|
||||
if !ValidKey(key) {
|
||||
return nil, domain.ErrPrivacyKeyInvalid
|
||||
}
|
||||
}
|
||||
viewersByOwner := make(map[int64][]int64)
|
||||
viewerSet := make(map[int64]struct{})
|
||||
for viewerID, ownerIDs := range ownerUserIDsByViewer {
|
||||
if viewerID == 0 {
|
||||
continue
|
||||
}
|
||||
seenOwners := make(map[int64]struct{}, len(ownerIDs))
|
||||
for _, ownerID := range ownerIDs {
|
||||
if ownerID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenOwners[ownerID]; ok {
|
||||
continue
|
||||
}
|
||||
seenOwners[ownerID] = struct{}{}
|
||||
viewersByOwner[ownerID] = append(viewersByOwner[ownerID], viewerID)
|
||||
viewerSet[viewerID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(viewersByOwner) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if contactsByOwner == nil && s != nil && s.contacts != nil {
|
||||
loader, ok := s.contacts.(store.SparseContactProjectionStore)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("privacy contact store does not support sparse projection")
|
||||
}
|
||||
batch, err := loader.ContactProjectionForViewerUserIDs(ctx, viewersByOwner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contactsByOwner = batch.Contacts
|
||||
}
|
||||
owners := make([]int64, 0, len(viewersByOwner))
|
||||
for ownerID := range viewersByOwner {
|
||||
owners = append(owners, ownerID)
|
||||
}
|
||||
rulesByOwner := make(map[int64]map[domain.PrivacyKey]domain.PrivacyRules, len(owners))
|
||||
if s != nil && s.rules != nil {
|
||||
list, err := s.rules.ListPrivacyRules(ctx, owners, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rules := range list {
|
||||
if !ValidKey(rules.Key) {
|
||||
continue
|
||||
}
|
||||
if len(rules.Rules) == 0 {
|
||||
rules.Rules = domain.DefaultPrivacyRules(rules.Key)
|
||||
}
|
||||
if rulesByOwner[rules.OwnerUserID] == nil {
|
||||
rulesByOwner[rules.OwnerUserID] = make(map[domain.PrivacyKey]domain.PrivacyRules, len(keys))
|
||||
}
|
||||
rulesByOwner[rules.OwnerUserID][rules.Key] = cloneRules(rules)
|
||||
}
|
||||
}
|
||||
|
||||
needsByOwner := make(map[int64]evaluationNeeds, len(owners))
|
||||
needsViewerFacts := false
|
||||
membershipPairCount := 0
|
||||
for _, ownerID := range owners {
|
||||
var needs evaluationNeeds
|
||||
for _, key := range keys {
|
||||
rules, ok := rulesByOwner[ownerID][key]
|
||||
if !ok {
|
||||
rules = defaultRules(ownerID, key)
|
||||
}
|
||||
mergeNeeds(&needs, needsForRules(rules))
|
||||
}
|
||||
needsByOwner[ownerID] = needs
|
||||
needsViewerFacts = needsViewerFacts || needs.viewerBase
|
||||
viewerCount := len(viewersByOwner[ownerID])
|
||||
if !activeChannelMembershipPairsAllowed(membershipPairCount, len(needs.chatIDs), viewerCount) {
|
||||
return nil, activeChannelMembershipPairLimitError()
|
||||
}
|
||||
membershipPairCount += len(needs.chatIDs) * viewerCount
|
||||
}
|
||||
membershipKeys := make([]membershipKey, 0, membershipPairCount)
|
||||
for _, ownerID := range owners {
|
||||
needs := needsByOwner[ownerID]
|
||||
for _, chatID := range needs.chatIDs {
|
||||
for _, viewerID := range viewersByOwner[ownerID] {
|
||||
membershipKeys = append(membershipKeys, membershipKey{ChatID: chatID, UserID: viewerID})
|
||||
}
|
||||
}
|
||||
}
|
||||
viewers := make([]int64, 0, len(viewerSet))
|
||||
for viewerID := range viewerSet {
|
||||
viewers = append(viewers, viewerID)
|
||||
}
|
||||
var baseFacts map[int64]viewerFacts
|
||||
if needsViewerFacts {
|
||||
var err error
|
||||
baseFacts, err = s.loadViewerFacts(ctx, viewers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
membershipFacts, err := s.loadMembershipFactsForKeys(ctx, membershipKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := s.now().Unix()
|
||||
for _, ownerID := range owners {
|
||||
perViewer := make(map[int64]map[domain.PrivacyKey]bool, len(viewersByOwner[ownerID]))
|
||||
for _, viewerID := range viewersByOwner[ownerID] {
|
||||
visibility := make(map[domain.PrivacyKey]bool, len(keys))
|
||||
if ownerID == viewerID {
|
||||
for _, key := range keys {
|
||||
visibility[key] = true
|
||||
}
|
||||
perViewer[viewerID] = visibility
|
||||
continue
|
||||
}
|
||||
contact, isContact := contactsByOwner[ownerID][viewerID]
|
||||
for _, key := range keys {
|
||||
rules, ok := rulesByOwner[ownerID][key]
|
||||
if !ok {
|
||||
rules = defaultRules(ownerID, key)
|
||||
}
|
||||
evalCtx := domain.PrivacyContext{
|
||||
OwnerUserID: ownerID, ViewerUserID: viewerID,
|
||||
ViewerIsContact: isContact, ViewerCloseFriend: isContact && contact.CloseFriend,
|
||||
}
|
||||
applyViewerFacts(&evalCtx, baseFacts[viewerID], now)
|
||||
applyMembershipFacts(&evalCtx, needsByOwner[ownerID].chatIDs, membershipFacts)
|
||||
visibility[key] = Evaluate(rules, evalCtx)
|
||||
}
|
||||
perViewer[viewerID] = visibility
|
||||
}
|
||||
out[ownerID] = perViewer
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadMembershipFactsForKeys(ctx context.Context, input []membershipKey) (map[membershipKey]bool, error) {
|
||||
capacity := len(input)
|
||||
if capacity > store.MaxActiveChannelMemberPairs {
|
||||
capacity = store.MaxActiveChannelMemberPairs
|
||||
}
|
||||
seen := make(map[membershipKey]struct{}, capacity)
|
||||
keys := make([]membershipKey, 0, capacity)
|
||||
for _, key := range input {
|
||||
if key.ChatID == 0 || key.UserID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
if len(keys) >= store.MaxActiveChannelMemberPairs {
|
||||
return nil, activeChannelMembershipPairLimitError()
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return map[membershipKey]bool{}, nil
|
||||
}
|
||||
loadMissing := func(ctx context.Context, missing []membershipKey) (map[membershipKey]bool, error) {
|
||||
out := make(map[membershipKey]bool, len(missing))
|
||||
byChat := make(map[int64][]int64)
|
||||
for _, key := range missing {
|
||||
out[key] = false
|
||||
byChat[key.ChatID] = append(byChat[key.ChatID], key.UserID)
|
||||
}
|
||||
if s == nil || s.memberships == nil {
|
||||
return out, nil
|
||||
}
|
||||
activeByChat, err := s.memberships.FilterActiveChannelMemberPairs(ctx, byChat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for chatID, userIDs := range activeByChat {
|
||||
for _, userID := range userIDs {
|
||||
key := membershipKey{ChatID: chatID, UserID: userID}
|
||||
if _, requested := out[key]; requested {
|
||||
out[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if s == nil || s.membershipFacts == nil {
|
||||
return loadMissing(ctx, keys)
|
||||
}
|
||||
if len(keys) > privacyMembershipBatchAdmissionMaxPairs {
|
||||
for {
|
||||
loadEpoch := s.membershipFacts.LoadEpoch()
|
||||
loaded, err := loadMissing(ctx, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.membershipFacts.LoadEpoch() == loadEpoch {
|
||||
return loaded, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.membershipFacts.GetOrLoadBatch(ctx, keys,
|
||||
func(membershipKey) (int64, bool) { return 0, true }, loadMissing)
|
||||
}
|
||||
|
|
@ -2,10 +2,12 @@ package privacy
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
|
|
@ -26,8 +28,47 @@ func (p *countingBaseUsers) PrivacyBaseUsers(_ context.Context, userIDs []int64)
|
|||
}
|
||||
|
||||
type countingMemberships struct {
|
||||
calls int
|
||||
active map[int64]map[int64]bool
|
||||
calls int
|
||||
batchCalls int
|
||||
batchRequests []map[int64][]int64
|
||||
active map[int64]map[int64]bool
|
||||
}
|
||||
|
||||
type countingSparseContacts struct {
|
||||
store.ContactStore
|
||||
sparseCalls int
|
||||
getMany int
|
||||
requested map[int64][]int64
|
||||
}
|
||||
|
||||
func (c *countingSparseContacts) GetMany(ctx context.Context, ownerUserID int64, viewerUserIDs []int64) (map[int64]domain.Contact, error) {
|
||||
c.getMany++
|
||||
return c.ContactStore.GetMany(ctx, ownerUserID, viewerUserIDs)
|
||||
}
|
||||
|
||||
func (c *countingSparseContacts) ContactProjectionForViewerUserIDs(ctx context.Context, requested map[int64][]int64) (domain.ContactProjectionBatch, error) {
|
||||
c.sparseCalls++
|
||||
c.requested = make(map[int64][]int64, len(requested))
|
||||
for viewerID, targetIDs := range requested {
|
||||
c.requested[viewerID] = append([]int64(nil), targetIDs...)
|
||||
}
|
||||
return c.ContactStore.(store.SparseContactProjectionStore).ContactProjectionForViewerUserIDs(ctx, requested)
|
||||
}
|
||||
|
||||
func (p *countingMemberships) FilterActiveChannelMemberPairs(_ context.Context, requested map[int64][]int64) (map[int64][]int64, error) {
|
||||
p.batchCalls++
|
||||
cloned := make(map[int64][]int64, len(requested))
|
||||
out := make(map[int64][]int64, len(requested))
|
||||
for channelID, userIDs := range requested {
|
||||
cloned[channelID] = append([]int64(nil), userIDs...)
|
||||
for _, userID := range userIDs {
|
||||
if p.active[channelID][userID] {
|
||||
out[channelID] = append(out[channelID], userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.batchRequests = append(p.batchRequests, cloned)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *countingMemberships) FilterActiveChannelMemberIDs(_ context.Context, channelID int64, userIDs []int64) ([]int64, error) {
|
||||
|
|
@ -226,6 +267,47 @@ func TestCanSeeMatrixEquivalentToCanSee(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCanSeeMatrixLoadsOwnerViewerContactsInOneSparseBatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
inner := memory.NewContactStore()
|
||||
contacts := &countingSparseContacts{ContactStore: inner}
|
||||
svc := NewService(memory.NewPrivacyStore(), contacts)
|
||||
owners := []int64{6101, 6102}
|
||||
viewers := []int64{7101, 7102}
|
||||
for _, owner := range owners {
|
||||
if _, err := svc.SetRules(ctx, owner, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{
|
||||
{Kind: domain.PrivacyRuleAllowContacts},
|
||||
{Kind: domain.PrivacyRuleDisallowAll},
|
||||
}); err != nil {
|
||||
t.Fatalf("set owner %d rules: %v", owner, err)
|
||||
}
|
||||
}
|
||||
if _, err := inner.Upsert(ctx, owners[0], domain.ContactInput{ContactUserID: viewers[0]}); err != nil {
|
||||
t.Fatalf("upsert contact: %v", err)
|
||||
}
|
||||
|
||||
matrix, err := svc.CanSeeMatrix(ctx, owners, viewers, []domain.PrivacyKey{domain.PrivacyKeyPhoneNumber})
|
||||
if err != nil {
|
||||
t.Fatalf("CanSeeMatrix: %v", err)
|
||||
}
|
||||
if contacts.sparseCalls != 1 || contacts.getMany != 0 {
|
||||
t.Fatalf("contact reads = sparse %d / GetMany %d, want 1 / 0", contacts.sparseCalls, contacts.getMany)
|
||||
}
|
||||
for _, owner := range owners {
|
||||
if got := len(contacts.requested[owner]); got != len(viewers) {
|
||||
t.Fatalf("requested owner %d viewers = %v, want %v", owner, contacts.requested[owner], viewers)
|
||||
}
|
||||
}
|
||||
if !matrix[owners[0]][viewers[0]][domain.PrivacyKeyPhoneNumber] {
|
||||
t.Fatal("owner contact relation was not applied")
|
||||
}
|
||||
if matrix[owners[0]][viewers[1]][domain.PrivacyKeyPhoneNumber] ||
|
||||
matrix[owners[1]][viewers[0]][domain.PrivacyKeyPhoneNumber] ||
|
||||
matrix[owners[1]][viewers[1]][domain.PrivacyKeyPhoneNumber] {
|
||||
t.Fatalf("unexpected non-contact visibility: %+v", matrix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewerFactsReadModelBatchesCachesAndInvalidates(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
rules := memory.NewPrivacyStore()
|
||||
|
|
@ -313,15 +395,15 @@ func TestMembershipReadModelCachesNegativeFactsAndInvalidatesPair(t *testing.T)
|
|||
got[1001][2002][domain.PrivacyKeyChatInvite] {
|
||||
t.Fatalf("unexpected membership visibility matrix: %+v", got)
|
||||
}
|
||||
if memberships.calls != 2 {
|
||||
t.Fatalf("membership cold loads = %d, want one batch per referenced chat", memberships.calls)
|
||||
if memberships.batchCalls != 1 || memberships.calls != 0 {
|
||||
t.Fatalf("membership cold loads = batch %d scalar %d, want batch=1 scalar=0", memberships.batchCalls, memberships.calls)
|
||||
}
|
||||
|
||||
if allowed, err := svc.CanSee(ctx, 1001, 2002, domain.PrivacyKeyChatInvite); err != nil || allowed {
|
||||
t.Fatalf("warm negative membership = %v, err=%v; want false", allowed, err)
|
||||
}
|
||||
if memberships.calls != 2 {
|
||||
t.Fatalf("negative cache missed: calls=%d", memberships.calls)
|
||||
if memberships.batchCalls != 1 || memberships.calls != 0 {
|
||||
t.Fatalf("negative cache missed: batch=%d scalar=%d", memberships.batchCalls, memberships.calls)
|
||||
}
|
||||
|
||||
memberships.active[9002][2002] = true
|
||||
|
|
@ -329,7 +411,127 @@ func TestMembershipReadModelCachesNegativeFactsAndInvalidatesPair(t *testing.T)
|
|||
if allowed, err := svc.CanSee(ctx, 1001, 2002, domain.PrivacyKeyChatInvite); err != nil || !allowed {
|
||||
t.Fatalf("invalidated membership = %v, err=%v; want true", allowed, err)
|
||||
}
|
||||
if memberships.calls != 3 {
|
||||
t.Fatalf("pair invalidation reloads = %d, want 3", memberships.calls)
|
||||
if memberships.batchCalls != 2 || memberships.calls != 0 {
|
||||
t.Fatalf("pair invalidation reloads = batch %d scalar %d, want batch=2 scalar=0", memberships.batchCalls, memberships.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSparsePrivacyMembershipUsesOneExactPairBatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const (
|
||||
ownerA = int64(1001)
|
||||
ownerB = int64(1002)
|
||||
viewerA = int64(2001)
|
||||
viewerB = int64(2002)
|
||||
chatA = int64(9001)
|
||||
chatB = int64(9002)
|
||||
)
|
||||
rules := memory.NewPrivacyStore()
|
||||
memberships := &countingMemberships{active: map[int64]map[int64]bool{
|
||||
chatA: {viewerA: true, viewerB: true},
|
||||
chatB: {viewerA: true, viewerB: true},
|
||||
}}
|
||||
svc := NewService(rules, memory.NewContactStore()).ConfigureReadModels(nil, memberships)
|
||||
for ownerID, chatID := range map[int64]int64{ownerA: chatA, ownerB: chatB} {
|
||||
if _, err := svc.SetRules(ctx, ownerID, domain.PrivacyKeyProfilePhoto, []domain.PrivacyRule{
|
||||
{Kind: domain.PrivacyRuleAllowChatParticipants, ChatIDs: []int64{chatID}},
|
||||
{Kind: domain.PrivacyRuleDisallowAll},
|
||||
}); err != nil {
|
||||
t.Fatalf("SetRules(%d): %v", ownerID, err)
|
||||
}
|
||||
}
|
||||
got, err := svc.CanSeeForViewerUserIDs(ctx, map[int64][]int64{
|
||||
viewerA: {ownerA},
|
||||
viewerB: {ownerB},
|
||||
}, []domain.PrivacyKey{domain.PrivacyKeyProfilePhoto}, map[int64]map[int64]domain.Contact{})
|
||||
if err != nil {
|
||||
t.Fatalf("CanSeeForViewerUserIDs: %v", err)
|
||||
}
|
||||
if !got[ownerA][viewerA][domain.PrivacyKeyProfilePhoto] || !got[ownerB][viewerB][domain.PrivacyKeyProfilePhoto] {
|
||||
t.Fatalf("visibility = %+v, want both exact pairs visible", got)
|
||||
}
|
||||
if memberships.batchCalls != 1 || memberships.calls != 0 {
|
||||
t.Fatalf("membership loads = batch %d scalar %d, want batch=1 scalar=0", memberships.batchCalls, memberships.calls)
|
||||
}
|
||||
requested := memberships.batchRequests[0]
|
||||
if len(requested) != 2 || len(requested[chatA]) != 1 || requested[chatA][0] != viewerA || len(requested[chatB]) != 1 || requested[chatB][0] != viewerB {
|
||||
t.Fatalf("membership request = %+v, want only (%d,%d) and (%d,%d)", requested, chatA, viewerA, chatB, viewerB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSparsePrivacyMembershipRejectsDerivedPairOverflowBeforeLoad(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
rules := memory.NewPrivacyStore()
|
||||
memberships := &countingMemberships{active: map[int64]map[int64]bool{}}
|
||||
svc := NewService(rules, memory.NewContactStore()).ConfigureReadModels(nil, memberships)
|
||||
owners := []int64{1001, 1002, 1003, 1004, 1005}
|
||||
for ownerIndex, ownerID := range owners {
|
||||
chatIDs := make([]int64, 5000)
|
||||
for i := range chatIDs {
|
||||
chatIDs[i] = int64(100000 + ownerIndex*10000 + i)
|
||||
}
|
||||
if _, err := svc.SetRules(ctx, ownerID, domain.PrivacyKeyProfilePhoto, []domain.PrivacyRule{
|
||||
{Kind: domain.PrivacyRuleAllowChatParticipants, ChatIDs: chatIDs},
|
||||
{Kind: domain.PrivacyRuleDisallowAll},
|
||||
}); err != nil {
|
||||
t.Fatalf("SetRules(%d): %v", ownerID, err)
|
||||
}
|
||||
}
|
||||
_, err := svc.CanSeeForViewerUserIDs(ctx, map[int64][]int64{
|
||||
2001: owners,
|
||||
2002: owners,
|
||||
2003: owners,
|
||||
}, []domain.PrivacyKey{domain.PrivacyKeyProfilePhoto}, map[int64]map[int64]domain.Contact{})
|
||||
if !errors.Is(err, store.ErrActiveChannelMemberPairsLimit) {
|
||||
t.Fatalf("CanSeeForViewerUserIDs error = %v, want ErrActiveChannelMemberPairsLimit", err)
|
||||
}
|
||||
if memberships.batchCalls != 0 || memberships.calls != 0 {
|
||||
t.Fatalf("membership loads = batch %d scalar %d, want fail before load", memberships.batchCalls, memberships.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDensePrivacyMembershipRejectsDerivedPairOverflowBeforeLoad(t *testing.T) {
|
||||
memberships := &countingMemberships{active: map[int64]map[int64]bool{}}
|
||||
svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore()).ConfigureReadModels(nil, memberships)
|
||||
chatIDs := make([]int64, 257)
|
||||
viewerIDs := make([]int64, 256)
|
||||
for i := range chatIDs {
|
||||
chatIDs[i] = int64(i + 1)
|
||||
}
|
||||
for i := range viewerIDs {
|
||||
viewerIDs[i] = int64(1000 + i)
|
||||
}
|
||||
_, err := svc.loadMembershipFacts(context.Background(), chatIDs, viewerIDs)
|
||||
if !errors.Is(err, store.ErrActiveChannelMemberPairsLimit) {
|
||||
t.Fatalf("loadMembershipFacts error = %v, want ErrActiveChannelMemberPairsLimit", err)
|
||||
}
|
||||
if memberships.batchCalls != 0 || memberships.calls != 0 {
|
||||
t.Fatalf("membership loads = batch %d scalar %d, want fail before load", memberships.batchCalls, memberships.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLargeMembershipBatchBypassesLRUAdmissionWithoutEvictingHotPair(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
memberships := &countingMemberships{active: map[int64]map[int64]bool{}}
|
||||
svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore()).ConfigureReadModels(nil, memberships)
|
||||
hot := membershipKey{ChatID: 9001, UserID: 2001}
|
||||
if _, err := svc.loadMembershipFactsForKeys(ctx, []membershipKey{hot}); err != nil {
|
||||
t.Fatalf("warm hot membership pair: %v", err)
|
||||
}
|
||||
large := make([]membershipKey, store.MaxActiveChannelMemberPairs)
|
||||
for i := range large {
|
||||
large[i] = membershipKey{ChatID: 9002, UserID: int64(100000 + i)}
|
||||
}
|
||||
if _, err := svc.loadMembershipFactsForKeys(ctx, large); err != nil {
|
||||
t.Fatalf("load large membership batch: %v", err)
|
||||
}
|
||||
if memberships.batchCalls != 2 || memberships.calls != 0 {
|
||||
t.Fatalf("loads after large batch = batch %d scalar %d, want batch=2 scalar=0", memberships.batchCalls, memberships.calls)
|
||||
}
|
||||
if _, err := svc.loadMembershipFactsForKeys(ctx, []membershipKey{hot}); err != nil {
|
||||
t.Fatalf("reload hot membership pair: %v", err)
|
||||
}
|
||||
if memberships.batchCalls != 2 || memberships.calls != 0 {
|
||||
t.Fatalf("hot pair was evicted by non-admitted batch: batch=%d scalar=%d", memberships.batchCalls, memberships.calls)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
const (
|
||||
ModelDialogLight = "dialog_light"
|
||||
ModelDialogOwner = "dialog_owner"
|
||||
ModelContactAccount = "contact_account"
|
||||
ModelChannelBase = "channel_base"
|
||||
ModelChannelMember = "channel_member"
|
||||
|
|
@ -15,6 +16,9 @@ const (
|
|||
ModelPrivateMediaCounts = "private_media_counts"
|
||||
ModelChannelParticipants = "channel_participants"
|
||||
ModelChannelSelfBoosts = "channel_self_boosts"
|
||||
ModelUserVisibility = "user_visibility"
|
||||
ModelStoryPeer = "story_peer"
|
||||
ModelStoryHiddenList = "story_hidden_list"
|
||||
)
|
||||
|
||||
func MixHashes(values ...int64) int64 {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package secretchat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
|
|
@ -11,39 +12,44 @@ import (
|
|||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// idAllocRetries 是 chat_id 撞键自愈的有界重试次数。
|
||||
const idAllocRetries = 4
|
||||
|
||||
// Service 实现密聊握手状态机 + qts 消息投递。所有返回的 domain.SecretChat 都是当时快照。
|
||||
// 访问校验(self/bot/拉黑/隐私)在 rpc 层先行;本层做 DH 校验、id/access_hash 分配、
|
||||
// 访问校验(self/bot/拉黑/隐私)在 rpc 层先行;本层做 DH 校验、chat_id wire 不变量、access_hash 分配、
|
||||
// 状态机迁移与 qts 队列写入。绑定维度是设备级 perm auth_key(int64)。
|
||||
type Service struct {
|
||||
store store.SecretChatStore
|
||||
queue store.EncryptedQueueStore
|
||||
ids store.SecretChatIDAllocator
|
||||
}
|
||||
|
||||
// NewService 创建密聊服务。
|
||||
func NewService(st store.SecretChatStore, queue store.EncryptedQueueStore, ids store.SecretChatIDAllocator) *Service {
|
||||
return &Service{store: st, queue: queue, ids: ids}
|
||||
func NewService(st store.SecretChatStore, queue store.EncryptedQueueStore) *Service {
|
||||
return &Service{store: st, queue: queue}
|
||||
}
|
||||
|
||||
// RequestEncryption 受理 requestEncryption:校验 g_a → 幂等去重 → 分配 chat_id + 双
|
||||
// RequestEncryption 受理 requestEncryption:校验 g_a → 校验 random_id/chat_id 全局唯一性 → 分配双
|
||||
// access_hash → 盲存 g_a → 落 requested 态。返回的密聊由 rpc 层投影为 admin 视角
|
||||
// encryptedChatWaiting(同步响应)与 participant 视角 encryptedChatRequested(推送)。
|
||||
func (s *Service) RequestEncryption(ctx context.Context, req domain.SecretChatRequest) (domain.SecretChat, error) {
|
||||
if req.AdminUserID == 0 || req.ParticipantUserID == 0 || req.AdminAuthKeyID == 0 {
|
||||
return domain.SecretChat{}, ErrGAInvalid
|
||||
}
|
||||
if req.RandomID == 0 {
|
||||
return domain.SecretChat{}, domain.ErrSecretChatRandomIDDuplicate
|
||||
}
|
||||
ga, err := validateDHParam(req.GA)
|
||||
if err != nil {
|
||||
return domain.SecretChat{}, err
|
||||
}
|
||||
// 幂等:同发起设备 + random_id 重发返回既有 chat(DISCARDED 视为新请求)。
|
||||
if existing, ok, err := s.store.GetByAdminRandom(ctx, req.AdminAuthKeyID, req.RandomID); err != nil {
|
||||
// Telegram wire 契约:requestEncryption.random_id 同时就是 chat_id。TDLib 会先以
|
||||
// random_id 创建本地 SecretChatActor,并在消费响应时强校验 response.id 相等;禁止
|
||||
// 用服务端序列替换。全局主键碰撞只允许相同意图的网络重放,其余显式 duplicate。
|
||||
chatID := int(req.RandomID)
|
||||
if existing, ok, err := s.store.GetSecretChat(ctx, chatID); err != nil {
|
||||
return domain.SecretChat{}, err
|
||||
} else if ok && !existing.Terminal() {
|
||||
return existing, nil
|
||||
} else if ok {
|
||||
if sameSecretChatRequest(existing, req, ga) && !existing.Terminal() {
|
||||
return existing, nil
|
||||
}
|
||||
return domain.SecretChat{}, domain.ErrSecretChatRandomIDDuplicate
|
||||
}
|
||||
adminAH, err := randomAccessHash()
|
||||
if err != nil {
|
||||
|
|
@ -54,6 +60,7 @@ func (s *Service) RequestEncryption(ctx context.Context, req domain.SecretChatRe
|
|||
return domain.SecretChat{}, err
|
||||
}
|
||||
chat := domain.SecretChat{
|
||||
ID: chatID,
|
||||
AdminAccessHash: adminAH,
|
||||
ParticipantAccessHash: participantAH,
|
||||
AdminUserID: req.AdminUserID,
|
||||
|
|
@ -64,46 +71,27 @@ func (s *Service) RequestEncryption(ctx context.Context, req domain.SecretChatRe
|
|||
RandomID: req.RandomID,
|
||||
Date: req.Date,
|
||||
}
|
||||
for attempt := 0; ; attempt++ {
|
||||
chatID, err := s.nextChatID(ctx, attempt)
|
||||
if err != nil {
|
||||
return domain.SecretChat{}, err
|
||||
}
|
||||
chat.ID = chatID
|
||||
err = s.store.CreateSecretChat(ctx, chat)
|
||||
if err == nil {
|
||||
return chat, nil
|
||||
}
|
||||
if errors.Is(err, domain.ErrSecretChatIDConflict) && attempt < idAllocRetries {
|
||||
continue
|
||||
}
|
||||
if err := s.store.CreateSecretChat(ctx, chat); err == nil {
|
||||
return chat, nil
|
||||
} else if !errors.Is(err, domain.ErrSecretChatRandomIDDuplicate) {
|
||||
return domain.SecretChat{}, err
|
||||
}
|
||||
// 并发相同请求可能在预查后由另一 goroutine 插入;只在重新读取后仍证明
|
||||
// 是完全相同意图时收敛为幂等成功。
|
||||
existing, ok, getErr := s.store.GetSecretChat(ctx, chatID)
|
||||
if getErr != nil {
|
||||
return domain.SecretChat{}, getErr
|
||||
}
|
||||
if ok && sameSecretChatRequest(existing, req, ga) && !existing.Terminal() {
|
||||
return existing, nil
|
||||
}
|
||||
return domain.SecretChat{}, domain.ErrSecretChatRandomIDDuplicate
|
||||
}
|
||||
|
||||
// nextChatID 分配下一个 chat_id;撞键后用 AtLeast(MaxSecretChatID) 顶起计数器自愈。
|
||||
// 校验 int32 正区间上界(EncryptedChat.ID 是 int32 量级)。
|
||||
func (s *Service) nextChatID(ctx context.Context, attempt int) (int, error) {
|
||||
var (
|
||||
id int
|
||||
err error
|
||||
)
|
||||
if attempt == 0 {
|
||||
id, err = s.ids.NextSecretChatID(ctx)
|
||||
} else {
|
||||
floor, ferr := s.store.MaxSecretChatID(ctx)
|
||||
if ferr != nil {
|
||||
return 0, ferr
|
||||
}
|
||||
id, err = s.ids.NextSecretChatIDAtLeast(ctx, floor)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if id <= 0 || id > 0x7fffffff {
|
||||
return 0, fmt.Errorf("secretchat: chat id out of int32 range: %d", id)
|
||||
}
|
||||
return id, nil
|
||||
func sameSecretChatRequest(chat domain.SecretChat, req domain.SecretChatRequest, normalizedGA []byte) bool {
|
||||
return chat.ID == int(req.RandomID) && chat.RandomID == req.RandomID &&
|
||||
chat.AdminUserID == req.AdminUserID && chat.AdminAuthKeyID == req.AdminAuthKeyID &&
|
||||
chat.ParticipantUserID == req.ParticipantUserID && bytes.Equal(chat.GA, normalizedGA)
|
||||
}
|
||||
|
||||
// AcceptEncryption 受理 acceptEncryption:定位 + participant 视角 access_hash 校验 →
|
||||
|
|
@ -132,15 +120,24 @@ func (s *Service) AcceptEncryption(ctx context.Context, chatID int, viewerUserID
|
|||
return s.store.AcceptSecretChat(ctx, chatID, participantAuthKeyID, gbPadded, keyFingerprint)
|
||||
}
|
||||
|
||||
// DiscardEncryption 受理 discardEncryption:定位 + 参与者校验 → 迁移到 discarded。
|
||||
// DiscardEncryption 受理 discardEncryption:定位 + 参与者/绑定设备校验 → 迁移到 discarded。
|
||||
// already=true 表示已是终态(幂等成功)。返回的密聊由 rpc 层投影为对端
|
||||
// encryptedChatDiscarded 推送。
|
||||
func (s *Service) DiscardEncryption(ctx context.Context, chatID int, viewerUserID int64, deleteHistory bool) (domain.SecretChat, bool, error) {
|
||||
func (s *Service) DiscardEncryption(ctx context.Context, chatID int, viewerUserID, viewerAuthKeyID int64, deleteHistory bool) (domain.SecretChat, bool, error) {
|
||||
chat, ok, err := s.store.GetSecretChat(ctx, chatID)
|
||||
if err != nil {
|
||||
return domain.SecretChat{}, false, err
|
||||
}
|
||||
if !ok || !chat.HasParticipant(viewerUserID) {
|
||||
if !ok || !chat.HasParticipant(viewerUserID) || viewerAuthKeyID == 0 {
|
||||
return domain.SecretChat{}, false, domain.ErrSecretChatNotFound
|
||||
}
|
||||
// Admin 从 request 起即绑定;participant 在 accept 前尚无绑定,任一收到账号级邀请的
|
||||
// participant 设备都可拒绝。accept 一旦完成,双方所有操作都必须来自各自绑定设备。
|
||||
boundAuthKeyID := chat.AuthKeyOf(viewerUserID)
|
||||
if boundAuthKeyID != 0 && boundAuthKeyID != viewerAuthKeyID {
|
||||
return domain.SecretChat{}, false, domain.ErrSecretChatNotFound
|
||||
}
|
||||
if boundAuthKeyID == 0 && viewerUserID != chat.ParticipantUserID {
|
||||
return domain.SecretChat{}, false, domain.ErrSecretChatNotFound
|
||||
}
|
||||
return s.store.DiscardSecretChat(ctx, chatID, deleteHistory)
|
||||
|
|
@ -180,16 +177,17 @@ func (s *Service) DiscardForAuthKey(ctx context.Context, authKeyID int64) ([]dom
|
|||
return discarded, nil
|
||||
}
|
||||
|
||||
// SendEncrypted 受理 sendEncrypted*:定位 + 发送方视角 access_hash 校验 + 态须 normal →
|
||||
// SendEncrypted 受理 sendEncrypted*:定位 + 发送方绑定设备/access_hash 校验 + 态须 normal →
|
||||
// 给【对端绑定设备】分配 qts 并把不透明 bytes 写入投递队列(幂等:同 chat+random_id 返既有
|
||||
// qts/date)。返回密聊快照 + 已落库消息(携 qts/date,rpc 层据此推 updateNewEncryptedMessage
|
||||
// 并回 SentEncryptedMessage{date})。盲中継:不解密 bytes。
|
||||
func (s *Service) SendEncrypted(ctx context.Context, chatID int, viewerUserID, accessHash int64, delivery domain.SecretMessageDelivery) (domain.SecretChat, domain.SecretChatMessage, error) {
|
||||
func (s *Service) SendEncrypted(ctx context.Context, chatID int, viewerUserID, viewerAuthKeyID, accessHash int64, delivery domain.SecretMessageDelivery) (domain.SecretChat, domain.SecretChatMessage, error) {
|
||||
chat, ok, err := s.store.GetSecretChat(ctx, chatID)
|
||||
if err != nil {
|
||||
return domain.SecretChat{}, domain.SecretChatMessage{}, err
|
||||
}
|
||||
if !ok || !chat.HasParticipant(viewerUserID) || chat.AccessHashFor(viewerUserID) != accessHash {
|
||||
if !ok || !chat.HasParticipant(viewerUserID) || viewerAuthKeyID == 0 ||
|
||||
chat.AuthKeyOf(viewerUserID) != viewerAuthKeyID || chat.AccessHashFor(viewerUserID) != accessHash {
|
||||
return domain.SecretChat{}, domain.SecretChatMessage{}, domain.ErrSecretChatNotFound
|
||||
}
|
||||
if chat.State != domain.SecretChatStateNormal {
|
||||
|
|
|
|||
|
|
@ -3,30 +3,13 @@ package secretchat
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// fakeChatIDAllocator 是单调自增的测试分配器(无 Redis)。
|
||||
type fakeChatIDAllocator struct{ n int }
|
||||
|
||||
func (a *fakeChatIDAllocator) NextSecretChatID(context.Context) (int, error) {
|
||||
a.n++
|
||||
return a.n, nil
|
||||
}
|
||||
|
||||
func (a *fakeChatIDAllocator) NextSecretChatIDAtLeast(_ context.Context, floor int) (int, error) {
|
||||
if a.n < floor {
|
||||
a.n = floor
|
||||
}
|
||||
a.n++
|
||||
return a.n, nil
|
||||
}
|
||||
|
||||
func (a *fakeChatIDAllocator) CurrentSecretChatID(context.Context) (int, error) { return a.n, nil }
|
||||
|
||||
// validGA 返回一个落在合法 DH 区间的 256 字节 g_a(首字节 0x55 ≈ 2^2046,
|
||||
// 既 > 2^1984 又 < p≈0xc7..)。
|
||||
func validGA() []byte {
|
||||
|
|
@ -40,7 +23,7 @@ func validGA() []byte {
|
|||
|
||||
func newTestService() (*Service, *memory.SecretChatStore) {
|
||||
st := memory.NewSecretChatStore()
|
||||
return NewService(st, memory.NewEncryptedQueueStore(), &fakeChatIDAllocator{}), st
|
||||
return NewService(st, memory.NewEncryptedQueueStore()), st
|
||||
}
|
||||
|
||||
const (
|
||||
|
|
@ -48,6 +31,7 @@ const (
|
|||
partUser = int64(2002)
|
||||
adminAuthKey = int64(0x1111)
|
||||
partAuthKey = int64(0x2222)
|
||||
otherAuthKey = int64(0x3333)
|
||||
keyFP = int64(0x0123456789abcdef)
|
||||
)
|
||||
|
||||
|
|
@ -69,8 +53,8 @@ func TestRequestEncryption(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("RequestEncryption: %v", err)
|
||||
}
|
||||
if chat.ID <= 0 || chat.ID > 0x7fffffff {
|
||||
t.Fatalf("chat id out of int32 range: %d", chat.ID)
|
||||
if chat.ID != int(requestFixture().RandomID) {
|
||||
t.Fatalf("chat id = %d, want request random_id %d", chat.ID, requestFixture().RandomID)
|
||||
}
|
||||
if chat.State != domain.SecretChatStateRequested {
|
||||
t.Fatalf("state = %q, want requested", chat.State)
|
||||
|
|
@ -105,6 +89,94 @@ func TestRequestEncryptionIdempotent(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRequestEncryptionConcurrentExactRetry(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
results := make([]domain.SecretChat, 2)
|
||||
errs := make([]error, 2)
|
||||
var wg sync.WaitGroup
|
||||
for i := range results {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
results[i], errs[i] = svc.RequestEncryption(ctx, requestFixture())
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("concurrent request %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if results[0].ID != int(requestFixture().RandomID) || results[1].ID != results[0].ID ||
|
||||
results[1].AdminAccessHash != results[0].AdminAccessHash ||
|
||||
results[1].ParticipantAccessHash != results[0].ParticipantAccessHash {
|
||||
t.Fatalf("concurrent exact retry diverged: first=%+v second=%+v", results[0], results[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestEncryptionPreservesNegativeRandomID(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
req := requestFixture()
|
||||
req.RandomID = -12345
|
||||
chat, err := svc.RequestEncryption(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("request negative random_id: %v", err)
|
||||
}
|
||||
if chat.ID != int(req.RandomID) || chat.RandomID != req.RandomID {
|
||||
t.Fatalf("chat id/random_id = %d/%d, want %d", chat.ID, chat.RandomID, req.RandomID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestEncryptionRejectsChangedIntentAndGlobalCollision(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
if _, err := svc.RequestEncryption(ctx, requestFixture()); err != nil {
|
||||
t.Fatalf("first request: %v", err)
|
||||
}
|
||||
|
||||
changedPeer := requestFixture()
|
||||
changedPeer.ParticipantUserID++
|
||||
if _, err := svc.RequestEncryption(ctx, changedPeer); !errors.Is(err, domain.ErrSecretChatRandomIDDuplicate) {
|
||||
t.Fatalf("changed peer err = %v, want ErrSecretChatRandomIDDuplicate", err)
|
||||
}
|
||||
|
||||
changedGA := requestFixture()
|
||||
changedGA.GA = validGA()
|
||||
changedGA.GA[1] ^= 0x01
|
||||
if _, err := svc.RequestEncryption(ctx, changedGA); !errors.Is(err, domain.ErrSecretChatRandomIDDuplicate) {
|
||||
t.Fatalf("changed g_a err = %v, want ErrSecretChatRandomIDDuplicate", err)
|
||||
}
|
||||
|
||||
otherAuthKey := requestFixture()
|
||||
otherAuthKey.AdminUserID++
|
||||
otherAuthKey.AdminAuthKeyID++
|
||||
if _, err := svc.RequestEncryption(ctx, otherAuthKey); !errors.Is(err, domain.ErrSecretChatRandomIDDuplicate) {
|
||||
t.Fatalf("global collision err = %v, want ErrSecretChatRandomIDDuplicate", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestEncryptionRejectsZeroAndDiscardedReuse(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
zero := requestFixture()
|
||||
zero.RandomID = 0
|
||||
if _, err := svc.RequestEncryption(ctx, zero); !errors.Is(err, domain.ErrSecretChatRandomIDDuplicate) {
|
||||
t.Fatalf("zero random_id err = %v, want ErrSecretChatRandomIDDuplicate", err)
|
||||
}
|
||||
|
||||
chat, err := svc.RequestEncryption(ctx, requestFixture())
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
if _, _, err := svc.DiscardEncryption(ctx, chat.ID, adminUser, adminAuthKey, true); err != nil {
|
||||
t.Fatalf("discard: %v", err)
|
||||
}
|
||||
if _, err := svc.RequestEncryption(ctx, requestFixture()); !errors.Is(err, domain.ErrSecretChatRandomIDDuplicate) {
|
||||
t.Fatalf("discarded reuse err = %v, want ErrSecretChatRandomIDDuplicate", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestEncryptionInvalidGA(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
req := requestFixture()
|
||||
|
|
@ -174,6 +246,63 @@ func TestAcceptEncryptionDoubleAccept(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAcceptEncryptionConcurrentDevicesSingleWinner(t *testing.T) {
|
||||
svc, st := newTestService()
|
||||
ctx := context.Background()
|
||||
chat, err := svc.RequestEncryption(ctx, requestFixture())
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
|
||||
authKeys := []int64{partAuthKey, otherAuthKey}
|
||||
errs := make([]error, len(authKeys))
|
||||
var wg sync.WaitGroup
|
||||
for i, authKeyID := range authKeys {
|
||||
wg.Add(1)
|
||||
go func(i int, authKeyID int64) {
|
||||
defer wg.Done()
|
||||
_, errs[i] = svc.AcceptEncryption(ctx, chat.ID, partUser, authKeyID, chat.ParticipantAccessHash, validGA(), keyFP)
|
||||
}(i, authKeyID)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
winners := 0
|
||||
losers := 0
|
||||
for _, err := range errs {
|
||||
switch {
|
||||
case err == nil:
|
||||
winners++
|
||||
case errors.Is(err, domain.ErrSecretChatAlreadyAccepted):
|
||||
losers++
|
||||
default:
|
||||
t.Fatalf("concurrent accept err = %v", err)
|
||||
}
|
||||
}
|
||||
if winners != 1 || losers != 1 {
|
||||
t.Fatalf("concurrent accepts winners=%d losers=%d, want 1/1", winners, losers)
|
||||
}
|
||||
|
||||
stored, ok, err := st.GetSecretChat(ctx, chat.ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("get accepted chat: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if stored.State != domain.SecretChatStateNormal ||
|
||||
(stored.ParticipantAuthKeyID != partAuthKey && stored.ParticipantAuthKeyID != otherAuthKey) {
|
||||
t.Fatalf("accepted chat = %+v, want normal bound to one participant device", stored)
|
||||
}
|
||||
loserAuthKeyID := partAuthKey
|
||||
if stored.ParticipantAuthKeyID == partAuthKey {
|
||||
loserAuthKeyID = otherAuthKey
|
||||
}
|
||||
if _, _, err := svc.DiscardEncryption(ctx, chat.ID, partUser, loserAuthKeyID, true); !errors.Is(err, domain.ErrSecretChatNotFound) {
|
||||
t.Fatalf("loser discard err = %v, want ErrSecretChatNotFound", err)
|
||||
}
|
||||
stored, ok, err = st.GetSecretChat(ctx, chat.ID)
|
||||
if err != nil || !ok || stored.State != domain.SecretChatStateNormal {
|
||||
t.Fatalf("chat after loser discard = %+v ok=%v err=%v, want normal", stored, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptEncryptionInvalidGB(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
|
|
@ -188,7 +317,7 @@ func TestDiscardEncryption(t *testing.T) {
|
|||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
chat, _ := svc.RequestEncryption(ctx, requestFixture())
|
||||
got, already, err := svc.DiscardEncryption(ctx, chat.ID, adminUser, true)
|
||||
got, already, err := svc.DiscardEncryption(ctx, chat.ID, adminUser, adminAuthKey, true)
|
||||
if err != nil {
|
||||
t.Fatalf("discard: %v", err)
|
||||
}
|
||||
|
|
@ -199,7 +328,7 @@ func TestDiscardEncryption(t *testing.T) {
|
|||
t.Fatalf("discarded chat = %+v", got)
|
||||
}
|
||||
// 幂等:再 discard 返回 already=true。
|
||||
_, already, err = svc.DiscardEncryption(ctx, chat.ID, partUser, false)
|
||||
_, already, err = svc.DiscardEncryption(ctx, chat.ID, partUser, partAuthKey, false)
|
||||
if err != nil || !already {
|
||||
t.Fatalf("idempotent discard: already=%v err=%v", already, err)
|
||||
}
|
||||
|
|
@ -209,7 +338,7 @@ func TestDiscardEncryptionNonParticipant(t *testing.T) {
|
|||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
chat, _ := svc.RequestEncryption(ctx, requestFixture())
|
||||
_, _, err := svc.DiscardEncryption(ctx, chat.ID, int64(9999), false)
|
||||
_, _, err := svc.DiscardEncryption(ctx, chat.ID, int64(9999), int64(9999), false)
|
||||
if !errors.Is(err, domain.ErrSecretChatNotFound) {
|
||||
t.Fatalf("err = %v, want ErrSecretChatNotFound", err)
|
||||
}
|
||||
|
|
@ -245,20 +374,20 @@ func TestSendEncryptedQtsAllocation(t *testing.T) {
|
|||
chat := acceptedChat(t, svc)
|
||||
|
||||
// admin 发 → 投给 participant 设备(partAuthKey),qts 从 1 起。
|
||||
_, m1, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 111, Bytes: []byte{1, 2, 3}, Date: 2000})
|
||||
_, m1, err := svc.SendEncrypted(ctx, chat.ID, adminUser, adminAuthKey, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 111, Bytes: []byte{1, 2, 3}, Date: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("send 1: %v", err)
|
||||
}
|
||||
if m1.Qts != 1 || m1.ReceiverAuthKeyID != partAuthKey || m1.ReceiverUserID != partUser {
|
||||
t.Fatalf("msg1 = %+v (want qts=1, receiver=participant device)", m1)
|
||||
}
|
||||
_, m2, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 222, Bytes: []byte{4}, Date: 2001})
|
||||
_, m2, err := svc.SendEncrypted(ctx, chat.ID, adminUser, adminAuthKey, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 222, Bytes: []byte{4}, Date: 2001})
|
||||
if err != nil || m2.Qts != 2 {
|
||||
t.Fatalf("msg2 qts = %d err=%v, want 2", m2.Qts, err)
|
||||
}
|
||||
|
||||
// 幂等重发同 random_id → 返回首次 qts/date,不分配新 qts。
|
||||
_, dup, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 111, Bytes: []byte{1, 2, 3}, Date: 9999})
|
||||
_, dup, err := svc.SendEncrypted(ctx, chat.ID, adminUser, adminAuthKey, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 111, Bytes: []byte{1, 2, 3}, Date: 9999})
|
||||
if err != nil {
|
||||
t.Fatalf("dup send: %v", err)
|
||||
}
|
||||
|
|
@ -267,7 +396,7 @@ func TestSendEncryptedQtsAllocation(t *testing.T) {
|
|||
}
|
||||
|
||||
// participant 发 → 投给 admin 设备(adminAuthKey),独立 qts 序列从 1 起。
|
||||
_, pm, err := svc.SendEncrypted(ctx, chat.ID, partUser, chat.ParticipantAccessHash, domain.SecretMessageDelivery{RandomID: 333, Bytes: []byte{9}, Date: 2002})
|
||||
_, pm, err := svc.SendEncrypted(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash, domain.SecretMessageDelivery{RandomID: 333, Bytes: []byte{9}, Date: 2002})
|
||||
if err != nil {
|
||||
t.Fatalf("participant send: %v", err)
|
||||
}
|
||||
|
|
@ -280,17 +409,55 @@ func TestSendEncryptedWrongAccessHash(t *testing.T) {
|
|||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
chat := acceptedChat(t, svc)
|
||||
_, _, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash+1, domain.SecretMessageDelivery{RandomID: 1, Bytes: []byte{1}, Date: 2000})
|
||||
_, _, err := svc.SendEncrypted(ctx, chat.ID, adminUser, adminAuthKey, chat.AdminAccessHash+1, domain.SecretMessageDelivery{RandomID: 1, Bytes: []byte{1}, Date: 2000})
|
||||
if !errors.Is(err, domain.ErrSecretChatNotFound) {
|
||||
t.Fatalf("err = %v, want ErrSecretChatNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEncryptedRejectsUnboundAccountDevice(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
chat := acceptedChat(t, svc)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
userID int64
|
||||
accessHash int64
|
||||
}{
|
||||
{name: "admin", userID: adminUser, accessHash: chat.AdminAccessHash},
|
||||
{name: "participant", userID: partUser, accessHash: chat.ParticipantAccessHash},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, err := svc.SendEncrypted(ctx, chat.ID, tc.userID, otherAuthKey, tc.accessHash, domain.SecretMessageDelivery{
|
||||
RandomID: 991, Bytes: []byte{1}, Date: 2000,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrSecretChatNotFound) {
|
||||
t.Fatalf("err = %v, want ErrSecretChatNotFound", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscardEncryptionRejectsUnboundAccountDeviceAfterAccept(t *testing.T) {
|
||||
svc, st := newTestService()
|
||||
ctx := context.Background()
|
||||
chat := acceptedChat(t, svc)
|
||||
|
||||
if _, _, err := svc.DiscardEncryption(ctx, chat.ID, partUser, otherAuthKey, true); !errors.Is(err, domain.ErrSecretChatNotFound) {
|
||||
t.Fatalf("unbound discard err = %v, want ErrSecretChatNotFound", err)
|
||||
}
|
||||
stored, ok, err := st.GetSecretChat(ctx, chat.ID)
|
||||
if err != nil || !ok || stored.State != domain.SecretChatStateNormal {
|
||||
t.Fatalf("chat after rejected discard = %+v ok=%v err=%v", stored, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEncryptedNonNormal(t *testing.T) {
|
||||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
chat, _ := svc.RequestEncryption(ctx, requestFixture()) // requested, 未 accept
|
||||
_, _, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 1, Bytes: []byte{1}, Date: 2000})
|
||||
_, _, err := svc.SendEncrypted(ctx, chat.ID, adminUser, adminAuthKey, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 1, Bytes: []byte{1}, Date: 2000})
|
||||
if !errors.Is(err, domain.ErrSecretChatNotFound) {
|
||||
t.Fatalf("err = %v, want ErrSecretChatNotFound (未成型不能发)", err)
|
||||
}
|
||||
|
|
@ -301,7 +468,7 @@ func TestListNewMessagesAndAck(t *testing.T) {
|
|||
ctx := context.Background()
|
||||
chat := acceptedChat(t, svc)
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, _, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: int64(1000 + i), Bytes: []byte{byte(i)}, Date: 2000 + i}); err != nil {
|
||||
if _, _, err := svc.SendEncrypted(ctx, chat.ID, adminUser, adminAuthKey, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: int64(1000 + i), Bytes: []byte{byte(i)}, Date: 2000 + i}); err != nil {
|
||||
t.Fatalf("send %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -336,7 +503,7 @@ func TestAcceptAfterDiscard(t *testing.T) {
|
|||
svc, _ := newTestService()
|
||||
ctx := context.Background()
|
||||
chat, _ := svc.RequestEncryption(ctx, requestFixture())
|
||||
if _, _, err := svc.DiscardEncryption(ctx, chat.ID, adminUser, false); err != nil {
|
||||
if _, _, err := svc.DiscardEncryption(ctx, chat.ID, adminUser, adminAuthKey, false); err != nil {
|
||||
t.Fatalf("discard: %v", err)
|
||||
}
|
||||
_, err := svc.AcceptEncryption(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash, validGA(), keyFP)
|
||||
|
|
|
|||
|
|
@ -190,6 +190,28 @@ func (s *Service) GetPeerStoryProjections(ctx context.Context, viewerUserID int6
|
|||
return s.stories.GetPeerStoryProjections(ctx, viewerUserID, peers, now)
|
||||
}
|
||||
|
||||
// ActiveStoryPeerExpirations returns the viewer-independent active-story gate
|
||||
// used before the privacy-sensitive peer projection. A missing peer is a
|
||||
// durable negative fact until its story_peer token advances.
|
||||
func (s *Service) ActiveStoryPeerExpirations(ctx context.Context, peers []domain.Peer, now int) (map[domain.Peer]int, error) {
|
||||
if len(peers) > domain.MaxStoryIDs {
|
||||
return nil, domain.ErrStoryIDInvalid
|
||||
}
|
||||
if s == nil || s.stories == nil || len(peers) == 0 {
|
||||
return map[domain.Peer]int{}, nil
|
||||
}
|
||||
return s.stories.ActiveStoryPeerExpirations(ctx, peers, now)
|
||||
}
|
||||
|
||||
// ListHiddenStoryPeers returns one sparse viewer-owned preference snapshot.
|
||||
// It is intentionally separate from active-story visibility and may be empty.
|
||||
func (s *Service) ListHiddenStoryPeers(ctx context.Context, viewerUserID int64) ([]domain.Peer, error) {
|
||||
if s == nil || s.stories == nil || viewerUserID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.stories.ListHiddenStoryPeers(ctx, viewerUserID)
|
||||
}
|
||||
|
||||
func (s *Service) ReadStories(ctx context.Context, viewerUserID int64, peer domain.Peer, maxID, date int) (domain.StoryReadResult, error) {
|
||||
if maxID <= 0 || maxID > domain.MaxStoryID {
|
||||
return domain.StoryReadResult{}, domain.ErrStoryIDInvalid
|
||||
|
|
|
|||
|
|
@ -282,6 +282,41 @@ func (s *Service) DeleteAllowedURL(ctx context.Context, botUserID int64, kind do
|
|||
return s.store.DeleteTelegramLoginAllowedURL(ctx, botUserID, kind, normalized)
|
||||
}
|
||||
|
||||
type WidgetClientResolution struct {
|
||||
ClientID string
|
||||
Origin string
|
||||
}
|
||||
|
||||
// ResolveWidgetClient verifies the public compatibility shim's username
|
||||
// resolution result against the authoritative Login client and registered Web
|
||||
// origin. The username lookup itself stays outside this aggregate; callers
|
||||
// must resolve it to a current bot user before entering this method.
|
||||
func (s *Service) ResolveWidgetClient(ctx context.Context, botUserID int64, rawOrigin string) (WidgetClientResolution, error) {
|
||||
if botUserID <= 0 {
|
||||
return WidgetClientResolution{}, domain.ErrTelegramLoginClientInvalid
|
||||
}
|
||||
origin, err := NormalizeWebOrigin(rawOrigin, s.allowHTTP)
|
||||
if err != nil {
|
||||
return WidgetClientResolution{}, domain.ErrTelegramLoginOriginNotAllowed
|
||||
}
|
||||
client, found, err := s.store.GetTelegramLoginClientByBot(ctx, botUserID)
|
||||
if err != nil {
|
||||
return WidgetClientResolution{}, err
|
||||
}
|
||||
if !found || !client.Enabled || !s.signingAlgorithmSupported(client.SigningAlgorithm) ||
|
||||
client.BotUserID != botUserID || client.ClientID != strconv.FormatInt(botUserID, 10) {
|
||||
return WidgetClientResolution{}, domain.ErrTelegramLoginClientDisabled
|
||||
}
|
||||
allowed, err := s.store.IsTelegramLoginURLAllowed(ctx, botUserID, domain.TelegramLoginAllowedWebOrigin, origin)
|
||||
if err != nil {
|
||||
return WidgetClientResolution{}, err
|
||||
}
|
||||
if !allowed {
|
||||
return WidgetClientResolution{}, domain.ErrTelegramLoginOriginNotAllowed
|
||||
}
|
||||
return WidgetClientResolution{ClientID: client.ClientID, Origin: origin}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetClientEnabled(ctx context.Context, botUserID int64, enabled bool) error {
|
||||
if enabled {
|
||||
client, found, err := s.store.GetTelegramLoginClientByBot(ctx, botUserID)
|
||||
|
|
|
|||
|
|
@ -19,12 +19,21 @@ const (
|
|||
// Normal correctness relies on write-path invalidation, not natural expiry.
|
||||
DefaultContactProjectionCacheTTL = 24 * time.Hour
|
||||
|
||||
contactSnapshotMaxViewers = 4096
|
||||
contactReversePairMaxEntries = 262144
|
||||
contactPersonalPhotoSnapshotCap = 4096
|
||||
// DefaultContactSnapshotMaxViewers covers the 10k online target plus bounded
|
||||
// reconnect overlap. Eviction is exact LRU; reaching the limit must never
|
||||
// clear every viewer snapshot at once.
|
||||
DefaultContactSnapshotMaxViewers = 16_384
|
||||
contactReversePairMaxEntries = 262144
|
||||
contactProjectionPairMaxEntries = 262144
|
||||
// One dense request must not monopolize the pair LRU or hold the global
|
||||
// cache lock while inserting and evicting hundreds of thousands of cells.
|
||||
// Larger results are still returned; they simply are not admitted per pair.
|
||||
contactProjectionDenseAdmissionMaxCells = contactProjectionPairMaxEntries / 16
|
||||
)
|
||||
|
||||
type contactAccountSnapshot struct {
|
||||
// contacts and ordered are immutable after the snapshot is published in
|
||||
// CachedContactStore.contacts. Readers intentionally retain shallow copies.
|
||||
contacts map[int64]domain.Contact
|
||||
ordered []domain.Contact
|
||||
hash int64
|
||||
|
|
@ -32,6 +41,7 @@ type contactAccountSnapshot struct {
|
|||
}
|
||||
|
||||
type personalPhotoSnapshot struct {
|
||||
// refs is immutable after the snapshot is published in personalPhotos.
|
||||
refs map[int64]domain.ProfilePhotoRef
|
||||
expireAt time.Time
|
||||
}
|
||||
|
|
@ -42,8 +52,8 @@ type reverseContactKey struct {
|
|||
}
|
||||
|
||||
type reverseContactSnapshot struct {
|
||||
contact domain.Contact
|
||||
found bool
|
||||
// contact is an immutable cached clone. nil is the negative-cache value.
|
||||
contact *domain.Contact
|
||||
expireAt time.Time
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +62,79 @@ type reverseContactEntry struct {
|
|||
snapshot reverseContactSnapshot
|
||||
}
|
||||
|
||||
type contactProjectionKey struct {
|
||||
viewerUserID int64
|
||||
contactUserID int64
|
||||
}
|
||||
|
||||
// cachedContactProjectionOverlay is the viewer-owned part of a contact row.
|
||||
// Base user data is loaded and cached independently, so retaining domain.User
|
||||
// here would multiply a large, viewer-independent value across every pair.
|
||||
// Values are immutable after publication; noteEntities is cloned on both sides
|
||||
// of the cache boundary.
|
||||
type cachedContactProjectionOverlay struct {
|
||||
firstName string
|
||||
lastName string
|
||||
phone string
|
||||
note string
|
||||
noteEntities []domain.MessageEntity
|
||||
mutual bool
|
||||
closeFriend bool
|
||||
}
|
||||
|
||||
func newCachedContactProjectionOverlay(contact domain.Contact) *cachedContactProjectionOverlay {
|
||||
return &cachedContactProjectionOverlay{
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
func (o *cachedContactProjectionOverlay) domainContact(contactUserID int64) domain.Contact {
|
||||
if o == nil {
|
||||
return domain.Contact{}
|
||||
}
|
||||
return domain.Contact{
|
||||
User: domain.User{ID: contactUserID},
|
||||
FirstName: o.firstName,
|
||||
LastName: o.lastName,
|
||||
Phone: o.phone,
|
||||
Note: o.note,
|
||||
NoteEntities: append([]domain.MessageEntity(nil), o.noteEntities...),
|
||||
Mutual: o.mutual,
|
||||
CloseFriend: o.closeFriend,
|
||||
}
|
||||
}
|
||||
|
||||
type contactProjectionSnapshot struct {
|
||||
// Positive values point at immutable cached clones; nil is negative. Keeping
|
||||
// the compact viewer-owned overlay outside the entry makes negative pairs
|
||||
// consume only two pointers plus their expiry and avoids duplicating a full
|
||||
// base User for every positive pair.
|
||||
contact *cachedContactProjectionOverlay
|
||||
personalPhoto *domain.ProfilePhotoRef
|
||||
expireAt time.Time
|
||||
}
|
||||
|
||||
// contactProjectionLookup is a transient, caller-owned copy. It deliberately
|
||||
// retains the old value+found shape so no mutable slice from a cached pointer is
|
||||
// exposed after the cache lock is released.
|
||||
type contactProjectionLookup struct {
|
||||
contact domain.Contact
|
||||
contactFound bool
|
||||
personalPhoto domain.ProfilePhotoRef
|
||||
personalPhotoFound bool
|
||||
}
|
||||
|
||||
type contactProjectionEntry struct {
|
||||
key contactProjectionKey
|
||||
snapshot contactProjectionSnapshot
|
||||
}
|
||||
|
||||
type contactSnapshotLoadResult struct {
|
||||
snap contactAccountSnapshot
|
||||
stored bool
|
||||
|
|
@ -67,6 +150,21 @@ type personalPhotoSnapshotLoadResult struct {
|
|||
stored bool
|
||||
}
|
||||
|
||||
type contactProjectionLoadResult struct {
|
||||
batch domain.ContactProjectionBatch
|
||||
current bool
|
||||
}
|
||||
|
||||
type contactCacheViewerFence struct {
|
||||
userID int64
|
||||
generation uint64
|
||||
}
|
||||
|
||||
type contactCacheFence struct {
|
||||
flushGeneration uint64
|
||||
viewers []contactCacheViewerFence
|
||||
}
|
||||
|
||||
// CachedContactStore wraps ContactStore with account-level read model snapshots.
|
||||
//
|
||||
// Contact data is low-churn and high-read: TDesktop repeatedly asks for the same
|
||||
|
|
@ -79,34 +177,65 @@ type CachedContactStore struct {
|
|||
ttl time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.RWMutex
|
||||
contacts map[int64]contactAccountSnapshot
|
||||
personalPhotos map[int64]personalPhotoSnapshot
|
||||
reverse map[reverseContactKey]*list.Element
|
||||
reverseLRU *list.List
|
||||
reverseByOwner map[int64]map[int64]struct{}
|
||||
reverseCap int
|
||||
epoch uint64
|
||||
sf singleflight.Group
|
||||
mu sync.RWMutex
|
||||
contacts map[int64]contactAccountSnapshot
|
||||
contactLRU *list.List
|
||||
contactElements map[int64]*list.Element
|
||||
contactCap int
|
||||
personalPhotos map[int64]personalPhotoSnapshot
|
||||
personalPhotoLRU *list.List
|
||||
personalElements map[int64]*list.Element
|
||||
personalPhotoCap int
|
||||
reverse map[reverseContactKey]*list.Element
|
||||
reverseLRU *list.List
|
||||
reverseByOwner map[int64]map[int64]struct{}
|
||||
reverseCap int
|
||||
projection map[contactProjectionKey]*list.Element
|
||||
projectionLRU *list.List
|
||||
projectionByViewer map[int64]map[int64]struct{}
|
||||
projectionByTarget map[int64]map[int64]struct{}
|
||||
projectionCap int
|
||||
flushGeneration uint64
|
||||
viewerGenerations map[int64]uint64
|
||||
sf singleflight.Group
|
||||
}
|
||||
|
||||
func NewCachedContactStore(inner store.ContactStore, ttl time.Duration) *CachedContactStore {
|
||||
return NewCachedContactStoreWithMaxViewers(inner, ttl, DefaultContactSnapshotMaxViewers)
|
||||
}
|
||||
|
||||
func NewCachedContactStoreWithMaxViewers(inner store.ContactStore, ttl time.Duration, maxViewers int) *CachedContactStore {
|
||||
if inner == nil {
|
||||
return nil
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultContactProjectionCacheTTL
|
||||
}
|
||||
if maxViewers <= 0 {
|
||||
maxViewers = DefaultContactSnapshotMaxViewers
|
||||
}
|
||||
return &CachedContactStore{
|
||||
inner: inner,
|
||||
ttl: ttl,
|
||||
now: time.Now,
|
||||
contacts: make(map[int64]contactAccountSnapshot, 1024),
|
||||
personalPhotos: make(map[int64]personalPhotoSnapshot, 1024),
|
||||
reverse: make(map[reverseContactKey]*list.Element, 4096),
|
||||
reverseLRU: list.New(),
|
||||
reverseByOwner: make(map[int64]map[int64]struct{}, 1024),
|
||||
reverseCap: contactReversePairMaxEntries,
|
||||
inner: inner,
|
||||
ttl: ttl,
|
||||
now: time.Now,
|
||||
contacts: make(map[int64]contactAccountSnapshot, 1024),
|
||||
contactLRU: list.New(),
|
||||
contactElements: make(map[int64]*list.Element, 1024),
|
||||
contactCap: maxViewers,
|
||||
personalPhotos: make(map[int64]personalPhotoSnapshot, 1024),
|
||||
personalPhotoLRU: list.New(),
|
||||
personalElements: make(map[int64]*list.Element, 1024),
|
||||
personalPhotoCap: maxViewers,
|
||||
reverse: make(map[reverseContactKey]*list.Element, 4096),
|
||||
reverseLRU: list.New(),
|
||||
reverseByOwner: make(map[int64]map[int64]struct{}, 1024),
|
||||
reverseCap: contactReversePairMaxEntries,
|
||||
projection: make(map[contactProjectionKey]*list.Element, 4096),
|
||||
projectionLRU: list.New(),
|
||||
projectionByViewer: make(map[int64]map[int64]struct{}, 1024),
|
||||
projectionByTarget: make(map[int64]map[int64]struct{}, 1024),
|
||||
projectionCap: contactProjectionPairMaxEntries,
|
||||
viewerGenerations: make(map[int64]uint64, 1024),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,6 +326,157 @@ func (c *CachedContactStore) GetReverseContacts(ctx context.Context, userID int6
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) ContactProjectionForViewers(ctx context.Context, viewerUserIDs, contactUserIDs []int64) (domain.ContactProjectionBatch, error) {
|
||||
viewers := dedupContactIDs(viewerUserIDs)
|
||||
targets := dedupContactIDs(contactUserIDs)
|
||||
if len(viewers) == 0 || len(targets) == 0 {
|
||||
return domain.ContactProjectionBatch{
|
||||
Contacts: map[int64]map[int64]domain.Contact{},
|
||||
PersonalPhotos: map[int64]map[int64]domain.ProfilePhotoRef{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
for {
|
||||
out := domain.ContactProjectionBatch{
|
||||
Contacts: make(map[int64]map[int64]domain.Contact, len(viewers)),
|
||||
PersonalPhotos: make(map[int64]map[int64]domain.ProfilePhotoRef, len(viewers)),
|
||||
}
|
||||
readFence := c.captureCacheFenceSlices(viewers, targets)
|
||||
now := c.now()
|
||||
coldViewers := make(map[int64]struct{}, len(viewers))
|
||||
coldTargets := make(map[int64]struct{}, len(targets))
|
||||
for _, viewerID := range viewers {
|
||||
var contactSnap contactAccountSnapshot
|
||||
contactsWarm := false
|
||||
if snap, ok := c.lookupContactSnapshot(viewerID, now); ok {
|
||||
contactsWarm = true
|
||||
contactSnap = snap
|
||||
for _, targetID := range targets {
|
||||
if contact, found := snap.contacts[targetID]; found {
|
||||
putContactProjectionContact(&out, viewerID, targetID, contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
personalPhotosWarm := false
|
||||
if snap, ok := c.lookupPersonalPhotoSnapshot(viewerID, now); ok {
|
||||
personalPhotosWarm = true
|
||||
for _, targetID := range targets {
|
||||
if ref, found := snap.refs[targetID]; found {
|
||||
putContactProjectionPersonalPhoto(&out, viewerID, targetID, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, targetID := range targets {
|
||||
if contactsWarm && personalPhotosWarm {
|
||||
continue
|
||||
}
|
||||
if contactsWarm {
|
||||
if _, found := contactSnap.contacts[targetID]; !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if snap, ok := c.lookupContactProjectionPair(viewerID, targetID, now); ok {
|
||||
if !contactsWarm && snap.contactFound {
|
||||
putContactProjectionContact(&out, viewerID, targetID, snap.contact)
|
||||
}
|
||||
if !personalPhotosWarm && snap.personalPhotoFound {
|
||||
putContactProjectionPersonalPhoto(&out, viewerID, targetID, snap.personalPhoto)
|
||||
}
|
||||
continue
|
||||
}
|
||||
coldViewers[viewerID] = struct{}{}
|
||||
coldTargets[targetID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if !c.cacheFenceCurrent(readFence) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return domain.ContactProjectionBatch{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(coldViewers) == 0 || len(coldTargets) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
cold := make([]int64, 0, len(coldViewers))
|
||||
for viewerID := range coldViewers {
|
||||
cold = append(cold, viewerID)
|
||||
}
|
||||
coldIDs := make([]int64, 0, len(coldTargets))
|
||||
for targetID := range coldTargets {
|
||||
coldIDs = append(coldIDs, targetID)
|
||||
}
|
||||
loaded, err := c.loadContactProjectionForViewers(ctx, cold, coldIDs)
|
||||
if err != nil {
|
||||
return domain.ContactProjectionBatch{}, err
|
||||
}
|
||||
if !c.cacheFenceCurrent(readFence) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return domain.ContactProjectionBatch{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
mergeContactProjectionBatch(&out, loaded)
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) loadContactProjectionForViewers(ctx context.Context, viewerUserIDs, contactUserIDs []int64) (domain.ContactProjectionBatch, error) {
|
||||
viewers := append([]int64(nil), viewerUserIDs...)
|
||||
targets := append([]int64(nil), contactUserIDs...)
|
||||
sort.Slice(viewers, func(i, j int) bool { return viewers[i] < viewers[j] })
|
||||
sort.Slice(targets, func(i, j int) bool { return targets[i] < targets[j] })
|
||||
sfKey := fmt.Sprintf("contact-projection:%v:%v", viewers, targets)
|
||||
for {
|
||||
v, err, _ := c.sf.Do(sfKey, func() (any, error) {
|
||||
loadFence := c.captureCacheFenceSlices(viewers, targets)
|
||||
batch, err := c.inner.ContactProjectionForViewers(ctx, viewers, targets)
|
||||
if err != nil {
|
||||
return contactProjectionLoadResult{}, err
|
||||
}
|
||||
now := c.now()
|
||||
expireAt := now.Add(c.ttl)
|
||||
admitPairs := admitDenseContactProjectionPairs(len(viewers), len(targets))
|
||||
c.mu.Lock()
|
||||
current := c.cacheFenceCurrentLocked(loadFence)
|
||||
if current && admitPairs {
|
||||
for _, viewerID := range viewers {
|
||||
for _, targetID := range targets {
|
||||
contact, contactFound := batch.Contacts[viewerID][targetID]
|
||||
ref, personalPhotoFound := batch.PersonalPhotos[viewerID][targetID]
|
||||
c.storeContactProjectionPairLocked(
|
||||
contactProjectionKey{viewerUserID: viewerID, contactUserID: targetID},
|
||||
contact, contactFound, ref, personalPhotoFound, expireAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return contactProjectionLoadResult{
|
||||
batch: cloneContactProjectionBatch(batch),
|
||||
current: current,
|
||||
}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ContactProjectionBatch{}, err
|
||||
}
|
||||
result := v.(contactProjectionLoadResult)
|
||||
if result.current {
|
||||
return result.batch, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return domain.ContactProjectionBatch{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func admitDenseContactProjectionPairs(viewerCount, targetCount int) bool {
|
||||
if viewerCount <= 0 || targetCount <= 0 || targetCount > contactProjectionDenseAdmissionMaxCells {
|
||||
return false
|
||||
}
|
||||
// Division avoids overflowing int for attacker-controlled vector lengths.
|
||||
return viewerCount <= contactProjectionDenseAdmissionMaxCells/targetCount
|
||||
}
|
||||
|
||||
// loadReverseContacts performs at most one batched cold-store read for all
|
||||
// missing owner→viewer pairs, then caches both hits and misses. Privacy
|
||||
// projection therefore stays memory-only after warm-up instead of repeating a
|
||||
|
|
@ -207,7 +487,7 @@ func (c *CachedContactStore) loadReverseContacts(ctx context.Context, userID int
|
|||
sfKey := fmt.Sprintf("contact-reverse:%d:%v", userID, owners)
|
||||
for {
|
||||
v, err, _ := c.sf.Do(sfKey, func() (any, error) {
|
||||
loadEpoch := c.cacheEpoch()
|
||||
loadFence := c.captureCacheFenceSlices(owners, []int64{userID})
|
||||
contacts, err := c.inner.GetReverseContacts(ctx, userID, owners)
|
||||
if err != nil {
|
||||
return reverseContactLoadResult{}, err
|
||||
|
|
@ -215,16 +495,12 @@ func (c *CachedContactStore) loadReverseContacts(ctx context.Context, userID int
|
|||
now := c.now()
|
||||
expireAt := now.Add(c.ttl)
|
||||
c.mu.Lock()
|
||||
stored := c.epoch == loadEpoch
|
||||
stored := c.cacheFenceCurrentLocked(loadFence)
|
||||
if stored {
|
||||
for _, ownerID := range owners {
|
||||
key := reverseContactKey{ownerUserID: ownerID, contactUserID: userID}
|
||||
contact, found := contacts[ownerID]
|
||||
c.storeReverseContactLocked(key, reverseContactSnapshot{
|
||||
contact: cloneCachedContact(contact),
|
||||
found: found,
|
||||
expireAt: expireAt,
|
||||
})
|
||||
c.storeReverseContactLocked(key, contact, found, expireAt)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
|
@ -249,6 +525,9 @@ func (c *CachedContactStore) loadReverseContacts(ctx context.Context, userID int
|
|||
func (c *CachedContactStore) Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
|
||||
contact, err := c.inner.Upsert(ctx, userID, input)
|
||||
if err == nil {
|
||||
// Published account snapshots are immutable. Invalidate instead of
|
||||
// modifying their inner maps/slices in place or publishing a mutation
|
||||
// payload whose cache-write order may differ from its DB commit order.
|
||||
c.InvalidateViewers(userID, input.ContactUserID)
|
||||
}
|
||||
return contact, err
|
||||
|
|
@ -257,12 +536,11 @@ func (c *CachedContactStore) Upsert(ctx context.Context, userID int64, input dom
|
|||
func (c *CachedContactStore) UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error) {
|
||||
contacts, err := c.inner.UpsertMany(ctx, userID, inputs)
|
||||
if err == nil {
|
||||
ids := make([]int64, 0, len(inputs)+1)
|
||||
ids = append(ids, userID)
|
||||
ids := make([]int64, 0, len(inputs))
|
||||
for _, input := range inputs {
|
||||
ids = append(ids, input.ContactUserID)
|
||||
}
|
||||
c.InvalidateViewers(ids...)
|
||||
c.InvalidateViewers(append([]int64{userID}, ids...)...)
|
||||
}
|
||||
return contacts, err
|
||||
}
|
||||
|
|
@ -270,7 +548,9 @@ func (c *CachedContactStore) UpsertMany(ctx context.Context, userID int64, input
|
|||
func (c *CachedContactStore) UpdateNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, bool, error) {
|
||||
contact, found, err := c.inner.UpdateNote(ctx, userID, contactUserID, note, entities)
|
||||
if err == nil {
|
||||
c.InvalidateViewers(userID)
|
||||
if found {
|
||||
c.InvalidateViewers(userID)
|
||||
}
|
||||
}
|
||||
return contact, found, err
|
||||
}
|
||||
|
|
@ -285,7 +565,10 @@ func (c *CachedContactStore) SetCloseFriends(ctx context.Context, userID int64,
|
|||
|
||||
func (c *CachedContactStore) SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) {
|
||||
contact, found, err := c.inner.SetPersonalPhoto(ctx, userID, contactUserID, photoID, date)
|
||||
if err == nil {
|
||||
if err == nil && found {
|
||||
// Do not perform a post-commit read followed by write-through: two
|
||||
// concurrent mutations can complete their cache writes in the opposite
|
||||
// order and reinsert a stale pair after a newer NOTIFY invalidation.
|
||||
c.InvalidateViewers(userID)
|
||||
}
|
||||
return contact, found, err
|
||||
|
|
@ -314,10 +597,7 @@ func (c *CachedContactStore) PersonalPhotos(ctx context.Context, userID int64, c
|
|||
func (c *CachedContactStore) Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) {
|
||||
count, err := c.inner.Delete(ctx, userID, contactUserIDs)
|
||||
if err == nil {
|
||||
ids := make([]int64, 0, len(contactUserIDs)+1)
|
||||
ids = append(ids, userID)
|
||||
ids = append(ids, contactUserIDs...)
|
||||
c.InvalidateViewers(ids...)
|
||||
c.InvalidateViewers(append([]int64{userID}, contactUserIDs...)...)
|
||||
}
|
||||
return count, err
|
||||
}
|
||||
|
|
@ -356,20 +636,16 @@ func (c *CachedContactStore) contactSnapshot(ctx context.Context, userID int64)
|
|||
if snap, ok := c.lookupContactSnapshot(userID, now); ok {
|
||||
return contactSnapshotLoadResult{snap: snap, stored: true}, nil
|
||||
}
|
||||
loadEpoch := c.cacheEpoch()
|
||||
loadFence := c.captureCacheFence(userID)
|
||||
list, err := c.inner.ListByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return contactSnapshotLoadResult{}, err
|
||||
}
|
||||
snap := buildContactAccountSnapshot(list, now.Add(c.ttl))
|
||||
c.mu.Lock()
|
||||
stored := c.epoch == loadEpoch
|
||||
stored := c.cacheFenceCurrentLocked(loadFence)
|
||||
if stored {
|
||||
if len(c.contacts) >= contactSnapshotMaxViewers {
|
||||
c.contacts = make(map[int64]contactAccountSnapshot, 1024)
|
||||
c.personalPhotos = make(map[int64]personalPhotoSnapshot, 1024)
|
||||
}
|
||||
c.contacts[userID] = snap
|
||||
c.storeContactSnapshotLocked(userID, snap)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return contactSnapshotLoadResult{snap: snap, stored: stored}, nil
|
||||
|
|
@ -388,18 +664,45 @@ func (c *CachedContactStore) contactSnapshot(ctx context.Context, userID int64)
|
|||
}
|
||||
|
||||
func (c *CachedContactStore) lookupContactSnapshot(userID int64, now time.Time) (contactAccountSnapshot, bool) {
|
||||
c.mu.RLock()
|
||||
c.mu.Lock()
|
||||
snap, ok := c.contacts[userID]
|
||||
c.mu.RUnlock()
|
||||
if !ok || !snap.expireAt.After(now) {
|
||||
if ok {
|
||||
c.InvalidateViewers(userID)
|
||||
}
|
||||
if !ok {
|
||||
c.mu.Unlock()
|
||||
return contactAccountSnapshot{}, false
|
||||
}
|
||||
if !snap.expireAt.After(now) {
|
||||
c.advanceViewerGenerationLocked(userID)
|
||||
c.invalidateViewerLocked(userID)
|
||||
c.mu.Unlock()
|
||||
return contactAccountSnapshot{}, false
|
||||
}
|
||||
if element := c.contactElements[userID]; element != nil {
|
||||
c.contactLRU.MoveToFront(element)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return snap, true
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) storeContactSnapshotLocked(userID int64, snap contactAccountSnapshot) {
|
||||
if element := c.contactElements[userID]; element != nil {
|
||||
c.contacts[userID] = snap
|
||||
c.contactLRU.MoveToFront(element)
|
||||
return
|
||||
}
|
||||
c.contacts[userID] = snap
|
||||
c.contactElements[userID] = c.contactLRU.PushFront(userID)
|
||||
for c.contactLRU.Len() > c.contactCap {
|
||||
oldest := c.contactLRU.Back()
|
||||
if oldest == nil {
|
||||
break
|
||||
}
|
||||
oldestUserID := oldest.Value.(int64)
|
||||
delete(c.contacts, oldestUserID)
|
||||
delete(c.contactElements, oldestUserID)
|
||||
c.contactLRU.Remove(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) personalPhotoSnapshot(ctx context.Context, userID int64) (personalPhotoSnapshot, error) {
|
||||
for {
|
||||
if snap, ok := c.lookupPersonalPhotoSnapshot(userID, c.now()); ok {
|
||||
|
|
@ -410,7 +713,7 @@ func (c *CachedContactStore) personalPhotoSnapshot(ctx context.Context, userID i
|
|||
if snap, ok := c.lookupPersonalPhotoSnapshot(userID, now); ok {
|
||||
return personalPhotoSnapshotLoadResult{snap: snap, stored: true}, nil
|
||||
}
|
||||
loadEpoch := c.cacheEpoch()
|
||||
loadFence := c.captureCacheFence(userID)
|
||||
contacts, err := c.contactSnapshot(ctx, userID)
|
||||
if err != nil {
|
||||
return personalPhotoSnapshotLoadResult{}, err
|
||||
|
|
@ -428,12 +731,9 @@ func (c *CachedContactStore) personalPhotoSnapshot(ctx context.Context, userID i
|
|||
}
|
||||
snap := personalPhotoSnapshot{refs: cloneCachedProfilePhotoRefs(refs), expireAt: now.Add(c.ttl)}
|
||||
c.mu.Lock()
|
||||
stored := c.epoch == loadEpoch
|
||||
stored := c.cacheFenceCurrentLocked(loadFence)
|
||||
if stored {
|
||||
if len(c.personalPhotos) >= contactPersonalPhotoSnapshotCap {
|
||||
c.personalPhotos = make(map[int64]personalPhotoSnapshot, 1024)
|
||||
}
|
||||
c.personalPhotos[userID] = snap
|
||||
c.storePersonalPhotoSnapshotLocked(userID, snap)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return personalPhotoSnapshotLoadResult{snap: snap, stored: stored}, nil
|
||||
|
|
@ -452,18 +752,45 @@ func (c *CachedContactStore) personalPhotoSnapshot(ctx context.Context, userID i
|
|||
}
|
||||
|
||||
func (c *CachedContactStore) lookupPersonalPhotoSnapshot(userID int64, now time.Time) (personalPhotoSnapshot, bool) {
|
||||
c.mu.RLock()
|
||||
c.mu.Lock()
|
||||
snap, ok := c.personalPhotos[userID]
|
||||
c.mu.RUnlock()
|
||||
if !ok || !snap.expireAt.After(now) {
|
||||
if ok {
|
||||
c.InvalidateViewers(userID)
|
||||
}
|
||||
if !ok {
|
||||
c.mu.Unlock()
|
||||
return personalPhotoSnapshot{}, false
|
||||
}
|
||||
if !snap.expireAt.After(now) {
|
||||
c.advanceViewerGenerationLocked(userID)
|
||||
c.invalidateViewerLocked(userID)
|
||||
c.mu.Unlock()
|
||||
return personalPhotoSnapshot{}, false
|
||||
}
|
||||
if element := c.personalElements[userID]; element != nil {
|
||||
c.personalPhotoLRU.MoveToFront(element)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return snap, true
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) storePersonalPhotoSnapshotLocked(userID int64, snap personalPhotoSnapshot) {
|
||||
if element := c.personalElements[userID]; element != nil {
|
||||
c.personalPhotos[userID] = snap
|
||||
c.personalPhotoLRU.MoveToFront(element)
|
||||
return
|
||||
}
|
||||
c.personalPhotos[userID] = snap
|
||||
c.personalElements[userID] = c.personalPhotoLRU.PushFront(userID)
|
||||
for c.personalPhotoLRU.Len() > c.personalPhotoCap {
|
||||
oldest := c.personalPhotoLRU.Back()
|
||||
if oldest == nil {
|
||||
break
|
||||
}
|
||||
oldestUserID := oldest.Value.(int64)
|
||||
delete(c.personalPhotos, oldestUserID)
|
||||
delete(c.personalElements, oldestUserID)
|
||||
c.personalPhotoLRU.Remove(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) lookupReverseContact(ownerUserID, contactUserID int64, now time.Time) (domain.Contact, bool, bool) {
|
||||
key := reverseContactKey{ownerUserID: ownerUserID, contactUserID: contactUserID}
|
||||
c.mu.Lock()
|
||||
|
|
@ -481,10 +808,19 @@ func (c *CachedContactStore) lookupReverseContact(ownerUserID, contactUserID int
|
|||
}
|
||||
c.reverseLRU.MoveToFront(element)
|
||||
c.mu.Unlock()
|
||||
return cloneCachedContact(snap.contact), snap.found, true
|
||||
if snap.contact == nil {
|
||||
return domain.Contact{}, false, true
|
||||
}
|
||||
return cloneCachedContact(*snap.contact), true, true
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) storeReverseContactLocked(key reverseContactKey, snapshot reverseContactSnapshot) {
|
||||
func (c *CachedContactStore) storeReverseContactLocked(key reverseContactKey, contact domain.Contact, found bool, expireAt time.Time) {
|
||||
var cached *domain.Contact
|
||||
if found {
|
||||
clone := cloneCachedContact(contact)
|
||||
cached = &clone
|
||||
}
|
||||
snapshot := reverseContactSnapshot{contact: cached, expireAt: expireAt}
|
||||
if element, ok := c.reverse[key]; ok {
|
||||
entry := element.Value.(*reverseContactEntry)
|
||||
entry.snapshot = snapshot
|
||||
|
|
@ -517,46 +853,222 @@ func (c *CachedContactStore) removeReverseElementLocked(element *list.Element) {
|
|||
c.reverseLRU.Remove(element)
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) lookupContactProjectionPair(viewerUserID, contactUserID int64, now time.Time) (contactProjectionLookup, bool) {
|
||||
key := contactProjectionKey{viewerUserID: viewerUserID, contactUserID: contactUserID}
|
||||
c.mu.Lock()
|
||||
element, ok := c.projection[key]
|
||||
if !ok {
|
||||
c.mu.Unlock()
|
||||
return contactProjectionLookup{}, false
|
||||
}
|
||||
entry := element.Value.(*contactProjectionEntry)
|
||||
snap := entry.snapshot
|
||||
if !snap.expireAt.After(now) {
|
||||
c.removeContactProjectionElementLocked(element)
|
||||
c.mu.Unlock()
|
||||
return contactProjectionLookup{}, false
|
||||
}
|
||||
c.projectionLRU.MoveToFront(element)
|
||||
c.mu.Unlock()
|
||||
result := contactProjectionLookup{}
|
||||
if snap.contact != nil {
|
||||
result.contact = snap.contact.domainContact(contactUserID)
|
||||
result.contactFound = true
|
||||
}
|
||||
if snap.personalPhoto != nil {
|
||||
result.personalPhoto = cloneCachedProfilePhotoRef(*snap.personalPhoto)
|
||||
result.personalPhotoFound = true
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) storeContactProjectionPairLocked(
|
||||
key contactProjectionKey,
|
||||
contact domain.Contact,
|
||||
contactFound bool,
|
||||
personalPhoto domain.ProfilePhotoRef,
|
||||
personalPhotoFound bool,
|
||||
expireAt time.Time,
|
||||
) {
|
||||
var cachedContact *cachedContactProjectionOverlay
|
||||
if contactFound {
|
||||
cachedContact = newCachedContactProjectionOverlay(contact)
|
||||
}
|
||||
var cachedPersonalPhoto *domain.ProfilePhotoRef
|
||||
if personalPhotoFound {
|
||||
clone := cloneCachedProfilePhotoRef(personalPhoto)
|
||||
cachedPersonalPhoto = &clone
|
||||
}
|
||||
snapshot := contactProjectionSnapshot{
|
||||
contact: cachedContact,
|
||||
personalPhoto: cachedPersonalPhoto,
|
||||
expireAt: expireAt,
|
||||
}
|
||||
if element, ok := c.projection[key]; ok {
|
||||
entry := element.Value.(*contactProjectionEntry)
|
||||
entry.snapshot = snapshot
|
||||
c.projectionLRU.MoveToFront(element)
|
||||
return
|
||||
}
|
||||
element := c.projectionLRU.PushFront(&contactProjectionEntry{key: key, snapshot: snapshot})
|
||||
c.projection[key] = element
|
||||
if c.projectionByViewer[key.viewerUserID] == nil {
|
||||
c.projectionByViewer[key.viewerUserID] = make(map[int64]struct{})
|
||||
}
|
||||
c.projectionByViewer[key.viewerUserID][key.contactUserID] = struct{}{}
|
||||
if c.projectionByTarget[key.contactUserID] == nil {
|
||||
c.projectionByTarget[key.contactUserID] = make(map[int64]struct{})
|
||||
}
|
||||
c.projectionByTarget[key.contactUserID][key.viewerUserID] = struct{}{}
|
||||
for c.projectionLRU.Len() > c.projectionCap {
|
||||
c.removeContactProjectionElementLocked(c.projectionLRU.Back())
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) removeContactProjectionElementLocked(element *list.Element) {
|
||||
if element == nil {
|
||||
return
|
||||
}
|
||||
entry := element.Value.(*contactProjectionEntry)
|
||||
delete(c.projection, entry.key)
|
||||
if targets := c.projectionByViewer[entry.key.viewerUserID]; targets != nil {
|
||||
delete(targets, entry.key.contactUserID)
|
||||
if len(targets) == 0 {
|
||||
delete(c.projectionByViewer, entry.key.viewerUserID)
|
||||
}
|
||||
}
|
||||
if viewers := c.projectionByTarget[entry.key.contactUserID]; viewers != nil {
|
||||
delete(viewers, entry.key.viewerUserID)
|
||||
if len(viewers) == 0 {
|
||||
delete(c.projectionByTarget, entry.key.contactUserID)
|
||||
}
|
||||
}
|
||||
c.projectionLRU.Remove(element)
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) InvalidateViewers(ids ...int64) {
|
||||
if c == nil || len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.epoch++
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
delete(c.contacts, id)
|
||||
delete(c.personalPhotos, id)
|
||||
for contactUserID := range c.reverseByOwner[id] {
|
||||
if element, ok := c.reverse[reverseContactKey{ownerUserID: id, contactUserID: contactUserID}]; ok {
|
||||
c.removeReverseElementLocked(element)
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
c.advanceViewerGenerationLocked(id)
|
||||
c.invalidateViewerLocked(id)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) invalidateViewerLocked(id int64) {
|
||||
delete(c.contacts, id)
|
||||
if element := c.contactElements[id]; element != nil {
|
||||
delete(c.contactElements, id)
|
||||
c.contactLRU.Remove(element)
|
||||
}
|
||||
delete(c.personalPhotos, id)
|
||||
if element := c.personalElements[id]; element != nil {
|
||||
delete(c.personalElements, id)
|
||||
c.personalPhotoLRU.Remove(element)
|
||||
}
|
||||
for contactUserID := range c.reverseByOwner[id] {
|
||||
c.removeReverseKeyLocked(reverseContactKey{ownerUserID: id, contactUserID: contactUserID})
|
||||
}
|
||||
for contactUserID := range c.projectionByViewer[id] {
|
||||
c.removeContactProjectionKeyLocked(contactProjectionKey{viewerUserID: id, contactUserID: contactUserID})
|
||||
}
|
||||
for viewerUserID := range c.projectionByTarget[id] {
|
||||
c.removeContactProjectionKeyLocked(contactProjectionKey{viewerUserID: viewerUserID, contactUserID: id})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) removeReverseKeyLocked(key reverseContactKey) {
|
||||
if element, ok := c.reverse[key]; ok {
|
||||
c.removeReverseElementLocked(element)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) removeContactProjectionKeyLocked(key contactProjectionKey) {
|
||||
if element, ok := c.projection[key]; ok {
|
||||
c.removeContactProjectionElementLocked(element)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) FlushReadModelCache() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.epoch++
|
||||
c.flushGeneration++
|
||||
c.viewerGenerations = make(map[int64]uint64, 1024)
|
||||
c.contacts = make(map[int64]contactAccountSnapshot, 1024)
|
||||
c.contactElements = make(map[int64]*list.Element, 1024)
|
||||
c.contactLRU.Init()
|
||||
c.personalPhotos = make(map[int64]personalPhotoSnapshot, 1024)
|
||||
c.personalElements = make(map[int64]*list.Element, 1024)
|
||||
c.personalPhotoLRU.Init()
|
||||
c.reverse = make(map[reverseContactKey]*list.Element, 4096)
|
||||
c.reverseLRU.Init()
|
||||
c.reverseByOwner = make(map[int64]map[int64]struct{}, 1024)
|
||||
c.projection = make(map[contactProjectionKey]*list.Element, 4096)
|
||||
c.projectionLRU.Init()
|
||||
c.projectionByViewer = make(map[int64]map[int64]struct{}, 1024)
|
||||
c.projectionByTarget = make(map[int64]map[int64]struct{}, 1024)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) cacheEpoch() uint64 {
|
||||
func (c *CachedContactStore) captureCacheFence(userIDs ...int64) contactCacheFence {
|
||||
return c.captureCacheFenceSlices(userIDs, nil)
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) captureCacheFenceSlices(first, second []int64) contactCacheFence {
|
||||
c.mu.RLock()
|
||||
epoch := c.epoch
|
||||
fence := contactCacheFence{
|
||||
flushGeneration: c.flushGeneration,
|
||||
viewers: make([]contactCacheViewerFence, 0, len(first)+len(second)),
|
||||
}
|
||||
for _, userIDs := range [][]int64{first, second} {
|
||||
for _, userID := range userIDs {
|
||||
if userID == 0 {
|
||||
continue
|
||||
}
|
||||
fence.viewers = append(fence.viewers, contactCacheViewerFence{
|
||||
userID: userID,
|
||||
generation: c.viewerGenerations[userID],
|
||||
})
|
||||
}
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
return epoch
|
||||
return fence
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) cacheFenceCurrent(fence contactCacheFence) bool {
|
||||
c.mu.RLock()
|
||||
current := c.cacheFenceCurrentLocked(fence)
|
||||
c.mu.RUnlock()
|
||||
return current
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) cacheFenceCurrentLocked(fence contactCacheFence) bool {
|
||||
if c.flushGeneration != fence.flushGeneration {
|
||||
return false
|
||||
}
|
||||
for _, viewer := range fence.viewers {
|
||||
if c.viewerGenerations[viewer.userID] != viewer.generation {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) advanceViewerGenerationLocked(userID int64) {
|
||||
c.viewerGenerations[userID]++
|
||||
}
|
||||
|
||||
func buildContactAccountSnapshot(list domain.ContactList, expireAt time.Time) contactAccountSnapshot {
|
||||
|
|
@ -581,6 +1093,48 @@ func cloneCachedContactMap(in map[int64]domain.Contact) map[int64]domain.Contact
|
|||
return out
|
||||
}
|
||||
|
||||
func cloneContactProjectionBatch(in domain.ContactProjectionBatch) domain.ContactProjectionBatch {
|
||||
out := domain.ContactProjectionBatch{
|
||||
Contacts: make(map[int64]map[int64]domain.Contact, len(in.Contacts)),
|
||||
PersonalPhotos: make(map[int64]map[int64]domain.ProfilePhotoRef, len(in.PersonalPhotos)),
|
||||
}
|
||||
mergeContactProjectionBatch(&out, in)
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeContactProjectionBatch(dst *domain.ContactProjectionBatch, src domain.ContactProjectionBatch) {
|
||||
for viewerID, contacts := range src.Contacts {
|
||||
for targetID, contact := range contacts {
|
||||
putContactProjectionContact(dst, viewerID, targetID, contact)
|
||||
}
|
||||
}
|
||||
for viewerID, refs := range src.PersonalPhotos {
|
||||
for targetID, ref := range refs {
|
||||
putContactProjectionPersonalPhoto(dst, viewerID, targetID, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func putContactProjectionContact(batch *domain.ContactProjectionBatch, viewerID, targetID int64, contact domain.Contact) {
|
||||
if batch.Contacts == nil {
|
||||
batch.Contacts = map[int64]map[int64]domain.Contact{}
|
||||
}
|
||||
if batch.Contacts[viewerID] == nil {
|
||||
batch.Contacts[viewerID] = map[int64]domain.Contact{}
|
||||
}
|
||||
batch.Contacts[viewerID][targetID] = cloneCachedContact(contact)
|
||||
}
|
||||
|
||||
func putContactProjectionPersonalPhoto(batch *domain.ContactProjectionBatch, viewerID, targetID int64, ref domain.ProfilePhotoRef) {
|
||||
if batch.PersonalPhotos == nil {
|
||||
batch.PersonalPhotos = map[int64]map[int64]domain.ProfilePhotoRef{}
|
||||
}
|
||||
if batch.PersonalPhotos[viewerID] == nil {
|
||||
batch.PersonalPhotos[viewerID] = map[int64]domain.ProfilePhotoRef{}
|
||||
}
|
||||
batch.PersonalPhotos[viewerID][targetID] = cloneCachedProfilePhotoRef(ref)
|
||||
}
|
||||
|
||||
func dedupContactIDs(ids []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
out := make([]int64, 0, len(ids))
|
||||
|
|
|
|||
174
internal/app/userprojection/contact_cache_sparse.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package userprojection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
var _ store.SparseContactProjectionStore = (*CachedContactStore)(nil)
|
||||
|
||||
// ContactProjectionForViewerUserIDs keeps the pair cache useful for sparse
|
||||
// outbox projection without ever broadening a cold read into viewers x targets.
|
||||
func (c *CachedContactStore) ContactProjectionForViewerUserIDs(ctx context.Context, requested map[int64][]int64) (domain.ContactProjectionBatch, error) {
|
||||
pairs := canonicalContactProjectionPairs(requested)
|
||||
if len(pairs) == 0 {
|
||||
return emptyContactProjectionBatch(), nil
|
||||
}
|
||||
for {
|
||||
out := emptyContactProjectionBatch()
|
||||
readFence := c.captureCacheFence(sparseContactProjectionFenceIDs(pairs)...)
|
||||
now := c.now()
|
||||
cold := make(map[int64][]int64)
|
||||
for _, pair := range pairs {
|
||||
contactKnown := false
|
||||
if snap, ok := c.lookupContactSnapshot(pair.viewerUserID, now); ok {
|
||||
contactKnown = true
|
||||
if contact, found := snap.contacts[pair.contactUserID]; found {
|
||||
putContactProjectionContact(&out, pair.viewerUserID, pair.contactUserID, contact)
|
||||
} else {
|
||||
// Personal photos are rows on contacts and cannot exist when the
|
||||
// viewer has no contact row for this target.
|
||||
continue
|
||||
}
|
||||
}
|
||||
photoKnown := false
|
||||
if snap, ok := c.lookupPersonalPhotoSnapshot(pair.viewerUserID, now); ok {
|
||||
photoKnown = true
|
||||
if ref, found := snap.refs[pair.contactUserID]; found {
|
||||
putContactProjectionPersonalPhoto(&out, pair.viewerUserID, pair.contactUserID, ref)
|
||||
}
|
||||
}
|
||||
if contactKnown && photoKnown {
|
||||
continue
|
||||
}
|
||||
if snap, ok := c.lookupContactProjectionPair(pair.viewerUserID, pair.contactUserID, now); ok {
|
||||
if !contactKnown && snap.contactFound {
|
||||
putContactProjectionContact(&out, pair.viewerUserID, pair.contactUserID, snap.contact)
|
||||
}
|
||||
if !photoKnown && snap.personalPhotoFound {
|
||||
putContactProjectionPersonalPhoto(&out, pair.viewerUserID, pair.contactUserID, snap.personalPhoto)
|
||||
}
|
||||
continue
|
||||
}
|
||||
cold[pair.viewerUserID] = append(cold[pair.viewerUserID], pair.contactUserID)
|
||||
}
|
||||
if !c.cacheFenceCurrent(readFence) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return domain.ContactProjectionBatch{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(cold) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
loaded, err := c.loadSparseContactProjection(ctx, cold)
|
||||
if err != nil {
|
||||
return domain.ContactProjectionBatch{}, err
|
||||
}
|
||||
if !c.cacheFenceCurrent(readFence) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return domain.ContactProjectionBatch{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
mergeContactProjectionBatch(&out, loaded)
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
type sparseContactProjectionPair struct {
|
||||
viewerUserID int64
|
||||
contactUserID int64
|
||||
}
|
||||
|
||||
func canonicalContactProjectionPairs(requested map[int64][]int64) []sparseContactProjectionPair {
|
||||
seen := make(map[sparseContactProjectionPair]struct{})
|
||||
for viewerID, ids := range requested {
|
||||
if viewerID == 0 {
|
||||
continue
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id != 0 {
|
||||
seen[sparseContactProjectionPair{viewerUserID: viewerID, contactUserID: id}] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]sparseContactProjectionPair, 0, len(seen))
|
||||
for pair := range seen {
|
||||
out = append(out, pair)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].viewerUserID == out[j].viewerUserID {
|
||||
return out[i].contactUserID < out[j].contactUserID
|
||||
}
|
||||
return out[i].viewerUserID < out[j].viewerUserID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) loadSparseContactProjection(ctx context.Context, requested map[int64][]int64) (domain.ContactProjectionBatch, error) {
|
||||
pairs := canonicalContactProjectionPairs(requested)
|
||||
canonical := make(map[int64][]int64)
|
||||
for _, pair := range pairs {
|
||||
canonical[pair.viewerUserID] = append(canonical[pair.viewerUserID], pair.contactUserID)
|
||||
}
|
||||
sfKey := fmt.Sprintf("contact-projection-sparse:%v", pairs)
|
||||
for {
|
||||
v, err, _ := c.sf.Do(sfKey, func() (any, error) {
|
||||
loader, ok := c.inner.(store.SparseContactProjectionStore)
|
||||
if !ok {
|
||||
return contactProjectionLoadResult{}, fmt.Errorf("contact store does not support sparse projection")
|
||||
}
|
||||
loadFence := c.captureCacheFence(sparseContactProjectionFenceIDs(pairs)...)
|
||||
batch, err := loader.ContactProjectionForViewerUserIDs(ctx, canonical)
|
||||
if err != nil {
|
||||
return contactProjectionLoadResult{}, err
|
||||
}
|
||||
expireAt := c.now().Add(c.ttl)
|
||||
admitPairs := len(pairs) <= contactProjectionDenseAdmissionMaxCells
|
||||
c.mu.Lock()
|
||||
current := c.cacheFenceCurrentLocked(loadFence)
|
||||
if current && admitPairs {
|
||||
for _, pair := range pairs {
|
||||
contact, contactFound := batch.Contacts[pair.viewerUserID][pair.contactUserID]
|
||||
ref, photoFound := batch.PersonalPhotos[pair.viewerUserID][pair.contactUserID]
|
||||
c.storeContactProjectionPairLocked(
|
||||
contactProjectionKey{viewerUserID: pair.viewerUserID, contactUserID: pair.contactUserID},
|
||||
contact, contactFound, ref, photoFound, expireAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return contactProjectionLoadResult{batch: cloneContactProjectionBatch(batch), current: current}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ContactProjectionBatch{}, err
|
||||
}
|
||||
result := v.(contactProjectionLoadResult)
|
||||
if result.current {
|
||||
return result.batch, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return domain.ContactProjectionBatch{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sparseContactProjectionFenceIDs(pairs []sparseContactProjectionPair) []int64 {
|
||||
ids := make([]int64, 0, len(pairs)*2)
|
||||
for _, pair := range pairs {
|
||||
ids = append(ids, pair.viewerUserID, pair.contactUserID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func emptyContactProjectionBatch() domain.ContactProjectionBatch {
|
||||
return domain.ContactProjectionBatch{
|
||||
Contacts: map[int64]map[int64]domain.Contact{},
|
||||
PersonalPhotos: map[int64]map[int64]domain.ProfilePhotoRef{},
|
||||
}
|
||||
}
|
||||
|
|
@ -2,9 +2,12 @@ package userprojection
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
|
|
@ -19,6 +22,7 @@ type blockingFirstListContactStore struct {
|
|||
|
||||
mu sync.Mutex
|
||||
firstUsed bool
|
||||
listCalls int
|
||||
}
|
||||
|
||||
type blockingFirstPersonalPhotoStore struct {
|
||||
|
|
@ -31,8 +35,56 @@ type blockingFirstPersonalPhotoStore struct {
|
|||
firstUsed bool
|
||||
}
|
||||
|
||||
type stalePersonalPhotoWritebackContextKey struct{}
|
||||
|
||||
// stalePersonalPhotoWritebackStore deterministically models an older mutation
|
||||
// that commits first but returns to the cache wrapper after a newer mutation.
|
||||
// The old implementation performed a post-commit PersonalPhotos read and could
|
||||
// publish this captured old value after the newer mutation had completed.
|
||||
type stalePersonalPhotoWritebackStore struct {
|
||||
store.ContactStore
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
staleReadCalls int
|
||||
}
|
||||
|
||||
func (s *stalePersonalPhotoWritebackStore) SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) {
|
||||
contact, found, err := s.ContactStore.SetPersonalPhoto(ctx, userID, contactUserID, photoID, date)
|
||||
if err != nil || !found || ctx.Value(stalePersonalPhotoWritebackContextKey{}) != true {
|
||||
return contact, found, err
|
||||
}
|
||||
close(s.started)
|
||||
select {
|
||||
case <-s.release:
|
||||
case <-ctx.Done():
|
||||
return domain.Contact{}, false, ctx.Err()
|
||||
}
|
||||
return contact, found, nil
|
||||
}
|
||||
|
||||
func (s *stalePersonalPhotoWritebackStore) PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
if len(contactUserIDs) > 0 && ctx.Value(stalePersonalPhotoWritebackContextKey{}) == true {
|
||||
s.mu.Lock()
|
||||
s.staleReadCalls++
|
||||
s.mu.Unlock()
|
||||
return map[int64]domain.ProfilePhotoRef{
|
||||
contactUserIDs[0]: {PhotoID: 9001, Personal: true},
|
||||
}, nil
|
||||
}
|
||||
return s.ContactStore.PersonalPhotos(ctx, userID, contactUserIDs)
|
||||
}
|
||||
|
||||
func (s *stalePersonalPhotoWritebackStore) staleReads() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.staleReadCalls
|
||||
}
|
||||
|
||||
func (s *blockingFirstListContactStore) ListByUser(ctx context.Context, userID int64) (domain.ContactList, error) {
|
||||
s.mu.Lock()
|
||||
s.listCalls++
|
||||
if !s.firstUsed {
|
||||
s.firstUsed = true
|
||||
s.mu.Unlock()
|
||||
|
|
@ -48,6 +100,12 @@ func (s *blockingFirstListContactStore) ListByUser(ctx context.Context, userID i
|
|||
return s.ContactStore.ListByUser(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *blockingFirstListContactStore) callCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.listCalls
|
||||
}
|
||||
|
||||
func (s *blockingFirstPersonalPhotoStore) PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
s.mu.Lock()
|
||||
if !s.firstUsed {
|
||||
|
|
@ -84,6 +142,7 @@ type countingContactStore struct {
|
|||
listCalls int
|
||||
getManyCalls int
|
||||
reverseCalls int
|
||||
projectionCalls int
|
||||
personalPhotoCalls int
|
||||
setPersonalPhotoHit int
|
||||
}
|
||||
|
|
@ -103,6 +162,11 @@ func (s *countingContactStore) GetReverseContacts(ctx context.Context, userID in
|
|||
return s.ContactStore.GetReverseContacts(ctx, userID, ownerUserIDs)
|
||||
}
|
||||
|
||||
func (s *countingContactStore) ContactProjectionForViewers(ctx context.Context, viewerUserIDs, contactUserIDs []int64) (domain.ContactProjectionBatch, error) {
|
||||
s.projectionCalls++
|
||||
return s.ContactStore.ContactProjectionForViewers(ctx, viewerUserIDs, contactUserIDs)
|
||||
}
|
||||
|
||||
func (s *countingContactStore) PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
s.personalPhotoCalls++
|
||||
return s.ContactStore.PersonalPhotos(ctx, userID, contactUserIDs)
|
||||
|
|
@ -162,6 +226,413 @@ func TestCachedContactStoreCachesProjectionReads(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStoreContactSnapshotLRUEvictsOnlyOldestViewer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewContactStore()
|
||||
for viewerID := int64(1); viewerID <= 3; viewerID++ {
|
||||
if _, err := base.Upsert(ctx, viewerID, domain.ContactInput{
|
||||
ContactUserID: 100 + viewerID,
|
||||
FirstName: fmt.Sprintf("viewer-%d", viewerID),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed viewer %d: %v", viewerID, err)
|
||||
}
|
||||
}
|
||||
counting := &countingContactStore{ContactStore: base}
|
||||
cached := NewCachedContactStoreWithMaxViewers(counting, time.Hour, 2)
|
||||
|
||||
for _, viewerID := range []int64{1, 2, 1, 3} {
|
||||
if _, err := cached.ListByUser(ctx, viewerID); err != nil {
|
||||
t.Fatalf("list viewer %d: %v", viewerID, err)
|
||||
}
|
||||
}
|
||||
if counting.listCalls != 3 {
|
||||
t.Fatalf("ListByUser calls = %d, want 3 before evicted viewer is read", counting.listCalls)
|
||||
}
|
||||
cached.mu.RLock()
|
||||
_, hasOne := cached.contacts[1]
|
||||
_, hasTwo := cached.contacts[2]
|
||||
_, hasThree := cached.contacts[3]
|
||||
contactEntries := cached.contactLRU.Len()
|
||||
cached.mu.RUnlock()
|
||||
if !hasOne || hasTwo || !hasThree || contactEntries != 2 {
|
||||
t.Fatalf("contact LRU state = one:%v two:%v three:%v len:%d, want one+three only", hasOne, hasTwo, hasThree, contactEntries)
|
||||
}
|
||||
|
||||
if _, err := cached.ListByUser(ctx, 1); err != nil {
|
||||
t.Fatalf("list retained viewer 1: %v", err)
|
||||
}
|
||||
if counting.listCalls != 3 {
|
||||
t.Fatalf("retained viewer caused cold load: calls=%d, want 3", counting.listCalls)
|
||||
}
|
||||
if _, err := cached.ListByUser(ctx, 2); err != nil {
|
||||
t.Fatalf("list evicted viewer 2: %v", err)
|
||||
}
|
||||
if counting.listCalls != 4 {
|
||||
t.Fatalf("evicted viewer did not cold load exactly once: calls=%d, want 4", counting.listCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStoreContactAndPersonalPhotoLRUsAreIndependent(t *testing.T) {
|
||||
cached := NewCachedContactStoreWithMaxViewers(memory.NewContactStore(), time.Hour, 2)
|
||||
expireAt := time.Now().Add(time.Hour)
|
||||
contactSnap := func(userID int64) contactAccountSnapshot {
|
||||
return buildContactAccountSnapshot(domain.ContactList{Contacts: []domain.Contact{{
|
||||
User: domain.User{ID: 100 + userID},
|
||||
}}}, expireAt)
|
||||
}
|
||||
photoSnap := func(userID int64) personalPhotoSnapshot {
|
||||
return personalPhotoSnapshot{
|
||||
refs: map[int64]domain.ProfilePhotoRef{100 + userID: {PhotoID: 9000 + userID}},
|
||||
expireAt: expireAt,
|
||||
}
|
||||
}
|
||||
|
||||
cached.mu.Lock()
|
||||
cached.storeContactSnapshotLocked(1, contactSnap(1))
|
||||
cached.storeContactSnapshotLocked(2, contactSnap(2))
|
||||
cached.storePersonalPhotoSnapshotLocked(1, photoSnap(1))
|
||||
cached.storePersonalPhotoSnapshotLocked(2, photoSnap(2))
|
||||
cached.mu.Unlock()
|
||||
if _, ok := cached.lookupContactSnapshot(1, time.Now()); !ok {
|
||||
t.Fatal("contact viewer 1 missing before LRU touch")
|
||||
}
|
||||
cached.mu.Lock()
|
||||
cached.storeContactSnapshotLocked(3, contactSnap(3))
|
||||
cached.mu.Unlock()
|
||||
|
||||
cached.mu.RLock()
|
||||
_, contactOne := cached.contacts[1]
|
||||
_, contactTwo := cached.contacts[2]
|
||||
_, contactThree := cached.contacts[3]
|
||||
_, photoOne := cached.personalPhotos[1]
|
||||
_, photoTwo := cached.personalPhotos[2]
|
||||
cached.mu.RUnlock()
|
||||
if !contactOne || contactTwo || !contactThree {
|
||||
t.Fatalf("contact LRU = one:%v two:%v three:%v, want one+three", contactOne, contactTwo, contactThree)
|
||||
}
|
||||
if !photoOne || !photoTwo {
|
||||
t.Fatalf("contact eviction crossed into personal-photo LRU: one:%v two:%v", photoOne, photoTwo)
|
||||
}
|
||||
|
||||
if _, ok := cached.lookupPersonalPhotoSnapshot(2, time.Now()); !ok {
|
||||
t.Fatal("personal-photo viewer 2 missing before LRU touch")
|
||||
}
|
||||
cached.mu.Lock()
|
||||
cached.storePersonalPhotoSnapshotLocked(3, photoSnap(3))
|
||||
cached.mu.Unlock()
|
||||
cached.mu.RLock()
|
||||
_, photoOne = cached.personalPhotos[1]
|
||||
_, photoTwo = cached.personalPhotos[2]
|
||||
_, photoThree := cached.personalPhotos[3]
|
||||
_, contactOne = cached.contacts[1]
|
||||
_, contactThree = cached.contacts[3]
|
||||
cached.mu.RUnlock()
|
||||
if photoOne || !photoTwo || !photoThree {
|
||||
t.Fatalf("personal-photo LRU = one:%v two:%v three:%v, want two+three", photoOne, photoTwo, photoThree)
|
||||
}
|
||||
if !contactOne || !contactThree {
|
||||
t.Fatalf("personal-photo eviction crossed into contact LRU: one:%v three:%v", contactOne, contactThree)
|
||||
}
|
||||
|
||||
cached.InvalidateViewers(3)
|
||||
cached.mu.RLock()
|
||||
_, contactThree = cached.contacts[3]
|
||||
_, photoThree = cached.personalPhotos[3]
|
||||
_, contactElement := cached.contactElements[3]
|
||||
_, photoElement := cached.personalElements[3]
|
||||
cached.mu.RUnlock()
|
||||
if contactThree || photoThree || contactElement || photoElement {
|
||||
t.Fatalf("viewer invalidation left LRU state: contact=%v photo=%v contactElement=%v photoElement=%v",
|
||||
contactThree, photoThree, contactElement, photoElement)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStoreUnrelatedViewerInvalidationDoesNotRejectRefill(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewContactStore()
|
||||
if _, err := base.Upsert(ctx, 2, domain.ContactInput{ContactUserID: 20, FirstName: "current"}); err != nil {
|
||||
t.Fatalf("seed current contact: %v", err)
|
||||
}
|
||||
blocking := &blockingFirstListContactStore{
|
||||
ContactStore: base,
|
||||
started: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
first: domain.ContactList{Contacts: []domain.Contact{{
|
||||
User: domain.User{ID: 20},
|
||||
FirstName: "captured",
|
||||
}}},
|
||||
}
|
||||
cached := NewCachedContactStore(blocking, time.Hour)
|
||||
|
||||
type readResult struct {
|
||||
contacts map[int64]domain.Contact
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan readResult, 1)
|
||||
go func() {
|
||||
contacts, err := cached.GetMany(ctx, 2, []int64{20})
|
||||
resultCh <- readResult{contacts: contacts, err: err}
|
||||
}()
|
||||
waitForCacheTestSignal(t, blocking.started)
|
||||
cached.InvalidateViewers(1)
|
||||
close(blocking.release)
|
||||
|
||||
select {
|
||||
case result := <-resultCh:
|
||||
if result.err != nil {
|
||||
t.Fatalf("contact read: %v", result.err)
|
||||
}
|
||||
if got := result.contacts[20].FirstName; got != "captured" {
|
||||
t.Fatalf("unrelated invalidation rejected captured refill: got %q", got)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for contact read")
|
||||
}
|
||||
if calls := blocking.callCount(); calls != 1 {
|
||||
t.Fatalf("ListByUser calls = %d, want 1 after unrelated invalidation", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStoreContactProjectionForViewersUsesViewerOwnedPairCache(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewContactStore()
|
||||
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice"}); err != nil {
|
||||
t.Fatalf("seed viewer 1 contact: %v", err)
|
||||
}
|
||||
if _, _, err := base.SetPersonalPhoto(ctx, 1, 2, 9101, 100); err != nil {
|
||||
t.Fatalf("seed viewer 1 personal photo: %v", err)
|
||||
}
|
||||
if _, err := base.Upsert(ctx, 3, domain.ContactInput{ContactUserID: 2, FirstName: "Bob"}); err != nil {
|
||||
t.Fatalf("seed viewer 3 contact: %v", err)
|
||||
}
|
||||
if _, _, err := base.SetPersonalPhoto(ctx, 3, 2, 9103, 100); err != nil {
|
||||
t.Fatalf("seed viewer 3 personal photo: %v", err)
|
||||
}
|
||||
counting := &countingContactStore{ContactStore: base}
|
||||
cached := NewCachedContactStore(counting, 0)
|
||||
|
||||
if _, err := cached.GetMany(ctx, 1, []int64{2}); err != nil {
|
||||
t.Fatalf("prime viewer 1 contacts: %v", err)
|
||||
}
|
||||
if _, err := cached.PersonalPhotos(ctx, 1, []int64{2}); err != nil {
|
||||
t.Fatalf("prime viewer 1 photos: %v", err)
|
||||
}
|
||||
|
||||
first, err := cached.ContactProjectionForViewers(ctx, []int64{1, 3}, []int64{2})
|
||||
if err != nil {
|
||||
t.Fatalf("first projection: %v", err)
|
||||
}
|
||||
if first.Contacts[1][2].FirstName != "Alice" || first.PersonalPhotos[1][2].PhotoID != 9101 {
|
||||
t.Fatalf("viewer 1 projection = %+v %+v, want warm Alice/9101", first.Contacts[1][2], first.PersonalPhotos[1][2])
|
||||
}
|
||||
if first.Contacts[3][2].FirstName != "Bob" || first.PersonalPhotos[3][2].PhotoID != 9103 {
|
||||
t.Fatalf("viewer 3 projection = %+v %+v, want cold Bob/9103", first.Contacts[3][2], first.PersonalPhotos[3][2])
|
||||
}
|
||||
if counting.projectionCalls != 1 {
|
||||
t.Fatalf("projection calls after first = %d, want 1", counting.projectionCalls)
|
||||
}
|
||||
|
||||
second, err := cached.ContactProjectionForViewers(ctx, []int64{3}, []int64{2})
|
||||
if err != nil {
|
||||
t.Fatalf("second projection: %v", err)
|
||||
}
|
||||
if second.Contacts[3][2].FirstName != "Bob" || second.PersonalPhotos[3][2].PhotoID != 9103 {
|
||||
t.Fatalf("cached viewer 3 projection = %+v %+v, want Bob/9103", second.Contacts[3][2], second.PersonalPhotos[3][2])
|
||||
}
|
||||
if counting.projectionCalls != 1 {
|
||||
t.Fatalf("projection calls after cached read = %d, want 1", counting.projectionCalls)
|
||||
}
|
||||
|
||||
cached.InvalidateViewers(3)
|
||||
if _, err := cached.ContactProjectionForViewers(ctx, []int64{3}, []int64{2}); err != nil {
|
||||
t.Fatalf("projection after invalidation: %v", err)
|
||||
}
|
||||
if counting.projectionCalls != 2 {
|
||||
t.Fatalf("projection calls after invalidation = %d, want 2", counting.projectionCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStorePairSnapshotsAreCompact(t *testing.T) {
|
||||
pointerSize := unsafe.Sizeof(uintptr(0))
|
||||
timeSize := unsafe.Sizeof(time.Time{})
|
||||
if got, max := unsafe.Sizeof(reverseContactSnapshot{}), timeSize+2*pointerSize; got > max {
|
||||
t.Fatalf("reverseContactSnapshot size = %d, want <= %d (one value pointer plus expiry)", got, max)
|
||||
}
|
||||
if got, max := unsafe.Sizeof(contactProjectionSnapshot{}), timeSize+3*pointerSize; got > max {
|
||||
t.Fatalf("contactProjectionSnapshot size = %d, want <= %d (two value pointers plus expiry)", got, max)
|
||||
}
|
||||
if got, large := unsafe.Sizeof(reverseContactSnapshot{}), unsafe.Sizeof(domain.Contact{}); got >= large {
|
||||
t.Fatalf("reverseContactSnapshot size = %d, must not embed %d-byte domain.Contact", got, large)
|
||||
}
|
||||
if got, large := unsafe.Sizeof(contactProjectionSnapshot{}), unsafe.Sizeof(domain.Contact{}); got >= large {
|
||||
t.Fatalf("contactProjectionSnapshot size = %d, must not embed %d-byte domain.Contact", got, large)
|
||||
}
|
||||
if got, max := unsafe.Sizeof(cachedContactProjectionOverlay{}), uintptr(128); got > max {
|
||||
t.Fatalf("cachedContactProjectionOverlay size = %d, want <= %d bytes", got, max)
|
||||
}
|
||||
if got, large := unsafe.Sizeof(cachedContactProjectionOverlay{}), unsafe.Sizeof(domain.Contact{}); got >= large {
|
||||
t.Fatalf("cachedContactProjectionOverlay size = %d, must be smaller than %d-byte domain.Contact", got, large)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStorePairSnapshotsUseNilForNegativeAndClonePositiveValues(t *testing.T) {
|
||||
cached := NewCachedContactStore(memory.NewContactStore(), time.Hour)
|
||||
now := time.Unix(1000, 0)
|
||||
expireAt := now.Add(time.Hour)
|
||||
contact := domain.Contact{
|
||||
User: domain.User{
|
||||
ID: 2, AccessHash: 2002, Phone: "global-phone", FirstName: "Global", LastName: "User",
|
||||
Username: "global_user", Mutual: true, PhotoStripped: []byte{1, 2, 3},
|
||||
},
|
||||
FirstName: "Local",
|
||||
LastName: "Name",
|
||||
Phone: "known-phone",
|
||||
Note: "private note",
|
||||
NoteEntities: []domain.MessageEntity{{
|
||||
Type: domain.MessageEntityBold, Offset: 0, Length: 3,
|
||||
}},
|
||||
CloseFriend: true,
|
||||
}
|
||||
photo := domain.ProfilePhotoRef{PhotoID: 9001, Stripped: []byte{4, 5, 6}, Personal: true}
|
||||
positiveReverseKey := reverseContactKey{ownerUserID: 1, contactUserID: 2}
|
||||
negativeReverseKey := reverseContactKey{ownerUserID: 3, contactUserID: 2}
|
||||
positiveProjectionKey := contactProjectionKey{viewerUserID: 1, contactUserID: 2}
|
||||
negativeProjectionKey := contactProjectionKey{viewerUserID: 1, contactUserID: 99}
|
||||
|
||||
cached.mu.Lock()
|
||||
cached.storeReverseContactLocked(positiveReverseKey, contact, true, expireAt)
|
||||
cached.storeReverseContactLocked(negativeReverseKey, contact, false, expireAt)
|
||||
cached.storeContactProjectionPairLocked(positiveProjectionKey, contact, true, photo, true, expireAt)
|
||||
cached.storeContactProjectionPairLocked(negativeProjectionKey, contact, false, photo, false, expireAt)
|
||||
positiveReverse := cached.reverse[positiveReverseKey].Value.(*reverseContactEntry).snapshot
|
||||
negativeReverse := cached.reverse[negativeReverseKey].Value.(*reverseContactEntry).snapshot
|
||||
positiveProjection := cached.projection[positiveProjectionKey].Value.(*contactProjectionEntry).snapshot
|
||||
negativeProjection := cached.projection[negativeProjectionKey].Value.(*contactProjectionEntry).snapshot
|
||||
cached.mu.Unlock()
|
||||
|
||||
if positiveReverse.contact == nil || positiveProjection.contact == nil || positiveProjection.personalPhoto == nil {
|
||||
t.Fatalf("positive snapshots lost values: reverse=%+v projection=%+v", positiveReverse, positiveProjection)
|
||||
}
|
||||
if negativeReverse.contact != nil || negativeProjection.contact != nil || negativeProjection.personalPhoto != nil {
|
||||
t.Fatalf("negative snapshots retained value allocations: reverse=%+v projection=%+v", negativeReverse, negativeProjection)
|
||||
}
|
||||
|
||||
// Publication clones inputs; subsequent caller mutation cannot alter cache.
|
||||
contact.User.PhotoStripped[0] = 10
|
||||
contact.NoteEntities[0].Length = 10
|
||||
photo.Stripped[0] = 10
|
||||
|
||||
reverse, found, hit := cached.lookupReverseContact(1, 2, now)
|
||||
if !hit || !found || reverse.User.PhotoStripped[0] != 1 || reverse.NoteEntities[0].Length != 3 {
|
||||
t.Fatalf("positive reverse lookup = %+v found=%v hit=%v", reverse, found, hit)
|
||||
}
|
||||
reverse.User.PhotoStripped[0] = 11
|
||||
reverse.NoteEntities[0].Length = 11
|
||||
reverseAgain, found, hit := cached.lookupReverseContact(1, 2, now)
|
||||
if !hit || !found || reverseAgain.User.PhotoStripped[0] != 1 || reverseAgain.NoteEntities[0].Length != 3 {
|
||||
t.Fatalf("reverse lookup shared mutable slices: %+v found=%v hit=%v", reverseAgain, found, hit)
|
||||
}
|
||||
if _, found, hit := cached.lookupReverseContact(3, 2, now); !hit || found {
|
||||
t.Fatalf("negative reverse lookup found=%v hit=%v, want false/true", found, hit)
|
||||
}
|
||||
|
||||
pair, hit := cached.lookupContactProjectionPair(1, 2, now)
|
||||
if !hit || !pair.contactFound || !pair.personalPhotoFound || pair.personalPhoto.Stripped[0] != 4 {
|
||||
t.Fatalf("positive projection lookup = %+v hit=%v", pair, hit)
|
||||
}
|
||||
if !reflect.DeepEqual(pair.contact.User, domain.User{ID: 2}) {
|
||||
t.Fatalf("projection pair retained base user data: %+v", pair.contact.User)
|
||||
}
|
||||
if pair.contact.FirstName != "Local" || pair.contact.LastName != "Name" || pair.contact.Phone != "known-phone" ||
|
||||
pair.contact.Note != "private note" || !pair.contact.Mutual || !pair.contact.CloseFriend {
|
||||
t.Fatalf("projection pair lost viewer-owned overlay: %+v", pair.contact)
|
||||
}
|
||||
pair.contact.NoteEntities[0].Length = 12
|
||||
pair.personalPhoto.Stripped[0] = 12
|
||||
pairAgain, hit := cached.lookupContactProjectionPair(1, 2, now)
|
||||
if !hit || !reflect.DeepEqual(pairAgain.contact.User, domain.User{ID: 2}) || pairAgain.contact.NoteEntities[0].Length != 3 || pairAgain.personalPhoto.Stripped[0] != 4 {
|
||||
t.Fatalf("projection lookup shared mutable slices: %+v hit=%v", pairAgain, hit)
|
||||
}
|
||||
negative, hit := cached.lookupContactProjectionPair(1, 99, now)
|
||||
if !hit || negative.contactFound || negative.personalPhotoFound {
|
||||
t.Fatalf("negative projection lookup = %+v hit=%v, want cached miss", negative, hit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStoreLargeDenseProjectionDoesNotPollutePairCache(t *testing.T) {
|
||||
if !admitDenseContactProjectionPairs(1, contactProjectionDenseAdmissionMaxCells) {
|
||||
t.Fatal("admission rejected the documented cell limit")
|
||||
}
|
||||
if admitDenseContactProjectionPairs(1, contactProjectionDenseAdmissionMaxCells+1) {
|
||||
t.Fatal("admission accepted a batch above the documented cell limit")
|
||||
}
|
||||
|
||||
counting := &countingContactStore{ContactStore: memory.NewContactStore()}
|
||||
cached := NewCachedContactStore(counting, time.Hour)
|
||||
seedKey := contactProjectionKey{viewerUserID: 1, contactUserID: 2}
|
||||
cached.mu.Lock()
|
||||
cached.storeContactProjectionPairLocked(
|
||||
seedKey,
|
||||
domain.Contact{User: domain.User{ID: 2}, FirstName: "seed"}, true,
|
||||
domain.ProfilePhotoRef{}, false,
|
||||
cached.now().Add(time.Hour),
|
||||
)
|
||||
cached.mu.Unlock()
|
||||
|
||||
viewers := []int64{1001, 1002}
|
||||
targets := make([]int64, contactProjectionDenseAdmissionMaxCells/len(viewers)+1)
|
||||
for i := range targets {
|
||||
targets[i] = int64(100000 + i)
|
||||
}
|
||||
for call := 1; call <= 2; call++ {
|
||||
got, err := cached.ContactProjectionForViewers(context.Background(), viewers, targets)
|
||||
if err != nil {
|
||||
t.Fatalf("large dense projection call %d: %v", call, err)
|
||||
}
|
||||
if len(got.Contacts) != 0 || len(got.PersonalPhotos) != 0 {
|
||||
t.Fatalf("large empty projection call %d = %+v", call, got)
|
||||
}
|
||||
cached.mu.Lock()
|
||||
_, seedPresent := cached.projection[seedKey]
|
||||
pairCount := len(cached.projection)
|
||||
lruCount := cached.projectionLRU.Len()
|
||||
cached.mu.Unlock()
|
||||
if !seedPresent || pairCount != 1 || lruCount != 1 {
|
||||
t.Fatalf("large dense load polluted pair cache: seed=%v pairs=%d lru=%d", seedPresent, pairCount, lruCount)
|
||||
}
|
||||
}
|
||||
if counting.projectionCalls != 2 {
|
||||
t.Fatalf("projection calls = %d, want 2 because oversized results are returned but not admitted", counting.projectionCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStoreContactProjectionSkipsColdReadForKnownNonContact(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewContactStore()
|
||||
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice"}); err != nil {
|
||||
t.Fatalf("seed contact: %v", err)
|
||||
}
|
||||
counting := &countingContactStore{ContactStore: base}
|
||||
cached := NewCachedContactStore(counting, 0)
|
||||
|
||||
if _, err := cached.GetMany(ctx, 1, []int64{99}); err != nil {
|
||||
t.Fatalf("prime viewer contact snapshot: %v", err)
|
||||
}
|
||||
got, err := cached.ContactProjectionForViewers(ctx, []int64{1}, []int64{99})
|
||||
if err != nil {
|
||||
t.Fatalf("projection: %v", err)
|
||||
}
|
||||
if len(got.Contacts[1]) != 0 || len(got.PersonalPhotos[1]) != 0 {
|
||||
t.Fatalf("known non-contact projection = %+v", got)
|
||||
}
|
||||
if counting.projectionCalls != 0 {
|
||||
t.Fatalf("projection calls = %d, want 0 for known non-contact", counting.projectionCalls)
|
||||
}
|
||||
if counting.personalPhotoCalls != 0 {
|
||||
t.Fatalf("personal photo calls = %d, want 0 for known non-contact", counting.personalPhotoCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStoreCachesLargeReverseContactBatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewContactStore()
|
||||
|
|
@ -237,7 +708,7 @@ func TestCachedContactStoreReversePairsUsePerEntryLRU(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStoreInvalidatesAccountSnapshot(t *testing.T) {
|
||||
func TestCachedContactStoreInvalidatesAccountSnapshotAfterMutation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewContactStore()
|
||||
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice"}); err != nil {
|
||||
|
|
@ -264,7 +735,123 @@ func TestCachedContactStoreInvalidatesAccountSnapshot(t *testing.T) {
|
|||
t.Fatalf("second = %+v, want Alicia after invalidation", second[2])
|
||||
}
|
||||
if counting.listCalls != 2 {
|
||||
t.Fatalf("ListByUser calls = %d, want 2 after write invalidation", counting.listCalls)
|
||||
t.Fatalf("ListByUser calls = %d, want 2 after safe invalidation and reload", counting.listCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStorePublishedSnapshotsStayImmutableDuringMutations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(context.Context, *CachedContactStore) error
|
||||
}{
|
||||
{
|
||||
name: "upsert",
|
||||
mutate: func(ctx context.Context, cached *CachedContactStore) error {
|
||||
_, err := cached.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "After"})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete",
|
||||
mutate: func(ctx context.Context, cached *CachedContactStore) error {
|
||||
_, err := cached.Delete(ctx, 1, []int64{2})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "close_friends",
|
||||
mutate: func(ctx context.Context, cached *CachedContactStore) error {
|
||||
_, err := cached.SetCloseFriends(ctx, 1, []int64{2})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "personal_photo",
|
||||
mutate: func(ctx context.Context, cached *CachedContactStore) error {
|
||||
_, found, err := cached.SetPersonalPhoto(ctx, 1, 2, 9002, 101)
|
||||
if err == nil && !found {
|
||||
return fmt.Errorf("contact not found")
|
||||
}
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewContactStore()
|
||||
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Before"}); err != nil {
|
||||
t.Fatalf("seed contact: %v", err)
|
||||
}
|
||||
if _, found, err := base.SetPersonalPhoto(ctx, 1, 2, 9001, 100); err != nil || !found {
|
||||
t.Fatalf("seed personal photo: %v found=%v", err, found)
|
||||
}
|
||||
cached := NewCachedContactStore(base, 0)
|
||||
if _, err := cached.GetMany(ctx, 1, []int64{2}); err != nil {
|
||||
t.Fatalf("warm contacts: %v", err)
|
||||
}
|
||||
if _, err := cached.PersonalPhotos(ctx, 1, []int64{2}); err != nil {
|
||||
t.Fatalf("warm personal photos: %v", err)
|
||||
}
|
||||
|
||||
cached.mu.RLock()
|
||||
contactSnap, contactsWarm := cached.contacts[1]
|
||||
photoSnap, photosWarm := cached.personalPhotos[1]
|
||||
cached.mu.RUnlock()
|
||||
if !contactsWarm || !photosWarm {
|
||||
t.Fatal("snapshots were not warm before mutation")
|
||||
}
|
||||
|
||||
started := make(chan struct{})
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
signaled := false
|
||||
for {
|
||||
contact := contactSnap.contacts[2]
|
||||
for i := range contactSnap.ordered {
|
||||
_ = contactSnap.ordered[i].User.ID
|
||||
}
|
||||
ref := photoSnap.refs[2]
|
||||
_, _ = contact.FirstName, ref.PhotoID
|
||||
if !signaled {
|
||||
close(started)
|
||||
signaled = true
|
||||
}
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
waitForCacheTestSignal(t, started)
|
||||
if err := tc.mutate(ctx, cached); err != nil {
|
||||
close(stop)
|
||||
<-done
|
||||
t.Fatalf("mutation: %v", err)
|
||||
}
|
||||
close(stop)
|
||||
<-done
|
||||
|
||||
// A snapshot obtained before invalidation remains a valid immutable
|
||||
// value for an in-flight reader; only the outer cache entry is removed.
|
||||
if got := contactSnap.contacts[2]; got.FirstName != "Before" || got.CloseFriend {
|
||||
t.Fatalf("published contact snapshot mutated in place: %+v", got)
|
||||
}
|
||||
if got := photoSnap.refs[2]; got.PhotoID != 9001 {
|
||||
t.Fatalf("published photo snapshot mutated in place: %+v", got)
|
||||
}
|
||||
cached.mu.RLock()
|
||||
_, contactsWarm = cached.contacts[1]
|
||||
_, photosWarm = cached.personalPhotos[1]
|
||||
cached.mu.RUnlock()
|
||||
if contactsWarm || photosWarm {
|
||||
t.Fatalf("mutation left stale snapshots published: contacts=%v photos=%v", contactsWarm, photosWarm)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -363,6 +950,59 @@ func TestCachedContactStoreDoesNotRefillStaleSnapshotAfterInvalidation(t *testin
|
|||
if cachedHit[2].FirstName != "Alicia" {
|
||||
t.Fatalf("cached value after stale load retry = %+v, want Alicia", cachedHit[2])
|
||||
}
|
||||
if calls := blocking.callCount(); calls != 2 {
|
||||
t.Fatalf("ListByUser calls = %d, want stale load plus exact-viewer retry", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStoreFlushRejectsEveryInFlightRefill(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewContactStore()
|
||||
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice"}); err != nil {
|
||||
t.Fatalf("seed contact: %v", err)
|
||||
}
|
||||
first, err := base.ListByUser(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot first contact list: %v", err)
|
||||
}
|
||||
blocking := &blockingFirstListContactStore{
|
||||
ContactStore: base,
|
||||
started: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
first: first,
|
||||
}
|
||||
cached := NewCachedContactStore(blocking, time.Hour)
|
||||
|
||||
type readResult struct {
|
||||
contacts map[int64]domain.Contact
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan readResult, 1)
|
||||
go func() {
|
||||
contacts, err := cached.GetMany(ctx, 1, []int64{2})
|
||||
resultCh <- readResult{contacts: contacts, err: err}
|
||||
}()
|
||||
waitForCacheTestSignal(t, blocking.started)
|
||||
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alicia"}); err != nil {
|
||||
t.Fatalf("update contact while first load is blocked: %v", err)
|
||||
}
|
||||
cached.FlushReadModelCache()
|
||||
close(blocking.release)
|
||||
|
||||
select {
|
||||
case result := <-resultCh:
|
||||
if result.err != nil {
|
||||
t.Fatalf("contact read: %v", result.err)
|
||||
}
|
||||
if got := result.contacts[2].FirstName; got != "Alicia" {
|
||||
t.Fatalf("flush allowed stale refill: got %q", got)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for contact read")
|
||||
}
|
||||
if calls := blocking.callCount(); calls != 2 {
|
||||
t.Fatalf("ListByUser calls = %d, want stale load plus post-flush retry", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStoreInvalidatesPersonalPhoto(t *testing.T) {
|
||||
|
|
@ -412,7 +1052,10 @@ func TestCachedContactStoreInvalidatesPersonalPhoto(t *testing.T) {
|
|||
t.Fatalf("PersonalPhotos calls after invalidation = %d, want 2", counting.personalPhotoCalls)
|
||||
}
|
||||
if counting.listCalls != 2 {
|
||||
t.Fatalf("ListByUser calls after invalidation = %d, want 2", counting.listCalls)
|
||||
t.Fatalf("ListByUser calls after mutation = %d, want 2 after safe invalidation and reload", counting.listCalls)
|
||||
}
|
||||
if counting.setPersonalPhotoHit != 1 {
|
||||
t.Fatalf("SetPersonalPhoto calls = %d, want 1", counting.setPersonalPhotoHit)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -466,3 +1109,68 @@ func TestCachedContactStoreDoesNotRefillStalePersonalPhotoAfterInvalidation(t *t
|
|||
t.Fatalf("personal photo after concurrent invalidation = %+v, want 9002", result.refs[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedContactStoreOlderPersonalPhotoMutationCannotReinsertStalePair(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewContactStore()
|
||||
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice"}); err != nil {
|
||||
t.Fatalf("seed contact: %v", err)
|
||||
}
|
||||
if _, found, err := base.SetPersonalPhoto(ctx, 1, 2, 9000, 99); err != nil || !found {
|
||||
t.Fatalf("seed personal photo: %v found=%v", err, found)
|
||||
}
|
||||
inner := &stalePersonalPhotoWritebackStore{
|
||||
ContactStore: base,
|
||||
started: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
cached := NewCachedContactStore(inner, 0)
|
||||
if refs, err := cached.PersonalPhotos(ctx, 1, []int64{2}); err != nil || refs[2].PhotoID != 9000 {
|
||||
t.Fatalf("warm personal photo = %+v err=%v, want 9000", refs[2], err)
|
||||
}
|
||||
|
||||
olderCtx := context.WithValue(ctx, stalePersonalPhotoWritebackContextKey{}, true)
|
||||
type setResult struct {
|
||||
found bool
|
||||
err error
|
||||
}
|
||||
olderResult := make(chan setResult, 1)
|
||||
go func() {
|
||||
_, found, err := cached.SetPersonalPhoto(olderCtx, 1, 2, 9001, 100)
|
||||
olderResult <- setResult{found: found, err: err}
|
||||
}()
|
||||
waitForCacheTestSignal(t, inner.started)
|
||||
|
||||
// The newer DB commit completes and invalidates the warm snapshot first.
|
||||
if _, found, err := cached.SetPersonalPhoto(ctx, 1, 2, 9002, 101); err != nil || !found {
|
||||
t.Fatalf("newer personal photo mutation: %v found=%v", err, found)
|
||||
}
|
||||
close(inner.release)
|
||||
select {
|
||||
case result := <-olderResult:
|
||||
if result.err != nil || !result.found {
|
||||
t.Fatalf("older personal photo mutation: %v found=%v", result.err, result.found)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for older personal photo mutation")
|
||||
}
|
||||
|
||||
if calls := inner.staleReads(); calls != 0 {
|
||||
t.Fatalf("post-commit stale PersonalPhotos reads = %d, want 0", calls)
|
||||
}
|
||||
cached.mu.RLock()
|
||||
_, contactsWarm := cached.contacts[1]
|
||||
_, photosWarm := cached.personalPhotos[1]
|
||||
_, pairWarm := cached.projection[contactProjectionKey{viewerUserID: 1, contactUserID: 2}]
|
||||
cached.mu.RUnlock()
|
||||
if contactsWarm || photosWarm || pairWarm {
|
||||
t.Fatalf("older mutation reinserted stale cache state: contacts=%v photos=%v pair=%v", contactsWarm, photosWarm, pairWarm)
|
||||
}
|
||||
refs, err := cached.PersonalPhotos(ctx, 1, []int64{2})
|
||||
if err != nil {
|
||||
t.Fatalf("reload current personal photo: %v", err)
|
||||
}
|
||||
if got := refs[2].PhotoID; got != 9002 {
|
||||
t.Fatalf("personal photo after out-of-order completions = %d, want 9002", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
142
internal/app/userprojection/durable_user_facts.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package userprojection
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/app/readmodel"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
type accountFreezeFact struct {
|
||||
value domain.AccountFreeze
|
||||
found bool
|
||||
}
|
||||
|
||||
// DurableUserProjectionFacts caches only viewer-independent durable overlays.
|
||||
// Contact/privacy/presence decisions remain outside and are evaluated after
|
||||
// these facts are loaded.
|
||||
type DurableUserProjectionFacts struct {
|
||||
freezes AccountFreezeProvider
|
||||
versions store.ReadModelVersionStore
|
||||
|
||||
freezeCache *readmodelcache.Cache[int64, accountFreezeFact]
|
||||
}
|
||||
|
||||
func NewDurableUserProjectionFacts(
|
||||
freezes AccountFreezeProvider,
|
||||
versions store.ReadModelVersionStore,
|
||||
maxEntries int,
|
||||
) *DurableUserProjectionFacts {
|
||||
return &DurableUserProjectionFacts{
|
||||
freezes: freezes,
|
||||
versions: versions,
|
||||
freezeCache: readmodelcache.New[int64, accountFreezeFact](readmodelcache.Config[int64, accountFreezeFact]{
|
||||
MaxEntries: maxEntries,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *DurableUserProjectionFacts) AccountFreezes(ctx context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error) {
|
||||
out := make(map[int64]domain.AccountFreeze)
|
||||
ids := uniqueDurableFactUserIDs(userIDs)
|
||||
if f == nil || f.freezes == nil || len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
hashes, err := f.factHashes(ctx, readmodel.ModelUserVisibility, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loaded, err := f.freezeCache.GetOrLoadBatch(ctx, ids,
|
||||
func(userID int64) (int64, bool) {
|
||||
hash := hashes[userID]
|
||||
return hash, f.versions != nil && hash != 0
|
||||
},
|
||||
func(ctx context.Context, missing []int64) (map[int64]accountFreezeFact, error) {
|
||||
values, err := f.freezes.AccountFreezes(ctx, missing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make(map[int64]accountFreezeFact, len(missing))
|
||||
for _, userID := range missing {
|
||||
entry := accountFreezeFact{}
|
||||
if value, ok := values[userID]; ok {
|
||||
entry = accountFreezeFact{value: value, found: true}
|
||||
}
|
||||
entries[userID] = entry
|
||||
}
|
||||
return entries, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for userID, entry := range loaded {
|
||||
if entry.found {
|
||||
out[userID] = entry.value
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AccountFreeze exposes the same versioned positive/negative cache to scalar
|
||||
// RPC gates. It deliberately delegates to the batch path so gate reads and
|
||||
// user/dialog projection cannot drift into separate cache semantics.
|
||||
func (f *DurableUserProjectionFacts) AccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error) {
|
||||
if userID == 0 {
|
||||
return domain.AccountFreeze{}, false, nil
|
||||
}
|
||||
items, err := f.AccountFreezes(ctx, []int64{userID})
|
||||
if err != nil {
|
||||
return domain.AccountFreeze{}, false, err
|
||||
}
|
||||
value, found := items[userID]
|
||||
return value, found, nil
|
||||
}
|
||||
|
||||
func (f *DurableUserProjectionFacts) factHashes(ctx context.Context, model string, userIDs []int64) (map[int64]int64, error) {
|
||||
out := make(map[int64]int64, len(userIDs))
|
||||
if f == nil || f.versions == nil {
|
||||
return out, nil
|
||||
}
|
||||
keys := make([]store.ReadModelKey, 0, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
keys = append(keys, store.ReadModelKey{Model: model, OwnerUserID: 0, PeerType: domain.PeerTypeUser, PeerID: userID})
|
||||
}
|
||||
rows, err := f.versions.ReadModelHashes(ctx, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, key := range keys {
|
||||
out[key.PeerID] = rows[key]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *DurableUserProjectionFacts) InvalidateAccountFreezeFact(userID int64) {
|
||||
if f != nil && userID != 0 {
|
||||
f.freezeCache.Invalidate(userID)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *DurableUserProjectionFacts) FlushUserProjectionFactReadModel() {
|
||||
if f != nil {
|
||||
f.freezeCache.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueDurableFactUserIDs(ids []int64) []int64 {
|
||||
out := make([]int64, 0, len(ids))
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
153
internal/app/userprojection/durable_user_facts_test.go
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
package userprojection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/app/readmodel"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
type durableFactVersions struct {
|
||||
mu sync.Mutex
|
||||
hashes map[store.ReadModelKey]int64
|
||||
}
|
||||
|
||||
func (v *durableFactVersions) ReadModelHash(_ context.Context, model string, ownerUserID int64, peerType domain.PeerType, peerID int64) (int64, bool, error) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
hash := v.hashes[store.ReadModelKey{Model: model, OwnerUserID: ownerUserID, PeerType: peerType, PeerID: peerID}]
|
||||
return hash, hash != 0, nil
|
||||
}
|
||||
|
||||
func (v *durableFactVersions) ReadModelHashes(_ context.Context, keys []store.ReadModelKey) (map[store.ReadModelKey]int64, error) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
out := make(map[store.ReadModelKey]int64, len(keys))
|
||||
for _, key := range keys {
|
||||
if hash := v.hashes[key]; hash != 0 {
|
||||
out[key] = hash
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (v *durableFactVersions) set(model string, userID, hash int64) {
|
||||
v.mu.Lock()
|
||||
v.hashes[store.ReadModelKey{Model: model, OwnerUserID: 0, PeerType: domain.PeerTypeUser, PeerID: userID}] = hash
|
||||
v.mu.Unlock()
|
||||
}
|
||||
|
||||
type countingDurableFreezeFacts struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
values map[int64]domain.AccountFreeze
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *countingDurableFreezeFacts) AccountFreezes(_ context.Context, ids []int64) (map[int64]domain.AccountFreeze, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
out := make(map[int64]domain.AccountFreeze)
|
||||
for _, id := range ids {
|
||||
if value, ok := f.values[id]; ok {
|
||||
out[id] = value
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestDurableUserProjectionFactsCachesPositiveAndNegativeByVersion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
versions := &durableFactVersions{hashes: make(map[store.ReadModelKey]int64)}
|
||||
for _, id := range []int64{1, 2} {
|
||||
versions.set(readmodel.ModelUserVisibility, id, 10+id)
|
||||
}
|
||||
freezes := &countingDurableFreezeFacts{values: map[int64]domain.AccountFreeze{1: {UserID: 1, Frozen: true}}}
|
||||
facts := NewDurableUserProjectionFacts(freezes, versions, 10)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
gotFreezes, err := facts.AccountFreezes(ctx, []int64{1, 2, 1})
|
||||
if err != nil || len(gotFreezes) != 1 || !gotFreezes[1].Frozen {
|
||||
t.Fatalf("AccountFreezes(%d) = %+v err=%v", i, gotFreezes, err)
|
||||
}
|
||||
}
|
||||
if freezes.calls != 1 {
|
||||
t.Fatalf("backend calls freezes = %d, want 1 including negative hits", freezes.calls)
|
||||
}
|
||||
|
||||
versions.set(readmodel.ModelUserVisibility, 2, 32)
|
||||
freezes.values[2] = domain.AccountFreeze{UserID: 2, Frozen: true}
|
||||
gotFreezes, err := facts.AccountFreezes(ctx, []int64{1, 2})
|
||||
if err != nil || !gotFreezes[2].Frozen {
|
||||
t.Fatalf("AccountFreezes after version bump = %+v err=%v", gotFreezes, err)
|
||||
}
|
||||
if freezes.calls != 2 {
|
||||
t.Fatalf("backend calls after one-key bumps freezes = %d, want 2", freezes.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableUserProjectionFactsScalarFreezeGateReusesVersionedFact(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
versions := &durableFactVersions{hashes: make(map[store.ReadModelKey]int64)}
|
||||
versions.set(readmodel.ModelUserVisibility, 1, 11)
|
||||
versions.set(readmodel.ModelUserVisibility, 2, 12)
|
||||
freezes := &countingDurableFreezeFacts{values: map[int64]domain.AccountFreeze{
|
||||
1: {UserID: 1, Frozen: true},
|
||||
}}
|
||||
facts := NewDurableUserProjectionFacts(freezes, versions, 10)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
freeze, found, err := facts.AccountFreeze(ctx, 1)
|
||||
if err != nil || !found || !freeze.Frozen {
|
||||
t.Fatalf("positive scalar gate %d = %+v found=%v err=%v", i, freeze, found, err)
|
||||
}
|
||||
freeze, found, err = facts.AccountFreeze(ctx, 2)
|
||||
if err != nil || found || freeze.Frozen {
|
||||
t.Fatalf("negative scalar gate %d = %+v found=%v err=%v", i, freeze, found, err)
|
||||
}
|
||||
}
|
||||
if freezes.calls != 2 {
|
||||
t.Fatalf("backend calls = %d, want one positive and one negative fill", freezes.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableUserProjectionFactErrorsAreNotNegativeCached(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
versions := &durableFactVersions{hashes: make(map[store.ReadModelKey]int64)}
|
||||
versions.set(readmodel.ModelUserVisibility, 1, 11)
|
||||
freezes := &countingDurableFreezeFacts{values: map[int64]domain.AccountFreeze{}, err: errors.New("freeze unavailable")}
|
||||
facts := NewDurableUserProjectionFacts(freezes, versions, 10)
|
||||
|
||||
if _, err := facts.AccountFreezes(ctx, []int64{1}); err == nil {
|
||||
t.Fatal("AccountFreezes error = nil")
|
||||
}
|
||||
freezes.err = nil
|
||||
if _, err := facts.AccountFreezes(ctx, []int64{1}); err != nil {
|
||||
t.Fatalf("recovered AccountFreezes: %v", err)
|
||||
}
|
||||
if freezes.calls != 2 {
|
||||
t.Fatalf("backend calls freezes = %d, want retry", freezes.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableUserProjectionFactExplicitInvalidation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
versions := &durableFactVersions{hashes: make(map[store.ReadModelKey]int64)}
|
||||
versions.set(readmodel.ModelUserVisibility, 1, 11)
|
||||
freezes := &countingDurableFreezeFacts{values: map[int64]domain.AccountFreeze{}}
|
||||
facts := NewDurableUserProjectionFacts(freezes, versions, 10)
|
||||
_, _ = facts.AccountFreezes(ctx, []int64{1})
|
||||
facts.InvalidateAccountFreezeFact(1)
|
||||
_, _ = facts.AccountFreezes(ctx, []int64{1})
|
||||
if freezes.calls != 2 {
|
||||
t.Fatalf("backend calls after invalidation freezes = %d, want 2", freezes.calls)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,11 +8,16 @@ import (
|
|||
"telesrv/internal/readmodelcache"
|
||||
)
|
||||
|
||||
// DefaultPhotoCacheTTL 是头像投影缓存的兜底有效期;正常正确性依赖写入侧触发
|
||||
// read_model_versions/NOTIFY 后显式失效,TTL 只负责覆盖进程外漏通知或手工改库。
|
||||
const DefaultPhotoCacheTTL = 10 * time.Second
|
||||
const (
|
||||
// DefaultPhotoCacheTTL 是头像投影缓存的兜底有效期;正常正确性依赖写入侧触发
|
||||
// read_model_versions/NOTIFY 后显式失效,TTL 只负责覆盖漏通知或手工改库。
|
||||
// 10s 会让 60s 的 10k 登录突发反复丢失稳定负结果,不能作为正常新鲜度机制。
|
||||
DefaultPhotoCacheTTL = 24 * time.Hour
|
||||
|
||||
const photoCacheMaxEntries = 200000
|
||||
// DefaultPhotoCacheMaxEntries 覆盖 10k owner 的 profile/fallback 两种 key,
|
||||
// 并为共享对话引用保留余量。底层是逐项 LRU,不允许整表清空。
|
||||
DefaultPhotoCacheMaxEntries = 200_000
|
||||
)
|
||||
|
||||
// combinedPhotoProvider 是同时具备 batch 与 kind 两种头像查询能力的底层 provider(postgres
|
||||
// MediaStore 即满足)。
|
||||
|
|
@ -50,21 +55,32 @@ type CachedPhotoProvider struct {
|
|||
|
||||
// NewCachedPhotoProvider 包装底层 provider;ttl<=0 用 DefaultPhotoCacheTTL。
|
||||
func NewCachedPhotoProvider(inner combinedPhotoProvider, ttl time.Duration) *CachedPhotoProvider {
|
||||
return newCachedPhotoProviderWithClock(inner, ttl, nil)
|
||||
return NewCachedPhotoProviderWithMaxEntries(inner, ttl, DefaultPhotoCacheMaxEntries)
|
||||
}
|
||||
|
||||
func NewCachedPhotoProviderWithMaxEntries(inner combinedPhotoProvider, ttl time.Duration, maxEntries int) *CachedPhotoProvider {
|
||||
return newCachedPhotoProvider(inner, ttl, maxEntries, nil)
|
||||
}
|
||||
|
||||
// newCachedPhotoProviderWithClock 允许注入时钟,仅供测试确定地推进 TTL;now=nil 用真实时钟。
|
||||
func newCachedPhotoProviderWithClock(inner combinedPhotoProvider, ttl time.Duration, now func() time.Time) *CachedPhotoProvider {
|
||||
return newCachedPhotoProvider(inner, ttl, DefaultPhotoCacheMaxEntries, now)
|
||||
}
|
||||
|
||||
func newCachedPhotoProvider(inner combinedPhotoProvider, ttl time.Duration, maxEntries int, now func() time.Time) *CachedPhotoProvider {
|
||||
if inner == nil {
|
||||
return nil
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultPhotoCacheTTL
|
||||
}
|
||||
if maxEntries <= 0 {
|
||||
maxEntries = DefaultPhotoCacheMaxEntries
|
||||
}
|
||||
return &CachedPhotoProvider{
|
||||
inner: inner,
|
||||
cache: readmodelcache.New[photoCacheKey, photoCacheValue](readmodelcache.Config[photoCacheKey, photoCacheValue]{
|
||||
MaxEntries: photoCacheMaxEntries,
|
||||
MaxEntries: maxEntries,
|
||||
TTL: ttl,
|
||||
Now: now,
|
||||
Clone: clonePhotoCacheValue,
|
||||
|
|
|
|||
|
|
@ -133,6 +133,55 @@ func TestCachedPhotoProviderCachesHitsAndMisses(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCachedPhotoProviderDefaultTTLRetainsLoginRampWorkingSet(t *testing.T) {
|
||||
inner := &countingPhotoProvider{refs: map[int64]domain.ProfilePhotoRef{}}
|
||||
now := time.Unix(1000, 0)
|
||||
c := newCachedPhotoProvider(inner, 0, DefaultPhotoCacheMaxEntries, func() time.Time { return now })
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile); err != nil {
|
||||
t.Fatalf("first: %v", err)
|
||||
}
|
||||
now = now.Add(time.Minute)
|
||||
if _, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile); err != nil {
|
||||
t.Fatalf("within login ramp: %v", err)
|
||||
}
|
||||
if inner.kindCalls != 1 {
|
||||
t.Fatalf("default TTL expired inside 60s login ramp: calls=%d, want 1", inner.kindCalls)
|
||||
}
|
||||
|
||||
now = now.Add(DefaultPhotoCacheTTL)
|
||||
if _, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile); err != nil {
|
||||
t.Fatalf("after safety TTL: %v", err)
|
||||
}
|
||||
if inner.kindCalls != 2 {
|
||||
t.Fatalf("safety TTL did not reload: calls=%d, want 2", inner.kindCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedPhotoProviderConfiguredCapacityEvictsOneLRUKey(t *testing.T) {
|
||||
inner := &countingPhotoProvider{refs: map[int64]domain.ProfilePhotoRef{}}
|
||||
now := time.Unix(1000, 0)
|
||||
c := newCachedPhotoProvider(inner, time.Hour, 2, func() time.Time { return now })
|
||||
ctx := context.Background()
|
||||
read := func(ownerID int64) {
|
||||
t.Helper()
|
||||
if _, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{ownerID}, domain.ProfilePhotoKindProfile); err != nil {
|
||||
t.Fatalf("owner %d: %v", ownerID, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, ownerID := range []int64{1, 2, 1, 3, 1, 2} {
|
||||
read(ownerID)
|
||||
}
|
||||
if inner.kindCalls != 4 {
|
||||
t.Fatalf("kind calls = %d, want 4 with owner 1 touched and only owner 2 evicted", inner.kindCalls)
|
||||
}
|
||||
if c.cache.Len() != 2 {
|
||||
t.Fatalf("cache entries = %d, want configured capacity 2", c.cache.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedPhotoProviderInvalidatesOwnerAndFlushes(t *testing.T) {
|
||||
inner := &countingPhotoProvider{refs: map[int64]domain.ProfilePhotoRef{1: {PhotoID: 111}}}
|
||||
now := time.Unix(1000, 0)
|
||||
|
|
|
|||
|
|
@ -118,12 +118,10 @@ func (p *Projector) One(ctx context.Context, viewerUserID int64, user domain.Use
|
|||
// ForViewers 跨多个 viewer 批量投影同一组 owner 用户(fan-out 模板化)。它把 per-viewer 各跑
|
||||
// 一遍 ForViewer(=projectBatch) 的成本(O(viewer)×(photos+contacts+privacy) 查询)压成:
|
||||
// - 一次 profile/fallback 头像批量(跨 viewer 复用)
|
||||
// - O(owner) 次 GetReverseContacts(改名/电话覆盖,按 owner 反查 viewer)
|
||||
// - 一次 viewer-owned contact projection(联系人改名/电话覆盖 + personal photo overlay)
|
||||
// - O(owner) 次 GetMany + 一次 ListPrivacyRules(CanSeeMatrix 内做)
|
||||
//
|
||||
// 返回 map[viewerID][]domain.User,每个切片与对应 viewer 的 ForViewer(viewer, users) **字节等价,
|
||||
// 唯一例外是 personal photo overlay**:v1 简化为 fan-out 模板不做 per-viewer personal photo
|
||||
// (无 O(owner) 反查接口),客户端下次 getChannelDifference/getHistory 会走 projectBatch 完整投影自愈。
|
||||
// 返回 map[viewerID][]domain.User,每个切片与对应 viewer 的 ForViewer(viewer, users) 字节等价。
|
||||
// 调用方传入的 users 不被修改(内部复制)。
|
||||
func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users []domain.User) (map[int64][]domain.User, error) {
|
||||
users = sanitizeDeletedUsers(users)
|
||||
|
|
@ -143,26 +141,34 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
|||
}
|
||||
ids := uniqueUserIDs(users)
|
||||
|
||||
// 三组预取互不依赖(共享头像、反向联系人覆盖、privacy 矩阵),并发执行收敛成一波。
|
||||
// 三组预取互不依赖(共享头像、viewer-owned 联系人投影、privacy 矩阵),并发执行收敛成一波。
|
||||
var (
|
||||
profileRefs map[int64]domain.ProfilePhotoRef
|
||||
fallbackRefs map[int64]domain.ProfilePhotoRef
|
||||
contactsByViewer map[int64]map[int64]domain.Contact
|
||||
matrix map[int64]map[int64]map[domain.PrivacyKey]bool
|
||||
freezes map[int64]domain.AccountFreeze
|
||||
profileRefs map[int64]domain.ProfilePhotoRef
|
||||
fallbackRefs map[int64]domain.ProfilePhotoRef
|
||||
contactsByViewer map[int64]map[int64]domain.Contact
|
||||
personalRefsByViewer map[int64]map[int64]domain.ProfilePhotoRef
|
||||
matrix map[int64]map[int64]map[domain.PrivacyKey]bool
|
||||
freezes map[int64]domain.AccountFreeze
|
||||
)
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
// 1) 共享头像:profile/fallback 一次批量,跨全部 viewer 复用;personal photo v1 跳过(见 doc)。
|
||||
// 1) 共享头像:profile/fallback 一次批量,跨全部 viewer 复用。
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
profileRefs, fallbackRefs, err = p.batchProfileFallbackPhotos(gctx, ids)
|
||||
return err
|
||||
})
|
||||
// 2) 改名/电话覆盖:O(owner) 次 GetReverseContacts(owner, viewers) 重组为 [viewer][owner]Contact,
|
||||
// 与 projectBatch 的 GetMany(viewer, owners) 命中同一条联系人记录(方向对称)。
|
||||
// 2) 改名/电话覆盖 + personal photo:按 viewer 拥有的联系人行批量读取,
|
||||
// 与 projectBatch 的 GetMany/PersonalPhotos(viewer, owners) 命中同一语义。
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
contactsByViewer, err = p.reverseContactsByViewer(gctx, ids, viewers)
|
||||
if p.contacts == nil || len(ids) == 0 || len(viewers) == 0 {
|
||||
return nil
|
||||
}
|
||||
batch, err := p.contacts.ContactProjectionForViewers(gctx, viewers, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contactsByViewer = batch.Contacts
|
||||
personalRefsByViewer = batch.PersonalPhotos
|
||||
return err
|
||||
})
|
||||
// 3) privacy 可见性矩阵:O(owner) 查询;nil(无 MatrixPrivacyEvaluator)时 applyPrivacy 回退逐 CanSee。
|
||||
|
|
@ -183,10 +189,10 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
|||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 4) 逐 viewer 组装,复用与 projectBatch 完全相同的 apply* 链(personalRefs 传 nil)。
|
||||
// 4) 逐 viewer 组装,复用与 projectBatch 完全相同的 apply* 链。
|
||||
for _, viewer := range viewers {
|
||||
projected := make([]domain.User, len(users))
|
||||
copy(projected, users)
|
||||
personalRefs := personalRefsByViewer[viewer]
|
||||
projected := cloneUsers(users)
|
||||
cache := make(map[int64]domain.User, len(projected))
|
||||
for i := range projected {
|
||||
u := projected[i]
|
||||
|
|
@ -201,7 +207,7 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
|||
projected[i] = pj
|
||||
continue
|
||||
}
|
||||
pj := applyBasePhotos(u, profileRefs, fallbackRefs, nil, viewer)
|
||||
pj := applyBasePhotos(u, profileRefs, fallbackRefs, personalRefs, viewer)
|
||||
if viewer != 0 && u.ID != viewer && u.ID != domain.OfficialSystemUserID && !u.Bot {
|
||||
contact, found := contactsByViewer[viewer][u.ID]
|
||||
pj = applyContactProjection(pj, contact, found)
|
||||
|
|
@ -211,7 +217,7 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
|||
}
|
||||
var perr error
|
||||
hasKnownContactPhone := found && contact.Phone != ""
|
||||
pj, perr = applyPrivacy(ctx, p.privacy, viewer, pj, hasKnownContactPhone, vis, profileRefs, fallbackRefs, nil)
|
||||
pj, perr = applyPrivacy(ctx, p.privacy, viewer, pj, hasKnownContactPhone, vis, profileRefs, fallbackRefs, personalRefs)
|
||||
if perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
|
|
@ -225,8 +231,8 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// batchProfileFallbackPhotos 取 owner 的 profile/fallback 头像(与 projectBatch 同逻辑),personal
|
||||
// 头像不取(ForViewers v1 跳过)。photos 为 nil 时返回空 map(applyBasePhotos 视为无头像查询)。
|
||||
// batchProfileFallbackPhotos 取 owner 的 profile/fallback 头像(与 projectBatch 同逻辑)。
|
||||
// photos 为 nil 时返回空 map(applyBasePhotos 视为无头像查询)。
|
||||
func (p *Projector) batchProfileFallbackPhotos(ctx context.Context, ids []int64) (profileRefs, fallbackRefs map[int64]domain.ProfilePhotoRef, err error) {
|
||||
profileRefs = map[int64]domain.ProfilePhotoRef{}
|
||||
fallbackRefs = map[int64]domain.ProfilePhotoRef{}
|
||||
|
|
@ -253,30 +259,6 @@ func (p *Projector) batchProfileFallbackPhotos(ctx context.Context, ids []int64)
|
|||
return refs, fallbackRefs, nil
|
||||
}
|
||||
|
||||
// reverseContactsByViewer 以 O(owner) 次 GetReverseContacts(owner, viewers) 取「每个 viewer 对各
|
||||
// owner 的联系人记录」并重组为 map[viewer]map[owner]Contact。该记录与 projectBatch 的
|
||||
// GetMany(viewer, owners)[owner] 是同一条(contacts 表上 (user_id=viewer, contact_user_id=owner)
|
||||
// 的同一行,两端 store 均如此),用于 applyContactProjection 的改名/电话覆盖与 isContact 判定。
|
||||
func (p *Projector) reverseContactsByViewer(ctx context.Context, ownerIDs, viewers []int64) (map[int64]map[int64]domain.Contact, error) {
|
||||
out := make(map[int64]map[int64]domain.Contact, len(viewers))
|
||||
if p.contacts == nil || len(ownerIDs) == 0 || len(viewers) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
for _, owner := range ownerIDs {
|
||||
byViewer, err := p.contacts.GetReverseContacts(ctx, owner, viewers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for viewer, contact := range byViewer {
|
||||
if out[viewer] == nil {
|
||||
out[viewer] = make(map[int64]domain.Contact, len(ownerIDs))
|
||||
}
|
||||
out[viewer][owner] = contact
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func cloneUsers(users []domain.User) []domain.User {
|
||||
if len(users) == 0 {
|
||||
return nil
|
||||
|
|
@ -284,6 +266,7 @@ func cloneUsers(users []domain.User) []domain.User {
|
|||
out := make([]domain.User, len(users))
|
||||
copy(out, users)
|
||||
for i := range out {
|
||||
out[i].PhotoStripped = append([]byte(nil), out[i].PhotoStripped...)
|
||||
out[i].ContactNoteEntities = append([]domain.MessageEntity(nil), out[i].ContactNoteEntities...)
|
||||
out[i].RestrictionReasons = append([]domain.UserRestrictionReason(nil), out[i].RestrictionReasons...)
|
||||
}
|
||||
|
|
@ -332,8 +315,7 @@ func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users [
|
|||
if err != nil || len(refs) == 0 {
|
||||
return users
|
||||
}
|
||||
out := make([]domain.User, len(users))
|
||||
copy(out, users)
|
||||
out := cloneUsers(users)
|
||||
for i := range out {
|
||||
if ref, ok := refs[out[i].ID]; ok {
|
||||
applyPhotoRef(&out[i], ref)
|
||||
|
|
@ -351,8 +333,7 @@ func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID in
|
|||
if contacts == nil || viewerUserID == 0 || len(users) == 0 {
|
||||
return users, nil
|
||||
}
|
||||
out := make([]domain.User, len(users))
|
||||
copy(out, users)
|
||||
out := cloneUsers(users)
|
||||
cache := make(map[int64]domain.User, len(users))
|
||||
for i := range out {
|
||||
u := out[i]
|
||||
|
|
@ -386,8 +367,7 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
|||
if len(users) == 0 {
|
||||
return users, nil
|
||||
}
|
||||
out := make([]domain.User, len(users))
|
||||
copy(out, users)
|
||||
out := cloneUsers(users)
|
||||
out = sanitizeDeletedUsers(out)
|
||||
ids := uniqueUserIDs(out)
|
||||
var (
|
||||
|
|
@ -619,12 +599,20 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
|
|||
if contact.Phone != "" {
|
||||
user.Phone = contact.Phone
|
||||
}
|
||||
if contact.User.FirstName != "" || contact.User.LastName != "" {
|
||||
if contact.FirstName != "" || contact.LastName != "" {
|
||||
// FirstName uses NULLIF at the durable read boundary while LastName is an
|
||||
// explicit owner-local value. Preserve the base first name when only a
|
||||
// local last name exists; setting a local first name with an empty last
|
||||
// name intentionally clears the base last name.
|
||||
if contact.FirstName != "" {
|
||||
user.FirstName = contact.FirstName
|
||||
user.LastName = contact.LastName
|
||||
} else {
|
||||
user.LastName = contact.LastName
|
||||
}
|
||||
} else if contact.User.FirstName != "" || contact.User.LastName != "" {
|
||||
user.FirstName = contact.User.FirstName
|
||||
user.LastName = contact.User.LastName
|
||||
} else if contact.FirstName != "" || contact.LastName != "" {
|
||||
user.FirstName = contact.FirstName
|
||||
user.LastName = contact.LastName
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
|
|
|||
179
internal/app/userprojection/projection_sparse.go
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
package userprojection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSparseContactProjectionUnsupported = errors.New("sparse contact projection is not supported")
|
||||
ErrSparsePrivacyProjectionUnsupported = errors.New("sparse privacy projection is not supported")
|
||||
)
|
||||
|
||||
// SparsePrivacyEvaluator evaluates only the supplied viewer->owner pairs. The
|
||||
// inverse contact rows are supplied from the projector's shared sparse contact
|
||||
// read so privacy does not issue another contact query.
|
||||
type SparsePrivacyEvaluator interface {
|
||||
CanSeeForViewerUserIDs(
|
||||
ctx context.Context,
|
||||
ownerUserIDsByViewer map[int64][]int64,
|
||||
keys []domain.PrivacyKey,
|
||||
contactsByOwner map[int64]map[int64]domain.Contact,
|
||||
) (map[int64]map[int64]map[domain.PrivacyKey]bool, error)
|
||||
}
|
||||
|
||||
// ForViewerUserIDs projects a sparse viewer->owner graph. Viewer-independent
|
||||
// facts are loaded for the union once; viewer-specific facts are read only for
|
||||
// graph edges that occur in the request (plus their inverse contact edge needed
|
||||
// by privacy evaluation).
|
||||
func (p *Projector) ForViewerUserIDs(ctx context.Context, userIDsByViewer map[int64][]int64, baseUsers []domain.User) (map[int64][]domain.User, error) {
|
||||
requested := normalizeSparseUserIDs(userIDsByViewer)
|
||||
out := make(map[int64][]domain.User, len(requested))
|
||||
if len(requested) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
baseUsers = sanitizeDeletedUsers(baseUsers)
|
||||
baseByID := make(map[int64]domain.User, len(baseUsers))
|
||||
for _, user := range baseUsers {
|
||||
if user.ID != 0 {
|
||||
baseByID[user.ID] = user
|
||||
}
|
||||
}
|
||||
if p == nil {
|
||||
for viewerID, ids := range requested {
|
||||
out[viewerID] = sparseBaseUsers(ids, baseByID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
unionIDs := make([]int64, 0, len(baseByID))
|
||||
seenUnion := make(map[int64]struct{}, len(baseByID))
|
||||
contactPairs := make(map[int64][]int64)
|
||||
privacyPairs := make(map[int64][]int64)
|
||||
for viewerID, ids := range requested {
|
||||
for _, ownerID := range ids {
|
||||
user, found := baseByID[ownerID]
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenUnion[ownerID]; !ok && !user.Deleted {
|
||||
seenUnion[ownerID] = struct{}{}
|
||||
unionIDs = append(unionIDs, ownerID)
|
||||
}
|
||||
if user.Deleted || ownerID == viewerID {
|
||||
continue
|
||||
}
|
||||
// Personal-photo overlay applies independently of contact/privacy
|
||||
// exemptions, so retain every real viewer->owner edge here.
|
||||
contactPairs[viewerID] = append(contactPairs[viewerID], ownerID)
|
||||
if ownerID == domain.OfficialSystemUserID || user.Bot {
|
||||
continue
|
||||
}
|
||||
privacyPairs[viewerID] = append(privacyPairs[viewerID], ownerID)
|
||||
// Privacy's ViewerIsContact is the inverse owner->viewer row. Merge it
|
||||
// into the same exact-pair store call.
|
||||
contactPairs[ownerID] = append(contactPairs[ownerID], viewerID)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
profileRefs map[int64]domain.ProfilePhotoRef
|
||||
fallbackRefs map[int64]domain.ProfilePhotoRef
|
||||
contactBatch domain.ContactProjectionBatch
|
||||
visibility map[int64]map[int64]map[domain.PrivacyKey]bool
|
||||
freezes map[int64]domain.AccountFreeze
|
||||
)
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
profileRefs, fallbackRefs, err = p.batchProfileFallbackPhotos(gctx, unionIDs)
|
||||
return err
|
||||
})
|
||||
if p.contacts != nil && len(contactPairs) > 0 {
|
||||
g.Go(func() error {
|
||||
loader, ok := p.contacts.(store.SparseContactProjectionStore)
|
||||
if !ok {
|
||||
return ErrSparseContactProjectionUnsupported
|
||||
}
|
||||
var err error
|
||||
contactBatch, err = loader.ContactProjectionForViewerUserIDs(gctx, contactPairs)
|
||||
return err
|
||||
})
|
||||
}
|
||||
if p.freezes != nil && len(unionIDs) > 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
freezes, err = p.freezes.AccountFreezes(gctx, unionIDs)
|
||||
return err
|
||||
})
|
||||
}
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.privacy != nil && len(privacyPairs) > 0 {
|
||||
evaluator, ok := p.privacy.(SparsePrivacyEvaluator)
|
||||
if !ok {
|
||||
return nil, ErrSparsePrivacyProjectionUnsupported
|
||||
}
|
||||
var err error
|
||||
visibility, err = evaluator.CanSeeForViewerUserIDs(ctx, privacyPairs, privacyProjectionKeys, contactBatch.Contacts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
for viewerID, ids := range requested {
|
||||
projected := sparseBaseUsers(ids, baseByID)
|
||||
personalRefs := contactBatch.PersonalPhotos[viewerID]
|
||||
for i := range projected {
|
||||
user := projected[i]
|
||||
if user.Deleted {
|
||||
projected[i] = user.DeletedTombstone()
|
||||
continue
|
||||
}
|
||||
user = applyBasePhotos(user, profileRefs, fallbackRefs, personalRefs, viewerID)
|
||||
if viewerID != 0 && user.ID != viewerID && user.ID != domain.OfficialSystemUserID && !user.Bot {
|
||||
contact, found := contactBatch.Contacts[viewerID][user.ID]
|
||||
user = applyContactProjection(user, contact, found)
|
||||
var vis map[domain.PrivacyKey]bool
|
||||
if visibility != nil {
|
||||
vis = visibility[user.ID][viewerID]
|
||||
}
|
||||
var err error
|
||||
user, err = applyPrivacy(ctx, p.privacy, viewerID, user, found && contact.Phone != "", vis, profileRefs, fallbackRefs, personalRefs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
user = applyAccountFreezeProjection(user, viewerID, freezes[user.ID])
|
||||
projected[i] = user
|
||||
}
|
||||
out[viewerID] = projected
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeSparseUserIDs(in map[int64][]int64) map[int64][]int64 {
|
||||
out := make(map[int64][]int64, len(in))
|
||||
for viewerID, ids := range in {
|
||||
if viewerID == 0 {
|
||||
continue
|
||||
}
|
||||
out[viewerID] = dedupNonZeroInt64(ids)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sparseBaseUsers(ids []int64, baseByID map[int64]domain.User) []domain.User {
|
||||
users := make([]domain.User, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if user, ok := baseByID[id]; ok {
|
||||
users = append(users, user)
|
||||
}
|
||||
}
|
||||
return cloneUsers(users)
|
||||
}
|
||||
115
internal/app/userprojection/projection_sparse_test.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package userprojection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
privacyapp "telesrv/internal/app/privacy"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type recordingSparseContactStore struct {
|
||||
store.ContactStore
|
||||
sparseCalls int
|
||||
denseCalls int
|
||||
requested map[int64][]int64
|
||||
}
|
||||
|
||||
func (s *recordingSparseContactStore) ContactProjectionForViewers(ctx context.Context, viewers, owners []int64) (domain.ContactProjectionBatch, error) {
|
||||
s.denseCalls++
|
||||
return s.ContactStore.ContactProjectionForViewers(ctx, viewers, owners)
|
||||
}
|
||||
|
||||
func (s *recordingSparseContactStore) ContactProjectionForViewerUserIDs(ctx context.Context, requested map[int64][]int64) (domain.ContactProjectionBatch, error) {
|
||||
s.sparseCalls++
|
||||
s.requested = make(map[int64][]int64, len(requested))
|
||||
for viewerID, ids := range requested {
|
||||
s.requested[viewerID] = append([]int64(nil), ids...)
|
||||
}
|
||||
return s.ContactStore.(store.SparseContactProjectionStore).ContactProjectionForViewerUserIDs(ctx, requested)
|
||||
}
|
||||
|
||||
func TestForViewerUserIDsUsesActualPairsAndMatchesScalarProjection(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const (
|
||||
viewerA = int64(1101)
|
||||
viewerB = int64(1102)
|
||||
ownerA = int64(2101)
|
||||
ownerB = int64(2102)
|
||||
)
|
||||
contacts := memory.NewContactStore()
|
||||
// Seed both requested and cross-viewer rows. A dense matrix would expose the
|
||||
// cross aliases/photos; the sparse request must never ask for those pairs.
|
||||
for _, input := range []struct {
|
||||
viewer int64
|
||||
owner int64
|
||||
name string
|
||||
photo int64
|
||||
}{
|
||||
{viewerA, ownerA, "A for viewer A", 9101},
|
||||
{viewerA, ownerB, "B cross leak", 9191},
|
||||
{viewerB, ownerB, "B for viewer B", 9102},
|
||||
{viewerB, ownerA, "A cross leak", 9192},
|
||||
// Reverse rows are the privacy ViewerIsContact facts.
|
||||
{ownerA, viewerA, "viewer A", 0},
|
||||
{ownerB, viewerB, "viewer B", 0},
|
||||
} {
|
||||
if _, err := contacts.Upsert(ctx, input.viewer, domain.ContactInput{ContactUserID: input.owner, FirstName: input.name}); err != nil {
|
||||
t.Fatalf("upsert %d->%d: %v", input.viewer, input.owner, err)
|
||||
}
|
||||
if input.photo != 0 {
|
||||
if _, _, err := contacts.SetPersonalPhoto(ctx, input.viewer, input.owner, input.photo, 100); err != nil {
|
||||
t.Fatalf("personal photo %d->%d: %v", input.viewer, input.owner, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
recording := &recordingSparseContactStore{ContactStore: contacts}
|
||||
privacy := privacyapp.NewService(memory.NewPrivacyStore(), recording)
|
||||
projector := New(WithContactStore(recording), WithPrivacyEvaluator(privacy))
|
||||
base := []domain.User{
|
||||
{ID: ownerA, AccessHash: 31, Phone: "15552101", FirstName: "Owner A"},
|
||||
{ID: ownerB, AccessHash: 32, Phone: "15552102", FirstName: "Owner B"},
|
||||
}
|
||||
wantA, err := projector.ForViewer(ctx, viewerA, base[:1])
|
||||
if err != nil {
|
||||
t.Fatalf("scalar viewer A: %v", err)
|
||||
}
|
||||
wantB, err := projector.ForViewer(ctx, viewerB, base[1:])
|
||||
if err != nil {
|
||||
t.Fatalf("scalar viewer B: %v", err)
|
||||
}
|
||||
recording.sparseCalls = 0
|
||||
recording.denseCalls = 0
|
||||
recording.requested = nil
|
||||
|
||||
got, err := projector.ForViewerUserIDs(ctx, map[int64][]int64{
|
||||
viewerA: {ownerA},
|
||||
viewerB: {ownerB},
|
||||
}, base)
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewerUserIDs: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got[viewerA], wantA) || !reflect.DeepEqual(got[viewerB], wantB) {
|
||||
t.Fatalf("sparse projection = %+v, want scalar A=%+v B=%+v", got, wantA, wantB)
|
||||
}
|
||||
if recording.sparseCalls != 1 || recording.denseCalls != 0 {
|
||||
t.Fatalf("contact projection calls = sparse %d dense %d, want 1/0", recording.sparseCalls, recording.denseCalls)
|
||||
}
|
||||
wantPairs := map[int64][]int64{
|
||||
viewerA: {ownerA},
|
||||
viewerB: {ownerB},
|
||||
ownerA: {viewerA},
|
||||
ownerB: {viewerB},
|
||||
}
|
||||
for viewerID, ids := range wantPairs {
|
||||
if !reflect.DeepEqual(recording.requested[viewerID], ids) {
|
||||
t.Fatalf("requested[%d] = %v, want %v (all=%+v)", viewerID, recording.requested[viewerID], ids, recording.requested)
|
||||
}
|
||||
}
|
||||
if len(recording.requested) != len(wantPairs) {
|
||||
t.Fatalf("requested pairs = %+v, contains unexpected cross-viewer edges", recording.requested)
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,15 @@ import (
|
|||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestCloneUsersDoesNotSharePhotoStripped(t *testing.T) {
|
||||
source := []domain.User{{ID: 1, PhotoStripped: []byte{1, 2, 3}}}
|
||||
cloned := cloneUsers(source)
|
||||
cloned[0].PhotoStripped[0] = 9
|
||||
if source[0].PhotoStripped[0] != 1 {
|
||||
t.Fatalf("cloneUsers shared PhotoStripped backing storage: source=%v clone=%v", source[0].PhotoStripped, cloned[0].PhotoStripped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectorCombinesProfilePhotosAndViewerContacts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const viewerID int64 = 1001
|
||||
|
|
@ -224,10 +233,8 @@ func TestProjectorAccountFreezeIsViewerScopedAndReversible(t *testing.T) {
|
|||
|
||||
// TestForViewersEquivalentToForViewer 锁定 fan-out 模板化的核心安全网:ForViewers(viewers, users)
|
||||
// 的每个 viewer 切片必须与逐 viewer 的 ForViewer(viewer, users) 字节等价(隐私/改名/头像投影
|
||||
// 不能因 O(owner) 模板化而漂移泄漏)。**唯一允许的差异是 personal photo overlay**:v1 模板不做
|
||||
// per-viewer personal photo,故对「该 viewer 给该 owner 设过 personal photo」的对,比较前 mask 掉
|
||||
// 5 个头像字段;其余对做完整字节比较。覆盖:默认规则陌生人/联系人改名+电话/status 隐藏/profile
|
||||
// 头像隐藏走 fallback/self/bot/系统账号/viewer 自身也作为 owner 出现。
|
||||
// 不能因批量模板化而漂移泄漏)。覆盖:默认规则陌生人/联系人改名+电话/personal photo/status
|
||||
// 隐藏/profile 头像隐藏走 fallback/self/bot/系统账号/viewer 自身也作为 owner 出现。
|
||||
func TestForViewersEquivalentToForViewer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const (
|
||||
|
|
@ -248,7 +255,7 @@ func TestForViewersEquivalentToForViewer(t *testing.T) {
|
|||
if _, err := contacts.Upsert(ctx, v1, domain.ContactInput{ContactUserID: o2, Phone: "1111", FirstName: "Alice", LastName: "Friend"}); err != nil {
|
||||
t.Fatalf("upsert contact: %v", err)
|
||||
}
|
||||
// v1 给 o2 设 personal photo(仅 v1 视角生效 → ForViewer 会带它,ForViewers v1 跳过 → 该对需 mask)。
|
||||
// v1 给 o2 设 personal photo:ForViewers 必须与 ForViewer 一样带出 viewer-specific 头像。
|
||||
if _, _, err := contacts.SetPersonalPhoto(ctx, v1, o2, 9300, 300); err != nil {
|
||||
t.Fatalf("set personal photo: %v", err)
|
||||
}
|
||||
|
|
@ -287,13 +294,13 @@ func TestForViewersEquivalentToForViewer(t *testing.T) {
|
|||
{ID: v1, AccessHash: 16, Phone: "15550000016", FirstName: "Viewer1"}, // viewer 自身也作为 owner 出现
|
||||
}
|
||||
|
||||
// 哪些 (viewer, owner) 对存在 personal photo —— 比较时需 mask 头像字段(v1 模板有意跳过)。
|
||||
personalPairs := map[[2]int64]bool{{v1, o2}: true}
|
||||
|
||||
batch, err := projector.ForViewers(ctx, viewers, users)
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewers: %v", err)
|
||||
}
|
||||
if got := projectionUser(t, batch[v1], o2); got.PhotoID != 9300 || !got.PhotoPersonal {
|
||||
t.Fatalf("fanout personal photo = id %d personal %v, want personal 9300", got.PhotoID, got.PhotoPersonal)
|
||||
}
|
||||
for _, viewer := range viewers {
|
||||
want, err := projector.ForViewer(ctx, viewer, users)
|
||||
if err != nil {
|
||||
|
|
@ -311,10 +318,6 @@ func TestForViewersEquivalentToForViewer(t *testing.T) {
|
|||
if w.ID != g.ID {
|
||||
t.Fatalf("viewer %d idx %d id mismatch got=%d want=%d", viewer, i, g.ID, w.ID)
|
||||
}
|
||||
if personalPairs[[2]int64{viewer, w.ID}] {
|
||||
maskPhoto(&w)
|
||||
maskPhoto(&g)
|
||||
}
|
||||
if !reflect.DeepEqual(w, g) {
|
||||
t.Fatalf("viewer %d owner %d: ForViewers != ForViewer\n got=%+v\nwant=%+v", viewer, w.ID, g, w)
|
||||
}
|
||||
|
|
@ -322,14 +325,6 @@ func TestForViewersEquivalentToForViewer(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func maskPhoto(u *domain.User) {
|
||||
u.PhotoID = 0
|
||||
u.PhotoDCID = 0
|
||||
u.PhotoStripped = nil
|
||||
u.PhotoPersonal = false
|
||||
u.PhotoHasVideo = false
|
||||
}
|
||||
|
||||
func projectionUser(t *testing.T, users []domain.User, id int64) domain.User {
|
||||
t.Helper()
|
||||
for _, user := range users {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package users
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
|
@ -13,7 +14,14 @@ import (
|
|||
)
|
||||
|
||||
// ErrNotAuthorized 表示当前 auth_key 尚未登录。
|
||||
var ErrNotAuthorized = errors.New("not authorized")
|
||||
var (
|
||||
ErrNotAuthorized = errors.New("not authorized")
|
||||
ErrSystemUserImmutable = errors.New("system user identity is immutable")
|
||||
ErrBatchUsersLimit = errors.New("batch users limit exceeded")
|
||||
ErrBatchViewerCells = errors.New("batch viewer projection cell limit exceeded")
|
||||
ErrBatchUserMissing = errors.New("batch user projection source is incomplete")
|
||||
ErrLastSeenBatchUnsupported = errors.New("last seen batch store unsupported")
|
||||
)
|
||||
|
||||
// ProfilePhotoProvider 批量返回用户当前头像(用于把 PhotoID/DCID/Stripped 富化到 domain.User)。
|
||||
type ProfilePhotoProvider = userprojection.ProfilePhotoProvider
|
||||
|
|
@ -93,6 +101,9 @@ const (
|
|||
maxProfileAboutRunes = 70
|
||||
maxProfileAboutRunesPremium = 140
|
||||
maxBatchUsers = 1000
|
||||
// A dense fan-out materializes one complete domain.User per viewer/owner
|
||||
// cell in both the result and the batch cache. Bound the retained graph.
|
||||
maxBatchViewerProjectionCells = 131072
|
||||
)
|
||||
|
||||
// NewService 创建用户服务。
|
||||
|
|
@ -161,6 +172,19 @@ func (s *Service) AdminUser(ctx context.Context, userID int64) (domain.User, boo
|
|||
return s.loadBaseUserByID(ctx, userID)
|
||||
}
|
||||
|
||||
// BotStatus returns only the immutable viewer-independent bot fact. Presence
|
||||
// classification must not pay for contact/privacy/photo projection.
|
||||
func (s *Service) BotStatus(ctx context.Context, userID int64) (bool, bool, error) {
|
||||
if userID == 0 {
|
||||
return false, false, nil
|
||||
}
|
||||
u, found, err := s.loadBaseUserByID(ctx, userID)
|
||||
if err != nil || !found {
|
||||
return false, found, err
|
||||
}
|
||||
return u.Bot, true, nil
|
||||
}
|
||||
|
||||
// PrivacyBaseUsers returns viewer-independent bot/premium facts through the
|
||||
// shared base-user read model. Privacy uses this as a batched cold loader behind
|
||||
// its bounded process cache; no viewer projection is performed, avoiding a
|
||||
|
|
@ -186,11 +210,11 @@ func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int6
|
|||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
if len(ids) >= maxBatchUsers {
|
||||
return nil, fmt.Errorf("%w: more than %d unique owners", ErrBatchUsersLimit, maxBatchUsers)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
if len(ids) >= maxBatchUsers {
|
||||
break
|
||||
}
|
||||
}
|
||||
users, err := s.loadBaseUsersByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
|
|
@ -200,22 +224,68 @@ func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int6
|
|||
}
|
||||
|
||||
// ByIDsForViewers 跨多个 viewer 批量投影同一组 user(fan-out 模板化):base user 只加载一次,
|
||||
// 隐私/改名/头像投影经 userprojection.ForViewers 压成 O(owner) 查询。返回 map[viewerID][]User,
|
||||
// 每个切片与 ByIDs(viewer, ids) 字节等价——**唯一例外是 personal photo overlay**(ForViewers v1
|
||||
// 跳过,客户端下次 getChannelDifference/getHistory 自愈)。供 channel fan-out 预热每 viewer 投影,
|
||||
// 隐私/改名/头像投影经 userprojection.ForViewers 收敛成批量查询。返回 map[viewerID][]User,
|
||||
// 每个切片与 ByIDs(viewer, ids) 字节等价,包含 viewer-specific personal photo overlay。
|
||||
// 供 channel fan-out 预热每 viewer 投影,
|
||||
// 把 per-recipient 的 ByIDs(=ForViewer) 折叠成一次跨 viewer 投影。不做 ByIDs 的单 caller 鉴权
|
||||
// (viewer 是 fan-out 收件人集合,非 RPC 调用方)。
|
||||
func (s *Service) ByIDsForViewers(ctx context.Context, viewerUserIDs []int64, userIDs []int64) (map[int64][]domain.User, error) {
|
||||
if len(viewerUserIDs) == 0 || len(userIDs) == 0 {
|
||||
return map[int64][]domain.User{}, nil
|
||||
}
|
||||
ids := uniqueUserIDs(userIDs, maxBatchUsers)
|
||||
ids := uniqueUserIDs(userIDs, 0)
|
||||
if len(ids) > maxBatchUsers {
|
||||
return nil, fmt.Errorf("%w: got %d unique owners, maximum %d", ErrBatchUsersLimit, len(ids), maxBatchUsers)
|
||||
}
|
||||
viewers := uniqueUserIDs(viewerUserIDs, 0)
|
||||
if !batchViewerProjectionCellsAllowed(len(viewers), len(ids)) {
|
||||
return nil, fmt.Errorf("%w: got %d viewers x %d owners, maximum %d cells", ErrBatchViewerCells, len(viewers), len(ids), maxBatchViewerProjectionCells)
|
||||
}
|
||||
base, err := s.loadBaseUsersByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base, err = requireBatchBaseUsers(ids, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// projector 为 nil 时 ForViewers 返回各 viewer 的原始 base 副本(与 projectUsers 的 nil 分支一致)。
|
||||
return s.projector.ForViewers(ctx, viewerUserIDs, base)
|
||||
return s.projector.ForViewers(ctx, viewers, base)
|
||||
}
|
||||
|
||||
func batchViewerProjectionCellsAllowed(viewers, owners int) bool {
|
||||
if viewers <= 0 || owners <= 0 {
|
||||
return true
|
||||
}
|
||||
// Division avoids overflow from viewers*owners on hostile inputs.
|
||||
return viewers <= maxBatchViewerProjectionCells/owners
|
||||
}
|
||||
|
||||
// requireBatchBaseUsers turns the fan-out projection API into a complete
|
||||
// envelope contract. Deleted users remain durable tombstones and therefore
|
||||
// still appear in base; a truly missing referenced user must fail closed rather
|
||||
// than produce a message whose sender cannot be resolved. System users are
|
||||
// protocol-local constants and do not require a backing users row.
|
||||
func requireBatchBaseUsers(ids []int64, base []domain.User) ([]domain.User, error) {
|
||||
byID := make(map[int64]domain.User, len(base))
|
||||
for _, user := range base {
|
||||
if user.ID != 0 {
|
||||
byID[user.ID] = user
|
||||
}
|
||||
}
|
||||
out := make([]domain.User, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if user, ok := byID[id]; ok {
|
||||
out = append(out, user)
|
||||
continue
|
||||
}
|
||||
if system, ok := domain.SystemUserByID(id); ok {
|
||||
out = append(out, system)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("%w: user_id=%d", ErrBatchUserMissing, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CheckUsername 校验当前用户是否可以占用 username。
|
||||
|
|
@ -277,35 +347,6 @@ func (s *Service) UpdateUsername(ctx context.Context, userID int64, username str
|
|||
return s.projectOne(ctx, self.ID, u)
|
||||
}
|
||||
|
||||
// SetPhone force-sets a user's phone number (admin use -- no code
|
||||
// verification, unlike the user-facing verified change-phone flow in
|
||||
// internal/app/account). Pre-checks availability via ByPhone before writing,
|
||||
// on top of the store's own unique-constraint backstop.
|
||||
func (s *Service) SetPhone(ctx context.Context, userID int64, phone string) (domain.User, error) {
|
||||
self, err := s.loadSelf(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
phone = domain.NormalizePhone(strings.TrimSpace(phone))
|
||||
if !domain.ValidPhone(phone) {
|
||||
return domain.User{}, domain.ErrPhoneNumberInvalid
|
||||
}
|
||||
if phone == self.Phone {
|
||||
return s.projectOne(ctx, self.ID, self)
|
||||
}
|
||||
if existing, found, err := s.users.ByPhone(ctx, phone); err != nil {
|
||||
return domain.User{}, err
|
||||
} else if found && existing.ID != self.ID {
|
||||
return domain.User{}, domain.ErrPhoneNumberOccupied
|
||||
}
|
||||
u, err := s.users.UpdatePhone(ctx, self.ID, phone)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
s.refreshCachedUsers(ctx, u)
|
||||
return s.projectOne(ctx, self.ID, u)
|
||||
}
|
||||
|
||||
// UpdateProfile 修改当前用户的基础资料。未设置的字段保持原值。
|
||||
func (s *Service) UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) {
|
||||
self, err := s.loadSelf(ctx, userID)
|
||||
|
|
@ -345,6 +386,37 @@ func (s *Service) UpdateProfile(ctx context.Context, userID int64, update domain
|
|||
return s.projectOne(ctx, self.ID, u)
|
||||
}
|
||||
|
||||
// SetPhone force-sets the authoritative phone for the trusted admin path. It
|
||||
// remains a non-PTS profile mutation because updateUserPhone and updateUser
|
||||
// carry no pts/pts_count in every admitted exact layer.
|
||||
func (s *Service) SetPhone(ctx context.Context, userID int64, phone string) (domain.User, error) {
|
||||
self, err := s.loadSelf(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if self.Bot || domain.IsSystemUserID(self.ID) {
|
||||
return domain.User{}, domain.ErrPhoneChangeForbidden
|
||||
}
|
||||
phone = domain.NormalizePhone(strings.TrimSpace(phone))
|
||||
if !domain.ValidPhone(phone) {
|
||||
return domain.User{}, domain.ErrPhoneNumberInvalid
|
||||
}
|
||||
if phone == self.Phone {
|
||||
return s.projectOne(ctx, self.ID, self)
|
||||
}
|
||||
if existing, found, err := s.users.ByPhone(ctx, phone); err != nil {
|
||||
return domain.User{}, err
|
||||
} else if found && existing.ID != self.ID {
|
||||
return domain.User{}, domain.ErrPhoneNumberOccupied
|
||||
}
|
||||
u, err := s.users.UpdatePhone(ctx, self.ID, phone)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
s.refreshCachedUsers(ctx, u)
|
||||
return s.projectOne(ctx, self.ID, u)
|
||||
}
|
||||
|
||||
// UpdateLastSeen records the latest visible account activity time.
|
||||
func (s *Service) UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error {
|
||||
if userID == 0 {
|
||||
|
|
@ -360,6 +432,45 @@ func (s *Service) UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt i
|
|||
return nil
|
||||
}
|
||||
|
||||
// UpdateLastSeenBatch is the production lifecycle-presence write boundary. It
|
||||
// requires a real batch-capable store: silently looping over UpdateLastSeen
|
||||
// would recreate the exact per-account transaction fan-out this API exists to
|
||||
// remove. The cache delete is part of batch completion; callers may retry the
|
||||
// whole idempotent batch when Redis is temporarily unavailable.
|
||||
func (s *Service) UpdateLastSeenBatch(ctx context.Context, updates []store.UserLastSeenUpdate) error {
|
||||
batch, ok := s.users.(store.UserLastSeenBatchStore)
|
||||
if !ok {
|
||||
return ErrLastSeenBatchUnsupported
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
if len(latest) == 0 {
|
||||
return nil
|
||||
}
|
||||
merged := make([]store.UserLastSeenUpdate, 0, len(latest))
|
||||
userIDs := make([]int64, 0, len(latest))
|
||||
for userID, lastSeenAt := range latest {
|
||||
merged = append(merged, store.UserLastSeenUpdate{UserID: userID, LastSeenAt: lastSeenAt})
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
if err := batch.UpdateLastSeenBatch(ctx, merged); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.cache != nil {
|
||||
if err := s.cache.Delete(ctx, userIDs); err != nil {
|
||||
return fmt.Errorf("invalidate last seen batch user cache: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PremiumActive 报告用户当前是否有效会员。走基础用户缓存路径、不做 viewer
|
||||
// 投影,供限额双档判断(pin 上限、reaction 上限、bio 长度等)低成本调用。
|
||||
func (s *Service) PremiumActive(ctx context.Context, userID int64) bool {
|
||||
|
|
@ -412,6 +523,9 @@ func (s *Service) SetVerified(ctx context.Context, userID int64, verified bool)
|
|||
if userID == 0 {
|
||||
return domain.User{}, ErrNotAuthorized
|
||||
}
|
||||
if domain.IsSystemUserID(userID) && !verified {
|
||||
return domain.User{}, ErrSystemUserImmutable
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
|
|
@ -630,6 +744,7 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user
|
|||
return domain.User{}, false, err
|
||||
}
|
||||
username = normalizeUsername(username)
|
||||
_, reservedSystemUsername := domain.SystemUserByUsername(username)
|
||||
// Resolution covers both the editable username slot (5..32) and
|
||||
// Fragment-style collectible usernames (4..32). Keep the stricter
|
||||
// validUsername check on create/update paths; only lookup accepts the
|
||||
|
|
@ -638,8 +753,7 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user
|
|||
// server-controlled handles, not user input -- but still resolve through
|
||||
// the normal DB-backed path below, so caching/projection/hidden-bot
|
||||
// handling stay exactly as for any other account.
|
||||
_, isSystemUsername := domain.SystemUserByUsername(username)
|
||||
if !isSystemUsername && !domain.ValidCollectibleUsername(username) {
|
||||
if !reservedSystemUsername && !domain.ValidCollectibleUsername(username) {
|
||||
return domain.User{}, false, domain.ErrUsernameInvalid
|
||||
}
|
||||
u, found, err := s.users.ByUsername(ctx, username)
|
||||
|
|
@ -664,7 +778,7 @@ func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone s
|
|||
if _, err := s.loadSelf(ctx, currentUserID); err != nil {
|
||||
return domain.User{}, false, err
|
||||
}
|
||||
phone = normalizePhone(phone)
|
||||
phone = domain.NormalizePhone(phone)
|
||||
if phone == "" {
|
||||
return domain.User{}, false, domain.ErrPhoneNotOccupied
|
||||
}
|
||||
|
|
@ -721,7 +835,10 @@ func (s *Service) loadBaseUserByID(ctx context.Context, userID int64) (domain.Us
|
|||
}
|
||||
|
||||
func (s *Service) loadBaseUsersByIDs(ctx context.Context, userIDs []int64) ([]domain.User, error) {
|
||||
ids := uniqueUserIDs(userIDs, maxBatchUsers)
|
||||
ids := uniqueUserIDs(userIDs, maxBatchUsers+1)
|
||||
if len(ids) > maxBatchUsers {
|
||||
return nil, fmt.Errorf("%w: more than %d unique owners", ErrBatchUsersLimit, maxBatchUsers)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -799,6 +916,15 @@ func (s *Service) dropCachedUsers(ctx context.Context, userIDs ...int64) {
|
|||
_ = s.cache.Delete(ctx, userIDs)
|
||||
}
|
||||
|
||||
// InvalidateUsers drops viewer-independent user snapshots after an aggregate
|
||||
// transaction updates users without passing through this service.
|
||||
func (s *Service) InvalidateUsers(ctx context.Context, userIDs ...int64) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.dropCachedUsers(ctx, userIDs...)
|
||||
}
|
||||
|
||||
func uniqueUserIDs(ids []int64, limit int) []int64 {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
|
|
@ -857,18 +983,3 @@ func validUsername(username string) bool {
|
|||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizePhone(phone string) string {
|
||||
phone = strings.TrimSpace(phone)
|
||||
if phone == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(phone))
|
||||
for _, r := range phone {
|
||||
if r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
|
|
|||
58
internal/app/users/service_sparse.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package users
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// ByIDsForViewerUserIDs projects an actual sparse viewer->owner graph. Base
|
||||
// users are loaded once for the union; unlike ByIDsForViewers, owners belonging
|
||||
// to one viewer are never implicitly projected for every other viewer.
|
||||
func (s *Service) ByIDsForViewerUserIDs(ctx context.Context, userIDsByViewer map[int64][]int64) (map[int64][]domain.User, error) {
|
||||
requested := make(map[int64][]int64, len(userIDsByViewer))
|
||||
union := make([]int64, 0)
|
||||
seenUnion := make(map[int64]struct{})
|
||||
pairs := 0
|
||||
for viewerID, userIDs := range userIDsByViewer {
|
||||
if viewerID == 0 {
|
||||
continue
|
||||
}
|
||||
ids := uniqueUserIDs(userIDs, 0)
|
||||
if !sparseViewerProjectionPairsAllowed(pairs, len(ids)) {
|
||||
return nil, fmt.Errorf("%w: got more than %d sparse pairs", ErrBatchViewerCells, maxBatchViewerProjectionCells)
|
||||
}
|
||||
pairs += len(ids)
|
||||
requested[viewerID] = ids
|
||||
for _, id := range ids {
|
||||
if _, ok := seenUnion[id]; ok {
|
||||
continue
|
||||
}
|
||||
seenUnion[id] = struct{}{}
|
||||
union = append(union, id)
|
||||
if len(union) > maxBatchUsers {
|
||||
return nil, ErrBatchUsersLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(requested) == 0 || len(union) == 0 {
|
||||
return map[int64][]domain.User{}, nil
|
||||
}
|
||||
base, err := s.loadBaseUsersByIDs(ctx, union)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base, err = requireBatchBaseUsers(union, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.projector.ForViewerUserIDs(ctx, requested, base)
|
||||
}
|
||||
|
||||
func sparseViewerProjectionPairsAllowed(current, additional int) bool {
|
||||
if current < 0 || additional < 0 || current > maxBatchViewerProjectionCells {
|
||||
return false
|
||||
}
|
||||
return additional <= maxBatchViewerProjectionCells-current
|
||||
}
|
||||
138
internal/app/users/service_sparse_test.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package users
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
privacyapp "telesrv/internal/app/privacy"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type countingSparseBaseUserStore struct {
|
||||
store.UserStore
|
||||
byIDsCalls int
|
||||
byIDs []int64
|
||||
}
|
||||
|
||||
func (s *countingSparseBaseUserStore) ByIDs(ctx context.Context, ids []int64) ([]domain.User, error) {
|
||||
s.byIDsCalls++
|
||||
s.byIDs = append([]int64(nil), ids...)
|
||||
return s.UserStore.ByIDs(ctx, ids)
|
||||
}
|
||||
|
||||
type countingSparsePhotoProvider struct {
|
||||
profile map[int64]domain.ProfilePhotoRef
|
||||
profileCalls int
|
||||
fallbackCalls int
|
||||
}
|
||||
|
||||
func (p *countingSparsePhotoProvider) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
return p.CurrentProfilePhotosKind(ctx, ownerType, ids, domain.ProfilePhotoKindProfile)
|
||||
}
|
||||
|
||||
func (p *countingSparsePhotoProvider) CurrentProfilePhotosKind(_ context.Context, _ domain.PeerType, ids []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
if kind == domain.ProfilePhotoKindFallback {
|
||||
p.fallbackCalls++
|
||||
return map[int64]domain.ProfilePhotoRef{}, nil
|
||||
}
|
||||
p.profileCalls++
|
||||
out := make(map[int64]domain.ProfilePhotoRef, len(ids))
|
||||
for _, id := range ids {
|
||||
if ref, ok := p.profile[id]; ok {
|
||||
out[id] = ref
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestByIDsForViewerUserIDsLoadsUnionOnceAndPreservesViewerSemantics(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewUserStore()
|
||||
viewerA, _ := base.Create(ctx, domain.User{Phone: "15550001", FirstName: "Viewer A"})
|
||||
viewerB, _ := base.Create(ctx, domain.User{Phone: "15550002", FirstName: "Viewer B"})
|
||||
ownerA, _ := base.Create(ctx, domain.User{Phone: "15550101", FirstName: "Owner A"})
|
||||
ownerB, _ := base.Create(ctx, domain.User{Phone: "15550102", FirstName: "Owner B"})
|
||||
contacts := memory.NewContactStore()
|
||||
if _, err := contacts.Upsert(ctx, viewerA.ID, domain.ContactInput{ContactUserID: ownerA.ID, FirstName: "Alias A", Phone: "local-a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := contacts.Upsert(ctx, viewerB.ID, domain.ContactInput{ContactUserID: ownerB.ID, FirstName: "Alias B"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found, err := contacts.SetPersonalPhoto(ctx, viewerA.ID, ownerA.ID, 9901, 1); err != nil || !found {
|
||||
t.Fatalf("SetPersonalPhoto: found=%v err=%v", found, err)
|
||||
}
|
||||
rules := memory.NewPrivacyStore()
|
||||
privacy := privacyapp.NewService(rules, contacts)
|
||||
if _, err := privacy.SetRules(ctx, ownerA.ID, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := privacy.SetRules(ctx, ownerB.ID, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
countingUsers := &countingSparseBaseUserStore{UserStore: base}
|
||||
photos := &countingSparsePhotoProvider{profile: map[int64]domain.ProfilePhotoRef{
|
||||
viewerA.ID: {PhotoID: 9801, DCID: 2}, viewerB.ID: {PhotoID: 9802, DCID: 2},
|
||||
ownerA.ID: {PhotoID: 9811, DCID: 2}, ownerB.ID: {PhotoID: 9812, DCID: 2},
|
||||
}}
|
||||
svc := NewService(countingUsers, WithContactStore(contacts), WithPrivacyEvaluator(privacy), WithPhotoProvider(photos))
|
||||
got, err := svc.ByIDsForViewerUserIDs(ctx, map[int64][]int64{
|
||||
viewerA.ID: {ownerA.ID, viewerA.ID},
|
||||
viewerB.ID: {ownerB.ID, viewerB.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ByIDsForViewerUserIDs: %v", err)
|
||||
}
|
||||
if countingUsers.byIDsCalls != 1 {
|
||||
t.Fatalf("base ByIDs calls = %d, want one union load", countingUsers.byIDsCalls)
|
||||
}
|
||||
sort.Slice(countingUsers.byIDs, func(i, j int) bool { return countingUsers.byIDs[i] < countingUsers.byIDs[j] })
|
||||
wantIDs := []int64{viewerA.ID, viewerB.ID, ownerA.ID, ownerB.ID}
|
||||
sort.Slice(wantIDs, func(i, j int) bool { return wantIDs[i] < wantIDs[j] })
|
||||
if len(countingUsers.byIDs) != len(wantIDs) {
|
||||
t.Fatalf("base ids = %v, want %v", countingUsers.byIDs, wantIDs)
|
||||
}
|
||||
for i := range wantIDs {
|
||||
if countingUsers.byIDs[i] != wantIDs[i] {
|
||||
t.Fatalf("base ids = %v, want %v", countingUsers.byIDs, wantIDs)
|
||||
}
|
||||
}
|
||||
if photos.profileCalls != 1 || photos.fallbackCalls != 1 {
|
||||
t.Fatalf("photo reads = profile %d fallback %d, want one each", photos.profileCalls, photos.fallbackCalls)
|
||||
}
|
||||
a := got[viewerA.ID][0]
|
||||
if a.ID != ownerA.ID || a.FirstName != "Alias A" || a.Phone != "local-a" || a.PhotoID != 9901 || !a.PhotoPersonal {
|
||||
t.Fatalf("viewer A owner projection = %+v", a)
|
||||
}
|
||||
selfA := got[viewerA.ID][1]
|
||||
if selfA.ID != viewerA.ID || selfA.FirstName != "Viewer A" || selfA.Phone != "15550001" || selfA.PhotoID != 9801 {
|
||||
t.Fatalf("viewer A self projection = %+v", selfA)
|
||||
}
|
||||
b := got[viewerB.ID][0]
|
||||
if b.ID != ownerB.ID || b.FirstName != "Alias B" || b.Phone != "15550102" || b.PhotoID != 9812 || b.PhotoPersonal {
|
||||
t.Fatalf("viewer B owner projection = %+v", b)
|
||||
}
|
||||
selfB := got[viewerB.ID][1]
|
||||
if selfB.ID != viewerB.ID || selfB.FirstName != "Viewer B" || selfB.Phone != "15550002" || selfB.PhotoID != 9802 {
|
||||
t.Fatalf("viewer B self projection = %+v", selfB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByIDsForViewerUserIDsRejectsMissingReferencedUserAndPairOverflow(t *testing.T) {
|
||||
svc := NewService(memory.NewUserStore())
|
||||
if _, err := svc.ByIDsForViewerUserIDs(context.Background(), map[int64][]int64{
|
||||
1001: {2001},
|
||||
}); !errors.Is(err, ErrBatchUserMissing) {
|
||||
t.Fatalf("missing referenced user err = %v, want ErrBatchUserMissing", err)
|
||||
}
|
||||
if !sparseViewerProjectionPairsAllowed(maxBatchViewerProjectionCells-1, 1) {
|
||||
t.Fatal("sparse pair admission rejected the exact boundary")
|
||||
}
|
||||
if sparseViewerProjectionPairsAllowed(maxBatchViewerProjectionCells, 1) {
|
||||
t.Fatal("sparse pair admission accepted a batch above the boundary")
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import (
|
|||
|
||||
privacyapp "telesrv/internal/app/privacy"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
|
|
@ -160,6 +161,84 @@ func TestResolveUsernameHidesMarksbotWhenThirdPartyVerificationHidden(t *testing
|
|||
}
|
||||
}
|
||||
|
||||
func TestByIDsForViewersRejectsOwnerSetAboveBound(t *testing.T) {
|
||||
svc := NewService(memory.NewUserStore())
|
||||
ids := make([]int64, maxBatchUsers+1)
|
||||
for i := range ids {
|
||||
ids[i] = int64(i + 1)
|
||||
}
|
||||
if _, err := svc.ByIDsForViewers(context.Background(), []int64{1}, ids); !errors.Is(err, ErrBatchUsersLimit) {
|
||||
t.Fatalf("ByIDsForViewers err = %v, want ErrBatchUsersLimit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByIDsRejectsOwnerSetAboveBoundInsteadOfTruncating(t *testing.T) {
|
||||
svc := NewService(memory.NewUserStore())
|
||||
ids := make([]int64, maxBatchUsers+1)
|
||||
for i := range ids {
|
||||
ids[i] = int64(i + 1)
|
||||
}
|
||||
if _, err := svc.ByIDs(context.Background(), 1, ids); !errors.Is(err, ErrBatchUsersLimit) {
|
||||
t.Fatalf("ByIDs err = %v, want ErrBatchUsersLimit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotStatusReadsViewerIndependentBaseFact(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewUserStore()
|
||||
bot, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000077", FirstName: "Bot", Bot: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewService(base)
|
||||
got, found, err := svc.BotStatus(ctx, bot.ID)
|
||||
if err != nil || !found || !got {
|
||||
t.Fatalf("BotStatus = %v, found=%v, err=%v", got, found, err)
|
||||
}
|
||||
if got, found, err := svc.BotStatus(ctx, bot.ID+1000); err != nil || found || got {
|
||||
t.Fatalf("missing BotStatus = %v, found=%v, err=%v", got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivacyBaseUsersRejectsViewerSetAboveBoundInsteadOfNegativeCachingTruncation(t *testing.T) {
|
||||
svc := NewService(memory.NewUserStore())
|
||||
ids := make([]int64, maxBatchUsers+1)
|
||||
for i := range ids {
|
||||
ids[i] = int64(i + 1)
|
||||
}
|
||||
if _, err := svc.PrivacyBaseUsers(context.Background(), ids); !errors.Is(err, ErrBatchUsersLimit) {
|
||||
t.Fatalf("PrivacyBaseUsers err = %v, want ErrBatchUsersLimit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByIDsForViewersRejectsDenseCellSetAboveBound(t *testing.T) {
|
||||
svc := NewService(memory.NewUserStore())
|
||||
owners := make([]int64, maxBatchUsers)
|
||||
for i := range owners {
|
||||
owners[i] = int64(i + 1)
|
||||
}
|
||||
viewers := make([]int64, maxBatchViewerProjectionCells/maxBatchUsers+1)
|
||||
for i := range viewers {
|
||||
viewers[i] = int64(10_000 + i)
|
||||
}
|
||||
if _, err := svc.ByIDsForViewers(context.Background(), viewers, owners); !errors.Is(err, ErrBatchViewerCells) {
|
||||
t.Fatalf("ByIDsForViewers err = %v, want ErrBatchViewerCells", err)
|
||||
}
|
||||
if !batchViewerProjectionCellsAllowed(1, maxBatchViewerProjectionCells) {
|
||||
t.Fatal("cell limit rejected exact boundary")
|
||||
}
|
||||
if batchViewerProjectionCellsAllowed(2, maxBatchViewerProjectionCells) {
|
||||
t.Fatal("cell limit accepted overflow boundary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestByIDsForViewersRejectsMissingReferencedUser(t *testing.T) {
|
||||
svc := NewService(memory.NewUserStore())
|
||||
if _, err := svc.ByIDsForViewers(context.Background(), []int64{1001}, []int64{2001}); !errors.Is(err, ErrBatchUserMissing) {
|
||||
t.Fatalf("ByIDsForViewers err = %v, want ErrBatchUserMissing", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePhoneHonorsAddedByPhone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
|
|
@ -434,6 +513,48 @@ func TestServiceUsesBaseCacheWithoutCachingViewerOverlay(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestServiceUpdateLastSeenBatchIsMonotonicAndInvalidatesOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewUserStore()
|
||||
first, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000881", FirstName: "First"})
|
||||
if err != nil {
|
||||
t.Fatalf("create first: %v", err)
|
||||
}
|
||||
second, err := base.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000882", FirstName: "Second"})
|
||||
if err != nil {
|
||||
t.Fatalf("create second: %v", err)
|
||||
}
|
||||
cache := newMemoryBaseUserCache()
|
||||
if err := cache.PutMany(ctx, []domain.User{first, second}); err != nil {
|
||||
t.Fatalf("prime cache: %v", err)
|
||||
}
|
||||
svc := NewService(base, WithBaseUserCache(cache))
|
||||
if err := svc.UpdateLastSeenBatch(ctx, []store.UserLastSeenUpdate{
|
||||
{UserID: second.ID, LastSeenAt: 20},
|
||||
{UserID: first.ID, LastSeenAt: 9},
|
||||
{UserID: first.ID, LastSeenAt: 17},
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateLastSeenBatch: %v", err)
|
||||
}
|
||||
loadedFirst, found, err := base.ByID(ctx, first.ID)
|
||||
if err != nil || !found || loadedFirst.LastSeenAt != 17 {
|
||||
t.Fatalf("first last seen = %d found=%v err=%v, want 17", loadedFirst.LastSeenAt, found, err)
|
||||
}
|
||||
loadedSecond, found, err := base.ByID(ctx, second.ID)
|
||||
if err != nil || !found || loadedSecond.LastSeenAt != 20 {
|
||||
t.Fatalf("second last seen = %d found=%v err=%v, want 20", loadedSecond.LastSeenAt, found, err)
|
||||
}
|
||||
if cache.deleteCalls != 1 {
|
||||
t.Fatalf("cache delete calls = %d, want one batch invalidation", cache.deleteCalls)
|
||||
}
|
||||
if _, ok := cache.users[first.ID]; ok {
|
||||
t.Fatal("first user remained cached")
|
||||
}
|
||||
if _, ok := cache.users[second.ID]; ok {
|
||||
t.Fatal("second user remained cached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRefreshesBaseCacheAfterProfileUpdate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := memory.NewUserStore()
|
||||
|
|
@ -507,6 +628,9 @@ func TestServiceSetVerifiedRefreshesBaseCache(t *testing.T) {
|
|||
if cleared.Verified {
|
||||
t.Fatalf("cleared verified = true, want false")
|
||||
}
|
||||
if _, err := svc.SetVerified(ctx, domain.OfficialSystemUserID, false); !errors.Is(err, ErrSystemUserImmutable) {
|
||||
t.Fatalf("clear system user verified err=%v, want ErrSystemUserImmutable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRefreshesBaseCacheAfterColorUpdate(t *testing.T) {
|
||||
|
|
@ -612,7 +736,8 @@ func (s *countingUserStore) ByIDs(ctx context.Context, ids []int64) ([]domain.Us
|
|||
}
|
||||
|
||||
type memoryBaseUserCache struct {
|
||||
users map[int64]domain.User
|
||||
users map[int64]domain.User
|
||||
deleteCalls int
|
||||
}
|
||||
|
||||
func newMemoryBaseUserCache() *memoryBaseUserCache {
|
||||
|
|
@ -639,6 +764,7 @@ func (c *memoryBaseUserCache) PutMany(_ context.Context, users []domain.User) er
|
|||
}
|
||||
|
||||
func (c *memoryBaseUserCache) Delete(_ context.Context, ids []int64) error {
|
||||
c.deleteCalls++
|
||||
for _, id := range ids {
|
||||
delete(c.users, id)
|
||||
}
|
||||
|
|
|
|||
121
internal/app/welcomemessages/service.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package welcomemessages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
type ChannelAccess interface {
|
||||
ResolveChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error)
|
||||
}
|
||||
|
||||
type Option func(*Service)
|
||||
|
||||
func WithClock(now func() time.Time) Option {
|
||||
return func(s *Service) {
|
||||
if now != nil {
|
||||
s.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
messages store.WelcomeMessageStore
|
||||
channels ChannelAccess
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(messages store.WelcomeMessageStore, channels ChannelAccess, options ...Option) *Service {
|
||||
s := &Service{messages: messages, channels: channels, now: time.Now}
|
||||
for _, option := range options {
|
||||
if option != nil {
|
||||
option(s)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Authorize is the cheap gate RPC uses before resolving upload/media/rich
|
||||
// content. Mutations call authorize again immediately before the store write so
|
||||
// a concurrent demotion cannot turn this preflight into a stale capability.
|
||||
func (s *Service) Authorize(ctx context.Context, userID int64, peer domain.Peer) error {
|
||||
return s.authorize(ctx, userID, peer)
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, userID int64, peer domain.Peer, randomID int64, content domain.WelcomeMessageContent) (domain.WelcomeMessage, bool, error) {
|
||||
if err := s.authorize(ctx, userID, peer); err != nil {
|
||||
return domain.WelcomeMessage{}, false, err
|
||||
}
|
||||
if err := content.Validate(); err != nil {
|
||||
return domain.WelcomeMessage{}, false, err
|
||||
}
|
||||
fingerprint, err := domain.WelcomeCreateFingerprint(peer, userID, randomID, content)
|
||||
if err != nil {
|
||||
return domain.WelcomeMessage{}, false, domain.ErrWelcomeMessageInvalid
|
||||
}
|
||||
return s.messages.CreateWelcomeMessage(ctx, domain.CreateWelcomeMessageRequest{
|
||||
Peer: peer, CreatorUserID: userID, Date: int(s.now().Unix()), RandomID: randomID,
|
||||
Content: content, CreateFingerprint: fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) Edit(ctx context.Context, userID int64, peer domain.Peer, id int, fields domain.WelcomeMessageEditFields) (domain.WelcomeMessage, error) {
|
||||
if err := s.authorize(ctx, userID, peer); err != nil {
|
||||
return domain.WelcomeMessage{}, err
|
||||
}
|
||||
return s.messages.EditWelcomeMessage(ctx, domain.EditWelcomeMessageRequest{
|
||||
Peer: peer, ID: id, EditDate: int(s.now().Unix()), Fields: fields,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, userID int64, peer domain.Peer, hash int64) (domain.WelcomeMessageList, error) {
|
||||
if err := s.authorize(ctx, userID, peer); err != nil {
|
||||
return domain.WelcomeMessageList{}, err
|
||||
}
|
||||
return s.messages.ListWelcomeMessages(ctx, peer, hash)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, userID int64, peer domain.Peer, id int) (bool, error) {
|
||||
if err := s.authorize(ctx, userID, peer); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return s.messages.DeleteWelcomeMessage(ctx, peer, id)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteAll(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
|
||||
if err := s.authorize(ctx, userID, peer); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return s.messages.DeleteAllWelcomeMessages(ctx, peer)
|
||||
}
|
||||
|
||||
// HasAny is used only after the ordinary full-chat access check has succeeded.
|
||||
// It deliberately does not require manage_welcome_messages so non-admin members
|
||||
// receive the same has_welcome_messages projection as official clients.
|
||||
func (s *Service) HasAny(ctx context.Context, peer domain.Peer) (bool, error) {
|
||||
if s == nil || s.messages == nil || peer.Type != domain.PeerTypeChannel || peer.ID <= 0 {
|
||||
return false, domain.ErrWelcomeMessageInvalid
|
||||
}
|
||||
return s.messages.HasWelcomeMessages(ctx, peer)
|
||||
}
|
||||
|
||||
func (s *Service) authorize(ctx context.Context, userID int64, peer domain.Peer) error {
|
||||
if s == nil || s.messages == nil || s.channels == nil || userID <= 0 ||
|
||||
peer.Type != domain.PeerTypeChannel || peer.ID <= 0 {
|
||||
return domain.ErrWelcomeMessageInvalid
|
||||
}
|
||||
view, err := s.channels.ResolveChannel(ctx, userID, peer.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if view.Channel.Monoforum {
|
||||
return domain.ErrWelcomeMessagePeerInvalid
|
||||
}
|
||||
if !view.Self.CanManageWelcomeMessages() {
|
||||
return domain.ErrWelcomeMessageForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
96
internal/app/welcomemessages/service_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package welcomemessages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type welcomeStoreSpy struct {
|
||||
creates int
|
||||
}
|
||||
|
||||
func (s *welcomeStoreSpy) CreateWelcomeMessage(_ context.Context, req domain.CreateWelcomeMessageRequest) (domain.WelcomeMessage, bool, error) {
|
||||
s.creates++
|
||||
return domain.WelcomeMessage{
|
||||
ID: 1, Peer: req.Peer, CreatorUserID: req.CreatorUserID, Date: req.Date,
|
||||
RandomID: req.RandomID, Content: req.Content, CreateFingerprint: req.CreateFingerprint, Version: 1,
|
||||
}, true, nil
|
||||
}
|
||||
func (*welcomeStoreSpy) EditWelcomeMessage(context.Context, domain.EditWelcomeMessageRequest) (domain.WelcomeMessage, error) {
|
||||
return domain.WelcomeMessage{}, nil
|
||||
}
|
||||
func (*welcomeStoreSpy) ListWelcomeMessages(context.Context, domain.Peer, int64) (domain.WelcomeMessageList, error) {
|
||||
return domain.WelcomeMessageList{Hash: 1}, nil
|
||||
}
|
||||
func (*welcomeStoreSpy) DeleteWelcomeMessage(context.Context, domain.Peer, int) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
func (*welcomeStoreSpy) DeleteAllWelcomeMessages(context.Context, domain.Peer) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
func (*welcomeStoreSpy) HasWelcomeMessages(context.Context, domain.Peer) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
type welcomeChannelAccess struct {
|
||||
view domain.ChannelView
|
||||
err error
|
||||
}
|
||||
|
||||
func (a *welcomeChannelAccess) ResolveChannel(context.Context, int64, int64) (domain.ChannelView, error) {
|
||||
return a.view, a.err
|
||||
}
|
||||
|
||||
func TestServiceRechecksManageWelcomeMessagesPermission(t *testing.T) {
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 77}
|
||||
store := &welcomeStoreSpy{}
|
||||
channels := &welcomeChannelAccess{view: domain.ChannelView{
|
||||
Channel: domain.Channel{ID: peer.ID, Megagroup: true},
|
||||
Self: domain.ChannelMember{
|
||||
ChannelID: peer.ID, UserID: 9, Role: domain.ChannelRoleAdmin,
|
||||
Status: domain.ChannelMemberActive,
|
||||
AdminRights: domain.ChannelAdminRights{ManageWelcomeMessages: true},
|
||||
},
|
||||
}}
|
||||
service := NewService(store, channels, WithClock(func() time.Time { return time.Unix(1700000000, 0) }))
|
||||
message, created, err := service.Create(context.Background(), 9, peer, 1001, domain.WelcomeMessageContent{Message: "hello"})
|
||||
if err != nil || !created || message.Date != 1700000000 || store.creates != 1 {
|
||||
t.Fatalf("authorized create = %+v created=%v calls=%d err=%v", message, created, store.creates, err)
|
||||
}
|
||||
|
||||
channels.view.Self.Status = domain.ChannelMemberLeft
|
||||
if _, _, err := service.Create(context.Background(), 9, peer, 1002, domain.WelcomeMessageContent{Message: "blocked"}); !errors.Is(err, domain.ErrWelcomeMessageForbidden) || store.creates != 1 {
|
||||
t.Fatalf("inactive admin create err=%v calls=%d", err, store.creates)
|
||||
}
|
||||
|
||||
channels.view.Self = domain.ChannelMember{UserID: 9, Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive}
|
||||
if _, err := service.List(context.Background(), 9, peer, 0); err != nil {
|
||||
t.Fatalf("creator list: %v", err)
|
||||
}
|
||||
channels.view.Channel.Monoforum = true
|
||||
if _, err := service.List(context.Background(), 9, peer, 0); !errors.Is(err, domain.ErrWelcomeMessagePeerInvalid) {
|
||||
t.Fatalf("monoforum list err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRejectsOrdinaryMemberAndAllowsJoinedBroadcastAdmin(t *testing.T) {
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 88}
|
||||
store := &welcomeStoreSpy{}
|
||||
channels := &welcomeChannelAccess{view: domain.ChannelView{
|
||||
Channel: domain.Channel{ID: peer.ID, Broadcast: true},
|
||||
Self: domain.ChannelMember{UserID: 10, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberActive},
|
||||
}}
|
||||
service := NewService(store, channels)
|
||||
if _, err := service.DeleteAll(context.Background(), 10, peer); !errors.Is(err, domain.ErrWelcomeMessageForbidden) {
|
||||
t.Fatalf("ordinary member delete-all err=%v", err)
|
||||
}
|
||||
channels.view.Self.Role = domain.ChannelRoleAdmin
|
||||
channels.view.Self.AdminRights.ManageWelcomeMessages = true
|
||||
if ok, err := service.DeleteAll(context.Background(), 10, peer); err != nil || !ok {
|
||||
t.Fatalf("joined broadcast admin delete-all=%v,%v", ok, err)
|
||||
}
|
||||
}
|
||||