mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-08 08:02:13 +00:00
209 lines
5.9 KiB
Go
209 lines
5.9 KiB
Go
package placement
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/warmbly/warmbly/internal/config"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/observability/errs"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
// Tick is one pass of the poller: read verdicts, expire what never arrived,
|
|
// sync the cloud's panel, close finished tests, start due monitors and
|
|
// advance batches.
|
|
func (s *service) Tick(ctx context.Context) error {
|
|
now := s.now()
|
|
touched := map[uuid.UUID]bool{}
|
|
|
|
landings, err := s.Repo.FindLandings(ctx, 500)
|
|
if err != nil {
|
|
errs.CaptureException(err)
|
|
return err
|
|
}
|
|
for _, l := range landings {
|
|
folder := models.ClassifyPlacementLanding(l.Folder, l.Flags)
|
|
if err := s.Repo.RecordLanding(ctx, l.ResultID, folder, strings.Join(l.Flags, ","), now); err != nil {
|
|
errs.CaptureException(err)
|
|
continue
|
|
}
|
|
touched[l.TestID] = true
|
|
}
|
|
|
|
timeout := now.Add(-time.Duration(config.PlacementClassifyTimeoutMinutes) * time.Minute)
|
|
// An instance reports failed and cancelled copies itself; this only
|
|
// catches one that went away. The longest test the settings allow (100
|
|
// seeds, twice, 600 seconds apart) sends in about 33 hours.
|
|
if err := s.Repo.ExpireProbes(ctx, timeout, timeout, now.Add(-48*time.Hour)); err != nil {
|
|
errs.CaptureException(err)
|
|
}
|
|
|
|
for id := range s.syncCloud(ctx) {
|
|
touched[id] = true
|
|
}
|
|
|
|
finished, err := s.Repo.FinishTests(ctx)
|
|
if err != nil {
|
|
errs.CaptureException(err)
|
|
}
|
|
if err := s.Repo.PruneRenders(ctx); err != nil {
|
|
errs.CaptureException(err)
|
|
}
|
|
notifiedGroups := map[uuid.UUID]bool{}
|
|
for _, f := range finished {
|
|
delete(touched, f.ID)
|
|
if f.OrganizationID != nil && s.Publisher != nil {
|
|
s.Publisher.PublishPlacementTest(ctx, *f.OrganizationID, f.ID, f.CampaignID, f.Status)
|
|
}
|
|
s.onFinished(ctx, f, notifiedGroups)
|
|
}
|
|
for id := range touched {
|
|
if t, err := s.Repo.GetTest(ctx, id); err == nil && t != nil {
|
|
s.publish(ctx, t)
|
|
}
|
|
}
|
|
|
|
for _, id := range s.settleCredits(ctx) {
|
|
if t, err := s.Repo.GetTest(ctx, id); err == nil && t != nil {
|
|
s.publish(ctx, t)
|
|
}
|
|
}
|
|
|
|
s.runMonitors(ctx)
|
|
s.runBatches(ctx)
|
|
return nil
|
|
}
|
|
|
|
// settleCredits decides every finished paid test once: one that delivered
|
|
// no copy gets its credits back, one that delivered keeps the charge. The
|
|
// ledger refund is keyed, so a pass that dies before the stamp repeats the
|
|
// stamp and never the refund.
|
|
func (s *service) settleCredits(ctx context.Context) []uuid.UUID {
|
|
if s.Credits == nil {
|
|
return nil
|
|
}
|
|
open, err := s.Repo.UnsettledPaidTests(ctx, 100)
|
|
if err != nil {
|
|
errs.CaptureException(err)
|
|
return nil
|
|
}
|
|
var refunded []uuid.UUID
|
|
for _, r := range open {
|
|
back := 0
|
|
if !r.Delivered {
|
|
// Attributed like the charge, so it reads as the same member's.
|
|
meta := models.CreditMeta{Context: models.CreditContext{Detail: "Placement test refund"}}
|
|
if r.CreatedBy != nil {
|
|
meta.ActorID = *r.CreatedBy
|
|
}
|
|
if back, err = s.Credits.RefundCharge(models.WithCreditMeta(ctx, meta), r.OrganizationID, chargeKey(r.ID), "placement_test_refund"); err != nil {
|
|
errs.CaptureException(err)
|
|
continue
|
|
}
|
|
}
|
|
if ok, err := s.Repo.SettleCredits(ctx, r.ID, back); err != nil {
|
|
errs.CaptureException(err)
|
|
} else if ok && !r.Delivered {
|
|
refunded = append(refunded, r.ID)
|
|
}
|
|
}
|
|
return refunded
|
|
}
|
|
|
|
// onFinished tells whoever started a test where it landed, and checks a
|
|
// monitor's test against its threshold. A tracking comparison reports once,
|
|
// when both halves are done.
|
|
func (s *service) onFinished(ctx context.Context, f repository.PlacementFinished, notifiedGroups map[uuid.UUID]bool) {
|
|
if f.OrganizationID == nil || f.Status == models.PlacementStatusCancelled {
|
|
return
|
|
}
|
|
ids := []uuid.UUID{f.ID}
|
|
var tests []models.PlacementTest
|
|
if f.CompareGroupID != nil {
|
|
if notifiedGroups[*f.CompareGroupID] {
|
|
return
|
|
}
|
|
group, err := s.Repo.ListGroup(ctx, *f.OrganizationID, *f.CompareGroupID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, t := range group {
|
|
if t.FinishedAt == nil && t.ID != f.ID {
|
|
return
|
|
}
|
|
}
|
|
notifiedGroups[*f.CompareGroupID] = true
|
|
tests = group
|
|
ids = ids[:0]
|
|
for _, t := range group {
|
|
ids = append(ids, t.ID)
|
|
}
|
|
} else {
|
|
t, err := s.Repo.GetTest(ctx, f.ID)
|
|
if err != nil || t == nil {
|
|
return
|
|
}
|
|
tests = []models.PlacementTest{*t}
|
|
}
|
|
results, err := s.Repo.ListResults(ctx, ids)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
var headline models.PlacementCounts
|
|
lines := make([]string, 0, len(tests))
|
|
for _, t := range tests {
|
|
v := s.view(t, results[t.ID], false)
|
|
if len(tests) > 1 {
|
|
label := "Without tracking"
|
|
if t.Tracked() {
|
|
label = "With tracking"
|
|
}
|
|
lines = append(lines, label+": "+summaryLine(v.Summary))
|
|
} else {
|
|
lines = append(lines, summaryLine(v.Summary))
|
|
}
|
|
// The alert reads the copy the campaign really sends.
|
|
if len(tests) == 1 || t.Tracked() {
|
|
headline = v.Summary
|
|
}
|
|
}
|
|
link := "/app/placement/" + f.ID.String()
|
|
body := strings.Join(lines, "\n")
|
|
|
|
switch f.Origin {
|
|
case models.PlacementOriginManual:
|
|
if f.CreatedBy != nil && s.Notifier != nil {
|
|
s.Notifier.Notify(ctx, *f.CreatedBy, f.OrganizationID, models.NotifPlacementFinished,
|
|
"Placement test finished", body, link, map[string]any{"placement_test_id": f.ID.String()})
|
|
}
|
|
case models.PlacementOriginMonitor:
|
|
if f.MonitorID != nil {
|
|
s.checkMonitorAlert(ctx, *f.MonitorID, *f.OrganizationID, f.CampaignID, headline, body, link)
|
|
}
|
|
}
|
|
}
|
|
|
|
// summaryLine reads a test in one sentence.
|
|
func summaryLine(c models.PlacementCounts) string {
|
|
if c.Delivered == 0 {
|
|
return "No copy reached a seed inbox."
|
|
}
|
|
return fmt.Sprintf("%d of %d copies reached the inbox, %d a Gmail tab, %d spam and %d never arrived.",
|
|
c.Inbox, c.Delivered, c.Promotions+c.Other, c.Spam, c.Missing)
|
|
}
|
|
|
|
// percent is a rate as a whole percentage, -1 when there is none.
|
|
func percent(r *float64) int {
|
|
if r == nil {
|
|
return -1
|
|
}
|
|
return int(math.Round(*r * 100))
|
|
}
|