fix: sync non-PTS privacy updates

This commit is contained in:
iamxvbaba 2026-07-24 11:57:00 +08:00
parent 6cafa40c7b
commit cc76cd3679
17 changed files with 196 additions and 421 deletions

View file

@ -24,16 +24,6 @@ func NewPrivacyStore(db sqlcgen.DBTX) *PrivacyStore {
return &PrivacyStore{db: db}
}
func (s *PrivacyStore) SupportsDurablePrivacyUpdates() bool {
if s == nil {
return false
}
_, ok := s.db.(interface {
Begin(context.Context) (pgx.Tx, error)
})
return ok
}
func (s *PrivacyStore) GetPrivacyRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, bool, error) {
row := s.db.QueryRow(ctx, `
SELECT rules::text
@ -77,56 +67,6 @@ ON CONFLICT (owner_user_id, privacy_key) DO UPDATE SET
return nil
}
// SetPrivacyRulesWithUpdate commits the mutable rule row and the immutable
// account update snapshot in one transaction. A privacy rule can therefore
// never become visible without a matching pts event/outbox item.
func (s *PrivacyStore) SetPrivacyRulesWithUpdate(
ctx context.Context,
rules domain.PrivacyRules,
event domain.UpdateEvent,
excludeAuthKeyID [8]byte,
excludeSessionID int64,
) (domain.UpdateEvent, error) {
beginner, ok := s.db.(interface {
Begin(context.Context) (pgx.Tx, error)
})
if !ok {
return domain.UpdateEvent{}, fmt.Errorf("privacy update transaction unavailable")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.UpdateEvent{}, fmt.Errorf("begin privacy update: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
if err := setPrivacyRules(ctx, tx, rules); err != nil {
return domain.UpdateEvent{}, err
}
if event.Date == 0 {
return domain.UpdateEvent{}, fmt.Errorf("privacy update date is required")
}
event.Type = domain.UpdateEventPrivacy
event.Privacy = rules
event.PtsCount = 1
qtx := sqlcgen.New(tx)
recorded, err := NewUpdateEventStore(tx).appendInTx(
ctx, tx, qtx, rules.OwnerUserID, event, true,
excludeAuthKeyID, excludeSessionID, true,
)
if err != nil {
return domain.UpdateEvent{}, fmt.Errorf("append privacy update: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.UpdateEvent{}, fmt.Errorf("commit privacy update: %w", err)
}
committed = true
return recorded, nil
}
func (s *PrivacyStore) ListPrivacyRules(ctx context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error) {
if len(ownerUserIDs) == 0 || len(keys) == 0 {
return nil, nil

View file

@ -0,0 +1,103 @@
package postgres
import (
"context"
"errors"
"testing"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5/pgconn"
"telesrv/internal/domain"
)
// TestPrivacyRulesDoNotAllocateAccountPts protects the protocol boundary:
// account privacy is authoritative absolute state, not a message-box event.
func TestPrivacyRulesDoNotAllocateAccountPts(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
users := NewUserStore(pool)
suffix := randomSuffix(t)
user, err := users.Create(ctx, domain.User{
AccessHash: 9201,
Phone: "+1665" + suffix + "01",
FirstName: "PrivacyPts",
})
if err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", user.ID)
})
var privacyPayloadTableAbsent bool
if err := pool.QueryRow(ctx, `
SELECT to_regclass('public.user_update_privacy_payloads') IS NULL`).Scan(&privacyPayloadTableAbsent); err != nil {
t.Fatalf("inspect privacy payload schema: %v", err)
}
if !privacyPayloadTableAbsent {
t.Fatal("development-only user_update_privacy_payloads table still exists")
}
if _, err := pool.Exec(ctx, `
INSERT INTO user_update_events (user_id, pts, pts_count, date, event_type)
VALUES ($1, 1, 1, 1700000000, 'privacy')`, user.ID); err == nil {
t.Fatal("development-only privacy update event type is still accepted")
} else {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != pgerrcode.CheckViolation {
t.Fatalf("insert privacy update event error=%v, want check violation", err)
}
}
type updateFootprint struct {
eventCount int
maxPts int
outboxCount int
watermarkRow int
watermarkPts int
}
readFootprint := func() updateFootprint {
t.Helper()
var got updateFootprint
if err := pool.QueryRow(ctx, `
SELECT count(*), COALESCE(max(pts), 0)
FROM user_update_events
WHERE user_id = $1`, user.ID).Scan(&got.eventCount, &got.maxPts); err != nil {
t.Fatalf("read update events footprint: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT count(*)
FROM dispatch_outbox
WHERE target_user_id = $1`, user.ID).Scan(&got.outboxCount); err != nil {
t.Fatalf("read outbox footprint: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT count(*), COALESCE(max(contiguous_pts), 0)
FROM user_update_watermarks
WHERE user_id = $1`, user.ID).Scan(&got.watermarkRow, &got.watermarkPts); err != nil {
t.Fatalf("read update watermark footprint: %v", err)
}
return got
}
before := readFootprint()
store := NewPrivacyStore(pool)
want := domain.PrivacyRules{
OwnerUserID: user.ID,
Key: domain.PrivacyKeyPhoneNumber,
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
}
if err := store.SetPrivacyRules(ctx, want); err != nil {
t.Fatalf("set privacy rules: %v", err)
}
got, found, err := store.GetPrivacyRules(ctx, user.ID, want.Key)
if err != nil || !found {
t.Fatalf("get privacy rules: found=%v err=%v", found, err)
}
if len(got.Rules) != 1 || got.Rules[0].Kind != domain.PrivacyRuleDisallowAll {
t.Fatalf("stored privacy rules=%+v, want disallow_all", got)
}
after := readFootprint()
if after != before {
t.Fatalf("privacy write changed PTS footprint: before=%+v after=%+v", before, after)
}
}

View file

@ -244,35 +244,12 @@ func appendUserUpdateEvent(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Quer
}); err != nil {
return err
}
if err := appendPrivacyPayload(ctx, db, userID, event); err != nil {
return err
}
if err := appendQuickReplyPayload(ctx, db, userID, event); err != nil {
return err
}
return nil
}
func appendPrivacyPayload(ctx context.Context, db sqlcgen.DBTX, userID int64, event domain.UpdateEvent) error {
if event.Type != domain.UpdateEventPrivacy {
return nil
}
if event.Privacy.OwnerUserID != userID || event.Privacy.Key == "" || len(event.Privacy.Rules) == 0 {
return domain.ErrPrivacyRuleInvalid
}
raw, err := json.Marshal(event.Privacy)
if err != nil {
return fmt.Errorf("encode privacy update payload: %w", err)
}
if _, err := db.Exec(ctx, `
INSERT INTO user_update_privacy_payloads (user_id, pts, payload)
VALUES ($1, $2, $3::jsonb)
`, userID, event.Pts, string(raw)); err != nil {
return fmt.Errorf("save privacy update payload: %w", err)
}
return nil
}
func appendQuickReplyPayload(ctx context.Context, db sqlcgen.DBTX, userID int64, event domain.UpdateEvent) error {
switch event.Type {
case domain.UpdateEventQuickReplies,
@ -489,9 +466,6 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim
}
out = append(out, event)
}
if err := s.hydratePrivacyEvents(ctx, out); err != nil {
return nil, err
}
return out, nil
}
@ -691,77 +665,9 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev
}
out = append(out, event)
}
if err := s.hydratePrivacyEvents(ctx, out); err != nil {
return nil, err
}
return out, nil
}
type privacyEventCursor struct {
userID int64
pts int
}
// hydratePrivacyEvents fetches all immutable privacy payloads for one
// difference/outbox batch in one query. Ordinary event batches incur no extra
// query at all.
func (s *UpdateEventStore) hydratePrivacyEvents(ctx context.Context, events []domain.UpdateEvent) error {
indexes := make(map[privacyEventCursor]int)
userIDs := make([]int64, 0)
pts := make([]int32, 0)
for i := range events {
if events[i].Type != domain.UpdateEventPrivacy {
continue
}
key := privacyEventCursor{userID: events[i].UserID, pts: events[i].Pts}
indexes[key] = i
userIDs = append(userIDs, key.userID)
pts = append(pts, int32(key.pts))
}
if len(indexes) == 0 {
return nil
}
rows, err := s.db.Query(ctx, `
SELECT p.user_id, p.pts, p.payload::text
FROM unnest($1::bigint[], $2::int[]) AS requested(user_id, pts)
JOIN user_update_privacy_payloads p USING (user_id, pts)
`, userIDs, pts)
if err != nil {
return fmt.Errorf("list privacy update payloads: %w", err)
}
defer rows.Close()
found := 0
for rows.Next() {
var userID int64
var eventPts int
var raw string
if err := rows.Scan(&userID, &eventPts, &raw); err != nil {
return fmt.Errorf("scan privacy update payload: %w", err)
}
index, ok := indexes[privacyEventCursor{userID: userID, pts: eventPts}]
if !ok {
continue
}
var payload domain.PrivacyRules
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return fmt.Errorf("decode privacy update payload: %w", err)
}
if payload.OwnerUserID != userID || payload.Key == "" || len(payload.Rules) == 0 {
return fmt.Errorf("invalid privacy update payload for user %d pts %d", userID, eventPts)
}
events[index].Privacy = payload
delete(indexes, privacyEventCursor{userID: userID, pts: eventPts})
found++
}
if err := rows.Err(); err != nil {
return fmt.Errorf("list privacy update payloads rows: %w", err)
}
if found != len(userIDs) || len(indexes) != 0 {
return fmt.Errorf("privacy update payload missing")
}
return nil
}
func usersFromUpdateEventRow(row sqlcgen.ListUserUpdateEventsAfterRow) []domain.User {
return mergeEventUsers(
domain.User{