Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .unreleased/pr_10175
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Fixes: #10071 Prune the real-time branch of hierarchical continuous aggregates at any nesting depth
Thanks: @viniciusrsouza for reporting the issue
2 changes: 1 addition & 1 deletion src/nodes/chunk_append/exec.c
Original file line number Diff line number Diff line change
Expand Up @@ -1001,7 +1001,7 @@ ts_constify_restrictinfos(PlannerInfo *root, List *restrictinfos)
* transformations again. This might allow us to exclude chunks
* based on a parameterized time_bucket expression.
*/
Expr *additional_clause = ts_transform_time_bucket_comparison(constified);
Expr *additional_clause = ts_transform_nested_time_bucket_comparison(constified);
if (additional_clause != NULL)
{
/*
Expand Down
32 changes: 29 additions & 3 deletions src/planner/expand_hypertable.c
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,32 @@ ts_transform_time_bucket_comparison(Expr *node)
return &op->xpr;
}

/*
* Fully unwrap a (possibly nested) time_bucket() comparison by applying the
* single-level transform repeatedly until the bound reaches the raw column.
*
* ts_transform_time_bucket_comparison() only strips the outermost time_bucket().
* In hierarchical continuous aggregates the time_bucket() input column can be
* itself another time_bucket() call.
*
* Used both during hypertable expansion and from the chunk-append executor's
* runtime constification.
*/
Expr *
ts_transform_nested_time_bucket_comparison(Expr *qual)
{
Expr *nested = ts_transform_time_bucket_comparison(qual);
Expr *transformed = NULL;

while (nested != NULL)
{
transformed = nested;
nested = ts_transform_time_bucket_comparison(transformed);
}

return transformed;
}

/*
* Since baserestrictinfo is not yet set by the planner, we have to derive
* it ourselves. It's safe for us to miss some restrict info clauses (this
Expand Down Expand Up @@ -784,7 +810,7 @@ process_quals(Node *quals, CollectQualCtx *ctx, bool is_outer_join)
* check for time_bucket comparisons
* time_bucket(Const, time_colum) > Const
*/
Expr *transformed = ts_transform_time_bucket_comparison(qual);
Expr *transformed = ts_transform_nested_time_bucket_comparison(qual);
if (transformed != NULL)
{
/*
Expand Down Expand Up @@ -835,7 +861,7 @@ timebucket_annotate(Node *quals, CollectQualCtx *ctx)
* check for time_bucket comparisons
* time_bucket(Const, time_colum) > Const
*/
Expr *transformed = ts_transform_time_bucket_comparison(qual);
Expr *transformed = ts_transform_nested_time_bucket_comparison(qual);
if (transformed != NULL)
{
/*
Expand Down Expand Up @@ -1054,7 +1080,7 @@ get_simplified_restrictions(PlannerInfo *root, List *restrictions)
* check for time_bucket comparisons
* time_bucket(Const, time_colum) > Const
*/
Expr *transformed = ts_transform_time_bucket_comparison(qual);
Expr *transformed = ts_transform_nested_time_bucket_comparison(qual);
if (transformed != NULL)
{
/*
Expand Down
1 change: 1 addition & 0 deletions src/planner/planner.h
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ extern void ts_plan_expand_hypertable_chunks(Hypertable *ht, PlannerInfo *root,
bool include_osm);
extern void ts_plan_expand_timebucket_annotate(PlannerInfo *root, RelOptInfo *rel);
extern Expr *ts_transform_time_bucket_comparison(Expr *);
extern Expr *ts_transform_nested_time_bucket_comparison(Expr *);
extern Node *ts_constify_now(PlannerInfo *root, List *rtable, Node *node);
extern void ts_planner_constraint_cleanup(PlannerInfo *root, RelOptInfo *rel);
extern Node *ts_add_space_constraints(PlannerInfo *root, List *rtable, Node *node);
Expand Down
130 changes: 130 additions & 0 deletions tsl/test/expected/cagg_hierarchical_realtime-16.out
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
-- This file and its contents are licensed under the Timescale License.
-- Please see the included NOTICE for copyright information and
-- LICENSE-TIMESCALE for a copy of the license.
-- Regression test for #10071.
--
-- A real-time continuous aggregate (timescaledb.materialized_only = false) that
-- is three or more levels deep in a hierarchy (a cagg on a cagg on a cagg) must
-- prune its real-time branch when refreshed for a window entirely below the
-- watermark, exactly as a two-level cagg does. A refresh reads the cagg's
-- internal partial view, so EXPLAINing a below-watermark SELECT on that partial
-- view reproduces the refresh scan. Before the fix the real-time branch was
-- pruned only up to two levels; at three or more it re-scanned the whole
-- un-materialized tail of the base hypertable and discarded every row.
SET timezone TO PST8PDT;
SET max_parallel_workers_per_gather TO 0;
SET client_min_messages TO WARNING;
CREATE TABLE conditions (ts timestamptz NOT NULL, value double precision NOT NULL);
SELECT create_hypertable('conditions', by_range('ts', INTERVAL '7 days'));
create_hypertable
-------------------
(1,t)

INSERT INTO conditions
SELECT ts, 1.0
FROM generate_series(TIMESTAMPTZ '2026-01-01', TIMESTAMPTZ '2026-04-29', INTERVAL '1 hour') AS ts;
-- Level 1: 6-hour buckets on the raw hypertable
CREATE MATERIALIZED VIEW cagg_l1
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
SELECT time_bucket('6 hours', ts) AS ts, count(*) AS cnt, sum(value) AS sum_value
FROM conditions GROUP BY 1 WITH NO DATA;
-- Level 2: 1-day buckets on cagg_l1 (raw -> 6h -> 1day)
CREATE MATERIALIZED VIEW cagg_l2
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
SELECT time_bucket('1 day', ts) AS ts, sum(cnt) AS cnt, sum(sum_value) AS sum_value
FROM cagg_l1 GROUP BY 1 WITH NO DATA;
-- Level 3: 7-day buckets on cagg_l2 (raw -> 6h -> 1day -> 7day)
CREATE MATERIALIZED VIEW cagg_l3
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
SELECT time_bucket('7 days', ts) AS ts, sum(cnt) AS cnt, sum(sum_value) AS sum_value
FROM cagg_l2 GROUP BY 1 WITH NO DATA;
-- Level 4: 28-day buckets on cagg_l3 (raw -> 6h -> 1day -> 7day -> 28day)
CREATE MATERIALIZED VIEW cagg_l4
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
SELECT time_bucket('28 days', ts) AS ts, sum(cnt) AS cnt, sum(sum_value) AS sum_value
FROM cagg_l3 GROUP BY 1 WITH NO DATA;
-- Materialize everything up to 2026-04-01, leaving an un-materialized tail.
CALL refresh_continuous_aggregate('cagg_l1', NULL, TIMESTAMPTZ '2026-04-01');
CALL refresh_continuous_aggregate('cagg_l2', NULL, TIMESTAMPTZ '2026-04-01');
CALL refresh_continuous_aggregate('cagg_l3', NULL, TIMESTAMPTZ '2026-04-01');
CALL refresh_continuous_aggregate('cagg_l4', NULL, TIMESTAMPTZ '2026-04-01');
-- A refresh reads the cagg's internal partial view, so EXPLAINing a
-- below-watermark SELECT on that partial view reproduces the refresh scan. Look
-- up each partial view name.
SELECT format('%I.%I', partial_view_schema, partial_view_name) AS pv_l2
FROM _timescaledb_catalog.continuous_agg WHERE user_view_name = 'cagg_l2' \gset
SELECT format('%I.%I', partial_view_schema, partial_view_name) AS pv_l3
FROM _timescaledb_catalog.continuous_agg WHERE user_view_name = 'cagg_l3' \gset
SELECT format('%I.%I', partial_view_schema, partial_view_name) AS pv_l4
FROM _timescaledb_catalog.continuous_agg WHERE user_view_name = 'cagg_l4' \gset
\set PREFIX 'EXPLAIN (COSTS OFF, TIMING OFF, SUMMARY OFF)'
-- For a window entirely below the watermark the real-time branch must be pruned
-- at every depth: the plan should fold it to "One-Time Filter: false" and touch
-- only the materialized hypertable, never the base-hypertable (_hyper_1_*)
-- chunks.
-- 2 levels (control: pruned before and after the fix)
:PREFIX SELECT * FROM :pv_l2 WHERE ts >= TIMESTAMPTZ '2026-01-13' AND ts < TIMESTAMPTZ '2026-02-10';
--- QUERY PLAN ---
GroupAggregate
Group Key: (time_bucket('@ 1 day'::interval, _hyper_2_19_chunk.ts))
-> Sort
Sort Key: (time_bucket('@ 1 day'::interval, _hyper_2_19_chunk.ts))
-> Result
-> Append
-> Index Scan using _hyper_2_19_chunk__materialized_hypertable_2_ts_idx on _hyper_2_19_chunk
Index Cond: ((ts < 'Tue Mar 31 23:00:00 2026 PDT'::timestamp with time zone) AND (ts >= 'Tue Jan 13 00:00:00 2026 PST'::timestamp with time zone) AND (ts < 'Wed Feb 11 00:00:00 2026 PST'::timestamp with time zone))
Filter: ((time_bucket('@ 1 day'::interval, ts) >= 'Tue Jan 13 00:00:00 2026 PST'::timestamp with time zone) AND (time_bucket('@ 1 day'::interval, ts) < 'Tue Feb 10 00:00:00 2026 PST'::timestamp with time zone))
-> HashAggregate
Group Key: time_bucket('@ 6 hours'::interval, ts)
-> Result
One-Time Filter: false

-- 3 levels (the bug: real-time branch must be pruned)
:PREFIX SELECT * FROM :pv_l3 WHERE ts >= TIMESTAMPTZ '2026-01-13' AND ts < TIMESTAMPTZ '2026-02-10';
--- QUERY PLAN ---
GroupAggregate
Group Key: (time_bucket('@ 7 days'::interval, _hyper_3_21_chunk.ts))
-> Sort
Sort Key: (time_bucket('@ 7 days'::interval, _hyper_3_21_chunk.ts))
-> Result
-> Append
-> Index Scan using _hyper_3_21_chunk__materialized_hypertable_3_ts_idx on _hyper_3_21_chunk
Index Cond: ((ts < 'Tue Mar 31 17:00:00 2026 PDT'::timestamp with time zone) AND (ts >= 'Tue Jan 13 00:00:00 2026 PST'::timestamp with time zone) AND (ts < 'Tue Feb 17 00:00:00 2026 PST'::timestamp with time zone))
Filter: ((time_bucket('@ 7 days'::interval, ts) >= 'Tue Jan 13 00:00:00 2026 PST'::timestamp with time zone) AND (time_bucket('@ 7 days'::interval, ts) < 'Tue Feb 10 00:00:00 2026 PST'::timestamp with time zone))
-> HashAggregate
Group Key: time_bucket('@ 1 day'::interval, (time_bucket('@ 6 hours'::interval, ts)))
-> Result
-> HashAggregate
Group Key: time_bucket('@ 6 hours'::interval, ts)
-> Result
One-Time Filter: false

-- 4 levels
:PREFIX SELECT * FROM :pv_l4 WHERE ts >= TIMESTAMPTZ '2026-01-13' AND ts < TIMESTAMPTZ '2026-02-10';
--- QUERY PLAN ---
GroupAggregate
Group Key: (time_bucket('@ 28 days'::interval, _materialized_hypertable_4.ts))
-> Sort
Sort Key: (time_bucket('@ 28 days'::interval, _materialized_hypertable_4.ts))
-> Result
-> Append
-> Append
-> Index Scan using _hyper_4_22_chunk__materialized_hypertable_4_ts_idx on _hyper_4_22_chunk
Index Cond: ((ts < 'Sun Mar 29 17:00:00 2026 PDT'::timestamp with time zone) AND (ts >= 'Tue Jan 13 00:00:00 2026 PST'::timestamp with time zone) AND (ts < 'Tue Mar 10 01:00:00 2026 PDT'::timestamp with time zone))
Filter: ((time_bucket('@ 28 days'::interval, ts) >= 'Tue Jan 13 00:00:00 2026 PST'::timestamp with time zone) AND (time_bucket('@ 28 days'::interval, ts) < 'Tue Feb 10 00:00:00 2026 PST'::timestamp with time zone))
-> Index Scan using _hyper_4_23_chunk__materialized_hypertable_4_ts_idx on _hyper_4_23_chunk
Index Cond: ((ts < 'Sun Mar 29 17:00:00 2026 PDT'::timestamp with time zone) AND (ts >= 'Tue Jan 13 00:00:00 2026 PST'::timestamp with time zone) AND (ts < 'Tue Mar 10 01:00:00 2026 PDT'::timestamp with time zone))
Filter: ((time_bucket('@ 28 days'::interval, ts) >= 'Tue Jan 13 00:00:00 2026 PST'::timestamp with time zone) AND (time_bucket('@ 28 days'::interval, ts) < 'Tue Feb 10 00:00:00 2026 PST'::timestamp with time zone))
-> HashAggregate
Group Key: time_bucket('@ 7 days'::interval, (time_bucket('@ 1 day'::interval, (time_bucket('@ 6 hours'::interval, ts)))))
-> Result
-> HashAggregate
Group Key: time_bucket('@ 1 day'::interval, (time_bucket('@ 6 hours'::interval, ts)))
-> Result
-> HashAggregate
Group Key: time_bucket('@ 6 hours'::interval, ts)
-> Result
One-Time Filter: false

DROP TABLE conditions CASCADE;
RESET client_min_messages;
Loading
Loading