diff --git a/backend/database/recipientGroupRecipient.go b/backend/database/recipientGroupRecipient.go index 8424b37..aa36fc7 100644 --- a/backend/database/recipientGroupRecipient.go +++ b/backend/database/recipientGroupRecipient.go @@ -2,6 +2,7 @@ package database import ( "github.com/google/uuid" + "gorm.io/gorm" ) const ( @@ -20,3 +21,11 @@ type RecipientGroupRecipient struct { func (RecipientGroupRecipient) TableName() string { return RECIPIENT_GROUP_RECIPIENT_TABLE } + +// Migrate adds an index leading with recipient_group_id. The existing unique index +// leads with recipient_id, so counting or listing a group's members +// (WHERE recipient_group_id = ?) could not use it and scanned the recipients per +// group, making the group list quadratic in recipients times groups. +func (RecipientGroupRecipient) Migrate(db *gorm.DB) error { + return db.Exec(`CREATE INDEX IF NOT EXISTS idx_rgr_group ON recipient_group_recipients(recipient_group_id, recipient_id)`).Error +} diff --git a/backend/service/campaign.go b/backend/service/campaign.go index babf691..4a21c83 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -502,6 +502,36 @@ func (c *Campaign) insertScheduledRecipient( } } +// insertScheduledRecipients writes the materialized campaign recipients. With no +// lure codes it inserts them in one transaction so the rows share a single fsync; +// per row autocommit is fsync bound and made a large campaign exceed the server +// write timeout during create. Lure code campaigns keep the per row path, because a +// code redraw on a conflict runs on a second connection and would block on the +// transaction's own write lock. +func (c *Campaign) insertScheduledRecipients( + ctx context.Context, + allocator *lureCodeAllocator, + campaignRecipients []*model.CampaignRecipient, +) error { + if allocator != nil && allocator.state.enabled { + for _, cr := range campaignRecipients { + if err := c.insertScheduledRecipient(ctx, allocator, cr); err != nil { + return err + } + } + return nil + } + return c.CampaignRecipientRepository.DB.Transaction(func(tx *gorm.DB) error { + txRepo := &repository.CampaignRecipient{DB: tx} + for _, cr := range campaignRecipients { + if _, err := txRepo.Insert(ctx, cr); err != nil { + return err + } + } + return nil + }) +} + // snapshotLureSettings copies the template's lure URL settings and training flag // onto the campaign while it still holds no recipients. // @@ -975,12 +1005,12 @@ func (c *Campaign) schedule( c.Logger.Errorw("failed to allocate lure code", "error", err) return err } - // save - err = c.insertScheduledRecipient(ctx, allocator, campaignRecipients[i]) - if err != nil { - c.Logger.Errorw("failed to create campaign", "error", err) - return errs.Wrap(err) - } + } + // insert all rows in one transaction so a large campaign does not exceed the + // server write timeout from per row fsyncs + if err = c.insertScheduledRecipients(ctx, allocator, campaignRecipients); err != nil { + c.Logger.Errorw("failed to create campaign", "error", err) + return errs.Wrap(err) } err = c.setMostNotableCampaignEvent( ctx,