perf: sync protocol and core hardening updates

This commit is contained in:
A 2026-07-11 19:48:26 +08:00
parent 152fed3b87
commit 4390ebf5a9
283 changed files with 29231 additions and 2295 deletions

View file

@ -0,0 +1,18 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// AlbumGroupStore 持久化 sendMultiMedia 的预发送分组预留。
//
// ReserveAlbumGroup 必须原子满足:
// - 请求内没有旧绑定时,全部 random_id 绑定 ProposedGroupedID;
// - 命中唯一旧 grouped_id 时,全部缺失项收敛到该旧值;
// - 命中多个旧 grouped_id 时返回 domain.ErrMessageRandomIDDuplicate,且不写入;
// - 并发、多实例的重叠请求等价于某个串行顺序。
type AlbumGroupStore interface {
ReserveAlbumGroup(ctx context.Context, req domain.AlbumGroupReservationRequest) (groupedID int64, err error)
}

View file

@ -9,6 +9,8 @@ import (
type BootstrapUpdateJobStore interface {
EnqueueLoginMessage(ctx context.Context, job domain.BootstrapUpdateJob) (domain.BootstrapUpdateJob, error)
// MarkReadyForSession allows a new session on the same auth key to take over
// a pending signup baseline fence; a different auth key can never release it.
MarkReadyForSession(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64) (int, error)
ClaimReady(ctx context.Context, limit int, leaseTimeout time.Duration) ([]domain.BootstrapUpdateJob, error)
MarkPublished(ctx context.Context, id int64) error

View file

@ -2,6 +2,7 @@ package store
import (
"context"
"time"
"telesrv/internal/domain"
)
@ -164,6 +165,12 @@ type ChannelStore interface {
GeneralForumTopic(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelForumTopic, error)
ListMessageReadParticipants(ctx context.Context, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error)
ListChannelDifference(ctx context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error)
// PruneChannelUpdateEvents atomically removes at most limit complete event rows through throughPts
// and advances the channel retained floor only to the last contiguous row actually removed.
PruneChannelUpdateEvents(ctx context.Context, channelID int64, throughPts, limit int) (domain.ChannelUpdateRetentionResult, error)
// DeleteExpiredChannelUpdateEvents selects expired channel-log heads using an indexed, bounded seek
// and delegates each channel to the same atomic floor+delete primitive. It never uses SQL OFFSET.
DeleteExpiredChannelUpdateEvents(ctx context.Context, olderThan time.Duration, limit int) (int, error)
ListActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error)
ListDirtyActiveChannelsForUser(ctx context.Context, userID int64, sinceDate int, afterChannelID int64, limit int) ([]domain.DirtyChannel, error)
ListActiveChannelMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error)
@ -171,6 +178,10 @@ type ChannelStore interface {
ListChannelInviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error)
FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
MaxChannelPts(ctx context.Context, channelID int64) (int, error)
// MaxChannelPtsBatch returns existing channel watermarks with one bounded store round trip.
// Missing/deleted ids are omitted so a stale process-local membership key cannot poison the
// entire fan-out recovery sweep.
MaxChannelPtsBatch(ctx context.Context, channelIDs []int64) (map[int64]int, error)
// SetActiveCall 写入/清除(callID=0)channel 行上的活跃群通话关联
//(channel.call_active/call_not_empty flag 与 channelFull.call 的数据源)。
SetActiveCall(ctx context.Context, channelID, callID, callAccessHash int64, notEmpty bool) (domain.Channel, error)

View file

@ -2,21 +2,47 @@ package store
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
)
const PhoneCodePurposeChangePhone = "change_phone"
const (
PhoneCodePurposeChangePhone = "change_phone"
PhoneCodeChannelPhone = "phone"
PhoneCodeChannelEmailLogin = "email_login"
PhoneCodeChannelEmailSetupRequired = "email_setup_required"
)
// PhoneCodeVersionCurrent is the only version accepted by the atomic login
// state machine. Version zero is the pre-state-machine shape and deliberately
// fails closed instead of being normalized on read.
const PhoneCodeVersionCurrent = 1
// PhoneCode 是一条验证码记录(与某次 sendCode 的 phone_code_hash 或邮箱验证键关联)。
// Purpose/UserID/AuthKeyID/SessionID 为已登录敏感操作提供作用域;登录验证码保持零值。
type PhoneCode struct {
Version int
// Revision is an opaque store-managed CAS token. Callers must pass it back
// through PhoneCodeSnapshot and must never synthesize or persist it outside
// CodeStore.
Revision string
// IssuedUserID is encoded as a JSON string so Redis Lua can round-trip the
// full int64 range without cjson's IEEE-754 number precision loss.
IssuedUserID int64 `json:",string"`
SignUpVerified bool
Phone string
Code string
Channel string
Purpose string
UserID int64
AuthKeyID [8]byte
SessionID int64
// UserID is also encoded as a string because scoped verification mutates the
// record in Redis Lua and must not round an int64 owner through cjson.
UserID int64 `json:",string"`
AuthKeyID [8]byte
// SessionID is audit metadata but still crosses Redis Lua on wrong attempts;
// encode it as a string to preserve MTProto's full signed 64-bit value.
SessionID int64 `json:",string"`
Email string
PendingEmail string
Attempts int
@ -26,6 +52,35 @@ type PhoneCode struct {
LoginEmailHash string
}
type PhoneCodeSnapshot struct {
Record PhoneCode
Revision string
}
func NewPhoneCodeRevisionToken() (string, error) {
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", fmt.Errorf("generate phone code revision: %w", err)
}
return hex.EncodeToString(raw[:]), nil
}
// LoginCodeVerifyStatus separates an expired/consumed hash from a live hash
// whose scope or code did not match. RPC maps these to PHONE_CODE_EXPIRED and
// PHONE_CODE_INVALID respectively.
type LoginCodeVerifyStatus uint8
const (
LoginCodeVerifyMissing LoginCodeVerifyStatus = iota
LoginCodeVerifyInvalid
LoginCodeVerifyAccepted
)
type LoginCodeVerifyResult struct {
Status LoginCodeVerifyStatus
Record PhoneCode
}
// PhoneCodeScope 标识已登录敏感操作的一次性验证码作用域。SessionID 故意不在
// 作用域内:同一 perm auth key 等待验证码期间允许重建 MTProto session。
// 登录/注册验证码没有 Purpose/UserID/AuthKeyID,保持非 scoped 行为。
@ -56,9 +111,36 @@ type CodeStore interface {
// 活跃验证码;普通登录码仍按 hash 独立保存。
Set(ctx context.Context, phoneCodeHash string, code PhoneCode, ttl time.Duration) error
Get(ctx context.Context, phoneCodeHash string) (PhoneCode, bool, error)
Update(ctx context.Context, phoneCodeHash string, code PhoneCode) error
Del(ctx context.Context, phoneCodeHash string) error
// ConsumeScoped 仅当 hash 仍是 scope 的当前活跃 hash 时原子读取并删除;
// 并发调用至多一个返回 found=true。
ConsumeScoped(ctx context.Context, phoneCodeHash string, scope PhoneCodeScope) (PhoneCode, bool, error)
// VerifyScoped atomically verifies a current-version code only while hash is
// still the active value for scope. A wrong code increments Attempts and, at
// the threshold, removes both the code and scope index. A correct code
// consumes both keys, so concurrent callers can observe Accepted at most once.
VerifyScoped(ctx context.Context, phoneCodeHash string, scope PhoneCodeScope, code string, defaultMaxAttempts int) (LoginCodeVerifyResult, error)
// VerifyLogin atomically validates one current-version, unscoped login code.
// A correct code is consumed unless keepForSignUp is true, in which case the
// same TTL is retained and SignUpVerified is set. Wrong-code attempts are
// incremented in the same linearization point and delete the record at the
// configured threshold.
VerifyLogin(ctx context.Context, phoneCodeHash, phone, code string, keepForSignUp bool, defaultMaxAttempts int) (LoginCodeVerifyResult, error)
// ConsumeSignUpVerified atomically consumes a marker created by VerifyLogin.
// Concurrent sign-up calls can return found=true at most once.
ConsumeSignUpVerified(ctx context.Context, phoneCodeHash, phone string) (PhoneCode, bool, error)
// TakeLoginCode atomically removes a current-version, unscoped login record
// after matching its phone. Cancel/resend use the returned record to decide
// what successor to issue; concurrent Verify/Take calls have one winner.
TakeLoginCode(ctx context.Context, phoneCodeHash, phone string) (PhoneCode, bool, error)
// InvalidateLoginCode is the server-side cleanup primitive for owner drift
// or a failed post-verification workflow. Unlike TakeLoginCode it may delete
// a SignUpVerified marker; user-driven cancel/resend must not call it.
InvalidateLoginCode(ctx context.Context, phoneCodeHash, phone string) (bool, error)
// GetSnapshot and CompareAnd* provide optimistic concurrency for unscoped
// fixed-key verification flows (for example login-email setup/change).
// CompareAndUpdate preserves the current TTL and rotates the opaque revision.
GetSnapshot(ctx context.Context, phoneCodeHash string) (PhoneCodeSnapshot, bool, error)
CompareAndUpdate(ctx context.Context, phoneCodeHash, expectedRevision string, next PhoneCode) (bool, error)
CompareAndDelete(ctx context.Context, phoneCodeHash, expectedRevision string) (bool, error)
}

View file

@ -2,11 +2,20 @@ package store
import (
"context"
"errors"
"time"
"telesrv/internal/domain"
)
// ErrDispatchLeaseLost means a completion belongs to an older claim attempt.
// Callers must not overwrite/delete the row now owned by a newer worker.
var ErrDispatchLeaseLost = errors.New("dispatch outbox lease lost")
// DispatchOutboxLogicalShards 是稳定的 user→lane 哈希空间。运行时 worker 数
// 只能改变 shard 的归属,不能改变这个值;PG 表达式索引也固定使用 256。
const DispatchOutboxLogicalShards = 256
// DispatchOutboxItem 是待投递给在线 session 的 update 任务。
type DispatchOutboxItem struct {
ID int64
@ -21,7 +30,7 @@ type DispatchOutboxItem struct {
// DispatchOutboxStore 持久化 transactional outbox。
type DispatchOutboxStore interface {
ClaimPending(ctx context.Context, limit int) ([]DispatchOutboxItem, error)
MarkDelivered(ctx context.Context, targetUserID, id int64) error
MarkFailed(ctx context.Context, targetUserID, id int64, lastError string) error
MarkDelivered(ctx context.Context, item DispatchOutboxItem) error
MarkFailed(ctx context.Context, item DispatchOutboxItem, lastError string) error
DeleteFailed(ctx context.Context, olderThan time.Duration, limit int) (int, error)
}

View file

@ -0,0 +1,67 @@
package store
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"fmt"
"telesrv/internal/domain"
)
const (
maxPhoneCodeHashBytes = 512
maxLoginCodeBytes = 64
)
// LoginCodeDeliveryStore atomically creates the recipient message box, dialog,
// account update event and online-dispatch task for a 777000 login code.
type LoginCodeDeliveryStore interface {
DeliverLoginCodeMessage(ctx context.Context, req domain.LoginCodeDeliveryRequest) (domain.LoginCodeDeliveryResult, error)
}
// LoginCodeDeliveryKey is the only phone-code-hash representation permitted at
// rest. The raw phone_code_hash stays at the auth/store call boundary.
func LoginCodeDeliveryKey(phoneCodeHash string) ([sha256.Size]byte, error) {
if phoneCodeHash == "" || len(phoneCodeHash) > maxPhoneCodeHashBytes {
return [sha256.Size]byte{}, domain.ErrLoginCodeDeliveryInvalid
}
return sha256.Sum256([]byte(phoneCodeHash)), nil
}
// LoginCodeFingerprint binds a delivery receipt to its immutable secret code.
// It is keyed by the high-entropy raw phone_code_hash, which is never stored;
// unlike a bare digest of a short login code this cannot be brute-forced from
// the compact receipt alone after the message itself has been deleted.
func LoginCodeFingerprint(phoneCodeHash, code string) ([sha256.Size]byte, error) {
if phoneCodeHash == "" || len(phoneCodeHash) > maxPhoneCodeHashBytes || code == "" || len(code) > maxLoginCodeBytes {
return [sha256.Size]byte{}, domain.ErrLoginCodeDeliveryInvalid
}
mac := hmac.New(sha256.New, []byte(phoneCodeHash))
_, _ = mac.Write([]byte(code))
var fingerprint [sha256.Size]byte
copy(fingerprint[:], mac.Sum(nil))
return fingerprint, nil
}
func SameLoginCodeFingerprint(stored []byte, expected [sha256.Size]byte) bool {
return len(stored) == sha256.Size && subtle.ConstantTimeCompare(stored, expected[:]) == 1
}
// RestoreLoginCodeDeliveryMessage reconstructs the immutable first result from
// a compact receipt. The secret code is not duplicated in the receipt: exact
// replay has already proven the supplied code fingerprint matches.
func RestoreLoginCodeDeliveryMessage(userID int64, code string, date int, privateMessageID int64, messageBoxID, pts int) (domain.Message, error) {
if privateMessageID <= 0 || messageBoxID <= 0 || messageBoxID > domain.MaxMessageBoxID || pts <= 0 {
return domain.Message{}, fmt.Errorf("restore login code delivery: %w: uid=%d box=%d pts=%d", domain.ErrLoginCodeDeliveryInvalid, privateMessageID, messageBoxID, pts)
}
msg, err := domain.OfficialLoginCodeMessage(userID, code, date)
if err != nil {
return domain.Message{}, err
}
msg.ID = messageBoxID
msg.UID = privateMessageID
msg.Pts = pts
return msg, nil
}

View file

@ -0,0 +1,67 @@
package store
import (
"crypto/sha256"
"errors"
"reflect"
"testing"
"telesrv/internal/domain"
)
func TestLoginCodeDeliveryKeyAndFingerprint(t *testing.T) {
const phoneCodeHash = "opaque-high-entropy-phone-code-hash"
key, err := LoginCodeDeliveryKey(phoneCodeHash)
if err != nil {
t.Fatalf("LoginCodeDeliveryKey: %v", err)
}
if want := sha256.Sum256([]byte(phoneCodeHash)); key != want {
t.Fatalf("delivery key = %x, want SHA-256 %x", key, want)
}
fingerprint, err := LoginCodeFingerprint(phoneCodeHash, "12345")
if err != nil {
t.Fatalf("LoginCodeFingerprint: %v", err)
}
if !SameLoginCodeFingerprint(fingerprint[:], fingerprint) {
t.Fatal("fingerprint does not compare equal to itself")
}
otherHash, err := LoginCodeFingerprint("another-phone-code-hash", "12345")
if err != nil {
t.Fatalf("other hash fingerprint: %v", err)
}
otherCode, err := LoginCodeFingerprint(phoneCodeHash, "54321")
if err != nil {
t.Fatalf("other code fingerprint: %v", err)
}
if fingerprint == otherHash || fingerprint == otherCode {
t.Fatal("fingerprint must bind both the raw phone_code_hash and code")
}
if SameLoginCodeFingerprint(fingerprint[:31], fingerprint) {
t.Fatal("truncated fingerprint compared equal")
}
if _, err := LoginCodeDeliveryKey(string(make([]byte, maxPhoneCodeHashBytes+1))); !errors.Is(err, domain.ErrLoginCodeDeliveryInvalid) {
t.Fatalf("oversized phone_code_hash err = %v, want ErrLoginCodeDeliveryInvalid", err)
}
if _, err := LoginCodeFingerprint(phoneCodeHash, string(make([]byte, maxLoginCodeBytes+1))); !errors.Is(err, domain.ErrLoginCodeDeliveryInvalid) {
t.Fatalf("oversized code err = %v, want ErrLoginCodeDeliveryInvalid", err)
}
}
func TestRestoreLoginCodeDeliveryMessage(t *testing.T) {
got, err := RestoreLoginCodeDeliveryMessage(1000000001, "12345", 1700000000, 91, 7, 12)
if err != nil {
t.Fatalf("RestoreLoginCodeDeliveryMessage: %v", err)
}
want, err := domain.OfficialLoginCodeMessage(1000000001, "12345", 1700000000)
if err != nil {
t.Fatalf("OfficialLoginCodeMessage: %v", err)
}
want.UID, want.ID, want.Pts = 91, 7, 12
if !reflect.DeepEqual(got, want) {
t.Fatalf("restored message = %+v, want %+v", got, want)
}
if _, err := RestoreLoginCodeDeliveryMessage(1000000001, "12345", 1700000000, 0, 7, 12); !errors.Is(err, domain.ErrLoginCodeDeliveryInvalid) {
t.Fatalf("invalid uid err = %v, want ErrLoginCodeDeliveryInvalid", err)
}
}

View file

@ -18,6 +18,11 @@ type MediaStore interface {
LoadFileParts(ctx context.Context, ownerUserID, fileID int64) ([]domain.UploadPart, error)
DeleteFileParts(ctx context.Context, ownerUserID, fileID int64) ([]string, error)
DeleteExpiredUploadParts(ctx context.Context, before time.Time, limit int) ([]string, error)
// Uploaded media receipts survive transient part cleanup so messages.sendMedia can replay an
// InputMediaUploadedPhoto/Document after a lost response. Put returns the durable winner; when
// created=false another concurrent materializer won the (owner,file_id) key.
GetUploadedMediaReceipt(ctx context.Context, ownerUserID, fileID int64) (domain.UploadedMediaReceipt, bool, error)
PutUploadedMediaReceipt(ctx context.Context, receipt domain.UploadedMediaReceipt) (stored domain.UploadedMediaReceipt, created bool, err error)
// blob 索引。
PutFileBlob(ctx context.Context, blob domain.FileBlob) error

View file

@ -0,0 +1,68 @@
package memory
import (
"bytes"
"context"
"telesrv/internal/domain"
)
type albumGroupKey struct {
senderUserID int64
peerType domain.PeerType
peerID int64
randomID int64
}
type albumGroupRecord struct {
groupedID int64
intentHash [32]byte
}
// ReserveAlbumGroup 在 MessageStore 的同一把互斥锁下完成读旧组、选胜者与补齐绑定,
// 因而并发重叠批次不会产生拆组。
func (s *MessageStore) ReserveAlbumGroup(_ context.Context, req domain.AlbumGroupReservationRequest) (int64, error) {
if err := req.Validate(); err != nil {
return 0, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.albumGroups == nil {
s.albumGroups = make(map[albumGroupKey]albumGroupRecord)
}
groupedID := int64(0)
type pendingBinding struct {
key albumGroupKey
intentHash [32]byte
}
bindings := make([]pendingBinding, 0, len(req.Items))
for _, item := range req.Items {
key := albumGroupKey{
senderUserID: req.SenderUserID,
peerType: req.Peer.Type,
peerID: req.Peer.ID,
randomID: item.RandomID,
}
var intentHash [32]byte
copy(intentHash[:], item.IntentHash)
bindings = append(bindings, pendingBinding{key: key, intentHash: intentHash})
if existing, exists := s.albumGroups[key]; exists {
if !bytes.Equal(existing.intentHash[:], item.IntentHash) {
return 0, domain.ErrMessageRandomIDDuplicate
}
if groupedID != 0 && groupedID != existing.groupedID {
return 0, domain.ErrMessageRandomIDDuplicate
}
groupedID = existing.groupedID
}
}
if groupedID == 0 {
groupedID = req.ProposedGroupedID
}
for _, binding := range bindings {
s.albumGroups[binding.key] = albumGroupRecord{groupedID: groupedID, intentHash: binding.intentHash}
}
return groupedID, nil
}

View file

@ -0,0 +1,131 @@
package memory
import (
"context"
"crypto/sha256"
"errors"
"sync"
"testing"
"telesrv/internal/domain"
)
func albumIntent(label string) []byte {
sum := sha256.Sum256([]byte(label))
return sum[:]
}
func albumReq(sender int64, peer domain.Peer, groupedID int64, items ...domain.AlbumGroupReservationItem) domain.AlbumGroupReservationRequest {
return domain.AlbumGroupReservationRequest{
SenderUserID: sender,
Peer: peer,
Items: items,
ProposedGroupedID: groupedID,
}
}
func albumItem(randomID int64, label string) domain.AlbumGroupReservationItem {
return domain.AlbumGroupReservationItem{RandomID: randomID, IntentHash: albumIntent(label)}
}
func TestAlbumGroupReservationFullThenSubsetAndIntentConflict(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 2002}
full := []domain.AlbumGroupReservationItem{albumItem(1, "one"), albumItem(2, "two"), albumItem(3, "three")}
groupedID, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 101, full...))
if err != nil || groupedID != 101 {
t.Fatalf("reserve full = %d err=%v, want 101", groupedID, err)
}
replayed, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 202, full[1:]...))
if err != nil || replayed != groupedID {
t.Fatalf("reserve subset = %d err=%v, want original %d", replayed, err, groupedID)
}
for _, item := range full {
got, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 303, item))
if err != nil || got != groupedID {
t.Fatalf("single random_id %d = %d err=%v, want %d", item.RandomID, got, err, groupedID)
}
}
changed := albumItem(2, "changed payload")
if _, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 404, changed)); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("changed intent err=%v, want ErrMessageRandomIDDuplicate", err)
}
}
func TestAlbumGroupReservationConcurrentOverlapConverges(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 9001}
requests := []domain.AlbumGroupReservationRequest{
albumReq(1001, peer, 111, albumItem(11, "one"), albumItem(12, "shared")),
albumReq(1001, peer, 222, albumItem(12, "shared"), albumItem(13, "three")),
}
results := make([]int64, 2)
errs := make([]error, 2)
start := make(chan struct{})
var wg sync.WaitGroup
for i := range requests {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
results[i], errs[i] = messages.ReserveAlbumGroup(ctx, requests[i])
}(i)
}
close(start)
wg.Wait()
if errs[0] != nil || errs[1] != nil || results[0] == 0 || results[0] != results[1] {
t.Fatalf("concurrent results=%v errs=%v, want same non-zero group", results, errs)
}
for _, item := range []domain.AlbumGroupReservationItem{albumItem(11, "one"), albumItem(12, "shared"), albumItem(13, "three")} {
got, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 333, item))
if err != nil || got != results[0] {
t.Fatalf("converged random_id %d = %d err=%v, want %d", item.RandomID, got, err, results[0])
}
}
}
func TestAlbumGroupReservationRejectsMixedOldGroupsAtomically(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 2002}
one := albumItem(21, "one")
two := albumItem(22, "two")
three := albumItem(23, "three")
if _, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 121, one)); err != nil {
t.Fatal(err)
}
if _, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 122, two)); err != nil {
t.Fatal(err)
}
if _, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 123, one, two, three)); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("mixed old groups err=%v, want ErrMessageRandomIDDuplicate", err)
}
// 失败批次不能把尚未存在的 random_id 23 偷绑到任一旧组。
got, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 124, three))
if err != nil || got != 124 {
t.Fatalf("post-conflict unbound item = %d err=%v, want fresh 124", got, err)
}
}
func TestAlbumGroupReservationScopeIncludesPeer(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
item := albumItem(31, "same intent")
tests := []struct {
peer domain.Peer
group int64
}{
{peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2002}, group: 131},
{peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2003}, group: 132},
{peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 2002}, group: 133},
}
for _, tc := range tests {
got, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, tc.peer, tc.group, item))
if err != nil || got != tc.group {
t.Fatalf("peer %+v = %d err=%v, want isolated %d", tc.peer, got, err, tc.group)
}
}
}

View file

@ -276,6 +276,11 @@ func NewCodeStore() *CodeStore {
}
func (s *CodeStore) Set(_ context.Context, hash string, code store.PhoneCode, ttl time.Duration) error {
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return err
}
code.Revision = revision
s.mu.Lock()
scope := code.Scope()
if scope.Valid() {
@ -303,6 +308,11 @@ func (s *CodeStore) Get(_ context.Context, hash string) (store.PhoneCode, bool,
}
func (s *CodeStore) Update(_ context.Context, hash string, code store.PhoneCode) error {
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return err
}
code.Revision = revision
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.m[hash]
@ -343,7 +353,13 @@ func (s *CodeStore) ConsumeScoped(_ context.Context, hash string, scope store.Ph
}
return store.PhoneCode{}, false, nil
}
if e.code.Scope() != scope {
if e.code.Version != store.PhoneCodeVersionCurrent || e.code.Scope() != scope {
delete(s.m, hash)
delete(s.scopes, scope)
actualScope := e.code.Scope()
if actualScope.Valid() && s.scopes[actualScope] == hash {
delete(s.scopes, actualScope)
}
return store.PhoneCode{}, false, nil
}
s.deleteCodeLocked(hash, e.code)

View file

@ -67,10 +67,15 @@ func (s *BootstrapUpdateJobStore) MarkReadyForSession(_ context.Context, userID
s.mu.Lock()
defer s.mu.Unlock()
for id, job := range s.jobs {
if job.UserID != userID || job.AuthKeyID != authKeyID || job.SessionID != sessionID || job.Status != domain.BootstrapUpdateJobPending {
if job.UserID != userID || job.AuthKeyID != authKeyID || job.Status != domain.BootstrapUpdateJobPending {
continue
}
job.Status = domain.BootstrapUpdateJobReady
// A reconnect on the same physical/business auth key is the same
// verified device. Transfer the pending baseline fence to the session
// that actually completed getState/getDifference instead of orphaning
// the job on the disconnected signup session.
job.SessionID = sessionID
job.ReadyAt = now
job.UpdatedAt = now
s.jobs[id] = job

View file

@ -319,14 +319,8 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID
if !ok || member.Status != domain.ChannelMemberActive {
continue
}
dirty := false
for _, event := range s.events[channelID] {
if event.Date > sinceDate {
dirty = true
break
}
}
if dirty {
checkpoint := s.channelUpdateCheckpointLocked(channelID, channel)
if checkpoint.LatestEventDate > sinceDate {
out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts})
}
}

View file

@ -240,7 +240,16 @@ func (s *ChannelStore) deleteChannelMessagesLocked(channel domain.Channel, membe
MessageIDs: append([]int(nil), deleted...),
SenderUserID: actorUserID,
}
s.events[channel.ID] = append(s.events[channel.ID], event)
s.appendChannelEventLocked(event)
for _, id := range deleted {
msg, ok := s.findMessageLocked(channel.ID, id)
if !ok || msg.RandomID == 0 || msg.SenderUserID == 0 {
continue
}
key := channelMessageReplayKey{channelID: channel.ID, messageID: msg.ID}
cloned := cloneChannelEvent(event)
s.deleteReceipts[key] = &cloned
}
return deleted, event, channel, nil
}

View file

@ -58,7 +58,7 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan
Message: cloneChannelMessage(msg),
SenderUserID: req.UserID,
}
s.events[req.ChannelID] = append(s.events[req.ChannelID], event)
s.appendChannelEventLocked(event)
return domain.EditChannelMessageResult{
Channel: channel,
Message: cloneChannelMessage(msg),
@ -108,7 +108,7 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan
Message: cloneChannelMessage(msg),
SenderUserID: req.UserID,
}
s.events[req.ChannelID] = append(s.events[req.ChannelID], event)
s.appendChannelEventLocked(event)
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
ChannelID: req.ChannelID,
UserID: req.UserID,
@ -150,7 +150,7 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan
SenderUserID: req.UserID,
}
s.messages[req.ChannelID] = append(s.messages[req.ChannelID], serviceMsg)
s.events[req.ChannelID] = append(s.events[req.ChannelID], serviceEvent)
s.appendChannelEventLocked(serviceEvent)
s.updateForumTopicTopMessageLocked(req.ChannelID, serviceMsg)
channel.TopMessageID = serviceMsg.ID
channel.Pts = servicePts
@ -303,7 +303,7 @@ func (s *ChannelStore) UpdatePinnedMessage(_ context.Context, req domain.UpdateC
SenderUserID: req.UserID,
Pinned: req.Pinned,
}
s.events[req.ChannelID] = append(s.events[req.ChannelID], event)
s.appendChannelEventLocked(event)
logMsg := msg
logMsg.Pinned = req.Pinned
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
@ -372,7 +372,7 @@ func (s *ChannelStore) UnpinAllChannelMessages(_ context.Context, req domain.Unp
SenderUserID: req.UserID,
Pinned: false,
}
s.events[req.ChannelID] = append(s.events[req.ChannelID], event)
s.appendChannelEventLocked(event)
return domain.UpdateChannelPinnedMessageResult{
Channel: channel,
Event: cloneChannelEvent(event),

View file

@ -2,8 +2,10 @@ package memory
import (
"context"
"fmt"
"strings"
"telesrv/internal/domain"
"telesrv/internal/store"
"time"
)
@ -14,8 +16,27 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
if strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() && req.RichMessage.IsZero() {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
}
var fingerprint []byte
var err error
if req.RandomID != 0 {
fingerprint, err = store.ChannelSendFingerprint(req)
if err != nil {
return domain.SendChannelMessageResult{}, err
}
req.IdempotencyFingerprint = fingerprint
}
s.mu.Lock()
defer s.mu.Unlock()
if req.RandomID != 0 {
if replay, found, replayErr := s.lookupChannelSendReplayLocked(domain.ChannelSendReplayRequest{
ChannelID: req.ChannelID,
SenderUserID: req.UserID,
RandomID: req.RandomID,
IdempotencyFingerprint: fingerprint,
}); replayErr != nil || found {
return replay, replayErr
}
}
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
if err != nil {
return domain.SendChannelMessageResult{}, err
@ -34,23 +55,6 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
if !canSendChannelMessageWithBoost(channel, member, fromBoostsApplied) {
return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden
}
if req.RandomID != 0 {
if id, ok := s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}]; ok {
msg, ok := s.findMessageLocked(req.ChannelID, id)
if ok {
event := s.eventForMessageLocked(req.ChannelID, id)
if event.Message.ID != 0 {
msg = event.Message
}
return domain.SendChannelMessageResult{
Channel: channel,
Message: cloneChannelMessage(msg),
Event: event,
Duplicate: true,
}, nil
}
}
}
if wait := channelSlowModeWait(channel, member, req.Date); wait > 0 {
return domain.SendChannelMessageResult{}, domain.NewSlowModeWaitError(wait)
}
@ -99,7 +103,7 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
Message: cloneChannelMessage(discussionMsg),
}
s.messages[linked.ID] = append(s.messages[linked.ID], discussionMsg)
s.events[linked.ID] = append(s.events[linked.ID], discussionEvent)
s.appendChannelEventLocked(discussionEvent)
linked.TopMessageID = discussionMsgID
linked.Pts = discussionPts
s.channels[linked.ID] = linked
@ -145,6 +149,13 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
Pts: pts,
}
msg.Replies = s.channelMessageRepliesLocked(req.UserID, req.ChannelID, msg)
var sendSnapshot []byte
if req.RandomID != 0 {
sendSnapshot, err = store.EncodeChannelSendSnapshot(msg)
if err != nil {
return domain.SendChannelMessageResult{}, err
}
}
event := domain.ChannelUpdateEvent{
ChannelID: req.ChannelID,
Type: domain.ChannelUpdateNewMessage,
@ -155,7 +166,7 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
SenderUserID: req.UserID,
}
s.messages[req.ChannelID] = append(s.messages[req.ChannelID], msg)
s.events[req.ChannelID] = append(s.events[req.ChannelID], event)
s.appendChannelEventLocked(event)
if !channel.Broadcast || channel.Megagroup {
mentionTargets := req.MentionUserIDs
if msg.ReplyTo != nil && msg.ReplyTo.MessageID > 0 {
@ -178,7 +189,11 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
})
}
if req.RandomID != 0 {
s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}] = msg.ID
key := channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}
s.randomToID[key] = msg.ID
replayKey := channelMessageReplayKey{channelID: req.ChannelID, messageID: msg.ID}
s.sendSnapshots[replayKey] = sendSnapshot
s.sendFingerprints[replayKey] = append([]byte(nil), fingerprint...)
}
channel.TopMessageID = msg.ID
channel.Pts = pts
@ -209,6 +224,85 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
}, nil
}
// LookupChannelSendReplay returns a committed regular-channel or monoforum send receipt without
// evaluating current membership, write permissions, slow mode or any message allocation path.
func (s *ChannelStore) LookupChannelSendReplay(_ context.Context, req domain.ChannelSendReplayRequest) (domain.SendChannelMessageResult, bool, error) {
if req.ChannelID == 0 || req.SenderUserID == 0 || req.RandomID == 0 {
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory channel send replay: invalid scope")
}
if req.SavedPeer.ID == 0 {
if req.SavedPeer.Type != "" {
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory channel send replay: incomplete saved peer scope")
}
} else if req.SavedPeer.Type != domain.PeerTypeUser {
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory channel send replay: invalid saved peer scope")
}
if err := store.ValidateSendFingerprint(req.IdempotencyFingerprint, "channel send replay"); err != nil {
return domain.SendChannelMessageResult{}, false, err
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.lookupChannelSendReplayLocked(req)
}
func (s *ChannelStore) lookupChannelSendReplayLocked(req domain.ChannelSendReplayRequest) (domain.SendChannelMessageResult, bool, error) {
var id int
if req.SavedPeer.ID == 0 {
var found bool
id, found = s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.SenderUserID, randomID: req.RandomID}]
if !found {
return domain.SendChannelMessageResult{}, false, nil
}
} else {
msg, found := s.findMonoforumDuplicateLocked(req.ChannelID, req.SenderUserID, req.SavedPeer, req.RandomID)
if !found {
return domain.SendChannelMessageResult{}, false, nil
}
id = msg.ID
}
replayKey := channelMessageReplayKey{channelID: req.ChannelID, messageID: id}
if !store.SameSendFingerprint(s.sendFingerprints[replayKey], req.IdempotencyFingerprint) {
return domain.SendChannelMessageResult{}, false, domain.ErrMessageRandomIDDuplicate
}
first, err := store.DecodeChannelSendSnapshot(s.sendSnapshots[replayKey])
if err != nil {
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory duplicate channel message snapshot: %w", err)
}
if first.ID != id || first.ChannelID != req.ChannelID || first.SenderUserID != req.SenderUserID || first.RandomID != req.RandomID || first.SavedPeer != req.SavedPeer {
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory duplicate channel message snapshot disagrees with random_id receipt")
}
replay := first
var replayDelete *domain.ChannelUpdateEvent
if current, found := s.findMessageLocked(req.ChannelID, id); found && !current.Deleted {
replay = cloneChannelMessage(current)
} else if receipt := s.deleteReceipts[replayKey]; receipt != nil {
cloned := cloneChannelEvent(*receipt)
replayDelete = &cloned
} else {
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory duplicate channel message %d is absent without a durable delete receipt", id)
}
channel, ok := s.channels[req.ChannelID]
if !ok {
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory duplicate channel message %d has no channel", id)
}
event := domain.ChannelUpdateEvent{
ChannelID: first.ChannelID,
Type: domain.ChannelUpdateNewMessage,
Pts: first.Pts,
PtsCount: 1,
Date: first.Date,
Message: cloneChannelMessage(replay),
SenderUserID: first.SenderUserID,
}
return domain.SendChannelMessageResult{
Channel: cloneChannel(channel),
Message: cloneChannelMessage(replay),
Event: event,
Duplicate: true,
ReplayDeleteEvent: replayDelete,
}, true, nil
}
func channelDeliverySkipSet(ids []int64) map[int64]struct{} {
if len(ids) == 0 {
return nil
@ -267,7 +361,7 @@ func (s *ChannelStore) appendChannelServiceMessageLocked(channelID, senderUserID
UserIDs: append([]int64(nil), action.UserIDs...),
}
s.messages[channelID] = append(s.messages[channelID], msg)
s.events[channelID] = append(s.events[channelID], event)
s.appendChannelEventLocked(event)
return msg, event
}

View file

@ -7,6 +7,7 @@ import (
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。
@ -16,8 +17,28 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
}
var fingerprint []byte
var err error
if req.RandomID != 0 {
fingerprint, err = store.MonoforumSendFingerprint(req)
if err != nil {
return domain.SendChannelMessageResult{}, err
}
req.IdempotencyFingerprint = fingerprint
}
s.mu.Lock()
defer s.mu.Unlock()
if req.RandomID != 0 {
if replay, found, replayErr := s.lookupChannelSendReplayLocked(domain.ChannelSendReplayRequest{
ChannelID: req.MonoforumID,
SenderUserID: req.SenderUserID,
SavedPeer: req.SavedPeer,
RandomID: req.RandomID,
IdempotencyFingerprint: fingerprint,
}); replayErr != nil || found {
return replay, replayErr
}
}
channel, ok := s.channels[req.MonoforumID]
if !ok || channel.Deleted || !channel.Monoforum {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
@ -25,17 +46,6 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
if req.Date == 0 {
req.Date = int(time.Now().Unix())
}
if req.RandomID != 0 {
// 去重维度 = (sender, saved_peer, random_id),与 postgres 迁移 0022 的唯一索引一致;
// 不复用账号级 randomToID(其按 channel+sender+random_id 三元组,会被跨子会话同 random_id 互相覆盖)。
if dup, ok := s.findMonoforumDuplicateLocked(req.MonoforumID, req.SenderUserID, req.SavedPeer, req.RandomID); ok {
event := s.eventForMessageLocked(req.MonoforumID, dup.ID)
if event.Message.ID != 0 {
dup = event.Message
}
return domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(dup), Event: event, Duplicate: true}, nil
}
}
pts := s.nextChannelPtsLocked(req.MonoforumID)
msgID := s.nextChannelMessageIDLocked(req.MonoforumID)
msg := domain.ChannelMessage{
@ -50,6 +60,14 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
Entities: append([]domain.MessageEntity(nil), req.Entities...),
Pts: pts,
}
var sendSnapshot []byte
if req.RandomID != 0 {
var snapshotErr error
sendSnapshot, snapshotErr = store.EncodeChannelSendSnapshot(msg)
if snapshotErr != nil {
return domain.SendChannelMessageResult{}, snapshotErr
}
}
event := domain.ChannelUpdateEvent{
ChannelID: req.MonoforumID,
Type: domain.ChannelUpdateNewMessage,
@ -60,7 +78,12 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
SenderUserID: req.SenderUserID,
}
s.messages[req.MonoforumID] = append(s.messages[req.MonoforumID], msg)
s.events[req.MonoforumID] = append(s.events[req.MonoforumID], event)
if req.RandomID != 0 {
replayKey := channelMessageReplayKey{channelID: req.MonoforumID, messageID: msg.ID}
s.sendSnapshots[replayKey] = sendSnapshot
s.sendFingerprints[replayKey] = append([]byte(nil), fingerprint...)
}
s.appendChannelEventLocked(event)
channel.TopMessageID = msgID
channel.Pts = pts
s.channels[req.MonoforumID] = channel
@ -75,7 +98,7 @@ func (s *ChannelStore) findMonoforumDuplicateLocked(monoforumID, senderUserID in
msgs := s.messages[monoforumID]
for i := len(msgs) - 1; i >= 0; i-- {
m := msgs[i]
if !m.Deleted && m.RandomID == randomID && m.SenderUserID == senderUserID && m.SavedPeer == savedPeer {
if m.RandomID == randomID && m.SenderUserID == senderUserID && m.SavedPeer == savedPeer {
return m, true
}
}

View file

@ -2,6 +2,7 @@ package memory
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
@ -69,6 +70,9 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
if !dup.Duplicate || dup.Message.ID != m1.Message.ID {
t.Fatalf("dup = %+v, want duplicate of m1 id %d", dup.Message, m1.Message.ID)
}
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 111, Message: "changed", Date: 1_700_001_004}); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("conflicting monoforum replay err=%v, want ErrMessageRandomIDDuplicate", err)
}
hist, err := store.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: sub, Limit: 10})
if err != nil {
@ -131,4 +135,21 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
if dialogs.Dialogs[1].SavedPeer != sub || dialogs.Dialogs[1].TopMessageID == 0 {
t.Fatalf("dialogs[1] = %+v, want sub with top message", dialogs.Dialogs[1])
}
store.mu.Lock()
_, deleteEvent, _, err := store.deleteChannelMessagesLocked(store.channels[monoID], domain.ChannelMember{ChannelID: monoID, UserID: 1, Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive}, []int{a.Message.ID}, 1, 1_700_001_013)
store.mu.Unlock()
if err != nil {
t.Fatalf("delete monoforum message: %v", err)
}
ptsBeforeReplay, eventsBeforeReplay := store.ptsSeq[monoID], len(store.events[monoID])
deletedReplay, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 9001, Message: "to sub", Date: 1_700_001_014})
if err != nil {
t.Fatalf("replay deleted monoforum message: %v", err)
}
if !deletedReplay.Duplicate || deletedReplay.Message.ID != a.Message.ID || deletedReplay.Message.Body != "to sub" || deletedReplay.ReplayDeleteEvent == nil || deletedReplay.ReplayDeleteEvent.Pts != deleteEvent.Pts {
t.Fatalf("deleted monoforum replay = %+v, want first snapshot + durable delete %+v", deletedReplay, deleteEvent)
}
if store.ptsSeq[monoID] != ptsBeforeReplay || len(store.events[monoID]) != eventsBeforeReplay {
t.Fatalf("deleted monoforum replay mutated pts/events = %d/%d, want %d/%d", store.ptsSeq[monoID], len(store.events[monoID]), ptsBeforeReplay, eventsBeforeReplay)
}
}

View file

@ -0,0 +1,22 @@
package memory
import (
"context"
"testing"
)
func TestMaxChannelPtsBatchUsesOneSnapshotAndOmitsMissing(t *testing.T) {
channels := NewChannelStore()
channels.mu.Lock()
channels.ptsSeq[10] = 7
channels.ptsSeq[20] = 11
channels.mu.Unlock()
got, err := channels.MaxChannelPtsBatch(context.Background(), []int64{20, 999, 10, 20})
if err != nil {
t.Fatalf("MaxChannelPtsBatch: %v", err)
}
if len(got) != 2 || got[10] != 7 || got[20] != 11 {
t.Fatalf("batch pts = %v, want map[10:7 20:11] with missing id omitted", got)
}
}

View file

@ -0,0 +1,76 @@
package memory
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
)
func TestChannelRandomIDReplayUsesCurrentSnapshotAndDurableDeleteMemory(t *testing.T) {
ctx := context.Background()
channels := NewChannelStore()
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "replay convergence",
Megagroup: true,
Date: 1_700_001_000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
req := domain.SendChannelMessageRequest{
UserID: 1, ChannelID: created.Channel.ID, RandomID: 77001,
Message: "original", Date: 1_700_001_001,
}
first, err := channels.SendChannelMessage(ctx, req)
if err != nil {
t.Fatalf("send channel message: %v", err)
}
conflict := req
conflict.Message = "same random id, different intent"
if _, err := channels.SendChannelMessage(ctx, conflict); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("conflicting channel replay err=%v, want ErrMessageRandomIDDuplicate", err)
}
edited, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{
UserID: 1, ChannelID: created.Channel.ID, ID: first.Message.ID,
Message: "edited", EditDate: 1_700_001_002,
})
if err != nil {
t.Fatalf("edit channel message: %v", err)
}
ptsBeforeReplay := channels.ptsSeq[created.Channel.ID]
eventsBeforeReplay := len(channels.events[created.Channel.ID])
replay, err := channels.SendChannelMessage(ctx, req)
if err != nil {
t.Fatalf("replay after edit: %v", err)
}
if !replay.Duplicate || replay.Message.Body != "edited" || replay.Message.Pts != edited.Message.Pts || replay.Event.Pts != first.Event.Pts || replay.ReplayDeleteEvent != nil {
t.Fatalf("replay after edit = %+v, want current snapshot with first-send pts", replay)
}
if channels.ptsSeq[created.Channel.ID] != ptsBeforeReplay || len(channels.events[created.Channel.ID]) != eventsBeforeReplay {
t.Fatalf("edit replay mutated channel pts/events = %d/%d, want %d/%d", channels.ptsSeq[created.Channel.ID], len(channels.events[created.Channel.ID]), ptsBeforeReplay, eventsBeforeReplay)
}
deleted, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{
UserID: 1, ChannelID: created.Channel.ID, IDs: []int{first.Message.ID}, Date: 1_700_001_003,
})
if err != nil {
t.Fatalf("delete channel message: %v", err)
}
ptsBeforeReplay = channels.ptsSeq[created.Channel.ID]
eventsBeforeReplay = len(channels.events[created.Channel.ID])
replay, err = channels.SendChannelMessage(ctx, req)
if err != nil {
t.Fatalf("replay after delete: %v", err)
}
if !replay.Duplicate || replay.Message.Body != "original" || replay.Message.Pts != first.Message.Pts || replay.Event.Pts != first.Event.Pts {
t.Fatalf("replay after delete = %+v, want immutable first snapshot", replay)
}
if replay.ReplayDeleteEvent == nil || replay.ReplayDeleteEvent.Pts != deleted.Event.Pts || len(replay.ReplayDeleteEvent.MessageIDs) != 1 || replay.ReplayDeleteEvent.MessageIDs[0] != first.Message.ID {
t.Fatalf("replay delete = %+v, want durable event %+v", replay.ReplayDeleteEvent, deleted.Event)
}
if channels.ptsSeq[created.Channel.ID] != ptsBeforeReplay || len(channels.events[created.Channel.ID]) != eventsBeforeReplay {
t.Fatalf("delete replay mutated channel pts/events = %d/%d, want %d/%d", channels.ptsSeq[created.Channel.ID], len(channels.events[created.Channel.ID]), ptsBeforeReplay, eventsBeforeReplay)
}
}

View file

@ -13,6 +13,11 @@ type channelRandomKey struct {
randomID int64
}
type channelMessageReplayKey struct {
channelID int64
messageID int
}
type boostSlotKey struct {
userID int64
slot int
@ -58,33 +63,37 @@ func (w channelReadWatermark) advance(userID int64, maxID int) channelReadWaterm
// ChannelStore is an in-memory channel/supergroup store for tests and local development.
type ChannelStore struct {
mu sync.RWMutex
nextID int64
nextHash int64
channels map[int64]domain.Channel
members map[int64]map[int64]domain.ChannelMember
dialogs map[int64]map[int64]domain.ChannelDialog
topics map[int64]map[int]domain.ChannelForumTopic
messages map[int64][]domain.ChannelMessage
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
mu sync.RWMutex
nextID int64
nextHash int64
channels map[int64]domain.Channel
members map[int64]map[int64]domain.ChannelMember
dialogs map[int64]map[int64]domain.ChannelDialog
topics map[int64]map[int]domain.ChannelForumTopic
messages map[int64][]domain.ChannelMessage
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
// paidReactions 是 per-(channel,message,user) 付费 reaction 累计星数 + 匿名标志。
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
top map[int64]map[string]domain.TopMessageReaction
recent map[int64]map[string]domain.RecentMessageReaction
savedTags map[int64]map[string]domain.SavedReactionTag
mentions map[int64]map[int64]map[int]memoryMention
msgViews map[int64]map[int]int
msgViewers map[int64]map[int]map[int64]struct{}
events map[int64][]domain.ChannelUpdateEvent
adminLogs map[int64][]domain.ChannelAdminLogEvent
invites map[string]domain.ChannelInvite
importers map[int64]map[int64]domain.ChannelInviteImporter
msgSeq map[int64]int
ptsSeq map[int64]int
logSeq map[int64]int64
randomToID map[channelRandomKey]int
boostSlots map[boostSlotKey]domain.PremiumBoostSlot
readMarks map[int64]channelReadWatermark
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
top map[int64]map[string]domain.TopMessageReaction
recent map[int64]map[string]domain.RecentMessageReaction
savedTags map[int64]map[string]domain.SavedReactionTag
mentions map[int64]map[int64]map[int]memoryMention
msgViews map[int64]map[int]int
msgViewers map[int64]map[int]map[int64]struct{}
events map[int64][]domain.ChannelUpdateEvent
retention map[int64]domain.ChannelUpdateRetentionCheckpoint
adminLogs map[int64][]domain.ChannelAdminLogEvent
invites map[string]domain.ChannelInvite
importers map[int64]map[int64]domain.ChannelInviteImporter
msgSeq map[int64]int
ptsSeq map[int64]int
logSeq map[int64]int64
randomToID map[channelRandomKey]int
sendSnapshots map[channelMessageReplayKey][]byte
sendFingerprints map[channelMessageReplayKey][]byte
deleteReceipts map[channelMessageReplayKey]*domain.ChannelUpdateEvent
boostSlots map[boostSlotKey]domain.PremiumBoostSlot
readMarks map[int64]channelReadWatermark
// topicReads 是 per-(channel,user,topic) 已读水位(forum 话题独立已读,不碰频道级 member 水位)。
topicReads map[int64]map[int64]map[int]memoryTopicRead
// polls 是共享 poll 权威(与 MessageStore 同一实例);nil 时 poll 链路按未接入处理。
@ -99,31 +108,35 @@ func (s *ChannelStore) AttachPollStore(polls *PollStore) {
// NewChannelStore creates an in-memory ChannelStore.
func NewChannelStore() *ChannelStore {
return &ChannelStore{
nextID: firstMemoryChannelID,
nextHash: 900000000000,
channels: make(map[int64]domain.Channel),
members: make(map[int64]map[int64]domain.ChannelMember),
dialogs: make(map[int64]map[int64]domain.ChannelDialog),
topics: make(map[int64]map[int]domain.ChannelForumTopic),
messages: make(map[int64][]domain.ChannelMessage),
reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction),
paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction),
top: make(map[int64]map[string]domain.TopMessageReaction),
recent: make(map[int64]map[string]domain.RecentMessageReaction),
savedTags: make(map[int64]map[string]domain.SavedReactionTag),
mentions: make(map[int64]map[int64]map[int]memoryMention),
msgViews: make(map[int64]map[int]int),
msgViewers: make(map[int64]map[int]map[int64]struct{}),
events: make(map[int64][]domain.ChannelUpdateEvent),
adminLogs: make(map[int64][]domain.ChannelAdminLogEvent),
invites: make(map[string]domain.ChannelInvite),
importers: make(map[int64]map[int64]domain.ChannelInviteImporter),
msgSeq: make(map[int64]int),
ptsSeq: make(map[int64]int),
logSeq: make(map[int64]int64),
randomToID: make(map[channelRandomKey]int),
boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot),
readMarks: make(map[int64]channelReadWatermark),
topicReads: make(map[int64]map[int64]map[int]memoryTopicRead),
nextID: firstMemoryChannelID,
nextHash: 900000000000,
channels: make(map[int64]domain.Channel),
members: make(map[int64]map[int64]domain.ChannelMember),
dialogs: make(map[int64]map[int64]domain.ChannelDialog),
topics: make(map[int64]map[int]domain.ChannelForumTopic),
messages: make(map[int64][]domain.ChannelMessage),
reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction),
paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction),
top: make(map[int64]map[string]domain.TopMessageReaction),
recent: make(map[int64]map[string]domain.RecentMessageReaction),
savedTags: make(map[int64]map[string]domain.SavedReactionTag),
mentions: make(map[int64]map[int64]map[int]memoryMention),
msgViews: make(map[int64]map[int]int),
msgViewers: make(map[int64]map[int]map[int64]struct{}),
events: make(map[int64][]domain.ChannelUpdateEvent),
retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint),
adminLogs: make(map[int64][]domain.ChannelAdminLogEvent),
invites: make(map[string]domain.ChannelInvite),
importers: make(map[int64]map[int64]domain.ChannelInviteImporter),
msgSeq: make(map[int64]int),
ptsSeq: make(map[int64]int),
logSeq: make(map[int64]int64),
randomToID: make(map[channelRandomKey]int),
sendSnapshots: make(map[channelMessageReplayKey][]byte),
sendFingerprints: make(map[channelMessageReplayKey][]byte),
deleteReceipts: make(map[channelMessageReplayKey]*domain.ChannelUpdateEvent),
boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot),
readMarks: make(map[int64]channelReadWatermark),
topicReads: make(map[int64]map[int64]map[int]memoryTopicRead),
}
}

View file

@ -1093,8 +1093,12 @@ func TestChannelMessageReplyMarkupSurvivesReadPaths(t *testing.T) {
UserID: 1,
ChannelID: created.Channel.ID,
RandomID: 38_001,
Message: "duplicate must not replace",
Date: 1_700_000_382,
Message: "via inline keyboard",
ViaBotID: 99,
ReplyMarkup: &domain.MessageReplyMarkup{Inline: [][]domain.MarkupButton{{
{Type: domain.MarkupButtonCallback, Text: "Open", Data: []byte{0x00, 0xff, 0x42}},
}}},
Date: 1_700_000_382,
})
if err != nil {
t.Fatalf("duplicate send: %v", err)

View file

@ -0,0 +1,150 @@
package memory
import (
"context"
"strings"
"testing"
"time"
"telesrv/internal/domain"
)
func TestChannelUpdateRetentionFloorDifferenceAndDirtyCheckpointMemory(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
const ownerID int64 = 701
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: ownerID,
Title: "retention memory",
Megagroup: true,
Date: 1_700_010_000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID := created.Channel.ID
sent := make([]domain.SendChannelMessageResult, 0, 3)
for i := 1; i <= 3; i++ {
result, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: ownerID,
ChannelID: channelID,
RandomID: int64(9000 + i),
Message: "retention",
Date: 1_700_010_000 + i,
})
if err != nil {
t.Fatalf("send message %d: %v", i, err)
}
sent = append(sent, result)
}
// Delete create + first two messages. The third message remains as the normal incremental page.
pruned, err := store.PruneChannelUpdateEvents(ctx, channelID, sent[1].Event.Pts, 100)
if err != nil {
t.Fatalf("prune channel updates: %v", err)
}
if pruned.Deleted != 3 || pruned.Checkpoint.RetainedThroughPts != sent[1].Event.Pts {
t.Fatalf("prune result = %+v, want deleted=3 floor=%d", pruned, sent[1].Event.Pts)
}
if pruned.Checkpoint.LatestPts != sent[2].Event.Pts || pruned.Checkpoint.LatestEventDate != sent[2].Event.Date {
t.Fatalf("checkpoint latest = %+v, want pts/date %d/%d", pruned.Checkpoint, sent[2].Event.Pts, sent[2].Event.Date)
}
below, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: ownerID, ChannelID: channelID, Pts: pruned.Checkpoint.RetainedThroughPts - 1, Limit: 100,
})
if err != nil {
t.Fatalf("difference below retained floor: %v", err)
}
if !below.TooLong || below.Pts != sent[2].Event.Pts || below.Dialog.ChannelID != channelID {
t.Fatalf("difference below floor = %+v, want complete too-long snapshot at pts %d", below, sent[2].Event.Pts)
}
atFloor, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: ownerID, ChannelID: channelID, Pts: pruned.Checkpoint.RetainedThroughPts, Limit: 100,
})
if err != nil {
t.Fatalf("difference at retained floor: %v", err)
}
if atFloor.TooLong || len(atFloor.Events) != 1 || atFloor.Events[0].Pts != sent[2].Event.Pts {
t.Fatalf("difference at floor = %+v, want one normal incremental event at pts %d", atFloor, sent[2].Event.Pts)
}
// Remove the remaining row. Dirty-channel recovery must still use checkpoint.latest_event_date.
allPruned, err := store.PruneChannelUpdateEvents(ctx, channelID, sent[2].Event.Pts, 100)
if err != nil {
t.Fatalf("prune remaining channel updates: %v", err)
}
if allPruned.Deleted != 1 || len(store.events[channelID]) != 0 {
t.Fatalf("remaining prune = %+v events=%v, want empty event log", allPruned, store.events[channelID])
}
dirty, err := store.ListDirtyActiveChannelsForUser(ctx, ownerID, sent[2].Event.Date-1, 0, 10)
if err != nil {
t.Fatalf("list dirty channels after prune: %v", err)
}
if len(dirty) != 1 || dirty[0].ChannelID != channelID || dirty[0].Pts != sent[2].Event.Pts {
t.Fatalf("dirty channels after prune = %+v, want channel %d pts %d", dirty, channelID, sent[2].Event.Pts)
}
}
func TestPruneChannelUpdateEventsRejectsInvalidPtsCountMemory(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 901,
Title: "invalid retention event",
Megagroup: true,
Date: 1_700_020_000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID := created.Channel.ID
channel := store.channels[channelID]
channel.Pts++
store.channels[channelID] = channel
store.ptsSeq[channelID] = channel.Pts
store.appendChannelEventLocked(domain.ChannelUpdateEvent{
ChannelID: channelID,
Type: domain.ChannelUpdateNoop,
Pts: channel.Pts,
PtsCount: 0,
Date: 1_700_020_001,
})
_, err = store.PruneChannelUpdateEvents(ctx, channelID, channel.Pts, 100)
if err == nil || !strings.Contains(err.Error(), "invalid pts_count=0") {
t.Fatalf("prune invalid event err = %v, want fail-fast pts_count error", err)
}
}
func TestDeleteExpiredChannelUpdateEventsIsBoundedMemory(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 801,
Title: "expired retention memory",
Megagroup: true,
Date: 1_600_000_000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
for i := 1; i <= 3; i++ {
if _, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 801, ChannelID: created.Channel.ID, RandomID: int64(8000 + i), Message: "old", Date: 1_600_000_000 + i,
}); err != nil {
t.Fatalf("send old message %d: %v", i, err)
}
}
deleted, err := store.DeleteExpiredChannelUpdateEvents(ctx, time.Hour, 2)
if err != nil {
t.Fatalf("delete expired channel updates: %v", err)
}
if deleted != 2 {
t.Fatalf("deleted = %d, want bounded batch 2", deleted)
}
checkpoint := store.retention[created.Channel.ID]
if checkpoint.RetainedThroughPts != 2 || len(store.events[created.Channel.ID]) != 2 {
t.Fatalf("after bounded prune checkpoint=%+v events=%d, want floor=2 and 2 rows", checkpoint, len(store.events[created.Channel.ID]))
}
}

View file

@ -2,7 +2,11 @@ package memory
import (
"context"
"fmt"
"sort"
"strings"
"time"
"telesrv/internal/domain"
)
@ -37,7 +41,8 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
Dialog: dialog,
}, nil
}
if channel.Pts-req.Pts > limit {
checkpoint := s.channelUpdateCheckpointLocked(req.ChannelID, channel)
if req.Pts < checkpoint.RetainedThroughPts || channel.Pts-req.Pts > limit {
messages := make([]domain.ChannelMessage, 0, domain.MaxChannelDifferenceTooLongMessages)
for i := len(s.messages[req.ChannelID]) - 1; i >= 0 && len(messages) < domain.MaxChannelDifferenceTooLongMessages; i-- {
msg := s.messages[req.ChannelID][i]
@ -122,6 +127,170 @@ func (s *ChannelStore) MaxChannelPts(_ context.Context, channelID int64) (int, e
return s.ptsSeq[channelID], nil
}
func (s *ChannelStore) MaxChannelPtsBatch(_ context.Context, channelIDs []int64) (map[int64]int, error) {
out := make(map[int64]int, len(channelIDs))
s.mu.RLock()
for _, channelID := range channelIDs {
if pts, ok := s.ptsSeq[channelID]; ok {
out[channelID] = pts
}
}
s.mu.RUnlock()
return out, nil
}
// appendChannelEventLocked is the only memory-store append boundary for channel-scoped durable
// events. Keeping the checkpoint current here mirrors the PostgreSQL event+checkpoint transaction.
func (s *ChannelStore) appendChannelEventLocked(event domain.ChannelUpdateEvent) {
s.events[event.ChannelID] = append(s.events[event.ChannelID], event)
checkpoint := s.retention[event.ChannelID]
checkpoint.ChannelID = event.ChannelID
if event.Pts > checkpoint.LatestPts {
checkpoint.LatestPts = event.Pts
}
if event.Date > checkpoint.LatestEventDate {
checkpoint.LatestEventDate = event.Date
}
s.retention[event.ChannelID] = checkpoint
}
func (s *ChannelStore) channelUpdateCheckpointLocked(channelID int64, channel domain.Channel) domain.ChannelUpdateRetentionCheckpoint {
checkpoint := s.retention[channelID]
checkpoint.ChannelID = channelID
if channel.Pts > checkpoint.LatestPts {
checkpoint.LatestPts = channel.Pts
}
for _, event := range s.events[channelID] {
if event.Pts > checkpoint.LatestPts {
checkpoint.LatestPts = event.Pts
}
if event.Date > checkpoint.LatestEventDate {
checkpoint.LatestEventDate = event.Date
}
}
return checkpoint
}
// PruneChannelUpdateEvents removes a bounded contiguous prefix and advances the retained floor in
// the same memory-store critical section. throughPts may land inside a pts_count interval; that row
// is retained because channel event rows are indivisible.
func (s *ChannelStore) PruneChannelUpdateEvents(_ context.Context, channelID int64, throughPts, limit int) (domain.ChannelUpdateRetentionResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.pruneChannelUpdateEventsLocked(channelID, throughPts, 0, limit)
}
// DeleteExpiredChannelUpdateEvents uses the oldest retained event of each channel as an indexed-seek
// analogue, then prunes candidates oldest-first. There is no offset scan and total deleted rows never
// exceeds limit.
func (s *ChannelStore) DeleteExpiredChannelUpdateEvents(_ context.Context, olderThan time.Duration, limit int) (int, error) {
if olderThan <= 0 {
return 0, nil
}
limit = normalizeChannelRetentionLimit(limit)
cutoff := int(time.Now().Add(-olderThan).Unix())
s.mu.Lock()
defer s.mu.Unlock()
type candidate struct {
channelID int64
date int
}
candidates := make([]candidate, 0)
for channelID, channel := range s.channels {
checkpoint := s.channelUpdateCheckpointLocked(channelID, channel)
for _, event := range s.events[channelID] {
if event.Pts <= checkpoint.RetainedThroughPts {
continue
}
if event.Date < cutoff {
candidates = append(candidates, candidate{channelID: channelID, date: event.Date})
}
break
}
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].date == candidates[j].date {
return candidates[i].channelID < candidates[j].channelID
}
return candidates[i].date < candidates[j].date
})
deleted := 0
for _, item := range candidates {
if deleted >= limit {
break
}
channel := s.channels[item.channelID]
result, err := s.pruneChannelUpdateEventsLocked(item.channelID, channel.Pts, cutoff, limit-deleted)
if err != nil {
return deleted, err
}
deleted += result.Deleted
}
return deleted, nil
}
func (s *ChannelStore) pruneChannelUpdateEventsLocked(channelID int64, throughPts, beforeDate, limit int) (domain.ChannelUpdateRetentionResult, error) {
channel, ok := s.channels[channelID]
if !ok || channelID == 0 || throughPts < 0 {
return domain.ChannelUpdateRetentionResult{}, domain.ErrChannelInvalid
}
limit = normalizeChannelRetentionLimit(limit)
checkpoint := s.channelUpdateCheckpointLocked(channelID, channel)
if throughPts > checkpoint.LatestPts {
throughPts = checkpoint.LatestPts
}
if throughPts <= checkpoint.RetainedThroughPts {
s.retention[channelID] = checkpoint
return domain.ChannelUpdateRetentionResult{Checkpoint: checkpoint}, nil
}
cursor := checkpoint.RetainedThroughPts
deleted := 0
keep := make([]domain.ChannelUpdateEvent, 0, len(s.events[channelID]))
canPrune := true
for _, event := range s.events[channelID] {
if event.Pts <= checkpoint.RetainedThroughPts {
keep = append(keep, event)
continue
}
if !canPrune || deleted >= limit || event.Pts > throughPts || (beforeDate > 0 && event.Date >= beforeDate) {
canPrune = false
keep = append(keep, event)
continue
}
ptsCount := event.PtsCount
if ptsCount <= 0 {
return domain.ChannelUpdateRetentionResult{}, fmt.Errorf(
"prune channel update events: channel %d has invalid pts_count=%d at pts=%d",
channelID, ptsCount, event.Pts,
)
}
if event.Pts != cursor+ptsCount {
return domain.ChannelUpdateRetentionResult{}, fmt.Errorf(
"prune channel update events: channel %d has gap after pts %d: event pts=%d pts_count=%d",
channelID, cursor, event.Pts, ptsCount,
)
}
cursor = event.Pts
deleted++
}
if deleted > 0 {
s.events[channelID] = keep
checkpoint.RetainedThroughPts = cursor
}
s.retention[channelID] = checkpoint
return domain.ChannelUpdateRetentionResult{Checkpoint: checkpoint, Deleted: deleted}, nil
}
func normalizeChannelRetentionLimit(limit int) int {
if limit <= 0 || limit > domain.MaxChannelUpdateRetentionBatch {
return domain.MaxChannelUpdateRetentionBatch
}
return limit
}
func (s *ChannelStore) nextChannelPtsLocked(channelID int64) int {
s.ptsSeq[channelID]++
return s.ptsSeq[channelID]

View file

@ -0,0 +1,70 @@
package memory
import (
"context"
"telesrv/internal/store"
)
func (s *CodeStore) GetSnapshot(_ context.Context, hash string) (store.PhoneCodeSnapshot, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
entry, found := s.liveCodeLocked(hash)
if !found {
return store.PhoneCodeSnapshot{}, false, nil
}
if entry.code.Version != store.PhoneCodeVersionCurrent || entry.code.Revision == "" {
s.deleteCodeLocked(hash, entry.code)
return store.PhoneCodeSnapshot{}, false, nil
}
return store.PhoneCodeSnapshot{Record: entry.code, Revision: entry.code.Revision}, true, nil
}
func (s *CodeStore) CompareAndUpdate(_ context.Context, hash, expectedRevision string, next store.PhoneCode) (bool, error) {
if expectedRevision == "" || next.Version != store.PhoneCodeVersionCurrent || next.Purpose != "" {
return false, nil
}
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return false, err
}
next.Revision = revision
s.mu.Lock()
defer s.mu.Unlock()
entry, found := s.liveCodeLocked(hash)
if !found {
return false, nil
}
if entry.code.Version != store.PhoneCodeVersionCurrent || entry.code.Revision == "" {
s.deleteCodeLocked(hash, entry.code)
return false, nil
}
if entry.code.Purpose != "" || entry.code.Revision != expectedRevision {
return false, nil
}
entry.code = next
s.m[hash] = entry
return true, nil
}
func (s *CodeStore) CompareAndDelete(_ context.Context, hash, expectedRevision string) (bool, error) {
if expectedRevision == "" {
return false, nil
}
s.mu.Lock()
defer s.mu.Unlock()
entry, found := s.liveCodeLocked(hash)
if !found {
return false, nil
}
if entry.code.Version != store.PhoneCodeVersionCurrent || entry.code.Revision == "" {
s.deleteCodeLocked(hash, entry.code)
return false, nil
}
if entry.code.Purpose != "" || entry.code.Revision != expectedRevision {
return false, nil
}
s.deleteCodeLocked(hash, entry.code)
return true, nil
}

View file

@ -0,0 +1,213 @@
package memory
import (
"context"
"fmt"
"sync"
"testing"
"time"
"telesrv/internal/store"
)
func TestCodeStoreRevisionCAS(t *testing.T) {
ctx := context.Background()
codes := NewCodeStore()
record := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016201",
Code: "111111",
Channel: "email_setup",
PendingEmail: "first@example.test",
MaxAttempts: 5,
}
if err := codes.Set(ctx, "email-fixed", record, time.Minute); err != nil {
t.Fatal(err)
}
snapshot, found, err := codes.GetSnapshot(ctx, "email-fixed")
if err != nil || !found || snapshot.Revision == "" || snapshot.Record.Revision != snapshot.Revision {
t.Fatalf("snapshot=%+v found=%v err=%v", snapshot, found, err)
}
originalExpiry := codes.m["email-fixed"].expires
next := snapshot.Record
next.Code = "222222"
next.Attempts = 1
if applied, err := codes.CompareAndUpdate(ctx, "email-fixed", "stale-token", next); err != nil || applied {
t.Fatalf("wrong-token update applied=%v err=%v", applied, err)
}
unchanged, found, err := codes.GetSnapshot(ctx, "email-fixed")
if err != nil || !found || unchanged.Revision != snapshot.Revision || unchanged.Record.Code != record.Code {
t.Fatalf("after wrong-token snapshot=%+v found=%v err=%v", unchanged, found, err)
}
if applied, err := codes.CompareAndUpdate(ctx, "email-fixed", snapshot.Revision, next); err != nil || !applied {
t.Fatalf("current-token update applied=%v err=%v", applied, err)
}
updated, found, err := codes.GetSnapshot(ctx, "email-fixed")
if err != nil || !found || updated.Record.Code != next.Code || updated.Record.Attempts != 1 || updated.Revision == snapshot.Revision {
t.Fatalf("updated snapshot=%+v found=%v err=%v", updated, found, err)
}
if expiry := codes.m["email-fixed"].expires; !expiry.Equal(originalExpiry) {
t.Fatalf("CAS update expiry=%v, want unchanged %v", expiry, originalExpiry)
}
if applied, err := codes.CompareAndDelete(ctx, "email-fixed", snapshot.Revision); err != nil || applied {
t.Fatalf("stale delete applied=%v err=%v", applied, err)
}
if applied, err := codes.CompareAndDelete(ctx, "email-fixed", updated.Revision); err != nil || !applied {
t.Fatalf("current delete applied=%v err=%v", applied, err)
}
if _, found, _ := codes.GetSnapshot(ctx, "email-fixed"); found {
t.Fatal("CAS-deleted code remains")
}
}
func TestCodeStoreRevisionCASFailClosedAndScopeIsolation(t *testing.T) {
ctx := context.Background()
codes := NewCodeStore()
now := time.Now().Add(time.Minute)
codes.m["legacy"] = codeEntry{
code: store.PhoneCode{Version: 0, Phone: "15550016202", Code: "12345"},
expires: now,
}
if _, found, err := codes.GetSnapshot(ctx, "legacy"); err != nil || found {
t.Fatalf("legacy snapshot found=%v err=%v", found, err)
}
if _, found := codes.m["legacy"]; found {
t.Fatal("legacy snapshot record was not deleted")
}
codes.m["no-revision"] = codeEntry{
code: store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016202",
Code: "12345",
},
expires: now,
}
if _, found, err := codes.GetSnapshot(ctx, "no-revision"); err != nil || found {
t.Fatalf("revisionless snapshot found=%v err=%v", found, err)
}
if _, found := codes.m["no-revision"]; found {
t.Fatal("revisionless snapshot record was not deleted")
}
scoped := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016203",
Code: "12345",
Purpose: store.PhoneCodePurposeChangePhone,
UserID: 42,
AuthKeyID: [8]byte{1},
}
if err := codes.Set(ctx, "scoped-cas", scoped, time.Minute); err != nil {
t.Fatal(err)
}
snapshot, found, err := codes.GetSnapshot(ctx, "scoped-cas")
if err != nil || !found {
t.Fatalf("scoped snapshot found=%v err=%v", found, err)
}
if applied, err := codes.CompareAndUpdate(ctx, "scoped-cas", snapshot.Revision, snapshot.Record); err != nil || applied {
t.Fatalf("scoped update applied=%v err=%v", applied, err)
}
if applied, err := codes.CompareAndDelete(ctx, "scoped-cas", snapshot.Revision); err != nil || applied {
t.Fatalf("scoped delete applied=%v err=%v", applied, err)
}
if _, found, _ := codes.Get(ctx, "scoped-cas"); !found {
t.Fatal("generic CAS mutated scoped code")
}
}
func TestCodeStoreRevisionCASPreventsABAAndHasSingleWinner(t *testing.T) {
ctx := context.Background()
codes := NewCodeStore()
record := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016204",
Code: "123456",
Channel: "email_change",
}
if err := codes.Set(ctx, "aba", record, time.Minute); err != nil {
t.Fatal(err)
}
old, found, err := codes.GetSnapshot(ctx, "aba")
if err != nil || !found {
t.Fatalf("old snapshot found=%v err=%v", found, err)
}
if err := codes.Set(ctx, "aba", record, time.Minute); err != nil {
t.Fatal(err)
}
current, found, err := codes.GetSnapshot(ctx, "aba")
if err != nil || !found || current.Revision == old.Revision {
t.Fatalf("replacement snapshot=%+v old=%+v found=%v err=%v", current, old, found, err)
}
if applied, err := codes.CompareAndDelete(ctx, "aba", old.Revision); err != nil || applied {
t.Fatalf("ABA stale delete applied=%v err=%v", applied, err)
}
const workers = 64
results := make(chan bool, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
next := current.Record
next.Code = fmt.Sprintf("%06d", index)
applied, err := codes.CompareAndUpdate(ctx, "aba", current.Revision, next)
if err != nil {
errs <- err
return
}
results <- applied
}(i)
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("concurrent CAS update: %v", err)
}
winners := 0
for applied := range results {
if applied {
winners++
}
}
if winners != 1 {
t.Fatalf("concurrent CAS update winners=%d, want 1", winners)
}
winner, found, err := codes.GetSnapshot(ctx, "aba")
if err != nil || !found || winner.Revision == current.Revision {
t.Fatalf("winner snapshot=%+v found=%v err=%v", winner, found, err)
}
results = make(chan bool, workers)
errs = make(chan error, workers)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
applied, err := codes.CompareAndDelete(ctx, "aba", winner.Revision)
if err != nil {
errs <- err
return
}
results <- applied
}()
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("concurrent CAS delete: %v", err)
}
winners = 0
for applied := range results {
if applied {
winners++
}
}
if winners != 1 {
t.Fatalf("concurrent CAS delete winners=%d, want 1", winners)
}
}

View file

@ -13,6 +13,7 @@ func TestCodeStoreScopedRotationAndSingleConsume(t *testing.T) {
ctx := context.Background()
codes := NewCodeStore()
rec := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550015001",
Code: "12345",
Purpose: store.PhoneCodePurposeChangePhone,
@ -68,7 +69,7 @@ func TestCodeStoreScopedRotationAndSingleConsume(t *testing.T) {
func TestCodeStoreScopedIsolation(t *testing.T) {
ctx := context.Background()
codes := NewCodeStore()
a := store.PhoneCode{Phone: "15550015002", Code: "12345", Purpose: store.PhoneCodePurposeChangePhone, UserID: 42, AuthKeyID: [8]byte{1}}
a := store.PhoneCode{Version: store.PhoneCodeVersionCurrent, Phone: "15550015002", Code: "12345", Purpose: store.PhoneCodePurposeChangePhone, UserID: 42, AuthKeyID: [8]byte{1}}
b := a
b.AuthKeyID = [8]byte{2}
if err := codes.Set(ctx, "hash-a", a, time.Minute); err != nil {
@ -90,3 +91,24 @@ func TestCodeStoreScopedIsolation(t *testing.T) {
t.Fatal("other scope was removed")
}
}
func TestCodeStoreConsumeScopedRejectsAndDeletesLegacyVersion(t *testing.T) {
ctx := context.Background()
codes := NewCodeStore()
rec := store.PhoneCode{
Version: 0, Phone: "15550015003", Code: "12345",
Purpose: store.PhoneCodePurposeChangePhone, UserID: 43, AuthKeyID: [8]byte{3},
}
if err := codes.Set(ctx, "legacy-scope", rec, time.Minute); err != nil {
t.Fatal(err)
}
if _, found, err := codes.ConsumeScoped(ctx, "legacy-scope", rec.Scope()); err != nil || found {
t.Fatalf("legacy scoped consume found=%v err=%v, want false/nil", found, err)
}
if _, found, _ := codes.Get(ctx, "legacy-scope"); found {
t.Fatal("legacy scoped code remains after fail-closed consume")
}
if _, ok := codes.scopes[rec.Scope()]; ok {
t.Fatal("legacy scoped index remains after fail-closed consume")
}
}

View file

@ -0,0 +1,211 @@
package memory
import (
"context"
"crypto/subtle"
"time"
"telesrv/internal/store"
)
func (s *CodeStore) VerifyLogin(_ context.Context, hash, phone, code string, keepForSignUp bool, defaultMaxAttempts int) (store.LoginCodeVerifyResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.liveCodeLocked(hash)
if !ok {
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
}
record := entry.code
if record.Version != store.PhoneCodeVersionCurrent {
s.deleteCodeLocked(hash, record)
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
}
// Sign-up verification is a terminal state for VerifyLogin. Keep the marker
// for the one caller that already received signUpRequired, but do not report
// a second Accepted result or let later wrong-code calls exhaust it.
if record.SignUpVerified {
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
}
if record.Purpose != "" || record.Phone != phone || !loginCodeVerifiable(record) || record.Code == "" || code == "" {
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyInvalid, Record: record}, nil
}
if subtle.ConstantTimeCompare([]byte(record.Code), []byte(code)) != 1 {
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return store.LoginCodeVerifyResult{}, err
}
record.Attempts++
record.Revision = revision
entry.code = record
maxAttempts := record.MaxAttempts
if maxAttempts <= 0 {
maxAttempts = defaultMaxAttempts
}
if maxAttempts <= 0 {
maxAttempts = 1
}
if record.Attempts >= maxAttempts {
s.deleteCodeLocked(hash, record)
} else {
s.m[hash] = entry
}
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyInvalid, Record: record}, nil
}
if keepForSignUp {
if record.IssuedUserID != 0 {
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyInvalid, Record: record}, nil
}
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return store.LoginCodeVerifyResult{}, err
}
record.SignUpVerified = true
record.Revision = revision
entry.code = record
s.m[hash] = entry
} else {
s.deleteCodeLocked(hash, record)
}
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyAccepted, Record: record}, nil
}
func (s *CodeStore) VerifyScoped(_ context.Context, hash string, scope store.PhoneCodeScope, code string, defaultMaxAttempts int) (store.LoginCodeVerifyResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
if !scope.Valid() || s.scopes[scope] != hash {
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
}
entry, ok := s.liveCodeLocked(hash)
if !ok {
// liveCodeLocked removes the index when it can decode the stored scope;
// also close the stale-index-only case.
if s.scopes[scope] == hash {
delete(s.scopes, scope)
}
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
}
record := entry.code
if record.Version != store.PhoneCodeVersionCurrent || record.Scope() != scope || record.SignUpVerified || record.Code == "" {
// Fail closed on a legacy or internally inconsistent record. Clean both
// the scope encoded in the record and the scope that selected this hash.
s.deleteCodeLocked(hash, record)
if s.scopes[scope] == hash {
delete(s.scopes, scope)
}
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
}
if code == "" {
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyInvalid, Record: record}, nil
}
if subtle.ConstantTimeCompare([]byte(record.Code), []byte(code)) != 1 {
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return store.LoginCodeVerifyResult{}, err
}
record.Attempts++
record.Revision = revision
entry.code = record
maxAttempts := record.MaxAttempts
if maxAttempts <= 0 {
maxAttempts = defaultMaxAttempts
}
if maxAttempts <= 0 {
maxAttempts = 1
}
if record.Attempts >= maxAttempts {
s.deleteCodeLocked(hash, record)
} else {
s.m[hash] = entry
}
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyInvalid, Record: record}, nil
}
s.deleteCodeLocked(hash, record)
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyAccepted, Record: record}, nil
}
func (s *CodeStore) ConsumeSignUpVerified(_ context.Context, hash, phone string) (store.PhoneCode, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.liveCodeLocked(hash)
if !ok {
return store.PhoneCode{}, false, nil
}
record := entry.code
if record.Version != store.PhoneCodeVersionCurrent {
s.deleteCodeLocked(hash, record)
return store.PhoneCode{}, false, nil
}
if record.Purpose != "" || record.Phone != phone || !loginCodeVerifiable(record) || record.IssuedUserID != 0 || !record.SignUpVerified {
return store.PhoneCode{}, false, nil
}
s.deleteCodeLocked(hash, record)
return record, true, nil
}
func (s *CodeStore) TakeLoginCode(_ context.Context, hash, phone string) (store.PhoneCode, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.liveCodeLocked(hash)
if !ok {
return store.PhoneCode{}, false, nil
}
record := entry.code
if record.Version != store.PhoneCodeVersionCurrent {
s.deleteCodeLocked(hash, record)
return store.PhoneCode{}, false, nil
}
if record.SignUpVerified {
return store.PhoneCode{}, false, nil
}
if record.Purpose != "" || record.Phone != phone || !loginCodeTakeable(record) {
return store.PhoneCode{}, false, nil
}
s.deleteCodeLocked(hash, record)
return record, true, nil
}
func (s *CodeStore) InvalidateLoginCode(_ context.Context, hash, phone string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.liveCodeLocked(hash)
if !ok {
return false, nil
}
record := entry.code
if record.Version != store.PhoneCodeVersionCurrent {
s.deleteCodeLocked(hash, record)
return false, nil
}
if record.Purpose != "" || record.Phone != phone || !loginCodeTakeable(record) {
return false, nil
}
s.deleteCodeLocked(hash, record)
return true, nil
}
func loginCodeVerifiable(record store.PhoneCode) bool {
return record.Channel == store.PhoneCodeChannelPhone || record.Channel == store.PhoneCodeChannelEmailLogin
}
func loginCodeTakeable(record store.PhoneCode) bool {
return loginCodeVerifiable(record) || record.Channel == store.PhoneCodeChannelEmailSetupRequired
}
func (s *CodeStore) liveCodeLocked(hash string) (codeEntry, bool) {
entry, ok := s.m[hash]
if !ok {
return codeEntry{}, false
}
if time.Now().After(entry.expires) {
s.deleteCodeLocked(hash, entry.code)
return codeEntry{}, false
}
return entry, true
}

View file

@ -0,0 +1,129 @@
package memory
import (
"context"
"fmt"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
type loginCodeDeliveryRecord struct {
userID int64
codeFingerprint [32]byte
privateMessageID int64
messageBoxID int
pts int
messageDate int
}
// LoginCodeDeliveryStore composes the message projection with the same
// durable update-event store consumed by updates.getDifference. Keeping this
// as an explicit dependency prevents tests (and future in-memory runtimes)
// from accidentally creating a visible message without its pts event.
type LoginCodeDeliveryStore struct {
messages *MessageStore
events *UpdateEventStore
}
func NewLoginCodeDeliveryStore(messages *MessageStore, events *UpdateEventStore) *LoginCodeDeliveryStore {
return &LoginCodeDeliveryStore{messages: messages, events: events}
}
// DeliverLoginCodeMessage is the in-memory equivalent of the PostgreSQL
// transaction. Message, dialog, pts, durable event and immutable receipt are
// published under the two backing stores' locks; a repeated phone_code_hash
// returns the first snapshot.
func (s *LoginCodeDeliveryStore) DeliverLoginCodeMessage(_ context.Context, req domain.LoginCodeDeliveryRequest) (domain.LoginCodeDeliveryResult, error) {
if s == nil || s.messages == nil || s.events == nil {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("memory login code delivery: %w: message and update stores are required", domain.ErrLoginCodeDeliveryInvalid)
}
deliveryKey, err := store.LoginCodeDeliveryKey(req.PhoneCodeHash)
if err != nil {
return domain.LoginCodeDeliveryResult{}, err
}
codeFingerprint, err := store.LoginCodeFingerprint(req.PhoneCodeHash, req.Code)
if err != nil {
return domain.LoginCodeDeliveryResult{}, err
}
if req.Date == 0 {
req.Date = int(time.Now().Unix())
}
if req.ExpiresAt <= int64(req.Date) {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("memory login code receipt expiry: %w: date=%d expires_at=%d", domain.ErrLoginCodeDeliveryInvalid, req.Date, req.ExpiresAt)
}
base, err := domain.OfficialLoginCodeMessage(req.UserID, req.Code, req.Date)
if err != nil {
return domain.LoginCodeDeliveryResult{}, err
}
// Lock ordering is local and fixed: MessageStore -> UpdateEventStore ->
// DialogStore. No other memory operation takes the first two together.
s.messages.mu.Lock()
defer s.messages.mu.Unlock()
s.events.mu.Lock()
defer s.events.mu.Unlock()
if receipt, ok := s.messages.loginCodeDeliveries[deliveryKey]; ok {
if receipt.userID != req.UserID || !store.SameLoginCodeFingerprint(receipt.codeFingerprint[:], codeFingerprint) {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("memory login code delivery: %w", domain.ErrLoginCodeDeliveryConflict)
}
msg, err := store.RestoreLoginCodeDeliveryMessage(
receipt.userID,
req.Code,
receipt.messageDate,
receipt.privateMessageID,
receipt.messageBoxID,
receipt.pts,
)
if err != nil {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("memory login code delivery replay: %w", err)
}
return domain.LoginCodeDeliveryResult{Message: msg, Created: false}, nil
}
currentEventPts := 0
for _, event := range s.events.events[req.UserID] {
if event.Pts > currentEventPts {
currentEventPts = event.Pts
}
}
if messagePts := s.messages.nextPts[req.UserID]; messagePts > currentEventPts {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("memory login code delivery: %w: message pts %d exceeds durable event pts %d", domain.ErrLoginCodeDeliveryInvalid, messagePts, currentEventPts)
}
base.ID = s.messages.nextBoxIDLocked(req.UserID)
base.UID = s.messages.nextUID
s.messages.nextUID++
base.Pts = currentEventPts + 1
s.messages.nextPts[req.UserID] = base.Pts
s.messages.m[req.UserID] = append(s.messages.m[req.UserID], cloneMessage(base))
if s.messages.dialogs != nil {
s.messages.dialogs.mu.Lock()
list := s.messages.dialogs.m[req.UserID]
list = upsertMemoryDialog(list, domain.Dialog{
Peer: base.Peer,
TopMessage: base.ID,
TopMessageDate: base.Date,
UnreadCount: s.messages.privateUnreadCountLocked(req.UserID, base.Peer),
})
if !hasUser(list.Users, domain.OfficialSystemUserID) {
list.Users = append(list.Users, domain.OfficialSystemUser())
}
list.Messages = append(list.Messages, cloneMessage(base))
s.messages.dialogs.m[req.UserID] = list
s.messages.dialogs.mu.Unlock()
}
event := newMessageEvent(base)
s.events.events[req.UserID] = append(s.events.events[req.UserID], cloneUpdateEvent(event))
s.messages.loginCodeDeliveries[deliveryKey] = loginCodeDeliveryRecord{
userID: req.UserID,
codeFingerprint: codeFingerprint,
privateMessageID: base.UID,
messageBoxID: base.ID,
pts: base.Pts,
messageDate: base.Date,
}
return domain.LoginCodeDeliveryResult{Message: cloneMessage(base), Created: true}, nil
}

View file

@ -0,0 +1,165 @@
package memory
import (
"context"
"errors"
"reflect"
"sync"
"sync/atomic"
"testing"
"telesrv/internal/domain"
)
func TestLoginCodeDeliveryStoreCommitsMessageEventDialogAndReplay(t *testing.T) {
ctx := context.Background()
const userID int64 = 1000000001
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
events := NewUpdateEventStore()
deliveries := NewLoginCodeDeliveryStore(messages, events)
req := domain.LoginCodeDeliveryRequest{
UserID: userID,
PhoneCodeHash: "phone-code-hash-one",
Code: "12345",
Date: 1700000000,
ExpiresAt: 1700000300,
}
first, err := deliveries.DeliverLoginCodeMessage(ctx, req)
if err != nil {
t.Fatalf("DeliverLoginCodeMessage: %v", err)
}
if !first.Created || first.Message.ID != 1 || first.Message.UID != 1 || first.Message.Pts != 1 || first.Message.Out ||
first.Message.OwnerUserID != userID || first.Message.Peer.ID != domain.OfficialSystemUserID || first.Message.From.ID != domain.OfficialSystemUserID {
t.Fatalf("first delivery = %+v, want first incoming 777000 message", first)
}
if len(messages.m[userID]) != 1 || !reflect.DeepEqual(messages.m[userID][0], first.Message) {
t.Fatalf("message projection = %+v, want committed message", messages.m[userID])
}
if len(events.events[userID]) != 1 {
t.Fatalf("durable events = %+v, want one new_message", events.events[userID])
}
event := events.events[userID][0]
if event.Type != domain.UpdateEventNewMessage || event.Pts != first.Message.Pts || event.PtsCount != 1 || !reflect.DeepEqual(event.Message, first.Message) {
t.Fatalf("event = %+v, want message-identical new_message", event)
}
list := dialogs.m[userID]
if len(list.Dialogs) != 1 || list.Dialogs[0].Peer.ID != domain.OfficialSystemUserID || list.Dialogs[0].TopMessage != first.Message.ID || list.Dialogs[0].UnreadCount != 1 {
t.Fatalf("dialog projection = %+v, want unread 777000 dialog", list.Dialogs)
}
if len(list.Users) != 1 || list.Users[0].ID != domain.OfficialSystemUserID {
t.Fatalf("dialog users = %+v, want official system user", list.Users)
}
replayReq := req
replayReq.Date++
replay, err := deliveries.DeliverLoginCodeMessage(ctx, replayReq)
if err != nil {
t.Fatalf("replay DeliverLoginCodeMessage: %v", err)
}
if replay.Created || !reflect.DeepEqual(replay.Message, first.Message) {
t.Fatalf("replay = %+v, want immutable first result %+v", replay, first)
}
if len(messages.m[userID]) != 1 || len(events.events[userID]) != 1 || len(messages.loginCodeDeliveries) != 1 {
t.Fatalf("replay created facts: messages=%d events=%d receipts=%d", len(messages.m[userID]), len(events.events[userID]), len(messages.loginCodeDeliveries))
}
second, err := deliveries.DeliverLoginCodeMessage(ctx, domain.LoginCodeDeliveryRequest{
UserID: userID,
PhoneCodeHash: "phone-code-hash-two",
Code: "67890",
Date: 1700000010,
ExpiresAt: 1700000310,
})
if err != nil {
t.Fatalf("second distinct delivery: %v", err)
}
if !second.Created || second.Message.ID != 2 || second.Message.UID != 2 || second.Message.Pts != 2 || len(events.events[userID]) != 2 {
t.Fatalf("second delivery = %+v events=%+v, want contiguous allocations", second, events.events[userID])
}
if got := dialogs.m[userID].Dialogs[0].UnreadCount; got != 2 {
t.Fatalf("dialog unread = %d, want 2", got)
}
}
func TestLoginCodeDeliveryStoreConcurrentReplayAndConflict(t *testing.T) {
ctx := context.Background()
const userID int64 = 1000000002
messages := NewMessageStore(NewDialogStore())
events := NewUpdateEventStore()
deliveries := NewLoginCodeDeliveryStore(messages, events)
req := domain.LoginCodeDeliveryRequest{
UserID: userID,
PhoneCodeHash: "concurrent-phone-code-hash",
Code: "24680",
Date: 1700000100,
ExpiresAt: 1700000400,
}
const workers = 32
var created atomic.Int32
results := make(chan domain.LoginCodeDeliveryResult, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
got, err := deliveries.DeliverLoginCodeMessage(ctx, req)
if err != nil {
errs <- err
return
}
if got.Created {
created.Add(1)
}
results <- got
}()
}
wg.Wait()
close(errs)
close(results)
for err := range errs {
t.Fatalf("concurrent delivery: %v", err)
}
if created.Load() != 1 {
t.Fatalf("created calls = %d, want exactly 1", created.Load())
}
for got := range results {
if got.Message.ID != 1 || got.Message.UID != 1 || got.Message.Pts != 1 {
t.Fatalf("concurrent result = %+v, want the same first allocation", got)
}
}
if len(messages.m[userID]) != 1 || len(events.events[userID]) != 1 || len(messages.loginCodeDeliveries) != 1 {
t.Fatalf("concurrent facts: messages=%d events=%d receipts=%d", len(messages.m[userID]), len(events.events[userID]), len(messages.loginCodeDeliveries))
}
changedCode := req
changedCode.Code = "13579"
if _, err := deliveries.DeliverLoginCodeMessage(ctx, changedCode); !errors.Is(err, domain.ErrLoginCodeDeliveryConflict) {
t.Fatalf("changed-code replay err = %v, want ErrLoginCodeDeliveryConflict", err)
}
changedUser := req
changedUser.UserID++
if _, err := deliveries.DeliverLoginCodeMessage(ctx, changedUser); !errors.Is(err, domain.ErrLoginCodeDeliveryConflict) {
t.Fatalf("changed-user replay err = %v, want ErrLoginCodeDeliveryConflict", err)
}
if len(messages.m[userID]) != 1 || len(events.events[userID]) != 1 {
t.Fatal("conflicting replay changed committed facts")
}
}
func TestLoginCodeDeliveryStoreRequiresSharedEventStore(t *testing.T) {
messages := NewMessageStore()
_, err := NewLoginCodeDeliveryStore(messages, nil).DeliverLoginCodeMessage(context.Background(), domain.LoginCodeDeliveryRequest{
UserID: 1000000003,
PhoneCodeHash: "missing-event-store",
Code: "12345",
Date: 1700000200,
ExpiresAt: 1700000500,
})
if !errors.Is(err, domain.ErrLoginCodeDeliveryInvalid) {
t.Fatalf("missing event store err = %v, want ErrLoginCodeDeliveryInvalid", err)
}
}

View file

@ -0,0 +1,106 @@
package memory
import (
"context"
"sync"
"testing"
"time"
"telesrv/internal/store"
)
func TestCodeStoreAtomicLoginInvalidation(t *testing.T) {
ctx := context.Background()
const phone = "15550016011"
newRecord := func() store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: phone,
Code: "12345",
Channel: store.PhoneCodeChannelPhone,
MaxAttempts: 5,
}
}
t.Run("owner cleanup may delete a terminal sign-up marker", func(t *testing.T) {
codes := NewCodeStore()
if err := codes.Set(ctx, "invalidate-marker", newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
verified, err := codes.VerifyLogin(ctx, "invalidate-marker", phone, "12345", true, 5)
if err != nil || verified.Status != store.LoginCodeVerifyAccepted || !verified.Record.SignUpVerified {
t.Fatalf("mark sign-up = %+v err=%v", verified, err)
}
if removed, err := codes.InvalidateLoginCode(ctx, "invalidate-marker", "15550016999"); err != nil || removed {
t.Fatalf("cross-phone invalidate removed=%v err=%v", removed, err)
}
if _, found, err := codes.ConsumeSignUpVerified(ctx, "invalidate-marker", "15550016999"); err != nil || found {
t.Fatalf("cross-phone consume found=%v err=%v", found, err)
}
if removed, err := codes.InvalidateLoginCode(ctx, "invalidate-marker", phone); err != nil || !removed {
t.Fatalf("owner invalidate removed=%v err=%v", removed, err)
}
if _, found, err := codes.ConsumeSignUpVerified(ctx, "invalidate-marker", phone); err != nil || found {
t.Fatalf("consume after invalidate found=%v err=%v", found, err)
}
})
t.Run("legacy records fail closed", func(t *testing.T) {
codes := NewCodeStore()
legacy := newRecord()
legacy.Version = 0
if err := codes.Set(ctx, "invalidate-legacy", legacy, time.Minute); err != nil {
t.Fatal(err)
}
if removed, err := codes.InvalidateLoginCode(ctx, "invalidate-legacy", phone); err != nil || removed {
t.Fatalf("legacy invalidate removed=%v err=%v, want false", removed, err)
}
if _, found, err := codes.Get(ctx, "invalidate-legacy"); err != nil || found {
t.Fatalf("legacy record found=%v err=%v after fail-closed invalidate", found, err)
}
})
t.Run("invalidate and sign-up consume have one winner", func(t *testing.T) {
codes := NewCodeStore()
if err := codes.Set(ctx, "invalidate-race", newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
if verified, err := codes.VerifyLogin(ctx, "invalidate-race", phone, "12345", true, 5); err != nil || verified.Status != store.LoginCodeVerifyAccepted {
t.Fatalf("mark sign-up = %+v err=%v", verified, err)
}
const workers = 64
results := make(chan bool, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(invalidate bool) {
defer wg.Done()
if invalidate {
removed, err := codes.InvalidateLoginCode(ctx, "invalidate-race", phone)
if err != nil {
t.Errorf("InvalidateLoginCode: %v", err)
}
results <- removed
return
}
_, found, err := codes.ConsumeSignUpVerified(ctx, "invalidate-race", phone)
if err != nil {
t.Errorf("ConsumeSignUpVerified: %v", err)
}
results <- found
}(i%2 == 0)
}
wg.Wait()
close(results)
winners := 0
for won := range results {
if won {
winners++
}
}
if winners != 1 {
t.Fatalf("invalidate/consume winners=%d, want 1", winners)
}
})
}

View file

@ -0,0 +1,447 @@
package memory
import (
"context"
"sync"
"testing"
"time"
"telesrv/internal/store"
)
func TestCodeStoreAtomicLoginStateMachine(t *testing.T) {
ctx := context.Background()
const phone = "15550016001"
newRecord := func() store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
IssuedUserID: 1000000001,
Phone: phone,
Code: "12345",
Channel: "phone",
MaxAttempts: 2,
}
}
t.Run("version mismatch fails closed for every atomic entry", func(t *testing.T) {
codes := NewCodeStore()
legacy := newRecord()
legacy.Version = 0
if err := codes.Set(ctx, "legacy-verify", legacy, time.Minute); err != nil {
t.Fatal(err)
}
result, err := codes.VerifyLogin(ctx, "legacy-verify", phone, legacy.Code, false, 5)
if err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("legacy VerifyLogin = %+v err=%v, want Missing", result, err)
}
if _, found, _ := codes.Get(ctx, "legacy-verify"); found {
t.Fatal("legacy VerifyLogin record was not deleted")
}
unknown := newRecord()
unknown.Version = store.PhoneCodeVersionCurrent + 1
if err := codes.Set(ctx, "unknown-take", unknown, time.Minute); err != nil {
t.Fatal(err)
}
if _, found, err := codes.TakeLoginCode(ctx, "unknown-take", phone); err != nil || found {
t.Fatalf("unknown TakeLoginCode found=%v err=%v, want false", found, err)
}
if _, found, _ := codes.Get(ctx, "unknown-take"); found {
t.Fatal("unknown TakeLoginCode record was not deleted")
}
legacy.SignUpVerified = true
if err := codes.Set(ctx, "legacy-signup", legacy, time.Minute); err != nil {
t.Fatal(err)
}
if _, found, err := codes.ConsumeSignUpVerified(ctx, "legacy-signup", phone); err != nil || found {
t.Fatalf("legacy ConsumeSignUpVerified found=%v err=%v, want false", found, err)
}
if _, found, _ := codes.Get(ctx, "legacy-signup"); found {
t.Fatal("legacy sign-up marker was not deleted")
}
})
t.Run("scope mismatch does not burn victim attempts", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord()
if err := codes.Set(ctx, "scope", record, time.Minute); err != nil {
t.Fatal(err)
}
result, err := codes.VerifyLogin(ctx, "scope", "15550016999", record.Code, false, 5)
if err != nil || result.Status != store.LoginCodeVerifyInvalid || result.Record.Attempts != 0 {
t.Fatalf("wrong-phone VerifyLogin = %+v err=%v", result, err)
}
stored, found, err := codes.Get(ctx, "scope")
if err != nil || !found || stored.Attempts != 0 {
t.Fatalf("wrong-phone stored=%+v found=%v err=%v", stored, found, err)
}
if _, found, err := codes.TakeLoginCode(ctx, "scope", "15550016999"); err != nil || found {
t.Fatalf("cross-phone TakeLoginCode found=%v err=%v", found, err)
}
scoped := newRecord()
scoped.Purpose = store.PhoneCodePurposeChangePhone
scoped.UserID = 42
scoped.AuthKeyID = [8]byte{1}
if err := codes.Set(ctx, "scoped", scoped, time.Minute); err != nil {
t.Fatal(err)
}
result, err = codes.VerifyLogin(ctx, "scoped", phone, scoped.Code, false, 5)
if err != nil || result.Status != store.LoginCodeVerifyInvalid {
t.Fatalf("scoped VerifyLogin = %+v err=%v, want Invalid", result, err)
}
if _, found, err := codes.TakeLoginCode(ctx, "scoped", phone); err != nil || found {
t.Fatalf("scoped TakeLoginCode found=%v err=%v", found, err)
}
if _, found, _ := codes.Get(ctx, "scoped"); !found {
t.Fatal("login operations deleted a scoped change-phone code")
}
})
t.Run("wrong code increments atomically and threshold deletes", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord()
if err := codes.Set(ctx, "wrong", record, time.Minute); err != nil {
t.Fatal(err)
}
first, err := codes.VerifyLogin(ctx, "wrong", phone, "00000", false, 9)
if err != nil || first.Status != store.LoginCodeVerifyInvalid || first.Record.Attempts != 1 {
t.Fatalf("first wrong code = %+v err=%v", first, err)
}
stored, found, err := codes.Get(ctx, "wrong")
if err != nil || !found || stored.Attempts != 1 {
t.Fatalf("stored after first wrong = %+v found=%v err=%v", stored, found, err)
}
second, err := codes.VerifyLogin(ctx, "wrong", phone, "00000", false, 9)
if err != nil || second.Status != store.LoginCodeVerifyInvalid || second.Record.Attempts != 2 {
t.Fatalf("threshold wrong code = %+v err=%v", second, err)
}
if _, found, _ := codes.Get(ctx, "wrong"); found {
t.Fatal("threshold-exhausted code remains")
}
after, err := codes.VerifyLogin(ctx, "wrong", phone, record.Code, false, 9)
if err != nil || after.Status != store.LoginCodeVerifyMissing {
t.Fatalf("verify after exhaustion = %+v err=%v, want Missing", after, err)
}
fallback := newRecord()
fallback.MaxAttempts = 0
if err := codes.Set(ctx, "fallback", fallback, time.Minute); err != nil {
t.Fatal(err)
}
if got, err := codes.VerifyLogin(ctx, "fallback", phone, "bad", false, 1); err != nil || got.Status != store.LoginCodeVerifyInvalid {
t.Fatalf("default threshold verify = %+v err=%v", got, err)
}
if _, found, _ := codes.Get(ctx, "fallback"); found {
t.Fatal("default threshold did not delete code")
}
})
t.Run("accepted consume and sign-up marker are terminal", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord()
if err := codes.Set(ctx, "consume", record, time.Minute); err != nil {
t.Fatal(err)
}
accepted, err := codes.VerifyLogin(ctx, "consume", phone, record.Code, false, 5)
if err != nil || accepted.Status != store.LoginCodeVerifyAccepted || accepted.Record.SignUpVerified {
t.Fatalf("consume verify = %+v err=%v", accepted, err)
}
if _, found, _ := codes.Get(ctx, "consume"); found {
t.Fatal("accepted existing-user code remains")
}
if err := codes.Set(ctx, "issued-existing", record, time.Minute); err != nil {
t.Fatal(err)
}
wrongScope, err := codes.VerifyLogin(ctx, "issued-existing", phone, record.Code, true, 5)
if err != nil || wrongScope.Status != store.LoginCodeVerifyInvalid {
t.Fatalf("existing-issued keep-for-signup = %+v err=%v, want Invalid", wrongScope, err)
}
if _, found, err := codes.ConsumeSignUpVerified(ctx, "issued-existing", phone); err != nil || found {
t.Fatalf("existing-issued sign-up consume found=%v err=%v", found, err)
}
signUpRecord := record
signUpRecord.IssuedUserID = 0
if err := codes.Set(ctx, "signup", signUpRecord, time.Minute); err != nil {
t.Fatal(err)
}
expires := codes.m["signup"].expires
marked, err := codes.VerifyLogin(ctx, "signup", phone, record.Code, true, 5)
if err != nil || marked.Status != store.LoginCodeVerifyAccepted || !marked.Record.SignUpVerified {
t.Fatalf("sign-up verify = %+v err=%v", marked, err)
}
if got := codes.m["signup"]; !got.code.SignUpVerified || !got.expires.Equal(expires) {
t.Fatalf("sign-up marker=%+v expiry=%v, want marker with unchanged %v", got.code, got.expires, expires)
}
repeated, err := codes.VerifyLogin(ctx, "signup", phone, record.Code, true, 5)
if err != nil || repeated.Status != store.LoginCodeVerifyMissing {
t.Fatalf("repeated sign-up verify = %+v err=%v, want terminal Missing", repeated, err)
}
if _, found, err := codes.ConsumeSignUpVerified(ctx, "signup", "15550016999"); err != nil || found {
t.Fatalf("cross-phone sign-up consume found=%v err=%v", found, err)
}
if _, found, err := codes.TakeLoginCode(ctx, "signup", phone); err != nil || found {
t.Fatalf("terminal marker take found=%v err=%v, want false", found, err)
}
consumed, found, err := codes.ConsumeSignUpVerified(ctx, "signup", phone)
if err != nil || !found || !consumed.SignUpVerified || consumed.Code != signUpRecord.Code || consumed.IssuedUserID != 0 {
t.Fatalf("sign-up consume = %+v found=%v err=%v", consumed, found, err)
}
if _, found, err := codes.ConsumeSignUpVerified(ctx, "signup", phone); err != nil || found {
t.Fatalf("second sign-up consume found=%v err=%v", found, err)
}
})
t.Run("take returns the removed record exactly once", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord()
if err := codes.Set(ctx, "take", record, time.Minute); err != nil {
t.Fatal(err)
}
expected, found, err := codes.Get(ctx, "take")
if err != nil || !found {
t.Fatalf("load take record found=%v err=%v", found, err)
}
if _, found, err := codes.TakeLoginCode(ctx, "take", "15550016999"); err != nil || found {
t.Fatalf("cross-phone take found=%v err=%v", found, err)
}
taken, found, err := codes.TakeLoginCode(ctx, "take", phone)
if err != nil || !found || taken != expected {
t.Fatalf("take = %+v found=%v err=%v, want %+v", taken, found, err, expected)
}
if _, found, err := codes.TakeLoginCode(ctx, "take", phone); err != nil || found {
t.Fatalf("second take found=%v err=%v", found, err)
}
})
}
func TestCodeStoreAtomicLoginConcurrency(t *testing.T) {
ctx := context.Background()
const (
phone = "15550016002"
workers = 64
)
newRecord := func() store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: phone,
Code: "12345",
Channel: "phone",
MaxAttempts: 7,
}
}
t.Run("consume verify has one accepted", func(t *testing.T) {
codes := NewCodeStore()
if err := codes.Set(ctx, "verify-race", newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentMemoryVerify(t, codes, "verify-race", phone, "12345", false, workers)
if statuses[store.LoginCodeVerifyAccepted] != 1 || statuses[store.LoginCodeVerifyMissing] != workers-1 || statuses[store.LoginCodeVerifyInvalid] != 0 {
t.Fatalf("verify race statuses = %+v", statuses)
}
})
t.Run("mark and consume each have one winner", func(t *testing.T) {
codes := NewCodeStore()
if err := codes.Set(ctx, "signup-race", newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentMemoryVerify(t, codes, "signup-race", phone, "12345", true, workers)
if statuses[store.LoginCodeVerifyAccepted] != 1 || statuses[store.LoginCodeVerifyMissing] != workers-1 {
t.Fatalf("sign-up verify race statuses = %+v", statuses)
}
found := concurrentMemoryConsumeSignUp(t, codes, "signup-race", phone, workers)
if found != 1 {
t.Fatalf("sign-up consumes = %d, want 1", found)
}
})
t.Run("take has one winner", func(t *testing.T) {
codes := NewCodeStore()
if err := codes.Set(ctx, "take-race", newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
found := concurrentMemoryTake(t, codes, "take-race", phone, workers)
if found != 1 {
t.Fatalf("takes = %d, want 1", found)
}
})
t.Run("wrong attempts cannot be lost", func(t *testing.T) {
codes := NewCodeStore()
if err := codes.Set(ctx, "wrong-race", newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentMemoryVerify(t, codes, "wrong-race", phone, "00000", false, workers)
if statuses[store.LoginCodeVerifyInvalid] != 7 || statuses[store.LoginCodeVerifyMissing] != workers-7 {
t.Fatalf("wrong-code race statuses = %+v, want 7 Invalid then Missing", statuses)
}
if _, found, _ := codes.Get(ctx, "wrong-race"); found {
t.Fatal("wrong-code race left an exhausted code")
}
})
t.Run("verify and cancel-resend take share one winner", func(t *testing.T) {
codes := NewCodeStore()
if err := codes.Set(ctx, "mixed-race", newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
results := make(chan bool, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(take bool) {
defer wg.Done()
if take {
_, found, err := codes.TakeLoginCode(ctx, "mixed-race", phone)
if err != nil {
t.Errorf("TakeLoginCode: %v", err)
}
results <- found
return
}
verified, err := codes.VerifyLogin(ctx, "mixed-race", phone, "12345", false, 5)
if err != nil {
t.Errorf("VerifyLogin: %v", err)
}
results <- verified.Status == store.LoginCodeVerifyAccepted
}(i%2 == 0)
}
wg.Wait()
close(results)
winners := 0
for won := range results {
if won {
winners++
}
}
if winners != 1 {
t.Fatalf("mixed verify/take winners = %d, want 1", winners)
}
})
t.Run("sign-up mark and take share one winner", func(t *testing.T) {
codes := NewCodeStore()
if err := codes.Set(ctx, "mixed-signup-race", newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
results := make(chan bool, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(take bool) {
defer wg.Done()
if take {
_, found, err := codes.TakeLoginCode(ctx, "mixed-signup-race", phone)
if err != nil {
t.Errorf("TakeLoginCode: %v", err)
}
results <- found
return
}
verified, err := codes.VerifyLogin(ctx, "mixed-signup-race", phone, "12345", true, 5)
if err != nil {
t.Errorf("VerifyLogin: %v", err)
}
results <- verified.Status == store.LoginCodeVerifyAccepted
}(i%2 == 0)
}
wg.Wait()
close(results)
winners := 0
for won := range results {
if won {
winners++
}
}
if winners != 1 {
t.Fatalf("mixed sign-up/take winners = %d, want 1", winners)
}
})
}
func concurrentMemoryVerify(t *testing.T, codes *CodeStore, hash, phone, code string, keep bool, workers int) map[store.LoginCodeVerifyStatus]int {
t.Helper()
ctx := context.Background()
results := make(chan store.LoginCodeVerifyStatus, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
result, err := codes.VerifyLogin(ctx, hash, phone, code, keep, 5)
if err != nil {
t.Errorf("VerifyLogin: %v", err)
return
}
results <- result.Status
}()
}
wg.Wait()
close(results)
counts := make(map[store.LoginCodeVerifyStatus]int)
for status := range results {
counts[status]++
}
return counts
}
func concurrentMemoryTake(t *testing.T, codes *CodeStore, hash, phone string, workers int) int {
t.Helper()
ctx := context.Background()
results := make(chan bool, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, found, err := codes.TakeLoginCode(ctx, hash, phone)
if err != nil {
t.Errorf("TakeLoginCode: %v", err)
return
}
results <- found
}()
}
wg.Wait()
close(results)
foundCount := 0
for found := range results {
if found {
foundCount++
}
}
return foundCount
}
func concurrentMemoryConsumeSignUp(t *testing.T, codes *CodeStore, hash, phone string, workers int) int {
t.Helper()
ctx := context.Background()
results := make(chan bool, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, found, err := codes.ConsumeSignUpVerified(ctx, hash, phone)
if err != nil {
t.Errorf("ConsumeSignUpVerified: %v", err)
return
}
results <- found
}()
}
wg.Wait()
close(results)
foundCount := 0
for found := range results {
if found {
foundCount++
}
}
return foundCount
}

View file

@ -37,9 +37,12 @@ func (s *MessageStore) DeleteMessages(_ context.Context, req domain.DeleteMessag
}
type deletedMemoryMessage struct {
userID int64
peer domain.Peer
id int
userID int64
peer domain.Peer
id int
privateMessageID int64
messageSenderID int64
randomID int64
}
func (s *MessageStore) finishMemoryDeleteLocked(res domain.DeleteMessagesResult, deleted []deletedMemoryMessage, date int, preserveEmptyDialogs bool) domain.DeleteMessagesResult {
@ -83,6 +86,19 @@ func (s *MessageStore) finishMemoryDeleteLocked(res domain.DeleteMessagesResult,
Date: date,
MessageIDs: ids,
}
for _, row := range deleted {
if row.userID != userID || row.messageSenderID != userID || row.randomID == 0 || row.privateMessageID == 0 {
continue
}
key := privateSendDedupKey{senderUserID: userID, randomID: row.randomID}
record, ok := s.privateSendDedup[key]
if !ok {
continue
}
cloned := cloneUpdateEvent(event)
record.senderDeleteEvent = &cloned
s.privateSendDedup[key] = record
}
res.Deleted = append(res.Deleted, domain.DeletedMessagesForUser{
UserID: userID,
MessageIDs: ids,

View file

@ -21,7 +21,14 @@ func (s *MessageStore) deleteMemoryMessagesLocked(userID int64, limit int, match
more = true
continue
}
deleted = append(deleted, deletedMemoryMessage{userID: userID, peer: msg.Peer, id: msg.ID})
deleted = append(deleted, deletedMemoryMessage{
userID: userID,
peer: msg.Peer,
id: msg.ID,
privateMessageID: msg.UID,
messageSenderID: msg.From.ID,
randomID: msg.RandomID,
})
if msg.UID != 0 {
revokeUIDs[msg.UID] = struct{}{}
}
@ -45,7 +52,14 @@ func (s *MessageStore) deleteMemoryMessagesByUIDLocked(uids map[int64]struct{},
kept := messages[:0]
for _, msg := range messages {
if _, ok := uids[msg.UID]; ok {
deleted = append(deleted, deletedMemoryMessage{userID: userID, peer: msg.Peer, id: msg.ID})
deleted = append(deleted, deletedMemoryMessage{
userID: userID,
peer: msg.Peer,
id: msg.ID,
privateMessageID: msg.UID,
messageSenderID: msg.From.ID,
randomID: msg.RandomID,
})
continue
}
kept = append(kept, msg)

View file

@ -0,0 +1,164 @@
package memory
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
)
func TestMessageStorePrivateRandomIDConflictAndReplayFacts(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
base := domain.SendPrivateTextRequest{
SenderUserID: 1001, RecipientUserID: 1002, RandomID: 501,
Message: "immutable", Date: 1700000000,
}
first, err := messages.SendPrivateText(ctx, base)
if err != nil {
t.Fatalf("first send: %v", err)
}
replay := base
replay.Date++
replay.OriginSessionID = 77
replay.RecipientBlocked = true
duplicate, err := messages.SendPrivateText(ctx, replay)
if err != nil {
t.Fatalf("exact replay: %v", err)
}
if !duplicate.Duplicate || duplicate.SenderMessage.ID != first.SenderMessage.ID || duplicate.RecipientMessage.ID != first.RecipientMessage.ID {
t.Fatalf("exact replay = %+v, want original delivered boxes", duplicate)
}
tests := []struct {
name string
mutate func(*domain.SendPrivateTextRequest)
}{
{name: "peer", mutate: func(req *domain.SendPrivateTextRequest) { req.RecipientUserID = 1003 }},
{name: "body", mutate: func(req *domain.SendPrivateTextRequest) { req.Message = "changed" }},
{name: "media", mutate: func(req *domain.SendPrivateTextRequest) {
req.Media = &domain.MessageMedia{
Kind: domain.MessageMediaKindContact,
Contact: &domain.MessageContact{
PhoneNumber: "+10000000000",
FirstName: "Changed",
},
}
}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := base
tc.mutate(&req)
if _, err := messages.SendPrivateText(ctx, req); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("conflicting replay err = %v, want ErrMessageRandomIDDuplicate", err)
}
})
}
}
func TestMessageStorePrivateRandomIDSelfAndBlockedReplay(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
selfReq := domain.SendPrivateTextRequest{
SenderUserID: 2001, RecipientUserID: 2001, RandomID: 601,
Message: "saved note", Date: 1700000100,
}
self, err := messages.SendPrivateText(ctx, selfReq)
if err != nil {
t.Fatalf("self first: %v", err)
}
selfReq.Date++
selfReplay, err := messages.SendPrivateText(ctx, selfReq)
if err != nil {
t.Fatalf("self replay: %v", err)
}
if !selfReplay.Duplicate || selfReplay.SenderMessage.ID != self.SenderMessage.ID || selfReplay.RecipientMessage.ID != self.SenderMessage.ID {
t.Fatalf("self replay = %+v, want original single box", selfReplay)
}
blockedReq := domain.SendPrivateTextRequest{
SenderUserID: 2002, RecipientUserID: 2003, RandomID: 602,
Message: "blocked", Date: 1700000110, RecipientBlocked: true,
}
blocked, err := messages.SendPrivateText(ctx, blockedReq)
if err != nil {
t.Fatalf("blocked first: %v", err)
}
if blocked.RecipientMessage.ID != 0 {
t.Fatalf("blocked recipient = %+v, want empty", blocked.RecipientMessage)
}
blockedReq.Date++
blockedReq.RecipientBlocked = false
blockedReplay, err := messages.SendPrivateText(ctx, blockedReq)
if err != nil {
t.Fatalf("blocked replay: %v", err)
}
if !blockedReplay.Duplicate || blockedReplay.SenderMessage.ID != blocked.SenderMessage.ID || blockedReplay.RecipientMessage.ID != 0 {
t.Fatalf("blocked replay = %+v, want original sender-only result", blockedReplay)
}
}
func TestMessageStorePrivateRandomIDReplayUsesCurrentSnapshotAndDurableDeleteMemory(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
req := domain.SendPrivateTextRequest{
SenderUserID: 3001, RecipientUserID: 3002, RandomID: 701,
Message: "original", Date: 1700000200,
}
first, err := messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("first send: %v", err)
}
edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{
OwnerUserID: req.SenderUserID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID},
ID: first.SenderMessage.ID,
Message: "edited projection",
EditDate: 1700000201,
})
if err != nil {
t.Fatalf("edit message: %v", err)
}
senderPtsBeforeReplay := messages.nextPts[req.SenderUserID]
recipientPtsBeforeReplay := messages.nextPts[req.RecipientUserID]
replay, err := messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("replay after edit: %v", err)
}
if replay.SenderMessage.ID != first.SenderMessage.ID || replay.SenderMessage.Pts != edited.Self().Message.Pts || replay.SenderMessage.Body != "edited projection" {
t.Fatalf("replay after edit = %+v, want current visible snapshot", replay.SenderMessage)
}
if replay.SenderEvent.Pts != first.SenderEvent.Pts || replay.ReplayDeleteEvent != nil {
t.Fatalf("replay after edit event = %+v delete=%+v, want original send pts and no delete", replay.SenderEvent, replay.ReplayDeleteEvent)
}
if messages.nextPts[req.SenderUserID] != senderPtsBeforeReplay || messages.nextPts[req.RecipientUserID] != recipientPtsBeforeReplay {
t.Fatalf("edit replay advanced pts sender/recipient = %d/%d, want %d/%d", messages.nextPts[req.SenderUserID], messages.nextPts[req.RecipientUserID], senderPtsBeforeReplay, recipientPtsBeforeReplay)
}
deleted, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{
OwnerUserID: req.SenderUserID,
IDs: []int{first.SenderMessage.ID},
Revoke: true,
Date: 1700000202,
})
if err != nil {
t.Fatalf("delete message: %v", err)
}
senderPtsBeforeReplay = messages.nextPts[req.SenderUserID]
recipientPtsBeforeReplay = messages.nextPts[req.RecipientUserID]
replay, err = messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("replay after delete: %v", err)
}
if replay.SenderMessage.ID != first.SenderMessage.ID || replay.SenderMessage.Pts != first.SenderMessage.Pts || replay.SenderMessage.Body != "original" {
t.Fatalf("replay after delete = %+v, want immutable first snapshot", replay.SenderMessage)
}
if replay.ReplayDeleteEvent == nil || replay.ReplayDeleteEvent.Pts != deleted.Self().Event.Pts ||
len(replay.ReplayDeleteEvent.MessageIDs) != 1 || replay.ReplayDeleteEvent.MessageIDs[0] != first.SenderMessage.ID {
t.Fatalf("replay delete event = %+v, want durable delete %+v", replay.ReplayDeleteEvent, deleted.Self().Event)
}
if messages.nextPts[req.SenderUserID] != senderPtsBeforeReplay || messages.nextPts[req.RecipientUserID] != recipientPtsBeforeReplay {
t.Fatalf("delete replay advanced pts sender/recipient = %d/%d, want %d/%d", messages.nextPts[req.SenderUserID], messages.nextPts[req.RecipientUserID], senderPtsBeforeReplay, recipientPtsBeforeReplay)
}
}

View file

@ -2,10 +2,25 @@ package memory
import (
"context"
"fmt"
"telesrv/internal/domain"
"telesrv/internal/store"
"time"
)
type privateSendDedupKey struct {
senderUserID int64
randomID int64
}
type privateSendDedupRecord struct {
recipientUserID int64
senderSnapshot []byte
recipientMessage domain.Message
fingerprint []byte
senderDeleteEvent *domain.UpdateEvent
}
func (s *MessageStore) Create(_ context.Context, msg domain.Message) (domain.Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
@ -30,29 +45,19 @@ func (s *MessageStore) Create(_ context.Context, msg domain.Message) (domain.Mes
}
func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) {
fingerprint, err := store.PrivateSendFingerprint(req)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
for _, msg := range s.m[req.SenderUserID] {
if msg.RandomID != 0 && msg.RandomID == req.RandomID {
recipient := domain.Message{}
if req.SenderUserID != req.RecipientUserID {
for _, peerMsg := range s.m[req.RecipientUserID] {
if peerMsg.UID == msg.UID {
recipient = peerMsg
break
}
}
} else {
recipient = msg
}
return domain.SendPrivateTextResult{
SenderMessage: cloneMessage(msg),
RecipientMessage: cloneMessage(recipient),
SenderEvent: newMessageEvent(msg),
RecipientEvent: newMessageEvent(recipient),
Duplicate: true,
}, nil
}
if replay, found, err := s.lookupPrivateSendReplayLocked(domain.PrivateSendReplayRequest{
SenderUserID: req.SenderUserID,
RecipientUserID: req.RecipientUserID,
RandomID: req.RandomID,
IdempotencyFingerprint: fingerprint,
}); err != nil || found {
return replay, err
}
if req.Date == 0 {
req.Date = int(time.Now().Unix())
@ -109,10 +114,25 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
recipient.Pts = s.nextPtsLocked(req.RecipientUserID)
recipient.MediaUnread = req.Media.HasUnreadPayload()
}
var senderSnapshot []byte
if req.RandomID != 0 {
senderSnapshot, err = store.EncodePrivateSendSnapshot(sender)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
}
s.m[req.SenderUserID] = append(s.m[req.SenderUserID], sender)
if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked {
s.m[req.RecipientUserID] = append(s.m[req.RecipientUserID], recipient)
}
if req.RandomID != 0 {
s.privateSendDedup[privateSendDedupKey{senderUserID: req.SenderUserID, randomID: req.RandomID}] = privateSendDedupRecord{
recipientUserID: req.RecipientUserID,
senderSnapshot: senderSnapshot,
recipientMessage: immutablePrivateSendReceipt(recipient),
fingerprint: append([]byte(nil), fingerprint...),
}
}
if s.dialogs != nil {
if recipient.ID != 0 {
s.upsertMemoryDialogsLocked(sender, recipient)
@ -128,6 +148,81 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
}, nil
}
// LookupPrivateSendReplay returns an existing immutable/current replay receipt without running
// any send permission, reply resolution or allocation path.
func (s *MessageStore) LookupPrivateSendReplay(_ context.Context, req domain.PrivateSendReplayRequest) (domain.SendPrivateTextResult, bool, error) {
if req.SenderUserID == 0 || req.RecipientUserID == 0 || req.RandomID == 0 {
return domain.SendPrivateTextResult{}, false, fmt.Errorf("memory private send replay: invalid scope")
}
if err := store.ValidateSendFingerprint(req.IdempotencyFingerprint, "private send replay"); err != nil {
return domain.SendPrivateTextResult{}, false, err
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.lookupPrivateSendReplayLocked(req)
}
func (s *MessageStore) lookupPrivateSendReplayLocked(req domain.PrivateSendReplayRequest) (domain.SendPrivateTextResult, bool, error) {
record, ok := s.privateSendDedup[privateSendDedupKey{senderUserID: req.SenderUserID, randomID: req.RandomID}]
if !ok {
return domain.SendPrivateTextResult{}, false, nil
}
if record.recipientUserID != req.RecipientUserID || !store.SameSendFingerprint(record.fingerprint, req.IdempotencyFingerprint) {
return domain.SendPrivateTextResult{}, false, domain.ErrMessageRandomIDDuplicate
}
firstSender, err := store.DecodePrivateSendSnapshot(record.senderSnapshot)
if err != nil {
return domain.SendPrivateTextResult{}, false, fmt.Errorf("memory duplicate private message snapshot: %w", err)
}
sender := firstSender
visible := false
for _, current := range s.m[req.SenderUserID] {
if current.UID == firstSender.UID && current.ID == firstSender.ID {
sender = cloneMessage(current)
sender.RandomID = firstSender.RandomID
visible = true
break
}
}
if !visible && record.senderDeleteEvent == nil {
return domain.SendPrivateTextResult{}, false, fmt.Errorf("memory duplicate private message %d is absent without a durable sender delete receipt", firstSender.UID)
}
recipient := cloneMessage(record.recipientMessage)
var replayDelete *domain.UpdateEvent
if record.senderDeleteEvent != nil {
cloned := cloneUpdateEvent(*record.senderDeleteEvent)
replayDelete = &cloned
}
return domain.SendPrivateTextResult{
SenderMessage: sender,
RecipientMessage: recipient,
SenderEvent: newMessageEvent(firstSender),
RecipientEvent: newMessageEvent(recipient),
Duplicate: true,
ReplayDeleteEvent: replayDelete,
}, true, nil
}
// immutablePrivateSendReceipt keeps the recipient allocation facts used by
// store-level idempotency tests. The sender response snapshot is stored as a
// versioned JSON value above so all nested media/reply graphs are immutable.
func immutablePrivateSendReceipt(msg domain.Message) domain.Message {
if msg.ID == 0 {
return domain.Message{}
}
return domain.Message{
ID: msg.ID,
UID: msg.UID,
RandomID: msg.RandomID,
OwnerUserID: msg.OwnerUserID,
Peer: msg.Peer,
From: msg.From,
Date: msg.Date,
Out: msg.Out,
Pts: msg.Pts,
}
}
func (s *MessageStore) resolveMemoryReplyLocked(req domain.SendPrivateTextRequest) (*domain.MessageReply, *domain.MessageReply, error) {
if req.ReplyTo == nil {
return nil, nil, nil

View file

@ -7,14 +7,17 @@ import (
// MessageStore 是 store.MessageStore 的内存实现。
type MessageStore struct {
mu sync.RWMutex
m map[int64][]domain.Message
nextUID int64
nextBox map[int64]int
nextPts map[int64]int
readOutboxDates map[readOutboxDateKey]int
privateReactions map[int64]map[int64][]domain.ChannelMessagePeerReaction
dialogs *DialogStore
mu sync.RWMutex
m map[int64][]domain.Message
nextUID int64
nextBox map[int64]int
nextPts map[int64]int
readOutboxDates map[readOutboxDateKey]int
privateReactions map[int64]map[int64][]domain.ChannelMessagePeerReaction
privateSendDedup map[privateSendDedupKey]privateSendDedupRecord
loginCodeDeliveries map[[32]byte]loginCodeDeliveryRecord
albumGroups map[albumGroupKey]albumGroupRecord
dialogs *DialogStore
// polls 是共享 poll 权威(投票校验与读路径 enrichment);nil 时 poll 链路按未接入处理。
polls *PollStore
// savedPins 是收藏夹子会话置顶顺序(下标即 pinned_order,越小越前)。
@ -35,13 +38,16 @@ type readOutboxDateKey struct {
// NewMessageStore 创建内存 MessageStore。
func NewMessageStore(dialogs ...*DialogStore) *MessageStore {
s := &MessageStore{
m: make(map[int64][]domain.Message),
nextUID: 1,
nextBox: make(map[int64]int),
nextPts: make(map[int64]int),
readOutboxDates: make(map[readOutboxDateKey]int),
privateReactions: make(map[int64]map[int64][]domain.ChannelMessagePeerReaction),
savedPins: make(map[int64][]domain.Peer),
m: make(map[int64][]domain.Message),
nextUID: 1,
nextBox: make(map[int64]int),
nextPts: make(map[int64]int),
readOutboxDates: make(map[readOutboxDateKey]int),
privateReactions: make(map[int64]map[int64][]domain.ChannelMessagePeerReaction),
privateSendDedup: make(map[privateSendDedupKey]privateSendDedupRecord),
loginCodeDeliveries: make(map[[32]byte]loginCodeDeliveryRecord),
albumGroups: make(map[albumGroupKey]albumGroupRecord),
savedPins: make(map[int64][]domain.Peer),
}
if len(dialogs) > 0 {
s.dialogs = dialogs[0]

View file

@ -162,7 +162,7 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
assertWebViewData("sender", got.SenderMessage)
assertWebViewData("recipient", got.RecipientMessage)
dup, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
_, err = messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: req.SenderUserID,
RecipientUserID: req.RecipientUserID,
RandomID: req.RandomID,
@ -178,13 +178,9 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
},
},
})
if err != nil {
t.Fatalf("SendPrivateText duplicate: %v", err)
if !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("changed-media duplicate err = %v, want ErrMessageRandomIDDuplicate", err)
}
if !dup.Duplicate || dup.SenderMessage.ID != got.SenderMessage.ID || dup.RecipientMessage.ID != got.RecipientMessage.ID {
t.Fatalf("duplicate = %+v, want original boxes", dup)
}
assertWebViewData("duplicate sender", dup.SenderMessage)
recipientHistory, err := messages.ListByUser(ctx, req.RecipientUserID, domain.MessageFilter{
HasPeer: true,

View file

@ -0,0 +1,240 @@
package memory
import (
"context"
"sync"
"testing"
"time"
"telesrv/internal/store"
)
func TestCodeStoreAtomicScopedVerification(t *testing.T) {
ctx := context.Background()
newRecord := func() store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016021",
Code: "12345",
Channel: store.PhoneCodeChannelPhone,
Purpose: store.PhoneCodePurposeChangePhone,
UserID: 420021,
AuthKeyID: [8]byte{1, 2, 3, 4},
MaxAttempts: 2,
}
}
t.Run("only the active hash and exact scope can mutate", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord()
if err := codes.Set(ctx, "scoped-old", record, time.Minute); err != nil {
t.Fatal(err)
}
if err := codes.Set(ctx, "scoped-current", record, time.Minute); err != nil {
t.Fatal(err)
}
if result, err := codes.VerifyScoped(ctx, "scoped-old", record.Scope(), record.Code, 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("old-hash verify=%+v err=%v", result, err)
}
otherScope := record.Scope()
otherScope.AuthKeyID = [8]byte{9}
if result, err := codes.VerifyScoped(ctx, "scoped-current", otherScope, "00000", 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("cross-scope verify=%+v err=%v", result, err)
}
stored, found, err := codes.Get(ctx, "scoped-current")
if err != nil || !found || stored.Attempts != 0 {
t.Fatalf("victim after cross-scope verify=%+v found=%v err=%v", stored, found, err)
}
})
t.Run("wrong attempts preserve ttl then delete code and index", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord()
if err := codes.Set(ctx, "scoped-wrong", record, time.Minute); err != nil {
t.Fatal(err)
}
before := codes.m["scoped-wrong"]
first, err := codes.VerifyScoped(ctx, "scoped-wrong", record.Scope(), "00000", 9)
if err != nil || first.Status != store.LoginCodeVerifyInvalid || first.Record.Attempts != 1 {
t.Fatalf("first wrong=%+v err=%v", first, err)
}
after := codes.m["scoped-wrong"]
if !after.expires.Equal(before.expires) || after.code.Revision == before.code.Revision {
t.Fatalf("wrong attempt expiry/revision before=%+v after=%+v", before, after)
}
second, err := codes.VerifyScoped(ctx, "scoped-wrong", record.Scope(), "00000", 9)
if err != nil || second.Status != store.LoginCodeVerifyInvalid || second.Record.Attempts != 2 {
t.Fatalf("threshold wrong=%+v err=%v", second, err)
}
if _, found, _ := codes.Get(ctx, "scoped-wrong"); found {
t.Fatal("threshold-exhausted scoped code remains")
}
if got := codes.scopes[record.Scope()]; got != "" {
t.Fatalf("threshold-exhausted scope index=%q, want missing", got)
}
if result, err := codes.VerifyScoped(ctx, "scoped-wrong", record.Scope(), record.Code, 9); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("verify after exhaustion=%+v err=%v", result, err)
}
})
t.Run("correct code consumes both keys exactly once", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord()
if err := codes.Set(ctx, "scoped-correct", record, time.Minute); err != nil {
t.Fatal(err)
}
expected, found, err := codes.Get(ctx, "scoped-correct")
if err != nil || !found {
t.Fatalf("get expected found=%v err=%v", found, err)
}
accepted, err := codes.VerifyScoped(ctx, "scoped-correct", record.Scope(), record.Code, 5)
if err != nil || accepted.Status != store.LoginCodeVerifyAccepted || accepted.Record != expected {
t.Fatalf("accepted=%+v err=%v, want %+v", accepted, err, expected)
}
if _, found, _ := codes.Get(ctx, "scoped-correct"); found || codes.scopes[record.Scope()] != "" {
t.Fatal("accepted scoped code or index remains")
}
if repeated, err := codes.VerifyScoped(ctx, "scoped-correct", record.Scope(), record.Code, 5); err != nil || repeated.Status != store.LoginCodeVerifyMissing {
t.Fatalf("repeated verify=%+v err=%v", repeated, err)
}
})
t.Run("legacy and inconsistent records fail closed", func(t *testing.T) {
codes := NewCodeStore()
legacy := newRecord()
legacy.Version = 0
if err := codes.Set(ctx, "scoped-legacy", legacy, time.Minute); err != nil {
t.Fatal(err)
}
if result, err := codes.VerifyScoped(ctx, "scoped-legacy", legacy.Scope(), legacy.Code, 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("legacy verify=%+v err=%v", result, err)
}
if _, found, _ := codes.Get(ctx, "scoped-legacy"); found || codes.scopes[legacy.Scope()] != "" {
t.Fatal("legacy code or index remains")
}
inconsistent := newRecord()
if err := codes.Set(ctx, "scoped-inconsistent", inconsistent, time.Minute); err != nil {
t.Fatal(err)
}
entry := codes.m["scoped-inconsistent"]
entry.code.Phone = "15550016999"
codes.m["scoped-inconsistent"] = entry
if result, err := codes.VerifyScoped(ctx, "scoped-inconsistent", inconsistent.Scope(), inconsistent.Code, 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("inconsistent verify=%+v err=%v", result, err)
}
if _, found, _ := codes.Get(ctx, "scoped-inconsistent"); found || codes.scopes[inconsistent.Scope()] != "" {
t.Fatal("inconsistent code or selecting index remains")
}
})
}
func TestCodeStoreAtomicScopedConcurrency(t *testing.T) {
ctx := context.Background()
const workers = 64
newRecord := func(maxAttempts int) store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016022",
Code: "12345",
Channel: store.PhoneCodeChannelPhone,
Purpose: store.PhoneCodePurposeChangePhone,
UserID: 420022,
AuthKeyID: [8]byte{5, 6, 7, 8},
MaxAttempts: maxAttempts,
}
}
t.Run("correct verification has one winner", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord(7)
if err := codes.Set(ctx, "scoped-verify-race", record, time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentMemoryScopedVerify(t, codes, "scoped-verify-race", record.Scope(), record.Code, workers)
if statuses[store.LoginCodeVerifyAccepted] != 1 || statuses[store.LoginCodeVerifyMissing] != workers-1 || statuses[store.LoginCodeVerifyInvalid] != 0 {
t.Fatalf("correct race statuses=%+v", statuses)
}
})
t.Run("wrong attempts cannot be lost", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord(7)
if err := codes.Set(ctx, "scoped-wrong-race", record, time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentMemoryScopedVerify(t, codes, "scoped-wrong-race", record.Scope(), "00000", workers)
if statuses[store.LoginCodeVerifyInvalid] != 7 || statuses[store.LoginCodeVerifyMissing] != workers-7 {
t.Fatalf("wrong race statuses=%+v", statuses)
}
if _, found, _ := codes.Get(ctx, "scoped-wrong-race"); found || codes.scopes[record.Scope()] != "" {
t.Fatal("wrong race left code or scope index")
}
})
t.Run("verification and cancellation share one winner", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord(7)
if err := codes.Set(ctx, "scoped-mixed-race", record, time.Minute); err != nil {
t.Fatal(err)
}
results := make(chan bool, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(cancel bool) {
defer wg.Done()
if cancel {
_, found, err := codes.ConsumeScoped(ctx, "scoped-mixed-race", record.Scope())
if err != nil {
t.Errorf("ConsumeScoped: %v", err)
}
results <- found
return
}
result, err := codes.VerifyScoped(ctx, "scoped-mixed-race", record.Scope(), record.Code, 5)
if err != nil {
t.Errorf("VerifyScoped: %v", err)
}
results <- result.Status == store.LoginCodeVerifyAccepted
}(i%2 == 0)
}
wg.Wait()
close(results)
winners := 0
for won := range results {
if won {
winners++
}
}
if winners != 1 {
t.Fatalf("verify/cancel winners=%d, want 1", winners)
}
})
}
func concurrentMemoryScopedVerify(t *testing.T, codes *CodeStore, hash string, scope store.PhoneCodeScope, code string, workers int) map[store.LoginCodeVerifyStatus]int {
t.Helper()
results := make(chan store.LoginCodeVerifyStatus, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
result, err := codes.VerifyScoped(context.Background(), hash, scope, code, 5)
if err != nil {
t.Errorf("VerifyScoped: %v", err)
return
}
results <- result.Status
}()
}
wg.Wait()
close(results)
statuses := make(map[store.LoginCodeVerifyStatus]int)
for status := range results {
statuses[status]++
}
return statuses
}

View file

@ -8,8 +8,9 @@ import (
// UpdateStateStore 是 store.UpdateStateStore 的内存实现。
type UpdateStateStore struct {
mu sync.RWMutex
states map[updateStateKey]domain.UpdateState
mu sync.RWMutex
states map[updateStateKey]domain.UpdateState
observed map[updateStateKey]domain.UpdateState
}
// UpdateEventStore 是 store.UpdateEventStore 的内存实现。
@ -157,7 +158,10 @@ func (s *UpdateEventStore) MaxContiguousPts(_ context.Context, userID int64) (in
// NewUpdateStateStore 创建内存 UpdateStateStore。
func NewUpdateStateStore() *UpdateStateStore {
return &UpdateStateStore{states: make(map[updateStateKey]domain.UpdateState)}
return &UpdateStateStore{
states: make(map[updateStateKey]domain.UpdateState),
observed: make(map[updateStateKey]domain.UpdateState),
}
}
func (s *UpdateStateStore) Get(_ context.Context, id [8]byte, userID int64) (domain.UpdateState, bool, error) {
@ -191,9 +195,39 @@ func (s *UpdateStateStore) Save(_ context.Context, id [8]byte, userID int64, st
return nil
}
func (s *UpdateStateStore) ObserveClientState(_ context.Context, id [8]byte, userID int64, st domain.UpdateState) error {
s.mu.Lock()
key := updateStateKey{authKeyID: id, userID: userID}
prev := s.observed[key]
if st.Pts < prev.Pts {
st.Pts = prev.Pts
}
if st.Qts < prev.Qts {
st.Qts = prev.Qts
}
if st.Date < prev.Date {
st.Date = prev.Date
}
if st.Seq < prev.Seq {
st.Seq = prev.Seq
}
s.observed[key] = st
s.mu.Unlock()
return nil
}
// ObservedClientState 暴露给同包/服务测试验证 retention 安全水位;业务读路径仍用 Get。
func (s *UpdateStateStore) ObservedClientState(id [8]byte, userID int64) (domain.UpdateState, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
st, ok := s.observed[updateStateKey{authKeyID: id, userID: userID}]
return st, ok
}
func (s *UpdateStateStore) Delete(_ context.Context, id [8]byte, userID int64) error {
s.mu.Lock()
delete(s.states, updateStateKey{authKeyID: id, userID: userID})
delete(s.observed, updateStateKey{authKeyID: id, userID: userID})
s.mu.Unlock()
return nil
}
@ -203,6 +237,7 @@ func (s *UpdateStateStore) DeleteAuthKey(_ context.Context, id [8]byte) error {
for k := range s.states {
if k.authKeyID == id {
delete(s.states, k)
delete(s.observed, k)
}
}
s.mu.Unlock()

View file

@ -0,0 +1,162 @@
package postgres
import (
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"sort"
"telesrv/internal/domain"
)
// ReserveAlbumGroup 先按稳定顺序获取整批 key 的事务级 advisory locks,再读取旧绑定
// 并一次性补齐缺失项。锁覆盖不存在的行,因此避免单靠 UNIQUE/ON CONFLICT 时两个实例
// 对重叠批次分别选出不同 grouped_id 的 write-skew。
func (s *MessageStore) ReserveAlbumGroup(ctx context.Context, req domain.AlbumGroupReservationRequest) (int64, error) {
if err := req.Validate(); err != nil {
return 0, err
}
beginner, ok := s.db.(txBeginner)
if !ok {
return 0, errors.New("reserve album group requires transaction-capable postgres handle")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("reserve album group begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
lockIDs := albumGroupAdvisoryLockIDs(req)
for _, lockID := range lockIDs {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, lockID); err != nil {
return 0, fmt.Errorf("reserve album group lock: %w", err)
}
}
randomIDs := make([]int64, 0, len(req.Items))
requestedIntents := make(map[int64][]byte, len(req.Items))
for _, item := range req.Items {
randomIDs = append(randomIDs, item.RandomID)
requestedIntents[item.RandomID] = item.IntentHash
}
rows, err := tx.Query(ctx, `
SELECT random_id, grouped_id, intent_hash
FROM album_group_reservations
WHERE sender_user_id = $1
AND peer_type = $2
AND peer_id = $3
AND random_id = ANY($4::bigint[])
ORDER BY random_id`, req.SenderUserID, string(req.Peer.Type), req.Peer.ID, randomIDs)
if err != nil {
return 0, fmt.Errorf("reserve album group read existing: %w", err)
}
existingGroups := make(map[int64]struct{}, 2)
for rows.Next() {
var randomID int64
var groupedID int64
var intentHash []byte
if err := rows.Scan(&randomID, &groupedID, &intentHash); err != nil {
rows.Close()
return 0, fmt.Errorf("reserve album group scan existing: %w", err)
}
if !bytes.Equal(intentHash, requestedIntents[randomID]) {
rows.Close()
return 0, fmt.Errorf("%w: album random_id %d intent changed", domain.ErrMessageRandomIDDuplicate, randomID)
}
existingGroups[groupedID] = struct{}{}
}
readErr := rows.Err()
rows.Close()
if readErr != nil {
return 0, fmt.Errorf("reserve album group iterate existing: %w", readErr)
}
if len(existingGroups) > 1 {
return 0, fmt.Errorf("%w: album request spans multiple grouped_id values", domain.ErrMessageRandomIDDuplicate)
}
groupedID := req.ProposedGroupedID
for existingGroup := range existingGroups {
groupedID = existingGroup
}
for _, item := range req.Items {
if _, err := tx.Exec(ctx, `
INSERT INTO album_group_reservations (
sender_user_id, peer_type, peer_id, random_id, intent_hash, grouped_id
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (sender_user_id, peer_type, peer_id, random_id) DO NOTHING`,
req.SenderUserID, string(req.Peer.Type), req.Peer.ID, item.RandomID, item.IntentHash, groupedID); err != nil {
return 0, fmt.Errorf("reserve album group insert binding: %w", err)
}
}
// 防御性复核:advisory lock 协议若被未来代码绕过,也不能把拆组状态作为成功返回。
rows, err = tx.Query(ctx, `
SELECT random_id, grouped_id, intent_hash
FROM album_group_reservations
WHERE sender_user_id = $1
AND peer_type = $2
AND peer_id = $3
AND random_id = ANY($4::bigint[])`,
req.SenderUserID, string(req.Peer.Type), req.Peer.ID, randomIDs)
if err != nil {
return 0, fmt.Errorf("reserve album group verify: %w", err)
}
verified := 0
for rows.Next() {
var randomID, storedGroup int64
var intentHash []byte
if err := rows.Scan(&randomID, &storedGroup, &intentHash); err != nil {
rows.Close()
return 0, fmt.Errorf("reserve album group verify scan: %w", err)
}
if storedGroup != groupedID || !bytes.Equal(intentHash, requestedIntents[randomID]) {
rows.Close()
return 0, fmt.Errorf("%w: album reservation diverged for random_id %d", domain.ErrMessageRandomIDDuplicate, randomID)
}
verified++
}
verifyErr := rows.Err()
rows.Close()
if verifyErr != nil {
return 0, fmt.Errorf("reserve album group verify iterate: %w", verifyErr)
}
if verified != len(req.Items) {
return 0, fmt.Errorf("%w: album reservation count=%d/%d", domain.ErrMessageRandomIDDuplicate, verified, len(req.Items))
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("reserve album group commit: %w", err)
}
return groupedID, nil
}
// albumGroupAdvisoryLockIDs 对每个业务 key 派生一个 64-bit advisory lock,并按数值
// 排序、去重。hash 碰撞最多造成无害串行化;排序保证重叠批次不会互相反序死锁。
func albumGroupAdvisoryLockIDs(req domain.AlbumGroupReservationRequest) []int64 {
ids := make([]int64, 0, len(req.Items))
for _, item := range req.Items {
h := sha256.New()
_, _ = h.Write([]byte("telesrv:album-group:v1\x00"))
var word [8]byte
binary.BigEndian.PutUint64(word[:], uint64(req.SenderUserID))
_, _ = h.Write(word[:])
_, _ = h.Write([]byte(req.Peer.Type))
binary.BigEndian.PutUint64(word[:], uint64(req.Peer.ID))
_, _ = h.Write(word[:])
binary.BigEndian.PutUint64(word[:], uint64(item.RandomID))
_, _ = h.Write(word[:])
sum := h.Sum(nil)
ids = append(ids, int64(binary.BigEndian.Uint64(sum[:8])))
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
out := ids[:0]
for _, id := range ids {
if len(out) == 0 || out[len(out)-1] != id {
out = append(out, id)
}
}
return out
}

View file

@ -0,0 +1,118 @@
package postgres
import (
"context"
"crypto/sha256"
"errors"
"sync"
"testing"
"telesrv/internal/domain"
)
func pgAlbumItem(randomID int64, label string) domain.AlbumGroupReservationItem {
sum := sha256.Sum256([]byte(label))
return domain.AlbumGroupReservationItem{RandomID: randomID, IntentHash: sum[:]}
}
func pgAlbumReq(sender int64, peer domain.Peer, group int64, items ...domain.AlbumGroupReservationItem) domain.AlbumGroupReservationRequest {
return domain.AlbumGroupReservationRequest{
SenderUserID: sender,
Peer: peer,
Items: items,
ProposedGroupedID: group,
}
}
func TestAlbumGroupReservationConvergesAcrossPostgresInstances(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender, err := users.Create(ctx, domain.User{AccessHash: 7601, Phone: "+1760" + suffix + "01", FirstName: "AlbumSender"})
if err != nil {
t.Fatalf("create sender: %v", err)
}
recipient, err := users.Create(ctx, domain.User{AccessHash: 7602, Phone: "+1760" + suffix + "02", FirstName: "AlbumRecipient"})
if err != nil {
t.Fatalf("create recipient: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM album_group_reservations WHERE sender_user_id = $1", sender.ID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
})
privatePeer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}
full := []domain.AlbumGroupReservationItem{
pgAlbumItem(76001, "one"),
pgAlbumItem(76002, "two"),
pgAlbumItem(76003, "three"),
}
firstStore := NewMessageStore(pool)
groupedID, err := firstStore.ReserveAlbumGroup(ctx, pgAlbumReq(sender.ID, privatePeer, 761, full...))
if err != nil || groupedID != 761 {
t.Fatalf("reserve full = %d err=%v, want 761", groupedID, err)
}
// 新 store 实例模拟另一进程;失败子集必须恢复首次整包的组。
replayed, err := NewMessageStore(pool).ReserveAlbumGroup(ctx, pgAlbumReq(sender.ID, privatePeer, 762, full[1:]...))
if err != nil || replayed != groupedID {
t.Fatalf("reserve subset = %d err=%v, want %d", replayed, err, groupedID)
}
if _, err := NewMessageStore(pool).ReserveAlbumGroup(ctx, pgAlbumReq(sender.ID, privatePeer, 763, pgAlbumItem(76002, "changed"))); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("changed intent err=%v, want ErrMessageRandomIDDuplicate", err)
}
// 同 random_id 在不同 peer 作用域独立,不会误并相册。
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: recipient.ID}
channelGroup, err := firstStore.ReserveAlbumGroup(ctx, pgAlbumReq(sender.ID, channelPeer, 764, full[0]))
if err != nil || channelGroup != 764 {
t.Fatalf("channel peer isolated group = %d err=%v, want 764", channelGroup, err)
}
// 两个实例同时预留部分重叠的批次,shared random_id 的 advisory lock 必须让
// 两边串行收敛;最终 4/5/6 三个 item 全部同组。
left := pgAlbumReq(sender.ID, privatePeer, 765, pgAlbumItem(76004, "four"), pgAlbumItem(76005, "shared"))
right := pgAlbumReq(sender.ID, privatePeer, 766, pgAlbumItem(76005, "shared"), pgAlbumItem(76006, "six"))
requests := []domain.AlbumGroupReservationRequest{left, right}
results := make([]int64, 2)
errs := make([]error, 2)
start := make(chan struct{})
var wg sync.WaitGroup
for i := range requests {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
results[i], errs[i] = NewMessageStore(pool).ReserveAlbumGroup(ctx, requests[i])
}(i)
}
close(start)
wg.Wait()
if errs[0] != nil || errs[1] != nil || results[0] == 0 || results[0] != results[1] {
t.Fatalf("concurrent groups=%v errs=%v, want one non-zero group", results, errs)
}
for _, item := range []domain.AlbumGroupReservationItem{left.Items[0], left.Items[1], right.Items[1]} {
got, err := NewMessageStore(pool).ReserveAlbumGroup(ctx, pgAlbumReq(sender.ID, privatePeer, 767, item))
if err != nil || got != results[0] {
t.Fatalf("verify random_id %d = %d err=%v, want %d", item.RandomID, got, err, results[0])
}
}
// 一个请求同时命中两个历史组必须整体失败,不能绑定其中的新 item。
oldA := pgAlbumItem(76007, "old-a")
oldB := pgAlbumItem(76008, "old-b")
newItem := pgAlbumItem(76009, "new")
if _, err := firstStore.ReserveAlbumGroup(ctx, pgAlbumReq(sender.ID, privatePeer, 768, oldA)); err != nil {
t.Fatal(err)
}
if _, err := firstStore.ReserveAlbumGroup(ctx, pgAlbumReq(sender.ID, privatePeer, 769, oldB)); err != nil {
t.Fatal(err)
}
if _, err := firstStore.ReserveAlbumGroup(ctx, pgAlbumReq(sender.ID, privatePeer, 770, oldA, oldB, newItem)); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("mixed old groups err=%v, want ErrMessageRandomIDDuplicate", err)
}
fresh, err := firstStore.ReserveAlbumGroup(ctx, pgAlbumReq(sender.ID, privatePeer, 771, newItem))
if err != nil || fresh != 771 {
t.Fatalf("post-conflict fresh item = %d err=%v, want 771", fresh, err)
}
}

View file

@ -34,6 +34,7 @@ func TestAuthSignUpWritesOfficialLoginMessagePostgres(t *testing.T) {
nil,
"12345",
appauth.WithLoginMessages(messages, dialogs),
appauth.WithLoginCodeDelivery(messages),
)
var authKeyID [8]byte
@ -85,7 +86,7 @@ func TestAuthSignUpWritesOfficialLoginMessagePostgres(t *testing.T) {
}
}
func TestAuthSignInOfficialLoginMessagePreservesReadWatermarkPostgres(t *testing.T) {
func TestAuthSendCodeOfficialLoginMessagePreservesReadWatermarkBeforeSignInPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
@ -105,6 +106,7 @@ func TestAuthSignInOfficialLoginMessagePreservesReadWatermarkPostgres(t *testing
nil,
"12345",
appauth.WithLoginMessages(messages, dialogs),
appauth.WithLoginCodeDelivery(messages),
)
var authKeyID [8]byte
@ -126,6 +128,9 @@ func TestAuthSignInOfficialLoginMessagePreservesReadWatermarkPostgres(t *testing
if err != nil {
t.Fatalf("SendCode signup: %v", err)
}
if _, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "12345"); err != nil || !needSignUp {
t.Fatalf("SignIn before signup needSignUp=%v err=%v, want true/nil", needSignUp, err)
}
u, first, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "PgLogin", "Read")
if err != nil {
t.Fatalf("SignUp: %v", err)
@ -180,19 +185,29 @@ FROM target`, u.ID, domain.OfficialSystemUserID).Scan(&top, &readMax, &unread, &
if err != nil {
t.Fatalf("SendCode signin second: %v", err)
}
_, second, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "12345")
secondID := first.ID + 1
assertOfficialDialog(secondID, first.ID, 1)
_, lateSecond, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "12345")
if err != nil || needSignUp {
t.Fatalf("SignIn second needSignUp=%v err=%v", needSignUp, err)
}
assertOfficialDialog(second.ID, first.ID, 1)
if lateSecond.ID != 0 {
t.Fatalf("SignIn second returned late login message %+v, want zero", lateSecond)
}
assertOfficialDialog(secondID, first.ID, 1)
hash, err = svc.SendCode(ctx, phone)
if err != nil {
t.Fatalf("SendCode signin third: %v", err)
}
_, third, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "12345")
thirdID := first.ID + 2
assertOfficialDialog(thirdID, first.ID, 2)
_, lateThird, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "12345")
if err != nil || needSignUp {
t.Fatalf("SignIn third needSignUp=%v err=%v", needSignUp, err)
}
assertOfficialDialog(third.ID, first.ID, 2)
if lateThird.ID != 0 {
t.Fatalf("SignIn third returned late login message %+v, want zero", lateThird)
}
assertOfficialDialog(thirdID, first.ID, 2)
}

View file

@ -5,6 +5,7 @@ import (
"encoding/binary"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
@ -25,20 +26,23 @@ func NewAuthKeyStore(db sqlcgen.DBTX) *AuthKeyStore {
}
// Save 实现 store.AuthKeyStore。auth_key_id 以小端解释为 int64 存入 BIGINT;
// created_at 交由 DB 默认值(now()),故传入的 CreatedAt 不落库。
// created_at/last_used_at 交由 DB 默认值(now()),故传入的 CreatedAt 不落库。
func (s *AuthKeyStore) Save(ctx context.Context, k store.AuthKeyData) error {
if _, err := s.db.Exec(ctx, `
INSERT INTO auth_keys (auth_key_id, body, server_salt)
VALUES ($1, $2, $3)
ON CONFLICT (auth_key_id) DO UPDATE
SET body = EXCLUDED.body, server_salt = EXCLUDED.server_salt
SET body = EXCLUDED.body, server_salt = EXCLUDED.server_salt, last_used_at = now()
`, authKeyIDToInt64(k.ID), k.Value[:], k.ServerSalt); err != nil {
return fmt.Errorf("upsert auth key: %w", err)
}
return nil
}
// Get 实现 store.AuthKeyStore。不存在时 found=false。
// Get 实现 store.AuthKeyStore。不存在时 found=false。读取与 last_used_at touch 是同一条
// UPDATE ... RETURNING:若 orphan GC 已锁定并删除该行,Get 等待后得到 no rows;若 Get 先
// 完成,GC 的 cutoff/final predicate 会看到新水位并跳过。这样连接不会在“读到旧 key、尚未
// 注册进 SessionManager”的窗口被后台清理。
func (s *AuthKeyStore) Get(ctx context.Context, id [8]byte) (store.AuthKeyData, bool, error) {
var (
body []byte
@ -52,10 +56,11 @@ func (s *AuthKeyStore) Get(ctx context.Context, id [8]byte) (store.AuthKeyData,
appVersion string
)
err := s.db.QueryRow(ctx, `
SELECT auth_key_id, body, server_salt, created_at,
layer, device_model, platform, system_version, api_id, app_version
FROM auth_keys
UPDATE auth_keys
SET last_used_at = now()
WHERE auth_key_id = $1
RETURNING auth_key_id, body, server_salt, created_at,
layer, device_model, platform, system_version, api_id, app_version
`, authKeyIDToInt64(id)).Scan(new(int64), &body, &serverSalt, &createdAt, &layer, &deviceModel, &platform, &systemVersion, &apiID, &appVersion)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@ -83,6 +88,52 @@ WHERE auth_key_id = $1
return data, true, nil
}
const activeAuthKeyHeartbeatBatch = 4096
// TouchActiveRawAuthKeys refreshes the durable activity lease for raw auth keys currently held by
// this server instance. Orphan collection is database-global while SessionManager is process-local;
// without this heartbeat, instance A can collect a long-lived unauthorised key that is active on
// instance B after its one-time Get touch ages past the retention cutoff.
//
// The caller runs this well inside the orphan-retention window and skips collection if a heartbeat
// fails. Batching keeps the ANY array and one UPDATE bounded at large connection counts.
func (s *AuthKeyStore) TouchActiveRawAuthKeys(ctx context.Context, ids [][8]byte) error {
if len(ids) == 0 {
return nil
}
seen := make(map[int64]struct{}, len(ids))
keyIDs := make([]int64, 0, len(ids))
for _, id := range ids {
keyID := authKeyIDToInt64(id)
if _, duplicate := seen[keyID]; duplicate {
continue
}
seen[keyID] = struct{}{}
keyIDs = append(keyIDs, keyID)
}
for start := 0; start < len(keyIDs); start += activeAuthKeyHeartbeatBatch {
end := start + activeAuthKeyHeartbeatBatch
if end > len(keyIDs) {
end = len(keyIDs)
}
batch := keyIDs[start:end]
tag, err := s.db.Exec(ctx, `
UPDATE auth_keys
SET last_used_at = now()
WHERE auth_key_id = ANY($1::bigint[])`, batch)
if err != nil {
return fmt.Errorf("touch active raw auth keys: %w", err)
}
if tag.RowsAffected() != int64(len(batch)) {
return fmt.Errorf(
"touch active raw auth keys: refreshed %d of %d keys",
tag.RowsAffected(), len(batch),
)
}
}
return nil
}
func (s *AuthKeyStore) UpdateClientInfo(ctx context.Context, id [8]byte, info store.AuthKeyClientInfo) error {
if _, err := s.db.Exec(ctx, `
UPDATE auth_keys
@ -108,23 +159,100 @@ WHERE auth_key_id = $1
// raw temp key 重连时仍能进入 RPC 层,只得到 AUTH_KEY_UNREGISTERED,而不是连接层 404。
func (s *AuthKeyStore) Delete(ctx context.Context, id [8]byte) error {
keyID := authKeyIDToInt64(id)
if _, err := s.db.Exec(ctx, `
WITH doomed_temp AS (
var touched int
if err := s.db.QueryRow(ctx, `
WITH doomed_temp AS MATERIALIZED (
SELECT temp_auth_key_id
FROM temp_auth_key_bindings
WHERE perm_auth_key_id = $1
), doomed_keys AS MATERIALIZED (
SELECT $1::bigint AS auth_key_id
UNION
SELECT temp_auth_key_id FROM doomed_temp
), deleted_update_states AS (
-- update_states intentionally has no auth_keys FK: remove device cursors in the
-- same statement/transaction as both the permanent and derived temp keys.
DELETE FROM update_states
WHERE auth_key_id IN (SELECT auth_key_id FROM doomed_keys)
RETURNING auth_key_id
), deleted_temp AS (
DELETE FROM auth_keys
WHERE auth_key_id IN (SELECT temp_auth_key_id FROM doomed_temp)
WHERE auth_key_id IN (SELECT auth_key_id FROM doomed_keys)
RETURNING auth_key_id
)
DELETE FROM auth_keys
WHERE auth_key_id = $1
`, keyID); err != nil {
SELECT
(SELECT count(*) FROM deleted_update_states)::int +
(SELECT count(*) FROM deleted_temp)::int`, keyID).Scan(&touched); err != nil {
return fmt.Errorf("delete auth key and temp bindings: %w", err)
}
return nil
}
// DeleteOrphaned 回收握手已落库、但从未形成 authorization/temp binding 且当前没有
// 活跃物理连接的旧 auth key。last_used_at 与 Get 的 UPDATE ... RETURNING 行锁配对,封住
// active-key 快照之后新连接开始使用旧 key 的竞态;所有引用条件仍在最终 DELETE 中复核。
// protected 必须是 SessionManager 的 raw key。
func (s *AuthKeyStore) DeleteOrphaned(ctx context.Context, olderThan time.Duration, limit int, protected [][8]byte) (int, error) {
if olderThan <= 0 || limit <= 0 {
return 0, nil
}
if limit > 100000 {
limit = 100000
}
protectedIDs := make([]int64, 0, len(protected))
for _, id := range protected {
protectedIDs = append(protectedIDs, authKeyIDToInt64(id))
}
var deleted int
err := s.db.QueryRow(ctx, `
WITH candidates AS MATERIALIZED (
SELECT k.auth_key_id
FROM auth_keys k
WHERE k.last_used_at < now() - make_interval(secs => $1::double precision)
AND NOT (k.auth_key_id = ANY($2::bigint[]))
AND NOT EXISTS (
SELECT 1 FROM authorizations a WHERE a.auth_key_id = k.auth_key_id
)
AND NOT EXISTS (
SELECT 1
FROM temp_auth_key_bindings b
WHERE b.temp_auth_key_id = k.auth_key_id OR b.perm_auth_key_id = k.auth_key_id
)
ORDER BY k.last_used_at ASC, k.auth_key_id ASC
LIMIT $3
FOR UPDATE OF k SKIP LOCKED
), deleted_update_states AS (
-- Historical authorization-only deletion could leave a cursor without an
-- auth_keys FK. GC owns that stale row once the raw key is proven orphaned.
DELETE FROM update_states s
USING candidates c
WHERE s.auth_key_id = c.auth_key_id
RETURNING s.auth_key_id
), deleted_keys AS (
DELETE FROM auth_keys k
USING candidates c
WHERE k.auth_key_id = c.auth_key_id
AND k.last_used_at < now() - make_interval(secs => $1::double precision)
AND NOT (k.auth_key_id = ANY($2::bigint[]))
AND NOT EXISTS (
SELECT 1 FROM authorizations a WHERE a.auth_key_id = k.auth_key_id
)
AND NOT EXISTS (
SELECT 1
FROM temp_auth_key_bindings b
WHERE b.temp_auth_key_id = k.auth_key_id OR b.perm_auth_key_id = k.auth_key_id
)
RETURNING k.auth_key_id
)
SELECT count(*)::int
FROM deleted_keys
CROSS JOIN LATERAL (SELECT count(*) FROM deleted_update_states) AS touched`, olderThan.Seconds(), protectedIDs, limit).Scan(&deleted)
if err != nil {
return 0, fmt.Errorf("delete orphaned auth keys: %w", err)
}
return deleted, nil
}
// authKeyIDToInt64 把 [8]byte 的 auth_key_id 按小端解释为 int64(MTProto 定义即 SHA1 低 64 位)。
func authKeyIDToInt64(id [8]byte) int64 {
return int64(binary.LittleEndian.Uint64(id[:]))

View file

@ -0,0 +1,215 @@
package postgres
import (
"context"
"crypto/rand"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func TestAuthKeyStoreDeleteOrphanedIsBoundedAndProtectsReferencesPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
keys := NewAuthKeyStore(pool)
auths := NewAuthorizationStore(pool)
userID := createRevokeTestUser(t, ctx, pool, "orphan-auth-key")
newKey := func() [8]byte {
var id [8]byte
if _, err := rand.Read(id[:]); err != nil {
t.Fatalf("random auth key id: %v", err)
}
if err := keys.Save(ctx, store.AuthKeyData{ID: id}); err != nil {
t.Fatalf("save auth key %x: %v", id, err)
}
t.Cleanup(func() { _ = keys.Delete(ctx, id) })
return id
}
orphanOne, orphanTwo := newKey(), newKey()
recent := newKey()
authorized := newKey()
temp, perm := newKey(), newKey()
active := newKey()
if _, err := pool.Exec(ctx, `
INSERT INTO update_states (auth_key_id, user_id, pts, observed_pts)
VALUES ($1, $3, 0, 0), ($2, $3, 0, 0)`,
authKeyIDToInt64(orphanOne), authKeyIDToInt64(orphanTwo), userID); err != nil {
t.Fatalf("insert stale orphan update states: %v", err)
}
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: authorized, UserID: userID}); err != nil {
t.Fatalf("bind authorization: %v", err)
}
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
TempAuthKeyID: temp, PermAuthKeyID: authKeyIDToInt64(perm), Nonce: 1,
TempSessionID: 2, ExpiresAt: int(time.Now().Add(time.Hour).Unix()), EncryptedMessage: []byte{1},
}); err != nil {
t.Fatalf("save temp binding: %v", err)
}
// Use a test-only historical window so a shared developer database's unrelated 24h-old
// handshake keys cannot win the bounded candidate slot or be mutated by this test.
const retention = 150 * 365 * 24 * time.Hour
old := time.Now().Add(-200 * 365 * 24 * time.Hour)
oldIDs := [][8]byte{orphanOne, orphanTwo, authorized, temp, perm, active}
for _, id := range oldIDs {
if _, err := pool.Exec(ctx, "UPDATE auth_keys SET created_at = $2, last_used_at = $2 WHERE auth_key_id = $1", authKeyIDToInt64(id), old); err != nil {
t.Fatalf("age auth key %x: %v", id, err)
}
}
deleted, err := keys.DeleteOrphaned(ctx, retention, 1, [][8]byte{active})
if err != nil || deleted != 1 {
t.Fatalf("first bounded orphan delete = %d/%v, want 1/nil", deleted, err)
}
var remainingOrphans int
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM auth_keys WHERE auth_key_id = ANY($1::bigint[])
`, []int64{authKeyIDToInt64(orphanOne), authKeyIDToInt64(orphanTwo)}).Scan(&remainingOrphans); err != nil {
t.Fatalf("count remaining orphans: %v", err)
}
if remainingOrphans != 1 {
t.Fatalf("remaining old unreferenced orphans = %d, want 1 after batch=1", remainingOrphans)
}
deleted, err = keys.DeleteOrphaned(ctx, retention, 20, [][8]byte{active})
if err != nil || deleted != 1 {
t.Fatalf("second orphan delete = %d/%v, want remaining 1/nil", deleted, err)
}
var orphanStates int
if err := pool.QueryRow(ctx, `
SELECT count(*)::int
FROM update_states
WHERE auth_key_id = ANY($1::bigint[])`, []int64{
authKeyIDToInt64(orphanOne), authKeyIDToInt64(orphanTwo),
}).Scan(&orphanStates); err != nil {
t.Fatalf("count orphan update states: %v", err)
}
if orphanStates != 0 {
t.Fatalf("orphan update states = %d, want 0 after atomic key GC", orphanStates)
}
for name, id := range map[string][8]byte{
"recent": recent, "authorized": authorized, "temp": temp, "perm": perm, "active": active,
} {
if _, found, err := keys.Get(ctx, id); err != nil || !found {
t.Fatalf("protected %s key %x found=%v err=%v, want retained", name, id, found, err)
}
}
}
func TestAuthKeyStoreDeleteCleansPermanentAndTempUpdateStatesPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
keys := NewAuthKeyStore(pool)
userID := createRevokeTestUser(t, ctx, pool, "auth-key-delete-state")
perm := randomUpdateRetentionAuthKey(t)
temp := randomUpdateRetentionAuthKey(t)
for _, id := range [][8]byte{perm, temp} {
if err := keys.Save(ctx, store.AuthKeyData{ID: id}); err != nil {
t.Fatalf("save auth key %x: %v", id, err)
}
id := id
t.Cleanup(func() { _ = keys.Delete(ctx, id) })
}
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
TempAuthKeyID: temp,
PermAuthKeyID: authKeyIDToInt64(perm),
Nonce: 31,
TempSessionID: 32,
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
EncryptedMessage: []byte{1},
}); err != nil {
t.Fatalf("save temp binding: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO update_states (auth_key_id, user_id, pts, observed_pts)
VALUES ($1, $3, 0, 0), ($2, $3, 0, 0)`,
authKeyIDToInt64(perm), authKeyIDToInt64(temp), userID); err != nil {
t.Fatalf("insert permanent/temp update states: %v", err)
}
if err := keys.Delete(ctx, perm); err != nil {
t.Fatalf("delete permanent auth key: %v", err)
}
ids := []int64{authKeyIDToInt64(perm), authKeyIDToInt64(temp)}
var keyRows, stateRows int
if err := pool.QueryRow(ctx, `
SELECT
(SELECT count(*) FROM auth_keys WHERE auth_key_id = ANY($1::bigint[]))::int,
(SELECT count(*) FROM update_states WHERE auth_key_id = ANY($1::bigint[]))::int`, ids).Scan(&keyRows, &stateRows); err != nil {
t.Fatalf("count deleted auth key state: %v", err)
}
if keyRows != 0 || stateRows != 0 {
t.Fatalf("remaining key/state rows = %d/%d, want 0/0", keyRows, stateRows)
}
}
func TestAuthKeyGetTouchPreventsOrphanCollectionPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
keys := NewAuthKeyStore(pool)
var id [8]byte
if _, err := rand.Read(id[:]); err != nil {
t.Fatalf("random auth key id: %v", err)
}
if err := keys.Save(ctx, store.AuthKeyData{ID: id}); err != nil {
t.Fatalf("save auth key: %v", err)
}
t.Cleanup(func() { _ = keys.Delete(ctx, id) })
const retention = 150 * 365 * 24 * time.Hour
old := time.Now().Add(-200 * 365 * 24 * time.Hour)
if _, err := pool.Exec(ctx, "UPDATE auth_keys SET created_at = $2, last_used_at = $2 WHERE auth_key_id = $1", authKeyIDToInt64(id), old); err != nil {
t.Fatalf("age auth key: %v", err)
}
if _, found, err := keys.Get(ctx, id); err != nil || !found {
t.Fatalf("touch auth key found=%v err=%v", found, err)
}
deleted, err := keys.DeleteOrphaned(ctx, retention, 10, nil)
if err != nil {
t.Fatalf("delete orphaned: %v", err)
}
if deleted != 0 {
t.Fatalf("deleted = %d, want 0 after atomic Get touch", deleted)
}
if _, found, err := keys.Get(ctx, id); err != nil || !found {
t.Fatalf("touched key retained found=%v err=%v", found, err)
}
}
func TestActiveRawAuthKeyHeartbeatProtectsOtherInstanceKeyPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
keys := NewAuthKeyStore(pool)
var id [8]byte
if _, err := rand.Read(id[:]); err != nil {
t.Fatalf("random auth key id: %v", err)
}
t.Cleanup(func() { _ = keys.Delete(ctx, id) })
if err := keys.Save(ctx, store.AuthKeyData{ID: id}); err != nil {
t.Fatalf("save auth key: %v", err)
}
old := time.Now().Add(-48 * time.Hour)
if _, err := pool.Exec(ctx, "UPDATE auth_keys SET created_at = $2, last_used_at = $2 WHERE auth_key_id = $1", authKeyIDToInt64(id), old); err != nil {
t.Fatalf("age active auth key: %v", err)
}
// Model another process heartbeating its local SessionManager snapshot. The collector on this
// process has no protected-list entry for the key and must still respect durable last_used_at.
if err := keys.TouchActiveRawAuthKeys(ctx, [][8]byte{id, id}); err != nil {
t.Fatalf("heartbeat active raw auth key: %v", err)
}
deleted, err := keys.DeleteOrphaned(ctx, 24*time.Hour, 10, nil)
if err != nil {
t.Fatalf("delete orphaned after heartbeat: %v", err)
}
if deleted != 0 {
t.Fatalf("deleted = %d, want active key protected by durable heartbeat", deleted)
}
if _, found, err := keys.Get(ctx, id); err != nil || !found {
t.Fatalf("heartbeat key found=%v err=%v, want present", found, err)
}
}

View file

@ -28,7 +28,111 @@ func (s *AuthorizationStore) Bind(ctx context.Context, a domain.Authorization) e
if a.Hash == 0 {
a.Hash = authorizationHash(a.AuthKeyID)
}
_, err := s.db.Exec(ctx, `
bind := func(db sqlcgen.DBTX) error {
return bindAuthorization(ctx, db, a)
}
var err error
if tx, ok := s.db.(pgx.Tx); ok {
err = bind(tx)
} else {
err = withTx(ctx, s.db, "bind authorization", func(tx pgx.Tx) error {
return bind(tx)
})
}
if err != nil {
return fmt.Errorf("upsert authorization: %w", err)
}
return nil
}
// bindAuthorization 把 auth_key→user 绑定和设备 update baseline 作为同一个状态边界提交。
//
// 锁顺序固定为:auth_keys 母行 → 目标 user_update_watermarks →
// user_update_retention → 目标 update_states。前两个 user 锁与
// pruneConfirmedUserPrefixTx 一致,使新授权的 observed baseline 和 retained floor 不会
// 交叉提交成静默空洞。母行锁又能在首次 authorization 尚不存在时串行化同一
// raw auth key 的并发登录/换号。
func bindAuthorization(ctx context.Context, db sqlcgen.DBTX, a domain.Authorization) error {
keyID := authKeyIDToInt64(a.AuthKeyID)
var lockedKeyID int64
if err := db.QueryRow(ctx, `
SELECT auth_key_id
FROM auth_keys
WHERE auth_key_id = $1
FOR UPDATE`, keyID).Scan(&lockedKeyID); err != nil {
return fmt.Errorf("lock auth key for authorization: %w", err)
}
if _, err := db.Exec(ctx, `
INSERT INTO user_update_watermarks (user_id, contiguous_pts)
VALUES ($1, 0)
ON CONFLICT (user_id) DO NOTHING`, a.UserID); err != nil {
return fmt.Errorf("ensure authorization user update watermark: %w", err)
}
var currentPts int
if err := db.QueryRow(ctx, `
SELECT contiguous_pts
FROM user_update_watermarks
WHERE user_id = $1
FOR UPDATE`, a.UserID).Scan(&currentPts); err != nil {
return fmt.Errorf("lock authorization user update watermark: %w", err)
}
if _, err := db.Exec(ctx, `
INSERT INTO user_update_retention (user_id)
VALUES ($1)
ON CONFLICT (user_id) DO NOTHING`, a.UserID); err != nil {
return fmt.Errorf("ensure authorization user update retention: %w", err)
}
var retainedFloor int
if err := db.QueryRow(ctx, `
SELECT retained_through_pts
FROM user_update_retention
WHERE user_id = $1
FOR UPDATE`, a.UserID).Scan(&retainedFloor); err != nil {
return fmt.Errorf("lock authorization user update retention: %w", err)
}
if retainedFloor > currentPts {
return fmt.Errorf(
"authorization update baseline invariant violation: user %d retained floor %d exceeds contiguous watermark %d",
a.UserID, retainedFloor, currentPts,
)
}
// 每次 Bind 都是一次显式登录 baseline:delivered pts 推进到已锁定的账号连续水位;
// observed 只推进到已删除的 retained floor,不把 live tail 伪装成客户端确认。
// 历史遗留的 state 若超出账号 contiguous watermark,必须 fail-fast;不得用
// GREATEST 把非法 future cursor 保留下来。WHERE 也封住“预检后并发插入”的竞态。
tag, err := db.Exec(ctx, `
INSERT INTO update_states (auth_key_id, user_id, pts, qts, date, seq, observed_pts)
VALUES ($1, $2, $3, 0, EXTRACT(EPOCH FROM now())::int, 0, $4)
ON CONFLICT (auth_key_id, user_id) DO UPDATE SET
pts = GREATEST(update_states.pts, EXCLUDED.pts),
qts = GREATEST(update_states.qts, EXCLUDED.qts),
date = GREATEST(update_states.date, EXCLUDED.date),
seq = GREATEST(update_states.seq, EXCLUDED.seq),
observed_pts = GREATEST(update_states.observed_pts, EXCLUDED.observed_pts),
updated_at = now()
WHERE update_states.pts >= 0
AND update_states.pts <= $3
AND update_states.observed_pts <= $3`, keyID, a.UserID, currentPts, retainedFloor)
if err != nil {
return fmt.Errorf("upsert authorization update baseline: %w", err)
}
if tag.RowsAffected() != 1 {
return fmt.Errorf(
"authorization update baseline invariant violation: auth key %x user %d has pts or observed_pts outside contiguous watermark %d",
a.AuthKeyID, a.UserID, currentPts,
)
}
if _, err := db.Exec(ctx, `
DELETE FROM update_states
WHERE auth_key_id = $1
AND user_id <> $2`, keyID, a.UserID); err != nil {
return fmt.Errorf("delete stale cross-user update states: %w", err)
}
if _, err := db.Exec(ctx, `
INSERT INTO authorizations (auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (auth_key_id) DO UPDATE SET
@ -43,10 +147,9 @@ ON CONFLICT (auth_key_id) DO UPDATE SET
ip = EXCLUDED.ip,
password_pending = EXCLUDED.password_pending,
active_at = now()`,
authKeyIDToInt64(a.AuthKeyID), a.UserID, a.Hash, int32(a.Layer), a.DeviceModel, a.Platform, a.SystemVersion, int32(a.APIID), a.AppVersion, a.IP, a.PasswordPending,
)
if err != nil {
return fmt.Errorf("upsert authorization: %w", err)
keyID, a.UserID, a.Hash, int32(a.Layer), a.DeviceModel, a.Platform, a.SystemVersion, int32(a.APIID), a.AppVersion, a.IP, a.PasswordPending,
); err != nil {
return fmt.Errorf("write authorization: %w", err)
}
return nil
}
@ -129,7 +232,8 @@ RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_vers
}
// RevokeByHash 删除协议 auth_key 作为远程踢设备的持久化事实入口。
// authorizations/update_states 通过 FK cascade 删除;关联 temp auth key 显式删除,避免 raw temp key 重连。
// authorizations 通过 FK cascade 删除;update_states 没有 auth_keys FK,必须显式清理;
// 关联 temp auth key 也显式删除,避免 raw temp key 重连。
func (s *AuthorizationStore) RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
row := s.db.QueryRow(ctx, `
WITH target AS MATERIALIZED (
@ -144,12 +248,18 @@ WITH target AS MATERIALIZED (
WHERE perm_auth_key_id IN (SELECT auth_key_id FROM target)
)
RETURNING auth_key_id
), deleted_update_states AS (
DELETE FROM update_states
WHERE auth_key_id IN (SELECT auth_key_id FROM target)
RETURNING auth_key_id
), deleted_keys AS (
DELETE FROM auth_keys
WHERE auth_key_id IN (SELECT auth_key_id FROM target)
RETURNING auth_key_id
), touched AS (
SELECT count(*) FROM deleted_temp
SELECT
(SELECT count(*) FROM deleted_temp) +
(SELECT count(*) FROM deleted_update_states) AS count
)
SELECT target.auth_key_id, target.user_id, target.hash, target.layer, target.device_model, target.platform,
target.system_version, target.api_id, target.app_version, target.ip, target.password_pending,
@ -207,12 +317,18 @@ WITH target AS MATERIALIZED (
WHERE perm_auth_key_id IN (SELECT auth_key_id FROM target)
)
RETURNING auth_key_id
), deleted_update_states AS (
DELETE FROM update_states
WHERE auth_key_id IN (SELECT auth_key_id FROM target)
RETURNING auth_key_id
), deleted_keys AS (
DELETE FROM auth_keys
WHERE auth_key_id IN (SELECT auth_key_id FROM target)
RETURNING auth_key_id
), touched AS (
SELECT count(*) FROM deleted_temp
SELECT
(SELECT count(*) FROM deleted_temp) +
(SELECT count(*) FROM deleted_update_states) AS count
)
SELECT target.auth_key_id, target.user_id, target.hash, target.layer, target.device_model, target.platform,
target.system_version, target.api_id, target.app_version, target.ip, target.password_pending,

View file

@ -60,11 +60,11 @@ func (s *BootstrapUpdateJobStore) MarkReadyForSession(ctx context.Context, userI
tag, err := s.db.Exec(ctx, `
UPDATE bootstrap_update_jobs
SET status = 'ready',
session_id = $3,
ready_at = now(),
updated_at = now()
WHERE user_id = $1
AND auth_key_id = $2
AND session_id = $3
AND status = 'pending'`,
userID, authKeyIDToInt64(authKeyID), sessionID)
if err != nil {

View file

@ -0,0 +1,53 @@
package postgres
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
func TestBootstrapUpdateJobPostgresSameAuthKeyReconnectTakesOverPendingSession(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
user := createLoginCodeDeliveryTestUser(t, ctx, pool, "bootstrap-reconnect")
msg, err := NewMessageStore(pool).Create(ctx, domain.Message{
OwnerUserID: user.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
Date: int(time.Now().Unix()),
Body: "Login code: 12345",
})
if err != nil {
t.Fatalf("create bootstrap message: %v", err)
}
bootstrap := NewBootstrapUpdateJobStore(pool)
authKeyID := [8]byte{1, 3, 5, 7}
const (
oldSessionID = int64(11001)
newSessionID = int64(22002)
)
job, err := bootstrap.EnqueueLoginMessage(ctx, domain.BootstrapUpdateJob{
Kind: domain.BootstrapUpdateJobLoginMessage, UserID: user.ID,
AuthKeyID: authKeyID, SessionID: oldSessionID, MessageBoxID: msg.ID,
})
if err != nil {
t.Fatalf("enqueue bootstrap: %v", err)
}
if ready, err := bootstrap.MarkReadyForSession(ctx, user.ID, [8]byte{9}, newSessionID); err != nil || ready != 0 {
t.Fatalf("different-auth ready=%d err=%v, want 0/nil", ready, err)
}
ready, err := bootstrap.MarkReadyForSession(ctx, user.ID, authKeyID, newSessionID)
if err != nil || ready != 1 {
t.Fatalf("same-auth reconnect ready=%d err=%v, want 1/nil", ready, err)
}
var status string
var sessionID int64
if err := pool.QueryRow(ctx, `SELECT status, session_id FROM bootstrap_update_jobs WHERE id = $1`, job.ID).Scan(&status, &sessionID); err != nil {
t.Fatalf("load bootstrap job: %v", err)
}
if status != string(domain.BootstrapUpdateJobReady) || sessionID != newSessionID {
t.Fatalf("bootstrap status/session = %s/%d, want ready/%d", status, sessionID, newSessionID)
}
}

View file

@ -3,6 +3,7 @@ package postgres
import (
"context"
"telesrv/internal/domain"
"telesrv/internal/store"
"testing"
)
@ -173,7 +174,6 @@ func TestChannelStorePublicPreviewDifferenceSkipsNonMemberMessages(t *testing.T)
if err != nil {
t.Fatalf("send channel message: %v", err)
}
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: viewer.ID,
ChannelID: channelID,
@ -233,16 +233,37 @@ func TestChannelStoreDifferenceUsesDurableMessageSnapshots(t *testing.T) {
t.Fatalf("create channel: %v", err)
}
channelID = created.Channel.ID
sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
sendReq := domain.SendChannelMessageRequest{
UserID: owner.ID,
ChannelID: channelID,
RandomID: 941,
Message: "original",
Date: 1700000381,
})
}
sent, err := channels.SendChannelMessage(ctx, sendReq)
if err != nil {
t.Fatalf("send channel message: %v", err)
}
fingerprint, err := store.ChannelSendFingerprint(sendReq)
if err != nil {
t.Fatalf("fingerprint channel message: %v", err)
}
replayReq := domain.ChannelSendReplayRequest{ChannelID: channelID, SenderUserID: owner.ID, RandomID: sent.Message.RandomID, IdempotencyFingerprint: fingerprint}
type replayState struct {
pts int
events int
}
loadReplayState := func() replayState {
t.Helper()
var state replayState
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, channelID).Scan(&state.pts); err != nil {
t.Fatalf("load channel pts: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_update_events WHERE channel_id = $1`, channelID).Scan(&state.events); err != nil {
t.Fatalf("count channel events: %v", err)
}
return state
}
if _, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{
UserID: owner.ID,
ChannelID: channelID,
@ -261,12 +282,16 @@ func TestChannelStoreDifferenceUsesDurableMessageSnapshots(t *testing.T) {
}); err != nil {
t.Fatalf("second edit: %v", err)
}
duplicate, found, err := channels.duplicateChannelMessage(ctx, channelID, owner.ID, sent.Message.RandomID)
beforeReplay := loadReplayState()
duplicate, found, err := channels.LookupChannelSendReplay(ctx, replayReq)
if err != nil {
t.Fatalf("duplicate channel message: %v", err)
}
if !found || !duplicate.Duplicate || duplicate.Event.Type != domain.ChannelUpdateNewMessage || duplicate.Message.Body != "original" || duplicate.Event.Message.Body != "original" {
t.Fatalf("duplicate after edit = %+v found=%v, want original new-message snapshot", duplicate, found)
if !found || !duplicate.Duplicate || duplicate.Event.Type != domain.ChannelUpdateNewMessage || duplicate.Message.Body != "second edit" || duplicate.Event.Message.Body != "second edit" || duplicate.Event.Pts != sent.Event.Pts {
t.Fatalf("duplicate after edit = %+v found=%v, want current snapshot with first-send pts", duplicate, found)
}
if after := loadReplayState(); after != beforeReplay {
t.Fatalf("edit replay mutated channel state = %+v, want %+v", after, beforeReplay)
}
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
@ -287,6 +312,26 @@ func TestChannelStoreDifferenceUsesDurableMessageSnapshots(t *testing.T) {
if diff.OtherUpdates[0].Message.Body != "first edit" || diff.OtherUpdates[1].Message.Body != "second edit" {
t.Fatalf("edit snapshots = %q/%q, want first edit/second edit", diff.OtherUpdates[0].Message.Body, diff.OtherUpdates[1].Message.Body)
}
deleted, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{
UserID: owner.ID, ChannelID: channelID, IDs: []int{sent.Message.ID}, Date: 1700000384,
})
if err != nil {
t.Fatalf("delete channel message: %v", err)
}
beforeReplay = loadReplayState()
duplicate, found, err = channels.LookupChannelSendReplay(ctx, replayReq)
if err != nil {
t.Fatalf("duplicate deleted channel message: %v", err)
}
if !found || !duplicate.Duplicate || duplicate.Message.Body != "original" || duplicate.Message.Pts != sent.Message.Pts || duplicate.Event.Pts != sent.Event.Pts {
t.Fatalf("duplicate after delete = %+v found=%v, want immutable first-send snapshot", duplicate, found)
}
if duplicate.ReplayDeleteEvent == nil || duplicate.ReplayDeleteEvent.Pts != deleted.Event.Pts || len(duplicate.ReplayDeleteEvent.MessageIDs) != 1 || duplicate.ReplayDeleteEvent.MessageIDs[0] != sent.Message.ID {
t.Fatalf("duplicate delete receipt = %+v, want durable event %+v", duplicate.ReplayDeleteEvent, deleted.Event)
}
if after := loadReplayState(); after != beforeReplay {
t.Fatalf("delete replay mutated channel state = %+v, want %+v", after, beforeReplay)
}
}
func TestChannelStoreSendFailureBeforePtsAllocationDoesNotRecordNoopGap(t *testing.T) {
@ -505,6 +550,13 @@ func TestReserveChannelPtsRollsBackWithTransaction(t *testing.T) {
if got != created.Channel.Pts {
t.Fatalf("channel pts after rollback = %d, want unchanged %d", got, created.Channel.Pts)
}
batch, err := channels.MaxChannelPtsBatch(ctx, []int64{channelID, -channelID, channelID})
if err != nil {
t.Fatalf("MaxChannelPtsBatch: %v", err)
}
if len(batch) != 1 || batch[channelID] != created.Channel.Pts {
t.Fatalf("batch channel pts = %v, want only %d:%d", batch, channelID, created.Channel.Pts)
}
}
func TestChannelStoreDifferenceTooLongSnapshot(t *testing.T) {

View file

@ -332,17 +332,12 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(ctx context.Context, userI
SELECT i.channel_id, c.pts
FROM user_channel_member_index i
JOIN channels c ON c.id = i.channel_id AND NOT c.deleted
JOIN channel_update_checkpoints cp ON cp.channel_id = i.channel_id
WHERE i.user_id = $1
AND i.status = 'active'
AND NOT i.deleted
AND i.channel_id > $3
AND EXISTS (
SELECT 1
FROM channel_update_events e
WHERE e.channel_id = i.channel_id
AND e.date > $2
LIMIT 1
)
AND cp.latest_event_date > $2
ORDER BY i.channel_id ASC
LIMIT $4`, userID, sinceDate, afterChannelID, limit)
if err != nil {

View file

@ -379,8 +379,14 @@ ORDER BY id`, channel.ID, id32)
deleted32 := int32s(deleted)
if _, err := tx.Exec(ctx, `
UPDATE channel_messages
SET deleted = true, pts = $3, updated_at = now()
WHERE channel_id = $1 AND id = ANY($2::int[])`, channel.ID, deleted32, pts); err != nil {
SET deleted = true,
pts = $3,
delete_pts = $3,
delete_pts_count = $4,
delete_date = $5,
delete_message_ids = to_jsonb($2::int[]),
updated_at = now()
WHERE channel_id = $1 AND id = ANY($2::int[])`, channel.ID, deleted32, pts, len(deleted), date); err != nil {
return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("soft delete channel messages: %w", err)
}
if err := deleteChannelUnreadMentionsTx(ctx, tx, channel.ID, deleted); err != nil {

View file

@ -8,18 +8,26 @@ import (
"github.com/jackc/pgx/v5"
"strings"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) {
if req.UserID == 0 || req.ChannelID == 0 || (strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() && req.RichMessage.IsZero()) {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
}
requestFingerprint, err := store.ChannelSendFingerprint(req)
if err != nil {
return domain.SendChannelMessageResult{}, err
}
// Normalize the fallback to an explicit receipt so retries of the internal
// transaction use exactly the same bytes as the first attempt.
req.IdempotencyFingerprint = requestFingerprint
if req.Date == 0 {
req.Date = nowUnix()
}
var lastErr error
for attempt := 0; attempt < retryableChannelTxAttempts; attempt++ {
res, err := s.sendChannelMessageOnce(ctx, req)
res, err := s.sendChannelMessageOnce(ctx, req, requestFingerprint)
if err == nil || !isRetryablePostgresTxError(err) || ctx.Err() != nil {
return res, err
}
@ -28,9 +36,14 @@ func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendCh
return domain.SendChannelMessageResult{}, lastErr
}
func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) {
if req.RandomID != 0 {
if dup, found, err := s.duplicateChannelMessage(ctx, req.ChannelID, req.UserID, req.RandomID); err != nil {
func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.SendChannelMessageRequest, requestFingerprint []byte) (domain.SendChannelMessageResult, error) {
if req.RandomID != 0 && !req.IdempotencyPreflighted {
if dup, found, err := s.LookupChannelSendReplay(ctx, domain.ChannelSendReplayRequest{
ChannelID: req.ChannelID,
SenderUserID: req.UserID,
RandomID: req.RandomID,
IdempotencyFingerprint: requestFingerprint,
}); err != nil {
return domain.SendChannelMessageResult{}, err
} else if found {
return dup, nil
@ -207,13 +220,30 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se
Message: msg,
SenderUserID: req.UserID,
}
if err := insertChannelMessageTx(ctx, tx, msg); err != nil {
if err := insertChannelMessageWithFingerprintTx(ctx, tx, msg, requestFingerprint); err != nil {
if isUniqueViolation(err) {
dup, found, dupErr := s.duplicateChannelMessage(ctx, req.ChannelID, req.UserID, req.RandomID)
if dupErr != nil || !found {
if req.RandomID == 0 {
return domain.SendChannelMessageResult{}, err
}
// A failed statement leaves the transaction aborted while its pool
// connection remains checked out. Release it before the winner lookup;
// otherwise a one-connection pool deadlocks waiting on itself.
if rollbackErr := tx.Rollback(ctx); rollbackErr != nil && !errors.Is(rollbackErr, pgx.ErrTxClosed) {
return domain.SendChannelMessageResult{}, fmt.Errorf("rollback channel random_id conflict: %w", rollbackErr)
}
committed = true // transaction is finalized by rollback; suppress deferred rollback
dup, found, dupErr := s.LookupChannelSendReplay(ctx, domain.ChannelSendReplayRequest{
ChannelID: req.ChannelID,
SenderUserID: req.UserID,
RandomID: req.RandomID,
IdempotencyFingerprint: requestFingerprint,
})
if dupErr != nil {
return domain.SendChannelMessageResult{}, dupErr
}
dup.Duplicate = true
if !found {
return domain.SendChannelMessageResult{}, fmt.Errorf("channel random_id unique conflict without replay receipt")
}
return dup, nil
}
return domain.SendChannelMessageResult{}, err
@ -320,8 +350,33 @@ func filterSkippedChannelRecipients(recipients []int64, skip map[int64]struct{})
return out
}
func (s *ChannelStore) duplicateChannelMessage(ctx context.Context, channelID, userID, randomID int64) (domain.SendChannelMessageResult, bool, error) {
row := s.db.QueryRow(ctx, `SELECT `+channelMessageColumns+` FROM channel_messages WHERE channel_id = $1 AND sender_user_id = $2 AND random_id = $3`, channelID, userID, randomID)
// LookupChannelSendReplay reads an immutable random_id receipt without running
// membership, permission, slow-mode, source/media resolution or allocation. A
// zero SavedPeer selects an ordinary channel receipt; monoforum sub-dialogs are
// scoped by the complete saved peer.
func (s *ChannelStore) LookupChannelSendReplay(ctx context.Context, lookup domain.ChannelSendReplayRequest) (domain.SendChannelMessageResult, bool, error) {
if lookup.ChannelID == 0 || lookup.SenderUserID == 0 || lookup.RandomID == 0 {
return domain.SendChannelMessageResult{}, false, fmt.Errorf("channel send replay: invalid scope")
}
if err := store.ValidateSendFingerprint(lookup.IdempotencyFingerprint, "channel send replay"); err != nil {
return domain.SendChannelMessageResult{}, false, err
}
var row pgx.Row
if lookup.SavedPeer.ID == 0 {
if lookup.SavedPeer.Type != "" {
return domain.SendChannelMessageResult{}, false, fmt.Errorf("channel send replay: incomplete saved peer scope")
}
row = s.db.QueryRow(ctx, `SELECT `+channelMessageColumns+` FROM channel_messages
WHERE channel_id = $1 AND sender_user_id = $2 AND saved_peer_type = '' AND saved_peer_id = 0 AND random_id = $3`,
lookup.ChannelID, lookup.SenderUserID, lookup.RandomID)
} else {
if lookup.SavedPeer.Type != domain.PeerTypeUser {
return domain.SendChannelMessageResult{}, false, fmt.Errorf("channel send replay: invalid saved peer scope")
}
row = s.db.QueryRow(ctx, `SELECT `+channelMessageColumns+` FROM channel_messages
WHERE channel_id = $1 AND sender_user_id = $2 AND saved_peer_type = $3 AND saved_peer_id = $4 AND random_id = $5`,
lookup.ChannelID, lookup.SenderUserID, string(lookup.SavedPeer.Type), lookup.SavedPeer.ID, lookup.RandomID)
}
msg, err := scanChannelMessage(row)
if errors.Is(err, pgx.ErrNoRows) {
return domain.SendChannelMessageResult{}, false, nil
@ -329,18 +384,71 @@ func (s *ChannelStore) duplicateChannelMessage(ctx context.Context, channelID, u
if err != nil {
return domain.SendChannelMessageResult{}, false, err
}
channel, err := getChannelByID(ctx, s.db, channelID)
result, err := s.channelDuplicateReplayResult(ctx, msg, lookup.IdempotencyFingerprint)
if err != nil {
return domain.SendChannelMessageResult{}, false, err
}
event, err := s.eventForChannelMessage(ctx, channelID, msg.ID)
result.Duplicate = true
return result, true, nil
}
func (s *ChannelStore) channelDuplicateReplayResult(ctx context.Context, msg domain.ChannelMessage, expectedFingerprint []byte) (domain.SendChannelMessageResult, error) {
var storedFingerprint []byte
var snapshotJSON, deleteIDsJSON string
var deletePts, deletePtsCount, deleteDate int
if err := s.db.QueryRow(ctx, `
SELECT request_fingerprint, send_snapshot::text, delete_pts, delete_pts_count, delete_date, delete_message_ids::text
FROM channel_messages
WHERE channel_id = $1 AND id = $2`, msg.ChannelID, msg.ID).Scan(
&storedFingerprint, &snapshotJSON, &deletePts, &deletePtsCount, &deleteDate, &deleteIDsJSON,
); err != nil {
return domain.SendChannelMessageResult{}, err
}
if !store.SameSendFingerprint(storedFingerprint, expectedFingerprint) {
return domain.SendChannelMessageResult{}, domain.ErrMessageRandomIDDuplicate
}
first, err := store.DecodeChannelSendSnapshot([]byte(snapshotJSON))
if err != nil {
return domain.SendChannelMessageResult{}, false, err
return domain.SendChannelMessageResult{}, fmt.Errorf("decode duplicate channel message %d snapshot: %w", msg.ID, err)
}
if event.Message.ID != 0 {
msg = event.Message
if first.ChannelID != msg.ChannelID || first.ID != msg.ID || first.SenderUserID != msg.SenderUserID || first.RandomID != msg.RandomID || first.SavedPeer != msg.SavedPeer {
return domain.SendChannelMessageResult{}, fmt.Errorf("duplicate channel message %d snapshot disagrees with random_id receipt", msg.ID)
}
return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Duplicate: true}, true, nil
channel, err := getChannelByID(ctx, s.db, msg.ChannelID)
if err != nil {
return domain.SendChannelMessageResult{}, err
}
replay := msg
var replayDelete *domain.ChannelUpdateEvent
if msg.Deleted {
replay = first
messageIDs, err := decodeEventMessageIDs(deleteIDsJSON)
if err != nil {
return domain.SendChannelMessageResult{}, fmt.Errorf("decode duplicate channel message %d delete ids: %w", msg.ID, err)
}
if deletePts <= 0 || deletePtsCount <= 0 || len(messageIDs) == 0 {
return domain.SendChannelMessageResult{}, fmt.Errorf("duplicate channel message %d is deleted without a durable delete receipt", msg.ID)
}
deleteEvent := domain.ChannelUpdateEvent{
ChannelID: msg.ChannelID,
Type: domain.ChannelUpdateDeleteMessages,
Pts: deletePts,
PtsCount: deletePtsCount,
Date: deleteDate,
MessageIDs: messageIDs,
}
replayDelete = &deleteEvent
}
event := domain.ChannelUpdateEvent{
ChannelID: msg.ChannelID,
Type: domain.ChannelUpdateNewMessage,
Pts: first.Pts,
PtsCount: 1,
Date: first.Date,
Message: replay,
SenderUserID: first.SenderUserID,
}
return domain.SendChannelMessageResult{Channel: channel, Message: replay, Event: event, Duplicate: true, ReplayDeleteEvent: replayDelete}, nil
}
func (s *ChannelStore) insertServiceMessage(ctx context.Context, tx pgx.Tx, channel domain.Channel, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.ChannelMessage, domain.ChannelUpdateEvent, error) {
@ -400,6 +508,26 @@ func channelServiceActionForMessage(channelID int64, msgID int, action domain.Ch
}
func insertChannelMessageTx(ctx context.Context, tx pgx.Tx, msg domain.ChannelMessage) error {
return insertChannelMessageWithFingerprintTx(ctx, tx, msg, nil)
}
// insertChannelMessageWithFingerprintTx is the only first-send write boundary
// for client-random-id channel messages. Callers that create service/discussion
// rows without random_id use insertChannelMessageTx and persist the legacy-safe
// empty default instead.
func insertChannelMessageWithFingerprintTx(ctx context.Context, tx pgx.Tx, msg domain.ChannelMessage, requestFingerprint []byte) error {
if msg.RandomID != 0 {
if err := store.ValidateSendFingerprint(requestFingerprint, "insert channel message"); err != nil {
return err
}
} else if len(requestFingerprint) != 0 {
if err := store.ValidateSendFingerprint(requestFingerprint, "insert channel message"); err != nil {
return err
}
}
if requestFingerprint == nil {
requestFingerprint = []byte{}
}
entities, err := encodeMessageEntities(msg.Entities)
if err != nil {
return err
@ -428,6 +556,13 @@ func insertChannelMessageTx(ctx context.Context, tx pgx.Tx, msg domain.ChannelMe
if err != nil {
return err
}
sendSnapshot := []byte("{}")
if msg.RandomID != 0 {
sendSnapshot, err = store.EncodeChannelSendSnapshot(msg)
if err != nil {
return err
}
}
var sendAsType sql.NullString
var sendAsID sql.NullInt64
if msg.SendAs != nil && msg.SendAs.ID != 0 {
@ -456,12 +591,12 @@ INSERT INTO channel_messages (
channel_id, id, random_id, sender_user_id, from_peer_type, from_peer_id,
send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards,
body, entities, reply_to, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id,
fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media, reply_markup, rich_message, ttl_period, expires_at, post_author, via_bot_id, from_boosts_applied, grouped_id, saved_peer_type, saved_peer_id
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37)`,
fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media, reply_markup, rich_message, ttl_period, expires_at, post_author, via_bot_id, from_boosts_applied, grouped_id, saved_peer_type, saved_peer_id, send_snapshot, request_fingerprint
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38::jsonb,$39::bytea)`,
msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID, string(msg.From.Type), msg.From.ID,
sendAsType, sendAsID, msg.Date, msg.EditDate, msg.Post, msg.Silent, msg.NoForwards,
msg.Body, entities, reply, replyMsgID, replyPeerType, replyPeerID, replyTopID,
forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media, replyMarkup, richMessage, msg.TTLPeriod, msg.ExpiresAt, msg.PostAuthor, msg.ViaBotID, msg.FromBoostsApplied, msg.GroupedID, string(msg.SavedPeer.Type), msg.SavedPeer.ID); err != nil {
forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media, replyMarkup, richMessage, msg.TTLPeriod, msg.ExpiresAt, msg.PostAuthor, msg.ViaBotID, msg.FromBoostsApplied, msg.GroupedID, string(msg.SavedPeer.Type), msg.SavedPeer.ID, sendSnapshot, requestFingerprint); err != nil {
return fmt.Errorf("insert channel message: %w", err)
}
// 共享媒体索引(迁移 0118):创建即按媒体类别建索引行,供 messages.search 媒体标签页。

View file

@ -9,6 +9,7 @@ import (
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。
@ -19,11 +20,22 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
}
requestFingerprint, err := store.MonoforumSendFingerprint(req)
if err != nil {
return domain.SendChannelMessageResult{}, err
}
req.IdempotencyFingerprint = requestFingerprint
if req.Date == 0 {
req.Date = nowUnix()
}
if req.RandomID != 0 {
if dup, found, err := s.duplicateMonoforumMessage(ctx, req.MonoforumID, req.SenderUserID, req.SavedPeer, req.RandomID); err != nil {
if req.RandomID != 0 && !req.IdempotencyPreflighted {
if dup, found, err := s.LookupChannelSendReplay(ctx, domain.ChannelSendReplayRequest{
ChannelID: req.MonoforumID,
SenderUserID: req.SenderUserID,
SavedPeer: req.SavedPeer,
RandomID: req.RandomID,
IdempotencyFingerprint: requestFingerprint,
}); err != nil {
return domain.SendChannelMessageResult{}, err
} else if found {
return dup, nil
@ -82,15 +94,32 @@ func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.Send
Message: msg,
SenderUserID: req.SenderUserID,
}
if err := insertChannelMessageTx(ctx, tx, msg); err != nil {
if err := insertChannelMessageWithFingerprintTx(ctx, tx, msg, requestFingerprint); err != nil {
if isUniqueViolation(err) {
// 唯一约束按 (channel,sender,random_id) 三元组;只有同一订阅者子会话的真重发才算重复。
// 跨子会话复用同一 random_id(异常客户端)按 saved_peer 过滤后命中不到 → 干净返错,不串消息。
dup, found, dupErr := s.duplicateMonoforumMessage(ctx, req.MonoforumID, req.SenderUserID, req.SavedPeer, req.RandomID)
if dupErr != nil || !found {
if req.RandomID == 0 {
return domain.SendChannelMessageResult{}, err
}
// The winner lookup must not ask the pool for a second connection
// while this aborted transaction still owns the first one.
if rollbackErr := tx.Rollback(ctx); rollbackErr != nil && !errors.Is(rollbackErr, pgx.ErrTxClosed) {
return domain.SendChannelMessageResult{}, fmt.Errorf("rollback monoforum random_id conflict: %w", rollbackErr)
}
committed = true // transaction is finalized by rollback; suppress deferred rollback
// The four-column unique scope is only the race fence. Acceptance
// still requires the exact immutable request fingerprint.
dup, found, dupErr := s.LookupChannelSendReplay(ctx, domain.ChannelSendReplayRequest{
ChannelID: req.MonoforumID,
SenderUserID: req.SenderUserID,
SavedPeer: req.SavedPeer,
RandomID: req.RandomID,
IdempotencyFingerprint: requestFingerprint,
})
if dupErr != nil {
return domain.SendChannelMessageResult{}, dupErr
}
dup.Duplicate = true
if !found {
return domain.SendChannelMessageResult{}, fmt.Errorf("monoforum random_id unique conflict without replay receipt")
}
return dup, nil
}
return domain.SendChannelMessageResult{}, err
@ -183,33 +212,6 @@ func (s *ChannelStore) ResolveMonoforumSend(ctx context.Context, viewerUserID, m
return mono, isAdmin, nil
}
// duplicateMonoforumMessage 按 (channel,sender,saved_peer,random_id) 查重发,确保同一发件人向不同
// 订阅者子会话用相同 random_id 时不会互相误判为重复。
func (s *ChannelStore) duplicateMonoforumMessage(ctx context.Context, channelID, senderUserID int64, savedPeer domain.Peer, randomID int64) (domain.SendChannelMessageResult, bool, error) {
row := s.db.QueryRow(ctx, `SELECT `+channelMessageColumns+` FROM channel_messages
WHERE channel_id = $1 AND sender_user_id = $2 AND saved_peer_type = $3 AND saved_peer_id = $4 AND random_id = $5`,
channelID, senderUserID, string(savedPeer.Type), savedPeer.ID, randomID)
msg, err := scanChannelMessage(row)
if errors.Is(err, pgx.ErrNoRows) {
return domain.SendChannelMessageResult{}, false, nil
}
if err != nil {
return domain.SendChannelMessageResult{}, false, err
}
channel, err := getChannelByID(ctx, s.db, channelID)
if err != nil {
return domain.SendChannelMessageResult{}, false, err
}
event, err := s.eventForChannelMessage(ctx, channelID, msg.ID)
if err != nil {
return domain.SendChannelMessageResult{}, false, err
}
if event.Message.ID != 0 {
msg = event.Message
}
return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Duplicate: true}, true, nil
}
// ListMonoforumDialogs 列出 monoforum 的订阅者子会话(每个 saved_peer 一条,取其 top 消息),
// 按 top 消息 id 倒序分页。走部分索引 channel_messages_monoforum_sublist_idx。
func (s *ChannelStore) ListMonoforumDialogs(ctx context.Context, filter domain.MonoforumDialogsFilter) (domain.MonoforumDialogList, error) {

View file

@ -2,9 +2,11 @@ package postgres
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// TestSendMonoforumMessageAndHistoryPostgres 回归频道私信(monoforum)发送+读历史的 PG 实现:
@ -95,6 +97,16 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
if !dup.Duplicate || dup.Message.ID != m1.Message.ID {
t.Fatalf("dup = %+v, want duplicate of m1 id %d", dup.Message, m1.Message.ID)
}
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "changed", Date: 1700001004}); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("changed monoforum intent err = %v, want ErrMessageRandomIDDuplicate", err)
}
var monoforumFingerprint []byte
if err := pool.QueryRow(ctx, `SELECT request_fingerprint FROM channel_messages WHERE channel_id=$1 AND id=$2`, monoID, m1.Message.ID).Scan(&monoforumFingerprint); err != nil {
t.Fatalf("load monoforum fingerprint: %v", err)
}
if len(monoforumFingerprint) != 32 {
t.Fatalf("monoforum fingerprint length = %d, want 32", len(monoforumFingerprint))
}
// 历史(经 scanChannelMessage 读回 saved_peer)。
hist, err := channels.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: subPeer, Limit: 10})
@ -136,6 +148,20 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
if b.Duplicate || b.Message.ID == a.Message.ID {
t.Fatalf("cross-sublist same random_id wrongly deduped: a=%d b=%d dup=%v", a.Message.ID, b.Message.ID, b.Duplicate)
}
// SavedPeer belongs both to the lookup scope and to the fallback intent.
// Cross-sublist sends remain legal, while presenting another sublist's
// fingerprint for an existing scope must be rejected rather than replayed.
otherIntentFingerprint, err := store.MonoforumSendFingerprint(domain.SendMonoforumMessageRequest{
MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: otherPeer, RandomID: 9001, Message: "to sub",
})
if err != nil {
t.Fatalf("fingerprint mismatched saved peer: %v", err)
}
if _, _, err := channels.LookupChannelSendReplay(ctx, domain.ChannelSendReplayRequest{
ChannelID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 9001, IdempotencyFingerprint: otherIntentFingerprint,
}); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("mismatched saved-peer fingerprint err = %v, want ErrMessageRandomIDDuplicate", err)
}
// 同一子会话真重发仍去重。
again, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 9001, Message: "to sub", Date: 1700001012})
if err != nil {
@ -159,4 +185,45 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
if dialogs.Dialogs[1].SavedPeer != subPeer || dialogs.Dialogs[1].TopMessageID == 0 {
t.Fatalf("dialogs[1] = %+v, want sub with top message", dialogs.Dialogs[1])
}
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin monoforum delete: %v", err)
}
mono, err := getChannelByID(ctx, tx, monoID)
if err != nil {
_ = tx.Rollback(ctx)
t.Fatalf("load monoforum for delete: %v", err)
}
_, deleteEvent, _, err := channels.deleteChannelMessagesTx(ctx, tx, mono, domain.ChannelMember{ChannelID: monoID, UserID: owner.ID, Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive}, []int{a.Message.ID}, owner.ID, 1700001013)
if err != nil {
_ = tx.Rollback(ctx)
t.Fatalf("delete monoforum message: %v", err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit monoforum delete: %v", err)
}
var ptsBeforeReplay, eventsBeforeReplay int
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, monoID).Scan(&ptsBeforeReplay); err != nil {
t.Fatalf("load monoforum pts: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_update_events WHERE channel_id = $1`, monoID).Scan(&eventsBeforeReplay); err != nil {
t.Fatalf("count monoforum events: %v", err)
}
deletedReplay, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 9001, Message: "to sub", Date: 1700001014})
if err != nil {
t.Fatalf("replay deleted monoforum message: %v", err)
}
if !deletedReplay.Duplicate || deletedReplay.Message.ID != a.Message.ID || deletedReplay.Message.Body != "to sub" || deletedReplay.ReplayDeleteEvent == nil || deletedReplay.ReplayDeleteEvent.Pts != deleteEvent.Pts {
t.Fatalf("deleted monoforum replay = %+v, want first snapshot + durable delete %+v", deletedReplay, deleteEvent)
}
var ptsAfterReplay, eventsAfterReplay int
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, monoID).Scan(&ptsAfterReplay); err != nil {
t.Fatalf("reload monoforum pts: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_update_events WHERE channel_id = $1`, monoID).Scan(&eventsAfterReplay); err != nil {
t.Fatalf("recount monoforum events: %v", err)
}
if ptsAfterReplay != ptsBeforeReplay || eventsAfterReplay != eventsBeforeReplay {
t.Fatalf("deleted monoforum replay mutated pts/events = %d/%d, want %d/%d", ptsAfterReplay, eventsAfterReplay, ptsBeforeReplay, eventsBeforeReplay)
}
}

View file

@ -0,0 +1,490 @@
package postgres
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/deploy"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func TestChannelSendFingerprintReplayPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 181, Phone: "+1781" + suffix + "01", FirstName: "ChannelReplayOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
var channelID int64
t.Cleanup(func() {
if channelID != 0 {
_, _ = pool.Exec(ctx, `DELETE FROM channels WHERE id = $1`, channelID)
}
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID)
})
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Channel replay " + suffix,
Megagroup: true,
Date: 1700100000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID = created.Channel.ID
base := domain.SendChannelMessageRequest{
UserID: owner.ID,
ChannelID: channelID,
RandomID: 781001,
Message: "immutable original",
Entities: []domain.MessageEntity{{
Type: domain.MessageEntityBold,
Offset: 0,
Length: 9,
}},
Date: 1700100001,
}
wantFingerprint, err := store.ChannelSendFingerprint(base)
if err != nil {
t.Fatalf("fingerprint base: %v", err)
}
first, err := channels.SendChannelMessage(ctx, base)
if err != nil {
t.Fatalf("first send: %v", err)
}
var storedFingerprint []byte
if err := pool.QueryRow(ctx, `SELECT request_fingerprint FROM channel_messages WHERE channel_id = $1 AND id = $2`, channelID, first.Message.ID).Scan(&storedFingerprint); err != nil {
t.Fatalf("load fingerprint: %v", err)
}
if !bytes.Equal(storedFingerprint, wantFingerprint) {
t.Fatalf("stored fingerprint = %x, want %x", storedFingerprint, wantFingerprint)
}
type durableState struct {
pts int
events int
rows int
}
loadState := func(randomID int64) durableState {
t.Helper()
var state durableState
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, channelID).Scan(&state.pts); err != nil {
t.Fatalf("load channel pts: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_update_events WHERE channel_id = $1`, channelID).Scan(&state.events); err != nil {
t.Fatalf("count channel events: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id = $1 AND sender_user_id = $2 AND random_id = $3`, channelID, owner.ID, randomID).Scan(&state.rows); err != nil {
t.Fatalf("count random receipt: %v", err)
}
return state
}
before := loadState(base.RandomID)
exact := base
exact.Date += 100 // execution time is not part of immutable intent
replay, err := channels.SendChannelMessage(ctx, exact)
if err != nil {
t.Fatalf("exact replay: %v", err)
}
if !replay.Duplicate || replay.Message.ID != first.Message.ID || replay.Event.Pts != first.Event.Pts {
t.Fatalf("exact replay = %+v, want first id=%d pts=%d", replay, first.Message.ID, first.Event.Pts)
}
if after := loadState(base.RandomID); after != before {
t.Fatalf("exact replay mutated state = %+v, want %+v", after, before)
}
conflicts := []struct {
name string
mutate func(*domain.SendChannelMessageRequest)
}{
{name: "body", mutate: func(req *domain.SendChannelMessageRequest) { req.Message = "changed body" }},
{name: "media", mutate: func(req *domain.SendChannelMessageRequest) {
req.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 781, AccessHash: 782, DCID: 2}}
}},
{name: "reply", mutate: func(req *domain.SendChannelMessageRequest) {
req.ReplyTo = &domain.MessageReply{MessageID: first.Message.ID}
}},
{name: "group", mutate: func(req *domain.SendChannelMessageRequest) { req.GroupedID = 781003 }},
}
for _, tc := range conflicts {
t.Run("conflict_"+tc.name, func(t *testing.T) {
changed := base
tc.mutate(&changed)
if _, err := channels.SendChannelMessage(ctx, changed); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("changed %s replay err = %v, want ErrMessageRandomIDDuplicate", tc.name, err)
}
if after := loadState(base.RandomID); after != before {
t.Fatalf("changed %s replay mutated state = %+v, want %+v", tc.name, after, before)
}
})
}
if _, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{
UserID: owner.ID, ChannelID: channelID, ID: first.Message.ID, Message: "edited current", EditDate: 1700100002,
}); err != nil {
t.Fatalf("edit channel message: %v", err)
}
editedReplay, err := channels.SendChannelMessage(ctx, exact)
if err != nil {
t.Fatalf("replay edited message: %v", err)
}
if !editedReplay.Duplicate || editedReplay.Message.Body != "edited current" || editedReplay.Event.Pts != first.Event.Pts {
t.Fatalf("edited replay = %+v, want current projection with first pts", editedReplay)
}
deleted, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{
UserID: owner.ID, ChannelID: channelID, IDs: []int{first.Message.ID}, Date: 1700100003,
})
if err != nil {
t.Fatalf("delete channel message: %v", err)
}
deletedReplay, err := channels.SendChannelMessage(ctx, exact)
if err != nil {
t.Fatalf("replay deleted message: %v", err)
}
if !deletedReplay.Duplicate || deletedReplay.Message.Body != base.Message || deletedReplay.Message.ID != first.Message.ID || deletedReplay.ReplayDeleteEvent == nil || deletedReplay.ReplayDeleteEvent.Pts != deleted.Event.Pts {
t.Fatalf("deleted replay = %+v, want immutable first snapshot + delete receipt %+v", deletedReplay, deleted.Event)
}
// A raw request-boundary fingerprint must be stored byte-for-byte rather
// than replaced with the domain fallback.
raw := sha256.Sum256([]byte("raw channel TL intent"))
rawReq := domain.SendChannelMessageRequest{
UserID: owner.ID, ChannelID: channelID, RandomID: 781002, Message: "raw fingerprint", Date: 1700100010,
IdempotencyFingerprint: raw[:],
}
rawSent, err := channels.SendChannelMessage(ctx, rawReq)
if err != nil {
t.Fatalf("raw fingerprint send: %v", err)
}
storedFingerprint = nil
if err := pool.QueryRow(ctx, `SELECT request_fingerprint FROM channel_messages WHERE channel_id = $1 AND id = $2`, channelID, rawSent.Message.ID).Scan(&storedFingerprint); err != nil {
t.Fatalf("load raw fingerprint: %v", err)
}
if !bytes.Equal(storedFingerprint, raw[:]) {
t.Fatalf("stored raw fingerprint = %x, want %x", storedFingerprint, raw)
}
// Simulate a rolling old writer that omits the new column. The empty
// default keeps the write compatible, but it is never accepted as replay.
legacyID := rawSent.Message.ID + 100
legacyRandomID := int64(781099)
if _, err := pool.Exec(ctx, `
INSERT INTO channel_messages (channel_id, id, random_id, sender_user_id, from_peer_id, message_date, pts, body)
VALUES ($1,$2,$3,$4,$4,$5,$6,$7)`, channelID, legacyID, legacyRandomID, owner.ID, 1700100020, rawSent.Event.Pts+100, "legacy unknown intent"); err != nil {
t.Fatalf("old-writer insert without fingerprint: %v", err)
}
var legacyFingerprint []byte
if err := pool.QueryRow(ctx, `SELECT request_fingerprint FROM channel_messages WHERE channel_id=$1 AND id=$2`, channelID, legacyID).Scan(&legacyFingerprint); err != nil {
t.Fatalf("load legacy fingerprint: %v", err)
}
if len(legacyFingerprint) != 0 {
t.Fatalf("legacy fingerprint length = %d, want empty", len(legacyFingerprint))
}
legacyReq := domain.SendChannelMessageRequest{UserID: owner.ID, ChannelID: channelID, RandomID: legacyRandomID, Message: "legacy unknown intent", Date: 1700100021}
legacyExpected, err := store.ChannelSendFingerprint(legacyReq)
if err != nil {
t.Fatalf("fingerprint legacy retry: %v", err)
}
if _, _, err := channels.LookupChannelSendReplay(ctx, domain.ChannelSendReplayRequest{
ChannelID: channelID, SenderUserID: owner.ID, RandomID: legacyRandomID, IdempotencyFingerprint: legacyExpected,
}); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("legacy empty lookup err = %v, want ErrMessageRandomIDDuplicate", err)
}
if _, err := channels.SendChannelMessage(ctx, legacyReq); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("legacy empty send err = %v, want ErrMessageRandomIDDuplicate", err)
}
}
func TestChannelSendFingerprintConcurrentRacePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 191, Phone: "+1781" + suffix + "11", FirstName: "ChannelRaceOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
var channelIDs []int64
t.Cleanup(func() {
if len(channelIDs) != 0 {
_, _ = pool.Exec(ctx, `DELETE FROM channels WHERE id = ANY($1::bigint[])`, channelIDs)
}
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID)
})
newChannel := func(title string) int64 {
t.Helper()
created, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: title + suffix, Megagroup: true, Date: 1700110000})
if err != nil {
t.Fatalf("create %s channel: %v", title, err)
}
channelIDs = append(channelIDs, created.Channel.ID)
return created.Channel.ID
}
run := func(reqs [2]domain.SendChannelMessageRequest) ([2]domain.SendChannelMessageResult, [2]error) {
t.Helper()
var results [2]domain.SendChannelMessageResult
var errs [2]error
start := make(chan struct{})
var wg sync.WaitGroup
for i := range reqs {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
results[i], errs[i] = NewChannelStore(pool).SendChannelMessage(ctx, reqs[i])
}(i)
}
close(start)
wg.Wait()
return results, errs
}
exactChannelID := newChannel("exact race ")
exactReq := domain.SendChannelMessageRequest{
UserID: owner.ID, ChannelID: exactChannelID, RandomID: 791001, Message: "same intent", Date: 1700110001,
IdempotencyPreflighted: true,
}
exactResults, exactErrs := run([2]domain.SendChannelMessageRequest{exactReq, exactReq})
for i, err := range exactErrs {
if err != nil {
t.Fatalf("exact race result[%d] err = %v", i, err)
}
}
if exactResults[0].Message.ID != exactResults[1].Message.ID || exactResults[0].Duplicate == exactResults[1].Duplicate {
t.Fatalf("exact race results = %+v / %+v, want same id and one duplicate", exactResults[0], exactResults[1])
}
assertChannelRandomReceiptCount(t, ctx, pool, exactChannelID, owner.ID, exactReq.RandomID, 1)
conflictChannelID := newChannel("conflict race ")
conflictA := domain.SendChannelMessageRequest{
UserID: owner.ID, ChannelID: conflictChannelID, RandomID: 791002, Message: "intent A", Date: 1700110010,
IdempotencyPreflighted: true,
}
conflictB := conflictA
conflictB.Message = "intent B"
conflictResults, conflictErrs := run([2]domain.SendChannelMessageRequest{conflictA, conflictB})
nilCount, duplicateErrCount := 0, 0
for _, err := range conflictErrs {
switch {
case err == nil:
nilCount++
case errors.Is(err, domain.ErrMessageRandomIDDuplicate):
duplicateErrCount++
default:
t.Fatalf("conflicting race unexpected err = %v; results=%+v", err, conflictResults)
}
}
if nilCount != 1 || duplicateErrCount != 1 {
t.Fatalf("conflicting race errors = %v, want one success and one duplicate", conflictErrs)
}
assertChannelRandomReceiptCount(t, ctx, pool, conflictChannelID, owner.ID, conflictA.RandomID, 1)
}
func TestChannelSendFingerprintSingleConnectionConflictLookupPostgres(t *testing.T) {
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test")
}
setupPool := testPool(t)
setupCtx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(setupPool)
owner, err := users.Create(setupCtx, domain.User{AccessHash: 192, Phone: "+1781" + suffix + "21", FirstName: "OneConnectionOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
var channelIDs []int64
t.Cleanup(func() {
cleanupCtx := context.Background()
if len(channelIDs) != 0 {
_, _ = setupPool.Exec(cleanupCtx, `DELETE FROM channels WHERE id = ANY($1::bigint[])`, channelIDs)
}
_, _ = setupPool.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, owner.ID)
})
setupChannels := NewChannelStore(setupPool)
created, err := setupChannels.CreateChannel(setupCtx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "one connection " + suffix, Megagroup: true, Date: 1700120000,
})
if err != nil {
t.Fatalf("create ordinary channel: %v", err)
}
channelIDs = append(channelIDs, created.Channel.ID)
ordinaryReq := domain.SendChannelMessageRequest{
UserID: owner.ID, ChannelID: created.Channel.ID, RandomID: 792001, Message: "single pool exact", Date: 1700120001,
IdempotencyPreflighted: true,
}
broadcast, err := setupChannels.CreateChannel(setupCtx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "one connection mono " + suffix, Broadcast: true, Date: 1700120010,
})
if err != nil {
t.Fatalf("create broadcast: %v", err)
}
channelIDs = append(channelIDs, broadcast.Channel.ID)
enabled, err := setupChannels.SetPaidMessagesPrice(setupCtx, owner.ID, broadcast.Channel.ID, 0, true)
if err != nil {
t.Fatalf("enable monoforum: %v", err)
}
monoID := enabled.Channel.LinkedMonoforumID
channelIDs = append(channelIDs, monoID)
// Fixture creation itself has legacy allocator paths that require more than
// one connection. Constrain only the send/replay path under test.
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatalf("parse postgres config: %v", err)
}
cfg.MaxConns = 1
cfg.MinConns = 0
pool, err := pgxpool.NewWithConfig(context.Background(), cfg)
if err != nil {
t.Fatalf("open one-connection pool: %v", err)
}
t.Cleanup(pool.Close)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
t.Cleanup(cancel)
msgIDs := &singleConnectionMessageIDAllocator{current: make(map[int64]int)}
for _, id := range []int64{created.Channel.ID, monoID} {
var current int
if err := setupPool.QueryRow(setupCtx, `SELECT COALESCE(MAX(id), 0) FROM channel_messages WHERE channel_id=$1`, id).Scan(&current); err != nil {
t.Fatalf("seed message allocator for channel %d: %v", id, err)
}
msgIDs.current[id] = current
}
oneConnectionStore := func() *ChannelStore {
return NewChannelStore(pool, WithChannelAllocators(nil, msgIDs))
}
var ordinaryResults [2]domain.SendChannelMessageResult
var ordinaryErrs [2]error
start := make(chan struct{})
var wg sync.WaitGroup
for i := range ordinaryResults {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
ordinaryResults[i], ordinaryErrs[i] = oneConnectionStore().SendChannelMessage(ctx, ordinaryReq)
}(i)
}
close(start)
wg.Wait()
for i, err := range ordinaryErrs {
if err != nil {
t.Fatalf("one-connection ordinary result[%d] err = %v", i, err)
}
}
if ordinaryResults[0].Message.ID != ordinaryResults[1].Message.ID || ordinaryResults[0].Duplicate == ordinaryResults[1].Duplicate {
t.Fatalf("one-connection ordinary results = %+v / %+v, want same id and one duplicate", ordinaryResults[0], ordinaryResults[1])
}
monoReq := domain.SendMonoforumMessageRequest{
MonoforumID: monoID, SenderUserID: owner.ID,
SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
RandomID: 792002, Message: "single pool mono exact", Date: 1700120011,
IdempotencyPreflighted: true,
}
var monoResults [2]domain.SendChannelMessageResult
var monoErrs [2]error
start = make(chan struct{})
for i := range monoResults {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
monoResults[i], monoErrs[i] = oneConnectionStore().SendMonoforumMessage(ctx, monoReq)
}(i)
}
close(start)
wg.Wait()
for i, err := range monoErrs {
if err != nil {
t.Fatalf("one-connection monoforum result[%d] err = %v", i, err)
}
}
if monoResults[0].Message.ID != monoResults[1].Message.ID || monoResults[0].Duplicate == monoResults[1].Duplicate {
t.Fatalf("one-connection monoforum results = %+v / %+v, want same id and one duplicate", monoResults[0], monoResults[1])
}
}
type singleConnectionMessageIDAllocator struct {
mu sync.Mutex
current map[int64]int
}
func (a *singleConnectionMessageIDAllocator) NextChannelMessageID(_ context.Context, channelID int64) (int, error) {
a.mu.Lock()
defer a.mu.Unlock()
a.current[channelID]++
return a.current[channelID], nil
}
func (a *singleConnectionMessageIDAllocator) CurrentChannelMessageID(_ context.Context, channelID int64) (int, error) {
a.mu.Lock()
defer a.mu.Unlock()
return a.current[channelID], nil
}
func assertChannelRandomReceiptCount(t *testing.T, ctx context.Context, pool *pgxpool.Pool, channelID, senderUserID, randomID int64, want int) {
t.Helper()
var got int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id=$1 AND sender_user_id=$2 AND random_id=$3`, channelID, senderUserID, randomID).Scan(&got); err != nil {
t.Fatalf("count channel random receipt: %v", err)
}
if got != want {
t.Fatalf("channel random receipt count = %d, want %d", got, want)
}
}
func TestChannelSendFingerprintMigrationRoundTripPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
downSQL, err := deploy.Migrations.ReadFile("migrations/0078_channel_send_fingerprint.down.sql")
if err != nil {
t.Fatalf("read 0078 down: %v", err)
}
upSQL, err := deploy.Migrations.ReadFile("migrations/0078_channel_send_fingerprint.up.sql")
if err != nil {
t.Fatalf("read 0078 up: %v", err)
}
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin 0078 round trip: %v", err)
}
defer func() { _ = tx.Rollback(context.Background()) }()
if _, err := tx.Exec(ctx, string(downSQL)); err != nil {
t.Fatalf("0078 down: %v", err)
}
if _, err := tx.Exec(ctx, string(upSQL)); err != nil {
t.Fatalf("0078 up: %v", err)
}
var defaultExpr string
var constraintExists bool
if err := tx.QueryRow(ctx, `
SELECT column_default,
EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_messages_request_fingerprint_size')
FROM information_schema.columns
WHERE table_schema='public' AND table_name='channel_messages' AND column_name='request_fingerprint'`).Scan(&defaultExpr, &constraintExists); err != nil {
t.Fatalf("inspect 0078: %v", err)
}
if !strings.Contains(defaultExpr, `\x`) || !constraintExists {
t.Fatalf("0078 default=%q constraint=%v, want empty bytea rolling default + size constraint", defaultExpr, constraintExists)
}
}

View file

@ -0,0 +1,285 @@
package postgres
import (
"context"
"errors"
"fmt"
"math"
"time"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
const (
channelUpdateRetentionCandidateBatch = 256
// Keep one channel row/checkpoint hot-lock window short even when the maintenance pass has a
// large global budget. The outer seek loop may consume many chunks; this is a transaction cap,
// not a per-pass correctness cap.
channelUpdateRetentionTransactionBatch = 256
)
// PruneChannelUpdateEvents atomically removes a bounded contiguous prefix of one channel's durable
// event log. The retained floor advances only through complete event rows actually deleted; a target
// inside a pts_count interval leaves that row and the floor untouched.
func (s *ChannelStore) PruneChannelUpdateEvents(ctx context.Context, channelID int64, throughPts, limit int) (domain.ChannelUpdateRetentionResult, error) {
return s.pruneChannelUpdateEvents(ctx, channelID, throughPts, 0, limit)
}
// DeleteExpiredChannelUpdateEvents performs a bounded global retention pass without OFFSET. The
// candidate seek uses (date,channel_id,pts), selects only the oldest retained row of each channel,
// then delegates deletion/floor advancement to the per-channel transactional primitive.
func (s *ChannelStore) DeleteExpiredChannelUpdateEvents(ctx context.Context, olderThan time.Duration, limit int) (int, error) {
if olderThan <= 0 {
return 0, nil
}
limit = normalizeChannelUpdateRetentionLimit(limit)
cutoff := int(time.Now().Add(-olderThan).Unix())
deleted := 0
excluded := make([]int64, 0)
var isolatedErrors []error
for deleted < limit {
candidateLimit := limit - deleted
if candidateLimit > channelUpdateRetentionCandidateBatch {
candidateLimit = channelUpdateRetentionCandidateBatch
}
channelIDs, err := s.expiredChannelUpdateCandidates(ctx, cutoff, candidateLimit, excluded)
if err != nil {
isolatedErrors = append(isolatedErrors, err)
return deleted, errors.Join(isolatedErrors...)
}
if len(channelIDs) == 0 {
break
}
for _, channelID := range channelIDs {
if deleted >= limit {
break
}
chunkLimit := limit - deleted
if chunkLimit > channelUpdateRetentionTransactionBatch {
chunkLimit = channelUpdateRetentionTransactionBatch
}
result, err := s.pruneChannelUpdateEvents(ctx, channelID, math.MaxInt32, cutoff, chunkLimit)
if err != nil {
// A durable-log gap/invalid row is an invariant violation for this channel, but it must
// not starve every healthy channel behind the oldest candidate. Isolate it for this pass,
// keep its floor unchanged (the tx rolled back), continue globally, then report all errors.
excluded = append(excluded, channelID)
isolatedErrors = append(isolatedErrors, fmt.Errorf("channel %d retention isolated: %w", channelID, err))
continue
}
if result.Deleted == 0 {
// Another retention worker may have consumed this head after the seek.
// Exclude it for this pass so one raced channel cannot spin forever.
excluded = append(excluded, channelID)
continue
}
deleted += result.Deleted
}
}
return deleted, errors.Join(isolatedErrors...)
}
// expiredChannelUpdateCandidates keeps each SQL seek bounded, while the caller loops through as
// many seeks as needed to consume the requested deletion budget. The 256 value is a fetch/page
// size, not a per-maintenance-pass correctness cap.
func (s *ChannelStore) expiredChannelUpdateCandidates(ctx context.Context, cutoff, limit int, excluded []int64) ([]int64, error) {
rows, err := s.db.Query(ctx, `
SELECT e.channel_id
FROM channel_update_events e
LEFT JOIN channel_update_checkpoints cp ON cp.channel_id = e.channel_id
WHERE e.date < $1
AND e.pts > COALESCE(cp.retained_through_pts, 0)
AND NOT (e.channel_id = ANY($3::bigint[]))
AND NOT EXISTS (
SELECT 1
FROM channel_update_events earlier
WHERE earlier.channel_id = e.channel_id
AND earlier.pts > COALESCE(cp.retained_through_pts, 0)
AND earlier.pts < e.pts
)
ORDER BY e.date ASC, e.channel_id ASC, e.pts ASC
LIMIT $2`, cutoff, limit, excluded)
if err != nil {
return nil, fmt.Errorf("list expired channel update candidates: %w", err)
}
defer rows.Close()
channelIDs := make([]int64, 0, limit)
for rows.Next() {
var channelID int64
if err := rows.Scan(&channelID); err != nil {
return nil, fmt.Errorf("scan expired channel update candidate: %w", err)
}
channelIDs = append(channelIDs, channelID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate expired channel update candidates: %w", err)
}
return channelIDs, nil
}
func (s *ChannelStore) pruneChannelUpdateEvents(ctx context.Context, channelID int64, throughPts, beforeDate, limit int) (domain.ChannelUpdateRetentionResult, error) {
if channelID == 0 || throughPts < 0 {
return domain.ChannelUpdateRetentionResult{}, domain.ErrChannelInvalid
}
limit = normalizeChannelUpdateRetentionLimit(limit)
if limit > channelUpdateRetentionTransactionBatch {
limit = channelUpdateRetentionTransactionBatch
}
var result domain.ChannelUpdateRetentionResult
err := withTx(ctx, s.db, "prune channel update events", func(tx pgx.Tx) error {
checkpoint, err := lockChannelUpdateCheckpoint(ctx, tx, channelID)
if err != nil {
return err
}
if throughPts > checkpoint.LatestPts {
throughPts = checkpoint.LatestPts
}
if throughPts <= checkpoint.RetainedThroughPts {
result.Checkpoint = checkpoint
return nil
}
rows, err := tx.Query(ctx, `
SELECT pts, pts_count, date
FROM channel_update_events
WHERE channel_id = $1
AND pts > $2
AND pts <= $3
ORDER BY pts ASC
LIMIT $4
FOR UPDATE`, channelID, checkpoint.RetainedThroughPts, throughPts, limit)
if err != nil {
return fmt.Errorf("list channel update prune prefix: %w", err)
}
defer rows.Close()
cursor := checkpoint.RetainedThroughPts
ptsToDelete := make([]int32, 0, limit)
for rows.Next() {
var pts, ptsCount, date int
if err := rows.Scan(&pts, &ptsCount, &date); err != nil {
return fmt.Errorf("scan channel update prune prefix: %w", err)
}
if beforeDate > 0 && date >= beforeDate {
break
}
if ptsCount <= 0 {
return fmt.Errorf("prune channel update events: channel %d has invalid pts_count=%d at pts=%d", channelID, ptsCount, pts)
}
if pts != cursor+ptsCount {
return fmt.Errorf(
"prune channel update events: channel %d has gap after pts %d: event pts=%d pts_count=%d",
channelID, cursor, pts, ptsCount,
)
}
cursor = pts
ptsToDelete = append(ptsToDelete, int32(pts))
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate channel update prune prefix: %w", err)
}
rows.Close()
if len(ptsToDelete) == 0 {
result.Checkpoint = checkpoint
return nil
}
tag, err := tx.Exec(ctx, `
DELETE FROM channel_update_events
WHERE channel_id = $1
AND pts = ANY($2::int[])`, channelID, ptsToDelete)
if err != nil {
return fmt.Errorf("delete channel update prune prefix: %w", err)
}
if got := int(tag.RowsAffected()); got != len(ptsToDelete) {
return fmt.Errorf("delete channel update prune prefix: deleted %d rows, expected %d", got, len(ptsToDelete))
}
tag, err = tx.Exec(ctx, `
UPDATE channel_update_checkpoints
SET retained_through_pts = $2,
latest_event_date = GREATEST(latest_event_date, $3),
latest_pts = GREATEST(latest_pts, $4),
updated_at = now()
WHERE channel_id = $1`, channelID, cursor, checkpoint.LatestEventDate, checkpoint.LatestPts)
if err != nil {
return fmt.Errorf("advance channel update retained floor: %w", err)
}
if tag.RowsAffected() != 1 {
return fmt.Errorf("advance channel update retained floor: checkpoint row disappeared for channel %d", channelID)
}
checkpoint.RetainedThroughPts = cursor
result = domain.ChannelUpdateRetentionResult{Checkpoint: checkpoint, Deleted: len(ptsToDelete)}
return nil
})
if err != nil {
return domain.ChannelUpdateRetentionResult{}, err
}
return result, nil
}
// lockChannelUpdateCheckpoint follows the channel writer lock order: channels row first, checkpoint
// second. Event insertion updates channels.pts before upserting the checkpoint, so retention cannot
// race a committed pts without its durable event/checkpoint.
func lockChannelUpdateCheckpoint(ctx context.Context, tx pgx.Tx, channelID int64) (domain.ChannelUpdateRetentionCheckpoint, error) {
var lockedChannelID int64
if err := tx.QueryRow(ctx, `
SELECT id
FROM channels
WHERE id = $1
FOR UPDATE`, channelID).Scan(&lockedChannelID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ChannelUpdateRetentionCheckpoint{}, domain.ErrChannelInvalid
}
return domain.ChannelUpdateRetentionCheckpoint{}, fmt.Errorf("lock channel for update retention: %w", err)
}
checkpoint := domain.ChannelUpdateRetentionCheckpoint{ChannelID: channelID}
if err := tx.QueryRow(ctx, `
SELECT retained_through_pts, latest_event_date, latest_pts
FROM channel_update_checkpoints
WHERE channel_id = $1
FOR UPDATE`, channelID).Scan(
&checkpoint.RetainedThroughPts,
&checkpoint.LatestEventDate,
&checkpoint.LatestPts,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ChannelUpdateRetentionCheckpoint{}, fmt.Errorf(
"lock channel update checkpoint: invariant violation: channel %d has no retention checkpoint",
channelID,
)
}
return domain.ChannelUpdateRetentionCheckpoint{}, fmt.Errorf("lock channel update checkpoint: %w", err)
}
return checkpoint, nil
}
func normalizeChannelUpdateRetentionLimit(limit int) int {
if limit <= 0 || limit > domain.MaxChannelUpdateRetentionBatch {
return domain.MaxChannelUpdateRetentionBatch
}
return limit
}
func getChannelUpdateCheckpoint(ctx context.Context, db sqlcgen.DBTX, channelID int64) (domain.ChannelUpdateRetentionCheckpoint, error) {
checkpoint := domain.ChannelUpdateRetentionCheckpoint{ChannelID: channelID}
err := db.QueryRow(ctx, `
SELECT retained_through_pts, latest_event_date, latest_pts
FROM channel_update_checkpoints
WHERE channel_id = $1`, channelID).Scan(
&checkpoint.RetainedThroughPts,
&checkpoint.LatestEventDate,
&checkpoint.LatestPts,
)
if errors.Is(err, pgx.ErrNoRows) {
return domain.ChannelUpdateRetentionCheckpoint{}, fmt.Errorf(
"get channel update checkpoint: invariant violation: channel %d has no retention checkpoint",
channelID,
)
}
if err != nil {
return domain.ChannelUpdateRetentionCheckpoint{}, fmt.Errorf("get channel update checkpoint: %w", err)
}
return checkpoint, nil
}

View file

@ -0,0 +1,232 @@
package postgres
import (
"context"
"fmt"
"strings"
"testing"
"time"
"telesrv/internal/domain"
)
func TestChannelUpdateRetentionFloorDifferenceAndDirtyCheckpointPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: 71,
Phone: "+1766" + suffix + "01",
FirstName: "RetentionOwner",
})
if err != nil {
t.Fatalf("create owner: %v", err)
}
var channelID int64
t.Cleanup(func() {
if channelID != 0 {
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
}
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
})
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Retention PG " + suffix,
Megagroup: true,
Date: 1_700_020_000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID = created.Channel.ID
sent := make([]domain.SendChannelMessageResult, 0, 3)
for i := 1; i <= 3; i++ {
result, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID, ChannelID: channelID, RandomID: int64(7000 + i), Message: "retention", Date: 1_700_020_000 + i,
})
if err != nil {
t.Fatalf("send message %d: %v", i, err)
}
sent = append(sent, result)
}
pruned, err := channels.PruneChannelUpdateEvents(ctx, channelID, sent[1].Event.Pts, 100)
if err != nil {
t.Fatalf("prune channel updates: %v", err)
}
if pruned.Deleted != 3 || pruned.Checkpoint.RetainedThroughPts != sent[1].Event.Pts {
t.Fatalf("prune result = %+v, want deleted=3 floor=%d", pruned, sent[1].Event.Pts)
}
var floor, latestDate, latestPts, remaining int
if err := pool.QueryRow(ctx, `
SELECT cp.retained_through_pts, cp.latest_event_date, cp.latest_pts,
(SELECT COUNT(*) FROM channel_update_events e WHERE e.channel_id = cp.channel_id)::int
FROM channel_update_checkpoints cp
WHERE cp.channel_id = $1`, channelID).Scan(&floor, &latestDate, &latestPts, &remaining); err != nil {
t.Fatalf("read retention checkpoint: %v", err)
}
if floor != sent[1].Event.Pts || latestDate != sent[2].Event.Date || latestPts != sent[2].Event.Pts || remaining != 1 {
t.Fatalf("checkpoint/db = floor:%d latest:%d/%d remaining:%d", floor, latestDate, latestPts, remaining)
}
below, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: owner.ID, ChannelID: channelID, Pts: floor - 1, Limit: 100,
})
if err != nil {
t.Fatalf("difference below retained floor: %v", err)
}
if !below.TooLong || below.Pts != sent[2].Event.Pts {
t.Fatalf("difference below floor = %+v, want too-long snapshot at pts %d", below, sent[2].Event.Pts)
}
atFloor, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: owner.ID, ChannelID: channelID, Pts: floor, Limit: 100,
})
if err != nil {
t.Fatalf("difference at retained floor: %v", err)
}
if atFloor.TooLong || len(atFloor.Events) != 1 || atFloor.Events[0].Pts != sent[2].Event.Pts {
t.Fatalf("difference at floor = %+v, want normal incremental event pts %d", atFloor, sent[2].Event.Pts)
}
allPruned, err := channels.PruneChannelUpdateEvents(ctx, channelID, sent[2].Event.Pts, 100)
if err != nil {
t.Fatalf("prune remaining channel update: %v", err)
}
if allPruned.Deleted != 1 {
t.Fatalf("remaining prune = %+v, want deleted=1", allPruned)
}
dirty, err := channels.ListDirtyActiveChannelsForUser(ctx, owner.ID, sent[2].Event.Date-1, 0, 10)
if err != nil {
t.Fatalf("list dirty channels after prune: %v", err)
}
if len(dirty) != 1 || dirty[0].ChannelID != channelID || dirty[0].Pts != sent[2].Event.Pts {
t.Fatalf("dirty channels after prune = %+v, want channel %d pts %d", dirty, channelID, sent[2].Event.Pts)
}
}
func TestDeleteExpiredChannelUpdateEventsContinuesPastCandidatePagePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: time.Now().UnixNano(),
Phone: "+1767" + suffix + "01",
FirstName: "RetentionPageOwner",
})
if err != nil {
t.Fatalf("create retention page owner: %v", err)
}
channelIDs := make([]int64, 0, 320)
t.Cleanup(func() {
if len(channelIDs) > 0 {
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs)
}
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
})
channels := NewChannelStore(pool)
for i := 0; i < 320; i++ {
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: fmt.Sprintf("Retention page %s/%03d", suffix, i),
Megagroup: true,
// Keep these rows ahead of ordinary developer/test data in the global seek.
Date: 1,
})
if err != nil {
t.Fatalf("create retention candidate %d: %v", i, err)
}
channelIDs = append(channelIDs, created.Channel.ID)
}
deleted, err := channels.DeleteExpiredChannelUpdateEvents(ctx, time.Second, len(channelIDs))
if err != nil {
t.Fatalf("delete expired channel updates across pages: %v", err)
}
if deleted != len(channelIDs) {
t.Fatalf("deleted expired channel updates = %d, want %d (must continue after page 256)", deleted, len(channelIDs))
}
var remaining, advanced int
if err := pool.QueryRow(ctx, `
SELECT
(SELECT count(*) FROM channel_update_events WHERE channel_id = ANY($1::bigint[]))::int,
(SELECT count(*) FROM channel_update_checkpoints
WHERE channel_id = ANY($1::bigint[]) AND retained_through_pts = 1)::int`, channelIDs).Scan(&remaining, &advanced); err != nil {
t.Fatalf("read paged channel retention result: %v", err)
}
if remaining != 0 || advanced != len(channelIDs) {
t.Fatalf("paged retention remaining/advanced = %d/%d, want 0/%d", remaining, advanced, len(channelIDs))
}
}
func TestDeleteExpiredChannelUpdateEventsIsolatesGapAndContinuesPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
owner, err := NewUserStore(pool).Create(ctx, domain.User{
AccessHash: time.Now().UnixNano(),
Phone: "+1768" + suffix + "01",
FirstName: "RetentionGapOwner",
})
if err != nil {
t.Fatalf("create owner: %v", err)
}
channels := NewChannelStore(pool)
bad, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "Retention bad " + suffix, Megagroup: true, Date: 1,
})
if err != nil {
t.Fatalf("create bad channel: %v", err)
}
healthy, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "Retention healthy " + suffix, Megagroup: true, Date: 2,
})
if err != nil {
t.Fatalf("create healthy channel: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", []int64{bad.Channel.ID, healthy.Channel.ID})
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
})
// Deliberately model a persisted invariant violation: floor=0 but the first event ends at pts=2
// with pts_count=1. The bad channel is the oldest global candidate and must be reported without
// preventing the healthy channel behind it from advancing.
if _, err := pool.Exec(ctx, `
WITH moved_event AS (
UPDATE channel_update_events SET pts = 2, date = 1 WHERE channel_id = $1 RETURNING channel_id
), moved_channel AS (
UPDATE channels SET pts = 2 WHERE id = $1 RETURNING id
)
UPDATE channel_update_checkpoints
SET latest_pts = 2, latest_event_date = 1
WHERE channel_id = $1
`, bad.Channel.ID); err != nil {
t.Fatalf("inject channel retention gap: %v", err)
}
deleted, err := channels.DeleteExpiredChannelUpdateEvents(ctx, time.Second, 10)
if err == nil || !strings.Contains(err.Error(), "has gap") {
t.Fatalf("gap retention err = %v, want reported invariant violation", err)
}
if deleted < 1 {
t.Fatalf("deleted across bad+healthy channels = %d, want at least the healthy channel's event", deleted)
}
var badFloor, badRows, healthyFloor, healthyRows int
if scanErr := pool.QueryRow(ctx, `
SELECT
(SELECT retained_through_pts FROM channel_update_checkpoints WHERE channel_id = $1)::int,
(SELECT count(*) FROM channel_update_events WHERE channel_id = $1)::int,
(SELECT retained_through_pts FROM channel_update_checkpoints WHERE channel_id = $2)::int,
(SELECT count(*) FROM channel_update_events WHERE channel_id = $2)::int
`, bad.Channel.ID, healthy.Channel.ID).Scan(&badFloor, &badRows, &healthyFloor, &healthyRows); scanErr != nil {
t.Fatalf("read isolated retention state: %v", scanErr)
}
if badFloor != 0 || badRows != 1 || healthyFloor != 1 || healthyRows != 0 {
t.Fatalf("isolated state bad=%d/%d healthy=%d/%d, want 0/1 and 1/0", badFloor, badRows, healthyFloor, healthyRows)
}
}

View file

@ -37,7 +37,11 @@ func (s *ChannelStore) ListChannelDifference(ctx context.Context, req domain.Cha
Dialog: previewChannelDialog(req.UserID, channel, member),
}, nil
}
if channel.Pts-req.Pts > limit {
checkpoint, err := getChannelUpdateCheckpoint(ctx, s.db, req.ChannelID)
if err != nil {
return domain.ChannelDifference{}, err
}
if req.Pts < checkpoint.RetainedThroughPts || channel.Pts-req.Pts > limit {
args := []any{req.ChannelID}
where := "channel_id = $1 AND NOT deleted"
if member.AvailableMinID > 0 {
@ -210,6 +214,30 @@ func (s *ChannelStore) MaxChannelPts(ctx context.Context, channelID int64) (int,
return pts, err
}
func (s *ChannelStore) MaxChannelPtsBatch(ctx context.Context, channelIDs []int64) (map[int64]int, error) {
out := make(map[int64]int, len(channelIDs))
if len(channelIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `SELECT id, pts FROM channels WHERE id = ANY($1::bigint[])`, channelIDs)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var channelID int64
var pts int
if err := rows.Scan(&channelID, &pts); err != nil {
return nil, err
}
out[channelID] = pts
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func transientChannelParticipantEvent(channelID, actorUserID int64, previous, participant domain.ChannelMember, date int) domain.ChannelUpdateEvent {
return domain.ChannelUpdateEvent{
ChannelID: channelID,
@ -302,6 +330,18 @@ INSERT INTO channel_update_events (
ids, event.SenderUserID, userIDs, payload); err != nil {
return fmt.Errorf("insert channel event: %w", err)
}
// The checkpoint is updated in the same business transaction as the event row. Retention may
// later remove the row, but account-level dirty-channel recovery still has the latest date/pts.
if _, err := tx.Exec(ctx, `
INSERT INTO channel_update_checkpoints (
channel_id, retained_through_pts, latest_event_date, latest_pts
) VALUES ($1, 0, $2, $3)
ON CONFLICT (channel_id) DO UPDATE SET
latest_event_date = GREATEST(channel_update_checkpoints.latest_event_date, EXCLUDED.latest_event_date),
latest_pts = GREATEST(channel_update_checkpoints.latest_pts, EXCLUDED.latest_pts),
updated_at = now()`, event.ChannelID, event.Date, event.Pts); err != nil {
return fmt.Errorf("upsert channel update checkpoint: %w", err)
}
return nil
}

View file

@ -217,7 +217,7 @@ func TestDispatchOutboxLifecycleKeepsDurableEvents(t *testing.T) {
if len(claimed) != 1 || claimed[0].TargetUserID != owner.ID || claimed[0].Pts != 1 || claimed[0].Attempts != 1 || claimed[0].ExcludeSessionID != 101 {
t.Fatalf("claimed first = %+v, want owner pts=1 attempts=1", claimed)
}
if err := outbox.MarkDelivered(ctx, owner.ID, claimed[0].ID); err != nil {
if err := outbox.MarkDelivered(ctx, claimed[0]); err != nil {
t.Fatalf("MarkDelivered: %v", err)
}
if got := outboxRows(1); got != 0 {
@ -246,7 +246,7 @@ func TestDispatchOutboxLifecycleKeepsDurableEvents(t *testing.T) {
if len(claimed) != 1 || claimed[0].TargetUserID != owner.ID || claimed[0].Pts != 2 || claimed[0].Attempts != 2 {
t.Fatalf("claimed stale = %+v, want owner pts=2 attempts=2", claimed)
}
if err := outbox.MarkFailed(ctx, owner.ID, claimed[0].ID, "temporary"); err != nil {
if err := outbox.MarkFailed(ctx, claimed[0], "temporary"); err != nil {
t.Fatalf("MarkFailed temporary: %v", err)
}
var status string
@ -275,7 +275,8 @@ func TestDispatchOutboxLifecycleKeepsDurableEvents(t *testing.T) {
`, owner.ID, claimed[0].ID); err != nil {
t.Fatalf("prepare terminal failure: %v", err)
}
if err := outbox.MarkFailed(ctx, owner.ID, claimed[0].ID, "permanent"); err != nil {
claimed[0].Attempts = 5
if err := outbox.MarkFailed(ctx, claimed[0], "permanent"); err != nil {
t.Fatalf("MarkFailed permanent: %v", err)
}
if err := tx.QueryRow(ctx, `

View file

@ -12,7 +12,11 @@ import (
// defaultDispatchLease 是 'dispatching' 行被判定租约过期、可被重新 claim 的默认时长。
// 与 docs/message-module.md 的 outbox 背压参数对应;生产由 config 注入覆盖。
const defaultDispatchLease = 30 * time.Second
const (
defaultDispatchLease = 30 * time.Second
defaultDispatchPoisonCleanupBatch = 256
maxDispatchPoisonCleanupBatch = 1000
)
// DispatchOutboxStore 用 PostgreSQL 实现 transactional outbox。
type DispatchOutboxStore struct {
@ -63,6 +67,48 @@ func (s *DispatchOutboxStore) ClaimPending(ctx context.Context, limit int) ([]st
if err != nil {
return nil, fmt.Errorf("claim dispatch outbox: %w", err)
}
return dispatchItemsFromClaimRows(rows), nil
}
// ClaimPendingShards 只领取固定 logical shard 集合中的用户 head 事件。
// shardCount 是稳定哈希空间,shardIDs 是当前 worker 独占的子集;worker 数变化只改变
// shard→worker 的运行时归属,不改变 user→shard,从而避免同一用户被并行领取。
func (s *DispatchOutboxStore) ClaimPendingShards(ctx context.Context, shardCount int, shardIDs []int, limit int) ([]store.DispatchOutboxItem, error) {
if shardCount <= 0 || len(shardIDs) == 0 {
return nil, nil
}
if shardCount != store.DispatchOutboxLogicalShards {
return nil, fmt.Errorf("claim dispatch outbox shards: shard count %d, want stable %d", shardCount, store.DispatchOutboxLogicalShards)
}
if limit <= 0 {
limit = 100
}
if limit > 1000 {
limit = 1000
}
ids := make([]int16, 0, len(shardIDs))
seen := make(map[int]struct{}, len(shardIDs))
for _, id := range shardIDs {
if id < 0 || id >= shardCount {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, int16(id))
}
if len(ids) == 0 {
return nil, nil
}
rows, err := s.q.ClaimDispatchOutboxShards(ctx, sqlcgen.ClaimDispatchOutboxShardsParams{
LeaseSeconds: s.leaseSeconds,
LimitCount: int32(limit),
ShardIds: ids,
})
if err != nil {
return nil, fmt.Errorf("claim dispatch outbox shards: %w", err)
}
out := make([]store.DispatchOutboxItem, 0, len(rows))
for _, row := range rows {
out = append(out, store.DispatchOutboxItem{
@ -78,6 +124,22 @@ func (s *DispatchOutboxStore) ClaimPending(ctx context.Context, limit int) ([]st
return out, nil
}
func dispatchItemsFromClaimRows(rows []sqlcgen.ClaimDispatchOutboxRow) []store.DispatchOutboxItem {
out := make([]store.DispatchOutboxItem, 0, len(rows))
for _, row := range rows {
out = append(out, store.DispatchOutboxItem{
ID: row.ID,
TargetUserID: row.TargetUserID,
Pts: int(row.Pts),
EventType: domain.UpdateEventType(row.EventType),
ExcludeAuthKeyID: authKeyIDFromInt64(row.ExcludeAuthKeyID),
ExcludeSessionID: row.ExcludeSessionID,
Attempts: int(row.Attempts),
})
}
return out
}
// MarkDeliveredBatch 一次性删除一批已投递的 outbox 行(方案 A:投递成功即删),取代逐条 MarkDelivered。
func (s *DispatchOutboxStore) MarkDeliveredBatch(ctx context.Context, items []store.DispatchOutboxItem) error {
if len(items) == 0 {
@ -85,49 +147,66 @@ func (s *DispatchOutboxStore) MarkDeliveredBatch(ctx context.Context, items []st
}
targetUserIDs := make([]int64, len(items))
ids := make([]int64, len(items))
expectedAttempts := make([]int32, len(items))
for i, it := range items {
targetUserIDs[i] = it.TargetUserID
ids[i] = it.ID
expectedAttempts[i] = int32(it.Attempts)
}
if err := s.q.MarkDispatchDeliveredBatch(ctx, sqlcgen.MarkDispatchDeliveredBatchParams{
TargetUserIds: targetUserIDs,
Ids: ids,
}); err != nil {
rows, err := s.q.MarkDispatchDeliveredBatch(ctx, sqlcgen.MarkDispatchDeliveredBatchParams{
TargetUserIds: targetUserIDs,
Ids: ids,
ExpectedAttempts: expectedAttempts,
})
if err != nil {
return fmt.Errorf("mark dispatch delivered batch: %w", err)
}
return nil
}
func (s *DispatchOutboxStore) MarkDelivered(ctx context.Context, targetUserID, id int64) error {
if err := s.q.MarkDispatchDelivered(ctx, sqlcgen.MarkDispatchDeliveredParams{
TargetUserID: targetUserID,
ID: id,
}); err != nil {
return fmt.Errorf("mark dispatch delivered: %w", err)
if rows != int64(len(items)) {
return fmt.Errorf("mark dispatch delivered batch: %w: updated %d of %d", store.ErrDispatchLeaseLost, rows, len(items))
}
return nil
}
func (s *DispatchOutboxStore) MarkFailed(ctx context.Context, targetUserID, id int64, lastError string) error {
if err := s.q.MarkDispatchFailed(ctx, sqlcgen.MarkDispatchFailedParams{
TargetUserID: targetUserID,
ID: id,
LastError: lastError,
}); err != nil {
func (s *DispatchOutboxStore) MarkDelivered(ctx context.Context, item store.DispatchOutboxItem) error {
rows, err := s.q.MarkDispatchDelivered(ctx, sqlcgen.MarkDispatchDeliveredParams{
TargetUserID: item.TargetUserID,
ID: item.ID,
ExpectedAttempts: int32(item.Attempts),
})
if err != nil {
return fmt.Errorf("mark dispatch delivered: %w", err)
}
if rows != 1 {
return fmt.Errorf("mark dispatch delivered: %w", store.ErrDispatchLeaseLost)
}
return nil
}
func (s *DispatchOutboxStore) MarkFailed(ctx context.Context, item store.DispatchOutboxItem, lastError string) error {
rows, err := s.q.MarkDispatchFailed(ctx, sqlcgen.MarkDispatchFailedParams{
TargetUserID: item.TargetUserID,
ID: item.ID,
LastError: lastError,
ExpectedAttempts: int32(item.Attempts),
})
if err != nil {
return fmt.Errorf("mark dispatch failed: %w", err)
}
if rows != 1 {
return fmt.Errorf("mark dispatch failed: %w", store.ErrDispatchLeaseLost)
}
return nil
}
func (s *DispatchOutboxStore) DeleteFailed(ctx context.Context, olderThan time.Duration, limit int) (int, error) {
if olderThan <= 0 {
olderThan = 24 * time.Hour
olderThan = time.Minute
}
if limit <= 0 {
limit = 10000
limit = defaultDispatchPoisonCleanupBatch
}
if limit > 100000 {
limit = 100000
if limit > maxDispatchPoisonCleanupBatch {
limit = maxDispatchPoisonCleanupBatch
}
deleted, err := s.q.DeleteFailedDispatchOutbox(ctx, sqlcgen.DeleteFailedDispatchOutboxParams{
OlderThanSeconds: int32(olderThan / time.Second),

View file

@ -0,0 +1,352 @@
package postgres
import (
"context"
"errors"
"sync"
"testing"
"time"
"telesrv/internal/domain"
storepkg "telesrv/internal/store"
)
func TestDispatchOutboxUserHeadBlocksHigherPts(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
owner := createTestUser(t, ctx, NewUserStore(pool), "+1884"+suffix+"01", "OutboxHead", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
})
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer func() { _ = tx.Rollback(ctx) }()
// ClaimPending is intentionally global. Isolate this transaction from durable tasks left by
// earlier integration tests; the rollback restores those rows after this case completes.
if _, err := tx.Exec(ctx, `DELETE FROM dispatch_outbox`); err != nil {
t.Fatalf("isolate dispatch outbox: %v", err)
}
events := NewUpdateEventStore(tx)
outbox := NewDispatchOutboxStore(tx, WithLeaseTimeout(time.Hour))
appendEvent := func() int {
t.Helper()
event, err := events.AppendAllocatedWithDispatch(ctx, owner.ID, domain.UpdateEvent{
Type: domain.UpdateEventDialogPinned,
PtsCount: 1,
Date: 1700002000,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
Bool: true,
}, [8]byte{}, 0)
if err != nil {
t.Fatalf("append event: %v", err)
}
return event.Pts
}
shard := int(owner.ID % int64(storepkg.DispatchOutboxLogicalShards))
pts1, pts2 := appendEvent(), appendEvent()
assertDispatchHead := func(wantPts int) {
t.Helper()
var gotPts int
err := tx.QueryRow(ctx, `
SELECT head_pts
FROM dispatch_outbox_user_heads
WHERE target_user_id = $1
`, owner.ID).Scan(&gotPts)
if err != nil {
t.Fatalf("load durable dispatch head: %v", err)
}
if gotPts != wantPts {
t.Fatalf("durable dispatch head pts = %d, want %d", gotPts, wantPts)
}
}
assertDispatchHead(pts1)
wrongShard := (shard + 1) % storepkg.DispatchOutboxLogicalShards
if wrong, err := outbox.ClaimPendingShards(ctx, storepkg.DispatchOutboxLogicalShards, []int{wrongShard}, 100); err != nil || len(wrong) != 0 {
t.Fatalf("wrong-shard claim = %+v err=%v, want empty", wrong, err)
}
claimed, err := outbox.ClaimPending(ctx, 100)
if err != nil {
t.Fatalf("claim head: %v", err)
}
if len(claimed) != 1 || claimed[0].TargetUserID != owner.ID || claimed[0].Pts != pts1 {
t.Fatalf("first claim = %+v, want only pts %d", claimed, pts1)
}
if blocked, err := outbox.ClaimPending(ctx, 100); err != nil || len(blocked) != 0 {
t.Fatalf("claim behind live dispatching head = %+v err=%v, want empty (pts %d blocked)", blocked, err, pts2)
}
if blocked, err := outbox.ClaimPendingShards(ctx, storepkg.DispatchOutboxLogicalShards, []int{shard}, 100); err != nil || len(blocked) != 0 {
t.Fatalf("shard claim behind live dispatching head = %+v err=%v, want empty", blocked, err)
}
if _, err := tx.Exec(ctx, `UPDATE dispatch_outbox SET updated_at = now() - interval '2 hours' WHERE target_user_id = $1 AND id = $2`, owner.ID, claimed[0].ID); err != nil {
t.Fatalf("age dispatch lease: %v", err)
}
reclaimed, err := outbox.ClaimPending(ctx, 100)
if err != nil {
t.Fatalf("reclaim stale head: %v", err)
}
if len(reclaimed) != 1 || reclaimed[0].Pts != pts1 || reclaimed[0].Attempts != 2 {
t.Fatalf("stale reclaim = %+v, want pts %d attempts 2", reclaimed, pts1)
}
if err := outbox.MarkDelivered(ctx, claimed[0]); !errors.Is(err, storepkg.ErrDispatchLeaseLost) {
t.Fatalf("old lease delivered err = %v, want ErrDispatchLeaseLost", err)
}
if err := outbox.MarkFailed(ctx, claimed[0], "stale worker"); !errors.Is(err, storepkg.ErrDispatchLeaseLost) {
t.Fatalf("old lease failed err = %v, want ErrDispatchLeaseLost", err)
}
var fencedStatus string
var fencedAttempts int
if err := tx.QueryRow(ctx, `SELECT status, attempts FROM dispatch_outbox WHERE target_user_id = $1 AND id = $2`, owner.ID, reclaimed[0].ID).Scan(&fencedStatus, &fencedAttempts); err != nil {
t.Fatalf("load fenced head: %v", err)
}
if fencedStatus != "dispatching" || fencedAttempts != 2 {
t.Fatalf("fenced head = status %s attempts %d, want dispatching/2", fencedStatus, fencedAttempts)
}
if err := outbox.MarkDelivered(ctx, reclaimed[0]); err != nil {
t.Fatalf("deliver head: %v", err)
}
assertDispatchHead(pts2)
next, err := outbox.ClaimPendingShards(ctx, storepkg.DispatchOutboxLogicalShards, []int{shard}, 100)
if err != nil {
t.Fatalf("claim next after head delivered: %v", err)
}
if len(next) != 1 || next[0].Pts != pts2 {
t.Fatalf("next claim = %+v, want pts %d", next, pts2)
}
if err := outbox.MarkDelivered(ctx, next[0]); err != nil {
t.Fatalf("deliver second: %v", err)
}
var remainingHeads int
if err := tx.QueryRow(ctx, `SELECT count(*) FROM dispatch_outbox_user_heads WHERE target_user_id = $1`, owner.ID).Scan(&remainingHeads); err != nil {
t.Fatalf("count durable dispatch heads: %v", err)
}
if remainingHeads != 0 {
t.Fatalf("durable dispatch heads after lane drain = %d, want 0", remainingHeads)
}
pts3, pts4 := appendEvent(), appendEvent()
head, err := outbox.ClaimPendingShards(ctx, storepkg.DispatchOutboxLogicalShards, []int{shard}, 100)
if err != nil {
t.Fatalf("claim terminal-failure head: %v", err)
}
if len(head) != 1 || head[0].Pts != pts3 {
t.Fatalf("terminal head = %+v, want pts %d", head, pts3)
}
if _, err := tx.Exec(ctx, `UPDATE dispatch_outbox SET status = 'dispatching', attempts = 5 WHERE target_user_id = $1 AND id = $2`, owner.ID, head[0].ID); err != nil {
t.Fatalf("prepare terminal failure: %v", err)
}
head[0].Attempts = 5
if err := outbox.MarkFailed(ctx, head[0], "permanent"); err != nil {
t.Fatalf("mark terminal failed: %v", err)
}
if got, err := outbox.ClaimPending(ctx, 100); err != nil || len(got) != 0 {
t.Fatalf("global claim behind failed head = %+v err=%v, want empty (pts %d blocked)", got, err, pts4)
}
if got, err := outbox.ClaimPendingShards(ctx, storepkg.DispatchOutboxLogicalShards, []int{shard}, 100); err != nil || len(got) != 0 {
t.Fatalf("shard claim behind failed head = %+v err=%v, want empty", got, err)
}
if _, err := tx.Exec(ctx, `UPDATE dispatch_outbox SET updated_at = now() - interval '2 minutes' WHERE target_user_id = $1 AND id = $2`, owner.ID, head[0].ID); err != nil {
t.Fatalf("age poison head: %v", err)
}
if deleted, err := outbox.DeleteFailed(ctx, time.Minute, 1); err != nil || deleted != 1 {
t.Fatalf("delete quarantined failed head = %d err=%v, want 1", deleted, err)
}
var durablePoisonEvent int
if err := tx.QueryRow(ctx, `SELECT count(*) FROM user_update_events WHERE user_id = $1 AND pts = $2`, owner.ID, pts3).Scan(&durablePoisonEvent); err != nil || durablePoisonEvent != 1 {
t.Fatalf("durable poison event count = %d err=%v, want 1 for difference recovery", durablePoisonEvent, err)
}
assertDispatchHead(pts4)
unblocked, err := outbox.ClaimPendingShards(ctx, storepkg.DispatchOutboxLogicalShards, []int{shard}, 100)
if err != nil {
t.Fatalf("claim after failed cleanup: %v", err)
}
if len(unblocked) != 1 || unblocked[0].Pts != pts4 {
t.Fatalf("claim after failed cleanup = %+v, want pts %d", unblocked, pts4)
}
if err := outbox.MarkDelivered(ctx, unblocked[0]); err != nil {
t.Fatalf("deliver unblocked: %v", err)
}
pts5 := appendEvent()
tag, err := tx.Exec(ctx, `
INSERT INTO dispatch_outbox (target_user_id, pts, event_type)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
`, owner.ID, pts5, string(domain.UpdateEventDialogPinned))
if err != nil {
t.Fatalf("duplicate enqueue: %v", err)
}
if tag.RowsAffected() != 0 {
t.Fatalf("duplicate enqueue rows = %d, want 0 from (user,pts) unique key", tag.RowsAffected())
}
var taskCount int
if err := tx.QueryRow(ctx, `SELECT count(*) FROM dispatch_outbox WHERE target_user_id = $1 AND pts = $2`, owner.ID, pts5).Scan(&taskCount); err != nil || taskCount != 1 {
t.Fatalf("duplicate task count = %d err=%v, want 1", taskCount, err)
}
if _, err := outbox.ClaimPendingShards(ctx, storepkg.DispatchOutboxLogicalShards-1, []int{shard}, 1); err == nil {
t.Fatal("unstable shard count accepted")
}
}
func TestDispatchOutboxShardClaimersAreMutuallyExclusive(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
// This case claims through the real pool from two concurrent transactions. Clear stale tasks
// left by unrelated cases so the assertion measures this one user lane, not suite order.
if _, err := pool.Exec(ctx, `DELETE FROM dispatch_outbox`); err != nil {
t.Fatalf("isolate dispatch outbox: %v", err)
}
suffix := randomSuffix(t)
owner := createTestUser(t, ctx, NewUserStore(pool), "+1885"+suffix+"01", "OutboxLane", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = $1", owner.ID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
})
if _, err := NewUpdateEventStore(pool).AppendAllocatedWithDispatch(ctx, owner.ID, domain.UpdateEvent{
Type: domain.UpdateEventDialogPinned,
PtsCount: 1,
Date: 1700002100,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
Bool: true,
}, [8]byte{}, 0); err != nil {
t.Fatalf("append event: %v", err)
}
outbox := NewDispatchOutboxStore(pool, WithLeaseTimeout(time.Hour))
shard := int(owner.ID % int64(storepkg.DispatchOutboxLogicalShards))
start := make(chan struct{})
results := make(chan []storepkg.DispatchOutboxItem, 2)
errs := make(chan error, 2)
var wg sync.WaitGroup
for range 2 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
items, err := outbox.ClaimPendingShards(ctx, storepkg.DispatchOutboxLogicalShards, []int{shard}, 1)
if err != nil {
errs <- err
return
}
results <- items
}()
}
close(start)
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("concurrent shard claim: %v", err)
}
claimed := 0
for items := range results {
claimed += len(items)
}
if claimed != 1 {
t.Fatalf("concurrent claimed rows = %d, want exactly one user head", claimed)
}
}
func TestDispatchOutboxLeaseExpiryAndBatchCompletionShareLockOrderPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
if _, err := pool.Exec(ctx, `DELETE FROM dispatch_outbox`); err != nil {
t.Fatalf("isolate dispatch outbox: %v", err)
}
suffix := randomSuffix(t)
users := NewUserStore(pool)
first := createTestUser(t, ctx, users, "+1886"+suffix+"01", "OutboxLockA", "")
second := createTestUser(t, ctx, users, "+1886"+suffix+"02", "OutboxLockB", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])`, []int64{first.ID, second.ID})
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1::bigint[])`, []int64{first.ID, second.ID})
})
events := NewUpdateEventStore(pool)
outbox := NewDispatchOutboxStore(pool, WithLeaseTimeout(time.Second))
for round := 0; round < 20; round++ {
for _, userID := range []int64{first.ID, second.ID} {
if _, err := events.AppendAllocatedWithDispatch(ctx, userID, domain.UpdateEvent{
Type: domain.UpdateEventDialogPinned, PtsCount: 1, Date: 1_700_030_000 + round,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID}, Bool: true,
}, [8]byte{}, 0); err != nil {
t.Fatalf("round %d append user %d: %v", round, userID, err)
}
}
claimed, err := outbox.ClaimPending(ctx, 2)
if err != nil || len(claimed) != 2 {
t.Fatalf("round %d initial claim = %+v err=%v, want 2", round, claimed, err)
}
if _, err := pool.Exec(ctx, `
UPDATE dispatch_outbox
SET updated_at = now() - interval '2 seconds'
WHERE (target_user_id, id) IN (($1, $2), ($3, $4))
`, claimed[0].TargetUserID, claimed[0].ID, claimed[1].TargetUserID, claimed[1].ID); err != nil {
t.Fatalf("round %d age leases: %v", round, err)
}
reversed := []storepkg.DispatchOutboxItem{claimed[1], claimed[0]}
start := make(chan struct{})
errs := make(chan error, 2)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
<-start
_, claimErr := outbox.ClaimPending(ctx, 2)
errs <- claimErr
}()
go func() {
defer wg.Done()
<-start
markErr := outbox.MarkDeliveredBatch(ctx, reversed)
if errors.Is(markErr, storepkg.ErrDispatchLeaseLost) {
markErr = nil
}
errs <- markErr
}()
close(start)
wg.Wait()
close(errs)
for raceErr := range errs {
if raceErr != nil {
t.Fatalf("round %d lease/completion race: %v", round, raceErr)
}
}
if _, err := pool.Exec(ctx, `DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])`, []int64{first.ID, second.ID}); err != nil {
t.Fatalf("round %d drain raced rows: %v", round, err)
}
}
}
func TestDispatchOutboxDurableHeadRejectsStaleRowReference(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
owner := createTestUser(t, ctx, NewUserStore(pool), "+1886"+suffix+"01", "OutboxHeadFK", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = $1", owner.ID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
})
if _, err := NewUpdateEventStore(pool).AppendAllocatedWithDispatch(ctx, owner.ID, domain.UpdateEvent{
Type: domain.UpdateEventDialogPinned, PtsCount: 1, Date: 1700002200,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, Bool: true,
}, [8]byte{}, 0); err != nil {
t.Fatalf("append event: %v", err)
}
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `UPDATE dispatch_outbox_user_heads SET head_id = head_id + 1000000 WHERE target_user_id = $1`, owner.ID); err != nil {
t.Fatalf("stage stale head: %v", err)
}
if _, err := tx.Exec(ctx, `SET CONSTRAINTS dispatch_outbox_user_heads_outbox_fkey IMMEDIATE`); err == nil {
t.Fatal("stale durable head reference unexpectedly satisfied deferred FK")
}
}

View file

@ -15,13 +15,25 @@ import (
func TestGroupCallStoreContractPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
var nextChannel int64 = 910_000_000
// A run-unique namespace keeps filtered subtest runs independent from stale
// rows left by an interrupted older run. Per-subtest cleanup below still makes
// successful runs leave no state behind.
var nextChannel = int64(1_000_000_000 + time.Now().UnixNano()%100_000_000)
storetest.RunGroupCallStoreContract(t, func(t *testing.T) (store.GroupCallStore, int64) {
nextChannel++
channelID := nextChannel
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM group_calls WHERE channel_id = $1", channelID)
})
// Conference contract rows have channel_id=0 and derive their call IDs from
// this synthetic channel namespace. Clean both shapes before and after each
// subtest so a previous failed run cannot feed discarded calls into the next
// run (and so conference invite/chain rows cascade away as well).
cleanup := func() {
_, _ = pool.Exec(ctx, `
DELETE FROM group_calls
WHERE channel_id = $1
OR (call_id >= $1 * 100 AND call_id < $1 * 100 + 100)`, channelID)
}
cleanup()
t.Cleanup(cleanup)
return NewGroupCallStore(pool), channelID
})
}

View file

@ -0,0 +1,342 @@
package postgres
import (
"context"
"encoding/binary"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// The two-int advisory-lock namespace is disjoint from the one-bigint user
// locks used by lockUsersForUpdate. Only 32 digest bits are needed here:
// collisions merely serialize unrelated deliveries and cannot merge receipts.
const loginCodeDeliveryAdvisoryNamespace int32 = 0x4c434f44 // "LCOD"
const (
loginCodeDeliveryRecoveryTimeout = 2 * time.Second
loginCodeDeliveryRecoveryPoll = 20 * time.Millisecond
)
type loginCodeDeliveryReceiptQuerier interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
type loginCodeDeliveryReceipt struct {
userID int64
codeFingerprint []byte
privateMessageID int64
messageBoxID int
pts int
messageDate int
}
// DeliverLoginCodeMessage commits the account-visible 777000 message, dialog
// projection, user pts event, dispatch outbox row and compact idempotency
// receipt in one transaction. The raw phone_code_hash is never persisted.
func (s *MessageStore) DeliverLoginCodeMessage(ctx context.Context, req domain.LoginCodeDeliveryRequest) (domain.LoginCodeDeliveryResult, error) {
deliveryKey, err := store.LoginCodeDeliveryKey(req.PhoneCodeHash)
if err != nil {
return domain.LoginCodeDeliveryResult{}, err
}
codeFingerprint, err := store.LoginCodeFingerprint(req.PhoneCodeHash, req.Code)
if err != nil {
return domain.LoginCodeDeliveryResult{}, err
}
if req.Date == 0 {
req.Date = int(time.Now().Unix())
}
if req.ExpiresAt <= int64(req.Date) {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("login code receipt expiry: %w: date=%d expires_at=%d", domain.ErrLoginCodeDeliveryInvalid, req.Date, req.ExpiresAt)
}
base, err := domain.OfficialLoginCodeMessage(req.UserID, req.Code, req.Date)
if err != nil {
return domain.LoginCodeDeliveryResult{}, err
}
entitiesJSON, err := encodeMessageEntities(base.Entities)
if err != nil {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("encode login code entities: %w", err)
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("deliver login code: database does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("begin login code delivery: %w", err)
}
committed := false
defer func() {
if !committed {
rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginCodeDeliveryRecoveryTimeout)
defer cancel()
_ = tx.Rollback(rollbackCtx)
}
}()
// Serialize the global idempotency key before any per-user row/advisory
// lock. This makes same-key concurrent calls deterministic even if a caller
// accidentally supplies a different user ID.
lockKey := int32(binary.BigEndian.Uint32(deliveryKey[:4]))
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1::integer, $2::integer)`, loginCodeDeliveryAdvisoryNamespace, lockKey); err != nil {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("lock login code delivery: %w", err)
}
receipt, found, err := getLoginCodeDeliveryReceipt(ctx, tx, deliveryKey)
if err != nil {
return domain.LoginCodeDeliveryResult{}, err
}
if found {
if receipt.userID != req.UserID || !store.SameLoginCodeFingerprint(receipt.codeFingerprint, codeFingerprint) {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("deliver login code replay: %w", domain.ErrLoginCodeDeliveryConflict)
}
msg, err := store.RestoreLoginCodeDeliveryMessage(
receipt.userID,
req.Code,
receipt.messageDate,
receipt.privateMessageID,
receipt.messageBoxID,
receipt.pts,
)
if err != nil {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("restore login code replay: %w", err)
}
return domain.LoginCodeDeliveryResult{Message: msg, Created: false}, nil
}
// All user-scoped message/update writers share this lock and acquire it
// before watermark/dialog rows, keeping box IDs and pts contiguous.
if err := lockUsersForUpdate(ctx, tx, req.UserID); err != nil {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("lock login code recipient: %w", err)
}
if err := ensureOfficialSystemUserWithDB(ctx, tx, base); err != nil {
return domain.LoginCodeDeliveryResult{}, err
}
qtx := sqlcgen.New(tx)
pm, err := qtx.CreatePrivateMessage(ctx, sqlcgen.CreatePrivateMessageParams{
SenderUserID: domain.OfficialSystemUserID,
RecipientUserID: req.UserID,
RandomID: 0,
MessageDate: int32(base.Date),
Body: base.Body,
RequestFingerprint: []byte{},
RecipientDelivered: true,
EntitiesJson: entitiesJSON,
QuoteEntitiesJson: []byte("[]"),
MediaJson: []byte("{}"),
ReplyMarkupJson: []byte("{}"),
RichMessageJson: []byte("{}"),
})
if err != nil {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("create login code private message: %w", err)
}
boxID, err := s.nextLoginCodeBoxID(ctx, qtx, req.UserID)
if err != nil {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("allocate login code box id: %w", err)
}
if boxID <= 0 || boxID > domain.MaxMessageBoxID {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("allocate login code box id: %w: %d", domain.ErrLoginCodeDeliveryInvalid, boxID)
}
pts, err := s.reservePts(ctx, tx, req.UserID)
if err != nil {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("allocate login code pts: %w", err)
}
boxRow, err := qtx.CreateMessageBox(ctx, sqlcgen.CreateMessageBoxParams{
OwnerUserID: req.UserID,
BoxID: int32(boxID),
PrivateMessageID: pm.ID,
MessageSenderID: domain.OfficialSystemUserID,
PeerType: string(domain.PeerTypeUser),
PeerID: domain.OfficialSystemUserID,
FromUserID: domain.OfficialSystemUserID,
MessageDate: int32(base.Date),
Outgoing: false,
Body: base.Body,
EntitiesJson: entitiesJSON,
QuoteEntitiesJson: []byte("[]"),
Pts: int32(pts),
MediaJson: []byte("{}"),
ReplyMarkupJson: []byte("{}"),
RichMessageJson: []byte("{}"),
})
if err != nil {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("create login code recipient box: %w", err)
}
msg := messageFromBoxRow(boxRow)
if err := qtx.UpsertInboxDialog(ctx, sqlcgen.UpsertInboxDialogParams{
UserID: req.UserID,
PeerType: string(domain.PeerTypeUser),
PeerID: domain.OfficialSystemUserID,
TopMessageID: int32(msg.ID),
TopMessageDate: int32(msg.Date),
}); err != nil {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("upsert login code dialog: %w", err)
}
if err := appendNewMessageEvent(ctx, qtx, msg); err != nil {
return domain.LoginCodeDeliveryResult{}, err
}
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
TargetUserID: req.UserID,
Pts: int32(msg.Pts),
EventType: string(domain.UpdateEventNewMessage),
ExcludeAuthKeyID: 0,
ExcludeSessionID: 0,
}); err != nil {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("enqueue login code dispatch: %w", err)
}
tag, err := tx.Exec(ctx, `
UPDATE private_messages
SET recipient_box_id = $3,
recipient_pts = $4
WHERE sender_user_id = $1
AND id = $2
AND recipient_delivered
AND recipient_box_id = 0
AND recipient_pts = 0`, domain.OfficialSystemUserID, pm.ID, msg.ID, msg.Pts)
if err != nil {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("save login code private receipt: %w", err)
}
if tag.RowsAffected() != 1 {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("save login code private receipt: message %d lost its allocation boundary", pm.ID)
}
if _, err := tx.Exec(ctx, `
INSERT INTO login_code_message_deliveries (
delivery_key,
code_fingerprint,
user_id,
private_message_id,
message_box_id,
pts,
message_date,
expires_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
deliveryKey[:], codeFingerprint[:], req.UserID, msg.UID, msg.ID, msg.Pts, msg.Date, time.Unix(req.ExpiresAt, 0).UTC(),
); err != nil {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("save login code delivery receipt: %w", err)
}
if err := tx.Commit(ctx); err != nil {
result, recoverErr := s.recoverLoginCodeDeliveryAfterCommitError(ctx, req, deliveryKey, codeFingerprint)
if recoverErr != nil {
return domain.LoginCodeDeliveryResult{}, errors.Join(
fmt.Errorf("commit login code delivery: %w", err),
recoverErr,
)
}
committed = true
return result, nil
}
committed = true
return domain.LoginCodeDeliveryResult{Message: msg, Created: true}, nil
}
func (s *MessageStore) recoverLoginCodeDeliveryAfterCommitError(
ctx context.Context,
req domain.LoginCodeDeliveryRequest,
deliveryKey, codeFingerprint [32]byte,
) (domain.LoginCodeDeliveryResult, error) {
probeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loginCodeDeliveryRecoveryTimeout)
defer cancel()
ticker := time.NewTicker(loginCodeDeliveryRecoveryPoll)
defer ticker.Stop()
for {
receipt, found, err := getLoginCodeDeliveryReceipt(probeCtx, s.db, deliveryKey)
if err != nil {
return domain.LoginCodeDeliveryResult{}, errors.Join(
domain.ErrLoginCodeDeliveryCommitAmbiguous,
fmt.Errorf("probe login code delivery receipt after commit error: %w", err),
)
}
if found {
if receipt.userID != req.UserID || !store.SameLoginCodeFingerprint(receipt.codeFingerprint, codeFingerprint) {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("probe login code delivery receipt after commit error: %w", domain.ErrLoginCodeDeliveryConflict)
}
msg, err := store.RestoreLoginCodeDeliveryMessage(
receipt.userID,
req.Code,
receipt.messageDate,
receipt.privateMessageID,
receipt.messageBoxID,
receipt.pts,
)
if err != nil {
return domain.LoginCodeDeliveryResult{}, errors.Join(
domain.ErrLoginCodeDeliveryCommitAmbiguous,
fmt.Errorf("restore probed login code delivery: %w", err),
)
}
// The receipt proves durable success but cannot prove whether this
// caller or an equivalent replay won the commit race.
return domain.LoginCodeDeliveryResult{Message: msg, Created: false}, nil
}
select {
case <-probeCtx.Done():
return domain.LoginCodeDeliveryResult{}, errors.Join(
domain.ErrLoginCodeDeliveryCommitAmbiguous,
fmt.Errorf("probe login code delivery receipt after commit error: %w", probeCtx.Err()),
)
case <-ticker.C:
}
}
}
func getLoginCodeDeliveryReceipt(ctx context.Context, q loginCodeDeliveryReceiptQuerier, deliveryKey [32]byte) (loginCodeDeliveryReceipt, bool, error) {
var receipt loginCodeDeliveryReceipt
var boxID, pts, messageDate int32
err := q.QueryRow(ctx, `
SELECT user_id,
code_fingerprint,
private_message_id,
message_box_id,
pts,
message_date
FROM login_code_message_deliveries
WHERE delivery_key = $1`, deliveryKey[:]).Scan(
&receipt.userID,
&receipt.codeFingerprint,
&receipt.privateMessageID,
&boxID,
&pts,
&messageDate,
)
if errors.Is(err, pgx.ErrNoRows) {
return loginCodeDeliveryReceipt{}, false, nil
}
if err != nil {
return loginCodeDeliveryReceipt{}, false, fmt.Errorf("load login code delivery receipt: %w", err)
}
receipt.messageBoxID = int(boxID)
receipt.pts = int(pts)
receipt.messageDate = int(messageDate)
return receipt, true, nil
}
func (s *MessageStore) nextLoginCodeBoxID(ctx context.Context, qtx *sqlcgen.Queries, userID int64) (int, error) {
// The default allocator queries PostgreSQL. Run that query on the active
// transaction connection: querying s.q while holding the transaction can
// deadlock a MaxConns=1 pool. External allocators (Redis/counters) retain
// their normal semantics.
switch s.boxIDs.(type) {
case pgBoxIDAllocator, *pgBoxIDAllocator:
current, err := qtx.MaxMessageBoxID(ctx, userID)
if err != nil {
return 0, err
}
return int(current) + 1, nil
default:
return s.boxIDs.NextBoxID(ctx, userID)
}
}

View file

@ -0,0 +1,475 @@
package postgres
import (
"context"
"errors"
"fmt"
"reflect"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func TestLoginCodeDeliveryPostgresAtomicFactsAndReplay(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
user := createLoginCodeDeliveryTestUser(t, ctx, pool, "basic")
req := domain.LoginCodeDeliveryRequest{
UserID: user.ID,
PhoneCodeHash: "pg-login-code-basic-" + randomSuffix(t),
Code: "12345",
Date: 1700001000,
ExpiresAt: 1700001300,
}
first, err := NewMessageStore(pool).DeliverLoginCodeMessage(ctx, req)
if err != nil {
t.Fatalf("DeliverLoginCodeMessage: %v", err)
}
if !first.Created || first.Message.ID != 1 || first.Message.Pts != 1 || first.Message.UID <= 0 || first.Message.Out ||
first.Message.OwnerUserID != user.ID || first.Message.Peer.ID != domain.OfficialSystemUserID || first.Message.From.ID != domain.OfficialSystemUserID {
t.Fatalf("first delivery = %+v, want first incoming 777000 message", first)
}
assertLoginCodeDeliveryFacts(t, ctx, pool, user.ID, first.Message, 1)
var senderUserID, recipientUserID, randomID int64
var delivered bool
var senderBoxID, senderPts, recipientBoxID, recipientPts int32
if err := pool.QueryRow(ctx, `
SELECT sender_user_id,
recipient_user_id,
random_id,
recipient_delivered,
sender_box_id,
sender_pts,
recipient_box_id,
recipient_pts
FROM private_messages
WHERE sender_user_id = $1 AND id = $2`, domain.OfficialSystemUserID, first.Message.UID).Scan(
&senderUserID,
&recipientUserID,
&randomID,
&delivered,
&senderBoxID,
&senderPts,
&recipientBoxID,
&recipientPts,
); err != nil {
t.Fatalf("load private message receipt: %v", err)
}
if senderUserID != domain.OfficialSystemUserID || recipientUserID != user.ID || randomID != 0 || !delivered ||
senderBoxID != 0 || senderPts != 0 || int(recipientBoxID) != first.Message.ID || int(recipientPts) != first.Message.Pts {
t.Fatalf("private receipt sender=%d recipient=%d random=%d delivered=%v sender=%d/%d recipient=%d/%d",
senderUserID, recipientUserID, randomID, delivered, senderBoxID, senderPts, recipientBoxID, recipientPts)
}
var officialSenderBoxes int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE owner_user_id = $1 AND private_message_id = $2`, domain.OfficialSystemUserID, first.Message.UID).Scan(&officialSenderBoxes); err != nil {
t.Fatalf("count official sender boxes: %v", err)
}
if officialSenderBoxes != 0 {
t.Fatalf("official sender boxes = %d, want recipient-only login notification", officialSenderBoxes)
}
var deliveryKey, codeFingerprint []byte
if err := pool.QueryRow(ctx, `
SELECT delivery_key, code_fingerprint
FROM login_code_message_deliveries
WHERE user_id = $1 AND message_box_id = $2`, user.ID, first.Message.ID).Scan(&deliveryKey, &codeFingerprint); err != nil {
t.Fatalf("load compact receipt: %v", err)
}
if len(deliveryKey) != 32 || len(codeFingerprint) != 32 || string(deliveryKey) == req.PhoneCodeHash {
t.Fatalf("compact receipt key/fingerprint lengths = %d/%d", len(deliveryKey), len(codeFingerprint))
}
replayReq := req
replayReq.Date += 99
replay, err := NewMessageStore(pool).DeliverLoginCodeMessage(ctx, replayReq)
if err != nil {
t.Fatalf("replay DeliverLoginCodeMessage: %v", err)
}
if replay.Created || !reflect.DeepEqual(replay.Message, first.Message) {
t.Fatalf("replay = %+v, want immutable first result %+v", replay, first)
}
assertLoginCodeDeliveryFacts(t, ctx, pool, user.ID, first.Message, 1)
changedCode := req
changedCode.Code = "54321"
if _, err := NewMessageStore(pool).DeliverLoginCodeMessage(ctx, changedCode); !errors.Is(err, domain.ErrLoginCodeDeliveryConflict) {
t.Fatalf("changed-code replay err = %v, want ErrLoginCodeDeliveryConflict", err)
}
assertLoginCodeDeliveryFacts(t, ctx, pool, user.ID, first.Message, 1)
otherUser := createLoginCodeDeliveryTestUser(t, ctx, pool, "conflict")
changedUser := req
changedUser.UserID = otherUser.ID
if _, err := NewMessageStore(pool).DeliverLoginCodeMessage(ctx, changedUser); !errors.Is(err, domain.ErrLoginCodeDeliveryConflict) {
t.Fatalf("changed-user replay err = %v, want ErrLoginCodeDeliveryConflict", err)
}
var otherFacts int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE owner_user_id = $1`, otherUser.ID).Scan(&otherFacts); err != nil {
t.Fatalf("count changed-user facts: %v", err)
}
if otherFacts != 0 {
t.Fatalf("changed-user replay created %d message boxes", otherFacts)
}
}
func TestLoginCodeDeliveryPostgresConcurrentExactlyOnce(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
user := createLoginCodeDeliveryTestUser(t, ctx, pool, "concurrent")
req := domain.LoginCodeDeliveryRequest{
UserID: user.ID,
PhoneCodeHash: "pg-login-code-concurrent-" + randomSuffix(t),
Code: "24680",
Date: 1700001100,
ExpiresAt: 1700001400,
}
const workers = 24
var created atomic.Int32
results := make(chan domain.LoginCodeDeliveryResult, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
got, err := NewMessageStore(pool).DeliverLoginCodeMessage(ctx, req)
if err != nil {
errs <- err
return
}
if got.Created {
created.Add(1)
}
results <- got
}()
}
wg.Wait()
close(errs)
close(results)
for err := range errs {
t.Fatalf("concurrent delivery: %v", err)
}
if created.Load() != 1 {
t.Fatalf("created calls = %d, want exactly 1", created.Load())
}
var first domain.Message
for got := range results {
if first.ID == 0 {
first = got.Message
continue
}
if !reflect.DeepEqual(got.Message, first) {
t.Fatalf("concurrent result = %+v, want %+v", got.Message, first)
}
}
if first.ID == 0 {
t.Fatal("no successful concurrent result")
}
assertLoginCodeDeliveryFacts(t, ctx, pool, user.ID, first, 1)
}
func TestLoginCodeDeliveryPostgresCommitAckLossRecoversFromReceipt(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
user := createLoginCodeDeliveryTestUser(t, ctx, pool, "commit-ack-loss")
req := domain.LoginCodeDeliveryRequest{
UserID: user.ID,
PhoneCodeHash: "pg-login-code-commit-ack-loss-" + randomSuffix(t),
Code: "86420",
Date: int(time.Now().Unix()),
ExpiresAt: time.Now().Add(5 * time.Minute).Unix(),
}
got, err := NewMessageStore(&commitAckLossDB{Pool: pool}).DeliverLoginCodeMessage(ctx, req)
if err != nil {
t.Fatalf("DeliverLoginCodeMessage with lost commit ACK: %v", err)
}
if got.Created {
t.Fatalf("commit-ACK recovery Created = true, want conservative replay result")
}
assertLoginCodeDeliveryFacts(t, ctx, pool, user.ID, got.Message, 1)
replay, err := NewMessageStore(pool).DeliverLoginCodeMessage(ctx, req)
if err != nil {
t.Fatalf("replay after lost commit ACK: %v", err)
}
if replay.Created || !reflect.DeepEqual(replay.Message, got.Message) {
t.Fatalf("replay = %+v, want recovered snapshot %+v", replay, got)
}
}
func TestLoginCodeDeliveryPostgresDifferentUsersDoNotRewriteOfficialUser(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
firstUser := createLoginCodeDeliveryTestUser(t, ctx, pool, "official-row-first")
now := int(time.Now().Unix())
if _, err := NewMessageStore(pool).DeliverLoginCodeMessage(ctx, domain.LoginCodeDeliveryRequest{
UserID: firstUser.ID, PhoneCodeHash: "official-row-first-" + randomSuffix(t), Code: "12345", Date: now, ExpiresAt: int64(now + 300),
}); err != nil {
t.Fatalf("first delivery: %v", err)
}
var xminBefore string
if err := pool.QueryRow(ctx, `SELECT xmin::text FROM users WHERE id = $1`, domain.OfficialSystemUserID).Scan(&xminBefore); err != nil {
t.Fatalf("load official user xmin: %v", err)
}
const workers = 12
users := make([]domain.User, workers)
hashes := make([]string, workers)
for i := range users {
users[i] = createLoginCodeDeliveryTestUser(t, ctx, pool, fmt.Sprintf("official-row-%02d", i))
hashes[i] = fmt.Sprintf("official-row-concurrent-%d-%s", i, randomSuffix(t))
}
var wg sync.WaitGroup
errs := make(chan error, workers)
for i, user := range users {
wg.Add(1)
go func(i int, user domain.User) {
defer wg.Done()
_, err := NewMessageStore(pool).DeliverLoginCodeMessage(ctx, domain.LoginCodeDeliveryRequest{
UserID: user.ID, PhoneCodeHash: hashes[i], Code: "12345", Date: now, ExpiresAt: int64(now + 300),
})
if err != nil {
errs <- err
}
}(i, user)
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatalf("different-user delivery: %v", err)
}
var xminAfter string
if err := pool.QueryRow(ctx, `SELECT xmin::text FROM users WHERE id = $1`, domain.OfficialSystemUserID).Scan(&xminAfter); err != nil {
t.Fatalf("reload official user xmin: %v", err)
}
if xminAfter != xminBefore {
t.Fatalf("official system user row was rewritten: xmin %s -> %s", xminBefore, xminAfter)
}
}
func TestLoginCodeDeliveryPostgresReceiptRetentionIsBoundedAndSeekOrdered(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
now := time.Now().Truncate(time.Second)
users := []domain.User{
createLoginCodeDeliveryTestUser(t, ctx, pool, "expiry-old-1"),
createLoginCodeDeliveryTestUser(t, ctx, pool, "expiry-old-2"),
createLoginCodeDeliveryTestUser(t, ctx, pool, "expiry-future"),
}
for i, user := range users {
expiresAt := now.Add(time.Hour).Unix()
if i < 2 {
expiresAt = now.Add(time.Duration(i-2) * time.Minute).Unix()
}
if _, err := NewMessageStore(pool).DeliverLoginCodeMessage(ctx, domain.LoginCodeDeliveryRequest{
UserID: user.ID, PhoneCodeHash: fmt.Sprintf("expiry-%d-%s", i, randomSuffix(t)), Code: "12345", Date: int(now.Add(-time.Hour).Unix()), ExpiresAt: expiresAt,
}); err != nil {
t.Fatalf("seed expiry receipt %d: %v", i, err)
}
}
store := NewMessageStore(pool)
deleted, err := store.DeleteExpiredLoginCodeDeliveries(ctx, now, 1)
if err != nil || deleted != 1 {
t.Fatalf("first bounded retention = %d, %v; want 1", deleted, err)
}
deleted, err = store.DeleteExpiredLoginCodeDeliveries(ctx, now, 10)
if err != nil || deleted != 1 {
t.Fatalf("second bounded retention = %d, %v; want 1", deleted, err)
}
var receipts, messages, events int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM login_code_message_deliveries WHERE user_id = ANY($1)`, []int64{users[0].ID, users[1].ID, users[2].ID}).Scan(&receipts); err != nil {
t.Fatalf("count retained receipts: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE owner_user_id = ANY($1) AND peer_id = $2`, []int64{users[0].ID, users[1].ID, users[2].ID}, domain.OfficialSystemUserID).Scan(&messages); err != nil {
t.Fatalf("count retained messages: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM user_update_events WHERE user_id = ANY($1) AND event_type = 'new_message'`, []int64{users[0].ID, users[1].ID, users[2].ID}).Scan(&events); err != nil {
t.Fatalf("count retained events: %v", err)
}
if receipts != 1 || messages != 3 || events != 3 {
t.Fatalf("after receipt GC receipts/messages/events = %d/%d/%d, want 1/3/3", receipts, messages, events)
}
}
func TestLoginCodeDeliveryPostgresRollsBackEveryFact(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
user := createLoginCodeDeliveryTestUser(t, ctx, pool, "rollback")
first, err := NewMessageStore(pool).DeliverLoginCodeMessage(ctx, domain.LoginCodeDeliveryRequest{
UserID: user.ID,
PhoneCodeHash: "pg-login-code-rollback-first-" + randomSuffix(t),
Code: "11111",
Date: 1700001200,
ExpiresAt: 1700001500,
})
if err != nil {
t.Fatalf("seed first delivery: %v", err)
}
if first.Message.ID != 1 || first.Message.Pts != 1 {
t.Fatalf("first allocation = id %d pts %d, want 1/1", first.Message.ID, first.Message.Pts)
}
failedReq := domain.LoginCodeDeliveryRequest{
UserID: user.ID,
PhoneCodeHash: "pg-login-code-rollback-failed-" + randomSuffix(t),
Code: "22222",
Date: 1700001201,
ExpiresAt: 1700001501,
}
failing := NewMessageStore(pool, WithMessageAllocators(loginCodeFixedBoxAllocator{boxID: first.Message.ID}))
if _, err := failing.DeliverLoginCodeMessage(ctx, failedReq); err == nil {
t.Fatal("duplicate box allocator delivery succeeded, want rollback")
}
assertLoginCodeDeliveryFacts(t, ctx, pool, user.ID, first.Message, 1)
failedKey, err := store.LoginCodeDeliveryKey(failedReq.PhoneCodeHash)
if err != nil {
t.Fatalf("failed delivery key: %v", err)
}
var failedReceipts, failedBodies int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM login_code_message_deliveries WHERE delivery_key = $1`, failedKey[:]).Scan(&failedReceipts); err != nil {
t.Fatalf("count failed receipts: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM private_messages WHERE sender_user_id = $1 AND recipient_user_id = $2 AND body LIKE '%22222%'`, domain.OfficialSystemUserID, user.ID).Scan(&failedBodies); err != nil {
t.Fatalf("count failed private messages: %v", err)
}
if failedReceipts != 0 || failedBodies != 0 {
t.Fatalf("failed transaction leaked receipts=%d private_messages=%d", failedReceipts, failedBodies)
}
third, err := NewMessageStore(pool).DeliverLoginCodeMessage(ctx, domain.LoginCodeDeliveryRequest{
UserID: user.ID,
PhoneCodeHash: "pg-login-code-rollback-third-" + randomSuffix(t),
Code: "33333",
Date: 1700001202,
ExpiresAt: 1700001502,
})
if err != nil {
t.Fatalf("delivery after rollback: %v", err)
}
if third.Message.ID != 2 || third.Message.Pts != 2 {
t.Fatalf("allocation after rollback = id %d pts %d, want contiguous 2/2", third.Message.ID, third.Message.Pts)
}
}
type loginCodeFixedBoxAllocator struct {
boxID int
}
type commitAckLossDB struct {
*pgxpool.Pool
}
func (d *commitAckLossDB) Begin(ctx context.Context) (pgx.Tx, error) {
tx, err := d.Pool.Begin(ctx)
if err != nil {
return nil, err
}
return &commitAckLossTx{Tx: tx}, nil
}
type commitAckLossTx struct {
pgx.Tx
}
func (t *commitAckLossTx) Commit(ctx context.Context) error {
if err := t.Tx.Commit(ctx); err != nil {
return err
}
return errors.New("synthetic lost commit acknowledgement")
}
func (a loginCodeFixedBoxAllocator) NextBoxID(context.Context, int64) (int, error) {
return a.boxID, nil
}
func (a loginCodeFixedBoxAllocator) CurrentBoxID(context.Context, int64) (int, error) {
return a.boxID, nil
}
func createLoginCodeDeliveryTestUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool, label string) domain.User {
t.Helper()
user, err := NewUserStore(pool).Create(ctx, domain.User{
AccessHash: 8100000000,
Phone: "+1888" + randomSuffix(t),
FirstName: "LoginCode" + label,
})
if err != nil {
t.Fatalf("create login code test user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, user.ID)
})
return user
}
func assertLoginCodeDeliveryFacts(t *testing.T, ctx context.Context, pool *pgxpool.Pool, userID int64, msg domain.Message, want int) {
t.Helper()
queries := []struct {
name string
sql string
args []any
}{
{"private_messages", `SELECT count(*) FROM private_messages WHERE sender_user_id = $1 AND recipient_user_id = $2`, []any{domain.OfficialSystemUserID, userID}},
{"message_boxes", `SELECT count(*) FROM message_boxes WHERE owner_user_id = $1 AND peer_type = 'user' AND peer_id = $2`, []any{userID, domain.OfficialSystemUserID}},
{"dialogs", `SELECT count(*) FROM dialogs WHERE user_id = $1 AND peer_type = 'user' AND peer_id = $2`, []any{userID, domain.OfficialSystemUserID}},
{"user_update_events", `SELECT count(*) FROM user_update_events WHERE user_id = $1 AND event_type = 'new_message'`, []any{userID}},
{"dispatch_outbox", `SELECT count(*) FROM dispatch_outbox WHERE target_user_id = $1 AND event_type = 'new_message'`, []any{userID}},
{"delivery_receipts", `SELECT count(*) FROM login_code_message_deliveries WHERE user_id = $1`, []any{userID}},
}
for _, query := range queries {
var got int
if err := pool.QueryRow(ctx, query.sql, query.args...).Scan(&got); err != nil {
t.Fatalf("count %s: %v", query.name, err)
}
if got != want {
t.Fatalf("%s count = %d, want %d", query.name, got, want)
}
}
var boxPts, eventPts, eventBoxID, outboxPts int32
var eventType, outboxEventType string
if err := pool.QueryRow(ctx, `
SELECT b.pts,
e.pts,
e.message_box_id,
e.event_type,
o.pts,
o.event_type
FROM message_boxes b
JOIN user_update_events e
ON e.user_id = b.owner_user_id
AND e.message_box_id = b.box_id
JOIN dispatch_outbox o
ON o.target_user_id = e.user_id
AND o.pts = e.pts
WHERE b.owner_user_id = $1
AND b.box_id = $2`, userID, msg.ID).Scan(&boxPts, &eventPts, &eventBoxID, &eventType, &outboxPts, &outboxEventType); err != nil {
t.Fatalf("load login message/event/outbox chain: %v", err)
}
if int(boxPts) != msg.Pts || eventPts != boxPts || eventBoxID != int32(msg.ID) || eventType != string(domain.UpdateEventNewMessage) ||
outboxPts != eventPts || outboxEventType != eventType {
t.Fatalf("box/event/outbox chain = box_pts %d event %d/%d/%s outbox %d/%s, message=%+v",
boxPts, eventPts, eventBoxID, eventType, outboxPts, outboxEventType, msg)
}
var topMessageID, unreadCount int32
if err := pool.QueryRow(ctx, `
SELECT top_message_id, unread_count
FROM dialogs
WHERE user_id = $1 AND peer_type = 'user' AND peer_id = $2`, userID, domain.OfficialSystemUserID).Scan(&topMessageID, &unreadCount); err != nil {
t.Fatalf("load login code dialog: %v", err)
}
if int(topMessageID) != msg.ID || int(unreadCount) != want {
t.Fatalf("dialog top/unread = %d/%d, want %d/%d", topMessageID, unreadCount, msg.ID, want)
}
}

View file

@ -0,0 +1,32 @@
package postgres
import (
"context"
"fmt"
"time"
)
// DeleteExpiredLoginCodeDeliveries seek-deletes compact idempotency receipts
// whose corresponding opaque codes are no longer usable. Message/update facts
// are deliberately retained; only the replay receipt is ephemeral.
func (s *MessageStore) DeleteExpiredLoginCodeDeliveries(ctx context.Context, expiredBefore time.Time, limit int) (int, error) {
if limit <= 0 {
return 0, nil
}
tag, err := s.db.Exec(ctx, `
WITH doomed AS (
SELECT delivery_key
FROM login_code_message_deliveries
WHERE expires_at <= $1
ORDER BY expires_at, delivery_key
LIMIT $2
FOR UPDATE SKIP LOCKED
)
DELETE FROM login_code_message_deliveries AS d
USING doomed
WHERE d.delivery_key = doomed.delivery_key`, expiredBefore.UTC(), limit)
if err != nil {
return 0, fmt.Errorf("delete expired login code delivery receipts: %w", err)
}
return int(tag.RowsAffected()), nil
}

View file

@ -44,6 +44,69 @@ func bytesOrEmpty(b []byte) []byte {
var _ store.MediaStore = (*MediaStore)(nil)
func validUploadedMediaReceipt(receipt domain.UploadedMediaReceipt) bool {
if receipt.OwnerUserID == 0 || receipt.FileID == 0 || receipt.MediaID == 0 || len(receipt.IntentHash) != 32 {
return false
}
return receipt.Kind == domain.UploadedMediaPhoto || receipt.Kind == domain.UploadedMediaDocument
}
func (s *MediaStore) GetUploadedMediaReceipt(ctx context.Context, ownerUserID, fileID int64) (domain.UploadedMediaReceipt, bool, error) {
var receipt domain.UploadedMediaReceipt
var kind string
err := s.db.QueryRow(ctx, `
SELECT owner_user_id, file_id, intent_hash, media_kind, media_id, created_at
FROM uploaded_media_receipts
WHERE owner_user_id = $1 AND file_id = $2`, ownerUserID, fileID).Scan(
&receipt.OwnerUserID,
&receipt.FileID,
&receipt.IntentHash,
&kind,
&receipt.MediaID,
&receipt.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return domain.UploadedMediaReceipt{}, false, nil
}
if err != nil {
return domain.UploadedMediaReceipt{}, false, fmt.Errorf("get uploaded media receipt: %w", err)
}
receipt.Kind = domain.UploadedMediaKind(kind)
if !validUploadedMediaReceipt(receipt) {
return domain.UploadedMediaReceipt{}, false, fmt.Errorf(
"get uploaded media receipt: invalid owner=%d file=%d kind=%q media=%d hash=%d",
receipt.OwnerUserID, receipt.FileID, receipt.Kind, receipt.MediaID, len(receipt.IntentHash),
)
}
return receipt, true, nil
}
func (s *MediaStore) PutUploadedMediaReceipt(ctx context.Context, receipt domain.UploadedMediaReceipt) (domain.UploadedMediaReceipt, bool, error) {
if !validUploadedMediaReceipt(receipt) {
return domain.UploadedMediaReceipt{}, false, fmt.Errorf(
"put uploaded media receipt: invalid owner=%d file=%d kind=%q media=%d hash=%d",
receipt.OwnerUserID, receipt.FileID, receipt.Kind, receipt.MediaID, len(receipt.IntentHash),
)
}
tag, err := s.db.Exec(ctx, `
INSERT INTO uploaded_media_receipts (owner_user_id, file_id, intent_hash, media_kind, media_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (owner_user_id, file_id) DO NOTHING`,
receipt.OwnerUserID, receipt.FileID, receipt.IntentHash, string(receipt.Kind), receipt.MediaID,
)
if err != nil {
return domain.UploadedMediaReceipt{}, false, fmt.Errorf("put uploaded media receipt: %w", err)
}
stored, found, err := s.GetUploadedMediaReceipt(ctx, receipt.OwnerUserID, receipt.FileID)
if err != nil {
return domain.UploadedMediaReceipt{}, false, err
}
if !found {
return domain.UploadedMediaReceipt{}, false, fmt.Errorf("put uploaded media receipt: row disappeared after insert")
}
return stored, tag.RowsAffected() == 1, nil
}
// ---- 上传分片 ----
func (s *MediaStore) SaveFilePart(ctx context.Context, part domain.UploadPart) error {

View file

@ -220,6 +220,43 @@ func TestMediaStoreRoundTrip(t *testing.T) {
}
}
func TestUploadedMediaReceiptFirstWriterWinsPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
media := NewMediaStore(pool)
userID := createRevokeTestUser(t, ctx, pool, "upload-receipt")
const fileID = int64(880055501)
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), "DELETE FROM uploaded_media_receipts WHERE owner_user_id = $1 AND file_id = $2", userID, fileID)
})
first := domain.UploadedMediaReceipt{
OwnerUserID: userID,
FileID: fileID,
IntentHash: bytes.Repeat([]byte{1}, 32),
Kind: domain.UploadedMediaPhoto,
MediaID: 7001,
}
stored, created, err := media.PutUploadedMediaReceipt(ctx, first)
if err != nil || !created || stored.MediaID != first.MediaID || !bytes.Equal(stored.IntentHash, first.IntentHash) {
t.Fatalf("first receipt = %+v created=%v err=%v", stored, created, err)
}
second := first
second.IntentHash = bytes.Repeat([]byte{2}, 32)
second.Kind = domain.UploadedMediaDocument
second.MediaID = 7002
stored, created, err = media.PutUploadedMediaReceipt(ctx, second)
if err != nil || created {
t.Fatalf("conflicting receipt created=%v err=%v", created, err)
}
if stored.Kind != first.Kind || stored.MediaID != first.MediaID || !bytes.Equal(stored.IntentHash, first.IntentHash) {
t.Fatalf("conflicting receipt replaced first writer: %+v", stored)
}
got, found, err := media.GetUploadedMediaReceipt(ctx, userID, fileID)
if err != nil || !found || got.MediaID != first.MediaID {
t.Fatalf("get receipt = %+v found=%v err=%v", got, found, err)
}
}
func TestMediaStoreDocumentCacheCopiesAndRefreshes(t *testing.T) {
pool := testPool(t)
ctx := context.Background()

View file

@ -195,6 +195,29 @@ func (s *MessageStore) finishDeleteMessagesTx(ctx context.Context, db sqlcgen.DB
Date: date,
MessageIDs: ids,
}
deleteIDsJSON, err := encodeEventMessageIDs(event.MessageIDs)
if err != nil {
return res, fmt.Errorf("encode sender delete receipt ids: %w", err)
}
senderPrivateIDs := make([]int64, 0, len(rows))
for _, row := range rows {
if row.ownerUserID == userID && row.messageSenderID == userID && row.privateMessageID != 0 {
senderPrivateIDs = append(senderPrivateIDs, row.privateMessageID)
}
}
if len(senderPrivateIDs) > 0 {
if _, err := db.Exec(ctx, `
UPDATE private_messages
SET sender_delete_pts = $3,
sender_delete_pts_count = $4,
sender_delete_date = $5,
sender_delete_message_ids = $6::jsonb
WHERE sender_user_id = $1
AND id = ANY($2::bigint[])
AND sender_box_id > 0`, userID, senderPrivateIDs, event.Pts, event.PtsCount, event.Date, deleteIDsJSON); err != nil {
return res, fmt.Errorf("save sender delete replay receipt: %w", err)
}
}
if err := appendDeleteMessagesEvent(ctx, q, event); err != nil {
return res, err
}

View file

@ -490,6 +490,12 @@ func TestMessageStoreDeleteHistoryBatchesHugeMaxID(t *testing.T) {
sender_user_id,
recipient_user_id,
random_id,
request_fingerprint,
recipient_delivered,
sender_box_id,
sender_pts,
recipient_box_id,
recipient_pts,
message_date,
body,
entities
@ -498,6 +504,12 @@ func TestMessageStoreDeleteHistoryBatchesHugeMaxID(t *testing.T) {
$1::bigint,
$2::bigint,
910000000 + g,
decode(repeat('00', 32), 'hex'),
false,
g,
g,
0,
0,
1700002000 + g,
'bulk history',
'[]'::jsonb
@ -530,7 +542,7 @@ func TestMessageStoreDeleteHistoryBatchesHugeMaxID(t *testing.T) {
true,
'bulk history',
'[]'::jsonb,
0
(random_id - 910000000)::int
FROM pm
`, owner.ID, peerUser.ID, total); err != nil {
t.Fatalf("seed bulk history: %v", err)

View file

@ -3,6 +3,7 @@ package postgres
import (
"bytes"
"context"
"errors"
"testing"
"time"
@ -193,15 +194,30 @@ func TestSendPrivateViaBotIDSurvivesReadPaths(t *testing.T) {
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: randomID,
Message: "inline via duplicate",
Message: "inline via",
ViaBotID: viaBotID,
Date: int(time.Now().Unix()),
})
if err != nil {
t.Fatalf("duplicate private via bot: %v", err)
}
if !dup.Duplicate || dup.SenderMessage.ViaBotID != viaBotID || dup.RecipientMessage.ViaBotID != viaBotID {
t.Fatalf("duplicate via = duplicate %v sender %d recipient %d, want duplicate true via %d", dup.Duplicate, dup.SenderMessage.ViaBotID, dup.RecipientMessage.ViaBotID, viaBotID)
if !dup.Duplicate ||
dup.SenderMessage.ID != res.SenderMessage.ID || dup.SenderMessage.Pts != res.SenderMessage.Pts ||
dup.RecipientMessage.ID != res.RecipientMessage.ID || dup.RecipientMessage.Pts != res.RecipientMessage.Pts {
t.Fatalf("duplicate immutable receipt = %+v/%+v, want sender id/pts %d/%d recipient %d/%d",
dup.SenderMessage, dup.RecipientMessage,
res.SenderMessage.ID, res.SenderMessage.Pts,
res.RecipientMessage.ID, res.RecipientMessage.Pts)
}
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: randomID,
Message: "inline via conflict",
ViaBotID: viaBotID,
Date: int(time.Now().Unix()),
}); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("conflicting via-bot replay err = %v, want ErrMessageRandomIDDuplicate", err)
}
recipientHistory, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{Limit: 10})

View file

@ -194,44 +194,44 @@ ORDER BY owner_user_id ASC, box_id ASC
requireUniquePartitionCountAtMost(t, deleteByPrivatePlan, `message_boxes_p\d+`, 2)
dispatchPlan := explainText(t, ctx, tx, `
WITH picked AS (
SELECT target_user_id, pts, id
FROM dispatch_outbox
WHERE (
status = 'pending'
AND next_attempt_at <= now()
WITH picked_heads AS (
SELECT h.target_user_id, h.head_id, h.head_pts
FROM dispatch_outbox_user_heads h
WHERE h.logical_shard = ANY($1::smallint[])
AND (
(h.status = 'pending' AND h.next_attempt_at <= now())
OR
(h.status = 'dispatching' AND h.updated_at < now() - interval '30 seconds')
)
OR (
status = 'dispatching'
AND updated_at < now() - interval '30 seconds'
)
ORDER BY next_attempt_at ASC, target_user_id ASC, id ASC
ORDER BY h.next_attempt_at ASC, h.target_user_id ASC, h.head_pts ASC, h.head_id ASC
LIMIT 100
FOR UPDATE SKIP LOCKED
FOR UPDATE OF h SKIP LOCKED
)
SELECT target_user_id, pts, id
FROM picked
`)
SELECT d.target_user_id, d.pts, d.id
FROM picked_heads h
JOIN dispatch_outbox d
ON d.target_user_id = h.target_user_id
AND d.id = h.head_id
`, []int16{int16(recipient.ID % 256)})
requirePlanContains(t, dispatchPlan, "dispatch_outbox_user_heads_dispatching_shard_idx")
requirePlanContains(t, dispatchPlan, "dispatch_outbox")
requirePlanContains(t, dispatchPlan, "Index")
requirePlanNotMatches(t, dispatchPlan, `dispatch_outbox_p\d+`)
requirePlanNotContains(t, dispatchPlan, "Seq Scan")
failedCleanupPlan := explainText(t, ctx, tx, `
WITH doomed AS (
SELECT target_user_id, id
FROM dispatch_outbox
WITH doomed AS MATERIALIZED (
SELECT target_user_id, head_id AS id
FROM dispatch_outbox_user_heads
WHERE status = 'failed'
AND updated_at < now() - interval '1 day'
ORDER BY updated_at ASC, target_user_id ASC, id ASC
AND updated_at < now() - interval '1 minute'
ORDER BY updated_at ASC, target_user_id ASC, head_id ASC
LIMIT 100
)
SELECT target_user_id, id
FROM doomed
`)
requirePlanContains(t, failedCleanupPlan, "dispatch_outbox")
requirePlanContains(t, failedCleanupPlan, "Index")
requirePlanNotMatches(t, failedCleanupPlan, `dispatch_outbox_p\d+`)
requirePlanContains(t, failedCleanupPlan, "dispatch_outbox_user_heads_failed_cleanup_idx")
requirePlanNotContains(t, failedCleanupPlan, "Seq Scan")
}

View file

@ -10,6 +10,7 @@ import (
"github.com/jackc/pgx/v5/pgconn"
"sort"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
"time"
)
@ -50,6 +51,10 @@ func (s *MessageStore) Create(ctx context.Context, msg domain.Message) (domain.M
}
func (s *MessageStore) ensureOfficialSystemUser(ctx context.Context, msg domain.Message) error {
return ensureOfficialSystemUserWithDB(ctx, s.db, msg)
}
func ensureOfficialSystemUserWithDB(ctx context.Context, db sqlcgen.DBTX, msg domain.Message) error {
if msg.Peer.Type != domain.PeerTypeUser && msg.From.Type != domain.PeerTypeUser {
return nil
}
@ -60,22 +65,10 @@ func (s *MessageStore) ensureOfficialSystemUser(ctx context.Context, msg domain.
if !ok {
return nil
}
if _, err := s.db.Exec(ctx, `
if _, err := db.Exec(ctx, `
INSERT INTO users (id, access_hash, phone, first_name, last_name, username, country_code, verified, support, about, is_bot, bot_info_version)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
phone = EXCLUDED.phone,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
username = EXCLUDED.username,
country_code = EXCLUDED.country_code,
verified = EXCLUDED.verified,
support = EXCLUDED.support,
about = EXCLUDED.about,
is_bot = EXCLUDED.is_bot,
bot_info_version = EXCLUDED.bot_info_version,
updated_at = now()
ON CONFLICT (id) DO NOTHING
`, u.ID, u.AccessHash, u.Phone, u.FirstName, u.LastName, u.Username, u.CountryCode, u.Verified, u.Support, u.About, u.Bot, u.BotInfoVersion); err != nil {
return fmt.Errorf("ensure official system user: %w", err)
}
@ -129,6 +122,21 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
if err != nil {
return domain.SendPrivateTextResult{}, err
}
requestFingerprint, err := store.PrivateSendFingerprint(req)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
// 常见的 lost-response 重放在开事务和拿双方 advisory lock 之前直接返回;
// 并发首次请求仍由事务内 unique conflict + qtx 兜底,不能只依赖本次预查。
// RPC/app 已完成同一只读查询时可跳过这次重复 round-trip。
if !req.IdempotencyPreflighted {
if duplicate, found, err := s.duplicateSendResult(ctx, s.q, req, requestFingerprint); err != nil {
return domain.SendPrivateTextResult{}, err
} else if found {
duplicate.Duplicate = true
return duplicate, nil
}
}
senderReply, recipientReply, err := s.resolvePrivateSendReply(ctx, req)
if err != nil {
return domain.SendPrivateTextResult{}, err
@ -186,30 +194,36 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
}
privateArg := sqlcgen.CreatePrivateMessageParams{
SenderUserID: req.SenderUserID,
RecipientUserID: req.RecipientUserID,
RandomID: req.RandomID,
MessageDate: int32(req.Date),
Body: req.Message,
TtlPeriod: int32(ttlPeriod),
ExpiresAt: int32(expiresAt),
EntitiesJson: entities,
MediaJson: mediaJSON,
ReplyMarkupJson: replyMarkupJSON,
RichMessageJson: richMessageJSON,
ViaBotID: req.ViaBotID,
GroupedID: req.GroupedID,
Effect: req.Effect,
SenderUserID: req.SenderUserID,
RecipientUserID: req.RecipientUserID,
RandomID: req.RandomID,
RequestFingerprint: requestFingerprint,
RecipientDelivered: deliverRecipient,
MessageDate: int32(req.Date),
Body: req.Message,
TtlPeriod: int32(ttlPeriod),
ExpiresAt: int32(expiresAt),
EntitiesJson: entities,
MediaJson: mediaJSON,
ReplyMarkupJson: replyMarkupJSON,
RichMessageJson: richMessageJSON,
ViaBotID: req.ViaBotID,
GroupedID: req.GroupedID,
Effect: req.Effect,
}
applyCreatePrivateMessageMetadata(&privateArg, senderMeta)
pm, err := qtx.CreatePrivateMessage(ctx, privateArg)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// 幂等重复:返回原消息盒;此时还没有分配 pts,重复发送不应制造额外事件。
dup, dupErr := s.duplicateSendResult(ctx, req.SenderUserID, req.RecipientUserID, req.RandomID)
// 预查与 INSERT 之间另一请求可能已提交。必须在当前 qtx 读取,
// 不能持事务连接/advisory lock 再从 s.q 申请第二条池连接。
dup, found, dupErr := s.duplicateSendResult(ctx, qtx, req, requestFingerprint)
if dupErr != nil {
return domain.SendPrivateTextResult{}, dupErr
}
if !found {
return domain.SendPrivateTextResult{}, fmt.Errorf("duplicate private message disappeared after unique conflict")
}
dup.Duplicate = true
return dup, nil
}
@ -267,6 +281,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
return domain.SendPrivateTextResult{}, fmt.Errorf("create sender box: %w", err)
}
sender := messageFromBoxRow(senderRow)
sender.RandomID = req.RandomID
// 共享媒体索引(0118):发送者侧 box 按媒体类别建索引(peer=收件人)。
if err := insertMessageBoxMediaIndexTx(ctx, tx, req.SenderUserID, req.RecipientUserID, int(senderBoxID), req.Date, req.Media, req.Entities); err != nil {
return domain.SendPrivateTextResult{}, err
@ -328,6 +343,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
return domain.SendPrivateTextResult{}, fmt.Errorf("create recipient box: %w", err)
}
recipient = messageFromBoxRow(recipientRow)
recipient.RandomID = req.RandomID
// 共享媒体索引(0118):收件人侧 box 按媒体类别建索引(peer=发送者)。
if err := insertMessageBoxMediaIndexTx(ctx, tx, req.RecipientUserID, req.SenderUserID, int(recipientBoxID), req.Date, req.Media, req.Entities); err != nil {
return domain.SendPrivateTextResult{}, err
@ -355,6 +371,33 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
}
}
receiptRecipientBoxID, receiptRecipientPts := recipientBoxID, recipientPts
if selfMessage {
receiptRecipientBoxID, receiptRecipientPts = sender.ID, sender.Pts
}
senderSnapshot, err := store.EncodePrivateSendSnapshot(sender)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
tag, err := tx.Exec(ctx, `
UPDATE private_messages
SET sender_box_id = $3,
sender_pts = $4,
recipient_box_id = $5,
recipient_pts = $6,
sender_snapshot = $7::jsonb
WHERE sender_user_id = $1
AND id = $2
AND sender_box_id = 0
AND sender_pts = 0
AND sender_snapshot = '{}'::jsonb`, req.SenderUserID, pm.ID, sender.ID, sender.Pts, receiptRecipientBoxID, receiptRecipientPts, senderSnapshot)
if err != nil {
return domain.SendPrivateTextResult{}, fmt.Errorf("save private send receipt: %w", err)
}
if tag.RowsAffected() != 1 {
return domain.SendPrivateTextResult{}, fmt.Errorf("save private send receipt: private message %d already has or lost its immutable receipt", pm.ID)
}
if err := tx.Commit(ctx); err != nil {
return domain.SendPrivateTextResult{}, fmt.Errorf("commit send message tx: %w", err)
}
@ -367,6 +410,28 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
}, nil
}
// LookupPrivateSendReplay reads an existing receipt without permission checks, source/media
// resolution, locks or allocations. The authenticated app/RPC layer supplies sender identity.
func (s *MessageStore) LookupPrivateSendReplay(ctx context.Context, lookup domain.PrivateSendReplayRequest) (domain.SendPrivateTextResult, bool, error) {
if lookup.SenderUserID == 0 || lookup.RecipientUserID == 0 || lookup.RandomID == 0 {
return domain.SendPrivateTextResult{}, false, fmt.Errorf("private send replay: invalid scope")
}
if err := store.ValidateSendFingerprint(lookup.IdempotencyFingerprint, "private send replay"); err != nil {
return domain.SendPrivateTextResult{}, false, err
}
res, found, err := s.duplicateSendResult(ctx, s.q, domain.SendPrivateTextRequest{
SenderUserID: lookup.SenderUserID,
RecipientUserID: lookup.RecipientUserID,
RandomID: lookup.RandomID,
IdempotencyFingerprint: lookup.IdempotencyFingerprint,
}, lookup.IdempotencyFingerprint)
if err != nil || !found {
return domain.SendPrivateTextResult{}, found, err
}
res.Duplicate = true
return res, true, nil
}
type boxIDCounterBumper interface {
BumpBoxIDAtLeast(ctx context.Context, userID int64, floor int) error
}
@ -400,49 +465,96 @@ func isMessageBoxDuplicateKey(err error) bool {
return pgErr.Code == "23505" && strings.Contains(pgErr.ConstraintName, "message_boxes")
}
func (s *MessageStore) duplicateSendResult(ctx context.Context, senderUserID, recipientUserID, randomID int64) (domain.SendPrivateTextResult, error) {
pm, err := s.q.GetPrivateMessageByRandomID(ctx, sqlcgen.GetPrivateMessageByRandomIDParams{
SenderUserID: senderUserID,
RandomID: randomID,
func (s *MessageStore) duplicateSendResult(ctx context.Context, q *sqlcgen.Queries, req domain.SendPrivateTextRequest, requestFingerprint []byte) (domain.SendPrivateTextResult, bool, error) {
pm, err := q.GetPrivateMessageByRandomID(ctx, sqlcgen.GetPrivateMessageByRandomIDParams{
SenderUserID: req.SenderUserID,
RandomID: req.RandomID,
})
if err != nil {
return domain.SendPrivateTextResult{}, fmt.Errorf("get duplicate private message: %w", err)
if errors.Is(err, pgx.ErrNoRows) {
return domain.SendPrivateTextResult{}, false, nil
}
return domain.SendPrivateTextResult{}, false, fmt.Errorf("get duplicate private message: %w", err)
}
senderRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{
OwnerUserID: senderUserID,
if pm.SenderUserID != req.SenderUserID ||
pm.RecipientUserID != req.RecipientUserID ||
!store.SamePrivateSendFingerprint(pm.RequestFingerprint, requestFingerprint) {
return domain.SendPrivateTextResult{}, false, domain.ErrMessageRandomIDDuplicate
}
if pm.SenderBoxID <= 0 || pm.SenderPts <= 0 {
return domain.SendPrivateTextResult{}, false, fmt.Errorf(
"duplicate private message %d has invalid immutable sender receipt box=%d pts=%d",
pm.ID, pm.SenderBoxID, pm.SenderPts,
)
}
firstSender, err := store.DecodePrivateSendSnapshot([]byte(pm.SenderSnapshotJson))
if err != nil {
return domain.SendPrivateTextResult{}, false, fmt.Errorf("decode duplicate private message %d sender snapshot: %w", pm.ID, err)
}
if firstSender.ID != int(pm.SenderBoxID) || firstSender.UID != pm.ID || firstSender.RandomID != pm.RandomID ||
firstSender.OwnerUserID != pm.SenderUserID || firstSender.Pts != int(pm.SenderPts) {
return domain.SendPrivateTextResult{}, false, fmt.Errorf("duplicate private message %d sender snapshot disagrees with immutable receipt", pm.ID)
}
sender := firstSender
currentRow, currentErr := q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{
OwnerUserID: pm.SenderUserID,
PrivateMessageID: pm.ID,
})
if err != nil {
return domain.SendPrivateTextResult{}, fmt.Errorf("get duplicate sender box: %w", err)
if currentErr == nil {
sender = messageFromGetBoxRow(currentRow)
sender.RandomID = pm.RandomID
} else if !errors.Is(currentErr, pgx.ErrNoRows) {
return domain.SendPrivateTextResult{}, false, fmt.Errorf("get current duplicate private message %d sender box: %w", pm.ID, currentErr)
}
var replayDelete *domain.UpdateEvent
if errors.Is(currentErr, pgx.ErrNoRows) {
messageIDs, decodeErr := decodeEventMessageIDs(pm.SenderDeleteMessageIdsJson)
if decodeErr != nil {
return domain.SendPrivateTextResult{}, false, fmt.Errorf("decode duplicate private message %d delete ids: %w", pm.ID, decodeErr)
}
if pm.SenderDeletePts <= 0 || pm.SenderDeletePtsCount <= 0 || len(messageIDs) == 0 {
return domain.SendPrivateTextResult{}, false, fmt.Errorf("duplicate private message %d sender box is absent without a durable delete receipt", pm.ID)
}
event := domain.UpdateEvent{
UserID: pm.SenderUserID,
Type: domain.UpdateEventDeleteMessages,
Pts: int(pm.SenderDeletePts),
PtsCount: int(pm.SenderDeletePtsCount),
Date: int(pm.SenderDeleteDate),
MessageIDs: messageIDs,
}
replayDelete = &event
}
sender := messageFromGetBoxRow(senderRow)
recipient := domain.Message{}
if recipientUserID == senderUserID {
if req.RecipientUserID == req.SenderUserID {
recipient = sender
}
if recipientUserID != senderUserID {
recipientRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{
OwnerUserID: recipientUserID,
PrivateMessageID: pm.ID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.SendPrivateTextResult{
SenderMessage: sender,
SenderEvent: eventFromMessage(sender),
RecipientEvent: domain.UpdateEvent{},
}, nil
}
return domain.SendPrivateTextResult{}, fmt.Errorf("get duplicate recipient box: %w", err)
if req.RecipientUserID != req.SenderUserID && pm.RecipientDelivered {
if pm.RecipientBoxID <= 0 || pm.RecipientPts <= 0 {
return domain.SendPrivateTextResult{}, false, fmt.Errorf(
"duplicate private message %d declares recipient delivery with invalid immutable receipt box=%d pts=%d",
pm.ID, pm.RecipientBoxID, pm.RecipientPts,
)
}
recipient = domain.Message{
ID: int(pm.RecipientBoxID),
UID: pm.ID,
RandomID: pm.RandomID,
OwnerUserID: pm.RecipientUserID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: pm.SenderUserID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: pm.SenderUserID},
Date: int(pm.MessageDate),
Out: false,
Pts: int(pm.RecipientPts),
}
recipient = messageFromGetBoxRow(recipientRow)
}
return domain.SendPrivateTextResult{
SenderMessage: sender,
RecipientMessage: recipient,
SenderEvent: eventFromMessage(sender),
RecipientEvent: eventFromMessage(recipient),
}, nil
SenderMessage: sender,
RecipientMessage: recipient,
SenderEvent: eventFromMessage(firstSender),
RecipientEvent: eventFromMessage(recipient),
ReplayDeleteEvent: replayDelete,
}, true, nil
}
func (s *MessageStore) resolvePrivateSendReply(ctx context.Context, req domain.SendPrivateTextRequest) (*domain.MessageReply, *domain.MessageReply, error) {

View file

@ -0,0 +1,319 @@
package postgres
import (
"context"
"errors"
"os"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
func TestMessageStorePrivateRandomIDConflictMatrix(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender := createTestUser(t, ctx, users, "+1881"+suffix+"01", "IDSender", "")
recipient := createTestUser(t, ctx, users, "+1881"+suffix+"02", "IDRecipient", "")
other := createTestUser(t, ctx, users, "+1881"+suffix+"03", "IDOther", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID, other.ID})
})
messages := NewMessageStore(pool)
base := domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: 771001,
Message: "immutable payload",
Date: 1700001000,
}
first, err := messages.SendPrivateText(ctx, base)
if err != nil {
t.Fatalf("first send: %v", err)
}
tests := []struct {
name string
mutate func(*domain.SendPrivateTextRequest)
}{
{name: "peer", mutate: func(req *domain.SendPrivateTextRequest) { req.RecipientUserID = other.ID }},
{name: "body", mutate: func(req *domain.SendPrivateTextRequest) { req.Message = "different body" }},
{name: "media", mutate: func(req *domain.SendPrivateTextRequest) {
req.Media = &domain.MessageMedia{
Kind: domain.MessageMediaKindContact,
Contact: &domain.MessageContact{
PhoneNumber: "+10000000000",
FirstName: "Different",
},
}
}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := base
tc.mutate(&req)
if _, err := messages.SendPrivateText(ctx, req); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("conflicting replay err = %v, want ErrMessageRandomIDDuplicate", err)
}
})
}
var privateCount, boxCount, eventCount, outboxCount int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM private_messages WHERE sender_user_id = $1 AND random_id = $2`, sender.ID, base.RandomID).Scan(&privateCount); err != nil {
t.Fatalf("count private messages: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE private_message_id = $1`, first.SenderMessage.UID).Scan(&boxCount); err != nil {
t.Fatalf("count message boxes: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM user_update_events WHERE user_id = ANY($1::bigint[])`, []int64{sender.ID, recipient.ID, other.ID}).Scan(&eventCount); err != nil {
t.Fatalf("count update events: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])`, []int64{sender.ID, recipient.ID, other.ID}).Scan(&outboxCount); err != nil {
t.Fatalf("count outbox: %v", err)
}
if privateCount != 1 || boxCount != 2 || eventCount != 2 || outboxCount != 2 {
t.Fatalf("rows after conflicts = private %d boxes %d events %d outbox %d, want 1/2/2/2", privateCount, boxCount, eventCount, outboxCount)
}
}
func TestMessageStorePrivateRandomIDReplaySelfAndBlocked(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
self := createTestUser(t, ctx, users, "+1882"+suffix+"01", "IDSelf", "")
sender := createTestUser(t, ctx, users, "+1882"+suffix+"02", "BlockedSender", "")
recipient := createTestUser(t, ctx, users, "+1882"+suffix+"03", "BlockedRecipient", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{self.ID, sender.ID, recipient.ID})
})
messages := NewMessageStore(pool)
selfReq := domain.SendPrivateTextRequest{
SenderUserID: self.ID, RecipientUserID: self.ID, RandomID: 772001,
Message: "saved note", Date: 1700001100,
}
selfFirst, err := messages.SendPrivateText(ctx, selfReq)
if err != nil {
t.Fatalf("self first: %v", err)
}
selfReq.Date++
selfReq.OriginSessionID = 99
selfReq.RecipientBlocked = true
selfReplay, err := messages.SendPrivateText(ctx, selfReq)
if err != nil {
t.Fatalf("self replay: %v", err)
}
if !selfReplay.Duplicate || selfReplay.SenderMessage.ID != selfFirst.SenderMessage.ID || selfReplay.RecipientMessage.ID != selfFirst.SenderMessage.ID {
t.Fatalf("self replay = %+v, want original single box", selfReplay)
}
blockedReq := domain.SendPrivateTextRequest{
SenderUserID: sender.ID, RecipientUserID: recipient.ID, RandomID: 772002,
Message: "blocked delivery", Date: 1700001110, RecipientBlocked: true,
}
blockedFirst, err := messages.SendPrivateText(ctx, blockedReq)
if err != nil {
t.Fatalf("blocked first: %v", err)
}
if blockedFirst.RecipientMessage.ID != 0 {
t.Fatalf("blocked recipient message = %+v, want empty", blockedFirst.RecipientMessage)
}
blockedReq.Date++
blockedReq.RecipientBlocked = false
blockedReplay, err := messages.SendPrivateText(ctx, blockedReq)
if err != nil {
t.Fatalf("blocked replay: %v", err)
}
if !blockedReplay.Duplicate || blockedReplay.SenderMessage.ID != blockedFirst.SenderMessage.ID || blockedReplay.RecipientMessage.ID != 0 {
t.Fatalf("blocked replay = %+v, want original sender-only result", blockedReplay)
}
var selfBoxes, blockedBoxes, recipientEvents, recipientOutbox int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE private_message_id = $1`, selfFirst.SenderMessage.UID).Scan(&selfBoxes); err != nil {
t.Fatalf("count self boxes: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE private_message_id = $1`, blockedFirst.SenderMessage.UID).Scan(&blockedBoxes); err != nil {
t.Fatalf("count blocked boxes: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM user_update_events WHERE user_id = $1`, recipient.ID).Scan(&recipientEvents); err != nil {
t.Fatalf("count blocked recipient events: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM dispatch_outbox WHERE target_user_id = $1`, recipient.ID).Scan(&recipientOutbox); err != nil {
t.Fatalf("count blocked recipient outbox: %v", err)
}
if selfBoxes != 1 || blockedBoxes != 1 || recipientEvents != 0 || recipientOutbox != 0 {
t.Fatalf("replay rows = self boxes %d blocked boxes %d recipient events %d outbox %d, want 1/1/0/0", selfBoxes, blockedBoxes, recipientEvents, recipientOutbox)
}
}
func TestMessageStorePrivateRandomIDReplayUsesCurrentSnapshotAndDurableDelete(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender := createTestUser(t, ctx, users, "+1884"+suffix+"01", "ReceiptSender", "")
recipient := createTestUser(t, ctx, users, "+1884"+suffix+"02", "ReceiptRecipient", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
})
messages := NewMessageStore(pool)
type replayState struct {
events int
outbox int
pts int
}
loadReplayState := func() replayState {
t.Helper()
var state replayState
if err := pool.QueryRow(ctx, `SELECT count(*) FROM user_update_events WHERE user_id = $1`, sender.ID).Scan(&state.events); err != nil {
t.Fatalf("count sender events: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM dispatch_outbox WHERE target_user_id = $1`, sender.ID).Scan(&state.outbox); err != nil {
t.Fatalf("count sender outbox: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT contiguous_pts FROM user_update_watermarks WHERE user_id = $1`, sender.ID).Scan(&state.pts); err != nil {
t.Fatalf("load sender pts: %v", err)
}
return state
}
req := domain.SendPrivateTextRequest{
SenderUserID: sender.ID, RecipientUserID: recipient.ID, RandomID: 774001,
Message: "immutable receipt", Date: 1700001300,
}
first, err := messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("first send: %v", err)
}
edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{
OwnerUserID: sender.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID},
ID: first.SenderMessage.ID,
Message: "edited projection",
EditDate: 1700001301,
})
if err != nil {
t.Fatalf("edit message: %v", err)
}
beforeReplay := loadReplayState()
replay, err := messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("replay after edit: %v", err)
}
if replay.SenderMessage.ID != first.SenderMessage.ID || replay.SenderMessage.Pts != edited.Self().Message.Pts || replay.SenderMessage.Body != "edited projection" ||
replay.RecipientMessage.ID != first.RecipientMessage.ID || replay.RecipientMessage.Pts != first.RecipientMessage.Pts {
t.Fatalf("replay after edit = %+v/%+v, want current sender snapshot and immutable recipient receipt %d/%d",
replay.SenderMessage, replay.RecipientMessage,
first.RecipientMessage.ID, first.RecipientMessage.Pts)
}
if replay.SenderEvent.Pts != first.SenderEvent.Pts || replay.ReplayDeleteEvent != nil {
t.Fatalf("replay after edit event = %+v delete=%+v, want first-send pts and no delete", replay.SenderEvent, replay.ReplayDeleteEvent)
}
if after := loadReplayState(); after != beforeReplay {
t.Fatalf("edit replay mutated durable state = %+v, want %+v", after, beforeReplay)
}
deleted, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{
OwnerUserID: sender.ID,
IDs: []int{first.SenderMessage.ID},
Revoke: true,
Date: 1700001302,
})
if err != nil {
t.Fatalf("delete message: %v", err)
}
beforeReplay = loadReplayState()
replay, err = messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("replay after delete: %v", err)
}
if replay.SenderMessage.ID != first.SenderMessage.ID || replay.SenderMessage.Pts != first.SenderMessage.Pts || replay.SenderMessage.Body != "immutable receipt" {
t.Fatalf("replay after delete = %+v, want immutable first sender snapshot", replay.SenderMessage)
}
if replay.ReplayDeleteEvent == nil || replay.ReplayDeleteEvent.Pts != deleted.Self().Event.Pts ||
len(replay.ReplayDeleteEvent.MessageIDs) != 1 || replay.ReplayDeleteEvent.MessageIDs[0] != first.SenderMessage.ID {
t.Fatalf("replay delete event = %+v, want durable delete %+v", replay.ReplayDeleteEvent, deleted.Self().Event)
}
if after := loadReplayState(); after != beforeReplay {
t.Fatalf("delete replay mutated durable state = %+v, want %+v", after, beforeReplay)
}
}
// beginHookDB lets the test commit a competing send exactly after the outer
// fast-path lookup and before its transaction starts. With MaxConns=1, a
// duplicate fallback that queries the pool while holding the transaction would
// wait until the context deadline; reading through qtx completes immediately.
type beginHookDB struct {
*pgxpool.Pool
once sync.Once
before func(context.Context) error
beforeErr error
}
func (db *beginHookDB) Begin(ctx context.Context) (pgx.Tx, error) {
db.once.Do(func() {
if db.before != nil {
db.beforeErr = db.before(ctx)
}
})
if db.beforeErr != nil {
return nil, db.beforeErr
}
return db.Pool.Begin(ctx)
}
func TestMessageStorePrivateRandomIDConflictFallbackUsesTransactionConnection(t *testing.T) {
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test")
}
if err := Migrate(dsn); err != nil {
t.Fatalf("migrate: %v", err)
}
config, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatalf("parse pool config: %v", err)
}
config.MaxConns = 1
pool, err := pgxpool.NewWithConfig(context.Background(), config)
if err != nil {
t.Fatalf("open single-connection pool: %v", err)
}
t.Cleanup(pool.Close)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender := createTestUser(t, ctx, users, "+1883"+suffix+"01", "PoolSender", "")
recipient := createTestUser(t, ctx, users, "+1883"+suffix+"02", "PoolRecipient", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
})
req := domain.SendPrivateTextRequest{
SenderUserID: sender.ID, RecipientUserID: recipient.ID, RandomID: 773001,
Message: "commit between preflight and insert", Date: 1700001200,
}
boxIDs := &perUserCounterAllocator{}
db := &beginHookDB{Pool: pool}
db.before = func(ctx context.Context) error {
_, err := NewMessageStore(pool, WithMessageAllocators(boxIDs)).SendPrivateText(ctx, req)
return err
}
deadlineCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
got, err := NewMessageStore(db, WithMessageAllocators(boxIDs)).SendPrivateText(deadlineCtx, req)
if err != nil {
t.Fatalf("conflict fallback with MaxConns=1: %v", err)
}
if !got.Duplicate || got.SenderMessage.ID == 0 || got.RecipientMessage.ID == 0 {
t.Fatalf("conflict fallback result = %+v, want committed duplicate boxes", got)
}
}

View file

@ -0,0 +1,83 @@
package postgres
import (
"bytes"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func TestPrivateSendRequestFingerprintUsesImmutableIntent(t *testing.T) {
req := domain.SendPrivateTextRequest{
SenderUserID: 1001,
RecipientUserID: 1002,
RandomID: 99,
Message: "hello",
Silent: true,
Date: 1700000000,
OriginSessionID: 7,
RecipientBlocked: true,
}
fingerprint := func(in domain.SendPrivateTextRequest) []byte {
t.Helper()
got, err := store.PrivateSendFingerprint(in)
if err != nil {
t.Fatalf("privateSendRequestFingerprint: %v", err)
}
return got
}
first := fingerprint(req)
replay := req
replay.Date++
replay.OriginSessionID++
replay.OriginAuthKeyID[0] = 9
replay.RecipientBlocked = false
if got := fingerprint(replay); !bytes.Equal(first, got) {
t.Fatalf("execution-context-only changes altered fingerprint: %x != %x", got, first)
}
changedPeer := req
changedPeer.RecipientUserID++
if got := fingerprint(changedPeer); bytes.Equal(first, got) {
t.Fatal("changed recipient retained fingerprint")
}
changedBody := req
changedBody.Message = "different"
if got := fingerprint(changedBody); bytes.Equal(first, got) {
t.Fatal("changed body retained fingerprint")
}
changedMedia := req
changedMedia.Media = &domain.MessageMedia{
Kind: domain.MessageMediaKindContact,
Contact: &domain.MessageContact{
PhoneNumber: "+10000000000",
FirstName: "Changed",
},
}
if got := fingerprint(changedMedia); bytes.Equal(first, got) {
t.Fatal("changed media retained fingerprint")
}
}
func TestPrivateSendRequestFingerprintPrefersRPCFingerprint(t *testing.T) {
want := bytes.Repeat([]byte{0x5a}, 32)
req := domain.SendPrivateTextRequest{IdempotencyFingerprint: want}
got, err := store.PrivateSendFingerprint(req)
if err != nil {
t.Fatalf("privateSendRequestFingerprint: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("fingerprint = %x, want %x", got, want)
}
got[0] ^= 0xff
if got[0] == want[0] {
t.Fatal("returned fingerprint aliases caller storage")
}
req.IdempotencyFingerprint = []byte{1, 2, 3}
if _, err := store.PrivateSendFingerprint(req); err == nil {
t.Fatal("short caller fingerprint accepted")
}
}

View file

@ -2,6 +2,7 @@ package postgres
import (
"context"
"errors"
"sync"
"testing"
@ -180,15 +181,9 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
},
},
}
dup, err := messages.SendPrivateText(ctx, dupReq)
if err != nil {
t.Fatalf("SendPrivateText duplicate: %v", err)
if _, err := messages.SendPrivateText(ctx, dupReq); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("changed-media duplicate err = %v, want ErrMessageRandomIDDuplicate", err)
}
if !dup.Duplicate || dup.SenderMessage.ID != got.SenderMessage.ID || dup.RecipientMessage.ID != got.RecipientMessage.ID {
t.Fatalf("duplicate = %+v, want original boxes", dup)
}
assertWebViewData("duplicate sender", dup.SenderMessage)
assertWebViewData("duplicate recipient", dup.RecipientMessage)
recipientHistory, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{
HasPeer: true,

View file

@ -0,0 +1,222 @@
package postgres
import (
"context"
"errors"
"strings"
"testing"
"github.com/jackc/pgx/v5"
"telesrv/deploy"
"telesrv/internal/domain"
storepkg "telesrv/internal/store"
)
func TestPrivateSendFreshMigrationsKeepLegacyWriterDefaults(t *testing.T) {
t.Parallel()
for _, test := range []struct {
migration string
adds []string
drops []string
}{
{
migration: "migrations/0062_private_message_idempotency.up.sql",
adds: []string{
"ADD COLUMN request_fingerprint bytea NOT NULL DEFAULT '\\x'",
"ADD COLUMN recipient_delivered boolean NOT NULL DEFAULT false",
},
drops: []string{
"ALTER COLUMN request_fingerprint DROP DEFAULT",
"ALTER COLUMN recipient_delivered DROP DEFAULT",
},
},
{
migration: "migrations/0068_private_message_send_receipt.up.sql",
adds: []string{
"ADD COLUMN sender_box_id integer NOT NULL DEFAULT 0",
"ADD COLUMN sender_pts integer NOT NULL DEFAULT 0",
"ADD COLUMN recipient_box_id integer NOT NULL DEFAULT 0",
"ADD COLUMN recipient_pts integer NOT NULL DEFAULT 0",
},
drops: []string{
"ALTER COLUMN sender_box_id DROP DEFAULT",
"ALTER COLUMN sender_pts DROP DEFAULT",
"ALTER COLUMN recipient_box_id DROP DEFAULT",
"ALTER COLUMN recipient_pts DROP DEFAULT",
},
},
} {
t.Run(test.migration, func(t *testing.T) {
sql, err := deploy.Migrations.ReadFile(test.migration)
if err != nil {
t.Fatalf("read %s: %v", test.migration, err)
}
body := string(sql)
for _, add := range test.adds {
if !strings.Contains(body, add) {
t.Errorf("%s does not install legacy-writer default %q", test.migration, add)
}
}
for _, drop := range test.drops {
if strings.Contains(body, drop) {
t.Errorf("%s removes legacy-writer default %q", test.migration, drop)
}
}
})
}
}
func TestPrivateSendMigration75To77PreservesOldWritersAndRejectsUnknownReceiptsPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender := createTestUser(t, ctx, users, "+1888"+suffix+"01", "MigrationSender", "")
recipient := createTestUser(t, ctx, users, "+1888"+suffix+"02", "MigrationRecipient", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
})
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin migration compatibility tx: %v", err)
}
defer func() { _ = tx.Rollback(context.Background()) }()
// Recreate the schema shape left by the original 0062/0068 migrations at
// version 75, then apply the corrective expand migration in isolation.
if _, err := tx.Exec(ctx, `
ALTER TABLE public.private_messages
ALTER COLUMN request_fingerprint DROP DEFAULT,
ALTER COLUMN recipient_delivered DROP DEFAULT,
ALTER COLUMN sender_box_id DROP DEFAULT,
ALTER COLUMN sender_pts DROP DEFAULT,
ALTER COLUMN recipient_box_id DROP DEFAULT,
ALTER COLUMN recipient_pts DROP DEFAULT`); err != nil {
t.Fatalf("simulate version 75 defaults: %v", err)
}
upSQL, err := deploy.Migrations.ReadFile("migrations/0077_correct_private_send_defaults.up.sql")
if err != nil {
t.Fatalf("read 0077 up: %v", err)
}
if _, err := tx.Exec(ctx, string(upSQL)); err != nil {
t.Fatalf("apply 0077 up: %v", err)
}
var defaultCount int
if err := tx.QueryRow(ctx, `
SELECT count(*)
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'private_messages'
AND column_name = ANY($1::text[])
AND column_default IS NOT NULL`, []string{
"request_fingerprint", "recipient_delivered", "sender_box_id",
"sender_pts", "recipient_box_id", "recipient_pts",
}).Scan(&defaultCount); err != nil {
t.Fatalf("inspect restored defaults: %v", err)
}
if defaultCount != 6 {
t.Fatalf("restored private-send defaults = %d, want 6", defaultCount)
}
insertLegacyBoxes := func(privateMessageID int64, boxID, pts, date int, body string) {
t.Helper()
if _, err := tx.Exec(ctx, `
INSERT INTO message_boxes (
owner_user_id, box_id, private_message_id, message_sender_id,
peer_type, peer_id, from_user_id, message_date, outgoing, body, entities, pts
) VALUES
($1, $3, $5, $1, 'user', $2, $1, $6, true, $7, '[]'::jsonb, $4),
($2, $3, $5, $1, 'user', $1, $1, $6, false, $7, '[]'::jsonb, $4)`,
sender.ID, recipient.ID, boxID, pts, privateMessageID, date, body); err != nil {
t.Fatalf("insert legacy message boxes: %v", err)
}
}
pre0062 := domain.SendPrivateTextRequest{
SenderUserID: sender.ID, RecipientUserID: recipient.ID,
RandomID: 887_001, Message: "pre-0062 writer", Date: 1_700_040_001,
}
var pre0062ID int64
if err := tx.QueryRow(ctx, `
INSERT INTO private_messages (
sender_user_id, recipient_user_id, random_id, message_date, body, entities
) VALUES ($1, $2, $3, $4, $5, '[]'::jsonb)
RETURNING id`, pre0062.SenderUserID, pre0062.RecipientUserID, pre0062.RandomID, pre0062.Date, pre0062.Message).Scan(&pre0062ID); err != nil {
t.Fatalf("pre-0062 INSERT after migration: %v", err)
}
insertLegacyBoxes(pre0062ID, 1, 1, pre0062.Date, pre0062.Message)
assertLegacyPrivateSendSentinels(t, ctx, tx, pre0062ID, false)
if _, err := NewMessageStore(tx).SendPrivateText(ctx, pre0062); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("pre-0062 unknown replay err = %v, want ErrMessageRandomIDDuplicate", err)
}
era0062 := domain.SendPrivateTextRequest{
SenderUserID: sender.ID, RecipientUserID: recipient.ID,
RandomID: 887_002, Message: "0062-era writer", Date: 1_700_040_002,
}
fingerprint, err := storepkg.PrivateSendFingerprint(era0062)
if err != nil {
t.Fatalf("fingerprint 0062-era request: %v", err)
}
var era0062ID int64
if err := tx.QueryRow(ctx, `
INSERT INTO private_messages (
sender_user_id, recipient_user_id, random_id, request_fingerprint,
recipient_delivered, message_date, body, entities
) VALUES ($1, $2, $3, $4, true, $5, $6, '[]'::jsonb)
RETURNING id`, era0062.SenderUserID, era0062.RecipientUserID, era0062.RandomID,
fingerprint, era0062.Date, era0062.Message).Scan(&era0062ID); err != nil {
t.Fatalf("0062-era INSERT after migration: %v", err)
}
insertLegacyBoxes(era0062ID, 2, 2, era0062.Date, era0062.Message)
assertLegacyPrivateSendSentinels(t, ctx, tx, era0062ID, true)
if _, err := NewMessageStore(tx).SendPrivateText(ctx, era0062); err == nil ||
errors.Is(err, domain.ErrMessageRandomIDDuplicate) ||
!strings.Contains(err.Error(), "invalid immutable sender receipt") {
t.Fatalf("0062-era unknown receipt err = %v, want explicit invalid receipt failure", err)
}
var privateRows, eventRows, outboxRows int
if err := tx.QueryRow(ctx, `SELECT count(*) FROM private_messages WHERE sender_user_id = $1`, sender.ID).Scan(&privateRows); err != nil {
t.Fatalf("count legacy private rows: %v", err)
}
if err := tx.QueryRow(ctx, `SELECT count(*) FROM user_update_events WHERE user_id = ANY($1::bigint[])`, []int64{sender.ID, recipient.ID}).Scan(&eventRows); err != nil {
t.Fatalf("count legacy replay events: %v", err)
}
if err := tx.QueryRow(ctx, `SELECT count(*) FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])`, []int64{sender.ID, recipient.ID}).Scan(&outboxRows); err != nil {
t.Fatalf("count legacy replay outbox: %v", err)
}
if privateRows != 2 || eventRows != 0 || outboxRows != 0 {
t.Fatalf("legacy replay facts private/events/outbox = %d/%d/%d, want 2/0/0", privateRows, eventRows, outboxRows)
}
}
func assertLegacyPrivateSendSentinels(t *testing.T, ctx context.Context, q pgx.Tx, id int64, delivered bool) {
t.Helper()
var (
fingerprint []byte
gotDelivered bool
senderBoxID, senderPts, recipientBoxID, recipientPts int
)
if err := q.QueryRow(ctx, `
SELECT request_fingerprint, recipient_delivered,
sender_box_id, sender_pts, recipient_box_id, recipient_pts
FROM private_messages
WHERE id = $1`, id).Scan(
&fingerprint, &gotDelivered,
&senderBoxID, &senderPts, &recipientBoxID, &recipientPts,
); err != nil {
t.Fatalf("load legacy private-send sentinels: %v", err)
}
if (!delivered && (len(fingerprint) != 0 || gotDelivered)) ||
senderBoxID != 0 || senderPts != 0 || recipientBoxID != 0 || recipientPts != 0 {
t.Fatalf("legacy sentinels fingerprint=%x delivered=%v receipt=%d/%d/%d/%d",
fingerprint, gotDelivered, senderBoxID, senderPts, recipientBoxID, recipientPts)
}
if delivered && (len(fingerprint) != 32 || !gotDelivered) {
t.Fatalf("0062-era fingerprint/delivery = %x/%v, want 32 bytes/true", fingerprint, gotDelivered)
}
}

View file

@ -4,6 +4,12 @@ WITH pm AS (
sender_user_id,
recipient_user_id,
random_id,
request_fingerprint,
recipient_delivered,
sender_box_id,
sender_pts,
recipient_box_id,
recipient_pts,
message_date,
body,
entities
@ -11,6 +17,12 @@ WITH pm AS (
sqlc.arg(from_user_id),
sqlc.arg(owner_user_id),
0,
'\x'::bytea,
false,
0,
0,
0,
0,
sqlc.arg(message_date),
sqlc.arg(body),
sqlc.arg(entities_json)::jsonb
@ -82,6 +94,12 @@ INSERT INTO private_messages (
sender_user_id,
recipient_user_id,
random_id,
request_fingerprint,
recipient_delivered,
sender_box_id,
sender_pts,
recipient_box_id,
recipient_pts,
message_date,
ttl_period,
expires_at,
@ -108,7 +126,9 @@ INSERT INTO private_messages (
grouped_id,
effect
) VALUES (
$1, $2, $3, $4, sqlc.arg(ttl_period)::int, sqlc.arg(expires_at)::int, $5, sqlc.arg(entities_json)::jsonb,
$1, $2, $3, sqlc.arg(request_fingerprint)::bytea, sqlc.arg(recipient_delivered)::boolean,
0, 0, 0, 0,
$4, sqlc.arg(ttl_period)::int, sqlc.arg(expires_at)::int, $5, sqlc.arg(entities_json)::jsonb,
sqlc.arg(silent)::boolean,
sqlc.arg(noforwards)::boolean,
sqlc.arg(reply_to_msg_id)::int,
@ -149,6 +169,17 @@ SELECT
sender_user_id,
recipient_user_id,
random_id,
request_fingerprint,
recipient_delivered,
sender_box_id,
sender_pts,
recipient_box_id,
recipient_pts,
sender_snapshot::text AS sender_snapshot_json,
sender_delete_pts,
sender_delete_pts_count,
sender_delete_date,
sender_delete_message_ids::text AS sender_delete_message_ids_json,
message_date,
ttl_period,
expires_at,

View file

@ -208,29 +208,31 @@ INSERT INTO dispatch_outbox (
ON CONFLICT DO NOTHING;
-- name: ClaimDispatchOutbox :many
WITH picked AS (
SELECT d.target_user_id, d.id
FROM dispatch_outbox d
-- durable head 表只保留每用户一行,并同步 head 的 readiness。claim 先锁
-- lane head 再更新对应 outbox 行,既不会扫描 backlog,也不会并发领取同一用户。
WITH picked_heads AS (
SELECT h.target_user_id, h.head_id
FROM dispatch_outbox_user_heads h
WHERE (
d.status = 'pending'
AND d.next_attempt_at <= now()
)
OR (
d.status = 'dispatching'
AND d.updated_at < now() - make_interval(secs => sqlc.arg(lease_seconds)::int)
)
ORDER BY d.next_attempt_at ASC, d.target_user_id ASC, d.pts ASC, d.id ASC
h.status = 'pending'
AND h.next_attempt_at <= now()
)
OR (
h.status = 'dispatching'
AND h.updated_at < now() - make_interval(secs => sqlc.arg(lease_seconds)::int)
)
ORDER BY h.next_attempt_at ASC, h.target_user_id ASC, h.head_pts ASC, h.head_id ASC
LIMIT sqlc.arg(limit_count)
FOR UPDATE SKIP LOCKED
FOR UPDATE OF h SKIP LOCKED
)
UPDATE dispatch_outbox d
SET
status = 'dispatching',
attempts = d.attempts + 1,
updated_at = now()
FROM picked p
FROM picked_heads p
WHERE d.target_user_id = p.target_user_id
AND d.id = p.id
AND d.id = p.head_id
RETURNING
d.id,
d.target_user_id,
@ -240,25 +242,85 @@ RETURNING
d.exclude_session_id,
d.attempts;
-- name: MarkDispatchDelivered :exec
-- name: ClaimDispatchOutboxShards :many
-- 固定 logical shard 由 target_user_id 决定;运行时 worker 只领取分配给自己的
-- shard 集合,因此同一用户永远只有一条串行 lane,而不同用户可并行。
WITH picked_heads AS (
SELECT h.target_user_id, h.head_id
FROM dispatch_outbox_user_heads h
-- 256 与 store.DispatchOutboxLogicalShards、0069 generated column 是同一
-- schema 常量;不得随 worker 数变化。
WHERE h.logical_shard = ANY(sqlc.arg(shard_ids)::smallint[])
AND (
(
h.status = 'pending'
AND h.next_attempt_at <= now()
)
OR (
h.status = 'dispatching'
AND h.updated_at < now() - make_interval(secs => sqlc.arg(lease_seconds)::int)
)
)
ORDER BY h.next_attempt_at ASC, h.target_user_id ASC, h.head_pts ASC, h.head_id ASC
LIMIT sqlc.arg(limit_count)
FOR UPDATE OF h SKIP LOCKED
)
UPDATE dispatch_outbox d
SET
status = 'dispatching',
attempts = d.attempts + 1,
updated_at = now()
FROM picked_heads p
WHERE d.target_user_id = p.target_user_id
AND d.id = p.head_id
RETURNING
d.id,
d.target_user_id,
d.pts,
d.event_type,
d.exclude_auth_key_id,
d.exclude_session_id,
d.attempts;
-- name: MarkDispatchDelivered :execrows
-- 方案 A:投递成功即删除。outbox 是任务队列,delivered 行无保留价值
-- (消息在 message_boxes、离线补偿在 user_update_events),删除让表维持「未完成任务」小稳态。
DELETE FROM dispatch_outbox
WHERE target_user_id = $1
AND id = $2;
-- claim 的锁序是 user_heads→outbox;completion 必须先显式锁同一 head 再删 outbox,
-- 否则租约过期 claim 与完成恰好竞争时会形成 outbox→head / head→outbox 环路。
WITH locked_head AS MATERIALIZED (
SELECT h.target_user_id
FROM dispatch_outbox_user_heads h
WHERE h.target_user_id = sqlc.arg(target_user_id)::bigint
FOR UPDATE
)
DELETE FROM dispatch_outbox d
USING locked_head h
WHERE d.target_user_id = h.target_user_id
AND d.id = sqlc.arg(id)::bigint
AND d.status = 'dispatching'
AND d.attempts = sqlc.arg(expected_attempts)::int;
-- name: MarkDispatchFailed :exec
UPDATE dispatch_outbox
-- name: MarkDispatchFailed :execrows
WITH locked_head AS MATERIALIZED (
SELECT h.target_user_id
FROM dispatch_outbox_user_heads h
WHERE h.target_user_id = sqlc.arg(target_user_id)::bigint
FOR UPDATE
)
UPDATE dispatch_outbox d
SET
status = CASE WHEN attempts >= 5 THEN 'failed' ELSE 'pending' END,
status = CASE WHEN d.attempts >= 5 THEN 'failed' ELSE 'pending' END,
next_attempt_at = CASE
WHEN attempts >= 5 THEN next_attempt_at
ELSE now() + make_interval(secs => LEAST(60, attempts * attempts))
WHEN d.attempts >= 5 THEN d.next_attempt_at
ELSE now() + make_interval(secs => LEAST(60, d.attempts * d.attempts))
END,
last_error = $3,
last_error = sqlc.arg(last_error)::text,
updated_at = now()
WHERE target_user_id = $1
AND id = $2;
FROM locked_head h
WHERE d.target_user_id = h.target_user_id
AND d.id = sqlc.arg(id)::bigint
AND d.status = 'dispatching'
AND d.attempts = sqlc.arg(expected_attempts)::int;
-- name: BatchListDispatchEvents :many
-- 按 (user_id, pts) 精确批量取账号事件,供 outbox worker 一次性加载一批 claim 的事件详情,
@ -394,22 +456,44 @@ LEFT JOIN users from_u ON from_u.id = m.from_user_id
LEFT JOIN users fwd_u ON m.fwd_from_peer_type = 'user' AND fwd_u.id = m.fwd_from_peer_id
LEFT JOIN users reply_u ON m.reply_to_peer_type = 'user' AND reply_u.id = m.reply_to_peer_id;
-- name: MarkDispatchDeliveredBatch :exec
-- name: MarkDispatchDeliveredBatch :execrows
-- 批量删除一批已投递的 (target_user_id, id);target_user_id 入 WHERE 命中唯一索引并避免串删。
WITH input AS MATERIALIZED (
SELECT tu.target_user_id, di.id, ea.attempts
FROM unnest(@target_user_ids::bigint[]) WITH ORDINALITY AS tu(target_user_id, ord)
JOIN unnest(@ids::bigint[]) WITH ORDINALITY AS di(id, ord) USING (ord)
JOIN unnest(@expected_attempts::int[]) WITH ORDINALITY AS ea(attempts, ord) USING (ord)
),
locked_heads AS MATERIALIZED (
SELECT h.target_user_id
FROM dispatch_outbox_user_heads h
JOIN (SELECT DISTINCT target_user_id FROM input) i USING (target_user_id)
-- Match ClaimDispatchOutbox[Shards] exactly. A stale-lease claim may lock several
-- dispatching heads while this completion batch locks the same set; a different
-- multi-row order would merely move the deadlock one level up.
ORDER BY h.next_attempt_at, h.target_user_id, h.head_pts, h.head_id
FOR UPDATE OF h
)
DELETE FROM dispatch_outbox d
USING unnest(@target_user_ids::bigint[]) WITH ORDINALITY AS tu(target_user_id, ord)
JOIN unnest(@ids::bigint[]) WITH ORDINALITY AS di(id, ord) USING (ord)
WHERE d.target_user_id = tu.target_user_id
AND d.id = di.id;
USING input i, locked_heads h
WHERE d.target_user_id = h.target_user_id
AND d.target_user_id = i.target_user_id
AND d.id = i.id
AND d.status = 'dispatching'
AND d.attempts = i.attempts;
-- name: DeleteFailedDispatchOutbox :one
WITH doomed AS (
SELECT target_user_id, id
FROM dispatch_outbox
WHERE status = 'failed'
AND updated_at < now() - make_interval(secs => sqlc.arg(older_than_seconds)::int)
ORDER BY updated_at ASC, target_user_id ASC, id ASC
-- failed 只能成为 lane head;从 head 表开始并先锁 head,既走 0074 的小索引,也与
-- claim/completion 保持同一 user_heads→outbox 锁序。删除的只是在线任务,durable
-- user_update_events 不动,故客户端仍可经 difference 恢复。
WITH doomed AS MATERIALIZED (
SELECT h.target_user_id, h.head_id AS id
FROM dispatch_outbox_user_heads h
WHERE h.status = 'failed'
AND h.updated_at < now() - make_interval(secs => sqlc.arg(older_than_seconds)::int)
ORDER BY h.updated_at ASC, h.target_user_id ASC, h.head_id ASC
LIMIT sqlc.arg(limit_count)
FOR UPDATE OF h SKIP LOCKED
),
deleted AS (
DELETE FROM dispatch_outbox d

View file

@ -0,0 +1,137 @@
package postgres
import (
"context"
"errors"
"testing"
"github.com/jackc/pgx/v5/pgconn"
"telesrv/deploy"
"telesrv/internal/domain"
)
func TestRetentionDownMigrationsRejectAdvancedFloorsPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
userID := createRevokeTestUser(t, ctx, pool, "retention-down-guard")
channel, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: userID,
Title: "retention down guard",
Megagroup: true,
Date: 1_700_030_000,
})
if err != nil {
t.Fatalf("create guarded channel: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channel.Channel.ID)
})
for _, test := range []struct {
name string
migration string
advance func(context.Context, interface {
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
}) error
}{
{
name: "channel",
migration: "migrations/0063_channel_update_retention.down.sql",
advance: func(ctx context.Context, tx interface {
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
}) error {
_, err := tx.Exec(ctx, `
UPDATE channel_update_checkpoints
SET retained_through_pts = 1
WHERE channel_id = $1`, channel.Channel.ID)
return err
},
},
{
name: "user",
migration: "migrations/0064_user_update_retention.down.sql",
advance: func(ctx context.Context, tx interface {
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
}) error {
_, err := tx.Exec(ctx, `
INSERT INTO user_update_retention (user_id, retained_through_pts, retained_through_date)
VALUES ($1, 1, 1)
ON CONFLICT (user_id) DO UPDATE SET
retained_through_pts = 1,
retained_through_date = 1`, userID)
return err
},
},
} {
t.Run(test.name, func(t *testing.T) {
downSQL, err := deploy.Migrations.ReadFile(test.migration)
if err != nil {
t.Fatalf("read %s: %v", test.migration, err)
}
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin guarded down migration: %v", err)
}
defer func() { _ = tx.Rollback(context.Background()) }()
if err := test.advance(ctx, tx); err != nil {
t.Fatalf("advance retained floor: %v", err)
}
if _, err := tx.Exec(ctx, string(downSQL)); err == nil {
t.Fatalf("%s succeeded with retained floor > 0", test.migration)
} else {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != "55000" {
t.Fatalf("%s error = %v, want SQLSTATE 55000", test.migration, err)
}
}
})
}
}
func TestPerformanceMigrationIndexesPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
// 0072 must reuse the base schema's unique key for its durable-head FK. A second identical
// unique index would add outbox enqueue/delete write amplification without improving lookup.
// Likewise 0067 supersedes the transitional created_at orphan-GC index with last_used_at.
var (
baseOutboxUnique, duplicateOutboxUnique bool
lastUsedAuthIndex, obsoleteAuthIndex bool
pendingHeadIndex, staleHeadIndex bool
poisonHeadIndex bool
tempExpiryIndex bool
)
if err := pool.QueryRow(ctx, `
SELECT
to_regclass('public.dispatch_outbox_target_user_id_id_key') IS NOT NULL,
to_regclass('public.dispatch_outbox_target_id_uidx') IS NOT NULL,
to_regclass('public.auth_keys_orphan_last_used_idx') IS NOT NULL,
to_regclass('public.auth_keys_orphan_retention_idx') IS NOT NULL,
to_regclass('public.dispatch_outbox_user_heads_pending_shard_idx') IS NOT NULL,
to_regclass('public.dispatch_outbox_user_heads_dispatching_shard_idx') IS NOT NULL,
to_regclass('public.dispatch_outbox_user_heads_failed_cleanup_idx') IS NOT NULL,
to_regclass('public.temp_auth_key_bindings_expiry_idx') IS NOT NULL
`).Scan(
&baseOutboxUnique,
&duplicateOutboxUnique,
&lastUsedAuthIndex,
&obsoleteAuthIndex,
&pendingHeadIndex,
&staleHeadIndex,
&poisonHeadIndex,
&tempExpiryIndex,
); err != nil {
t.Fatalf("inspect performance migration indexes: %v", err)
}
if !baseOutboxUnique || duplicateOutboxUnique {
t.Fatalf("outbox target/id indexes base=%v duplicate=%v, want true/false", baseOutboxUnique, duplicateOutboxUnique)
}
if !lastUsedAuthIndex || obsoleteAuthIndex {
t.Fatalf("auth orphan indexes last_used=%v created_at=%v, want true/false", lastUsedAuthIndex, obsoleteAuthIndex)
}
if !pendingHeadIndex || !staleHeadIndex || !poisonHeadIndex || !tempExpiryIndex {
t.Fatalf("ready/expiry indexes pending=%v stale=%v poison=%v temp_expiry=%v, want all true", pendingHeadIndex, staleHeadIndex, poisonHeadIndex, tempExpiryIndex)
}
}

View file

@ -207,9 +207,17 @@ func TestSavedDialogsBackfillRule(t *testing.T) {
t.Helper()
var privateID int64
if err := pool.QueryRow(ctx, `
INSERT INTO private_messages (sender_user_id, recipient_user_id, random_id, message_date, body, entities)
VALUES ($1, $1, $2::bigint, 1700000800, 'legacy', '[]'::jsonb)
RETURNING id`, owner.ID, 841100+boxID).Scan(&privateID); err != nil {
INSERT INTO private_messages (
sender_user_id, recipient_user_id, random_id, request_fingerprint, recipient_delivered,
sender_box_id, sender_pts, recipient_box_id, recipient_pts,
message_date, body, entities
)
VALUES (
$1, $1, $2::bigint, decode(repeat('00', 32), 'hex'), false,
$3::int, $3::int, 0, 0,
1700000800, 'legacy', '[]'::jsonb
)
RETURNING id`, owner.ID, 841100+boxID, boxID).Scan(&privateID); err != nil {
t.Fatalf("insert legacy private message %d: %v", boxID, err)
}
if _, err := pool.Exec(ctx, `

View file

@ -0,0 +1,45 @@
package postgres
import (
"context"
"testing"
"telesrv/deploy"
)
func TestSendReplaySnapshotMigrationRoundTripPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
downSQL, err := deploy.Migrations.ReadFile("migrations/0073_send_replay_snapshots.down.sql")
if err != nil {
t.Fatalf("read 0073 down: %v", err)
}
upSQL, err := deploy.Migrations.ReadFile("migrations/0073_send_replay_snapshots.up.sql")
if err != nil {
t.Fatalf("read 0073 up: %v", err)
}
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin migration round trip: %v", err)
}
defer func() { _ = tx.Rollback(context.Background()) }()
if _, err := tx.Exec(ctx, string(downSQL)); err != nil {
t.Fatalf("0073 down: %v", err)
}
if _, err := tx.Exec(ctx, string(upSQL)); err != nil {
t.Fatalf("0073 up: %v", err)
}
var privateSnapshot, channelSnapshot, privateDeleteIDs, channelDeleteIDs bool
if err := tx.QueryRow(ctx, `
SELECT
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='private_messages' AND column_name='sender_snapshot'),
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='channel_messages' AND column_name='send_snapshot'),
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='private_messages' AND column_name='sender_delete_message_ids'),
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='channel_messages' AND column_name='delete_message_ids')
`).Scan(&privateSnapshot, &channelSnapshot, &privateDeleteIDs, &channelDeleteIDs); err != nil {
t.Fatalf("inspect 0073 columns: %v", err)
}
if !privateSnapshot || !channelSnapshot || !privateDeleteIDs || !channelDeleteIDs {
t.Fatalf("0073 columns private/channel snapshot=%v/%v delete_ids=%v/%v, want all true", privateSnapshot, channelSnapshot, privateDeleteIDs, channelDeleteIDs)
}
}

View file

@ -84,6 +84,12 @@ WITH pm AS (
sender_user_id,
recipient_user_id,
random_id,
request_fingerprint,
recipient_delivered,
sender_box_id,
sender_pts,
recipient_box_id,
recipient_pts,
message_date,
body,
entities
@ -91,6 +97,12 @@ WITH pm AS (
$1,
$2,
0,
'\x'::bytea,
false,
0,
0,
0,
0,
$3,
$4,
$5::jsonb
@ -530,6 +542,12 @@ INSERT INTO private_messages (
sender_user_id,
recipient_user_id,
random_id,
request_fingerprint,
recipient_delivered,
sender_box_id,
sender_pts,
recipient_box_id,
recipient_pts,
message_date,
ttl_period,
expires_at,
@ -556,27 +574,29 @@ INSERT INTO private_messages (
grouped_id,
effect
) VALUES (
$1, $2, $3, $4, $6::int, $7::int, $5, $8::jsonb,
$9::boolean,
$10::boolean,
$11::int,
$12::text,
$13::bigint,
$14::int,
$15::int,
$16::text,
$17::jsonb,
$18::int,
$19::text,
$20::bigint,
$1, $2, $3, $6::bytea, $7::boolean,
0, 0, 0, 0,
$4, $8::int, $9::int, $5, $10::jsonb,
$11::boolean,
$12::boolean,
$13::int,
$14::text,
$15::bigint,
$16::int,
$17::int,
$18::text,
$19::jsonb,
$20::int,
$21::text,
$22::int,
$23::jsonb,
$24::jsonb,
$22::bigint,
$23::text,
$24::int,
$25::jsonb,
$26::bigint,
$27::bigint,
$28::bigint
$26::jsonb,
$27::jsonb,
$28::bigint,
$29::bigint,
$30::bigint
)
ON CONFLICT (sender_user_id, random_id) WHERE random_id <> 0 DO NOTHING
RETURNING
@ -593,34 +613,36 @@ RETURNING
`
type CreatePrivateMessageParams struct {
SenderUserID int64
RecipientUserID int64
RandomID int64
MessageDate int32
Body string
TtlPeriod int32
ExpiresAt int32
EntitiesJson []byte
Silent bool
Noforwards bool
ReplyToMsgID int32
ReplyToPeerType string
ReplyToPeerID int64
ReplyToTopID int32
ReplyToStoryID int32
QuoteText string
QuoteEntitiesJson []byte
QuoteOffset int32
FwdFromPeerType string
FwdFromPeerID int64
FwdFromName string
FwdDate int32
MediaJson []byte
ReplyMarkupJson []byte
RichMessageJson []byte
ViaBotID int64
GroupedID int64
Effect int64
SenderUserID int64
RecipientUserID int64
RandomID int64
MessageDate int32
Body string
RequestFingerprint []byte
RecipientDelivered bool
TtlPeriod int32
ExpiresAt int32
EntitiesJson []byte
Silent bool
Noforwards bool
ReplyToMsgID int32
ReplyToPeerType string
ReplyToPeerID int64
ReplyToTopID int32
ReplyToStoryID int32
QuoteText string
QuoteEntitiesJson []byte
QuoteOffset int32
FwdFromPeerType string
FwdFromPeerID int64
FwdFromName string
FwdDate int32
MediaJson []byte
ReplyMarkupJson []byte
RichMessageJson []byte
ViaBotID int64
GroupedID int64
Effect int64
}
type CreatePrivateMessageRow struct {
@ -643,6 +665,8 @@ func (q *Queries) CreatePrivateMessage(ctx context.Context, arg CreatePrivateMes
arg.RandomID,
arg.MessageDate,
arg.Body,
arg.RequestFingerprint,
arg.RecipientDelivered,
arg.TtlPeriod,
arg.ExpiresAt,
arg.EntitiesJson,
@ -1969,6 +1993,17 @@ SELECT
sender_user_id,
recipient_user_id,
random_id,
request_fingerprint,
recipient_delivered,
sender_box_id,
sender_pts,
recipient_box_id,
recipient_pts,
sender_snapshot::text AS sender_snapshot_json,
sender_delete_pts,
sender_delete_pts_count,
sender_delete_date,
sender_delete_message_ids::text AS sender_delete_message_ids_json,
message_date,
ttl_period,
expires_at,
@ -1987,16 +2022,27 @@ type GetPrivateMessageByRandomIDParams struct {
}
type GetPrivateMessageByRandomIDRow struct {
ID int64
SenderUserID int64
RecipientUserID int64
RandomID int64
MessageDate int32
TtlPeriod int32
ExpiresAt int32
EditDate int32
Body string
EntitiesJson string
ID int64
SenderUserID int64
RecipientUserID int64
RandomID int64
RequestFingerprint []byte
RecipientDelivered bool
SenderBoxID int32
SenderPts int32
RecipientBoxID int32
RecipientPts int32
SenderSnapshotJson string
SenderDeletePts int32
SenderDeletePtsCount int32
SenderDeleteDate int32
SenderDeleteMessageIdsJson string
MessageDate int32
TtlPeriod int32
ExpiresAt int32
EditDate int32
Body string
EntitiesJson string
}
func (q *Queries) GetPrivateMessageByRandomID(ctx context.Context, arg GetPrivateMessageByRandomIDParams) (GetPrivateMessageByRandomIDRow, error) {
@ -2007,6 +2053,17 @@ func (q *Queries) GetPrivateMessageByRandomID(ctx context.Context, arg GetPrivat
&i.SenderUserID,
&i.RecipientUserID,
&i.RandomID,
&i.RequestFingerprint,
&i.RecipientDelivered,
&i.SenderBoxID,
&i.SenderPts,
&i.RecipientBoxID,
&i.RecipientPts,
&i.SenderSnapshotJson,
&i.SenderDeletePts,
&i.SenderDeletePtsCount,
&i.SenderDeleteDate,
&i.SenderDeleteMessageIdsJson,
&i.MessageDate,
&i.TtlPeriod,
&i.ExpiresAt,

View file

@ -174,6 +174,7 @@ type AuthKey struct {
SystemVersion string
ApiID int32
AppVersion string
LastUsedAt pgtype.Timestamptz
}
type Authorization struct {
@ -621,6 +622,11 @@ type ChannelMessage struct {
GroupedID int64
SavedPeerType string
SavedPeerID int64
SendSnapshot []byte
DeletePts int32
DeletePtsCount int32
DeleteDate int32
DeleteMessageIds []byte
}
type ChannelMessageMedium struct {
@ -689,6 +695,14 @@ type ChannelUnreadMentionIndex struct {
CreatedAt pgtype.Timestamptz
}
type ChannelUpdateCheckpoint struct {
ChannelID int64
RetainedThroughPts int32
LatestEventDate int32
LatestPts int32
UpdatedAt pgtype.Timestamptz
}
type ChannelUpdateEvent struct {
ChannelID int64
Pts int32
@ -835,6 +849,16 @@ type DispatchOutbox struct {
UpdatedAt pgtype.Timestamptz
}
type DispatchOutboxUserHead struct {
TargetUserID int64
HeadID int64
HeadPts int32
LogicalShard *int16
Status string
NextAttemptAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type Document struct {
ID int64
AccessHash int64
@ -1160,38 +1184,49 @@ type PrivateMediaCategoryCount struct {
}
type PrivateMessage struct {
ID int64
SenderUserID int64
RecipientUserID int64
RandomID int64
MessageDate int32
Body string
Entities []byte
CreatedAt pgtype.Timestamptz
EditDate int32
Silent bool
Noforwards bool
ReplyToMsgID int32
ReplyToPeerType string
ReplyToPeerID int64
ReplyToTopID int32
QuoteText string
QuoteEntities []byte
QuoteOffset int32
FwdFromPeerType string
FwdFromPeerID int64
FwdFromName string
FwdDate int32
Media []byte
TtlPeriod int32
ExpiresAt int32
ReplyMarkup []byte
ViaBotID int64
RichMessage []byte
GroupedID int64
ReplyToStoryID int32
Effect int64
HideEdited bool
ID int64
SenderUserID int64
RecipientUserID int64
RandomID int64
MessageDate int32
Body string
Entities []byte
CreatedAt pgtype.Timestamptz
EditDate int32
Silent bool
Noforwards bool
ReplyToMsgID int32
ReplyToPeerType string
ReplyToPeerID int64
ReplyToTopID int32
QuoteText string
QuoteEntities []byte
QuoteOffset int32
FwdFromPeerType string
FwdFromPeerID int64
FwdFromName string
FwdDate int32
Media []byte
TtlPeriod int32
ExpiresAt int32
ReplyMarkup []byte
ViaBotID int64
RichMessage []byte
GroupedID int64
ReplyToStoryID int32
Effect int64
HideEdited bool
RequestFingerprint []byte
RecipientDelivered bool
SenderBoxID int32
SenderPts int32
RecipientBoxID int32
RecipientPts int32
SenderSnapshot []byte
SenderDeletePts int32
SenderDeletePtsCount int32
SenderDeleteDate int32
SenderDeleteMessageIds []byte
}
type PrivateMessageReaction struct {
@ -1482,13 +1517,14 @@ type ThemeUserInstall struct {
}
type UpdateState struct {
AuthKeyID int64
Pts int32
Qts int32
Date int32
Seq int32
UpdatedAt pgtype.Timestamptz
UserID int64
AuthKeyID int64
Pts int32
Qts int32
Date int32
Seq int32
UpdatedAt pgtype.Timestamptz
UserID int64
ObservedPts int32
}
type UploadPart struct {
@ -1504,6 +1540,15 @@ type UploadPart struct {
Sha256 []byte
}
type UploadedMediaReceipt struct {
OwnerUserID int64
FileID int64
IntentHash []byte
MediaKind string
MediaID int64
CreatedAt pgtype.Timestamptz
}
type User struct {
ID int64
AccessHash int64
@ -1637,6 +1682,13 @@ type UserUpdateEvent struct {
EventPhone string
}
type UserUpdateRetention struct {
UserID int64
RetainedThroughPts int32
RetainedThroughDate int32
UpdatedAt pgtype.Timestamptz
}
type UserUpdateWatermark struct {
UserID int64
ContiguousPts int32

View file

@ -527,29 +527,29 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp
}
const claimDispatchOutbox = `-- name: ClaimDispatchOutbox :many
WITH picked AS (
SELECT d.target_user_id, d.id
FROM dispatch_outbox d
WITH picked_heads AS (
SELECT h.target_user_id, h.head_id
FROM dispatch_outbox_user_heads h
WHERE (
d.status = 'pending'
AND d.next_attempt_at <= now()
)
OR (
d.status = 'dispatching'
AND d.updated_at < now() - make_interval(secs => $1::int)
)
ORDER BY d.next_attempt_at ASC, d.target_user_id ASC, d.pts ASC, d.id ASC
h.status = 'pending'
AND h.next_attempt_at <= now()
)
OR (
h.status = 'dispatching'
AND h.updated_at < now() - make_interval(secs => $1::int)
)
ORDER BY h.next_attempt_at ASC, h.target_user_id ASC, h.head_pts ASC, h.head_id ASC
LIMIT $2
FOR UPDATE SKIP LOCKED
FOR UPDATE OF h SKIP LOCKED
)
UPDATE dispatch_outbox d
SET
status = 'dispatching',
attempts = d.attempts + 1,
updated_at = now()
FROM picked p
FROM picked_heads p
WHERE d.target_user_id = p.target_user_id
AND d.id = p.id
AND d.id = p.head_id
RETURNING
d.id,
d.target_user_id,
@ -575,6 +575,8 @@ type ClaimDispatchOutboxRow struct {
Attempts int32
}
// durable head 表只保留每用户一行,并同步 head 的 readiness。claim 先锁
// lane head 再更新对应 outbox 行,既不会扫描 backlog,也不会并发领取同一用户。
func (q *Queries) ClaimDispatchOutbox(ctx context.Context, arg ClaimDispatchOutboxParams) ([]ClaimDispatchOutboxRow, error) {
rows, err := q.db.Query(ctx, claimDispatchOutbox, arg.LeaseSeconds, arg.LimitCount)
if err != nil {
@ -603,14 +605,100 @@ func (q *Queries) ClaimDispatchOutbox(ctx context.Context, arg ClaimDispatchOutb
return items, nil
}
const claimDispatchOutboxShards = `-- name: ClaimDispatchOutboxShards :many
WITH picked_heads AS (
SELECT h.target_user_id, h.head_id
FROM dispatch_outbox_user_heads h
-- 256 与 store.DispatchOutboxLogicalShards、0069 generated column 是同一
-- schema 常量;不得随 worker 数变化。
WHERE h.logical_shard = ANY($1::smallint[])
AND (
(
h.status = 'pending'
AND h.next_attempt_at <= now()
)
OR (
h.status = 'dispatching'
AND h.updated_at < now() - make_interval(secs => $2::int)
)
)
ORDER BY h.next_attempt_at ASC, h.target_user_id ASC, h.head_pts ASC, h.head_id ASC
LIMIT $3
FOR UPDATE OF h SKIP LOCKED
)
UPDATE dispatch_outbox d
SET
status = 'dispatching',
attempts = d.attempts + 1,
updated_at = now()
FROM picked_heads p
WHERE d.target_user_id = p.target_user_id
AND d.id = p.head_id
RETURNING
d.id,
d.target_user_id,
d.pts,
d.event_type,
d.exclude_auth_key_id,
d.exclude_session_id,
d.attempts
`
type ClaimDispatchOutboxShardsParams struct {
ShardIds []int16
LeaseSeconds int32
LimitCount int32
}
type ClaimDispatchOutboxShardsRow struct {
ID int64
TargetUserID int64
Pts int32
EventType string
ExcludeAuthKeyID int64
ExcludeSessionID int64
Attempts int32
}
// 固定 logical shard 由 target_user_id 决定;运行时 worker 只领取分配给自己的
// shard 集合,因此同一用户永远只有一条串行 lane,而不同用户可并行。
func (q *Queries) ClaimDispatchOutboxShards(ctx context.Context, arg ClaimDispatchOutboxShardsParams) ([]ClaimDispatchOutboxShardsRow, error) {
rows, err := q.db.Query(ctx, claimDispatchOutboxShards, arg.ShardIds, arg.LeaseSeconds, arg.LimitCount)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ClaimDispatchOutboxShardsRow
for rows.Next() {
var i ClaimDispatchOutboxShardsRow
if err := rows.Scan(
&i.ID,
&i.TargetUserID,
&i.Pts,
&i.EventType,
&i.ExcludeAuthKeyID,
&i.ExcludeSessionID,
&i.Attempts,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const deleteFailedDispatchOutbox = `-- name: DeleteFailedDispatchOutbox :one
WITH doomed AS (
SELECT target_user_id, id
FROM dispatch_outbox
WHERE status = 'failed'
AND updated_at < now() - make_interval(secs => $1::int)
ORDER BY updated_at ASC, target_user_id ASC, id ASC
WITH doomed AS MATERIALIZED (
SELECT h.target_user_id, h.head_id AS id
FROM dispatch_outbox_user_heads h
WHERE h.status = 'failed'
AND h.updated_at < now() - make_interval(secs => $1::int)
ORDER BY h.updated_at ASC, h.target_user_id ASC, h.head_id ASC
LIMIT $2
FOR UPDATE OF h SKIP LOCKED
),
deleted AS (
DELETE FROM dispatch_outbox d
@ -628,6 +716,9 @@ type DeleteFailedDispatchOutboxParams struct {
LimitCount int32
}
// failed 只能成为 lane head;从 head 表开始并先锁 head,既走 0074 的小索引,也与
// claim/completion 保持同一 user_heads→outbox 锁序。删除的只是在线任务,durable
// user_update_events 不动,故客户端仍可经 difference 恢复。
func (q *Queries) DeleteFailedDispatchOutbox(ctx context.Context, arg DeleteFailedDispatchOutboxParams) (int32, error) {
row := q.db.QueryRow(ctx, deleteFailedDispatchOutbox, arg.OlderThanSeconds, arg.LimitCount)
var deleted_count int32
@ -1087,66 +1178,121 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd
return items, nil
}
const markDispatchDelivered = `-- name: MarkDispatchDelivered :exec
DELETE FROM dispatch_outbox
WHERE target_user_id = $1
AND id = $2
const markDispatchDelivered = `-- name: MarkDispatchDelivered :execrows
WITH locked_head AS MATERIALIZED (
SELECT h.target_user_id
FROM dispatch_outbox_user_heads h
WHERE h.target_user_id = $3::bigint
FOR UPDATE
)
DELETE FROM dispatch_outbox d
USING locked_head h
WHERE d.target_user_id = h.target_user_id
AND d.id = $1::bigint
AND d.status = 'dispatching'
AND d.attempts = $2::int
`
type MarkDispatchDeliveredParams struct {
TargetUserID int64
ID int64
ID int64
ExpectedAttempts int32
TargetUserID int64
}
// 方案 A:投递成功即删除。outbox 是任务队列,delivered 行无保留价值
// (消息在 message_boxes、离线补偿在 user_update_events),删除让表维持「未完成任务」小稳态。
func (q *Queries) MarkDispatchDelivered(ctx context.Context, arg MarkDispatchDeliveredParams) error {
_, err := q.db.Exec(ctx, markDispatchDelivered, arg.TargetUserID, arg.ID)
return err
// claim 的锁序是 user_heads→outbox;completion 必须先显式锁同一 head 再删 outbox,
// 否则租约过期 claim 与完成恰好竞争时会形成 outbox→head / head→outbox 环路。
func (q *Queries) MarkDispatchDelivered(ctx context.Context, arg MarkDispatchDeliveredParams) (int64, error) {
result, err := q.db.Exec(ctx, markDispatchDelivered, arg.ID, arg.ExpectedAttempts, arg.TargetUserID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const markDispatchDeliveredBatch = `-- name: MarkDispatchDeliveredBatch :exec
const markDispatchDeliveredBatch = `-- name: MarkDispatchDeliveredBatch :execrows
WITH input AS MATERIALIZED (
SELECT tu.target_user_id, di.id, ea.attempts
FROM unnest($1::bigint[]) WITH ORDINALITY AS tu(target_user_id, ord)
JOIN unnest($2::bigint[]) WITH ORDINALITY AS di(id, ord) USING (ord)
JOIN unnest($3::int[]) WITH ORDINALITY AS ea(attempts, ord) USING (ord)
),
locked_heads AS MATERIALIZED (
SELECT h.target_user_id
FROM dispatch_outbox_user_heads h
JOIN (SELECT DISTINCT target_user_id FROM input) i USING (target_user_id)
-- Match ClaimDispatchOutbox[Shards] exactly. A stale-lease claim may lock several
-- dispatching heads while this completion batch locks the same set; a different
-- multi-row order would merely move the deadlock one level up.
ORDER BY h.next_attempt_at, h.target_user_id, h.head_pts, h.head_id
FOR UPDATE OF h
)
DELETE FROM dispatch_outbox d
USING unnest($1::bigint[]) WITH ORDINALITY AS tu(target_user_id, ord)
JOIN unnest($2::bigint[]) WITH ORDINALITY AS di(id, ord) USING (ord)
WHERE d.target_user_id = tu.target_user_id
AND d.id = di.id
USING input i, locked_heads h
WHERE d.target_user_id = h.target_user_id
AND d.target_user_id = i.target_user_id
AND d.id = i.id
AND d.status = 'dispatching'
AND d.attempts = i.attempts
`
type MarkDispatchDeliveredBatchParams struct {
TargetUserIds []int64
Ids []int64
TargetUserIds []int64
Ids []int64
ExpectedAttempts []int32
}
// 批量删除一批已投递的 (target_user_id, id);target_user_id 入 WHERE 命中唯一索引并避免串删。
func (q *Queries) MarkDispatchDeliveredBatch(ctx context.Context, arg MarkDispatchDeliveredBatchParams) error {
_, err := q.db.Exec(ctx, markDispatchDeliveredBatch, arg.TargetUserIds, arg.Ids)
return err
func (q *Queries) MarkDispatchDeliveredBatch(ctx context.Context, arg MarkDispatchDeliveredBatchParams) (int64, error) {
result, err := q.db.Exec(ctx, markDispatchDeliveredBatch, arg.TargetUserIds, arg.Ids, arg.ExpectedAttempts)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const markDispatchFailed = `-- name: MarkDispatchFailed :exec
UPDATE dispatch_outbox
const markDispatchFailed = `-- name: MarkDispatchFailed :execrows
WITH locked_head AS MATERIALIZED (
SELECT h.target_user_id
FROM dispatch_outbox_user_heads h
WHERE h.target_user_id = $4::bigint
FOR UPDATE
)
UPDATE dispatch_outbox d
SET
status = CASE WHEN attempts >= 5 THEN 'failed' ELSE 'pending' END,
status = CASE WHEN d.attempts >= 5 THEN 'failed' ELSE 'pending' END,
next_attempt_at = CASE
WHEN attempts >= 5 THEN next_attempt_at
ELSE now() + make_interval(secs => LEAST(60, attempts * attempts))
WHEN d.attempts >= 5 THEN d.next_attempt_at
ELSE now() + make_interval(secs => LEAST(60, d.attempts * d.attempts))
END,
last_error = $3,
last_error = $1::text,
updated_at = now()
WHERE target_user_id = $1
AND id = $2
FROM locked_head h
WHERE d.target_user_id = h.target_user_id
AND d.id = $2::bigint
AND d.status = 'dispatching'
AND d.attempts = $3::int
`
type MarkDispatchFailedParams struct {
TargetUserID int64
ID int64
LastError string
LastError string
ID int64
ExpectedAttempts int32
TargetUserID int64
}
func (q *Queries) MarkDispatchFailed(ctx context.Context, arg MarkDispatchFailedParams) error {
_, err := q.db.Exec(ctx, markDispatchFailed, arg.TargetUserID, arg.ID, arg.LastError)
return err
func (q *Queries) MarkDispatchFailed(ctx context.Context, arg MarkDispatchFailedParams) (int64, error) {
result, err := q.db.Exec(ctx, markDispatchFailed,
arg.LastError,
arg.ID,
arg.ExpectedAttempts,
arg.TargetUserID,
)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const maxUserPts = `-- name: MaxUserPts :one

View file

@ -0,0 +1,300 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
const userUpdateRetentionTransactionBatch = 256
// DeleteConfirmedPrefix 删除账号 durable update 的共同确认安全前缀。
//
// 安全边界:只考虑当前 authorizations;任一授权缺 update_states 时其 observed 水位按 0,
// 因而不会删除它可能仍需的事件。AuthorizationStore.Bind 会为新授权以账号当前水位
// 初始化 delivered state、以已回收 floor 初始化 observed baseline:新设备无需恢复其授权
// 创建前已删除的事件,但在主动报告后续 pts 前仍会阻塞新的前缀回收。
func (s *UpdateEventStore) DeleteConfirmedPrefix(ctx context.Context, olderThan time.Duration, limit int) (int, error) {
if s == nil || s.db == nil {
return 0, nil
}
if olderThan <= 0 {
olderThan = 7 * 24 * time.Hour
}
if limit <= 0 {
limit = 10000
}
if limit > 100000 {
limit = 100000
}
cutoff := int32(time.Now().Add(-olderThan).Unix())
deletedTotal := 0
excluded := make([]int64, 0)
for deletedTotal < limit {
userID, err := s.oldestConfirmedRetentionCandidate(ctx, cutoff, excluded)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
break
}
return deletedTotal, err
}
deleted := 0
err = withTx(ctx, s.db, "delete confirmed user update prefix", func(tx pgx.Tx) error {
var pruneErr error
chunkLimit := limit - deletedTotal
if chunkLimit > userUpdateRetentionTransactionBatch {
chunkLimit = userUpdateRetentionTransactionBatch
}
deleted, pruneErr = pruneConfirmedUserPrefixTx(ctx, tx, userID, cutoff, chunkLimit)
return pruneErr
})
if err != nil {
return deletedTotal, err
}
if deleted == 0 {
// candidate 在选取与加锁之间可能被其它 worker 处理,或遇到既有空洞;
// 本轮排除后继续找其它用户,避免一个竞态账号饿死全局回收。
// candidate SQL 已只允许 floor 后的 immediate head,不再让“新 head+旧 tail”
// 或缺口账号占用任意 256-pass 配额;因此这里也不再设人为 256 截断。
excluded = append(excluded, userID)
continue
}
deletedTotal += deleted
}
return deletedTotal, nil
}
func (s *UpdateEventStore) oldestConfirmedRetentionCandidate(ctx context.Context, cutoff int32, excluded []int64) (int64, error) {
var userID int64
err := s.db.QueryRow(ctx, `
SELECT e.user_id
FROM user_update_events e
LEFT JOIN user_update_retention r ON r.user_id = e.user_id
WHERE e.date < $1
AND e.pts > COALESCE(r.retained_through_pts, 0)
AND e.pts_count > 0
-- Only the first complete event immediately after the retained floor may make
-- a user a candidate. A later old-dated tail behind a recent head must not
-- repeatedly win the global date seek and then produce a zero-row prune.
AND e.pts = COALESCE(r.retained_through_pts, 0) + e.pts_count
AND NOT EXISTS (
SELECT 1
FROM user_update_events earlier
WHERE earlier.user_id = e.user_id
AND earlier.pts > COALESCE(r.retained_through_pts, 0)
AND earlier.pts < e.pts
)
AND NOT (e.user_id = ANY($2::bigint[]))
AND EXISTS (SELECT 1 FROM authorizations a WHERE a.user_id = e.user_id)
AND e.pts <= COALESCE((
SELECT MIN(COALESCE(s.observed_pts, 0))
FROM authorizations a
LEFT JOIN update_states s
ON s.auth_key_id = a.auth_key_id
AND s.user_id = a.user_id
WHERE a.user_id = e.user_id
), 0)
ORDER BY e.date ASC, e.user_id ASC, e.pts ASC
LIMIT 1`, cutoff, excluded).Scan(&userID)
if err != nil {
return 0, err
}
return userID, nil
}
type retainedUserEventRow struct {
pts int
ptsCount int
date int
}
func pruneConfirmedUserPrefixTx(ctx context.Context, tx pgx.Tx, userID int64, cutoff int32, limit int) (int, error) {
if userID == 0 || limit <= 0 {
return 0, nil
}
// 与所有 pts 分配共享 watermark 行锁:新业务事件不能在 floor 计算与删除之间穿插。
var currentPts int
if err := tx.QueryRow(ctx, `
SELECT contiguous_pts
FROM user_update_watermarks
WHERE user_id = $1
FOR UPDATE`, userID).Scan(&currentPts); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil
}
return 0, fmt.Errorf("lock user update watermark: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO user_update_retention (user_id)
VALUES ($1)
ON CONFLICT (user_id) DO NOTHING`, userID); err != nil {
return 0, fmt.Errorf("ensure user update retention: %w", err)
}
var floor int
if err := tx.QueryRow(ctx, `
SELECT retained_through_pts
FROM user_update_retention
WHERE user_id = $1
FOR UPDATE`, userID).Scan(&floor); err != nil {
return 0, fmt.Errorf("lock user update retention: %w", err)
}
var authCount, safePts int
if err := tx.QueryRow(ctx, `
SELECT COUNT(*)::int, COALESCE(MIN(COALESCE(s.observed_pts, 0)), 0)::int
FROM authorizations a
LEFT JOIN update_states s
ON s.auth_key_id = a.auth_key_id
AND s.user_id = a.user_id
WHERE a.user_id = $1`, userID).Scan(&authCount, &safePts); err != nil {
return 0, fmt.Errorf("load confirmed user update floor: %w", err)
}
if authCount == 0 || safePts <= floor {
return 0, nil
}
if safePts > currentPts {
return 0, fmt.Errorf("confirmed user update pts %d exceeds current %d for user %d", safePts, currentPts, userID)
}
rows, err := tx.Query(ctx, `
SELECT pts, pts_count, date
FROM user_update_events
WHERE user_id = $1
AND pts > $2
AND pts <= $3
AND date < $4
ORDER BY pts ASC
LIMIT $5`, userID, floor, safePts, cutoff, limit)
if err != nil {
return 0, fmt.Errorf("list confirmed user update prefix: %w", err)
}
defer rows.Close()
events := make([]retainedUserEventRow, 0, limit)
expected := floor
for rows.Next() {
var event retainedUserEventRow
if err := rows.Scan(&event.pts, &event.ptsCount, &event.date); err != nil {
return 0, fmt.Errorf("scan confirmed user update prefix: %w", err)
}
if event.ptsCount <= 0 {
return 0, fmt.Errorf("invalid pts_count %d at user %d pts %d", event.ptsCount, userID, event.pts)
}
expected += event.ptsCount
if event.pts != expected {
// 绝不跨越既有空洞推进 retained floor。
break
}
events = append(events, event)
}
if err := rows.Err(); err != nil {
rows.Close()
return 0, fmt.Errorf("iterate confirmed user update prefix: %w", err)
}
// A gap/date boundary may stop iteration before pgx consumed the result set. Close explicitly
// before issuing DELETE on the same transaction connection; otherwise pgx reports conn busy.
rows.Close()
if len(events) == 0 {
return 0, nil
}
pts := make([]int32, len(events))
for i, event := range events {
pts[i] = int32(event.pts)
}
// Every outbox mutation follows user_heads→outbox. Retention may remove a pending or leased
// task after the client has already confirmed its durable event; lock the lane head first so
// it cannot deadlock a lease-expiry claim/completion. No head means these events have no online
// task and the durable prefix can still be pruned safely.
var lockedDispatchUserID int64
err = tx.QueryRow(ctx, `
SELECT target_user_id
FROM dispatch_outbox_user_heads
WHERE target_user_id = $1
FOR UPDATE`, userID).Scan(&lockedDispatchUserID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return 0, fmt.Errorf("lock retained user update dispatch head: %w", err)
}
// A retained durable event can still have a pending/dispatching outbox row (for example a
// client confirmed the pts through difference while an online push lease was in flight).
// Remove those leases first, in this same transaction. The outbox head trigger promotes the
// next user lane row; any worker holding an old attempts token is fenced by MarkDelivered/
// MarkFailed returning ErrDispatchLeaseLost after this commit.
if _, err := tx.Exec(ctx, `
DELETE FROM dispatch_outbox
WHERE target_user_id = $1
AND pts = ANY($2::int[])`, userID, pts); err != nil {
return 0, fmt.Errorf("delete retained user update dispatch outbox: %w", err)
}
tag, err := tx.Exec(ctx, `
DELETE FROM user_update_events
WHERE user_id = $1
AND pts = ANY($2::int[])`, userID, pts)
if err != nil {
return 0, fmt.Errorf("delete confirmed user update prefix: %w", err)
}
if tag.RowsAffected() != int64(len(events)) {
return 0, fmt.Errorf("delete confirmed user update prefix affected %d rows, want %d", tag.RowsAffected(), len(events))
}
last := events[len(events)-1]
if _, err := tx.Exec(ctx, `
UPDATE user_update_retention
SET retained_through_pts = $2,
retained_through_date = $3,
updated_at = now()
WHERE user_id = $1`, userID, last.pts, last.date); err != nil {
return 0, fmt.Errorf("advance user update retention: %w", err)
}
return len(events), nil
}
// UserUpdateRetentionCheckpoint 返回当前 auth key 已明确确认、可通过普通
// differenceSlice 跳过的安全前缀。
//
// 一旦 retained floor > 0,仍存在的 authorization 必须同时有 observed_pts >= floor;
// AuthorizationStore.Bind 在同一事务建立这个 baseline。若这里看到 authorization 存在但
// state 缺失/倒退,说明生命周期不变量已经破坏。此时必须 fail-fast,不能返回 ok=false 后让
// GetDifference 从已删除前缀继续读并伪装成空差分。
func (s *UpdateEventStore) UserUpdateRetentionCheckpoint(ctx context.Context, authKeyID [8]byte, userID int64) (pts, date int, ok bool, err error) {
if s == nil || s.db == nil || userID == 0 || authKeyID == ([8]byte{}) {
return 0, 0, false, nil
}
var (
authorized bool
observed int
)
err = s.db.QueryRow(ctx, `
SELECT
r.retained_through_pts,
r.retained_through_date,
EXISTS (
SELECT 1
FROM authorizations a
WHERE a.auth_key_id = $1
AND a.user_id = r.user_id
) AS authorized,
COALESCE((
SELECT s.observed_pts
FROM update_states s
WHERE s.auth_key_id = $1
AND s.user_id = r.user_id
), -1)::int AS observed_pts
FROM user_update_retention r
WHERE r.user_id = $2
AND r.retained_through_pts > 0`, authKeyIDToInt64(authKeyID), userID).Scan(&pts, &date, &authorized, &observed)
if errors.Is(err, pgx.ErrNoRows) {
return 0, 0, false, nil
}
if err != nil {
return 0, 0, false, fmt.Errorf("get user update retention checkpoint: %w", err)
}
if !authorized {
return 0, 0, false, nil
}
if observed < pts {
return 0, 0, false, fmt.Errorf(
"get user update retention checkpoint: invariant violation: auth key %x user %d observed pts %d below retained floor %d",
authKeyID, userID, observed, pts,
)
}
return pts, date, true, nil
}

View file

@ -0,0 +1,643 @@
package postgres
import (
"context"
"crypto/rand"
"errors"
"fmt"
"testing"
"time"
appupdates "telesrv/internal/app/updates"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func TestUserUpdateRetentionUsesClientObservedCommonPrefixPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
userID := createRevokeTestUser(t, ctx, pool, "update-retention")
keys := NewAuthKeyStore(pool)
auths := NewAuthorizationStore(pool)
states := NewUpdateStateStore(pool)
events := NewUpdateEventStore(pool)
newKey := func() [8]byte {
var id [8]byte
if _, err := rand.Read(id[:]); err != nil {
t.Fatalf("random auth key id: %v", err)
}
return id
}
authOne, authTwo := newKey(), newKey()
for _, id := range [][8]byte{authOne, authTwo} {
if err := keys.Save(ctx, store.AuthKeyData{ID: id}); err != nil {
t.Fatalf("save auth key %x: %v", id, err)
}
id := id
t.Cleanup(func() { _ = keys.Delete(ctx, id) })
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: id, UserID: userID}); err != nil {
t.Fatalf("bind authorization %x: %v", id, err)
}
}
const oldDate = 1_600_000_000
for i := 1; i <= 3; i++ {
if _, err := events.AppendAllocated(ctx, userID, domain.UpdateEvent{
Type: domain.UpdateEventNoop, PtsCount: 1, Date: oldDate + i,
}); err != nil {
t.Fatalf("append event %d: %v", i, err)
}
}
// Save is the state the server has sent/constructed. Neither device has proved receipt, so it
// must not authorize retention even though both delivered cursors are at pts=3.
for _, id := range [][8]byte{authOne, authTwo} {
if err := states.Save(ctx, id, userID, domain.UpdateState{Pts: 3, Date: oldDate + 3}); err != nil {
t.Fatalf("save delivered state %x: %v", id, err)
}
}
if deleted, err := events.DeleteConfirmedPrefix(ctx, time.Second, 10); err != nil || deleted != 0 {
t.Fatalf("delete with no observed cursor = %d/%v, want 0/nil", deleted, err)
}
if err := states.ObserveClientState(ctx, authOne, userID, domain.UpdateState{Pts: 3, Date: oldDate + 3}); err != nil {
t.Fatalf("observe first device: %v", err)
}
if deleted, err := events.DeleteConfirmedPrefix(ctx, time.Second, 10); err != nil || deleted != 0 {
t.Fatalf("delete while second device unobserved = %d/%v, want 0/nil", deleted, err)
}
// Common observed floor=min(3,1)=1, so exactly the first contiguous event is removable.
if err := states.ObserveClientState(ctx, authTwo, userID, domain.UpdateState{Pts: 1, Date: oldDate + 1}); err != nil {
t.Fatalf("observe second device pts=1: %v", err)
}
deleted, err := events.DeleteConfirmedPrefix(ctx, time.Second, 10)
if err != nil || deleted != 1 {
t.Fatalf("delete common prefix = %d/%v, want 1/nil", deleted, err)
}
pts, date, ok, err := events.UserUpdateRetentionCheckpoint(ctx, authTwo, userID)
if err != nil || !ok || pts != 1 || date != oldDate+1 {
t.Fatalf("checkpoint = pts:%d date:%d ok:%v err:%v, want 1/%d/true/nil", pts, date, ok, err, oldDate+1)
}
remaining, err := events.ListAfter(ctx, userID, 0, 10)
if err != nil || len(remaining) != 2 || remaining[0].Pts != 2 || remaining[1].Pts != 3 {
t.Fatalf("remaining events = %+v err=%v, want pts 2,3", remaining, err)
}
if err := states.ObserveClientState(ctx, authTwo, userID, domain.UpdateState{Pts: 3, Date: oldDate + 3}); err != nil {
t.Fatalf("observe second device pts=3: %v", err)
}
deleted, err = events.DeleteConfirmedPrefix(ctx, time.Second, 10)
if err != nil || deleted != 2 {
t.Fatalf("delete remaining common prefix = %d/%v, want 2/nil", deleted, err)
}
// A newly created authorization did not exist when the common prefix was confirmed. Seed its
// observed baseline at the retained floor (not at current pts): it can receive an ordinary
// empty differenceSlice checkpoint instead of falling into a silent hole, while still blocking
// any future pruning until it reports subsequent progress itself.
authThree := newKey()
if err := keys.Save(ctx, store.AuthKeyData{ID: authThree}); err != nil {
t.Fatalf("save third auth key: %v", err)
}
t.Cleanup(func() { _ = keys.Delete(ctx, authThree) })
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: authThree, UserID: userID}); err != nil {
t.Fatalf("bind third authorization: %v", err)
}
pts, date, ok, err = events.UserUpdateRetentionCheckpoint(ctx, authThree, userID)
if err != nil || !ok || pts != 3 || date != oldDate+3 {
t.Fatalf("new authorization checkpoint = pts:%d date:%d ok:%v err:%v, want 3/%d/true/nil", pts, date, ok, err, oldDate+3)
}
var observed int
if err := pool.QueryRow(ctx, `
SELECT observed_pts FROM update_states WHERE auth_key_id = $1 AND user_id = $2
`, authKeyIDToInt64(authThree), userID).Scan(&observed); err != nil {
t.Fatalf("load third observed floor: %v", err)
}
if observed != 3 {
t.Fatalf("new authorization observed_pts = %d, want retained floor 3", observed)
}
}
func TestAuthorizationBindSwitchesAccountAfterRetainedFloorPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
oldUserID := createRevokeTestUser(t, ctx, pool, "retention-switch-old")
newUserID := createRevokeTestUser(t, ctx, pool, "retention-switch-new")
keys := NewAuthKeyStore(pool)
auths := NewAuthorizationStore(pool)
states := NewUpdateStateStore(pool)
events := NewUpdateEventStore(pool)
mainKey := randomUpdateRetentionAuthKey(t)
guardKey := randomUpdateRetentionAuthKey(t)
for _, id := range [][8]byte{mainKey, guardKey} {
if err := keys.Save(ctx, store.AuthKeyData{ID: id}); err != nil {
t.Fatalf("save auth key %x: %v", id, err)
}
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM update_states WHERE auth_key_id = ANY($1::bigint[])", []int64{
authKeyIDToInt64(mainKey), authKeyIDToInt64(guardKey),
})
_ = keys.Delete(ctx, mainKey)
_ = keys.Delete(ctx, guardKey)
})
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: mainKey, UserID: oldUserID}); err != nil {
t.Fatalf("bind main key to old account: %v", err)
}
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: guardKey, UserID: newUserID}); err != nil {
t.Fatalf("bind guard key to new account: %v", err)
}
const oldDate = 1_600_100_000
for i := 1; i <= 3; i++ {
if _, err := events.AppendAllocated(ctx, newUserID, domain.UpdateEvent{
Type: domain.UpdateEventNoop, PtsCount: 1, Date: oldDate + i,
}); err != nil {
t.Fatalf("append new-account event %d: %v", i, err)
}
}
if err := states.ObserveClientState(ctx, guardKey, newUserID, domain.UpdateState{Pts: 3, Date: oldDate + 3}); err != nil {
t.Fatalf("observe guard through pts 3: %v", err)
}
if deleted, err := events.DeleteConfirmedPrefix(ctx, time.Second, 10); err != nil || deleted != 3 {
t.Fatalf("prune new-account prefix = %d/%v, want 3/nil", deleted, err)
}
// This is the account-switch boundary that used to be followed by Router.ClearAuthKey,
// deleting the state Bind had just created for newUserID.
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: mainKey, UserID: newUserID}); err != nil {
t.Fatalf("switch main key to new account: %v", err)
}
var oldStates int
if err := pool.QueryRow(ctx, `
SELECT COUNT(*)::int
FROM update_states
WHERE auth_key_id = $1 AND user_id = $2`, authKeyIDToInt64(mainKey), oldUserID).Scan(&oldStates); err != nil {
t.Fatalf("count old-account states: %v", err)
}
if oldStates != 0 {
t.Fatalf("old-account update states = %d, want 0", oldStates)
}
var delivered, observed int
if err := pool.QueryRow(ctx, `
SELECT pts, observed_pts
FROM update_states
WHERE auth_key_id = $1 AND user_id = $2`, authKeyIDToInt64(mainKey), newUserID).Scan(&delivered, &observed); err != nil {
t.Fatalf("load switched-account state: %v", err)
}
if delivered != 3 || observed != 3 {
t.Fatalf("switched-account state = delivered:%d observed:%d, want 3/3", delivered, observed)
}
diff, err := appupdates.NewService(states, events).GetDifference(
ctx,
mainKey,
newUserID,
domain.UpdateState{Pts: 0, Date: oldDate},
)
if err != nil {
t.Fatalf("difference after account switch: %v", err)
}
if !diff.Partial || len(diff.Events) != 0 || diff.State.Pts != 3 {
t.Fatalf("switch checkpoint difference = %+v, want empty slice at retained pts 3", diff)
}
}
func TestAuthorizationBindRejectsFutureSameUserStatePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
userID := createRevokeTestUser(t, ctx, pool, "retention-stale-rebind")
keys := NewAuthKeyStore(pool)
auths := NewAuthorizationStore(pool)
states := NewUpdateStateStore(pool)
events := NewUpdateEventStore(pool)
guardKey := randomUpdateRetentionAuthKey(t)
staleKey := randomUpdateRetentionAuthKey(t)
for _, id := range [][8]byte{guardKey, staleKey} {
if err := keys.Save(ctx, store.AuthKeyData{ID: id}); err != nil {
t.Fatalf("save auth key %x: %v", id, err)
}
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM update_states WHERE auth_key_id = ANY($1::bigint[])", []int64{
authKeyIDToInt64(guardKey), authKeyIDToInt64(staleKey),
})
_ = keys.Delete(ctx, guardKey)
_ = keys.Delete(ctx, staleKey)
})
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: guardKey, UserID: userID}); err != nil {
t.Fatalf("bind guard authorization: %v", err)
}
const oldDate = 1_600_200_000
for i := 1; i <= 5; i++ {
if _, err := events.AppendAllocated(ctx, userID, domain.UpdateEvent{
Type: domain.UpdateEventNoop, PtsCount: 1, Date: oldDate + i,
}); err != nil {
t.Fatalf("append event %d: %v", i, err)
}
}
if err := states.ObserveClientState(ctx, guardKey, userID, domain.UpdateState{Pts: 3, Date: oldDate + 3}); err != nil {
t.Fatalf("observe guard pts 3: %v", err)
}
if deleted, err := events.DeleteConfirmedPrefix(ctx, time.Second, 10); err != nil || deleted != 3 {
t.Fatalf("prune stale-rebind prefix = %d/%v, want 3/nil", deleted, err)
}
// Deliberately inject historical corruption: the authorization is absent while a stale cursor
// claims a future pts beyond the account's contiguous watermark (5). Bind must fail-fast and
// leave the key unauthorized; preserving pts=7 would make future retention/difference lie.
if _, err := pool.Exec(ctx, `
INSERT INTO update_states (auth_key_id, user_id, pts, qts, date, seq, observed_pts)
VALUES ($1, $2, 7, 4, $3, 2, 1)`, authKeyIDToInt64(staleKey), userID, oldDate+1); err != nil {
t.Fatalf("insert stale update state: %v", err)
}
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: staleKey, UserID: userID}); err == nil {
t.Fatal("Bind accepted future update state, want invariant error")
}
if _, found, err := auths.ByAuthKey(ctx, staleKey); err != nil || found {
t.Fatalf("authorization after rejected Bind found=%v err=%v, want false/nil", found, err)
}
var delivered, qts, seq, observed int
if err := pool.QueryRow(ctx, `
SELECT pts, qts, seq, observed_pts
FROM update_states
WHERE auth_key_id = $1 AND user_id = $2`, authKeyIDToInt64(staleKey), userID).Scan(&delivered, &qts, &seq, &observed); err != nil {
t.Fatalf("load rejected stale state: %v", err)
}
if delivered != 7 || qts != 4 || seq != 2 || observed != 1 {
t.Fatalf("rejected stale state mutated = pts:%d qts:%d seq:%d observed:%d, want 7/4/2/1", delivered, qts, seq, observed)
}
// Once an explicit repair brings the persisted cursor back inside the current account
// watermark, Bind may establish the retained-floor baseline without moving qts/seq backwards.
if _, err := pool.Exec(ctx, `
UPDATE update_states
SET pts = 5
WHERE auth_key_id = $1 AND user_id = $2`, authKeyIDToInt64(staleKey), userID); err != nil {
t.Fatalf("repair future delivered state: %v", err)
}
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: staleKey, UserID: userID}); err != nil {
t.Fatalf("bind explicitly repaired authorization: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT pts, qts, seq, observed_pts
FROM update_states
WHERE auth_key_id = $1 AND user_id = $2`, authKeyIDToInt64(staleKey), userID).Scan(&delivered, &qts, &seq, &observed); err != nil {
t.Fatalf("load bound repaired state: %v", err)
}
if delivered != 5 || qts != 4 || seq != 2 || observed != 3 {
t.Fatalf("bound repaired state = pts:%d qts:%d seq:%d observed:%d, want 5/4/2/3", delivered, qts, seq, observed)
}
// Protection: if the lifecycle invariant is corrupted again, checkpoint lookup must fail-fast
// instead of letting GetDifference fall through to an empty read below deleted history.
if _, err := pool.Exec(ctx, `
UPDATE update_states
SET observed_pts = 1
WHERE auth_key_id = $1 AND user_id = $2`, authKeyIDToInt64(staleKey), userID); err != nil {
t.Fatalf("corrupt observed state for guard test: %v", err)
}
if _, _, _, err := events.UserUpdateRetentionCheckpoint(ctx, staleKey, userID); err == nil {
t.Fatal("checkpoint with observed below retained floor succeeded, want invariant error")
}
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: staleKey, UserID: userID}); err != nil {
t.Fatalf("same-user Bind did not repair observed floor: %v", err)
}
if pts, _, ok, err := events.UserUpdateRetentionCheckpoint(ctx, staleKey, userID); err != nil || !ok || pts != 3 {
t.Fatalf("checkpoint after same-user repair = pts:%d ok:%v err:%v", pts, ok, err)
}
}
func TestAuthorizationBindSerializesWithRetentionTwoConnectionsPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
userID := createRevokeTestUser(t, ctx, pool, "retention-bind-race")
keys := NewAuthKeyStore(pool)
auths := NewAuthorizationStore(pool)
states := NewUpdateStateStore(pool)
events := NewUpdateEventStore(pool)
guardKey := randomUpdateRetentionAuthKey(t)
newKey := randomUpdateRetentionAuthKey(t)
for _, id := range [][8]byte{guardKey, newKey} {
if err := keys.Save(ctx, store.AuthKeyData{ID: id}); err != nil {
t.Fatalf("save auth key %x: %v", id, err)
}
id := id
t.Cleanup(func() { _ = keys.Delete(ctx, id) })
}
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: guardKey, UserID: userID}); err != nil {
t.Fatalf("bind guard authorization: %v", err)
}
const eventDate = 1_600_300_001
if _, err := events.AppendAllocated(ctx, userID, domain.UpdateEvent{
Type: domain.UpdateEventNoop, PtsCount: 1, Date: eventDate,
}); err != nil {
t.Fatalf("append guarded event: %v", err)
}
if err := states.ObserveClientState(ctx, guardKey, userID, domain.UpdateState{Pts: 1, Date: eventDate}); err != nil {
t.Fatalf("observe guard event: %v", err)
}
retentionConn, err := pool.Acquire(ctx)
if err != nil {
t.Fatalf("acquire retention connection: %v", err)
}
defer retentionConn.Release()
bindConn, err := pool.Acquire(ctx)
if err != nil {
t.Fatalf("acquire bind connection: %v", err)
}
defer bindConn.Release()
tx, err := retentionConn.Begin(ctx)
if err != nil {
t.Fatalf("begin retention transaction: %v", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(context.Background())
}
}()
var currentPts, floor int
if err := tx.QueryRow(ctx, `
SELECT contiguous_pts
FROM user_update_watermarks
WHERE user_id = $1
FOR UPDATE`, userID).Scan(&currentPts); err != nil {
t.Fatalf("lock retention watermark: %v", err)
}
if err := tx.QueryRow(ctx, `
SELECT retained_through_pts
FROM user_update_retention
WHERE user_id = $1
FOR UPDATE`, userID).Scan(&floor); err != nil {
t.Fatalf("lock retention floor: %v", err)
}
if currentPts != 1 || floor != 0 {
t.Fatalf("pre-race watermark/floor = %d/%d, want 1/0", currentPts, floor)
}
bindCtx, cancelBind := context.WithTimeout(ctx, 5*time.Second)
defer cancelBind()
bindDone := make(chan error, 1)
go func() {
bindDone <- NewAuthorizationStore(bindConn).Bind(bindCtx, domain.Authorization{
AuthKeyID: newKey,
UserID: userID,
})
}()
// Observe the second physical connection waiting on the watermark row. This proves the
// synchronization is a database lock, rather than relying on scheduler timing in the test.
bindPID := bindConn.Conn().PgConn().PID()
waitDeadline := time.Now().Add(2 * time.Second)
waiting := false
for time.Now().Before(waitDeadline) {
select {
case err := <-bindDone:
t.Fatalf("Bind completed before retained-floor transaction committed: %v", err)
default:
}
if err := tx.QueryRow(ctx, `
SELECT COALESCE(wait_event_type = 'Lock', false)
FROM pg_stat_activity
WHERE pid = $1`, bindPID).Scan(&waiting); err != nil {
t.Fatalf("inspect bind lock wait: %v", err)
}
if waiting {
break
}
time.Sleep(10 * time.Millisecond)
}
if !waiting {
t.Fatal("Bind connection did not wait on retention watermark lock")
}
// Complete the valid confirmed-prefix transition while Bind is waiting. After commit Bind
// must read floor=1 and atomically seed observed_pts=1; floor=0 would create a silent hole.
if tag, err := tx.Exec(ctx, `
DELETE FROM user_update_events
WHERE user_id = $1 AND pts = 1`, userID); err != nil || tag.RowsAffected() != 1 {
t.Fatalf("delete retained event rows=%d err=%v, want 1/nil", tag.RowsAffected(), err)
}
if _, err := tx.Exec(ctx, `
UPDATE user_update_retention
SET retained_through_pts = 1,
retained_through_date = $2,
updated_at = now()
WHERE user_id = $1`, userID, eventDate); err != nil {
t.Fatalf("advance retained floor: %v", err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit retained floor: %v", err)
}
committed = true
select {
case err := <-bindDone:
if err != nil {
t.Fatalf("Bind after retention commit: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("Bind remained blocked after retention commit")
}
var delivered, observed int
if err := pool.QueryRow(ctx, `
SELECT pts, observed_pts
FROM update_states
WHERE auth_key_id = $1 AND user_id = $2`, authKeyIDToInt64(newKey), userID).Scan(&delivered, &observed); err != nil {
t.Fatalf("load raced bind baseline: %v", err)
}
if delivered != 1 || observed != 1 {
t.Fatalf("raced bind baseline = delivered:%d observed:%d, want 1/1", delivered, observed)
}
}
func TestUserUpdateRetentionOldTailsDoNotConsumeCandidatePassPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
const tailUsers = 256
const totalUsers = tailUsers + 1
prefix := fmt.Sprintf("+188%010d", time.Now().UnixNano()%10_000_000_000)
rows, err := pool.Query(ctx, `
INSERT INTO users (access_hash, phone, first_name)
SELECT $1::bigint + n, $2 || lpad(n::text, 3, '0'), 'retention-old-tail'
FROM generate_series(1, $3::int) AS n
RETURNING id
`, time.Now().UnixNano(), prefix, totalUsers)
if err != nil {
t.Fatalf("bulk insert old-tail users: %v", err)
}
userIDs := make([]int64, 0, totalUsers)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
rows.Close()
t.Fatalf("scan old-tail user: %v", err)
}
userIDs = append(userIDs, userID)
}
if err := rows.Err(); err != nil {
rows.Close()
t.Fatalf("iterate old-tail users: %v", err)
}
rows.Close()
if len(userIDs) != totalUsers {
t.Fatalf("inserted users = %d, want %d", len(userIDs), totalUsers)
}
authKeyIDs := make([]int64, len(userIDs))
watermarks := make([]int32, len(userIDs))
for i, userID := range userIDs {
authKeyIDs[i] = -userID
if i < tailUsers {
watermarks[i] = 2
} else {
watermarks[i] = 1
}
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM update_states WHERE auth_key_id = ANY($1::bigint[])", authKeyIDs)
_, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = ANY($1::bigint[])", authKeyIDs)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", userIDs)
})
if _, err := pool.Exec(ctx, `
INSERT INTO auth_keys (auth_key_id, body, server_salt)
SELECT id, decode(repeat('00', 256), 'hex'), 0
FROM unnest($1::bigint[]) AS id`, authKeyIDs); err != nil {
t.Fatalf("bulk insert old-tail auth keys: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO authorizations (auth_key_id, user_id)
SELECT * FROM unnest($1::bigint[], $2::bigint[])`, authKeyIDs, userIDs); err != nil {
t.Fatalf("bulk insert old-tail authorizations: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO user_update_watermarks (user_id, contiguous_pts)
SELECT * FROM unnest($1::bigint[], $2::integer[])`, userIDs, watermarks); err != nil {
t.Fatalf("bulk insert old-tail watermarks: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO update_states (auth_key_id, user_id, pts, observed_pts)
SELECT auth_key_id, user_id, pts, pts
FROM unnest($1::bigint[], $2::bigint[], $3::integer[]) AS input(auth_key_id, user_id, pts)`, authKeyIDs, userIDs, watermarks); err != nil {
t.Fatalf("bulk insert old-tail states: %v", err)
}
recentHeadDate := int32(time.Now().Add(time.Hour).Unix())
if _, err := pool.Exec(ctx, `
INSERT INTO user_update_events (user_id, pts, pts_count, date, event_type)
SELECT user_id, 1, 1, $2, 'noop'
FROM unnest($1::bigint[]) AS user_id`, userIDs[:tailUsers], recentHeadDate); err != nil {
t.Fatalf("insert recent old-tail heads: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO user_update_events (user_id, pts, pts_count, date, event_type)
SELECT user_id, 2, 1, 1, 'noop'
FROM unnest($1::bigint[]) AS user_id`, userIDs[:tailUsers]); err != nil {
t.Fatalf("insert old tails: %v", err)
}
healthyUserID := userIDs[len(userIDs)-1]
if _, err := pool.Exec(ctx, `
INSERT INTO user_update_events (user_id, pts, pts_count, date, event_type)
VALUES ($1, 1, 1, 2, 'noop')`, healthyUserID); err != nil {
t.Fatalf("insert healthy retention head: %v", err)
}
deleted, err := NewUpdateEventStore(pool).DeleteConfirmedPrefix(ctx, time.Second, 1)
if err != nil || deleted != 1 {
t.Fatalf("delete after 256 old tails = %d/%v, want healthy 1/nil", deleted, err)
}
var healthyRows, tailRows int
if err := pool.QueryRow(ctx, `
SELECT
(SELECT count(*) FROM user_update_events WHERE user_id = $1)::int,
(SELECT count(*) FROM user_update_events WHERE user_id = ANY($2::bigint[]))::int`, healthyUserID, userIDs[:tailUsers]).Scan(&healthyRows, &tailRows); err != nil {
t.Fatalf("count old-tail retention rows: %v", err)
}
if healthyRows != 0 || tailRows != tailUsers*2 {
t.Fatalf("remaining healthy/tail rows = %d/%d, want 0/%d", healthyRows, tailRows, tailUsers*2)
}
}
func TestUserUpdateRetentionDeletesDispatchLeaseAndPromotesHeadPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
userID := createRevokeTestUser(t, ctx, pool, "retention-dispatch-lease")
keys := NewAuthKeyStore(pool)
auths := NewAuthorizationStore(pool)
states := NewUpdateStateStore(pool)
events := NewUpdateEventStore(pool)
outbox := NewDispatchOutboxStore(pool, WithLeaseTimeout(time.Hour))
authKeyID := randomUpdateRetentionAuthKey(t)
if err := keys.Save(ctx, store.AuthKeyData{ID: authKeyID}); err != nil {
t.Fatalf("save retention dispatch auth key: %v", err)
}
t.Cleanup(func() { _ = keys.Delete(ctx, authKeyID) })
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: authKeyID, UserID: userID}); err != nil {
t.Fatalf("bind retention dispatch authorization: %v", err)
}
appendDispatch := func(date int) domain.UpdateEvent {
t.Helper()
event, err := events.AppendAllocatedWithDispatch(ctx, userID, domain.UpdateEvent{
Type: domain.UpdateEventDialogPinned,
PtsCount: 1,
Date: date,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
Bool: true,
}, [8]byte{}, 0)
if err != nil {
t.Fatalf("append retention dispatch event: %v", err)
}
return event
}
first := appendDispatch(1)
second := appendDispatch(2)
claimed := store.DispatchOutboxItem{TargetUserID: userID, Pts: first.Pts}
if err := pool.QueryRow(ctx, `
UPDATE dispatch_outbox
SET status = 'dispatching',
attempts = attempts + 1,
updated_at = now()
WHERE target_user_id = $1 AND pts = $2
RETURNING id, attempts`, userID, first.Pts).Scan(&claimed.ID, &claimed.Attempts); err != nil {
t.Fatalf("acquire exact retention dispatch lease: %v", err)
}
if err := states.ObserveClientState(ctx, authKeyID, userID, domain.UpdateState{Pts: first.Pts, Date: first.Date}); err != nil {
t.Fatalf("observe retained dispatch pts: %v", err)
}
deleted, err := events.DeleteConfirmedPrefix(ctx, time.Second, 1)
if err != nil || deleted != 1 {
t.Fatalf("delete retained dispatch prefix = %d/%v, want 1/nil", deleted, err)
}
// The in-flight worker owns an attempts token for a row retention just removed. It must be
// fenced instead of recreating/marking the deleted head, while the next pts becomes claimable.
if err := outbox.MarkDelivered(ctx, claimed); !errors.Is(err, store.ErrDispatchLeaseLost) {
t.Fatalf("deliver retained dispatch lease err = %v, want ErrDispatchLeaseLost", err)
}
var eventRows, outboxRows, headPts int
var headStatus string
if err := pool.QueryRow(ctx, `
SELECT
(SELECT count(*) FROM user_update_events WHERE user_id = $1 AND pts = $2)::int,
(SELECT count(*) FROM dispatch_outbox WHERE target_user_id = $1 AND pts = $2)::int,
(SELECT head_pts FROM dispatch_outbox_user_heads WHERE target_user_id = $1),
(SELECT status FROM dispatch_outbox_user_heads WHERE target_user_id = $1)`, userID, first.Pts).Scan(&eventRows, &outboxRows, &headPts, &headStatus); err != nil {
t.Fatalf("load retained dispatch/head state: %v", err)
}
if eventRows != 0 || outboxRows != 0 || headPts != second.Pts || headStatus != "pending" {
t.Fatalf("retained event/outbox/head = %d/%d/%d/%s, want 0/0/%d/pending", eventRows, outboxRows, headPts, headStatus, second.Pts)
}
}
func randomUpdateRetentionAuthKey(t *testing.T) [8]byte {
t.Helper()
var id [8]byte
if _, err := rand.Read(id[:]); err != nil {
t.Fatalf("random auth key id: %v", err)
}
return id
}

View file

@ -13,12 +13,13 @@ import (
// UpdateStateStore 用 PostgreSQL 实现 store.UpdateStateStore。
type UpdateStateStore struct {
q *sqlcgen.Queries
q *sqlcgen.Queries
db sqlcgen.DBTX
}
// NewUpdateStateStore 基于 pgx 连接池(或事务)创建 UpdateStateStore。
func NewUpdateStateStore(db sqlcgen.DBTX) *UpdateStateStore {
return &UpdateStateStore{q: sqlcgen.New(db)}
return &UpdateStateStore{q: sqlcgen.New(db), db: db}
}
func (s *UpdateStateStore) Get(ctx context.Context, id [8]byte, userID int64) (domain.UpdateState, bool, error) {
@ -54,6 +55,21 @@ func (s *UpdateStateStore) Save(ctx context.Context, id [8]byte, userID int64, s
return nil
}
func (s *UpdateStateStore) ObserveClientState(ctx context.Context, id [8]byte, userID int64, st domain.UpdateState) error {
if st.Pts < 0 {
st.Pts = 0
}
if _, err := s.db.Exec(ctx, `
INSERT INTO update_states (auth_key_id, user_id, pts, qts, date, seq, observed_pts)
VALUES ($1, $2, $3, $4, $5, $6, $3)
ON CONFLICT (auth_key_id, user_id) DO UPDATE SET
observed_pts = GREATEST(update_states.observed_pts, EXCLUDED.observed_pts),
updated_at = now()`, authKeyIDToInt64(id), userID, st.Pts, st.Qts, st.Date, st.Seq); err != nil {
return fmt.Errorf("observe client update state: %w", err)
}
return nil
}
func (s *UpdateStateStore) Delete(ctx context.Context, id [8]byte, userID int64) error {
if err := s.q.DeleteUpdateState(ctx, sqlcgen.DeleteUpdateStateParams{
AuthKeyID: authKeyIDToInt64(id),

View file

@ -0,0 +1,243 @@
package store
import (
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"telesrv/internal/domain"
)
const privateSendFingerprintVersion = 1
const channelSendFingerprintVersion = 1
const sendSnapshotVersion = 1
type privateSendSnapshotEnvelope struct {
Version int `json:"version"`
Message domain.Message `json:"message"`
}
type channelSendSnapshotEnvelope struct {
Version int `json:"version"`
Message domain.ChannelMessage `json:"message"`
}
// privateSendFingerprintPayload 只包含一次发送的客户端不可变意图。Date、origin
// auth/session、当前 block 状态与 automation 元数据均为执行环境,不得让同一请求在
// 重连或状态变化后变成另一条逻辑消息。sender/random_id 由幂等索引键单独约束。
type privateSendFingerprintPayload struct {
Version int `json:"version"`
RecipientUserID int64 `json:"recipient_user_id"`
Message string `json:"message"`
Entities []domain.MessageEntity `json:"entities"`
Media *domain.MessageMedia `json:"media"`
Silent bool `json:"silent"`
NoForwards bool `json:"noforwards"`
ReplyTo *domain.MessageReply `json:"reply_to,omitempty"`
Forward *domain.MessageForward `json:"forward,omitempty"`
TTLPeriod int `json:"ttl_period"`
ViaBotID int64 `json:"via_bot_id"`
GroupedID int64 `json:"grouped_id"`
Effect int64 `json:"effect"`
ReplyMarkup *domain.MessageReplyMarkup `json:"reply_markup"`
RichMessage *domain.MessageRichMessage `json:"rich_message"`
}
// channelSendFingerprintPayload contains only the durable intent of an internal channel send.
// Operational projection fields (Date, recipient/mention lists, PostAuthor and lookup hints) are
// intentionally absent: they may change after the first commit and cannot redefine a replay.
type channelSendFingerprintPayload struct {
Version int `json:"version"`
ChannelID int64 `json:"channel_id"`
Message string `json:"message"`
Entities []domain.MessageEntity `json:"entities"`
Media *domain.MessageMedia `json:"media"`
Silent bool `json:"silent"`
NoForwards bool `json:"noforwards"`
ReplyTo *domain.MessageReply `json:"reply_to,omitempty"`
Forward *domain.MessageForward `json:"forward,omitempty"`
ViaBotID int64 `json:"via_bot_id"`
GroupedID int64 `json:"grouped_id"`
ReplyMarkup *domain.MessageReplyMarkup `json:"reply_markup"`
RichMessage *domain.MessageRichMessage `json:"rich_message"`
SendAs *domain.Peer `json:"send_as,omitempty"`
Action *domain.ChannelMessageAction `json:"action,omitempty"`
TTLPeriod int `json:"ttl_period"`
}
type monoforumSendFingerprintPayload struct {
Version int `json:"version"`
ChannelID int64 `json:"channel_id"`
SavedPeer domain.Peer `json:"saved_peer"`
Message string `json:"message"`
Entities []domain.MessageEntity `json:"entities"`
}
// PrivateSendFingerprint returns a SHA-256 fingerprint of the original send
// intent. RPC callers should supply their precomputed raw-TL fingerprint;
// internal callers get a deterministic domain-level fallback.
func PrivateSendFingerprint(req domain.SendPrivateTextRequest) ([]byte, error) {
if len(req.IdempotencyFingerprint) > 0 {
if len(req.IdempotencyFingerprint) != sha256.Size {
return nil, fmt.Errorf("private send idempotency fingerprint: got %d bytes, want %d", len(req.IdempotencyFingerprint), sha256.Size)
}
return append([]byte(nil), req.IdempotencyFingerprint...), nil
}
payload, err := json.Marshal(privateSendFingerprintPayload{
Version: privateSendFingerprintVersion,
RecipientUserID: req.RecipientUserID,
Message: req.Message,
Entities: req.Entities,
Media: req.Media,
Silent: req.Silent,
NoForwards: req.NoForwards,
ReplyTo: req.ReplyTo,
Forward: req.Forward,
TTLPeriod: req.TTLPeriod,
ViaBotID: req.ViaBotID,
GroupedID: req.GroupedID,
Effect: req.Effect,
ReplyMarkup: req.ReplyMarkup,
RichMessage: req.RichMessage,
})
if err != nil {
return nil, fmt.Errorf("marshal private send fingerprint: %w", err)
}
sum := sha256.Sum256(payload)
return sum[:], nil
}
// ChannelSendFingerprint returns the request-boundary SHA-256 value when present, otherwise a
// deterministic domain fallback for app/Bot API callers that do not originate from a TL request.
func ChannelSendFingerprint(req domain.SendChannelMessageRequest) ([]byte, error) {
if len(req.IdempotencyFingerprint) > 0 {
if err := ValidateSendFingerprint(req.IdempotencyFingerprint, "channel send"); err != nil {
return nil, err
}
return append([]byte(nil), req.IdempotencyFingerprint...), nil
}
payload, err := json.Marshal(channelSendFingerprintPayload{
Version: channelSendFingerprintVersion,
ChannelID: req.ChannelID,
Message: req.Message,
Entities: req.Entities,
Media: req.Media,
Silent: req.Silent,
NoForwards: req.NoForwards,
ReplyTo: req.ReplyTo,
Forward: req.Forward,
ViaBotID: req.ViaBotID,
GroupedID: req.GroupedID,
ReplyMarkup: req.ReplyMarkup,
RichMessage: req.RichMessage,
SendAs: req.SendAs,
Action: req.Action,
TTLPeriod: req.TTLPeriod,
})
if err != nil {
return nil, fmt.Errorf("marshal channel send fingerprint: %w", err)
}
sum := sha256.Sum256(payload)
return sum[:], nil
}
// MonoforumSendFingerprint is scoped to one subscriber sub-dialog. SavedPeer is also part of the
// lookup key, but retaining it in the fallback prevents a future index/scope regression from
// silently accepting a cross-dialog replay.
func MonoforumSendFingerprint(req domain.SendMonoforumMessageRequest) ([]byte, error) {
if len(req.IdempotencyFingerprint) > 0 {
if err := ValidateSendFingerprint(req.IdempotencyFingerprint, "monoforum send"); err != nil {
return nil, err
}
return append([]byte(nil), req.IdempotencyFingerprint...), nil
}
payload, err := json.Marshal(monoforumSendFingerprintPayload{
Version: channelSendFingerprintVersion,
ChannelID: req.MonoforumID,
SavedPeer: req.SavedPeer,
Message: req.Message,
Entities: req.Entities,
})
if err != nil {
return nil, fmt.Errorf("marshal monoforum send fingerprint: %w", err)
}
sum := sha256.Sum256(payload)
return sum[:], nil
}
// ValidateSendFingerprint rejects empty, truncated and oversized receipts. Callers must never
// guess a legacy/corrupt fingerprint from a mutable message projection.
func ValidateSendFingerprint(fingerprint []byte, operation string) error {
if len(fingerprint) != sha256.Size {
return fmt.Errorf("%s idempotency fingerprint: got %d bytes, want %d", operation, len(fingerprint), sha256.Size)
}
return nil
}
// SameSendFingerprint requires two complete SHA-256 values.
func SameSendFingerprint(stored, expected []byte) bool {
return len(stored) == sha256.Size && len(expected) == sha256.Size && bytes.Equal(stored, expected)
}
// SamePrivateSendFingerprint requires two complete SHA-256 values. Legacy or
// corrupt empty values are never guessed from mutable message projections.
func SamePrivateSendFingerprint(stored, expected []byte) bool {
return SameSendFingerprint(stored, expected)
}
// EncodePrivateSendSnapshot freezes the sender-visible message returned by the
// first successful send. The snapshot is independent from mutable message-box
// projections so an edit or delete cannot erase the facts needed to acknowledge
// a later lost-response replay.
func EncodePrivateSendSnapshot(msg domain.Message) ([]byte, error) {
if msg.ID <= 0 || msg.UID <= 0 || msg.RandomID == 0 || msg.OwnerUserID == 0 || msg.Pts <= 0 {
return nil, fmt.Errorf("private send snapshot: invalid id=%d uid=%d random_id=%d owner=%d pts=%d", msg.ID, msg.UID, msg.RandomID, msg.OwnerUserID, msg.Pts)
}
raw, err := json.Marshal(privateSendSnapshotEnvelope{Version: sendSnapshotVersion, Message: msg})
if err != nil {
return nil, fmt.Errorf("marshal private send snapshot: %w", err)
}
return raw, nil
}
// DecodePrivateSendSnapshot returns a fresh object graph on every replay. Empty
// legacy rows are rejected rather than reconstructed from an edited projection.
func DecodePrivateSendSnapshot(raw []byte) (domain.Message, error) {
var envelope privateSendSnapshotEnvelope
if err := json.Unmarshal(raw, &envelope); err != nil {
return domain.Message{}, fmt.Errorf("unmarshal private send snapshot: %w", err)
}
msg := envelope.Message
if envelope.Version != sendSnapshotVersion || msg.ID <= 0 || msg.UID <= 0 || msg.RandomID == 0 || msg.OwnerUserID == 0 || msg.Pts <= 0 {
return domain.Message{}, fmt.Errorf("private send snapshot: invalid version=%d id=%d uid=%d random_id=%d owner=%d pts=%d", envelope.Version, msg.ID, msg.UID, msg.RandomID, msg.OwnerUserID, msg.Pts)
}
return msg, nil
}
// EncodeChannelSendSnapshot freezes the first sender echo for random_id replay.
func EncodeChannelSendSnapshot(msg domain.ChannelMessage) ([]byte, error) {
if msg.ChannelID == 0 || msg.ID <= 0 || msg.RandomID == 0 || msg.SenderUserID == 0 || msg.Pts <= 0 {
return nil, fmt.Errorf("channel send snapshot: invalid channel=%d id=%d random_id=%d sender=%d pts=%d", msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID, msg.Pts)
}
raw, err := json.Marshal(channelSendSnapshotEnvelope{Version: sendSnapshotVersion, Message: msg})
if err != nil {
return nil, fmt.Errorf("marshal channel send snapshot: %w", err)
}
return raw, nil
}
// DecodeChannelSendSnapshot returns a fresh immutable first-send projection.
func DecodeChannelSendSnapshot(raw []byte) (domain.ChannelMessage, error) {
var envelope channelSendSnapshotEnvelope
if err := json.Unmarshal(raw, &envelope); err != nil {
return domain.ChannelMessage{}, fmt.Errorf("unmarshal channel send snapshot: %w", err)
}
msg := envelope.Message
if envelope.Version != sendSnapshotVersion || msg.ChannelID == 0 || msg.ID <= 0 || msg.RandomID == 0 || msg.SenderUserID == 0 || msg.Pts <= 0 {
return domain.ChannelMessage{}, fmt.Errorf("channel send snapshot: invalid version=%d channel=%d id=%d random_id=%d sender=%d pts=%d", envelope.Version, msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID, msg.Pts)
}
return msg, nil
}

View file

@ -0,0 +1,56 @@
package store
import (
"testing"
"telesrv/internal/domain"
)
func TestPrivateSendSnapshotIsDeepAndVersioned(t *testing.T) {
message := domain.Message{
ID: 7, UID: 8, RandomID: 9, OwnerUserID: 10,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 11},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 10},
Date: 12, Out: true, Body: "first", Pts: 13,
Entities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Length: 5}},
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{PhoneNumber: "+1", FirstName: "Alice"}},
}
raw, err := EncodePrivateSendSnapshot(message)
if err != nil {
t.Fatalf("encode private snapshot: %v", err)
}
message.Body = "mutated"
message.Entities[0].Length = 1
message.Media.Contact.FirstName = "Mutated"
decoded, err := DecodePrivateSendSnapshot(raw)
if err != nil {
t.Fatalf("decode private snapshot: %v", err)
}
if decoded.Body != "first" || decoded.Entities[0].Length != 5 || decoded.Media.Contact.FirstName != "Alice" {
t.Fatalf("decoded private snapshot = %+v, want immutable nested graph", decoded)
}
decoded.Media.Contact.FirstName = "Second mutation"
again, err := DecodePrivateSendSnapshot(raw)
if err != nil || again.Media.Contact.FirstName != "Alice" {
t.Fatalf("second decode = %+v err=%v, want fresh graph", again, err)
}
}
func TestChannelSendSnapshotRejectsEmptyLegacyValue(t *testing.T) {
if _, err := DecodeChannelSendSnapshot([]byte(`{}`)); err == nil {
t.Fatal("empty legacy channel snapshot decoded successfully")
}
message := domain.ChannelMessage{
ChannelID: 21, ID: 22, RandomID: 23, SenderUserID: 24,
From: domain.Peer{Type: domain.PeerTypeUser, ID: 24},
Date: 25, Body: "channel", Pts: 26,
}
raw, err := EncodeChannelSendSnapshot(message)
if err != nil {
t.Fatalf("encode channel snapshot: %v", err)
}
decoded, err := DecodeChannelSendSnapshot(raw)
if err != nil || decoded.ChannelID != message.ChannelID || decoded.ID != message.ID || decoded.RandomID != message.RandomID || decoded.SenderUserID != message.SenderUserID || decoded.Body != message.Body || decoded.Pts != message.Pts {
t.Fatalf("decoded channel snapshot = %+v err=%v, want %+v", decoded, err, message)
}
}

View file

@ -158,4 +158,21 @@ func TestRedisRateLimiterWindow(t *testing.T) {
if allowed || retry <= 0 {
t.Fatalf("AllowN second allowed=%v retry=%d, want limited with retry", allowed, retry)
}
// Heal a counter left without TTL by an older split INCRBY→EXPIRE writer.
// Without this branch one transient crash could permanently deny login-code
// issuance for the affected phone/auth-key limiter dimension.
orphanKey := key + ":orphan-no-ttl"
redisOrphanKey := rateLimitKey(orphanKey)
t.Cleanup(func() { _ = c.Del(ctx, redisOrphanKey).Err() })
if err := c.Set(ctx, redisOrphanKey, 100, 0).Err(); err != nil {
t.Fatalf("seed no-TTL counter: %v", err)
}
allowed, retry, err = limiter.Allow(ctx, orphanKey, 1, 5*time.Second)
if err != nil || allowed || retry <= 0 || retry > 5 {
t.Fatalf("heal no-TTL counter allowed=%v retry=%d err=%v", allowed, retry, err)
}
if ttl, err := c.PTTL(ctx, redisOrphanKey).Result(); err != nil || ttl <= 0 || ttl > 5*time.Second {
t.Fatalf("healed counter TTL=%v err=%v, want (0,5s]", ttl, err)
}
}

View file

@ -68,12 +68,39 @@ if not raw then
redis.call('DEL', KEYS[2])
return false
end
local decoded, record = pcall(cjson.decode, raw)
if not decoded or type(record) ~= 'table'
or tonumber(record.Version or 0) ~= tonumber(ARGV[2]) then
redis.call('DEL', KEYS[1])
redis.call('DEL', KEYS[2])
return false
end
redis.call('DEL', KEYS[1])
redis.call('DEL', KEYS[2])
return raw
`
const updatePhoneCodeScript = `
if redis.call('EXISTS', KEYS[1]) == 0 then
return 0
end
redis.call('SET', KEYS[1], ARGV[1], 'KEEPTTL')
return 1
`
const deleteUndecodablePhoneCodeScript = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
`
func (s *CodeStore) Set(ctx context.Context, hash string, code store.PhoneCode, ttl time.Duration) error {
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return err
}
code.Revision = revision
v, err := json.Marshal(code)
if err != nil {
return fmt.Errorf("marshal phone code: %w", err)
@ -113,25 +140,30 @@ func (s *CodeStore) Get(ctx context.Context, hash string) (store.PhoneCode, bool
}
var code store.PhoneCode
if err := json.Unmarshal(raw, &code); err != nil {
return store.PhoneCode{}, false, fmt.Errorf("unmarshal phone code: %w", err)
// Version-zero records used JSON numbers for int64 fields. Once those
// fields became quoted strings, such a record is intentionally unusable;
// compare-and-delete it so a concurrent Set successor cannot be removed.
// Its scope cannot be decoded here; a stale scope index is harmless and is
// removed by the next scoped Set, ConsumeScoped, or VerifyScoped.
if deleteErr := s.c.Eval(ctx, deleteUndecodablePhoneCodeScript, []string{codeKey(hash)}, string(raw)).Err(); deleteErr != nil {
return store.PhoneCode{}, false, fmt.Errorf("delete undecodable phone code after %v: %w", err, deleteErr)
}
return store.PhoneCode{}, false, nil
}
return code, true, nil
}
func (s *CodeStore) Update(ctx context.Context, hash string, code store.PhoneCode) error {
key := codeKey(hash)
ttl, err := s.c.PTTL(ctx, key).Result()
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return fmt.Errorf("redis ttl phone code: %w", err)
}
if ttl <= 0 {
return nil
return err
}
code.Revision = revision
v, err := json.Marshal(code)
if err != nil {
return fmt.Errorf("marshal phone code: %w", err)
}
if err := s.c.Set(ctx, key, v, ttl).Err(); err != nil {
if err := s.c.Eval(ctx, updatePhoneCodeScript, []string{codeKey(hash)}, string(v)).Err(); err != nil {
return fmt.Errorf("redis update phone code: %w", err)
}
return nil
@ -169,6 +201,7 @@ func (s *CodeStore) ConsumeScoped(ctx context.Context, hash string, scope store.
consumeScopedCodeScript,
[]string{codeKey(hash), codeScopeKey(scope)},
hash,
store.PhoneCodeVersionCurrent,
).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
@ -187,7 +220,7 @@ func (s *CodeStore) ConsumeScoped(ctx context.Context, hash string, scope store.
if err := json.Unmarshal([]byte(raw), &code); err != nil {
return store.PhoneCode{}, false, fmt.Errorf("unmarshal consumed phone code: %w", err)
}
if code.Scope() != scope {
if code.Version != store.PhoneCodeVersionCurrent || code.Scope() != scope {
return store.PhoneCode{}, false, fmt.Errorf("consumed phone code scope mismatch")
}
return code, true, nil

View file

@ -0,0 +1,147 @@
package redisstore
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/redis/go-redis/v9"
"telesrv/internal/store"
)
const getPhoneCodeSnapshotScript = `
local raw = redis.call('GET', KEYS[1])
if not raw then
return ''
end
local decoded, record = pcall(cjson.decode, raw)
if not decoded or type(record) ~= 'table'
or tonumber(record.Version or 0) ~= tonumber(ARGV[1])
or (record.Revision or '') == '' then
redis.call('DEL', KEYS[1])
return ''
end
return raw
`
const compareAndUpdatePhoneCodeScript = `
local raw = redis.call('GET', KEYS[1])
if not raw then
return 0
end
local decoded, record = pcall(cjson.decode, raw)
if not decoded or type(record) ~= 'table'
or tonumber(record.Version or 0) ~= tonumber(ARGV[1])
or (record.Revision or '') == '' then
redis.call('DEL', KEYS[1])
return 0
end
if (record.Purpose or '') ~= '' or record.Revision ~= ARGV[2] then
return 0
end
redis.call('SET', KEYS[1], ARGV[3], 'KEEPTTL')
return 1
`
const compareAndDeletePhoneCodeScript = `
local raw = redis.call('GET', KEYS[1])
if not raw then
return 0
end
local decoded, record = pcall(cjson.decode, raw)
if not decoded or type(record) ~= 'table'
or tonumber(record.Version or 0) ~= tonumber(ARGV[1])
or (record.Revision or '') == '' then
redis.call('DEL', KEYS[1])
return 0
end
if (record.Purpose or '') ~= '' or record.Revision ~= ARGV[2] then
return 0
end
redis.call('DEL', KEYS[1])
return 1
`
func (s *CodeStore) GetSnapshot(ctx context.Context, hash string) (store.PhoneCodeSnapshot, bool, error) {
value, err := s.c.Eval(
ctx,
getPhoneCodeSnapshotScript,
[]string{codeKey(hash)},
store.PhoneCodeVersionCurrent,
).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return store.PhoneCodeSnapshot{}, false, nil
}
return store.PhoneCodeSnapshot{}, false, fmt.Errorf("redis get phone code snapshot: %w", err)
}
raw, ok := value.(string)
if !ok {
return store.PhoneCodeSnapshot{}, false, fmt.Errorf("redis get phone code snapshot: unexpected result %T", value)
}
if raw == "" {
return store.PhoneCodeSnapshot{}, false, nil
}
var record store.PhoneCode
if err := json.Unmarshal([]byte(raw), &record); err != nil {
return store.PhoneCodeSnapshot{}, false, fmt.Errorf("redis get phone code snapshot decode: %w", err)
}
if record.Version != store.PhoneCodeVersionCurrent || record.Revision == "" {
return store.PhoneCodeSnapshot{}, false, fmt.Errorf("redis get phone code snapshot returned invalid version/revision")
}
return store.PhoneCodeSnapshot{Record: record, Revision: record.Revision}, true, nil
}
func (s *CodeStore) CompareAndUpdate(ctx context.Context, hash, expectedRevision string, next store.PhoneCode) (bool, error) {
if expectedRevision == "" || next.Version != store.PhoneCodeVersionCurrent || next.Purpose != "" {
return false, nil
}
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return false, err
}
next.Revision = revision
raw, err := json.Marshal(next)
if err != nil {
return false, fmt.Errorf("marshal compare-and-update phone code: %w", err)
}
value, err := s.c.Eval(
ctx,
compareAndUpdatePhoneCodeScript,
[]string{codeKey(hash)},
store.PhoneCodeVersionCurrent,
expectedRevision,
string(raw),
).Result()
if err != nil {
return false, fmt.Errorf("redis compare-and-update phone code: %w", err)
}
return redisCASApplied(value, "compare-and-update phone code")
}
func (s *CodeStore) CompareAndDelete(ctx context.Context, hash, expectedRevision string) (bool, error) {
if expectedRevision == "" {
return false, nil
}
value, err := s.c.Eval(
ctx,
compareAndDeletePhoneCodeScript,
[]string{codeKey(hash)},
store.PhoneCodeVersionCurrent,
expectedRevision,
).Result()
if err != nil {
return false, fmt.Errorf("redis compare-and-delete phone code: %w", err)
}
return redisCASApplied(value, "compare-and-delete phone code")
}
func redisCASApplied(value any, operation string) (bool, error) {
number, ok := value.(int64)
if !ok || (number != 0 && number != 1) {
return false, fmt.Errorf("redis %s: unexpected result %v (%T)", operation, value, value)
}
return number == 1, nil
}

View file

@ -0,0 +1,237 @@
package redisstore
import (
"context"
"encoding/json"
"fmt"
"sync"
"testing"
"time"
"telesrv/internal/store"
)
func TestRedisCodeStoreRevisionCAS(t *testing.T) {
codes, client, hash := newRedisLoginCodeHarness(t)
ctx := context.Background()
record := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016301",
Code: "111111",
Channel: "email_setup",
PendingEmail: "first@example.test",
MaxAttempts: 5,
}
key := hash("email-fixed")
if err := codes.Set(ctx, key, record, 45*time.Second); err != nil {
t.Fatal(err)
}
snapshot, found, err := codes.GetSnapshot(ctx, key)
if err != nil || !found || snapshot.Revision == "" || snapshot.Record.Revision != snapshot.Revision {
t.Fatalf("snapshot=%+v found=%v err=%v", snapshot, found, err)
}
before, err := client.PTTL(ctx, codeKey(key)).Result()
if err != nil {
t.Fatal(err)
}
next := snapshot.Record
next.Code = "222222"
next.Attempts = 1
if applied, err := codes.CompareAndUpdate(ctx, key, "stale-token", next); err != nil || applied {
t.Fatalf("wrong-token update applied=%v err=%v", applied, err)
}
if applied, err := codes.CompareAndUpdate(ctx, key, snapshot.Revision, next); err != nil || !applied {
t.Fatalf("current update applied=%v err=%v", applied, err)
}
updated, found, err := codes.GetSnapshot(ctx, key)
if err != nil || !found || updated.Record.Code != next.Code || updated.Record.Attempts != 1 || updated.Revision == snapshot.Revision {
t.Fatalf("updated=%+v found=%v err=%v", updated, found, err)
}
after, err := client.PTTL(ctx, codeKey(key)).Result()
if err != nil || after <= 0 || after > before || before-after > 2*time.Second {
t.Fatalf("CAS TTL before=%v after=%v err=%v", before, after, err)
}
if applied, err := codes.CompareAndDelete(ctx, key, snapshot.Revision); err != nil || applied {
t.Fatalf("stale delete applied=%v err=%v", applied, err)
}
if applied, err := codes.CompareAndDelete(ctx, key, updated.Revision); err != nil || !applied {
t.Fatalf("current delete applied=%v err=%v", applied, err)
}
assertRedisCodeMissing(t, ctx, codes, key)
}
func TestRedisCodeStoreRevisionCASFailClosedAndScopeIsolation(t *testing.T) {
codes, client, hash := newRedisLoginCodeHarness(t)
ctx := context.Background()
legacyHash := hash("legacy")
legacy := store.PhoneCode{
Version: 0,
Revision: "legacy-revision",
Phone: "15550016302",
Code: "12345",
}
raw, err := json.Marshal(legacy)
if err != nil {
t.Fatal(err)
}
if err := client.Set(ctx, codeKey(legacyHash), raw, time.Minute).Err(); err != nil {
t.Fatal(err)
}
if _, found, err := codes.GetSnapshot(ctx, legacyHash); err != nil || found {
t.Fatalf("legacy snapshot found=%v err=%v", found, err)
}
assertRedisCodeMissing(t, ctx, codes, legacyHash)
noRevisionHash := hash("no-revision")
legacy.Version = store.PhoneCodeVersionCurrent
legacy.Revision = ""
raw, err = json.Marshal(legacy)
if err != nil {
t.Fatal(err)
}
if err := client.Set(ctx, codeKey(noRevisionHash), raw, time.Minute).Err(); err != nil {
t.Fatal(err)
}
if _, found, err := codes.GetSnapshot(ctx, noRevisionHash); err != nil || found {
t.Fatalf("revisionless snapshot found=%v err=%v", found, err)
}
assertRedisCodeMissing(t, ctx, codes, noRevisionHash)
scopedHash := hash("scoped")
scoped := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016303",
Code: "12345",
Purpose: store.PhoneCodePurposeChangePhone,
UserID: 42,
AuthKeyID: [8]byte{1},
}
if err := codes.Set(ctx, scopedHash, scoped, time.Minute); err != nil {
t.Fatal(err)
}
snapshot, found, err := codes.GetSnapshot(ctx, scopedHash)
if err != nil || !found {
t.Fatalf("scoped snapshot found=%v err=%v", found, err)
}
if applied, err := codes.CompareAndUpdate(ctx, scopedHash, snapshot.Revision, snapshot.Record); err != nil || applied {
t.Fatalf("scoped update applied=%v err=%v", applied, err)
}
if applied, err := codes.CompareAndDelete(ctx, scopedHash, snapshot.Revision); err != nil || applied {
t.Fatalf("scoped delete applied=%v err=%v", applied, err)
}
if _, found, _ := codes.Get(ctx, scopedHash); !found {
t.Fatal("generic CAS mutated scoped record")
}
}
func TestRedisCodeStoreRevisionCASPreventsABAAndHasSingleWinner(t *testing.T) {
codes, _, hash := newRedisLoginCodeHarness(t)
ctx := context.Background()
record := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016304",
Code: "123456",
Channel: "email_change",
}
key := hash("aba")
if err := codes.Set(ctx, key, record, time.Minute); err != nil {
t.Fatal(err)
}
old, found, err := codes.GetSnapshot(ctx, key)
if err != nil || !found {
t.Fatalf("old snapshot found=%v err=%v", found, err)
}
if err := codes.Set(ctx, key, record, time.Minute); err != nil {
t.Fatal(err)
}
current, found, err := codes.GetSnapshot(ctx, key)
if err != nil || !found || current.Revision == old.Revision {
t.Fatalf("replacement current=%+v old=%+v found=%v err=%v", current, old, found, err)
}
if applied, err := codes.CompareAndDelete(ctx, key, old.Revision); err != nil || applied {
t.Fatalf("ABA stale delete applied=%v err=%v", applied, err)
}
const workers = 48
results := make(chan bool, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
next := current.Record
next.Code = fmt.Sprintf("%06d", index)
applied, err := codes.CompareAndUpdate(ctx, key, current.Revision, next)
if err != nil {
errs <- err
return
}
results <- applied
}(i)
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("concurrent CAS update: %v", err)
}
if winners := countRedisTrue(results); winners != 1 {
t.Fatalf("concurrent update winners=%d, want 1", winners)
}
winner, found, err := codes.GetSnapshot(ctx, key)
if err != nil || !found || winner.Revision == current.Revision {
t.Fatalf("winner=%+v found=%v err=%v", winner, found, err)
}
results = make(chan bool, workers)
errs = make(chan error, workers)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
applied, err := codes.CompareAndDelete(ctx, key, winner.Revision)
if err != nil {
errs <- err
return
}
results <- applied
}()
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("concurrent CAS delete: %v", err)
}
if winners := countRedisTrue(results); winners != 1 {
t.Fatalf("concurrent delete winners=%d, want 1", winners)
}
}
func TestRedisCodeStoreLegacyUpdateCannotResurrectConsumedKey(t *testing.T) {
codes, _, hash := newRedisLoginCodeHarness(t)
ctx := context.Background()
record := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016305",
Code: "12345",
Channel: store.PhoneCodeChannelPhone,
}
key := hash("no-resurrection")
if err := codes.Set(ctx, key, record, time.Minute); err != nil {
t.Fatal(err)
}
stale, found, err := codes.Get(ctx, key)
if err != nil || !found {
t.Fatalf("load stale record found=%v err=%v", found, err)
}
if _, found, err := codes.TakeLoginCode(ctx, key, record.Phone); err != nil || !found {
t.Fatalf("consume before stale update found=%v err=%v", found, err)
}
stale.Attempts++
if err := codes.Update(ctx, key, stale); err != nil {
t.Fatalf("stale legacy Update: %v", err)
}
assertRedisCodeMissing(t, ctx, codes, key)
}

View file

@ -27,6 +27,7 @@ func TestRedisCodeStoreScopedRotationAndSingleConsume(t *testing.T) {
oldHash := fmt.Sprintf("scope-old-%d", suffix)
newHash := fmt.Sprintf("scope-new-%d", suffix)
rec := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: fmt.Sprintf("1555%d", suffix),
Code: "12345",
Purpose: store.PhoneCodePurposeChangePhone,
@ -81,3 +82,34 @@ func TestRedisCodeStoreScopedRotationAndSingleConsume(t *testing.T) {
t.Fatalf("remaining redis keys=%d err=%v", exists, err)
}
}
func TestRedisCodeStoreConsumeScopedRejectsAndDeletesLegacyVersion(t *testing.T) {
addr := os.Getenv("TELESRV_TEST_REDIS_ADDR")
if addr == "" {
t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test")
}
ctx := context.Background()
c, err := Open(ctx, addr, "", 0)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
hash := fmt.Sprintf("legacy-scope-%d", time.Now().UnixNano())
rec := store.PhoneCode{
Version: 0, Phone: "15550015004", Code: "12345",
Purpose: store.PhoneCodePurposeChangePhone, UserID: 44, AuthKeyID: [8]byte{4},
}
scopeKey := codeScopeKey(rec.Scope())
t.Cleanup(func() { _ = c.Del(ctx, codeKey(hash), scopeKey).Err() })
codes := NewCodeStore(c)
if err := codes.Set(ctx, hash, rec, time.Minute); err != nil {
t.Fatal(err)
}
if _, found, err := codes.ConsumeScoped(ctx, hash, rec.Scope()); err != nil || found {
t.Fatalf("legacy scoped consume found=%v err=%v, want false/nil", found, err)
}
if exists, err := c.Exists(ctx, codeKey(hash), scopeKey).Result(); err != nil || exists != 0 {
t.Fatalf("legacy scoped keys remain=%d err=%v", exists, err)
}
}

View file

@ -0,0 +1,385 @@
package redisstore
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"github.com/redis/go-redis/v9"
"telesrv/internal/store"
)
const verifyLoginCodeScript = `
local raw = redis.call('GET', KEYS[1])
if not raw then
return {0, ''}
end
local decoded, record = pcall(cjson.decode, raw)
if not decoded or type(record) ~= 'table' then
redis.call('DEL', KEYS[1])
return {0, ''}
end
if tonumber(record.Version or 0) ~= tonumber(ARGV[1]) then
redis.call('DEL', KEYS[1])
return {0, ''}
end
if record.SignUpVerified == true then
return {0, ''}
end
local channel = record.Channel or ''
if (record.Purpose or '') ~= '' or (record.Phone or '') ~= ARGV[2]
or (channel ~= ARGV[6] and channel ~= ARGV[7])
or (record.Code or '') == '' or ARGV[3] == '' then
return {1, raw}
end
if (record.Code or '') ~= ARGV[3] then
local attempts = tonumber(record.Attempts or 0) + 1
record.Attempts = attempts
record.Revision = ARGV[8]
local max_attempts = tonumber(record.MaxAttempts or 0)
if not max_attempts or max_attempts <= 0 then
max_attempts = tonumber(ARGV[5]) or 0
end
if max_attempts <= 0 then
max_attempts = 1
end
local updated = cjson.encode(record)
if attempts >= max_attempts then
redis.call('DEL', KEYS[1])
else
redis.call('SET', KEYS[1], updated, 'KEEPTTL')
end
return {1, updated}
end
if ARGV[4] == '1' then
if tonumber(record.IssuedUserID or '0') ~= 0 then
return {1, raw}
end
record.SignUpVerified = true
record.Revision = ARGV[8]
local updated = cjson.encode(record)
redis.call('SET', KEYS[1], updated, 'KEEPTTL')
return {2, updated}
end
redis.call('DEL', KEYS[1])
return {2, raw}
`
const verifyScopedCodeScript = `
if redis.call('GET', KEYS[2]) ~= ARGV[1] then
return {0, ''}
end
local raw = redis.call('GET', KEYS[1])
if not raw then
redis.call('DEL', KEYS[2])
return {0, ''}
end
local decoded, record = pcall(cjson.decode, raw)
if not decoded or type(record) ~= 'table' then
redis.call('DEL', KEYS[1], KEYS[2])
return {0, ''}
end
if tonumber(record.Version or 0) ~= tonumber(ARGV[2]) then
redis.call('DEL', KEYS[1], KEYS[2])
return {0, ''}
end
local encoded_auth_key = ''
if type(record.AuthKeyID) == 'table' then
encoded_auth_key = cjson.encode(record.AuthKeyID)
end
if (record.Purpose or '') ~= ARGV[6]
or tonumber(record.UserID or 0) ~= tonumber(ARGV[7])
or encoded_auth_key ~= ARGV[8]
or (record.Phone or '') ~= ARGV[9]
or record.SignUpVerified == true
or (record.Code or '') == '' then
redis.call('DEL', KEYS[1], KEYS[2])
return {0, ''}
end
if ARGV[3] == '' then
return {1, raw}
end
if (record.Code or '') ~= ARGV[3] then
local attempts = tonumber(record.Attempts or 0) + 1
record.Attempts = attempts
record.Revision = ARGV[5]
local max_attempts = tonumber(record.MaxAttempts or 0)
if not max_attempts or max_attempts <= 0 then
max_attempts = tonumber(ARGV[4]) or 0
end
if max_attempts <= 0 then
max_attempts = 1
end
local updated = cjson.encode(record)
if attempts >= max_attempts then
redis.call('DEL', KEYS[1], KEYS[2])
else
redis.call('SET', KEYS[1], updated, 'KEEPTTL')
end
return {1, updated}
end
redis.call('DEL', KEYS[1], KEYS[2])
return {2, raw}
`
const takeLoginCodeScript = `
local raw = redis.call('GET', KEYS[1])
if not raw then
return ''
end
local decoded, record = pcall(cjson.decode, raw)
if not decoded or type(record) ~= 'table' then
redis.call('DEL', KEYS[1])
return ''
end
if tonumber(record.Version or 0) ~= tonumber(ARGV[1]) then
redis.call('DEL', KEYS[1])
return ''
end
if record.SignUpVerified == true then
return ''
end
local channel = record.Channel or ''
if (record.Purpose or '') ~= '' or (record.Phone or '') ~= ARGV[2]
or (channel ~= ARGV[3] and channel ~= ARGV[4] and channel ~= ARGV[5]) then
return ''
end
redis.call('DEL', KEYS[1])
return raw
`
const consumeSignUpVerifiedScript = `
local raw = redis.call('GET', KEYS[1])
if not raw then
return ''
end
local decoded, record = pcall(cjson.decode, raw)
if not decoded or type(record) ~= 'table' then
redis.call('DEL', KEYS[1])
return ''
end
if tonumber(record.Version or 0) ~= tonumber(ARGV[1]) then
redis.call('DEL', KEYS[1])
return ''
end
local channel = record.Channel or ''
if (record.Purpose or '') ~= '' or (record.Phone or '') ~= ARGV[2]
or (channel ~= ARGV[3] and channel ~= ARGV[4])
or tonumber(record.IssuedUserID or '0') ~= 0
or record.SignUpVerified ~= true then
return ''
end
redis.call('DEL', KEYS[1])
return raw
`
const invalidateLoginCodeScript = `
local raw = redis.call('GET', KEYS[1])
if not raw then
return ''
end
local decoded, record = pcall(cjson.decode, raw)
if not decoded or type(record) ~= 'table' then
redis.call('DEL', KEYS[1])
return ''
end
if tonumber(record.Version or 0) ~= tonumber(ARGV[1]) then
redis.call('DEL', KEYS[1])
return ''
end
local channel = record.Channel or ''
if (record.Purpose or '') ~= '' or (record.Phone or '') ~= ARGV[2]
or (channel ~= ARGV[3] and channel ~= ARGV[4] and channel ~= ARGV[5]) then
return ''
end
redis.call('DEL', KEYS[1])
return raw
`
func (s *CodeStore) VerifyLogin(ctx context.Context, hash, phone, code string, keepForSignUp bool, defaultMaxAttempts int) (store.LoginCodeVerifyResult, error) {
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return store.LoginCodeVerifyResult{}, err
}
keep := 0
if keepForSignUp {
keep = 1
}
value, err := s.c.Eval(
ctx,
verifyLoginCodeScript,
[]string{codeKey(hash)},
store.PhoneCodeVersionCurrent,
phone,
code,
keep,
defaultMaxAttempts,
store.PhoneCodeChannelPhone,
store.PhoneCodeChannelEmailLogin,
revision,
).Result()
if err != nil {
return store.LoginCodeVerifyResult{}, fmt.Errorf("redis verify login code: %w", err)
}
return decodeRedisLoginCodeVerification(value)
}
func (s *CodeStore) VerifyScoped(ctx context.Context, hash string, scope store.PhoneCodeScope, code string, defaultMaxAttempts int) (store.LoginCodeVerifyResult, error) {
if !scope.Valid() {
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
}
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return store.LoginCodeVerifyResult{}, err
}
authKeyID, err := json.Marshal(scope.AuthKeyID)
if err != nil {
return store.LoginCodeVerifyResult{}, fmt.Errorf("marshal scoped phone code auth key: %w", err)
}
value, err := s.c.Eval(
ctx,
verifyScopedCodeScript,
[]string{codeKey(hash), codeScopeKey(scope)},
hash,
store.PhoneCodeVersionCurrent,
code,
defaultMaxAttempts,
revision,
scope.Purpose,
strconv.FormatInt(scope.UserID, 10),
string(authKeyID),
scope.Phone,
).Result()
if err != nil {
return store.LoginCodeVerifyResult{}, fmt.Errorf("redis verify scoped phone code: %w", err)
}
result, err := decodeRedisLoginCodeVerification(value)
if err != nil {
return store.LoginCodeVerifyResult{}, fmt.Errorf("redis verify scoped phone code: %w", err)
}
if result.Status != store.LoginCodeVerifyMissing && result.Record.Scope() != scope {
return store.LoginCodeVerifyResult{}, fmt.Errorf("redis verify scoped phone code returned a record outside the requested scope")
}
return result, nil
}
func (s *CodeStore) ConsumeSignUpVerified(ctx context.Context, hash, phone string) (store.PhoneCode, bool, error) {
return s.consumeLoginCode(
ctx,
consumeSignUpVerifiedScript,
"consume sign-up verified code",
hash,
phone,
true,
true,
store.PhoneCodeChannelPhone,
store.PhoneCodeChannelEmailLogin,
)
}
func (s *CodeStore) TakeLoginCode(ctx context.Context, hash, phone string) (store.PhoneCode, bool, error) {
return s.consumeLoginCode(
ctx,
takeLoginCodeScript,
"take login code",
hash,
phone,
false,
false,
store.PhoneCodeChannelPhone,
store.PhoneCodeChannelEmailLogin,
store.PhoneCodeChannelEmailSetupRequired,
)
}
func (s *CodeStore) InvalidateLoginCode(ctx context.Context, hash, phone string) (bool, error) {
_, found, err := s.consumeLoginCode(
ctx,
invalidateLoginCodeScript,
"invalidate login code",
hash,
phone,
false,
true,
store.PhoneCodeChannelPhone,
store.PhoneCodeChannelEmailLogin,
store.PhoneCodeChannelEmailSetupRequired,
)
return found, err
}
func (s *CodeStore) consumeLoginCode(ctx context.Context, script, operation, hash, phone string, requireVerified, allowVerified bool, channels ...string) (store.PhoneCode, bool, error) {
args := make([]any, 0, 2+len(channels))
args = append(args, store.PhoneCodeVersionCurrent, phone)
for _, channel := range channels {
args = append(args, channel)
}
value, err := s.c.Eval(
ctx,
script,
[]string{codeKey(hash)},
args...,
).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return store.PhoneCode{}, false, nil
}
return store.PhoneCode{}, false, fmt.Errorf("redis %s: %w", operation, err)
}
raw, ok := value.(string)
if !ok {
return store.PhoneCode{}, false, fmt.Errorf("redis %s: unexpected result %T", operation, value)
}
if raw == "" {
return store.PhoneCode{}, false, nil
}
var record store.PhoneCode
if err := json.Unmarshal([]byte(raw), &record); err != nil {
return store.PhoneCode{}, false, fmt.Errorf("redis %s decode: %w", operation, err)
}
if record.Version != store.PhoneCodeVersionCurrent || record.Purpose != "" || record.Phone != phone ||
!loginCodeChannelAllowed(record.Channel, channels) ||
(requireVerified && (record.IssuedUserID != 0 || !record.SignUpVerified)) ||
(!requireVerified && !allowVerified && record.SignUpVerified) {
return store.PhoneCode{}, false, fmt.Errorf("redis %s returned a record outside the requested login scope", operation)
}
return record, true, nil
}
func loginCodeChannelAllowed(channel string, allowed []string) bool {
for _, item := range allowed {
if channel == item {
return true
}
}
return false
}
func decodeRedisLoginCodeVerification(value any) (store.LoginCodeVerifyResult, error) {
items, ok := value.([]interface{})
if !ok || len(items) != 2 {
return store.LoginCodeVerifyResult{}, fmt.Errorf("redis verify login code: unexpected result %T", value)
}
statusNumber, ok := items[0].(int64)
if !ok || statusNumber < int64(store.LoginCodeVerifyMissing) || statusNumber > int64(store.LoginCodeVerifyAccepted) {
return store.LoginCodeVerifyResult{}, fmt.Errorf("redis verify login code: invalid status %v", items[0])
}
result := store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyStatus(statusNumber)}
if result.Status == store.LoginCodeVerifyMissing {
return result, nil
}
raw, ok := items[1].(string)
if !ok || raw == "" {
return store.LoginCodeVerifyResult{}, fmt.Errorf("redis verify login code: status %d has invalid record %T", result.Status, items[1])
}
if err := json.Unmarshal([]byte(raw), &result.Record); err != nil {
return store.LoginCodeVerifyResult{}, fmt.Errorf("redis verify login code decode: %w", err)
}
if result.Record.Version != store.PhoneCodeVersionCurrent {
return store.LoginCodeVerifyResult{}, fmt.Errorf("redis verify login code returned version %d", result.Record.Version)
}
return result, nil
}

View file

@ -0,0 +1,517 @@
package redisstore
import (
"context"
"fmt"
"math"
"os"
"sync"
"testing"
"time"
"github.com/redis/go-redis/v9"
"telesrv/internal/store"
)
func TestRedisCodeStoreAtomicLoginStateMachine(t *testing.T) {
codes, client, hash := newRedisLoginCodeHarness(t)
ctx := context.Background()
const phone = "15550016101"
newRecord := func() store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
IssuedUserID: 1000000101,
Phone: phone,
Code: "12345",
Channel: store.PhoneCodeChannelPhone,
MaxAttempts: 2,
}
}
t.Run("version and corrupt records fail closed", func(t *testing.T) {
legacy := newRecord()
legacy.Version = 0
verifyHash := hash("legacy-verify")
if err := codes.Set(ctx, verifyHash, legacy, time.Minute); err != nil {
t.Fatal(err)
}
result, err := codes.VerifyLogin(ctx, verifyHash, phone, legacy.Code, false, 5)
if err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("legacy verify = %+v err=%v", result, err)
}
assertRedisCodeMissing(t, ctx, codes, verifyHash)
// This is the actual pre-state-machine JSON shape: int64 fields were
// numbers, not the quoted strings emitted by current Set.
legacyRaw := fmt.Sprintf(
`{"Version":0,"IssuedUserID":1000000101,"Phone":%q,"Code":"12345","Channel":"phone","UserID":42,"SessionID":9007199254740993}`,
phone,
)
getLegacyHash := hash("legacy-raw-number-get")
if err := client.Set(ctx, codeKey(getLegacyHash), legacyRaw, time.Minute).Err(); err != nil {
t.Fatal(err)
}
if got, found, err := codes.Get(ctx, getLegacyHash); err != nil || found {
t.Fatalf("legacy raw-number Get=%+v found=%v err=%v, want Missing", got, found, err)
}
if exists, err := client.Exists(ctx, codeKey(getLegacyHash)).Result(); err != nil || exists != 0 {
t.Fatalf("legacy raw-number Get left key exists=%d err=%v", exists, err)
}
verifyLegacyHash := hash("legacy-raw-number-verify")
if err := client.Set(ctx, codeKey(verifyLegacyHash), legacyRaw, time.Minute).Err(); err != nil {
t.Fatal(err)
}
result, err = codes.VerifyLogin(ctx, verifyLegacyHash, phone, "12345", false, 5)
if err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("legacy raw-number VerifyLogin=%+v err=%v, want Missing", result, err)
}
if exists, err := client.Exists(ctx, codeKey(verifyLegacyHash)).Result(); err != nil || exists != 0 {
t.Fatalf("legacy raw-number VerifyLogin left key exists=%d err=%v", exists, err)
}
unknown := newRecord()
unknown.Version++
takeHash := hash("unknown-take")
if err := codes.Set(ctx, takeHash, unknown, time.Minute); err != nil {
t.Fatal(err)
}
if _, found, err := codes.TakeLoginCode(ctx, takeHash, phone); err != nil || found {
t.Fatalf("unknown take found=%v err=%v", found, err)
}
assertRedisCodeMissing(t, ctx, codes, takeHash)
legacy.SignUpVerified = true
consumeHash := hash("legacy-consume")
if err := codes.Set(ctx, consumeHash, legacy, time.Minute); err != nil {
t.Fatal(err)
}
if _, found, err := codes.ConsumeSignUpVerified(ctx, consumeHash, phone); err != nil || found {
t.Fatalf("legacy sign-up consume found=%v err=%v", found, err)
}
assertRedisCodeMissing(t, ctx, codes, consumeHash)
corruptHash := hash("corrupt")
if err := client.Set(ctx, codeKey(corruptHash), `{`, time.Minute).Err(); err != nil {
t.Fatal(err)
}
result, err = codes.VerifyLogin(ctx, corruptHash, phone, "12345", false, 5)
if err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("corrupt verify = %+v err=%v", result, err)
}
assertRedisCodeMissing(t, ctx, codes, corruptHash)
})
t.Run("scope channel and issued-user gates", func(t *testing.T) {
record := newRecord()
scopeHash := hash("scope")
if err := codes.Set(ctx, scopeHash, record, time.Minute); err != nil {
t.Fatal(err)
}
result, err := codes.VerifyLogin(ctx, scopeHash, "15550016999", record.Code, false, 5)
if err != nil || result.Status != store.LoginCodeVerifyInvalid || result.Record.Attempts != 0 {
t.Fatalf("cross-phone verify = %+v err=%v", result, err)
}
stored, found, err := codes.Get(ctx, scopeHash)
if err != nil || !found || stored.Attempts != 0 {
t.Fatalf("cross-phone stored=%+v found=%v err=%v", stored, found, err)
}
wrongChannel := record
wrongChannel.Channel = "email_setup"
channelHash := hash("channel")
if err := codes.Set(ctx, channelHash, wrongChannel, time.Minute); err != nil {
t.Fatal(err)
}
result, err = codes.VerifyLogin(ctx, channelHash, phone, wrongChannel.Code, false, 5)
if err != nil || result.Status != store.LoginCodeVerifyInvalid {
t.Fatalf("wrong-channel verify = %+v err=%v", result, err)
}
if _, found, err := codes.TakeLoginCode(ctx, channelHash, phone); err != nil || found {
t.Fatalf("wrong-channel take found=%v err=%v", found, err)
}
issuedHash := hash("issued-existing")
record.IssuedUserID = math.MaxInt64 - 1
if err := codes.Set(ctx, issuedHash, record, time.Minute); err != nil {
t.Fatal(err)
}
result, err = codes.VerifyLogin(ctx, issuedHash, phone, record.Code, true, 5)
if err != nil || result.Status != store.LoginCodeVerifyInvalid || result.Record.IssuedUserID != record.IssuedUserID {
t.Fatalf("issued-existing keep = %+v err=%v, want exact int64 Invalid", result, err)
}
taken, found, err := codes.TakeLoginCode(ctx, issuedHash, phone)
if err != nil || !found || taken.IssuedUserID != record.IssuedUserID {
t.Fatalf("issued-existing take=%+v found=%v err=%v", taken, found, err)
}
setupRequired := newRecord()
setupRequired.Channel = store.PhoneCodeChannelEmailSetupRequired
setupRequired.Code = ""
setupHash := hash("setup-required")
if err := codes.Set(ctx, setupHash, setupRequired, time.Minute); err != nil {
t.Fatal(err)
}
if _, found, err := codes.TakeLoginCode(ctx, setupHash, phone); err != nil || !found {
t.Fatalf("setup-required take found=%v err=%v, want true", found, err)
}
})
t.Run("wrong attempts and ttl are atomic", func(t *testing.T) {
record := newRecord()
wrongHash := hash("wrong")
if err := codes.Set(ctx, wrongHash, record, 45*time.Second); err != nil {
t.Fatal(err)
}
before, err := client.PTTL(ctx, codeKey(wrongHash)).Result()
if err != nil {
t.Fatal(err)
}
first, err := codes.VerifyLogin(ctx, wrongHash, phone, "00000", false, 9)
if err != nil || first.Status != store.LoginCodeVerifyInvalid || first.Record.Attempts != 1 {
t.Fatalf("first wrong = %+v err=%v", first, err)
}
after, err := client.PTTL(ctx, codeKey(wrongHash)).Result()
if err != nil || after <= 0 || after > before || before-after > 2*time.Second {
t.Fatalf("wrong-code TTL before=%v after=%v err=%v, want KEEPTTL", before, after, err)
}
second, err := codes.VerifyLogin(ctx, wrongHash, phone, "00000", false, 9)
if err != nil || second.Status != store.LoginCodeVerifyInvalid || second.Record.Attempts != 2 {
t.Fatalf("threshold wrong = %+v err=%v", second, err)
}
assertRedisCodeMissing(t, ctx, codes, wrongHash)
})
t.Run("consume and sign-up marker terminal states", func(t *testing.T) {
record := newRecord()
consumeHash := hash("verify-consume")
if err := codes.Set(ctx, consumeHash, record, time.Minute); err != nil {
t.Fatal(err)
}
accepted, err := codes.VerifyLogin(ctx, consumeHash, phone, record.Code, false, 5)
if err != nil || accepted.Status != store.LoginCodeVerifyAccepted {
t.Fatalf("consume verify = %+v err=%v", accepted, err)
}
assertRedisCodeMissing(t, ctx, codes, consumeHash)
signUp := record
signUp.IssuedUserID = 0
signUpHash := hash("signup")
if err := codes.Set(ctx, signUpHash, signUp, 45*time.Second); err != nil {
t.Fatal(err)
}
before, err := client.PTTL(ctx, codeKey(signUpHash)).Result()
if err != nil {
t.Fatal(err)
}
marked, err := codes.VerifyLogin(ctx, signUpHash, phone, signUp.Code, true, 5)
if err != nil || marked.Status != store.LoginCodeVerifyAccepted || !marked.Record.SignUpVerified {
t.Fatalf("mark sign-up = %+v err=%v", marked, err)
}
after, err := client.PTTL(ctx, codeKey(signUpHash)).Result()
if err != nil || after <= 0 || after > before || before-after > 2*time.Second {
t.Fatalf("marker TTL before=%v after=%v err=%v, want KEEPTTL", before, after, err)
}
repeated, err := codes.VerifyLogin(ctx, signUpHash, phone, signUp.Code, true, 5)
if err != nil || repeated.Status != store.LoginCodeVerifyMissing {
t.Fatalf("repeated marker verify = %+v err=%v", repeated, err)
}
if _, found, err := codes.TakeLoginCode(ctx, signUpHash, phone); err != nil || found {
t.Fatalf("terminal marker take found=%v err=%v, want false", found, err)
}
consumed, found, err := codes.ConsumeSignUpVerified(ctx, signUpHash, phone)
if err != nil || !found || !consumed.SignUpVerified || consumed.IssuedUserID != 0 {
t.Fatalf("consume marker=%+v found=%v err=%v", consumed, found, err)
}
if _, found, err := codes.ConsumeSignUpVerified(ctx, signUpHash, phone); err != nil || found {
t.Fatalf("second marker consume found=%v err=%v", found, err)
}
})
}
func TestRedisCodeStoreAtomicLoginConcurrency(t *testing.T) {
codes, _, hash := newRedisLoginCodeHarness(t)
ctx := context.Background()
const (
phone = "15550016102"
workers = 48
)
newRecord := func() store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: phone,
Code: "12345",
Channel: store.PhoneCodeChannelPhone,
MaxAttempts: 7,
}
}
t.Run("consume verify one accepted", func(t *testing.T) {
key := hash("verify-race")
if err := codes.Set(ctx, key, newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentRedisVerify(t, codes, key, phone, "12345", false, workers)
if statuses[store.LoginCodeVerifyAccepted] != 1 || statuses[store.LoginCodeVerifyMissing] != workers-1 || statuses[store.LoginCodeVerifyInvalid] != 0 {
t.Fatalf("verify race statuses=%+v", statuses)
}
})
t.Run("mark and consume each one winner", func(t *testing.T) {
key := hash("signup-race")
if err := codes.Set(ctx, key, newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentRedisVerify(t, codes, key, phone, "12345", true, workers)
if statuses[store.LoginCodeVerifyAccepted] != 1 || statuses[store.LoginCodeVerifyMissing] != workers-1 {
t.Fatalf("sign-up verify race statuses=%+v", statuses)
}
if found := concurrentRedisConsume(t, codes, key, phone, workers); found != 1 {
t.Fatalf("sign-up consume winners=%d, want 1", found)
}
})
t.Run("take one winner", func(t *testing.T) {
key := hash("take-race")
if err := codes.Set(ctx, key, newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
if found := concurrentRedisTake(t, codes, key, phone, workers); found != 1 {
t.Fatalf("take winners=%d, want 1", found)
}
})
t.Run("wrong attempts cannot be lost", func(t *testing.T) {
key := hash("wrong-race")
if err := codes.Set(ctx, key, newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentRedisVerify(t, codes, key, phone, "00000", false, workers)
if statuses[store.LoginCodeVerifyInvalid] != 7 || statuses[store.LoginCodeVerifyMissing] != workers-7 {
t.Fatalf("wrong race statuses=%+v", statuses)
}
assertRedisCodeMissing(t, ctx, codes, key)
})
t.Run("verify and take share one winner", func(t *testing.T) {
key := hash("mixed-race")
if err := codes.Set(ctx, key, newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
results := make(chan bool, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(take bool) {
defer wg.Done()
if take {
_, found, err := codes.TakeLoginCode(ctx, key, phone)
if err != nil {
errs <- err
return
}
results <- found
return
}
result, err := codes.VerifyLogin(ctx, key, phone, "12345", false, 5)
if err != nil {
errs <- err
return
}
results <- result.Status == store.LoginCodeVerifyAccepted
}(i%2 == 0)
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("mixed race: %v", err)
}
winners := 0
for won := range results {
if won {
winners++
}
}
if winners != 1 {
t.Fatalf("mixed race winners=%d, want 1", winners)
}
})
t.Run("sign-up mark and take share one winner", func(t *testing.T) {
key := hash("mixed-signup-race")
if err := codes.Set(ctx, key, newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
results := make(chan bool, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(take bool) {
defer wg.Done()
if take {
_, found, err := codes.TakeLoginCode(ctx, key, phone)
if err != nil {
errs <- err
return
}
results <- found
return
}
result, err := codes.VerifyLogin(ctx, key, phone, "12345", true, 5)
if err != nil {
errs <- err
return
}
results <- result.Status == store.LoginCodeVerifyAccepted
}(i%2 == 0)
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("mixed sign-up race: %v", err)
}
if winners := countRedisTrue(results); winners != 1 {
t.Fatalf("mixed sign-up race winners=%d, want 1", winners)
}
})
}
func newRedisLoginCodeHarness(t *testing.T) (*CodeStore, *redis.Client, func(string) string) {
t.Helper()
addr := os.Getenv("TELESRV_TEST_REDIS_ADDR")
if addr == "" {
t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis login-code integration tests")
}
ctx := context.Background()
client, err := Open(ctx, addr, "", 0)
if err != nil {
t.Fatalf("open redis: %v", err)
}
prefix := fmt.Sprintf("atomic-login-%d-", time.Now().UnixNano())
var mu sync.Mutex
keys := make([]string, 0)
newHash := func(label string) string {
value := prefix + label
mu.Lock()
keys = append(keys, codeKey(value))
mu.Unlock()
return value
}
t.Cleanup(func() {
mu.Lock()
cleanup := append([]string(nil), keys...)
mu.Unlock()
if len(cleanup) > 0 {
_ = client.Del(context.Background(), cleanup...).Err()
}
_ = client.Close()
})
return NewCodeStore(client), client, newHash
}
func assertRedisCodeMissing(t *testing.T, ctx context.Context, codes *CodeStore, hash string) {
t.Helper()
if _, found, err := codes.Get(ctx, hash); err != nil || found {
t.Fatalf("code %q found=%v err=%v, want missing", hash, found, err)
}
}
func concurrentRedisVerify(t *testing.T, codes *CodeStore, hash, phone, code string, keep bool, workers int) map[store.LoginCodeVerifyStatus]int {
t.Helper()
ctx := context.Background()
results := make(chan store.LoginCodeVerifyStatus, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
result, err := codes.VerifyLogin(ctx, hash, phone, code, keep, 5)
if err != nil {
errs <- err
return
}
results <- result.Status
}()
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("VerifyLogin: %v", err)
}
counts := make(map[store.LoginCodeVerifyStatus]int)
for status := range results {
counts[status]++
}
return counts
}
func concurrentRedisTake(t *testing.T, codes *CodeStore, hash, phone string, workers int) int {
t.Helper()
ctx := context.Background()
results := make(chan bool, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, found, err := codes.TakeLoginCode(ctx, hash, phone)
if err != nil {
errs <- err
return
}
results <- found
}()
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("TakeLoginCode: %v", err)
}
return countRedisTrue(results)
}
func concurrentRedisConsume(t *testing.T, codes *CodeStore, hash, phone string, workers int) int {
t.Helper()
ctx := context.Background()
results := make(chan bool, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, found, err := codes.ConsumeSignUpVerified(ctx, hash, phone)
if err != nil {
errs <- err
return
}
results <- found
}()
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("ConsumeSignUpVerified: %v", err)
}
return countRedisTrue(results)
}
func countRedisTrue(results <-chan bool) int {
count := 0
for found := range results {
if found {
count++
}
}
return count
}

View file

@ -0,0 +1,103 @@
package redisstore
import (
"context"
"sync"
"testing"
"time"
"telesrv/internal/store"
)
func TestRedisCodeStoreAtomicLoginInvalidation(t *testing.T) {
codes, _, hash := newRedisLoginCodeHarness(t)
ctx := context.Background()
const phone = "15550016111"
newRecord := func() store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: phone,
Code: "12345",
Channel: store.PhoneCodeChannelPhone,
MaxAttempts: 5,
}
}
t.Run("owner cleanup may delete a terminal sign-up marker", func(t *testing.T) {
key := hash("invalidate-marker")
if err := codes.Set(ctx, key, newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
verified, err := codes.VerifyLogin(ctx, key, phone, "12345", true, 5)
if err != nil || verified.Status != store.LoginCodeVerifyAccepted || !verified.Record.SignUpVerified {
t.Fatalf("mark sign-up=%+v err=%v", verified, err)
}
if removed, err := codes.InvalidateLoginCode(ctx, key, "15550016999"); err != nil || removed {
t.Fatalf("cross-phone invalidate removed=%v err=%v", removed, err)
}
if removed, err := codes.InvalidateLoginCode(ctx, key, phone); err != nil || !removed {
t.Fatalf("owner invalidate removed=%v err=%v", removed, err)
}
if _, found, err := codes.ConsumeSignUpVerified(ctx, key, phone); err != nil || found {
t.Fatalf("consume after invalidate found=%v err=%v", found, err)
}
})
t.Run("legacy records fail closed", func(t *testing.T) {
key := hash("invalidate-legacy")
legacy := newRecord()
legacy.Version = 0
if err := codes.Set(ctx, key, legacy, time.Minute); err != nil {
t.Fatal(err)
}
if removed, err := codes.InvalidateLoginCode(ctx, key, phone); err != nil || removed {
t.Fatalf("legacy invalidate removed=%v err=%v, want false", removed, err)
}
assertRedisCodeMissing(t, ctx, codes, key)
})
t.Run("invalidate and sign-up consume have one winner", func(t *testing.T) {
key := hash("invalidate-race")
if err := codes.Set(ctx, key, newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
if verified, err := codes.VerifyLogin(ctx, key, phone, "12345", true, 5); err != nil || verified.Status != store.LoginCodeVerifyAccepted {
t.Fatalf("mark sign-up=%+v err=%v", verified, err)
}
const workers = 48
results := make(chan bool, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(invalidate bool) {
defer wg.Done()
if invalidate {
removed, err := codes.InvalidateLoginCode(ctx, key, phone)
if err != nil {
errs <- err
return
}
results <- removed
return
}
_, found, err := codes.ConsumeSignUpVerified(ctx, key, phone)
if err != nil {
errs <- err
return
}
results <- found
}(i%2 == 0)
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("invalidate/consume race: %v", err)
}
if winners := countRedisTrue(results); winners != 1 {
t.Fatalf("invalidate/consume winners=%d, want 1", winners)
}
})
}

View file

@ -3,7 +3,6 @@ package redisstore
import (
"context"
"fmt"
"math"
"time"
"github.com/redis/go-redis/v9"
@ -23,6 +22,16 @@ func rateLimitKey(key string) string {
return "ratelimit:" + key
}
const rateLimitIncrementScript = `
local count = redis.call('INCRBY', KEYS[1], ARGV[1])
local ttl_ms = redis.call('PTTL', KEYS[1])
if ttl_ms < 0 then
redis.call('PEXPIRE', KEYS[1], ARGV[2])
ttl_ms = tonumber(ARGV[2])
end
return {count, ttl_ms}
`
func (l *RateLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, int, error) {
return l.AllowN(ctx, key, 1, limit, window)
}
@ -41,24 +50,29 @@ func (l *RateLimiter) AllowN(ctx context.Context, key string, cost, limit int, w
return false, 0, fmt.Errorf("redis rate limiter: nil client")
}
redisKey := rateLimitKey(key)
count, err := l.c.IncrBy(ctx, redisKey, int64(cost)).Result()
if err != nil {
return false, 0, fmt.Errorf("redis incrby rate limit: %w", err)
windowMillis := window.Milliseconds()
if windowMillis <= 0 {
windowMillis = 1
}
if count == int64(cost) {
if err := l.c.Expire(ctx, redisKey, window).Err(); err != nil {
return false, 0, fmt.Errorf("redis expire rate limit: %w", err)
}
value, err := l.c.Eval(ctx, rateLimitIncrementScript, []string{redisKey}, cost, windowMillis).Result()
if err != nil {
return false, 0, fmt.Errorf("redis increment rate limit: %w", err)
}
items, ok := value.([]interface{})
if !ok || len(items) != 2 {
return false, 0, fmt.Errorf("redis increment rate limit: unexpected result %T", value)
}
count, countOK := items[0].(int64)
ttlMillis, ttlOK := items[1].(int64)
if !countOK || !ttlOK || ttlMillis <= 0 {
return false, 0, fmt.Errorf("redis increment rate limit: invalid result %#v", items)
}
if count <= int64(limit) {
return true, 0, nil
}
ttl, err := l.c.TTL(ctx, redisKey).Result()
if err != nil {
return false, 0, fmt.Errorf("redis ttl rate limit: %w", err)
retry := (ttlMillis + 999) / 1000
if retry <= 0 {
retry = 1
}
if ttl <= 0 {
ttl = window
}
return false, int(math.Ceil(ttl.Seconds())), nil
return false, int(retry), nil
}

View file

@ -0,0 +1,288 @@
package redisstore
import (
"context"
"encoding/json"
"math"
"sync"
"testing"
"time"
"github.com/redis/go-redis/v9"
"telesrv/internal/store"
)
func TestRedisCodeStoreAtomicScopedVerification(t *testing.T) {
codes, client, hash := newRedisLoginCodeHarness(t)
ctx := context.Background()
newRecord := func() store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016121",
Code: "12345",
Channel: store.PhoneCodeChannelPhone,
Purpose: store.PhoneCodePurposeChangePhone,
UserID: math.MaxInt64 - 121,
AuthKeyID: [8]byte{1, 2, 3, 4},
SessionID: math.MaxInt64 - 21,
MaxAttempts: 2,
}
}
recordForCleanup := newRecord()
t.Cleanup(func() { _ = client.Del(context.Background(), codeScopeKey(recordForCleanup.Scope())).Err() })
t.Run("only the active hash and exact scope can mutate", func(t *testing.T) {
record := newRecord()
oldHash := hash("scoped-old")
currentHash := hash("scoped-current")
if err := codes.Set(ctx, oldHash, record, time.Minute); err != nil {
t.Fatal(err)
}
if err := codes.Set(ctx, currentHash, record, time.Minute); err != nil {
t.Fatal(err)
}
if result, err := codes.VerifyScoped(ctx, oldHash, record.Scope(), record.Code, 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("old-hash verify=%+v err=%v", result, err)
}
otherScope := record.Scope()
otherScope.AuthKeyID = [8]byte{9}
if result, err := codes.VerifyScoped(ctx, currentHash, otherScope, "00000", 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("cross-scope verify=%+v err=%v", result, err)
}
stored, found, err := codes.Get(ctx, currentHash)
if err != nil || !found || stored.Attempts != 0 || stored.UserID != record.UserID {
t.Fatalf("victim after cross-scope verify=%+v found=%v err=%v", stored, found, err)
}
})
t.Run("wrong attempts preserve ttl then delete code and index", func(t *testing.T) {
record := newRecord()
key := hash("scoped-wrong")
if err := codes.Set(ctx, key, record, 45*time.Second); err != nil {
t.Fatal(err)
}
beforeTTL, err := client.PTTL(ctx, codeKey(key)).Result()
if err != nil {
t.Fatal(err)
}
before, found, err := codes.Get(ctx, key)
if err != nil || !found {
t.Fatalf("get before found=%v err=%v", found, err)
}
first, err := codes.VerifyScoped(ctx, key, record.Scope(), "00000", 9)
if err != nil || first.Status != store.LoginCodeVerifyInvalid || first.Record.Attempts != 1 || first.Record.UserID != record.UserID || first.Record.SessionID != record.SessionID {
t.Fatalf("first wrong=%+v err=%v", first, err)
}
afterTTL, err := client.PTTL(ctx, codeKey(key)).Result()
if err != nil || afterTTL <= 0 || afterTTL > beforeTTL || beforeTTL-afterTTL > 2*time.Second {
t.Fatalf("wrong-attempt TTL before=%v after=%v err=%v", beforeTTL, afterTTL, err)
}
after, found, err := codes.Get(ctx, key)
if err != nil || !found || after.Revision == before.Revision || after.UserID != record.UserID || after.SessionID != record.SessionID {
t.Fatalf("get after=%+v found=%v err=%v", after, found, err)
}
second, err := codes.VerifyScoped(ctx, key, record.Scope(), "00000", 9)
if err != nil || second.Status != store.LoginCodeVerifyInvalid || second.Record.Attempts != 2 {
t.Fatalf("threshold wrong=%+v err=%v", second, err)
}
assertRedisScopedMissing(t, ctx, client, key, record.Scope())
if result, err := codes.VerifyScoped(ctx, key, record.Scope(), record.Code, 9); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("verify after exhaustion=%+v err=%v", result, err)
}
})
t.Run("correct code consumes both keys exactly once", func(t *testing.T) {
record := newRecord()
key := hash("scoped-correct")
if err := codes.Set(ctx, key, record, time.Minute); err != nil {
t.Fatal(err)
}
expected, found, err := codes.Get(ctx, key)
if err != nil || !found {
t.Fatalf("get expected found=%v err=%v", found, err)
}
accepted, err := codes.VerifyScoped(ctx, key, record.Scope(), record.Code, 5)
if err != nil || accepted.Status != store.LoginCodeVerifyAccepted || accepted.Record != expected || accepted.Record.UserID != record.UserID {
t.Fatalf("accepted=%+v err=%v, want %+v", accepted, err, expected)
}
assertRedisScopedMissing(t, ctx, client, key, record.Scope())
if repeated, err := codes.VerifyScoped(ctx, key, record.Scope(), record.Code, 5); err != nil || repeated.Status != store.LoginCodeVerifyMissing {
t.Fatalf("repeated verify=%+v err=%v", repeated, err)
}
})
t.Run("legacy corrupt and inconsistent records fail closed", func(t *testing.T) {
legacy := newRecord()
legacy.Version = 0
legacyHash := hash("scoped-legacy")
if err := codes.Set(ctx, legacyHash, legacy, time.Minute); err != nil {
t.Fatal(err)
}
if result, err := codes.VerifyScoped(ctx, legacyHash, legacy.Scope(), legacy.Code, 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("legacy verify=%+v err=%v", result, err)
}
assertRedisScopedMissing(t, ctx, client, legacyHash, legacy.Scope())
corrupt := newRecord()
corruptHash := hash("scoped-corrupt")
if err := codes.Set(ctx, corruptHash, corrupt, time.Minute); err != nil {
t.Fatal(err)
}
if err := client.Set(ctx, codeKey(corruptHash), `{`, time.Minute).Err(); err != nil {
t.Fatal(err)
}
if result, err := codes.VerifyScoped(ctx, corruptHash, corrupt.Scope(), corrupt.Code, 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("corrupt verify=%+v err=%v", result, err)
}
assertRedisScopedMissing(t, ctx, client, corruptHash, corrupt.Scope())
inconsistent := newRecord()
inconsistentHash := hash("scoped-inconsistent")
if err := codes.Set(ctx, inconsistentHash, inconsistent, time.Minute); err != nil {
t.Fatal(err)
}
stored, found, err := codes.Get(ctx, inconsistentHash)
if err != nil || !found {
t.Fatalf("get inconsistent seed found=%v err=%v", found, err)
}
stored.Phone = "15550016999"
raw, err := json.Marshal(stored)
if err != nil {
t.Fatal(err)
}
if err := client.Set(ctx, codeKey(inconsistentHash), raw, time.Minute).Err(); err != nil {
t.Fatal(err)
}
if result, err := codes.VerifyScoped(ctx, inconsistentHash, inconsistent.Scope(), inconsistent.Code, 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("inconsistent verify=%+v err=%v", result, err)
}
assertRedisScopedMissing(t, ctx, client, inconsistentHash, inconsistent.Scope())
})
}
func TestRedisCodeStoreAtomicScopedConcurrency(t *testing.T) {
codes, client, hash := newRedisLoginCodeHarness(t)
ctx := context.Background()
const workers = 48
newRecord := func(maxAttempts int) store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016122",
Code: "12345",
Channel: store.PhoneCodeChannelPhone,
Purpose: store.PhoneCodePurposeChangePhone,
UserID: math.MaxInt64 - 122,
AuthKeyID: [8]byte{5, 6, 7, 8},
SessionID: math.MaxInt64 - 22,
MaxAttempts: maxAttempts,
}
}
cleanupRecord := newRecord(7)
t.Cleanup(func() { _ = client.Del(context.Background(), codeScopeKey(cleanupRecord.Scope())).Err() })
t.Run("correct verification has one winner", func(t *testing.T) {
record := newRecord(7)
key := hash("scoped-verify-race")
if err := codes.Set(ctx, key, record, time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentRedisScopedVerify(t, codes, key, record.Scope(), record.Code, workers)
if statuses[store.LoginCodeVerifyAccepted] != 1 || statuses[store.LoginCodeVerifyMissing] != workers-1 || statuses[store.LoginCodeVerifyInvalid] != 0 {
t.Fatalf("correct race statuses=%+v", statuses)
}
})
t.Run("wrong attempts cannot be lost", func(t *testing.T) {
record := newRecord(7)
key := hash("scoped-wrong-race")
if err := codes.Set(ctx, key, record, time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentRedisScopedVerify(t, codes, key, record.Scope(), "00000", workers)
if statuses[store.LoginCodeVerifyInvalid] != 7 || statuses[store.LoginCodeVerifyMissing] != workers-7 {
t.Fatalf("wrong race statuses=%+v", statuses)
}
assertRedisScopedMissing(t, ctx, client, key, record.Scope())
})
t.Run("verification and cancellation share one winner", func(t *testing.T) {
record := newRecord(7)
key := hash("scoped-mixed-race")
if err := codes.Set(ctx, key, record, time.Minute); err != nil {
t.Fatal(err)
}
results := make(chan bool, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(cancel bool) {
defer wg.Done()
if cancel {
_, found, err := codes.ConsumeScoped(ctx, key, record.Scope())
if err != nil {
errs <- err
return
}
results <- found
return
}
result, err := codes.VerifyScoped(ctx, key, record.Scope(), record.Code, 5)
if err != nil {
errs <- err
return
}
results <- result.Status == store.LoginCodeVerifyAccepted
}(i%2 == 0)
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("verify/cancel race: %v", err)
}
if winners := countRedisTrue(results); winners != 1 {
t.Fatalf("verify/cancel winners=%d, want 1", winners)
}
})
}
func concurrentRedisScopedVerify(t *testing.T, codes *CodeStore, hash string, scope store.PhoneCodeScope, code string, workers int) map[store.LoginCodeVerifyStatus]int {
t.Helper()
results := make(chan store.LoginCodeVerifyStatus, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
result, err := codes.VerifyScoped(context.Background(), hash, scope, code, 5)
if err != nil {
errs <- err
return
}
results <- result.Status
}()
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("VerifyScoped: %v", err)
}
statuses := make(map[store.LoginCodeVerifyStatus]int)
for status := range results {
statuses[status]++
}
return statuses
}
func assertRedisScopedMissing(t *testing.T, ctx context.Context, client *redis.Client, hash string, scope store.PhoneCodeScope) {
t.Helper()
exists, err := client.Exists(ctx, codeKey(hash), codeScopeKey(scope)).Result()
if err != nil || exists != 0 {
t.Fatalf("scoped keys remain=%d err=%v", exists, err)
}
}

View file

@ -0,0 +1,20 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// PrivateSendReplayStore exposes the immutable random_id receipt independently from the send
// command. App/RPC preflight uses it before rate limits, permission gates and media/source
// resolution; SendPrivateText still owns the transactional race fence.
type PrivateSendReplayStore interface {
LookupPrivateSendReplay(ctx context.Context, req domain.PrivateSendReplayRequest) (domain.SendPrivateTextResult, bool, error)
}
// ChannelSendReplayStore is the channel/monoforum counterpart. SavedPeer in the request is part
// of the monoforum idempotency scope and is zero for ordinary channel sends.
type ChannelSendReplayStore interface {
LookupChannelSendReplay(ctx context.Context, req domain.ChannelSendReplayRequest) (domain.SendChannelMessageResult, bool, error)
}

Some files were not shown because too many files have changed in this diff Show more