Work KT 30 Jul 27 Aug probe mqtt.koveh.com

Schwarz campus · MQTT shape · 28 Aug 2026 01:35 Berlin

The telegram is a counter. We store it like a warehouse.

Live S3 leftover parquet, Athena building_counters_v2_current, and the 23:17 UTC collector log. No extra Solace session. The campus is not the Glue SNS error. The campus is a cumulative integer arriving once a minute.

Snapshot 28 Aug 2026 01:31 Europe/Berlin · airflow-rc read of s3://realcube-integrations · Athena SELECT only · mqtt.koveh.com

Profit if we stop treating MQTT as a data lake. One minute of campus traffic is ~100 rows, ~6 KB of values. Prod writes ~100 objects of 4262 bytes each, then Glue Spark 2× G.1X runs ~4–5 minutes to append Iceberg. Iceberg August alone already has 20 000+ part files (~80 MB). DuckDB last-wins over day-partitioned parquet is the same query the DAG already writes in SQL. Glue is the expensive way to get ROW_NUMBER() OVER (PARTITION BY meter ORDER BY ts DESC).
100 metersstatus ONLINE · 01:20 Berlin
1 row / file4262 bytes parquet · leftover after Glue
1449 msgs / 14 minDAG 23:17 UTC · ~103 / min
28 719 rowsA01 in Athena, August, through 23:22 UTC

What we do wrong

MQTT is not kWh. It is the meter’s lifetime count. Consumption is a subtraction of two counts at two timestamps. Health is the same subtraction over 15 minutes and 60 minutes. Daily Exposé is midnight minus previous midnight. That is the whole product.

We instead write every telegram as its own S3 object, boot Spark to merge them, keep Iceberg at MaxConcurrentRuns=1, then run two Airflow clocks against that one writer. Iurii’s SNS Max concurrent runs exceeded is that clock collision. It is not campus-down. Skip-if-running is already on the live 3/18 merger. Do not raise MaxConcurrentRuns.

What the telegram isWhat prod does
Payloadone values[] + one glt_values[], no device clockpandas → parquet → PutObject per message
Timereceive time, Berlin wall clockfilename message_YYYYMMDD_HHMMSS_µs.parquet; Iceberg timestamp stored UTC-naive
Needlast count, last time, count at Berlin midnight~6 000 tiny files / hour, then Spark, then Athena last-wins anyway
60-min TEcount(now) − count(now − 60 min)last_60_min pointer is ~75 min; last_45_min is the 60-min slot

How MQTT actually looks

Subscribe neuberger/t/sit/neuberger/# on pssolsch01.messaging.smartcity.hn:8893, user realcube-client. Topic suffix is /{building}/meter/{meter}. Body is JSON. Collector zips values with glt_values. Every leftover file tonight has exactly one row. The original telegram is therefore length-1 arrays. There is no timestamp inside the JSON. We stamp Berlin now on receive.

{
  "topic": "neuberger/t/sit/neuberger/A-Bau/meter/A01",
  "payload": {
    "values": [
      { "type": "energy", "unit": "kWh", "value": 179086.90625 }
    ],
    "glt_values": [
      { "glt_name": "A01" }
    ]
  },
  "received_at": "2026-08-28T01:23:13.193483+02:00"
}

S3 object that telegram became: schwarz-group/2026/8/28/A-Bau/A01/raw/message_20260828_012313_193483.parquet, 4262 bytes, schema building, glt_name, type, unit, decimal256 value, timestamp. Glue later maps that to Iceberg meter, meter_type, meter_unit, meter_value, timestamp.

A01 is a counter — 13 leftover minutes, live

Glue last succeeded 23:21–23:26 UTC, so leftover raw files are only the minutes after that merge. A01 ticks every 60.0 s. Value only goes up. Minute delta is ~0.31–0.42 kWh. That is load, not a new “energy event”.

Berlin fileCount (kWh)Δ min
01:23:13179 086.90625
01:24:13179 087.218750.3125
01:25:13179 087.5468750.328125
01:26:13179 087.9218750.375
01:27:13179 088.343750.421875
01:28:13179 088.68750.34375
01:29:13179 089.0156250.328125
01:30:13179 089.3281250.3125
01:31:13179 089.7031250.375
01:32:13179 090.1093750.40625
01:33:13179 090.43750.328125
01:34:13179 090.750.3125
01:35:13179 091.1093750.359375

Athena on the same meter, August: 28 719 rows, first 2026-08-01 00:00:41.299, last 2026-08-27 23:22:13.230 UTC (qid ca7677b1-…, scanned 20 304 059 bytes). 27 days × 1440 min = 38 880 slots. Coverage ~74 %, close to the 14-minute listen inside a 17-minute DAG slot (14/17 = 82 %) plus merge lag. We already drop ~3 minutes of telegrams every slot by design.

Same minute, other meters (leftover parquet 01:23 Berlin)

Topic metertypeunitvalue
A-Bau/A01energykWh179 086.90625
A-Bau/A02energykWh0
A-Bau/D03energykWh0
B-Bau/B08energykWh3.79
H-Bau/I14coolingMWh3 957.909912
H-Bau/H06water685.25
R-Bau/R1heatingMWh133.594
T-Bau/T17water0
U-Bau/U1energyMWh36 181.19
L-Bau/L1energykWh6 569 473
M-Bau/M16energykWh8 699 833
I14 is two topics. Collector log 23:17 UTC: Received: H-Bau/I14 every minute (real cooling MWh), and six seconds later Failed to decode JSON on topic neuberger/t/sit/neuberger/I-Bau/meter/I14: with an empty payload. Status JSON lists both H-Bau/I14 and I-Bau/I14. The empty I-Bau telegram is not “MQTT down”. It is a second path with no body. Do not open another Solace client to “debug I14”.

A02, D03, T17 reading 0 is campus state (OPEN_ISSUES §6), not ingest loss. Native unit stays on the wire: U1 and I14 and R1 are MWh, A01 is kWh, H06 is m³. DAG multiplies MWh × 1000 on Athena read. Thresholds stay native. That contract is already correct. The waste is the file layout around it.

The algorithms. Three queries. No Spark.

Let v(m, t) be the cumulative count for meter m at receive time t. Payload has no t; t is Berlin now. All product numbers are differences of v.

MQTT JSON v, unit, type append row t = received_at current(m) = v last kWh_day = v(EOD)−v(SOD) TE_60 = v(t)−v(t−60m) Meter Management building_energies threshold mail
-- current reading (Iceberg “_current” table)
SELECT * FROM (
  SELECT *,
         row_number() OVER (PARTITION BY building, meter ORDER BY received_at DESC) AS rn
  FROM read_parquet('dt=*/*.parquet')
) WHERE rn = 1;

-- Berlin day consumption
-- SOD = last v with received_at <= Berlin midnight
-- EOD = last v with received_at < next midnight
kWh_day(m, d) = v(m, EOD_d) - v(m, SOD_d)

-- 60-minute window, time-normalized
TE_60(m, t) = (v(m, t) - v(m, t - 60 min)) * (60 min / actual_gap)

Prod already has the first query in reader.py as Athena SQL. Prod already has the second as query_daily_readings (min_by / max_by per Berlin day). Prod does not have the third: it subtracts a rotated pointer that is 75 minutes old and does not scale by gap. That is leftover TE while 15/60 live values are already under the limit.

File layout that matches the math. Keep messages in RAM. Flush one parquet per window: dt=YYYY-MM-DD/part-HHMMSS.parquet, zstd, all meters, ~100 rows. Optional daily compact: concatenate that day’s parts. Query with DuckDB on the collector host or Athena over the same prefix. Iceberg is optional history, not the write path for 20 GB campus parquet.

This pipe vs that pipe

Prod now

Solace MQTTneuberger/# · ~1 telegram / meter / min
↓ 14 min listen · DAG 0/17
1 parquet / telegramschwarz-group/Y/M/D/{bau}/{meter}/raw/ · 4262 B
↓ leftover 100 meters × 11 files = 1100 objects after last Glue
Glue Spark 2× G.1Xrealcube-integration-schwarz-merge · 247–387 s billed
↓ Iceberg year/month parts (20 000+ files in 2026-08, truncated count)
Athena last-winsbuilding_counters_v2_current · then health + daily upsert

Same MQTT, stop at parquet

Solace MQTTsame topic, same JSON, same units
↓ buffer in process
1 parquet / windowdt=day/part-HHMMSS.parquet · ~100 rows
↓ DuckDB / pyarrow
last-wins + midnight deltano Glue job, no MaxConcurrentRuns
health + daily upsertunchanged RC API / sc-healthcheck contract

Glue tonight, last eight runs, all SUCCEEDED, schedule :03/:21/:39/:57 only (skip-if-running is holding): 281 s, 247 s, 387 s, 272 s, 270 s, 252 s, 272 s, 274 s. Worker G.1X × 2. The 387 s run is the :57 overlapping the next :03 in wall time — that is why a second clock at :00 used to SNS-fail. v4 0/15 stays paused, not on RC disk.

Hetzner 40 s probe on 27 Aug was a safety window on the shared user, not the architecture. It proved one 5029-byte file holds 100 meters. That page is archive. This page is the algorithm.

Logs (28 Aug morning, no extra MQTT client)

mqtt_status.json

ONLINE · mqtt_online true · meters_sending_data true · error null timestamp 2026-08-28T01:20:15.434161+02:00 meters_detected 101 keys including H-Bau/I14 and I-Bau/I14

Collector · dag_id=schwarz_mqtt_to_s3_bucket_v2 · run scheduled__2026-08-27T23:17:00+00:00

23:20:26 URL pssolsch01.messaging.smartcity.hn:8893 · Username realcube-client 23:20:26 Extra topic neuberger/t/sit/neuberger/# 23:20:26 MQTT CONNACK received — subscribing 23:20:32–23:21:12 Waiting for first message… 5s … 45s 23:21:12 First message received at 2026-08-28T01:21:12.993793+02:00 23:21:13 Received: A-Bau/A12 23:21:16 Received: H-Bau/I14 23:21:22 Failed to decode JSON on topic …/I-Bau/meter/I14: ← empty body, every minute 23:35:17 Collection complete. Total messages: 1449

CONNACK is instant. First telegram waits ~47 s because the meters publish on a one-minute tick, not because the broker is dead. Task wall time is the 14-minute listen plus that wait. That is why Airflow shows ~14–15 min. It is not compute.

Glue · realcube-integration-schwarz-merge · jr_08b5b4c7…

SUCCEEDED 2026-08-27 23:21:14 → 23:26:02 UTC · ExecutionTime 281 s · G.1X × 2 · attempt 0 DAG schwarz_s3_parquet_to_iceberg_merger · scheduled__2026-08-27T23:21:00+00:00 · trigger then sensor until SUCCEEDED

S3 leftover at 01:31 Berlin (after that Glue)

prefix schwarz-group/2026/8/28/ · 1100 objects · 4 688 783 bytes 100 meters × 11 files each · size min 4257 / median 4262 / max 4283 Iceberg live path s3://realcube-integrations/iceberg/schwarz_group/building_counters_v2/ year=2026/month=8/ · counted 20 000 objects then stopped · ~80 010 292 bytes on that prefix alone

Leftovers on work.koveh.com

The hub still sells the 40 s Hetzner probe as the ingest story. That page is too thin. This page replaces it. Other Schwarz pages are a different layer (Berichte, CO₂, Peter loops). They stay. They were not updated for Glue skip or for the dual I14 topic.

PageStateDo
/schwarz-mqtt/ · mqtt.koveh.comthis pageCanonical: telegram, algorithms, logs, Iurii copy
/schwarz-mqtt-ingest/archive27 Aug 40 s probe. Keep. Do not treat as architecture
/schwarz-mqtt-kt/stale edges30 Jul Solace names still right. Missing Glue skip, 60-min pointer, I14 empty topic
/peter-12aug/still trueLoop A 15 min liveness ≠ Loop B daily kWh
/rdm-255-schwarz/other layerWarehouse CO₂ / fact_energy, not MQTT files
/hub-consistency/4 AugDoes not mention tiny parquet or Iceberg part explosion
/airflow-updates/PR listGlue skip was PR #30, merged. Ingest cutover is not a PR yet

Text for Iurii

Short. Numbers from this snapshot. Ask is: keep MaxConcurrentRuns=1; optional JobRunQueuingEnabled; do not start a second Glue clock. Ingest cutover is our DAG, not his job.

Iurii — on the Glue SNS (Max concurrent runs exceeded). What we were doing wrong: two Airflow clocks both called StartJobRun on realcube-integration-schwarz-merge. Iceberg MaxConcurrentRuns stays 1. A :57 run still goes ~4–5 min, so a :00 start was refused. That is not campus MQTT down. What the MQTT data actually is (checked on leftover parquet + collector log, no extra Solace session): • topic neuberger/t/sit/neuberger/{building}/meter/{meter} • JSON values[] + glt_values[] — cumulative counter, no device timestamp • ~100 meters, ~1 telegram/min, 1 row per 4262-byte parquet • last collector window 23:17 UTC: CONNACK ok, 1449 messages in 14 min, status ONLINE • I-Bau/I14 publishes an empty payload every minute; H-Bau/I14 is the real cooling MWh — skip, do not page Glue last 8 runs tonight: all SUCCEEDED, only :03/:21/:39/:57. Skip-if-running is on the live merger. v4 0/15 stays paused. Please do not raise MaxConcurrentRuns. Optional: JobRunQueuingEnabled=true as a backstop. Drop any leftover 15-min starter if one still exists on your side. Separately (our DAG, not Glue IAM): writing one S3 object per telegram then Spark-merging ~20 GB campus parquet is the cost. Next ingest should buffer and write one parquet per window; last-wins is already the Athena query. We will not cut over without a host diff. Page: https://mqtt.koveh.com/ (same as https://work.koveh.com/schwarz-mqtt/)

Do not

Code for the compact lake already exists under /root/realcube/bugfixes/schwarz-sane-collector/schwarz_ingest/lake.py (DuckDB last-wins, start-of-day). Prod collector remains dags/schwarz/mqtt/collector.py until a surgical host deploy.