fix: sync scoped connection and outbox exclusion updates
This commit is contained in:
parent
aa21bd04e1
commit
cbccd6a8d9
58 changed files with 919 additions and 1435 deletions
|
|
@ -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, `
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
31
internal/store/postgres/dispatch_outbox_exclusion_test.go
Normal file
31
internal/store/postgres/dispatch_outbox_exclusion_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue