Continuous aggregates

Rollup termaterialisasi yang menyegarkan dirinya sendiri, beserta kebijakan refresh.

Daftar isi

Continuous aggregate adalah materialized view khusus TimescaleDB yang menyimpan hasil agregasi time_bucket secara fisik, lalu menyegarkan dirinya sendiri pada jadwal yang ditetapkan oleh kebijakan refresh. Topik ini menjelaskan continuous aggregate sebagai objek tersendiri, cara membuatnya dengan klausa WITH (timescaledb.continuous = true), kebijakan add_continuous_aggregate_policy yang mengotomatiskan materialisasi, serta mode real-time aggregates yang menggabungkan baris termaterialisasi dengan baris segar. Setelah membaca topik ini, sebuah hypertable berisi jutaan titik mentah dapat dilengkapi dengan satu atau beberapa continuous aggregate yang melayani dashboard harian tanpa memindai ulang tabel mentah pada setiap query.

Continuous aggregate sebagai view termaterialisasi#

Pada Postgres biasa, materialized view adalah snapshot hasil query yang harus disegarkan secara manual dengan REFRESH MATERIALIZED VIEW. TimescaleDB memperluas konsep itu menjadi continuous aggregate, yaitu materialized view yang mengetahui ketergantungannya pada kolom waktu hypertable, sehingga penyegaran dapat dibatasi pada rentang waktu tertentu tanpa menghitung ulang seluruh isi. Materialisasi disimpan pada hypertable internal, sehingga hasilnya juga dapat dipartisi, dikompresi, dan dipertahankan oleh kebijakan retensi layaknya hypertable biasa.

Karena hasilnya disimpan fisik, query ke continuous aggregate tidak membaca hypertable sumber secara langsung, melainkan membaca tabel materialisasi yang berisi jauh lebih sedikit baris. Sebuah hypertable dengan satu titik per menit selama tujuh hari menyimpan 10.080 baris, sedangkan continuous aggregate harian yang sama menyimpan tujuh baris. Selisih ini bukan sekadar optimasi kecil, sebab dashboard yang menampilkan grafik mingguan tidak lagi memicu pemindaian 10.080 baris setiap kali dimuat, melainkan cukup memindai tujuh baris termaterialisasi.

Membuat continuous aggregate#

Pembuatan continuous aggregate dimulai dari sebuah hypertable yang sudah ada. Sintaksisnya menyerupai CREATE MATERIALIZED VIEW biasa, dengan tambahan klausa WITH (timescaledb.continuous = true) yang menandai view sebagai continuous aggregate, serta WITH NO DATA yang mencegah materialisasi langsung pada saat pembuatan. Setelah view dibuat, materialisasi dijalankan oleh kebijakan refresh atau secara manual melalui CALL refresh_continuous_aggregate. Perintah berikut tidak dijalankan di sandbox karena memerlukan Postgres dengan TimescaleDB aktif, sehingga hanya ditampilkan sebagai referensi sintaksis.

postgres Pembuatan continuous aggregate harian
-- hypertable 'suhu_sensor' sudah ada dan berisi telemetri menitan
CREATE MATERIALIZED VIEW suhu_harian
WITH (timescaledb.continuous = true) AS
SELECT
    time_bucket('1 day', ts) AS bucket,
    sensor_id,
    min(suhu_c)  AS suhu_min,
    max(suhu_c)  AS suhu_max,
    avg(suhu_c)  AS suhu_avg,
    count(*)     AS titik
FROM suhu_sensor
GROUP BY bucket, sensor_id
WITH NO DATA;

-- materialisasi pertama untuk rentang tertentu, misalnya satu minggu
CALL refresh_continuous_aggregate('suhu_harian',
    '2026-11-01 00:00:00+00',
    '2026-11-08 00:00:00+00');
text Keluaran

Kueri SELECT dari continuous aggregate ditulis persis seperti query dari tabel biasa, sehingga aplikasi yang sudah membaca dari hypertable sumber dapat dialihkan ke continuous aggregate tanpa mengubah logika agregasi. Yang berubah hanyalah nama objek pada klausa FROM, sedangkan kolom, tipe data, dan urutan hasil tetap sama.

Kebijakan refresh otomatis#

Materialisasi yang dijalankan manual dengan CALL refresh_continuous_aggregate cukup untuk pengisian awal, tetapi tidak memadai untuk operasi harian, sebab rentang waktu baru selalu mengalir terus. TimescaleDB menyediakan add_continuous_aggregate_policy untuk mengotomatiskan materialisasi. Kebijakan ini menerima tiga argumen utama, yaitu start_offset, end_offset, dan schedule_interval. Argumen start_offset menentukan seberapa jauh ke masa lalu materialisasi dimulai, sedangkan end_offset menentukan seberapa dekat ke waktu sekarang materialisasi berhenti. Kombinasi keduanya membentuk jendela refresh yang diproses pada setiap iterasi.

Pilihan end_offset menentukan sifat materialisasi. Nilai INTERVAL '1 hour' berarti materialisasi tidak menyentuh data berusia kurang dari satu jam, sehingga late arrival yang berusia beberapa menit masih dapat masuk pada iterasi berikutnya. Nilai INTERVAL '5 minutes' berarti materialisasi menyentuh data yang sangat segar, sehingga risiko pemrosesan ulang bucket menjadi lebih tinggi. Argumen schedule_interval menentukan seberapa sering pekerjaan refresh dijalankan oleh job latar belakang TimescaleDB, dan nilainya sebaiknya tidak lebih pendek dari end_offset, sebab menyegarkan data yang belum berubah menjadi pekerjaan sia-sia. Perintah berikut tidak dijalankan di sandbox dan hanya menjadi referensi sintaksis.

postgres Kebijakan refresh continuous aggregate
SELECT add_continuous_aggregate_policy(
    'suhu_harian',
    start_offset      => INTERVAL '7 days',
    end_offset        => INTERVAL '1 hour',
    schedule_interval => INTERVAL '1 hour'
);
text Keluaran
add_continuous_aggregate_policy
1012

Simulasi materialisasi dengan pandas#

Karena continuous aggregate pada dasarnya adalah agregasi time_bucket yang disimpan fisik, padanan paling dekat pada Python adalah operasi resample di pandas. Skrip berikut membangkitkan satu minggu telemetri dengan interval satu menit, lalu mematerialisasikannya menjadi bucket per jam dan per hari. Skrip juga menghitung ukuran penyimpanan kasar dengan asumsi overhead tuple Postgres, sehingga perbandingan antara baris mentah dan baris teragregasi menjadi nyata. Skrip tidak menjalankan SQL apa pun.

python simulasi_cagg.py
"""Simulasi materialisasi continuous aggregates pada TimescaleDB.

Skrip ini tidak menjalankan SQL. Tujuannya memperlihatkan apa yang dilakukan
continuous aggregates TimescaleDB dengan membandingkan ukuran hasil
materialisasi pada beberapa resolusi (mentah, per jam, per hari). Skrip
membangkitkan satu minggu telemetri dengan interval satu menit, lalu
mengagregasi dengan pandas resample sebagai padanan time_bucket + GROUP BY,
dan menghitung ukuran penyimpanan kasar baris mentah vs baris teragregasi.
"""
from __future__ import annotations

import pandas as pd

AWAL = "2026-11-01 00:00:00"
HARI = 7


def bangun_telemetri() -> pd.DataFrame:
    ts = pd.date_range(AWAL, periods=HARI * 1_440, freq="1min", tz="UTC")
    suhu = 22.0 + (ts.hour.to_series().reset_index(drop=True) - 12).abs() * 0.2
    return pd.DataFrame({"ts": ts, "sensor_id": "S01", "suhu_c": suhu.round(2)})


def materialisasi(df: pd.DataFrame, freq: str, label: str) -> pd.DataFrame:
    return (
        df.set_index("ts")
        .groupby("sensor_id")
        .resample(freq)["suhu_c"]
        .agg(["min", "max", "mean", "count"])
        .reset_index()
        .rename(columns={"level_1": "ts"})
        .assign(resolusi=label)
    )


def ukuran_kasar(df: pd.DataFrame, byte_per_baris: int) -> int:
    # timestamptz 8 byte + text sensor_id rata-rata 6 byte + double 8 byte +
    # overhead tuple Postgres ~24 byte. Pendekatan kasar, bukan pengukuran disk.
    return len(df) * byte_per_baris


def main() -> None:
    raw = bangun_telemetri()
    agg_jam = materialisasi(raw, "1h", "1 jam")
    agg_hari = materialisasi(raw, "1D", "1 hari")

    print("hypertable: suhu_sensor (1 titik/menit, 7 hari)")
    print(f"rentang: {raw['ts'].min()} -> {raw['ts'].max()}")
    print()

    ringkas = pd.DataFrame({
        "resolusi": ["mentah (1 menit)", "1 jam", "1 hari"],
        "baris": [len(raw), len(agg_jam), len(agg_hari)],
        "kolom_agregat": ["-", "min,max,avg,count", "min,max,avg,count"],
    })
    print(ringkas.to_string(index=False))
    print()

    OVERHEAD_MENTAH = 8 + 6 + 8 + 24  # ts + sensor_id + suhu_c + tuple overhead
    OVERHEAD_AGG = 8 + 6 + (8 * 4) + 24  # ts + sensor_id + 4 agregat + overhead

    mentah_b = ukuran_kasar(raw, OVERHEAD_MENTAH)
    jam_b = ukuran_kasar(agg_jam, OVERHEAD_AGG)
    hari_b = ukuran_kasar(agg_hari, OVERHEAD_AGG)

    print(f"ukuran kasar baris mentah:        {mentah_b:>9} byte "
          f"({mentah_b / 1024:.1f} KB)")
    print(f"ukuran kasar cagg per jam:        {jam_b:>9} byte "
          f"({jam_b / 1024:.1f} KB)")
    print(f"ukuran kasar cagg per hari:       {hari_b:>9} byte "
          f"({hari_b / 1024:.1f} KB)")
    print()
    print(f"rasio baris mentah -> cagg per jam:  "
          f"{len(raw) / len(agg_jam):.1f}x lebih sedikit baris")
    print(f"rasio baris mentah -> cagg per hari: "
          f"{len(raw) / len(agg_hari):.1f}x lebih sedikit baris")
    print(f"rasio ukuran kasar mentah -> cagg per hari: "
          f"{mentah_b / hari_b:.1f}x lebih kecil")
    print()
    print("padanan SQL TimescaleDB:")
    print("  CREATE MATERIALIZED VIEW suhu_harian")
    print("  WITH (timescaledb.continuous = true) AS")
    print("  SELECT time_bucket('1 day', ts) AS bucket,")
    print("         sensor_id,")
    print("         min(suhu_c), max(suhu_c), avg(suhu_c), count(*)")
    print("  FROM suhu_sensor")
    print("  GROUP BY bucket, sensor_id")
    print("  WITH NO DATA;")


if __name__ == "__main__":
    main()
text Keluaran
hypertable: suhu_sensor (1 titik/menit, 7 hari)
rentang: 2026-11-01 00:00:00+00:00 -> 2026-11-07 23:59:00+00:00

        resolusi  baris     kolom_agregat
mentah (1 menit)  10080                 -
           1 jam    168 min,max,avg,count
          1 hari      7 min,max,avg,count

ukuran kasar baris mentah:           463680 byte (452.8 KB)
ukuran kasar cagg per jam:            11760 byte (11.5 KB)
ukuran kasar cagg per hari:             490 byte (0.5 KB)

rasio baris mentah -> cagg per jam:  60.0x lebih sedikit baris
rasio baris mentah -> cagg per hari: 1440.0x lebih sedikit baris
rasio ukuran kasar mentah -> cagg per hari: 946.3x lebih kecil

padanan SQL TimescaleDB:
  CREATE MATERIALIZED VIEW suhu_harian
  WITH (timescaledb.continuous = true) AS
  SELECT time_bucket('1 day', ts) AS bucket,
         sensor_id,
         min(suhu_c), max(suhu_c), avg(suhu_c), count(*)
  FROM suhu_sensor
  GROUP BY bucket, sensor_id
  WITH NO DATA;

