merged from gramsrv upstream

This commit is contained in:
onysd 2026-09-01 12:06:31 +03:00
parent 79c64ee916
commit 21a0856587
651 changed files with 54774 additions and 4590 deletions

View file

@ -232,7 +232,9 @@ func (s *PasswordStore) GetAccountSettings(ctx context.Context, userID int64) (d
row := s.db.QueryRow(ctx, `
SELECT archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders,
hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button,
noncontact_peers_paid_stars, account_ttl_days, sensitive_content_enabled, contact_signup_silent
noncontact_peers_paid_stars, disallow_unlimited_stargifts, disallow_limited_stargifts,
disallow_unique_stargifts, disallow_premium_gifts, disallow_stargifts_from_channels,
account_ttl_days, sensitive_content_enabled, contact_signup_silent
FROM account_settings
WHERE user_id = $1`, userID)
settings := domain.DefaultAccountSettings()
@ -240,7 +242,11 @@ WHERE user_id = $1`, userID)
if err := row.Scan(
&gp.ArchiveAndMuteNewNoncontactPeers, &gp.KeepArchivedUnmuted, &gp.KeepArchivedFolders,
&gp.HideReadMarks, &gp.NewNoncontactPeersRequirePremium, &gp.DisplayGiftsButton,
&gp.NoncontactPeersPaidStars, &settings.AccountTTLDays, &settings.SensitiveContentEnabled, &settings.ContactSignUpSilent,
&gp.NoncontactPeersPaidStars,
&gp.DisallowedGifts.UnlimitedStargifts, &gp.DisallowedGifts.LimitedStargifts,
&gp.DisallowedGifts.UniqueStargifts, &gp.DisallowedGifts.PremiumGifts,
&gp.DisallowedGifts.StargiftsFromChannel,
&settings.AccountTTLDays, &settings.SensitiveContentEnabled, &settings.ContactSignUpSilent,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountSettings{}, false, nil
@ -258,7 +264,9 @@ func (s *PasswordStore) GetAccountSettingsBatch(ctx context.Context, userIDs []i
rows, err := s.db.Query(ctx, `
SELECT user_id, archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders,
hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button,
noncontact_peers_paid_stars, account_ttl_days, sensitive_content_enabled, contact_signup_silent
noncontact_peers_paid_stars, disallow_unlimited_stargifts, disallow_limited_stargifts,
disallow_unique_stargifts, disallow_premium_gifts, disallow_stargifts_from_channels,
account_ttl_days, sensitive_content_enabled, contact_signup_silent
FROM account_settings
WHERE user_id = ANY($1::bigint[])`, userIDs)
if err != nil {
@ -273,7 +281,11 @@ WHERE user_id = ANY($1::bigint[])`, userIDs)
&userID,
&gp.ArchiveAndMuteNewNoncontactPeers, &gp.KeepArchivedUnmuted, &gp.KeepArchivedFolders,
&gp.HideReadMarks, &gp.NewNoncontactPeersRequirePremium, &gp.DisplayGiftsButton,
&gp.NoncontactPeersPaidStars, &settings.AccountTTLDays, &settings.SensitiveContentEnabled, &settings.ContactSignUpSilent,
&gp.NoncontactPeersPaidStars,
&gp.DisallowedGifts.UnlimitedStargifts, &gp.DisallowedGifts.LimitedStargifts,
&gp.DisallowedGifts.UniqueStargifts, &gp.DisallowedGifts.PremiumGifts,
&gp.DisallowedGifts.StargiftsFromChannel,
&settings.AccountTTLDays, &settings.SensitiveContentEnabled, &settings.ContactSignUpSilent,
); err != nil {
return nil, fmt.Errorf("scan account settings batch: %w", err)
}
@ -291,8 +303,10 @@ func (s *PasswordStore) SaveAccountSettings(ctx context.Context, userID int64, s
INSERT INTO account_settings (
user_id, archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders,
hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button,
noncontact_peers_paid_stars, account_ttl_days, sensitive_content_enabled, contact_signup_silent
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
noncontact_peers_paid_stars, disallow_unlimited_stargifts, disallow_limited_stargifts,
disallow_unique_stargifts, disallow_premium_gifts, disallow_stargifts_from_channels,
account_ttl_days, sensitive_content_enabled, contact_signup_silent
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
ON CONFLICT (user_id) DO UPDATE SET
archive_and_mute_new_noncontact_peers = EXCLUDED.archive_and_mute_new_noncontact_peers,
keep_archived_unmuted = EXCLUDED.keep_archived_unmuted,
@ -301,6 +315,11 @@ ON CONFLICT (user_id) DO UPDATE SET
new_noncontact_peers_require_premium = EXCLUDED.new_noncontact_peers_require_premium,
display_gifts_button = EXCLUDED.display_gifts_button,
noncontact_peers_paid_stars = EXCLUDED.noncontact_peers_paid_stars,
disallow_unlimited_stargifts = EXCLUDED.disallow_unlimited_stargifts,
disallow_limited_stargifts = EXCLUDED.disallow_limited_stargifts,
disallow_unique_stargifts = EXCLUDED.disallow_unique_stargifts,
disallow_premium_gifts = EXCLUDED.disallow_premium_gifts,
disallow_stargifts_from_channels = EXCLUDED.disallow_stargifts_from_channels,
account_ttl_days = EXCLUDED.account_ttl_days,
sensitive_content_enabled = EXCLUDED.sensitive_content_enabled,
contact_signup_silent = EXCLUDED.contact_signup_silent,
@ -308,7 +327,11 @@ ON CONFLICT (user_id) DO UPDATE SET
userID,
gp.ArchiveAndMuteNewNoncontactPeers, gp.KeepArchivedUnmuted, gp.KeepArchivedFolders,
gp.HideReadMarks, gp.NewNoncontactPeersRequirePremium, gp.DisplayGiftsButton,
gp.NoncontactPeersPaidStars, settings.NormalizedTTLDays(), settings.SensitiveContentEnabled, settings.ContactSignUpSilent,
gp.NoncontactPeersPaidStars,
gp.DisallowedGifts.UnlimitedStargifts, gp.DisallowedGifts.LimitedStargifts,
gp.DisallowedGifts.UniqueStargifts, gp.DisallowedGifts.PremiumGifts,
gp.DisallowedGifts.StargiftsFromChannel,
settings.NormalizedTTLDays(), settings.SensitiveContentEnabled, settings.ContactSignUpSilent,
); err != nil {
return fmt.Errorf("save account settings: %w", err)
}

View file

@ -16,7 +16,7 @@ import (
)
// AccountLifecycleStore is the PostgreSQL implementation of the unified
// account tombstone, delayed deletion and deletion notification boundary.
// account tombstone and delayed deletion boundary.
type AccountLifecycleStore struct {
pool *pgxpool.Pool
}
@ -157,19 +157,15 @@ func (s *AccountLifecycleStore) ExecuteAccountDeletion(ctx context.Context, user
if !due {
return domain.AccountDeletionResult{User: u, Changed: false}, nil
}
if err := enqueueAccountDeletionNotifications(ctx, tx, userID); err != nil {
return domain.AccountDeletionResult{}, err
}
if err := settleDeletedAccountFinancialState(ctx, tx, userID, now); err != nil {
return domain.AccountDeletionResult{}, err
}
// Human account deletion is deliberately a short logical tombstone boundary.
// Relationships, history, memberships, settings and financial rows remain
// attached to the stable user id; reads project that id as Deleted Account.
// The physical cleanup helpers remain available only to the separate bot-
// deletion boundary, whose lifecycle semantics are intentionally different.
revoked, err := revokeByUserExceptTx(ctx, tx, userID, 0)
if err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("revoke deleted account authorizations: %w", err)
}
if err := purgeDeletedAccountPrivateState(ctx, tx, userID, now); err != nil {
return domain.AccountDeletionResult{}, err
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, "", ""); err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("release deleted account username: %w", err)
}
@ -281,45 +277,6 @@ SELECT user_id, source, due_at FROM dedup ORDER BY due_at, user_id LIMIT $2`, no
return out, rows.Err()
}
func (s *AccountLifecycleStore) ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error) {
if s == nil || s.pool == nil || limit <= 0 || lease <= 0 {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
WITH claim AS (
SELECT id FROM account_deletion_notifications
WHERE (status = 'pending' AND next_attempt_at <= $1)
OR (status = 'dispatching' AND lease_until <= $1)
ORDER BY next_attempt_at, id FOR UPDATE SKIP LOCKED LIMIT $2
)
UPDATE account_deletion_notifications n
SET status = 'dispatching', attempts = attempts + 1, lease_until = $3, updated_at = $1
FROM claim WHERE n.id = claim.id
RETURNING n.id, n.target_user_id, n.deleted_user_id, n.attempts`, now, limit, now.Add(lease))
if err != nil {
return nil, fmt.Errorf("claim account deletion notifications: %w", err)
}
defer rows.Close()
out := make([]domain.AccountDeletionNotification, 0)
for rows.Next() {
var n domain.AccountDeletionNotification
if err := rows.Scan(&n.ID, &n.TargetUserID, &n.DeletedUserID, &n.Attempts); err != nil {
return nil, fmt.Errorf("scan account deletion notification: %w", err)
}
out = append(out, n)
}
return out, rows.Err()
}
func (s *AccountLifecycleStore) CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error {
_, err := s.pool.Exec(ctx, `UPDATE account_deletion_notifications
SET status = 'delivered', lease_until = NULL, last_error = '', updated_at = $2 WHERE id = $1`, id, now)
if err != nil {
return fmt.Errorf("complete account deletion notification: %w", err)
}
return nil
}
type accountDeletionRowScanner interface {
Scan(dest ...any) error
}
@ -448,38 +405,6 @@ func truncateUTF8Bytes(value string, maxBytes int) string {
return value[:cut]
}
func enqueueAccountDeletionNotifications(ctx context.Context, tx pgx.Tx, userID int64) error {
const maxAccountDeletionNotificationAudience = 4096
_, err := tx.Exec(ctx, `
INSERT INTO account_deletion_notifications (target_user_id, deleted_user_id)
SELECT audience.user_id, $1
FROM (
SELECT user_id
FROM (
SELECT contact_user_id AS user_id, 0 AS priority, 0 AS activity
FROM contacts WHERE user_id = $1
UNION ALL
SELECT user_id, 0, 0 FROM contacts WHERE contact_user_id = $1
UNION ALL
SELECT peer_id, 1, top_message_date
FROM dialogs WHERE user_id = $1 AND peer_type = 'user'
UNION ALL
SELECT user_id, 1, top_message_date
FROM dialogs WHERE peer_type = 'user' AND peer_id = $1
) candidates
GROUP BY user_id
ORDER BY min(priority), max(activity) DESC, user_id
LIMIT $2
) audience
JOIN users u ON u.id = audience.user_id
WHERE audience.user_id <> $1 AND u.deleted_at IS NULL
ON CONFLICT (target_user_id, deleted_user_id) DO NOTHING`, userID, maxAccountDeletionNotificationAudience)
if err != nil {
return fmt.Errorf("enqueue account deletion notifications: %w", err)
}
return nil
}
func revokeOneAuthorizationTx(ctx context.Context, tx pgx.Tx, userID int64, authKeyID [8]byte) ([]domain.Authorization, error) {
id := authKeyIDToInt64(authKeyID)
if id == 0 {
@ -514,10 +439,16 @@ FROM authorizations WHERE auth_key_id = $1 AND user_id = $2 FOR UPDATE`, id, use
return []domain.Authorization{a}, nil
}
func purgeDeletedAccountPrivateState(ctx context.Context, tx pgx.Tx, userID int64, now time.Time) error {
func purgeDeletedBotPrivateState(ctx context.Context, tx pgx.Tx, userID int64, now time.Time) error {
// Leave shared private_messages/channel_messages and immutable transaction
// ledgers intact. Only the deleted user's private projections and settings are
// removed; other users continue to reference the tombstone sender.
// The purge can empty the durable delivery lane. Fence concurrent appends
// before deleting its outbox/head/event facts so no committed task becomes
// undiscoverable during the account lifecycle transition.
if err := lockDispatchOutboxLanesExclusive(ctx, tx, []int64{userID}); err != nil {
return fmt.Errorf("lock deleted bot dispatch lane: %w", err)
}
statements := []string{
`DELETE FROM account_privacy_rules WHERE owner_user_id = $1`,
`DELETE FROM account_reaction_settings WHERE user_id = $1`,
@ -580,6 +511,7 @@ func purgeDeletedAccountPrivateState(ctx context.Context, tx pgx.Tx, userID int6
`DELETE FROM group_call_invites WHERE inviter_user_id = $1 OR invitee_user_id = $1`,
`DELETE FROM channel_boost_slots WHERE user_id = $1`,
`DELETE FROM channel_invite_importers WHERE user_id = $1`,
`DELETE FROM welcome_message_deliveries WHERE target_user_id = $1`,
`DELETE FROM channel_topic_read WHERE user_id = $1`,
`DELETE FROM channel_unread_mentions WHERE user_id = $1`,
`DELETE FROM channel_unread_mention_index WHERE user_id = $1`,
@ -621,121 +553,3 @@ WHERE admin_user_id = $1 OR participant_user_id = $1`, userID); err != nil {
}
return nil
}
func settleDeletedAccountFinancialState(ctx context.Context, tx pgx.Tx, userID int64, now time.Time) error {
nowUnix := int(now.Unix())
rows, err := tx.Query(ctx, `
SELECT id, buyer_user_id, currency, amount
FROM star_gift_offers
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND status = 'pending'
ORDER BY id FOR UPDATE`, userID)
if err != nil {
return fmt.Errorf("lock deleted account gift offers: %w", err)
}
type offer struct {
id, buyer, amount int64
currency string
}
offers := make([]offer, 0)
for rows.Next() {
var o offer
if err := rows.Scan(&o.id, &o.buyer, &o.currency, &o.amount); err != nil {
rows.Close()
return fmt.Errorf("scan deleted account gift offer: %w", err)
}
offers = append(offers, o)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
for _, o := range offers {
var balance int64
if o.currency == "XTR" {
if err := tx.QueryRow(ctx, `
INSERT INTO stars_balances (user_id, balance) VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET balance = stars_balances.balance + EXCLUDED.balance, updated_at = now()
RETURNING balance`, o.buyer, o.amount).Scan(&balance); err != nil {
return fmt.Errorf("refund deleted account stars offer: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO stars_transactions
(user_id, peer_type, peer_id, amount, reason, title, description, date)
VALUES ($1, 'user', $2, $3, 'gift_offer_refund_account_deleted', 'Gift offer refunded', '', $4)`, o.buyer, userID, o.amount, nowUnix); err != nil {
return fmt.Errorf("record deleted account stars refund: %w", err)
}
} else {
if err := tx.QueryRow(ctx, `
INSERT INTO ton_balances (user_id, balance_nanoton) VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET balance_nanoton = ton_balances.balance_nanoton + EXCLUDED.balance_nanoton, updated_at = now()
RETURNING balance_nanoton`, o.buyer, o.amount).Scan(&balance); err != nil {
return fmt.Errorf("refund deleted account TON offer: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions
(user_id, amount_nanoton, reason, peer_type, peer_id, date)
VALUES ($1, $2, 'gift_offer_refund_account_deleted', 'user', $3, $4)`, o.buyer, o.amount, userID, nowUnix); err != nil {
return fmt.Errorf("record deleted account TON refund: %w", err)
}
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_offers
SET status = 'cancelled', resolved_at = $2, balance_after = $3
WHERE id = $1 AND status = 'pending'`, o.id, nowUnix, balance); err != nil {
return fmt.Errorf("cancel deleted account gift offer: %w", err)
}
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_offers
SET status = 'cancelled', resolved_at = $2, balance_after = 0
WHERE buyer_user_id = $1 AND status = 'pending'`, userID, nowUnix); err != nil {
return fmt.Errorf("cancel deleted buyer gift offers: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_withdrawal_requests
SET status = 'failed', completed_at = $2 WHERE owner_user_id = $1 AND status = 'pending'`, userID, nowUnix); err != nil {
return fmt.Errorf("fail deleted account withdrawals: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_auction_bids SET active = false, version = version + 1
WHERE bidder_user_id = $1 AND active = true`, userID); err != nil {
return fmt.Errorf("deactivate deleted account auction bids: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts
SET burned = true, owner_name = '', updated_at = $2
WHERE owner_peer_type = 'user' AND owner_peer_id = $1`, userID, now); err != nil {
return fmt.Errorf("burn deleted account unique gifts: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts
SET lifecycle_status = 'burned', unsaved = true, pinned_order = 0
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND unique_gift_id IS NOT NULL`, userID); err != nil {
return fmt.Errorf("burn deleted account saved gifts: %w", err)
}
if _, err := tx.Exec(ctx, `DELETE FROM peer_star_gifts
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND unique_gift_id IS NULL`, userID); err != nil {
return fmt.Errorf("delete deleted account regular gifts: %w", err)
}
var stars int64
if err := tx.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id = $1 FOR UPDATE`, userID).Scan(&stars); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("lock deleted account stars balance: %w", err)
}
if stars != 0 {
if _, err := tx.Exec(ctx, `UPDATE stars_balances SET balance = 0, updated_at = $2 WHERE user_id = $1`, userID, now); err != nil {
return fmt.Errorf("zero deleted account stars: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO stars_transactions
(user_id, peer_type, peer_id, amount, reason, title, description, date)
VALUES ($1, 'user', $1, $2, 'account_deleted', 'Account deleted', '', $3)`, userID, -stars, nowUnix); err != nil {
return fmt.Errorf("record deleted account stars clearing: %w", err)
}
}
var ton int64
if err := tx.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id = $1 FOR UPDATE`, userID).Scan(&ton); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("lock deleted account TON balance: %w", err)
}
if ton != 0 {
if _, err := tx.Exec(ctx, `UPDATE ton_balances SET balance_nanoton = 0, updated_at = $2 WHERE user_id = $1`, userID, now); err != nil {
return fmt.Errorf("zero deleted account TON: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions
(user_id, amount_nanoton, reason, date) VALUES ($1, $2, 'account_deleted', $3)`, userID, -ton, nowUnix); err != nil {
return fmt.Errorf("record deleted account TON clearing: %w", err)
}
}
return nil
}

View file

@ -23,7 +23,15 @@ func TestAccountLifecycleScheduleCancelAndTombstonePostgres(t *testing.T) {
users := NewUserStore(pool)
deleted := createTestUser(t, ctx, users, fmt.Sprintf("15571%d", nonce), "Delete", "Me")
peer := createTestUser(t, ctx, users, fmt.Sprintf("15572%d", nonce), "Keep", "Peer")
var channelID int64
var collectiblePhoneID int64
t.Cleanup(func() {
if channelID != 0 {
_, _ = pool.Exec(ctx, `DELETE FROM channels WHERE id = $1`, channelID)
}
if collectiblePhoneID != 0 {
_, _ = pool.Exec(ctx, `DELETE FROM collectible_phones WHERE id = $1`, collectiblePhoneID)
}
_, _ = pool.Exec(ctx, `DELETE FROM stars_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM ton_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM stars_balances WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
@ -33,6 +41,11 @@ func TestAccountLifecycleScheduleCancelAndTombstonePostgres(t *testing.T) {
_, _ = pool.Exec(ctx, `DELETE FROM private_messages WHERE sender_user_id = ANY($1) OR recipient_user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1)`, []int64{deleted.ID, peer.ID})
})
deletedUsername := fmt.Sprintf("deleteme%d", nonce)
deleted, err := users.UpdateUsername(ctx, deleted.ID, deletedUsername)
if err != nil {
t.Fatalf("set deleted user username: %v", err)
}
authOne := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 1)
authTwo := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 2)
@ -55,6 +68,36 @@ VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err !=
if _, err := pool.Exec(ctx, `INSERT INTO ton_balances (user_id, balance_nanoton) VALUES ($1, 100)`, deleted.ID); err != nil {
t.Fatalf("insert TON balance: %v", err)
}
collectiblePhone := fmt.Sprintf("888%010d", nonce%10_000_000_000)
if err := pool.QueryRow(ctx, `INSERT INTO collectible_phones
(phone, tier, status, owner_user_id, purchase_date, currency, amount, created_at, updated_at)
VALUES ($1, 'standard', 'owned', $2, $3, 'XTR', 100, $3, $3)
RETURNING id`, collectiblePhone, deleted.ID, time.Now().UTC()).Scan(&collectiblePhoneID); err != nil {
t.Fatalf("insert collectible phone: %v", err)
}
createdChannel, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: deleted.ID,
Title: "Retained deletion membership",
Megagroup: true,
Date: int(time.Now().Unix()),
})
if err != nil {
t.Fatalf("create retained channel membership: %v", err)
}
channelID = createdChannel.Channel.ID
var contactVersionBefore, channelParticipantsVersionBefore int64
if err := pool.QueryRow(ctx, `
SELECT COALESCE((SELECT version FROM read_model_versions
WHERE model = 'contact_account' AND owner_user_id = $1
AND peer_type = 'user' AND peer_id = $1), 0)`, peer.ID).Scan(&contactVersionBefore); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `
SELECT COALESCE((SELECT version FROM read_model_versions
WHERE model = 'channel_participants' AND owner_user_id = 0
AND peer_type = 'channel' AND peer_id = $1), 0)`, channelID).Scan(&channelParticipantsVersionBefore); err != nil {
t.Fatal(err)
}
lifecycle := NewAccountLifecycleStore(pool)
now := time.Now().UTC().Truncate(time.Second)
@ -80,6 +123,15 @@ VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err !=
if _, found, err := NewAuthKeyStore(pool).Get(ctx, authTwo); err != nil || !found {
t.Fatalf("other auth key after cancel found=%v err=%v, want retained", found, err)
}
digestTwo := sha256.Sum256([]byte("confirm-two"))
pendingBeforeDelete, created, err := lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{
UserID: deleted.ID, RequesterAuthKeyID: authTwo, Reason: "Delete account",
ConfirmHashDigest: digestTwo, ServiceMessage: "tg://confirmphone?phone=hidden&hash=confirm-two",
RequestedAt: now.Add(time.Minute), ExecuteAt: now.Add(7 * 24 * time.Hour),
})
if err != nil || !created {
t.Fatalf("schedule deletion before tombstone = %+v created=%v err=%v", pendingBeforeDelete, created, err)
}
result, err := lifecycle.ExecuteAccountDeletion(ctx, deleted.ID, domain.AccountDeletionManual, "manual", now.Add(2*time.Minute))
if err != nil {
@ -91,9 +143,48 @@ VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err !=
if _, found, err := users.ByPhone(ctx, deleted.Phone); err != nil || found {
t.Fatalf("released phone found=%v err=%v", found, err)
}
if _, found, err := users.ByUsername(ctx, deletedUsername); err != nil || found {
t.Fatalf("released username found=%v err=%v", found, err)
}
if tombstone, found, err := users.ByID(ctx, deleted.ID); err != nil || !found || !tombstone.Deleted || tombstone.FirstName != "" {
t.Fatalf("tombstone = %+v found=%v err=%v", tombstone, found, err)
}
if _, found, err := NewAuthorizationStore(pool).ByAuthKey(ctx, authTwo); err != nil || found {
t.Fatalf("authorization after tombstone found=%v err=%v, want revoked", found, err)
}
if _, found, err := NewAuthKeyStore(pool).Get(ctx, authTwo); err != nil || !found {
t.Fatalf("permanent protocol auth key after tombstone found=%v err=%v, want retained", found, err)
}
var requestState string
if err := pool.QueryRow(ctx, `SELECT state FROM account_deletion_requests WHERE id = $1`, pendingBeforeDelete.ID).Scan(&requestState); err != nil {
t.Fatal(err)
}
if requestState != "executed" {
t.Fatalf("pending request state after tombstone = %q, want executed", requestState)
}
var deletedVersion, contactVersionAfter, channelParticipantsVersionAfter int64
if err := pool.QueryRow(ctx, `
SELECT version FROM read_model_versions
WHERE model = 'user_deleted' AND owner_user_id = $1
AND peer_type = 'user' AND peer_id = $1`, deleted.ID).Scan(&deletedVersion); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `
SELECT COALESCE((SELECT version FROM read_model_versions
WHERE model = 'contact_account' AND owner_user_id = $1
AND peer_type = 'user' AND peer_id = $1), 0)`, peer.ID).Scan(&contactVersionAfter); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `
SELECT COALESCE((SELECT version FROM read_model_versions
WHERE model = 'channel_participants' AND owner_user_id = 0
AND peer_type = 'channel' AND peer_id = $1), 0)`, channelID).Scan(&channelParticipantsVersionAfter); err != nil {
t.Fatal(err)
}
if deletedVersion < 1 || contactVersionAfter != contactVersionBefore || channelParticipantsVersionAfter != channelParticipantsVersionBefore {
t.Fatalf("logical-delete read-model fanout deleted=%d contact=%d->%d channel=%d->%d",
deletedVersion, contactVersionBefore, contactVersionAfter, channelParticipantsVersionBefore, channelParticipantsVersionAfter)
}
if _, err := users.UpdateProfile(ctx, deleted.ID, "Resurrected", "", ""); err == nil {
t.Fatal("deleted account profile mutation unexpectedly succeeded")
}
@ -105,7 +196,10 @@ VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err !=
if err != nil || len(history.Messages) != 1 || history.Messages[0].Body != "keep shared history" || history.Messages[0].From.ID != deleted.ID {
t.Fatalf("peer history after deletion = %+v err=%v", history, err)
}
var peerBoxes, settings, contacts, notifications int
var ownerBoxes, peerBoxes, settings, contacts, notifications int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE owner_user_id = $1`, deleted.ID).Scan(&ownerBoxes); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE owner_user_id = $1 AND from_user_id = $2`, peer.ID, deleted.ID).Scan(&peerBoxes); err != nil {
t.Fatal(err)
}
@ -118,8 +212,13 @@ VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err !=
if err := pool.QueryRow(ctx, `SELECT count(*) FROM account_deletion_notifications WHERE target_user_id = $1 AND deleted_user_id = $2`, peer.ID, deleted.ID).Scan(&notifications); err != nil {
t.Fatal(err)
}
if peerBoxes != 1 || settings != 0 || contacts != 0 || notifications != 1 {
t.Fatalf("post-delete state peerBoxes=%d settings=%d contacts=%d notifications=%d", peerBoxes, settings, contacts, notifications)
var memberStatus string
if err := pool.QueryRow(ctx, `SELECT status FROM channel_members WHERE channel_id = $1 AND user_id = $2`, channelID, deleted.ID).Scan(&memberStatus); err != nil {
t.Fatal(err)
}
if ownerBoxes == 0 || peerBoxes != 1 || settings != 1 || contacts != 1 || notifications != 0 || memberStatus != "active" {
t.Fatalf("logical-delete retained state ownerBoxes=%d peerBoxes=%d settings=%d contacts=%d notifications=%d memberStatus=%q",
ownerBoxes, peerBoxes, settings, contacts, notifications, memberStatus)
}
var stars, ton, starClear, tonClear int64
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id = $1`, deleted.ID).Scan(&stars); err != nil {
@ -134,8 +233,16 @@ VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err !=
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount_nanoton), 0) FROM ton_transactions WHERE user_id = $1 AND reason = 'account_deleted'`, deleted.ID).Scan(&tonClear); err != nil {
t.Fatal(err)
}
if stars != 0 || ton != 0 || starClear != -50 || tonClear != -100 {
t.Fatalf("financial clearing stars=%d ton=%d star_tx=%d ton_tx=%d", stars, ton, starClear, tonClear)
if stars != 50 || ton != 100 || starClear != 0 || tonClear != 0 {
t.Fatalf("logical-delete retained finances stars=%d ton=%d star_tx=%d ton_tx=%d", stars, ton, starClear, tonClear)
}
var collectibleStatus string
var collectibleOwner int64
if err := pool.QueryRow(ctx, `SELECT status, owner_user_id FROM collectible_phones WHERE id = $1`, collectiblePhoneID).Scan(&collectibleStatus, &collectibleOwner); err != nil {
t.Fatal(err)
}
if collectibleStatus != "owned" || collectibleOwner != deleted.ID {
t.Fatalf("logical-delete collectible phone status=%q owner=%d, want owned by tombstone %d", collectibleStatus, collectibleOwner, deleted.ID)
}
}

View file

@ -36,6 +36,10 @@ func TestAccountSettingsRoundTripPostgres(t *testing.T) {
HideReadMarks: true,
DisplayGiftsButton: true,
NoncontactPeersPaidStars: 75,
DisallowedGifts: domain.DisallowedGifts{
UnlimitedStargifts: true,
PremiumGifts: true,
},
},
AccountTTLDays: 30,
SensitiveContentEnabled: true,

View file

@ -0,0 +1,379 @@
package postgres
import (
"context"
"errors"
"fmt"
"sync"
"time"
"telesrv/internal/domain"
)
// ActiveChannelIDsBatchMetrics exposes bounded aggregate cold-loader signals;
// owner identities never become metric labels.
type ActiveChannelIDsBatchMetrics interface {
ActiveChannelIDsBatch(selectors int, rows int, d time.Duration, err error)
ActiveChannelIDsPending(delta int)
}
type ActiveChannelIDsBatchConfig struct {
MaxSize int
MaxWait time.Duration
QueueSize int
QueryTimeout time.Duration
Metrics ActiveChannelIDsBatchMetrics
}
type activeChannelIDsSelector struct {
userID int64
afterChannelID int64
limit int
}
type activeChannelIDsBatchRequest struct {
selector activeChannelIDsSelector
result chan activeChannelIDsBatchResult
}
type activeChannelIDsBatchResult struct {
channelIDs []int64
err error
}
type activeChannelIDsBatchBackend interface {
listActiveChannelIDPages(context.Context, []activeChannelIDsSelector) ([][]int64, error)
}
// ActiveChannelIDsPageBatcher combines independent readiness cache misses into
// one PostgreSQL call. It is a synchronous bounded read source: failures are
// returned to every selector and never fall back to one query per account.
type ActiveChannelIDsPageBatcher struct {
base activeChannelIDsBatchBackend
cfg ActiveChannelIDsBatchConfig
queue chan activeChannelIDsBatchRequest
stop chan struct{}
done chan struct{}
cancel context.CancelFunc
once sync.Once
gate sync.RWMutex
closed bool
}
func NewActiveChannelIDsPageBatcher(
base *ChannelStore,
cfg ActiveChannelIDsBatchConfig,
) (*ActiveChannelIDsPageBatcher, error) {
if base == nil || base.db == nil {
return nil, errors.New("initialize active channel IDs batcher: nil store")
}
return newActiveChannelIDsPageBatcher(base, cfg)
}
func newActiveChannelIDsPageBatcher(
base activeChannelIDsBatchBackend,
cfg ActiveChannelIDsBatchConfig,
) (*ActiveChannelIDsPageBatcher, error) {
if base == nil {
return nil, errors.New("initialize active channel IDs batcher: nil backend")
}
if cfg.MaxSize <= 0 || cfg.MaxSize > 4096 {
return nil, fmt.Errorf("initialize active channel IDs batcher: max size %d outside [1,4096]", cfg.MaxSize)
}
if cfg.MaxWait <= 0 || cfg.MaxWait > time.Second {
return nil, fmt.Errorf("initialize active channel IDs batcher: max wait %v outside (0,1s]", cfg.MaxWait)
}
if cfg.QueueSize < cfg.MaxSize || cfg.QueueSize > 1<<20 {
return nil, fmt.Errorf("initialize active channel IDs batcher: queue size %d outside [%d,%d]", cfg.QueueSize, cfg.MaxSize, 1<<20)
}
if cfg.QueryTimeout <= 0 || cfg.QueryTimeout > 30*time.Second {
return nil, fmt.Errorf("initialize active channel IDs batcher: query timeout %v outside (0,30s]", cfg.QueryTimeout)
}
workerCtx, cancel := context.WithCancel(context.Background())
b := &ActiveChannelIDsPageBatcher{
base: base, cfg: cfg,
queue: make(chan activeChannelIDsBatchRequest, cfg.QueueSize),
stop: make(chan struct{}), done: make(chan struct{}), cancel: cancel,
}
go b.run(workerCtx)
return b, nil
}
func (b *ActiveChannelIDsPageBatcher) ListActiveChannelIDsForUser(
ctx context.Context,
userID, afterChannelID int64,
limit int,
) ([]int64, error) {
if userID == 0 || afterChannelID < 0 {
return nil, domain.ErrChannelInvalid
}
if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout {
limit = domain.MaxSynchronousChannelDialogFanout
}
if ctx == nil {
ctx = context.Background()
}
request := activeChannelIDsBatchRequest{
selector: activeChannelIDsSelector{userID: userID, afterChannelID: afterChannelID, limit: limit},
result: make(chan activeChannelIDsBatchResult, 1),
}
b.gate.RLock()
if b.closed {
b.gate.RUnlock()
return nil, context.Canceled
}
select {
case b.queue <- request:
if b.cfg.Metrics != nil {
b.cfg.Metrics.ActiveChannelIDsPending(1)
}
case <-ctx.Done():
b.gate.RUnlock()
return nil, ctx.Err()
}
b.gate.RUnlock()
select {
case result := <-request.result:
return result.channelIDs, result.err
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (b *ActiveChannelIDsPageBatcher) Close() {
if b == nil {
return
}
b.once.Do(func() {
b.gate.Lock()
b.closed = true
close(b.stop)
b.cancel()
b.gate.Unlock()
<-b.done
})
}
func (b *ActiveChannelIDsPageBatcher) run(ctx context.Context) {
defer close(b.done)
pending := make([]activeChannelIDsBatchRequest, 0, b.cfg.MaxSize)
for {
if len(pending) == 0 {
select {
case request := <-b.queue:
pending = append(pending, request)
case <-b.stop:
b.failQueued(context.Canceled, pending)
return
}
}
if len(pending) < b.cfg.MaxSize {
timer := time.NewTimer(b.cfg.MaxWait)
collect:
for len(pending) < b.cfg.MaxSize {
select {
case request := <-b.queue:
pending = append(pending, request)
case <-timer.C:
break collect
case <-b.stop:
stopAndDrainTimer(timer)
b.failQueued(context.Canceled, pending)
return
}
}
stopAndDrainTimer(timer)
}
batch, remaining := selectDistinctActiveChannelIDsBatch(pending, b.cfg.MaxSize)
pending = remaining
b.execute(ctx, batch)
}
}
func selectDistinctActiveChannelIDsBatch(
pending []activeChannelIDsBatchRequest,
maxSize int,
) ([]activeChannelIDsBatchRequest, []activeChannelIDsBatchRequest) {
batch := make([]activeChannelIDsBatchRequest, 0, min(maxSize, len(pending)))
remaining := make([]activeChannelIDsBatchRequest, 0, len(pending))
seen := make(map[activeChannelIDsSelector]struct{}, min(maxSize, len(pending)))
for _, request := range pending {
if len(batch) >= maxSize {
remaining = append(remaining, request)
continue
}
if _, duplicate := seen[request.selector]; duplicate {
remaining = append(remaining, request)
continue
}
seen[request.selector] = struct{}{}
batch = append(batch, request)
}
return batch, remaining
}
func (b *ActiveChannelIDsPageBatcher) execute(ctx context.Context, batch []activeChannelIDsBatchRequest) {
if len(batch) == 0 {
return
}
selectors := make([]activeChannelIDsSelector, len(batch))
for index, request := range batch {
selectors[index] = request.selector
}
started := time.Now()
queryCtx, cancel := context.WithTimeout(ctx, b.cfg.QueryTimeout)
pages, err := b.base.listActiveChannelIDPages(queryCtx, selectors)
cancel()
rows := 0
if err == nil {
if len(pages) != len(batch) {
err = fmt.Errorf("list active channel IDs batch: result count %d, want %d", len(pages), len(batch))
} else {
for _, page := range pages {
rows += len(page)
}
}
}
if b.cfg.Metrics != nil {
b.cfg.Metrics.ActiveChannelIDsBatch(len(batch), rows, time.Since(started), err)
}
for index, request := range batch {
result := activeChannelIDsBatchResult{err: err}
if err == nil {
result.channelIDs = pages[index]
}
request.result <- result
if b.cfg.Metrics != nil {
b.cfg.Metrics.ActiveChannelIDsPending(-1)
}
}
}
func (b *ActiveChannelIDsPageBatcher) failQueued(err error, pending []activeChannelIDsBatchRequest) {
for _, request := range pending {
b.failRequest(request, err)
}
for {
select {
case request := <-b.queue:
b.failRequest(request, err)
default:
return
}
}
}
func (b *ActiveChannelIDsPageBatcher) failRequest(request activeChannelIDsBatchRequest, err error) {
request.result <- activeChannelIDsBatchResult{err: err}
if b.cfg.Metrics != nil {
b.cfg.Metrics.ActiveChannelIDsPending(-1)
}
}
func (s *ChannelStore) listActiveChannelIDPages(
ctx context.Context,
selectors []activeChannelIDsSelector,
) ([][]int64, error) {
pages := make([][]int64, len(selectors))
if len(selectors) == 0 {
return pages, nil
}
userIDs := make([]int64, len(selectors))
afterChannelIDs := make([]int64, len(selectors))
limits := make([]int32, len(selectors))
seen := make(map[activeChannelIDsSelector]struct{}, len(selectors))
for index, selector := range selectors {
if selector.userID == 0 || selector.afterChannelID < 0 || selector.limit <= 0 ||
selector.limit > domain.MaxSynchronousChannelDialogFanout {
return nil, fmt.Errorf("list active channel IDs batch: invalid selector at index %d", index)
}
if _, duplicate := seen[selector]; duplicate {
return nil, fmt.Errorf("list active channel IDs batch: duplicate selector at index %d", index)
}
seen[selector] = struct{}{}
userIDs[index] = selector.userID
afterChannelIDs[index] = selector.afterChannelID
limits[index] = int32(selector.limit)
}
rows, err := s.db.Query(ctx, `
WITH input AS (
SELECT *
FROM unnest($1::bigint[], $2::bigint[], $3::integer[])
WITH ORDINALITY AS value(user_id, after_channel_id, page_limit, ordinal)
)
SELECT input.ordinal, visible.channel_id
FROM input
JOIN LATERAL (
SELECT channel_id
FROM (
SELECT membership.channel_id
FROM user_channel_member_index AS membership
WHERE membership.user_id = input.user_id
AND membership.status = 'active'
AND NOT membership.deleted
UNION
SELECT mono.id
FROM channels AS mono
JOIN channels AS parent
ON parent.id = mono.linked_monoforum_id
AND NOT parent.deleted
AND parent.broadcast_messages_allowed
AND parent.linked_monoforum_id = mono.id
WHERE mono.monoforum
AND NOT mono.deleted
AND (
EXISTS (
SELECT 1
FROM channel_members AS admin
WHERE admin.channel_id = parent.id
AND admin.user_id = input.user_id
AND admin.status = 'active'
AND (
admin.role = 'creator'
OR (
admin.role = 'admin'
AND COALESCE((admin.admin_rights->>'ManageDirectMessages')::boolean, false)
)
)
)
OR EXISTS (
SELECT 1
FROM channel_messages AS message
WHERE message.channel_id = mono.id
AND message.saved_peer_type = 'user'
AND message.saved_peer_id = input.user_id
AND NOT message.deleted
)
)
) AS visible_channels
WHERE channel_id > input.after_channel_id
ORDER BY channel_id
LIMIT input.page_limit
) AS visible ON true
ORDER BY input.ordinal, visible.channel_id`, userIDs, afterChannelIDs, limits)
if err != nil {
return nil, fmt.Errorf("list active channel IDs batch: %w", err)
}
defer rows.Close()
for rows.Next() {
var ordinal int64
var channelID int64
if err := rows.Scan(&ordinal, &channelID); err != nil {
return nil, err
}
if ordinal <= 0 || ordinal > int64(len(pages)) {
return nil, fmt.Errorf("list active channel IDs batch: invalid ordinal %d", ordinal)
}
page := pages[ordinal-1]
selector := selectors[ordinal-1]
if channelID <= selector.afterChannelID || (len(page) > 0 && channelID <= page[len(page)-1]) || len(page) >= selector.limit {
return nil, fmt.Errorf("list active channel IDs batch: invalid page row for ordinal %d", ordinal)
}
pages[ordinal-1] = append(page, channelID)
}
if err := rows.Err(); err != nil {
return nil, err
}
return pages, nil
}

View file

@ -0,0 +1,60 @@
package postgres
import (
"context"
"slices"
"testing"
"telesrv/internal/domain"
)
func TestChannelStoreListActiveChannelIDPagesPreservesSelectorOrdinality(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 901, Phone: "+1887" + suffix + "01", FirstName: "BatchOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
member, err := users.Create(ctx, domain.User{AccessHash: 902, Phone: "+1887" + suffix + "02", FirstName: "BatchMember"})
if err != nil {
t.Fatalf("create member: %v", err)
}
channels := NewChannelStore(pool)
first, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "Batch First " + suffix, Megagroup: true,
MemberUserIDs: []int64{member.ID}, Date: 1700007010,
})
if err != nil {
t.Fatalf("create first channel: %v", err)
}
second, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "Batch Second " + suffix, Megagroup: true, Date: 1700007011,
})
if err != nil {
t.Fatalf("create second channel: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", []int64{first.Channel.ID, second.Channel.ID})
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID})
})
selectors := []activeChannelIDsSelector{
{userID: owner.ID, afterChannelID: 0, limit: 1},
{userID: member.ID, afterChannelID: 0, limit: 1000},
{userID: owner.ID, afterChannelID: first.Channel.ID, limit: 1000},
}
pages, err := channels.listActiveChannelIDPages(ctx, selectors)
if err != nil {
t.Fatalf("list batch: %v", err)
}
for index, selector := range selectors {
want, err := channels.ListActiveChannelIDsForUser(ctx, selector.userID, selector.afterChannelID, selector.limit)
if err != nil {
t.Fatalf("list direct selector %d: %v", index, err)
}
if !slices.Equal(pages[index], want) {
t.Fatalf("page %d = %v, want %v", index, pages[index], want)
}
}
}

View file

@ -0,0 +1,148 @@
package postgres
import (
"context"
"errors"
"slices"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestSelectDistinctActiveChannelIDsBatchDefersDuplicate(t *testing.T) {
selector := activeChannelIDsSelector{userID: 1, limit: 1000}
first := activeChannelIDsBatchRequest{selector: selector}
duplicate := activeChannelIDsBatchRequest{selector: selector}
other := activeChannelIDsBatchRequest{selector: activeChannelIDsSelector{userID: 2, limit: 1000}}
batch, remaining := selectDistinctActiveChannelIDsBatch([]activeChannelIDsBatchRequest{first, duplicate, other}, 3)
if len(batch) != 2 || batch[0].selector.userID != 1 || batch[1].selector.userID != 2 {
t.Fatalf("batch = %#v", batch)
}
if len(remaining) != 1 || remaining[0].selector != selector {
t.Fatalf("remaining = %#v", remaining)
}
}
func TestActiveChannelIDsPageBatcherCoalescesSelectors(t *testing.T) {
const count = 32
backend := &fakeActiveChannelIDsBatchBackend{}
batcher, err := newActiveChannelIDsPageBatcher(backend, ActiveChannelIDsBatchConfig{
MaxSize: count, MaxWait: 100 * time.Millisecond, QueueSize: count * 2, QueryTimeout: time.Second,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(batcher.Close)
start := make(chan struct{})
errs := make(chan error, count)
var wg sync.WaitGroup
for index := range count {
index := index
wg.Add(1)
go func() {
defer wg.Done()
<-start
got, err := batcher.ListActiveChannelIDsForUser(context.Background(), int64(index+1), 0, 1000)
if err != nil {
errs <- err
} else if !slices.Equal(got, []int64{int64(index + 1)}) {
errs <- errors.New("unexpected active channel IDs page")
}
}()
}
close(start)
wg.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
if backend.calls.Load() != 1 || backend.inputs.Load() != count {
t.Fatalf("backend calls=%d inputs=%d", backend.calls.Load(), backend.inputs.Load())
}
}
func TestActiveChannelIDsPageBatcherCapacityAndShutdownAreExplicit(t *testing.T) {
started := make(chan struct{})
backend := &fakeActiveChannelIDsBatchBackend{started: started, block: true}
metrics := &fakeActiveChannelIDsBatchMetrics{}
batcher, err := newActiveChannelIDsPageBatcher(backend, ActiveChannelIDsBatchConfig{
MaxSize: 1, MaxWait: time.Millisecond, QueueSize: 1, QueryTimeout: time.Second, Metrics: metrics,
})
if err != nil {
t.Fatal(err)
}
results := make(chan error, 2)
go func() {
_, err := batcher.ListActiveChannelIDsForUser(context.Background(), 1, 0, 1000)
results <- err
}()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("first batch did not start")
}
go func() {
_, err := batcher.ListActiveChannelIDsForUser(context.Background(), 2, 0, 1000)
results <- err
}()
deadline := time.Now().Add(time.Second)
for metrics.pending.Load() != 2 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if _, err := batcher.ListActiveChannelIDsForUser(ctx, 3, 0, 1000); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("capacity wait err = %v", err)
}
batcher.Close()
for range 2 {
if err := <-results; !errors.Is(err, context.Canceled) {
t.Fatalf("shutdown result = %v", err)
}
}
if metrics.pending.Load() != 0 {
t.Fatalf("pending = %d", metrics.pending.Load())
}
if _, err := batcher.ListActiveChannelIDsForUser(context.Background(), 4, 0, 1000); !errors.Is(err, context.Canceled) {
t.Fatalf("post-close err = %v", err)
}
}
type fakeActiveChannelIDsBatchBackend struct {
calls atomic.Int64
inputs atomic.Int64
started chan struct{}
block bool
once sync.Once
}
func (f *fakeActiveChannelIDsBatchBackend) listActiveChannelIDPages(
ctx context.Context,
selectors []activeChannelIDsSelector,
) ([][]int64, error) {
f.calls.Add(1)
f.inputs.Add(int64(len(selectors)))
if f.started != nil {
f.once.Do(func() { close(f.started) })
}
if f.block {
<-ctx.Done()
return nil, ctx.Err()
}
pages := make([][]int64, len(selectors))
for index, selector := range selectors {
pages[index] = []int64{selector.userID}
}
return pages, nil
}
type fakeActiveChannelIDsBatchMetrics struct {
pending atomic.Int64
}
func (*fakeActiveChannelIDsBatchMetrics) ActiveChannelIDsBatch(int, int, time.Duration, error) {}
func (m *fakeActiveChannelIDsBatchMetrics) ActiveChannelIDsPending(delta int) {
m.pending.Add(int64(delta))
}

View file

@ -39,22 +39,15 @@ func TestAuthIdentitySelectorRetriesUncommittedFirstBindSnapshotPostgres(t *test
if err := advanceConn.QueryRow(ctx, `SELECT pg_backend_pid()`).Scan(&advancePID); err != nil {
t.Fatal(err)
}
barrier := newAuthStoreQueryBarrier(advanceConn, "auth_identity_hint", "")
msgID := authKeySessionLayerTestMsgID(time.Now().UTC(), 1)
type advanceResult struct {
value store.AuthKeySessionLayer
applied bool
err error
}
result := make(chan advanceResult, 1)
go func() {
value, applied, err := NewAuthKeyStore(barrier).AdvanceSessionLayer(ctx, temp, 8703, 227, msgID)
result <- advanceResult{value: value, applied: applied, err: err}
}()
<-barrier.observed
// The selector already read "unbound". Stage a committed binding behind
// its statement snapshot while retaining P/raw row locks in the outer tx.
// Stage the first binding but do not commit it. Save holds the permanent
// identity gate and raw row, so the selector sees the old unbound hint and
// then waits on the raw row inside the server-side advance function.
bindTx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
@ -63,7 +56,11 @@ func TestAuthIdentitySelectorRetriesUncommittedFirstBindSnapshotPostgres(t *test
if err := NewTempAuthKeyBindingStore(bindTx).Save(ctx, binding); err != nil {
t.Fatalf("stage first bind: %v", err)
}
close(barrier.release)
result := make(chan advanceResult, 1)
go func() {
value, applied, err := NewAuthKeyStore(advanceConn).AdvanceSessionLayer(ctx, temp, 8703, 227, msgID)
result <- advanceResult{value: value, applied: applied, err: err}
}()
waitForPostgresBackendLockWait(t, ctx, pool, advancePID)
if err := bindTx.Commit(ctx); err != nil {
t.Fatalf("commit first bind: %v", err)
@ -166,11 +163,22 @@ func TestAuthIdentitySelectorSerializesWithPermanentRevocationAndDeletePostgres(
if err := <-opResult; err != nil {
t.Fatalf("%s error = %v", op, err)
}
assertRevokeTestNoAuthorization(t, ctx, auths, perm)
if op == "revoke" {
// Remote authorization revocation deliberately preserves protocol
// keys and their binding so reconnect reaches the RPC authorization
// gate and receives AUTH_KEY_UNREGISTERED rather than transport -404.
assertRevokeTestPresentAuthKey(t, ctx, keys, temp)
assertRevokeTestPresentAuthKey(t, ctx, keys, perm)
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || !found {
t.Fatalf("binding after revoke found=%v err=%v, want present", found, err)
}
return
}
assertTempIdentityAuthKeyMissing(t, ctx, keys, temp)
assertTempIdentityAuthKeyMissing(t, ctx, keys, perm)
assertRevokeTestNoAuthorization(t, ctx, auths, perm)
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || found {
t.Fatalf("binding after %s found=%v err=%v", op, found, err)
t.Fatalf("binding after delete found=%v err=%v, want absent", found, err)
}
})
}

View file

@ -446,7 +446,7 @@ INSERT INTO public.secret_chats (
) VALUES (
860086, 86, 87,
$1, $2, $3, $4,
'waiting', 86, 1
'waiting', 860086, 1
)`, adminUserID, adminAuthKeyID, participantUserID, participantAuthKeyID); err != nil {
t.Fatalf("insert temporary-key secret chat fixture: %v", err)
}

View file

@ -54,11 +54,104 @@ WHERE auth_keys.body = EXCLUDED.body
// 完成GC 的 cutoff/final predicate 会看到新水位并跳过。这样连接不会在“读到旧 key、尚未
// 注册进 SessionManager”的窗口被后台清理。
func (s *AuthKeyStore) Get(ctx context.Context, id [8]byte) (store.AuthKeyData, bool, error) {
data, err := scanAuthKeyData(s.db.QueryRow(ctx, `
UPDATE auth_keys
SET last_used_at = now()
WHERE auth_key_id = $1
RETURNING auth_key_id, body, server_salt, created_at,
expires_at, layer, layer_observation_id,
device_model, platform, system_version, api_id, app_version
`, authKeyIDToInt64(id)))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return store.AuthKeyData{}, false, nil
}
return store.AuthKeyData{}, false, fmt.Errorf("get auth key: %w", err)
}
if data.ID != id {
return store.AuthKeyData{}, false, fmt.Errorf("get auth key returned id %x, want %x", data.ID, id)
}
return data, true, nil
}
// Revalidate reads the immutable key/protocol tuple after an activation claim
// is visible. It deliberately does not touch last_used_at: the physical
// connection's initial Get already established the orphan lease and the claim
// is now the local delete/revoke serialization boundary.
func (s *AuthKeyStore) Revalidate(ctx context.Context, id [8]byte) (store.AuthKeyData, bool, error) {
data, err := scanAuthKeyData(s.db.QueryRow(ctx, `
SELECT auth_key_id, body, server_salt, created_at,
expires_at, layer, layer_observation_id,
device_model, platform, system_version, api_id, app_version
FROM auth_keys
WHERE auth_key_id = $1
`, authKeyIDToInt64(id)))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return store.AuthKeyData{}, false, nil
}
return store.AuthKeyData{}, false, fmt.Errorf("revalidate auth key: %w", err)
}
if data.ID != id {
return store.AuthKeyData{}, false, fmt.Errorf("revalidate auth key returned id %x, want %x", data.ID, id)
}
return data, true, nil
}
// LoadBindingKeys touches and returns both cryptographic proof keys in one
// statement. Missing rows remain explicit in the result so the application can
// preserve its temp-rotation versus invalid-encrypted-proof error split.
func (s *AuthKeyStore) LoadBindingKeys(ctx context.Context, tempID, permID [8]byte) (store.AuthKeyBindingKeys, error) {
rows, err := s.db.Query(ctx, `
UPDATE auth_keys
SET last_used_at = now()
WHERE auth_key_id = ANY($1::bigint[])
RETURNING auth_key_id, body, server_salt, created_at,
expires_at, layer, layer_observation_id,
device_model, platform, system_version, api_id, app_version
`, []int64{authKeyIDToInt64(tempID), authKeyIDToInt64(permID)})
if err != nil {
return store.AuthKeyBindingKeys{}, fmt.Errorf("load auth key binding pair: %w", err)
}
defer rows.Close()
var result store.AuthKeyBindingKeys
for rows.Next() {
data, scanErr := scanAuthKeyData(rows)
if scanErr != nil {
return store.AuthKeyBindingKeys{}, fmt.Errorf("scan auth key binding pair: %w", scanErr)
}
switch data.ID {
case tempID:
result.Temporary = data
result.TemporaryFound = true
case permID:
result.Permanent = data
result.PermanentFound = true
default:
return store.AuthKeyBindingKeys{}, fmt.Errorf("load auth key binding pair returned unexpected id %x", data.ID)
}
}
if err := rows.Err(); err != nil {
return store.AuthKeyBindingKeys{}, fmt.Errorf("iterate auth key binding pair: %w", err)
}
if tempID == permID && result.TemporaryFound {
result.Permanent = result.Temporary
result.PermanentFound = true
}
return result, nil
}
type authKeyDataScanner interface {
Scan(dest ...any) error
}
func scanAuthKeyData(row authKeyDataScanner) (store.AuthKeyData, error) {
var (
storedID int64
body []byte
serverSalt int64
expiresAt int
createdAt pgtype.Timestamptz
expiresAt int
layer int
layerObservationID int64
deviceModel string
@ -67,25 +160,18 @@ func (s *AuthKeyStore) Get(ctx context.Context, id [8]byte) (store.AuthKeyData,
apiID int
appVersion string
)
err := s.db.QueryRow(ctx, `
UPDATE auth_keys
SET last_used_at = now()
WHERE auth_key_id = $1
RETURNING auth_key_id, body, server_salt, created_at,
expires_at, layer, layer_observation_id,
device_model, platform, system_version, api_id, app_version
`, authKeyIDToInt64(id)).Scan(new(int64), &body, &serverSalt, &createdAt, &expiresAt, &layer, &layerObservationID, &deviceModel, &platform, &systemVersion, &apiID, &appVersion)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return store.AuthKeyData{}, false, nil
}
return store.AuthKeyData{}, false, fmt.Errorf("get auth key: %w", err)
if err := row.Scan(
&storedID, &body, &serverSalt, &createdAt,
&expiresAt, &layer, &layerObservationID,
&deviceModel, &platform, &systemVersion, &apiID, &appVersion,
); err != nil {
return store.AuthKeyData{}, err
}
if len(body) != len(store.AuthKeyData{}.Value) {
return store.AuthKeyData{}, false, fmt.Errorf("auth key body length = %d, want 256", len(body))
return store.AuthKeyData{}, fmt.Errorf("auth key body length = %d, want 256", len(body))
}
data := store.AuthKeyData{
ID: id,
ID: authKeyIDFromInt64(storedID),
ServerSalt: serverSalt,
ExpiresAt: expiresAt,
Layer: layer,
@ -100,7 +186,7 @@ RETURNING auth_key_id, body, server_salt, created_at,
if createdAt.Valid {
data.CreatedAt = createdAt.Time.Unix()
}
return data, true, nil
return data, nil
}
const activeAuthKeyHeartbeatBatch = 4096

View file

@ -0,0 +1,344 @@
package postgres
import (
"context"
"errors"
"fmt"
"sync"
"time"
"telesrv/internal/store"
)
// AuthKeyGetBatchConfig bounds the synchronous first-frame auth-key lookup.
// Every accepted Get waits until its durable last_used_at touch has completed;
// this is not an asynchronous activity update.
type AuthKeyGetBatchConfig struct {
MaxSize int
MaxWait time.Duration
QueueSize int
QueryTimeout time.Duration
}
type authKeyGetBatchRequest struct {
ctx context.Context
id [8]byte
result chan authKeyGetBatchResult
}
type authKeyGetBatchResult struct {
data store.AuthKeyData
found bool
err error
}
// BatchedAuthKeyStore preserves store.AuthKeyStore semantics while combining
// contemporaneous first-frame Get calls into one PostgreSQL UPDATE ...
// RETURNING statement. Save/revalidate/bind/client-info/delete remain direct
// authority operations on the base store.
type BatchedAuthKeyStore struct {
base *AuthKeyStore
cfg AuthKeyGetBatchConfig
touchQueue chan authKeyGetBatchRequest
revalidateQueue chan authKeyGetBatchRequest
stop chan struct{}
cancel context.CancelFunc
once sync.Once
workers sync.WaitGroup
gate sync.RWMutex
closed bool
}
func NewBatchedAuthKeyStore(base *AuthKeyStore, cfg AuthKeyGetBatchConfig) (*BatchedAuthKeyStore, error) {
if base == nil || base.db == nil {
return nil, errors.New("initialize auth-key get batcher: nil store")
}
if cfg.MaxSize <= 0 || cfg.MaxSize > 4096 {
return nil, fmt.Errorf("initialize auth-key get batcher: max size %d outside [1,4096]", cfg.MaxSize)
}
if cfg.MaxWait <= 0 || cfg.MaxWait > 10*time.Millisecond {
return nil, fmt.Errorf("initialize auth-key get batcher: max wait %v outside (0,10ms]", cfg.MaxWait)
}
if cfg.QueueSize < cfg.MaxSize || cfg.QueueSize > 1<<20 {
return nil, fmt.Errorf("initialize auth-key get batcher: queue size %d outside [%d,%d]", cfg.QueueSize, cfg.MaxSize, 1<<20)
}
if cfg.QueryTimeout <= 0 || cfg.QueryTimeout > 30*time.Second {
return nil, fmt.Errorf("initialize auth-key get batcher: query timeout %v outside (0,30s]", cfg.QueryTimeout)
}
workerCtx, cancel := context.WithCancel(context.Background())
s := &BatchedAuthKeyStore{
base: base, cfg: cfg,
touchQueue: make(chan authKeyGetBatchRequest, cfg.QueueSize),
revalidateQueue: make(chan authKeyGetBatchRequest, cfg.QueueSize),
stop: make(chan struct{}), cancel: cancel,
}
s.workers.Add(2)
go s.run(workerCtx, s.touchQueue, true)
go s.run(workerCtx, s.revalidateQueue, false)
return s, nil
}
func (s *BatchedAuthKeyStore) Save(ctx context.Context, key store.AuthKeyData) error {
return s.base.Save(ctx, key)
}
func (s *BatchedAuthKeyStore) Get(ctx context.Context, id [8]byte) (store.AuthKeyData, bool, error) {
return s.lookup(ctx, id, s.touchQueue, true)
}
func (s *BatchedAuthKeyStore) lookup(
ctx context.Context,
id [8]byte,
queue chan authKeyGetBatchRequest,
waitDefinitive bool,
) (store.AuthKeyData, bool, error) {
if ctx == nil {
ctx = context.Background()
}
request := authKeyGetBatchRequest{ctx: ctx, id: id, result: make(chan authKeyGetBatchResult, 1)}
s.gate.RLock()
if s.closed {
s.gate.RUnlock()
return store.AuthKeyData{}, false, context.Canceled
}
select {
case queue <- request:
case <-ctx.Done():
s.gate.RUnlock()
return store.AuthKeyData{}, false, ctx.Err()
}
s.gate.RUnlock()
if waitDefinitive {
// Get owns a durable activity touch. Once admitted, wait for its
// definitive result even if the transport context is canceled, so a
// submitted write is never left as unobserved best effort.
result := <-request.result
return result.data, result.found, result.err
}
select {
case result := <-request.result:
return result.data, result.found, result.err
case <-ctx.Done():
return store.AuthKeyData{}, false, ctx.Err()
}
}
func (s *BatchedAuthKeyStore) Revalidate(ctx context.Context, id [8]byte) (store.AuthKeyData, bool, error) {
return s.lookup(ctx, id, s.revalidateQueue, false)
}
func (s *BatchedAuthKeyStore) LoadBindingKeys(ctx context.Context, tempID, permID [8]byte) (store.AuthKeyBindingKeys, error) {
return s.base.LoadBindingKeys(ctx, tempID, permID)
}
func (s *BatchedAuthKeyStore) UpdateClientInfo(ctx context.Context, id [8]byte, info store.AuthKeyClientInfo) error {
return s.base.UpdateClientInfo(ctx, id, info)
}
func (s *BatchedAuthKeyStore) Delete(ctx context.Context, id [8]byte) error {
return s.base.Delete(ctx, id)
}
func (s *BatchedAuthKeyStore) Close() {
if s == nil {
return
}
s.once.Do(func() {
s.gate.Lock()
s.closed = true
close(s.stop)
s.cancel()
s.gate.Unlock()
s.workers.Wait()
})
}
func (s *BatchedAuthKeyStore) run(
ctx context.Context,
queue chan authKeyGetBatchRequest,
touch bool,
) {
defer s.workers.Done()
pending := make([]authKeyGetBatchRequest, 0, s.cfg.MaxSize)
for {
if len(pending) == 0 {
select {
case request := <-queue:
pending = append(pending, request)
case <-s.stop:
failAuthKeyGetQueued(queue, context.Canceled, nil)
return
}
}
if len(pending) < s.cfg.MaxSize {
timer := time.NewTimer(s.cfg.MaxWait)
collect:
for len(pending) < s.cfg.MaxSize {
select {
case request := <-queue:
pending = append(pending, request)
case <-timer.C:
break collect
case <-s.stop:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
failAuthKeyGetQueued(queue, context.Canceled, pending)
return
}
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
batch := append([]authKeyGetBatchRequest(nil), pending...)
pending = pending[:0]
s.execute(ctx, batch, touch)
}
}
func (s *BatchedAuthKeyStore) execute(ctx context.Context, batch []authKeyGetBatchRequest, touch bool) {
active := batch[:0]
ids := make([][8]byte, 0, len(batch))
seen := make(map[[8]byte]struct{}, len(batch))
for _, request := range batch {
if err := request.ctx.Err(); err != nil {
request.result <- authKeyGetBatchResult{err: err}
continue
}
active = append(active, request)
if _, duplicate := seen[request.id]; duplicate {
continue
}
seen[request.id] = struct{}{}
ids = append(ids, request.id)
}
if len(active) == 0 {
return
}
queryCtx, cancel := context.WithTimeout(ctx, s.cfg.QueryTimeout)
var (
loaded map[[8]byte]store.AuthKeyData
err error
)
if touch {
loaded, err = s.base.getManyAndTouch(queryCtx, ids)
} else {
loaded, err = s.base.getMany(queryCtx, ids)
}
cancel()
if err != nil {
for _, request := range active {
request.result <- authKeyGetBatchResult{err: err}
}
return
}
for _, request := range active {
data, found := loaded[request.id]
request.result <- authKeyGetBatchResult{data: data, found: found}
}
}
func failAuthKeyGetQueued(queue chan authKeyGetBatchRequest, err error, pending []authKeyGetBatchRequest) {
for _, request := range pending {
request.result <- authKeyGetBatchResult{err: err}
}
for {
select {
case request := <-queue:
request.result <- authKeyGetBatchResult{err: err}
default:
return
}
}
}
func (s *AuthKeyStore) getManyAndTouch(ctx context.Context, ids [][8]byte) (map[[8]byte]store.AuthKeyData, error) {
if len(ids) == 0 {
return map[[8]byte]store.AuthKeyData{}, nil
}
keyIDs, requested := authKeyBatchIDs(ids)
rows, err := s.db.Query(ctx, `
/* auth_key_get_batch */
UPDATE auth_keys
SET last_used_at = now()
WHERE auth_key_id = ANY($1::bigint[])
RETURNING auth_key_id, body, server_salt, created_at,
expires_at, layer, layer_observation_id,
device_model, platform, system_version, api_id, app_version
`, keyIDs)
if err != nil {
return nil, fmt.Errorf("batch get auth keys: %w", err)
}
return scanAuthKeyBatch(rows, requested, "batched auth key")
}
func (s *AuthKeyStore) getMany(ctx context.Context, ids [][8]byte) (map[[8]byte]store.AuthKeyData, error) {
if len(ids) == 0 {
return map[[8]byte]store.AuthKeyData{}, nil
}
keyIDs, requested := authKeyBatchIDs(ids)
rows, err := s.db.Query(ctx, `
/* auth_key_revalidate_batch */
SELECT auth_key_id, body, server_salt, created_at,
expires_at, layer, layer_observation_id,
device_model, platform, system_version, api_id, app_version
FROM auth_keys
WHERE auth_key_id = ANY($1::bigint[])
`, keyIDs)
if err != nil {
return nil, fmt.Errorf("batch revalidate auth keys: %w", err)
}
return scanAuthKeyBatch(rows, requested, "revalidated auth key")
}
func authKeyBatchIDs(ids [][8]byte) ([]int64, map[[8]byte]struct{}) {
keyIDs := make([]int64, 0, len(ids))
requested := make(map[[8]byte]struct{}, len(ids))
for _, id := range ids {
if _, duplicate := requested[id]; duplicate {
continue
}
requested[id] = struct{}{}
keyIDs = append(keyIDs, authKeyIDToInt64(id))
}
return keyIDs, requested
}
func scanAuthKeyBatch(
rows interface {
Next() bool
Scan(...any) error
Err() error
Close()
},
requested map[[8]byte]struct{},
operation string,
) (map[[8]byte]store.AuthKeyData, error) {
defer rows.Close()
out := make(map[[8]byte]store.AuthKeyData, len(requested))
for rows.Next() {
data, scanErr := scanAuthKeyData(rows)
if scanErr != nil {
return nil, fmt.Errorf("scan %s: %w", operation, scanErr)
}
if _, expected := requested[data.ID]; !expected {
return nil, fmt.Errorf("%s returned unexpected id %x", operation, data.ID)
}
out[data.ID] = data
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate %s: %w", operation, err)
}
return out, nil
}
var _ store.AuthKeyStore = (*BatchedAuthKeyStore)(nil)

View file

@ -0,0 +1,189 @@
package postgres
import (
"context"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
func TestBatchedAuthKeyStorePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
const keyCount = 32
keys := NewAuthKeyStore(pool)
ids := make([][8]byte, 0, keyCount)
old := time.Now().Add(-time.Hour)
for index := 0; index < keyCount; index++ {
id := randomLayerTestAuthKeyID(t)
data := store.AuthKeyData{ID: id, ServerSalt: int64(index + 1)}
data.Value[0] = byte(index + 1)
if err := keys.Save(ctx, data); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `UPDATE auth_keys SET last_used_at = $2 WHERE auth_key_id = $1`, authKeyIDToInt64(id), old); err != nil {
t.Fatal(err)
}
ids = append(ids, id)
}
t.Cleanup(func() {
for _, id := range ids {
_ = keys.Delete(ctx, id)
}
})
counted := &authKeyGetCountingDB{db: pool}
batcher, err := NewBatchedAuthKeyStore(NewAuthKeyStore(counted), AuthKeyGetBatchConfig{
MaxSize: keyCount, MaxWait: 10 * time.Millisecond,
QueueSize: keyCount * 2, QueryTimeout: 5 * time.Second,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(batcher.Close)
start := make(chan struct{})
errs := make(chan error, keyCount)
var wg sync.WaitGroup
for index, id := range ids {
index, id := index, id
wg.Add(1)
go func() {
defer wg.Done()
<-start
data, found, getErr := batcher.Get(ctx, id)
if getErr != nil {
errs <- getErr
return
}
if !found || data.ID != id || data.ServerSalt != int64(index+1) || data.Value[0] != byte(index+1) {
errs <- errors.New("batched auth-key result mismatch")
}
}()
}
close(start)
wg.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
if calls := counted.batchQueries.Load(); calls <= 0 || calls > 4 {
t.Fatalf("batch SQL calls = %d, want 1..4 for %d concurrent keys", calls, keyCount)
}
for _, id := range ids {
var touched time.Time
if err := pool.QueryRow(ctx, `SELECT last_used_at FROM auth_keys WHERE auth_key_id = $1`, authKeyIDToInt64(id)).Scan(&touched); err != nil {
t.Fatal(err)
}
if !touched.After(old) {
t.Fatalf("auth key %x was not touched: %v", id, touched)
}
}
readMarker := time.Now().Add(-2 * time.Hour).Truncate(time.Microsecond)
for _, id := range ids {
if _, err := pool.Exec(ctx, `UPDATE auth_keys SET last_used_at = $2 WHERE auth_key_id = $1`, authKeyIDToInt64(id), readMarker); err != nil {
t.Fatal(err)
}
}
errs = make(chan error, keyCount)
start = make(chan struct{})
for _, id := range ids {
id := id
wg.Add(1)
go func() {
defer wg.Done()
<-start
data, found, getErr := batcher.Revalidate(ctx, id)
if getErr != nil || !found || data.ID != id {
errs <- errors.New("batched auth-key revalidate mismatch")
}
}()
}
close(start)
wg.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
if calls := counted.revalidateQueries.Load(); calls <= 0 || calls > 4 {
t.Fatalf("revalidate SQL calls = %d, want 1..4 for %d concurrent keys", calls, keyCount)
}
for _, id := range ids {
var lastUsed time.Time
if err := pool.QueryRow(ctx, `SELECT last_used_at FROM auth_keys WHERE auth_key_id = $1`, authKeyIDToInt64(id)).Scan(&lastUsed); err != nil {
t.Fatal(err)
}
if !lastUsed.Equal(readMarker) {
t.Fatalf("revalidate touched auth key %x: got %v want %v", id, lastUsed, readMarker)
}
}
missing := randomLayerTestAuthKeyID(t)
if _, found, err := batcher.Get(ctx, missing); err != nil || found {
t.Fatalf("missing Get = found %v err %v", found, err)
}
batcher.Close()
if _, _, err := batcher.Get(ctx, ids[0]); !errors.Is(err, context.Canceled) {
t.Fatalf("Get after close err = %v", err)
}
}
func TestNewBatchedAuthKeyStoreRejectsInvalidConfig(t *testing.T) {
pool := testPool(t)
base := NewAuthKeyStore(pool)
for _, cfg := range []AuthKeyGetBatchConfig{
{},
{MaxSize: 1, MaxWait: 11 * time.Millisecond, QueueSize: 1, QueryTimeout: time.Second},
{MaxSize: 2, MaxWait: time.Microsecond, QueueSize: 1, QueryTimeout: time.Second},
{MaxSize: 1, MaxWait: time.Microsecond, QueueSize: 1, QueryTimeout: 31 * time.Second},
} {
if batcher, err := NewBatchedAuthKeyStore(base, cfg); err == nil {
batcher.Close()
t.Fatalf("invalid config accepted: %+v", cfg)
}
}
}
type authKeyGetCountingDB struct {
db sqlcgen.DBTX
batchQueries atomic.Int64
revalidateQueries atomic.Int64
}
func (db *authKeyGetCountingDB) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
return db.db.Exec(ctx, sql, args...)
}
func (db *authKeyGetCountingDB) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
if strings.Contains(sql, "auth_key_get_batch") {
db.batchQueries.Add(1)
}
if strings.Contains(sql, "auth_key_revalidate_batch") {
db.revalidateQueries.Add(1)
}
return db.db.Query(ctx, sql, args...)
}
func (db *authKeyGetCountingDB) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
return db.db.QueryRow(ctx, sql, args...)
}
func (db *authKeyGetCountingDB) Begin(ctx context.Context) (pgx.Tx, error) {
beginner, ok := db.db.(txBeginner)
if !ok {
return nil, errors.New("counted database does not support transactions")
}
return beginner.Begin(ctx)
}
var _ sqlcgen.DBTX = (*authKeyGetCountingDB)(nil)

View file

@ -7,7 +7,10 @@ import (
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/store"
@ -94,6 +97,80 @@ func TestAuthKeyStoreRoundTrip(t *testing.T) {
}
}
func TestAuthKeyStoreSeparatesActivationRevalidationAndBindingPairTouchPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
keys := NewAuthKeyStore(pool)
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, int(time.Now().Add(time.Hour).Unix()))
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
old := time.Now().Add(-48 * time.Hour).UTC().Truncate(time.Microsecond)
if _, err := pool.Exec(ctx, `
UPDATE auth_keys SET last_used_at = $2
WHERE auth_key_id = ANY($1::bigint[])`,
[]int64{authKeyIDToInt64(temp), authKeyIDToInt64(perm)}, old,
); err != nil {
t.Fatalf("seed old auth-key activity: %v", err)
}
got, found, err := keys.Revalidate(ctx, temp)
if err != nil || !found || got.ID != temp {
t.Fatalf("revalidate temp auth key = (%+v,%v,%v)", got, found, err)
}
var revalidatedAt time.Time
if err := pool.QueryRow(ctx, `SELECT last_used_at FROM auth_keys WHERE auth_key_id = $1`, authKeyIDToInt64(temp)).Scan(&revalidatedAt); err != nil {
t.Fatalf("read activity after revalidate: %v", err)
}
if !revalidatedAt.Equal(old) {
t.Fatalf("activation revalidate touched last_used_at: got %s want %s", revalidatedAt, old)
}
counter := &authKeyStatementCounter{Pool: pool}
pair, err := NewAuthKeyStore(counter).LoadBindingKeys(ctx, temp, perm)
if err != nil {
t.Fatalf("load binding keys: %v", err)
}
if counter.statements != 1 {
t.Fatalf("binding key load statements = %d, want 1", counter.statements)
}
if !pair.TemporaryFound || pair.Temporary.ID != temp ||
!pair.PermanentFound || pair.Permanent.ID != perm {
t.Fatalf("binding key pair = %+v", pair)
}
var touched int
if err := pool.QueryRow(ctx, `
SELECT count(*)::int
FROM auth_keys
WHERE auth_key_id = ANY($1::bigint[])
AND last_used_at > $2`,
[]int64{authKeyIDToInt64(temp), authKeyIDToInt64(perm)}, old,
).Scan(&touched); err != nil {
t.Fatalf("read paired activity: %v", err)
}
if touched != 2 {
t.Fatalf("binding key rows touched = %d, want 2", touched)
}
}
type authKeyStatementCounter struct {
*pgxpool.Pool
statements int
}
func (c *authKeyStatementCounter) Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) {
c.statements++
return c.Pool.Exec(ctx, sql, arguments...)
}
func (c *authKeyStatementCounter) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
c.statements++
return c.Pool.Query(ctx, sql, args...)
}
func (c *authKeyStatementCounter) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
c.statements++
return c.Pool.QueryRow(ctx, sql, args...)
}
func TestAuthKeyStoreClientInfoRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()

View file

@ -66,6 +66,26 @@ func (s *AuthKeyStore) AdvanceSessionLayer(
if layer <= 0 || !validMessageID {
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeySessionLayerInvalid
}
current, advanced, err := s.tryAdvanceSessionLayerSameLayer(
ctx, authKeyIDToInt64(rawAuthKeyID), sessionID, layer, msgID, expiresAt,
)
if err != nil {
return store.AuthKeySessionLayer{}, false, err
}
if advanced {
return current, true, nil
}
return s.advanceSessionLayerFull(ctx, rawAuthKeyID, sessionID, layer, msgID, expiresAt)
}
func (s *AuthKeyStore) advanceSessionLayerFull(
ctx context.Context,
rawAuthKeyID [8]byte,
sessionID int64,
layer int,
msgID int64,
expiresAt time.Time,
) (store.AuthKeySessionLayer, bool, error) {
var (
current store.AuthKeySessionLayer
applied bool
@ -83,6 +103,81 @@ func (s *AuthKeyStore) AdvanceSessionLayer(
return current, applied, nil
}
// tryAdvanceSessionLayerSameLayer is the common invokeWithLayer path once an
// exact session has established its profile generation. It keeps the durable
// msg_id high-water mark exact while avoiding the identity gate, observation
// allocation and shared-default rewrites that are only needed when the Layer
// itself changes. The identity CTE admits only a structurally valid raw/bound
// key; every miss falls through to the full locked state machine.
func (s *AuthKeyStore) tryAdvanceSessionLayerSameLayer(
ctx context.Context,
rawID int64,
sessionID int64,
layer int,
msgID int64,
expiresAt time.Time,
) (store.AuthKeySessionLayer, bool, error) {
var current store.AuthKeySessionLayer
err := s.db.QueryRow(ctx, `
WITH identity AS MATERIALIZED (
SELECT raw.auth_key_id,
defaults.layer AS default_layer,
defaults.layer_observation_id AS default_observation_id
FROM auth_keys AS raw
LEFT JOIN temp_auth_key_bindings AS binding
ON binding.temp_auth_key_id = raw.auth_key_id
JOIN auth_keys AS defaults
ON defaults.auth_key_id = COALESCE(binding.perm_auth_key_id, raw.auth_key_id)
WHERE raw.auth_key_id = $1
AND (
binding.temp_auth_key_id IS NULL
OR (raw.expires_at > 0 AND defaults.expires_at = 0)
)
), advanced AS (
UPDATE auth_key_session_layers AS evidence
SET msg_id = $4,
expires_at = $5
FROM identity
WHERE evidence.raw_auth_key_id = $1
AND evidence.session_id = $2
AND evidence.layer = $3
AND evidence.msg_id < $4
AND evidence.expires_at > now()
AND $3 > 0
AND $4 > 0
AND $4 % 4 = 0
AND ($4 & 4294967295) <> 0
AND $5 > now()
AND $5 - interval '301 seconds' <= now() + interval '30 seconds'
RETURNING evidence.layer,
evidence.msg_id,
evidence.observation_id,
evidence.expires_at
)
SELECT advanced.layer,
advanced.msg_id,
advanced.observation_id,
advanced.expires_at,
identity.default_layer = advanced.layer
AND identity.default_observation_id = advanced.observation_id
FROM advanced
CROSS JOIN identity
`, rawID, sessionID, layer, msgID, expiresAt).Scan(
&current.Layer,
&current.MessageID,
&current.ObservationID,
&current.ExpiresAt,
&current.SharedDefault,
)
if errors.Is(err, pgx.ErrNoRows) {
return store.AuthKeySessionLayer{}, false, nil
}
if err != nil {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("advance same-Layer auth key session watermark: %w", err)
}
return current, true, nil
}
func advanceSessionLayerTx(
ctx context.Context,
tx pgx.Tx,
@ -92,109 +187,48 @@ func advanceSessionLayerTx(
msgID int64,
expiresAt time.Time,
) (store.AuthKeySessionLayer, bool, error) {
_, permID, _, err := lockRawAuthKeyInIdentityOrder(ctx, tx, rawID)
if err != nil {
return store.AuthKeySessionLayer{}, false, err
}
var (
status string
current store.AuthKeySessionLayer
now time.Time
applied bool
)
err = tx.QueryRow(ctx, `
SELECT layer, msg_id, observation_id, expires_at, now()
FROM auth_key_session_layers
WHERE raw_auth_key_id = $1 AND session_id = $2
FOR UPDATE
`, rawID, sessionID).Scan(
err := tx.QueryRow(ctx, `
SELECT advance_status,
current_layer,
current_msg_id,
current_observation_id,
current_expires_at,
shared_default,
applied
FROM public.telesrv_advance_auth_session_layer($1, $2, $3, $4, $5)
`, rawID, sessionID, layer, msgID, expiresAt).Scan(
&status,
&current.Layer,
&current.MessageID,
&current.ObservationID,
&current.ExpiresAt,
&now,
&current.SharedDefault,
&applied,
)
if errors.Is(err, pgx.ErrNoRows) {
if err := tx.QueryRow(ctx, `SELECT now()`).Scan(&now); err != nil {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("read session layer database time: %w", err)
}
current = store.AuthKeySessionLayer{}
} else if err != nil {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("lock auth key session layer: %w", err)
if err != nil {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("advance auth key session layer: %w", err)
}
if _, fresh := store.AuthKeySessionLayerEvidenceFresh(now, msgID); !fresh {
switch status {
case "ok":
return current, applied, nil
case "identity_changed":
return store.AuthKeySessionLayer{}, false, errAuthIdentityChanged
case "auth_key_not_found":
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeyNotFound
case "binding_invalid":
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeyBindingInvalid
case "evidence_invalid":
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeySessionLayerInvalid
case "conflict":
return current, false, store.ErrAuthKeySessionLayerConflict
default:
return store.AuthKeySessionLayer{}, false, fmt.Errorf("advance auth key session layer: unknown database status %q", status)
}
if current.MessageID != 0 && now.Before(current.ExpiresAt) {
switch {
case msgID < current.MessageID:
if err := tx.QueryRow(ctx, `
SELECT layer = $2 AND layer_observation_id = $3
FROM auth_keys WHERE auth_key_id = $1
`, permID, current.Layer, current.ObservationID).Scan(&current.SharedDefault); err != nil {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("compare older session layer with shared default: %w", err)
}
return current, false, nil
case msgID == current.MessageID:
if layer != current.Layer {
return current, false, store.ErrAuthKeySessionLayerConflict
}
if err := tx.QueryRow(ctx, `
SELECT layer = $2 AND layer_observation_id = $3
FROM auth_keys WHERE auth_key_id = $1
`, permID, current.Layer, current.ObservationID).Scan(&current.SharedDefault); err != nil {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("compare duplicate session layer with shared default: %w", err)
}
return current, false, nil
}
}
var observationID int64
if err := tx.QueryRow(ctx, `SELECT nextval('auth_key_layer_observation_seq')`).Scan(&observationID); err != nil {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("allocate auth key layer observation: %w", err)
}
err = tx.QueryRow(ctx, `
INSERT INTO auth_key_session_layers (
raw_auth_key_id, session_id, layer, msg_id, observation_id, expires_at
) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (raw_auth_key_id, session_id) DO UPDATE SET
layer = EXCLUDED.layer,
msg_id = EXCLUDED.msg_id,
observation_id = EXCLUDED.observation_id,
expires_at = EXCLUDED.expires_at
RETURNING layer, msg_id, observation_id, expires_at
`, rawID, sessionID, layer, msgID, observationID, expiresAt).Scan(
&current.Layer,
&current.MessageID,
&current.ObservationID,
&current.ExpiresAt,
)
if err != nil {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("upsert auth key session layer: %w", err)
}
keyIDs := []int64{rawID}
if permID != rawID {
keyIDs = append(keyIDs, permID)
}
tag, err := tx.Exec(ctx, `
UPDATE auth_keys
SET layer = $2, layer_observation_id = $3
WHERE auth_key_id = ANY($1::bigint[])
AND layer_observation_id < $3
`, keyIDs, layer, observationID)
if err != nil {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("publish auth key session layer defaults: %w", err)
}
if tag.RowsAffected() != int64(len(keyIDs)) {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("publish auth key session layer defaults: updated %d of %d locked keys", tag.RowsAffected(), len(keyIDs))
}
if _, err := tx.Exec(ctx, `
UPDATE authorizations
SET layer = $2
WHERE auth_key_id = ANY($1::bigint[])
`, keyIDs, layer); err != nil {
return store.AuthKeySessionLayer{}, false, fmt.Errorf("mirror auth key session layer defaults: %w", err)
}
current.SharedDefault = true
return current, true, nil
}
func (s *AuthKeyStore) DeleteSessionLayer(

View file

@ -0,0 +1,408 @@
package postgres
import (
"context"
"errors"
"fmt"
"sync"
"time"
"telesrv/internal/store"
)
// AuthKeySessionLayerBatchConfig bounds the synchronous cross-session batch.
// A batch never contains the same raw auth-key/session identity twice and a
// caller does not return until its batch has committed or failed.
type AuthKeySessionLayerBatchConfig struct {
MaxSize int
MaxWait time.Duration
QueueSize int
QueryTimeout time.Duration
}
type authKeySessionLayerBatchKey struct {
rawAuthKeyID [8]byte
sessionID int64
}
type authKeySessionLayerBatchRequest struct {
ctx context.Context
rawAuthKeyID [8]byte
sessionID int64
layer int
msgID int64
expiresAt time.Time
result chan authKeySessionLayerBatchResult
}
type authKeySessionLayerBatchResult struct {
current store.AuthKeySessionLayer
fast bool
err error
}
// BatchedAuthKeySessionLayerStore preserves AuthKeySessionLayerStore semantics
// while combining contemporaneous same-Layer fast attempts for distinct
// sessions into one PostgreSQL statement. A miss is resolved synchronously by
// the original full identity transaction before the caller returns.
type BatchedAuthKeySessionLayerStore struct {
base *AuthKeyStore
cfg AuthKeySessionLayerBatchConfig
queue chan authKeySessionLayerBatchRequest
stop chan struct{}
done chan struct{}
cancel context.CancelFunc
once sync.Once
gate sync.RWMutex
closed bool
}
func NewBatchedAuthKeySessionLayerStore(
base *AuthKeyStore,
cfg AuthKeySessionLayerBatchConfig,
) (*BatchedAuthKeySessionLayerStore, error) {
if base == nil || base.db == nil {
return nil, errors.New("initialize auth key session Layer batcher: nil store")
}
if cfg.MaxSize <= 0 || cfg.MaxSize > 4096 {
return nil, fmt.Errorf("initialize auth key session Layer batcher: max size %d outside [1,4096]", cfg.MaxSize)
}
if cfg.MaxWait <= 0 || cfg.MaxWait > 10*time.Millisecond {
return nil, fmt.Errorf("initialize auth key session Layer batcher: max wait %v outside (0,10ms]", cfg.MaxWait)
}
if cfg.QueueSize < cfg.MaxSize || cfg.QueueSize > 1<<20 {
return nil, fmt.Errorf("initialize auth key session Layer batcher: queue size %d outside [%d,%d]", cfg.QueueSize, cfg.MaxSize, 1<<20)
}
if cfg.QueryTimeout <= 0 || cfg.QueryTimeout > 30*time.Second {
return nil, fmt.Errorf("initialize auth key session Layer batcher: query timeout %v outside (0,30s]", cfg.QueryTimeout)
}
workerCtx, cancel := context.WithCancel(context.Background())
s := &BatchedAuthKeySessionLayerStore{
base: base, cfg: cfg,
queue: make(chan authKeySessionLayerBatchRequest, cfg.QueueSize),
stop: make(chan struct{}), done: make(chan struct{}), cancel: cancel,
}
go s.run(workerCtx)
return s, nil
}
func (s *BatchedAuthKeySessionLayerStore) GetSessionLayer(
ctx context.Context,
rawAuthKeyID [8]byte,
sessionID int64,
) (store.AuthKeySessionLayer, bool, error) {
return s.base.GetSessionLayer(ctx, rawAuthKeyID, sessionID)
}
func (s *BatchedAuthKeySessionLayerStore) AdvanceSessionLayer(
ctx context.Context,
rawAuthKeyID [8]byte,
sessionID int64,
layer int,
msgID int64,
) (store.AuthKeySessionLayer, bool, error) {
if ctx == nil {
ctx = context.Background()
}
expiresAt, validMessageID := store.AuthKeySessionLayerExpiry(msgID)
if layer <= 0 || !validMessageID {
return store.AuthKeySessionLayer{}, false, store.ErrAuthKeySessionLayerInvalid
}
request := authKeySessionLayerBatchRequest{
ctx: ctx, rawAuthKeyID: rawAuthKeyID, sessionID: sessionID,
layer: layer, msgID: msgID, expiresAt: expiresAt,
result: make(chan authKeySessionLayerBatchResult, 1),
}
s.gate.RLock()
if s.closed {
s.gate.RUnlock()
return store.AuthKeySessionLayer{}, false, context.Canceled
}
select {
case s.queue <- request:
case <-ctx.Done():
s.gate.RUnlock()
return store.AuthKeySessionLayer{}, false, ctx.Err()
}
s.gate.RUnlock()
// Once accepted by the bounded queue, wait for the worker's definitive
// commit/error. This prevents a canceled caller from turning the submitted
// selector into an unobserved asynchronous best-effort write.
result := <-request.result
if result.err != nil {
return store.AuthKeySessionLayer{}, false, result.err
}
if result.fast {
return result.current, true, nil
}
if err := ctx.Err(); err != nil {
return store.AuthKeySessionLayer{}, false, err
}
return s.base.advanceSessionLayerFull(ctx, rawAuthKeyID, sessionID, layer, msgID, expiresAt)
}
func (s *BatchedAuthKeySessionLayerStore) DeleteSessionLayer(
ctx context.Context,
rawAuthKeyID [8]byte,
sessionID int64,
) (bool, error) {
return s.base.DeleteSessionLayer(ctx, rawAuthKeyID, sessionID)
}
func (s *BatchedAuthKeySessionLayerStore) DeleteExpiredSessionLayers(ctx context.Context, limit int) (int, error) {
return s.base.DeleteExpiredSessionLayers(ctx, limit)
}
func (s *BatchedAuthKeySessionLayerStore) Close() {
s.once.Do(func() {
s.gate.Lock()
s.closed = true
close(s.stop)
s.cancel()
s.gate.Unlock()
<-s.done
})
}
func (s *BatchedAuthKeySessionLayerStore) run(ctx context.Context) {
defer close(s.done)
pending := make([]authKeySessionLayerBatchRequest, 0, s.cfg.MaxSize)
for {
if len(pending) == 0 {
select {
case request := <-s.queue:
pending = append(pending, request)
case <-s.stop:
s.failQueued(context.Canceled, pending)
return
}
}
if len(pending) < s.cfg.MaxSize {
timer := time.NewTimer(s.cfg.MaxWait)
collect:
for len(pending) < s.cfg.MaxSize {
select {
case request := <-s.queue:
pending = append(pending, request)
case <-timer.C:
break collect
case <-s.stop:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
s.failQueued(context.Canceled, pending)
return
}
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
batch, remaining := selectDistinctLayerAdvanceBatch(pending, s.cfg.MaxSize)
pending = remaining
s.execute(ctx, batch)
}
}
func selectDistinctLayerAdvanceBatch(
pending []authKeySessionLayerBatchRequest,
maxSize int,
) ([]authKeySessionLayerBatchRequest, []authKeySessionLayerBatchRequest) {
batch := make([]authKeySessionLayerBatchRequest, 0, min(maxSize, len(pending)))
remaining := make([]authKeySessionLayerBatchRequest, 0, len(pending))
seen := make(map[authKeySessionLayerBatchKey]struct{}, min(maxSize, len(pending)))
for _, request := range pending {
if len(batch) >= maxSize {
remaining = append(remaining, request)
continue
}
key := authKeySessionLayerBatchKey{rawAuthKeyID: request.rawAuthKeyID, sessionID: request.sessionID}
if _, exists := seen[key]; exists {
remaining = append(remaining, request)
continue
}
seen[key] = struct{}{}
batch = append(batch, request)
}
return batch, remaining
}
func (s *BatchedAuthKeySessionLayerStore) execute(ctx context.Context, batch []authKeySessionLayerBatchRequest) {
active := batch[:0]
for _, request := range batch {
if err := request.ctx.Err(); err != nil {
request.result <- authKeySessionLayerBatchResult{err: err}
continue
}
active = append(active, request)
}
if len(active) == 0 {
return
}
queryCtx, cancel := context.WithTimeout(ctx, s.cfg.QueryTimeout)
results, err := s.base.tryAdvanceSessionLayersSameLayer(queryCtx, active)
cancel()
if err != nil {
for _, request := range active {
request.result <- authKeySessionLayerBatchResult{err: err}
}
return
}
for index, request := range active {
request.result <- results[index]
}
}
func (s *BatchedAuthKeySessionLayerStore) failQueued(err error, pending []authKeySessionLayerBatchRequest) {
for _, request := range pending {
request.result <- authKeySessionLayerBatchResult{err: err}
}
for {
select {
case request := <-s.queue:
request.result <- authKeySessionLayerBatchResult{err: err}
default:
return
}
}
}
func (s *AuthKeyStore) tryAdvanceSessionLayersSameLayer(
ctx context.Context,
requests []authKeySessionLayerBatchRequest,
) ([]authKeySessionLayerBatchResult, error) {
results := make([]authKeySessionLayerBatchResult, len(requests))
if len(requests) == 0 {
return results, nil
}
rawIDs := make([]int64, len(requests))
sessionIDs := make([]int64, len(requests))
layers := make([]int32, len(requests))
msgIDs := make([]int64, len(requests))
expiresAts := make([]time.Time, len(requests))
seen := make(map[authKeySessionLayerBatchKey]struct{}, len(requests))
for index, request := range requests {
key := authKeySessionLayerBatchKey{rawAuthKeyID: request.rawAuthKeyID, sessionID: request.sessionID}
if _, duplicate := seen[key]; duplicate {
return nil, fmt.Errorf("advance same-Layer auth key session batch: duplicate identity at index %d", index)
}
seen[key] = struct{}{}
rawIDs[index] = authKeyIDToInt64(request.rawAuthKeyID)
sessionIDs[index] = request.sessionID
layers[index] = int32(request.layer)
msgIDs[index] = request.msgID
expiresAts[index] = request.expiresAt
}
rows, err := s.db.Query(ctx, `
WITH input AS (
SELECT *
FROM unnest(
$1::bigint[],
$2::bigint[],
$3::integer[],
$4::bigint[],
$5::timestamptz[]
) WITH ORDINALITY AS value(raw_id, session_id, layer, msg_id, expires_at, ordinal)
), identity AS MATERIALIZED (
SELECT input.*,
defaults.layer AS default_layer,
defaults.layer_observation_id AS default_observation_id
FROM input
JOIN auth_keys AS raw
ON raw.auth_key_id = input.raw_id
LEFT JOIN temp_auth_key_bindings AS binding
ON binding.temp_auth_key_id = raw.auth_key_id
JOIN auth_keys AS defaults
ON defaults.auth_key_id = COALESCE(binding.perm_auth_key_id, raw.auth_key_id)
WHERE binding.temp_auth_key_id IS NULL
OR (raw.expires_at > 0 AND defaults.expires_at = 0)
), candidates AS MATERIALIZED (
SELECT identity.ordinal,
identity.msg_id,
identity.expires_at,
identity.default_layer,
identity.default_observation_id,
evidence.raw_auth_key_id,
evidence.session_id,
evidence.layer,
evidence.observation_id
FROM identity
JOIN auth_key_session_layers AS evidence
ON evidence.raw_auth_key_id = identity.raw_id
AND evidence.session_id = identity.session_id
WHERE evidence.layer = identity.layer
AND evidence.msg_id < identity.msg_id
AND evidence.expires_at > now()
AND identity.layer > 0
AND identity.msg_id > 0
AND identity.msg_id % 4 = 0
AND (identity.msg_id & 4294967295) <> 0
AND identity.expires_at > now()
AND identity.expires_at - interval '301 seconds' <= now() + interval '30 seconds'
ORDER BY evidence.raw_auth_key_id, evidence.session_id
FOR UPDATE OF evidence
), advanced AS (
UPDATE auth_key_session_layers AS evidence
SET msg_id = candidates.msg_id,
expires_at = candidates.expires_at
FROM candidates
WHERE evidence.raw_auth_key_id = candidates.raw_auth_key_id
AND evidence.session_id = candidates.session_id
RETURNING candidates.ordinal,
candidates.default_layer,
candidates.default_observation_id,
evidence.layer,
evidence.msg_id,
evidence.observation_id,
evidence.expires_at
)
SELECT ordinal,
layer,
msg_id,
observation_id,
expires_at,
default_layer = layer AND default_observation_id = observation_id
FROM advanced
ORDER BY ordinal
`, rawIDs, sessionIDs, layers, msgIDs, expiresAts)
if err != nil {
return nil, fmt.Errorf("advance same-Layer auth key session batch: %w", err)
}
defer rows.Close()
for rows.Next() {
var (
ordinal int64
current store.AuthKeySessionLayer
)
if err := rows.Scan(
&ordinal,
&current.Layer,
&current.MessageID,
&current.ObservationID,
&current.ExpiresAt,
&current.SharedDefault,
); err != nil {
return nil, fmt.Errorf("scan same-Layer auth key session batch: %w", err)
}
index := int(ordinal - 1)
if index < 0 || index >= len(results) || results[index].fast {
return nil, fmt.Errorf("advance same-Layer auth key session batch: invalid ordinal %d", ordinal)
}
results[index] = authKeySessionLayerBatchResult{current: current, fast: true}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("advance same-Layer auth key session batch rows: %w", err)
}
return results, nil
}
var _ store.AuthKeySessionLayerStore = (*BatchedAuthKeySessionLayerStore)(nil)

View file

@ -0,0 +1,159 @@
package postgres
import (
"context"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
func TestSelectDistinctLayerAdvanceBatchDefersSameSession(t *testing.T) {
first := authKeySessionLayerBatchRequest{rawAuthKeyID: [8]byte{1}, sessionID: 7}
duplicate := authKeySessionLayerBatchRequest{rawAuthKeyID: [8]byte{1}, sessionID: 7}
other := authKeySessionLayerBatchRequest{rawAuthKeyID: [8]byte{1}, sessionID: 8}
batch, remaining := selectDistinctLayerAdvanceBatch(
[]authKeySessionLayerBatchRequest{first, duplicate, other},
3,
)
if len(batch) != 2 || batch[0].sessionID != 7 || batch[1].sessionID != 8 {
t.Fatalf("batch = %#v", batch)
}
if len(remaining) != 1 || remaining[0].sessionID != 7 {
t.Fatalf("remaining = %#v", remaining)
}
}
func TestBatchedAuthKeySessionLayerStorePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
const accountCount = 32
now := time.Now().UTC()
keys := NewAuthKeyStore(pool)
type seeded struct {
id [8]byte
sessionID int64
observationID int64
msgID int64
}
seededKeys := make([]seeded, 0, accountCount)
for index := 0; index < accountCount; index++ {
id := randomLayerTestAuthKeyID(t)
sessionID := int64(91000 + index)
if err := keys.Save(ctx, store.AuthKeyData{ID: id}); err != nil {
t.Fatal(err)
}
firstMsgID := authKeySessionLayerTestMsgID(now, uint32(index+1))
first, applied, err := keys.AdvanceSessionLayer(ctx, id, sessionID, 227, firstMsgID)
if err != nil || !applied || first.ObservationID <= 0 {
t.Fatalf("seed %d = (%+v,%v,%v)", index, first, applied, err)
}
seededKeys = append(seededKeys, seeded{
id: id, sessionID: sessionID, observationID: first.ObservationID,
msgID: authKeySessionLayerTestMsgID(now, uint32(accountCount+index+1)),
})
}
t.Cleanup(func() {
for _, item := range seededKeys {
_ = keys.Delete(ctx, item.id)
}
})
counted := &layerBatchCountingDB{db: pool}
batchedBase := NewAuthKeyStore(counted)
batcher, err := NewBatchedAuthKeySessionLayerStore(batchedBase, AuthKeySessionLayerBatchConfig{
MaxSize: accountCount, MaxWait: 10 * time.Millisecond,
QueueSize: accountCount * 2, QueryTimeout: 5 * time.Second,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(batcher.Close)
start := make(chan struct{})
errs := make(chan error, accountCount)
var wg sync.WaitGroup
for _, item := range seededKeys {
item := item
wg.Add(1)
go func() {
defer wg.Done()
<-start
current, applied, err := batcher.AdvanceSessionLayer(ctx, item.id, item.sessionID, 227, item.msgID)
if err != nil {
errs <- err
return
}
if !applied || current.MessageID != item.msgID || current.ObservationID != item.observationID {
errs <- errors.New("same-Layer batch changed durable generation or failed to advance")
}
}()
}
close(start)
wg.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
if calls := counted.batchQueries.Load(); calls <= 0 || calls > 4 {
t.Fatalf("batch SQL calls = %d, want 1..4 for %d concurrent sessions", calls, accountCount)
}
for _, item := range seededKeys {
current, found, err := keys.GetSessionLayer(ctx, item.id, item.sessionID)
if err != nil || !found || current.MessageID != item.msgID || current.ObservationID != item.observationID {
t.Fatalf("durable result %x/%d = (%+v,%v,%v)", item.id, item.sessionID, current, found, err)
}
}
// A fast miss must synchronously execute the original full state machine,
// rather than treating a successful batch statement as success for every row.
missingSession := int64(99001)
missingMsgID := authKeySessionLayerTestMsgID(now, 1000)
created, applied, err := batcher.AdvanceSessionLayer(ctx, seededKeys[0].id, missingSession, 225, missingMsgID)
if err != nil || !applied || created.Layer != 225 || created.MessageID != missingMsgID || created.ObservationID <= 0 {
t.Fatalf("batch miss full fallback = (%+v,%v,%v)", created, applied, err)
}
batcher.Close()
if _, _, err := batcher.AdvanceSessionLayer(ctx, seededKeys[0].id, missingSession, 225, missingMsgID); !errors.Is(err, context.Canceled) {
t.Fatalf("advance after close err = %v", err)
}
}
type layerBatchCountingDB struct {
db sqlcgen.DBTX
batchQueries atomic.Int64
}
func (db *layerBatchCountingDB) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
return db.db.Exec(ctx, sql, args...)
}
func (db *layerBatchCountingDB) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
if strings.Contains(sql, "WITH input AS") && strings.Contains(sql, "candidates AS MATERIALIZED") {
db.batchQueries.Add(1)
}
return db.db.Query(ctx, sql, args...)
}
func (db *layerBatchCountingDB) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
return db.db.QueryRow(ctx, sql, args...)
}
func (db *layerBatchCountingDB) Begin(ctx context.Context) (pgx.Tx, error) {
beginner, ok := db.db.(txBeginner)
if !ok {
return nil, errors.New("counted database does not support transactions")
}
return beginner.Begin(ctx)
}
var _ sqlcgen.DBTX = (*layerBatchCountingDB)(nil)

View file

@ -37,9 +37,10 @@ func TestAuthKeySessionLayerTransactionAndRestartPostgres(t *testing.T) {
}
now := time.Now().UTC()
firstMsgID := authKeySessionLayerTestMsgID(now, 1)
newerMsgID := authKeySessionLayerTestMsgID(now, 2)
concurrentLowMsgID := authKeySessionLayerTestMsgID(now, 3)
concurrentHighMsgID := authKeySessionLayerTestMsgID(now, 4)
sameLayerMsgID := authKeySessionLayerTestMsgID(now, 2)
newerMsgID := authKeySessionLayerTestMsgID(now, 3)
concurrentLowMsgID := authKeySessionLayerTestMsgID(now, 4)
concurrentHighMsgID := authKeySessionLayerTestMsgID(now, 5)
for _, invalidMsgID := range []int64{
authKeySessionLayerTestMsgID(now.Add(-302*time.Second), 1),
authKeySessionLayerTestMsgID(now.Add(31*time.Second), 1),
@ -72,6 +73,24 @@ func TestAuthKeySessionLayerTransactionAndRestartPostgres(t *testing.T) {
t.Fatalf("bound default %x = (%+v,%v,%v)", id, got, found, err)
}
}
futureSameLayerMsgID := authKeySessionLayerTestMsgID(now.Add(31*time.Second), 1)
if _, _, err := NewAuthKeyStore(pool).AdvanceSessionLayer(ctx, temp, sessionID, 220, futureSameLayerMsgID); !errors.Is(err, store.ErrAuthKeySessionLayerInvalid) {
t.Fatalf("future same-Layer fast advance err = %v", err)
}
if got, found, err := NewAuthKeyStore(pool).GetSessionLayer(ctx, temp, sessionID); err != nil || !found || got.MessageID != firstMsgID || got.ObservationID != first.ObservationID {
t.Fatalf("rejected future same-Layer advance changed row = (%+v,%v,%v)", got, found, err)
}
sameLayer, applied, err := NewAuthKeyStore(pool).AdvanceSessionLayer(ctx, temp, sessionID, 220, sameLayerMsgID)
if err != nil || !applied || !sameLayer.SharedDefault || sameLayer.MessageID != sameLayerMsgID ||
sameLayer.ObservationID != first.ObservationID {
t.Fatalf("same-Layer high-water advance = (%+v,%v,%v)", sameLayer, applied, err)
}
for _, id := range [][8]byte{temp, perm} {
got, found, err := NewAuthKeyStore(pool).Get(ctx, id)
if err != nil || !found || got.Layer != 220 || got.LayerObservationID != first.ObservationID {
t.Fatalf("same-Layer default rewrite %x = (%+v,%v,%v)", id, got, found, err)
}
}
newer, applied, err := NewAuthKeyStore(pool).AdvanceSessionLayer(ctx, temp, sessionID, 227, newerMsgID)
if err != nil || !applied || !newer.SharedDefault || newer.ObservationID <= first.ObservationID {
@ -124,6 +143,27 @@ func TestAuthKeySessionLayerTransactionAndRestartPostgres(t *testing.T) {
t.Fatalf("transactional shared default %x = (%+v,%v,%v)", id, got, found, err)
}
}
// Expiry ends the old row's ordering authority. A still-fresh selector with
// a lower msg_id may replace it and must publish one new shared observation.
if _, err := pool.Exec(ctx, `
UPDATE auth_key_session_layers
SET expires_at = now() - interval '1 second'
WHERE raw_auth_key_id = $1 AND session_id = $2
`, authKeyIDToInt64(temp), sessionID); err != nil {
t.Fatalf("expire session Layer row: %v", err)
}
replacement, applied, err := restarted.AdvanceSessionLayer(ctx, temp, sessionID, 225, firstMsgID)
if err != nil || !applied || replacement.Layer != 225 || replacement.MessageID != firstMsgID ||
!replacement.SharedDefault || replacement.ObservationID <= current.ObservationID {
t.Fatalf("expired-row replacement = (%+v,%v,%v)", replacement, applied, err)
}
for _, id := range [][8]byte{temp, perm} {
got, found, err := restarted.Get(ctx, id)
if err != nil || !found || got.Layer != 225 || got.LayerObservationID != replacement.ObservationID {
t.Fatalf("replacement shared default %x = (%+v,%v,%v)", id, got, found, err)
}
}
}
func authKeySessionLayerTestMsgID(at time.Time, order uint32) int64 {

View file

@ -40,17 +40,37 @@ func (s *AuthorizationStore) Bind(ctx context.Context, a domain.Authorization) e
// 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 的并发登录/换号。
// 锁顺序固定为:目标 user advisory/row → auth_keys 母行 →
// user_update_watermarks → user_update_retention → 目标 update_states。其中 watermark
// 与 retention 两个 row lock 的顺序和 pruneConfirmedUserPrefixTx 一致,使新授权的
// observed baseline 和 retained floor 不会交叉提交成静默空洞。母行锁又能在首次
// authorization 尚不存在时串行化同一
// raw auth key 的并发登录/换号。user 锁与账号 tombstone 使用同一顺序;因此 Bind
// 要么先提交并被随后删除事务撤销,要么等删除提交后看见 tombstone 并拒绝,不能在
// 删除事务枚举 authorization 之后重新绑定账号。
func bindAuthorization(ctx context.Context, db sqlcgen.DBTX, a domain.Authorization) error {
keyID := authKeyIDToInt64(a.AuthKeyID)
tx, ok := db.(pgx.Tx)
if !ok {
return fmt.Errorf("bind authorization requires a transaction")
}
if err := lockUsersForUpdate(ctx, tx, a.UserID); err != nil {
return fmt.Errorf("lock authorization user: %w", err)
}
var active bool
if err := db.QueryRow(ctx, `
SELECT deleted_at IS NULL
FROM users
WHERE id = $1
FOR UPDATE`, a.UserID).Scan(&active); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrUserNotFound
}
return fmt.Errorf("lock authorization user row: %w", err)
}
if !active {
return domain.ErrAccountDeleted
}
if err := lockPermanentAuthIdentities(ctx, tx, []int64{keyID}); err != nil {
return err
}
@ -160,6 +180,7 @@ ON CONFLICT (auth_key_id) DO UPDATE SET
app_version = EXCLUDED.app_version,
ip = EXCLUDED.ip,
password_pending = EXCLUDED.password_pending,
created_at = now(),
active_at = now()`,
keyID, a.UserID, a.Hash, int32(authLayer), a.DeviceModel, a.Platform, a.SystemVersion, int32(a.APIID), a.AppVersion, a.IP, a.PasswordPending,
); err != nil {
@ -204,12 +225,19 @@ WHERE auth_key_id = $1`,
return nil
}
// MarkPasswordPassed 在两步验证通过后清除 password_pending使 auth_key 转为完全授权。
func (s *AuthorizationStore) MarkPasswordPassed(ctx context.Context, id [8]byte) error {
if _, err := s.db.Exec(ctx, `
UPDATE authorizations SET password_pending = false, active_at = now() WHERE auth_key_id = $1`, authKeyIDToInt64(id)); err != nil {
// MarkPasswordPassed atomically promotes only the pending identity whose
// password was just verified. A concurrent cross-user Bind must not let A's
// proof clear B's password_pending flag.
func (s *AuthorizationStore) MarkPasswordPassed(ctx context.Context, id [8]byte, expectedUserID int64) error {
tag, err := s.db.Exec(ctx, `
UPDATE authorizations SET password_pending = false, created_at = now(), active_at = now()
WHERE auth_key_id = $1 AND user_id = $2 AND password_pending`, authKeyIDToInt64(id), expectedUserID)
if err != nil {
return fmt.Errorf("mark authorization password passed: %w", err)
}
if tag.RowsAffected() != 1 {
return store.ErrAuthorizationStateChanged
}
return nil
}

View file

@ -2,6 +2,7 @@ package postgres
import (
"context"
"errors"
"fmt"
"testing"
"time"
@ -159,6 +160,99 @@ func TestAuthorizationStoreUpdateClientInfoMergesPostgres(t *testing.T) {
}
}
func TestAuthorizationStoreLoginAndPasswordCompletionRefreshSessionAgePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
userA := createRevokeTestUser(t, ctx, pool, "session-age-a")
userB := createRevokeTestUser(t, ctx, pool, "session-age-b")
keys := NewAuthKeyStore(pool)
auths := NewAuthorizationStore(pool)
key := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: key, UserID: userA, Hash: 9251}); err != nil {
t.Fatalf("bind initial authorization: %v", err)
}
var oldCreatedAt time.Time
if err := pool.QueryRow(ctx, `UPDATE authorizations SET created_at=now()-interval '48 hours'
WHERE auth_key_id=$1 RETURNING created_at`, authKeyIDToInt64(key)).Scan(&oldCreatedAt); err != nil {
t.Fatalf("backdate initial authorization: %v", err)
}
// Bind is an explicit login boundary, not a metadata refresh. Even a login
// to the same account must start a new withdrawal freshness window.
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: key, UserID: userA, Hash: 9252}); err != nil {
t.Fatalf("rebind same owner: %v", err)
}
assertFreshAuthorizationCreatedAt(t, ctx, pool, key, userA, oldCreatedAt)
if _, err := pool.Exec(ctx, `UPDATE authorizations SET created_at=now()-interval '48 hours'
WHERE auth_key_id=$1`, authKeyIDToInt64(key)); err != nil {
t.Fatalf("backdate authorization before owner change: %v", err)
}
if err := auths.Bind(ctx, domain.Authorization{
AuthKeyID: key, UserID: userB, Hash: 9253, PasswordPending: true,
}); err != nil {
t.Fatalf("bind new owner pending password: %v", err)
}
assertFreshAuthorizationCreatedAt(t, ctx, pool, key, userB, oldCreatedAt)
// A pending login may wait longer than 24 hours before auth.checkPassword.
// Full authorization starts only when that proof succeeds, so its age must
// be reset here rather than inheriting the pending row's old timestamp.
if _, err := pool.Exec(ctx, `UPDATE authorizations SET created_at=now()-interval '48 hours'
WHERE auth_key_id=$1`, authKeyIDToInt64(key)); err != nil {
t.Fatalf("backdate pending authorization: %v", err)
}
if err := auths.MarkPasswordPassed(ctx, key, userB); err != nil {
t.Fatalf("mark password passed: %v", err)
}
got, found, err := auths.ByAuthKey(ctx, key)
if err != nil || !found || got.PasswordPending {
t.Fatalf("completed password authorization = %+v found=%v err=%v", got, found, err)
}
assertFreshAuthorizationCreatedAt(t, ctx, pool, key, userB, oldCreatedAt)
// Model the exact proof/promote race: A's password was verified, then the
// same auth key was rebound to B in password_pending state before promotion.
// A's proof must not promote B or turn Router's stale A identity into a cache
// fact for this key.
raceKey := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
if err := auths.Bind(ctx, domain.Authorization{
AuthKeyID: raceKey, UserID: userA, Hash: 9254, PasswordPending: true,
}); err != nil {
t.Fatalf("bind proof owner A: %v", err)
}
if err := auths.Bind(ctx, domain.Authorization{
AuthKeyID: raceKey, UserID: userB, Hash: 9255, PasswordPending: true,
}); err != nil {
t.Fatalf("rebind pending owner B: %v", err)
}
if err := auths.MarkPasswordPassed(ctx, raceKey, userA); !errors.Is(err, store.ErrAuthorizationStateChanged) {
t.Fatalf("stale A proof promotion err=%v, want authorization state changed", err)
}
raced, found, err := auths.ByAuthKey(ctx, raceKey)
if err != nil || !found || raced.UserID != userB || !raced.PasswordPending {
t.Fatalf("authorization after stale A proof = %+v found=%v err=%v, want pending B", raced, found, err)
}
}
func assertFreshAuthorizationCreatedAt(t *testing.T, ctx context.Context, pool *pgxpool.Pool, key [8]byte, userID int64, old time.Time) {
t.Helper()
var (
actualUserID int64
createdAt time.Time
fresh bool
)
if err := pool.QueryRow(ctx, `SELECT user_id,created_at,created_at > now()-interval '1 minute'
FROM authorizations WHERE auth_key_id=$1`, authKeyIDToInt64(key)).Scan(&actualUserID, &createdAt, &fresh); err != nil {
t.Fatalf("read refreshed authorization: %v", err)
}
if actualUserID != userID || !fresh || !createdAt.After(old) {
t.Fatalf("authorization session age user=%d created_at=%v fresh=%v, want user=%d newer than %v",
actualUserID, createdAt, fresh, userID, old)
}
}
func TestAuthorizationStoreRevokeByHashConcurrentTempBindKeepsProtocolIdentityPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
@ -258,9 +352,10 @@ func TestAuthorizationStoreRevokeByHashSkipsKeyTransferredAfterCandidateReadPost
t.Fatalf("save temp binding before owner transfer: %v", err)
}
// Bind B performs the auth_keys-first ownership change inside an open
// transaction. Its uncommitted row is invisible to A's candidate lookup, but
// the parent FOR UPDATE lock is the deterministic barrier for revocation.
// Bind B locks its target user before performing the auth-key ownership change
// inside an open transaction. Its uncommitted row is invisible to A's candidate
// lookup, while the parent auth-key FOR UPDATE lock remains the deterministic
// barrier for revocation.
bindB, err := pool.Begin(testCtx)
if err != nil {
t.Fatalf("begin B bind transaction: %v", err)

View file

@ -0,0 +1,89 @@
package postgres
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
)
const tombstoneAuthorizationTestUserSQL = `
UPDATE users SET
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
verified = false, support = false, last_seen_at = 0,
premium_expires_at = NULL, emoji_status_document_id = 0, emoji_status_until = 0,
emoji_status_collectible_id = NULL, emoji_status_collectible = '{}'::jsonb,
color_set = false, color = 0, color_background_emoji_id = 0,
profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0,
birthday_day = 0, birthday_month = 0, birthday_year = 0, personal_channel_id = 0,
deleted_at = $2, deletion_source = 'manual', deletion_reason = '',
account_delete_at = NULL, updated_at = $2
WHERE id = $1 AND deleted_at IS NULL`
func TestAuthorizationStoreBindRejectsTombstonePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
userID := createRevokeTestUser(t, ctx, pool, "bind-tombstone")
key := saveTempIdentityTestAuthKey(t, ctx, pool, NewAuthKeyStore(pool), 0)
if _, err := pool.Exec(ctx, tombstoneAuthorizationTestUserSQL, userID, time.Now().UTC()); err != nil {
t.Fatalf("tombstone user: %v", err)
}
err := NewAuthorizationStore(pool).Bind(ctx, domain.Authorization{AuthKeyID: key, UserID: userID})
if !errors.Is(err, domain.ErrAccountDeleted) {
t.Fatalf("Bind tombstone err = %v, want ErrAccountDeleted", err)
}
assertRevokeTestNoAuthorization(t, ctx, NewAuthorizationStore(pool), key)
assertRevokeTestTableCount(t, ctx, pool, "update_states", "auth_key_id", authKeyIDToInt64(key), 0)
}
func TestAuthorizationStoreBindWaitsForTombstoneThenRejectsPostgres(t *testing.T) {
pool := testPool(t)
testCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
t.Cleanup(cancel)
userID := createRevokeTestUser(t, testCtx, pool, "bind-tombstone-race")
key := saveTempIdentityTestAuthKey(t, testCtx, pool, NewAuthKeyStore(pool), 0)
deleteTx, err := pool.Begin(testCtx)
if err != nil {
t.Fatalf("begin tombstone transaction: %v", err)
}
defer func() { _ = deleteTx.Rollback(context.Background()) }()
if err := lockUsersForUpdate(testCtx, deleteTx, userID); err != nil {
t.Fatalf("lock tombstone user: %v", err)
}
if _, err := deleteTx.Exec(testCtx, tombstoneAuthorizationTestUserSQL, userID, time.Now().UTC()); err != nil {
t.Fatalf("stage tombstone: %v", err)
}
bindConn, err := pool.Acquire(testCtx)
if err != nil {
t.Fatalf("acquire bind connection: %v", err)
}
t.Cleanup(bindConn.Release)
var bindPID int
if err := bindConn.QueryRow(testCtx, "SELECT pg_backend_pid()").Scan(&bindPID); err != nil {
t.Fatalf("get bind backend pid: %v", err)
}
bindResult := make(chan error, 1)
go func() {
bindResult <- NewAuthorizationStore(bindConn).Bind(testCtx, domain.Authorization{AuthKeyID: key, UserID: userID})
}()
waitForPostgresBackendLockWait(t, testCtx, pool, bindPID)
if err := deleteTx.Commit(testCtx); err != nil {
t.Fatalf("commit tombstone: %v", err)
}
select {
case err := <-bindResult:
if !errors.Is(err, domain.ErrAccountDeleted) {
t.Fatalf("Bind after tombstone lock err = %v, want ErrAccountDeleted", err)
}
case <-testCtx.Done():
t.Fatalf("Bind did not finish after tombstone commit: %v", testCtx.Err())
}
assertRevokeTestNoAuthorization(t, testCtx, NewAuthorizationStore(pool), key)
assertRevokeTestTableCount(t, testCtx, pool, "update_states", "auth_key_id", authKeyIDToInt64(key), 0)
}

View file

@ -0,0 +1,111 @@
package postgres
import (
"context"
"encoding/hex"
"fmt"
"telesrv/internal/domain"
)
// BlobMigrationObject is one immutable content-addressed object referenced by
// one or more logical file locations on the same permanent backend.
type BlobMigrationObject struct {
ObjectKey string
Size int64
SHA256 []byte
LocationRows int64
}
// ListBlobMigrationObjects keyset-pages distinct objects. Inconsistent size or
// digest metadata for one content key is returned as an error, never guessed.
func (s *MediaStore) ListBlobMigrationObjects(
ctx context.Context,
backend domain.MediaBackend,
afterObjectKey string,
limit int,
) ([]BlobMigrationObject, error) {
if limit <= 0 {
return nil, nil
}
rows, err := s.db.Query(ctx, `
SELECT
object_key,
min(size)::bigint,
count(DISTINCT size)::bigint,
min(encode(sha256, 'hex')),
count(DISTINCT encode(sha256, 'hex'))::bigint,
count(*)::bigint
FROM file_blobs
WHERE backend = $1 AND object_key > $2
GROUP BY object_key
ORDER BY object_key
LIMIT $3`, string(backend), afterObjectKey, limit)
if err != nil {
return nil, fmt.Errorf("list %s blob migration objects: %w", backend, err)
}
defer rows.Close()
objects := make([]BlobMigrationObject, 0, limit)
for rows.Next() {
var (
object BlobMigrationObject
sizeVariants int64
digestHex string
digestVariants int64
)
if err := rows.Scan(
&object.ObjectKey,
&object.Size,
&sizeVariants,
&digestHex,
&digestVariants,
&object.LocationRows,
); err != nil {
return nil, fmt.Errorf("scan blob migration object: %w", err)
}
if sizeVariants != 1 || digestVariants != 1 {
return nil, fmt.Errorf("blob %q has inconsistent persisted size or SHA-256 metadata", object.ObjectKey)
}
digest, err := hex.DecodeString(digestHex)
if err != nil || len(digest) != 32 {
return nil, fmt.Errorf("blob %q has invalid persisted SHA-256 metadata", object.ObjectKey)
}
if object.ObjectKey != digestHex {
return nil, fmt.Errorf("blob %q object key does not match persisted SHA-256 %q", object.ObjectKey, digestHex)
}
object.SHA256 = digest
objects = append(objects, object)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate blob migration objects: %w", err)
}
return objects, nil
}
// MoveFileBlobBackendForObject atomically relabels every logical location for a
// verified immutable object. It refuses partial/racing changes.
func (s *MediaStore) MoveFileBlobBackendForObject(
ctx context.Context,
from domain.MediaBackend,
to domain.MediaBackend,
objectKey string,
expectedRows int64,
) error {
if expectedRows <= 0 {
return fmt.Errorf("blob %q expected row count must be positive", objectKey)
}
result, err := s.db.Exec(ctx, `
UPDATE file_blobs
SET backend = $1
WHERE backend = $2 AND object_key = $3`, string(to), string(from), objectKey)
if err != nil {
return fmt.Errorf("move blob %q metadata from %s to %s: %w", objectKey, from, to, err)
}
if result.RowsAffected() != expectedRows {
return fmt.Errorf(
"move blob %q metadata changed %d rows, want %d",
objectKey, result.RowsAffected(), expectedRows,
)
}
return nil
}

View file

@ -0,0 +1,95 @@
package postgres
import (
"context"
"os"
"testing"
"time"
"telesrv/internal/domain"
)
func TestBlobStorageAdvisoryLock(t *testing.T) {
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test")
}
ctx := context.Background()
runtimeLock, err := AcquireBlobRuntimeLock(ctx, dsn)
if err != nil {
t.Fatalf("acquire runtime lock: %v", err)
}
if _, err := AcquireBlobMigrationLock(ctx, dsn); err == nil {
t.Fatal("exclusive migration lock acquired while runtime shared lock was held")
}
if err := runtimeLock.Close(); err != nil {
t.Fatalf("close runtime lock: %v", err)
}
migrationLock, err := AcquireBlobMigrationLock(ctx, dsn)
if err != nil {
t.Fatalf("acquire migration lock after runtime stopped: %v", err)
}
if err := migrationLock.Close(); err != nil {
t.Fatalf("close migration lock: %v", err)
}
}
func TestBlobMigrationMetadataRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
media := NewMediaStore(pool)
uniqueBefore, err := media.UniqueFileBlobBytes(ctx, domain.MediaBackendLocalFS)
if err != nil {
t.Fatalf("unique blob bytes before insert: %v", err)
}
suffix := time.Now().UnixNano()
first := postgresTestBlob("blob-migration:first:"+time.Unix(0, suffix).Format("150405.000000000"), "shared-migration", 4096, "application/octet-stream")
second := first
second.LocationKey = "blob-migration:second:" + time.Unix(0, suffix).Format("150405.000000000")
for _, blob := range []domain.FileBlob{first, second} {
if err := media.PutFileBlob(ctx, blob); err != nil {
t.Fatalf("put %s: %v", blob.LocationKey, err)
}
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), "DELETE FROM file_blobs WHERE location_key = ANY($1::text[])", []string{first.LocationKey, second.LocationKey})
})
counts, err := media.FileBlobBackendCounts(ctx)
if err != nil || counts[domain.MediaBackendLocalFS] < 2 {
t.Fatalf("backend counts=%v err=%v", counts, err)
}
uniqueBytes, err := media.UniqueFileBlobBytes(ctx, domain.MediaBackendLocalFS)
if err != nil {
t.Fatalf("unique blob bytes: %v", err)
}
if uniqueBytes-uniqueBefore != first.Size {
t.Fatalf("unique blob byte delta=%d, want shared object counted once as %d", uniqueBytes-uniqueBefore, first.Size)
}
objects, err := media.ListBlobMigrationObjects(ctx, domain.MediaBackendLocalFS, first.ObjectKey[:len(first.ObjectKey)-1], 10)
if err != nil {
t.Fatalf("list migration objects: %v", err)
}
var found *BlobMigrationObject
for i := range objects {
if objects[i].ObjectKey == first.ObjectKey {
found = &objects[i]
break
}
}
if found == nil || found.LocationRows != 2 || found.Size != first.Size {
t.Fatalf("migration object=%+v", found)
}
if err := media.MoveFileBlobBackendForObject(
ctx, domain.MediaBackendLocalFS, domain.MediaBackendS3,
found.ObjectKey, found.LocationRows,
); err != nil {
t.Fatalf("move backend: %v", err)
}
for _, key := range []string{first.LocationKey, second.LocationKey} {
blob, ok, err := media.GetFileBlob(ctx, key)
if err != nil || !ok || blob.Backend != domain.MediaBackendS3 {
t.Fatalf("get %s backend=%q ok=%v err=%v", key, blob.Backend, ok, err)
}
}
}

View file

@ -0,0 +1,79 @@
package postgres
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// blobStorageAdvisoryLockKey is the signed int64 encoding of "telesrvb". Every
// running server holds a shared session lock; the offline migration tool needs
// the exclusive form, which proves no server using this database is active.
const blobStorageAdvisoryLockKey int64 = 0x74656c6573727662
type BlobStorageLock struct {
conn *pgx.Conn
shared bool
}
func AcquireBlobRuntimeLock(ctx context.Context, dsn string) (*BlobStorageLock, error) {
return acquireBlobStorageLock(ctx, dsn, true)
}
func AcquireBlobMigrationLock(ctx context.Context, dsn string) (*BlobStorageLock, error) {
return acquireBlobStorageLock(ctx, dsn, false)
}
func acquireBlobStorageLock(ctx context.Context, dsn string, shared bool) (*BlobStorageLock, error) {
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("connect for blob storage lock: %w", err)
}
query := "SELECT pg_try_advisory_lock($1)"
kind := "exclusive migration"
if shared {
query = "SELECT pg_try_advisory_lock_shared($1)"
kind = "shared runtime"
}
var acquired bool
if err := conn.QueryRow(ctx, query, blobStorageAdvisoryLockKey).Scan(&acquired); err != nil {
_ = conn.Close(context.Background())
return nil, fmt.Errorf("acquire %s blob storage lock: %w", kind, err)
}
if !acquired {
_ = conn.Close(context.Background())
if shared {
return nil, fmt.Errorf("blob migration lock is active; wait for the offline migration to finish before starting telesrv")
}
return nil, fmt.Errorf("one or more telesrv processes are active; stop every process using this PostgreSQL database before migrating blobs")
}
return &BlobStorageLock{conn: conn, shared: shared}, nil
}
func (l *BlobStorageLock) Close() error {
if l == nil || l.conn == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
query := "SELECT pg_advisory_unlock($1)"
if l.shared {
query = "SELECT pg_advisory_unlock_shared($1)"
}
var unlocked bool
err := l.conn.QueryRow(ctx, query, blobStorageAdvisoryLockKey).Scan(&unlocked)
closeErr := l.conn.Close(ctx)
l.conn = nil
if err != nil {
return fmt.Errorf("release blob storage lock: %w", err)
}
if !unlocked {
return fmt.Errorf("blob storage advisory lock was not held by its session")
}
if closeErr != nil {
return fmt.Errorf("close blob storage lock connection: %w", closeErr)
}
return nil
}

View file

@ -0,0 +1,25 @@
package postgres
import (
"crypto/sha256"
"encoding/hex"
"telesrv/internal/domain"
)
func postgresTestBlob(locationKey, label string, size int64, mimeType string) domain.FileBlob {
data := make([]byte, size)
seed := sha256.Sum256([]byte(label))
for i := range data {
data[i] = seed[i%len(seed)]
}
digest := sha256.Sum256(data)
return domain.FileBlob{
LocationKey: locationKey,
Backend: domain.MediaBackendLocalFS,
ObjectKey: hex.EncodeToString(digest[:]),
Size: size,
SHA256: append([]byte(nil), digest[:]...),
MimeType: mimeType,
}
}

View file

@ -0,0 +1,376 @@
package postgres
import (
"context"
"errors"
"fmt"
"sync"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// BootstrapReadyBatchMetrics exposes only bounded aggregate signals. Selector
// identities are deliberately excluded from metrics.
type BootstrapReadyBatchMetrics interface {
BootstrapReadyBatch(inputs int, matched int, d time.Duration, err error)
BootstrapReadyPending(delta int)
}
type BootstrapReadyBatchConfig struct {
MaxSize int
MaxWait time.Duration
QueueSize int
QueryTimeout time.Duration
Metrics BootstrapReadyBatchMetrics
}
type bootstrapReadyBatchKey struct {
userID int64
authKeyID [8]byte
}
type bootstrapReadyBatchRequest struct {
userID int64
authKeyID [8]byte
sessionID int64
result chan bootstrapReadyBatchResult
}
type bootstrapReadyBatchResult struct {
matched int
err error
}
type bootstrapReadyBatchBackend interface {
store.BootstrapUpdateJobStore
markReadyForSessions(context.Context, []bootstrapReadyBatchRequest) ([]int, error)
}
// BatchedBootstrapUpdateJobStore preserves the synchronous post-response
// delivery fence while combining independent readiness selectors into one
// PostgreSQL statement. Once accepted, a selector waits for a definitive
// commit/error; it is never converted into an unobserved background write.
type BatchedBootstrapUpdateJobStore struct {
base bootstrapReadyBatchBackend
cfg BootstrapReadyBatchConfig
queue chan bootstrapReadyBatchRequest
stop chan struct{}
done chan struct{}
cancel context.CancelFunc
once sync.Once
gate sync.RWMutex
closed bool
}
func NewBatchedBootstrapUpdateJobStore(
base *BootstrapUpdateJobStore,
cfg BootstrapReadyBatchConfig,
) (*BatchedBootstrapUpdateJobStore, error) {
if base == nil || base.db == nil {
return nil, errors.New("initialize bootstrap readiness batcher: nil store")
}
return newBatchedBootstrapUpdateJobStore(base, cfg)
}
func newBatchedBootstrapUpdateJobStore(
base bootstrapReadyBatchBackend,
cfg BootstrapReadyBatchConfig,
) (*BatchedBootstrapUpdateJobStore, error) {
if base == nil {
return nil, errors.New("initialize bootstrap readiness batcher: nil backend")
}
if cfg.MaxSize <= 0 || cfg.MaxSize > 4096 {
return nil, fmt.Errorf("initialize bootstrap readiness batcher: max size %d outside [1,4096]", cfg.MaxSize)
}
if cfg.MaxWait <= 0 || cfg.MaxWait > time.Second {
return nil, fmt.Errorf("initialize bootstrap readiness batcher: max wait %v outside (0,1s]", cfg.MaxWait)
}
if cfg.QueueSize < cfg.MaxSize || cfg.QueueSize > 1<<20 {
return nil, fmt.Errorf("initialize bootstrap readiness batcher: queue size %d outside [%d,%d]", cfg.QueueSize, cfg.MaxSize, 1<<20)
}
if cfg.QueryTimeout <= 0 || cfg.QueryTimeout > 30*time.Second {
return nil, fmt.Errorf("initialize bootstrap readiness batcher: query timeout %v outside (0,30s]", cfg.QueryTimeout)
}
workerCtx, cancel := context.WithCancel(context.Background())
s := &BatchedBootstrapUpdateJobStore{
base: base, cfg: cfg,
queue: make(chan bootstrapReadyBatchRequest, cfg.QueueSize),
stop: make(chan struct{}), done: make(chan struct{}), cancel: cancel,
}
go s.run(workerCtx)
return s, nil
}
func (s *BatchedBootstrapUpdateJobStore) EnqueueLoginMessage(
ctx context.Context,
job domain.BootstrapUpdateJob,
) (domain.BootstrapUpdateJob, error) {
return s.base.EnqueueLoginMessage(ctx, job)
}
func (s *BatchedBootstrapUpdateJobStore) MarkReadyForSession(
ctx context.Context,
userID int64,
authKeyID [8]byte,
sessionID int64,
) (int, error) {
if ctx == nil {
ctx = context.Background()
}
request := bootstrapReadyBatchRequest{
userID: userID, authKeyID: authKeyID, sessionID: sessionID,
result: make(chan bootstrapReadyBatchResult, 1),
}
s.gate.RLock()
if s.closed {
s.gate.RUnlock()
return 0, context.Canceled
}
select {
case s.queue <- request:
if s.cfg.Metrics != nil {
s.cfg.Metrics.BootstrapReadyPending(1)
}
case <-ctx.Done():
s.gate.RUnlock()
return 0, ctx.Err()
}
s.gate.RUnlock()
// Accepted work ignores later caller cancellation and waits for the worker's
// definitive result. This prevents a physically delivered baseline from
// leaving an unknown asynchronous readiness mutation behind.
result := <-request.result
return result.matched, result.err
}
func (s *BatchedBootstrapUpdateJobStore) ClaimReady(
ctx context.Context,
limit int,
leaseTimeout time.Duration,
) ([]domain.BootstrapUpdateJob, error) {
return s.base.ClaimReady(ctx, limit, leaseTimeout)
}
func (s *BatchedBootstrapUpdateJobStore) MarkPublished(ctx context.Context, id int64) error {
return s.base.MarkPublished(ctx, id)
}
func (s *BatchedBootstrapUpdateJobStore) MarkFailed(ctx context.Context, id int64, lastError string) error {
return s.base.MarkFailed(ctx, id, lastError)
}
func (s *BatchedBootstrapUpdateJobStore) Close() {
s.once.Do(func() {
s.gate.Lock()
s.closed = true
close(s.stop)
s.cancel()
s.gate.Unlock()
<-s.done
})
}
func (s *BatchedBootstrapUpdateJobStore) run(ctx context.Context) {
defer close(s.done)
pending := make([]bootstrapReadyBatchRequest, 0, s.cfg.MaxSize)
for {
if len(pending) == 0 {
select {
case request := <-s.queue:
pending = append(pending, request)
case <-s.stop:
s.failQueued(context.Canceled, pending)
return
}
}
if len(pending) < s.cfg.MaxSize {
timer := time.NewTimer(s.cfg.MaxWait)
collect:
for len(pending) < s.cfg.MaxSize {
select {
case request := <-s.queue:
pending = append(pending, request)
case <-timer.C:
break collect
case <-s.stop:
stopAndDrainTimer(timer)
s.failQueued(context.Canceled, pending)
return
}
}
stopAndDrainTimer(timer)
}
batch, remaining := selectDistinctBootstrapReadyBatch(pending, s.cfg.MaxSize)
pending = remaining
s.execute(ctx, batch)
}
}
func stopAndDrainTimer(timer *time.Timer) {
if timer != nil && !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
func selectDistinctBootstrapReadyBatch(
pending []bootstrapReadyBatchRequest,
maxSize int,
) ([]bootstrapReadyBatchRequest, []bootstrapReadyBatchRequest) {
batch := make([]bootstrapReadyBatchRequest, 0, min(maxSize, len(pending)))
remaining := make([]bootstrapReadyBatchRequest, 0, len(pending))
seen := make(map[bootstrapReadyBatchKey]struct{}, min(maxSize, len(pending)))
for _, request := range pending {
if len(batch) >= maxSize {
remaining = append(remaining, request)
continue
}
key := bootstrapReadyBatchKey{userID: request.userID, authKeyID: request.authKeyID}
if _, exists := seen[key]; exists {
remaining = append(remaining, request)
continue
}
seen[key] = struct{}{}
batch = append(batch, request)
}
return batch, remaining
}
func (s *BatchedBootstrapUpdateJobStore) execute(ctx context.Context, batch []bootstrapReadyBatchRequest) {
if len(batch) == 0 {
return
}
started := time.Now()
queryCtx, cancel := context.WithTimeout(ctx, s.cfg.QueryTimeout)
results, err := s.base.markReadyForSessions(queryCtx, batch)
cancel()
matched := 0
if err == nil {
if len(results) != len(batch) {
err = fmt.Errorf("mark bootstrap readiness batch: result count %d, want %d", len(results), len(batch))
} else {
for _, count := range results {
matched += count
}
}
}
if s.cfg.Metrics != nil {
s.cfg.Metrics.BootstrapReadyBatch(len(batch), matched, time.Since(started), err)
}
for index, request := range batch {
result := bootstrapReadyBatchResult{err: err}
if err == nil {
result.matched = results[index]
}
request.result <- result
if s.cfg.Metrics != nil {
s.cfg.Metrics.BootstrapReadyPending(-1)
}
}
}
func (s *BatchedBootstrapUpdateJobStore) failQueued(err error, pending []bootstrapReadyBatchRequest) {
for _, request := range pending {
s.failRequest(request, err)
}
for {
select {
case request := <-s.queue:
s.failRequest(request, err)
default:
return
}
}
}
func (s *BatchedBootstrapUpdateJobStore) failRequest(request bootstrapReadyBatchRequest, err error) {
request.result <- bootstrapReadyBatchResult{err: err}
if s.cfg.Metrics != nil {
s.cfg.Metrics.BootstrapReadyPending(-1)
}
}
func (s *BootstrapUpdateJobStore) markReadyForSessions(
ctx context.Context,
requests []bootstrapReadyBatchRequest,
) ([]int, error) {
results := make([]int, len(requests))
if len(requests) == 0 {
return results, nil
}
userIDs := make([]int64, len(requests))
authKeyIDs := make([]int64, len(requests))
sessionIDs := make([]int64, len(requests))
seen := make(map[bootstrapReadyBatchKey]struct{}, len(requests))
for index, request := range requests {
key := bootstrapReadyBatchKey{userID: request.userID, authKeyID: request.authKeyID}
if _, duplicate := seen[key]; duplicate {
return nil, fmt.Errorf("mark bootstrap readiness batch: duplicate fence at index %d", index)
}
seen[key] = struct{}{}
userIDs[index] = request.userID
authKeyIDs[index] = authKeyIDToInt64(request.authKeyID)
sessionIDs[index] = request.sessionID
}
rows, err := s.db.Query(ctx, `
WITH input AS (
SELECT *
FROM unnest(
$1::bigint[],
$2::bigint[],
$3::bigint[]
) WITH ORDINALITY AS value(user_id, auth_key_id, session_id, ordinal)
), candidates AS MATERIALIZED (
SELECT input.ordinal,
input.session_id,
jobs.id
FROM input
JOIN bootstrap_update_jobs AS jobs
ON jobs.user_id = input.user_id
AND jobs.auth_key_id = input.auth_key_id
AND jobs.status = 'pending'
ORDER BY jobs.id, input.ordinal
FOR UPDATE OF jobs
), updated AS (
UPDATE bootstrap_update_jobs AS jobs
SET status = 'ready',
session_id = candidates.session_id,
ready_at = now(),
updated_at = now()
FROM candidates
WHERE jobs.id = candidates.id
RETURNING candidates.ordinal
)
SELECT ordinal, count(*)::bigint
FROM updated
GROUP BY ordinal
ORDER BY ordinal`, userIDs, authKeyIDs, sessionIDs)
if err != nil {
return nil, fmt.Errorf("mark bootstrap readiness batch: %w", err)
}
defer rows.Close()
for rows.Next() {
var ordinal, count int64
if err := rows.Scan(&ordinal, &count); err != nil {
return nil, fmt.Errorf("scan bootstrap readiness batch: %w", err)
}
index := int(ordinal - 1)
if index < 0 || index >= len(results) || results[index] != 0 || count <= 0 {
return nil, fmt.Errorf("mark bootstrap readiness batch: invalid ordinal/count %d/%d", ordinal, count)
}
results[index] = int(count)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("mark bootstrap readiness batch rows: %w", err)
}
return results, nil
}
var _ store.BootstrapUpdateJobStore = (*BatchedBootstrapUpdateJobStore)(nil)

View file

@ -0,0 +1,172 @@
package postgres
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"telesrv/internal/domain"
)
func TestSelectDistinctBootstrapReadyBatchDefersSameFence(t *testing.T) {
first := bootstrapReadyBatchRequest{userID: 1, authKeyID: [8]byte{1}, sessionID: 10}
duplicate := bootstrapReadyBatchRequest{userID: 1, authKeyID: [8]byte{1}, sessionID: 11}
other := bootstrapReadyBatchRequest{userID: 1, authKeyID: [8]byte{2}, sessionID: 12}
batch, remaining := selectDistinctBootstrapReadyBatch(
[]bootstrapReadyBatchRequest{first, duplicate, other},
3,
)
if len(batch) != 2 || batch[0].sessionID != 10 || batch[1].sessionID != 12 {
t.Fatalf("batch = %#v", batch)
}
if len(remaining) != 1 || remaining[0].sessionID != 11 {
t.Fatalf("remaining = %#v", remaining)
}
}
func TestBatchedBootstrapUpdateJobStoreCoalescesSynchronousSelectors(t *testing.T) {
const count = 16
backend := &fakeBootstrapReadyBackend{}
batcher, err := newBatchedBootstrapUpdateJobStore(backend, BootstrapReadyBatchConfig{
MaxSize: count, MaxWait: 100 * time.Millisecond,
QueueSize: count * 2, QueryTimeout: time.Second,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(batcher.Close)
start := make(chan struct{})
errs := make(chan error, count)
var wg sync.WaitGroup
for index := 0; index < count; index++ {
index := index
wg.Add(1)
go func() {
defer wg.Done()
<-start
matched, err := batcher.MarkReadyForSession(
context.Background(), int64(index+1), [8]byte{byte(index + 1)}, int64(index+100),
)
if err != nil {
errs <- err
} else if matched != 0 {
errs <- errors.New("unexpected bootstrap readiness match")
}
}()
}
close(start)
wg.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
if calls := backend.calls.Load(); calls != 1 {
t.Fatalf("batch calls = %d, want 1", calls)
}
if inputs := backend.inputs.Load(); inputs != count {
t.Fatalf("batch inputs = %d, want %d", inputs, count)
}
}
func TestBatchedBootstrapUpdateJobStoreCapacityAndShutdownAreExplicit(t *testing.T) {
started := make(chan struct{})
backend := &fakeBootstrapReadyBackend{started: started, block: true}
metrics := &fakeBootstrapReadyMetrics{}
batcher, err := newBatchedBootstrapUpdateJobStore(backend, BootstrapReadyBatchConfig{
MaxSize: 1, MaxWait: time.Millisecond, QueueSize: 1, QueryTimeout: time.Second, Metrics: metrics,
})
if err != nil {
t.Fatal(err)
}
results := make(chan error, 2)
go func() {
_, err := batcher.MarkReadyForSession(context.Background(), 1, [8]byte{1}, 1)
results <- err
}()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("first batch did not start")
}
go func() {
_, err := batcher.MarkReadyForSession(context.Background(), 2, [8]byte{2}, 2)
results <- err
}()
deadline := time.Now().Add(time.Second)
for metrics.pending.Load() != 2 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if metrics.pending.Load() != 2 {
t.Fatalf("pending = %d, want 2", metrics.pending.Load())
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if _, err := batcher.MarkReadyForSession(ctx, 3, [8]byte{3}, 3); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("capacity wait err = %v, want deadline exceeded", err)
}
batcher.Close()
for range 2 {
if err := <-results; !errors.Is(err, context.Canceled) {
t.Fatalf("shutdown result = %v, want canceled", err)
}
}
if pending := metrics.pending.Load(); pending != 0 {
t.Fatalf("pending after shutdown = %d", pending)
}
if _, err := batcher.MarkReadyForSession(context.Background(), 4, [8]byte{4}, 4); !errors.Is(err, context.Canceled) {
t.Fatalf("mark after close err = %v, want canceled", err)
}
}
type fakeBootstrapReadyBackend struct {
calls atomic.Int64
inputs atomic.Int64
started chan struct{}
block bool
once sync.Once
}
func (s *fakeBootstrapReadyBackend) markReadyForSessions(ctx context.Context, requests []bootstrapReadyBatchRequest) ([]int, error) {
s.calls.Add(1)
s.inputs.Add(int64(len(requests)))
if s.started != nil {
s.once.Do(func() { close(s.started) })
}
if s.block {
<-ctx.Done()
return nil, ctx.Err()
}
return make([]int, len(requests)), nil
}
func (*fakeBootstrapReadyBackend) EnqueueLoginMessage(context.Context, domain.BootstrapUpdateJob) (domain.BootstrapUpdateJob, error) {
return domain.BootstrapUpdateJob{}, nil
}
func (*fakeBootstrapReadyBackend) MarkReadyForSession(context.Context, int64, [8]byte, int64) (int, error) {
return 0, nil
}
func (*fakeBootstrapReadyBackend) ClaimReady(context.Context, int, time.Duration) ([]domain.BootstrapUpdateJob, error) {
return nil, nil
}
func (*fakeBootstrapReadyBackend) MarkPublished(context.Context, int64) error { return nil }
func (*fakeBootstrapReadyBackend) MarkFailed(context.Context, int64, string) error { return nil }
type fakeBootstrapReadyMetrics struct {
pending atomic.Int64
}
func (*fakeBootstrapReadyMetrics) BootstrapReadyBatch(int, int, time.Duration, error) {}
func (m *fakeBootstrapReadyMetrics) BootstrapReadyPending(delta int) {
m.pending.Add(int64(delta))
}

View file

@ -51,3 +51,60 @@ func TestBootstrapUpdateJobPostgresSameAuthKeyReconnectTakesOverPendingSession(t
t.Fatalf("bootstrap status/session = %s/%d, want ready/%d", status, sessionID, newSessionID)
}
}
func TestBootstrapUpdateJobPostgresMarksReadinessBatchByOrdinal(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
user := createLoginCodeDeliveryTestUser(t, ctx, pool, "bootstrap-batch")
messages := NewMessageStore(pool)
bootstrap := NewBootstrapUpdateJobStore(pool)
authKeyID := [8]byte{2, 4, 6, 8}
for index := 0; index < 2; index++ {
msg, err := messages.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()) + index,
Body: "Login code batch",
})
if err != nil {
t.Fatalf("create bootstrap message %d: %v", index, err)
}
if _, err := bootstrap.EnqueueLoginMessage(ctx, domain.BootstrapUpdateJob{
Kind: domain.BootstrapUpdateJobLoginMessage, UserID: user.ID,
AuthKeyID: authKeyID, SessionID: int64(100 + index), MessageBoxID: msg.ID,
}); err != nil {
t.Fatalf("enqueue bootstrap %d: %v", index, err)
}
}
results, err := bootstrap.markReadyForSessions(ctx, []bootstrapReadyBatchRequest{
{userID: user.ID + 1, authKeyID: authKeyID, sessionID: 700},
{userID: user.ID, authKeyID: [8]byte{9}, sessionID: 701},
{userID: user.ID, authKeyID: authKeyID, sessionID: 702},
})
if err != nil {
t.Fatal(err)
}
if len(results) != 3 || results[0] != 0 || results[1] != 0 || results[2] != 2 {
t.Fatalf("batch results = %#v, want [0 0 2]", results)
}
var count int
if err := pool.QueryRow(ctx, `
SELECT count(*)
FROM bootstrap_update_jobs
WHERE user_id = $1 AND auth_key_id = $2 AND status = 'ready' AND session_id = $3`,
user.ID, authKeyIDToInt64(authKeyID), int64(702)).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 2 {
t.Fatalf("ready jobs = %d, want 2", count)
}
if _, err := bootstrap.markReadyForSessions(ctx, []bootstrapReadyBatchRequest{
{userID: user.ID, authKeyID: authKeyID, sessionID: 1},
{userID: user.ID, authKeyID: authKeyID, sessionID: 2},
}); err == nil {
t.Fatal("duplicate fence accepted in one batch")
}
}

View file

@ -130,13 +130,10 @@ func (s *BotStore) DeleteBotAccount(ctx context.Context, botUserID int64) (domai
}
now := time.Now().UTC()
if err := enqueueAccountDeletionNotifications(ctx, tx, botUserID); err != nil {
return domain.User{}, err
}
if _, err := revokeByUserExceptTx(ctx, tx, botUserID, 0); err != nil {
return domain.User{}, fmt.Errorf("delete bot account: revoke sessions: %w", err)
}
if err := purgeDeletedAccountPrivateState(ctx, tx, botUserID, now); err != nil {
if err := purgeDeletedBotPrivateState(ctx, tx, botUserID, now); err != nil {
return domain.User{}, err
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, botUserID, "", ""); err != nil {

View file

@ -74,11 +74,11 @@ const (
maxVerificationIconNameBytes = 512
maxVerifierCompanyBytes = 512
// Rune-counting domain limits use their worst-case UTF-8 byte size in SQL.
// The final generated description may be longer than the 70-rune custom input.
maxVerifierDescriptionBytes = 280
// The final generated description may be longer than the custom-input limit.
maxVerifierDescriptionBytes = 4 * domain.MaxCustomVerificationDescriptionLength
maxVerifierGrantReasonBytes = 4096
maxCustomVerificationDescriptionBytes = 4096
maxCustomVerificationInputBytes = 280
maxCustomVerificationInputBytes = 4 * domain.MaxCustomVerificationDescriptionLength
maxCustomVerificationTitleBytes = 1024
maxCustomVerificationUsernameBytes = 64
maxCustomVerificationReasonBytes = 16384

View file

@ -1006,7 +1006,7 @@ func TestCustomVerificationRequestQueuePostgres(t *testing.T) {
}
// TestBotVerificationDescriptionsAcceptEmojiPostgres pins the app-configured
// 70-rune custom-description limit against UTF-8 byte constraints.
// configured custom-description limit against UTF-8 byte constraints.
func TestBotVerificationDescriptionsAcceptEmojiPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()

View file

@ -13,6 +13,25 @@ import (
"telesrv/internal/domain"
)
func (s *PasswordStore) HasBusinessAutomation(ctx context.Context, userID int64) (bool, error) {
var exists bool
err := s.db.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM user_business_profiles
WHERE user_id = $1
AND (greeting_message <> '{}'::jsonb OR away_message <> '{}'::jsonb)
UNION ALL
SELECT 1
FROM business_connected_bots
WHERE owner_user_id = $1
)`, userID).Scan(&exists)
if err != nil {
return false, fmt.Errorf("check business automation: %w", err)
}
return exists, nil
}
func (s *PasswordStore) GetBusinessProfile(ctx context.Context, userID int64) (domain.BusinessProfile, bool, error) {
row := s.db.QueryRow(ctx, `
SELECT

View file

@ -296,6 +296,46 @@ func TestBusinessStoresRoundTrip(t *testing.T) {
}
}
func TestHasBusinessAutomationUsesConfiguredGreetingOrAwayState(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
owner, err := NewUserStore(pool).Create(ctx, domain.User{
AccessHash: 51,
Phone: "+1999" + suffix + "01",
FirstName: "AutomationOwner",
})
if err != nil {
t.Fatalf("create owner: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
})
business := NewPasswordStore(pool)
if got, err := business.HasBusinessAutomation(ctx, owner.ID); err != nil || got {
t.Fatalf("empty HasBusinessAutomation = %v, %v; want false, nil", got, err)
}
if err := business.SaveBusinessProfile(ctx, domain.BusinessProfile{
UserID: owner.ID,
Intro: &domain.BusinessIntro{Title: "profile only"},
}); err != nil {
t.Fatalf("save non-automation profile: %v", err)
}
if got, err := business.HasBusinessAutomation(ctx, owner.ID); err != nil || got {
t.Fatalf("profile-only HasBusinessAutomation = %v, %v; want false, nil", got, err)
}
if err := business.SaveBusinessProfile(ctx, domain.BusinessProfile{
UserID: owner.ID,
Greeting: &domain.BusinessGreetingMessage{ShortcutID: 7},
}); err != nil {
t.Fatalf("save greeting profile: %v", err)
}
if got, err := business.HasBusinessAutomation(ctx, owner.ID); err != nil || !got {
t.Fatalf("greeting HasBusinessAutomation = %v, %v; want true, nil", got, err)
}
}
func randomSuffix(t *testing.T) string {
t.Helper()
var b [4]byte

View file

@ -0,0 +1,118 @@
package postgres
import (
"context"
"testing"
"telesrv/internal/domain"
)
func TestChannelActiveMembershipGenerationCoversMonoforumVisibility(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 971, Phone: "+1886" + suffix + "01", FirstName: "MonoVersionOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
subscriber, err := users.Create(ctx, domain.User{AccessHash: 972, Phone: "+1886" + suffix + "02", FirstName: "MonoVersionSubscriber"})
if err != nil {
t.Fatalf("create subscriber: %v", err)
}
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "Mono Version " + suffix, Broadcast: true, Date: 1700007110,
})
if err != nil {
t.Fatalf("create parent: %v", err)
}
enabled, err := channels.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true)
if err != nil {
t.Fatalf("enable monoforum: %v", err)
}
monoID := enabled.Channel.LinkedMonoforumID
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", []int64{created.Channel.ID, monoID})
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, subscriber.ID})
})
version := func(userID int64) int64 {
t.Helper()
var value int64
if err := pool.QueryRow(ctx, `
SELECT COALESCE((
SELECT version
FROM read_model_versions
WHERE model = 'channel_active_memberships'
AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1
), 0)`, userID).Scan(&value); err != nil {
t.Fatalf("read generation for %d: %v", userID, err)
}
return value
}
beforeSend := version(subscriber.ID)
sent, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: monoID, SenderUserID: subscriber.ID,
SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: subscriber.ID},
RandomID: 7711, Message: "visibility", Date: 1700007111,
})
if err != nil {
t.Fatalf("send monoforum message: %v", err)
}
if after := version(subscriber.ID); after <= beforeSend {
t.Fatalf("subscriber generation after send = %d, want > %d", after, beforeSend)
}
active, err := channels.ListActiveChannelIDsForUser(ctx, subscriber.ID, 0, 1000)
if err != nil {
t.Fatalf("list subscriber active IDs: %v", err)
}
if !containsInt64(active, monoID) {
t.Fatalf("subscriber active IDs = %v, want monoforum %d", active, monoID)
}
beforeDelete := version(subscriber.ID)
if _, err := pool.Exec(ctx, `
UPDATE channel_messages
SET deleted = true
WHERE channel_id = $1 AND id = $2`, monoID, sent.Message.ID); err != nil {
t.Fatalf("delete saved-peer message: %v", err)
}
if after := version(subscriber.ID); after <= beforeDelete {
t.Fatalf("subscriber generation after delete = %d, want > %d", after, beforeDelete)
}
active, err = channels.ListActiveChannelIDsForUser(ctx, subscriber.ID, 0, 1000)
if err != nil {
t.Fatalf("list subscriber active IDs after delete: %v", err)
}
if containsInt64(active, monoID) {
t.Fatalf("subscriber active IDs after last message delete = %v, monoforum remained", active)
}
beforeRights := version(owner.ID)
if _, err := pool.Exec(ctx, `
UPDATE channel_members
SET admin_rights = admin_rights || '{"ManageDirectMessages": true}'::jsonb,
updated_at = now()
WHERE channel_id = $1 AND user_id = $2`, created.Channel.ID, owner.ID); err != nil {
t.Fatalf("update manager rights: %v", err)
}
if after := version(owner.ID); after <= beforeRights {
t.Fatalf("manager generation after rights = %d, want > %d", after, beforeRights)
}
beforeToggleOwner := version(owner.ID)
beforeToggleSubscriber := version(subscriber.ID)
if _, err := channels.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, false); err != nil {
t.Fatalf("disable monoforum: %v", err)
}
if after := version(owner.ID); after <= beforeToggleOwner {
t.Fatalf("manager generation after disable = %d, want > %d", after, beforeToggleOwner)
}
// The subscriber no longer has a live message, so it is intentionally not
// part of the toggle fan-out. A previous deleted row cannot manufacture a
// new active-page dependency.
if after := version(subscriber.ID); after != beforeToggleSubscriber {
t.Fatalf("deleted-only subscriber generation after disable = %d, want %d", after, beforeToggleSubscriber)
}
}

View file

@ -115,7 +115,9 @@ func (s *ChannelStore) CreateChannel(ctx context.Context, req domain.CreateChann
if err != nil {
return domain.CreateChannelResult{}, fmt.Errorf("allocate channel message id: %w", err)
}
pts := 1
// PTS 1 is the empty channel message-box baseline. The create service
// message is the first real event, so its post-event state is 2.
pts := domain.FirstChannelEventPts
channel := domain.Channel{
ID: channelID,
AccessHash: accessHash,
@ -167,6 +169,13 @@ func (s *ChannelStore) CreateChannel(ctx context.Context, req domain.CreateChann
if err := insertChannelEventTx(ctx, tx, event); err != nil {
return domain.CreateChannelResult{}, err
}
if _, err := tx.Exec(ctx, `
UPDATE channel_update_checkpoints
SET retained_through_pts = $2,
updated_at = now()
WHERE channel_id = $1`, channelID, domain.InitialChannelPts); err != nil {
return domain.CreateChannelResult{}, fmt.Errorf("initialize channel pts baseline: %w", err)
}
for _, member := range members {
readMax := 0
if member.UserID == req.CreatorUserID {
@ -301,6 +310,13 @@ func (s *ChannelStore) ResolveChannel(ctx context.Context, viewerUserID, channel
return view, nil
}
// AuthoritativeResolveChannelCache declares that ResolveChannel is already
// protected by ChannelRowCache + ChannelMemberCache. Both consume exact
// channel_base/channel_member invalidations, reject stale in-flight writes by
// epoch, and flush after listener reconnect. The app layer must therefore not
// place a second read_model_versions gate in front of this store path.
func (*ChannelStore) AuthoritativeResolveChannelCache() {}
func (s *ChannelStore) GetChannels(ctx context.Context, viewerUserID int64, channelIDs []int64) ([]domain.ChannelView, error) {
if viewerUserID == 0 || len(channelIDs) == 0 {
return nil, nil
@ -573,19 +589,6 @@ WHERE id = $1`, channel.ID, participants, admins, kicked, banned); err != nil {
return channel, nil
}
func addPeerRef(peer domain.Peer, currentChannelID int64, userRefs, channelRefs map[int64]struct{}) {
switch peer.Type {
case domain.PeerTypeUser:
if peer.ID != 0 {
userRefs[peer.ID] = struct{}{}
}
case domain.PeerTypeChannel:
if peer.ID != 0 && peer.ID != currentChannelID {
channelRefs[peer.ID] = struct{}{}
}
}
}
func mapKeysInt64(items map[int64]struct{}) []int64 {
if len(items) == 0 {
return nil

View file

@ -7,6 +7,74 @@ import (
"telesrv/internal/domain"
)
func TestChannelCreateInitialPtsBaselinePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner := createTestUser(t, ctx, users, "+1887"+suffix+"80", "PtsOwner", "")
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: "Initial pts " + suffix,
Megagroup: true,
Date: 1_700_001_180,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID = created.Channel.ID
if created.Channel.Pts != domain.FirstChannelEventPts || created.Message.Pts != domain.FirstChannelEventPts ||
created.Event.Pts != domain.FirstChannelEventPts || created.Event.PtsCount != 1 {
t.Fatalf("create result = channel:%+v message:%+v event:%+v, want first event 2/1", created.Channel, created.Message, created.Event)
}
var channelPts, messagePts, eventPts, eventPtsCount, retainedFloor, latestPts int
if err := pool.QueryRow(ctx, `
SELECT c.pts, m.pts, e.pts, e.pts_count, cp.retained_through_pts, cp.latest_pts
FROM channels c
JOIN channel_messages m ON m.channel_id = c.id AND m.id = c.top_message_id
JOIN channel_update_events e ON e.channel_id = c.id AND e.message_id = m.id
JOIN channel_update_checkpoints cp ON cp.channel_id = c.id
WHERE c.id = $1`, channelID).Scan(
&channelPts, &messagePts, &eventPts, &eventPtsCount, &retainedFloor, &latestPts,
); err != nil {
t.Fatalf("read persisted initial pts: %v", err)
}
if channelPts != domain.FirstChannelEventPts || messagePts != domain.FirstChannelEventPts ||
eventPts != domain.FirstChannelEventPts || eventPtsCount != 1 ||
retainedFloor != domain.InitialChannelPts || latestPts != domain.FirstChannelEventPts {
t.Fatalf("persisted pts = channel:%d message:%d event:%d/%d checkpoint:%d/%d, want 2/2/2/1/1/2",
channelPts, messagePts, eventPts, eventPtsCount, retainedFloor, latestPts)
}
fromBaseline, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: owner.ID, ChannelID: channelID, Pts: domain.InitialChannelPts, Limit: 10,
})
if err != nil {
t.Fatalf("difference from baseline: %v", err)
}
if fromBaseline.TooLong || len(fromBaseline.Events) != 1 || fromBaseline.Events[0].Pts != domain.FirstChannelEventPts {
t.Fatalf("difference from baseline = %+v, want create event at pts=2", fromBaseline)
}
fromZero, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: owner.ID, ChannelID: channelID, Pts: 0, Limit: 10,
})
if err != nil {
t.Fatalf("difference from zero: %v", err)
}
if !fromZero.TooLong || fromZero.Pts != domain.FirstChannelEventPts || len(fromZero.NewMessages) != 1 {
t.Fatalf("difference from zero = %+v, want complete snapshot at pts=2", fromZero)
}
}
func TestChannelStoreGetChannelsBatchesVisibleAndPublicPreview(t *testing.T) {
pool := testPool(t)
ctx := context.Background()

View file

@ -2,6 +2,7 @@ package postgres
import (
"context"
"sync"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
@ -12,6 +13,14 @@ type channelDialogCacheKey struct {
channelID int64
}
type channelDialogCacheEntry struct {
dialog domain.ChannelDialog
listVisible bool
topMentioned bool
topMediaUnread bool
topUnreadProjected bool
}
// ChannelDialogCache 缓存 viewer 作用域的频道 dialog 投影,由统一缓存原语
// readmodelcache.Cache 承载(LRU 单条驱逐 / epoch 守卫 / singleflight 内建)。
//
@ -19,39 +28,59 @@ type channelDialogCacheKey struct {
// dialog_light(viewer,channel) 三个 read model;ReadModelChangeListener 在写侧 NOTIFY 时
// 失效对应键,重连时 flush。warm-from-list 经 put 回填(含 DefaultSendAs)。
type ChannelDialogCache struct {
cache *readmodelcache.Cache[channelDialogCacheKey, domain.ChannelDialog]
cache *readmodelcache.Cache[channelDialogCacheKey, channelDialogCacheEntry]
indexMu sync.Mutex
channelKeys map[int64]map[channelDialogCacheKey]struct{}
}
func NewChannelDialogCache(max int) *ChannelDialogCache {
cache := readmodelcache.New[channelDialogCacheKey, domain.ChannelDialog](readmodelcache.Config[channelDialogCacheKey, domain.ChannelDialog]{
c := &ChannelDialogCache{channelKeys: make(map[int64]map[channelDialogCacheKey]struct{})}
cache := readmodelcache.New[channelDialogCacheKey, channelDialogCacheEntry](readmodelcache.Config[channelDialogCacheKey, channelDialogCacheEntry]{
MaxEntries: max,
Clone: cloneChannelDialog,
Clone: cloneChannelDialogCacheEntry,
OnStore: c.indexEntry,
OnRemove: c.unindexEntry,
})
if cache == nil {
return nil
}
return &ChannelDialogCache{cache: cache}
c.cache = cache
return c
}
func (c *ChannelDialogCache) get(userID, channelID int64) (domain.ChannelDialog, bool) {
if c == nil || userID == 0 || channelID == 0 {
return domain.ChannelDialog{}, false
}
return c.cache.Peek(channelDialogCacheKey{userID: userID, channelID: channelID})
entry, ok := c.cache.Peek(channelDialogCacheKey{userID: userID, channelID: channelID})
return entry.dialog, ok
}
func (c *ChannelDialogCache) getListProjection(userID, channelID int64) (channelDialogCacheEntry, bool) {
if c == nil || userID == 0 || channelID == 0 {
return channelDialogCacheEntry{}, false
}
entry, ok := c.cache.Peek(channelDialogCacheKey{userID: userID, channelID: channelID})
return entry, ok && entry.listVisible
}
func (c *ChannelDialogCache) getOrLoad(ctx context.Context, userID, channelID int64, load func() (domain.ChannelDialog, error)) (domain.ChannelDialog, error) {
if c == nil || userID == 0 || channelID == 0 {
return load()
}
return c.cache.GetOrLoad(ctx, channelDialogCacheKey{userID: userID, channelID: channelID}, load)
entry, err := c.cache.GetOrLoad(ctx, channelDialogCacheKey{userID: userID, channelID: channelID}, func() (channelDialogCacheEntry, error) {
dialog, err := load()
return channelDialogCacheEntry{dialog: dialog}, err
})
return entry.dialog, err
}
func (c *ChannelDialogCache) put(dialog domain.ChannelDialog) {
if c == nil || dialog.UserID == 0 || dialog.ChannelID == 0 {
return
}
c.cache.Store(channelDialogCacheKey{userID: dialog.UserID, channelID: dialog.ChannelID}, dialog)
c.cache.Store(channelDialogCacheKey{userID: dialog.UserID, channelID: dialog.ChannelID}, channelDialogCacheEntry{dialog: dialog})
}
// cacheEpoch 在「列表暖写回」前快照 epoch配合 putIfEpoch 堵住 warm-vs-invalidation
@ -67,7 +96,30 @@ func (c *ChannelDialogCache) putIfEpoch(dialog domain.ChannelDialog, loadEpoch u
if c == nil || dialog.UserID == 0 || dialog.ChannelID == 0 {
return
}
c.cache.StoreIfEpoch(channelDialogCacheKey{userID: dialog.UserID, channelID: dialog.ChannelID}, dialog, loadEpoch)
c.cache.StoreIfEpoch(channelDialogCacheKey{userID: dialog.UserID, channelID: dialog.ChannelID}, channelDialogCacheEntry{dialog: dialog}, loadEpoch)
}
func (c *ChannelDialogCache) putListProjectionIfEpoch(
dialog domain.ChannelDialog,
topMentioned bool,
topMediaUnread bool,
topUnreadProjected bool,
loadEpoch uint64,
) {
if c == nil || dialog.UserID == 0 || dialog.ChannelID == 0 {
return
}
c.cache.StoreIfEpoch(
channelDialogCacheKey{userID: dialog.UserID, channelID: dialog.ChannelID},
channelDialogCacheEntry{
dialog: dialog,
listVisible: true,
topMentioned: topMentioned,
topMediaUnread: topMediaUnread,
topUnreadProjected: topUnreadProjected,
},
loadEpoch,
)
}
func (c *ChannelDialogCache) delete(userID, channelID int64) {
@ -81,7 +133,14 @@ func (c *ChannelDialogCache) deleteChannel(channelID int64) {
if c == nil || channelID == 0 {
return
}
c.cache.InvalidateWhere(func(k channelDialogCacheKey) bool { return k.channelID == channelID })
c.indexMu.Lock()
indexed := c.channelKeys[channelID]
keys := make([]channelDialogCacheKey, 0, len(indexed))
for key := range indexed {
keys = append(keys, key)
}
c.indexMu.Unlock()
c.cache.Invalidate(keys...)
}
func (c *ChannelDialogCache) flush() {
@ -89,12 +148,36 @@ func (c *ChannelDialogCache) flush() {
return
}
c.cache.Flush()
c.indexMu.Lock()
c.channelKeys = make(map[int64]map[channelDialogCacheKey]struct{})
c.indexMu.Unlock()
}
func cloneChannelDialog(dialog domain.ChannelDialog) domain.ChannelDialog {
if dialog.DefaultSendAs != nil {
peer := *dialog.DefaultSendAs
dialog.DefaultSendAs = &peer
func (c *ChannelDialogCache) indexEntry(key channelDialogCacheKey, _ channelDialogCacheEntry) {
c.indexMu.Lock()
keys := c.channelKeys[key.channelID]
if keys == nil {
keys = make(map[channelDialogCacheKey]struct{})
c.channelKeys[key.channelID] = keys
}
return dialog
keys[key] = struct{}{}
c.indexMu.Unlock()
}
func (c *ChannelDialogCache) unindexEntry(key channelDialogCacheKey, _ channelDialogCacheEntry) {
c.indexMu.Lock()
keys := c.channelKeys[key.channelID]
delete(keys, key)
if len(keys) == 0 {
delete(c.channelKeys, key.channelID)
}
c.indexMu.Unlock()
}
func cloneChannelDialogCacheEntry(entry channelDialogCacheEntry) channelDialogCacheEntry {
if entry.dialog.DefaultSendAs != nil {
peer := *entry.dialog.DefaultSendAs
entry.dialog.DefaultSendAs = &peer
}
return entry
}

View file

@ -67,6 +67,26 @@ func TestChannelDialogCachePutGetDeleteFlushAndClone(t *testing.T) {
}
}
func TestChannelDialogCacheSeparatesAuthoritativeListProjection(t *testing.T) {
c := NewChannelDialogCache(16)
dialog := domain.ChannelDialog{UserID: 10, ChannelID: 20, TopMessageID: 9}
c.put(dialog)
if _, ok := c.getListProjection(10, 20); ok {
t.Fatal("single-channel cache entry without active-list proof must not enter getDialogs")
}
epoch := c.cacheEpoch()
c.putListProjectionIfEpoch(dialog, true, true, true, epoch)
entry, ok := c.getListProjection(10, 20)
if !ok || !entry.listVisible || !entry.topMentioned || !entry.topMediaUnread || !entry.topUnreadProjected {
t.Fatalf("list projection = %+v,%v", entry, ok)
}
entry.dialog.TopMessageID = 99
again, ok := c.getListProjection(10, 20)
if !ok || again.dialog.TopMessageID != 9 {
t.Fatalf("list projection clone isolation = %+v,%v", again, ok)
}
}
func TestChannelDialogCacheDeleteChannelAndCap(t *testing.T) {
c := NewChannelDialogCache(2)
c.put(domain.ChannelDialog{UserID: 1, ChannelID: 10, TopMessageID: 1})

View file

@ -96,7 +96,8 @@ func TestChannelStoreListDialogsWarmsDialogCache(t *testing.T) {
})
cache := NewChannelDialogCache(16)
channels := NewChannelStore(pool, WithChannelDialogCache(cache))
memberCache := NewChannelMemberCache(16)
channels := NewChannelStore(pool, WithChannelDialogCache(cache), WithChannelMemberCache(memberCache))
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Dialog Warm " + suffix,
@ -127,6 +128,80 @@ func TestChannelStoreListDialogsWarmsDialogCache(t *testing.T) {
}
}
func TestChannelStoreMaterializedSnapshotWarmsExactDialogCache(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: 37,
Phone: "+1777" + suffix + "14",
FirstName: "SnapshotWarmOwner",
})
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)
})
cache := NewChannelDialogCache(16)
memberCache := NewChannelMemberCache(16)
channels := NewChannelStore(pool, WithChannelDialogCache(cache), WithChannelMemberCache(memberCache))
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Snapshot Warm " + suffix,
Megagroup: true,
Date: 1700000326,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID = created.Channel.ID
if _, err := pool.Exec(ctx, `
UPDATE channel_dialogs
SET default_send_as_peer_type = 'channel', default_send_as_peer_id = $2
WHERE user_id = $1 AND channel_id = $2`, owner.ID, channelID); err != nil {
t.Fatalf("seed default send as: %v", err)
}
ownerSnapshot, err := channels.ListAllBuiltinChannelDialogSnapshot(ctx, owner.ID)
if err != nil {
t.Fatalf("list materialized owner snapshot: %v", err)
}
if len(ownerSnapshot.Dialogs) != 1 || ownerSnapshot.Dialogs[0].DefaultSendAs == nil ||
ownerSnapshot.Dialogs[0].DefaultSendAs.Type != domain.PeerTypeChannel ||
ownerSnapshot.Dialogs[0].DefaultSendAs.ID != channelID ||
ownerSnapshot.Dialogs[0].ChannelMember == nil ||
ownerSnapshot.Dialogs[0].ChannelMember.UserID != owner.ID ||
ownerSnapshot.Dialogs[0].ChannelMember.ChannelID != channelID ||
ownerSnapshot.Dialogs[0].ChannelMember.Status != domain.ChannelMemberActive {
t.Fatalf("owner snapshot default send as = %+v", ownerSnapshot.Dialogs)
}
if _, ok := cache.get(owner.ID, channelID); ok {
t.Fatal("owner snapshot scan alone must not warm cache before shared hydration")
}
if _, err := channels.HydrateChannelDialogSnapshot(ctx, owner.ID, ownerSnapshot.Dialogs); err != nil {
t.Fatalf("hydrate materialized owner snapshot: %v", err)
}
cached, ok := cache.get(owner.ID, channelID)
if !ok || cached.TopMessageID != ownerSnapshot.Dialogs[0].TopMessage ||
cached.DefaultSendAs == nil || cached.DefaultSendAs.Type != domain.PeerTypeChannel ||
cached.DefaultSendAs.ID != channelID {
t.Fatalf("exact warmed dialog = %+v ok=%v", cached, ok)
}
warmedMember, ok := memberCache.get(channelID, owner.ID)
if !ok || warmedMember.ChannelID != channelID || warmedMember.UserID != owner.ID ||
warmedMember.Status != domain.ChannelMemberActive || warmedMember.Role != domain.ChannelRoleCreator {
t.Fatalf("exact warmed member = %+v ok=%v", warmedMember, ok)
}
}
func TestChannelStoreListDialogsScansChannelWallpaper(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
@ -315,6 +390,18 @@ FROM unnest($1::bigint[]) AS t(id)`, ids, owner.ID); err != nil {
}
channels := NewChannelStore(pool)
headers, err := channels.ListChannelDialogSnapshotHeaders(ctx, owner.ID, domain.DialogFilter{})
if err != nil {
t.Fatalf("list channel dialog snapshot headers: %v", err)
}
if len(headers.Dialogs) != count || headers.Count != count || len(headers.Messages) != 0 || len(headers.Channels) != 0 {
t.Fatalf("snapshot headers dialogs=%d count=%d messages=%d channels=%d, want %d lightweight headers",
len(headers.Dialogs), headers.Count, len(headers.Messages), len(headers.Channels), count)
}
if headers.Dialogs[0].Peer.ID != ids[len(ids)-1] || headers.Dialogs[len(headers.Dialogs)-1].Peer.ID != ids[0] {
t.Fatalf("snapshot header bounds = %d..%d, want %d..%d",
headers.Dialogs[0].Peer.ID, headers.Dialogs[len(headers.Dialogs)-1].Peer.ID, ids[len(ids)-1], ids[0])
}
var cursor domain.Dialog
var sixth domain.ChannelDialogList
for page := 0; page < 6; page++ {
@ -332,6 +419,9 @@ FROM unnest($1::bigint[]) AS t(id)`, ids, owner.ID); err != nil {
if len(got.Dialogs) == 0 {
t.Fatalf("page %d unexpectedly empty after cursor %+v", page+1, cursor)
}
if page < 5 && got.Count <= len(got.Dialogs) {
t.Fatalf("page %d count = %d dialogs = %d, want bounded has-more signal", page+1, got.Count, len(got.Dialogs))
}
cursor = got.Dialogs[len(got.Dialogs)-1]
if page == 5 {
sixth = got
@ -423,4 +513,31 @@ VALUES ($1, $2, $3, 1, 1700000500)`, owner.ID, archivedID, domain.DialogArchiveF
if len(archive.Dialogs) != 1 || archive.Dialogs[0].Peer.ID != archivedID {
t.Fatalf("archive dialogs = %+v, want archived channel beyond first query window", archive.Dialogs)
}
archiveHeaders, err := NewChannelStore(pool).ListChannelDialogSnapshotHeaders(ctx, owner.ID, domain.DialogFilter{
HasFolderID: true,
FolderID: domain.DialogArchiveFolderID,
})
if err != nil {
t.Fatalf("list archive channel snapshot headers: %v", err)
}
if len(archiveHeaders.Dialogs) != 1 || archiveHeaders.Dialogs[0].Peer.ID != archivedID {
t.Fatalf("archive snapshot headers = %+v, want archived channel", archiveHeaders.Dialogs)
}
allHeaders, err := NewChannelStore(pool).ListAllBuiltinChannelDialogSnapshot(ctx, owner.ID)
if err != nil {
t.Fatalf("list all built-in channel snapshot headers: %v", err)
}
if len(allHeaders.Dialogs) != count {
t.Fatalf("all built-in snapshot headers = %d, want %d", len(allHeaders.Dialogs), count)
}
foundArchived := false
for _, dialog := range allHeaders.Dialogs {
if dialog.Peer.ID == archivedID {
foundArchived = dialog.FolderID == domain.DialogArchiveFolderID
break
}
}
if !foundArchived {
t.Fatalf("all built-in snapshot did not retain archived channel %d", archivedID)
}
}

View file

@ -29,7 +29,7 @@ func TestChannelDialogTopMessageCarriesMentionFlags(t *testing.T) {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID})
})
channels := NewChannelStore(pool)
channels := NewChannelStore(pool, WithChannelTopMessageCache(NewChannelTopMessageCache(32)))
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "MentionDialog " + suffix,

View file

@ -3,6 +3,7 @@ package postgres
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
@ -13,9 +14,13 @@ import (
)
type channelDialogListItem struct {
channel domain.Channel
dialog domain.Dialog
defaultSendAs *domain.Peer
channel domain.Channel
dialog domain.Dialog
defaultSendAs *domain.Peer
topMentioned bool
topMediaUnread bool
topUnreadProjected bool
listCacheable bool
}
func channelDialogVisibleTopIDSQL() string {
@ -39,6 +44,10 @@ END`
}
func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) {
return s.listChannelDialogs(ctx, viewerUserID, filter)
}
func (s *ChannelStore) listChannelDialogs(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) {
if viewerUserID == 0 {
return domain.ChannelDialogList{}, nil
}
@ -59,6 +68,21 @@ func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int6
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
args := []any{viewerUserID, channelIDs}
where := []string{"m.user_id = $1", "m.channel_id = ANY($2::bigint[])", "m.status = 'active'"}
from := `FROM channel_members m
JOIN channels c ON c.id = m.channel_id AND c.id = ANY($2::bigint[]) AND NOT c.deleted
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.channel_id = ANY($2::bigint[]) AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id`
// archive 与 pinned 集合必然有显式 channel_dialogs 行。以该 owner 索引为入口,
// 避免 archive summary(limit=1) 和 getPinnedDialogs 为一个通常为空/很小的集合
// 仍扫描账号全部 active memberships。保留 $2 的 typed no-op后续动态条件的
// placeholder 编号无需分叉monoforum 仍在主查询后按原权限路径合并。
if (filter.HasFolderID && filter.FolderID == domain.DialogArchiveFolderID) || filter.PinnedOnly {
from = `FROM channel_dialogs d
JOIN channel_members m ON m.user_id = d.user_id AND m.channel_id = d.channel_id AND m.status = 'active'
JOIN channels c ON c.id = d.channel_id AND NOT c.deleted
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = d.channel_id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted`
where = []string{"d.user_id = $1", "cardinality($2::bigint[]) >= 0"}
}
if filter.HasFolderID && filter.FolderID < domain.DialogCustomFolderMinID {
args = append(args, filter.FolderID)
where = append(where, fmt.Sprintf("COALESCE(d.folder_id, 0) = $%d", len(args)))
@ -138,7 +162,13 @@ func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int6
where = append(where, "false")
}
}
args = append(args, channelDialogQueryLimit)
// 只多取一行作为 has-more 证据。旧实现无视 RPC limit 固定读取 500 个完整
// channel + viewer dialog并对每行动态派生 unreadgetDialogs(limit=100)
// 因而最多水合 5 倍对象archive summary(limit=1) 最坏放大 500 倍。
// 所有 folder/offset 条件已经在 SQL LIMIT 前完成monoforum 结果也会在下方
// 合并排序,所以每个来源取 limit+1 足以构造正确的合并页和继续分页信号。
queryLimit := limit + 1
args = append(args, queryLimit)
limitArg := fmt.Sprintf("$%d", len(args))
// 暖写回的 epoch 守卫:在加载前快照,写回时若期间收到失效(epoch 变更)则拒绝陈旧投影,
// 避免 scan→put 窗口内的并发失效被裸 Store 覆盖回(@角标/未读 lost-update)。
@ -162,10 +192,7 @@ SELECT `+channelColumns+`,
d.default_send_as_peer_id,
m.history_clear_anchor_id,
m.history_clear_anchor_date
FROM channel_members m
JOIN channels c ON c.id = m.channel_id AND c.id = ANY($2::bigint[]) AND NOT c.deleted
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.channel_id = ANY($2::bigint[]) AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id
`+from+`
WHERE `+strings.Join(where, " AND ")+`
ORDER BY COALESCE(d.pinned, false) DESC,
COALESCE(d.pinned_order, 0) DESC,
@ -243,7 +270,7 @@ LIMIT `+limitArg, args...)
// default_send_as否则 getFullChannel/getSendAs 命中暖缓存会丢失「以频道发言」默认值。
cd := channelDialogFromDialog(viewerUserID, item.dialog)
cd.DefaultSendAs = item.defaultSendAs
s.dialogCache.putIfEpoch(cd, dialogCacheEpoch)
s.dialogCache.putListProjectionIfEpoch(cd, false, false, false, dialogCacheEpoch)
}
out.Dialogs = append(out.Dialogs, item.dialog)
out.Channels = append(out.Channels, item.channel)
@ -251,13 +278,308 @@ LIMIT `+limitArg, args...)
// getDialogs 的 top message 必须按 viewer 补 mentioned/media_unread 与
// reactionsTDesktop 把它先入缓存且不被后续 difference/getHistory 的
// 完整版覆盖,缺标志会让客户端永不上报 contents-read@ 角标重启回潮。
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil {
if err := s.populateChannelDialogTopMessageReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages, false); err != nil {
return domain.ChannelDialogList{}, err
}
projectChannelDialogHistoryClearMessages(out.Dialogs, out.Messages)
return out, nil
}
// ListChannelDialogSnapshotHeaders builds the bounded owner-specific ordering
// index used by app pagination. It intentionally excludes channel metadata and
// top-message payloads; those are hydrated per page through versioned peer read
// models, so one owner snapshot remains lightweight and shared channel facts do
// not get duplicated for every online account.
func (s *ChannelStore) ListChannelDialogSnapshotHeaders(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) {
if viewerUserID == 0 {
return domain.ChannelDialogList{}, nil
}
if filter.Folder != nil || (filter.HasFolderID && filter.FolderID >= domain.DialogCustomFolderMinID) ||
filter.OffsetDate != 0 || filter.OffsetID != 0 || filter.HasOffsetPeer {
return domain.ChannelDialogList{}, errors.New("channel dialog snapshot headers require an offset-free built-in folder")
}
channelIDs, hasBroadcastAdmin, err := s.listActiveChannelDialogCandidateIDs(ctx, viewerUserID, false)
if err != nil {
return domain.ChannelDialogList{}, err
}
if len(channelIDs) == 0 {
return domain.ChannelDialogList{}, nil
}
visibleTopID := channelDialogVisibleTopIDSQL()
visibleTopDate := channelDialogVisibleTopDateSQL("COALESCE(top_msg.message_date, d.top_message_date, c.date)", "0")
args := []any{viewerUserID}
where := []string{"i.user_id = $1", "i.status = 'active'", "NOT i.deleted", "m.status = 'active'"}
from := `FROM user_channel_member_index i
JOIN channel_members m ON m.user_id = i.user_id AND m.channel_id = i.channel_id
JOIN channels c ON c.id = i.channel_id AND NOT c.deleted
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = i.channel_id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id`
if (filter.HasFolderID && filter.FolderID == domain.DialogArchiveFolderID) || filter.PinnedOnly {
from = `FROM channel_dialogs d
JOIN user_channel_member_index i ON i.user_id = d.user_id AND i.channel_id = d.channel_id
JOIN channel_members m ON m.user_id = i.user_id AND m.channel_id = i.channel_id
JOIN channels c ON c.id = d.channel_id AND NOT c.deleted
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = d.channel_id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted`
where = []string{"d.user_id = $1", "i.status = 'active'", "NOT i.deleted", "m.status = 'active'"}
}
if filter.HasFolderID {
args = append(args, filter.FolderID)
where = append(where, fmt.Sprintf("COALESCE(d.folder_id, 0) = $%d", len(args)))
} else {
where = append(where, "COALESCE(d.folder_id, 0) = 0")
}
if filter.PinnedOnly {
where = append(where, "COALESCE(d.pinned, false)")
}
if filter.ExcludePinned {
where = append(where, "NOT COALESCE(d.pinned, false)")
}
args = append(args, channelDialogCandidateLimit)
rows, err := s.db.Query(ctx, `
WITH dependency_peers AS MATERIALIZED (
SELECT i.channel_id AS id
FROM user_channel_member_index i
WHERE i.user_id = $1
AND i.status = 'active'
AND NOT i.deleted
UNION
SELECT parent.linked_monoforum_id
FROM user_channel_member_index i
JOIN channels parent ON parent.id = i.channel_id
WHERE i.user_id = $1
AND i.status = 'active'
AND NOT i.deleted
AND parent.linked_monoforum_id <> 0
),
dependency AS MATERIALIZED (
SELECT COALESCE(bit_xor(v.hash), 0)::bigint AS hash
FROM read_model_versions v
JOIN dependency_peers peer ON peer.id = v.peer_id
WHERE v.peer_type = 'channel'
AND (
(v.model = 'channel_base' AND v.owner_user_id = 0)
OR
(v.model IN ('channel_member', 'dialog_light') AND v.owner_user_id = $1)
)
)
SELECT c.id,
`+visibleTopID+`,
`+visibleTopDate+`,
COALESCE(d.folder_id, 0),
COALESCE(d.pinned, false),
COALESCE(d.pinned_order, 0),
dependency.hash
`+from+`
CROSS JOIN dependency
WHERE `+strings.Join(where, " AND ")+`
ORDER BY COALESCE(d.pinned, false) DESC,
COALESCE(d.pinned_order, 0) DESC,
`+visibleTopDate+` DESC,
`+visibleTopID+` DESC,
c.id DESC
LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return domain.ChannelDialogList{}, fmt.Errorf("list channel dialog snapshot headers: %w", err)
}
defer rows.Close()
dialogs := make([]domain.Dialog, 0, minInt(len(channelIDs), 1024))
seenChannels := make(map[int64]struct{}, len(channelIDs))
var dependencyHash int64
for rows.Next() {
var dialog domain.Dialog
var channelID int64
if err := rows.Scan(
&channelID,
&dialog.TopMessage,
&dialog.TopMessageDate,
&dialog.FolderID,
&dialog.Pinned,
&dialog.PinnedOrder,
&dependencyHash,
); err != nil {
return domain.ChannelDialogList{}, err
}
dialog.Peer = domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}
dialogs = append(dialogs, dialog)
seenChannels[channelID] = struct{}{}
}
if err := rows.Err(); err != nil {
return domain.ChannelDialogList{}, err
}
if hasBroadcastAdmin {
items, err := s.listMonoforumAdminDialogItems(ctx, viewerUserID, channelIDs, filter, seenChannels)
if err != nil {
return domain.ChannelDialogList{}, err
}
for _, item := range items {
dialogs = append(dialogs, item.dialog)
}
}
if len(dialogs) > channelDialogCandidateLimit {
return domain.ChannelDialogList{}, fmt.Errorf("channel dialog snapshot exceeds %d entries", channelDialogCandidateLimit)
}
sort.SliceStable(dialogs, func(i, j int) bool {
if dialogs[i].Pinned != dialogs[j].Pinned {
return dialogs[i].Pinned
}
if dialogs[i].PinnedOrder != dialogs[j].PinnedOrder {
return dialogs[i].PinnedOrder > dialogs[j].PinnedOrder
}
if dialogs[i].TopMessageDate != dialogs[j].TopMessageDate {
return dialogs[i].TopMessageDate > dialogs[j].TopMessageDate
}
if dialogs[i].TopMessage != dialogs[j].TopMessage {
return dialogs[i].TopMessage > dialogs[j].TopMessage
}
return dialogs[i].Peer.ID > dialogs[j].Peer.ID
})
return domain.ChannelDialogList{
Dialogs: dialogs,
Count: len(dialogs),
Hash: mixDialogListDependencyHash(dialogListHash(dialogs), dependencyHash),
}, nil
}
// ListAllBuiltinChannelDialogSnapshot loads every owner-varying dialog fact
// needed to derive main/archive/pinned pages in one bounded scan. Shared
// channel rows and top-message payloads remain channel-keyed response overlays.
func (s *ChannelStore) ListAllBuiltinChannelDialogSnapshot(ctx context.Context, viewerUserID int64) (domain.ChannelDialogList, error) {
if viewerUserID == 0 {
return domain.ChannelDialogList{}, nil
}
visibleTopID := channelDialogVisibleTopIDSQL()
visibleTopDate := channelDialogVisibleTopDateSQL("COALESCE(top_msg.message_date, d.top_message_date, c.date)", "0")
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
rows, err := s.db.Query(ctx, `
SELECT c.id,
`+visibleTopID+`,
`+visibleTopDate+`,
COALESCE(d.folder_id, 0),
`+visibleReadInbox+`,
LEAST(GREATEST(c.top_message_id, 0), GREATEST(COALESCE(d.read_outbox_max_id, 0), m.read_outbox_max_id, CASE WHEN c.read_inbox_top1_user_id = m.user_id THEN c.read_inbox_top2 ELSE c.read_inbox_top1 END)),
`+visibleUnreadCount+`,
COALESCE(d.pinned, false),
COALESCE(d.pinned_order, 0),
COALESCE(d.unread_mark, m.unread_mark),
COALESCE(d.unread_mentions_count, 0),
COALESCE(d.unread_reactions_count, 0),
COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type,
d.default_send_as_peer_id,
m.history_clear_anchor_id,
m.history_clear_anchor_date,
top_unread.message_id IS NOT NULL,
COALESCE(top_unread.unread, false),
m.user_id,
m.inviter_user_id,
m.role,
m.status,
m.joined_at,
m.left_at,
m.admin_rights::text,
m.banned_rights::text,
m.rank,
m.available_min_id,
m.available_min_pts,
m.read_inbox_max_id,
m.read_outbox_max_id,
m.unread_mark,
m.slowmode_last_send_date,
bool_or(i.broadcast AND i.role IN ('creator', 'admin')) OVER ()
FROM user_channel_member_index AS i
JOIN channel_members AS m
ON m.user_id = i.user_id
AND m.channel_id = i.channel_id
AND m.status = 'active'
JOIN channels AS c
ON c.id = i.channel_id
AND NOT c.deleted
LEFT JOIN channel_messages AS top_msg
ON top_msg.channel_id = i.channel_id
AND top_msg.id = c.top_message_id
AND NOT top_msg.deleted
LEFT JOIN channel_dialogs AS d
ON d.user_id = i.user_id
AND d.channel_id = i.channel_id
LEFT JOIN channel_unread_mentions AS top_unread
ON top_unread.user_id = i.user_id
AND top_unread.channel_id = i.channel_id
AND top_unread.message_id = `+visibleTopID+`
WHERE i.user_id = $1
AND i.status = 'active'
AND NOT i.deleted
AND COALESCE(d.folder_id, 0) IN (0, 1)
ORDER BY COALESCE(d.pinned, false) DESC,
COALESCE(d.pinned_order, 0) DESC,
`+visibleTopDate+` DESC,
`+visibleTopID+` DESC,
c.id DESC
LIMIT $2`, viewerUserID, channelDialogCandidateLimit+1)
if err != nil {
return domain.ChannelDialogList{}, fmt.Errorf("list all built-in channel dialog snapshot: %w", err)
}
defer rows.Close()
dialogs := make([]domain.Dialog, 0, 128)
parentChannelIDs := make([]int64, 0, 128)
seenChannels := make(map[int64]struct{}, 128)
hasBroadcastAdmin := false
for rows.Next() {
var values channelDialogProjectionValues
var rowHasBroadcastAdmin bool
destinations := append(values.scanDestinations(), &rowHasBroadcastAdmin)
if err := rows.Scan(destinations...); err != nil {
return domain.ChannelDialogList{}, fmt.Errorf("scan all built-in channel dialog snapshot: %w", err)
}
channelID, dialog, defaultSendAs, _, _ := values.result()
dialog.DefaultSendAs = defaultSendAs
dialogs = append(dialogs, dialog)
parentChannelIDs = append(parentChannelIDs, channelID)
seenChannels[channelID] = struct{}{}
hasBroadcastAdmin = hasBroadcastAdmin || rowHasBroadcastAdmin
}
if err := rows.Err(); err != nil {
return domain.ChannelDialogList{}, fmt.Errorf("list all built-in channel dialog snapshot rows: %w", err)
}
if len(dialogs) > channelDialogCandidateLimit {
return domain.ChannelDialogList{}, fmt.Errorf("channel dialog snapshot exceeds %d entries", channelDialogCandidateLimit)
}
if hasBroadcastAdmin {
items, err := s.listMonoforumAdminDialogItems(
ctx, viewerUserID, parentChannelIDs, domain.DialogFilter{}, seenChannels,
)
if err != nil {
return domain.ChannelDialogList{}, err
}
for _, item := range items {
if item.dialog.FolderID == domain.DialogMainFolderID || item.dialog.FolderID == domain.DialogArchiveFolderID {
item.dialog.DefaultSendAs = item.defaultSendAs
dialogs = append(dialogs, item.dialog)
}
}
}
if len(dialogs) > channelDialogCandidateLimit {
return domain.ChannelDialogList{}, fmt.Errorf("channel dialog snapshot exceeds %d entries", channelDialogCandidateLimit)
}
sort.SliceStable(dialogs, func(i, j int) bool {
if dialogs[i].Pinned != dialogs[j].Pinned {
return dialogs[i].Pinned
}
if dialogs[i].PinnedOrder != dialogs[j].PinnedOrder {
return dialogs[i].PinnedOrder > dialogs[j].PinnedOrder
}
if dialogs[i].TopMessageDate != dialogs[j].TopMessageDate {
return dialogs[i].TopMessageDate > dialogs[j].TopMessageDate
}
if dialogs[i].TopMessage != dialogs[j].TopMessage {
return dialogs[i].TopMessage > dialogs[j].TopMessage
}
return dialogs[i].Peer.ID > dialogs[j].Peer.ID
})
return domain.ChannelDialogList{Dialogs: dialogs, Count: len(dialogs)}, nil
}
func (s *ChannelStore) listMonoforumAdminDialogItems(ctx context.Context, viewerUserID int64, parentChannelIDs []int64, filter domain.DialogFilter, seen map[int64]struct{}) ([]channelDialogListItem, error) {
if viewerUserID == 0 || len(parentChannelIDs) == 0 {
return nil, nil
@ -331,7 +653,7 @@ type channelMessageLookupKey struct {
func (s *ChannelStore) channelDialogTopMessages(ctx context.Context, db sqlcgen.DBTX, dialogs []domain.Dialog) (map[channelMessageLookupKey]domain.ChannelMessage, error) {
seen := make(map[channelMessageLookupKey]struct{}, len(dialogs))
idsByChannel := make(map[int64][]int, len(dialogs))
keys := make([]channelMessageLookupKey, 0, len(dialogs))
for _, dialog := range dialogs {
if dialog.Peer.Type != domain.PeerTypeChannel || dialog.Peer.ID == 0 || dialog.TopMessage <= 0 {
continue
@ -341,10 +663,55 @@ func (s *ChannelStore) channelDialogTopMessages(ctx context.Context, db sqlcgen.
continue
}
seen[key] = struct{}{}
idsByChannel[dialog.Peer.ID] = append(idsByChannel[dialog.Peer.ID], dialog.TopMessage)
keys = append(keys, key)
}
if len(keys) == 0 {
return nil, nil
}
load := func(ctx context.Context, missing []channelMessageLookupKey) (map[channelMessageLookupKey]domain.ChannelMessage, error) {
return loadChannelDialogTopMessages(ctx, db, missing)
}
var (
out map[channelMessageLookupKey]domain.ChannelMessage
err error
)
if s.topMessageCacheActive(db) {
out, err = s.topMsgCache.getOrLoadBatch(ctx, keys, load)
} else {
out, err = load(ctx, keys)
}
if err != nil {
return nil, err
}
// History-clear anchors are owner-local projections and must never enter
// the shared cache. Apply them to the cloned result after cache hydration.
for _, dialog := range dialogs {
if dialog.Peer.Type != domain.PeerTypeChannel ||
dialog.TopMessage <= 0 ||
dialog.TopMessage != dialog.HistoryClearAnchorID {
continue
}
key := channelMessageLookupKey{channelID: dialog.Peer.ID, id: dialog.TopMessage}
out[key] = domain.ProjectChannelHistoryClearMessage(
out[key],
dialog.Peer.ID,
dialog.HistoryClearAnchorID,
dialog.HistoryClearAnchorDate,
)
}
return out, nil
}
func loadChannelDialogTopMessages(ctx context.Context, db sqlcgen.DBTX, keys []channelMessageLookupKey) (map[channelMessageLookupKey]domain.ChannelMessage, error) {
idsByChannel := make(map[int64][]int, len(keys))
for _, key := range keys {
if key.channelID == 0 || key.id <= 0 {
continue
}
idsByChannel[key.channelID] = append(idsByChannel[key.channelID], key.id)
}
if len(idsByChannel) == 0 {
return nil, nil
return map[channelMessageLookupKey]domain.ChannelMessage{}, nil
}
channelIDs := make([]int64, 0, len(idsByChannel))
for channelID := range idsByChannel {
@ -371,7 +738,7 @@ WHERE `+where.String(), args...)
return nil, fmt.Errorf("list channel dialog top messages: %w", err)
}
defer rows.Close()
out := make(map[channelMessageLookupKey]domain.ChannelMessage, len(seen))
out := make(map[channelMessageLookupKey]domain.ChannelMessage, len(keys))
for rows.Next() {
msg, err := scanChannelMessage(rows)
if err != nil {
@ -382,25 +749,14 @@ WHERE `+where.String(), args...)
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("scan channel dialog top messages: %w", err)
}
for _, dialog := range dialogs {
if dialog.Peer.Type != domain.PeerTypeChannel ||
dialog.TopMessage <= 0 ||
dialog.TopMessage != dialog.HistoryClearAnchorID {
continue
}
key := channelMessageLookupKey{channelID: dialog.Peer.ID, id: dialog.TopMessage}
out[key] = domain.ProjectChannelHistoryClearMessage(
out[key],
dialog.Peer.ID,
dialog.HistoryClearAnchorID,
dialog.HistoryClearAnchorDate,
)
}
return out, nil
}
func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64, channelIDs []int64) (domain.ChannelDialogList, error) {
out := domain.ChannelDialogList{}
if viewerUserID == 0 || len(channelIDs) == 0 {
return domain.ChannelDialogList{}, nil
}
orderedIDs := make([]int64, 0, len(channelIDs))
seen := make(map[int64]struct{}, len(channelIDs))
for _, channelID := range channelIDs {
if channelID == 0 {
@ -410,6 +766,79 @@ func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64
continue
}
seen[channelID] = struct{}{}
orderedIDs = append(orderedIDs, channelID)
}
if len(orderedIDs) == 0 {
return domain.ChannelDialogList{}, nil
}
itemsByID := make(map[int64]channelDialogListItem, len(orderedIDs))
loadedIDs := make([]int64, 0, len(orderedIDs))
misses := make([]int64, 0, len(orderedIDs))
dialogCacheActive := s.dialogCacheActive(s.db)
memberCacheActive := s.memberCacheActive(s.db)
var dialogCacheEpoch uint64
var memberCacheEpoch uint64
if dialogCacheActive {
dialogCacheEpoch = s.dialogCache.cacheEpoch()
}
if memberCacheActive {
memberCacheEpoch = s.memberCache.cacheEpoch()
}
for _, channelID := range orderedIDs {
if dialogCacheActive {
if cached, ok := s.dialogCache.getListProjection(viewerUserID, channelID); ok {
itemsByID[channelID] = channelDialogListItem{
dialog: channelDialogToDialog(cached.dialog, 0),
defaultSendAs: cached.dialog.DefaultSendAs,
topMentioned: cached.topMentioned,
topMediaUnread: cached.topMediaUnread,
topUnreadProjected: cached.topUnreadProjected,
listCacheable: true,
}
loadedIDs = append(loadedIDs, channelID)
continue
}
}
misses = append(misses, channelID)
}
if len(misses) > 0 {
loaded, err := s.loadChannelDialogListItems(ctx, viewerUserID, misses)
if err != nil {
return domain.ChannelDialogList{}, err
}
for _, channelID := range misses {
if item, ok := loaded[channelID]; ok {
itemsByID[channelID] = item
loadedIDs = append(loadedIDs, channelID)
}
}
}
channelsByID, err := s.channelsByIDs(ctx, s.db, loadedIDs)
if err != nil {
return domain.ChannelDialogList{}, err
}
for _, channelID := range loadedIDs {
item := itemsByID[channelID]
channel := channelsByID[channelID]
if channel.ID == 0 {
// The shared row was deleted after the owner-state snapshot. Treat
// it as absent instead of returning a half-hydrated dialog.
delete(itemsByID, channelID)
continue
}
item.channel = channel
item.dialog.Pts = channel.Pts
itemsByID[channelID] = item
}
out := domain.ChannelDialogList{}
// A requested monoforum preview can be absent from channel_members. Keep the
// rare admission path exact, but only pay its per-peer checks for IDs missed
// by the normal batch query.
for _, channelID := range orderedIDs {
if _, ok := itemsByID[channelID]; ok {
continue
}
channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID)
synthetic := false
if err != nil {
@ -451,31 +880,248 @@ func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64
return domain.ChannelDialogList{}, err
}
}
msg, _ := s.getChannelMessage(ctx, s.db, channelID, dialog.TopMessageID)
if dialog.TopMessageID > 0 && dialog.TopMessageID == dialog.HistoryClearAnchorID {
msg = domain.ProjectChannelHistoryClearMessage(
msg,
channelID,
dialog.HistoryClearAnchorID,
dialog.HistoryClearAnchorDate,
)
itemsByID[channelID] = channelDialogListItem{
channel: channel,
dialog: channelDialogToDialog(dialog, channel.Pts),
defaultSendAs: dialog.DefaultSendAs,
listCacheable: !synthetic,
}
}
dialogs := make([]domain.Dialog, 0, len(itemsByID))
for _, channelID := range orderedIDs {
if item, ok := itemsByID[channelID]; ok {
item.dialog.TopMessageMentioned = item.topMentioned
item.dialog.TopMessageMediaUnread = item.topMediaUnread
item.dialog.TopMessageUnreadProjected = item.topUnreadProjected
itemsByID[channelID] = item
dialogs = append(dialogs, item.dialog)
}
}
topMessages, err := s.channelDialogTopMessages(ctx, s.db, dialogs)
if err != nil {
return domain.ChannelDialogList{}, err
}
allTopUnreadProjected := true
for _, channelID := range orderedIDs {
item, ok := itemsByID[channelID]
if !ok {
continue
}
msg := topMessages[channelMessageLookupKey{channelID: channelID, id: item.dialog.TopMessage}]
if msg.ID != 0 {
dialog.TopMessageDate = msg.Date
if item.topUnreadProjected {
msg.Mentioned = item.topMentioned
msg.MediaUnread = item.topMediaUnread
} else {
allTopUnreadProjected = false
}
item.dialog.TopMessageDate = msg.Date
out.Messages = append(out.Messages, msg)
}
out.Dialogs = append(out.Dialogs, channelDialogToDialog(dialog, channel.Pts))
out.Channels = append(out.Channels, channel)
if dialogCacheActive && item.listCacheable {
cached := channelDialogFromDialog(viewerUserID, item.dialog)
cached.DefaultSendAs = item.defaultSendAs
s.dialogCache.putListProjectionIfEpoch(
cached,
item.topMentioned,
item.topMediaUnread,
item.topUnreadProjected,
dialogCacheEpoch,
)
}
if memberCacheActive && item.dialog.ChannelMember != nil {
s.memberCache.putIfEpoch(*item.dialog.ChannelMember, memberCacheEpoch)
}
out.Dialogs = append(out.Dialogs, item.dialog)
out.Channels = append(out.Channels, item.channel)
}
out.Count = len(out.Dialogs)
// 与 ListChannelDialogs 同因top message 按 viewer 补未读标志与 reactions。
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil {
if err := s.populateChannelDialogTopMessageReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages, allTopUnreadProjected); err != nil {
return domain.ChannelDialogList{}, err
}
projectChannelDialogHistoryClearMessages(out.Dialogs, out.Messages)
return out, nil
}
// HydrateChannelDialogSnapshot attaches viewer-independent channel rows/top
// messages and the small viewer reaction overlay to already materialized
// owner dialog facts. It deliberately does not read channel_members or
// channel_dialogs: dialog_owner + channel_base generations protecting the
// caller's snapshot are the authority for those fields.
func (s *ChannelStore) HydrateChannelDialogSnapshot(
ctx context.Context,
viewerUserID int64,
dialogs []domain.Dialog,
) (domain.ChannelDialogList, error) {
if viewerUserID == 0 || len(dialogs) == 0 {
return domain.ChannelDialogList{}, nil
}
ordered := make([]domain.Dialog, 0, len(dialogs))
ids := make([]int64, 0, len(dialogs))
seen := make(map[int64]struct{}, len(dialogs))
for _, dialog := range dialogs {
if dialog.Peer.Type != domain.PeerTypeChannel || dialog.Peer.ID == 0 {
continue
}
if _, duplicate := seen[dialog.Peer.ID]; duplicate {
continue
}
seen[dialog.Peer.ID] = struct{}{}
ordered = append(ordered, dialog)
ids = append(ids, dialog.Peer.ID)
}
if len(ordered) == 0 {
return domain.ChannelDialogList{}, nil
}
dialogCacheActive := s.dialogCacheActive(s.db)
memberCacheActive := s.memberCacheActive(s.db)
var dialogCacheEpoch uint64
var memberCacheEpoch uint64
if dialogCacheActive {
// Snapshot the epoch before any shared hydration. A concurrent
// dialog_light/channel_member/channel_base invalidation must prevent
// the owner projection from being written back after it became stale.
dialogCacheEpoch = s.dialogCache.cacheEpoch()
}
if memberCacheActive {
memberCacheEpoch = s.memberCache.cacheEpoch()
}
channelsByID, err := s.channelsByIDs(ctx, s.db, ids)
if err != nil {
return domain.ChannelDialogList{}, err
}
for index := range ordered {
channel := channelsByID[ordered[index].Peer.ID]
if channel.ID == 0 {
return domain.ChannelDialogList{}, fmt.Errorf("hydrate channel dialog snapshot %d: %w", ordered[index].Peer.ID, domain.ErrChannelInvalid)
}
ordered[index].Pts = channel.Pts
}
topMessages, err := s.channelDialogTopMessages(ctx, s.db, ordered)
if err != nil {
return domain.ChannelDialogList{}, err
}
out := domain.ChannelDialogList{Dialogs: make([]domain.Dialog, 0, len(ordered))}
allTopUnreadProjected := true
for _, dialog := range ordered {
channel := channelsByID[dialog.Peer.ID]
message := topMessages[channelMessageLookupKey{channelID: dialog.Peer.ID, id: dialog.TopMessage}]
if message.ID != 0 {
if dialog.TopMessageUnreadProjected {
message.Mentioned = dialog.TopMessageMentioned
message.MediaUnread = dialog.TopMessageMediaUnread
} else {
allTopUnreadProjected = false
}
dialog.TopMessageDate = message.Date
out.Messages = append(out.Messages, message)
}
if dialogCacheActive {
cached := channelDialogFromDialog(viewerUserID, dialog)
cached.DefaultSendAs = clonePeer(dialog.DefaultSendAs)
s.dialogCache.putListProjectionIfEpoch(
cached,
dialog.TopMessageMentioned,
dialog.TopMessageMediaUnread,
dialog.TopMessageUnreadProjected,
dialogCacheEpoch,
)
}
if memberCacheActive && dialog.ChannelMember != nil {
s.memberCache.putIfEpoch(*dialog.ChannelMember, memberCacheEpoch)
}
out.Dialogs = append(out.Dialogs, dialog)
out.Channels = append(out.Channels, channel)
}
out.Count = len(out.Dialogs)
if err := s.populateChannelDialogTopMessageReactions(
ctx, s.db, viewerUserID, out.Channels, out.Messages, allTopUnreadProjected,
); err != nil {
return domain.ChannelDialogList{}, err
}
projectChannelDialogHistoryClearMessages(out.Dialogs, out.Messages)
return out, nil
}
func (s *ChannelStore) loadChannelDialogListItems(ctx context.Context, viewerUserID int64, channelIDs []int64) (map[int64]channelDialogListItem, error) {
visibleTopID := channelDialogVisibleTopIDSQL()
visibleTopDate := channelDialogVisibleTopDateSQL("COALESCE(top_msg.message_date, d.top_message_date, c.date)", "0")
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
rows, err := s.db.Query(ctx, `
SELECT c.id,
`+visibleTopID+`,
`+visibleTopDate+`,
COALESCE(d.folder_id, 0),
`+visibleReadInbox+`,
LEAST(GREATEST(c.top_message_id, 0), GREATEST(COALESCE(d.read_outbox_max_id, 0), m.read_outbox_max_id, CASE WHEN c.read_inbox_top1_user_id = m.user_id THEN c.read_inbox_top2 ELSE c.read_inbox_top1 END)),
`+visibleUnreadCount+`,
COALESCE(d.pinned, false),
COALESCE(d.pinned_order, 0),
COALESCE(d.unread_mark, m.unread_mark),
COALESCE(d.unread_mentions_count, 0),
COALESCE(d.unread_reactions_count, 0),
COALESCE(d.view_forum_as_messages, false),
COALESCE(d.has_scheduled, false),
d.default_send_as_peer_type,
d.default_send_as_peer_id,
m.history_clear_anchor_id,
m.history_clear_anchor_date,
top_unread.message_id IS NOT NULL,
COALESCE(top_unread.unread, false),
m.user_id,
m.inviter_user_id,
m.role,
m.status,
m.joined_at,
m.left_at,
m.admin_rights::text,
m.banned_rights::text,
m.rank,
m.available_min_id,
m.available_min_pts,
m.read_inbox_max_id,
m.read_outbox_max_id,
m.unread_mark,
m.slowmode_last_send_date
FROM channel_members m
JOIN channels c ON c.id = m.channel_id AND NOT c.deleted
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id
LEFT JOIN channel_unread_mentions top_unread
ON top_unread.user_id = m.user_id
AND top_unread.channel_id = m.channel_id
AND top_unread.message_id = `+visibleTopID+`
WHERE m.user_id = $1
AND m.channel_id = ANY($2::bigint[])
AND m.status = 'active'`, viewerUserID, channelIDs)
if err != nil {
return nil, fmt.Errorf("batch get channel dialogs: %w", err)
}
defer rows.Close()
itemsByID := make(map[int64]channelDialogListItem, len(channelIDs))
for rows.Next() {
channelID, dialog, defaultSendAs, topMentioned, topMediaUnread, err := scanChannelDialogProjectionRow(rows)
if err != nil {
return nil, err
}
itemsByID[channelID] = channelDialogListItem{
dialog: dialog,
defaultSendAs: defaultSendAs,
topMentioned: topMentioned,
topMediaUnread: topMediaUnread,
topUnreadProjected: true,
listCacheable: true,
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return itemsByID, nil
}
func projectChannelDialogHistoryClearMessages(dialogs []domain.Dialog, messages []domain.ChannelMessage) {
anchors := make(map[channelMessageLookupKey]domain.Dialog)
for _, dialog := range dialogs {
@ -1325,6 +1971,106 @@ func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.
return ch, dialog, defaultSendAs, nil
}
type channelDialogProjectionValues struct {
channelID int64
topID, topDate, folderID, readInbox, readOutbox int
unreadCount, pinnedOrder, unreadMentions, unreadReactions int
historyClearAnchorID, historyClearAnchorDate int
pinned, unreadMark, viewForumAsMessages, hasScheduled bool
defaultSendAsType sql.NullString
defaultSendAsID sql.NullInt64
topMentioned, topMediaUnread bool
memberUserID, memberInviterUserID int64
memberRole, memberStatus string
memberJoinedAt, memberLeftAt int
memberAdminRights, memberBannedRights, memberRank string
memberAvailableMinID, memberAvailableMinPts int
memberReadInboxMaxID, memberReadOutboxMaxID int
memberUnreadMark bool
memberSlowmodeLastSendDate int
}
func (v *channelDialogProjectionValues) scanDestinations() []any {
return []any{
&v.channelID,
&v.topID, &v.topDate,
&v.folderID, &v.readInbox, &v.readOutbox, &v.unreadCount, &v.pinned, &v.pinnedOrder, &v.unreadMark, &v.unreadMentions, &v.unreadReactions, &v.viewForumAsMessages, &v.hasScheduled,
&v.defaultSendAsType, &v.defaultSendAsID,
&v.historyClearAnchorID, &v.historyClearAnchorDate,
&v.topMentioned, &v.topMediaUnread,
&v.memberUserID, &v.memberInviterUserID,
&v.memberRole, &v.memberStatus,
&v.memberJoinedAt, &v.memberLeftAt,
&v.memberAdminRights, &v.memberBannedRights, &v.memberRank,
&v.memberAvailableMinID, &v.memberAvailableMinPts,
&v.memberReadInboxMaxID, &v.memberReadOutboxMaxID,
&v.memberUnreadMark, &v.memberSlowmodeLastSendDate,
}
}
func (v *channelDialogProjectionValues) result() (int64, domain.Dialog, *domain.Peer, bool, bool) {
dialog := domain.Dialog{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: v.channelID},
FolderID: v.folderID,
TopMessage: v.topID,
TopMessageDate: v.topDate,
HistoryClearAnchorID: v.historyClearAnchorID,
HistoryClearAnchorDate: v.historyClearAnchorDate,
ReadInboxMaxID: v.readInbox,
ReadOutboxMaxID: v.readOutbox,
UnreadCount: v.unreadCount,
UnreadMentions: v.unreadMentions,
UnreadReactions: v.unreadReactions,
Pinned: v.pinned,
PinnedOrder: v.pinnedOrder,
UnreadMark: v.unreadMark,
ViewForumAsMessages: v.viewForumAsMessages,
HasScheduled: v.hasScheduled,
TopMessageMentioned: v.topMentioned,
TopMessageMediaUnread: v.topMediaUnread,
TopMessageUnreadProjected: true,
}
var defaultSendAs *domain.Peer
if v.defaultSendAsType.Valid && v.defaultSendAsID.Valid && v.defaultSendAsID.Int64 != 0 {
defaultSendAs = &domain.Peer{Type: domain.PeerType(v.defaultSendAsType.String), ID: v.defaultSendAsID.Int64}
}
member := domain.ChannelMember{
ChannelID: v.channelID,
UserID: v.memberUserID,
InviterUserID: v.memberInviterUserID,
Role: domain.ChannelMemberRole(v.memberRole),
Status: domain.ChannelMemberStatus(v.memberStatus),
JoinedAt: v.memberJoinedAt,
LeftAt: v.memberLeftAt,
Rank: v.memberRank,
AvailableMinID: v.memberAvailableMinID,
AvailableMinPts: v.memberAvailableMinPts,
HistoryClearAnchorID: v.historyClearAnchorID,
HistoryClearAnchorDate: v.historyClearAnchorDate,
ReadInboxMaxID: v.memberReadInboxMaxID,
ReadOutboxMaxID: v.memberReadOutboxMaxID,
UnreadMark: v.memberUnreadMark,
SlowmodeLastSendDate: v.memberSlowmodeLastSendDate,
}
_ = json.Unmarshal([]byte(v.memberAdminRights), &member.AdminRights)
_ = json.Unmarshal([]byte(v.memberBannedRights), &member.BannedRights)
dialog.ChannelMember = &member
return v.channelID, dialog, defaultSendAs, v.topMentioned, v.topMediaUnread
}
// scanChannelDialogProjectionRow scans only owner-varying channel dialog
// state. Shared channel metadata is hydrated separately through ChannelRowCache
// so a page containing the same channel for many online owners does not decode
// and transfer the wide channels row repeatedly.
func scanChannelDialogProjectionRow(row rowScanner) (int64, domain.Dialog, *domain.Peer, bool, bool, error) {
var values channelDialogProjectionValues
if err := row.Scan(values.scanDestinations()...); err != nil {
return 0, domain.Dialog{}, nil, false, false, err
}
channelID, dialog, defaultSendAs, topMentioned, topMediaUnread := values.result()
return channelID, dialog, defaultSendAs, topMentioned, topMediaUnread, nil
}
func channelDialogToDialog(dialog domain.ChannelDialog, channelPts int) domain.Dialog {
return domain.Dialog{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
@ -1343,6 +2089,7 @@ func channelDialogToDialog(dialog domain.ChannelDialog, channelPts int) domain.D
UnreadMark: dialog.UnreadMark,
ViewForumAsMessages: dialog.ViewForumAsMessages,
HasScheduled: dialog.HasScheduled,
DefaultSendAs: clonePeer(dialog.DefaultSendAs),
Pts: channelPts,
}
}
@ -1366,9 +2113,18 @@ func channelDialogFromDialog(userID int64, dialog domain.Dialog) domain.ChannelD
UnreadMark: dialog.UnreadMark,
ViewForumAsMessages: dialog.ViewForumAsMessages,
HasScheduled: dialog.HasScheduled,
DefaultSendAs: clonePeer(dialog.DefaultSendAs),
}
}
func clonePeer(peer *domain.Peer) *domain.Peer {
if peer == nil {
return nil
}
cloned := *peer
return &cloned
}
func channelDialogMatchesFilter(dialog domain.Dialog, channel domain.Channel, filter domain.DialogFilter) bool {
if filter.HasFolderID {
if filter.FolderID < domain.DialogCustomFolderMinID {

View file

@ -0,0 +1,174 @@
package postgres
import (
"context"
"strconv"
"sync/atomic"
"time"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
)
type channelDifferenceBaseKey struct {
channelID int64
requestPts int
capturedPts int
capturedTopID int
limit int
}
// channelDifferenceBase contains only viewer-independent durable facts. Access,
// available-min, monoforum visibility, unread flags and dialog state are applied
// after the cache lookup by ListChannelDifference.
type channelDifferenceBase struct {
retainedThroughPts int
lastPts int
tooLong bool
events []domain.ChannelUpdateEvent
messages []domain.ChannelMessage
// mentionCandidateIDs is a viewer-independent sparse gate sourced from
// channel_unread_mention_index. When candidatesKnown is true, messages not
// present in this set cannot have a viewer mention overlay and must not cause
// a channel_unread_mentions query.
mentionCandidateIDs map[int]struct{}
candidatesKnown bool
}
type ChannelDifferenceCacheSnapshot struct {
Entries int
Weight int64
Hits uint64
Misses uint64
Loads uint64
LoadErrors uint64
}
// ChannelDifferenceBaseCache deduplicates immutable channel event/message pages
// shared by many viewers catching up from the same cursor. It never stores a
// permission decision or a final ChannelDifference response.
type ChannelDifferenceBaseCache struct {
cache *readmodelcache.Cache[channelDifferenceBaseKey, channelDifferenceBase]
hits atomic.Uint64
misses atomic.Uint64
loads atomic.Uint64
loadErrors atomic.Uint64
}
func NewChannelDifferenceBaseCache(maxEntries int, maxWeight int64, ttl time.Duration) *ChannelDifferenceBaseCache {
cache := readmodelcache.New[channelDifferenceBaseKey, channelDifferenceBase](readmodelcache.Config[channelDifferenceBaseKey, channelDifferenceBase]{
MaxEntries: maxEntries,
MaxWeight: maxWeight,
TTL: ttl,
Clone: cloneChannelDifferenceBase,
Weight: channelDifferenceBaseWeight,
KeyString: func(key channelDifferenceBaseKey) string {
return strconv.FormatInt(key.channelID, 10) + ":" +
strconv.Itoa(key.requestPts) + ":" + strconv.Itoa(key.capturedPts) + ":" +
strconv.Itoa(key.capturedTopID) + ":" + strconv.Itoa(key.limit)
},
})
if cache == nil {
return nil
}
return &ChannelDifferenceBaseCache{cache: cache}
}
func (c *ChannelDifferenceBaseCache) getOrLoad(
ctx context.Context,
key channelDifferenceBaseKey,
load func() (channelDifferenceBase, error),
) (channelDifferenceBase, error) {
if c == nil {
return load()
}
if _, ok := c.cache.Peek(key); ok {
c.hits.Add(1)
} else {
c.misses.Add(1)
}
return c.cache.GetOrLoad(ctx, key, func() (channelDifferenceBase, error) {
c.loads.Add(1)
value, err := load()
if err != nil {
c.loadErrors.Add(1)
}
return value, err
})
}
func (c *ChannelDifferenceBaseCache) deleteChannel(channelID int64) {
if c == nil || channelID == 0 {
return
}
c.cache.InvalidateWhere(func(key channelDifferenceBaseKey) bool { return key.channelID == channelID })
}
func (c *ChannelDifferenceBaseCache) flush() {
if c == nil {
return
}
c.cache.Flush()
}
func (c *ChannelDifferenceBaseCache) Snapshot() ChannelDifferenceCacheSnapshot {
if c == nil {
return ChannelDifferenceCacheSnapshot{}
}
return ChannelDifferenceCacheSnapshot{
Entries: c.cache.Len(),
Weight: c.cache.Weight(),
Hits: c.hits.Load(),
Misses: c.misses.Load(),
Loads: c.loads.Load(),
LoadErrors: c.loadErrors.Load(),
}
}
func cloneChannelDifferenceBase(base channelDifferenceBase) channelDifferenceBase {
candidates := base.mentionCandidateIDs
base.events = append([]domain.ChannelUpdateEvent(nil), base.events...)
for i := range base.events {
base.events[i].MessageIDs = append([]int(nil), base.events[i].MessageIDs...)
base.events[i].UserIDs = append([]int64(nil), base.events[i].UserIDs...)
base.events[i].Message = cloneChannelTopMessage(base.events[i].Message)
}
base.messages = append([]domain.ChannelMessage(nil), base.messages...)
for i := range base.messages {
base.messages[i] = cloneChannelTopMessage(base.messages[i])
}
if base.mentionCandidateIDs != nil {
base.mentionCandidateIDs = make(map[int]struct{}, len(base.mentionCandidateIDs))
for id := range candidates {
base.mentionCandidateIDs[id] = struct{}{}
}
}
return base
}
func channelDifferenceBaseWeight(base channelDifferenceBase) int64 {
weight := int64(96 + len(base.events)*192 + len(base.messages)*192 + len(base.mentionCandidateIDs)*16)
for _, event := range base.events {
weight += int64(len(event.MessageIDs)*8 + len(event.UserIDs)*8)
weight += channelDifferenceMessageWeight(event.Message)
}
for _, message := range base.messages {
weight += channelDifferenceMessageWeight(message)
}
return weight
}
func channelDifferenceMessageWeight(message domain.ChannelMessage) int64 {
if message.ID == 0 {
return 0
}
weight := int64(len(message.Body) + len(message.PostAuthor) + len(message.Entities)*48)
if message.RichMessage != nil {
weight += int64(len(message.RichMessage.Blocks) + len(message.RichMessage.BotAPIProjection))
}
if message.Action != nil {
weight += int64(len(message.Action.Title) + len(message.Action.UserIDs)*8 + len(message.Action.TodoItems)*64)
}
return weight
}

View file

@ -0,0 +1,205 @@
package postgres
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
)
func TestChannelDifferenceBaseLoaderRejectsChangedStableCutPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
owner, err := NewUserStore(pool).Create(ctx, domain.User{
AccessHash: 721, Phone: "+1993" + suffix + "01", FirstName: "DiffCutOwner",
})
if err != nil {
t.Fatal(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: "Difference Cut " + suffix, Megagroup: true, Date: 1701000200,
})
if err != nil {
t.Fatal(err)
}
channelID = created.Channel.ID
first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID, ChannelID: channelID, RandomID: 1701000201, Message: "first cut", Date: 1701000201,
})
if err != nil {
t.Fatal(err)
}
captured, member, _, err := channels.getChannelForViewer(ctx, pool, owner.ID, channelID)
if err != nil {
t.Fatal(err)
}
if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID, ChannelID: channelID, RandomID: 1701000202, Message: "future cut", Date: 1701000202,
}); err != nil {
t.Fatal(err)
}
_, err = channels.loadChannelDifferenceBase(ctx, captured, member, owner.ID, created.Channel.Pts, 100, true)
if !errors.Is(err, errChannelDifferenceCutChanged) {
t.Fatalf("load against captured pts %d after new event = %v, want stable-cut retry", first.Event.Pts, err)
}
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: owner.ID, ChannelID: channelID, Pts: created.Channel.Pts, Limit: 100,
})
if err != nil {
t.Fatal(err)
}
if !diff.Final || len(diff.Events) != 2 || diff.Events[0].Pts != first.Event.Pts {
t.Fatalf("retried difference = %+v, want both stable events", diff)
}
}
func TestChannelDifferenceBaseCacheSharesDurablePageAcrossViewersPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 701, Phone: "+1991" + suffix + "01", FirstName: "DiffCacheOwner"})
if err != nil {
t.Fatal(err)
}
memberA, err := users.Create(ctx, domain.User{AccessHash: 702, Phone: "+1991" + suffix + "02", FirstName: "DiffCacheA"})
if err != nil {
t.Fatal(err)
}
memberB, err := users.Create(ctx, domain.User{AccessHash: 703, Phone: "+1991" + suffix + "03", FirstName: "DiffCacheB"})
if err != nil {
t.Fatal(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 = ANY($1::bigint[])", []int64{owner.ID, memberA.ID, memberB.ID})
})
cache := NewChannelDifferenceBaseCache(32, 8<<20, time.Minute)
channels := NewChannelStore(pool, WithChannelDifferenceBaseCache(cache))
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Shared Difference " + suffix,
Megagroup: true,
MemberUserIDs: []int64{memberA.ID, memberB.ID},
Date: 1701000000,
})
if err != nil {
t.Fatal(err)
}
channelID = created.Channel.ID
sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID, ChannelID: channelID, RandomID: 1701000001,
Message: "shared immutable page", MentionUserIDs: []int64{memberA.ID}, Date: 1701000001,
})
if err != nil {
t.Fatal(err)
}
request := func(userID int64) domain.ChannelDifference {
t.Helper()
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: userID, ChannelID: channelID, Pts: created.Channel.Pts, Limit: 100,
})
if err != nil {
t.Fatal(err)
}
if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.NewMessages) != 1 || diff.NewMessages[0].Body != "shared immutable page" {
t.Fatalf("difference for %d = %+v", userID, diff)
}
if diff.Self.UserID != userID || diff.Dialog.UserID != userID {
t.Fatalf("viewer overlay crossed accounts: self=%d dialog=%d want=%d", diff.Self.UserID, diff.Dialog.UserID, userID)
}
return diff
}
first := request(memberA.ID)
if !first.NewMessages[0].Mentioned {
t.Fatalf("member A mention overlay missing: %+v", first.NewMessages[0])
}
second := request(memberB.ID)
if second.NewMessages[0].Mentioned || second.NewMessages[0].MediaUnread {
t.Fatalf("member B received member A mention overlay: %+v", second.NewMessages[0])
}
snapshot := cache.Snapshot()
if snapshot.Loads != 1 || snapshot.Entries != 1 || snapshot.Hits < 1 {
t.Fatalf("shared base snapshot = %+v, want one load and a hit", snapshot)
}
first.NewMessages[0].Body = "caller mutation"
third := request(memberA.ID)
if third.NewMessages[0].Body != "shared immutable page" {
t.Fatalf("caller mutation leaked into cache: %+v", third.NewMessages[0])
}
}
func TestChannelDifferenceRetentionInvalidatesSharedBasePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
owner, err := NewUserStore(pool).Create(ctx, domain.User{
AccessHash: 711, Phone: "+1992" + suffix + "01", FirstName: "DiffRetentionOwner",
})
if err != nil {
t.Fatal(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)
})
cache := NewChannelDifferenceBaseCache(32, 8<<20, time.Minute)
channels := NewChannelStore(pool, WithChannelDifferenceBaseCache(cache))
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "Difference Retention " + suffix, Megagroup: true, Date: 1701000100,
})
if err != nil {
t.Fatal(err)
}
channelID = created.Channel.ID
first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID, ChannelID: channelID, RandomID: 1701000101, Message: "first", Date: 1701000101,
})
if err != nil {
t.Fatal(err)
}
if _, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: owner.ID, ChannelID: channelID, Pts: created.Channel.Pts, Limit: 100,
}); err != nil {
t.Fatal(err)
}
if cache.Snapshot().Entries != 1 {
t.Fatalf("entries before prune = %d, want 1", cache.Snapshot().Entries)
}
pruned, err := channels.PruneChannelUpdateEvents(ctx, channelID, first.Event.Pts, 100)
if err != nil {
t.Fatal(err)
}
if pruned.Deleted == 0 || cache.Snapshot().Entries != 0 {
t.Fatalf("prune/cache = %+v/%+v, want deletion and immediate invalidation", pruned, cache.Snapshot())
}
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: owner.ID, ChannelID: channelID, Pts: created.Channel.Pts, Limit: 100,
})
if err != nil {
t.Fatal(err)
}
if !diff.TooLong || diff.Pts != first.Event.Pts {
t.Fatalf("difference after retained floor = %+v, want tooLong at pts %d", diff, first.Event.Pts)
}
}

View file

@ -0,0 +1,147 @@
package postgres
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"telesrv/internal/domain"
)
func TestChannelDifferenceBaseCacheSingleflightAndCloneIsolation(t *testing.T) {
cache := NewChannelDifferenceBaseCache(16, 1<<20, time.Minute)
key := channelDifferenceBaseKey{channelID: 7, requestPts: 10, capturedPts: 11, capturedTopID: 3, limit: 100}
var loads atomic.Int32
load := func() (channelDifferenceBase, error) {
loads.Add(1)
time.Sleep(10 * time.Millisecond)
return channelDifferenceBase{
lastPts: 11,
candidatesKnown: true,
mentionCandidateIDs: map[int]struct{}{3: {}},
events: []domain.ChannelUpdateEvent{{
ChannelID: 7,
Pts: 11,
PtsCount: 1,
Type: domain.ChannelUpdateNewMessage,
MessageIDs: []int{3},
Message: domain.ChannelMessage{
ChannelID: 7,
ID: 3,
Body: "immutable",
Entities: []domain.MessageEntity{{Offset: 1}},
},
}},
}, nil
}
const callers = 64
values := make([]channelDifferenceBase, callers)
errs := make([]error, callers)
var wg sync.WaitGroup
wg.Add(callers)
for i := range callers {
go func(i int) {
defer wg.Done()
values[i], errs[i] = cache.getOrLoad(context.Background(), key, load)
}(i)
}
wg.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("caller %d: %v", i, err)
}
}
if loads.Load() != 1 {
t.Fatalf("loads = %d, want 1", loads.Load())
}
values[0].events[0].MessageIDs[0] = 99
values[0].events[0].Message.Entities[0].Offset = 99
delete(values[0].mentionCandidateIDs, 3)
got, err := cache.getOrLoad(context.Background(), key, load)
if err != nil {
t.Fatal(err)
}
if got.events[0].MessageIDs[0] != 3 || got.events[0].Message.Entities[0].Offset != 1 {
t.Fatalf("cached value was aliased: %+v", got.events[0])
}
if _, ok := got.mentionCandidateIDs[3]; !ok {
t.Fatalf("cached mention candidates were aliased: %+v", got.mentionCandidateIDs)
}
snapshot := cache.Snapshot()
if snapshot.Entries != 1 || snapshot.Loads != 1 || snapshot.Hits == 0 || snapshot.Weight <= 0 {
t.Fatalf("snapshot = %+v", snapshot)
}
}
func TestChannelDifferenceBaseCacheSeparatesCutsAndInvalidatesChannel(t *testing.T) {
cache := NewChannelDifferenceBaseCache(16, 1<<20, time.Minute)
var loads atomic.Int32
load := func() (channelDifferenceBase, error) {
loads.Add(1)
return channelDifferenceBase{lastPts: 2}, nil
}
keys := []channelDifferenceBaseKey{
{channelID: 9, requestPts: 1, capturedPts: 2, capturedTopID: 1, limit: 100},
{channelID: 9, requestPts: 1, capturedPts: 3, capturedTopID: 2, limit: 100},
{channelID: 10, requestPts: 1, capturedPts: 2, capturedTopID: 1, limit: 100},
}
for _, key := range keys {
if _, err := cache.getOrLoad(context.Background(), key, load); err != nil {
t.Fatal(err)
}
}
if loads.Load() != 3 || cache.Snapshot().Entries != 3 {
t.Fatalf("loads/entries = %d/%d, want 3/3", loads.Load(), cache.Snapshot().Entries)
}
cache.deleteChannel(9)
if cache.Snapshot().Entries != 1 {
t.Fatalf("entries after channel invalidation = %d, want 1", cache.Snapshot().Entries)
}
}
func TestChannelDifferenceBaseCacheDoesNotCacheErrors(t *testing.T) {
cache := NewChannelDifferenceBaseCache(4, 1<<20, time.Minute)
key := channelDifferenceBaseKey{channelID: 12, requestPts: 1, capturedPts: 2, limit: 100}
want := errors.New("load failed")
for range 2 {
if _, err := cache.getOrLoad(context.Background(), key, func() (channelDifferenceBase, error) {
return channelDifferenceBase{}, want
}); !errors.Is(err, want) {
t.Fatalf("err = %v, want %v", err, want)
}
}
snapshot := cache.Snapshot()
if snapshot.Entries != 0 || snapshot.Loads != 2 || snapshot.LoadErrors != 2 {
t.Fatalf("snapshot = %+v", snapshot)
}
}
func TestChannelDifferenceUnreadFlagsSkipDatabaseWithoutMentionCandidates(t *testing.T) {
messages := []domain.ChannelMessage{{ChannelID: 12, ID: 7}}
base := channelDifferenceBase{candidatesKnown: true, mentionCandidateIDs: map[int]struct{}{}}
if err := populateChannelDifferenceUnreadFlags(context.Background(), nil, 99, messages, base); err != nil {
t.Fatal(err)
}
if messages[0].Mentioned || messages[0].MediaUnread {
t.Fatalf("empty candidate gate changed message flags: %+v", messages[0])
}
}
func TestReadModelListenerInvalidatesChannelDifferenceBase(t *testing.T) {
cache := NewChannelDifferenceBaseCache(4, 1<<20, time.Minute)
key := channelDifferenceBaseKey{channelID: 14, requestPts: 1, capturedPts: 2, limit: 100}
if _, err := cache.getOrLoad(context.Background(), key, func() (channelDifferenceBase, error) {
return channelDifferenceBase{lastPts: 2}, nil
}); err != nil {
t.Fatal(err)
}
listener := NewReadModelChangeListener("", ReadModelCacheSet{ChannelDifferences: cache}, nil)
listener.handlePayload(`{"model":"channel_difference_base","peer_type":"channel","peer_id":14}`)
if cache.Snapshot().Entries != 0 {
t.Fatalf("entries after retention invalidation = %d, want 0", cache.Snapshot().Entries)
}
}

View file

@ -132,6 +132,14 @@ func (s *ChannelStore) DeleteChannel(ctx context.Context, req domain.DeleteChann
linkedMono = &mono
}
}
if err := deleteChannelWelcomeMessageDeliveriesTx(ctx, tx, channel.ID); err != nil {
return domain.DeleteChannelResult{}, err
}
if linkedMono != nil {
if err := deleteChannelWelcomeMessageDeliveriesTx(ctx, tx, linkedMono.ID); err != nil {
return domain.DeleteChannelResult{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return domain.DeleteChannelResult{}, fmt.Errorf("commit delete channel: %w", err)
}
@ -556,6 +564,27 @@ func listChannelsByIDs(ctx context.Context, db sqlcgen.DBTX, ids []int64) ([]dom
return out, nil
}
// channelsByIDs hydrates viewer-independent channel rows in one batch and
// reuses them across owners. The per-viewer member/dialog state stays outside
// this cache and is read by its own owner-scoped query.
func (s *ChannelStore) channelsByIDs(ctx context.Context, db sqlcgen.DBTX, ids []int64) (map[int64]domain.Channel, error) {
load := func(ctx context.Context, missing []int64) (map[int64]domain.Channel, error) {
channels, err := listChannelsByIDs(ctx, db, missing)
if err != nil {
return nil, err
}
out := make(map[int64]domain.Channel, len(channels))
for _, channel := range channels {
out[channel.ID] = channel
}
return out, nil
}
if s.cacheActive(db) {
return s.rowCache.getOrLoadBatch(ctx, ids, load)
}
return load(ctx, ids)
}
func listChannelsByIDsInOrder(ctx context.Context, db sqlcgen.DBTX, ids []int64) ([]domain.Channel, error) {
channels, err := listChannelsByIDs(ctx, db, ids)
if err != nil {
@ -789,7 +818,33 @@ func (s *ChannelStore) resolveChannelReply(ctx context.Context, db sqlcgen.DBTX,
peer = channelPeer
}
if peer != channelPeer {
return nil, domain.ErrReplyMessageIDInvalid
// inputReplyToMessage.reply_to_peer_id may deliberately reference a
// message from another dialog (the official clients expose this as
// "Reply in another chat"). Keep that source pair intact instead of
// resolving it as a destination-channel thread reply.
if req.ReplyTo.MessageID <= 0 {
return nil, domain.ErrReplyMessageIDInvalid
}
switch peer.Type {
case domain.PeerTypeUser:
var exists bool
if err := db.QueryRow(ctx, `SELECT EXISTS (
SELECT 1 FROM message_boxes
WHERE owner_user_id=$1 AND peer_type='user' AND peer_id=$2 AND box_id=$3 AND NOT deleted
)`, req.UserID, peer.ID, req.ReplyTo.MessageID).Scan(&exists); err != nil || !exists {
return nil, domain.ErrReplyMessageIDInvalid
}
case domain.PeerTypeChannel:
target, err := s.getChannelMessage(ctx, db, peer.ID, req.ReplyTo.MessageID)
if err != nil || target.Deleted {
return nil, domain.ErrReplyMessageIDInvalid
}
default:
return nil, domain.ErrReplyMessageIDInvalid
}
reply := cloneMessageReply(req.ReplyTo)
reply.Peer = peer
return reply, nil
}
if req.ReplyTo.MessageID == 0 {
if req.ReplyTo.TopMessageID <= 0 || !channel.Forum {

View file

@ -0,0 +1,130 @@
package postgres
import (
"context"
"fmt"
"testing"
"telesrv/internal/domain"
)
func TestChannelStoreInviteBatchAdvancesDistinctReadModelsOncePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: 960001,
Phone: "+1960" + suffix + "00",
FirstName: "BatchInviteOwner",
})
if err != nil {
t.Fatalf("create owner: %v", err)
}
members := make([]domain.User, 8)
userIDs := make([]int64, len(members))
for i := range members {
members[i], err = users.Create(ctx, domain.User{
AccessHash: int64(960100 + i),
Phone: fmt.Sprintf("+1960%s%02d", suffix, i+1),
FirstName: fmt.Sprintf("BatchInvite%02d", i+1),
})
if err != nil {
t.Fatalf("create member %d: %v", i, err)
}
// Deliberately reverse the input. The store must establish one canonical
// lock/write order independent of the request order.
userIDs[len(members)-1-i] = members[i].ID
}
allUserIDs := append([]int64{owner.ID}, userIDs...)
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 = ANY($1::bigint[])`, allUserIDs)
})
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Batch Invite " + suffix,
Megagroup: true,
Date: 1700019600,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID = created.Channel.ID
version := func(model string, ownerID int64, peerType string, peerID int64) int64 {
t.Helper()
var got int64
err := pool.QueryRow(ctx, `
SELECT version
FROM read_model_versions
WHERE model=$1 AND owner_user_id=$2 AND peer_type=$3 AND peer_id=$4`, model, ownerID, peerType, peerID).Scan(&got)
if err != nil {
return 0
}
return got
}
participantsBefore := version("channel_participants", 0, "channel", channelID)
dialogOwnerBefore := make(map[int64]int64, len(userIDs))
for _, userID := range userIDs {
dialogOwnerBefore[userID] = version("dialog_owner", userID, "user", userID)
}
invited, err := channels.InviteToChannel(ctx, channelID, owner.ID, userIDs, 1700019601)
if err != nil {
t.Fatalf("batch invite: %v", err)
}
if len(invited.Members) != len(userIDs) {
t.Fatalf("invited members = %d, want %d", len(invited.Members), len(userIDs))
}
if len(invited.Recipients) != 0 {
t.Fatalf("durable invite recipients = %v, want realtime audience derived from session fabric", invited.Recipients)
}
if invited.Event.Pts != created.Channel.Pts+1 || invited.Event.PtsCount != 1 || invited.Channel.Pts != invited.Event.Pts {
t.Fatalf("invite pts=(event:%d/%d channel:%d), want one slot after %d", invited.Event.Pts, invited.Event.PtsCount, invited.Channel.Pts, created.Channel.Pts)
}
if invited.Message.Action == nil || invited.Message.Action.Type != domain.ChannelActionChatAddUser || len(invited.Message.Action.UserIDs) != len(userIDs) {
t.Fatalf("invite service action = %+v, want all invited users", invited.Message.Action)
}
if got := version("channel_participants", 0, "channel", channelID); got != participantsBefore+1 {
t.Fatalf("channel participants version = %d, want %d", got, participantsBefore+1)
}
for _, userID := range userIDs {
if got := version("channel_member", userID, "channel", channelID); got != 1 {
t.Errorf("channel_member version user %d = %d, want 1", userID, got)
}
if got := version("dialog_light", userID, "channel", channelID); got != 1 {
t.Errorf("dialog_light version user %d = %d, want 1", userID, got)
}
if got := version("channel_active_memberships", userID, "user", userID); got != 1 {
t.Errorf("active memberships version user %d = %d, want 1", userID, got)
}
if got := version("dialog_owner", userID, "user", userID); got != dialogOwnerBefore[userID]+1 {
t.Errorf("dialog_owner version user %d = %d, want %d", userID, got, dialogOwnerBefore[userID]+1)
}
}
var memberRows, indexRows, dialogRows, adminRows int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_members WHERE channel_id=$1 AND user_id=ANY($2::bigint[]) AND status='active'`, channelID, userIDs).Scan(&memberRows); err != nil {
t.Fatalf("count member rows: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM user_channel_member_index WHERE channel_id=$1 AND user_id=ANY($2::bigint[]) AND status='active'`, channelID, userIDs).Scan(&indexRows); err != nil {
t.Fatalf("count membership indexes: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_dialogs WHERE channel_id=$1 AND user_id=ANY($2::bigint[]) AND unread_count=1 AND unread_reactions_count=0`, channelID, userIDs).Scan(&dialogRows); err != nil {
t.Fatalf("count dialog rows: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_admin_log_events WHERE channel_id=$1 AND event_type='participant_invite'`, channelID).Scan(&adminRows); err != nil {
t.Fatalf("count invite admin logs: %v", err)
}
if memberRows != len(userIDs) || indexRows != len(userIDs) || dialogRows != len(userIDs) || adminRows != len(userIDs) {
t.Fatalf("batch rows member/index/dialog/admin = %d/%d/%d/%d, want %d each", memberRows, indexRows, dialogRows, adminRows, len(userIDs))
}
}

View file

@ -222,6 +222,9 @@ WHERE channel_id = $1 AND user_id = $2`, channel.ID, userID, member.ReadInboxMax
if err := refreshChannelUnreadReactionsCountTx(ctx, tx, userID, channel.ID); err != nil {
return domain.CreateChannelResult{}, err
}
if err := enqueueWelcomeMessageDeliveriesTx(ctx, tx, channel.ID, []domain.ChannelMember{member}); err != nil {
return domain.CreateChannelResult{}, err
}
return domain.CreateChannelResult{Channel: channel, Members: []domain.ChannelMember{member}, Message: msg, Event: event}, nil
}

View file

@ -2,8 +2,8 @@ package postgres
import (
"context"
"errors"
"fmt"
"sort"
"telesrv/internal/domain"
)
@ -39,13 +39,18 @@ func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUs
date = nowUnix()
}
requested := uniqueChannelUserIDs(userIDs, 0)
sort.Slice(requested, func(i, j int) bool { return requested[i] < requested[j] })
inviteOne := len(requested) == 1
canRestoreKicked := canBanChannelUsers(inviter)
invitedIDs := make([]int64, 0, len(requested))
members := make([]domain.ChannelMember, 0, len(requested))
restoredKicked := 0
existingMembers, err := channelMembersForUpdateBatchTx(ctx, tx, channelID, requested)
if err != nil {
return domain.CreateChannelResult{}, err
}
for _, userID := range requested {
if existing, err := s.getChannelMember(ctx, tx, channelID, userID); err == nil {
if existing, ok := existingMembers[userID]; ok {
if existing.Status == domain.ChannelMemberActive {
if inviteOne {
return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant
@ -63,8 +68,6 @@ func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUs
restoredKicked++
}
}
} else if !errors.Is(err, domain.ErrChannelPrivate) {
return domain.CreateChannelResult{}, err
}
member := domain.ChannelMember{
ChannelID: channelID,
@ -77,22 +80,19 @@ func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUs
AvailableMinPts: channelInitialAvailableMinPts(channel),
ReadInboxMaxID: channel.TopMessageID,
}
if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil {
return domain.CreateChannelResult{}, err
}
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
ChannelID: channelID,
UserID: inviterUserID,
Date: date,
Type: domain.ChannelAdminLogParticipantInvite,
Participant: &member,
}); err != nil {
return domain.CreateChannelResult{}, err
}
members = append(members, member)
invitedIDs = append(invitedIDs, userID)
}
if len(members) > 0 {
if err := enableChannelMembershipBatchTx(ctx, tx); err != nil {
return domain.CreateChannelResult{}, err
}
if err := upsertChannelMembersBatchTx(ctx, tx, channel, members); err != nil {
return domain.CreateChannelResult{}, err
}
if err := insertChannelInviteAdminLogsBatchTx(ctx, tx, channelID, inviterUserID, date, members); err != nil {
return domain.CreateChannelResult{}, err
}
if _, err := tx.Exec(ctx, `UPDATE channels SET participants_count = participants_count + $2, kicked_count = GREATEST(kicked_count - $3, 0), updated_at = now() WHERE id = $1`, channelID, len(members), restoredKicked); err != nil {
return domain.CreateChannelResult{}, fmt.Errorf("update channel participants: %w", err)
}
@ -112,21 +112,24 @@ func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUs
channel.TopMessageID = msg.ID
channel.Pts = event.Pts
}
for _, member := range members {
if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil {
return domain.CreateChannelResult{}, err
}
// 被重新拉入群也是重进:按新 available_min_id 重算未读 reaction 计数清幽灵角标。
if err := refreshChannelUnreadReactionsCountTx(ctx, tx, member.UserID, channel.ID); err != nil {
return domain.CreateChannelResult{}, err
}
if err := upsertChannelDialogsBatchTx(ctx, tx, channel, msg, members); err != nil {
return domain.CreateChannelResult{}, err
}
// 被重新拉入群也是重进:按新 available_min_id 集合重算未读 reaction 计数清幽灵角标。
if err := refreshChannelUnreadReactionsCountsBatchTx(ctx, tx, channel.ID, invitedIDs); err != nil {
return domain.CreateChannelResult{}, err
}
if err := enqueueWelcomeMessageDeliveriesTx(ctx, tx, channel.ID, members); err != nil {
return domain.CreateChannelResult{}, err
}
if err := bumpChannelMembershipReadModelsBatchTx(ctx, tx, channel.ID, invitedIDs); err != nil {
return domain.CreateChannelResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.CreateChannelResult{}, fmt.Errorf("commit invite channel: %w", err)
}
committed = true
recipients, _ := s.ListActiveChannelMemberIDs(ctx, inviterUserID, channelID, 0)
return domain.CreateChannelResult{Channel: channel, Members: members, Message: msg, Event: event, Recipients: recipients}, nil
return domain.CreateChannelResult{Channel: channel, Members: members, Message: msg, Event: event}, nil
}
func canInviteToChannel(channel domain.Channel, member domain.ChannelMember) bool {

View file

@ -36,6 +36,7 @@ func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChan
return domain.EditChannelAdminResult{}, domain.ErrChannelAdminRequired
}
previous, err := s.getChannelMember(ctx, tx, req.ChannelID, req.MemberID)
membershipActivated := err != nil && errors.Is(err, domain.ErrChannelPrivate)
if err != nil {
if !errors.Is(err, domain.ErrChannelPrivate) {
return domain.EditChannelAdminResult{}, err
@ -52,6 +53,9 @@ func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChan
ReadInboxMaxID: channel.TopMessageID,
}
}
if previous.Status != domain.ChannelMemberActive {
membershipActivated = true
}
if previous.Role == domain.ChannelRoleCreator {
if req.MemberID != req.UserID || channel.CreatorUserID != req.UserID || actor.Role != domain.ChannelRoleCreator {
return domain.EditChannelAdminResult{}, domain.ErrChannelUserCreator
@ -100,6 +104,7 @@ func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChan
member.Rank = req.Rank
}
if previous.Status != domain.ChannelMemberActive {
member.JoinedAt = req.Date
if minPts := channelInitialAvailableMinPts(channel); minPts > member.AvailableMinPts {
member.AvailableMinPts = minPts
}
@ -137,6 +142,11 @@ func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChan
if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil {
return domain.EditChannelAdminResult{}, err
}
if membershipActivated {
if err := enqueueWelcomeMessageDeliveriesTx(ctx, tx, channel.ID, []domain.ChannelMember{member}); err != nil {
return domain.EditChannelAdminResult{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return domain.EditChannelAdminResult{}, fmt.Errorf("commit edit channel admin: %w", err)
}
@ -449,6 +459,11 @@ func (s *ChannelStore) EditChannelBanned(ctx context.Context, req domain.EditCha
return domain.EditChannelBannedResult{}, err
}
}
if previous.Status == domain.ChannelMemberActive && member.Status != domain.ChannelMemberActive {
if err := deleteWelcomeMessageDeliveriesTx(ctx, tx, req.ChannelID, []int64{req.Participant.ID}); err != nil {
return domain.EditChannelBannedResult{}, err
}
}
var serviceMsg domain.ChannelMessage
var serviceEvent domain.ChannelUpdateEvent
if channel.Megagroup && previous.Status == domain.ChannelMemberActive && member.Status == domain.ChannelMemberKicked {

View file

@ -56,6 +56,27 @@ func (c *ChannelMemberCache) put(member domain.ChannelMember) {
c.cache.Store(channelMemberCacheKey{channelID: member.ChannelID, userID: member.UserID}, member)
}
func (c *ChannelMemberCache) cacheEpoch() uint64 {
if c == nil {
return 0
}
return c.cache.LoadEpoch()
}
// putIfEpoch prevents a materialized owner snapshot that raced a membership
// invalidation from restoring stale access rights after the listener advanced
// the cache epoch.
func (c *ChannelMemberCache) putIfEpoch(member domain.ChannelMember, loadEpoch uint64) {
if c == nil || member.ChannelID == 0 || member.UserID == 0 {
return
}
c.cache.StoreIfEpoch(
channelMemberCacheKey{channelID: member.ChannelID, userID: member.UserID},
member,
loadEpoch,
)
}
func (c *ChannelMemberCache) delete(channelID, userID int64) {
if c == nil || channelID == 0 || userID == 0 {
return

View file

@ -55,6 +55,29 @@ func TestChannelMemberCachePutGetDeleteFlush(t *testing.T) {
}
}
func TestChannelMemberCachePutIfEpochRejectsStaleSnapshot(t *testing.T) {
c := NewChannelMemberCache(16)
epoch := c.cacheEpoch()
c.delete(10, 20)
c.putIfEpoch(domain.ChannelMember{
ChannelID: 10,
UserID: 20,
Status: domain.ChannelMemberActive,
}, epoch)
if _, ok := c.get(10, 20); ok {
t.Fatal("stale materialized membership restored after invalidation")
}
freshEpoch := c.cacheEpoch()
c.putIfEpoch(domain.ChannelMember{
ChannelID: 10,
UserID: 20,
Status: domain.ChannelMemberActive,
}, freshEpoch)
if member, ok := c.get(10, 20); !ok || member.Status != domain.ChannelMemberActive {
t.Fatalf("fresh materialized membership = %+v ok=%v", member, ok)
}
}
func TestChannelMemberCacheDeleteChannelAndCap(t *testing.T) {
c := NewChannelMemberCache(2)
c.put(domain.ChannelMember{ChannelID: 1, UserID: 10})

View file

@ -126,6 +126,9 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, userID, member.ReadInboxMaxI
if err := refreshChannelUnreadReactionsCountTx(ctx, tx, userID, channelID); err != nil {
return domain.CreateChannelResult{}, err
}
if err := enqueueWelcomeMessageDeliveriesTx(ctx, tx, channelID, []domain.ChannelMember{member}); err != nil {
return domain.CreateChannelResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.CreateChannelResult{}, fmt.Errorf("commit join channel: %w", err)
}
@ -242,6 +245,9 @@ WHERE id = $1`, channelID, channel.CreatorUserID, adminsDelta); err != nil {
if err := clearChannelMentionsForUserTx(ctx, tx, channelID, userID); err != nil {
return domain.CreateChannelResult{}, err
}
if err := deleteWelcomeMessageDeliveriesTx(ctx, tx, channelID, []int64{userID}); err != nil {
return domain.CreateChannelResult{}, err
}
var msg domain.ChannelMessage
var event domain.ChannelUpdateEvent
if channel.Megagroup {

View file

@ -10,6 +10,7 @@ import (
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func (s *ChannelStore) GetParticipants(ctx context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
@ -364,6 +365,70 @@ ORDER BY user_id`, channelID, candidates[start:end])
return out, nil
}
func (s *ChannelStore) FilterActiveChannelMemberPairs(ctx context.Context, userIDsByChannel map[int64][]int64) (map[int64][]int64, error) {
channelIDs, userIDs, err := flattenActiveChannelMemberPairs(userIDsByChannel)
if err != nil {
return nil, err
}
out := make(map[int64][]int64)
if len(channelIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
WITH requested(channel_id, user_id) AS (
SELECT * FROM unnest($1::bigint[], $2::bigint[])
)
SELECT r.channel_id, r.user_id
FROM requested r
JOIN channel_members m
ON m.channel_id = r.channel_id
AND m.user_id = r.user_id
WHERE m.status = 'active'
ORDER BY r.channel_id, r.user_id`, channelIDs, userIDs)
if err != nil {
return nil, fmt.Errorf("filter active channel member pairs: %w", err)
}
defer rows.Close()
for rows.Next() {
var channelID, userID int64
if err := rows.Scan(&channelID, &userID); err != nil {
return nil, err
}
out[channelID] = append(out[channelID], userID)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func flattenActiveChannelMemberPairs(userIDsByChannel map[int64][]int64) ([]int64, []int64, error) {
channelIDs := make([]int64, 0)
userIDs := make([]int64, 0)
seen := make(map[[2]int64]struct{})
for channelID, candidates := range userIDsByChannel {
if channelID == 0 {
continue
}
for _, userID := range candidates {
if userID == 0 {
continue
}
pair := [2]int64{channelID, userID}
if _, ok := seen[pair]; ok {
continue
}
if len(seen) >= store.MaxActiveChannelMemberPairs {
return nil, nil, fmt.Errorf("%w: maximum %d", store.ErrActiveChannelMemberPairsLimit, store.MaxActiveChannelMemberPairs)
}
seen[pair] = struct{}{}
channelIDs = append(channelIDs, channelID)
userIDs = append(userIDs, userID)
}
}
return channelIDs, userIDs, nil
}
func (s *ChannelStore) FilterChannelMessageAudienceIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) {
if channelID == 0 || len(userIDs) == 0 {
return nil, nil

View file

@ -0,0 +1,64 @@
package postgres
import (
"context"
"testing"
"telesrv/internal/domain"
)
func TestFilterActiveChannelMemberPairsPostgresKeepsExactEdges(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner := createTestUser(t, ctx, users, "+1911"+suffix+"01", "Pair", "Owner")
memberA := createTestUser(t, ctx, users, "+1911"+suffix+"02", "Pair", "A")
memberB := createTestUser(t, ctx, users, "+1911"+suffix+"03", "Pair", "B")
userIDs := []int64{owner.ID, memberA.ID, memberB.ID}
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 = ANY($1::bigint[])", userIDs)
})
channels := NewChannelStore(pool)
first, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
MemberUserIDs: []int64{memberA.ID, memberB.ID},
Title: "pair first " + suffix,
Megagroup: true,
Date: 1700000000,
})
if err != nil {
t.Fatalf("CreateChannel(first): %v", err)
}
channelIDs = append(channelIDs, first.Channel.ID)
second, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
MemberUserIDs: []int64{memberA.ID, memberB.ID},
Title: "pair second " + suffix,
Megagroup: true,
Date: 1700000001,
})
if err != nil {
t.Fatalf("CreateChannel(second): %v", err)
}
channelIDs = append(channelIDs, second.Channel.ID)
got, err := channels.FilterActiveChannelMemberPairs(ctx, map[int64][]int64{
first.Channel.ID: {memberA.ID},
second.Channel.ID: {memberB.ID},
})
if err != nil {
t.Fatalf("FilterActiveChannelMemberPairs: %v", err)
}
if len(got[first.Channel.ID]) != 1 || got[first.Channel.ID][0] != memberA.ID {
t.Fatalf("first channel result = %+v, want [%d]", got[first.Channel.ID], memberA.ID)
}
if len(got[second.Channel.ID]) != 1 || got[second.Channel.ID][0] != memberB.ID {
t.Fatalf("second channel result = %+v, want [%d]", got[second.Channel.ID], memberB.ID)
}
}

View file

@ -0,0 +1,274 @@
package postgres
import (
"context"
"encoding/json"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
func channelMembersForUpdateBatchTx(ctx context.Context, tx pgx.Tx, channelID int64, userIDs []int64) (map[int64]domain.ChannelMember, error) {
rows, err := tx.Query(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at,
admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts,
history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
FROM channel_members
WHERE channel_id = $1 AND user_id = ANY($2::bigint[])
ORDER BY user_id
FOR UPDATE`, channelID, userIDs)
if err != nil {
return nil, fmt.Errorf("lock channel invite members: %w", err)
}
defer rows.Close()
out := make(map[int64]domain.ChannelMember, len(userIDs))
for rows.Next() {
member, err := scanChannelMember(rows)
if err != nil {
return nil, err
}
out[member.UserID] = member
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("lock channel invite members: %w", err)
}
return out, nil
}
func enableChannelMembershipBatchTx(ctx context.Context, tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT set_config('telesrv.membership_batch_mode', 'on', true)`); err != nil {
return fmt.Errorf("enable channel membership batch invalidation: %w", err)
}
return nil
}
func upsertChannelMembersBatchTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, members []domain.ChannelMember) error {
if len(members) == 0 {
return nil
}
userIDs := make([]int64, len(members))
inviterIDs := make([]int64, len(members))
joinedAt := make([]int32, len(members))
availableMinIDs := make([]int32, len(members))
availableMinPts := make([]int32, len(members))
readInboxMaxIDs := make([]int32, len(members))
for i, member := range members {
userIDs[i] = member.UserID
inviterIDs[i] = member.InviterUserID
joinedAt[i] = int32(member.JoinedAt)
availableMinIDs[i] = int32(member.AvailableMinID)
availableMinPts[i] = int32(member.AvailableMinPts)
readInboxMaxIDs[i] = int32(member.ReadInboxMaxID)
}
if _, err := tx.Exec(ctx, `
WITH input AS MATERIALIZED (
SELECT user_id, inviter_user_id, joined_at, available_min_id, available_min_pts, read_inbox_max_id
FROM unnest(
$2::bigint[], $3::bigint[], $4::integer[], $5::integer[], $6::integer[], $7::integer[]
) AS value(user_id, inviter_user_id, joined_at, available_min_id, available_min_pts, read_inbox_max_id)
)
INSERT INTO channel_members (
channel_id, user_id, inviter_user_id, role, status, joined_at, left_at,
admin_rights, banned_rights, rank, available_min_id, available_min_pts,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
)
SELECT $1, user_id, inviter_user_id, 'member', 'active', joined_at, 0,
'{}'::jsonb, '{}'::jsonb, '', available_min_id, available_min_pts,
read_inbox_max_id, 0, false, 0
FROM input
ORDER BY user_id
ON CONFLICT (channel_id, user_id) DO UPDATE SET
inviter_user_id = EXCLUDED.inviter_user_id,
role = EXCLUDED.role,
status = EXCLUDED.status,
joined_at = EXCLUDED.joined_at,
left_at = EXCLUDED.left_at,
admin_rights = EXCLUDED.admin_rights,
banned_rights = EXCLUDED.banned_rights,
rank = EXCLUDED.rank,
available_min_id = GREATEST(channel_members.available_min_id, EXCLUDED.available_min_id),
available_min_pts = GREATEST(channel_members.available_min_pts, EXCLUDED.available_min_pts),
read_inbox_max_id = GREATEST(channel_members.read_inbox_max_id, EXCLUDED.read_inbox_max_id),
updated_at = now()`, channel.ID, userIDs, inviterIDs, joinedAt, availableMinIDs, availableMinPts, readInboxMaxIDs); err != nil {
return fmt.Errorf("batch upsert channel members: %w", err)
}
if _, err := tx.Exec(ctx, `
WITH input AS MATERIALIZED (
SELECT user_id
FROM unnest($2::bigint[]) AS value(user_id)
)
INSERT INTO user_channel_member_index (
user_id, channel_id, status, megagroup, broadcast, deleted,
role, left_at, forum, public_username, can_pin_messages
)
SELECT user_id, $1, 'active', $3, $4, $5, 'member', 0, $6, $7, false
FROM input
ORDER BY user_id
ON CONFLICT (user_id, channel_id) DO UPDATE SET
status = EXCLUDED.status,
megagroup = EXCLUDED.megagroup,
broadcast = EXCLUDED.broadcast,
deleted = EXCLUDED.deleted,
role = EXCLUDED.role,
left_at = EXCLUDED.left_at,
forum = EXCLUDED.forum,
public_username = EXCLUDED.public_username,
can_pin_messages = EXCLUDED.can_pin_messages,
updated_at = now()`, channel.ID, userIDs, channel.Megagroup, channel.Broadcast, channel.Deleted, channel.Forum, channel.Username != ""); err != nil {
return fmt.Errorf("batch upsert user channel member index: %w", err)
}
return nil
}
func insertChannelInviteAdminLogsBatchTx(ctx context.Context, tx pgx.Tx, channelID, inviterUserID int64, date int, members []domain.ChannelMember) error {
if len(members) == 0 {
return nil
}
type row struct {
Ordinal int `json:"ordinal"`
Participant domain.ChannelMember `json:"participant"`
}
input := make([]row, len(members))
for i, member := range members {
input[i] = row{Ordinal: i + 1, Participant: member}
}
payload, err := json.Marshal(input)
if err != nil {
return fmt.Errorf("marshal channel invite admin logs: %w", err)
}
if _, err := tx.Exec(ctx, `
WITH input AS MATERIALIZED (
SELECT ordinal, participant
FROM jsonb_to_recordset($5::jsonb) AS value(ordinal integer, participant jsonb)
), allocated AS MATERIALIZED (
UPDATE channels
SET admin_log_seq = admin_log_seq + $4, updated_at = now()
WHERE id = $1
RETURNING admin_log_seq
)
INSERT INTO channel_admin_log_events (
channel_id, id, actor_user_id, event_date, event_type, participant, query
)
SELECT $1, allocated.admin_log_seq - $4 + input.ordinal, $2, $3,
'participant_invite', input.participant, ''
FROM input
CROSS JOIN allocated
ORDER BY input.ordinal`, channelID, inviterUserID, date, len(members), string(payload)); err != nil {
return fmt.Errorf("batch insert channel invite admin logs: %w", err)
}
return nil
}
func upsertChannelDialogsBatchTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, top domain.ChannelMessage, members []domain.ChannelMember) error {
if len(members) == 0 {
return nil
}
topDate := top.Date
if topDate == 0 {
topDate = channel.Date
}
userIDs := make([]int64, len(members))
readInboxMaxIDs := make([]int32, len(members))
readOutboxMaxIDs := make([]int32, len(members))
for i, member := range members {
userIDs[i] = member.UserID
readInboxMaxIDs[i] = int32(member.ReadInboxMaxID)
readOutboxMaxIDs[i] = int32(member.ReadOutboxMaxID)
}
if _, err := tx.Exec(ctx, `
WITH input AS MATERIALIZED (
SELECT user_id, read_inbox_max_id, read_outbox_max_id
FROM unnest($4::bigint[], $5::integer[], $6::integer[])
AS value(user_id, read_inbox_max_id, read_outbox_max_id)
)
INSERT INTO channel_dialogs (
user_id, channel_id, top_message_id, top_message_date,
read_inbox_max_id, read_outbox_max_id, unread_count, unread_mark
)
SELECT user_id, $1, $2, $3, read_inbox_max_id, read_outbox_max_id, 0, false
FROM input
ORDER BY user_id
ON CONFLICT (user_id, channel_id) DO UPDATE SET
top_message_id = GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id),
top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date),
read_inbox_max_id = GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id),
read_outbox_max_id = GREATEST(channel_dialogs.read_outbox_max_id, EXCLUDED.read_outbox_max_id),
unread_mark = false,
updated_at = now()`, channel.ID, channel.TopMessageID, topDate, userIDs, readInboxMaxIDs, readOutboxMaxIDs); err != nil {
return fmt.Errorf("batch upsert channel dialogs: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE channel_dialogs AS dialog
SET unread_count = (
SELECT COUNT(*)::int
FROM (
SELECT 1
FROM channel_messages AS message
WHERE message.channel_id = dialog.channel_id
AND message.id > dialog.read_inbox_max_id
AND message.id <= dialog.top_message_id
AND message.sender_user_id <> dialog.user_id
AND NOT message.deleted
LIMIT $3
) AS capped
),
updated_at = now()
WHERE dialog.channel_id = $1
AND dialog.user_id = ANY($2::bigint[])`, channel.ID, userIDs, domain.MaxDialogUnreadCount); err != nil {
return fmt.Errorf("batch refresh channel dialog unread count: %w", err)
}
return nil
}
func refreshChannelUnreadReactionsCountsBatchTx(ctx context.Context, tx pgx.Tx, channelID int64, userIDs []int64) error {
if len(userIDs) == 0 {
return nil
}
if _, err := tx.Exec(ctx, `
WITH input AS MATERIALIZED (
SELECT user_id FROM unnest($2::bigint[]) AS value(user_id)
), counts AS MATERIALIZED (
SELECT input.user_id,
(
SELECT COUNT(DISTINCT reaction.message_id)::int
FROM channel_message_reactions AS reaction
JOIN channel_messages AS message
ON message.channel_id = reaction.channel_id AND message.id = reaction.message_id
JOIN channel_members AS member
ON member.channel_id = reaction.channel_id AND member.user_id = input.user_id
WHERE reaction.sender_user_id = input.user_id
AND reaction.channel_id = $1
AND reaction.unread
AND reaction.reacted_user_id <> input.user_id
AND message.id > member.available_min_id
AND NOT message.deleted
AND member.status = 'active'
AND NOT COALESCE((member.banned_rights->>'ViewMessages')::boolean, false)
) AS count
FROM input
)
INSERT INTO channel_dialogs (user_id, channel_id, unread_reactions_count)
SELECT user_id, $1, count
FROM counts
ORDER BY user_id
ON CONFLICT (user_id, channel_id) DO UPDATE SET
unread_reactions_count = EXCLUDED.unread_reactions_count,
updated_at = now()`, channelID, userIDs); err != nil {
return fmt.Errorf("batch refresh channel unread reactions count: %w", err)
}
return nil
}
func bumpChannelMembershipReadModelsBatchTx(ctx context.Context, tx pgx.Tx, channelID int64, userIDs []int64) error {
if len(userIDs) == 0 {
return nil
}
if _, err := tx.Exec(ctx, `SELECT public.telesrv_bump_channel_membership_read_models($1, $2::bigint[])`, channelID, userIDs); err != nil {
return fmt.Errorf("batch bump channel membership read models: %w", err)
}
return nil
}

View file

@ -163,32 +163,6 @@ func channelMessageReplyFromColumns(reply *domain.MessageReply, msgID int, peerT
return out
}
func collectChannelMessageRefs(msg domain.ChannelMessage, currentChannelID int64, userRefs, channelRefs map[int64]struct{}) {
if msg.SenderUserID != 0 {
userRefs[msg.SenderUserID] = struct{}{}
}
addPeerRef(msg.From, currentChannelID, userRefs, channelRefs)
if msg.SendAs != nil {
addPeerRef(*msg.SendAs, currentChannelID, userRefs, channelRefs)
}
if msg.Forward != nil {
addPeerRef(msg.Forward.From, currentChannelID, userRefs, channelRefs)
}
if msg.ViaBotID != 0 {
userRefs[msg.ViaBotID] = struct{}{}
}
if msg.ReplyTo != nil {
addPeerRef(msg.ReplyTo.Peer, currentChannelID, userRefs, channelRefs)
}
if msg.Action != nil {
for _, id := range msg.Action.UserIDs {
if id != 0 {
userRefs[id] = struct{}{}
}
}
}
}
type pgChannelMessageIDAllocator struct {
db sqlcgen.DBTX
}

View file

@ -1003,7 +1003,7 @@ WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, maxID, req.D
}
msg, _ := s.getChannelMessage(ctx, tx, req.ChannelID, channel.TopMessageID)
if changed {
outboxUpdates, err = advanceChannelReadOutboxTx(ctx, tx, channel, msg, req.UserID, previous, maxID)
outboxUpdates, err = advanceChannelReadOutboxTx(ctx, tx, channel.ID, req.UserID, previous, maxID)
if err != nil {
return domain.ReadChannelHistoryResult{}, err
}

View file

@ -46,6 +46,18 @@ func emptyChannelMessageReactions(channel domain.Channel) domain.ChannelMessageR
}
func (s *ChannelStore) populateChannelMessagesReactions(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, channels []domain.Channel, messages []domain.ChannelMessage) error {
return s.populateChannelMessagesReactionsWhere(ctx, db, viewerUserID, channels, messages, nil, false)
}
func (s *ChannelStore) populateChannelMessagesReactionsWhere(
ctx context.Context,
db sqlcgen.DBTX,
viewerUserID int64,
channels []domain.Channel,
messages []domain.ChannelMessage,
reactionEligible func(domain.ChannelMessage) bool,
unreadAlreadyProjected bool,
) error {
if len(messages) == 0 {
return nil
}
@ -53,8 +65,10 @@ func (s *ChannelStore) populateChannelMessagesReactions(ctx context.Context, db
if err := s.populateChannelMessagesPolls(ctx, db, viewerUserID, messages); err != nil {
return err
}
if err := populateChannelMessageUnreadFlags(ctx, db, viewerUserID, messages); err != nil {
return err
if !unreadAlreadyProjected {
if err := populateChannelMessageUnreadFlags(ctx, db, viewerUserID, messages); err != nil {
return err
}
}
channelsByID := make(map[int64]domain.Channel, len(channels))
for _, ch := range channels {
@ -68,6 +82,9 @@ func (s *ChannelStore) populateChannelMessagesReactions(ctx context.Context, db
if messages[i].ChannelID == 0 || messages[i].ID <= 0 {
continue
}
if reactionEligible != nil && !reactionEligible(messages[i]) {
continue
}
key := channelReactionMessageKey{channelID: messages[i].ChannelID, messageID: messages[i].ID}
if _, ok := indexes[key]; !ok {
idsByChannel[messages[i].ChannelID] = append(idsByChannel[messages[i].ChannelID], int32(messages[i].ID))
@ -202,6 +219,30 @@ ORDER BY channel_id ASC, message_id ASC, reaction_date DESC, reacted_user_id DES
return nil
}
// populateChannelDialogTopMessageReactions keeps poll and unread-mention
// enrichment exact for every message, but uses the shared top-message
// existence cache to avoid querying three reaction tables when no reaction row
// can possibly contribute to the viewer projection.
func (s *ChannelStore) populateChannelDialogTopMessageReactions(
ctx context.Context,
db sqlcgen.DBTX,
viewerUserID int64,
channels []domain.Channel,
messages []domain.ChannelMessage,
unreadAlreadyProjected bool,
) error {
if !s.topMessageCacheActive(db) || len(messages) == 0 {
return s.populateChannelMessagesReactions(ctx, db, viewerUserID, channels, messages)
}
presence, err := s.topMsgCache.reactionPresenceFor(ctx, db, messages)
if err != nil {
return fmt.Errorf("load channel top reaction presence: %w", err)
}
return s.populateChannelMessagesReactionsWhere(ctx, db, viewerUserID, channels, messages, func(msg domain.ChannelMessage) bool {
return presence[channelMessageLookupKey{channelID: msg.ChannelID, id: msg.ID}].any()
}, unreadAlreadyProjected)
}
func channelReactionOffset(row domain.ChannelMessagePeerReaction) string {
return strconv.Itoa(row.Date) + ":" + strconv.FormatInt(row.UserID, 10) + ":" + string(row.Reaction.Type) + ":" + row.Reaction.Value()
}

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"telesrv/internal/domain"
)

View file

@ -3,11 +3,66 @@ package postgres
import (
"context"
"errors"
"os"
"testing"
"time"
"telesrv/internal/domain"
)
func TestChannelTopReactionPresenceNegativeCacheInvalidatesOnReaction(t *testing.T) {
env := newReactionPolicyTestEnv(t, false)
ctx := context.Background()
topCache := NewChannelTopMessageCache(32)
env.channels.topMsgCache = topCache
key := channelMessageLookupKey{channelID: env.channelID, id: env.messageID}
// Observe listener readiness through a sentinel flush before warming the
// negative reaction-presence entry.
topCache.reactionPresence.Store(key, channelTopReactionPresence{Normal: true})
lctx, cancel := context.WithCancel(ctx)
defer cancel()
listener := NewReadModelChangeListener(os.Getenv("TELESRV_TEST_POSTGRES_DSN"), ReadModelCacheSet{
ChannelTopMessages: topCache,
}, nil)
go listener.Run(lctx)
if !waitUntil(2*time.Second, func() bool {
_, ok := topCache.reactionPresence.Peek(key)
return !ok
}) {
t.Fatal("read-model listener did not flush reaction sentinel")
}
before, err := env.channels.GetChannelDialogs(ctx, env.ownerID, []int64{env.channelID})
if err != nil {
t.Fatalf("warm no-reaction dialog: %v", err)
}
if len(before.Messages) != 1 || before.Messages[0].Reactions != nil {
t.Fatalf("before reaction messages = %+v", before.Messages)
}
if presence, ok := topCache.reactionPresence.Peek(key); !ok || presence.any() {
t.Fatalf("negative presence not cached: ok=%v value=%+v", ok, presence)
}
if _, err := env.react(t, env.memberID, "U0001f44d"); err != nil {
t.Fatalf("add reaction: %v", err)
}
if !waitUntil(3*time.Second, func() bool {
_, ok := topCache.reactionPresence.Peek(key)
return !ok
}) {
t.Fatal("reaction write did not invalidate negative presence")
}
after, err := env.channels.GetChannelDialogs(ctx, env.ownerID, []int64{env.channelID})
if err != nil {
t.Fatalf("dialog after reaction: %v", err)
}
if len(after.Messages) != 1 || after.Messages[0].Reactions == nil || len(after.Messages[0].Reactions.Results) != 1 || after.Messages[0].Reactions.Results[0].Count != 1 {
t.Fatalf("after reaction messages = %+v", after.Messages)
}
}
type reactionPolicyTestEnv struct {
channels *ChannelStore
channelID int64

View file

@ -2,7 +2,6 @@ package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"sort"
@ -359,7 +358,7 @@ func (s *ChannelStore) ReadChannelHistory(ctx context.Context, req domain.ReadCh
return domain.ReadChannelHistoryResult{}, lastErr
}
func advanceChannelReadOutboxTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, top domain.ChannelMessage, readerUserID int64, previous, maxID int) ([]domain.ChannelReadOutboxUpdate, error) {
func advanceChannelReadOutboxTx(ctx context.Context, tx pgx.Tx, channelID, readerUserID int64, previous, maxID int) ([]domain.ChannelReadOutboxUpdate, error) {
if maxID <= previous {
return nil, nil
}
@ -368,7 +367,7 @@ func advanceChannelReadOutboxTx(ctx context.Context, tx pgx.Tx, channel domain.C
lowerID = maxID - domain.MaxChannelReadOutboxScanMessages
}
rows, err := tx.Query(ctx, `
WITH latest_sender_messages AS (
WITH latest_sender_messages AS MATERIALIZED (
SELECT sender_user_id, MAX(id) AS max_id
FROM channel_messages
WHERE channel_id = $1
@ -379,52 +378,35 @@ WITH latest_sender_messages AS (
GROUP BY sender_user_id
ORDER BY max_id DESC
LIMIT $5
), updated AS (
UPDATE channel_members AS member
SET read_outbox_max_id = GREATEST(member.read_outbox_max_id, latest.max_id),
updated_at = now()
FROM latest_sender_messages AS latest
WHERE member.channel_id = $1
AND member.user_id = latest.sender_user_id
AND member.status = 'active'
AND member.read_outbox_max_id < latest.max_id
RETURNING member.user_id, member.read_outbox_max_id
)
SELECT sender_user_id, max_id
FROM latest_sender_messages
ORDER BY sender_user_id ASC`, channel.ID, lowerID, maxID, readerUserID, domain.MaxChannelReadOutboxFanout)
SELECT user_id, read_outbox_max_id
FROM updated
ORDER BY user_id ASC`, channelID, lowerID, maxID, readerUserID, domain.MaxChannelReadOutboxFanout)
if err != nil {
return nil, fmt.Errorf("list channel read outbox senders: %w", err)
return nil, fmt.Errorf("advance channel sender read outbox: %w", err)
}
defer rows.Close()
type candidate struct {
userID int64
maxID int
}
candidates := make([]candidate, 0, domain.MaxChannelReadOutboxFanout)
out := make([]domain.ChannelReadOutboxUpdate, 0, domain.MaxChannelReadOutboxFanout)
for rows.Next() {
var item candidate
if err := rows.Scan(&item.userID, &item.maxID); err != nil {
var item domain.ChannelReadOutboxUpdate
if err := rows.Scan(&item.UserID, &item.MaxID); err != nil {
return nil, err
}
candidates = append(candidates, item)
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]domain.ChannelReadOutboxUpdate, 0, len(candidates))
for _, item := range candidates {
var readOutboxMaxID, readInboxMaxID int
err := tx.QueryRow(ctx, `
UPDATE channel_members
SET read_outbox_max_id = GREATEST(read_outbox_max_id, $3),
updated_at = now()
WHERE channel_id = $1
AND user_id = $2
AND status = 'active'
AND read_outbox_max_id < $3
RETURNING read_outbox_max_id, read_inbox_max_id`, channel.ID, item.userID, item.maxID).Scan(&readOutboxMaxID, &readInboxMaxID)
if errors.Is(err, pgx.ErrNoRows) {
continue
}
if err != nil {
return nil, fmt.Errorf("update channel sender read outbox: %w", err)
}
if err := upsertChannelDialogTx(ctx, tx, item.userID, channel, top, readInboxMaxID, readOutboxMaxID); err != nil {
return nil, err
}
out = append(out, domain.ChannelReadOutboxUpdate{UserID: item.userID, MaxID: readOutboxMaxID})
}
return out, nil
}

View file

@ -2,12 +2,93 @@ package postgres
import (
"context"
"fmt"
"reflect"
"telesrv/internal/domain"
"telesrv/internal/observability/dbtrace"
"testing"
"time"
)
func TestChannelStoreReadHistorySenderFanoutUsesConstantStatements(t *testing.T) {
pool := testPool(t)
baseCtx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
reader, err := users.Create(baseCtx, domain.User{
AccessHash: 351, Phone: "+1776" + suffix + "00", FirstName: "SetReader",
})
if err != nil {
t.Fatalf("create reader: %v", err)
}
const senderCount = 12
senders := make([]domain.User, 0, senderCount)
userIDs := []int64{reader.ID}
for i := 0; i < senderCount; i++ {
sender, createErr := users.Create(baseCtx, domain.User{
AccessHash: int64(352 + i), Phone: fmt.Sprintf("+1776%s%02d", suffix, i+1), FirstName: "SetSender",
})
if createErr != nil {
t.Fatalf("create sender %d: %v", i, createErr)
}
senders = append(senders, sender)
userIDs = append(userIDs, sender.ID)
}
var channelID int64
t.Cleanup(func() {
if channelID != 0 {
_, _ = pool.Exec(baseCtx, "DELETE FROM channels WHERE id = $1", channelID)
}
_, _ = pool.Exec(baseCtx, "DELETE FROM users WHERE id = ANY($1::bigint[])", userIDs)
})
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(baseCtx, domain.CreateChannelRequest{
CreatorUserID: reader.ID, Title: "Set Read Outbox " + suffix, Megagroup: true,
MemberUserIDs: userIDs[1:], Date: 1700000300,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID = created.Channel.ID
topID := 0
for i, sender := range senders {
sent, sendErr := channels.SendChannelMessage(baseCtx, domain.SendChannelMessageRequest{
UserID: sender.ID, ChannelID: channelID, RandomID: int64(936000 + i),
Message: "set-based channel read outbox", Date: 1700000310 + i,
})
if sendErr != nil {
t.Fatalf("send %d: %v", i, sendErr)
}
topID = sent.Message.ID
}
ctx, stats := dbtrace.WithStats(baseCtx)
read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
UserID: reader.ID, ChannelID: channelID, MaxID: topID, Date: 1700000400,
})
if err != nil {
t.Fatalf("read channel history: %v", err)
}
if len(read.OutboxUpdates) != senderCount {
t.Fatalf("outbox updates = %d, want %d: %+v", len(read.OutboxUpdates), senderCount, read.OutboxUpdates)
}
if snapshot := stats.Snapshot(); snapshot.Errors != 0 || snapshot.Queries > 16 {
t.Fatalf("read-history query stats = %+v, want constant <=16 queries for %d senders", snapshot, senderCount)
}
for i, sender := range senders {
var readOutbox int
if err := pool.QueryRow(baseCtx, `
SELECT read_outbox_max_id FROM channel_members
WHERE channel_id=$1 AND user_id=$2`, channelID, sender.ID).Scan(&readOutbox); err != nil {
t.Fatalf("load sender %d read outbox: %v", i, err)
}
if readOutbox <= 0 || readOutbox > topID {
t.Fatalf("sender %d read outbox = %d, want 1..%d", i, readOutbox, topID)
}
}
}
func TestChannelStoreReadOutboxDoesNotRegressSenderDialogUnread(t *testing.T) {
pool := testPool(t)
ctx := context.Background()

View file

@ -49,6 +49,39 @@ func (c *ChannelRowCache) getOrLoad(ctx context.Context, id int64, load func() (
return c.cache.GetOrLoad(ctx, id, load)
}
// getOrLoadBatch resolves a page of shared channel rows with one database read
// for all misses. A zero-ID Channel is the negative-cache sentinel for an ID
// that disappeared between the owner-state query and shared-row hydration;
// channel_base invalidation removes both positive and negative entries.
func (c *ChannelRowCache) getOrLoadBatch(
ctx context.Context,
ids []int64,
load func(context.Context, []int64) (map[int64]domain.Channel, error),
) (map[int64]domain.Channel, error) {
if c == nil {
return load(ctx, ids)
}
return c.cache.GetOrLoadBatch(
ctx,
ids,
func(int64) (int64, bool) { return 0, true },
func(ctx context.Context, missing []int64) (map[int64]domain.Channel, error) {
loaded, err := load(ctx, missing)
if err != nil {
return nil, err
}
// GetOrLoadBatch requires an explicit value for every key so absent
// rows do not immediately stampede the database again.
for _, id := range missing {
if _, ok := loaded[id]; !ok {
loaded[id] = domain.Channel{}
}
}
return loaded, nil
},
)
}
func (c *ChannelRowCache) put(ch domain.Channel) {
if c == nil || ch.ID == 0 {
return

View file

@ -164,3 +164,30 @@ func TestChannelRowCacheSingleflightsColdLoad(t *testing.T) {
t.Fatalf("cache hit called load again: calls=%d", got)
}
}
func TestChannelRowCacheBatchesMissesAndNegativeRows(t *testing.T) {
c := NewChannelRowCache(8)
loads := 0
load := func(_ context.Context, ids []int64) (map[int64]domain.Channel, error) {
loads++
out := make(map[int64]domain.Channel, len(ids))
for _, id := range ids {
if id != 2 {
out[id] = domain.Channel{ID: id, Title: "channel"}
}
}
return out, nil
}
for i := 0; i < 2; i++ {
got, err := c.getOrLoadBatch(context.Background(), []int64{1, 2, 3}, load)
if err != nil {
t.Fatal(err)
}
if got[1].ID != 1 || got[2].ID != 0 || got[3].ID != 3 {
t.Fatalf("batch result = %+v", got)
}
}
if loads != 1 {
t.Fatalf("batch loads = %d, want 1", loads)
}
}

View file

@ -502,7 +502,7 @@ func (s *ChannelStore) SetChannelEmojiStatusAdmin(ctx context.Context, channelID
// which requires an acting admin member and broadcasts to the channel's
// timeline. Mirrors SetChannelColorAdmin/SetChannelEmojiStatusAdmin's shape.
func (s *ChannelStore) SetChannelPhotoAdmin(ctx context.Context, channelID int64, photo domain.Photo) (domain.Channel, error) {
if channelID == 0 {
if channelID == 0 || photo.ID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
channel, err := s.channelByID(ctx, s.db, channelID)
@ -513,10 +513,15 @@ func (s *ChannelStore) SetChannelPhotoAdmin(ctx context.Context, channelID int64
if stripped == nil {
stripped = []byte{}
}
if _, err := s.db.Exec(ctx, `UPDATE channels SET photo_id = $2, photo_dc_id = $3, photo_stripped = $4, updated_at = now() WHERE id = $1`,
channelID, photo.ID, photo.DCID, stripped); err != nil {
result, err := s.db.Exec(ctx, `UPDATE channels
SET photo_id = $2, photo_dc_id = $3, photo_stripped = $4, updated_at = now()
WHERE id = $1 AND NOT deleted`, channelID, photo.ID, photo.DCID, stripped)
if err != nil {
return domain.Channel{}, fmt.Errorf("set channel photo admin: %w", err)
}
if result.RowsAffected() != 1 {
return domain.Channel{}, domain.ErrChannelInvalid
}
if s.rowCache != nil {
s.rowCache.delete(channelID)
}

View file

@ -0,0 +1,531 @@
package postgres
import (
"context"
"fmt"
"sort"
"strconv"
"telesrv/internal/domain"
)
func (s *ChannelStore) GetChannelStats(ctx context.Context, req domain.ChannelStatsRequest) (domain.ChannelStats, error) {
if req.ViewerUserID == 0 || req.ChannelID == 0 || !req.Period.Valid() {
return domain.ChannelStats{}, domain.ErrChannelInvalid
}
channel, member, err := s.getChannelForMember(ctx, s.db, req.ViewerUserID, req.ChannelID)
if err != nil {
return domain.ChannelStats{}, err
}
if member.Role != domain.ChannelRoleCreator && member.Role != domain.ChannelRoleAdmin {
return domain.ChannelStats{}, domain.ErrChannelAdminRequired
}
stats := domain.ChannelStats{Channel: channel, Period: req.Period}
days, dayIndex := newPGStatsDays(req.Period)
previousMin := req.Period.PreviousMinDate()
memberRows, err := s.db.Query(ctx, `
SELECT joined_at, left_at
FROM channel_members
WHERE channel_id = $1
AND joined_at > 0
AND joined_at < $2
AND (left_at = 0 OR left_at >= $3)`, req.ChannelID, req.Period.MaxDate, previousMin)
if err != nil {
return domain.ChannelStats{}, fmt.Errorf("query channel stats members: %w", err)
}
for memberRows.Next() {
var joinedAt, leftAt int
if err := memberRows.Scan(&joinedAt, &leftAt); err != nil {
memberRows.Close()
return domain.ChannelStats{}, err
}
if pgStatsMemberActiveAt(joinedAt, leftAt, req.Period.MaxDate-1) {
stats.Members.Current++
}
if pgStatsMemberActiveAt(joinedAt, leftAt, req.Period.MinDate-1) {
stats.Members.Previous++
}
if i, ok := dayIndex[pgStatsDay(joinedAt)]; ok && joinedAt >= req.Period.MinDate && joinedAt < req.Period.MaxDate {
days[i].NewMembers++
}
for i := range days {
at := days[i].Date + 86400 - 1
if at >= req.Period.MaxDate {
at = req.Period.MaxDate - 1
}
if pgStatsMemberActiveAt(joinedAt, leftAt, at) {
days[i].Members++
}
}
}
if err := memberRows.Err(); err != nil {
memberRows.Close()
return domain.ChannelStats{}, err
}
memberRows.Close()
var currentMessages, previousMessages int
var currentViews, previousViews int64
var currentPosters, previousPosters int
if err := s.db.QueryRow(ctx, `
SELECT
count(*) FILTER (WHERE message_date >= $2)::int,
count(*) FILTER (WHERE message_date < $2)::int,
COALESCE(sum(views_count) FILTER (WHERE message_date >= $2), 0)::bigint,
COALESCE(sum(views_count) FILTER (WHERE message_date < $2), 0)::bigint,
count(DISTINCT sender_user_id) FILTER (WHERE message_date >= $2 AND sender_user_id <> 0)::int,
count(DISTINCT sender_user_id) FILTER (WHERE message_date < $2 AND sender_user_id <> 0)::int
FROM channel_messages
WHERE channel_id = $1
AND NOT deleted
AND action = '{}'::jsonb
AND message_date >= $3
AND message_date < $4`, req.ChannelID, req.Period.MinDate, previousMin, req.Period.MaxDate).Scan(
&currentMessages, &previousMessages, &currentViews, &previousViews, &currentPosters, &previousPosters,
); err != nil {
return domain.ChannelStats{}, fmt.Errorf("aggregate channel stats messages: %w", err)
}
var currentViewers, previousViewers int
if err := s.db.QueryRow(ctx, `
SELECT
count(DISTINCT viewer_user_id) FILTER (WHERE viewed_at >= $2)::int,
count(DISTINCT viewer_user_id) FILTER (WHERE viewed_at < $2)::int
FROM channel_message_viewers
WHERE channel_id = $1
AND viewed_at >= $3
AND viewed_at < $4`, req.ChannelID, req.Period.MinDate, previousMin, req.Period.MaxDate).Scan(&currentViewers, &previousViewers); err != nil {
return domain.ChannelStats{}, fmt.Errorf("aggregate channel stats viewers: %w", err)
}
var currentReactions, previousReactions int
if err := s.db.QueryRow(ctx, `
SELECT
count(*) FILTER (WHERE m.message_date >= $2)::int,
count(*) FILTER (WHERE m.message_date < $2)::int
FROM channel_message_reactions r
JOIN channel_messages m ON m.channel_id = r.channel_id AND m.id = r.message_id
WHERE m.channel_id = $1
AND NOT m.deleted
AND m.action = '{}'::jsonb
AND m.message_date >= $3
AND m.message_date < $4`, req.ChannelID, req.Period.MinDate, previousMin, req.Period.MaxDate).Scan(&currentReactions, &previousReactions); err != nil {
return domain.ChannelStats{}, fmt.Errorf("aggregate channel stats reactions: %w", err)
}
var currentShares, previousShares int
if err := s.db.QueryRow(ctx, `
SELECT
count(*) FILTER (WHERE src.message_date >= $2)::int,
count(*) FILTER (WHERE src.message_date < $2)::int
FROM channel_messages f
JOIN channels destination ON destination.id = f.channel_id
JOIN channel_messages src
ON src.channel_id = $1
AND src.id::text = f.fwd_from #>> '{ChannelPost}'
AND NOT src.deleted
AND src.action = '{}'::jsonb
WHERE NOT f.deleted
AND NOT destination.deleted
AND (destination.broadcast OR destination.megagroup)
AND btrim(COALESCE(destination.username, '')) <> ''
AND f.fwd_from #>> '{From,Type}' = $5
AND f.fwd_from #>> '{From,ID}' = $6
AND src.message_date >= $3
AND src.message_date < $4`, req.ChannelID, req.Period.MinDate, previousMin, req.Period.MaxDate,
string(domain.PeerTypeChannel), strconv.FormatInt(req.ChannelID, 10)).Scan(&currentShares, &previousShares); err != nil {
return domain.ChannelStats{}, fmt.Errorf("aggregate channel stats shares: %w", err)
}
stats.Messages = domain.StatsValueAndPrev{Current: float64(currentMessages), Previous: float64(previousMessages)}
stats.Viewers = domain.StatsValueAndPrev{Current: float64(currentViewers), Previous: float64(previousViewers)}
stats.Posters = domain.StatsValueAndPrev{Current: float64(currentPosters), Previous: float64(previousPosters)}
stats.ViewsPerPost = pgStatsAverage(currentViews, currentMessages, previousViews, previousMessages)
stats.SharesPerPost = pgStatsAverage(int64(currentShares), currentMessages, int64(previousShares), previousMessages)
stats.ReactionsPerPost = pgStatsAverage(int64(currentReactions), currentMessages, int64(previousReactions), previousMessages)
messageRows, err := s.db.Query(ctx, `
SELECT (message_date / 86400) * 86400 AS day,
count(*)::int,
COALESCE(sum(views_count), 0)::int,
count(DISTINCT sender_user_id) FILTER (WHERE sender_user_id <> 0)::int
FROM channel_messages
WHERE channel_id = $1 AND NOT deleted AND action = '{}'::jsonb AND message_date >= $2 AND message_date < $3
GROUP BY day
ORDER BY day`, req.ChannelID, req.Period.MinDate, req.Period.MaxDate)
if err != nil {
return domain.ChannelStats{}, fmt.Errorf("query channel stats days: %w", err)
}
for messageRows.Next() {
var date, messages, views, posters int
if err := messageRows.Scan(&date, &messages, &views, &posters); err != nil {
messageRows.Close()
return domain.ChannelStats{}, err
}
if i, ok := dayIndex[date]; ok {
days[i].Messages, days[i].Views, days[i].Posters = messages, views, posters
}
}
if err := messageRows.Err(); err != nil {
messageRows.Close()
return domain.ChannelStats{}, err
}
messageRows.Close()
viewerRows, err := s.db.Query(ctx, `
SELECT (viewed_at / 86400) * 86400 AS day, count(DISTINCT viewer_user_id)::int
FROM channel_message_viewers
WHERE channel_id = $1 AND viewed_at >= $2 AND viewed_at < $3
GROUP BY day`, req.ChannelID, req.Period.MinDate, req.Period.MaxDate)
if err != nil {
return domain.ChannelStats{}, fmt.Errorf("query channel stats viewer days: %w", err)
}
for viewerRows.Next() {
var date, viewers int
if err := viewerRows.Scan(&date, &viewers); err != nil {
viewerRows.Close()
return domain.ChannelStats{}, err
}
if i, ok := dayIndex[date]; ok {
days[i].Viewers = viewers
}
}
if err := viewerRows.Err(); err != nil {
viewerRows.Close()
return domain.ChannelStats{}, err
}
viewerRows.Close()
reactionRows, err := s.db.Query(ctx, `
SELECT (m.message_date / 86400) * 86400 AS day, r.reaction_type, r.reaction_value, count(*)::int
FROM channel_message_reactions r
JOIN channel_messages m ON m.channel_id = r.channel_id AND m.id = r.message_id
WHERE m.channel_id = $1 AND NOT m.deleted AND m.action = '{}'::jsonb AND m.message_date >= $2 AND m.message_date < $3
GROUP BY day, r.reaction_type, r.reaction_value
ORDER BY day, r.reaction_type, r.reaction_value`, req.ChannelID, req.Period.MinDate, req.Period.MaxDate)
if err != nil {
return domain.ChannelStats{}, fmt.Errorf("query channel stats reaction days: %w", err)
}
for reactionRows.Next() {
var date, count int
var reactionType, reactionValue string
if err := reactionRows.Scan(&date, &reactionType, &reactionValue, &count); err != nil {
reactionRows.Close()
return domain.ChannelStats{}, err
}
reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), reactionValue)
if !ok {
reactionRows.Close()
return domain.ChannelStats{}, fmt.Errorf("invalid persisted channel stats reaction %q/%q", reactionType, reactionValue)
}
if i, ok := dayIndex[date]; ok {
days[i].Reactions += count
days[i].ByReaction = append(days[i].ByReaction, domain.StatsReactionCount{Reaction: reaction, Count: count})
}
}
if err := reactionRows.Err(); err != nil {
reactionRows.Close()
return domain.ChannelStats{}, err
}
reactionRows.Close()
shareRows, err := s.db.Query(ctx, `
SELECT (src.message_date / 86400) * 86400 AS day, count(*)::int
FROM channel_messages f
JOIN channels destination ON destination.id = f.channel_id
JOIN channel_messages src
ON src.channel_id = $1
AND src.id::text = f.fwd_from #>> '{ChannelPost}'
AND NOT src.deleted
AND src.action = '{}'::jsonb
WHERE NOT f.deleted
AND NOT destination.deleted
AND (destination.broadcast OR destination.megagroup)
AND btrim(COALESCE(destination.username, '')) <> ''
AND f.fwd_from #>> '{From,Type}' = $4
AND f.fwd_from #>> '{From,ID}' = $5
AND src.message_date >= $2 AND src.message_date < $3
GROUP BY day`, req.ChannelID, req.Period.MinDate, req.Period.MaxDate,
string(domain.PeerTypeChannel), strconv.FormatInt(req.ChannelID, 10))
if err != nil {
return domain.ChannelStats{}, fmt.Errorf("query channel stats share days: %w", err)
}
for shareRows.Next() {
var date, shares int
if err := shareRows.Scan(&date, &shares); err != nil {
shareRows.Close()
return domain.ChannelStats{}, err
}
if i, ok := dayIndex[date]; ok {
days[i].Shares = shares
}
}
if err := shareRows.Err(); err != nil {
shareRows.Close()
return domain.ChannelStats{}, err
}
shareRows.Close()
sortPGStatsReactions(days)
stats.Days = days
topRows, err := s.db.Query(ctx, `
SELECT sender_user_id, count(*)::int,
CASE WHEN count(*) = 0 THEN 0 ELSE (sum(char_length(body)) / count(*))::int END
FROM channel_messages
WHERE channel_id = $1 AND NOT deleted AND action = '{}'::jsonb AND sender_user_id <> 0
AND message_date >= $2 AND message_date < $3
GROUP BY sender_user_id
ORDER BY count(*) DESC, sender_user_id
LIMIT $4`, req.ChannelID, req.Period.MinDate, req.Period.MaxDate, domain.MaxChannelStatsTopPosters)
if err != nil {
return domain.ChannelStats{}, fmt.Errorf("query channel stats top posters: %w", err)
}
for topRows.Next() {
var item domain.ChannelStatsTopPoster
if err := topRows.Scan(&item.UserID, &item.Messages, &item.AvgChars); err != nil {
topRows.Close()
return domain.ChannelStats{}, err
}
stats.TopPosters = append(stats.TopPosters, item)
}
if err := topRows.Err(); err != nil {
topRows.Close()
return domain.ChannelStats{}, err
}
topRows.Close()
recentRows, err := s.db.Query(ctx, `
SELECT src.id, src.views_count,
(SELECT count(*)::int
FROM channel_messages f
JOIN channels destination ON destination.id = f.channel_id
WHERE NOT f.deleted AND NOT destination.deleted
AND (destination.broadcast OR destination.megagroup)
AND btrim(COALESCE(destination.username, '')) <> ''
AND f.fwd_from #>> '{From,Type}' = $3
AND f.fwd_from #>> '{From,ID}' = $4
AND f.fwd_from #>> '{ChannelPost}' = src.id::text),
(SELECT count(*)::int FROM channel_message_reactions r
WHERE r.channel_id = src.channel_id AND r.message_id = src.id)
FROM channel_messages src
WHERE src.channel_id = $1 AND NOT src.deleted AND src.action = '{}'::jsonb
ORDER BY src.message_date DESC, src.id DESC
LIMIT $2`, req.ChannelID, domain.MaxChannelStatsRecentPosts,
string(domain.PeerTypeChannel), strconv.FormatInt(req.ChannelID, 10))
if err != nil {
return domain.ChannelStats{}, fmt.Errorf("query channel stats recent posts: %w", err)
}
for recentRows.Next() {
var item domain.ChannelStatsRecentPost
if err := recentRows.Scan(&item.MessageID, &item.Views, &item.Forwards, &item.Reactions); err != nil {
recentRows.Close()
return domain.ChannelStats{}, err
}
stats.RecentPosts = append(stats.RecentPosts, item)
}
if err := recentRows.Err(); err != nil {
recentRows.Close()
return domain.ChannelStats{}, err
}
recentRows.Close()
return stats, nil
}
func (s *ChannelStore) GetChannelMessageStats(ctx context.Context, req domain.ChannelMessageStatsRequest) (domain.ChannelMessageStats, error) {
if req.ViewerUserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
req.MessageID > domain.MaxMessageBoxID || !req.Period.Valid() {
return domain.ChannelMessageStats{}, domain.ErrMessageIDInvalid
}
channel, member, err := s.getChannelForMember(ctx, s.db, req.ViewerUserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageStats{}, err
}
if member.Role != domain.ChannelRoleCreator && member.Role != domain.ChannelRoleAdmin {
return domain.ChannelMessageStats{}, domain.ErrChannelAdminRequired
}
message, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID)
if err != nil || message.Deleted {
return domain.ChannelMessageStats{}, domain.ErrMessageIDInvalid
}
days, dayIndex := newPGStatsDays(req.Period)
viewRows, err := s.db.Query(ctx, `
SELECT (viewed_at / 86400) * 86400 AS day, count(*)::int
FROM channel_message_viewers
WHERE channel_id = $1 AND message_id = $2 AND viewed_at >= $3 AND viewed_at < $4
GROUP BY day`, req.ChannelID, req.MessageID, req.Period.MinDate, req.Period.MaxDate)
if err != nil {
return domain.ChannelMessageStats{}, fmt.Errorf("query channel message stats views: %w", err)
}
for viewRows.Next() {
var date, count int
if err := viewRows.Scan(&date, &count); err != nil {
viewRows.Close()
return domain.ChannelMessageStats{}, err
}
if i, ok := dayIndex[date]; ok {
days[i].Views = count
}
}
if err := viewRows.Err(); err != nil {
viewRows.Close()
return domain.ChannelMessageStats{}, err
}
viewRows.Close()
reactionRows, err := s.db.Query(ctx, `
SELECT (reaction_date / 86400) * 86400 AS day, reaction_type, reaction_value, count(*)::int
FROM channel_message_reactions
WHERE channel_id = $1 AND message_id = $2 AND reaction_date >= $3 AND reaction_date < $4
GROUP BY day, reaction_type, reaction_value
ORDER BY day, reaction_type, reaction_value`, req.ChannelID, req.MessageID, req.Period.MinDate, req.Period.MaxDate)
if err != nil {
return domain.ChannelMessageStats{}, fmt.Errorf("query channel message stats reactions: %w", err)
}
for reactionRows.Next() {
var date, count int
var reactionType, reactionValue string
if err := reactionRows.Scan(&date, &reactionType, &reactionValue, &count); err != nil {
reactionRows.Close()
return domain.ChannelMessageStats{}, err
}
reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), reactionValue)
if !ok {
reactionRows.Close()
return domain.ChannelMessageStats{}, fmt.Errorf("invalid persisted message stats reaction %q/%q", reactionType, reactionValue)
}
if i, ok := dayIndex[date]; ok {
days[i].Reactions += count
days[i].ByReaction = append(days[i].ByReaction, domain.StatsReactionCount{Reaction: reaction, Count: count})
}
}
if err := reactionRows.Err(); err != nil {
reactionRows.Close()
return domain.ChannelMessageStats{}, err
}
reactionRows.Close()
sortPGStatsReactions(days)
return domain.ChannelMessageStats{Channel: channel, Message: message, Period: req.Period, Days: days}, nil
}
func (s *ChannelStore) ListChannelMessagePublicForwards(ctx context.Context, req domain.ChannelMessagePublicForwardListRequest) (domain.ChannelMessagePublicForwardList, error) {
if req.ViewerUserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
req.MessageID > domain.MaxMessageBoxID || req.Limit <= 0 || req.Limit > domain.MaxChannelMessagePublicForwards {
return domain.ChannelMessagePublicForwardList{}, domain.ErrChannelInvalid
}
cursor, err := domain.ParseChannelMessagePublicForwardCursor(req.Offset)
if err != nil {
return domain.ChannelMessagePublicForwardList{}, err
}
_, member, err := s.getChannelForMember(ctx, s.db, req.ViewerUserID, req.ChannelID)
if err != nil {
return domain.ChannelMessagePublicForwardList{}, err
}
if member.Role != domain.ChannelRoleCreator && member.Role != domain.ChannelRoleAdmin {
return domain.ChannelMessagePublicForwardList{}, domain.ErrChannelAdminRequired
}
source, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID)
if err != nil || source.Deleted {
return domain.ChannelMessagePublicForwardList{}, domain.ErrMessageIDInvalid
}
where := `
NOT deleted
AND fwd_from #>> '{From,Type}' = $1
AND fwd_from #>> '{From,ID}' = $2
AND fwd_from #>> '{ChannelPost}' = $3
AND EXISTS (
SELECT 1 FROM channels c
WHERE c.id = channel_messages.channel_id
AND NOT c.deleted
AND (c.broadcast OR c.megagroup)
AND btrim(COALESCE(c.username, '')) <> ''
)`
args := []any{string(domain.PeerTypeChannel), strconv.FormatInt(req.ChannelID, 10), strconv.Itoa(req.MessageID)}
var count int
if err := s.db.QueryRow(ctx, `SELECT count(*)::int FROM channel_messages WHERE `+where, args...).Scan(&count); err != nil {
return domain.ChannelMessagePublicForwardList{}, fmt.Errorf("count channel message public forwards: %w", err)
}
cursorClause := ""
if cursor.Date != 0 {
args = append(args, cursor.Date, cursor.ChannelID, cursor.MessageID)
cursorClause = fmt.Sprintf(`
AND (
message_date < $%d
OR (message_date = $%d AND (
channel_id > $%d
OR (channel_id = $%d AND id < $%d)
))
)`, len(args)-2, len(args)-2, len(args)-1, len(args)-1, len(args))
}
args = append(args, req.Limit+1)
rows, err := s.db.Query(ctx, `
SELECT `+channelMessageColumns+`
FROM channel_messages
WHERE `+where+cursorClause+`
ORDER BY message_date DESC, channel_id ASC, id DESC
LIMIT $`+strconv.Itoa(len(args)), args...)
if err != nil {
return domain.ChannelMessagePublicForwardList{}, fmt.Errorf("list channel message public forwards: %w", err)
}
defer rows.Close()
messages := make([]domain.ChannelMessage, 0, req.Limit+1)
for rows.Next() {
message, err := scanChannelMessage(rows)
if err != nil {
return domain.ChannelMessagePublicForwardList{}, err
}
messages = append(messages, message)
}
if err := rows.Err(); err != nil {
return domain.ChannelMessagePublicForwardList{}, err
}
next := ""
if len(messages) > req.Limit {
messages = messages[:req.Limit]
next = domain.FormatChannelMessagePublicForwardCursor(messages[len(messages)-1])
}
return domain.ChannelMessagePublicForwardList{Count: count, Messages: messages, NextOffset: next}, nil
}
func newPGStatsDays(period domain.StatsPeriod) ([]domain.ChannelStatsDay, map[int]int) {
start, end := pgStatsDay(period.MinDate), pgStatsDay(period.MaxDate-1)
days := make([]domain.ChannelStatsDay, 0, (end-start)/86400+1)
index := make(map[int]int)
for date := start; date <= end; date += 86400 {
index[date] = len(days)
days = append(days, domain.ChannelStatsDay{Date: date})
}
return days, index
}
func pgStatsDay(date int) int {
if date <= 0 {
return 0
}
return date - date%86400
}
func pgStatsMemberActiveAt(joinedAt, leftAt, at int) bool {
return joinedAt > 0 && joinedAt <= at && (leftAt == 0 || leftAt > at)
}
func pgStatsAverage(current int64, currentCount int, previous int64, previousCount int) domain.StatsValueAndPrev {
var out domain.StatsValueAndPrev
if currentCount > 0 {
out.Current = float64(current) / float64(currentCount)
}
if previousCount > 0 {
out.Previous = float64(previous) / float64(previousCount)
}
return out
}
func sortPGStatsReactions(days []domain.ChannelStatsDay) {
for i := range days {
sort.Slice(days[i].ByReaction, func(a, b int) bool {
return days[i].ByReaction[a].Reaction.Key() < days[i].ByReaction[b].Reaction.Key()
})
}
}

View file

@ -0,0 +1,137 @@
package postgres
import (
"context"
"testing"
"telesrv/internal/domain"
)
func TestChannelStatsPostgresAggregatesAndPagesPublicForwards(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: 99001, Phone: "+1888" + suffix + "01", FirstName: "StatsOwner",
})
if err != nil {
t.Fatalf("create owner: %v", err)
}
channels := NewChannelStore(pool)
channelIDs := make([]int64, 0, 3)
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)
})
period := domain.StatsPeriod{MinDate: 1_700_006_400, MaxDate: 1_700_611_200}
source, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "PG stats source " + suffix, Broadcast: true, Date: period.MinDate - 100,
})
if err != nil {
t.Fatalf("create source: %v", err)
}
channelIDs = append(channelIDs, source.Channel.ID)
if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID, ChannelID: source.Channel.ID, RandomID: 1, Message: "previous", Date: period.MinDate - 10,
}); err != nil {
t.Fatalf("send previous: %v", err)
}
post, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID, ChannelID: source.Channel.ID, RandomID: 2, Message: "current", Date: period.MinDate + 10,
})
if err != nil {
t.Fatalf("send current: %v", err)
}
if _, err := channels.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{
UserID: owner.ID, ChannelID: source.Channel.ID, IDs: []int{post.Message.ID}, Increment: true, Date: period.MinDate + 20,
}); err != nil {
t.Fatalf("increment view: %v", err)
}
if _, err := channels.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{
UserID: owner.ID, ChannelID: source.Channel.ID, MessageID: post.Message.ID,
Reactions: []domain.MessageReaction{{Type: domain.MessageReactionEmoji, Emoticon: "👍"}}, Date: period.MinDate + 30,
}); err != nil {
t.Fatalf("react: %v", err)
}
publicCreated, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "PG public destination " + suffix, Broadcast: true, Date: period.MinDate + 40,
})
if err != nil {
t.Fatalf("create public destination: %v", err)
}
channelIDs = append(channelIDs, publicCreated.Channel.ID)
publicChannel, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{
UserID: owner.ID, ChannelID: publicCreated.Channel.ID, Username: "statsfw" + suffix,
})
if err != nil {
t.Fatalf("make destination public: %v", err)
}
privateCreated, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "PG private destination " + suffix, Broadcast: true, Date: period.MinDate + 40,
})
if err != nil {
t.Fatalf("create private destination: %v", err)
}
channelIDs = append(channelIDs, privateCreated.Channel.ID)
forward := &domain.MessageForward{
From: domain.Peer{Type: domain.PeerTypeChannel, ID: source.Channel.ID}, Date: post.Message.Date, ChannelPost: post.Message.ID,
}
for i, date := range []int{period.MinDate + 50, period.MinDate + 60} {
if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID, ChannelID: publicChannel.ID, RandomID: int64(10 + i), Message: "public forward", Forward: forward, Date: date,
}); err != nil {
t.Fatalf("send public forward %d: %v", i, err)
}
}
if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID, ChannelID: privateCreated.Channel.ID, RandomID: 20, Message: "private forward", Forward: forward, Date: period.MinDate + 70,
}); err != nil {
t.Fatalf("send private forward: %v", err)
}
stats, err := channels.GetChannelStats(ctx, domain.ChannelStatsRequest{
ViewerUserID: owner.ID, ChannelID: source.Channel.ID, Period: period,
})
if err != nil {
t.Fatalf("get stats: %v", err)
}
if stats.Members.Current != 1 || stats.Messages.Current != 1 || stats.Messages.Previous != 1 ||
stats.Viewers.Current != 1 || stats.ViewsPerPost.Current != 1 || stats.SharesPerPost.Current != 2 ||
stats.ReactionsPerPost.Current != 1 {
t.Fatalf("stats = %+v, want persisted values", stats)
}
if len(stats.Days) == 0 || stats.Days[0].Views != 1 || stats.Days[0].Shares != 2 || stats.Days[0].Reactions != 1 {
t.Fatalf("stats days = %+v", stats.Days)
}
messageStats, err := channels.GetChannelMessageStats(ctx, domain.ChannelMessageStatsRequest{
ViewerUserID: owner.ID, ChannelID: source.Channel.ID, MessageID: post.Message.ID, Period: period,
})
if err != nil {
t.Fatalf("get message stats: %v", err)
}
if len(messageStats.Days) == 0 || messageStats.Days[0].Views != 1 || messageStats.Days[0].Reactions != 1 {
t.Fatalf("message stats days = %+v", messageStats.Days)
}
first, err := channels.ListChannelMessagePublicForwards(ctx, domain.ChannelMessagePublicForwardListRequest{
ViewerUserID: owner.ID, ChannelID: source.Channel.ID, MessageID: post.Message.ID, Limit: 1,
})
if err != nil {
t.Fatalf("list first page: %v", err)
}
if first.Count != 2 || len(first.Messages) != 1 || first.NextOffset == "" {
t.Fatalf("first page = %+v", first)
}
second, err := channels.ListChannelMessagePublicForwards(ctx, domain.ChannelMessagePublicForwardListRequest{
ViewerUserID: owner.ID, ChannelID: source.Channel.ID, MessageID: post.Message.ID, Offset: first.NextOffset, Limit: 1,
})
if err != nil {
t.Fatalf("list second page: %v", err)
}
if second.Count != 2 || len(second.Messages) != 1 || second.Messages[0].ID == first.Messages[0].ID || second.NextOffset != "" {
t.Fatalf("second page = %+v", second)
}
}

View file

@ -17,14 +17,16 @@ const retryableChannelTxAttempts = 3
// ChannelStore 用 PostgreSQL 实现 store.ChannelStore。
type ChannelStore struct {
db sqlcgen.DBTX
ids store.ChannelIDAllocator
msgIDs store.ChannelMessageIDAllocator
log *zap.Logger
rowCache *ChannelRowCache
memberCache *ChannelMemberCache
dialogCache *ChannelDialogCache
boostCache *ChannelBoostCache
db sqlcgen.DBTX
ids store.ChannelIDAllocator
msgIDs store.ChannelMessageIDAllocator
log *zap.Logger
rowCache *ChannelRowCache
topMsgCache *ChannelTopMessageCache
memberCache *ChannelMemberCache
dialogCache *ChannelDialogCache
boostCache *ChannelBoostCache
differenceCache *ChannelDifferenceBaseCache
}
// ChannelStoreOption 调整 PostgreSQL ChannelStore 依赖。
@ -53,6 +55,14 @@ func WithChannelRowCache(cache *ChannelRowCache) ChannelStoreOption {
}
}
// WithChannelTopMessageCache injects the shared dialog-top message cache. The
// cache never contains viewer reaction/read overlays.
func WithChannelTopMessageCache(cache *ChannelTopMessageCache) ChannelStoreOption {
return func(s *ChannelStore) {
s.topMsgCache = cache
}
}
// WithChannelMemberCache 注入「频道成员/访问态」进程内缓存。
// 传 nil 等于禁用;事务内仍绕过,提交后由 read model listener 失效。
func WithChannelMemberCache(cache *ChannelMemberCache) ChannelStoreOption {
@ -77,12 +87,25 @@ func WithChannelBoostCache(cache *ChannelBoostCache) ChannelStoreOption {
}
}
// WithChannelDifferenceBaseCache injects the shared immutable event/message
// page cache used by updates.getChannelDifference. Viewer access and overlays
// remain outside this cache.
func WithChannelDifferenceBaseCache(cache *ChannelDifferenceBaseCache) ChannelStoreOption {
return func(s *ChannelStore) {
s.differenceCache = cache
}
}
// cacheActive 报告当前句柄是否可用频道行缓存:仅启用缓存且走连接池(非事务)时。
// 事务内(db != s.db)一律绕过缓存实时读,保证事务读己写。
func (s *ChannelStore) cacheActive(db sqlcgen.DBTX) bool {
return s.rowCache != nil && db == s.db
}
func (s *ChannelStore) topMessageCacheActive(db sqlcgen.DBTX) bool {
return s.topMsgCache != nil && db == s.db
}
func (s *ChannelStore) memberCacheActive(db sqlcgen.DBTX) bool {
return s.memberCache != nil && db == s.db
}

View file

@ -13,6 +13,12 @@ import (
const suggestedPostSettlementAge = 24 * 60 * 60
// A claim is durable queue metadata, not a business transition. It prevents
// two dispatcher instances that selected the same due key from processing it
// concurrently. A crashed worker only delays that key; it does not block any
// sibling aggregate, and the bounded failure backoff may shorten the lease.
const suggestedPostLifecycleClaimSeconds = 5 * 60
type persistedSuggestedPostApproval struct {
monoforumID, parentID, actorID, payerID int64
messageID, scheduleDate, approvalServiceID, publishedMessageID, settlementDue, finalServiceID int
@ -62,9 +68,22 @@ func (s *ChannelStore) ToggleSuggestedPostApproval(ctx context.Context, req doma
if parent.Deleted || !parent.Broadcast || parent.LinkedMonoforumID != mono.ID {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
}
// All lifecycle/toggle paths lock an existing approval before its original
// message. A first command has no row to lock, so it rechecks after taking
// the message lock; this preserves single creation without a gap lock.
existing, found, err := loadSuggestedPostApprovalTx(ctx, tx, mono.ID, req.MessageID, true)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
if _, err := tx.Exec(ctx, `SELECT 1 FROM channel_messages WHERE channel_id=$1 AND id=$2 FOR UPDATE`, mono.ID, req.MessageID); err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
if !found {
existing, found, err = loadSuggestedPostApprovalTx(ctx, tx, mono.ID, req.MessageID, true)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
}
original, err := s.getChannelMessage(ctx, tx, mono.ID, req.MessageID)
if err != nil {
if errors.Is(err, domain.ErrMessageIDInvalid) {
@ -92,10 +111,6 @@ func (s *ChannelStore) ToggleSuggestedPostApproval(ctx context.Context, req doma
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostApprovalForbidden
}
existing, found, err := loadSuggestedPostApprovalTx(ctx, tx, mono.ID, original.ID, true)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, err
}
if found && existing.state != domain.SuggestedPostStateBalanceLow {
result, err := s.loadSuggestedPostResultTx(ctx, tx, existing, true)
if err != nil {
@ -300,10 +315,21 @@ func upsertSuggestedPostApprovalTx(ctx context.Context, tx pgx.Tx, row persisted
if row.price != nil {
kind, amount, nanos = string(row.price.Kind), row.price.Amount, row.price.Nanos
}
_, err := tx.Exec(ctx, `INSERT INTO suggested_post_approvals(monoforum_id,suggestion_message_id,parent_channel_id,actor_user_id,payer_user_id,state,price_kind,price_amount,price_nanos,schedule_date,approval_service_message_id,published_message_id,settlement_due,final_service_message_id,created_at,updated_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$15)
ON CONFLICT(monoforum_id,suggestion_message_id) DO UPDATE SET actor_user_id=EXCLUDED.actor_user_id,state=EXCLUDED.state,price_kind=EXCLUDED.price_kind,price_amount=EXCLUDED.price_amount,price_nanos=EXCLUDED.price_nanos,schedule_date=EXCLUDED.schedule_date,approval_service_message_id=EXCLUDED.approval_service_message_id,published_message_id=EXCLUDED.published_message_id,settlement_due=EXCLUDED.settlement_due,final_service_message_id=EXCLUDED.final_service_message_id,updated_at=EXCLUDED.updated_at`,
row.monoforumID, row.messageID, row.parentID, row.actorID, row.payerID, string(row.state), kind, amount, nanos, row.scheduleDate, row.approvalServiceID, row.publishedMessageID, row.settlementDue, row.finalServiceID, date)
nextAttemptAt := 0
switch row.state {
case domain.SuggestedPostStateScheduled:
nextAttemptAt = row.scheduleDate
case domain.SuggestedPostStatePublished:
nextAttemptAt = row.settlementDue
}
_, err := tx.Exec(ctx, `INSERT INTO suggested_post_approvals(monoforum_id,suggestion_message_id,parent_channel_id,actor_user_id,payer_user_id,state,price_kind,price_amount,price_nanos,schedule_date,approval_service_message_id,published_message_id,settlement_due,final_service_message_id,next_attempt_at,created_at,updated_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$16)
ON CONFLICT(monoforum_id,suggestion_message_id) DO UPDATE SET actor_user_id=EXCLUDED.actor_user_id,state=EXCLUDED.state,price_kind=EXCLUDED.price_kind,price_amount=EXCLUDED.price_amount,price_nanos=EXCLUDED.price_nanos,schedule_date=EXCLUDED.schedule_date,approval_service_message_id=EXCLUDED.approval_service_message_id,published_message_id=EXCLUDED.published_message_id,settlement_due=EXCLUDED.settlement_due,final_service_message_id=EXCLUDED.final_service_message_id,lifecycle_attempts=0,next_attempt_at=EXCLUDED.next_attempt_at,last_lifecycle_error='',updated_at=EXCLUDED.updated_at`,
row.monoforumID, row.messageID, row.parentID, row.actorID, row.payerID, string(row.state), kind, amount, nanos, row.scheduleDate, row.approvalServiceID, row.publishedMessageID, row.settlementDue, row.finalServiceID, nextAttemptAt, date)
if err == nil {
_, err = tx.Exec(ctx, `DELETE FROM suggested_post_lifecycle_wakeups
WHERE monoforum_id=$1 AND suggestion_message_id=$2`, row.monoforumID, row.messageID)
}
return err
}
@ -393,17 +419,29 @@ func (s *ChannelStore) ProcessSuggestedPostLifecycle(ctx context.Context, req do
req.Limit = 100
}
rows, err := s.db.Query(ctx, `
WITH timed AS MATERIALIZED (
SELECT monoforum_id,suggestion_message_id,next_attempt_at AS due_at
FROM suggested_post_approvals
WHERE state IN ('scheduled','published') AND next_attempt_at <= $1
ORDER BY next_attempt_at,monoforum_id,suggestion_message_id
LIMIT $2
), woken AS MATERIALIZED (
SELECT w.monoforum_id,w.suggestion_message_id,w.created_at AS due_at
FROM suggested_post_lifecycle_wakeups w
JOIN suggested_post_approvals a
ON a.monoforum_id=w.monoforum_id AND a.suggestion_message_id=w.suggestion_message_id
WHERE a.state IN ('scheduled','published')
ORDER BY w.created_at,w.monoforum_id,w.suggestion_message_id
LIMIT $2
), due AS (
SELECT * FROM timed
UNION ALL
SELECT * FROM woken
)
SELECT monoforum_id,suggestion_message_id
FROM suggested_post_approvals a
WHERE (a.state='scheduled' AND a.schedule_date <= $1)
OR (a.state='scheduled' AND EXISTS (
SELECT 1 FROM channel_messages sm
WHERE sm.channel_id=a.monoforum_id AND sm.id=a.suggestion_message_id AND sm.deleted))
OR (a.state='published' AND (a.settlement_due <= $1 OR EXISTS (
SELECT 1 FROM channel_messages m
WHERE m.channel_id=a.parent_channel_id AND m.id=a.published_message_id AND m.deleted)))
ORDER BY CASE WHEN a.state='scheduled' THEN a.schedule_date ELSE a.settlement_due END,
a.monoforum_id,a.suggestion_message_id
FROM due
GROUP BY monoforum_id,suggestion_message_id
ORDER BY MIN(due_at),monoforum_id,suggestion_message_id
LIMIT $2`, req.Now, req.Limit)
if err != nil {
return nil, fmt.Errorf("list due suggested posts: %w", err)
@ -427,16 +465,76 @@ LIMIT $2`, req.Now, req.Limit)
}
rows.Close()
out := make([]domain.ToggleSuggestedPostApprovalResult, 0, len(keys))
var failures []error
for _, k := range keys {
result, changed, err := s.processSuggestedPostLifecycleOne(ctx, k.mono, k.message, req.Now)
claimed, err := s.claimSuggestedPostLifecycle(ctx, k.mono, k.message, req.Now)
if err != nil {
return out, err
failures = append(failures, fmt.Errorf("claim suggested post lifecycle %d/%d: %w", k.mono, k.message, err))
continue
}
if !claimed {
continue
}
result, changed, err := s.processSuggestedPostLifecycleOne(ctx, k.mono, k.message, req.Now)
if changed {
// A post-commit reload can fail after the durable transition already
// succeeded. Preserve that result so its committed updates still reach
// the fanout layer; only pre-commit failures need retry metadata.
out = append(out, result)
}
if err != nil {
failures = append(failures, fmt.Errorf("suggested post lifecycle %d/%d: %w", k.mono, k.message, err))
if !changed {
if recordErr := s.recordSuggestedPostLifecycleFailure(ctx, k.mono, k.message, req.Now, err); recordErr != nil {
failures = append(failures, fmt.Errorf("record suggested post lifecycle failure %d/%d: %w", k.mono, k.message, recordErr))
}
}
continue
}
}
return out, nil
return out, errors.Join(failures...)
}
func (s *ChannelStore) claimSuggestedPostLifecycle(ctx context.Context, monoforumID int64, messageID, now int) (bool, error) {
if monoforumID <= 0 || messageID <= 0 || now <= 0 {
return false, domain.ErrSuggestedPostInvalid
}
var claimed bool
err := s.db.QueryRow(ctx, `WITH claimed AS (
UPDATE suggested_post_approvals a
SET next_attempt_at=$3+$4,
updated_at=GREATEST(a.updated_at,$3)
WHERE a.monoforum_id=$1 AND a.suggestion_message_id=$2
AND a.state IN ('scheduled','published')
AND (a.next_attempt_at <= $3 OR EXISTS (
SELECT 1 FROM suggested_post_lifecycle_wakeups w
WHERE w.monoforum_id=a.monoforum_id AND w.suggestion_message_id=a.suggestion_message_id))
RETURNING a.monoforum_id,a.suggestion_message_id
), cleared AS (
DELETE FROM suggested_post_lifecycle_wakeups w
USING claimed c
WHERE w.monoforum_id=c.monoforum_id AND w.suggestion_message_id=c.suggestion_message_id
)
SELECT EXISTS(SELECT 1 FROM claimed)`, monoforumID, messageID, now, suggestedPostLifecycleClaimSeconds).Scan(&claimed)
return claimed, err
}
func (s *ChannelStore) recordSuggestedPostLifecycleFailure(ctx context.Context, monoforumID int64, messageID, now int, cause error) error {
if monoforumID <= 0 || messageID <= 0 || now <= 0 || cause == nil {
return domain.ErrSuggestedPostInvalid
}
lastError := []rune(strings.TrimSpace(cause.Error()))
if len(lastError) > 512 {
lastError = lastError[:512]
}
_, err := s.db.Exec(ctx, `UPDATE suggested_post_approvals
SET lifecycle_attempts=LEAST(lifecycle_attempts+1,1000000),
next_attempt_at=$3+LEAST(300,5*(LEAST(lifecycle_attempts,59)+1)),
last_lifecycle_error=$4,
updated_at=GREATEST(updated_at,$3)
WHERE monoforum_id=$1 AND suggestion_message_id=$2 AND state IN ('scheduled','published')`,
monoforumID, messageID, now, string(lastError))
return err
}
func (s *ChannelStore) processSuggestedPostLifecycleOne(ctx context.Context, monoID int64, messageID, now int) (domain.ToggleSuggestedPostApprovalResult, bool, error) {
@ -476,7 +574,10 @@ func (s *ChannelStore) processSuggestedPostLifecycleOne(ctx context.Context, mon
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, false, err
}
original, err := s.getChannelMessage(ctx, tx, row.monoforumID, row.messageID)
// Serialize a scheduled publish with deletion of the original suggestion.
// The delete trigger only reads the approval row and writes a wakeup, so
// taking approval -> message locks here does not introduce a reverse edge.
original, err := getSuggestedPostMessageForShare(ctx, tx, row.monoforumID, row.messageID)
if err != nil {
return domain.ToggleSuggestedPostApprovalResult{}, false, err
}
@ -506,6 +607,15 @@ func (s *ChannelStore) processSuggestedPostLifecycleOne(ctx context.Context, mon
changed = true
}
if !changed {
nextAttemptAt := row.scheduleDate
if row.state == domain.SuggestedPostStatePublished {
nextAttemptAt = row.settlementDue
}
if _, err := tx.Exec(ctx, `UPDATE suggested_post_approvals
SET lifecycle_attempts=0,next_attempt_at=$3,last_lifecycle_error='',updated_at=GREATEST(updated_at,$4)
WHERE monoforum_id=$1 AND suggestion_message_id=$2`, row.monoforumID, row.messageID, nextAttemptAt, now); err != nil {
return result, false, err
}
if err := tx.Commit(ctx); err != nil {
return result, false, err
}
@ -519,17 +629,33 @@ func (s *ChannelStore) processSuggestedPostLifecycleOne(ctx context.Context, mon
return result, false, err
}
committed = true
result.Monoforum, err = getChannelByID(ctx, s.db, row.monoforumID)
reloadedMonoforum, err := getChannelByID(ctx, s.db, row.monoforumID)
if err != nil {
return result, true, fmt.Errorf("reload lifecycle monoforum after commit: %w", err)
}
result.Parent, err = getChannelByID(ctx, s.db, row.parentID)
result.Monoforum = reloadedMonoforum
reloadedParent, err := getChannelByID(ctx, s.db, row.parentID)
if err != nil {
return result, true, fmt.Errorf("reload lifecycle parent after commit: %w", err)
}
result.Parent = reloadedParent
return result, true, nil
}
func getSuggestedPostMessageForShare(ctx context.Context, tx pgx.Tx, channelID int64, messageID int) (domain.ChannelMessage, error) {
if channelID <= 0 || messageID <= 0 {
return domain.ChannelMessage{}, domain.ErrMessageIDInvalid
}
msg, err := scanChannelMessage(tx.QueryRow(ctx, `SELECT `+channelMessageColumns+`
FROM channel_messages
WHERE channel_id=$1 AND id=$2
FOR SHARE`, channelID, messageID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.ChannelMessage{}, domain.ErrMessageIDInvalid
}
return msg, err
}
func (r persistedSuggestedPostApproval) savedPeer() domain.Peer {
return domain.Peer{Type: domain.PeerTypeUser, ID: r.payerID}
}

View file

@ -0,0 +1,226 @@
package postgres
import (
"context"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
"telesrv/internal/store/postgres/sqlcgen"
)
// ChannelTopMessageCache stores the viewer-independent channel_messages row
// used as a dialog's top payload. Viewer overlays (mentioned/media_unread,
// normal/paid reactions) are deliberately applied after this cache.
//
// channel_base is the dependency token: edits/deletes/new tops and any other
// mutation that can alter the visible top payload bump it. The read-model
// listener invalidates every cached key for that channel and flushes on
// reconnect, while the cache epoch prevents a pre-invalidation batch load from
// being written back afterwards.
type ChannelTopMessageCache struct {
cache *readmodelcache.Cache[channelMessageLookupKey, domain.ChannelMessage]
reactionPresence *readmodelcache.Cache[channelMessageLookupKey, channelTopReactionPresence]
}
type channelTopReactionPresence struct {
Normal bool
Paid bool
}
func (p channelTopReactionPresence) any() bool { return p.Normal || p.Paid }
func NewChannelTopMessageCache(max int) *ChannelTopMessageCache {
cache := readmodelcache.New[channelMessageLookupKey, domain.ChannelMessage](readmodelcache.Config[channelMessageLookupKey, domain.ChannelMessage]{
MaxEntries: max,
Clone: cloneChannelTopMessage,
})
if cache == nil {
return nil
}
return &ChannelTopMessageCache{
cache: cache,
reactionPresence: readmodelcache.New[channelMessageLookupKey, channelTopReactionPresence](readmodelcache.Config[channelMessageLookupKey, channelTopReactionPresence]{
MaxEntries: max,
}),
}
}
func (c *ChannelTopMessageCache) getOrLoadBatch(
ctx context.Context,
keys []channelMessageLookupKey,
load func(context.Context, []channelMessageLookupKey) (map[channelMessageLookupKey]domain.ChannelMessage, error),
) (map[channelMessageLookupKey]domain.ChannelMessage, error) {
if c == nil {
return load(ctx, keys)
}
return c.cache.GetOrLoadBatch(
ctx,
keys,
func(channelMessageLookupKey) (int64, bool) { return 0, true },
func(ctx context.Context, missing []channelMessageLookupKey) (map[channelMessageLookupKey]domain.ChannelMessage, error) {
loaded, err := load(ctx, missing)
if err != nil {
return nil, err
}
for _, key := range missing {
if _, ok := loaded[key]; !ok {
loaded[key] = domain.ChannelMessage{}
}
}
return loaded, nil
},
)
}
func (c *ChannelTopMessageCache) deleteChannel(channelID int64) {
if c == nil || channelID == 0 {
return
}
c.cache.InvalidateWhere(func(key channelMessageLookupKey) bool { return key.channelID == channelID })
c.reactionPresence.InvalidateWhere(func(key channelMessageLookupKey) bool { return key.channelID == channelID })
}
func (c *ChannelTopMessageCache) flush() {
if c == nil {
return
}
c.cache.Flush()
c.reactionPresence.Flush()
}
// reactionPresenceFor returns only a shared existence bit. It never caches
// counts, chosen state, recent order or paid identities, all of which remain
// viewer/current-data projections. A negative bit is enough to skip three
// guaranteed-empty reaction queries for the many top messages with no
// reactions at all.
func (c *ChannelTopMessageCache) reactionPresenceFor(
ctx context.Context,
db sqlcgen.DBTX,
messages []domain.ChannelMessage,
) (map[channelMessageLookupKey]channelTopReactionPresence, error) {
keys := make([]channelMessageLookupKey, 0, len(messages))
for _, msg := range messages {
if msg.ChannelID == 0 || msg.ID <= 0 || domain.IsChannelHistoryClearMessage(msg) {
continue
}
keys = append(keys, channelMessageLookupKey{channelID: msg.ChannelID, id: msg.ID})
}
if len(keys) == 0 {
return map[channelMessageLookupKey]channelTopReactionPresence{}, nil
}
return c.reactionPresence.GetOrLoadBatch(
ctx,
keys,
func(channelMessageLookupKey) (int64, bool) { return 0, true },
func(ctx context.Context, missing []channelMessageLookupKey) (map[channelMessageLookupKey]channelTopReactionPresence, error) {
channelIDs := make([]int64, 0, len(missing))
messageIDs := make([]int32, 0, len(missing))
for _, key := range missing {
channelIDs = append(channelIDs, key.channelID)
messageIDs = append(messageIDs, pgInt32NonNegative(key.id))
}
rows, err := db.Query(ctx, `
WITH requested AS (
SELECT channel_id, message_id
FROM unnest($1::bigint[], $2::int[]) AS r(channel_id, message_id)
)
SELECT r.channel_id, r.message_id,
EXISTS (
SELECT 1 FROM channel_message_reactions normal
WHERE normal.channel_id=r.channel_id AND normal.message_id=r.message_id
),
EXISTS (
SELECT 1 FROM channel_message_paid_reactions paid
WHERE paid.channel_id=r.channel_id AND paid.message_id=r.message_id
)
FROM requested r`, channelIDs, messageIDs)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[channelMessageLookupKey]channelTopReactionPresence, len(missing))
for rows.Next() {
var key channelMessageLookupKey
var presence channelTopReactionPresence
if err := rows.Scan(&key.channelID, &key.id, &presence.Normal, &presence.Paid); err != nil {
return nil, err
}
out[key] = presence
}
if err := rows.Err(); err != nil {
return nil, err
}
for _, key := range missing {
if _, ok := out[key]; !ok {
out[key] = channelTopReactionPresence{}
}
}
return out, nil
},
)
}
// cloneChannelTopMessage isolates every mutable field that is enriched by the
// dialog projection path. Media is an immutable decoded storage snapshot; the
// hot path only reads it and never mutates its nested objects.
func cloneChannelTopMessage(msg domain.ChannelMessage) domain.ChannelMessage {
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
msg.ReplyTo = cloneMessageReply(msg.ReplyTo)
msg.Forward = cloneMessageForward(msg.Forward)
msg.Action = cloneChannelMessageAction(msg.Action)
if msg.SendAs != nil {
peer := *msg.SendAs
msg.SendAs = &peer
}
if msg.SuggestedPost != nil {
suggested := *msg.SuggestedPost
if suggested.Price != nil {
price := *suggested.Price
suggested.Price = &price
}
msg.SuggestedPost = &suggested
}
if msg.Discussion != nil {
discussion := *msg.Discussion
msg.Discussion = &discussion
}
if msg.Replies != nil {
replies := *msg.Replies
replies.RecentRepliers = append([]domain.Peer(nil), msg.Replies.RecentRepliers...)
msg.Replies = &replies
}
if msg.Reactions != nil {
reactions := *msg.Reactions
reactions.Results = append([]domain.ChannelMessageReactionCount(nil), msg.Reactions.Results...)
reactions.Recent = append([]domain.ChannelMessagePeerReaction(nil), msg.Reactions.Recent...)
msg.Reactions = &reactions
}
if msg.RichMessage != nil {
rich := *msg.RichMessage
rich.Blocks = append([]byte(nil), msg.RichMessage.Blocks...)
rich.Photos = append([]domain.Photo(nil), msg.RichMessage.Photos...)
rich.Documents = append([]domain.Document(nil), msg.RichMessage.Documents...)
rich.BotAPIProjection = append([]byte(nil), msg.RichMessage.BotAPIProjection...)
msg.RichMessage = &rich
}
if msg.ReplyMarkup != nil {
markup := *msg.ReplyMarkup
if msg.ReplyMarkup.Inline != nil {
markup.Inline = make([][]domain.MarkupButton, len(msg.ReplyMarkup.Inline))
for i, row := range msg.ReplyMarkup.Inline {
markup.Inline[i] = append([]domain.MarkupButton(nil), row...)
for j := range markup.Inline[i] {
markup.Inline[i][j].Data = append([]byte(nil), row[j].Data...)
}
}
}
if msg.ReplyMarkup.Keyboard != nil {
markup.Keyboard = make([][]domain.MarkupButton, len(msg.ReplyMarkup.Keyboard))
for i, row := range msg.ReplyMarkup.Keyboard {
markup.Keyboard[i] = append([]domain.MarkupButton(nil), row...)
}
}
msg.ReplyMarkup = &markup
}
return msg
}

View file

@ -0,0 +1,121 @@
package postgres
import (
"context"
"os"
"testing"
"time"
"telesrv/internal/domain"
)
func TestChannelTopMessageCacheInvalidatesOnTopPayloadNotify(t *testing.T) {
pool := testPool(t)
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: 461,
Phone: "+1888" + suffix + "91",
FirstName: "TopCacheOwner",
})
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)
})
topCache := NewChannelTopMessageCache(32)
rowCache := NewChannelRowCache(32)
channels := NewChannelStore(pool,
WithChannelRowCache(rowCache),
WithChannelTopMessageCache(topCache),
)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Top cache " + suffix,
Megagroup: true,
Date: 1700000760,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID = created.Channel.ID
sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID,
ChannelID: channelID,
RandomID: 761,
Message: "before cache invalidation",
Date: 1700000761,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
key := channelMessageLookupKey{channelID: channelID, id: sent.Message.ID}
// Seed one sentinel so listener reconnect flush is an observable readiness
// barrier instead of a timing sleep.
topCache.cache.Store(key, domain.ChannelMessage{ChannelID: channelID, ID: sent.Message.ID, Body: "sentinel"})
lctx, cancel := context.WithCancel(ctx)
defer cancel()
listener := NewReadModelChangeListener(dsn, ReadModelCacheSet{
ChannelRows: rowCache,
ChannelTopMessages: topCache,
}, nil)
go listener.Run(lctx)
if !waitUntil(2*time.Second, func() bool {
_, ok := topCache.cache.Peek(key)
return !ok
}) {
t.Fatal("read-model listener did not establish LISTEN and flush sentinel")
}
view, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID})
if err != nil {
t.Fatalf("warm channel dialog: %v", err)
}
if len(view.Messages) != 1 || view.Messages[0].Body != "before cache invalidation" {
t.Fatalf("warm messages = %+v", view.Messages)
}
if cached, ok := topCache.cache.Peek(key); !ok || cached.Body != "before cache invalidation" {
t.Fatalf("top payload was not cached: ok=%v value=%+v", ok, cached)
}
materialized, err := channels.HydrateChannelDialogSnapshot(ctx, owner.ID, view.Dialogs)
if err != nil {
t.Fatalf("hydrate materialized owner dialog: %v", err)
}
if len(materialized.Dialogs) != 1 || len(materialized.Channels) != 1 || len(materialized.Messages) != 1 ||
materialized.Dialogs[0].Peer.ID != channelID || materialized.Messages[0].Body != "before cache invalidation" {
t.Fatalf("materialized channel snapshot = dialogs:%+v channels:%+v messages:%+v",
materialized.Dialogs, materialized.Channels, materialized.Messages)
}
const after = "after cache invalidation"
if _, err := pool.Exec(ctx, `
UPDATE channel_messages
SET body=$3, edit_date=$4
WHERE channel_id=$1 AND id=$2`, channelID, sent.Message.ID, after, 1700000762); err != nil {
t.Fatalf("update top payload: %v", err)
}
if !waitUntil(3*time.Second, func() bool {
_, ok := topCache.cache.Peek(key)
return !ok
}) {
t.Fatal("top channel message cache was not invalidated by channel_base")
}
view, err = channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID})
if err != nil {
t.Fatalf("read channel dialog after invalidation: %v", err)
}
if len(view.Messages) != 1 || view.Messages[0].Body != after {
t.Fatalf("post-invalidation messages = %+v, want body %q", view.Messages, after)
}
}

View file

@ -0,0 +1,90 @@
package postgres
import (
"context"
"testing"
"telesrv/internal/domain"
)
func TestChannelTopMessageCacheBatchesClonesAndInvalidates(t *testing.T) {
cache := NewChannelTopMessageCache(8)
if cache == nil {
t.Fatal("NewChannelTopMessageCache returned nil")
}
keys := []channelMessageLookupKey{{channelID: 7, id: 11}, {channelID: 7, id: 12}, {channelID: 8, id: 21}}
loads := 0
load := func(_ context.Context, missing []channelMessageLookupKey) (map[channelMessageLookupKey]domain.ChannelMessage, error) {
loads++
out := make(map[channelMessageLookupKey]domain.ChannelMessage, len(missing))
for _, key := range missing {
out[key] = domain.ChannelMessage{
ChannelID: key.channelID,
ID: key.id,
Entities: []domain.MessageEntity{{Offset: 1, Length: 2}},
Action: &domain.ChannelMessageAction{UserIDs: []int64{3}},
RichMessage: &domain.MessageRichMessage{
Blocks: []byte{4, 5},
},
}
}
return out, nil
}
first, err := cache.getOrLoadBatch(context.Background(), keys, load)
if err != nil {
t.Fatal(err)
}
if loads != 1 {
t.Fatalf("cold batch loads = %d, want 1", loads)
}
first[keys[0]].Entities[0].Offset = 99
first[keys[0]].Action.UserIDs[0] = 99
first[keys[0]].RichMessage.Blocks[0] = 99
second, err := cache.getOrLoadBatch(context.Background(), keys, load)
if err != nil {
t.Fatal(err)
}
if loads != 1 {
t.Fatalf("warm batch loads = %d, want 1", loads)
}
if got := second[keys[0]]; got.Entities[0].Offset != 1 || got.Action.UserIDs[0] != 3 || got.RichMessage.Blocks[0] != 4 {
t.Fatalf("cached message alias-mutated: %+v", got)
}
listener := NewReadModelChangeListener("", ReadModelCacheSet{ChannelTopMessages: cache}, nil)
cache.reactionPresence.Store(keys[0], channelTopReactionPresence{Normal: true})
listener.handlePayload(`{"model":"channel_base","owner_user_id":0,"peer_type":"channel","peer_id":7}`)
if _, ok := cache.reactionPresence.Peek(keys[0]); ok {
t.Fatal("channel_base did not invalidate top reaction presence")
}
if _, err := cache.getOrLoadBatch(context.Background(), keys, load); err != nil {
t.Fatal(err)
}
if loads != 2 {
t.Fatalf("channel invalidation loads = %d, want 2", loads)
}
}
func TestChannelTopMessageCacheNegativeResultIsCached(t *testing.T) {
cache := NewChannelTopMessageCache(4)
key := channelMessageLookupKey{channelID: 9, id: 1}
loads := 0
load := func(context.Context, []channelMessageLookupKey) (map[channelMessageLookupKey]domain.ChannelMessage, error) {
loads++
return map[channelMessageLookupKey]domain.ChannelMessage{}, nil
}
for i := 0; i < 2; i++ {
got, err := cache.getOrLoadBatch(context.Background(), []channelMessageLookupKey{key}, load)
if err != nil {
t.Fatal(err)
}
if got[key].ID != 0 {
t.Fatalf("negative result = %+v", got[key])
}
}
if loads != 1 {
t.Fatalf("negative cache loads = %d, want 1", loads)
}
}

View file

@ -209,6 +209,15 @@ WHERE channel_id = $1`, channelID, cursor, checkpoint.LatestEventDate, checkpoin
if tag.RowsAffected() != 1 {
return fmt.Errorf("advance channel update retained floor: checkpoint row disappeared for channel %d", channelID)
}
// Retention changes whether an old cursor receives a normal page or
// channelDifferenceTooLong without changing channel.pts. Publish a
// dedicated generation so every instance drops immutable pages built
// against the previous floor.
if _, err := tx.Exec(ctx, `SELECT telesrv_bump_read_model_version(
'channel_difference_base', 0, 'channel', $1
)`, channelID); err != nil {
return fmt.Errorf("bump channel difference retention read model: %w", err)
}
checkpoint.RetainedThroughPts = cursor
result = domain.ChannelUpdateRetentionResult{Checkpoint: checkpoint, Deleted: len(ptsToDelete)}
return nil
@ -216,6 +225,9 @@ WHERE channel_id = $1`, channelID, cursor, checkpoint.LatestEventDate, checkpoin
if err != nil {
return domain.ChannelUpdateRetentionResult{}, err
}
if result.Deleted > 0 && s.differenceCache != nil {
s.differenceCache.deleteChannel(channelID)
}
return result, nil
}

View file

@ -3,6 +3,7 @@ package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
@ -12,13 +13,31 @@ import (
"telesrv/internal/store/postgres/sqlcgen"
)
var errChannelDifferenceCutChanged = errors.New("channel difference stable cut changed")
func (s *ChannelStore) ListChannelDifference(ctx context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) {
for attempt := 0; attempt < 3; attempt++ {
diff, retry, err := s.listChannelDifferenceAttempt(ctx, req)
if !retry {
return diff, err
}
if s.rowCache != nil {
s.rowCache.delete(req.ChannelID)
}
if s.differenceCache != nil {
s.differenceCache.deleteChannel(req.ChannelID)
}
}
return domain.ChannelDifference{}, fmt.Errorf("list channel difference: stable cut changed repeatedly for channel %d", req.ChannelID)
}
func (s *ChannelStore) listChannelDifferenceAttempt(ctx context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, bool, error) {
channel, member, preview, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID)
if err != nil {
return domain.ChannelDifference{}, err
return domain.ChannelDifference{}, false, err
}
if req.Pts < 0 || req.Pts > channel.Pts {
return domain.ChannelDifference{}, domain.ErrPersistentTimestamp
return domain.ChannelDifference{}, false, domain.ErrPersistentTimestamp
}
if !preview && member.AvailableMinPts > req.Pts {
req.Pts = minInt(member.AvailableMinPts, channel.Pts)
@ -27,32 +46,53 @@ func (s *ChannelStore) ListChannelDifference(ctx context.Context, req domain.Cha
if limit <= 0 || limit > domain.MaxChannelDifferenceLimit {
limit = domain.MaxChannelDifferenceLimit
}
checkpoint, err := getChannelUpdateCheckpoint(ctx, s.db, req.ChannelID)
if err != nil {
return domain.ChannelDifference{}, err
// A current-PTS request has no retention range to prove. Access, membership,
// available_min_pts and future/stale bounds were already checked above; do
// not spend two pool acquisitions reading a checkpoint and an empty event
// range during reconnect storms.
if req.Pts == channel.Pts {
diff := domain.ChannelDifference{
Channel: channel,
Self: member,
Pts: channel.Pts,
Final: true,
Timeout: 30,
}
if preview {
diff.Dialog = previewChannelDialog(req.UserID, channel, member)
} else {
dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel)
if err != nil {
return domain.ChannelDifference{}, false, err
}
diff.Dialog = dialog
}
return diff, false, nil
}
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 {
args = append(args, member.AvailableMinID)
where += fmt.Sprintf(" AND id > $%d", len(args))
}
if channel.Monoforum && !member.CanManageDirectMessages() {
args = append(args, req.UserID)
where += fmt.Sprintf(" AND saved_peer_type = 'user' AND saved_peer_id = $%d", len(args))
}
args = append(args, domain.MaxChannelDifferenceTooLongMessages)
rows, err := s.db.Query(ctx, `
SELECT `+channelMessageColumns+`
FROM channel_messages
WHERE `+where+`
ORDER BY id DESC
LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return domain.ChannelDifference{}, fmt.Errorf("list channel too long messages: %w", err)
}
defer rows.Close()
key := channelDifferenceBaseKey{
channelID: req.ChannelID,
requestPts: req.Pts,
capturedPts: channel.Pts,
capturedTopID: channel.TopMessageID,
limit: limit,
}
sharedBase := !channel.Monoforum && s.differenceCache != nil
load := func() (channelDifferenceBase, error) {
return s.loadChannelDifferenceBase(ctx, channel, member, req.UserID, req.Pts, limit, sharedBase)
}
var base channelDifferenceBase
if channel.Monoforum || s.differenceCache == nil {
base, err = load()
} else {
base, err = s.differenceCache.getOrLoad(ctx, key, load)
}
if errors.Is(err, errChannelDifferenceCutChanged) {
return domain.ChannelDifference{}, true, nil
}
if err != nil {
return domain.ChannelDifference{}, false, err
}
if base.tooLong {
diff := domain.ChannelDifference{
Channel: channel,
Self: member,
@ -61,102 +101,42 @@ LIMIT $`+fmt.Sprint(len(args)), args...)
TooLong: true,
Timeout: 30,
}
for rows.Next() {
msg, err := scanChannelMessage(rows)
if err != nil {
return domain.ChannelDifference{}, err
for _, msg := range base.messages {
if member.AvailableMinID > 0 && msg.ID <= member.AvailableMinID {
continue
}
if !channelMessageVisibleToViewer(channel, member, req.UserID, msg) {
continue
}
diff.NewMessages = append(diff.NewMessages, msg)
}
if err := rows.Err(); err != nil {
return domain.ChannelDifference{}, err
}
if err := populateChannelMessageUnreadFlags(ctx, s.db, req.UserID, diff.NewMessages); err != nil {
return domain.ChannelDifference{}, err
if err := populateChannelDifferenceUnreadFlags(ctx, s.db, req.UserID, diff.NewMessages, base); err != nil {
return domain.ChannelDifference{}, false, err
}
if preview {
diff.Dialog = previewChannelDialog(req.UserID, channel, member)
} else {
dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel)
if err != nil {
return domain.ChannelDifference{}, err
return domain.ChannelDifference{}, false, err
}
diff.Dialog = dialog
}
return diff, nil
}
rows, err := s.db.Query(ctx, `
SELECT channel_id, pts, pts_count, date, event_type, message_id, message_ids::text, sender_user_id, user_ids::text, payload::text
FROM channel_update_events
WHERE channel_id = $1 AND pts > $2
ORDER BY pts ASC
LIMIT $3`, req.ChannelID, req.Pts, limit)
if err != nil {
return domain.ChannelDifference{}, fmt.Errorf("list channel difference: %w", err)
return diff, false, nil
}
diff := domain.ChannelDifference{Channel: channel, Self: member, Pts: channel.Pts, Final: true, Timeout: 30}
userRefs := make(map[int64]struct{})
channelRefs := make(map[int64]struct{})
lastPts := req.Pts
type differenceEventRow struct {
event domain.ChannelUpdateEvent
messageID int
}
eventRows := make([]differenceEventRow, 0, limit)
for rows.Next() {
event, messageID, err := scanChannelEvent(rows)
if err != nil {
return domain.ChannelDifference{}, err
}
ptsCount := event.PtsCount
if ptsCount <= 0 {
ptsCount = 1
}
if event.Pts != lastPts+ptsCount {
s.log.Warn("channel_difference_stopped_at_gap",
zap.String("scope", "channel"),
zap.Int64("user_id", req.UserID),
zap.Int64("channel_id", req.ChannelID),
zap.Int("request_pts", req.Pts),
zap.Int("channel_pts", channel.Pts),
zap.Int("returned_pts", lastPts),
zap.Int("expected_pts", lastPts+ptsCount),
zap.Int("got_pts", event.Pts),
zap.Int("got_pts_count", ptsCount),
zap.String("event_type", string(event.Type)),
zap.Int("limit", limit),
)
break
}
lastPts = event.Pts
eventRows = append(eventRows, differenceEventRow{event: event, messageID: messageID})
}
if err := rows.Err(); err != nil {
rows.Close()
return domain.ChannelDifference{}, err
}
rows.Close()
var visibleMonoforumMessageIDs map[int]struct{}
if channel.Monoforum && !member.CanManageDirectMessages() {
messageIDs := make([]int, 0)
for _, row := range eventRows {
messageIDs = append(messageIDs, row.event.MessageIDs...)
for _, event := range base.events {
messageIDs = append(messageIDs, event.MessageIDs...)
}
visibleMonoforumMessageIDs, err = s.monoforumVisibleMessageIDs(ctx, req.ChannelID, req.UserID, messageIDs)
if err != nil {
return domain.ChannelDifference{}, err
return domain.ChannelDifference{}, false, err
}
}
for _, row := range eventRows {
event := row.event
messageID := row.messageID
if messageID != 0 && event.Message.ID == 0 {
msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, messageID)
if err != nil {
return domain.ChannelDifference{}, err
}
event.Message = msg
}
for _, event := range base.events {
visibleEvent, ok := domain.FilterChannelUpdateEventForAvailableMinID(event, member.AvailableMinID)
if !ok {
continue
@ -171,7 +151,6 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
if preview && event.Type == domain.ChannelUpdateParticipant {
continue
}
collectChannelEventRefs(event, req.ChannelID, userRefs, channelRefs)
diff.Events = append(diff.Events, event)
diff.Pts = event.Pts
switch event.Type {
@ -182,12 +161,12 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
}
}
if len(diff.Events) == 0 {
diff.Pts = lastPts
} else if lastPts > diff.Pts {
diff.Pts = lastPts
diff.Pts = base.lastPts
} else if base.lastPts > diff.Pts {
diff.Pts = base.lastPts
}
if err := populateChannelMessageUnreadFlags(ctx, s.db, req.UserID, diff.NewMessages); err != nil {
return domain.ChannelDifference{}, err
if err := populateChannelDifferenceUnreadFlags(ctx, s.db, req.UserID, diff.NewMessages, base); err != nil {
return domain.ChannelDifference{}, false, err
}
// OtherUpdates 里带消息的事件未读/提及标记一次批量回填(原来逐事件一条 SQL 的 N+1
otherMsgs := make([]domain.ChannelMessage, 0, len(diff.OtherUpdates))
@ -200,34 +179,259 @@ LIMIT $3`, req.ChannelID, req.Pts, limit)
otherIdx = append(otherIdx, i)
}
if len(otherMsgs) > 0 {
if err := populateChannelMessageUnreadFlags(ctx, s.db, req.UserID, otherMsgs); err != nil {
return domain.ChannelDifference{}, err
if err := populateChannelDifferenceUnreadFlags(ctx, s.db, req.UserID, otherMsgs, base); err != nil {
return domain.ChannelDifference{}, false, err
}
for j, i := range otherIdx {
diff.OtherUpdates[i].Message = otherMsgs[j]
}
}
users, err := listUsersByIDs(ctx, s.db, mapKeysInt64(userRefs))
if err != nil {
return domain.ChannelDifference{}, err
}
channels, err := listChannelsByIDs(ctx, s.db, mapKeysInt64(channelRefs))
if err != nil {
return domain.ChannelDifference{}, err
}
diff.Users = users
diff.Channels = channels
if preview {
diff.Dialog = previewChannelDialog(req.UserID, channel, member)
} else {
dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel)
if err != nil {
return domain.ChannelDifference{}, err
return domain.ChannelDifference{}, false, err
}
diff.Dialog = dialog
}
diff.Final = lastPts >= channel.Pts
return diff, nil
diff.Final = base.lastPts >= channel.Pts
return diff, false, nil
}
func (s *ChannelStore) loadChannelDifferenceBase(
ctx context.Context,
channel domain.Channel,
member domain.ChannelMember,
viewerUserID int64,
requestPts int,
limit int,
loadMentionCandidates bool,
) (channelDifferenceBase, error) {
checkpoint, err := getChannelUpdateCheckpoint(ctx, s.db, channel.ID)
if err != nil {
return channelDifferenceBase{}, err
}
base := channelDifferenceBase{
retainedThroughPts: checkpoint.RetainedThroughPts,
lastPts: requestPts,
}
if requestPts < checkpoint.RetainedThroughPts || channel.Pts-requestPts > limit {
base.tooLong = true
args := []any{channel.ID, channel.TopMessageID}
where := "channel_id = $1 AND id <= $2 AND NOT deleted"
// Non-monoforum pages are viewer-independent: apply available_min after
// the shared lookup. Monoforum latest-100 selection is viewer-specific,
// so that path bypasses the shared cache and keeps its predicate here.
if channel.Monoforum {
if member.AvailableMinID > 0 {
args = append(args, member.AvailableMinID)
where += fmt.Sprintf(" AND id > $%d", len(args))
}
if !member.CanManageDirectMessages() {
args = append(args, viewerUserID)
where += fmt.Sprintf(" AND saved_peer_type = 'user' AND saved_peer_id = $%d", len(args))
}
}
args = append(args, domain.MaxChannelDifferenceTooLongMessages)
rows, err := s.db.Query(ctx, `
SELECT `+channelMessageColumns+`
FROM channel_messages
WHERE `+where+`
ORDER BY id DESC
LIMIT $`+fmt.Sprint(len(args)), args...)
if err != nil {
return channelDifferenceBase{}, fmt.Errorf("list channel too long messages: %w", err)
}
for rows.Next() {
msg, err := scanChannelMessage(rows)
if err != nil {
rows.Close()
return channelDifferenceBase{}, err
}
base.messages = append(base.messages, msg)
}
if err := rows.Err(); err != nil {
rows.Close()
return channelDifferenceBase{}, err
}
rows.Close()
if loadMentionCandidates {
if err := s.loadChannelDifferenceMentionCandidates(ctx, channel.ID, &base); err != nil {
return channelDifferenceBase{}, err
}
}
if err := s.verifyChannelDifferenceCut(ctx, channel, checkpoint.RetainedThroughPts); err != nil {
return channelDifferenceBase{}, err
}
return base, nil
}
rows, err := s.db.Query(ctx, `
SELECT channel_id, pts, pts_count, date, event_type, message_id, message_ids::text, sender_user_id, user_ids::text, payload::text
FROM channel_update_events
WHERE channel_id = $1 AND pts > $2 AND pts <= $3
ORDER BY pts ASC
LIMIT $4`, channel.ID, requestPts, channel.Pts, limit)
if err != nil {
return channelDifferenceBase{}, fmt.Errorf("list channel difference: %w", err)
}
for rows.Next() {
event, messageID, err := scanChannelEvent(rows)
if err != nil {
rows.Close()
return channelDifferenceBase{}, err
}
ptsCount := event.PtsCount
if ptsCount <= 0 {
ptsCount = 1
}
if event.Pts != base.lastPts+ptsCount {
s.log.Warn("channel_difference_stopped_at_gap",
zap.String("scope", "channel"),
zap.Int64("channel_id", channel.ID),
zap.Int("request_pts", requestPts),
zap.Int("channel_pts", channel.Pts),
zap.Int("returned_pts", base.lastPts),
zap.Int("expected_pts", base.lastPts+ptsCount),
zap.Int("got_pts", event.Pts),
zap.Int("got_pts_count", ptsCount),
zap.String("event_type", string(event.Type)),
zap.Int("limit", limit),
)
break
}
if messageID != 0 && event.Message.ID == 0 {
event.Message, err = s.getChannelMessageAtOrBeforePts(ctx, channel.ID, messageID, channel.Pts)
if err != nil {
rows.Close()
return channelDifferenceBase{}, err
}
}
base.lastPts = event.Pts
base.events = append(base.events, event)
}
if err := rows.Err(); err != nil {
rows.Close()
return channelDifferenceBase{}, err
}
rows.Close()
if loadMentionCandidates {
if err := s.loadChannelDifferenceMentionCandidates(ctx, channel.ID, &base); err != nil {
return channelDifferenceBase{}, err
}
}
if err := s.verifyChannelDifferenceCut(ctx, channel, checkpoint.RetainedThroughPts); err != nil {
return channelDifferenceBase{}, err
}
return base, nil
}
func (s *ChannelStore) loadChannelDifferenceMentionCandidates(ctx context.Context, channelID int64, base *channelDifferenceBase) error {
if base == nil {
return nil
}
base.candidatesKnown = true
base.mentionCandidateIDs = make(map[int]struct{})
messageIDs := make([]int, 0, len(base.messages)+len(base.events))
seen := make(map[int]struct{}, cap(messageIDs))
add := func(id int) {
if id <= 0 {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
messageIDs = append(messageIDs, id)
}
for _, message := range base.messages {
add(message.ID)
}
for _, event := range base.events {
add(event.Message.ID)
}
if len(messageIDs) == 0 {
return nil
}
rows, err := s.db.Query(ctx, `
SELECT DISTINCT message_id
FROM channel_unread_mention_index
WHERE channel_id = $1 AND message_id = ANY($2::int[])`, channelID, int32s(messageIDs))
if err != nil {
return fmt.Errorf("load channel difference mention candidates: %w", err)
}
defer rows.Close()
for rows.Next() {
var messageID int
if err := rows.Scan(&messageID); err != nil {
return err
}
base.mentionCandidateIDs[messageID] = struct{}{}
}
if err := rows.Err(); err != nil {
return fmt.Errorf("read channel difference mention candidates: %w", err)
}
return nil
}
func populateChannelDifferenceUnreadFlags(
ctx context.Context,
db sqlcgen.DBTX,
viewerUserID int64,
messages []domain.ChannelMessage,
base channelDifferenceBase,
) error {
if !base.candidatesKnown {
return populateChannelMessageUnreadFlags(ctx, db, viewerUserID, messages)
}
selected := make([]domain.ChannelMessage, 0, len(messages))
indexes := make([]int, 0, len(messages))
for i, message := range messages {
if _, ok := base.mentionCandidateIDs[message.ID]; !ok {
continue
}
selected = append(selected, message)
indexes = append(indexes, i)
}
if len(selected) == 0 {
return nil
}
if err := populateChannelMessageUnreadFlags(ctx, db, viewerUserID, selected); err != nil {
return err
}
for i, messageIndex := range indexes {
messages[messageIndex].Mentioned = selected[i].Mentioned
messages[messageIndex].MediaUnread = selected[i].MediaUnread
}
return nil
}
func (s *ChannelStore) verifyChannelDifferenceCut(ctx context.Context, captured domain.Channel, retainedThroughPts int) error {
var pts, topMessageID, floor int
err := s.db.QueryRow(ctx, `
SELECT c.pts, c.top_message_id, cp.retained_through_pts
FROM channels c
JOIN channel_update_checkpoints cp ON cp.channel_id = c.id
WHERE c.id = $1 AND NOT c.deleted`, captured.ID).Scan(&pts, &topMessageID, &floor)
if err != nil {
return fmt.Errorf("verify channel difference cut: %w", err)
}
if pts != captured.Pts || topMessageID != captured.TopMessageID || floor != retainedThroughPts {
return errChannelDifferenceCutChanged
}
return nil
}
func (s *ChannelStore) getChannelMessageAtOrBeforePts(ctx context.Context, channelID int64, messageID, capturedPts int) (domain.ChannelMessage, error) {
msg, err := scanChannelMessage(s.db.QueryRow(ctx, `
SELECT `+channelMessageColumns+`
FROM channel_messages
WHERE channel_id = $1 AND id = $2 AND pts <= $3`, channelID, messageID, capturedPts))
if err != nil {
return domain.ChannelMessage{}, fmt.Errorf("load channel difference legacy message at stable cut: %w", err)
}
return msg, nil
}
func (s *ChannelStore) monoforumVisibleMessageIDs(ctx context.Context, channelID, userID int64, ids []int) (map[int]struct{}, error) {
@ -493,23 +697,3 @@ func adminLogEventTypesForFilter(filter domain.ChannelAdminLogFilter) []string {
add(filter.Send, domain.ChannelAdminLogSendMessage)
return types
}
func collectChannelEventRefs(event domain.ChannelUpdateEvent, currentChannelID int64, userRefs, channelRefs map[int64]struct{}) {
if event.SenderUserID != 0 {
userRefs[event.SenderUserID] = struct{}{}
}
for _, id := range event.UserIDs {
if id != 0 {
userRefs[id] = struct{}{}
}
}
for _, member := range []domain.ChannelMember{event.Previous, event.Participant} {
if member.UserID != 0 {
userRefs[member.UserID] = struct{}{}
}
if member.InviterUserID != 0 {
userRefs[member.InviterUserID] = struct{}{}
}
}
collectChannelMessageRefs(event.Message, currentChannelID, userRefs, channelRefs)
}

View file

@ -18,19 +18,30 @@ import (
)
type CommunityStore struct {
db sqlcgen.DBTX
ids store.ChannelIDAllocator
msgIDs store.ChannelMessageIDAllocator
db sqlcgen.DBTX
ids store.ChannelIDAllocator
msgIDs store.ChannelMessageIDAllocator
catalogCache *CommunityCatalogCache
}
func NewCommunityStore(db sqlcgen.DBTX, ids store.ChannelIDAllocator, msgIDs store.ChannelMessageIDAllocator) *CommunityStore {
type CommunityStoreOption func(*CommunityStore)
func WithCommunityCatalogCache(cache *CommunityCatalogCache) CommunityStoreOption {
return func(s *CommunityStore) { s.catalogCache = cache }
}
func NewCommunityStore(db sqlcgen.DBTX, ids store.ChannelIDAllocator, msgIDs store.ChannelMessageIDAllocator, opts ...CommunityStoreOption) *CommunityStore {
if ids == nil {
ids = pgChannelIDAllocator{db: db}
}
if msgIDs == nil {
msgIDs = pgChannelMessageIDAllocator{db: db}
}
return &CommunityStore{db: db, ids: ids, msgIDs: msgIDs}
s := &CommunityStore{db: db, ids: ids, msgIDs: msgIDs}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *CommunityStore) appendCommunityServiceMessageTx(ctx context.Context, tx pgx.Tx, peer domain.Peer, actorUserID int64, date int, communityID int64) (*domain.SendChannelMessageResult, error) {
@ -320,6 +331,18 @@ func (s *CommunityStore) GetCommunities(ctx context.Context, viewerUserID int64,
}
func (s *CommunityStore) ListJoinedCommunities(ctx context.Context, viewerUserID int64) ([]domain.CommunityView, error) {
if viewerUserID == 0 {
return nil, nil
}
if s.catalogCache != nil {
active, err := s.catalogCache.hasActive(ctx, s.db)
if err != nil {
return nil, fmt.Errorf("check community catalog: %w", err)
}
if !active {
return nil, nil
}
}
rows, err := s.db.Query(ctx, `
SELECT DISTINCT c.id
FROM communities c
@ -867,6 +890,9 @@ func (s *CommunityStore) banCommunityParticipantFromChannelTx(ctx context.Contex
if err := clearChannelMentionsForUserTx(ctx, tx, channelID, participantUserID); err != nil {
return domain.EditChannelBannedResult{}, false, err
}
if err := deleteWelcomeMessageDeliveriesTx(ctx, tx, channelID, []int64{participantUserID}); err != nil {
return domain.EditChannelBannedResult{}, false, err
}
var serviceMessage domain.ChannelMessage
var serviceEvent domain.ChannelUpdateEvent
if channel.Megagroup {

View file

@ -0,0 +1,47 @@
package postgres
import (
"context"
"telesrv/internal/readmodelcache"
"telesrv/internal/store/postgres/sqlcgen"
)
const communityCatalogPresenceKey = "active"
// CommunityCatalogCache is the global, version-invalidated gate in front of
// owner-specific joined-Community reads. It caches only whether any non-deleted
// Community exists; it never caches membership, collapsed/pinned state or a
// Community payload.
type CommunityCatalogCache struct {
cache *readmodelcache.Cache[string, bool]
}
func NewCommunityCatalogCache() *CommunityCatalogCache {
return &CommunityCatalogCache{cache: readmodelcache.New[string, bool](readmodelcache.Config[string, bool]{MaxEntries: 1})}
}
func (c *CommunityCatalogCache) hasActive(ctx context.Context, db sqlcgen.DBTX) (bool, error) {
if c == nil {
var active bool
err := db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM communities WHERE NOT deleted)`).Scan(&active)
return active, err
}
return c.cache.GetOrLoad(ctx, communityCatalogPresenceKey, func() (bool, error) {
var active bool
err := db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM communities WHERE NOT deleted)`).Scan(&active)
return active, err
})
}
func (c *CommunityCatalogCache) invalidate() {
if c != nil {
c.cache.Invalidate(communityCatalogPresenceKey)
}
}
func (c *CommunityCatalogCache) flush() {
if c != nil {
c.cache.Flush()
}
}

View file

@ -0,0 +1,71 @@
package postgres
import (
"context"
"os"
"testing"
"time"
"telesrv/internal/domain"
)
func TestCommunityCatalogCacheInvalidatesFromDatabaseTrigger(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
cache := NewCommunityCatalogCache()
active, err := cache.hasActive(ctx, pool)
if err != nil {
t.Fatal(err)
}
if active {
t.Skip("test database already contains an active Community")
}
// A sentinel is a deterministic LISTEN-ready barrier: the listener flushes
// it immediately after LISTEN succeeds.
cache.cache.Store(communityCatalogPresenceKey, true)
lctx, cancel := context.WithCancel(ctx)
defer cancel()
listener := NewReadModelChangeListener(os.Getenv("TELESRV_TEST_POSTGRES_DSN"), ReadModelCacheSet{CommunityCatalog: cache}, nil)
go listener.Run(lctx)
if !waitUntil(2*time.Second, func() bool {
_, ok := cache.cache.Peek(communityCatalogPresenceKey)
return !ok
}) {
t.Fatal("read-model listener did not flush Community sentinel")
}
if active, err = cache.hasActive(ctx, pool); err != nil || active {
t.Fatalf("re-warm empty catalog active=%v err=%v", active, err)
}
users := NewUserStore(pool)
suffix := randomSuffix(t)
owner, err := users.Create(ctx, domain.User{AccessHash: 471, Phone: "+1887" + suffix + "01", FirstName: "CommunityGateOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
communityID := time.Now().UnixNano() & 0x3fffffffffffffff
if communityID == 0 {
communityID = 1
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM communities WHERE id=$1", communityID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=$1", owner.ID)
})
if _, err := pool.Exec(ctx, `
INSERT INTO communities(id,access_hash,creator_user_id,title,date)
VALUES($1,$2,$3,$4,$5)`, communityID, -communityID, owner.ID, "Catalog trigger "+suffix, 1700000770); err != nil {
t.Fatalf("insert Community: %v", err)
}
if !waitUntil(3*time.Second, func() bool {
_, ok := cache.cache.Peek(communityCatalogPresenceKey)
return !ok
}) {
t.Fatal("communities trigger did not invalidate catalog presence")
}
active, err = cache.hasActive(ctx, pool)
if err != nil || !active {
t.Fatalf("catalog after insert active=%v err=%v, want true", active, err)
}
}

View file

@ -0,0 +1,18 @@
package postgres
import "testing"
func TestCommunityCatalogCacheInvalidatesAndFlushes(t *testing.T) {
cache := NewCommunityCatalogCache()
cache.cache.Store(communityCatalogPresenceKey, false)
listener := NewReadModelChangeListener("", ReadModelCacheSet{CommunityCatalog: cache}, nil)
listener.handlePayload(`{"model":"community_catalog","owner_user_id":0,"peer_type":"community","peer_id":0}`)
if _, ok := cache.cache.Peek(communityCatalogPresenceKey); ok {
t.Fatal("community_catalog event did not invalidate presence gate")
}
cache.cache.Store(communityCatalogPresenceKey, true)
listener.flush("test")
if _, ok := cache.cache.Peek(communityCatalogPresenceKey); ok {
t.Fatal("listener flush did not clear community catalog gate")
}
}

View file

@ -121,6 +121,33 @@ func TestCommunityStoreLifecycleIsAtomicInPostgres(t *testing.T) {
}, 0, 100); !errors.Is(err, domain.ErrCommunityAdminRequired) {
t.Fatalf("member Community banned list error = %v, want admin required", err)
}
welcomeContent := domain.WelcomeMessageContent{Message: "community welcome cleanup"}
welcomePeer := domain.Peer{Type: domain.PeerTypeChannel, ID: initial.Channel.ID}
welcomeFingerprint, err := domain.WelcomeCreateFingerprint(welcomePeer, owner.ID, 8_020_005, welcomeContent)
if err != nil {
t.Fatal(err)
}
if _, _, err := NewWelcomeMessageStore(pool).CreateWelcomeMessage(ctx, domain.CreateWelcomeMessageRequest{
Peer: welcomePeer, CreatorUserID: owner.ID, Date: 1_800_200_004, RandomID: 8_020_005,
Content: welcomeContent, CreateFingerprint: welcomeFingerprint,
}); err != nil {
t.Fatalf("create community cleanup welcome: %v", err)
}
memberRow, err := channels.getChannelMember(ctx, pool, initial.Channel.ID, member.ID)
if err != nil {
t.Fatal(err)
}
welcomeTx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
if err := enqueueWelcomeMessageDeliveriesTx(ctx, welcomeTx, initial.Channel.ID, []domain.ChannelMember{memberRow}); err != nil {
_ = welcomeTx.Rollback(ctx)
t.Fatal(err)
}
if err := welcomeTx.Commit(ctx); err != nil {
t.Fatal(err)
}
ban, err := store.ToggleCommunityParticipantBanned(ctx, owner.ID, communityID, member.ID, false, 1_800_200_005)
if err != nil {
@ -129,6 +156,10 @@ func TestCommunityStoreLifecycleIsAtomicInPostgres(t *testing.T) {
if !ban.Changed || len(ban.ChannelBans) != 1 || len(ban.RemovedLinks) != 1 {
t.Fatalf("ban result = %+v", ban)
}
var welcomeDeliveries int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM welcome_message_deliveries WHERE channel_id=$1 AND target_user_id=$2`, initial.Channel.ID, member.ID).Scan(&welcomeDeliveries); err != nil || welcomeDeliveries != 0 {
t.Fatalf("community ban welcome deliveries=%d err=%v", welcomeDeliveries, err)
}
if err := pool.QueryRow(ctx, "SELECT linked_community_id FROM channels WHERE id=$1", owned.Channel.ID).Scan(&linkedID); err != nil || linkedID != 0 {
t.Fatalf("owned linked_community_id after ban = %d err=%v, want 0", linkedID, err)
}

View file

@ -164,6 +164,288 @@ WHERE c.contact_user_id = $1
return out, nil
}
// GetReverseContactsForViewerUserIDs reads an exact set of owner->viewer
// relationship pairs for cross-request privacy batching. Privacy evaluation
// only consumes relationship facts (existence/close_friend), so this query must
// not join or copy viewer-independent users-table payloads into every pair.
func (s *ContactStore) GetReverseContactsForViewerUserIDs(
ctx context.Context,
viewerUserIDsByOwner map[int64][]int64,
) (map[int64]map[int64]domain.Contact, error) {
out := make(map[int64]map[int64]domain.Contact, len(viewerUserIDsByOwner))
ownerIDs, viewerIDs := flattenContactProjectionPairs(viewerUserIDsByOwner)
if len(ownerIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
/* reverse_contact_pair_batch */
WITH requested(owner_user_id, viewer_user_id) AS (
SELECT * FROM unnest($1::bigint[], $2::bigint[])
)
SELECT
c.user_id AS owner_user_id,
c.contact_user_id AS viewer_user_id,
c.mutual,
c.close_friend,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json
FROM requested r
JOIN contacts c
ON c.user_id = r.owner_user_id
AND c.contact_user_id = r.viewer_user_id
`, ownerIDs, viewerIDs)
if err != nil {
return nil, fmt.Errorf("get sparse reverse contacts: %w", err)
}
defer rows.Close()
for rows.Next() {
ownerID, contact, scanErr := scanSparseContactProjectionRows(rows)
if scanErr != nil {
return nil, scanErr
}
if out[ownerID] == nil {
out[ownerID] = make(map[int64]domain.Contact)
}
out[ownerID][contact.User.ID] = contact
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (s *ContactStore) ContactProjectionForViewers(ctx context.Context, viewerUserIDs, contactUserIDs []int64) (domain.ContactProjectionBatch, error) {
out := domain.ContactProjectionBatch{
Contacts: make(map[int64]map[int64]domain.Contact, len(viewerUserIDs)),
PersonalPhotos: make(map[int64]map[int64]domain.ProfilePhotoRef, len(viewerUserIDs)),
}
if len(viewerUserIDs) == 0 || len(contactUserIDs) == 0 {
return out, nil
}
viewers := dedupPositiveInt64(viewerUserIDs)
targets := dedupPositiveInt64(contactUserIDs)
if len(viewers) == 0 || len(targets) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
SELECT
c.user_id AS viewer_user_id,
c.contact_user_id,
c.mutual,
c.close_friend,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until,
u.emoji_status_document_id,
u.emoji_status_until,
u.emoji_status_collectible_id,
u.emoji_status_collectible,
u.last_seen_at
FROM contacts c
JOIN users u ON u.id = c.contact_user_id
WHERE c.user_id = ANY($1::bigint[])
AND c.contact_user_id = ANY($2::bigint[])
`, viewers, targets)
if err != nil {
return out, fmt.Errorf("get contact projection for viewers: %w", err)
}
for rows.Next() {
viewerID, contact, err := scanContactProjectionRows(rows)
if err != nil {
rows.Close()
return out, err
}
if out.Contacts[viewerID] == nil {
out.Contacts[viewerID] = make(map[int64]domain.Contact, len(targets))
}
out.Contacts[viewerID][contact.User.ID] = contact
}
if err := rows.Err(); err != nil {
rows.Close()
return out, err
}
rows.Close()
rows, err = s.db.Query(ctx, `
SELECT
c.user_id AS viewer_user_id,
c.contact_user_id,
c.personal_photo_id,
ph.dc_id,
ph.sizes::text AS sizes_json
FROM contacts c
JOIN photos ph ON ph.id = c.personal_photo_id
WHERE c.user_id = ANY($1::bigint[])
AND c.contact_user_id = ANY($2::bigint[])
AND c.personal_photo_id <> 0
`, viewers, targets)
if err != nil {
return out, fmt.Errorf("get contact projection personal photos: %w", err)
}
for rows.Next() {
var viewerID, contactUserID, photoID int64
var dcID int32
var sizesJSON string
if err := rows.Scan(&viewerID, &contactUserID, &photoID, &dcID, &sizesJSON); err != nil {
rows.Close()
return out, err
}
sizes, err := decodePhotoSizes(sizesJSON)
if err != nil {
rows.Close()
return out, err
}
if out.PersonalPhotos[viewerID] == nil {
out.PersonalPhotos[viewerID] = make(map[int64]domain.ProfilePhotoRef, len(targets))
}
out.PersonalPhotos[viewerID][contactUserID] = domain.ProfilePhotoRef{
PhotoID: photoID,
DCID: int(dcID),
Stripped: domain.StrippedFromSizes(sizes),
Personal: true,
HasVideo: domain.PhotoHasVideo(sizes),
}
}
if err := rows.Err(); err != nil {
rows.Close()
return out, err
}
rows.Close()
return out, nil
}
func (s *ContactStore) ContactProjectionForViewerUserIDs(ctx context.Context, contactUserIDsByViewer map[int64][]int64) (domain.ContactProjectionBatch, error) {
out := domain.ContactProjectionBatch{
Contacts: make(map[int64]map[int64]domain.Contact, len(contactUserIDsByViewer)),
PersonalPhotos: make(map[int64]map[int64]domain.ProfilePhotoRef, len(contactUserIDsByViewer)),
}
viewerIDs, contactUserIDs := flattenContactProjectionPairs(contactUserIDsByViewer)
if len(viewerIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
WITH requested(viewer_user_id, contact_user_id) AS (
SELECT * FROM unnest($1::bigint[], $2::bigint[])
)
SELECT
c.user_id AS viewer_user_id,
c.contact_user_id,
c.mutual,
c.close_friend,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json
FROM requested r
JOIN contacts c ON c.user_id = r.viewer_user_id AND c.contact_user_id = r.contact_user_id
`, viewerIDs, contactUserIDs)
if err != nil {
return out, fmt.Errorf("get sparse contact projection: %w", err)
}
for rows.Next() {
viewerID, contact, err := scanSparseContactProjectionRows(rows)
if err != nil {
rows.Close()
return out, err
}
if out.Contacts[viewerID] == nil {
out.Contacts[viewerID] = make(map[int64]domain.Contact)
}
out.Contacts[viewerID][contact.User.ID] = contact
}
if err := rows.Err(); err != nil {
rows.Close()
return out, err
}
rows.Close()
rows, err = s.db.Query(ctx, `
WITH requested(viewer_user_id, contact_user_id) AS (
SELECT * FROM unnest($1::bigint[], $2::bigint[])
)
SELECT
c.user_id AS viewer_user_id,
c.contact_user_id,
c.personal_photo_id,
ph.dc_id,
ph.sizes::text AS sizes_json
FROM requested r
JOIN contacts c ON c.user_id = r.viewer_user_id AND c.contact_user_id = r.contact_user_id
JOIN photos ph ON ph.id = c.personal_photo_id
WHERE c.personal_photo_id <> 0
`, viewerIDs, contactUserIDs)
if err != nil {
return out, fmt.Errorf("get sparse contact projection personal photos: %w", err)
}
for rows.Next() {
var viewerID, contactUserID, photoID int64
var dcID int32
var sizesJSON string
if err := rows.Scan(&viewerID, &contactUserID, &photoID, &dcID, &sizesJSON); err != nil {
rows.Close()
return out, err
}
sizes, err := decodePhotoSizes(sizesJSON)
if err != nil {
rows.Close()
return out, err
}
if out.PersonalPhotos[viewerID] == nil {
out.PersonalPhotos[viewerID] = make(map[int64]domain.ProfilePhotoRef)
}
out.PersonalPhotos[viewerID][contactUserID] = domain.ProfilePhotoRef{
PhotoID: photoID, DCID: int(dcID), Stripped: domain.StrippedFromSizes(sizes),
Personal: true, HasVideo: domain.PhotoHasVideo(sizes),
}
}
if err := rows.Err(); err != nil {
rows.Close()
return out, err
}
rows.Close()
return out, nil
}
func flattenContactProjectionPairs(contactUserIDsByViewer map[int64][]int64) ([]int64, []int64) {
viewers := make([]int64, 0)
targets := make([]int64, 0)
seen := make(map[[2]int64]struct{})
for viewerID, contactUserIDs := range contactUserIDsByViewer {
if viewerID == 0 {
continue
}
for _, targetID := range contactUserIDs {
if targetID == 0 {
continue
}
pair := [2]int64{viewerID, targetID}
if _, ok := seen[pair]; ok {
continue
}
seen[pair] = struct{}{}
viewers = append(viewers, viewerID)
targets = append(targets, targetID)
}
}
return viewers, targets
}
func (s *ContactStore) Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
entities, err := encodeMessageEntities(input.NoteEntities)
if err != nil {
@ -756,6 +1038,111 @@ func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) {
return ownerUserID, contact, nil
}
func scanSparseContactProjectionRows(row contactScanner) (int64, domain.Contact, error) {
var (
viewerUserID int64
contactUserID int64
mutual bool
closeFriend bool
contactPhone string
contactFirstName string
contactLastName string
note string
noteEntitiesJSON string
)
if err := row.Scan(
&viewerUserID,
&contactUserID,
&mutual,
&closeFriend,
&contactPhone,
&contactFirstName,
&contactLastName,
&note,
&noteEntitiesJSON,
); err != nil {
return 0, domain.Contact{}, err
}
entities, err := decodeMessageEntities(noteEntitiesJSON)
if err != nil {
return 0, domain.Contact{}, fmt.Errorf("decode sparse contact note entities: %w", err)
}
return viewerUserID, domain.Contact{
User: domain.User{ID: contactUserID},
FirstName: contactFirstName,
LastName: contactLastName,
Phone: contactPhone,
Note: note,
NoteEntities: entities,
Mutual: mutual,
CloseFriend: closeFriend,
}, nil
}
func scanContactProjectionRows(row contactScanner) (int64, domain.Contact, error) {
var (
viewerUserID int64
contactUserID int64
mutual bool
closeFriend bool
contactPhone string
contactFirstName string
contactLastName string
note string
noteEntitiesJSON string
id int64
accessHash int64
phone string
firstName string
lastName string
username string
countryCode string
verified bool
support bool
premiumUntil int64
emojiStatusDocID int64
emojiStatusUntil int64
emojiCollectibleID *int64
emojiCollectibleJSON []byte
lastSeenAt int32
)
if err := row.Scan(
&viewerUserID,
&contactUserID,
&mutual,
&closeFriend,
&contactPhone,
&contactFirstName,
&contactLastName,
&note,
&noteEntitiesJSON,
&id,
&accessHash,
&phone,
&firstName,
&lastName,
&username,
&countryCode,
&verified,
&support,
&premiumUntil,
&emojiStatusDocID,
&emojiStatusUntil,
&emojiCollectibleID,
&emojiCollectibleJSON,
&lastSeenAt,
); err != nil {
return 0, domain.Contact{}, err
}
_ = contactUserID
entities, err := decodeMessageEntities(noteEntitiesJSON)
if err != nil {
return 0, domain.Contact{}, err
}
contact := contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), emojiCollectibleID, emojiCollectibleJSON, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend)
return viewerUserID, contact, nil
}
func (s *ContactStore) Block(ctx context.Context, userID, blockedUserID int64, date int) (bool, error) {
if userID == 0 || blockedUserID == 0 || userID == blockedUserID {
return false, nil
@ -868,6 +1255,22 @@ LIMIT $3`, userID, offset, limit)
return out, rows.Err()
}
func dedupPositiveInt64(ids []int64) []int64 {
seen := make(map[int64]struct{}, len(ids))
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func contactListHash(contacts []domain.Contact) int64 {
if len(contacts) == 0 {
return 0

View file

@ -0,0 +1,157 @@
package postgres
import (
"context"
"reflect"
"testing"
"time"
"telesrv/internal/domain"
)
func TestContactProjectionForViewerUserIDsPostgresDoesNotCrossPairs(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
viewerA := createTestUser(t, ctx, users, "+1910"+suffix+"01", "Viewer", "A")
viewerB := createTestUser(t, ctx, users, "+1910"+suffix+"02", "Viewer", "B")
ownerA := createTestUser(t, ctx, users, "+1910"+suffix+"03", "Owner", "A")
ownerB := createTestUser(t, ctx, users, "+1910"+suffix+"04", "Owner", "B")
userIDs := []int64{viewerA.ID, viewerB.ID, ownerA.ID, ownerB.ID}
photoBase := time.Now().UnixNano() & 0x3fffffffffffffff
photoIDs := []int64{photoBase + 1, photoBase + 2, photoBase + 3, photoBase + 4}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", userIDs)
_, _ = pool.Exec(ctx, "DELETE FROM photos WHERE id = ANY($1::bigint[])", photoIDs)
})
media := NewMediaStore(pool)
for _, photoID := range photoIDs {
if err := media.PutPhoto(ctx, domain.Photo{
ID: photoID, AccessHash: photoID + 100, FileReference: []byte("sparse-ref"), Date: 1700000000, DCID: 2,
Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindStripped, Type: "i", Bytes: []byte{1, 2, byte(photoID)}}},
}); err != nil {
t.Fatalf("PutPhoto(%d): %v", photoID, err)
}
}
contacts := NewContactStore(pool)
rows := []struct {
viewer int64
owner int64
name string
photo int64
}{
{viewerA.ID, ownerA.ID, "A expected", photoIDs[0]},
{viewerA.ID, ownerB.ID, "B cross", photoIDs[1]},
{viewerB.ID, ownerA.ID, "A cross", photoIDs[2]},
{viewerB.ID, ownerB.ID, "B expected", photoIDs[3]},
}
for _, row := range rows {
if _, err := contacts.Upsert(ctx, row.viewer, domain.ContactInput{
ContactUserID: row.owner,
FirstName: row.name,
Phone: "known-phone",
Note: "private note",
NoteEntities: []domain.MessageEntity{{
Type: domain.MessageEntityBold, Length: 7,
}},
}); err != nil {
t.Fatalf("Upsert %d->%d: %v", row.viewer, row.owner, err)
}
if _, found, err := contacts.SetPersonalPhoto(ctx, row.viewer, row.owner, row.photo, 1700000001); err != nil || !found {
t.Fatalf("SetPersonalPhoto %d->%d: found=%v err=%v", row.viewer, row.owner, found, err)
}
}
got, err := contacts.ContactProjectionForViewerUserIDs(ctx, map[int64][]int64{
viewerA.ID: {ownerA.ID},
viewerB.ID: {ownerB.ID},
})
if err != nil {
t.Fatalf("ContactProjectionForViewerUserIDs: %v", err)
}
if len(got.Contacts[viewerA.ID]) != 1 || got.Contacts[viewerA.ID][ownerA.ID].FirstName != "A expected" {
t.Fatalf("viewer A contacts = %+v", got.Contacts[viewerA.ID])
}
contactA := got.Contacts[viewerA.ID][ownerA.ID]
if !reflect.DeepEqual(contactA.User, domain.User{ID: ownerA.ID}) {
t.Fatalf("viewer A sparse projection retained joined base user data: %+v", contactA.User)
}
if contactA.Phone != "known-phone" || contactA.Note != "private note" || len(contactA.NoteEntities) != 1 || contactA.NoteEntities[0].Length != 7 {
t.Fatalf("viewer A sparse overlay = %+v", contactA)
}
if len(got.Contacts[viewerB.ID]) != 1 || got.Contacts[viewerB.ID][ownerB.ID].FirstName != "B expected" {
t.Fatalf("viewer B contacts = %+v", got.Contacts[viewerB.ID])
}
if _, ok := got.Contacts[viewerA.ID][ownerB.ID]; ok {
t.Fatal("viewer A received crossed owner B")
}
if _, ok := got.Contacts[viewerB.ID][ownerA.ID]; ok {
t.Fatal("viewer B received crossed owner A")
}
if got.PersonalPhotos[viewerA.ID][ownerA.ID].PhotoID != photoIDs[0] || len(got.PersonalPhotos[viewerA.ID]) != 1 {
t.Fatalf("viewer A personal photos = %+v", got.PersonalPhotos[viewerA.ID])
}
if got.PersonalPhotos[viewerB.ID][ownerB.ID].PhotoID != photoIDs[3] || len(got.PersonalPhotos[viewerB.ID]) != 1 {
t.Fatalf("viewer B personal photos = %+v", got.PersonalPhotos[viewerB.ID])
}
}
func TestGetReverseContactsForViewerUserIDsPostgresDoesNotCrossPairs(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
viewerA := createTestUser(t, ctx, users, "+1911"+suffix+"01", "Viewer", "A")
viewerB := createTestUser(t, ctx, users, "+1911"+suffix+"02", "Viewer", "B")
ownerA := createTestUser(t, ctx, users, "+1911"+suffix+"03", "Owner", "A")
ownerB := createTestUser(t, ctx, users, "+1911"+suffix+"04", "Owner", "B")
userIDs := []int64{viewerA.ID, viewerB.ID, ownerA.ID, ownerB.ID}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", userIDs)
})
contacts := NewContactStore(pool)
for _, row := range []struct {
owner int64
viewer int64
name string
}{
{ownerA.ID, viewerA.ID, "A expected"},
{ownerA.ID, viewerB.ID, "A cross"},
{ownerB.ID, viewerA.ID, "B cross"},
{ownerB.ID, viewerB.ID, "B expected"},
} {
if _, err := contacts.Upsert(ctx, row.owner, domain.ContactInput{
ContactUserID: row.viewer,
FirstName: row.name,
Note: "relationship-only",
}); err != nil {
t.Fatalf("Upsert %d->%d: %v", row.owner, row.viewer, err)
}
}
if _, err := contacts.SetCloseFriends(ctx, ownerA.ID, []int64{viewerA.ID}); err != nil {
t.Fatal(err)
}
got, err := contacts.GetReverseContactsForViewerUserIDs(ctx, map[int64][]int64{
ownerA.ID: {viewerA.ID},
ownerB.ID: {viewerB.ID},
})
if err != nil {
t.Fatalf("GetReverseContactsForViewerUserIDs: %v", err)
}
contactA, found := got[ownerA.ID][viewerA.ID]
if !found || contactA.User.ID != viewerA.ID || contactA.FirstName != "A expected" || !contactA.CloseFriend {
t.Fatalf("owner A exact relationship = %+v found=%v", contactA, found)
}
contactB, found := got[ownerB.ID][viewerB.ID]
if !found || contactB.User.ID != viewerB.ID || contactB.FirstName != "B expected" || contactB.CloseFriend {
t.Fatalf("owner B exact relationship = %+v found=%v", contactB, found)
}
if _, found := got[ownerA.ID][viewerB.ID]; found {
t.Fatal("owner A received crossed viewer B")
}
if _, found := got[ownerB.ID][viewerA.ID]; found {
t.Fatal("owner B received crossed viewer A")
}
}

View file

@ -0,0 +1,40 @@
package postgres
import (
"reflect"
"testing"
"telesrv/internal/domain"
)
type sparseContactProjectionScanValues []any
func (values sparseContactProjectionScanValues) Scan(dest ...any) error {
for i := range dest {
reflect.ValueOf(dest[i]).Elem().Set(reflect.ValueOf(values[i]))
}
return nil
}
func TestScanSparseContactProjectionRowsKeepsOnlyOverlay(t *testing.T) {
encoded, err := encodeMessageEntities([]domain.MessageEntity{{
Type: domain.MessageEntityTextURL, Length: 4, URL: "https://example.test",
}})
if err != nil {
t.Fatal(err)
}
viewerID, contact, err := scanSparseContactProjectionRows(sparseContactProjectionScanValues{
int64(11), int64(22), true, true, "known-phone", "Local", "Name", "private note", string(encoded),
})
if err != nil {
t.Fatal(err)
}
if viewerID != 11 || !reflect.DeepEqual(contact.User, domain.User{ID: 22}) {
t.Fatalf("sparse identity = viewer %d user %+v", viewerID, contact.User)
}
if contact.FirstName != "Local" || contact.LastName != "Name" || contact.Phone != "known-phone" ||
contact.Note != "private note" || !contact.Mutual || !contact.CloseFriend ||
len(contact.NoteEntities) != 1 || contact.NoteEntities[0].URL != "https://example.test" {
t.Fatalf("sparse overlay = %+v", contact)
}
}

View file

@ -9,12 +9,44 @@ import (
// MessageBoxCounterSource 从 message_boxes durable log 恢复某 owner 的当前最大 box_id。
type MessageBoxCounterSource struct {
q *sqlcgen.Queries
db sqlcgen.DBTX
q *sqlcgen.Queries
}
// NewMessageBoxCounterSource 创建 Redis BoxIDAllocator 的 PG 恢复源。
func NewMessageBoxCounterSource(db sqlcgen.DBTX) *MessageBoxCounterSource {
return &MessageBoxCounterSource{q: sqlcgen.New(db)}
return &MessageBoxCounterSource{db: db, q: sqlcgen.New(db)}
}
func (s *MessageBoxCounterSource) CurrentBatch(ctx context.Context, userIDs []int64) (map[int64]int, error) {
if len(userIDs) == 0 {
return map[int64]int{}, nil
}
rows, err := s.db.Query(ctx, `
SELECT requested.user_id, COALESCE(MAX(m.box_id), 0)::integer
FROM unnest($1::bigint[]) AS requested(user_id)
LEFT JOIN message_boxes m ON m.owner_user_id = requested.user_id
GROUP BY requested.user_id`, userIDs)
if err != nil {
return nil, fmt.Errorf("batch max message box id: %w", err)
}
defer rows.Close()
out := make(map[int64]int, len(userIDs))
for rows.Next() {
var userID int64
var current int
if err := rows.Scan(&userID, &current); err != nil {
return nil, fmt.Errorf("scan batch max message box id: %w", err)
}
out[userID] = current
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate batch max message box id: %w", err)
}
if len(out) != len(userIDs) {
return nil, fmt.Errorf("batch max message box id: returned %d of %d counters", len(out), len(userIDs))
}
return out, nil
}
func (s *MessageBoxCounterSource) Current(ctx context.Context, userID int64) (int, error) {
@ -43,22 +75,19 @@ func (s *ChannelIDCounterSource) Current(ctx context.Context, _ int64) (int, err
return id, nil
}
// SecretChatIDCounterSource 从 secret_chats 表恢复全局 secret chat id迁移 0137
type SecretChatIDCounterSource struct {
db sqlcgen.DBTX
}
// NewSecretChatIDCounterSource 创建 Redis SecretChatIDAllocator 的 PG 恢复源。
func NewSecretChatIDCounterSource(db sqlcgen.DBTX) *SecretChatIDCounterSource {
return &SecretChatIDCounterSource{db: db}
}
func (s *SecretChatIDCounterSource) Current(ctx context.Context, _ int64) (int, error) {
var id int
if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(chat_id), 0) FROM secret_chats`).Scan(&id); err != nil {
return 0, fmt.Errorf("max secret chat id: %w", err)
func (s *ChannelIDCounterSource) CurrentBatch(ctx context.Context, userIDs []int64) (map[int64]int, error) {
if len(userIDs) == 0 {
return map[int64]int{}, nil
}
return id, nil
current, err := s.Current(ctx, 1)
if err != nil {
return nil, err
}
out := make(map[int64]int, len(userIDs))
for _, userID := range userIDs {
out[userID] = current
}
return out, nil
}
// ChannelMessageIDCounterSource 从 channel_messages 恢复某 channel 的当前最大 message id。
@ -77,3 +106,34 @@ func (s *ChannelMessageIDCounterSource) Current(ctx context.Context, channelID i
}
return id, nil
}
func (s *ChannelMessageIDCounterSource) CurrentBatch(ctx context.Context, channelIDs []int64) (map[int64]int, error) {
if len(channelIDs) == 0 {
return map[int64]int{}, nil
}
rows, err := s.db.Query(ctx, `
SELECT requested.channel_id, COALESCE(MAX(m.id), 0)::integer
FROM unnest($1::bigint[]) AS requested(channel_id)
LEFT JOIN channel_messages m ON m.channel_id = requested.channel_id
GROUP BY requested.channel_id`, channelIDs)
if err != nil {
return nil, fmt.Errorf("batch max channel message id: %w", err)
}
defer rows.Close()
out := make(map[int64]int, len(channelIDs))
for rows.Next() {
var channelID int64
var current int
if err := rows.Scan(&channelID, &current); err != nil {
return nil, fmt.Errorf("scan batch max channel message id: %w", err)
}
out[channelID] = current
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate batch max channel message id: %w", err)
}
if len(out) != len(channelIDs) {
return nil, fmt.Errorf("batch max channel message id: returned %d of %d counters", len(out), len(channelIDs))
}
return out, nil
}

View file

@ -20,6 +20,8 @@ type DialogStore struct {
q *sqlcgen.Queries
}
const dialogListSnapshotLimit = 10000
// NewDialogStore 基于 pgx 连接池(或事务)创建 DialogStore。
func NewDialogStore(db sqlcgen.DBTX) *DialogStore {
return &DialogStore{db: db, q: sqlcgen.New(db)}
@ -33,12 +35,19 @@ func (s *DialogStore) enrichDialogTopMessages(ctx context.Context, userID int64,
}
func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
return s.listByUser(ctx, userID, filter, 500)
}
func (s *DialogStore) listByUser(ctx context.Context, userID int64, filter domain.DialogFilter, maxLimit int) (domain.DialogList, error) {
limit := filter.Limit
if limit <= 0 {
limit = 100
}
if limit > 500 {
limit = 500
if maxLimit <= 0 {
maxLimit = 500
}
if limit > maxLimit {
limit = maxLimit
}
offsetPeerID := int64(0)
if filter.HasOffsetPeer {
@ -248,6 +257,195 @@ func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domai
return out, nil
}
// ListDialogSnapshotHeaders returns the complete bounded private-dialog owner
// index without hydrating peer users or top-message payloads. Page payloads are
// resolved later through the versioned per-peer read model.
func (s *DialogStore) ListDialogSnapshotHeaders(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
folderParams := dialogFolderQueryParams(filter.Folder)
rows, err := s.q.ListDialogSummaryByUser(ctx, sqlcgen.ListDialogSummaryByUserParams{
UserID: userID,
HasFolderID: filter.HasFolderID,
FolderID: pgInt32NonNegative(filter.FolderID),
FolderExcludeArchived: folderParams.excludeArchived,
FolderExcludeRead: folderParams.excludeRead,
FolderExcludePeerTypes: folderParams.excludeTypes,
FolderExcludePeerIds: folderParams.excludeIDs,
FolderIncludePeerTypes: folderParams.includeTypes,
FolderIncludePeerIds: folderParams.includeIDs,
FolderPinnedPeerTypes: folderParams.pinnedTypes,
FolderPinnedPeerIds: folderParams.pinnedIDs,
FolderContacts: folderParams.contacts,
FolderNonContacts: folderParams.nonContacts,
PinnedOnly: filter.PinnedOnly,
ExcludePinned: filter.ExcludePinned,
})
if err != nil {
return domain.DialogList{}, fmt.Errorf("list dialog snapshot headers: %w", err)
}
if len(rows) > dialogListSnapshotLimit {
return domain.DialogList{}, fmt.Errorf("private dialog snapshot exceeds %d entries", dialogListSnapshotLimit)
}
dialogs := make([]domain.Dialog, 0, len(rows))
peerTypes := make([]string, 0, len(rows))
peerIDs := make([]int64, 0, len(rows))
for _, row := range rows {
peerTypes = append(peerTypes, row.PeerType)
peerIDs = append(peerIDs, row.PeerID)
dialogs = append(dialogs, domain.Dialog{
Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID},
FolderID: int(row.FolderID),
TopMessage: int(row.TopMessageID),
TopMessageDate: int(row.TopMessageDate),
ReadInboxMaxID: int(row.ReadInboxMaxID),
ReadOutboxMaxID: int(row.ReadOutboxMaxID),
UnreadCount: int(row.UnreadCount),
UnreadMentions: int(row.UnreadMentionsCount),
UnreadReactions: int(row.UnreadReactionsCount),
TTLPeriod: int(row.TtlPeriod),
ThemeEmoticon: row.ThemeEmoticon,
HasScheduled: row.HasScheduled,
Pinned: row.Pinned,
PinnedOrder: int(row.PinnedOrder),
UnreadMark: row.UnreadMark,
PeerSettingsBarHidden: row.HiddenPeerSettingsBar,
})
}
var dependencyHash int64
if len(peerIDs) > 0 {
if err := s.db.QueryRow(ctx, `
WITH requested AS (
SELECT peer_type, peer_id
FROM unnest($2::text[], $3::bigint[]) AS peer(peer_type, peer_id)
)
SELECT COALESCE(bit_xor(v.hash), 0)::bigint
FROM requested peer
JOIN read_model_versions v
ON v.model = 'dialog_light'
AND v.owner_user_id = $1
AND v.peer_type = peer.peer_type
AND v.peer_id = peer.peer_id`, userID, peerTypes, peerIDs).Scan(&dependencyHash); err != nil {
return domain.DialogList{}, fmt.Errorf("read private dialog snapshot dependency hash: %w", err)
}
}
return domain.DialogList{
Dialogs: dialogs,
Count: len(dialogs),
Hash: mixDialogListDependencyHash(dialogListHash(dialogs), dependencyHash),
}, nil
}
// ListAllBuiltinDialogSnapshotHeaders loads one owner base across main and
// archive folders. Pinned/exclude-pinned/folder variants are derived by the app
// layer from this immutable base instead of repeating the owner scan.
func (s *DialogStore) ListAllBuiltinDialogSnapshotHeaders(ctx context.Context, userID int64) (domain.DialogList, error) {
if userID == 0 {
return domain.DialogList{}, nil
}
rows, err := s.db.Query(ctx, `
SELECT d.peer_type,
d.peer_id,
d.folder_id,
d.top_message_id,
d.top_message_date,
d.read_inbox_max_id,
d.read_outbox_max_id,
d.unread_count,
d.unread_mentions_count,
d.unread_reactions_count,
d.ttl_period,
d.theme_emoticon,
d.has_scheduled,
d.pinned,
d.pinned_order,
d.unread_mark,
d.hidden_peer_settings_bar
FROM dialogs AS d
WHERE d.user_id = $1
AND d.folder_id IN (0, 1)
ORDER BY d.pinned DESC,
CASE WHEN d.pinned THEN COALESCE(d.pinned_order, 0) ELSE 0 END DESC,
d.top_message_date DESC,
d.top_message_id DESC,
d.peer_id DESC
LIMIT $2`, userID, dialogListSnapshotLimit+1)
if err != nil {
return domain.DialogList{}, fmt.Errorf("list all built-in private dialog snapshot headers: %w", err)
}
defer rows.Close()
dialogs := make([]domain.Dialog, 0, 128)
for rows.Next() {
var dialog domain.Dialog
var peerType string
if err := rows.Scan(
&peerType,
&dialog.Peer.ID,
&dialog.FolderID,
&dialog.TopMessage,
&dialog.TopMessageDate,
&dialog.ReadInboxMaxID,
&dialog.ReadOutboxMaxID,
&dialog.UnreadCount,
&dialog.UnreadMentions,
&dialog.UnreadReactions,
&dialog.TTLPeriod,
&dialog.ThemeEmoticon,
&dialog.HasScheduled,
&dialog.Pinned,
&dialog.PinnedOrder,
&dialog.UnreadMark,
&dialog.PeerSettingsBarHidden,
); err != nil {
return domain.DialogList{}, fmt.Errorf("scan all built-in private dialog snapshot headers: %w", err)
}
dialog.Peer.Type = domain.PeerType(peerType)
dialogs = append(dialogs, dialog)
}
if err := rows.Err(); err != nil {
return domain.DialogList{}, fmt.Errorf("list all built-in private dialog snapshot header rows: %w", err)
}
if len(dialogs) > dialogListSnapshotLimit {
return domain.DialogList{}, fmt.Errorf("private dialog snapshot exceeds %d entries", dialogListSnapshotLimit)
}
return domain.DialogList{Dialogs: dialogs, Count: len(dialogs)}, nil
}
// ListPrivateDialogPeerIDs is the narrow presence-fanout read model. Presence
// needs only private peer IDs; routing it through GetDialogs would hydrate
// channels, top messages, drafts and viewer projections and can even omit
// private peers when the first page is channel-heavy.
func (s *DialogStore) ListPrivateDialogPeerIDs(ctx context.Context, userID int64, limit int) ([]int64, error) {
if userID == 0 {
return nil, nil
}
if limit <= 0 || limit > 4096 {
limit = 4096
}
rows, err := s.db.Query(ctx, `
SELECT peer_id
FROM dialogs
WHERE user_id = $1
AND peer_type = 'user'
AND peer_id <> $1
ORDER BY top_message_date DESC, top_message_id DESC, peer_id DESC
LIMIT $2`, userID, limit)
if err != nil {
return nil, fmt.Errorf("list private dialog peer ids: %w", err)
}
defer rows.Close()
ids := make([]int64, 0, minInt(limit, 128))
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return ids, nil
}
func (s *DialogStore) ListByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
if len(peers) == 0 {
return domain.DialogList{}, nil
@ -634,6 +832,35 @@ func (s *DialogStore) ListUnreadMarked(ctx context.Context, userID int64) ([]dom
return out, nil
}
func (s *DialogStore) ListDraftsByPeers(ctx context.Context, userID int64, peers []domain.Peer) ([]domain.DialogDraft, error) {
peerTypes := make([]string, 0, len(peers))
peerIDs := make([]int64, 0, len(peers))
seen := make(map[domain.Peer]struct{}, len(peers))
for _, peer := range peers {
if peer.ID == 0 {
continue
}
if _, ok := seen[peer]; ok {
continue
}
seen[peer] = struct{}{}
peerTypes = append(peerTypes, string(peer.Type))
peerIDs = append(peerIDs, peer.ID)
}
if len(peerIDs) == 0 {
return nil, nil
}
rows, err := s.q.ListDialogDraftsByPeers(ctx, sqlcgen.ListDialogDraftsByPeersParams{
UserID: userID,
PeerTypes: peerTypes,
PeerIds: peerIDs,
})
if err != nil {
return nil, fmt.Errorf("list dialog drafts by peers: %w", err)
}
return decodeDialogDrafts(rows)
}
func (s *DialogStore) SetChatTheme(ctx context.Context, userID int64, peer domain.Peer, emoticon string) (bool, error) {
if userID == 0 || peer.Type == "" || peer.ID == 0 {
return false, nil
@ -1050,3 +1277,26 @@ func dialogListHash(dialogs []domain.Dialog) int64 {
}
return int64(h.Sum64())
}
// mixDialogListDependencyHash turns durable read-model version tokens into the
// list hash without forcing the owner ordering scan to derive every mutable
// dialog field. A token change is sufficient to reject an old client hash;
// the page itself is then hydrated from the exact per-peer projection.
func mixDialogListDependencyHash(base int64, dependencies ...int64) int64 {
if base == 0 && len(dependencies) == 0 {
return 0
}
h := fnv.New64a()
var buf [8]byte
binary.LittleEndian.PutUint64(buf[:], uint64(base))
_, _ = h.Write(buf[:])
for _, dependency := range dependencies {
binary.LittleEndian.PutUint64(buf[:], uint64(dependency))
_, _ = h.Write(buf[:])
}
sum := int64(h.Sum64() & 0x7fffffffffffffff)
if sum == 0 {
return 1
}
return sum
}

View file

@ -47,3 +47,51 @@ func TestDialogDraftGetRoundTrip(t *testing.T) {
t.Fatalf("get deleted draft = found %v err %v, want absent", found, err)
}
}
func TestListDialogDraftsByPeersMatchesCompositePeerKeys(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
owner, err := NewUserStore(pool).Create(ctx, domain.User{AccessHash: 41, Phone: "+1888" + suffix + "01", FirstName: "DraftPageOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
userID := owner.ID
requestedUser := domain.Peer{Type: domain.PeerTypeUser, ID: userID + 1}
requestedChannel := domain.Peer{Type: domain.PeerTypeChannel, ID: userID + 2}
crossProductPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: requestedUser.ID}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM dialog_drafts WHERE user_id = $1", userID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", userID)
})
dialogs := NewDialogStore(pool)
for _, draft := range []domain.DialogDraft{
{Peer: requestedUser, Message: "requested user", Date: 101},
{Peer: requestedChannel, Message: "requested channel", Date: 102},
{Peer: crossProductPeer, Message: "must not leak", Date: 103},
{Peer: requestedUser, TopMessageID: 77, Message: "topic draft", Date: 104},
} {
if err := dialogs.SaveDraft(ctx, userID, draft); err != nil {
t.Fatalf("save draft %+v: %v", draft.Peer, err)
}
}
got, err := dialogs.ListDraftsByPeers(ctx, userID, []domain.Peer{requestedUser, requestedChannel, requestedUser})
if err != nil {
t.Fatalf("ListDraftsByPeers: %v", err)
}
if len(got) != 2 {
t.Fatalf("drafts = %+v, want exactly two requested top-level drafts", got)
}
want := map[domain.Peer]string{requestedUser: "requested user", requestedChannel: "requested channel"}
for _, draft := range got {
if want[draft.Peer] != draft.Message {
t.Fatalf("unexpected draft = %+v", draft)
}
delete(want, draft.Peer)
}
if len(want) != 0 {
t.Fatalf("missing drafts = %+v", want)
}
}

View file

@ -0,0 +1,26 @@
package postgres
import (
"testing"
"telesrv/internal/domain"
)
type dialogOwnerListenerCache struct {
owners []int64
}
func (*dialogOwnerListenerCache) InvalidateDialog(int64, domain.Peer) {}
func (*dialogOwnerListenerCache) FlushReadModelCache() {}
func (c *dialogOwnerListenerCache) InvalidateDialogOwner(ownerUserID int64) {
c.owners = append(c.owners, ownerUserID)
}
func TestReadModelListenerInvalidatesExactDialogOwner(t *testing.T) {
cache := &dialogOwnerListenerCache{}
listener := NewReadModelChangeListener("", ReadModelCacheSet{Dialogs: cache}, nil)
listener.handlePayload(`{"model":"dialog_owner","owner_user_id":1001,"peer_type":"user","peer_id":1001,"version":2,"hash":44}`)
if len(cache.owners) != 1 || cache.owners[0] != 1001 {
t.Fatalf("invalidated owners = %v, want [1001]", cache.owners)
}
}

View file

@ -0,0 +1,100 @@
package postgres
import (
"context"
"fmt"
"testing"
"time"
"github.com/jackc/pgx/v5"
)
func TestDialogOwnerReadModelSeedsAndAdvancesExactlyOnce(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
t.Cleanup(func() { _ = tx.Rollback(ctx) })
suffix := time.Now().UnixNano() % 1_000_000_000
ownerID := int64(7_100_000_000) + suffix
channelID := int64(8_100_000_000) + suffix
phone := fmt.Sprintf("199%011d", suffix)
if _, err := tx.Exec(ctx, `
INSERT INTO users (id, access_hash, phone, first_name)
VALUES ($1, $2, $3, 'dialog-owner-test')`, ownerID, ownerID+17, phone); err != nil {
t.Fatalf("insert owner: %v", err)
}
assertDialogOwnerVersion(t, ctx, tx, ownerID, 1)
if _, err := tx.Exec(ctx, `
INSERT INTO dialogs (user_id, peer_type, peer_id, top_message_id, top_message_date)
VALUES ($1, 'user', $2, 1, 10)`, ownerID, ownerID+1); err != nil {
t.Fatalf("insert private dialog: %v", err)
}
assertDialogOwnerVersion(t, ctx, tx, ownerID, 2)
if _, err := tx.Exec(ctx, `
UPDATE dialogs SET unread_count = 1, updated_at = now()
WHERE user_id = $1 AND peer_type = 'user' AND peer_id = $2`, ownerID, ownerID+1); err != nil {
t.Fatalf("update private dialog: %v", err)
}
assertDialogOwnerVersion(t, ctx, tx, ownerID, 3)
if _, err := tx.Exec(ctx, `
INSERT INTO dialog_drafts (user_id, peer_type, peer_id, date, draft)
VALUES ($1, 'user', $2, 11, '{"message":"draft"}'::jsonb)`, ownerID, ownerID+1); err != nil {
t.Fatalf("insert draft: %v", err)
}
assertDialogOwnerVersion(t, ctx, tx, ownerID, 4)
if _, err := tx.Exec(ctx, `
INSERT INTO channels (id, access_hash, creator_user_id, title, megagroup, date)
VALUES ($1, $2, $3, 'dialog-owner-channel', true, 12)`, channelID, channelID+19, ownerID); err != nil {
t.Fatalf("insert channel: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO channel_dialogs (user_id, channel_id, top_message_id, top_message_date)
VALUES ($1, $2, 1, 12)`, ownerID, channelID); err != nil {
t.Fatalf("insert channel dialog: %v", err)
}
assertDialogOwnerVersion(t, ctx, tx, ownerID, 5)
if _, err := tx.Exec(ctx, `
INSERT INTO channel_members (channel_id, user_id, role, status, joined_at)
VALUES ($1, $2, 'creator', 'active', 12)`, channelID, ownerID); err != nil {
t.Fatalf("insert channel member: %v", err)
}
assertDialogOwnerVersion(t, ctx, tx, ownerID, 6)
if _, err := tx.Exec(ctx, `
UPDATE channel_members SET read_inbox_max_id = 1, updated_at = now()
WHERE channel_id = $1 AND user_id = $2`, channelID, ownerID); err != nil {
t.Fatalf("update channel member: %v", err)
}
assertDialogOwnerVersion(t, ctx, tx, ownerID, 7)
// Every other exact-dialog dependency path (private top-message edits,
// reactions, contacts/profile fan-out) converges through this helper.
if _, err := tx.Exec(ctx, `SELECT public.telesrv_bump_dialog_light($1, 'user', $2)`, ownerID, ownerID+1); err != nil {
t.Fatalf("bump exact dialog dependency: %v", err)
}
assertDialogOwnerVersion(t, ctx, tx, ownerID, 8)
}
func assertDialogOwnerVersion(t *testing.T, ctx context.Context, tx pgx.Tx, ownerID, want int64) {
t.Helper()
var got int64
if err := tx.QueryRow(ctx, `
SELECT version
FROM read_model_versions
WHERE model = 'dialog_owner'
AND owner_user_id = $1
AND peer_type = 'user'
AND peer_id = $1`, ownerID).Scan(&got); err != nil {
t.Fatalf("read dialog_owner version: %v", err)
}
if got != want {
t.Fatalf("dialog_owner version = %d, want %d", got, want)
}
}

View file

@ -0,0 +1,190 @@
package postgres
import (
"context"
"testing"
"telesrv/internal/domain"
)
func TestChannelDialogSnapshotHashTracksDependenciesWithoutWideHeaders(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
owner, err := NewUserStore(pool).Create(ctx, domain.User{
AccessHash: 51,
Phone: "+1778" + suffix + "01",
FirstName: "SnapshotHashOwner",
})
if err != nil {
t.Fatalf("create owner: %v", err)
}
created, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Snapshot Hash " + suffix,
Megagroup: true,
Date: 1700000600,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID := created.Channel.ID
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
})
channels := NewChannelStore(pool)
load := func() domain.ChannelDialogList {
t.Helper()
list, err := channels.ListChannelDialogSnapshotHeaders(ctx, owner.ID, domain.DialogFilter{})
if err != nil {
t.Fatalf("list snapshot headers: %v", err)
}
if len(list.Dialogs) != 1 || list.Hash == 0 {
t.Fatalf("snapshot = %+v, want one dialog and non-zero hash", list)
}
dialog := list.Dialogs[0]
if dialog.Peer.ID != channelID || dialog.TopMessage == 0 || dialog.TopMessageDate == 0 {
t.Fatalf("ordering header = %+v, want peer/top/date", dialog)
}
if dialog.ReadInboxMaxID != 0 || dialog.ReadOutboxMaxID != 0 || dialog.UnreadCount != 0 ||
dialog.UnreadMentions != 0 || dialog.UnreadReactions != 0 || dialog.Pts != 0 {
t.Fatalf("snapshot header retained mutable hydration fields: %+v", dialog)
}
return list
}
before := load()
changed, err := channels.SetChannelDialogUnreadMark(ctx, owner.ID, channelID, true)
if err != nil || !changed {
t.Fatalf("set unread mark = %v, %v", changed, err)
}
afterOwnerState := load()
if afterOwnerState.Hash == before.Hash {
t.Fatalf("owner-local dependency hash stayed %d after unread mark", before.Hash)
}
if afterOwnerState.Dialogs[0].TopMessage != before.Dialogs[0].TopMessage ||
afterOwnerState.Dialogs[0].TopMessageDate != before.Dialogs[0].TopMessageDate {
t.Fatalf("unread mark changed ordering header: before=%+v after=%+v", before.Dialogs[0], afterOwnerState.Dialogs[0])
}
if _, err := pool.Exec(ctx, "UPDATE channels SET title = title || ' changed' WHERE id = $1", channelID); err != nil {
t.Fatalf("update channel title: %v", err)
}
afterSharedState := load()
if afterSharedState.Hash == afterOwnerState.Hash {
t.Fatalf("shared channel dependency hash stayed %d after channel-base change", afterOwnerState.Hash)
}
}
func TestPrivateDialogSnapshotHashTracksDraftDependency(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: 52,
Phone: "+1778" + suffix + "02",
FirstName: "PrivateSnapshotOwner",
})
if err != nil {
t.Fatalf("create owner: %v", err)
}
peer, err := users.Create(ctx, domain.User{
AccessHash: 53,
Phone: "+1778" + suffix + "03",
FirstName: "PrivateSnapshotPeer",
})
if err != nil {
t.Fatalf("create peer: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, peer.ID})
})
dialogs := NewDialogStore(pool)
dialogPeer := domain.Peer{Type: domain.PeerTypeUser, ID: peer.ID}
if err := dialogs.Upsert(ctx, owner.ID, domain.Dialog{
Peer: dialogPeer,
TopMessage: 7,
TopMessageDate: 1700000610,
}); err != nil {
t.Fatalf("upsert dialog: %v", err)
}
before, err := dialogs.ListDialogSnapshotHeaders(ctx, owner.ID, domain.DialogFilter{})
if err != nil {
t.Fatalf("list snapshot before draft: %v", err)
}
if len(before.Dialogs) != 1 || before.Hash == 0 {
t.Fatalf("snapshot before draft = %+v", before)
}
if err := dialogs.SaveDraft(ctx, owner.ID, domain.DialogDraft{
Peer: dialogPeer,
Date: 1700000611,
Message: "draft changes hash without changing ordering",
}); err != nil {
t.Fatalf("save draft: %v", err)
}
after, err := dialogs.ListDialogSnapshotHeaders(ctx, owner.ID, domain.DialogFilter{})
if err != nil {
t.Fatalf("list snapshot after draft: %v", err)
}
if after.Hash == before.Hash {
t.Fatalf("private snapshot hash stayed %d after draft change", before.Hash)
}
if after.Dialogs[0].TopMessage != before.Dialogs[0].TopMessage || after.Dialogs[0].TopMessageDate != before.Dialogs[0].TopMessageDate {
t.Fatalf("draft changed ordering header: before=%+v after=%+v", before.Dialogs[0], after.Dialogs[0])
}
}
func TestPrivateDialogAllBuiltinSnapshotIncludesMainAndArchive(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 61, Phone: "+1778" + suffix + "11", FirstName: "AllFolderOwner"})
if err != nil {
t.Fatal(err)
}
mainPeer, err := users.Create(ctx, domain.User{AccessHash: 62, Phone: "+1778" + suffix + "12", FirstName: "MainPeer"})
if err != nil {
t.Fatal(err)
}
archivePeer, err := users.Create(ctx, domain.User{AccessHash: 63, Phone: "+1778" + suffix + "13", FirstName: "ArchivePeer"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, mainPeer.ID, archivePeer.ID})
})
dialogs := NewDialogStore(pool)
for index, peer := range []int64{mainPeer.ID, archivePeer.ID} {
if err := dialogs.Upsert(ctx, owner.ID, domain.Dialog{
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peer},
TopMessage: 10 + index, TopMessageDate: 1700000700 + index,
}); err != nil {
t.Fatal(err)
}
}
if err := dialogs.EditPeerFolders(ctx, owner.ID, []domain.FolderPeerUpdate{{
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: archivePeer.ID}, FolderID: domain.DialogArchiveFolderID,
}}); err != nil {
t.Fatal(err)
}
all, err := dialogs.ListAllBuiltinDialogSnapshotHeaders(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
if len(all.Dialogs) != 2 {
t.Fatalf("all built-in private dialogs = %+v", all.Dialogs)
}
folders := map[int64]int{}
for _, dialog := range all.Dialogs {
folders[dialog.Peer.ID] = dialog.FolderID
}
if folders[mainPeer.ID] != domain.DialogMainFolderID || folders[archivePeer.ID] != domain.DialogArchiveFolderID {
t.Fatalf("all built-in private folders = %#v", folders)
}
}

View file

@ -0,0 +1,162 @@
package postgres
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
func TestDialogTopProjectionInvalidationTracksOnlyVisibleTopPayloads(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 1, Phone: "+1888" + suffix + "01", FirstName: "TopProjectionOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
friend, err := users.Create(ctx, domain.User{AccessHash: 2, Phone: "+1888" + suffix + "02", FirstName: "TopProjectionFriend"})
if err != nil {
t.Fatalf("create friend: %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 private_message_reactions WHERE user_id = ANY($1::bigint[]) OR message_sender_id = ANY($1::bigint[])", []int64{owner.ID, friend.ID})
_, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", []int64{owner.ID, friend.ID})
_, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", []int64{owner.ID, friend.ID})
_, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", []int64{owner.ID, friend.ID})
_, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", []int64{owner.ID, friend.ID})
_, _ = pool.Exec(ctx, "DELETE FROM read_model_versions WHERE owner_user_id = ANY($1::bigint[]) OR (peer_type = 'user' AND peer_id = ANY($1::bigint[]))", []int64{owner.ID, friend.ID})
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID})
})
messages := NewMessageStore(pool)
now := int(time.Now().Unix())
older, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: owner.ID, RecipientUserID: friend.ID, RandomID: time.Now().UnixNano(), Message: "older", Date: now,
})
if err != nil {
t.Fatalf("send older private message: %v", err)
}
newer, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: owner.ID, RecipientUserID: friend.ID, RandomID: time.Now().UnixNano() + 1, Message: "newer", Date: now + 1,
})
if err != nil {
t.Fatalf("send newer private message: %v", err)
}
ownerDialogVersion := func() int64 {
return testReadModelVersion(t, ctx, pool, "dialog_light", owner.ID, "user", friend.ID)
}
before := ownerDialogVersion()
if _, err := pool.Exec(ctx, "UPDATE message_boxes SET body = body || '-edited' WHERE owner_user_id = $1 AND box_id = $2", owner.ID, older.SenderMessage.ID); err != nil {
t.Fatalf("edit non-top private box: %v", err)
}
if got := ownerDialogVersion(); got != before {
t.Fatalf("non-top private edit bumped dialog_light: before=%d after=%d", before, got)
}
if _, err := pool.Exec(ctx, "UPDATE message_boxes SET body = body || '-edited' WHERE owner_user_id = $1 AND box_id = $2", owner.ID, newer.SenderMessage.ID); err != nil {
t.Fatalf("edit top private box: %v", err)
}
if got := ownerDialogVersion(); got <= before {
t.Fatalf("top private edit did not bump dialog_light: before=%d after=%d", before, got)
}
before = ownerDialogVersion()
if _, err := pool.Exec(ctx, `
INSERT INTO private_message_reactions
(message_sender_id, private_message_id, user_id, reaction_type, reaction_value, reaction_date, chosen_order)
VALUES ($1, $2, $3, 'emoji', 'non-top', $4, 1)`, owner.ID, older.SenderMessage.UID, friend.ID, now+2); err != nil {
t.Fatalf("insert non-top private reaction: %v", err)
}
if got := ownerDialogVersion(); got != before {
t.Fatalf("non-top private reaction bumped dialog_light: before=%d after=%d", before, got)
}
if _, err := pool.Exec(ctx, `
INSERT INTO private_message_reactions
(message_sender_id, private_message_id, user_id, reaction_type, reaction_value, reaction_date, chosen_order)
VALUES ($1, $2, $3, 'emoji', 'top', $4, 1)`, owner.ID, newer.SenderMessage.UID, friend.ID, now+3); err != nil {
t.Fatalf("insert top private reaction: %v", err)
}
if got := ownerDialogVersion(); got <= before {
t.Fatalf("top private reaction did not bump dialog_light: before=%d after=%d", before, got)
}
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID, Title: "Top Projection " + suffix, Megagroup: true, MemberUserIDs: []int64{friend.ID}, Date: now + 4,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID = created.Channel.ID
oldChannelMessage, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID, ChannelID: channelID, RandomID: time.Now().UnixNano() + 2, Message: "older channel", Date: now + 5,
})
if err != nil {
t.Fatalf("send older channel message: %v", err)
}
topChannelMessage, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID, ChannelID: channelID, RandomID: time.Now().UnixNano() + 3, Message: "top channel", Date: now + 6,
})
if err != nil {
t.Fatalf("send top channel message: %v", err)
}
channelVersion := func() int64 {
return testReadModelVersion(t, ctx, pool, "channel_base", 0, "channel", channelID)
}
before = channelVersion()
if _, err := pool.Exec(ctx, "UPDATE channel_messages SET body = body || '-edited' WHERE channel_id = $1 AND id = $2", channelID, oldChannelMessage.Message.ID); err != nil {
t.Fatalf("edit non-top channel message: %v", err)
}
if got := channelVersion(); got != before {
t.Fatalf("non-top channel edit bumped channel_base: before=%d after=%d", before, got)
}
if _, err := pool.Exec(ctx, "UPDATE channel_messages SET body = body || '-edited' WHERE channel_id = $1 AND id = $2", channelID, topChannelMessage.Message.ID); err != nil {
t.Fatalf("edit top channel message: %v", err)
}
if got := channelVersion(); got <= before {
t.Fatalf("top channel edit did not bump channel_base: before=%d after=%d", before, got)
}
before = channelVersion()
if _, err := pool.Exec(ctx, `
INSERT INTO channel_message_reactions
(channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value, reaction_date, chosen_order)
VALUES ($1, $2, $3, $4, 'emoji', 'non-top', $5, 1)`, channelID, oldChannelMessage.Message.ID, friend.ID, owner.ID, now+7); err != nil {
t.Fatalf("insert non-top channel reaction: %v", err)
}
if got := channelVersion(); got != before {
t.Fatalf("non-top channel reaction bumped channel_base: before=%d after=%d", before, got)
}
if _, err := pool.Exec(ctx, `
INSERT INTO channel_message_reactions
(channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value, reaction_date, chosen_order)
VALUES ($1, $2, $3, $4, 'emoji', 'top', $5, 1)`, channelID, topChannelMessage.Message.ID, friend.ID, owner.ID, now+8); err != nil {
t.Fatalf("insert top channel reaction: %v", err)
}
if got := channelVersion(); got <= before {
t.Fatalf("top channel reaction did not bump channel_base: before=%d after=%d", before, got)
}
}
func testReadModelVersion(t *testing.T, ctx context.Context, pool *pgxpool.Pool, model string, ownerUserID int64, peerType string, peerID int64) int64 {
t.Helper()
var version int64
if err := pool.QueryRow(ctx, `
SELECT COALESCE((SELECT version
FROM read_model_versions
WHERE model = $1 AND owner_user_id = $2 AND peer_type = $3 AND peer_id = $4), 0)`,
model, ownerUserID, peerType, peerID).Scan(&version); err != nil {
t.Fatalf("read %s version: %v", model, err)
}
return version
}

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"sort"
"time"
"telesrv/internal/domain"
@ -36,6 +37,7 @@ func enqueueDispatch(ctx context.Context, q *sqlcgen.Queries, arg sqlcgen.Enqueu
// DispatchOutboxStore 用 PostgreSQL 实现 transactional outbox。
type DispatchOutboxStore struct {
db sqlcgen.DBTX
q *sqlcgen.Queries
leaseSeconds int32
}
@ -58,6 +60,7 @@ func WithLeaseTimeout(d time.Duration) DispatchOutboxOption {
// NewDispatchOutboxStore 基于 pgx 连接池(或事务)创建 DispatchOutboxStore。
func NewDispatchOutboxStore(db sqlcgen.DBTX, opts ...DispatchOutboxOption) *DispatchOutboxStore {
s := &DispatchOutboxStore{
db: db,
q: sqlcgen.New(db),
leaseSeconds: int32(defaultDispatchLease / time.Second),
}
@ -169,10 +172,12 @@ func (s *DispatchOutboxStore) MarkDeliveredBatch(ctx context.Context, items []st
ids[i] = it.ID
expectedAttempts[i] = int32(it.Attempts)
}
rows, err := s.q.MarkDispatchDeliveredBatch(ctx, sqlcgen.MarkDispatchDeliveredBatchParams{
TargetUserIds: targetUserIDs,
Ids: ids,
ExpectedAttempts: expectedAttempts,
rows, err := s.withExclusiveLaneFences(ctx, targetUserIDs, func(db sqlcgen.DBTX) (int64, error) {
return sqlcgen.New(db).MarkDispatchDeliveredBatch(ctx, sqlcgen.MarkDispatchDeliveredBatchParams{
TargetUserIds: targetUserIDs,
Ids: ids,
ExpectedAttempts: expectedAttempts,
})
})
if err != nil {
return fmt.Errorf("mark dispatch delivered batch: %w", err)
@ -184,10 +189,12 @@ func (s *DispatchOutboxStore) MarkDeliveredBatch(ctx context.Context, items []st
}
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),
rows, err := s.withExclusiveLaneFences(ctx, []int64{item.TargetUserID}, func(db sqlcgen.DBTX) (int64, error) {
return sqlcgen.New(db).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)
@ -198,6 +205,68 @@ func (s *DispatchOutboxStore) MarkDelivered(ctx context.Context, item store.Disp
return nil
}
// withExclusiveLaneFences serializes the empty-lane transition with producers'
// shared append fences. The DELETE runs as a later READ COMMITTED statement, so
// it sees every producer that committed before the exclusive fence was granted.
func (s *DispatchOutboxStore) withExclusiveLaneFences(
ctx context.Context,
userIDs []int64,
work func(sqlcgen.DBTX) (int64, error),
) (int64, error) {
beginner, ok := s.db.(txBeginner)
if !ok {
return 0, fmt.Errorf("dispatch lane transition requires transaction-capable database")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("begin dispatch lane transition: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
if err := lockDispatchOutboxLanesExclusive(ctx, tx, userIDs); err != nil {
return 0, err
}
rows, err := work(tx)
if err != nil {
return 0, err
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("commit dispatch lane transition: %w", err)
}
committed = true
return rows, nil
}
func lockDispatchOutboxLanesExclusive(ctx context.Context, db sqlcgen.DBTX, userIDs []int64) error {
unique := make([]int64, 0, len(userIDs))
seen := make(map[int64]struct{}, len(userIDs))
for _, userID := range userIDs {
if userID <= 0 {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
unique = append(unique, userID)
}
if len(unique) == 0 {
return nil
}
sort.Slice(unique, func(i, j int) bool { return unique[i] < unique[j] })
if _, err := db.Exec(ctx, `
SELECT pg_advisory_xact_lock(dispatch_outbox_lane_advisory_key(streams.target_user_id))
FROM unnest($1::bigint[]) AS streams(target_user_id)
ORDER BY streams.target_user_id`, unique); err != nil {
return fmt.Errorf("lock dispatch outbox lane transition: %w", err)
}
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,
@ -224,9 +293,42 @@ func (s *DispatchOutboxStore) DeleteFailed(ctx context.Context, olderThan time.D
if limit > maxDispatchPoisonCleanupBatch {
limit = maxDispatchPoisonCleanupBatch
}
deleted, err := s.q.DeleteFailedDispatchOutbox(ctx, sqlcgen.DeleteFailedDispatchOutboxParams{
OlderThanSeconds: int32(olderThan / time.Second),
LimitCount: int32(limit),
olderThanSeconds := int32(olderThan / time.Second)
rows, err := s.db.Query(ctx, `
SELECT h.target_user_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`, olderThanSeconds, int32(limit))
if err != nil {
return 0, fmt.Errorf("list failed dispatch outbox lanes: %w", err)
}
userIDs := make([]int64, 0, limit)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
rows.Close()
return 0, fmt.Errorf("scan failed dispatch outbox lane: %w", err)
}
userIDs = append(userIDs, userID)
}
if err := rows.Err(); err != nil {
rows.Close()
return 0, fmt.Errorf("iterate failed dispatch outbox lanes: %w", err)
}
rows.Close()
if len(userIDs) == 0 {
return 0, nil
}
deleted, err := s.withExclusiveLaneFences(ctx, userIDs, func(db sqlcgen.DBTX) (int64, error) {
count, deleteErr := sqlcgen.New(db).DeleteFailedDispatchOutbox(ctx, sqlcgen.DeleteFailedDispatchOutboxParams{
OlderThanSeconds: olderThanSeconds,
LimitCount: int32(limit),
TargetUserIds: userIDs,
})
return int64(count), deleteErr
})
if err != nil {
return 0, fmt.Errorf("delete failed dispatch outbox: %w", err)

View file

@ -192,6 +192,94 @@ ON CONFLICT DO NOTHING
}
}
func TestDispatchOutboxAppendRacingLastHeadCompletionKeepsLaneDiscoverable(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
owner := createTestUser(t, ctx, NewUserStore(pool), "+1884"+suffix+"91", "OutboxFence", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
})
appendEvent := func(events *UpdateEventStore, date int) domain.UpdateEvent {
t.Helper()
event, err := events.AppendAllocatedWithDispatch(ctx, owner.ID, domain.UpdateEvent{
Type: domain.UpdateEventDialogPinned,
PtsCount: 1,
Date: date,
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
}
first := appendEvent(NewUpdateEventStore(pool), 1700002900)
outbox := NewDispatchOutboxStore(pool, WithLeaseTimeout(time.Hour))
claimed := storepkg.DispatchOutboxItem{TargetUserID: owner.ID, Pts: first.Pts, Attempts: 1}
if err := pool.QueryRow(ctx, `
UPDATE dispatch_outbox
SET status='dispatching', attempts=1, updated_at=now()
WHERE target_user_id=$1 AND pts=$2
RETURNING id`, owner.ID, first.Pts).Scan(&claimed.ID); err != nil {
t.Fatalf("claim first head: %v", err)
}
producer, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin producer: %v", err)
}
defer func() { _ = producer.Rollback(ctx) }()
second := appendEvent(NewUpdateEventStore(producer), 1700002901)
var producerPID, sharedFences int
if err := producer.QueryRow(ctx, `SELECT pg_backend_pid()`).Scan(&producerPID); err != nil {
t.Fatalf("load producer backend pid: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT count(*)
FROM pg_locks
WHERE locktype='advisory' AND pid=$1 AND mode='ShareLock' AND granted`, producerPID).Scan(&sharedFences); err != nil {
t.Fatalf("inspect producer lane fence: %v", err)
}
if sharedFences == 0 {
t.Fatal("producer did not retain a shared dispatch lane fence")
}
delivered := make(chan error, 1)
go func() { delivered <- outbox.MarkDelivered(ctx, claimed) }()
select {
case err := <-delivered:
t.Fatalf("completion crossed an uncommitted append fence: %v", err)
case <-time.After(100 * time.Millisecond):
}
if err := producer.Commit(ctx); err != nil {
t.Fatalf("commit producer: %v", err)
}
select {
case err := <-delivered:
if err != nil {
t.Fatalf("complete first head: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("completion did not resume after producer commit")
}
var outboxID, headID int64
var outboxPts, headPts int
if err := pool.QueryRow(ctx, `
SELECT d.id, d.pts, h.head_id, h.head_pts
FROM dispatch_outbox d
JOIN dispatch_outbox_user_heads h
ON h.target_user_id=d.target_user_id
WHERE d.target_user_id=$1`, owner.ID).Scan(&outboxID, &outboxPts, &headID, &headPts); err != nil {
t.Fatalf("load successor lane: %v", err)
}
if outboxID != headID || outboxPts != second.Pts || headPts != second.Pts {
t.Fatalf("successor outbox/head = %d/%d pts=%d/%d, want pts %d", outboxID, headID, outboxPts, headPts, second.Pts)
}
}
func TestDispatchOutboxShardClaimersAreMutuallyExclusive(t *testing.T) {
pool := testPool(t)
ctx := context.Background()

View file

@ -11,7 +11,7 @@ import (
"telesrv/internal/store/postgres/sqlcgen"
)
// EncryptedQueueStore 是 store.EncryptedQueueStore 的 PostgreSQL 实现(迁移 0138
// EncryptedQueueStore 是 store.EncryptedQueueStore 的 PostgreSQL 实现
// 盲中继 qts 投递队列qts 分配secret_qts_watermarks.reserved_qts 自增)+ 写队列行
// 在单事务内完成,保证设备 qts 无空洞。bytes 原样 BYTEA 存储,永不解密。
type EncryptedQueueStore struct {
@ -135,7 +135,8 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
}
func (s *EncryptedQueueStore) ListEncryptedMessagesSince(ctx context.Context, receiverAuthKeyID int64, sinceQts, limit int) ([]domain.SecretChatMessage, error) {
if limit <= 0 || limit > 1000 {
// RPC difference 以 1000 条为一页,并额外读取 1 条探测 hasMore。
if limit <= 0 || limit > 1001 {
limit = 1000
}
rows, err := s.db.Query(ctx,
@ -206,7 +207,8 @@ RETURNING id`,
}
func (s *EncryptedQueueStore) ListUndeliveredStateEvents(ctx context.Context, targetUserID, deviceAuthKeyID int64, limit int) ([]domain.EncryptedStateEvent, error) {
if limit <= 0 || limit > 1000 {
// RPC difference 以 1000 条为一页,并额外读取 1 条探测 hasMore。
if limit <= 0 || limit > 1001 {
limit = 1000
}
rows, err := s.db.Query(ctx, `

View file

@ -20,6 +20,13 @@ func (s *GifCatalogStore) CreateGifCatalogEntry(ctx context.Context, entry domai
if entry.ID == 0 || entry.DocumentID == 0 {
return domain.GifCatalogEntry{}, fmt.Errorf("create gif catalog entry: id and document_id are required")
}
var count int64
if err := s.db.QueryRow(ctx, `SELECT count(*) FROM gif_catalog`).Scan(&count); err != nil {
return domain.GifCatalogEntry{}, fmt.Errorf("count gif catalog entries: %w", err)
}
if count >= domain.MaxGifCatalogEntries {
return domain.GifCatalogEntry{}, domain.ErrGifCatalogFull
}
row := s.db.QueryRow(ctx, `
INSERT INTO gif_catalog (id, title, document_id, enabled, sort_order, created_by, source_filename, category)
VALUES ($1, $2, $3, true, $4, $5, $6, $7)

View file

@ -0,0 +1,108 @@
package postgres
import (
"context"
"errors"
"fmt"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
func TestGifCatalogCapacityIsAtomic(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
base := time.Now().UnixNano()
const extraDocuments = 2
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM gif_catalog WHERE id >= $1 AND id < $2`, base, base+domain.MaxGifCatalogEntries+extraDocuments)
_, _ = pool.Exec(ctx, `DELETE FROM documents WHERE id >= $1 AND id < $2`, base, base+domain.MaxGifCatalogEntries+extraDocuments)
})
var existing, reserved int
if err := pool.QueryRow(ctx, `
SELECT (SELECT count(*) FROM gif_catalog), entry_count
FROM gif_catalog_capacity WHERE singleton`).Scan(&existing, &reserved); err != nil {
t.Fatalf("load initial gif catalog capacity: %v", err)
}
if existing != 0 || reserved != 0 {
t.Fatalf("dedicated test database has gif catalog rows: count=%d reserved=%d", existing, reserved)
}
media := NewMediaStore(pool)
for i := 0; i < domain.MaxGifCatalogEntries+extraDocuments; i++ {
if err := media.PutDocument(ctx, domain.Document{
ID: base + int64(i), MimeType: "video/mp4", Size: 1, DCID: 2,
}); err != nil {
t.Fatalf("put document %d: %v", i, err)
}
}
type result struct {
entry domain.GifCatalogEntry
err error
}
results := make(chan result, domain.MaxGifCatalogEntries+1)
start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < domain.MaxGifCatalogEntries+1; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
entry, err := NewGifCatalogStore(pool).CreateGifCatalogEntry(ctx, domain.GifCatalogEntry{
ID: base + int64(i), Title: fmt.Sprintf("gif-%02d", i), DocumentID: base + int64(i),
})
results <- result{entry: entry, err: err}
}(i)
}
close(start)
wg.Wait()
close(results)
successes, full := make([]domain.GifCatalogEntry, 0, domain.MaxGifCatalogEntries), 0
for got := range results {
switch {
case got.err == nil:
successes = append(successes, got.entry)
case errors.Is(got.err, domain.ErrGifCatalogFull):
full++
default:
t.Fatalf("concurrent create: %v", got.err)
}
}
if len(successes) != domain.MaxGifCatalogEntries || full != 1 {
t.Fatalf("concurrent creates: success=%d full=%d", len(successes), full)
}
assertGifCatalogCapacity(t, ctx, pool, domain.MaxGifCatalogEntries)
if changed, err := NewGifCatalogStore(pool).DeleteGifCatalogEntry(ctx, successes[0].ID); err != nil || !changed {
t.Fatalf("delete catalog entry: changed=%v err=%v", changed, err)
}
assertGifCatalogCapacity(t, ctx, pool, domain.MaxGifCatalogEntries-1)
last := base + domain.MaxGifCatalogEntries + 1
if _, err := NewGifCatalogStore(pool).CreateGifCatalogEntry(ctx, domain.GifCatalogEntry{
ID: last, Title: "replacement", DocumentID: last,
}); err != nil {
t.Fatalf("create after release: %v", err)
}
assertGifCatalogCapacity(t, ctx, pool, domain.MaxGifCatalogEntries)
}
func assertGifCatalogCapacity(t *testing.T, ctx context.Context, pool *pgxpool.Pool, want int) {
t.Helper()
var rows, reserved int
if err := pool.QueryRow(ctx, `
SELECT (SELECT count(*) FROM gif_catalog), entry_count
FROM gif_catalog_capacity WHERE singleton`).Scan(&rows, &reserved); err != nil {
t.Fatalf("load gif catalog capacity: %v", err)
}
if rows != want || reserved != want {
t.Fatalf("gif catalog capacity: rows=%d reserved=%d want=%d", rows, reserved, want)
}
}

View file

@ -139,7 +139,7 @@ func (s *MessageStore) DeliverLoginCodeMessage(ctx context.Context, req domain.L
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("create login code private message: %w", err)
}
boxID, err := s.nextLoginCodeBoxID(ctx, qtx, req.UserID)
boxID, err := s.nextIncomingSystemBoxID(ctx, qtx, req.UserID)
if err != nil {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("allocate login code box id: %w", err)
}
@ -324,7 +324,7 @@ WHERE delivery_key = $1`, deliveryKey[:]).Scan(
return receipt, true, nil
}
func (s *MessageStore) nextLoginCodeBoxID(ctx context.Context, qtx *sqlcgen.Queries, userID int64) (int, error) {
func (s *MessageStore) nextIncomingSystemBoxID(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

View file

@ -279,6 +279,62 @@ WHERE peer_type = 'user' AND peer_id = $1`, domain.OfficialSystemUserID).Scan(&u
}
}
func TestLoginCodeDeliveryPostgresSurvivesCompiledOfficialUsernameChange(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
recipient := createLoginCodeDeliveryTestUser(t, ctx, pool, "official-source-update-recipient")
decoy := createLoginCodeDeliveryTestUser(t, ctx, pool, "official-source-update-decoy")
canonical := strings.ToLower(domain.OfficialSystemUser().Username)
legacy := "legacy_" + strings.ToLower(randomSuffix(t))
if canonical == "" {
t.Fatal("official system username must be non-empty")
}
// Model an installation upgraded across a source revision: the persisted
// official account still has the old username and the newly compiled default
// has since been claimed. Login-code delivery must not attempt request-time
// identity migration and fail on the unique indexes.
if _, err := pool.Exec(ctx, `
UPDATE peer_usernames SET username_lower = $2, username = $2, updated_at = now()
WHERE peer_type = 'user' AND peer_id = $1 AND editable`, domain.OfficialSystemUserID, legacy); err != nil {
t.Fatalf("move official username registry to legacy value: %v", err)
}
if _, err := pool.Exec(ctx, `UPDATE users SET username = $2 WHERE id = $1`, domain.OfficialSystemUserID, legacy); err != nil {
t.Fatalf("move official username to legacy value: %v", err)
}
if _, err := pool.Exec(ctx, `UPDATE users SET username = $2 WHERE id = $1`, decoy.ID, canonical); err != nil {
t.Fatalf("assign compiled username to decoy: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO peer_usernames (username_lower, username, peer_type, peer_id, active, editable, sort_order)
VALUES ($1, $1, 'user', $2, true, true, 0)`, canonical, decoy.ID); err != nil {
t.Fatalf("register compiled username for decoy: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM peer_usernames WHERE peer_type = 'user' AND peer_id = $1`, decoy.ID)
_, _ = pool.Exec(ctx, `UPDATE users SET username = '' WHERE id = $1`, decoy.ID)
_, _ = pool.Exec(ctx, `UPDATE users SET username = $2 WHERE id = $1`, domain.OfficialSystemUserID, canonical)
_, _ = pool.Exec(ctx, `
UPDATE peer_usernames SET username_lower = $2, username = $2, updated_at = now()
WHERE peer_type = 'user' AND peer_id = $1 AND editable`, domain.OfficialSystemUserID, canonical)
})
now := int(time.Now().Unix())
if _, err := NewMessageStore(pool).DeliverLoginCodeMessage(ctx, domain.LoginCodeDeliveryRequest{
UserID: recipient.ID, PhoneCodeHash: "official-source-update-" + randomSuffix(t),
Code: "12345", Date: now, ExpiresAt: int64(now + 300),
}); err != nil {
t.Fatalf("delivery with stale persisted official identity: %v", err)
}
var got string
if err := pool.QueryRow(ctx, `SELECT username FROM users WHERE id = $1`, domain.OfficialSystemUserID).Scan(&got); err != nil {
t.Fatalf("reload official identity: %v", err)
}
if got != legacy {
t.Fatalf("request-time delivery rewrote official username to %q, want persisted %q", got, legacy)
}
}
func TestLoginCodeDeliveryPostgresReceiptRetentionIsBoundedAndSeekOrdered(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
@ -414,6 +470,14 @@ func (a loginCodeFixedBoxAllocator) NextBoxID(context.Context, int64) (int, erro
return a.boxID, nil
}
func (a loginCodeFixedBoxAllocator) NextBoxIDs(_ context.Context, userIDs []int64) (map[int64]int, error) {
out := make(map[int64]int, len(userIDs))
for _, userID := range userIDs {
out[userID] = a.boxID
}
return out, nil
}
func (a loginCodeFixedBoxAllocator) CurrentBoxID(context.Context, int64) (int, error) {
return a.boxID, nil
}

View file

@ -1,8 +1,11 @@
package postgres
import (
"bytes"
"container/list"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"sync"
@ -204,17 +207,30 @@ func (s *MediaStore) DeleteExpiredUploadParts(ctx context.Context, before time.T
// ---- blob 索引 ----
func (s *MediaStore) PutFileBlob(ctx context.Context, blob domain.FileBlob) error {
backend := string(blob.Backend)
if backend == "" {
backend = string(domain.MediaBackendLocalFS)
if blob.LocationKey == "" || blob.Size < 0 {
return fmt.Errorf("invalid file blob location or size")
}
switch blob.Backend {
case domain.MediaBackendLocalFS, domain.MediaBackendS3:
default:
return fmt.Errorf("invalid file blob backend %q", blob.Backend)
}
keyDigest, err := hex.DecodeString(blob.ObjectKey)
if err != nil || len(keyDigest) != sha256.Size || hex.EncodeToString(keyDigest) != blob.ObjectKey {
return fmt.Errorf("file blob object key must be lowercase SHA-256 hex")
}
sha := blob.SHA256
if sha == nil {
sha = []byte{} // 列为 NOT NULLnil []byte 会被 pgx 当作 NULL。
if len(sha) == 0 {
// Canonicalize at the write boundary: BlobBackend guarantees that its
// returned object key is the content digest, so callers using Put (rather
// than PutReader) need not hash the same bytes a second time.
sha = keyDigest
} else if len(sha) != sha256.Size || !bytes.Equal(sha, keyDigest) {
return fmt.Errorf("file blob SHA-256 does not match object key")
}
return s.q.PutFileBlob(ctx, sqlcgen.PutFileBlobParams{
LocationKey: blob.LocationKey,
Backend: backend,
Backend: string(blob.Backend),
ObjectKey: blob.ObjectKey,
Size: blob.Size,
Sha256: sha,
@ -276,6 +292,59 @@ func (s *MediaStore) SumFileBlobBytes(ctx context.Context) (int64, error) {
return s.q.SumFileBlobBytes(ctx)
}
// FileBlobBackendCounts returns the persisted permanent-backend distribution.
// Startup uses it as a fail-fast invariant: a configured backend is never
// allowed to read rows written for another backend through an implicit fallback.
func (s *MediaStore) FileBlobBackendCounts(ctx context.Context) (map[domain.MediaBackend]int64, error) {
rows, err := s.db.Query(ctx, `
SELECT backend, count(*)::bigint
FROM file_blobs
GROUP BY backend`)
if err != nil {
return nil, fmt.Errorf("count file blob backends: %w", err)
}
defer rows.Close()
counts := make(map[domain.MediaBackend]int64)
for rows.Next() {
var backend string
var count int64
if err := rows.Scan(&backend, &count); err != nil {
return nil, fmt.Errorf("scan file blob backend count: %w", err)
}
counts[domain.MediaBackend(backend)] = count
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate file blob backend counts: %w", err)
}
return counts, nil
}
// UniqueFileBlobBytes returns the physical byte total represented by active
// metadata for one backend. Multiple logical locations may point at the same
// content-addressed object, so each object_key is counted exactly once. Size
// disagreement for a shared key is an invariant violation, not something to
// normalize for capacity accounting.
func (s *MediaStore) UniqueFileBlobBytes(ctx context.Context, backend domain.MediaBackend) (int64, error) {
var used int64
var consistent bool
err := s.db.QueryRow(ctx, `
SELECT COALESCE(SUM(max_size), 0)::bigint,
COALESCE(bool_and(min_size = max_size), true)
FROM (
SELECT object_key, MIN(size)::bigint AS min_size, MAX(size)::bigint AS max_size
FROM file_blobs
WHERE backend = $1
GROUP BY object_key
) objects`, string(backend)).Scan(&used, &consistent)
if err != nil {
return 0, fmt.Errorf("sum unique %s file blob bytes: %w", backend, err)
}
if !consistent {
return 0, fmt.Errorf("file_blobs contains inconsistent sizes for shared %s object keys", backend)
}
return used, nil
}
func (s *MediaStore) GetSeedState(ctx context.Context, key string) (string, bool, error) {
var hash string
if err := s.db.QueryRow(ctx, `

View file

@ -88,6 +88,18 @@ func TestChannelMediaIndexSearch(t *testing.T) {
wantIDs("music", search(domain.MediaCategoryMusic), musicID)
wantIDs("photoVideo", search(domain.MediaCategoryPhoto, domain.MediaCategoryVideo), videoID, photoID) // newest-first
wantIDs("voice empty", search(domain.MediaCategoryVoice))
combined, err := channels.SearchChannelMedia(ctx, owner.ID, channelID, domain.MediaSearchRequest{
Categories: []domain.MediaCategory{domain.MediaCategoryVideo},
Query: "VID",
SenderUserID: owner.ID,
MinDate: 1700002002,
MaxDate: 1700002004,
Limit: 50,
})
if err != nil {
t.Fatalf("combined channel media search: %v", err)
}
wantIDs("combined channel media", combined, videoID)
countOnly, err := channels.SearchChannelMedia(ctx, owner.ID, channelID, domain.MediaSearchRequest{
Categories: []domain.MediaCategory{domain.MediaCategoryPhoto},
Limit: 0,
@ -175,6 +187,20 @@ func TestPrivateMediaCategoryCountsMaterialized(t *testing.T) {
if err != nil {
t.Fatalf("send private media: %v", err)
}
combined, err := messages.SearchPrivateMedia(ctx, bob.ID, alice.ID, domain.MediaSearchRequest{
Categories: []domain.MediaCategory{domain.MediaCategoryFile},
Query: "DOC",
SenderUserID: alice.ID,
MinDate: 1700003000,
MaxDate: 1700003200,
Limit: 10,
})
if err != nil {
t.Fatalf("combined private media search: %v", err)
}
if combined.Count != 1 || len(combined.Messages) != 1 || combined.Messages[0].ID != sent.RecipientMessage.ID {
t.Fatalf("combined private media = count %d messages %+v", combined.Count, combined.Messages)
}
wantCount := func(name string, ownerID, peerID int64, category domain.MediaCategory, want int) {
counts, err := messages.CountPrivateMediaCategories(ctx, ownerID, peerID)
if err != nil {

View file

@ -29,20 +29,16 @@ func TestMediaStoreRoundTrip(t *testing.T) {
cleanupMediaStoreRoundTripRows(t, context.Background(), pool)
})
// ---- file blobnil sha256 应被归一为空,不报 NOT NULL----
if err := s.PutFileBlob(ctx, domain.FileBlob{
LocationKey: "doc:9100000000000000001",
ObjectKey: "ab/cd/abcdef",
Size: 1234,
MimeType: "application/x-tgsticker",
}); err != nil {
t.Fatalf("put file blob (nil sha256): %v", err)
// ---- file blobbackend/key/hash/size 是同一份合法永久对象事实)----
wantBlob := postgresTestBlob("doc:9100000000000000001", "media-round-trip", 1234, "application/x-tgsticker")
if err := s.PutFileBlob(ctx, wantBlob); err != nil {
t.Fatalf("put file blob: %v", err)
}
blob, ok, err := s.GetFileBlob(ctx, "doc:9100000000000000001")
if err != nil || !ok {
t.Fatalf("get file blob: ok=%v err=%v", ok, err)
}
if blob.ObjectKey != "ab/cd/abcdef" || blob.Size != 1234 || blob.Backend != domain.MediaBackendLocalFS {
if blob.ObjectKey != wantBlob.ObjectKey || blob.Size != 1234 || blob.Backend != domain.MediaBackendLocalFS || !bytes.Equal(blob.SHA256, wantBlob.SHA256) {
t.Fatalf("file blob mismatch: %+v", blob)
}

View file

@ -76,6 +76,106 @@ func reorderChannelMessagesByID(msgs []domain.ChannelMessage, order []int) []dom
return out
}
func privateMediaSearchBase(ownerUserID, peerID int64, cats []int16, req domain.MediaSearchRequest) (string, []any) {
args := []any{ownerUserID, peerID, cats}
where := `
FROM message_box_media mi
JOIN message_boxes mb ON mb.owner_user_id = mi.owner_user_id AND mb.box_id = mi.box_id
WHERE mi.owner_user_id = $1 AND mi.peer_id = $2 AND mi.category = ANY($3::smallint[])
AND NOT mb.deleted`
add := func(clause string, value any) {
args = append(args, value)
where += fmt.Sprintf(clause, len(args))
}
if req.MaxID > 0 {
add(" AND mi.box_id <= $%d", pgInt32NonNegative(req.MaxID))
}
if req.MinID > 0 {
add(" AND mi.box_id >= $%d", pgInt32NonNegative(req.MinID))
}
if req.Query != "" {
add(" AND mb.body ILIKE '%%' || $%d || '%%'", req.Query)
}
if req.SenderUserID != 0 {
add(" AND mb.from_user_id = $%d", req.SenderUserID)
}
if req.MinDate > 0 {
add(" AND mb.message_date > $%d", pgInt32NonNegative(req.MinDate))
}
if req.MaxDate > 0 {
add(" AND mb.message_date < $%d", pgInt32NonNegative(req.MaxDate))
}
if req.TopMsgID > 0 {
args = append(args, pgInt32NonNegative(req.TopMsgID))
where += fmt.Sprintf(" AND (mb.box_id = $%d OR mb.reply_to_top_id = $%d)", len(args), len(args))
}
if req.SavedPeer.ID != 0 {
args = append(args, string(req.SavedPeer.Type), req.SavedPeer.ID)
where += fmt.Sprintf(" AND mb.saved_peer_type = $%d AND mb.saved_peer_id = $%d", len(args)-1, len(args))
}
if keys := postgresSavedReactionKeys(req.SavedReactions); len(keys) > 0 {
args = append(args, keys)
where += fmt.Sprintf(` AND EXISTS (
SELECT 1 FROM saved_message_reaction_tags tag
WHERE tag.user_id = mb.owner_user_id AND tag.message_box_id = mb.box_id
AND (tag.reaction_type || ':' || tag.reaction_value) = ANY($%d::text[])
)`, len(args))
}
return where, args
}
func channelMediaSearchBase(
viewerUserID, channelID int64,
cats []int16,
channel domain.Channel,
member domain.ChannelMember,
req domain.MediaSearchRequest,
) (string, []any) {
args := []any{channelID, cats}
where := `
FROM channel_message_media mi
JOIN channel_messages m ON m.channel_id = mi.channel_id AND m.id = mi.id
WHERE mi.channel_id = $1 AND mi.category = ANY($2::smallint[])
AND NOT m.deleted`
add := func(clause string, value any) {
args = append(args, value)
where += fmt.Sprintf(clause, len(args))
}
if member.AvailableMinID > 0 {
add(" AND mi.id > $%d", pgInt32NonNegative(member.AvailableMinID))
}
if channel.Monoforum {
if member.CanManageDirectMessages() {
where += " AND m.saved_peer_id = 0"
} else {
add(" AND m.saved_peer_type = 'user' AND m.saved_peer_id = $%d", viewerUserID)
}
}
if req.MaxID > 0 {
add(" AND mi.id <= $%d", pgInt32NonNegative(req.MaxID))
}
if req.MinID > 0 {
add(" AND mi.id >= $%d", pgInt32NonNegative(req.MinID))
}
if req.Query != "" {
add(" AND m.body ILIKE '%%' || $%d || '%%'", req.Query)
}
if req.SenderUserID != 0 {
add(" AND m.sender_user_id = $%d", req.SenderUserID)
}
if req.MinDate > 0 {
add(" AND m.message_date > $%d", pgInt32NonNegative(req.MinDate))
}
if req.MaxDate > 0 {
add(" AND m.message_date < $%d", pgInt32NonNegative(req.MaxDate))
}
if req.TopMsgID > 0 {
args = append(args, pgInt32NonNegative(req.TopMsgID))
where += fmt.Sprintf(" AND (m.id = $%d OR m.reply_to_top_id = $%d)", len(args), len(args))
}
return where, args
}
// SearchPrivateMedia 返回某私聊会话中属于给定类别的消息(newest-first 分页)。
func (s *MessageStore) SearchPrivateMedia(ctx context.Context, ownerUserID, peerID int64, req domain.MediaSearchRequest) (domain.MessageList, error) {
cats := mediaCategoriesToInt16(req.Categories)
@ -83,19 +183,12 @@ func (s *MessageStore) SearchPrivateMedia(ctx context.Context, ownerUserID, peer
return domain.MessageList{}, nil
}
limit, offset := mediaSearchPaging(req)
maxID, minID, offsetID := int32(req.MaxID), int32(req.MinID), int32(req.OffsetID)
base, baseArgs := privateMediaSearchBase(ownerUserID, peerID, cats, req)
count := req.KnownCount
if !req.HasKnownCount {
var err error
count, err = mediaSearchCount(ctx, s.db, `
SELECT count(DISTINCT mi.box_id)::int
FROM message_box_media mi
JOIN message_boxes mb ON mb.owner_user_id = mi.owner_user_id AND mb.box_id = mi.box_id
WHERE mi.owner_user_id = $1 AND mi.peer_id = $2 AND mi.category = ANY($3::smallint[])
AND NOT mb.deleted
AND ($4 = 0 OR mi.box_id <= $4)
AND ($5 = 0 OR mi.box_id >= $5)`, ownerUserID, peerID, cats, maxID, minID)
count, err = mediaSearchCount(ctx, s.db, "SELECT count(DISTINCT mi.box_id)::int"+base, baseArgs...)
if err != nil {
return domain.MessageList{}, fmt.Errorf("count private media: %w", err)
}
@ -103,17 +196,14 @@ WHERE mi.owner_user_id = $1 AND mi.peer_id = $2 AND mi.category = ANY($3::smalli
if limit == 0 {
return domain.MessageList{Count: count}, nil
}
rows, err := s.db.Query(ctx, `
SELECT DISTINCT mi.box_id
FROM message_box_media mi
JOIN message_boxes mb ON mb.owner_user_id = mi.owner_user_id AND mb.box_id = mi.box_id
WHERE mi.owner_user_id = $1 AND mi.peer_id = $2 AND mi.category = ANY($3::smallint[])
AND NOT mb.deleted
AND ($4 = 0 OR mi.box_id <= $4)
AND ($5 = 0 OR mi.box_id >= $5)
AND ($6 = 0 OR mi.box_id < $6)
ORDER BY mi.box_id DESC
OFFSET $7 LIMIT $8`, ownerUserID, peerID, cats, maxID, minID, offsetID, offset, limit)
args := append([]any(nil), baseArgs...)
if req.OffsetID > 0 {
args = append(args, pgInt32NonNegative(req.OffsetID))
base += fmt.Sprintf(" AND mi.box_id < $%d", len(args))
}
args = append(args, offset, limit)
rows, err := s.db.Query(ctx, "SELECT DISTINCT mi.box_id"+base+
fmt.Sprintf(" ORDER BY mi.box_id DESC OFFSET $%d LIMIT $%d", len(args)-1, len(args)), args...)
if err != nil {
return domain.MessageList{}, fmt.Errorf("list private media ids: %w", err)
}
@ -174,24 +264,16 @@ func (s *ChannelStore) SearchChannelMedia(ctx context.Context, viewerUserID, cha
return domain.ChannelHistory{}, nil
}
limit, offset := mediaSearchPaging(req)
maxID, minID, offsetID := int32(req.MaxID), int32(req.MinID), int32(req.OffsetID)
channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID)
if err != nil {
return domain.ChannelHistory{}, err
}
base, baseArgs := channelMediaSearchBase(viewerUserID, channelID, cats, channel, member, req)
count := req.KnownCount
if !req.HasKnownCount {
var err error
count, err = mediaSearchCount(ctx, s.db, `
SELECT count(DISTINCT mi.id)::int
FROM channel_message_media mi
JOIN channel_messages m ON m.channel_id = mi.channel_id AND m.id = mi.id
WHERE mi.channel_id = $1 AND mi.category = ANY($2::smallint[])
AND NOT m.deleted
AND ($3 <= 0 OR mi.id > $3)
AND ($4 = 0 OR mi.id <= $4)
AND ($5 = 0 OR mi.id >= $5)`, channelID, cats, int32(member.AvailableMinID), maxID, minID)
count, err = mediaSearchCount(ctx, s.db, "SELECT count(DISTINCT mi.id)::int"+base, baseArgs...)
if err != nil {
return domain.ChannelHistory{}, fmt.Errorf("count channel media: %w", err)
}
@ -199,18 +281,14 @@ WHERE mi.channel_id = $1 AND mi.category = ANY($2::smallint[])
if limit == 0 {
return domain.ChannelHistory{Channel: channel, Self: member, Count: count}, nil
}
rows, err := s.db.Query(ctx, `
SELECT DISTINCT mi.id
FROM channel_message_media mi
JOIN channel_messages m ON m.channel_id = mi.channel_id AND m.id = mi.id
WHERE mi.channel_id = $1 AND mi.category = ANY($2::smallint[])
AND NOT m.deleted
AND ($3 <= 0 OR mi.id > $3)
AND ($4 = 0 OR mi.id <= $4)
AND ($5 = 0 OR mi.id >= $5)
AND ($6 = 0 OR mi.id < $6)
ORDER BY mi.id DESC
OFFSET $7 LIMIT $8`, channelID, cats, int32(member.AvailableMinID), maxID, minID, offsetID, offset, limit)
args := append([]any(nil), baseArgs...)
if req.OffsetID > 0 {
args = append(args, pgInt32NonNegative(req.OffsetID))
base += fmt.Sprintf(" AND mi.id < $%d", len(args))
}
args = append(args, offset, limit)
rows, err := s.db.Query(ctx, "SELECT DISTINCT mi.id"+base+
fmt.Sprintf(" ORDER BY mi.id DESC OFFSET $%d LIMIT $%d", len(args)-1, len(args)), args...)
if err != nil {
return domain.ChannelHistory{}, fmt.Errorf("list channel media ids: %w", err)
}

View file

@ -55,6 +55,9 @@ func (s *MessageStore) DeleteMessages(ctx context.Context, req domain.DeleteMess
if err := lockUsersForUpdate(ctx, tx, lockUserIDs...); err != nil {
return res, fmt.Errorf("lock delete messages user: %w", err)
}
if err := lockDispatchOutboxAppendFences(ctx, tx, lockUserIDs); err != nil {
return res, fmt.Errorf("lock delete messages dispatch append fences: %w", err)
}
rows, err := qtx.DeleteMessageBoxesByIDs(ctx, sqlcgen.DeleteMessageBoxesByIDsParams{
OwnerUserID: req.OwnerUserID,

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