Skip to content

Commit 94fc4a6

Browse files
Roll an hour up without letting it lose one of its three states (#28)
* Roll an hour up without letting it lose one of its three states presence_sample has been partitioned monthly since 0003 for rollups and retention that nobody had written. This writes them. The rollup keeps the tally and not the conclusion: presence_rollup_hour and presence_rollup_day carry counted and uncountable sample counts separately, min/max/mean over the counted ones alone, and no row at all for an hour nobody measured. A measured zero is a filled cell, a probe that got in and could not count is a hatched one, and an hour we never reached is the absence of a row -- the same three states the raw table keeps, after aggregation. The CHECK constraints refuse a bucket with a zero it did not measure and a bucket that claims to be a measurement of nothing. Partitions are now made ahead of need as well as before every append, so a month ending is never the first thing to discover a database we cannot write DDL to. The pass runs behind its own Postgres advisory lock, like the crawl loop -- CrawlLease is renamed AdvisoryLease, since two workers now share it. Retention is configuration, because 15.4 is open. It keeps everything at every grain by default, with 5.2's own figures a preset away, and it never runs ahead of the rollup: raw months go whole, and only once both grains have consumed them. 15.4 records the choice as ship conservative and tune. Salt rotation, which 11 promised and nothing implemented: a per-epoch HMAC salt derived from a deployment secret, an epoch label recorded beside every aggregate, and a rollup that refuses to combine an estimate across a rotation rather than publishing a bigger number. Names go in and sixteen bytes come out; nothing persists a name. Testcontainers 4.13 pulls SSH.NET 2025.1.0, which now has an advisory, and TreatWarningsAsErrors turns that into a broken restore for everyone. Bumped. * Let each grain remember for itself how far it has read The daily rollup resumed from the hourly watermark, which is fine right up until the two statements do not both run. A pass that rolled the hours, committed their watermark and then died left the days unwritten -- and the next pass, reading the hourly mark, skipped everything older than the overlap, wrote its own watermark as though it had read it, and let retention drop the raw months behind it. Six days of history came back as six hour buckets and one day bucket, and then the raw rows went. The grain 5.2 keeps for ever, missing, with the only other copy deleted. Each grain now resumes from its own watermark and writes that watermark only after its own aggregation, so an interrupted pass resumes instead of skipping, and retention still waits for whichever grain is behind. An estimate with no salt epoch also cost more than itself: the invariant ran inside JSON deserialisation, so one such row threw out of the middle of a read and took the whole window with it, while the aggregation read the same row without complaint. The reader now drops the estimate and keeps the buckets, and the SQL filters the same case, so both paths agree that an unlabelled estimate is not one anything may compare with another. The maintenance service starts beside the crawler's migration run rather than after it, so it asks whether the schema is there instead of finding out as a 42P01 and standing down for five minutes. 11's salt machinery is in place and nothing feeds it: the WHO parser counts rows and never extracts a name, so no probe produces aggregates. Said plainly in 5.2 and beside the registration, rather than left to read as delivered. --------- Co-authored-by: Harry Cordewener <nekomercutio@gmail.com>
1 parent fcca6db commit 94fc4a6

22 files changed

Lines changed: 2575 additions & 56 deletions

docs/specs/2026-07-30-mu-directory-design.md

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -226,10 +226,36 @@ called, by whatever assembles the probe's reading. It cannot be applied afterwar
226226
and choosing later, because there is nowhere to keep both. This is a constraint on the ingestor, not
227227
a limitation of the store.
228228

229-
**Rollups are not built and have no owner.** §5.2 states the retention shape — raw 90 days, hourly
230-
two years, daily forever — and nothing implements it. Until it does, the heatmap reads raw samples
231-
over an eight-week window. Monthly range-partitioning is in place, which is what makes a rollup a
232-
cheap addition rather than a rewrite.
229+
**Rollups are built; the retention beside them is configuration.** `PresenceMaintenance` runs on a
230+
Postgres advisory lock of its own — the same gate §12 puts on the crawl loop, a different key — and
231+
does three things per pass: makes the monthly partitions ahead of need, aggregates every complete
232+
hour into `presence_rollup_hour` and `presence_rollup_day`, and then applies whatever retention a
233+
deployment configured. **The three states survive the aggregation**: each bucket carries a tally of
234+
counted and of uncountable samples rather than a conclusion, min/max/mean are over counted samples
235+
alone and are null when there were none, and an hour nobody measured produces no row — which is
236+
§5.4's empty cell, unchanged in shape. The `CHECK` constraints refuse a bucket that claims a count it
237+
did not measure and one that claims to be a measurement of nothing at all.
238+
239+
Retention is `PresenceRetentionOptions` and defaults to **keeping everything at every grain**, for
240+
the reason in §15.4. The shape above — raw 90 days, hourly two years, daily forever — is available as
241+
`PresenceRetentionOptions.AsDesigned` and is one setting away. Raw samples go by whole partitions and
242+
never row by row, and never before both rollups have consumed them: **each grain resumes from its own
243+
watermark** and writes that watermark only after its own aggregation, and retention is clamped to the
244+
older of the two. A pass that rolled the hours and then died cannot leave the daily grain believing
245+
it had read a year it never saw, and cannot drop the raw months that are then the only copy.
246+
247+
The heatmap still reads raw samples over its eight-week window, which is why the shortest raw
248+
retention this accepts is that window. Pointing it at the hourly rollup is the remaining work.
249+
250+
**§11's salt machinery is in place and nothing feeds it yet, which is worth saying plainly.**
251+
`RotatingSaltProvider` derives a per-epoch salt from a deployment secret, `PresenceAggregates`
252+
refuses an estimate that does not name its epoch, the rollup carries an estimate only where a
253+
bucket's samples share one, and the reader and the aggregation agree that an unlabelled estimate is
254+
not readable. But no probe produces aggregates: the `WHO` parser counts rows and never extracts a
255+
name, so `PresenceAggregates` is constructed nowhere in `src/`, and the `aggregates` column, the
256+
`salt_epoch` columns and the constraints around them are unreached in the shipped pipeline. That is
257+
the safe half to have built first — the rule that names are never persisted is kept by there being
258+
no path that handles one — but the unique-player estimate is designed, not delivered.
233259

234260
**Activity band**, the facet §9 exposes, is derived here and defined once: `players now` (a non-null
235261
count above zero in the most recent hourly rollup), `active this week` (any such count in 7 days),
@@ -1095,7 +1121,21 @@ webhooks beyond RSS; non-English UI (listings carry `LANGUAGE` from day one).
10951121
decision and need not match.
10961122
3. **Hosting and cost envelope**, which bounds probe frequency and retention.
10971123
4. **Retention policy** for `PresenceSample` before rollup, and the salt rotation period for §11
1098-
aggregates.
1124+
aggregates — both unsettled, and both now **shipped conservative to be tuned**, because the
1125+
machinery could not wait for the answer and neither question has data behind it yet. §5.2's
1126+
rollups and partition maintenance are built; what is deliberately not compiled in is how long
1127+
anything is kept. `PresenceRetentionOptions` therefore **keeps everything by default** at all three
1128+
grains, with §5.2's own figures available as a named preset. §5.2 does authorise dropping raw
1129+
samples once they have been aggregated — it is the only data in the system that is ever deleted —
1130+
but the period it names has never been checked against a real deployment's storage, and §15.3, the
1131+
cost envelope that would bound it, is open too. Between an unvalidated number and a deletion, the
1132+
default is not to delete: turning retention on later costs one setting, and turning it on too early
1133+
costs measurements that cannot be taken again. The salt period defaults to **seven days** on the
1134+
same reasoning read the other way round — the two errors are not symmetrical. An epoch that proves
1135+
too short costs precision in an estimate and can be lengthened from the next epoch onwards; an
1136+
epoch that proves too long has already linked a season of observations together, and no later
1137+
setting un-links them. Tune both once there is a year of data and a hosting bill to read them
1138+
against.
10991139
5. **Auto-merge threshold** in §7.3 — needs calibration against real data, so ship conservative and
11001140
tune.
11011141
6. **The archive grace factor** in §7.5 — the quarter, the 60-day floor and the 365-day ceiling are

migrations/0003_presence_sample.sql

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
-- spec §5.2 — the only table in this schema that grows linearly with games × time, which is why it is
2-
-- the only partitioned one. RANGE on `at`, monthly, so §5.2's retention and rollups can later work on
3-
-- whole partitions rather than row-by-row deletes over hundreds of millions of rows.
2+
-- the only partitioned one. RANGE on `at`, monthly, so §5.2's retention and rollups work on whole
3+
-- partitions rather than row-by-row deletes over hundreds of millions of rows. Migration 0011 is what
4+
-- reads that promise back: it adds the two rollup tables and the watermark that retention checks
5+
-- before it drops a month.
46
--
57
-- There is no DEFAULT partition, deliberately: rows landing in one would block the later creation of
68
-- the partition that should have held them. NpgsqlPresenceStore creates the month's partition before
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
-- spec §5.2 — the rollups the monthly partitioning in 0003 was put there for, and the retention that
2+
-- can then work on whole partitions instead of row-by-row deletes over hundreds of millions of rows.
3+
--
4+
-- The rule these two tables exist to carry forward is §5.4's: an hour has THREE states, and a rollup
5+
-- that turns "probed but uncountable" or "not measured" into a zero has destroyed the only thing this
6+
-- schema is careful about. So the tally is kept, not the conclusion:
7+
--
8+
-- counted_samples > 0 -> a filled cell, INCLUDING a measured zero
9+
-- counted_samples = 0 AND unmeasurable_samples > 0 -> a hatched cell: probed, could not count
10+
-- no row at all -> not measured, which is the absence of a row
11+
-- here exactly as it is in presence_sample
12+
--
13+
-- min/max/sum are computed over counted samples only and are NULL when there were none, so a
14+
-- reader that asks for a number gets nothing rather than a zero it could mistake for a measurement.
15+
CREATE TABLE presence_rollup_hour (
16+
game_id uuid NOT NULL REFERENCES game (id),
17+
18+
-- The start of the hour, in UTC. Truncation is done in UTC on both sides of the wire so that a
19+
-- session's TimeZone setting can never move an hour into its neighbour.
20+
hour timestamptz NOT NULL,
21+
22+
-- §5.4's tally. Both of these are counts of rows in presence_sample, never of hours.
23+
counted_samples integer NOT NULL,
24+
unmeasurable_samples integer NOT NULL,
25+
26+
-- Over the counted samples alone. The mean is stored as sum/count rather than as an average so
27+
-- that the daily rollup can be computed from the hourly one exactly, rather than by averaging
28+
-- averages over hours that saw different numbers of probes.
29+
min_count integer,
30+
max_count integer,
31+
sum_count bigint,
32+
mean_count numeric GENERATED ALWAYS AS (sum_count::numeric / NULLIF(counted_samples, 0)) STORED,
33+
34+
-- §11. The salt rotates, and an estimate may only ever be read within one epoch: the epoch is
35+
-- recorded so that nothing downstream can combine two of them. Where an hour's samples spanned a
36+
-- rotation both columns are NULL — the honest answer, because the hashes that would have been
37+
-- needed to merge the two sets are never persisted.
38+
salt_epoch text,
39+
40+
-- The largest single-sample estimate inside that one epoch, and deliberately not a union: a union
41+
-- of distinct players needs the hashes, and the hashes never leave the probe. A lower bound that
42+
-- says so beats a total that cannot be justified.
43+
peak_distinct_estimate integer,
44+
45+
PRIMARY KEY (game_id, hour),
46+
47+
-- A rollup row is a statement that something was measured in that hour. A row of zeroes would be
48+
-- "not measured" wearing a measurement's clothes, which is §5.4's third state collapsed into the
49+
-- other two — so the schema refuses to hold one.
50+
CONSTRAINT presence_rollup_hour_measured_something CHECK (
51+
counted_samples > 0 OR unmeasurable_samples > 0),
52+
53+
CONSTRAINT presence_rollup_hour_tallies_are_not_negative CHECK (
54+
counted_samples >= 0 AND unmeasurable_samples >= 0),
55+
56+
-- The other half of the same rule: an hour with no counted sample has no count, and an hour with
57+
-- one has all three. Nothing may write an uncountable hour with a zero in it.
58+
CONSTRAINT presence_rollup_hour_counts_iff_counted CHECK (
59+
(counted_samples > 0) = (min_count IS NOT NULL)
60+
AND (counted_samples > 0) = (max_count IS NOT NULL)
61+
AND (counted_samples > 0) = (sum_count IS NOT NULL)),
62+
63+
CONSTRAINT presence_rollup_hour_counts_are_ordered CHECK (
64+
min_count IS NULL OR (min_count >= 0 AND max_count >= min_count AND sum_count >= max_count)),
65+
66+
CONSTRAINT presence_rollup_hour_estimate_names_its_epoch CHECK (
67+
peak_distinct_estimate IS NULL OR salt_epoch IS NOT NULL),
68+
69+
CONSTRAINT presence_rollup_hour_is_on_the_hour CHECK (
70+
hour AT TIME ZONE 'UTC' = date_trunc('hour', hour AT TIME ZONE 'UTC'))
71+
);
72+
73+
-- §9's heatmap reads one game over a window, which the primary key serves. This is for the ecosystem
74+
-- dashboard and the activity band, which read every game over one.
75+
CREATE INDEX presence_rollup_hour_hour_idx ON presence_rollup_hour (hour);
76+
77+
-- The same shape a day at a time, and the one §5.2 keeps for ever. Computed from the hourly rollup
78+
-- rather than from the raw table, so it survives the raw rows being dropped and stays exact.
79+
CREATE TABLE presence_rollup_day (
80+
game_id uuid NOT NULL REFERENCES game (id),
81+
day timestamptz NOT NULL,
82+
83+
counted_samples integer NOT NULL,
84+
unmeasurable_samples integer NOT NULL,
85+
86+
min_count integer,
87+
max_count integer,
88+
sum_count bigint,
89+
mean_count numeric GENERATED ALWAYS AS (sum_count::numeric / NULLIF(counted_samples, 0)) STORED,
90+
91+
salt_epoch text,
92+
peak_distinct_estimate integer,
93+
94+
PRIMARY KEY (game_id, day),
95+
96+
CONSTRAINT presence_rollup_day_measured_something CHECK (
97+
counted_samples > 0 OR unmeasurable_samples > 0),
98+
99+
CONSTRAINT presence_rollup_day_tallies_are_not_negative CHECK (
100+
counted_samples >= 0 AND unmeasurable_samples >= 0),
101+
102+
CONSTRAINT presence_rollup_day_counts_iff_counted CHECK (
103+
(counted_samples > 0) = (min_count IS NOT NULL)
104+
AND (counted_samples > 0) = (max_count IS NOT NULL)
105+
AND (counted_samples > 0) = (sum_count IS NOT NULL)),
106+
107+
CONSTRAINT presence_rollup_day_counts_are_ordered CHECK (
108+
min_count IS NULL OR (min_count >= 0 AND max_count >= min_count AND sum_count >= max_count)),
109+
110+
CONSTRAINT presence_rollup_day_estimate_names_its_epoch CHECK (
111+
peak_distinct_estimate IS NULL OR salt_epoch IS NOT NULL),
112+
113+
CONSTRAINT presence_rollup_day_is_on_the_day CHECK (
114+
day AT TIME ZONE 'UTC' = date_trunc('day', day AT TIME ZONE 'UTC'))
115+
);
116+
117+
CREATE INDEX presence_rollup_day_day_idx ON presence_rollup_day (day);
118+
119+
-- How far each rollup has consumed the raw table. Two jobs read this: the rollup itself, which
120+
-- resumes from it rather than re-aggregating history on every pass, and retention, which may not drop
121+
-- a raw partition that has not been rolled up yet — the one ordering that makes dropping raw samples
122+
-- recoverable-in-shape rather than a loss.
123+
CREATE TABLE presence_rollup_state (
124+
scope text PRIMARY KEY,
125+
rolled_up_through timestamptz NOT NULL,
126+
updated_at timestamptz NOT NULL DEFAULT now(),
127+
128+
CONSTRAINT presence_rollup_state_scope_vocabulary CHECK (scope IN ('hour', 'day'))
129+
);

0 commit comments

Comments
 (0)