fixes
This commit is contained in:
parent
21a0856587
commit
e8dc967e6a
26 changed files with 1373 additions and 481 deletions
|
|
@ -2,43 +2,76 @@ package store
|
|||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// BroadcastStore persists system broadcast campaigns and their durable
|
||||
// per-recipient delivery outbox.
|
||||
//
|
||||
// A BroadcastTargetAll campaign is not fully enumerated at creation:
|
||||
// CreateBroadcast only snapshots the target user-id range (the current max
|
||||
// user id) and returns. MaterializeBroadcastRecipients then advances that
|
||||
// campaign's enumeration a bounded batch at a time, so a huge user base
|
||||
// never blocks the admin's create call, or any one worker cycle, on a
|
||||
// single giant INSERT. ClaimBroadcastRecipients/ReleaseBroadcastRecipient/
|
||||
// CompleteBroadcastRecipient implement a lease-based handoff for the
|
||||
// delivery half of the cycle: a worker claims a bounded batch of eligible
|
||||
// rows under a time-limited lease, and either completes or releases each
|
||||
// one it processes. A lease that is never renewed simply expires, so a
|
||||
// worker crash mid-cycle cannot strand a row in 'processing' forever, and
|
||||
// two workers can never believe they both hold the same row's lease at once.
|
||||
type BroadcastStore interface {
|
||||
// CreateBroadcast inserts the broadcast row and one pending recipient row
|
||||
// per id in recipientUserIDs, in a single transaction: a broadcast with
|
||||
// zero recipients (an empty "selected" list, or an "all" snapshot taken
|
||||
// when there happen to be no eligible users) is rejected with
|
||||
// domain.ErrBroadcastNoRecipients rather than created empty.
|
||||
CreateBroadcast(ctx context.Context, message string, targetMode domain.BroadcastTargetMode, recipientUserIDs []int64, createdBy string) (domain.Broadcast, error)
|
||||
// PendingBroadcastRecipients returns undelivered outbox rows across every
|
||||
// broadcast, oldest first, each carrying its broadcast's message text so
|
||||
// the worker can send without a second round trip per row.
|
||||
PendingBroadcastRecipients(ctx context.Context, limit int) ([]PendingBroadcastRecipient, error)
|
||||
// MarkBroadcastRecipientSent closes a recipient row as delivered.
|
||||
MarkBroadcastRecipientSent(ctx context.Context, recipientID int64) error
|
||||
// MarkBroadcastRecipientFailed records a failed attempt. The row stays
|
||||
// 'pending' (retried on the next cycle) until attempts reaches
|
||||
// domain.MaxBroadcastRecipientAttempts, at which point it becomes the
|
||||
// terminal 'failed' status.
|
||||
MarkBroadcastRecipientFailed(ctx context.Context, recipientID int64, reason string) error
|
||||
// ListBroadcasts pages broadcasts newest-first, each with sent/failed
|
||||
// counts derived live from its recipient rows.
|
||||
// PreviewBroadcastRecipients validates and counts the intended recipient
|
||||
// set without creating anything -- for "selected" mode this also
|
||||
// validates every id names a real, non-bot, non-system account.
|
||||
PreviewBroadcastRecipients(ctx context.Context, mode domain.BroadcastTargetMode, selectedUserIDs []int64) (int64, error)
|
||||
// CreateBroadcast inserts the broadcast row. For BroadcastTargetAll this
|
||||
// only snapshots the current max user id and target count; no recipient
|
||||
// rows are inserted here (see MaterializeBroadcastRecipients). For
|
||||
// BroadcastTargetSelected, the given ids are validated and their
|
||||
// recipient rows are inserted immediately, since that list is already
|
||||
// bounded by domain.MaxBroadcastSelectedRecipients.
|
||||
CreateBroadcast(ctx context.Context, message string, entities []domain.MessageEntity, mode domain.BroadcastTargetMode, selectedUserIDs []int64, createdBy string) (domain.Broadcast, error)
|
||||
// MaterializeBroadcastRecipients advances one "all"-mode campaign's
|
||||
// enumeration by up to limit newly-inserted recipient rows, and reports
|
||||
// how many were inserted. A campaign with nothing left to enumerate (or
|
||||
// no "all"-mode campaign still enumerating at all) returns 0, nil.
|
||||
MaterializeBroadcastRecipients(ctx context.Context, limit int) (int, error)
|
||||
// ClaimBroadcastRecipients atomically leases up to limit eligible rows
|
||||
// (pending, or processing under an expired lease) to leaseToken for
|
||||
// lease, returning each claim together with its broadcast's message and
|
||||
// entities so the caller can deliver without a second round trip.
|
||||
ClaimBroadcastRecipients(ctx context.Context, leaseToken string, limit int, lease time.Duration) ([]BroadcastRecipientClaim, error)
|
||||
// CompleteBroadcastRecipient closes a claimed row as delivered, recording
|
||||
// the message identifiers the send produced, and advances its
|
||||
// broadcast's sent_count. It is a no-op returning
|
||||
// domain.ErrBroadcastLeaseLost if the claim's lease was lost (expired
|
||||
// and reclaimed, or otherwise no longer matches) in the meantime --
|
||||
// safe to call even after a duplicate/idempotent resend, since the
|
||||
// caller is expected to tolerate that error.
|
||||
CompleteBroadcastRecipient(ctx context.Context, claim BroadcastRecipientClaim, privateMessageID int64, messageBoxID int, pts int) error
|
||||
// ReleaseBroadcastRecipient returns a claimed row to 'pending' (with
|
||||
// backoff) after a failed delivery attempt, or to the terminal 'failed'
|
||||
// once domain.MaxBroadcastRecipientAttempts is reached, and advances its
|
||||
// broadcast's failed_count in that terminal case.
|
||||
ReleaseBroadcastRecipient(ctx context.Context, claim BroadcastRecipientClaim, cause string) error
|
||||
// ListBroadcasts pages broadcasts newest-first.
|
||||
ListBroadcasts(ctx context.Context, beforeID int64, limit int) ([]domain.Broadcast, bool, error)
|
||||
// BroadcastByID returns one broadcast with derived counts.
|
||||
// BroadcastByID returns one broadcast.
|
||||
BroadcastByID(ctx context.Context, id int64) (domain.Broadcast, bool, error)
|
||||
}
|
||||
|
||||
// PendingBroadcastRecipient is one undelivered outbox row, joined with its
|
||||
// broadcast's message text.
|
||||
type PendingBroadcastRecipient struct {
|
||||
// BroadcastRecipientClaim is one recipient row leased for delivery, carrying
|
||||
// its broadcast's message text and entities so the worker doesn't need a
|
||||
// second lookup before sending.
|
||||
type BroadcastRecipientClaim struct {
|
||||
RecipientID int64
|
||||
BroadcastID int64
|
||||
UserID int64
|
||||
Attempts int
|
||||
LeaseToken string
|
||||
Message string
|
||||
Entities []domain.MessageEntity
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,137 +11,257 @@ import (
|
|||
)
|
||||
|
||||
// BroadcastStore is the in-memory implementation of store.BroadcastStore,
|
||||
// used by admin/app unit tests.
|
||||
// used by admin/app unit tests. It has no concept of a "users table" to
|
||||
// snapshot against for "all" mode, so callers seed eligible user ids via
|
||||
// SeedEligibleUsers; MaterializeBroadcastRecipients walks that fixed set the
|
||||
// same way the postgres backend walks a keyset range.
|
||||
type BroadcastStore struct {
|
||||
mu sync.Mutex
|
||||
broadcasts map[int64]domain.Broadcast
|
||||
recipients map[int64]*memBroadcastRecipient
|
||||
nextBID int64
|
||||
nextRID int64
|
||||
}
|
||||
|
||||
type memBroadcastRecipient struct {
|
||||
domain.BroadcastRecipient
|
||||
message string
|
||||
mu sync.Mutex
|
||||
broadcasts map[int64]domain.Broadcast
|
||||
recipients map[int64]*domain.BroadcastRecipient
|
||||
eligibleUsers []int64 // sorted ascending, mirrors "all non-bot, non-system users"
|
||||
nextBID int64
|
||||
nextRID int64
|
||||
}
|
||||
|
||||
func NewBroadcastStore() *BroadcastStore {
|
||||
return &BroadcastStore{
|
||||
broadcasts: make(map[int64]domain.Broadcast),
|
||||
recipients: make(map[int64]*memBroadcastRecipient),
|
||||
recipients: make(map[int64]*domain.BroadcastRecipient),
|
||||
}
|
||||
}
|
||||
|
||||
var _ store.BroadcastStore = (*BroadcastStore)(nil)
|
||||
|
||||
func (s *BroadcastStore) CreateBroadcast(_ context.Context, message string, targetMode domain.BroadcastTargetMode, recipientUserIDs []int64, createdBy string) (domain.Broadcast, error) {
|
||||
if len(recipientUserIDs) == 0 {
|
||||
return domain.Broadcast{}, domain.ErrBroadcastNoRecipients
|
||||
}
|
||||
// SeedEligibleUsers sets the fixed set of user ids "all"-mode targets and
|
||||
// PreviewBroadcastRecipients/CreateBroadcast/MaterializeBroadcastRecipients
|
||||
// enumerate over, mirroring the postgres store's live users-table query.
|
||||
func (s *BroadcastStore) SeedEligibleUsers(userIDs []int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.nextBID++
|
||||
b := domain.Broadcast{
|
||||
ID: s.nextBID,
|
||||
Message: message,
|
||||
TargetMode: targetMode,
|
||||
CreatedBy: createdBy,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
seen := make(map[int64]bool, len(recipientUserIDs))
|
||||
for _, userID := range recipientUserIDs {
|
||||
if seen[userID] {
|
||||
continue
|
||||
}
|
||||
seen[userID] = true
|
||||
s.nextRID++
|
||||
s.recipients[s.nextRID] = &memBroadcastRecipient{
|
||||
BroadcastRecipient: domain.BroadcastRecipient{
|
||||
ID: s.nextRID,
|
||||
BroadcastID: b.ID,
|
||||
UserID: userID,
|
||||
Status: domain.BroadcastRecipientPending,
|
||||
},
|
||||
message: message,
|
||||
}
|
||||
b.TotalCount++
|
||||
}
|
||||
s.broadcasts[b.ID] = b
|
||||
return b, nil
|
||||
s.eligibleUsers = append([]int64(nil), userIDs...)
|
||||
sort.Slice(s.eligibleUsers, func(i, j int) bool { return s.eligibleUsers[i] < s.eligibleUsers[j] })
|
||||
}
|
||||
|
||||
func (s *BroadcastStore) PendingBroadcastRecipients(_ context.Context, limit int) ([]store.PendingBroadcastRecipient, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
func isEligibleSelected(userID int64) bool {
|
||||
return userID > 0 && !domain.IsSystemUserID(userID)
|
||||
}
|
||||
|
||||
func (s *BroadcastStore) PreviewBroadcastRecipients(_ context.Context, mode domain.BroadcastTargetMode, selectedUserIDs []int64) (int64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
switch mode {
|
||||
case domain.BroadcastTargetAll:
|
||||
if len(s.eligibleUsers) == 0 {
|
||||
return 0, domain.ErrBroadcastNoRecipients
|
||||
}
|
||||
return int64(len(s.eligibleUsers)), nil
|
||||
case domain.BroadcastTargetSelected:
|
||||
if len(selectedUserIDs) == 0 {
|
||||
return 0, domain.ErrBroadcastNoRecipients
|
||||
}
|
||||
for _, id := range selectedUserIDs {
|
||||
if !isEligibleSelected(id) {
|
||||
return 0, domain.ErrBroadcastRecipientInvalid
|
||||
}
|
||||
}
|
||||
return int64(len(selectedUserIDs)), nil
|
||||
default:
|
||||
return 0, domain.ErrBroadcastInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BroadcastStore) CreateBroadcast(_ context.Context, message string, entities []domain.MessageEntity, mode domain.BroadcastTargetMode, selectedUserIDs []int64, createdBy string) (domain.Broadcast, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
switch mode {
|
||||
case domain.BroadcastTargetAll:
|
||||
if len(s.eligibleUsers) == 0 {
|
||||
return domain.Broadcast{}, domain.ErrBroadcastNoRecipients
|
||||
}
|
||||
s.nextBID++
|
||||
b := domain.Broadcast{
|
||||
ID: s.nextBID, Message: message, Entities: entities, TargetMode: mode,
|
||||
TargetCount: int64(len(s.eligibleUsers)), CreatedBy: createdBy, CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
s.broadcasts[b.ID] = b
|
||||
return b, nil
|
||||
case domain.BroadcastTargetSelected:
|
||||
if len(selectedUserIDs) == 0 {
|
||||
return domain.Broadcast{}, domain.ErrBroadcastNoRecipients
|
||||
}
|
||||
for _, id := range selectedUserIDs {
|
||||
if !isEligibleSelected(id) {
|
||||
return domain.Broadcast{}, domain.ErrBroadcastRecipientInvalid
|
||||
}
|
||||
}
|
||||
s.nextBID++
|
||||
b := domain.Broadcast{
|
||||
ID: s.nextBID, Message: message, Entities: entities, TargetMode: mode,
|
||||
EnumerationDone: true, CreatedBy: createdBy, CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
seen := make(map[int64]bool, len(selectedUserIDs))
|
||||
for _, userID := range selectedUserIDs {
|
||||
if seen[userID] {
|
||||
continue
|
||||
}
|
||||
seen[userID] = true
|
||||
s.nextRID++
|
||||
s.recipients[s.nextRID] = &domain.BroadcastRecipient{
|
||||
ID: s.nextRID, BroadcastID: b.ID, UserID: userID,
|
||||
Status: domain.BroadcastRecipientPending, NextAttemptAt: time.Now().UTC(),
|
||||
}
|
||||
b.TargetCount++
|
||||
b.MaterializedCount++
|
||||
}
|
||||
s.broadcasts[b.ID] = b
|
||||
return b, nil
|
||||
default:
|
||||
return domain.Broadcast{}, domain.ErrBroadcastInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BroadcastStore) MaterializeBroadcastRecipients(_ context.Context, limit int) (int, error) {
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 100
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// Iteration order over a map is unspecified; sort by recipient id (assigned
|
||||
// in creation order) so this matches the postgres backend's "oldest first".
|
||||
ids := make([]int64, 0, len(s.recipients))
|
||||
var ids []int64
|
||||
for id, b := range s.broadcasts {
|
||||
if b.TargetMode == domain.BroadcastTargetAll && !b.EnumerationDone {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
sortInt64s(ids)
|
||||
bid := ids[0]
|
||||
b := s.broadcasts[bid]
|
||||
inserted := 0
|
||||
for _, userID := range s.eligibleUsers {
|
||||
if int64(inserted) >= int64(limit) {
|
||||
break
|
||||
}
|
||||
if s.hasRecipient(bid, userID) {
|
||||
continue
|
||||
}
|
||||
s.nextRID++
|
||||
s.recipients[s.nextRID] = &domain.BroadcastRecipient{
|
||||
ID: s.nextRID, BroadcastID: bid, UserID: userID,
|
||||
Status: domain.BroadcastRecipientPending, NextAttemptAt: time.Now().UTC(),
|
||||
}
|
||||
b.MaterializedCount++
|
||||
inserted++
|
||||
}
|
||||
if inserted < limit {
|
||||
b.EnumerationDone = true
|
||||
b.TargetCount = b.MaterializedCount
|
||||
}
|
||||
s.broadcasts[bid] = b
|
||||
return inserted, nil
|
||||
}
|
||||
|
||||
func (s *BroadcastStore) hasRecipient(broadcastID, userID int64) bool {
|
||||
for _, r := range s.recipients {
|
||||
if r.BroadcastID == broadcastID && r.UserID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *BroadcastStore) ClaimBroadcastRecipients(_ context.Context, leaseToken string, limit int, lease time.Duration) ([]store.BroadcastRecipientClaim, error) {
|
||||
if leaseToken == "" {
|
||||
return nil, domain.ErrBroadcastInvalid
|
||||
}
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 50
|
||||
}
|
||||
if lease <= 0 {
|
||||
lease = 30 * time.Second
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var ids []int64
|
||||
now := time.Now().UTC()
|
||||
for id, r := range s.recipients {
|
||||
if r.Status == domain.BroadcastRecipientPending {
|
||||
eligible := (r.Status == domain.BroadcastRecipientPending && !r.NextAttemptAt.After(now)) ||
|
||||
(r.Status == domain.BroadcastRecipientProcessing && r.LeaseUntil != nil && !r.LeaseUntil.After(now))
|
||||
if eligible {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sortInt64s(ids)
|
||||
out := make([]store.PendingBroadcastRecipient, 0, limit)
|
||||
if len(ids) > limit {
|
||||
ids = ids[:limit]
|
||||
}
|
||||
out := make([]store.BroadcastRecipientClaim, 0, len(ids))
|
||||
until := now.Add(lease)
|
||||
for _, id := range ids {
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
r := s.recipients[id]
|
||||
out = append(out, store.PendingBroadcastRecipient{
|
||||
RecipientID: r.ID, BroadcastID: r.BroadcastID, UserID: r.UserID, Attempts: r.Attempts, Message: r.message,
|
||||
r.Status = domain.BroadcastRecipientProcessing
|
||||
r.Attempts++
|
||||
r.LeaseToken = leaseToken
|
||||
r.LeaseUntil = &until
|
||||
r.UpdatedAt = now
|
||||
b := s.broadcasts[r.BroadcastID]
|
||||
out = append(out, store.BroadcastRecipientClaim{
|
||||
RecipientID: r.ID, BroadcastID: r.BroadcastID, UserID: r.UserID,
|
||||
Attempts: r.Attempts, LeaseToken: leaseToken, Message: b.Message, Entities: b.Entities,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *BroadcastStore) MarkBroadcastRecipientSent(_ context.Context, recipientID int64) error {
|
||||
func (s *BroadcastStore) CompleteBroadcastRecipient(_ context.Context, claim store.BroadcastRecipientClaim, privateMessageID int64, messageBoxID int, pts int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
r, ok := s.recipients[recipientID]
|
||||
if !ok || r.Status != domain.BroadcastRecipientPending {
|
||||
return nil
|
||||
r, ok := s.recipients[claim.RecipientID]
|
||||
if !ok || r.Status != domain.BroadcastRecipientProcessing || r.LeaseToken != claim.LeaseToken {
|
||||
return domain.ErrBroadcastLeaseLost
|
||||
}
|
||||
r.Status = domain.BroadcastRecipientSent
|
||||
now := time.Now().UTC()
|
||||
r.SentAt = &now
|
||||
r.Status = domain.BroadcastRecipientSent
|
||||
r.LeaseToken = ""
|
||||
r.LeaseUntil = nil
|
||||
r.LastError = ""
|
||||
r.PrivateMessageID = privateMessageID
|
||||
r.MessageBoxID = messageBoxID
|
||||
r.Pts = pts
|
||||
r.SentAt = &now
|
||||
r.UpdatedAt = now
|
||||
b := s.broadcasts[claim.BroadcastID]
|
||||
b.SentCount++
|
||||
s.broadcasts[claim.BroadcastID] = b
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BroadcastStore) MarkBroadcastRecipientFailed(_ context.Context, recipientID int64, reason string) error {
|
||||
func (s *BroadcastStore) ReleaseBroadcastRecipient(_ context.Context, claim store.BroadcastRecipientClaim, cause string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
r, ok := s.recipients[recipientID]
|
||||
if !ok || r.Status != domain.BroadcastRecipientPending {
|
||||
r, ok := s.recipients[claim.RecipientID]
|
||||
if !ok || r.Status != domain.BroadcastRecipientProcessing || r.LeaseToken != claim.LeaseToken {
|
||||
return nil
|
||||
}
|
||||
r.Attempts++
|
||||
r.LastError = reason
|
||||
now := time.Now().UTC()
|
||||
r.LeaseToken = ""
|
||||
r.LeaseUntil = nil
|
||||
r.LastError = cause
|
||||
r.UpdatedAt = now
|
||||
if r.Attempts >= domain.MaxBroadcastRecipientAttempts {
|
||||
r.Status = domain.BroadcastRecipientFailed
|
||||
b := s.broadcasts[claim.BroadcastID]
|
||||
b.FailedCount++
|
||||
s.broadcasts[claim.BroadcastID] = b
|
||||
} else {
|
||||
r.Status = domain.BroadcastRecipientPending
|
||||
r.NextAttemptAt = now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BroadcastStore) countsFor(broadcastID int64) (sent, failed int) {
|
||||
for _, r := range s.recipients {
|
||||
if r.BroadcastID != broadcastID {
|
||||
continue
|
||||
}
|
||||
switch r.Status {
|
||||
case domain.BroadcastRecipientSent:
|
||||
sent++
|
||||
case domain.BroadcastRecipientFailed:
|
||||
failed++
|
||||
}
|
||||
}
|
||||
return sent, failed
|
||||
}
|
||||
|
||||
func (s *BroadcastStore) ListBroadcasts(_ context.Context, beforeID int64, limit int) ([]domain.Broadcast, bool, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
|
|
@ -161,9 +281,7 @@ func (s *BroadcastStore) ListBroadcasts(_ context.Context, beforeID int64, limit
|
|||
}
|
||||
out := make([]domain.Broadcast, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
b := s.broadcasts[id]
|
||||
b.SentCount, b.FailedCount = s.countsFor(id)
|
||||
out = append(out, b)
|
||||
out = append(out, s.broadcasts[id])
|
||||
}
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
|
@ -175,7 +293,6 @@ func (s *BroadcastStore) BroadcastByID(_ context.Context, id int64) (domain.Broa
|
|||
if !ok {
|
||||
return domain.Broadcast{}, false, nil
|
||||
}
|
||||
b.SentCount, b.FailedCount = s.countsFor(id)
|
||||
return b, true, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ package postgres
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
|
|
@ -12,7 +15,10 @@ import (
|
|||
)
|
||||
|
||||
// BroadcastStore persists system broadcast campaigns (see
|
||||
// deploy/migrations/20260714003131_system_broadcasts.up.sql).
|
||||
// deploy/migrations/20260714003131_system_broadcasts.up.sql, extended by
|
||||
// deploy/migrations/20260901000024_broadcast_lease_delivery_and_entities.up.sql
|
||||
// with entities, incremental "all"-mode materialization and lease-based
|
||||
// delivery claims).
|
||||
type BroadcastStore struct {
|
||||
db sqlcgen.DBTX
|
||||
}
|
||||
|
|
@ -24,37 +30,113 @@ func NewBroadcastStore(db sqlcgen.DBTX) *BroadcastStore {
|
|||
|
||||
var _ store.BroadcastStore = (*BroadcastStore)(nil)
|
||||
|
||||
// CreateBroadcast inserts the broadcast row and one pending recipient row per
|
||||
// id, deduplicating recipientUserIDs (a "selected" list built by hand in the
|
||||
// panel could otherwise carry a repeat) via ON CONFLICT DO NOTHING against
|
||||
// the (broadcast_id, user_id) unique constraint.
|
||||
func (s *BroadcastStore) CreateBroadcast(ctx context.Context, message string, targetMode domain.BroadcastTargetMode, recipientUserIDs []int64, createdBy string) (domain.Broadcast, error) {
|
||||
if len(recipientUserIDs) == 0 {
|
||||
return domain.Broadcast{}, domain.ErrBroadcastNoRecipients
|
||||
const eligibleBroadcastUsersSQL = `
|
||||
FROM users
|
||||
WHERE NOT is_bot
|
||||
AND deleted_at IS NULL
|
||||
AND id <> ALL($1::bigint[])`
|
||||
|
||||
// PreviewBroadcastRecipients counts (and, for "selected", validates) the
|
||||
// intended recipient set without creating anything.
|
||||
func (s *BroadcastStore) PreviewBroadcastRecipients(ctx context.Context, mode domain.BroadcastTargetMode, selectedUserIDs []int64) (int64, error) {
|
||||
switch mode {
|
||||
case domain.BroadcastTargetAll:
|
||||
var count int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT count(*) `+eligibleBroadcastUsersSQL, domain.SystemUserIDs()).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("count broadcast recipients: %w", err)
|
||||
}
|
||||
if count == 0 {
|
||||
return 0, domain.ErrBroadcastNoRecipients
|
||||
}
|
||||
return count, nil
|
||||
case domain.BroadcastTargetSelected:
|
||||
return validateSelectedBroadcastUsers(ctx, s.db, selectedUserIDs)
|
||||
default:
|
||||
return 0, domain.ErrBroadcastInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func validateSelectedBroadcastUsers(ctx context.Context, db sqlcgen.DBTX, selectedUserIDs []int64) (int64, error) {
|
||||
if len(selectedUserIDs) == 0 {
|
||||
return 0, domain.ErrBroadcastNoRecipients
|
||||
}
|
||||
var count int64
|
||||
if err := db.QueryRow(ctx, `
|
||||
SELECT count(*)
|
||||
FROM users
|
||||
WHERE id = ANY($1::bigint[])
|
||||
AND NOT is_bot
|
||||
AND deleted_at IS NULL
|
||||
AND id <> ALL($2::bigint[])`, selectedUserIDs, domain.SystemUserIDs()).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("validate broadcast recipients: %w", err)
|
||||
}
|
||||
if count != int64(len(selectedUserIDs)) {
|
||||
return 0, domain.ErrBroadcastRecipientInvalid
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// CreateBroadcast inserts the broadcast row. For "all" mode it only
|
||||
// snapshots the current max eligible user id and target count -- recipient
|
||||
// rows are inserted incrementally by MaterializeBroadcastRecipients, not
|
||||
// here. For "selected" mode, whose recipient list is already bounded by
|
||||
// domain.MaxBroadcastSelectedRecipients, every recipient row is inserted in
|
||||
// the same transaction as the broadcast itself, deduplicating via
|
||||
// ON CONFLICT DO NOTHING against the (broadcast_id, user_id) unique
|
||||
// constraint (a hand-built selected list could otherwise carry a repeat).
|
||||
func (s *BroadcastStore) CreateBroadcast(ctx context.Context, message string, entities []domain.MessageEntity, mode domain.BroadcastTargetMode, selectedUserIDs []int64, createdBy string) (domain.Broadcast, error) {
|
||||
entitiesJSON, err := encodeMessageEntities(entities)
|
||||
if err != nil {
|
||||
return domain.Broadcast{}, fmt.Errorf("encode broadcast entities: %w", err)
|
||||
}
|
||||
var out domain.Broadcast
|
||||
err := withTx(ctx, s.db, "create broadcast", func(tx pgx.Tx) error {
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO broadcasts (message, target_mode, total_count, created_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, message, target_mode, total_count, created_by, created_at`,
|
||||
message, string(targetMode), len(recipientUserIDs), createdBy,
|
||||
).Scan(&out.ID, &out.Message, &out.TargetMode, &out.TotalCount, &out.CreatedBy, &out.CreatedAt); err != nil {
|
||||
return fmt.Errorf("insert broadcast: %w", err)
|
||||
}
|
||||
batch := &pgx.Batch{}
|
||||
for _, userID := range recipientUserIDs {
|
||||
batch.Queue(`
|
||||
INSERT INTO broadcast_recipients (broadcast_id, user_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (broadcast_id, user_id) DO NOTHING`, out.ID, userID)
|
||||
}
|
||||
results := tx.SendBatch(ctx, batch)
|
||||
defer results.Close()
|
||||
for range recipientUserIDs {
|
||||
if _, err := results.Exec(); err != nil {
|
||||
return fmt.Errorf("insert broadcast recipient: %w", err)
|
||||
err = withTx(ctx, s.db, "create broadcast", func(tx pgx.Tx) error {
|
||||
switch mode {
|
||||
case domain.BroadcastTargetAll:
|
||||
var maxUserID, count int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(max(id), 0), count(*) `+eligibleBroadcastUsersSQL, domain.SystemUserIDs()).Scan(&maxUserID, &count); err != nil {
|
||||
return fmt.Errorf("snapshot broadcast recipients: %w", err)
|
||||
}
|
||||
if count == 0 {
|
||||
return domain.ErrBroadcastNoRecipients
|
||||
}
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO broadcasts (
|
||||
message, entities, target_mode, snapshot_max_user_id, enumeration_done,
|
||||
target_count, created_by
|
||||
) VALUES ($1, $2::jsonb, 'all', $3, false, $4, $5)
|
||||
RETURNING `+broadcastColumns,
|
||||
message, string(entitiesJSON), maxUserID, count, createdBy,
|
||||
)
|
||||
if err := scanBroadcastRow(row, &out); err != nil {
|
||||
return fmt.Errorf("insert all-user broadcast: %w", err)
|
||||
}
|
||||
case domain.BroadcastTargetSelected:
|
||||
count, err := validateSelectedBroadcastUsers(ctx, tx, selectedUserIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO broadcasts (
|
||||
message, entities, target_mode, enumeration_done, target_count,
|
||||
materialized_count, created_by
|
||||
) VALUES ($1, $2::jsonb, 'selected', true, $3, $3, $4)
|
||||
RETURNING `+broadcastColumns,
|
||||
message, string(entitiesJSON), count, createdBy,
|
||||
)
|
||||
if err := scanBroadcastRow(row, &out); err != nil {
|
||||
return fmt.Errorf("insert selected broadcast: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO broadcast_recipients (broadcast_id, user_id)
|
||||
SELECT $1, user_id
|
||||
FROM unnest($2::bigint[]) AS selected(user_id)
|
||||
ON CONFLICT (broadcast_id, user_id) DO NOTHING`, out.ID, selectedUserIDs); err != nil {
|
||||
return fmt.Errorf("insert selected broadcast recipients: %w", err)
|
||||
}
|
||||
default:
|
||||
return domain.ErrBroadcastInvalid
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
|
@ -64,93 +146,217 @@ ON CONFLICT (broadcast_id, user_id) DO NOTHING`, out.ID, userID)
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// PendingBroadcastRecipients returns undelivered outbox rows, oldest first,
|
||||
// each carrying its broadcast's message text.
|
||||
func (s *BroadcastStore) PendingBroadcastRecipients(ctx context.Context, limit int) ([]store.PendingBroadcastRecipient, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
// MaterializeBroadcastRecipients advances one all-user campaign with a
|
||||
// single bounded keyset INSERT, picking whichever "all"-mode campaign still
|
||||
// has enumeration left (oldest first) under FOR UPDATE SKIP LOCKED, so
|
||||
// concurrent worker cycles never step on each other's progress.
|
||||
func (s *BroadcastStore) MaterializeBroadcastRecipients(ctx context.Context, limit int) (int, error) {
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 100
|
||||
}
|
||||
var inserted int
|
||||
err := s.db.QueryRow(ctx, `
|
||||
WITH campaign AS (
|
||||
SELECT id, snapshot_max_user_id, enumeration_cursor_user_id
|
||||
FROM broadcasts
|
||||
WHERE target_mode = 'all' AND NOT enumeration_done
|
||||
ORDER BY id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
), candidates AS (
|
||||
SELECT u.id
|
||||
FROM campaign c
|
||||
JOIN LATERAL (
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE id > c.enumeration_cursor_user_id
|
||||
AND id <= c.snapshot_max_user_id
|
||||
AND NOT is_bot
|
||||
AND deleted_at IS NULL
|
||||
AND id <> ALL($1::bigint[])
|
||||
ORDER BY id
|
||||
LIMIT $2
|
||||
) u ON true
|
||||
), materialized AS (
|
||||
INSERT INTO broadcast_recipients (broadcast_id, user_id)
|
||||
SELECT c.id, candidate.id
|
||||
FROM campaign c
|
||||
CROSS JOIN candidates candidate
|
||||
ON CONFLICT (broadcast_id, user_id) DO NOTHING
|
||||
RETURNING user_id
|
||||
), progress AS (
|
||||
UPDATE broadcasts b
|
||||
SET enumeration_cursor_user_id = COALESCE((SELECT max(id) FROM candidates), b.snapshot_max_user_id),
|
||||
enumeration_done = (SELECT count(*) FROM candidates) < $2,
|
||||
materialized_count = b.materialized_count + (SELECT count(*) FROM materialized),
|
||||
target_count = CASE
|
||||
WHEN (SELECT count(*) FROM candidates) < $2
|
||||
THEN b.materialized_count + (SELECT count(*) FROM materialized)
|
||||
ELSE b.target_count
|
||||
END
|
||||
FROM campaign c
|
||||
WHERE b.id = c.id
|
||||
RETURNING b.id
|
||||
)
|
||||
SELECT count(*)::int FROM materialized`, domain.SystemUserIDs(), limit).Scan(&inserted)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("materialize broadcast recipients: %w", err)
|
||||
}
|
||||
return inserted, nil
|
||||
}
|
||||
|
||||
// ClaimBroadcastRecipients atomically leases up to limit eligible rows
|
||||
// (pending, or processing under an expired lease) to leaseToken, joining
|
||||
// each claim with its broadcast's message and entities.
|
||||
func (s *BroadcastStore) ClaimBroadcastRecipients(ctx context.Context, leaseToken string, limit int, lease time.Duration) ([]store.BroadcastRecipientClaim, error) {
|
||||
if strings.TrimSpace(leaseToken) == "" || len(leaseToken) > 64 {
|
||||
return nil, domain.ErrBroadcastInvalid
|
||||
}
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 50
|
||||
}
|
||||
leaseSeconds := int(lease / time.Second)
|
||||
if leaseSeconds <= 0 || leaseSeconds > 3600 {
|
||||
leaseSeconds = 30
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT r.id, r.broadcast_id, r.user_id, r.attempts, b.message
|
||||
FROM broadcast_recipients r
|
||||
JOIN broadcasts b ON b.id = r.broadcast_id
|
||||
WHERE r.status = 'pending'
|
||||
ORDER BY r.id
|
||||
LIMIT $1`, limit)
|
||||
WITH candidates AS (
|
||||
SELECT id
|
||||
FROM broadcast_recipients
|
||||
WHERE (status = 'pending' AND next_attempt_at <= now())
|
||||
OR (status = 'processing' AND lease_until <= now())
|
||||
ORDER BY id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT $1
|
||||
), claimed AS (
|
||||
UPDATE broadcast_recipients r
|
||||
SET status = 'processing',
|
||||
attempts = attempts + 1,
|
||||
lease_token = $2,
|
||||
lease_until = now() + make_interval(secs => $3),
|
||||
updated_at = now()
|
||||
FROM candidates c
|
||||
WHERE r.id = c.id
|
||||
RETURNING r.id, r.broadcast_id, r.user_id, r.attempts
|
||||
)
|
||||
SELECT c.id, c.broadcast_id, c.user_id, c.attempts, b.message, b.entities::text
|
||||
FROM claimed c
|
||||
JOIN broadcasts b ON b.id = c.broadcast_id
|
||||
ORDER BY c.id`, limit, leaseToken, leaseSeconds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list pending broadcast recipients: %w", err)
|
||||
return nil, fmt.Errorf("claim broadcast recipients: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]store.PendingBroadcastRecipient, 0, limit)
|
||||
out := make([]store.BroadcastRecipientClaim, 0, limit)
|
||||
for rows.Next() {
|
||||
var item store.PendingBroadcastRecipient
|
||||
if err := rows.Scan(&item.RecipientID, &item.BroadcastID, &item.UserID, &item.Attempts, &item.Message); err != nil {
|
||||
return nil, fmt.Errorf("scan pending broadcast recipient: %w", err)
|
||||
var item store.BroadcastRecipientClaim
|
||||
var entitiesJSON string
|
||||
item.LeaseToken = leaseToken
|
||||
if err := rows.Scan(&item.RecipientID, &item.BroadcastID, &item.UserID, &item.Attempts, &item.Message, &entitiesJSON); err != nil {
|
||||
return nil, fmt.Errorf("scan broadcast recipient claim: %w", err)
|
||||
}
|
||||
entities, err := decodeMessageEntities(entitiesJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode broadcast recipient claim entities: %w", err)
|
||||
}
|
||||
item.Entities = entities
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate pending broadcast recipients: %w", err)
|
||||
return nil, fmt.Errorf("iterate broadcast recipient claims: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MarkBroadcastRecipientSent closes a recipient row as delivered. Closing an
|
||||
// already-closed row is a no-op: the outbox is exactly-once, not
|
||||
// at-least-once.
|
||||
func (s *BroadcastStore) MarkBroadcastRecipientSent(ctx context.Context, recipientID int64) error {
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
// CompleteBroadcastRecipient closes a claimed row as delivered and advances
|
||||
// its broadcast's sent_count in the same transaction.
|
||||
func (s *BroadcastStore) CompleteBroadcastRecipient(ctx context.Context, claim store.BroadcastRecipientClaim, privateMessageID int64, messageBoxID int, pts int) error {
|
||||
return withTx(ctx, s.db, "complete broadcast recipient", func(tx pgx.Tx) error {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE broadcast_recipients
|
||||
SET status = 'sent', sent_at = now(), last_error = ''
|
||||
WHERE id = $1 AND status = 'pending'`, recipientID); err != nil {
|
||||
return fmt.Errorf("mark broadcast recipient sent: %w", err)
|
||||
SET status = 'sent', lease_token = '', lease_until = NULL,
|
||||
last_error = '', private_message_id = $3, message_box_id = $4,
|
||||
pts = $5, sent_at = now(), updated_at = now()
|
||||
WHERE id = $1 AND status = 'processing' AND lease_token = $2`,
|
||||
claim.RecipientID, claim.LeaseToken, privateMessageID, messageBoxID, pts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete broadcast recipient: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return domain.ErrBroadcastLeaseLost
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE broadcasts SET sent_count = sent_count + 1 WHERE id = $1`, claim.BroadcastID); err != nil {
|
||||
return fmt.Errorf("advance broadcast sent count: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ReleaseBroadcastRecipient returns a claimed row to 'pending' with backoff,
|
||||
// or to the terminal 'failed' once domain.MaxBroadcastRecipientAttempts is
|
||||
// reached, advancing failed_count in that terminal case.
|
||||
func (s *BroadcastStore) ReleaseBroadcastRecipient(ctx context.Context, claim store.BroadcastRecipientClaim, cause string) error {
|
||||
if len(cause) > 500 {
|
||||
cause = cause[:500]
|
||||
}
|
||||
_, err := s.db.Exec(ctx, `
|
||||
WITH changed AS (
|
||||
UPDATE broadcast_recipients
|
||||
SET status = CASE WHEN attempts >= $3 THEN 'failed' ELSE 'pending' END,
|
||||
next_attempt_at = CASE
|
||||
WHEN attempts >= $3 THEN next_attempt_at
|
||||
ELSE now() + make_interval(secs => LEAST(300, (1 << LEAST(attempts, 8))))
|
||||
END,
|
||||
lease_token = '',
|
||||
lease_until = NULL,
|
||||
last_error = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
AND status = 'processing'
|
||||
AND lease_token = $2
|
||||
RETURNING broadcast_id, status
|
||||
)
|
||||
UPDATE broadcasts b
|
||||
SET failed_count = failed_count + 1
|
||||
FROM changed c
|
||||
WHERE b.id = c.broadcast_id AND c.status = 'failed'`, claim.RecipientID, claim.LeaseToken, domain.MaxBroadcastRecipientAttempts, cause)
|
||||
if err != nil {
|
||||
return fmt.Errorf("release broadcast recipient: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkBroadcastRecipientFailed records a failed delivery attempt. The row
|
||||
// stays 'pending' (retried on the next cycle) until attempts reaches
|
||||
// domain.MaxBroadcastRecipientAttempts, at which point it becomes the
|
||||
// terminal 'failed' status so a permanently blocked/deleted recipient
|
||||
// doesn't spin forever alongside real deliveries.
|
||||
func (s *BroadcastStore) MarkBroadcastRecipientFailed(ctx context.Context, recipientID int64, reason string) error {
|
||||
if len(reason) > 500 {
|
||||
reason = reason[:500]
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
UPDATE broadcast_recipients
|
||||
SET attempts = attempts + 1,
|
||||
last_error = $2,
|
||||
status = CASE WHEN attempts + 1 >= $3 THEN 'failed' ELSE 'pending' END
|
||||
WHERE id = $1 AND status = 'pending'`, recipientID, reason, domain.MaxBroadcastRecipientAttempts); err != nil {
|
||||
return fmt.Errorf("mark broadcast recipient failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const broadcastSelectColumns = `
|
||||
b.id, b.message, b.target_mode, b.total_count, b.created_by, b.created_at,
|
||||
count(*) FILTER (WHERE r.status = 'sent')::int AS sent_count,
|
||||
count(*) FILTER (WHERE r.status = 'failed')::int AS failed_count`
|
||||
const broadcastColumns = `
|
||||
id, message, entities::text, target_mode, target_count, materialized_count,
|
||||
sent_count, failed_count, enumeration_done, created_by, created_at`
|
||||
|
||||
func scanBroadcastRow(row interface{ Scan(...any) error }, item *domain.Broadcast) error {
|
||||
return row.Scan(&item.ID, &item.Message, &item.TargetMode, &item.TotalCount, &item.CreatedBy, &item.CreatedAt,
|
||||
&item.SentCount, &item.FailedCount)
|
||||
var entitiesJSON string
|
||||
if err := row.Scan(&item.ID, &item.Message, &entitiesJSON, &item.TargetMode, &item.TargetCount, &item.MaterializedCount,
|
||||
&item.SentCount, &item.FailedCount, &item.EnumerationDone, &item.CreatedBy, &item.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
entities, err := decodeMessageEntities(entitiesJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode broadcast entities: %w", err)
|
||||
}
|
||||
item.Entities = entities
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListBroadcasts pages broadcasts newest-first, each with sent/failed counts
|
||||
// derived live from its recipient rows (never stored, so they can't drift).
|
||||
// ListBroadcasts pages broadcasts newest-first.
|
||||
func (s *BroadcastStore) ListBroadcasts(ctx context.Context, beforeID int64, limit int) ([]domain.Broadcast, bool, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT `+broadcastSelectColumns+`
|
||||
FROM broadcasts b
|
||||
LEFT JOIN broadcast_recipients r ON r.broadcast_id = b.id
|
||||
WHERE $1::bigint = 0 OR b.id < $1
|
||||
GROUP BY b.id
|
||||
ORDER BY b.id DESC
|
||||
rows, err := s.db.Query(ctx, `SELECT `+broadcastColumns+`
|
||||
FROM broadcasts
|
||||
WHERE $1::bigint = 0 OR id < $1
|
||||
ORDER BY id DESC
|
||||
LIMIT $2`, beforeID, limit+1)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("list broadcasts: %w", err)
|
||||
|
|
@ -174,17 +380,14 @@ LIMIT $2`, beforeID, limit+1)
|
|||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
// BroadcastByID returns one broadcast with derived counts.
|
||||
// BroadcastByID returns one broadcast.
|
||||
func (s *BroadcastStore) BroadcastByID(ctx context.Context, id int64) (domain.Broadcast, bool, error) {
|
||||
var item domain.Broadcast
|
||||
err := scanBroadcastRow(s.db.QueryRow(ctx, `
|
||||
SELECT `+broadcastSelectColumns+`
|
||||
FROM broadcasts b
|
||||
LEFT JOIN broadcast_recipients r ON r.broadcast_id = b.id
|
||||
WHERE b.id = $1
|
||||
GROUP BY b.id`, id), &item)
|
||||
err := scanBroadcastRow(s.db.QueryRow(ctx, `SELECT `+broadcastColumns+`
|
||||
FROM broadcasts
|
||||
WHERE id = $1`, id), &item)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Broadcast{}, false, nil
|
||||
}
|
||||
return domain.Broadcast{}, false, fmt.Errorf("get broadcast: %w", err)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/deploy"
|
||||
)
|
||||
|
||||
const (
|
||||
broadcastLeaseDeliveryMigrationUp = "migrations/20260901000024_broadcast_lease_delivery_and_entities.up.sql"
|
||||
broadcastLeaseDeliveryMigrationDown = "migrations/20260901000024_broadcast_lease_delivery_and_entities.down.sql"
|
||||
)
|
||||
|
||||
// TestBroadcastLeaseDeliveryMigrationBackfillsLegacyDataPostgres proves the
|
||||
// ALTER-based migration in
|
||||
// deploy/migrations/20260901000024_broadcast_lease_delivery_and_entities.up.sql
|
||||
// applies cleanly against pre-existing broadcasts/broadcast_recipients rows
|
||||
// shaped by the original 20260714003131_system_broadcasts.up.sql schema --
|
||||
// specifically 'sent' recipient rows that predate private_message_id/
|
||||
// message_box_id/pts tracking, which the new sent-tracking CHECK constraint
|
||||
// must accept as a legitimate legacy case rather than reject.
|
||||
func TestBroadcastLeaseDeliveryMigrationBackfillsLegacyDataPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
upSQL, err := deploy.Migrations.ReadFile(broadcastLeaseDeliveryMigrationUp)
|
||||
if err != nil {
|
||||
t.Fatalf("read up migration: %v", err)
|
||||
}
|
||||
downSQL, err := deploy.Migrations.ReadFile(broadcastLeaseDeliveryMigrationDown)
|
||||
if err != nil {
|
||||
t.Fatalf("read down migration: %v", err)
|
||||
}
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin broadcast lease delivery migration test: %v", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.Background()) }()
|
||||
|
||||
// Return the schema to its pre-migration (20260714003131) shape.
|
||||
if _, err := tx.Exec(ctx, string(downSQL)); err != nil {
|
||||
t.Fatalf("revert broadcast lease delivery migration: %v", err)
|
||||
}
|
||||
|
||||
// Seed fixtures shaped exactly like production rows created before this
|
||||
// migration existed: a broadcast with only total_count, and recipient
|
||||
// rows in every legacy status -- including a 'sent' row that carries no
|
||||
// delivery-identifier tracking at all, since that tracking didn't exist
|
||||
// yet when it was created.
|
||||
var broadcastID int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO public.broadcasts (message, target_mode, total_count, created_by)
|
||||
VALUES ('legacy campaign', 'selected', 3, 'legacy-admin')
|
||||
RETURNING id`).Scan(&broadcastID); err != nil {
|
||||
t.Fatalf("insert legacy broadcast fixture: %v", err)
|
||||
}
|
||||
rows := []struct {
|
||||
userID int64
|
||||
status string
|
||||
}{
|
||||
{userID: 9_100_000_000_030_001, status: "sent"},
|
||||
{userID: 9_100_000_000_030_002, status: "pending"},
|
||||
{userID: 9_100_000_000_030_003, status: "failed"},
|
||||
}
|
||||
for _, r := range rows {
|
||||
var sentAtClause string
|
||||
if r.status == "sent" {
|
||||
sentAtClause = ", sent_at = now()"
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.broadcast_recipients (broadcast_id, user_id, status)
|
||||
VALUES ($1, $2, $3)`, broadcastID, r.userID, r.status); err != nil {
|
||||
t.Fatalf("insert legacy recipient fixture (status=%s): %v", r.status, err)
|
||||
}
|
||||
if sentAtClause != "" {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE public.broadcast_recipients SET sent_at = now() WHERE broadcast_id = $1 AND user_id = $2`, broadcastID, r.userID); err != nil {
|
||||
t.Fatalf("stamp legacy sent_at fixture: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-apply the migration under test. This must not fail against the
|
||||
// legacy 'sent' row above (private_message_id/message_box_id/pts all
|
||||
// still at their just-added zero default).
|
||||
if _, err := tx.Exec(ctx, string(upSQL)); err != nil {
|
||||
t.Fatalf("apply broadcast lease delivery migration over legacy data: %v", err)
|
||||
}
|
||||
|
||||
var targetCount, materializedCount, sentCount, failedCount int64
|
||||
var enumerationDone bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT target_count, materialized_count, sent_count, failed_count, enumeration_done
|
||||
FROM public.broadcasts WHERE id = $1`, broadcastID).Scan(&targetCount, &materializedCount, &sentCount, &failedCount, &enumerationDone); err != nil {
|
||||
t.Fatalf("read migrated broadcast: %v", err)
|
||||
}
|
||||
if targetCount != 3 {
|
||||
t.Fatalf("target_count = %d, want 3 (renamed from total_count)", targetCount)
|
||||
}
|
||||
if materializedCount != 3 {
|
||||
t.Fatalf("materialized_count = %d, want 3 (backfilled from target_count)", materializedCount)
|
||||
}
|
||||
if sentCount != 1 {
|
||||
t.Fatalf("sent_count = %d, want 1 (backfilled from recipient rows)", sentCount)
|
||||
}
|
||||
if failedCount != 1 {
|
||||
t.Fatalf("failed_count = %d, want 1 (backfilled from recipient rows)", failedCount)
|
||||
}
|
||||
if !enumerationDone {
|
||||
t.Fatalf("enumeration_done = false, want true (pre-existing campaigns were fully enumerated at creation)")
|
||||
}
|
||||
|
||||
var status string
|
||||
var privateMessageID int64
|
||||
var messageBoxID, pts int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT status, private_message_id, message_box_id, pts
|
||||
FROM public.broadcast_recipients
|
||||
WHERE broadcast_id = $1 AND user_id = $2`, broadcastID, rows[0].userID).Scan(&status, &privateMessageID, &messageBoxID, &pts); err != nil {
|
||||
t.Fatalf("read migrated legacy sent recipient: %v", err)
|
||||
}
|
||||
if status != "sent" || privateMessageID != 0 || messageBoxID != 0 || pts != 0 {
|
||||
t.Fatalf("legacy sent recipient = status=%q private_message_id=%d message_box_id=%d pts=%d, want sent/0/0/0 (untracked legacy case accepted)",
|
||||
status, privateMessageID, messageBoxID, pts)
|
||||
}
|
||||
|
||||
// A properly-tracked 'sent' row (what new code always writes, via
|
||||
// CompleteBroadcastRecipient) must also satisfy the CHECK.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.broadcast_recipients (broadcast_id, user_id, status, sent_at, private_message_id, message_box_id, pts)
|
||||
VALUES ($1, $2, 'sent', now(), 1, 1, 1)`, broadcastID, int64(9_100_000_000_030_099)); err != nil {
|
||||
t.Fatalf("insert of a properly-tracked 'sent' row failed: %v", err)
|
||||
}
|
||||
|
||||
// But a 'sent' row with only some tracking columns populated -- neither
|
||||
// the legacy all-zero case nor the fully-tracked case -- must still be
|
||||
// rejected.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.broadcast_recipients (broadcast_id, user_id, status, sent_at, private_message_id)
|
||||
VALUES ($1, $2, 'sent', now(), 1)`, broadcastID, int64(9_100_000_000_030_098)); err == nil {
|
||||
t.Fatalf("insert of a partially-tracked 'sent' row unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue