Part 4 · Chapter 4.1
You will produce: A million-message corpus, the analytical query written against Postgres for the first time, and four numbers showing the index that should fix it costs 49% more storage and buys noise · about 55 minutes including the exercise
Source: SRS — Software Requirements Specification · SAD — Software Architecture Document
Part 3 ended with a platform that meters. The quota chapter gave every environment a monthly count of messages sent, of distinct people who sent one, and of connection-minutes held open; it refuses a send when the count crosses a cap, and it emails somebody at fifty, eighty and a hundred per cent. Those counters are correct, they are cheap, and they are on the write path, which is where a counter has to be if a send is going to be refused synchronously.
Now open the SRS at FR-ANL and read what the dashboard is going to ask for. Messages and unique active users per tenant per day. Usage attributable by application, environment, channel and day. Delivery-latency percentiles per tenant per hour. A queryable request log. Charts over selectable ranges up to ninety days.
Ask, of Part 3's counters: which of those can they answer?
One. Am I over my cap this month? The counters hold a single number per environment per period, and they hold it because that is the only number the refusal needs. Every other question in FR-ANL wants the same events sliced a different way, and there is no slicing a scalar.
So this chapter does the obvious thing. It writes the analytical query against the database we already have, at a size worth measuring, and it finds out what that costs. Then it does the thing you are already thinking, which is to add the index, and it measures that too.
Neither result is the one this chapter was planned around, and the chapter is better for it.
The operational question has been the same since chapter 2.2: give me the next
fifty messages in this channel after cursor X. Part 2 shaped messages for
exactly that, and you watched it happen — chapter 2.4 measured a second index,
found UNIQUE (channel_id, sequence) already served the ordering, and migration
0001 dropped the twin. The comment is still there:
// No dedicated (channel_id, sequence DESC) index: DR-01's unique
// constraint above already supplies that ordering, and Postgres walks
// it backward for newest-first pages. Chapter 2.4 measured it and
// migration 0001 dropped the redundant twin (SAD §6.3, amended).
That was right. It is still right. This chapter is where the bill arrives.
flowchart TB
subgraph op["The operational question, and the shape Part 2 gave it"]
q1["give me the next 50 messages<br/>in THIS channel after cursor X"]
i1["UNIQUE (channel_id, sequence)<br/>DR-01 — and chapter 2.4 measured a<br/>second index and dropped it"]
q1 --> i1
end
subgraph an["The analytical question, and the shape it wants"]
q2["messages and unique active users<br/>per ENVIRONMENT per DAY, over 90 days<br/>(FR-ANL-05, FR-ANL-09)"]
i2["ORDER BY (environment_id, ts)<br/>SAD 6.2's ClickHouse table"]
q2 --> i2
end
note["messages carries no environment_id and nothing indexes created_at.<br/>The analytical question has to reach its tenant through a join and<br/>its dates through a scan. Neither table is wrong; they are shaped<br/>for different questions, and one table cannot have both shapes."]
op --- note
an --- noteFR-ANL-05 and FR-ANL-09 want messages and unique active users per environment per day, over ninety days. Written against this schema for the first time, that is:
SELECT date_trunc('day', m.created_at) AS day,
count(*) AS messages,
count(DISTINCT m.user_id) AS active_users
FROM messages m JOIN channels c ON c.id = m.channel_id
WHERE c.environment_id = $1
AND m.created_at >= now() - interval '90 days'
GROUP BY 1;A join and a scan, and both are forced. messages carries no environment_id,
so the tenant is reached through channels. Nothing indexes created_at, so the
date range is a scan. Compare the analytical store the SAD has been carrying
since its first draft: ORDER BY (environment_id, ts) — the two columns the
operational table orders by neither of.
The test lane holds 303,885 messages across 31,685 environments — about ten each. Its busiest environment has 1,018. That is not a scale at which the question means anything, and this project has already been caught by that once: the chapter on what a user sees measured a listing query at 0.87 ms on the lane and 159 ms against a real corpus, and the lane's answer would have settled the question in favour of doing nothing.
So the corpus is built rather than borrowed. scripts/scale/corpus.mjs creates
its own database, migrates it with the platform's own runner, and writes a
million messages into the subject environment's ninety-day window.
Three runs of the query against that corpus: 611.2, 585.9, 586.5 ms. It returns ninety-one rows — one per day — from a million.
The plan is where the chapter turns.
GroupAggregate (actual time=609.731..698.486)
-> Sort (actual time=608.870..656.611)
Sort Method: external merge Disk: 33312kB
-> Hash Join (actual time=6.750..277.774)
-> Seq Scan on messages m (actual time=6.383..140)
-> Seq Scan on channels c (actual time=0.017..0.142)
The scan is about 140 ms. The join takes it to 277. And then the sort takes 656,
spilling 33 MB to disk, because count(DISTINCT user_id) per day has to order a
million rows by (day, user_id) before it can count anything.
The other half of the plan was the send path. The chapter's hypothesis — the one it was planned around — was that an analytical query on the operational database taxes the writes beside it. So the harness runs a send loop at ten a second for sixty seconds, twice: once with the analytical query running continuously beside it, and once alone.
send p95, alone 20.5 ms mean of two control loops
send p95, beside 13.7 ms 102 analytical queries over the window
NFR-PRF-02's target 150 ms
The send path is faster beside a hundred analytical queries than without them, reproducibly, on two different schemas, with four control loops agreeing inside 1.3 ms.
The obvious objection to a 586 ms query is that it has no index. So: add the column the query wants, backfill it, index it, and measure again. On a copy — this never becomes a migration.
ALTER TABLE messages ADD COLUMN environment_id uuid;
UPDATE messages m SET environment_id = c.environment_id
FROM channels c WHERE c.id = m.channel_id;
CREATE INDEX ON messages (environment_id, created_at);The join disappears. The query is rewritten to reach environment_id directly,
and that is worth saying out loud: a faster query that is also a different
query proves less than it looks like it proves.
flowchart LR
subgraph base["baseline — 585.9 ms"]
b1["Seq Scan on messages<br/>~140 ms"] --> b2["Hash Join<br/>277 ms"] --> b3["Sort<br/>external merge, 33 MB<br/>656 ms"] --> b4["GroupAggregate<br/>698 ms"]
end
subgraph cf["with environment_id and its index — 560.2 ms"]
c3["Sort<br/>external merge, 33 MB<br/>577 ms"] --> c4["GroupAggregate<br/>619 ms"]
end
note["The index removes the join. It does not remove the sort, and the<br/>sort is the cost: count(DISTINCT user_id) per day has to order a<br/>million rows by (day, user_id) every time the question is asked.<br/>Across three corpora the gap between the two conditions is smaller<br/>than the gap between two runs of the same condition."]
base --- note
cf --- note run 1 run 2 run 3
baseline 585.9 603.4 -
with the index 560.2 - 552.0
Three corpora of identical volumes. The baseline varies by 17.5 ms between two of them. The gap between the two conditions is 25.7 ms on one pairing and 51.4 ms on another — comparable to the gap between two runs of the same condition. A single pairing would have reported "4.4% faster" as though it were a finding.
The index does not fix the query because the index is not where the time is. It removes a 140 ms scan and a join; it leaves a 656 ms sort exactly where it was, spilling the same 33 MB to disk. No index removes that sort, because the question is a distinct count per day and the rows have to be ordered before they can be counted.
flowchart TB
col["the column<br/>24.8 MB permanent"]
idx["the index<br/>62.0 MB permanent"]
bloat["the rewrite<br/>178.6 MB transient,<br/>reclaimed by VACUUM FULL"]
tbl["the table it is added to<br/>178.6 MB"]
col --> tot["86.8 MB permanent<br/>+49% of the table"]
idx --> tot
tbl -.->|"for comparison"| tot
bloat -.->|"not permanent, and three<br/>earlier versions of this<br/>number included it"| tottable before the column 178.6 MB
after ALTER + backfill 381.9 MB
after VACUUM FULL 203.4 MB
the column 24.8 MB permanent
the index 62.0 MB permanent
the rewrite 178.6 MB transient, reclaimed
86.8 MB permanent on a 178.6 MB table — forty-nine per cent more storage, to buy a difference this measurement cannot separate from noise.
The same query, against the test lane's busiest environment:
rows in environment query days returned
lane, busiest env 1,018 0.9 ms 1
the corpus 1,000,000 585.9 ms 91
A factor of 651. The predecessor's pair was 183, and this one is worse because the lane has grown sideways — more environments, not more messages in any one of them. The lane's answer to this question rounds to nothing, and a reader who measured there would conclude, correctly for their data and wrongly for the product, that there is no problem here at all.
The plan for this chapter said the analytical query would hurt the write path beside it, and that adding the index would fix the query while taxing every write. Neither happened. What the measurement found instead is narrower and harder to argue with:
That is the OLAP argument, arrived at by trying to make a different one and failing. The answer is not a better index. It is a store that maintains the distinct count as rows arrive instead of sorting for it at read time — which is what the next chapter starts building, and why its table is ordered by the two columns this one's is ordered by neither of.