rewrite for perf

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing
2026-09-05 12:18:30 +02:00
parent 0a08a245fa
commit 0c5f7dcf37
3 changed files with 49 additions and 49 deletions
+4 -5
View File
@@ -16,11 +16,10 @@ type CampaignEvent struct {
UserAgent *vo.OptionalString255 `json:"userAgent"`
Data *vo.OptionalString1MB `json:"data"`
Metadata *vo.OptionalString1MB `json:"metadata"`
// AnonymizedID is the pseudonym that links an anonymous campaign's events to one
// another and to the recipient row. It is an internal join key and must never be
// serialized: exposing it would let a reader group a pseudonym's events into a
// per-person journey and re-identify it by matching the send event to a
// recipient. Stats and grouping read it server-side, off the database.
// AnonymizedID is the pseudonym that links an anonymous campaign's events to each
// other and to the recipient row. It must never be sent to a client: a reader with
// it could group one person's events together and work out who they are by matching
// the send event to a recipient. Stats and grouping read it on the server.
AnonymizedID *uuid.UUID `json:"-"`
// if null the recipient has been anonymized
RecipientID *uuid.UUID `json:"recipientID"`
+7 -7
View File
@@ -22,10 +22,10 @@ type CampaignRecipient struct {
LastAttemptAt nullable.Nullable[time.Time] `json:"lastAttemptAt"`
SentAt nullable.Nullable[time.Time] `json:"sentAt"`
SelfManaged nullable.Nullable[bool] `json:"selfManaged"`
// AnonymizedID is the stable pseudonym. It is an internal join key that ties the
// pseudonym to identity while the recipient row still carries a name, so it must
// never be serialized: a reader with it could group a pseudonym's events into a
// per-person journey and re-identify them. Anonymization reads it server-side.
// AnonymizedID is the pseudonym stamped on a recipient's events. It ties those
// events back to identity while the recipient row still has a name, so it must
// never be sent to a client: a reader with it could group one person's events
// together and work out who they are. Only the server reads it.
AnonymizedID nullable.Nullable[uuid.UUID] `json:"-"`
// Sent is a coarse, timing-free send indicator used in place of the exact
// SendAt/SentAt for anonymous campaigns, which are withheld so per-recipient
@@ -36,9 +36,9 @@ type CampaignRecipient struct {
// null recipientID means that the data has been anonymized
RecipientID nullable.Nullable[uuid.UUID] `json:"recipientID"`
Recipient *Recipient `json:"recipient"`
// Position and Department are snapshotted from the recipient at
// materialization for anonymous campaigns so grouped statistics survive after
// the recipient relation is severed.
// Position and Department are copied from the recipient when an anonymous
// campaign's recipient list is built, so the grouped statistics still work after
// the link back to the recipient is removed.
Position nullable.Nullable[string] `json:"position"`
Department nullable.Nullable[string] `json:"department"`
NotableEventID nullable.Nullable[uuid.UUID] `json:"notableEventID"`
+38 -37
View File
@@ -2670,50 +2670,51 @@ func (r *Campaign) GetGroupedResultStats(
var rows []groupRow
// the person key is the pseudonym when present, else the recipient id, so counts
// work for normal, anonymous and anonymized campaigns; the group value is the
// snapshot, falling back to the recipient's attribute. groupColumn is allowlisted
// above, so its interpolation is safe.
// snapshot, falling back to the recipient's attribute. Events are aggregated to
// one row per person first, then joined once to the recipients so the join key is
// a plain column the database can index; joining the raw event rows on the
// COALESCE expression scans every recipient against every event and is quadratic.
// groupColumn is allowlisted above, so its interpolation is safe.
query := fmt.Sprintf(`
SELECT
COALESCE(NULLIF(cr.%[1]s, ''), r.%[1]s) AS grp,
COUNT(DISTINCT COALESCE(cr.anonymized_id, cr.recipient_id)) AS total,
COUNT(DISTINCT CASE WHEN clicked.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS clicked,
COUNT(DISTINCT CASE WHEN submitted.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS submitted,
COUNT(DISTINCT CASE WHEN reported.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS reported,
COUNT(DISTINCT CASE WHEN started.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS training_started,
COUNT(DISTINCT CASE WHEN completed.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS training_completed
FROM campaign_recipients cr
LEFT JOIN recipients r ON r.id = cr.recipient_id
p.grp AS grp,
COUNT(DISTINCT p.person) AS total,
COUNT(DISTINCT CASE WHEN ev.clicked = 1 THEN p.person END) AS clicked,
COUNT(DISTINCT CASE WHEN ev.submitted = 1 THEN p.person END) AS submitted,
COUNT(DISTINCT CASE WHEN ev.reported = 1 THEN p.person END) AS reported,
COUNT(DISTINCT CASE WHEN ev.training_started = 1 THEN p.person END) AS training_started,
COUNT(DISTINCT CASE WHEN ev.training_completed = 1 THEN p.person END) AS training_completed
FROM (
SELECT
COALESCE(cr.anonymized_id, cr.recipient_id) AS person,
COALESCE(NULLIF(cr.%[1]s, ''), r.%[1]s) AS grp
FROM campaign_recipients cr
LEFT JOIN recipients r ON r.id = cr.recipient_id
WHERE cr.campaign_id = ? AND COALESCE(cr.anonymized_id, cr.recipient_id) IS NOT NULL
) AS p
LEFT JOIN (
SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events
WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id IN (?, ?, ?)
) AS clicked ON clicked.pk = COALESCE(cr.anonymized_id, cr.recipient_id)
LEFT JOIN (
SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events
WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id = ?
) AS submitted ON submitted.pk = COALESCE(cr.anonymized_id, cr.recipient_id)
LEFT JOIN (
SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events
WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id = ?
) AS reported ON reported.pk = COALESCE(cr.anonymized_id, cr.recipient_id)
LEFT JOIN (
SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events
WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id = ?
) AS started ON started.pk = COALESCE(cr.anonymized_id, cr.recipient_id)
LEFT JOIN (
SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events
WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id = ?
) AS completed ON completed.pk = COALESCE(cr.anonymized_id, cr.recipient_id)
WHERE cr.campaign_id = ? AND COALESCE(cr.anonymized_id, cr.recipient_id) IS NOT NULL
GROUP BY COALESCE(NULLIF(cr.%[1]s, ''), r.%[1]s)
SELECT
COALESCE(anonymized_id, recipient_id) AS person,
MAX(CASE WHEN event_id IN (?, ?, ?) THEN 1 ELSE 0 END) AS clicked,
MAX(CASE WHEN event_id = ? THEN 1 ELSE 0 END) AS submitted,
MAX(CASE WHEN event_id = ? THEN 1 ELSE 0 END) AS reported,
MAX(CASE WHEN event_id = ? THEN 1 ELSE 0 END) AS training_started,
MAX(CASE WHEN event_id = ? THEN 1 ELSE 0 END) AS training_completed
FROM campaign_events
WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL
GROUP BY COALESCE(anonymized_id, recipient_id)
) AS ev ON ev.person = p.person
GROUP BY p.grp
ORDER BY total DESC
`, groupColumn)
res := r.DB.WithContext(ctx).Raw(query,
campaignID, beforeID, pageID, afterID,
campaignID, submitID,
campaignID, reportID,
campaignID, startedID,
campaignID, completedID,
campaignID,
beforeID, pageID, afterID,
submitID,
reportID,
startedID,
completedID,
campaignID,
).Scan(&rows)
if res.Error != nil {