perf: sync protocol and core hardening updates
This commit is contained in:
parent
152fed3b87
commit
4390ebf5a9
283 changed files with 29231 additions and 2295 deletions
18
internal/store/album_group.go
Normal file
18
internal/store/album_group.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
67
internal/store/login_code_delivery.go
Normal file
67
internal/store/login_code_delivery.go
Normal 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
|
||||
}
|
||||
67
internal/store/login_code_delivery_test.go
Normal file
67
internal/store/login_code_delivery_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
68
internal/store/memory/album_group.go
Normal file
68
internal/store/memory/album_group.go
Normal 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
|
||||
}
|
||||
131
internal/store/memory/album_group_test.go
Normal file
131
internal/store/memory/album_group_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
internal/store/memory/channel_recovery_test.go
Normal file
22
internal/store/memory/channel_recovery_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
76
internal/store/memory/channel_send_idempotency_test.go
Normal file
76
internal/store/memory/channel_send_idempotency_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
150
internal/store/memory/channel_update_retention_test.go
Normal file
150
internal/store/memory/channel_update_retention_test.go
Normal 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]))
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
70
internal/store/memory/code_cas.go
Normal file
70
internal/store/memory/code_cas.go
Normal 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
|
||||
}
|
||||
213
internal/store/memory/code_cas_test.go
Normal file
213
internal/store/memory/code_cas_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
211
internal/store/memory/login_code.go
Normal file
211
internal/store/memory/login_code.go
Normal 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
|
||||
}
|
||||
129
internal/store/memory/login_code_delivery.go
Normal file
129
internal/store/memory/login_code_delivery.go
Normal 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
|
||||
}
|
||||
165
internal/store/memory/login_code_delivery_test.go
Normal file
165
internal/store/memory/login_code_delivery_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
106
internal/store/memory/login_code_invalidate_test.go
Normal file
106
internal/store/memory/login_code_invalidate_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
447
internal/store/memory/login_code_state_test.go
Normal file
447
internal/store/memory/login_code_state_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
164
internal/store/memory/message_idempotency_test.go
Normal file
164
internal/store/memory/message_idempotency_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
240
internal/store/memory/scoped_code_state_test.go
Normal file
240
internal/store/memory/scoped_code_state_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
162
internal/store/postgres/album_group.go
Normal file
162
internal/store/postgres/album_group.go
Normal 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
|
||||
}
|
||||
118
internal/store/postgres/album_group_integration_test.go
Normal file
118
internal/store/postgres/album_group_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[:]))
|
||||
|
|
|
|||
215
internal/store/postgres/authkey_retention_integration_test.go
Normal file
215
internal/store/postgres/authkey_retention_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(¤tPts); 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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 媒体标签页。
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(¤t); 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)
|
||||
}
|
||||
}
|
||||
285
internal/store/postgres/channel_update_retention.go
Normal file
285
internal/store/postgres/channel_update_retention.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, `
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
342
internal/store/postgres/login_code_delivery.go
Normal file
342
internal/store/postgres/login_code_delivery.go
Normal 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)
|
||||
}
|
||||
}
|
||||
475
internal/store/postgres/login_code_delivery_integration_test.go
Normal file
475
internal/store/postgres/login_code_delivery_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
32
internal/store/postgres/login_code_delivery_retention.go
Normal file
32
internal/store/postgres/login_code_delivery_retention.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
83
internal/store/postgres/message_send_idempotency_test.go
Normal file
83
internal/store/postgres/message_send_idempotency_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
137
internal/store/postgres/retention_migration_integration_test.go
Normal file
137
internal/store/postgres/retention_migration_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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, `
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
300
internal/store/postgres/update_event_retention.go
Normal file
300
internal/store/postgres/update_event_retention.go
Normal 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(¤tPts); 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
|
||||
}
|
||||
|
|
@ -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(¤tPts); 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
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
243
internal/store/private_send_idempotency.go
Normal file
243
internal/store/private_send_idempotency.go
Normal 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
|
||||
}
|
||||
56
internal/store/private_send_idempotency_test.go
Normal file
56
internal/store/private_send_idempotency_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
147
internal/store/redisstore/code_cas.go
Normal file
147
internal/store/redisstore/code_cas.go
Normal 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
|
||||
}
|
||||
237
internal/store/redisstore/code_cas_integration_test.go
Normal file
237
internal/store/redisstore/code_cas_integration_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
385
internal/store/redisstore/login_code.go
Normal file
385
internal/store/redisstore/login_code.go
Normal 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
|
||||
}
|
||||
517
internal/store/redisstore/login_code_integration_test.go
Normal file
517
internal/store/redisstore/login_code_integration_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
288
internal/store/redisstore/scoped_code_state_integration_test.go
Normal file
288
internal/store/redisstore/scoped_code_state_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
20
internal/store/send_replay.go
Normal file
20
internal/store/send_replay.go
Normal 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
Loading…
Add table
Add a link
Reference in a new issue