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
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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue