fix: sync non-PTS moderation updates
This commit is contained in:
parent
cc76cd3679
commit
70e57b4d07
15 changed files with 331 additions and 378 deletions
|
|
@ -10,7 +10,6 @@ import (
|
|||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
func (s *ChannelStore) EditChannelTitle(ctx context.Context, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error) {
|
||||
|
|
@ -338,34 +337,6 @@ FOR UPDATE`, channelID).Scan(¤tScam, ¤tFake); err != nil {
|
|||
if _, err := tx.Exec(ctx, `UPDATE channels SET scam = $2, fake = $3, updated_at = now() WHERE id = $1 AND NOT deleted`, channelID, scam, fake); err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("set channel scam/fake: %w", err)
|
||||
}
|
||||
audience, err := channelModerationFlagAudience(ctx, tx, channelID, maxModerationFlagAudience)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
qtx := sqlcgen.New(tx)
|
||||
date := nowUnix()
|
||||
for _, viewerUserID := range audience {
|
||||
pts, err := reserveUserPts(ctx, tx, viewerUserID, 1)
|
||||
if err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("reserve channel moderation flag pts for viewer %d: %w", viewerUserID, err)
|
||||
}
|
||||
event := domain.UpdateEvent{
|
||||
UserID: viewerUserID,
|
||||
Type: domain.UpdateEventChannelState,
|
||||
Pts: pts, PtsCount: 1, Date: date,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID},
|
||||
}
|
||||
if err := appendUserUpdateEvent(ctx, tx, qtx, viewerUserID, event); err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("append channel moderation flag event for viewer %d: %w", viewerUserID, err)
|
||||
}
|
||||
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
|
||||
TargetUserID: viewerUserID,
|
||||
Pts: int32(pts),
|
||||
EventType: string(event.Type),
|
||||
}); err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("enqueue channel moderation flag dispatch for viewer %d: %w", viewerUserID, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("commit channel scam/fake: %w", err)
|
||||
}
|
||||
|
|
@ -378,37 +349,6 @@ FOR UPDATE`, channelID).Scan(¤tScam, ¤tFake); err != nil {
|
|||
return channel, nil
|
||||
}
|
||||
|
||||
func channelModerationFlagAudience(ctx context.Context, db sqlcgen.DBTX, channelID int64, limit int) ([]int64, error) {
|
||||
if channelID <= 0 || limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT cm.user_id
|
||||
FROM channel_members cm
|
||||
JOIN users u ON u.id = cm.user_id AND u.deleted_at IS NULL
|
||||
WHERE cm.channel_id = $1 AND cm.status = 'active'
|
||||
ORDER BY cm.user_id
|
||||
LIMIT $2`, channelID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list channel moderation flag audience: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]int64, 0)
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
return nil, fmt.Errorf("scan channel moderation flag audience: %w", err)
|
||||
}
|
||||
if userID != 0 {
|
||||
out = append(out, userID)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate channel moderation flag audience: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetChannelAdminSettings applies an admin-direct moderation-settings patch
|
||||
// (no membership/permission checks). nil fields are left unchanged.
|
||||
func (s *ChannelStore) SetChannelAdminSettings(ctx context.Context, channelID int64, patch domain.ChannelAdminSettings) (domain.Channel, error) {
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ func TestModerationFlagsRejectImpossibleStateAtPostgresBoundary(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestUserModerationFlagsCreateDurableViewerProfileEvents(t *testing.T) {
|
||||
func TestUserModerationFlagsDoNotAdvanceAccountPts(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
|
|
@ -72,6 +72,15 @@ func TestUserModerationFlagsCreateDurableViewerProfileEvents(t *testing.T) {
|
|||
}); err != nil {
|
||||
t.Fatalf("save reverse contact: %v", err)
|
||||
}
|
||||
viewers := []domain.User{target, savedTargetViewer, savedByTargetViewer, unrelated}
|
||||
baseline := make(map[int64]int, len(viewers))
|
||||
for _, viewer := range viewers {
|
||||
pts, err := events.MaxContiguousPts(ctx, viewer.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("viewer %d baseline pts: %v", viewer.ID, err)
|
||||
}
|
||||
baseline[viewer.ID] = pts
|
||||
}
|
||||
|
||||
updated, err := users.SetScamFake(ctx, target.ID, true, false)
|
||||
if err != nil {
|
||||
|
|
@ -81,20 +90,16 @@ func TestUserModerationFlagsCreateDurableViewerProfileEvents(t *testing.T) {
|
|||
t.Fatalf("updated flags = scam:%v fake:%v", updated.Scam, updated.Fake)
|
||||
}
|
||||
|
||||
for _, viewer := range []domain.User{target, savedTargetViewer, savedByTargetViewer} {
|
||||
got, err := events.ListAfter(ctx, viewer.ID, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list viewer %d events: %v", viewer.ID, err)
|
||||
for _, viewer := range viewers {
|
||||
pts, err := events.MaxContiguousPts(ctx, viewer.ID)
|
||||
if err != nil || pts != baseline[viewer.ID] {
|
||||
t.Fatalf("viewer %d pts=%d want=%d err=%v", viewer.ID, pts, baseline[viewer.ID], err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Type != domain.UpdateEventUserProfile ||
|
||||
got[0].Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: target.ID}) ||
|
||||
got[0].Pts != 1 || got[0].PtsCount != 1 {
|
||||
t.Fatalf("viewer %d events = %+v", viewer.ID, got)
|
||||
got, err := events.ListAfter(ctx, viewer.ID, baseline[viewer.ID], 10)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("viewer %d moderation events=%+v err=%v", viewer.ID, got, err)
|
||||
}
|
||||
}
|
||||
if got, err := events.ListAfter(ctx, unrelated.ID, 0, 10); err != nil || len(got) != 0 {
|
||||
t.Fatalf("unrelated events = %+v err=%v", got, err)
|
||||
}
|
||||
var outboxCount int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT count(*)
|
||||
|
|
@ -102,33 +107,42 @@ FROM dispatch_outbox
|
|||
WHERE target_user_id = ANY($1::bigint[])
|
||||
AND event_type = 'user_profile'`,
|
||||
[]int64{target.ID, savedTargetViewer.ID, savedByTargetViewer.ID, unrelated.ID},
|
||||
).Scan(&outboxCount); err != nil || outboxCount != 3 {
|
||||
).Scan(&outboxCount); err != nil || outboxCount != 0 {
|
||||
t.Fatalf("profile outbox count=%d err=%v", outboxCount, err)
|
||||
}
|
||||
|
||||
audience, err := users.ModerationFlagAudience(ctx, target.ID, 4096)
|
||||
if err != nil {
|
||||
t.Fatalf("moderation audience: %v", err)
|
||||
}
|
||||
audienceSet := make(map[int64]struct{}, len(audience))
|
||||
for _, userID := range audience {
|
||||
audienceSet[userID] = struct{}{}
|
||||
}
|
||||
for _, viewer := range []domain.User{target, savedTargetViewer, savedByTargetViewer} {
|
||||
if _, ok := audienceSet[viewer.ID]; !ok {
|
||||
t.Fatalf("viewer %d missing from audience %v", viewer.ID, audience)
|
||||
}
|
||||
}
|
||||
if _, ok := audienceSet[unrelated.ID]; ok {
|
||||
t.Fatalf("unrelated viewer included in audience %v", audience)
|
||||
}
|
||||
|
||||
if _, err := users.SetScamFake(ctx, target.ID, true, false); err != nil {
|
||||
t.Fatalf("repeat same flags: %v", err)
|
||||
}
|
||||
for _, viewer := range []domain.User{target, savedTargetViewer, savedByTargetViewer} {
|
||||
got, err := events.ListAfter(ctx, viewer.ID, 0, 10)
|
||||
if err != nil || len(got) != 1 {
|
||||
t.Fatalf("same-state viewer %d events = %+v err=%v", viewer.ID, got, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := users.SetScamFake(ctx, target.ID, false, true); err != nil {
|
||||
t.Fatalf("switch to fake: %v", err)
|
||||
}
|
||||
for _, viewer := range []domain.User{target, savedTargetViewer, savedByTargetViewer} {
|
||||
got, err := events.ListAfter(ctx, viewer.ID, 1, 10)
|
||||
if err != nil || len(got) != 1 || got[0].Pts != 2 ||
|
||||
got[0].Type != domain.UpdateEventUserProfile {
|
||||
t.Fatalf("second viewer %d events = %+v err=%v", viewer.ID, got, err)
|
||||
for _, viewer := range viewers {
|
||||
pts, err := events.MaxContiguousPts(ctx, viewer.ID)
|
||||
if err != nil || pts != baseline[viewer.ID] {
|
||||
t.Fatalf("viewer %d final pts=%d want=%d err=%v", viewer.ID, pts, baseline[viewer.ID], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelModerationFlagsCreateDurableMemberStateEvents(t *testing.T) {
|
||||
func TestChannelModerationFlagsDoNotAdvanceMemberAccountPts(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
|
|
@ -166,17 +180,15 @@ func TestChannelModerationFlagsCreateDurableMemberStateEvents(t *testing.T) {
|
|||
if !updated.Scam || updated.Fake {
|
||||
t.Fatalf("updated flags = scam:%v fake:%v", updated.Scam, updated.Fake)
|
||||
}
|
||||
for _, viewer := range []domain.User{owner, member} {
|
||||
got, err := events.ListAfter(ctx, viewer.ID, baseline[viewer.ID], 10)
|
||||
if err != nil || len(got) != 1 ||
|
||||
got[0].Type != domain.UpdateEventChannelState ||
|
||||
got[0].Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}) ||
|
||||
got[0].Pts != baseline[viewer.ID]+1 || got[0].PtsCount != 1 {
|
||||
t.Fatalf("viewer %d events = %+v err=%v", viewer.ID, got, err)
|
||||
for _, viewer := range []domain.User{owner, member, unrelated} {
|
||||
pts, err := events.MaxContiguousPts(ctx, viewer.ID)
|
||||
if err != nil || pts != baseline[viewer.ID] {
|
||||
t.Fatalf("viewer %d pts=%d want=%d err=%v", viewer.ID, pts, baseline[viewer.ID], err)
|
||||
}
|
||||
got, err := events.ListAfter(ctx, viewer.ID, baseline[viewer.ID], 10)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("viewer %d moderation events=%+v err=%v", viewer.ID, got, err)
|
||||
}
|
||||
}
|
||||
if got, err := events.ListAfter(ctx, unrelated.ID, baseline[unrelated.ID], 10); err != nil || len(got) != 0 {
|
||||
t.Fatalf("unrelated events = %+v err=%v", got, err)
|
||||
}
|
||||
|
||||
var outboxCount int
|
||||
|
|
@ -187,28 +199,20 @@ WHERE target_user_id = ANY($1::bigint[])
|
|||
AND event_type = 'channel_state'
|
||||
AND pts > 0`,
|
||||
[]int64{owner.ID, member.ID, unrelated.ID},
|
||||
).Scan(&outboxCount); err != nil || outboxCount != 2 {
|
||||
).Scan(&outboxCount); err != nil || outboxCount != 0 {
|
||||
t.Fatalf("channel state outbox count=%d err=%v", outboxCount, err)
|
||||
}
|
||||
|
||||
if _, err := channels.SetChannelScamFake(ctx, created.Channel.ID, true, false); err != nil {
|
||||
t.Fatalf("repeat same channel flags: %v", err)
|
||||
}
|
||||
for _, viewer := range []domain.User{owner, member} {
|
||||
got, err := events.ListAfter(ctx, viewer.ID, baseline[viewer.ID], 10)
|
||||
if err != nil || len(got) != 1 {
|
||||
t.Fatalf("same-state viewer %d events = %+v err=%v", viewer.ID, got, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := channels.SetChannelScamFake(ctx, created.Channel.ID, false, true); err != nil {
|
||||
t.Fatalf("switch channel to fake: %v", err)
|
||||
}
|
||||
for _, viewer := range []domain.User{owner, member} {
|
||||
got, err := events.ListAfter(ctx, viewer.ID, baseline[viewer.ID]+1, 10)
|
||||
if err != nil || len(got) != 1 || got[0].Pts != baseline[viewer.ID]+2 ||
|
||||
got[0].Type != domain.UpdateEventChannelState {
|
||||
t.Fatalf("second viewer %d events = %+v err=%v", viewer.ID, got, err)
|
||||
for _, viewer := range []domain.User{owner, member, unrelated} {
|
||||
pts, err := events.MaxContiguousPts(ctx, viewer.ID)
|
||||
if err != nil || pts != baseline[viewer.ID] {
|
||||
t.Fatalf("viewer %d final pts=%d want=%d err=%v", viewer.ID, pts, baseline[viewer.ID], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -391,33 +391,6 @@ FOR UPDATE`, userID).Scan(¤tScam, ¤tFake); err != nil {
|
|||
}
|
||||
return domain.User{}, fmt.Errorf("set user scam/fake: %w", err)
|
||||
}
|
||||
audience, err := moderationFlagAudience(ctx, tx, userID, maxModerationFlagAudience)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
date := int(time.Now().Unix())
|
||||
for _, viewerUserID := range audience {
|
||||
pts, err := reserveUserPts(ctx, tx, viewerUserID, 1)
|
||||
if err != nil {
|
||||
return domain.User{}, fmt.Errorf("reserve moderation flag pts for viewer %d: %w", viewerUserID, err)
|
||||
}
|
||||
event := domain.UpdateEvent{
|
||||
UserID: viewerUserID,
|
||||
Type: domain.UpdateEventUserProfile,
|
||||
Pts: pts, PtsCount: 1, Date: date,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID},
|
||||
}
|
||||
if err := appendUserUpdateEvent(ctx, tx, qtx, viewerUserID, event); err != nil {
|
||||
return domain.User{}, fmt.Errorf("append moderation flag event for viewer %d: %w", viewerUserID, err)
|
||||
}
|
||||
if err := enqueueDispatch(ctx, qtx, sqlcgen.EnqueueDispatchParams{
|
||||
TargetUserID: viewerUserID,
|
||||
Pts: int32(pts),
|
||||
EventType: string(event.Type),
|
||||
}); err != nil {
|
||||
return domain.User{}, fmt.Errorf("enqueue moderation flag dispatch for viewer %d: %w", viewerUserID, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.User{}, fmt.Errorf("commit user scam/fake: %w", err)
|
||||
}
|
||||
|
|
@ -427,10 +400,17 @@ FOR UPDATE`, userID).Scan(¤tScam, ¤tFake); err != nil {
|
|||
|
||||
const maxModerationFlagAudience = 4096
|
||||
|
||||
// moderationFlagAudience returns the bounded set of accounts that can already
|
||||
// observe the target through a direct contact or private dialog. The final
|
||||
// user_id ordering is deliberate: concurrent moderation changes acquire user
|
||||
// pts watermarks in the same global order and cannot deadlock by target order.
|
||||
// ModerationFlagAudience returns the bounded set of accounts that can already
|
||||
// observe the target through a direct contact or private dialog. It is used
|
||||
// only for best-effort, non-PTS updateUser fanout after the authoritative flag
|
||||
// mutation commits.
|
||||
func (s *UserStore) ModerationFlagAudience(ctx context.Context, userID int64, limit int) ([]int64, error) {
|
||||
if limit > maxModerationFlagAudience {
|
||||
limit = maxModerationFlagAudience
|
||||
}
|
||||
return moderationFlagAudience(ctx, s.db, userID, limit)
|
||||
}
|
||||
|
||||
func moderationFlagAudience(ctx context.Context, db sqlcgen.DBTX, userID int64, limit int) ([]int64, error) {
|
||||
if userID <= 0 || limit <= 0 {
|
||||
return nil, nil
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue