Case study · telco OSS data lake

An OSS lake where people, services and AI agents share the data of every network device

It started with one slow NOC screen and ended with the data in one place, shared by many teams and by AI agents.

An anonymized, composite case. The structure is generalized from a real one; the operator, cities and data are fictional.

1Situation

NOC · last hour

10 cells with most users

10 cells with weakest signal

An operator's OSS team runs a NOC screen showing, for the last hour, the cells with the most users and the cells with the weakest signal. Measurements from devices (UE: phones, IoT modules, vehicles, routers) were aggregated from the source every time, so the screen was slow.

  • A file-based lake struggled to meet interactive response times
  • Each stage defined its own schema; names and meanings drifted
  • Changing a pipeline or a schema took a long time
  • More unpredictable ad hoc questions, and queries from AI agents

A telco runs two families of systems. BSS handles customers and money (subscriptions, rating, billing); OSS handles the network itself (equipment, faults, quality). This lake is on the OSS side.

2The industry trend: virtualizing the OSS data layer

Instead of copying data into every system, operators are putting meaning and access control on top of one data layer, so people and AI agents use the same data. The layers below are summarized from public sources.

  1. Consumersautonomous-operations agents, network digital twin, NOC · BIManaged Postgres
  2. Access and governancestandard Open APIs, MCP tool catalogue, policy · audit, a human gate before writesManaged Postgres
  3. Semanticsontology · knowledge graph, topology · inventory models, standard information model
  4. Data productscurated, contracted data owned by each domain (gold)ClickHouse
  5. Storage and queryhistory in an open-table lake, queried in place instead of copiedClickHouse + Iceberg
  6. Ingestionstreaming from RAN · core · transport · probes, standardized network data collection
  7. Sourcesnetwork functions, element management, OSS/BSS systems of record

Managed Postgres keeps the consumer side's usage patterns and work records (subscriptions, API keys, watermarks, tickets, agent actions and approvals) — transactional data — in ClickHouse's Managed Postgres, linked to the lake with pg_clickhouse. not in the demo

3Architecture

The whole lake, generalized from a real case. Solid boxes run in this lab; dashed ones follow the same pattern and are shown only in the figure.

  • runs in this lab
  • same pattern (figure only)

Schema contract (Git): column names, descriptions and types are defined once in a contract file in Git, and every stage's schema and DDL is generated from it.

Control-plane API: topics, stream jobs, connectors, schemas and tables are declared, deployed and observed through an API instead of by hand. People and AI agents use the same API.

4Who uses it for what

Many teams use the same data, each in its own way. Four groups: seeing, taking data out, running the platform, and AI.

  • run in this lab result actually run on this lab's data
  • pattern how the real case is built; not run in this lab

Network operations centre (NOC) run in this lab

Where is it busy, where is it weak, where did something just break?

Figures are drawn with JavaScript.

  • cells with most users
  • cells with weakest signal
  • area whose signal just dropped
  • measurements
More

One screen with the cells that had the most users in the last hour (blue), the cells with the weakest signal (red), and the area whose signal dropped suddenly in the last minute (yellow).

How: only the answer the screen needs is precomputed every minute, so the screen opens at once. Cell outlines come from ClickHouse's H3 functions.

Operations dashboard · BI run in this lab

KPI trends that change every minute

Figures are drawn with JavaScript.

More

A dashboard tool sends the same query every minute and draws the lines. Below is the weak-signal share per city over the last hour; a spike means an area had an incident in that minute.

How: dashboard tools connect to ClickHouse directly. Even values that must be de-duplicated, such as user counts, are exact, by merging the states kept in the one-minute summary.

-- ---------------------------------------------------------------- 2 · dashboard
-- One point per city per minute for a time-series panel (Grafana, Superset, ...):
-- distinct users (uniq states merged across the city's cells) and weak-signal share.
CREATE OR REPLACE VIEW oss_lake.uc_dashboard AS
WITH (SELECT max(anchor_ts) FROM oss_lake.params) AS t
SELECT
    k.bucket                                                 AS minute,
    g.city                                                   AS city,
    uniqMerge(k.users_st)                                    AS users,
    sum(k.samples_n)                                         AS samples,
    round(100 * sum(k.poor_n) / sum(k.rsrp_n), 2)            AS poor_pct
FROM oss_lake.cell_kpi_1m AS k
INNER JOIN oss_lake.grid AS g ON g.h3 = k.cell
WHERE k.bucket >= t - INTERVAL 1 HOUR AND k.bucket < t
GROUP BY minute, city
ORDER BY minute, city;

Quality analyst run in this lab

Answering a question nobody prepared for

CityZoneWeak-signal share
City Adowntown0%
City Amiddle2.6%
City Aedge23.1%
City Bdowntown0%
City Bmiddle2.5%
City Bedge23.1%
City Cdowntown0.1%
City Cmiddle2.6%
City Cedge23.1%
More

Is weak signal concentrated downtown or at the edge? A question nobody built a report for, asked on the spot in SQL.

How: asked straight of the one-minute summary and joined with area data. This is the kind of question for which a file-based lake struggled to meet interactive response times.

-- ---------------------------------------------------------------- 1 · ad-hoc analysis
-- A quality analyst's question: where is weak signal concentrated, downtown or at the edge?
CREATE OR REPLACE VIEW oss_lake.uc_analysis AS
WITH (SELECT max(anchor_ts) FROM oss_lake.params) AS t
SELECT
    g.city                                                   AS city,
    multiIf(g.ring <= 10, 'downtown', g.ring <= 30, 'middle', 'edge') AS zone,
    uniqExact(k.cell)                                        AS cells,
    sum(k.samples_n)                                         AS samples,
    round(sum(k.rsrp_sum) / sum(k.rsrp_n), 1)                AS avg_rsrp,
    round(100 * sum(k.poor_n) / sum(k.rsrp_n), 1)            AS poor_pct
FROM oss_lake.cell_kpi_1m AS k
INNER JOIN oss_lake.grid AS g ON g.h3 = k.cell
WHERE k.bucket >= t - INTERVAL 1 HOUR AND k.bucket < t
GROUP BY city, zone
ORDER BY city, indexOf(['downtown', 'middle', 'edge'], zone);

On-call staff · downstream systems run in this lab

Subscription: tell me when something happens

  • 10:20 · City C area 8730c19a5ffffff: 48 cells, signal down 21.2 dB
More

When a cell's signal in the latest minute falls well below its own last hour, that is an alert. Cells are folded per area into one line a person can read. Below: alerts actually raised in this lab.

How: in the real case, when new data is ready an event goes out on a message queue saying "fetch now", and subscribers take it then. Results can also be pushed straight to a Kafka topic.

In production the same rows are pushed to a webhook with the URL table engine. Not used in the lab: an unreachable endpoint would fail the source INSERT.

Example; not run in this lab (it needs a Kafka broker).

-- Example, not run in this lab (it needs a Kafka broker): publish each minute's alerts to a
-- topic that an on-call app or chat bot subscribes to. Inserting into a Kafka-engine table
-- produces messages; a refreshable MV with APPEND does that once a minute.
CREATE TABLE oss_lake.alerts_topic
(
    area String, city UInt8, minute DateTime, cells UInt64, drop_db Float64, rsrp_now Float64
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'broker:9092',
         kafka_topic_list  = 'noc-alerts',
         kafka_group_name  = 'oss-lake',
         kafka_format      = 'JSONEachRow';

CREATE MATERIALIZED VIEW oss_lake.alerts_publish
REFRESH EVERY 1 MINUTE APPEND TO oss_lake.alerts_topic AS
SELECT area, city, minute, cells, drop_db, rsrp_now FROM oss_lake.uc_alert_areas;

Business apps · services · AI agents run in this lab

Request/response: take it with one HTTP call

curl 'http://localhost:18123/?user=oss_reader' \
  --data-binary 'SELECT area, city, minute, cells, drop_db, rsrp_now FROM oss_lake.uc_alert_areas FORMAT JSONEachRow'
{"area":"8730c19a5ffffff","city":2,"minute":"2026-10-18 10:20:00","cells":48,"drop_db":21.2,"rsrp_now":-114.1}
More

A consumer takes the data it needs with one HTTP request. In the real case an API service in front of ClickHouse handles authentication and the catalogue, and lets the consumer pick the original format or the industry-standard model. Below: a request sent straight to ClickHouse's HTTP interface and the start of its response.

How: ClickHouse takes queries over HTTP and streams the result row by row as it is produced. No driver needed.

Serving layer · business apps (future expansion) pattern

Managed Postgres: subscriptions, keys and business data in Postgres

More

A serving layer comes with small business data: subscriptions, API keys, how far each consumer has read (watermarks). The real case keeps it in Postgres. With ClickHouse's Managed Postgres that data and the lake sit in one cloud, and the pg_clickhouse extension lets plain Postgres SQL join the lake's alerts with owner teams and open tickets.

Basis: the same approach was verified in the pg_clickhouse lab of the clickhouse-managed-postgres-hols repository (2026-10-01). Not included in this lab.

Expansion example; not run in this lab. Host and password are placeholders.

-- Expansion example, not run in this lab. Runs on (Managed) Postgres, where an operations app
-- keeps tickets and area owners; pg_clickhouse reads the lake's alert view in place.
-- Same statements as clickhouse-managed-postgres-hols extensions/pg-clickhouse-lab
-- (verified there 2026-10-01 on pg_clickhouse 0.11.0, PostgreSQL 18.6); host and password are placeholders.
CREATE EXTENSION IF NOT EXISTS pg_clickhouse;

CREATE SERVER lake FOREIGN DATA WRAPPER clickhouse_fdw
    OPTIONS (driver 'binary', host '<clickhouse host>', port '9000', dbname 'oss_lake');
CREATE USER MAPPING FOR CURRENT_USER SERVER lake
    OPTIONS (user 'oss_reader', password '<password>');

CREATE SCHEMA lake;
IMPORT FOREIGN SCHEMA oss_lake LIMIT TO (uc_alert_areas) FROM SERVER lake INTO lake;

-- who to call: this minute's alert areas with their owner team and open tickets
SELECT a.area, a.cells, a.drop_db, o.team, count(t.id) AS open_tickets
FROM lake.uc_alert_areas AS a
JOIN ops.area_owner AS o ON o.area = a.area
LEFT JOIN ops.ticket AS t ON t.area = a.area AND t.status = 'open'
GROUP BY a.area, a.cells, a.drop_db, o.team
ORDER BY a.cells DESC;

AI agents pattern

Turning a question in plain language into SQL

More

An operator asks something like "which areas in City B lost signal in the last 30 minutes?"; the agent reads the catalogue and schema descriptions, writes SQL and asks the lake. The descriptions in the schema contract become the agent's context.

How: agents connect to ClickHouse over MCP or HTTP and query as a read-only user. Summary tables keep answers fast even when the questions cannot be predicted.

Data platform team pattern

Change the schema in one place and apply it at once

More

When each stage defines its own schema, names and meanings drift, and every change has to go through several teams. The real case keeps one data-contract file in Git as the reference and generates the message schema, the lake table definition and the ClickHouse DDL from it in CI.

How: ClickHouse takes the generated DDL as it is. Tables and MVs are plain SQL, so they are reviewed and deployed like code.

Data quality owner run in this lab

Checking incoming data at every stage

More

Measurements without a cell or a subscriber are dropped when data is refined; measurements without a signal value keep "missing", not 0. Row counts show what each stage dropped and kept.

How: the cleaning rules live in one place, the SQL of a materialized view, so rule and result are reviewed together. The original stays as received, so it can be rebuilt when a rule changes.

Platform operations (SRE) pattern

Observing the pipeline itself

More

With many collectors, topics, stream jobs and connectors, seeing where things slow down or fail becomes work of its own. Their logs, metrics and traces can go into the same ClickHouse.

How: ClickStack, the observability stack built on ClickHouse, takes OpenTelemetry data for search and dashboards.

Analysts · data scientists pattern

Old history and recent data together

More

Long history stays in an open-table-format lake (Iceberg and others); recent data is used fast in ClickHouse. Reading both in one query makes reprocessing and long-term trends easier.

How: ClickHouse can read Iceberg tables on object storage directly and join them with gold tables, without moving the lake.

Data engineers · other analytics platforms pattern

Bulk export: take a lot at once

More

When a large chunk is needed, for model training or loading another system, a whole period is exported at once.

How: ClickHouse can write query results straight to object storage as files, or stream them over HTTP. In the real case this is the next serving mode after request/response.

AI development team pattern

Checking that agents answer correctly

More

Agents can be trusted only when their SQL, answers, cost and latency are recorded and evaluated.

How: Langfuse, which uses ClickHouse as its store, shows agent traces, evaluations and cost on the same platform.

5Features verified in the PoC

In this PoC, materialized views inside ClickHouse stand in for the real structure's message bus and stream processing, and each feature below was checked on real data. The checks live in the lab's SQL, so every tools/hol run checks them again. Two items, '20 rows during refreshes' and 'a write is refused', were checked by hand (2026-10-18).

  • ✓
    Ingest as receivedstored without reshaping, daily partitions, 3-day TTLraw rows = generated rows
  • ✓
    Cleaning at insert timean MV turns the H3 string into a number, the IMSI ciphertext into a hash, applies the NULL policyone measurement compared before and after
  • ✓
    Enrichment from the inventorya district name per cell through a dictionary (dictGet)every row has a district (check in 05)
  • ✓
    Pre-aggregation at insertAggregatingMergeTree, uniq states + sums, cell × 1 minutea cell's rows for one minute → 1 row
  • ✓
    Gold on goldthe 10-minute rollup cascades from the 1-minute oneequal to a 10-minute aggregate from raw; the control fires
  • ✓
    A geography axisdistrict × 1 minute from the same silver rowssum = silver rows (check in 05)
  • ✓
    An answer refreshed every minutea refreshable MV swaps the 20 rows in one go20 rows read even during refreshes
  • ✓
    Same answer from every stagethe same NOC query on all four stagesidentical 20 rows (check in 03)
  • ✓
    Sudden-drop alertscells 12 dB or more below their last hour, folded per areathe area the generator degraded is caught every minute (check in 04)
  • ✓
    HTTP serving and a read-only userresults streamed as one JSON line per rowqueried as a read-only user; a write is refused
  • ✓
    H3 geo functionscell outlines and coarser-resolution rollups in SQLthe NOC map drawn from h3ToGeoBoundary

The verified flow: one measurement as each stage holds it

  1. Keep as received

    Stored without changes; rebuilt from here when rules change.

    h3 '8930e1d8c0bffff' · imsi '/ZM4BSO2…'

  2. Refine

    Types fixed, the subscriber number replaced by a hash; unusable rows dropped.

    h3 617853093946327039 · imsi → 624750…

  3. One-minute summary

    One row per cell per minute; any period can be merged again.

    one cell, one minute: 441 rows → 1

  4. Ready answer

    Only the answer the screen reads, recomputed every minute.

    the 20 rows the NOC map needs

Refine

cell × 1 min

region × 1 min

cell × 10 minbuilds on the 1-minute summary

Region summary: districts with the highest weak-signal share in the last hour

DistrictWeak-signal share
C North-West13.3%
B South-West13.3%
A North-East13.1%
B South-East12.5%
A West12.5%
For engineers: tables, transformations, SQL, measurements

The four stages are the tables ue_trace_raw, ue_trace, cell_kpi_1m and cell_rank_now in database oss_lake. The first two links are materialized views, the last a refreshable materialized view that runs every minute. The use cases are the views in 04-usecases.sql.

One measurement, before and after

As receivedRefinedWhat changed
event_ts
2026-10-18 10:20:00.011
event_ts
2026-10-18 10:20:00.011
unchanged
h3_res09
8930e1d8c0bffff
h3
617853093946327039
string → number (stringToH3)
imsi
/ZM4BSO2N5g8Ovcpun3PtidkglHPB22vffMxteYPT5w=
imsi_hash
6247503676043346188
ciphertext → hash (cityHash64); the number itself never enters the lake
rsrp
-69
rsrp
-69
Float32; stays NULL when missing
rsrq
-4.5
rsrq
-4.5
Float32
sinr
20
sinr
20
Float32
cqi
13
cqi
13
string → number
pci
290
—not used, dropped
probe
probe-a
—not used, dropped
—
region1
City A
district name from the inventory
—
region2
A Central
district name from the inventory

The one-minute summary row that 441 rows folded into

bucket2026-10-18 10:20:00
cell617853093946327039
users_stuniqState(…) → 133
samples_n441
rsrp_n399
rsrp_sum-31229
poor_n0

The 20 rows the NOC map reads

10 cells with most users

#cellusers
18930e1d8c0bffff5,475
28930c1903cbffff5,449
38930c1619d7ffff5,385
48930e1d8c03ffff2,596
58930c190227ffff2,565
68930c16198bffff2,545
78930e1d8c0fffff2,016
88930c16199bffff1,969
98930c190237ffff1,910
108930c190353ffff1,777

10 cells with weakest signal

#cellavg_rsrp
18930c16ad73ffff-117.9 dBm
28930e1c302fffff-117.5 dBm
38930c16ad3bffff-117.5 dBm
48930c19221bffff-117.4 dBm
58930c16ad03ffff-117.4 dBm
68930c16ad07ffff-117.4 dBm
78930e1c3397ffff-117.3 dBm
88930c1922cfffff-117.2 dBm
98930e1c339bffff-117.2 dBm
108930c16ad0fffff-117.2 dBm

Definitions

-- ---------------------------------------------------------------- the inventory (CM)
-- Every cell gets a site and a fictional district: 'Central' within 10 rings, otherwise one
-- of six 60-degree sectors around the city centre. Coverage holes are known weak spots.
INSERT INTO oss_lake.cell_inventory
WITH
    [(37.5665, 126.9780), (35.1796, 129.0756), (35.8714, 128.6014)] AS centres,
    ['North-East', 'East', 'South-East', 'South-West', 'West', 'North-West'] AS sectors,
    centres[city + 1] AS c,
    h3ToGeo(h3) AS p,
    (degrees(atan2((p.2 - c.2) * cos(radians(c.1)), p.1 - c.1)) + 360) % 360 AS bearing
SELECT
    h3                                                            AS cell,
    concat(['A', 'B', 'C'][city + 1], '-', leftPad(toString(intDiv(idx, 7)), 4, '0')) AS site_id,
    ['City A', 'City B', 'City C'][city + 1]                      AS region1,
    concat(['A', 'B', 'C'][city + 1], ' ',
           if(ring <= 10, 'Central', sectors[1 + toUInt8(floor(bearing / 60)) % 6])) AS region2,
    is_hole                                                       AS known_weak
FROM oss_lake.grid;
-- Rows without a cell or a subscriber cannot be placed or counted: silver drops them.
CREATE MATERIALIZED VIEW oss_lake.ue_trace_mv TO oss_lake.ue_trace AS
SELECT
    event_ts,
    stringToH3(assumeNotNull(h3_res09))       AS h3,
    cityHash64(assumeNotNull(imsi))           AS imsi_hash,
    CAST(rsrp AS Nullable(Float32))           AS rsrp,
    toFloat32(ifNull(rsrq, 0))                AS rsrq,
    toFloat32(ifNull(sinr, 0))                AS sinr,
    toUInt8OrZero(ifNull(cqi, ''))            AS cqi,
    -- a measurement gets a place name from the inventory
    dictGet('oss_lake.cell_dict', 'region1', h3) AS region1,
    dictGet('oss_lake.cell_dict', 'region2', h3) AS region2
FROM oss_lake.ue_trace_raw
WHERE h3_res09 IS NOT NULL AND imsi IS NOT NULL;
CREATE MATERIALIZED VIEW oss_lake.cell_kpi_1m_mv TO oss_lake.cell_kpi_1m AS
SELECT
    toStartOfMinute(event_ts)  AS bucket,
    h3                         AS cell,
    uniqState(imsi_hash)       AS users_st,
    count()                    AS samples_n,
    count(rsrp)                AS rsrp_n,
    sum(ifNull(rsrp, 0))       AS rsrp_sum,
    countIf(rsrp < -100)       AS poor_n
FROM oss_lake.ue_trace
GROUP BY bucket, cell;
CREATE MATERIALIZED VIEW oss_lake.region_kpi_1m_mv TO oss_lake.region_kpi_1m AS
SELECT
    toStartOfMinute(event_ts)  AS bucket,
    region1, region2,
    uniqState(imsi_hash)       AS users_st,
    count()                    AS samples_n,
    count(rsrp)                AS rsrp_n,
    sum(ifNull(rsrp, 0))       AS rsrp_sum,
    countIf(rsrp < -100)       AS poor_n
FROM oss_lake.ue_trace
GROUP BY bucket, region1, region2;
[[sql:gold10]]
-- No APPEND: every refresh replaces the 20 rows.
CREATE MATERIALIZED VIEW oss_lake.cell_rank_rmv
REFRESH EVERY 1 MINUTE TO oss_lake.cell_rank_now AS
WITH (SELECT max(anchor_ts) FROM oss_lake.params) AS t
SELECT
    lr.1 AS list, lr.2 AS rnk, cell, users, samples, avg_rsrp, poor_pct, t AS window_end
FROM
(
    SELECT
        cell,
        uniqMerge(users_st)                              AS users,
        sum(samples_n)                                   AS samples,
        round(sum(rsrp_sum) / sum(rsrp_n), 1)            AS avg_rsrp,
        round(100 * sum(poor_n) / sum(rsrp_n), 1)        AS poor_pct,
        sum(rsrp_n) >= 30                                AS eligible,
        row_number() OVER (ORDER BY users DESC, cell)    AS r_users,
        row_number() OVER (PARTITION BY eligible ORDER BY sum(rsrp_sum) / sum(rsrp_n), cell) AS r_signal
    FROM oss_lake.cell_kpi_1m
    WHERE bucket >= t - INTERVAL 1 HOUR AND bucket < t
    GROUP BY cell
)
ARRAY JOIN [('users', r_users), ('signal', if(eligible, r_signal, 999))] AS lr
WHERE lr.2 <= 10;
-- ---------------------------------------------------------------- 3 · alerts
-- Cells whose signal in the latest minute is at least 12 dB below their own last hour.
-- Subscribers (an on-call app, a chat bot) pick these up every minute; see 4 and
-- subscribe-kafka.sql for how they are delivered.
CREATE OR REPLACE VIEW oss_lake.uc_alert_cells AS
WITH (SELECT max(anchor_ts) FROM oss_lake.params) AS t
SELECT
    cell, city, area, minute, rsrp_now, rsrp_hour,
    round(rsrp_hour - rsrp_now, 1)                           AS drop_db,
    samples_now
FROM
(
    SELECT
        k.cell                                               AS cell,
        any(g.city)                                          AS city,
        h3ToParent(k.cell, 7)                                AS area,
        t - INTERVAL 1 MINUTE                                AS minute,
        sumIf(k.rsrp_sum, k.bucket = minute) / sumIf(k.rsrp_n, k.bucket = minute) AS rsrp_now,
        sum(k.rsrp_sum) / sum(k.rsrp_n)                      AS rsrp_hour,
        sumIf(k.rsrp_n, k.bucket = minute)                   AS samples_now
    FROM oss_lake.cell_kpi_1m AS k
    INNER JOIN oss_lake.grid AS g ON g.h3 = k.cell
    WHERE k.bucket >= t - INTERVAL 1 HOUR AND k.bucket < t
    GROUP BY cell
)
WHERE samples_now >= 3 AND rsrp_hour - rsrp_now >= 12;

Every stage gives the same answer

Ask the same NOC question of each of the four stages and the same 20 rows come back (03-queries.sql checks it). Rows and the amount read shrink at every stage.

  1. As received bronze21,600,000
  2. Refined silver21,470,680
  3. One-minute summary gold2,431,524
  4. Ready answer serving20
  • As received 710.6 ms · 10,807,707 rows read
  • Refined 229.7 ms · 10,742,997 rows read
  • One-minute summary 185 ms · 1,219,109 rows read
  • Ready answer 1.2 ms · 20 rows read

Measured 2026-10-18 10:22 UTC, ClickHouse 26.9.13.15, Apple M4 Pro (macOS arm64), container with 12 CPUs and 7.75 GiB. Median of 5 runs after one warm-up, server-side elapsed, query cache off. For reference.

A pitfall found while building it. An aggregate state type has to match whether its source column is Nullable. A mismatched MV is refused at CREATE, but if the source column is made Nullable later with ALTER, every INSERT after it fails while the source keeps filling (reproduction in the README).

6Lessons from the field

aNullable costs mostly in query complexity

The same NOC query written on bronze and on silver. The marked NULL checks, conversions and hashing disappear on silver.

As received
SELECT
    stringToH3(assumeNotNull(h3_res09))            AS cell,
    uniq(cityHash64(assumeNotNull(imsi)))          AS users,
    count()                                        AS samples,
    round(avg(rsrp), 1)                            AS avg_rsrp,
    round(100 * countIf(rsrp < -100) / count(rsrp), 1) AS poor_pct,
    count(rsrp) >= 30                              AS eligible,
    row_number() OVER (ORDER BY users DESC, cell)  AS r_users,
    row_number() OVER (PARTITION BY eligible ORDER BY avg(rsrp), cell) AS r_signal
FROM oss_lake.ue_trace_raw
WHERE event_ts >= t - INTERVAL 1 HOUR AND event_ts < t
  AND h3_res09 IS NOT NULL AND imsi IS NOT NULL
GROUP BY cell
Refined
SELECT
    h3                                             AS cell,
    uniq(imsi_hash)                                AS users,
    count()                                        AS samples,
    round(avg(rsrp), 1)                            AS avg_rsrp,
    round(100 * countIf(rsrp < -100) / count(rsrp), 1) AS poor_pct,
    count(rsrp) >= 30                              AS eligible,
    row_number() OVER (ORDER BY users DESC, cell)  AS r_users,
    row_number() OVER (PARTITION BY eligible ORDER BY avg(rsrp), cell) AS r_signal
FROM oss_lake.ue_trace
WHERE event_ts >= t - INTERVAL 1 HOUR AND event_ts < t
GROUP BY cell

bA uniq state is sized by distinct values per group, not by input type

Rather than hoping a type change shrinks it, choose the grain where user counts live (1 minute, 10 minutes).

One state: by input type and by distinct values

  • uniq · String · 300 distinct values1,203 B
  • uniq · UInt64 · 300 distinct values1,203 B
  • uniqCombined(12) · UInt64 · 300 distinct values2,651 B
  • uniq · UInt64 · 1,000 distinct values4,003 B
  • uniqCombined(12) · UInt64 · 1,000 distinct values2,651 B

All user-count states in this lab: 1-minute and 10-minute grain

  • 1 minute: 2,431,524 rows × 34 B on average79.3 MiB
  • 10 minutes: 263,946 rows × 193 B on average48.6 MiB

cBuild coarse rollups from fine ones

The 10-minute rollup is filled by an MV on the rows arriving in the 1-minute rollup, so raw data is aggregated once. A refreshable MV with APPEND would insert twice when its window is wider than its refresh interval.

types · cleaning · hash→cell × 1 min→cell × 10 min✓ equal to a 10-minute aggregate computed straight from raw (a deliberate-mismatch control fires)

dTime columns named _kst but typed UTC

Buckets and TTL shift by nine hours. Store instants with an explicit time zone.

event_kst 09:00≠UTC 00:00the same instant, only the label differs

eMany consumers need their own users and limits

Dashboards, an AI agent over MCP, a data catalogue and Kafka Connect batches each load the system differently.

  • read-only users with result-row and execution-time limits
  • separate users for the agent and the catalogue
  • larger batches or async inserts on the ingest side

Measured 2026-10-18, ClickHouse 26.9.13.15, on this lab's data and 05-lessons.sql.

Send data through it yourself

In the live demo, every press of "Send next minute" sends one minute of measurements through the four stages and refreshes the NOC map and the alerts.

cd usecase/oss-network-quality-lake
ui/up.sh

Open the live demo

Running it locally needs only Docker and Python 3. The demo on this site replays a few minutes recorded locally.