perf: sync protocol and core hardening updates

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -527,29 +527,29 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp
}
const claimDispatchOutbox = `-- name: ClaimDispatchOutbox :many
WITH picked AS (
SELECT d.target_user_id, d.id
FROM dispatch_outbox d
WITH picked_heads AS (
SELECT h.target_user_id, h.head_id
FROM dispatch_outbox_user_heads h
WHERE (
d.status = 'pending'
AND d.next_attempt_at <= now()
)
OR (
d.status = 'dispatching'
AND d.updated_at < now() - make_interval(secs => $1::int)
)
ORDER BY d.next_attempt_at ASC, d.target_user_id ASC, d.pts ASC, d.id ASC
h.status = 'pending'
AND h.next_attempt_at <= now()
)
OR (
h.status = 'dispatching'
AND h.updated_at < now() - make_interval(secs => $1::int)
)
ORDER BY h.next_attempt_at ASC, h.target_user_id ASC, h.head_pts ASC, h.head_id ASC
LIMIT $2
FOR UPDATE SKIP LOCKED
FOR UPDATE OF h SKIP LOCKED
)
UPDATE dispatch_outbox d
SET
status = 'dispatching',
attempts = d.attempts + 1,
updated_at = now()
FROM picked p
FROM picked_heads p
WHERE d.target_user_id = p.target_user_id
AND d.id = p.id
AND d.id = p.head_id
RETURNING
d.id,
d.target_user_id,
@ -575,6 +575,8 @@ type ClaimDispatchOutboxRow struct {
Attempts int32
}
// durable head 表只保留每用户一行,并同步 head 的 readiness。claim 先锁
// lane head 再更新对应 outbox 行,既不会扫描 backlog也不会并发领取同一用户。
func (q *Queries) ClaimDispatchOutbox(ctx context.Context, arg ClaimDispatchOutboxParams) ([]ClaimDispatchOutboxRow, error) {
rows, err := q.db.Query(ctx, claimDispatchOutbox, arg.LeaseSeconds, arg.LimitCount)
if err != nil {
@ -603,14 +605,100 @@ func (q *Queries) ClaimDispatchOutbox(ctx context.Context, arg ClaimDispatchOutb
return items, nil
}
const claimDispatchOutboxShards = `-- name: ClaimDispatchOutboxShards :many
WITH picked_heads AS (
SELECT h.target_user_id, h.head_id
FROM dispatch_outbox_user_heads h
-- 256 store.DispatchOutboxLogicalShards0069 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→outboxcompletion 必须先显式锁同一 head 再删 outbox
// 否则租约过期 claim 与完成恰好竞争时会形成 outbox→head / head→outbox 环路。
func (q *Queries) MarkDispatchDelivered(ctx context.Context, arg MarkDispatchDeliveredParams) (int64, error) {
result, err := q.db.Exec(ctx, markDispatchDelivered, arg.ID, arg.ExpectedAttempts, arg.TargetUserID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const markDispatchDeliveredBatch = `-- name: MarkDispatchDeliveredBatch :exec
const markDispatchDeliveredBatch = `-- name: MarkDispatchDeliveredBatch :execrows
WITH input AS MATERIALIZED (
SELECT tu.target_user_id, di.id, ea.attempts
FROM unnest($1::bigint[]) WITH ORDINALITY AS tu(target_user_id, ord)
JOIN unnest($2::bigint[]) WITH ORDINALITY AS di(id, ord) USING (ord)
JOIN unnest($3::int[]) WITH ORDINALITY AS ea(attempts, ord) USING (ord)
),
locked_heads AS MATERIALIZED (
SELECT h.target_user_id
FROM dispatch_outbox_user_heads h
JOIN (SELECT DISTINCT target_user_id FROM input) i USING (target_user_id)
-- Match ClaimDispatchOutbox[Shards] exactly. A stale-lease claim may lock several
-- dispatching heads while this completion batch locks the same set; a different
-- multi-row order would merely move the deadlock one level up.
ORDER BY h.next_attempt_at, h.target_user_id, h.head_pts, h.head_id
FOR UPDATE OF h
)
DELETE FROM dispatch_outbox d
USING unnest($1::bigint[]) WITH ORDINALITY AS tu(target_user_id, ord)
JOIN unnest($2::bigint[]) WITH ORDINALITY AS di(id, ord) USING (ord)
WHERE d.target_user_id = tu.target_user_id
AND d.id = di.id
USING input i, locked_heads h
WHERE d.target_user_id = h.target_user_id
AND d.target_user_id = i.target_user_id
AND d.id = i.id
AND d.status = 'dispatching'
AND d.attempts = i.attempts
`
type MarkDispatchDeliveredBatchParams struct {
TargetUserIds []int64
Ids []int64
TargetUserIds []int64
Ids []int64
ExpectedAttempts []int32
}
// 批量删除一批已投递的 (target_user_id, id)target_user_id 入 WHERE 命中唯一索引并避免串删。
func (q *Queries) MarkDispatchDeliveredBatch(ctx context.Context, arg MarkDispatchDeliveredBatchParams) error {
_, err := q.db.Exec(ctx, markDispatchDeliveredBatch, arg.TargetUserIds, arg.Ids)
return err
func (q *Queries) MarkDispatchDeliveredBatch(ctx context.Context, arg MarkDispatchDeliveredBatchParams) (int64, error) {
result, err := q.db.Exec(ctx, markDispatchDeliveredBatch, arg.TargetUserIds, arg.Ids, arg.ExpectedAttempts)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const markDispatchFailed = `-- name: MarkDispatchFailed :exec
UPDATE dispatch_outbox
const markDispatchFailed = `-- name: MarkDispatchFailed :execrows
WITH locked_head AS MATERIALIZED (
SELECT h.target_user_id
FROM dispatch_outbox_user_heads h
WHERE h.target_user_id = $4::bigint
FOR UPDATE
)
UPDATE dispatch_outbox d
SET
status = CASE WHEN attempts >= 5 THEN 'failed' ELSE 'pending' END,
status = CASE WHEN d.attempts >= 5 THEN 'failed' ELSE 'pending' END,
next_attempt_at = CASE
WHEN attempts >= 5 THEN next_attempt_at
ELSE now() + make_interval(secs => LEAST(60, attempts * attempts))
WHEN d.attempts >= 5 THEN d.next_attempt_at
ELSE now() + make_interval(secs => LEAST(60, d.attempts * d.attempts))
END,
last_error = $3,
last_error = $1::text,
updated_at = now()
WHERE target_user_id = $1
AND id = $2
FROM locked_head h
WHERE d.target_user_id = h.target_user_id
AND d.id = $2::bigint
AND d.status = 'dispatching'
AND d.attempts = $3::int
`
type MarkDispatchFailedParams struct {
TargetUserID int64
ID int64
LastError string
LastError string
ID int64
ExpectedAttempts int32
TargetUserID int64
}
func (q *Queries) MarkDispatchFailed(ctx context.Context, arg MarkDispatchFailedParams) error {
_, err := q.db.Exec(ctx, markDispatchFailed, arg.TargetUserID, arg.ID, arg.LastError)
return err
func (q *Queries) MarkDispatchFailed(ctx context.Context, arg MarkDispatchFailedParams) (int64, error) {
result, err := q.db.Exec(ctx, markDispatchFailed,
arg.LastError,
arg.ID,
arg.ExpectedAttempts,
arg.TargetUserID,
)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const maxUserPts = `-- name: MaxUserPts :one

View file

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

View file

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

View file

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