Review: Product Engagement Query with Window Functions Written by an AI Assistant
The growth team wanted a per-product engagement chart, and an AI coding assistant wrote the query behind it. It opened this pull request. You are the reviewer, and nothing merges without your approval.
The assistant's description:
One query for the growth dashboard: daily active users per product over the last 90 days, a 7-day rolling average, week-on-week change and the percentage of active users who bought that product for the first time that day. Window functions keep it to a single pass over the daily rows, and UK days are used throughout.
Where it runs: PostgreSQL 15, session time zone UTC. events has 2 billion rows partitioned by month; occurred_at and orders.created_at are timestamptz. There are about 40 products; the smaller ones often have days with no events at all. Each order is for one product. A checkout with items from two warehouses creates two orders in the same database transaction. The chart is used in the weekly trading meeting to decide which products get marketing budget.
Your task
- Flag every problem you would block the merge for. Click a line number to comment, and Shift-click to cover a range.
- For each one, choose the issue type and severity, and explain what fails and how to fix it.
- You are scored on the problems you catch, the right issue type and a clear explanation. Missing a critical problem fails the round.
Aim for 15 minutes, as in a real review round.
Compare the OVER clauses on lines 35 and 37. With 40 products, what row is 7 rows before a given row on line 37?
A small product has events on 1, 2 and 10 March only. Which days does the "7-day" average for 10 March include?
Two orders created in the same transaction share now(). What does RANK() give each of them?
What type is COUNT(*), and what is 12 / 300 in that type?
The comment says UK days. What time zone does date_trunc('day', ...) use here?
A strong reviewer blocks this change because the week-on-week column compares different products with each other, and the other columns are skewed enough to move budget decisions.
- LAG has no partition (line 37, critical).
LAG(d.dau, 7) OVER (ORDER BY d.day)runs over all 40 products' rows mixed together, ordered only by day, so the "previous week" value usually belongs to another product, and which one is not even deterministic. Every week-on-week figure is meaningless. AddPARTITION BY d.product_id. - ROWS counts rows, not days (lines 35–36, major). Days with no events produce no row, so for quiet products
6 PRECEDINGreaches back weeks and the average is over the wrong days;LAG(..., 7)has the same flaw. Build a calendar withgenerate_seriescross joined to products and fill missing days with 0, or useRANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW. - Ties in RANK count a buyer twice (lines 20–21, major). Orders created in the same transaction have identical
created_at, so both get rank 1 and the user is counted as two new buyers. UseROW_NUMBER()with a tie-breaker such aso.id. - Integer division (line 38, major).
COUNTreturnsbigint, sobuyers / dauis 0 whenever buyers are fewer than active users, which is almost always. Use100.0 * COALESCE(nb.buyers, 0) / d.dau. - UTC days, not UK days (line 7, major). With the session in UTC,
date_trunc('day', ...)splits days at UTC midnight on lines 7 and 27, so in British Summer Time the first hour of each UK day is counted on the day before. Usedate_trunc('day', e.occurred_at AT TIME ZONE 'Europe/London'), or the three-argumentdate_truncwith the zone. - Partial first day and short first windows (line 10, minor).
now() - interval '90 days'starts part-way through a day, so the first day's count is too low, and the first six rolling averages cover fewer than seven days. Start at a UK midnight, read 6 extra days, and return only the last 90.