Part 4 · Chapter 4.6
You will produce: The rollup DR-10 asks for, after finding that the one already shipped is read by nothing and sits over a table nothing writes — and the measurement that put FR-ANL-09's channel dimension and DR-10's cheap read into separate tables, at 525x the rows and a billing query that touched more of them than the raw events it replaced · about 55 minutes including the exercise
Source: SRS — Software Requirements Specification · SAD — Software Architecture Document
The brief for this chapter is one line in the structure document: "Daily rollup materialised views (DR-10) — billing never scans raw events."
There is already one. analytics/0001_daily_usage.sql shipped with chapter 4.2 — a
SummingMergeTree over message_events, measured there at 13.22 ms against the raw table's
585.9 ms. So the chapter cannot be "build the rollup". Before deciding what it is instead,
we asked two questions of the store.
The first: what reads it?
grep -rln "daily_usage" --include=*.ts --include=*.mjs . | grep -v node_modulesTwo files. One is a comment in the corpus loader. The other is analytics/query.mjs, which
opens with "FR-ANL-05's daily question, asked of the analytical store" — and is referenced
by no package.json script, no service and no config. The only file that has ever asked
the metering question of the analytical store is one nothing runs.
The second question: what writes its source?
for t in message_events api_requests connection_events webhook_attempts; do
echo -n "$t: "
curl -s -X POST http://localhost:8123/ -u relay:relay \
--data-binary "SELECT count() FROM relay_analytics.$t FORMAT TSV"
donemessage_events: 0
api_requests: 11683
connection_events: 154
webhook_attempts: 64
flowchart LR
subgraph producers["Tables with a producer"]
p1["webhook_attempts<br/>64 rows — since 4.3"]
p2["api_requests<br/>11,683 rows — since 4.4"]
p3["connection_events<br/>154 rows — since 4.5"]
end
subgraph orphan["The table the rollup reads"]
o1["message_events<br/>0 rows"]
o2["daily_usage<br/>0 rows"]
o1 --> o2
end
loader["scripts/scale/load-analytics.mjs<br/>a batch loader, run by hand"]
loader -.-> o1
note["message_events occurs in ZERO files under services/.<br/>Three of FR-ANL-05's four quantities come from it, so<br/>DR-10's 'billing never scans raw events' was satisfied<br/>by a rollup over a table that receives no events."]
orphan ~~~ notemessage_events occurs in zero files under services/. Its only writer is
scripts/scale/load-analytics.mjs, a batch script somebody runs by hand. Three of
FR-ANL-05's four metered quantities — messages sent, unique active users, stored message
count — are derived from it.
So DR-10's "billing never scans raw events" has been satisfied, since chapter 4.2, by a rollup over a table that receives no events, read by a script nothing invokes. Both halves conform to the clause. Neither does anything.
FR-ANL-05, whole: "The system shall meter, per tenant per day: messages sent, unique active users, connection-minutes, and stored message count." The shipped view carries the first two. Connection-minutes and the stored count are absent, and chapter 4.5 gave the analytical store its first connection records only one chapter ago.
FR-ANL-09 asks for something orthogonal: "Usage shall be attributable by application,
environment, channel, and day." The shipped view's sorting key is (environment_id, day).
The rollup this chapter builds carries all four quantities and a channel column, fed by two materialised views — one per source table, because a view fires on inserts to exactly one table and no single view can read two.
-- FR-ANL-05's four quantities, per tenant per day, in ONE ROW.
--
-- A TABLE, NOT A VIEW WITH AN INLINE ENGINE, AND THAT IS THE WHOLE REASON THIS FILE EXISTS.
-- Chapter 4.2's `daily_usage` was written as `CREATE MATERIALIZED VIEW ... ENGINE = ...`,
-- which gives it an IMPLICIT inner table (`.inner_id.<uuid>`) and no name a second view can
-- write into. A materialised view fires on inserts to exactly ONE source table, and this
-- chapter's four quantities come from two -- messages and active users from
-- `message_events`, connection-minutes from `connection_events` -- so a single view cannot
-- carry them.
--
-- Measured before anything was written (research R1): two views over two different sources,
-- both `TO` this table, each naming only its own columns. The columns a view omits arrive at
-- their type's zero and `SummingMergeTree` adds them, so one tenant-day ends up as one row.
-- The probe that first "proved" this had both views writing the SAME column and therefore
-- proved nothing about the shape that ships; the re-run is in `baseline.txt`.
CREATE TABLE IF NOT EXISTS relay_analytics.daily_usage_v2
(
environment_id UUID,
-- FR-ANL-09 names four attribution dimensions -- application, environment, channel, day
-- -- and `message_events` carries `channel_id` on the row, so channel costs a key column
-- rather than a join. APPLICATION IS ON NO ROW IN THIS STORE and its mapping lives in
-- Postgres, so it stays an open item rather than becoming a cross-path read.
--
-- A CONNECTION BELONGS TO A TENANT AND NOT TO A CHANNEL, so rows from the connection
-- view carry the zero UUID here. That is a value a reader will meet in the first query
-- they write, which is why it is documented rather than discovered.
channel_id UUID,
day Date,
messages UInt64,
-- AN AGGREGATE STATE, NOT A NUMBER. Written with `uniqState`, read with `uniqMerge`.
-- `Nullable(UUID)` because 046 measured a NULL `user_id` inserted into a non-nullable
-- column becoming the ZERO UUID silently -- one phantom active user per environment,
-- holding a deleted author's messages. 047 then measured `uniqState`/`uniqMerge`
-- ignoring NULL exactly as `uniqExact` does, so the fix survives into the rollup.
--
-- `uniq` IS APPROXIMATE: exact to roughly 60,000-65,000 distinct and off by 0.51% at
-- 70,000. Any figure published from this column states the corpus cardinality beside it,
-- and 047-1 is the record of why that matters against FR-ANL-06's 0.1% bound.
active_users_state AggregateFunction(uniq, Nullable(UUID)),
-- Int64, NOT UInt64, and it is a DELTA rather than a balance.
--
-- The stored message count is the only quantity here that is a stock: every other one is
-- a daily flow. So the column holds the day's change -- +1 created, -1 deleted -- and the
-- stored count is the cumulative sum up to and including a day. DR-17 states the
-- technique for the media analogue in as many words: "summing `media_events` deltas
-- (uploaded/deleted)".
--
-- UNSIGNED WOULD WRAP on any day whose deletions exceed its creations, which is any day
-- a tenant clears a backlog. Measured signed: 2 created, 1 deleted, 1 edited -> 1.
stored_delta Int64,
connection_minutes UInt64
)
ENGINE = SummingMergeTree
PARTITION BY toYYYYMM(day)
ORDER BY (environment_id, channel_id, day)
-- NO TTL, AND THAT IS DELIBERATE -- the same reasoning FR-003a records for `daily_usage`.
-- `message_events` expires at 90 days (DR-09) and metering must not lose history when raw
-- events do.
--
-- IT ALSO WIDENS AN OPEN ITEM. 048-4 carries "the rollup is still unbounded ... no clause
-- says how long metering history is kept", against a key of (environment_id, day). This key
-- adds `channel_id`, so the product becomes environments x channels x days. The item is
-- re-measured in this feature's gaps.md rather than carried forward as a sentence.A table, not a view with an inline engine — that difference is why this file exists at
all. 4.2's rollup was written as CREATE MATERIALIZED VIEW … ENGINE = …, which gives it an
implicit inner table named .inner_id.<uuid> and no name a second view can write into. Two
views cannot feed it, so the four quantities could never have joined it.
stored_delta is Int64 rather than UInt64 because the stored count is the only quantity
here that is a stock: every other one is a daily flow. The column holds a day's change —
+1 for a creation, -1 for a deletion — and the count as of a day is the cumulative sum
up to it. DR-17 states that technique for the media analogue in as many words: "summing
media_events deltas (uploaded/deleted)". Unsigned would wrap on any day a tenant clears a
backlog.
Each view names only its own columns and leaves the rest alone. The message view writes
messages, active_users_state and stored_delta; the connection view writes
connection_minutes and nothing else. SummingMergeTree adds the omitted ones at their
type's zero, so a tenant-day that has both kinds of traffic ends up as one row rather than
two.
That is the shape worth checking rather than assuming, because one of those columns has no
trivial zero. AggregateFunction(uniq, Nullable(UUID)) is an aggregate state, and an insert
through a view that omits it could reasonably have been refused. Measured:
row lands channel_id 00000000-… messages 0 stored_delta 0 connection_minutes 1
uniqMerge(active_users_state) over the omitted column 0, not an error
An omitted aggregate column reads back as an empty state. The whole four-quantity read then
comes out of the two views together — three creations, one deletion and one NULL author
through the message view, one close through the other — as messages 3 · stored 2 · minutes 1 · users 2. The NULL author is ignored rather than counted, which is chapter 4.1's
Nullable(UUID) fix surviving into a rollup fed from two directions.
A connection belongs to a tenant and not to a channel, so rows from the connection view carry
the zero UUID in channel_id. That is the first value a reader meets when they group by
channel, which is why it is in the schema's comments rather than left to be discovered.
With the table and both views applied, the first comparison of the chapter failed.
rollup, all environments 0
computed direct from raw 56
Fifty-six connection-minutes sat in connection_events, and the rollup said zero. The view
was not broken. It had simply never seen them: a materialised view fires on inserts, and
the 154 connection records predated it by a chapter. One new close inserted afterwards
moved the rollup to 2 while the rest stayed at 0 — a control, because a hypothesis about a
view's behaviour is cheap to state and cheap to test.
After the backfill, 56 and 56.
The store holds 154 connection records and no messages, which is enough to prove a view
correct and useless for proving a rollup cheap. DR-10 is a claim about cost, so the claim
needs data: corpus.mjs builds a Postgres corpus and load-analytics.mjs pulls it into the
analytical store.
CORPUS_ENVIRONMENTS=3 CORPUS_MESSAGES=200000 CORPUS_DAYS=91 \
node scripts/scale/corpus.mjs > corpus.json
node scripts/scale/load-analytics.mjs --corpus corpus.json248,155 rows over 91 days and 2,400 channels. Then one tenant's 91-day bill, read from the
server's own accounting in system.query_log:
against daily_usage_v2 (env, channel, day) 32,778 rows · 3.56 MiB · 4 ms
against message_events, the same question 32,768 rows · 1.31 MiB · 3 ms
The rollup read more rows than the raw table it exists to replace. Chapter 4.2 published 315 rows against 1,052,655 for this clause. This is the same clause, one key column wider.
flowchart TB
corpus["248,155 rows · 91 days · 2,400 channels · 3 environments"]
v2["daily_usage_v2<br/>(environment_id, channel_id, day)<br/>147,534 rows"]
bill["daily_usage_billing<br/>(environment_id, day)<br/>281 rows"]
raw["message_events<br/>248,155 rows"]
corpus --> v2
corpus --> bill
corpus --> raw
r1["a tenant's 91-day bill<br/>reads 32,778 rows · 4 ms"]
r2["the same question of the raw table<br/>reads 32,768 rows · 3 ms"]
v2 --> r1
raw --> r2
note["525x the rows, and the table is FULLY MERGED — every row is a<br/>distinct key and OPTIMIZE FINAL changes nothing. A billing read<br/>against it touches more rows than the raw events it replaces.<br/><br/>FR-ANL-09's channel dimension and DR-10's cheap read cannot<br/>share a key. Two rollups, and neither compromises the other."]
r1 ~~~ noteThe first suspicion is an unmerged table — SummingMergeTree holds one physical row per key
per insert until a background merge, so a freshly loaded table is always fat. It was not
that:
distinct (environment_id, channel_id, day) keys 147,534
distinct (environment_id, day) keys 286
channels in the corpus 2,400
Every row is already a distinct key, and OPTIMIZE FINAL changes nothing. The 525× is the
dimension's cost, not compaction debt. A tenant-day total has to sum across that tenant's
channels, and there are 2,400 of them.
So FR-ANL-09's channel dimension and DR-10's cheap read cannot share a key. Both clauses
are satisfiable; one table cannot satisfy both. The chapter ships two rollups — the
channel-keyed one for attribution, and a second keyed (environment_id, day) at 281 rows,
which is what billing reads. The compression tells the whole story in one pair of numbers:
raw message_events rows 248,155
daily_usage_billing (environment_id, day) 281 884x
daily_usage_v2 (env, channel, day) 147,534 1.7x
Chapter 4.1 published 1,000,000 raw rows becoming 89. The billing rollup is in that family. A 1.7× rollup is not a rollup; it is an index with extra steps, and it is exactly right for the question it answers.
import type { ClickHouse } from "./clickhouse.js";
// FR-ANL-05's four quantities, read from rollup rows and from nothing else.
//
// DR-10: "Materialised views shall maintain daily per-tenant rollups for metering, so billing
// never scans raw events." This is the read that clause is about, and before this chapter
// nothing in the platform performed it -- the only file that had ever asked FR-ANL-05's
// question of the analytical store was `analytics/query.mjs`, referenced by no script, no
// service and no config.
//
// IT GOES THROUGH `ClickHouse` RATHER THAN BESIDE IT. That interface was insert-and-count
// until this chapter; a read placed in this directory with its own `fetch` would open a
// second client against the same four environment variables and make "one client per
// service" a sentence rather than a property.
/** One tenant-day's metered totals. */
export interface DailyUsage {
day: string;
messages: number;
activeUsers: number;
connectionMinutes: number;
}
const DB = "relay_analytics";
/** FR-ANL-05 per tenant per day, over a closed period.
*
* `sum()` WITH `GROUP BY` IS THE CONTRACT, NOT A STYLE. `SummingMergeTree` holds one physical
* row per key per insert until a background merge, so a bare `SELECT messages` returns one
* row per insert -- 047 measured it answering `1000 1000 1000` where the truth was 3000. A
* read that is correct only after somebody has run `OPTIMIZE` is right in a demo and wrong in
* production.
*
* `uniqMerge`, NOT `sum`, for active users: the column is an aggregate state, and `uniq` is
* approximate above roughly 60,000-65,000 distinct. Any figure published from it states the
* corpus cardinality beside it.
*
* A DAY WITH NO ACTIVITY IS A MISSING ROW, NEVER A ROW OF ZEROS. A materialised view emits
* nothing for a group that had no input, so the absence of a row is the absence of data --
* not a measurement of zero. The caller fills gaps if it needs a dense series, and this
* function does not pretend to. */
export async function dailyUsage(
store: ClickHouse,
environmentId: string,
from: string,
to: string,
): Promise<DailyUsage[]> {
const rows = await store.query(
`SELECT day,
sum(messages) AS messages,
uniqMerge(active_users_state) AS active_users,
sum(connection_minutes) AS connection_minutes
FROM ${DB}.daily_usage_v2
WHERE environment_id = toUUID('${environmentId}')
AND day BETWEEN '${from}' AND '${to}'
GROUP BY day
ORDER BY day
FORMAT TSV`,
);
// NO FALLBACKS, AND THAT IS A DESIGN RATHER THAN AN OVERSIGHT. `?? ""` and `?? 0` on each
// column looked defensive and measured 50% branches: the SELECT above names four columns,
// so the absent arm cannot arise through the running query and no test can reach it. A
// branch that cannot go both ways is a branch that is never checked -- 4.5 reached
// 100/100/100/100 by deleting one, and this is the same move.
return rows.map((r) => ({
day: String(r[0]),
messages: Number(r[1]),
activeUsers: Number(r[2]),
connectionMinutes: Number(r[3]),
}));
}
/** The stored message count: a BALANCE, where every other quantity is a flow.
*
* NO LOWER BOUND, AND THAT IS THE WHOLE DIFFERENCE. The column holds a day's delta -- +1 for
* a creation, -1 for a deletion -- so the count of messages stored as of a day is the sum of
* every delta up to and including it. A `BETWEEN` here would report the period's CHANGE in
* stored messages, which is a different question that reads as a plausible wrong answer.
*
* DR-17 states the technique for the media analogue: "summing `media_events` deltas
* (uploaded/deleted)". This is the same shape one table over. */
export async function storedMessages(
store: ClickHouse,
environmentId: string,
asOf: string,
): Promise<number> {
const rows = await store.query(
`SELECT sum(stored_delta) FROM ${DB}.daily_usage_v2
WHERE environment_id = toUUID('${environmentId}') AND day <= '${asOf}'
FORMAT TSV`,
);
// NO EMPTY CHECK, BECAUSE THE EMPTY CASE DOES NOT EXIST. A first version guarded
// `rows.length === 0` and carried a comment claiming a test drove both arms; it did not.
// **A bare aggregate with no GROUP BY always returns exactly one row** -- asked of the
// server directly, `sum()` over a tenant with nothing stored answers `0`, not an empty
// result. The guard was unreachable and measured as half this file's branches.
//
// `flat()[0]` rather than `rows[0]?.[0]`: the optional chain is a branch too, and the
// same one. A tenant with no rows would give NaN here if the server could produce one,
// and it cannot.
return Number(rows.flat()[0]);
}That read goes through the store's own client rather than beside it. ClickHouse was an
insert-and-count interface until this chapter — insert, insertRequests,
insertConnections and three counts, with its post() helper a private closure — so a read
placed in that directory with its own fetch would have opened a second client against the
same four environment variables. It gains a method instead, as it has once per chapter since
4.3:
@@ -29,12 +29,24 @@ export interface ClickHouse {
* shapes are three types, and a `table` parameter would let the compiler watch a
* `ConnectionRow` go into `api_requests` without a word. */
insertConnections(rows: ConnectionRow[]): Promise<void>;
count(): Promise<number>;
countRequests(): Promise<number>;
countConnections(): Promise<number>;
+ /** A READ, AND THE FIRST ONE THIS INTERFACE HAS HAD (chapter 4.6).
+ *
+ * Everything above writes or counts. Chapter 4.6 needs to ASK the store a question --
+ * FR-ANL-05's four quantities for a tenant and a period -- and a read placed beside this
+ * file without going through it would open a second client against the same four
+ * environment variables. One client per service is the argument 4.5 made for NATS, and it
+ * is a property of the code only if the read comes through here.
+ *
+ * Rows as TSV lines, split by tab. The caller shapes them: this interface has refused a
+ * `table` parameter three times on the grounds that the compiler should watch the types,
+ * and a generic row decoder would be the same mistake one level up. */
+ query(sql: string): Promise<string[][]>;
}
export function createClickHouse({
host = process.env["RELAY_CLICKHOUSE_HOST"] ?? "localhost",
port = process.env["RELAY_CLICKHOUSE_HTTP_PORT"] ?? "8123",
user = process.env["RELAY_CLICKHOUSE_USER"] ?? "relay",
@@ -53,12 +65,16 @@ export function createClickHouse({
const text = await res.text();
if (!res.ok) throw new Error(text.trim().split("\n")[0] ?? "clickhouse insert failed");
return text.trim();
};
return {
+ query: async (sql: string): Promise<string[][]> => {
+ const text = await post(sql, "");
+ return text === "" ? [] : text.split("\n").map((line) => line.split("\t"));
+ },
// One statement, one block. No deduplication token: the table is a ReplacingMergeTree
// keyed on (environment_id, ts, delivery_id, attempt), so a re-inserted record collapses
// regardless of how it was batched -- which a token cannot do, because JetStream batch
// boundaries are not stable across a redelivery.
async insert(rows: AttemptRow[]): Promise<void> {
if (rows.length === 0) return;The compiler then named the one place that had to change: src/ingest.itest.ts(107,11): error TS2741: Property 'query' is missing. That stub is completed rather than widened —
a Partial<> or an as cast would silence the property that makes a growing interface safe,
and chapter 4.5 recorded the same error one method earlier.
sum() with GROUP BY is the contract rather than a habit. Three separate inserts for one
key, then the bare read:
physical rows for that key 3
SELECT messages, no GROUP BY 1000 1000 1000
sum() with GROUP BY 3000
The stored count is a separate read with no lower bound, because it is a balance:
export async function storedMessages(
store: ClickHouse,
environmentId: string,
asOf: string,
): Promise<number> {
const rows = await store.query(
`SELECT sum(stored_delta) FROM relay_analytics.daily_usage_v2
WHERE environment_id = toUUID('${environmentId}') AND day <= '${asOf}'
FORMAT TSV`,
);
return Number(rows.flat()[0]);
}A BETWEEN here reports the period's change in stored messages, which is a different
question that reads as a plausible wrong answer. Measured: two creations on the 22nd and a
deletion on the 23rd give 2 and then 1, while the 23rd alone gives −1.
One more number from the corpus, and it is the one that changes how you deploy this.
flowchart TB
load["load-analytics.mjs inserts 248,155 rows"]
view["the materialised view fires<br/>ON THE INSERT"]
ttl["the TTL deletes rows older than 90 days<br/>ON THE SAME INSERT"]
back["a backfill reads the table<br/>MINUTES LATER"]
load --> view
load --> ttl
ttl --> back
vres["242,667 messages · 92 days<br/>oldest 2026-06-16"]
bres["239,997 messages · 91 days<br/>oldest 2026-06-17"]
view --> vres
back --> bres
note["The difference is exactly one day: the one the TTL removed<br/>while the insert was still running. The view goes first.<br/><br/>So a backfill recovers only what still exists — and a rollup<br/>created late is permanently short by whatever has expired.<br/>The rollups carry no raw TTL precisely so they outlive it."]
vres ~~~ notemessage_events, event='created' now 239,997 91 days oldest 2026-06-17
daily_usage_v2 (the view saw it) 242,667 92 days oldest 2026-06-16
daily_usage_billing (backfilled after) 239,997 91 days oldest 2026-06-17
The difference is 2,670 rows on a single day: 2026-06-16, which message_events' 90-day TTL
deleted during the insert. Chapter 4.1 measured that behaviour — the TTL removes rows at
insert, not at merge — and the ordering is what matters here: the view fires first and counts
them, and a backfill arriving minutes later finds them gone.
For metering the view's number is the correct one. FR-003a gives the rollups no raw TTL precisely so they keep history the raw events lose. Which means a rollup created late is permanently short by whatever has already expired, and no amount of backfilling closes that gap. Build the rollup with the table, not after it.
docs/12 §4 argues this and this chapter does not re-derive it: "a quota must refuse a send
synchronously, so its counter cannot live downstream of a lossy stream", therefore "Two
counters of one quantity is the right answer and the reconciler is the price."
So usage_periods in Postgres is unchanged, and git diff is the evidence rather than a
sentence. The meter cannot say a connection existed, only how many minutes it owed. The
rollup cannot refuse a send, because a refusal is synchronous and this path is not.
What the chapter did have to decide is what a connection-minute is. Both definitions are computable in a view, which means the question was never blocked on a mechanism:
calendar buckets (what the rollup computes) 56 minutes
elapsed duration 0.6513 minutes
ratio 86x
median connection 252 ms
SRS Appendix C question 4 — "does connection-minute metering need per-second precision, or
is per-minute rounding acceptable?" — had been open since before Part 4, owned by Product
/ Billing. Revision 1.13 closes it: a calendar minute during any part of which a
connection was open, which is what meter.ts has billed since 3.24, so the two agree by
construction rather than by reconciliation.
The 86× is the argument. A client reconnecting every quarter-second occupies a connection slot continuously and, billed on elapsed duration, pays almost nothing — and what a connection costs is a handshake, a slot claim and a Redis round trip, not the seconds it then sits idle. What is given up is in the clause too: the calendar bucket over-reports short connections against wall-clock intuition, and a customer comparing their own timings against an invoice will find the invoice larger.
Constitution III, whole: "Analytical queries MUST NEVER execute against the operational database (PostgreSQL); billing, metering, and dashboard analytics read only from the analytical store (ClickHouse), fed via a durable queue."
Metering today reads only Postgres. And message_events, the source of three of
FR-ANL-05's four quantities, is fed by load-analytics.mjs:53 — postgresql('${PG_HOST}', …),
ClickHouse executing a query against Postgres, which is the clause's first prohibition rather
than its queue qualifier.
Which means the corpus command near the top of this chapter — the one that produced every
cost figure in it — is the violation. The only way to demonstrate the rollup the clause
asks for is to run the thing the clause forbids. That is not an inconvenience to route
around; it is the strongest available argument that message_events needs a producer, and
building one is a send-path change on the busiest path in the platform.
Constitution VII requires a conflict "resolved explicitly by amendment rather than silent divergence". The amendment is the constitution's own and not this chapter's to write, so SRS revision 1.13 records the conflict with its measurements, and the next feature inherits a decision rather than a discovery.