This commit is contained in:
onysd 2026-09-01 12:50:18 +03:00
parent 21a0856587
commit e8dc967e6a
26 changed files with 1373 additions and 481 deletions

View file

@ -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)

View file

@ -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")
}
}