Keluaran di atas memperlihatkan dua hal. Pertama, jumlah baris turun dari 10.080 baris mentah menjadi 168 baris pada resolusi jam dan tujuh baris pada resolusi hari, sehingga query yang menyentuh continuous aggregate hanya memindai sebagian kecil dari apa yang dipindai pada hypertable sumber. Kedua, ukuran penyimpanan kasar turun dari 452,8 KB menjadi 0,5 KB, rasio yang sekitar 946 kali, sehingga biaya penyimpanan dashboard harian menjadi sangat murah. Perlu dicatat bahwa ukuran di sini hanya pendekatan berdasarkan overhead tuple Postgres, bukan pengukuran sebenarnya pada disk, sebab pengukuran disk memerlukan server Postgres aktif yang tidak tersedia di sandbox ini.

Real-time aggregates: live dan materialized#

Continuous aggregate pada TimescaleDB 2.x mendukung mode real-time aggregates melalui opsi materialized_only = false. Pada mode ini, query ke continuous aggregate menggabungkan dua sumber, yaitu baris termaterialisasi yang sudah ada pada tabel materialisasi, dan baris segar yang belum termaterialisasi pada hypertable sumber. Penggabungan dilakukan oleh ekstensi secara transparan, sehingga query tetap ditulis seperti query dari view biasa. Mode ini berguna untuk dashboard yang menampilkan data hingga menit terakhir tanpa menunggu iterasi refresh berikutnya.

Kontrol atas mode ini diatur melalui ALTER MATERIALIZED VIEW ... SET (timescaledb.materialized_only = false). Pemilihan mode memengaruhi keterlambatan versus beban query. Mode materialized_only = true memberikan keterlambatan hingga schedule_interval, tetapi beban query rendah karena seluruhnya membaca tabel materialisasi. Mode materialized_only = false memberikan keterlambatan nol, tetapi beban query naik karena bagian segar tetap memindai hypertable sumber. Pada continuous aggregate harian, mode real-time umumnya digunakan bila pengguna dashboard menuntut melihat hari berjalan, sedangkan mode materialized-only digunakan bila continuous aggregate hanya melayani laporan harian final.

Alur refresh dalam satu pandangan#

Diagram berikut meringkas hubungan antara hypertable sumber, kebijakan refresh, dan continuous aggregate. Kebijakan refresh membaca rentang chunk mentah yang ditentukan oleh start_offset dan end_offset, lalu menulis ulang baris teragregasi pada continuous aggregate. Query yang datang setelahnya membaca continuous aggregate, bukan hypertable sumber, sehingga beban query turun secara substansial.

Hypertable (mentah)chunk hari ini10.080 barischunk kemarin10.080 barischunk lusa10.080 barisRefresh policystart_offset: 7 hariend_offset: 1 jamschedule: tiap 1 jamContinuous aggregatebucket harian7 baris teragregasimin, max, avg, counttersimpan sebagai tabelbacatulis ulangRefresh policy membaca rentang chunk mentah, lalu menulis ulang baris teragregasi pada continuous aggregate
Alur refresh continuous aggregate: kebijakan refresh membaca rentang chunk mentah, lalu menulis ulang baris teragregasi pada continuous aggregate

Kesalahan umum#

  • Menetapkan end_offset => INTERVAL '0 minutes' pada continuous aggregate harian. Akibatnya setiap late arrival memicu penulisan ulang bucket hari berjalan, sehingga beban refresh menjadi berat tanpa manfaat.
  • Memilih schedule_interval yang jauh lebih pendek dari end_offset. Kebijakan refresh dijalankan berulang tanpa ada data baru yang masuk ke rentang materialisasi, sehingga pekerjaan background worker terbuang.
  • Mengharapkan continuous aggregate otomatis mengikuti perubahan skema hypertable sumber. Penambahan kolom pada hypertable tidak menyebarkan diri ke continuous aggregate yang sudah ada, sehingga continuous aggregate perlu dibuat ulang bila query agregasinya berubah.

Ringkasan#

  • Continuous aggregate adalah materialized view khusus TimescaleDB yang menyimpan hasil time_bucket secara fisik dan disegarkan oleh kebijakan refresh.
  • Kebijakan add_continuous_aggregate_policy mengotomatiskan materialisasi melalui tiga argumen, yaitu start_offset, end_offset, dan schedule_interval.
  • Mode real-time aggregates menggabungkan baris termaterialisasi dengan baris segar, sehingga continuous aggregate dapat melayani query hingga menit terakhir tanpa menunggu iterasi refresh.

Referensi#