merged from gramsrv upstream
This commit is contained in:
parent
79c64ee916
commit
21a0856587
651 changed files with 54774 additions and 4590 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(¬ifications); 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
379
internal/store/postgres/active_channel_ids_batch.go
Normal file
379
internal/store/postgres/active_channel_ids_batch.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
148
internal/store/postgres/active_channel_ids_batch_test.go
Normal file
148
internal/store/postgres/active_channel_ids_batch_test.go
Normal 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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
344
internal/store/postgres/authkey_get_batch.go
Normal file
344
internal/store/postgres/authkey_get_batch.go
Normal 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)
|
||||
189
internal/store/postgres/authkey_get_batch_test.go
Normal file
189
internal/store/postgres/authkey_get_batch_test.go
Normal 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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
¤t.Layer,
|
||||
¤t.MessageID,
|
||||
¤t.ObservationID,
|
||||
¤t.ExpiresAt,
|
||||
¤t.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,
|
||||
¤t.Layer,
|
||||
¤t.MessageID,
|
||||
¤t.ObservationID,
|
||||
¤t.ExpiresAt,
|
||||
&now,
|
||||
¤t.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(¤t.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(¤t.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(
|
||||
¤t.Layer,
|
||||
¤t.MessageID,
|
||||
¤t.ObservationID,
|
||||
¤t.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(
|
||||
|
|
|
|||
408
internal/store/postgres/authkey_session_layer_batch.go
Normal file
408
internal/store/postgres/authkey_session_layer_batch.go
Normal 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,
|
||||
¤t.Layer,
|
||||
¤t.MessageID,
|
||||
¤t.ObservationID,
|
||||
¤t.ExpiresAt,
|
||||
¤t.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)
|
||||
159
internal/store/postgres/authkey_session_layer_batch_test.go
Normal file
159
internal/store/postgres/authkey_session_layer_batch_test.go
Normal 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)
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
111
internal/store/postgres/blob_migration.go
Normal file
111
internal/store/postgres/blob_migration.go
Normal 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
|
||||
}
|
||||
95
internal/store/postgres/blob_storage_integration_test.go
Normal file
95
internal/store/postgres/blob_storage_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
79
internal/store/postgres/blob_storage_lock.go
Normal file
79
internal/store/postgres/blob_storage_lock.go
Normal 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
|
||||
}
|
||||
25
internal/store/postgres/blob_test_helpers_test.go
Normal file
25
internal/store/postgres/blob_test_helpers_test.go
Normal 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,
|
||||
}
|
||||
}
|
||||
376
internal/store/postgres/bootstrap_update_job_batch.go
Normal file
376
internal/store/postgres/bootstrap_update_job_batch.go
Normal 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)
|
||||
172
internal/store/postgres/bootstrap_update_job_batch_test.go
Normal file
172
internal/store/postgres/bootstrap_update_job_batch_test.go
Normal 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))
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,并对每行动态派生 unread;getDialogs(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 与
|
||||
// reactions:TDesktop 把它先入缓存且不被后续 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 {
|
||||
|
|
|
|||
174
internal/store/postgres/channel_difference_cache.go
Normal file
174
internal/store/postgres/channel_difference_cache.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
147
internal/store/postgres/channel_difference_cache_test.go
Normal file
147
internal/store/postgres/channel_difference_cache_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
130
internal/store/postgres/channel_invite_batch_integration_test.go
Normal file
130
internal/store/postgres/channel_invite_batch_integration_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
274
internal/store/postgres/channel_membership_batch.go
Normal file
274
internal/store/postgres/channel_membership_batch.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
531
internal/store/postgres/channel_stats.go
Normal file
531
internal/store/postgres/channel_stats.go
Normal 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(
|
||||
¤tMessages, &previousMessages, ¤tViews, &previousViews, ¤tPosters, &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(¤tViewers, &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(¤tReactions, &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(¤tShares, &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()
|
||||
})
|
||||
}
|
||||
}
|
||||
137
internal/store/postgres/channel_stats_integration_test.go
Normal file
137
internal/store/postgres/channel_stats_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
226
internal/store/postgres/channel_top_message_cache.go
Normal file
226
internal/store/postgres/channel_top_message_cache.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
90
internal/store/postgres/channel_top_message_cache_test.go
Normal file
90
internal/store/postgres/channel_top_message_cache_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
47
internal/store/postgres/community_catalog_cache.go
Normal file
47
internal/store/postgres/community_catalog_cache.go
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
18
internal/store/postgres/community_catalog_cache_test.go
Normal file
18
internal/store/postgres/community_catalog_cache_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
¬e,
|
||||
¬eEntitiesJSON,
|
||||
); 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,
|
||||
¬e,
|
||||
¬eEntitiesJSON,
|
||||
&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
|
||||
|
|
|
|||
157
internal/store/postgres/contact_sparse_integration_test.go
Normal file
157
internal/store/postgres/contact_sparse_integration_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
40
internal/store/postgres/contact_sparse_scanner_test.go
Normal file
40
internal/store/postgres/contact_sparse_scanner_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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, ¤t); 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, ¤t); 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
internal/store/postgres/dialog_owner_listener_test.go
Normal file
26
internal/store/postgres/dialog_owner_listener_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
190
internal/store/postgres/dialog_snapshot_hash_integration_test.go
Normal file
190
internal/store/postgres/dialog_snapshot_hash_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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, `
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
108
internal/store/postgres/gif_catalog_integration_test.go
Normal file
108
internal/store/postgres/gif_catalog_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 NULL;nil []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, `
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -29,20 +29,16 @@ func TestMediaStoreRoundTrip(t *testing.T) {
|
|||
cleanupMediaStoreRoundTripRows(t, context.Background(), pool)
|
||||
})
|
||||
|
||||
// ---- file blob(nil 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 blob(backend/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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue