fix: sync scoped connection and outbox exclusion updates

This commit is contained in:
A 2026-07-12 12:01:53 +08:00
parent aa21bd04e1
commit cbccd6a8d9
58 changed files with 919 additions and 1435 deletions

View file

@ -169,7 +169,7 @@ func TestDispatchOutboxLifecycleKeepsDurableEvents(t *testing.T) {
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID + int64(pts)},
Bool: pts%2 == 0,
}
if _, err := events.AppendAllocatedWithDispatch(ctx, owner.ID, event, [8]byte{}, sessionID); err != nil {
if _, err := events.AppendAllocatedWithDispatch(ctx, owner.ID, event, [8]byte{1}, sessionID); err != nil {
t.Fatalf("AppendAllocatedWithDispatch pts=%d: %v", pts, err)
}
if _, err := tx.Exec(ctx, `

View file

@ -2,6 +2,7 @@ package postgres
import (
"context"
"errors"
"fmt"
"time"
@ -18,6 +19,21 @@ const (
maxDispatchPoisonCleanupBatch = 1000
)
var errInvalidDispatchOutboxExclusionPair = errors.New("dispatch outbox exclusion requires both raw auth key and session id")
// enqueueDispatch is the only production write boundary for dispatch_outbox.
// A zero pair means no originating session is excluded; a non-zero pair identifies
// one exact physical raw-auth/session tuple. A half pair is never meaningful because
// session IDs are not globally unique and must fail the surrounding transaction.
func enqueueDispatch(ctx context.Context, q *sqlcgen.Queries, arg sqlcgen.EnqueueDispatchParams) error {
hasAuthKey := arg.ExcludeAuthKeyID != 0
hasSession := arg.ExcludeSessionID != 0
if hasAuthKey != hasSession {
return errInvalidDispatchOutboxExclusionPair
}
return q.EnqueueDispatch(ctx, arg)
}
// DispatchOutboxStore 用 PostgreSQL 实现 transactional outbox。
type DispatchOutboxStore struct {
q *sqlcgen.Queries

View file

@ -0,0 +1,82 @@
package postgres
import (
"context"
"errors"
"testing"
"github.com/jackc/pgx/v5/pgconn"
"telesrv/internal/domain"
)
func TestDispatchOutboxExclusionPairInvariantPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
owner := createTestUser(t, ctx, NewUserStore(pool), "+1887"+suffix+"01", "OutboxPair", "")
t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) })
event := domain.UpdateEvent{
Type: domain.UpdateEventDialogPinned,
PtsCount: 1,
Date: 1700002300,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
Bool: true,
}
for _, test := range []struct {
name string
authKeyID [8]byte
sessionID int64
}{
{name: "auth key only", authKeyID: [8]byte{1}},
{name: "session only", sessionID: 77},
} {
t.Run("write boundary "+test.name, func(t *testing.T) {
_, err := NewUpdateEventStore(pool).AppendAllocatedWithDispatch(ctx, owner.ID, event, test.authKeyID, test.sessionID)
if !errors.Is(err, errInvalidDispatchOutboxExclusionPair) {
t.Fatalf("AppendAllocatedWithDispatch error = %v, want %v", err, errInvalidDispatchOutboxExclusionPair)
}
})
}
var eventCount int
if err := pool.QueryRow(ctx, "SELECT count(*)::int FROM user_update_events WHERE user_id = $1", owner.ID).Scan(&eventCount); err != nil {
t.Fatalf("count events after rejected writes: %v", err)
}
if eventCount != 0 {
t.Fatalf("events after rejected writes = %d, want 0 (transaction rollback)", eventCount)
}
stored, err := NewUpdateEventStore(pool).AppendAllocated(ctx, owner.ID, event)
if err != nil {
t.Fatalf("append durable event for constraint test: %v", err)
}
for _, test := range []struct {
name string
authKeyID int64
sessionID int64
}{
{name: "auth key only", authKeyID: 1},
{name: "session only", sessionID: 77},
} {
t.Run("database constraint "+test.name, func(t *testing.T) {
_, err := pool.Exec(ctx, `
INSERT INTO dispatch_outbox (
target_user_id, pts, event_type, exclude_auth_key_id, exclude_session_id
) VALUES ($1, $2, $3, $4, $5)`, owner.ID, stored.Pts, string(stored.Type), test.authKeyID, test.sessionID)
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != "23514" || pgErr.ConstraintName != "dispatch_outbox_exclusion_pair_check" {
t.Fatalf("direct insert error = %v, want check violation from dispatch_outbox_exclusion_pair_check", err)
}
})
}
var outboxCount int
if err := pool.QueryRow(ctx, "SELECT count(*)::int FROM dispatch_outbox WHERE target_user_id = $1", owner.ID).Scan(&outboxCount); err != nil {
t.Fatalf("count outbox after rejected inserts: %v", err)
}
if outboxCount != 0 {
t.Fatalf("outbox rows after rejected inserts = %d, want 0", outboxCount)
}
}

View file

@ -0,0 +1,31 @@
package postgres
import (
"context"
"errors"
"testing"
"telesrv/internal/store/postgres/sqlcgen"
)
func TestEnqueueDispatchRejectsHalfExclusionPair(t *testing.T) {
tests := []struct {
name string
authKeyID int64
sessionID int64
}{
{name: "auth key only", authKeyID: 1},
{name: "session only", sessionID: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := enqueueDispatch(context.Background(), nil, sqlcgen.EnqueueDispatchParams{
ExcludeAuthKeyID: test.authKeyID,
ExcludeSessionID: test.sessionID,
})
if !errors.Is(err, errInvalidDispatchOutboxExclusionPair) {
t.Fatalf("enqueueDispatch error = %v, want %v", err, errInvalidDispatchOutboxExclusionPair)
}
})
}
}

View file

@ -186,7 +186,7 @@ func (s *MessageStore) DeliverLoginCodeMessage(ctx context.Context, req domain.L
if err := appendNewMessageEvent(ctx, qtx, msg); err != nil {
return domain.LoginCodeDeliveryResult{}, err
}
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
TargetUserID: req.UserID,
Pts: int32(msg.Pts),
EventType: string(domain.UpdateEventNewMessage),

View file

@ -227,7 +227,7 @@ WHERE sender_user_id = $1
dispatchAuthKeyID = excludeAuthKeyID
dispatchSessionID = excludeSessionID
}
if err := q.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
TargetUserID: userID,
Pts: int32(deletePts),
EventType: string(domain.UpdateEventDeleteMessages),
@ -256,7 +256,7 @@ WHERE sender_user_id = $1
}); err != nil {
return res, fmt.Errorf("advance dialog read inbox after delete correction: %w", err)
}
if err := q.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
TargetUserID: userID,
Pts: int32(correction.Pts),
EventType: string(domain.UpdateEventReadHistoryInbox),

View file

@ -161,7 +161,7 @@ WHERE owner_user_id = $1 AND box_id = $2`, box.OwnerUserID, box.BoxID, int32(pts
if err := appendUserUpdateEvent(ctx, tx, qtx, msg.OwnerUserID, event); err != nil {
return res, fmt.Errorf("append web page event: %w", err)
}
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
TargetUserID: msg.OwnerUserID,
Pts: int32(pts),
EventType: string(domain.UpdateEventWebPage),
@ -257,7 +257,7 @@ WHERE message_sender_id = $1 AND private_message_id = $2`, messageSenderID, targ
dispatchAuthKeyID = req.OriginAuthKeyID
dispatchSessionID = req.OriginSessionID
}
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
TargetUserID: msg.OwnerUserID,
Pts: int32(pts),
EventType: string(domain.UpdateEventEditMessage),

View file

@ -379,7 +379,7 @@ func (s *MessageStore) ReadHistory(ctx context.Context, req domain.ReadHistoryRe
if err := appendUserUpdateEvent(ctx, tx, qtx, req.OwnerUserID, res.InboxEvent); err != nil {
return res, fmt.Errorf("append read inbox event: %w", err)
}
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
TargetUserID: req.OwnerUserID,
Pts: int32(readerPts),
EventType: string(domain.UpdateEventReadHistoryInbox),
@ -414,7 +414,7 @@ func (s *MessageStore) ReadHistory(ctx context.Context, req domain.ReadHistoryRe
if err := appendUserUpdateEvent(ctx, tx, qtx, candidate.SenderOwnerUserID, res.OutboxEvent); err != nil {
return res, fmt.Errorf("append read outbox event: %w", err)
}
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
TargetUserID: candidate.SenderOwnerUserID,
Pts: int32(senderPts),
EventType: string(domain.UpdateEventReadHistoryOutbox),

View file

@ -141,7 +141,7 @@ func (s *MessageStore) PinPrivateMessage(ctx context.Context, req domain.PinPriv
dispatchAuthKeyID = req.OriginAuthKeyID
dispatchSessionID = req.OriginSessionID
}
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
TargetUserID: side.userID,
Pts: int32(pts),
EventType: string(domain.UpdateEventPinnedMessages),
@ -286,7 +286,7 @@ func (s *MessageStore) UnpinAllPrivateMessages(ctx context.Context, req domain.U
dispatchAuthKeyID = req.OriginAuthKeyID
dispatchSessionID = req.OriginSessionID
}
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
TargetUserID: side.userID,
Pts: int32(pts),
EventType: string(domain.UpdateEventPinnedMessages),

View file

@ -182,7 +182,7 @@ WHERE d.user_id = $1
if err := appendUserUpdateEvent(ctx, tx, qtx, req.OwnerUserID, res.Event); err != nil {
return res, fmt.Errorf("append read message contents event: %w", err)
}
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
TargetUserID: req.OwnerUserID,
Pts: int32(pts),
EventType: string(domain.UpdateEventReadMessageContents),
@ -242,7 +242,7 @@ RETURNING box_id`, senderID, senderPrivateMessageIDs[senderID])
if err := appendUserUpdateEvent(ctx, tx, qtx, senderID, event); err != nil {
return res, fmt.Errorf("append sender content read event: %w", err)
}
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
TargetUserID: senderID,
Pts: int32(senderPts),
EventType: string(domain.UpdateEventReadMessageContents),

View file

@ -298,7 +298,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
if err := appendNewMessageEvent(ctx, qtx, sender); err != nil {
return domain.SendPrivateTextResult{}, err
}
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
TargetUserID: req.SenderUserID,
Pts: int32(senderPts),
EventType: string(domain.UpdateEventNewMessage),
@ -360,7 +360,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
if err := appendNewMessageEvent(ctx, qtx, recipient); err != nil {
return domain.SendPrivateTextResult{}, err
}
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
TargetUserID: req.RecipientUserID,
Pts: int32(recipientPts),
EventType: string(domain.UpdateEventNewMessage),

View file

@ -92,7 +92,7 @@ func (s *PhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChan
if err := appendUserUpdateEvent(ctx, tx, qtx, req.UserID, event); err != nil {
return domain.PhoneChangeResult{}, fmt.Errorf("append phone change event: %w", err)
}
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
TargetUserID: req.UserID,
Pts: int32(event.Pts),
EventType: string(event.Type),

View file

@ -122,7 +122,7 @@ func (s *UpdateEventStore) appendInTx(ctx context.Context, db sqlcgen.DBTX, q *s
return domain.UpdateEvent{}, fmt.Errorf("append update event: %w", err)
}
if dispatch {
if err := q.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
if err := enqueueDispatch(ctx, q, sqlcgen.EnqueueDispatchParams{
TargetUserID: userID,
Pts: int32(event.Pts),
EventType: string(event.Type),