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