diff --git a/.unreleased/pr_9119 b/.unreleased/pr_9119 new file mode 100644 index 00000000000..eed2eac754f --- /dev/null +++ b/.unreleased/pr_9119 @@ -0,0 +1 @@ +Implements: #9119 Support calendar-based chunking diff --git a/sql/ddl_api.sql b/sql/ddl_api.sql index 62235733c32..6a31d13915c 100644 --- a/sql/ddl_api.sql +++ b/sql/ddl_api.sql @@ -34,7 +34,8 @@ CREATE OR REPLACE FUNCTION @extschema@.create_hypertable( migrate_data BOOLEAN = FALSE, chunk_target_size TEXT = NULL, chunk_sizing_func REGPROC = '_timescaledb_functions.calculate_chunk_interval'::regproc, - time_partitioning_func REGPROC = NULL + time_partitioning_func REGPROC = NULL, + chunk_time_origin "any" = NULL::timestamptz ) RETURNS TABLE(hypertable_id INT, schema_name NAME, table_name NAME, created BOOL) AS '@MODULE_PATHNAME@', 'ts_hypertable_create' LANGUAGE C VOLATILE; -- A generalized hypertable creation API that can be used to convert a PostgreSQL table @@ -53,7 +54,6 @@ CREATE OR REPLACE FUNCTION @extschema@.create_hypertable( migrate_data BOOLEAN = FALSE ) RETURNS TABLE(hypertable_id INT, created BOOL) AS '@MODULE_PATHNAME@', 'ts_hypertable_create_general' LANGUAGE C VOLATILE; - -- Set adaptive chunking. To disable, set chunk_target_size => 'off'. CREATE OR REPLACE FUNCTION @extschema@.set_adaptive_chunking( hypertable REGCLASS, @@ -73,7 +73,9 @@ CREATE OR REPLACE FUNCTION @extschema@.set_adaptive_chunking( CREATE OR REPLACE FUNCTION @extschema@.set_chunk_time_interval( hypertable REGCLASS, chunk_time_interval ANYELEMENT, - dimension_name NAME = NULL + dimension_name NAME = NULL, + chunk_time_origin "any" = NULL::TIMESTAMPTZ, + calendar_chunking BOOL = NULL ) RETURNS VOID AS '@MODULE_PATHNAME@', 'ts_dimension_set_interval' LANGUAGE C VOLATILE; -- Update partition_interval for a hypertable. @@ -87,7 +89,9 @@ CREATE OR REPLACE FUNCTION @extschema@.set_chunk_time_interval( CREATE OR REPLACE FUNCTION @extschema@.set_partitioning_interval( hypertable REGCLASS, partition_interval ANYELEMENT, - dimension_name NAME = NULL + dimension_name NAME = NULL, + partition_origin "any" = NULL::TIMESTAMPTZ, + calendar_chunking BOOL = NULL ) RETURNS VOID AS '@MODULE_PATHNAME@', 'ts_dimension_set_interval' LANGUAGE C VOLATILE; CREATE OR REPLACE FUNCTION @extschema@.set_number_partitions( @@ -133,7 +137,8 @@ CREATE OR REPLACE FUNCTION @extschema@.add_dimension( number_partitions INTEGER = NULL, chunk_time_interval ANYELEMENT = NULL::BIGINT, partitioning_func REGPROC = NULL, - if_not_exists BOOLEAN = FALSE + if_not_exists BOOLEAN = FALSE, + chunk_time_origin "any" = NULL::timestamptz ) RETURNS TABLE(dimension_id INT, schema_name NAME, table_name NAME, column_name NAME, created BOOL) AS '@MODULE_PATHNAME@', 'ts_dimension_add' LANGUAGE C VOLATILE; @@ -184,7 +189,8 @@ CREATE OR REPLACE FUNCTION @extschema@.by_hash(column_name NAME, number_partitio CREATE OR REPLACE FUNCTION @extschema@.by_range(column_name NAME, partition_interval ANYELEMENT = NULL::bigint, - partition_func regproc = NULL) + partition_func regproc = NULL, + partition_origin "any" = NULL::TIMESTAMPTZ) RETURNS _timescaledb_internal.dimension_info LANGUAGE C AS '@MODULE_PATHNAME@', 'ts_range_dimension'; diff --git a/sql/pre_install/tables.sql b/sql/pre_install/tables.sql index 572dabebee0..c9a38a40097 100644 --- a/sql/pre_install/tables.sql +++ b/sql/pre_install/tables.sql @@ -94,8 +94,14 @@ CREATE TABLE _timescaledb_catalog.dimension ( partitioning_func_schema name NULL, partitioning_func name NULL, -- open dimensions (e.g., time) - interval_length bigint NULL, - -- compress interval is used by rollup procedure during compression + -- Origin for chunk alignment, stored as Unix epoch microseconds (not PostgreSQL epoch). + -- For timestamp types: microseconds since 1970-01-01 00:00:00 UTC. + -- For integer types: the raw integer value (currently not supported). + -- NULL means use the default origin (0 for legacy chunking, 2001-01-01 for calendar chunking). + interval_origin bigint NULL, + interval interval NULL, -- calendar-based interval (variable-length, e.g., '1 month'). + interval_length bigint NULL, -- fixed-size interval in microseconds for timestamp types, or raw value for integer types. + -- compress_interval_length is used by rollup procedure during compression -- in order to merge multiple chunks into a single one compress_interval_length bigint NULL, integer_now_func_schema name NULL, @@ -104,7 +110,7 @@ CREATE TABLE _timescaledb_catalog.dimension ( CONSTRAINT dimension_pkey PRIMARY KEY (id), CONSTRAINT dimension_hypertable_id_column_name_key UNIQUE (hypertable_id, column_name), CONSTRAINT dimension_check CHECK ((partitioning_func_schema IS NULL AND partitioning_func IS NULL) OR (partitioning_func_schema IS NOT NULL AND partitioning_func IS NOT NULL)), - CONSTRAINT dimension_check1 CHECK ((num_slices IS NULL AND interval_length IS NOT NULL) OR (num_slices IS NOT NULL AND interval_length IS NULL)), + CONSTRAINT dimension_check1 CHECK ((num_slices IS NULL AND (interval_length IS NOT NULL OR interval IS NOT NULL)) OR (num_slices IS NOT NULL AND interval_length IS NULL AND interval IS NULL)), CONSTRAINT dimension_check2 CHECK ((integer_now_func_schema IS NULL AND integer_now_func IS NULL) OR (integer_now_func_schema IS NOT NULL AND integer_now_func IS NOT NULL)), CONSTRAINT dimension_interval_length_check CHECK (interval_length IS NULL OR interval_length > 0), CONSTRAINT dimension_compress_interval_length_check CHECK (compress_interval_length IS NULL OR compress_interval_length > 0), diff --git a/sql/updates/latest-dev.sql b/sql/updates/latest-dev.sql index 1016fef2ab3..9837ae7430a 100644 --- a/sql/updates/latest-dev.sql +++ b/sql/updates/latest-dev.sql @@ -1,4 +1,21 @@ DROP VIEW IF EXISTS timescaledb_information.dimensions; + +-- Drop old function signatures that are being replaced with new signatures +-- that include partition_origin parameter +DROP FUNCTION IF EXISTS @extschema@.create_hypertable( + regclass, name, name, integer, name, name, anyelement, + boolean, boolean, regproc, boolean, text, regproc, regproc +); +DROP FUNCTION IF EXISTS @extschema@.set_chunk_time_interval(regclass, anyelement, name); +DROP FUNCTION IF EXISTS @extschema@.set_partitioning_interval(regclass, anyelement, name); +DROP FUNCTION IF EXISTS @extschema@.add_dimension(regclass, name, integer, anyelement, regproc, boolean); +DROP FUNCTION IF EXISTS @extschema@.by_range(name, anyelement, regproc); + +-- Drop old function signatures that are being replaced with new signatures +-- that include calendar_chunking parameter +DROP FUNCTION IF EXISTS @extschema@.set_chunk_time_interval(regclass, anyelement, name, "any"); +DROP FUNCTION IF EXISTS @extschema@.set_partitioning_interval(regclass, anyelement, name, "any"); + -- Block update if CAggs in old format are found DO $$ @@ -246,3 +263,111 @@ DROP FUNCTION timescaledb_experimental.time_bucket_ng(bucket_width INTERVAL, ts DROP FUNCTION timescaledb_experimental.time_bucket_ng(bucket_width INTERVAL, ts TIMESTAMPTZ); DROP FUNCTION timescaledb_experimental.time_bucket_ng(bucket_width INTERVAL, ts TIMESTAMPTZ, origin TIMESTAMPTZ); + +-- +-- Rebuild the catalog table `_timescaledb_catalog.dimension` to add calendar-based chunking columns +-- +-- New columns: +-- interval_origin: origin timestamp for chunk alignment (stored as bigint microseconds) +-- interval: calendar-based interval (e.g., '1 month', '1 year') +-- + +-- Drop views that depend on the dimension table +DROP VIEW IF EXISTS timescaledb_information.hypertable_columnstore_settings; +DROP VIEW IF EXISTS timescaledb_information.hypertable_compression_settings; +DROP VIEW IF EXISTS timescaledb_information.chunks; + +-- Drop foreign key constraints referencing dimension table +ALTER TABLE _timescaledb_catalog.dimension_slice + DROP CONSTRAINT dimension_slice_dimension_id_fkey; + +-- Drop the dimension table and its sequence from the extension so we can rebuild it +ALTER EXTENSION timescaledb + DROP TABLE _timescaledb_catalog.dimension; +ALTER EXTENSION timescaledb + DROP SEQUENCE _timescaledb_catalog.dimension_id_seq; + +-- Save existing data +CREATE TABLE _timescaledb_catalog._tmp_dimension AS + SELECT + id, + hypertable_id, + column_name, + column_type, + aligned, + num_slices, + partitioning_func_schema, + partitioning_func, + NULL::bigint AS interval_origin, + NULL::interval AS interval, + interval_length, + compress_interval_length, + integer_now_func_schema, + integer_now_func + FROM + _timescaledb_catalog.dimension + ORDER BY + id; + +-- Drop old table +DROP TABLE _timescaledb_catalog.dimension; + +-- Create new table with correct column order +CREATE TABLE _timescaledb_catalog.dimension ( + id serial NOT NULL, + hypertable_id integer NOT NULL, + column_name name NOT NULL, + column_type REGTYPE NOT NULL, + aligned boolean NOT NULL, + -- closed dimensions + num_slices smallint NULL, + partitioning_func_schema name NULL, + partitioning_func name NULL, + -- open dimensions (e.g., time) + interval_origin bigint NULL, + interval interval NULL, + interval_length bigint NULL, + -- compress_interval_length for rollup during compression + compress_interval_length bigint NULL, + integer_now_func_schema name NULL, + integer_now_func name NULL, + -- table constraints + CONSTRAINT dimension_pkey PRIMARY KEY (id), + CONSTRAINT dimension_hypertable_id_column_name_key UNIQUE (hypertable_id, column_name), + CONSTRAINT dimension_check CHECK ((partitioning_func_schema IS NULL AND partitioning_func IS NULL) OR (partitioning_func_schema IS NOT NULL AND partitioning_func IS NOT NULL)), + CONSTRAINT dimension_check1 CHECK ((num_slices IS NULL AND (interval_length IS NOT NULL OR interval IS NOT NULL)) OR (num_slices IS NOT NULL AND interval_length IS NULL AND interval IS NULL)), + CONSTRAINT dimension_check2 CHECK ((integer_now_func_schema IS NULL AND integer_now_func IS NULL) OR (integer_now_func_schema IS NOT NULL AND integer_now_func IS NOT NULL)), + CONSTRAINT dimension_interval_length_check CHECK (interval_length IS NULL OR interval_length > 0), + CONSTRAINT dimension_compress_interval_length_check CHECK (compress_interval_length IS NULL OR compress_interval_length > 0), + CONSTRAINT dimension_hypertable_id_fkey FOREIGN KEY (hypertable_id) REFERENCES _timescaledb_catalog.hypertable (id) ON DELETE CASCADE +); + +-- Copy data from temp table +INSERT INTO _timescaledb_catalog.dimension +SELECT * FROM _timescaledb_catalog._tmp_dimension; + +-- Drop temp table +DROP TABLE _timescaledb_catalog._tmp_dimension; + +-- Restore sequence value +SELECT setval(pg_get_serial_sequence('_timescaledb_catalog.dimension', 'id'), + max(id), true) +FROM _timescaledb_catalog.dimension; + +-- Re-add foreign key constraint +ALTER TABLE _timescaledb_catalog.dimension_slice + ADD CONSTRAINT dimension_slice_dimension_id_fkey + FOREIGN KEY (dimension_id) REFERENCES _timescaledb_catalog.dimension (id) ON DELETE CASCADE; + +-- Register for pg_dump +SELECT pg_catalog.pg_extension_config_dump('_timescaledb_catalog.dimension', ''); +SELECT pg_catalog.pg_extension_config_dump(pg_get_serial_sequence('_timescaledb_catalog.dimension', 'id'), ''); + +GRANT SELECT ON TABLE _timescaledb_catalog.dimension TO PUBLIC; +GRANT SELECT ON SEQUENCE _timescaledb_catalog.dimension_id_seq TO PUBLIC; + +ANALYZE _timescaledb_catalog.dimension; + +-- +-- END Rebuild the catalog table `_timescaledb_catalog.dimension` +-- diff --git a/sql/updates/reverse-dev.sql b/sql/updates/reverse-dev.sql index 64a9106a31d..9dfd20e7cfc 100644 --- a/sql/updates/reverse-dev.sql +++ b/sql/updates/reverse-dev.sql @@ -1,4 +1,118 @@ DROP VIEW IF EXISTS timescaledb_information.dimensions; + +-- Drop new function signatures that include origin parameter +DROP FUNCTION IF EXISTS @extschema@.create_hypertable( + regclass, name, name, integer, name, name, anyelement, + boolean, boolean, regproc, boolean, text, regproc, regproc, "any" +); +DROP FUNCTION IF EXISTS @extschema@.add_dimension(regclass, name, integer, anyelement, regproc, boolean, "any"); +DROP FUNCTION IF EXISTS @extschema@.by_range(name, anyelement, regproc, "any"); + +-- Drop new function signatures that include calendar_chunking parameter +DROP FUNCTION IF EXISTS @extschema@.set_chunk_time_interval(regclass, anyelement, name, "any", bool); +DROP FUNCTION IF EXISTS @extschema@.set_partitioning_interval(regclass, anyelement, name, "any", bool); + +-- +-- Rebuild the catalog table `_timescaledb_catalog.dimension` to remove interval_origin column +-- + +-- Drop views that depend on the dimension table +DROP VIEW IF EXISTS timescaledb_information.hypertables; +DROP VIEW IF EXISTS timescaledb_information.hypertable_columnstore_settings; +DROP VIEW IF EXISTS timescaledb_information.hypertable_compression_settings; +DROP VIEW IF EXISTS timescaledb_information.chunks; + +-- Drop foreign key constraints referencing dimension table +ALTER TABLE _timescaledb_catalog.dimension_slice + DROP CONSTRAINT dimension_slice_dimension_id_fkey; + +-- Drop the dimension table and its sequence from the extension so we can rebuild it +ALTER EXTENSION timescaledb + DROP TABLE _timescaledb_catalog.dimension; +ALTER EXTENSION timescaledb + DROP SEQUENCE _timescaledb_catalog.dimension_id_seq; + +-- Save existing data without interval_origin column +CREATE TABLE _timescaledb_catalog._tmp_dimension AS + SELECT + id, + hypertable_id, + column_name, + column_type, + aligned, + num_slices, + partitioning_func_schema, + partitioning_func, + interval_length, + compress_interval_length, + integer_now_func_schema, + integer_now_func + FROM + _timescaledb_catalog.dimension + ORDER BY + id; + +-- Drop old table +DROP TABLE _timescaledb_catalog.dimension; + +-- Create table without interval_origin column +CREATE TABLE _timescaledb_catalog.dimension ( + id serial NOT NULL, + hypertable_id integer NOT NULL, + column_name name NOT NULL, + column_type REGTYPE NOT NULL, + aligned boolean NOT NULL, + -- closed dimensions + num_slices smallint NULL, + partitioning_func_schema name NULL, + partitioning_func name NULL, + -- open dimensions (e.g., time) + interval_length bigint NULL, + -- compress interval for rollup during compression + compress_interval_length bigint NULL, + integer_now_func_schema name NULL, + integer_now_func name NULL, + -- table constraints + CONSTRAINT dimension_pkey PRIMARY KEY (id), + CONSTRAINT dimension_hypertable_id_column_name_key UNIQUE (hypertable_id, column_name), + CONSTRAINT dimension_check CHECK ((partitioning_func_schema IS NULL AND partitioning_func IS NULL) OR (partitioning_func_schema IS NOT NULL AND partitioning_func IS NOT NULL)), + CONSTRAINT dimension_check1 CHECK ((num_slices IS NULL AND interval_length IS NOT NULL) OR (num_slices IS NOT NULL AND interval_length IS NULL)), + CONSTRAINT dimension_check2 CHECK ((integer_now_func_schema IS NULL AND integer_now_func IS NULL) OR (integer_now_func_schema IS NOT NULL AND integer_now_func IS NOT NULL)), + CONSTRAINT dimension_interval_length_check CHECK (interval_length IS NULL OR interval_length > 0), + CONSTRAINT dimension_compress_interval_length_check CHECK (compress_interval_length IS NULL OR compress_interval_length > 0), + CONSTRAINT dimension_hypertable_id_fkey FOREIGN KEY (hypertable_id) REFERENCES _timescaledb_catalog.hypertable (id) ON DELETE CASCADE +); + +-- Copy data from temp table +INSERT INTO _timescaledb_catalog.dimension +SELECT * FROM _timescaledb_catalog._tmp_dimension; + +-- Drop temp table +DROP TABLE _timescaledb_catalog._tmp_dimension; + +-- Restore sequence value +SELECT setval(pg_get_serial_sequence('_timescaledb_catalog.dimension', 'id'), + max(id), true) +FROM _timescaledb_catalog.dimension; + +-- Re-add foreign key constraint +ALTER TABLE _timescaledb_catalog.dimension_slice + ADD CONSTRAINT dimension_slice_dimension_id_fkey + FOREIGN KEY (dimension_id) REFERENCES _timescaledb_catalog.dimension (id) ON DELETE CASCADE; + +-- Register for pg_dump +SELECT pg_catalog.pg_extension_config_dump('_timescaledb_catalog.dimension', ''); +SELECT pg_catalog.pg_extension_config_dump(pg_get_serial_sequence('_timescaledb_catalog.dimension', 'id'), ''); + +GRANT SELECT ON TABLE _timescaledb_catalog.dimension TO PUBLIC; +GRANT SELECT ON SEQUENCE _timescaledb_catalog.dimension_id_seq TO PUBLIC; + +ANALYZE _timescaledb_catalog.dimension; + +-- +-- END Rebuild the catalog table `_timescaledb_catalog.dimension` +-- + -- -- Rebuild the catalog table `_timescaledb_catalog.continuous_agg` to add `finalized` column -- @@ -113,4 +227,3 @@ DROP FUNCTION IF EXISTS _timescaledb_functions.compressed_data_to_array(_timesca DROP FUNCTION IF EXISTS _timescaledb_functions.compressed_data_column_size(_timescaledb_internal.compressed_data, ANYELEMENT); DROP FUNCTION IF EXISTS _timescaledb_functions.estimate_uncompressed_size; - diff --git a/sql/views.sql b/sql/views.sql index 342d6dd1ceb..964e7bc6d71 100644 --- a/sql/views.sql +++ b/sql/views.sql @@ -232,17 +232,21 @@ SELECT ht.schema_name AS hypertable_schema, rank() OVER (PARTITION BY hypertable_id ORDER BY dim.id) AS dimension_number, dim.column_name, dim.column_type, - CASE WHEN dim.interval_length IS NULL THEN + CASE WHEN dim.interval_length IS NULL AND dim.interval IS NULL THEN 'Space' ELSE 'Time' END AS dimension_type, - CASE WHEN dim.interval_length IS NOT NULL THEN - CASE WHEN dim.column_type = ANY(ARRAY['timestamp','timestamptz','date', 'uuid']::regtype[]) THEN + CASE WHEN dim.column_type = ANY(ARRAY['timestamp','timestamptz','date', 'uuid']::regtype[]) THEN + CASE WHEN dim.interval IS NOT NULL THEN + dim.interval + WHEN dim.interval_length IS NOT NULL THEN _timescaledb_functions.to_interval(dim.interval_length) ELSE NULL END + ELSE + NULL END AS time_interval, CASE WHEN dim.interval_length IS NOT NULL THEN CASE WHEN dim.column_type = ANY(ARRAY['timestamp','timestamptz','date', 'uuid']::regtype[]) THEN @@ -251,6 +255,26 @@ SELECT ht.schema_name AS hypertable_schema, dim.interval_length END END AS integer_interval, + CASE WHEN dim.interval_origin IS NOT NULL THEN + CASE WHEN dim.column_type = ANY(ARRAY['timestamp','timestamptz','uuid']::regtype[]) THEN + _timescaledb_functions.to_timestamp(dim.interval_origin) + WHEN dim.column_type = 'date'::regtype THEN + _timescaledb_functions.to_timestamp(dim.interval_origin)::date::timestamptz + ELSE + NULL + END + ELSE + NULL + END AS time_origin, + CASE WHEN dim.interval_origin IS NOT NULL THEN + CASE WHEN dim.column_type = ANY(ARRAY['timestamp','timestamptz','date','uuid']::regtype[]) THEN + NULL + ELSE + dim.interval_origin + END + ELSE + NULL + END AS integer_origin, dim.integer_now_func, dim.num_slices AS num_partitions FROM _timescaledb_catalog.hypertable ht, diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 44d4d37e11d..2f271456589 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -9,6 +9,7 @@ set(SOURCES chunk_constraint.c chunk_index.c chunk_insert_state.c + chunk_range.c chunk_scan.c chunk_tuple_routing.c constraint.c diff --git a/src/chunk_range.c b/src/chunk_range.c new file mode 100644 index 00000000000..30646432b9a --- /dev/null +++ b/src/chunk_range.c @@ -0,0 +1,738 @@ +/* + * This file and its contents are licensed under the Apache License 2.0. + * Please see the included NOTICE for copyright information and + * LICENSE-APACHE for a copy of the license. + */ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "chunk_range.h" + +/* + * Convert a timestamp Datum to a broken-down pg_tm structure. + * + * This utility function handles the conversion of a timestamp/timestamptz Datum + * to its constituent date/time components in a pg_tm struct. It properly handles + * timezone conversion for TIMESTAMPTZOID types. + * + * For TIMESTAMPTZ, pass the session timezone in attimezone. + * For TIMESTAMP, pass NULL for attimezone. + * + * Returns 0 on success, non-zero on error. + */ +static int +timestamp_datum_to_tm(Datum timestamp, Oid type, struct pg_tm *tm, fsec_t *fsec, pg_tz *attimezone) +{ + int tz; + const char *tzn; + + memset(tm, 0, sizeof(struct pg_tm)); + *fsec = 0; + + if (type == TIMESTAMPTZOID) + return timestamp2tm(DatumGetTimestampTz(timestamp), &tz, tm, fsec, &tzn, attimezone); + else + return timestamp2tm(DatumGetTimestamp(timestamp), NULL, tm, fsec, NULL, NULL); +} + +/* + * Convert pg_tm structures to chunk range boundaries. + * + * Converts tm_start and tm_end to timestamps and returns a ChunkRange. + * If conversion fails (out of range), uses -infinity/+infinity as fallbacks. + * These will be properly clamped to DIMENSION_SLICE_MINVALUE/MAXVALUE + * when converted to a DimensionSlice. + */ +static ChunkRange +tm_to_chunk_range(const struct pg_tm *tm_start, const struct pg_tm *tm_end, fsec_t fsec, Oid type, + pg_tz *attimezone) +{ + if (type == TIMESTAMPTZOID) + { + TimestampTz ts_start, ts_end; + int tz_start = DetermineTimeZoneOffset((struct pg_tm *) tm_start, attimezone); + if (tm2timestamp((struct pg_tm *) tm_start, fsec, &tz_start, &ts_start) != 0) + ts_start = DT_NOBEGIN; + + int tz_end = DetermineTimeZoneOffset((struct pg_tm *) tm_end, attimezone); + if (tm2timestamp((struct pg_tm *) tm_end, fsec, &tz_end, &ts_end) != 0) + ts_end = DT_NOEND; + + return ts_chunk_range_from_timestamptz(ts_start, ts_end); + } + else + { + Timestamp ts_start, ts_end; + Assert(type == TIMESTAMPOID); + + if (tm2timestamp((struct pg_tm *) tm_start, fsec, NULL, &ts_start) != 0) + ts_start = DT_NOBEGIN; + + if (tm2timestamp((struct pg_tm *) tm_end, fsec, NULL, &ts_end) != 0) + ts_end = DT_NOEND; + + return ts_chunk_range_from_timestamp(ts_start, ts_end); + } +} + +/* + * Calculate the chunk range for month-based intervals. + * + * Aligns the timestamp to the beginning of a month bucket based on the origin. + * Preserves the origin's day-of-month and time components. + * Out-of-range chunk boundaries are handled by tm_to_chunk_range which clamps + * to -infinity/+infinity when tm2timestamp fails. + */ +static ChunkRange +calc_month_chunk_range(Datum timestamp, Oid type, const Interval *interval, Datum origin) +{ + Assert(interval->month > 0 && interval->day == 0 && interval->time == 0); + + pg_tz *attimezone = (type == TIMESTAMPTZOID) ? session_timezone : NULL; + struct pg_tm tt, *tm = &tt; + struct pg_tm origin_tt, *origin_tm = &origin_tt; + fsec_t fsec, origin_fsec; + + if (timestamp_datum_to_tm(origin, type, origin_tm, &origin_fsec, attimezone) != 0) + ereport(ERROR, + (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE), + errmsg("origin timestamp out of range"))); + + if (timestamp_datum_to_tm(timestamp, type, tm, &fsec, attimezone) != 0) + ereport(ERROR, + (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE), errmsg("timestamp out of range"))); + + int year_diff = tm->tm_year - origin_tm->tm_year; + int month_diff = tm->tm_mon - origin_tm->tm_mon; + + /* + * Check if the timestamp's day/time is before the origin's day/time within the + * same month. If so, we need to subtract one month from the total month difference + * because the timestamp falls in the previous bucket. + */ + bool before_origin_day_time = + (tm->tm_mday < origin_tm->tm_mday) || + (tm->tm_mday == origin_tm->tm_mday && + (tm->tm_hour < origin_tm->tm_hour || + (tm->tm_hour == origin_tm->tm_hour && + (tm->tm_min < origin_tm->tm_min || + (tm->tm_min == origin_tm->tm_min && + (tm->tm_sec < origin_tm->tm_sec || + (tm->tm_sec == origin_tm->tm_sec && fsec < origin_fsec))))))); + if (before_origin_day_time) + month_diff--; + + int total_month_diff = year_diff * MONTHS_PER_YEAR + month_diff; + + int bucket_num; + if (total_month_diff >= 0) + bucket_num = total_month_diff / interval->month; + else + bucket_num = (total_month_diff - interval->month + 1) / interval->month; + + int month_offset = bucket_num * interval->month; + int total_months_from_jan = origin_tm->tm_mon - 1 + month_offset; + + int full_years, remaining_months; + if (total_months_from_jan >= 0) + { + full_years = total_months_from_jan / MONTHS_PER_YEAR; + remaining_months = total_months_from_jan % MONTHS_PER_YEAR; + } + else + { + full_years = (total_months_from_jan - MONTHS_PER_YEAR + 1) / MONTHS_PER_YEAR; + remaining_months = total_months_from_jan - (full_years * MONTHS_PER_YEAR); + } + + struct pg_tm tm_start; + memset(&tm_start, 0, sizeof(tm_start)); + tm_start.tm_year = origin_tm->tm_year + full_years; + tm_start.tm_mon = 1 + remaining_months; + tm_start.tm_mday = origin_tm->tm_mday; + tm_start.tm_hour = origin_tm->tm_hour; + tm_start.tm_min = origin_tm->tm_min; + tm_start.tm_sec = origin_tm->tm_sec; + + /* + * Adding interval->month can overflow if interval is extremely large. + * On overflow, clamp to INT_MAX - tm2timestamp will fail and we'll + * get +infinity for the chunk end. + */ + int end_total_months_from_jan; + if (pg_add_s32_overflow(total_months_from_jan, interval->month, &end_total_months_from_jan)) + end_total_months_from_jan = PG_INT32_MAX; + + int end_full_years, end_remaining_months; + if (end_total_months_from_jan >= 0) + { + end_full_years = end_total_months_from_jan / MONTHS_PER_YEAR; + end_remaining_months = end_total_months_from_jan % MONTHS_PER_YEAR; + } + else + { + end_full_years = (end_total_months_from_jan - MONTHS_PER_YEAR + 1) / MONTHS_PER_YEAR; + end_remaining_months = end_total_months_from_jan - (end_full_years * MONTHS_PER_YEAR); + } + + struct pg_tm tm_end; + memset(&tm_end, 0, sizeof(tm_end)); + tm_end.tm_year = origin_tm->tm_year + end_full_years; + tm_end.tm_mon = 1 + end_remaining_months; + tm_end.tm_mday = origin_tm->tm_mday; + tm_end.tm_hour = origin_tm->tm_hour; + tm_end.tm_min = origin_tm->tm_min; + tm_end.tm_sec = origin_tm->tm_sec; + + return tm_to_chunk_range(&tm_start, &tm_end, origin_fsec, type, attimezone); +} + +/* + * Calculate the chunk range for day-based intervals. + * + * Uses Julian day numbers to align the timestamp to the beginning of a day bucket. + * Preserves the origin's time-of-day components. + * Out-of-range chunk boundaries are handled by tm_to_chunk_range which clamps + * to -infinity/+infinity when tm2timestamp fails. + */ +static ChunkRange +calc_day_chunk_range(Datum timestamp, Oid type, const Interval *interval, Datum origin) +{ + Assert(interval->day > 0 && interval->month == 0 && interval->time == 0); + + pg_tz *attimezone = (type == TIMESTAMPTZOID) ? session_timezone : NULL; + struct pg_tm tt, *tm = &tt; + struct pg_tm origin_tt, *origin_tm = &origin_tt; + fsec_t fsec, origin_fsec; + + if (timestamp_datum_to_tm(origin, type, origin_tm, &origin_fsec, attimezone) != 0) + ereport(ERROR, + (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE), + errmsg("origin timestamp out of range"))); + + if (timestamp_datum_to_tm(timestamp, type, tm, &fsec, attimezone) != 0) + ereport(ERROR, + (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE), errmsg("timestamp out of range"))); + + int ts_julian = date2j(tm->tm_year, tm->tm_mon, tm->tm_mday); + int origin_julian = date2j(origin_tm->tm_year, origin_tm->tm_mon, origin_tm->tm_mday); + + /* + * Check if the timestamp's time-of-day is before the origin's time-of-day. + * If so, we need to subtract one day from the day count because the timestamp + * falls in the previous bucket. + */ + bool before_origin_time = (tm->tm_hour < origin_tm->tm_hour) || + (tm->tm_hour == origin_tm->tm_hour && + (tm->tm_min < origin_tm->tm_min || + (tm->tm_min == origin_tm->tm_min && + (tm->tm_sec < origin_tm->tm_sec || + (tm->tm_sec == origin_tm->tm_sec && fsec < origin_fsec))))); + + int days_since_origin = ts_julian - origin_julian; + + /* Adjust for time-of-day offset if timestamp is before origin time */ + if (before_origin_time) + days_since_origin--; + + int bucket_num; + if (days_since_origin >= 0) + bucket_num = days_since_origin / interval->day; + else + bucket_num = (days_since_origin - interval->day + 1) / interval->day; + + int day_offset = bucket_num * interval->day; + int start_julian = origin_julian + day_offset; + + struct pg_tm tm_start; + memset(&tm_start, 0, sizeof(tm_start)); + j2date(start_julian, &tm_start.tm_year, &tm_start.tm_mon, &tm_start.tm_mday); + tm_start.tm_hour = origin_tm->tm_hour; + tm_start.tm_min = origin_tm->tm_min; + tm_start.tm_sec = origin_tm->tm_sec; + + /* + * Adding interval->day can overflow if interval is extremely large. + * On overflow, clamp to INT_MAX - tm2timestamp will fail and we'll + * get +infinity for the chunk end. + */ + int end_julian; + if (pg_add_s32_overflow(start_julian, interval->day, &end_julian)) + end_julian = PG_INT32_MAX; + + struct pg_tm tm_end; + memset(&tm_end, 0, sizeof(tm_end)); + j2date(end_julian, &tm_end.tm_year, &tm_end.tm_mon, &tm_end.tm_mday); + tm_end.tm_hour = origin_tm->tm_hour; + tm_end.tm_min = origin_tm->tm_min; + tm_end.tm_sec = origin_tm->tm_sec; + + return tm_to_chunk_range(&tm_start, &tm_end, origin_fsec, type, attimezone); +} + +/* + * Calculate the chunk range for time-based (sub-day) intervals. + * + * The function is always called with an Interval that is pure time (fixed/non-variable). + * + * Uses microsecond arithmetic for precise alignment. + * + * The bucket calculation logic below is borrowed from PostgreSQL's + * timestamptz_bin() function in src/backend/utils/adt/timestamp.c + */ +static ChunkRange +calc_sub_day_chunk_range(Datum timestamp, Oid type, const Interval *interval, Datum origin) +{ + Assert(interval->time > 0 && interval->month == 0 && interval->day == 0); + + ChunkRange range = { .type = type }; + TimestampTz result, stride_usecs, tm_diff, tm_modulo, tm_delta; + TimestampTz ts_val = + (type == TIMESTAMPTZOID) ? DatumGetTimestampTz(timestamp) : DatumGetTimestamp(timestamp); + TimestampTz origin_val = + (type == TIMESTAMPTZOID) ? DatumGetTimestampTz(origin) : DatumGetTimestamp(origin); + + /* + * The function is always called with day==0 (see Assert), but keep this multiplication anyway + * to keep code similar to timestamptz_bin(). + */ + if (unlikely(pg_mul_s64_overflow(interval->day, USECS_PER_DAY, &stride_usecs)) || + unlikely(pg_add_s64_overflow(stride_usecs, interval->time, &stride_usecs))) + ereport(ERROR, + (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE), errmsg("interval out of range"))); + + if (stride_usecs <= 0) + ereport(ERROR, + (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE), + errmsg("stride must be greater than zero"))); + + if (unlikely(pg_sub_s64_overflow(ts_val, origin_val, &tm_diff))) + ereport(ERROR, + (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE), errmsg("interval out of range"))); + + /* These calculations cannot overflow */ + tm_modulo = tm_diff % stride_usecs; + tm_delta = tm_diff - tm_modulo; + result = origin_val + tm_delta; + + /* + * We want to round towards -infinity, not 0, when tm_diff is negative and + * not a multiple of stride_usecs. This adjustment *can* cause overflow, + * since the result might now be out of the range origin .. timestamp. + */ + if (tm_modulo < 0) + { + if (unlikely(pg_sub_s64_overflow(result, stride_usecs, &result)) || + !IS_VALID_TIMESTAMP(result)) + ereport(ERROR, + (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE), + errmsg("timestamp out of range"))); + } + + /* For time-only intervals, range_end is just result + time microseconds */ + TimestampTz range_end; + if (pg_add_s64_overflow(result, interval->time, &range_end) || !IS_VALID_TIMESTAMP(range_end)) + range_end = DT_NOEND; + + if (type == TIMESTAMPTZOID) + range = ts_chunk_range_from_timestamptz(result, range_end); + else + range = ts_chunk_range_from_timestamp(result, range_end); + + return range; +} + +/* + * Saturating timestamp + interval arithmetic. + * + * Similar to PostgreSQL's timestamp_pl_interval/timestamptz_pl_interval, + * but instead of throwing errors on overflow, clamps to min/max timestamp. + * This is needed for chunk range calculations where we iterate near time + * boundaries and don't want to fail on overflow. + * + * For TIMESTAMPTZ, timezone conversion is handled using the provided timezone. + * For TIMESTAMP, pass NULL for attimezone. + * + * Returns true if the operation succeeded without overflow, false if clamped. + */ +static bool +timestamp_interval_add_saturating(TimestampTz timestamp, const Interval *interval, + pg_tz *attimezone, TimestampTz *result) +{ + struct pg_tm tt, *tm = &tt; + fsec_t fsec; + int tz; + + /* Handle infinite timestamps */ + if (TIMESTAMP_NOT_FINITE(timestamp)) + { + *result = timestamp; + return true; + } + + /* Decompose timestamp to broken-down time */ + if (attimezone != NULL) + { + /* TIMESTAMPTZ: convert with timezone */ + if (timestamp2tm(timestamp, &tz, tm, &fsec, NULL, attimezone) != 0) + { + *result = (timestamp < 0) ? DT_NOBEGIN : DT_NOEND; + return false; + } + } + else + { + /* TIMESTAMP: no timezone */ + if (timestamp2tm(timestamp, NULL, tm, &fsec, NULL, NULL) != 0) + { + *result = (timestamp < 0) ? DT_NOBEGIN : DT_NOEND; + return false; + } + } + + /* Add months with overflow check */ + if (interval->month != 0) + { + int64 total_months = (int64) tm->tm_year * MONTHS_PER_YEAR + tm->tm_mon + interval->month; + + /* Check for year overflow */ + if (total_months / MONTHS_PER_YEAR > PG_INT32_MAX || + total_months / MONTHS_PER_YEAR < PG_INT32_MIN) + { + *result = (interval->month > 0) ? DT_NOEND : DT_NOBEGIN; + return false; + } + + tm->tm_year = (int) (total_months / MONTHS_PER_YEAR); + tm->tm_mon = total_months % MONTHS_PER_YEAR; + + /* Handle negative month result */ + if (tm->tm_mon <= 0) + { + tm->tm_mon += MONTHS_PER_YEAR; + tm->tm_year--; + } + + /* Adjust day if it exceeds days in resulting month */ + int days_in_month = day_tab[isleap(tm->tm_year)][tm->tm_mon - 1]; + if (tm->tm_mday > days_in_month) + tm->tm_mday = days_in_month; + } + + /* Add days */ + if (interval->day != 0) + { + int julian = date2j(tm->tm_year, tm->tm_mon, tm->tm_mday); + int new_julian; + + if (pg_add_s32_overflow(julian, interval->day, &new_julian)) + { + *result = (interval->day > 0) ? DT_NOEND : DT_NOBEGIN; + return false; + } + + j2date(new_julian, &tm->tm_year, &tm->tm_mon, &tm->tm_mday); + } + + /* Convert back to timestamp */ + TimestampTz ts_result; + if (attimezone != NULL) + { + /* TIMESTAMPTZ: determine timezone offset for result */ + tz = DetermineTimeZoneOffset(tm, attimezone); + if (tm2timestamp(tm, fsec, &tz, &ts_result) != 0) + { + *result = (interval->month > 0 || interval->day > 0) ? DT_NOEND : DT_NOBEGIN; + return false; + } + } + else + { + /* TIMESTAMP: no timezone */ + if (tm2timestamp(tm, fsec, NULL, &ts_result) != 0) + { + *result = (interval->month > 0 || interval->day > 0) ? DT_NOEND : DT_NOBEGIN; + return false; + } + } + + /* Add time component with overflow check */ + if (interval->time != 0) + { + if (pg_add_s64_overflow(ts_result, interval->time, &ts_result)) + { + *result = (interval->time > 0) ? DT_NOEND : DT_NOBEGIN; + return false; + } + } + + /* Final range check */ + if (!IS_VALID_TIMESTAMP(ts_result)) + { + *result = (ts_result > 0) ? DT_NOEND : DT_NOBEGIN; + return false; + } + + *result = ts_result; + return true; +} + +/* + * Saturating timestamp - interval arithmetic. + * Equivalent to timestamp_interval_add_saturating with negated interval. + */ +static bool +timestamp_interval_sub_saturating(TimestampTz timestamp, const Interval *interval, + pg_tz *attimezone, TimestampTz *result) +{ + Interval neg_interval; + neg_interval.month = -interval->month; + neg_interval.day = -interval->day; + neg_interval.time = -interval->time; + return timestamp_interval_add_saturating(timestamp, &neg_interval, attimezone, result); +} + +/* + * Estimate the average duration of an interval in microseconds. + * Used for optimizing the search by jumping close to the target. + */ +static int64 +estimate_interval_usecs(const Interval *interval) +{ + /* Average days per month: 365.25 / 12 = 30.4375 */ + const int64 AVG_USECS_PER_MONTH = (int64) (30.4375 * USECS_PER_DAY); + + return interval->month * AVG_USECS_PER_MONTH + interval->day * USECS_PER_DAY + interval->time; +} + +/* + * Calculate chunk range using iterative interval arithmetic. + * + * This function handles arbitrary intervals by iteratively adding/subtracting + * the interval until the bucket containing the timestamp is found. It works + * for any valid interval, including mixed intervals (e.g., "1 month 15 days"). + * + * Why this isn't used for all intervals: + * - For pure month intervals, calc_month_chunk_range() uses direct month + * arithmetic which is O(1) and handles month-boundary edge cases correctly. + * - For pure day intervals, calc_day_chunk_range() uses Julian day arithmetic + * which is O(1) and avoids DST-related issues. + * - For pure time intervals, calc_sub_day_chunk_range() uses microsecond + * arithmetic which is O(1) and exact. + * + * This general function is used for mixed intervals where the components + * interact in complex ways (e.g., adding "1 month 1 day" depends on which + * month you start from). The iteration is optimized by first jumping close + * to the target using an estimated interval duration, typically requiring + * only 1-3 iterations to find the exact bucket. + */ +ChunkRange +ts_chunk_range_calculate_general(Datum timestamp, Oid type, const Interval *interval, Datum origin, + int64 *iterations_out) +{ + /* At least one component must be non-zero */ + Assert(interval->month != 0 || interval->day != 0 || interval->time != 0); + + ChunkRange range = { .type = type }; + TimestampTz ts_val = + (type == TIMESTAMPTZOID) ? DatumGetTimestampTz(timestamp) : DatumGetTimestamp(timestamp); + TimestampTz origin_val = + (type == TIMESTAMPTZOID) ? DatumGetTimestampTz(origin) : DatumGetTimestamp(origin); + TimestampTz bucket_start = origin_val; + pg_tz *tz = (type == TIMESTAMPTZOID) ? session_timezone : NULL; + + /* + * Optimization: jump close to the target, adjusting if we overshoot. + * + * We start with an aggressive jump (100% of estimate), and if we overshoot, + * we back off progressively until we undershoot. This adaptive approach + * gets us very close while maintaining consistency. + * + * Note that we can only iterate in one direction depending on whether the + * point is before or after the origin. In other words, we cannot overshoot + * and then go "backwards" because mixed interval arithmetic is not + * reversible. Consider the following example: + * + * -- Adding 1 month to Jan 31 gives Feb 28 (not Feb 31 which doesn't + * exist) + * + * SELECT '2001-01-31'::date + '1 month'::interval; + * 2001-02-28 + * + * -- Subtracting 1 month from Feb 28 gives Jan 28 (not Jan 31) + * + * SELECT '2001-02-28'::date - '1 month'::interval; + * 2001-01-28 + * + * So: Jan 31 + 1 month - 1 month = Jan 28 ≠ Jan 31 + */ + int64 avg_interval_usecs = estimate_interval_usecs(interval); + + if (avg_interval_usecs > 0) + { + int64 distance_usecs = ts_val - origin_val; + int64 estimated_intervals = distance_usecs / avg_interval_usecs; + int max_attempts = 10; /* Safety limit */ + + /* Start aggressive, back off if overshooting */ + int64 jump_factor = 100; /* Start at 100% */ + + while (max_attempts-- > 0 && (estimated_intervals > 100 || estimated_intervals < -100)) + { + int64 jump_intervals = (estimated_intervals * jump_factor) / 100; + int64 scaled_month = (int64) interval->month * jump_intervals; + int64 scaled_day = (int64) interval->day * jump_intervals; + int64 scaled_time; + + /* Check for overflow */ + if (scaled_month < PG_INT32_MIN || scaled_month > PG_INT32_MAX || + scaled_day < PG_INT32_MIN || scaled_day > PG_INT32_MAX || + pg_mul_s64_overflow(interval->time, jump_intervals, &scaled_time)) + break; + + Interval jump_interval; + jump_interval.month = (int32) scaled_month; + jump_interval.day = (int32) scaled_day; + jump_interval.time = scaled_time; + + /* + * Note that when jumping backward the jump_interval is negative. + */ + TimestampTz jumped; + if (!timestamp_interval_add_saturating(bucket_start, &jump_interval, tz, &jumped)) + break; + + /* Check if we overshot */ + bool overshot = (jump_intervals > 0) ? (jumped > ts_val) : (jumped < ts_val); + + if (overshot) + { + /* Back off: reduce jump factor and try again from current position */ + jump_factor = jump_factor * 9 / 10; /* Reduce by 10% each time */ + if (jump_factor < 50) + break; /* Stop if we've backed off too much */ + } + else + { + /* Good jump - accept it and continue */ + bucket_start = jumped; + + /* Recalculate remaining distance for next iteration */ + distance_usecs = ts_val - bucket_start; + estimated_intervals = distance_usecs / avg_interval_usecs; + + /* Reset jump factor for next jump */ + jump_factor = 100; + } + } + } + + int64 iterations = 0; + TimestampTz range_end; + + if (ts_val >= bucket_start) + { + /* + * Search forward (timestamp at or after current bucket_start). + * + * We know the bucket's start and compute the end by adding the interval. + * We advance until ts_val < range_end (i.e., ts_val is within the bucket). + */ + if (!timestamp_interval_add_saturating(bucket_start, interval, tz, &range_end)) + range_end = DT_NOEND; + + while (ts_val >= range_end) + { + iterations++; + bucket_start = range_end; + TimestampTz next; + if (!timestamp_interval_add_saturating(bucket_start, interval, tz, &next)) + { + /* Overflow: this bucket extends to infinity */ + range_end = DT_NOEND; + break; + } + range_end = next; + } + } + else + { + /* + * Search backward (timestamp before current bucket_start). + * + * We know the bucket's end (it's the start of the next bucket) and + * compute the start by subtracting the interval. We advance until + * bucket_start <= ts_val (i.e., ts_val is within the bucket). + */ + range_end = bucket_start; + + while (bucket_start > ts_val) + { + iterations++; + range_end = bucket_start; + TimestampTz prev; + if (!timestamp_interval_sub_saturating(bucket_start, interval, tz, &prev)) + { + /* Overflow: this bucket starts at -infinity */ + bucket_start = DT_NOBEGIN; + break; + } + bucket_start = prev; + } + } + + if (iterations_out) + *iterations_out = iterations; + + if (type == TIMESTAMPTZOID) + range = ts_chunk_range_from_timestamptz(bucket_start, range_end); + else + range = ts_chunk_range_from_timestamp(bucket_start, range_end); + + return range; +} + +ChunkRange +ts_chunk_range_calculate(Datum timestamp, Oid type, const Interval *interval, Datum origin) +{ + /* Dispatch to the appropriate helper function based on interval type */ + if (interval->month > 0 && interval->day == 0 && interval->time == 0) + return calc_month_chunk_range(timestamp, type, interval, origin); + + if (interval->day > 0 && interval->month == 0 && interval->time == 0) + return calc_day_chunk_range(timestamp, type, interval, origin); + + if (interval->time > 0 && interval->month == 0 && interval->day == 0) + return calc_sub_day_chunk_range(timestamp, type, interval, origin); + + /* Non-calendar-compatible (mixed) intervals */ + if (interval->month != 0 || interval->day != 0 || interval->time != 0) + return ts_chunk_range_calculate_general(timestamp, type, interval, origin, NULL); + + /* Zero interval - return invalid range */ + ChunkRange range = { .type = InvalidOid }; + return range; +} + +#ifdef TS_DEBUG +/* + * Test wrapper for timestamp_interval_add_saturating. + * Exposes the static function for unit testing. + */ +bool +ts_test_timestamp_interval_add_saturating(TimestampTz timestamp, const Interval *interval, + pg_tz *attimezone, TimestampTz *result) +{ + return timestamp_interval_add_saturating(timestamp, interval, attimezone, result); +} +#endif diff --git a/src/chunk_range.h b/src/chunk_range.h new file mode 100644 index 00000000000..e33f1d189d9 --- /dev/null +++ b/src/chunk_range.h @@ -0,0 +1,108 @@ +/* + * This file and its contents are licensed under the Apache License 2.0. + * Please see the included NOTICE for copyright information and + * LICENSE-APACHE for a copy of the license. + */ +#pragma once + +#include + +#include "utils.h" +#include +#include +#include + +#include "export.h" +#include + +/* + * Represents a chunk time range with start and end boundaries. + * Used for calendar-aligned chunking calculations. + */ +typedef struct ChunkRange +{ + Oid type; + Datum range_start; + Datum range_end; +} ChunkRange; + +/* + * Default origin used for calendar-based chunking when no user-specified + * origin is provided. 2001-01-01 00:00:00 is a Monday, so 7-day intervals + * align with the start of the week by default. + */ +#define DEFAULT_ORIGIN_YEAR 2001 +#define DEFAULT_ORIGIN_MONTH 1 +#define DEFAULT_ORIGIN_DAY 1 + +/* + * Calculate the timezone-aligned chunk range for a given timestamp. + * + * The interval length can be expressed as pure months, pure days, or pure + * time (not combined). The start of the interval is evenly aligned with + * the specified origin, or defaults to 2001-01-01 00:00:00 if no origin + * is specified. + * + * The origin parameter should be a Datum of the same type as the timestamp + * (TIMESTAMPOID or TIMESTAMPTZOID), or 0 for the default origin. + * + * For example, with a timestamp of Feb 1 2024 and an interval of 2 months, + * the range would be Jan 1 2024 to Mar 1 2024 (assuming default origin). + */ +extern ChunkRange ts_chunk_range_calculate(Datum timestamp, Oid type, const Interval *interval, + Datum origin); + +/* + * Calculate the chunk range for non-calendar-compatible (mixed) intervals. + * + * Uses iterative timestamp arithmetic to find the bucket containing the timestamp. + * Optimized by estimating how many intervals to jump before iterating. + * + * The origin parameter should be a Datum of the same type as the timestamp. + * If iterations is not NULL, it will be set to the number of iterations used. + */ +extern ChunkRange ts_chunk_range_calculate_general(Datum timestamp, Oid type, + const Interval *interval, Datum origin, + int64 *iterations); + +static inline ChunkRange +ts_chunk_range_from_timestamp(Timestamp start, Timestamp end) +{ + return (ChunkRange){ + .type = TIMESTAMPOID, + .range_start = TimestampGetDatum(start), + .range_end = TimestampGetDatum(end), + }; +} + +static inline ChunkRange +ts_chunk_range_from_timestamptz(TimestampTz start, TimestampTz end) +{ + return (ChunkRange){ + .type = TIMESTAMPTZOID, + .range_start = TimestampTzGetDatum(start), + .range_end = TimestampTzGetDatum(end), + }; +} + +static inline int64 +ts_chunk_range_get_start_internal(const ChunkRange *cr) +{ + return ts_time_value_to_internal(cr->range_start, cr->type); +} + +static inline int64 +ts_chunk_range_get_end_internal(const ChunkRange *cr) +{ + return ts_time_value_to_internal(cr->range_end, cr->type); +} + +#ifdef TS_DEBUG +/* + * Saturating timestamp + interval arithmetic for testing. + * Returns true if operation succeeded, false if result was clamped to infinity. + */ +extern bool ts_test_timestamp_interval_add_saturating(TimestampTz timestamp, + const Interval *interval, pg_tz *attimezone, + TimestampTz *result); +#endif diff --git a/src/dimension.c b/src/dimension.c index 09a1a71763a..cb9eb9f7d4a 100644 --- a/src/dimension.c +++ b/src/dimension.c @@ -7,26 +7,38 @@ #include #include #include +#include #include +#include +#include #include #include #include +#include +#include #include #include +#include +#include +#include +#include +#include #include #include #include #include "compat/compat.h" -#include "cross_module_fn.h" +#include "annotations.h" +#include "chunk.h" +#include "chunk_range.h" #include "debug_point.h" #include "dimension.h" #include "dimension_slice.h" #include "dimension_vector.h" #include "error_utils.h" #include "errors.h" +#include "guc.h" #include "hypertable.h" -#include "hypertable_cache.h" #include "indexing.h" #include "partitioning.h" #include "scanner.h" @@ -59,6 +71,11 @@ enum Anum_generic_add_dimension #define Natts_generic_add_dimension (_Anum_generic_add_dimension_max - 1) +static int64 dimension_interval_to_internal(const char *colname, Oid dimtype, + const ChunkInterval *chunk_interval, + bool adaptive_chunking, bool suppress_warnings); +static void validate_origin_type_compatibility(Oid dimtype, Oid origin_type, const char *colname); + static int cmp_dimension_id(const void *left, const void *right) { @@ -151,6 +168,60 @@ hyperspace_get_num_dimensions_by_type(Hyperspace *hs, DimensionType type) return n; } +/* + * Get the default origin as a Datum of the appropriate type. + * Uses DEFAULT_ORIGIN_YEAR/MONTH/DAY (2001-01-01) as the default. + * + * For UUID columns, returns a TIMESTAMPTZOID origin. + * For DATE columns, returns a TIMESTAMPOID origin. + * For integer types, returns Int64GetDatum(0). + * + * If out_origin_type is not NULL, it will be set to the origin type. + */ +static Datum +get_default_origin_datum(Oid dimtype, Oid *out_origin_type) +{ + struct pg_tm tm_o; + int tz_origin = 0; + Oid origin_type = dimtype; + Timestamp origin; + + switch (dimtype) + { + case UUIDOID: + origin_type = TIMESTAMPTZOID; + break; + case DATEOID: + origin_type = TIMESTAMPOID; + break; + case TIMESTAMPTZOID: + case TIMESTAMPOID: + break; + default: + if (out_origin_type) + *out_origin_type = dimtype; + return Int64GetDatum(0); + } + + if (out_origin_type) + *out_origin_type = origin_type; + + memset(&tm_o, 0, sizeof(struct pg_tm)); + tm_o.tm_year = DEFAULT_ORIGIN_YEAR; + tm_o.tm_mday = DEFAULT_ORIGIN_DAY; + tm_o.tm_mon = DEFAULT_ORIGIN_MONTH; + + if (origin_type == TIMESTAMPTZOID) + tz_origin = DetermineTimeZoneOffset(&tm_o, session_timezone); + + tm2timestamp(&tm_o, 0, &tz_origin, &origin); + + if (origin_type == TIMESTAMPTZOID) + return TimestampTzGetDatum(origin); + else + return TimestampGetDatum(origin); +} + static inline DimensionType dimension_type(TupleInfo *ti) { @@ -159,6 +230,12 @@ dimension_type(TupleInfo *ti) return DIMENSION_TYPE_CLOSED; if (!slot_attisnull(ti->slot, Anum_dimension_interval_length) && + slot_attisnull(ti->slot, Anum_dimension_interval) && + slot_attisnull(ti->slot, Anum_dimension_num_slices)) + return DIMENSION_TYPE_OPEN; + + if (slot_attisnull(ti->slot, Anum_dimension_interval_length) && + !slot_attisnull(ti->slot, Anum_dimension_interval) && slot_attisnull(ti->slot, Anum_dimension_num_slices)) return DIMENSION_TYPE_OPEN; @@ -232,11 +309,85 @@ dimension_fill_in_from_tuple(Dimension *d, TupleInfo *ti, Oid main_table_relid) DatumGetInt16(values[AttrNumberGetAttrOffset(Anum_dimension_num_slices)]); else { - d->fd.interval_length = - DatumGetInt64(values[AttrNumberGetAttrOffset(Anum_dimension_interval_length)]); + Assert(isnull[AttrNumberGetAttrOffset(Anum_dimension_interval)] || + isnull[AttrNumberGetAttrOffset(Anum_dimension_interval_length)]); + + if (!isnull[AttrNumberGetAttrOffset(Anum_dimension_interval)]) + { + d->fd.interval = + *DatumGetIntervalP(values[AttrNumberGetAttrOffset(Anum_dimension_interval)]); + d->chunk_interval.type = INTERVALOID; + d->chunk_interval.interval = d->fd.interval; + } + else + { + Assert(!isnull[AttrNumberGetAttrOffset(Anum_dimension_interval_length)]); + + d->fd.interval_length = + DatumGetInt64(values[AttrNumberGetAttrOffset(Anum_dimension_interval_length)]); + d->chunk_interval.type = INT8OID; + d->chunk_interval.integer_interval = d->fd.interval_length; + } + if (!isnull[AttrNumberGetAttrOffset(Anum_dimension_compress_interval_length)]) d->fd.compress_interval_length = DatumGetInt64( values[AttrNumberGetAttrOffset(Anum_dimension_compress_interval_length)]); + + /* + * Get the effective dimension type. For dimensions with a partitioning + * function, this is the return type of the function (e.g., timestamptz), + * not the column type (e.g., text). + */ + Oid dimtype = ts_dimension_get_partition_type(d); + + if (!isnull[AttrNumberGetAttrOffset(Anum_dimension_interval_origin)]) + { + d->fd.interval_origin = + DatumGetInt64(values[AttrNumberGetAttrOffset(Anum_dimension_interval_origin)]); + d->chunk_interval.has_origin = true; + } + else if (IS_TIMESTAMP_TYPE(dimtype) || dimtype == UUIDOID) + { + /* + * Origin not stored in metadata - compute the default origin. + * The default origin is 2001-01-01 in local time for the dimension type. + * We store it in internal format (Unix epoch microseconds). + */ + Oid origin_type; + Datum default_origin = get_default_origin_datum(dimtype, &origin_type); + d->fd.interval_origin = ts_time_value_to_internal(default_origin, origin_type); + d->chunk_interval.has_origin = false; + } + else + { + /* For integer types and other types, origin is 0 */ + d->fd.interval_origin = 0; + d->chunk_interval.has_origin = false; + } + + /* + * Verify consistency between chunk_interval.type and fd.interval_length. + * In-memory representation: + * - Calendar mode: type == INTERVALOID, fd.interval_length == 0 + * - Non-calendar mode: type == INT8OID, fd.interval_length > 0 + * Note: In the catalog, calendar mode stores interval_length as NULL, + * but in-memory we use 0 to indicate calendar mode. + */ + Assert(!IS_CALENDAR_CHUNKING(d) || d->fd.interval_length == 0); + Assert(d->chunk_interval.type != INT8OID || d->fd.interval_length > 0); + + /* + * Populate chunk_interval origin from fd.interval_origin. + * fd.interval_origin is in internal format (Unix epoch for timestamps). + * chunk_interval stores timestamps in PostgreSQL epoch format. + * For UUID columns, origin is stored as TIMESTAMPTZOID since it represents + * the timestamp portion of the UUIDv7. + */ + Oid origin_type = (dimtype == UUIDOID) ? TIMESTAMPTZOID : dimtype; + chunk_interval_set_origin_internal(&d->chunk_interval, d->fd.interval_origin, origin_type); + + /* Verify origin round-trips correctly */ + Assert(d->fd.interval_origin == chunk_interval_get_origin_internal(&d->chunk_interval)); } d->column_attno = get_attnum(main_table_relid, NameStr(d->fd.column_name)); @@ -247,7 +398,7 @@ dimension_fill_in_from_tuple(Dimension *d, TupleInfo *ti, Oid main_table_relid) } static Datum -create_range_datum(FunctionCallInfo fcinfo, DimensionSlice *slice) +create_range_datum(FunctionCallInfo fcinfo, const DimensionSlice *slice) { TupleDesc tupdesc; Datum values[2]; @@ -266,35 +417,144 @@ create_range_datum(FunctionCallInfo fcinfo, DimensionSlice *slice) return HeapTupleGetDatum(tuple); } +static int64 +clamp_dimension_range_value(int64 unixts, Oid clamp_type) +{ + const int64 mints = ts_time_get_min(clamp_type); + + if (unixts <= mints) + return DIMENSION_SLICE_MINVALUE; + + const int64 maxts = ts_time_get_max(clamp_type); + + if (unixts >= maxts) + return DIMENSION_SLICE_MAXVALUE; + + return unixts; +} + +/* + * Convert a ChunkRange to a DimensionSlice. + * + * Converts range boundaries from native time format to internal (Unix epoch) format, + * then clamps to DIMENSION_SLICE_MINVALUE/MAXVALUE if at type boundaries. + */ +static DimensionSlice * +chunk_range_to_dimension_slice(int32 dimension_id, const ChunkRange *range) +{ + int64 range_start = ts_chunk_range_get_start_internal(range); + int64 range_end = ts_chunk_range_get_end_internal(range); + + range_start = clamp_dimension_range_value(range_start, range->type); + range_end = clamp_dimension_range_value(range_end, range->type); + + return ts_dimension_slice_create(dimension_id, range_start, range_end); +} + +/* + * Convert an internal origin value (Unix epoch microseconds) to a Datum + * of the appropriate timestamp type. + * + * If has_origin is false, returns the default origin (2001-01-01) for the + * given type. The internal_origin value is ignored in this case. + */ +static Datum +internal_origin_to_datum(int64 internal_origin, bool has_origin, Oid type) +{ + if (!has_origin) + return get_default_origin_datum(type, NULL); + + /* Convert from Unix epoch to PostgreSQL epoch */ + TimestampTz ts = internal_origin - TS_EPOCH_DIFF_MICROSECONDS; + + if (type == TIMESTAMPTZOID) + return TimestampTzGetDatum(ts); + else + return TimestampGetDatum(ts); +} + static DimensionSlice * calculate_open_range_default(const Dimension *dim, int64 value) { - int64 range_start, range_end; + int64 range_start = 0, range_end = 0; Oid dimtype = ts_dimension_get_partition_type(dim); + int64 interval_length = 0; - if (value < 0) + /* Use calendar-based chunking for Interval type chunk intervals */ + if (dim->chunk_interval.type == INTERVALOID) + { + Oid tstype = dimtype; + + if (dimtype == UUIDOID) + tstype = TIMESTAMPTZOID; + else if (dimtype == DATEOID) + tstype = TIMESTAMPOID; + + Datum ts = ts_internal_to_time_value(value, tstype); + /* + * For dimensions loaded from metadata, fd.interval_origin always has a + * valid value (either explicitly stored or computed default). Pass + * has_origin=true to use the stored value directly. + */ + Datum origin = internal_origin_to_datum(dim->fd.interval_origin, true, tstype); + Interval *interval = &((Dimension *) dim)->chunk_interval.interval; + ChunkRange crange = ts_chunk_range_calculate(ts, tstype, interval, origin); + + if (OidIsValid(crange.type)) + { + DimensionSlice *slice = chunk_range_to_dimension_slice(dim->fd.id, &crange); + Assert(value >= slice->fd.range_start && value < slice->fd.range_end); + return slice; + } + + /* Zero interval - fall back to legacy calculation */ + interval_length = interval_to_usec(interval); + } + else + { + /* Non-interval type - use legacy microsecond-based calculation */ + interval_length = dim->fd.interval_length; + } + + /* + * For legacy chunking, if an origin was explicitly set, align chunks to + * the origin. Otherwise, use the default behavior (origin = 0). + */ + int64 origin = dim->chunk_interval.has_origin ? dim->fd.interval_origin : 0; + + /* + * Calculate chunk range relative to origin by shifting the value, + * computing the aligned range, then shifting back. + */ + int64 value_from_origin = value - origin; + + if (value_from_origin < 0) { const int64 dim_min = ts_time_get_min(dimtype); - range_end = ((value + 1) / dim->fd.interval_length) * dim->fd.interval_length; + range_end = ((value_from_origin + 1) / interval_length) * interval_length + origin; - /* prevent integer underflow */ - if (dim_min - range_end > -dim->fd.interval_length) + /* + * Prevent integer underflow when computing range_start. + * Check if range_end - interval_length would be less than dim_min. + * Use dim_min + interval_length to avoid underflow in the comparison. + */ + if (range_end < dim_min + interval_length) range_start = DIMENSION_SLICE_MINVALUE; else - range_start = range_end - dim->fd.interval_length; + range_start = range_end - interval_length; } else { const int64 dim_end = ts_time_get_max(dimtype); - range_start = (value / dim->fd.interval_length) * dim->fd.interval_length; + range_start = (value_from_origin / interval_length) * interval_length + origin; /* prevent integer overflow */ - if (dim_end - range_start < dim->fd.interval_length) + if (dim_end - range_start < interval_length) range_end = DIMENSION_SLICE_MAXVALUE; else - range_end = range_start + dim->fd.interval_length; + range_end = range_start + interval_length; } return ts_dimension_slice_create(dim->fd.id, range_start, range_end); @@ -313,6 +573,10 @@ ts_dimension_calculate_open_range_default(PG_FUNCTION_ARGS) .type = DIMENSION_TYPE_OPEN, .fd.id = 0, .fd.interval_length = PG_GETARG_INT64(1), + .chunk_interval = { + .type = INT8OID, + .integer_interval = PG_GETARG_INT64(1), + }, .fd.column_type = TypenameGetTypid(PG_GETARG_CSTRING(2)), }; DimensionSlice *slice = calculate_open_range_default(&dim, value); @@ -320,6 +584,116 @@ ts_dimension_calculate_open_range_default(PG_FUNCTION_ARGS) PG_RETURN_DATUM(create_range_datum(fcinfo, slice)); } +static Datum +create_timestamp_range_datum(FunctionCallInfo fcinfo, const ChunkRange *range) +{ + TupleDesc tupdesc; + Datum values[2]; + bool nulls[2] = { false }; + HeapTuple tuple; + + if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE) + elog(ERROR, "function returning record called in context that cannot accept type record"); + + tupdesc = BlessTupleDesc(tupdesc); + + values[0] = range->range_start; + values[1] = range->range_end; + tuple = heap_form_tuple(tupdesc, values, nulls); + + return HeapTupleGetDatum(tuple); +} + +TS_FUNCTION_INFO_V1(ts_dimension_calculate_open_range_calendar); + +/* + * Expose open dimension range calculation for testing purposes. + * Arguments: timestamp, interval, [origin], [force_general] + * + * If force_general is true, uses the general iterative algorithm even for + * calendar-compatible intervals, allowing comparison between approaches. + */ +Datum +ts_dimension_calculate_open_range_calendar(PG_FUNCTION_ARGS) +{ + Oid type = get_fn_expr_argtype(fcinfo->flinfo, 0); + Datum timestamp = PG_GETARG_DATUM(0); + Interval *interval = DatumGetIntervalP(PG_GETARG_DATUM(1)); + Datum origin_datum; + int64 origin_internal = 0; + bool force_general = false; + const int dim_id = 0; + + /* Check for infinity timestamps - chunk range calculation is not defined for infinity */ + if (IS_TIMESTAMP_TYPE(type) && TIMESTAMP_NOT_FINITE(DatumGetTimestampTz(timestamp))) + ereport(ERROR, + (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE), + errmsg("timestamp out of range"), + errhint("Cannot calculate chunk range for infinity timestamps."))); + + /* Check if origin is provided (third argument) */ + if (!PG_ARGISNULL(2)) + { + origin_datum = PG_GETARG_DATUM(2); + Oid origin_type = get_fn_expr_argtype(fcinfo->flinfo, 2); + + /* Check for infinity origin */ + if (IS_TIMESTAMP_TYPE(origin_type) && + TIMESTAMP_NOT_FINITE(DatumGetTimestampTz(origin_datum))) + ereport(ERROR, + (errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE), + errmsg("origin timestamp out of range"), + errhint("Cannot use infinity as chunk origin."))); + + origin_internal = ts_time_value_to_internal(origin_datum, origin_type); + } + else + { + /* Use the default origin (2001-01-01) in current session timezone */ + Oid origin_type; + origin_datum = get_default_origin_datum(type, &origin_type); + origin_internal = ts_time_value_to_internal(origin_datum, origin_type); + } + + /* Check if force_general is provided (fourth argument) */ + if (!PG_ARGISNULL(3)) + force_general = PG_GETARG_BOOL(3); + + if (force_general) + { + /* Force using the general iterative algorithm */ + ChunkRange crange = + ts_chunk_range_calculate_general(timestamp, type, interval, origin_datum, NULL); + PG_RETURN_DATUM(create_timestamp_range_datum(fcinfo, &crange)); + } + + /* Use the standard path through calculate_open_range_default */ + Dimension dim = { + .type = DIMENSION_TYPE_OPEN, + .fd.id = dim_id, + .fd.interval_length = 0, + .fd.interval_origin = origin_internal, + .fd.column_type = type, + }; + + chunk_interval_set_with_origin(&dim.chunk_interval, + IntervalPGetDatum(interval), + INTERVALOID, + origin_datum, + type, + !PG_ARGISNULL(2)); + + int64 value = ts_time_value_to_internal(timestamp, type); + DimensionSlice *slice = calculate_open_range_default(&dim, value); + ChunkRange crange = { + .type = TIMESTAMPTZOID, + .range_start = ts_internal_to_time_value(slice->fd.range_start, TIMESTAMPTZOID), + .range_end = ts_internal_to_time_value(slice->fd.range_end, TIMESTAMPTZOID), + }; + + PG_RETURN_DATUM(create_timestamp_range_datum(fcinfo, &crange)); +} + static int64 calculate_closed_range_interval(const Dimension *dim) { @@ -441,8 +815,9 @@ ts_dimension_get_open_slice_ordinal(const Dimension *dim, const DimensionSlice * * intervals where repartitioning happens, there might be an unexpected number * of slices due to a mix of slices from both the old and the new partitioning * configuration. As a result, the ordinal value of a given slice might not - * actually match the partitioning settings at a given point in time. In this case, we will return - * the ordinal of current slice most overlapping the given slice (or first fully overlapped slice). + * actually match the partitioning settings at a given point in time. In this case, we will + * return the ordinal of current slice most overlapping the given slice (or first fully + * overlapped slice). */ static int ts_dimension_get_closed_slice_ordinal(const Dimension *dim, const DimensionSlice *target_slice) @@ -456,8 +831,9 @@ ts_dimension_get_closed_slice_ordinal(const Dimension *dim, const DimensionSlice Assert(NULL != target_slice); Assert(dim->fd.num_slices > 0); - /* Slicing assumes partitioning functions use the range [0, INT32_MAX], though the first slice - * uses INT64_MIN as its lower bound, and the last slice uses INT64_MAX as its upper bound. */ + /* Slicing assumes partitioning functions use the range [0, INT32_MAX], though the first + * slice uses INT64_MIN as its lower bound, and the last slice uses INT64_MAX as its upper + * bound. */ if (target_slice->fd.range_start == DIMENSION_SLICE_MINVALUE) return 0; @@ -467,9 +843,10 @@ ts_dimension_get_closed_slice_ordinal(const Dimension *dim, const DimensionSlice Assert(target_slice->fd.range_start > 0); Assert(target_slice->fd.range_end < DIMENSION_SLICE_CLOSED_MAX); - /* Given a target slice starting from some point p, determine a candidate slice in the current - * partitioning configuration that contains p. If that slice contains over half of our target - * slice, return it's ordinal. Otherwise return the ordinal for the next slice. */ + /* Given a target slice starting from some point p, determine a candidate slice in the + * current partitioning configuration that contains p. If that slice contains over half of + * our target slice, return it's ordinal. Otherwise return the ordinal for the next slice. + */ current_slice_size = calculate_closed_range_interval(dim); target_slice_size = target_slice->fd.range_end - target_slice->fd.range_start; candidate_slice_ordinal = target_slice->fd.range_start / current_slice_size; @@ -477,7 +854,7 @@ ts_dimension_get_closed_slice_ordinal(const Dimension *dim, const DimensionSlice current_slice_size - (target_slice->fd.range_start % current_slice_size); /* Note that if the candidate slice wholly contains the target slice, - * target_overlap_with_candidate_slice will actually be greater than target_slice_size. This + * target_overlap_with_candidate_slice will actually be greater than target_slice_size. This * doesn't affect the correctness of the following check. */ if (target_overlap_with_candidate_slice >= target_slice_size / 2) return candidate_slice_ordinal; @@ -720,7 +1097,14 @@ dimension_tuple_update(TupleInfo *ti, void *data) heap_deform_tuple(tuple, ts_scanner_get_tupledesc(ti), values, nulls); - Assert((dim->fd.num_slices <= 0 && dim->fd.interval_length > 0) || + /* + * Validate dimension type consistency: + * - Open dimension: num_slices <= 0 with either interval_length > 0 (legacy) + * or calendar chunking (chunk_interval.type == INTERVALOID) + * - Closed dimension: num_slices > 0 with interval_length <= 0 + */ + Assert((dim->fd.num_slices <= 0 && + (dim->fd.interval_length > 0 || dim->chunk_interval.type == INTERVALOID)) || (dim->fd.num_slices > 0 && dim->fd.interval_length <= 0)); values[AttrNumberGetAttrOffset(Anum_dimension_column_name)] = @@ -749,9 +1133,54 @@ dimension_tuple_update(TupleInfo *ti, void *data) nulls[AttrNumberGetAttrOffset(Anum_dimension_integer_now_func_schema)] = false; } - if (!nulls[AttrNumberGetAttrOffset(Anum_dimension_interval_length)]) + /* + * Update interval-related fields. For calendar chunking, we store the + * Interval value and origin. For legacy chunking, we store interval_length. + * + * Catalog storage (mutually exclusive): + * - Calendar: interval IS NOT NULL, interval_length IS NULL + * - Non-calendar: interval IS NULL, interval_length IS NOT NULL (and > 0) + * + * In-memory representation (fd.interval_length): + * - Calendar: fd.interval_length == 0 (catalog stores NULL) + * - Non-calendar: fd.interval_length > 0 (catalog stores same value) + */ + Assert(!IS_CALENDAR_CHUNKING(dim) || dim->fd.interval_length == 0); + Assert(dim->chunk_interval.type != INT8OID || dim->fd.interval_length > 0); + + if (IS_CALENDAR_CHUNKING(dim)) + { + /* Calendar chunking: store Interval value and origin */ + values[AttrNumberGetAttrOffset(Anum_dimension_interval)] = + IntervalPGetDatum(&dim->chunk_interval.interval); + nulls[AttrNumberGetAttrOffset(Anum_dimension_interval)] = false; + values[AttrNumberGetAttrOffset(Anum_dimension_interval_origin)] = + Int64GetDatum(dim->fd.interval_origin); + nulls[AttrNumberGetAttrOffset(Anum_dimension_interval_origin)] = false; + nulls[AttrNumberGetAttrOffset(Anum_dimension_interval_length)] = true; + } + else if (!IS_CALENDAR_CHUNKING(dim) && dim->fd.interval_length > 0) + { + /* + * Non-calendar (legacy) chunking: store interval_length and clear calendar + * interval field. If origin was explicitly set, store it for chunk alignment. + */ values[AttrNumberGetAttrOffset(Anum_dimension_interval_length)] = Int64GetDatum(dim->fd.interval_length); + nulls[AttrNumberGetAttrOffset(Anum_dimension_interval_length)] = false; + nulls[AttrNumberGetAttrOffset(Anum_dimension_interval)] = true; + + if (dim->chunk_interval.has_origin) + { + values[AttrNumberGetAttrOffset(Anum_dimension_interval_origin)] = + Int64GetDatum(dim->fd.interval_origin); + nulls[AttrNumberGetAttrOffset(Anum_dimension_interval_origin)] = false; + } + else + { + nulls[AttrNumberGetAttrOffset(Anum_dimension_interval_origin)] = true; + } + } if (dim->fd.compress_interval_length > 0) { @@ -776,9 +1205,30 @@ dimension_tuple_update(TupleInfo *ti, void *data) return SCAN_DONE; } +/* + * Convert a user-provided origin value to internal time format. + * + * The internal format is Unix epoch microseconds (microseconds since + * 1970-01-01 00:00:00 UTC), NOT PostgreSQL epoch (2000-01-01). This is + * important because the catalog stores interval_origin as a bigint in + * Unix epoch format. + * + * Supports TIMESTAMPOID, TIMESTAMPTZOID, DATEOID, and integer types. + * Returns 0 if origin_type is invalid (meaning use default origin). + */ +int64 +ts_dimension_origin_to_internal(Datum origin, Oid origin_type) +{ + if (!OidIsValid(origin_type)) + return 0; + + return ts_time_value_to_internal(origin, origin_type); +} + static int32 -dimension_insert_relation(Relation rel, int32 hypertable_id, Name colname, Oid coltype, - int16 num_slices, regproc partitioning_func, int64 interval_length) +dimension_insert_relation(Relation rel, int32 hypertable_id, Name colname, Oid coltype, Oid dimtype, + int16 num_slices, regproc partitioning_func, + const ChunkInterval *chunk_interval) { TupleDesc desc = RelationGetDescr(rel); Datum values[Natts_dimension]; @@ -808,17 +1258,93 @@ dimension_insert_relation(Relation rel, int32 hypertable_id, Name colname, Oid c if (num_slices > 0) { /* Closed (hash) dimension */ - Assert(num_slices > 0 && interval_length <= 0); + Assert(num_slices > 0 && !OidIsValid(chunk_interval->type)); values[AttrNumberGetAttrOffset(Anum_dimension_num_slices)] = Int16GetDatum(num_slices); values[AttrNumberGetAttrOffset(Anum_dimension_aligned)] = BoolGetDatum(false); nulls[AttrNumberGetAttrOffset(Anum_dimension_interval_length)] = true; + nulls[AttrNumberGetAttrOffset(Anum_dimension_interval)] = true; + nulls[AttrNumberGetAttrOffset(Anum_dimension_interval_origin)] = true; } else { /* Open (time) dimension */ - Assert(num_slices <= 0 && interval_length > 0); - values[AttrNumberGetAttrOffset(Anum_dimension_interval_length)] = - Int64GetDatum(interval_length); + Assert(num_slices <= 0); + Assert(OidIsValid(chunk_interval->type)); + + if (chunk_interval->type == INTERVALOID && ts_guc_enable_calendar_chunking) + { + /* + * Calendar chunking enabled: store the Interval value and origin. + * If origin not explicitly provided, compute the default origin + * (2001-01-01 00:00:00) in the current session timezone. + */ + values[AttrNumberGetAttrOffset(Anum_dimension_interval)] = + IntervalPGetDatum(&chunk_interval->interval); + nulls[AttrNumberGetAttrOffset(Anum_dimension_interval_length)] = true; + + if (chunk_interval->has_origin) + { + values[AttrNumberGetAttrOffset(Anum_dimension_interval_origin)] = + Int64GetDatum(chunk_interval_get_origin_internal(chunk_interval)); + } + else + { + /* Compute default origin in current session timezone */ + Oid origin_type; + Datum default_origin = get_default_origin_datum(dimtype, &origin_type); + int64 default_origin_internal = + ts_time_value_to_internal(default_origin, origin_type); + values[AttrNumberGetAttrOffset(Anum_dimension_interval_origin)] = + Int64GetDatum(default_origin_internal); + } + } + else if (chunk_interval->type == INTERVALOID) + { + /* + * Calendar chunking disabled: convert Interval to integer microseconds. + * If origin is explicitly provided, store it for chunk alignment. + */ + int64 interval = interval_to_usec((Interval *) &chunk_interval->interval); + values[AttrNumberGetAttrOffset(Anum_dimension_interval_length)] = + Int64GetDatum(interval); + nulls[AttrNumberGetAttrOffset(Anum_dimension_interval)] = true; + + if (chunk_interval->has_origin) + { + values[AttrNumberGetAttrOffset(Anum_dimension_interval_origin)] = + Int64GetDatum(chunk_interval_get_origin_internal(chunk_interval)); + } + else + { + nulls[AttrNumberGetAttrOffset(Anum_dimension_interval_origin)] = true; + } + } + else + { + /* + * Integer interval: store as interval_length. + * If origin is explicitly provided, store it for chunk alignment. + */ + int64 interval = dimension_interval_to_internal(NameStr(*colname), + dimtype, + chunk_interval, + false, + false); + values[AttrNumberGetAttrOffset(Anum_dimension_interval_length)] = + Int64GetDatum(interval); + nulls[AttrNumberGetAttrOffset(Anum_dimension_interval)] = true; + + if (chunk_interval->has_origin) + { + values[AttrNumberGetAttrOffset(Anum_dimension_interval_origin)] = + Int64GetDatum(chunk_interval_get_origin_internal(chunk_interval)); + } + else + { + nulls[AttrNumberGetAttrOffset(Anum_dimension_interval_origin)] = true; + } + } + values[AttrNumberGetAttrOffset(Anum_dimension_aligned)] = BoolGetDatum(true); nulls[AttrNumberGetAttrOffset(Anum_dimension_num_slices)] = true; } @@ -827,7 +1353,7 @@ dimension_insert_relation(Relation rel, int32 hypertable_id, Name colname, Oid c nulls[AttrNumberGetAttrOffset(Anum_dimension_integer_now_func_schema)] = true; nulls[AttrNumberGetAttrOffset(Anum_dimension_integer_now_func)] = true; - /* no compress interval length by default */ + /* no compress interval by default */ nulls[AttrNumberGetAttrOffset(Anum_dimension_compress_interval_length)] = true; ts_catalog_database_info_become_owner(ts_catalog_database_info_get(), &sec_ctx); @@ -840,8 +1366,8 @@ dimension_insert_relation(Relation rel, int32 hypertable_id, Name colname, Oid c } static int32 -dimension_insert(int32 hypertable_id, Name colname, Oid coltype, int16 num_slices, - regproc partitioning_func, int64 interval_length) +dimension_insert(int32 hypertable_id, Name colname, Oid coltype, Oid dimtype, int16 num_slices, + regproc partitioning_func, const ChunkInterval *chunk_interval) { Catalog *catalog = ts_catalog_get(); Relation rel; @@ -852,9 +1378,10 @@ dimension_insert(int32 hypertable_id, Name colname, Oid coltype, int16 num_slice hypertable_id, colname, coltype, + dimtype, num_slices, partitioning_func, - interval_length); + chunk_interval); table_close(rel, RowExclusiveLock); return dimension_id; } @@ -1002,7 +1529,7 @@ ts_hyperspace_calculate_point(const Hyperspace *hs, TupleTableSlot *slot) #define IS_VALID_NUM_SLICES(num_slices) ((num_slices) >= 1 && (num_slices) <= PG_INT16_MAX) static int64 -get_validated_integer_interval(Oid dimtype, int64 value) +get_validated_integer_interval(Oid dimtype, int64 value, bool suppress_warnings) { if (value < 1 || value > INT_TYPE_MAX(dimtype)) ereport(ERROR, @@ -1010,7 +1537,7 @@ get_validated_integer_interval(Oid dimtype, int64 value) errmsg("invalid interval: must be between 1 and " INT64_FORMAT, INT_TYPE_MAX(dimtype)))); - if (IS_TIMESTAMP_TYPE(dimtype) && value < USECS_PER_SEC) + if (!suppress_warnings && IS_TIMESTAMP_TYPE(dimtype) && value < USECS_PER_SEC) ereport(WARNING, (errcode(ERRCODE_AMBIGUOUS_PARAMETER), errmsg("unexpected interval: smaller than one second"), @@ -1053,16 +1580,32 @@ get_default_interval(Oid dimtype, bool adaptive_chunking) case TIMESTAMPTZOID: case DATEOID: case UUIDOID: - if (default_chunk_time_interval != NULL) + if (ts_guc_enable_calendar_chunking) { - chunk_interval.type = INTERVALOID; - chunk_interval.interval = *default_chunk_time_interval; + if (default_chunk_time_interval != NULL) + { + chunk_interval.type = INTERVALOID; + chunk_interval.interval = *default_chunk_time_interval; + } + else + { + /* + * Calendar chunking enabled: use INTERVALOID type with a '7 days' Interval. + * This allows the dimension to use calendar-based chunk alignment. + */ + chunk_interval.type = INTERVALOID; + chunk_interval.interval.time = 0; + chunk_interval.interval.day = 7; + chunk_interval.interval.month = 0; + } } else { chunk_interval.type = INT8OID; - if (adaptive_chunking) + if (default_chunk_time_interval != NULL) + chunk_interval.integer_interval = interval_to_usec(default_chunk_time_interval); + else if (adaptive_chunking) chunk_interval.integer_interval = DEFAULT_CHUNK_TIME_INTERVAL_ADAPTIVE; else chunk_interval.integer_interval = DEFAULT_CHUNK_TIME_INTERVAL; @@ -1081,9 +1624,11 @@ get_default_interval(Oid dimtype, bool adaptive_chunking) static int64 dimension_interval_to_internal(const char *colname, Oid dimtype, - const ChunkInterval *chunk_interval, bool adaptive_chunking) + const ChunkInterval *chunk_interval, bool adaptive_chunking, + bool suppress_warnings) { int64 interval; + ChunkInterval default_interval = { 0 }; Assert(chunk_interval != NULL); @@ -1093,16 +1638,28 @@ dimension_interval_to_internal(const char *colname, Oid dimtype, errmsg("invalid type for dimension \"%s\"", colname), errhint("Use an integer, timestamp, or date type."))); + if (!OidIsValid(chunk_interval->type)) + { + default_interval = get_default_interval(dimtype, adaptive_chunking); + chunk_interval = &default_interval; + } + switch (chunk_interval->type) { case INT2OID: - interval = get_validated_integer_interval(dimtype, chunk_interval->integer_interval); + interval = get_validated_integer_interval(dimtype, + (int16) chunk_interval->integer_interval, + suppress_warnings); break; case INT4OID: - interval = get_validated_integer_interval(dimtype, chunk_interval->integer_interval); + interval = get_validated_integer_interval(dimtype, + (int32) chunk_interval->integer_interval, + suppress_warnings); break; case INT8OID: - interval = get_validated_integer_interval(dimtype, chunk_interval->integer_interval); + interval = get_validated_integer_interval(dimtype, + chunk_interval->integer_interval, + suppress_warnings); break; case INTERVALOID: if (!IS_TIMESTAMP_TYPE(dimtype) && !IS_UUID_TYPE(dimtype)) @@ -1148,7 +1705,8 @@ ts_dimension_interval_to_internal_test(PG_FUNCTION_ARGS) else chunk_interval_set(&chunk_interval, PG_GETARG_DATUM(1), argtype); - PG_RETURN_INT64(dimension_interval_to_internal("testcol", dimtype, &chunk_interval, false)); + PG_RETURN_INT64( + dimension_interval_to_internal("testcol", dimtype, &chunk_interval, false, false)); } static void @@ -1170,7 +1728,8 @@ dimension_add_not_null_on_column(Oid table_relid, char *colname) void ts_dimension_update(const Hypertable *ht, const NameData *dimname, DimensionType dimtype, - Datum *interval, Oid *intervaltype, int16 *num_slices, Oid *integer_now_func) + const ChunkInterval *chunk_interval, int16 *num_slices, Oid *integer_now_func, + bool *use_calendar_chunking) { Dimension *dim; @@ -1204,17 +1763,83 @@ ts_dimension_update(const Hypertable *ht, const NameData *dimname, DimensionType Assert(dim->type == dimtype); - if (interval) + if (chunk_interval) { - Oid dimtype = ts_dimension_get_partition_type(dim); - ChunkInterval chunk_interval; - - chunk_interval_set(&chunk_interval, *interval, *intervaltype); - dim->fd.interval_length = - dimension_interval_to_internal(NameStr(dim->fd.column_name), - dimtype, - &chunk_interval, - hypertable_adaptive_chunking_enabled(ht)); + Oid partition_type = ts_dimension_get_partition_type(dim); + bool should_use_calendar; + + Assert(IS_OPEN_DIMENSION(dim)); + + /* + * Determine whether to use calendar chunking: + * - If use_calendar_chunking is explicitly set, use that value (overrides everything) + * - Otherwise, use existing mode (sticky behavior) + */ + if (use_calendar_chunking != NULL) + should_use_calendar = *use_calendar_chunking; + else + should_use_calendar = IS_CALENDAR_CHUNKING(dim); + + if (chunk_interval->type == INTERVALOID && should_use_calendar) + { + /* + * Calendar chunking: store the Interval value and origin. + * + * If hypertable is already using calendar mode, keep it in calendar + * mode regardless of the GUC setting. This makes the chunking mode + * "sticky" - once created in a mode, it stays in that mode. + * + * Simple struct assignment works because ChunkInterval stores + * values directly in unions, not as Datum pointers. + */ + dim->chunk_interval = *chunk_interval; + + /* Compute and store the origin in internal format (Unix epoch) */ + if (chunk_interval->has_origin) + { + dim->fd.interval_origin = chunk_interval_get_origin_internal(chunk_interval); + } + else if (dim->fd.interval_origin == 0) + { + /* + * Use default origin only if no origin was previously set. + * This preserves an existing custom origin when the user + * changes only the interval without specifying an origin. + */ + Oid origin_type; + Datum default_origin = get_default_origin_datum(partition_type, &origin_type); + dim->fd.interval_origin = ts_time_value_to_internal(default_origin, origin_type); + } + /* Otherwise, preserve existing origin */ + + /* Clear interval_length since we're using calendar chunking */ + dim->fd.interval_length = 0; + } + else + { + /* + * Non-calendar chunking: convert interval to integer microseconds. + * If origin is explicitly provided, store it for chunk alignment. + */ + dim->fd.interval_length = + dimension_interval_to_internal(NameStr(dim->fd.column_name), + partition_type, + chunk_interval, + hypertable_adaptive_chunking_enabled(ht), + false); + /* Set chunk_interval type to INT8OID to indicate non-calendar chunking */ + dim->chunk_interval.type = INT8OID; + dim->chunk_interval.integer_interval = dim->fd.interval_length; + + if (chunk_interval->has_origin) + { + dim->fd.interval_origin = chunk_interval_get_origin_internal(chunk_interval); + dim->chunk_interval.has_origin = true; + chunk_interval_set_origin_internal(&dim->chunk_interval, + dim->fd.interval_origin, + chunk_interval->origin_type); + } + } } if (num_slices) @@ -1267,7 +1892,7 @@ ts_dimension_set_num_slices(PG_FUNCTION_ARGS) * num_slices cannot be > INT16_MAX. */ num_slices = num_slices_arg & 0xffff; - ts_dimension_update(ht, colname, DIMENSION_TYPE_CLOSED, NULL, NULL, &num_slices, NULL); + ts_dimension_update(ht, colname, DIMENSION_TYPE_CLOSED, NULL, &num_slices, NULL, NULL); ts_cache_release(&hcache); PG_RETURN_VOID(); @@ -1285,16 +1910,23 @@ TS_FUNCTION_INFO_V1(ts_dimension_set_interval); * TIMESTAMP/TIMESTAMPTZ/DATE type, it can be integral which is treated as * microseconds, or an INTERVAL type. * dimension_name - The name of the dimension + * origin - The new origin for chunk alignment (optional) */ Datum ts_dimension_set_interval(PG_FUNCTION_ARGS) { Oid table_relid = PG_GETARG_OID(0); - Datum interval = PG_GETARG_DATUM(1); - Oid intervaltype = InvalidOid; + Datum interval_datum = PG_GETARG_DATUM(1); Name colname = PG_ARGISNULL(2) ? NULL : PG_GETARG_NAME(2); + bool origin_isnull = PG_ARGISNULL(3); + Datum origin_datum = origin_isnull ? UnassignedDatum : PG_GETARG_DATUM(3); + Oid origin_type = origin_isnull ? InvalidOid : get_fn_expr_argtype(fcinfo->flinfo, 3); + bool calendar_chunking_isnull = PG_ARGISNULL(4); + bool calendar_chunking_value = calendar_chunking_isnull ? false : PG_GETARG_BOOL(4); + bool *use_calendar_chunking = calendar_chunking_isnull ? NULL : &calendar_chunking_value; Cache *hcache = ts_hypertable_cache_pin(); Hypertable *ht; + ChunkInterval chunk_interval; TS_PREVENT_FUNC_IF_READ_ONLY(); @@ -1310,8 +1942,66 @@ ts_dimension_set_interval(PG_FUNCTION_ARGS) (errcode(ERRCODE_INVALID_PARAMETER_VALUE), errmsg("invalid interval: an explicit interval must be specified"))); - intervaltype = get_fn_expr_argtype(fcinfo->flinfo, 1); - ts_dimension_update(ht, colname, DIMENSION_TYPE_OPEN, &interval, &intervaltype, NULL, NULL); + /* + * Check if the hypertable uses calendar chunking and validate interval type. + * Calendar-based hypertables require an INTERVAL type for the chunk interval, + * unless the user is explicitly switching to non-calendar mode. + */ + { + Oid interval_type = get_fn_expr_argtype(fcinfo->flinfo, 1); + Dimension *dim = + ts_hyperspace_get_mutable_dimension_by_name(ht->space, + DIMENSION_TYPE_OPEN, + colname ? NameStr(*colname) : NULL); + if (dim == NULL) + dim = ts_hyperspace_get_mutable_dimension(ht->space, DIMENSION_TYPE_OPEN, 0); + + /* + * Error if using integer interval on calendar hypertable, unless explicitly + * switching to non-calendar mode via calendar_chunking => false. + */ + if (dim != NULL && IS_CALENDAR_CHUNKING(dim) && interval_type != INTERVALOID && + (use_calendar_chunking == NULL || *use_calendar_chunking == true)) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("cannot use integer interval on calendar-based hypertable"), + errhint("Use an INTERVAL type value (e.g., INTERVAL '1 day') " + "or set calendar_chunking => false to switch to non-calendar mode."))); + + /* + * Error if trying to enable calendar chunking with an integer interval. + */ + if (use_calendar_chunking != NULL && *use_calendar_chunking == true && + interval_type != INTERVALOID) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("cannot enable calendar chunking with an integer interval"), + errhint("Use an INTERVAL type value (e.g., INTERVAL '1 day')."))); + + /* Validate origin type compatibility with dimension type */ + if (!origin_isnull && dim != NULL) + validate_origin_type_compatibility(dim->fd.column_type, + origin_type, + NameStr(dim->fd.column_name)); + } + + { + Oid interval_type = get_fn_expr_argtype(fcinfo->flinfo, 1); + chunk_interval_set_with_origin(&chunk_interval, + interval_datum, + interval_type, + origin_datum, + origin_type, + !origin_isnull); + } + + ts_dimension_update(ht, + colname, + DIMENSION_TYPE_OPEN, + &chunk_interval, + NULL, + NULL, + use_calendar_chunking); ts_cache_release(&hcache); PG_RETURN_VOID(); @@ -1319,18 +2009,25 @@ ts_dimension_set_interval(PG_FUNCTION_ARGS) DimensionInfo * ts_dimension_info_create_open(Oid table_relid, Name column_name, Datum interval, Oid interval_type, - regproc partitioning_func) + regproc partitioning_func, Datum origin, Oid origin_type, + bool has_origin) { - DimensionInfo *info = palloc(sizeof(*info)); - *info = (DimensionInfo){ - .type = DIMENSION_TYPE_OPEN, - .table_relid = table_relid, - .partitioning_func = partitioning_func, - }; - - chunk_interval_set(&info->chunk_interval, interval, interval_type); + DimensionInfo *info = palloc0(sizeof(*info)); + AttrNumber colnum = get_attnum(table_relid, NameStr(*column_name)); + Oid coltype = get_atttype(table_relid, colnum); + info->type = DIMENSION_TYPE_OPEN; + info->table_relid = table_relid; + info->coltype = coltype; + info->partitioning_func = partitioning_func; namestrcpy(&info->colname, NameStr(*column_name)); + + chunk_interval_set_with_origin(&info->chunk_interval, + interval, + interval_type, + origin, + origin_type, + has_origin); return info; } @@ -1339,9 +2036,12 @@ ts_dimension_info_create_closed(Oid table_relid, Name column_name, int32 num_sli regproc partitioning_func) { DimensionInfo *info = palloc(sizeof(*info)); + AttrNumber colnum = get_attnum(table_relid, NameStr(*column_name)); + *info = (DimensionInfo){ .type = DIMENSION_TYPE_CLOSED, .table_relid = table_relid, + .coltype = get_atttype(table_relid, colnum), .num_slices = num_slices, .num_slices_is_set = (num_slices > 0), .partitioning_func = partitioning_func, @@ -1350,6 +2050,59 @@ ts_dimension_info_create_closed(Oid table_relid, Name column_name, int32 num_sli return info; } +/* + * Validate that the origin type is compatible with the dimension type. + * + * The origin must be of a type that can be meaningfully converted to the + * dimension's internal representation: + * - Integer dimensions require integer origins + * - Timestamp/date dimensions accept timestamp, timestamptz, or date origins + */ +static void +validate_origin_type_compatibility(Oid dimtype, Oid origin_type, const char *colname) +{ + bool dim_is_integer = IS_INTEGER_TYPE(dimtype); + bool dim_is_timestamp = IS_TIMESTAMP_TYPE(dimtype); + bool origin_is_integer = IS_INTEGER_TYPE(origin_type); + bool origin_is_timestamp = IS_TIMESTAMP_TYPE(origin_type); + + if (dim_is_integer) + { + /* Integer dimensions require integer origins */ + if (!origin_is_integer) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("origin type %s is not compatible with integer time column \"%s\"", + format_type_be(origin_type), + colname), + errhint("Use an integer origin value for integer time columns."))); + } + else if (dim_is_timestamp) + { + /* Timestamp/date dimensions accept timestamp, timestamptz, or date origins */ + if (!origin_is_timestamp) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("origin type %s is not compatible with %s column \"%s\"", + format_type_be(origin_type), + format_type_be(dimtype), + colname), + errhint("Use a timestamp, timestamptz, or date origin value."))); + } + else if (dimtype == UUIDOID) + { + /* UUID dimensions (UUID v7) accept timestamp, timestamptz, or date origins */ + if (!origin_is_timestamp) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("origin type %s is not compatible with uuid column \"%s\"", + format_type_be(origin_type), + colname), + errhint( + "Use a timestamp, timestamptz, or date origin value for UUID columns."))); + } +} + /* Validate the configuration of an open ("time") dimension */ static void dimension_info_validate_open(DimensionInfo *info) @@ -1372,24 +2125,49 @@ dimension_info_validate_open(DimensionInfo *info) dimtype = get_func_rettype(info->partitioning_func); } - /* - * Validate the dimension type before trying to get the default interval. - * This ensures we give a clear "invalid type" error rather than a confusing - * "cannot get default interval" error for unsupported types. - */ + /* Validate that the dimension type is valid for an open (time) dimension */ if (!IS_VALID_OPEN_DIM_TYPE(dimtype)) ereport(ERROR, (errcode(ERRCODE_WRONG_OBJECT_TYPE), errmsg("invalid type for dimension \"%s\"", NameStr(info->colname)), errhint("Use an integer, timestamp, or date type."))); + /* + * Validate origin type compatibility with dimension type. + * The origin must be convertible to the dimension's internal representation. + */ + if (info->chunk_interval.has_origin) + validate_origin_type_compatibility(dimtype, + info->chunk_interval.origin_type, + NameStr(info->colname)); + if (!OidIsValid(info->chunk_interval.type)) + { + /* + * No interval specified - use the default. Preserve origin fields since + * get_default_interval only sets interval type and value. + */ + int64 origin_internal = 0; + Oid origin_type = info->chunk_interval.origin_type; + bool has_origin = info->chunk_interval.has_origin; + + if (has_origin) + origin_internal = chunk_interval_get_origin_internal(&info->chunk_interval); + info->chunk_interval = get_default_interval(dimtype, info->adaptive_chunking); - info->interval = dimension_interval_to_internal(NameStr(info->colname), - dimtype, - &info->chunk_interval, - info->adaptive_chunking); + /* Restore origin fields */ + info->chunk_interval.has_origin = has_origin; + if (has_origin) + chunk_interval_set_origin_internal(&info->chunk_interval, origin_internal, origin_type); + } + + /* Validate the interval (suppress warnings as they will be issued during insert) */ + (void) dimension_interval_to_internal(NameStr(info->colname), + dimtype, + &info->chunk_interval, + info->adaptive_chunking, + true); } /* Validate the configuration of a closed ("space") dimension */ @@ -1445,7 +2223,6 @@ ts_dimension_info_validate(DimensionInfo *info) datum = SysCacheGetAttr(ATTNAME, tuple, Anum_pg_attribute_atttypid, &isnull); Assert(!isnull); - info->coltype = DatumGetObjectId(datum); datum = SysCacheGetAttr(ATTNAME, tuple, Anum_pg_attribute_attnotnull, &isnull); @@ -1508,17 +2285,24 @@ ts_dimension_info_validate(DimensionInfo *info) int32 ts_dimension_add_from_info(DimensionInfo *info) { + Oid dimtype; + if (info->set_not_null && info->type == DIMENSION_TYPE_OPEN) dimension_add_not_null_on_column(info->table_relid, NameStr(info->colname)); Assert(info->ht != NULL); + /* Dimension type is return type of partitioning function if present */ + dimtype = OidIsValid(info->partitioning_func) ? get_func_rettype(info->partitioning_func) : + info->coltype; + info->dimension_id = dimension_insert(info->ht->fd.id, &info->colname, info->coltype, + dimtype, info->num_slices, info->partitioning_func, - info->interval); + &info->chunk_interval); return info->dimension_id; } @@ -1571,6 +2355,13 @@ dimension_create_datum(FunctionCallInfo fcinfo, DimensionInfo *info, bool is_gen return HeapTupleGetDatum(tuple); } +void +ts_dimension_info_set_defaults(DimensionInfo *info) +{ + /* No defaults needed for origin - if has_origin is false, it means use default */ + (void) info; +} + /* * Add a new dimension to a hypertable. * @@ -1624,6 +2415,7 @@ ts_dimension_add_internal(FunctionCallInfo fcinfo, DimensionInfo *info, bool is_ errmsg("cannot omit both the number of partitions and the interval"))); ts_dimension_info_validate(info); + ts_dimension_info_set_defaults(info); if (!info->skip) { @@ -1692,21 +2484,34 @@ TS_FUNCTION_INFO_V1(ts_dimension_add_general); Datum ts_dimension_add(PG_FUNCTION_ARGS) { + bool origin_isnull = PG_ARGISNULL(6); + Datum origin_datum = origin_isnull ? UnassignedDatum : PG_GETARG_DATUM(6); + Oid origin_type = origin_isnull ? InvalidOid : get_fn_expr_argtype(fcinfo->flinfo, 6); + Name colname = PG_ARGISNULL(1) ? NULL : PG_GETARG_NAME(1); Oid interval_type = PG_ARGISNULL(3) ? InvalidOid : get_fn_expr_argtype(fcinfo->flinfo, 3); DimensionInfo info = { .type = PG_ARGISNULL(2) ? DIMENSION_TYPE_OPEN : DIMENSION_TYPE_CLOSED, .table_relid = PG_GETARG_OID(0), .num_slices = PG_ARGISNULL(2) ? DatumGetInt32(-1) : PG_GETARG_INT32(2), .num_slices_is_set = !PG_ARGISNULL(2), - .chunk_interval.type = interval_type, .partitioning_func = PG_ARGISNULL(4) ? InvalidOid : PG_GETARG_OID(4), .if_not_exists = PG_ARGISNULL(5) ? false : PG_GETARG_BOOL(5), }; + chunk_interval_set_with_origin(&info.chunk_interval, + PG_ARGISNULL(3) ? UnassignedDatum : PG_GETARG_DATUM(3), + interval_type, + origin_datum, + origin_type, + !origin_isnull); + TS_PREVENT_FUNC_IF_READ_ONLY(); - if (!PG_ARGISNULL(1)) - namestrcpy(&info.colname, NameStr(*PG_GETARG_NAME(1))); + if (NULL == colname) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), errmsg("column_name cannot be NULL"))); + + namestrcpy(&info.colname, NameStr(*colname)); if (!PG_ARGISNULL(3)) chunk_interval_set(&info.chunk_interval, PG_GETARG_DATUM(3), interval_type); @@ -1798,7 +2603,7 @@ make_dimension_info(Name colname, DimensionType dimtype) Datum ts_hash_dimension(PG_FUNCTION_ARGS) { - Ensure(PG_NARGS() > 2, "expected at most 3 arguments, invoked with %d arguments", PG_NARGS()); + Ensure(PG_NARGS() > 2, "expected at least 3 arguments, invoked with %d arguments", PG_NARGS()); Name column_name; GETARG_NOTNULL_NULLABLE(column_name, 0, "column_name", NAME); DimensionInfo *info = make_dimension_info(column_name, DIMENSION_TYPE_CLOSED); @@ -1817,15 +2622,24 @@ ts_hash_dimension(PG_FUNCTION_ARGS) Datum ts_range_dimension(PG_FUNCTION_ARGS) { - Ensure(PG_NARGS() > 2, "expected at most 3 arguments, invoked with %d arguments", PG_NARGS()); + Ensure(PG_NARGS() > 2, "expected at least 3 arguments, invoked with %d arguments", PG_NARGS()); Name column_name; GETARG_NOTNULL_NULLABLE(column_name, 0, "column_name", NAME); DimensionInfo *info = make_dimension_info(column_name, DIMENSION_TYPE_OPEN); Datum interval_datum = PG_ARGISNULL(1) ? Int32GetDatum(-1) : PG_GETARG_DATUM(1); Oid interval_type = PG_ARGISNULL(1) ? InvalidOid : get_fn_expr_argtype(fcinfo->flinfo, 1); + bool origin_isnull = PG_ARGISNULL(3); + Oid origin_type = origin_isnull ? InvalidOid : get_fn_expr_argtype(fcinfo->flinfo, 3); + info->partitioning_func = PG_ARGISNULL(2) ? InvalidOid : PG_GETARG_OID(2); - chunk_interval_set(&info->chunk_interval, interval_datum, interval_type); + chunk_interval_set_with_origin(&info->chunk_interval, + interval_datum, + interval_type, + origin_isnull ? UnassignedDatum : PG_GETARG_DATUM(3), + origin_type, + !origin_isnull); + PG_RETURN_POINTER(info); } @@ -1833,8 +2647,11 @@ Datum ts_dimension_add_general(PG_FUNCTION_ARGS) { DimensionInfo *info = NULL; + Oid relid = PG_GETARG_OID(0); GETARG_NOTNULL_POINTER(info, 1, "dimension", DimensionInfo); - info->table_relid = PG_GETARG_OID(0); + AttrNumber colattr = get_attnum(relid, NameStr(info->colname)); + info->coltype = get_atttype(relid, colattr); + info->table_relid = relid; if (PG_GETARG_BOOL(2)) info->if_not_exists = true; return ts_dimension_add_internal(fcinfo, info, true); diff --git a/src/dimension.h b/src/dimension.h index 16637c0c485..19d491744b8 100644 --- a/src/dimension.h +++ b/src/dimension.h @@ -9,7 +9,9 @@ #include #include #include +#include #include +#include #include "export.h" #include "time_utils.h" @@ -21,19 +23,35 @@ typedef struct DimensionSlice DimensionSlice; typedef struct DimensionVec DimensionVec; /* - * The chunk interval of an open partitioning dimension. + * ChunkInterval stores both the interval value and origin for a dimension. + * Values are stored directly (not as Datum pointers) making the struct safe + * to copy with simple struct assignment. * - * The type can either be INTERVALOID or an INT(2|4|8)OID. + * Use chunk_interval_get_datum() and chunk_interval_get_origin() to get Datum + * values that work correctly on both 32-bit and 64-bit platforms. */ typedef struct ChunkInterval { - Oid type; + Oid type; /* Interval type (INTERVALOID, INT8OID, INT4OID, INT2OID) */ + Oid origin_type; /* Origin type (column type for timestamps/integers, TIMESTAMPTZOID for UUID) + */ + bool has_origin; /* True if origin was explicitly specified */ + /* Interval value storage */ union { int64 integer_interval; /* For INT8OID, INT4OID, INT2OID */ Interval interval; /* For INTERVALOID */ }; + + /* Origin storage - type depends on origin_type. + * For timestamp types (including UUID columns): stored in PostgreSQL epoch (not Unix epoch!) + * For integer types: stored as-is */ + union + { + TimestampTz ts_origin; /* For TIMESTAMPTZOID, TIMESTAMPOID, DATEOID */ + int64 integer_origin; /* For integer types (INT2OID, INT4OID, INT8OID) */ + }; } ChunkInterval; /* @@ -61,6 +79,45 @@ chunk_interval_get_datum(const ChunkInterval *ci) } } +/* + * Get the origin value as a Datum from a ChunkInterval. + * For timestamps, ts_origin is already in PostgreSQL epoch format. + * For integers, integer_origin stores the value directly. + */ +static inline Datum +chunk_interval_get_origin(const ChunkInterval *ci) +{ + switch (ci->origin_type) + { + case TIMESTAMPTZOID: + return TimestampTzGetDatum(ci->ts_origin); + case TIMESTAMPOID: + return TimestampGetDatum(ci->ts_origin); + case DATEOID: + /* Convert from TimestampTz to Date using PostgreSQL's built-in conversion */ + return DirectFunctionCall1(timestamp_date, TimestampTzGetDatum(ci->ts_origin)); + case INT2OID: + return Int16GetDatum((int16) ci->integer_origin); + case INT4OID: + return Int32GetDatum((int32) ci->integer_origin); + case INT8OID: + default: + return Int64GetDatumFast(ci->integer_origin); + } +} + +/* + * Get the origin value as internal format (Unix epoch microseconds for timestamps). + * Use this when writing to the catalog or fd.interval_origin. + */ +static inline int64 +chunk_interval_get_origin_internal(const ChunkInterval *ci) +{ + Ensure(OidIsValid(ci->origin_type), + "chunk_interval_get_origin_internal called with invalid origin_type"); + return ts_time_value_to_internal(chunk_interval_get_origin(ci), ci->origin_type); +} + static inline void chunk_interval_set(ChunkInterval *chunk_interval, Datum interval, Oid type) { @@ -77,6 +134,63 @@ chunk_interval_set(ChunkInterval *chunk_interval, Datum interval, Oid type) chunk_interval->integer_interval = DatumGetInt16(interval); } +/* + * Set the origin value from internal format (Unix epoch microseconds for timestamps). + * Use this when reading from the catalog or fd.interval_origin. + */ +static inline void +chunk_interval_set_origin_internal(ChunkInterval *ci, int64 internal_origin, Oid origin_type) +{ + Ensure(OidIsValid(origin_type), + "chunk_interval_set_origin_internal called with invalid origin_type"); + ci->origin_type = origin_type; + + if (IS_TIMESTAMP_TYPE(origin_type)) + { + /* + * For all timestamp types (including DATEOID), convert internal directly to + * Timestamp and store. We use TIMESTAMPOID for the conversion to avoid + * precision loss that would occur when converting through DateADT. + * The origin_type is preserved so chunk_interval_get_origin can convert + * back to the correct type when needed. + */ + Datum ts_datum = ts_internal_to_time_value(internal_origin, TIMESTAMPOID); + ci->ts_origin = DatumGetTimestamp(ts_datum); + } + else + { + /* + * For integer types, the internal representation IS the stored value. + * No conversion needed - just store directly. + */ + ci->integer_origin = internal_origin; + } +} + +/* Forward declaration for use in inline function below */ +extern TSDLLEXPORT int64 ts_dimension_origin_to_internal(Datum origin, Oid origin_type); + +static inline void +chunk_interval_set_with_origin(ChunkInterval *ci, Datum interval, Oid interval_type, Datum origin, + Oid origin_type, bool has_origin) +{ + chunk_interval_set(ci, interval, interval_type); + + ci->origin_type = origin_type; + ci->has_origin = has_origin; + + if (has_origin && OidIsValid(origin_type)) + { + /* + * Convert origin to internal format (Unix epoch microseconds) and then + * back to the stored format. This handles type conversions properly, + * e.g., DATE values (days) to timestamps (microseconds). + */ + int64 internal_origin = ts_dimension_origin_to_internal(origin, origin_type); + chunk_interval_set_origin_internal(ci, internal_origin, origin_type); + } +} + typedef enum DimensionType { DIMENSION_TYPE_OPEN, @@ -91,11 +205,23 @@ typedef struct Dimension DimensionType type; AttrNumber column_attno; Oid main_table_relid; + ChunkInterval chunk_interval; PartitioningInfo *partitioning; } Dimension; #define IS_OPEN_DIMENSION(d) ((d)->type == DIMENSION_TYPE_OPEN) #define IS_CLOSED_DIMENSION(d) ((d)->type == DIMENSION_TYPE_CLOSED) +/* + * Check if a dimension uses calendar chunking vs non-calendar (fixed-size) chunking. + * Calendar mode: chunk_interval.type == INTERVALOID + * - Catalog: interval IS NOT NULL, interval_length IS NULL + * - In-memory: fd.interval_length == 0 + * Non-calendar mode: chunk_interval.type == INT8OID + * - Catalog: interval IS NULL, interval_length IS NOT NULL (> 0) + * - In-memory: fd.interval_length > 0 + * Closed (hash) dimensions: chunk_interval.type == InvalidOid (not applicable) + */ +#define IS_CALENDAR_CHUNKING(d) ((d)->chunk_interval.type == INTERVALOID) #define IS_VALID_OPEN_DIM_TYPE(type) \ (IS_INTEGER_TYPE(type) || IS_TIMESTAMP_TYPE(type) || IS_UUID_TYPE(type) || \ ts_type_is_int8_binary_compatible(type)) @@ -164,7 +290,6 @@ typedef struct DimensionInfo Oid coltype; DimensionType type; ChunkInterval chunk_interval; - int64 interval; int32 num_slices; regproc partitioning_func; bool if_not_exists; @@ -177,7 +302,7 @@ typedef struct DimensionInfo #define DIMENSION_INFO_IS_SET(di) (di != NULL && OidIsValid((di)->table_relid)) #define DIMENSION_INFO_IS_VALID(di) \ - (info->num_slices_is_set || OidIsValid(info->chunk_interval.type)) + ((di)->num_slices_is_set || OidIsValid((di)->chunk_interval.type)) extern Hyperspace *ts_dimension_scan(int32 hypertable_id, Oid main_table_relid, int16 num_dimension, MemoryContext mctx); @@ -207,19 +332,28 @@ extern int ts_dimension_delete_by_hypertable_id(int32 hypertable_id, bool delete extern TSDLLEXPORT DimensionInfo *ts_dimension_info_create_open(Oid table_relid, Name column_name, Datum interval, Oid interval_type, - regproc partitioning_func); + regproc partitioning_func, + Datum origin, Oid origin_type, + bool has_origin); +/* + * Convert a user-provided origin value to internal time format (Unix epoch microseconds). + * Supports TIMESTAMPOID, TIMESTAMPTZOID, DATEOID, and integer types. + * Returns 0 if origin_type is invalid. + */ +extern TSDLLEXPORT int64 ts_dimension_origin_to_internal(Datum origin, Oid origin_type); extern TSDLLEXPORT DimensionInfo *ts_dimension_info_create_closed(Oid table_relid, Name column_name, int32 num_slices, regproc partitioning_func); extern void ts_dimension_info_validate(DimensionInfo *info); +extern void ts_dimension_info_set_defaults(DimensionInfo *info); extern int32 ts_dimension_add_from_info(DimensionInfo *info); extern void ts_dimensions_rename_schema_name(const char *old_name, const char *new_name); extern TSDLLEXPORT void ts_dimension_update(const Hypertable *ht, const NameData *dimname, - DimensionType dimtype, Datum *interval, - Oid *intervaltype, int16 *num_slices, - Oid *integer_now_func); + DimensionType dimtype, + const ChunkInterval *chunk_interval, int16 *num_slices, + Oid *integer_now_func, bool *use_calendar_chunking); extern TSDLLEXPORT Point *ts_point_create(int16 num_dimensions); extern TSDLLEXPORT bool ts_is_equality_operator(Oid opno, Oid left, Oid right); extern TSDLLEXPORT Datum ts_dimension_info_in(PG_FUNCTION_ARGS); diff --git a/src/guc.c b/src/guc.c index bd327ed11f6..a69e39d8291 100644 --- a/src/guc.c +++ b/src/guc.c @@ -206,6 +206,7 @@ static bool ts_guc_enable_hypertable_compression = true; static bool ts_guc_enable_cagg_create = true; static bool ts_guc_enable_policy_create = true; static char *ts_guc_default_chunk_time_interval = NULL; +bool ts_guc_enable_calendar_chunking = false; typedef struct { @@ -1432,6 +1433,20 @@ _guc_init(void) /* assign_hook= */ assign_default_chunk_time_interval, /* show_hook= */ NULL); + DefineCustomBoolVariable(MAKE_EXTOPTION("enable_calendar_chunking"), + "Create chunks aligned with calendar.", + "When enabled, chunks are created so that they align " + "with the start and end of, e.g., days, months, and years. " + "This only applies to hypertables that use a primary partitioning " + "dimension that uses TIMESTAMPTZ, DATE, and UUID (version 7).", + &ts_guc_enable_calendar_chunking, + false, + PGC_USERSET, + 0, + NULL, + NULL, + NULL); + #ifdef TS_DEBUG DefineCustomBoolVariable(/* name= */ MAKE_EXTOPTION("shutdown_bgw_scheduler"), /* short_desc= */ "immediately shutdown the bgw scheduler", diff --git a/src/guc.h b/src/guc.h index 6bba60c64c5..3d9427c9a41 100644 --- a/src/guc.h +++ b/src/guc.h @@ -82,6 +82,7 @@ extern TSDLLEXPORT bool ts_guc_debug_skip_scan_info; /* Only settable in debug mode for testing */ extern TSDLLEXPORT bool ts_guc_enable_null_compression; extern TSDLLEXPORT bool ts_guc_enable_compression_ratio_warnings; +extern bool ts_guc_enable_calendar_chunking; typedef enum CompressTruncateBehaviour { diff --git a/src/hypertable.c b/src/hypertable.c index 9907a1fea22..74b5d783607 100644 --- a/src/hypertable.c +++ b/src/hypertable.c @@ -1538,6 +1538,11 @@ ts_hypertable_create(PG_FUNCTION_ARGS) text *target_size = PG_ARGISNULL(11) ? NULL : PG_GETARG_TEXT_P(11); Oid sizing_func = PG_ARGISNULL(12) ? InvalidOid : PG_GETARG_OID(12); regproc open_partitioning_func = PG_ARGISNULL(13) ? InvalidOid : PG_GETARG_OID(13); + const int origin_paramnum = 14; + bool origin_isnull = PG_ARGISNULL(origin_paramnum); + Datum origin = origin_isnull ? 0 : PG_GETARG_DATUM(origin_paramnum); + Oid origin_type = + origin_isnull ? InvalidOid : get_fn_expr_argtype(fcinfo->flinfo, origin_paramnum); if (!OidIsValid(table_relid)) ereport(ERROR, @@ -1550,11 +1555,13 @@ ts_hypertable_create(PG_FUNCTION_ARGS) DimensionInfo *open_dim_info = ts_dimension_info_create_open(table_relid, - open_dim_name, /* column name */ - default_interval, /* interval */ - interval_type, /* interval type */ - open_partitioning_func /* partitioning func */ - ); + open_dim_name, /* column name */ + default_interval, /* interval */ + interval_type, /* interval type */ + open_partitioning_func, /* partitioning func */ + origin, /* origin */ + origin_type, /* origin_type */ + !origin_isnull /* has_origin */); DimensionInfo *closed_dim_info = NULL; if (closed_dim_name) @@ -1634,7 +1641,10 @@ ts_hypertable_create_general(PG_FUNCTION_ARGS) /* * Fill in the rest of the info. */ + AttrNumber colattr = get_attnum(table_relid, NameStr(dim_info->colname)); dim_info->table_relid = table_relid; + dim_info->coltype = get_atttype(table_relid, colattr); + ts_dimension_info_set_defaults(dim_info); return ts_hypertable_create_internal(fcinfo, table_relid, @@ -2148,8 +2158,8 @@ ts_hypertable_set_integer_now_func(PG_FUNCTION_ARGS) DIMENSION_TYPE_OPEN, NULL, NULL, - NULL, - &now_func_oid); + &now_func_oid, + NULL); ts_cache_release(&hcache); PG_RETURN_NULL(); } diff --git a/src/process_utility.c b/src/process_utility.c index 6d87ae31337..baf2abc2096 100644 --- a/src/process_utility.c +++ b/src/process_utility.c @@ -3881,12 +3881,14 @@ process_create_table_end(Node *parsetree) Oid interval_type = InvalidOid; Datum interval = UnassignedDatum; + Oid origin_type = InvalidOid; + Datum origin = UnassignedDatum; + + AttrNumber time_attno = get_attnum(table_relid, time_column); + Oid time_type = get_atttype(table_relid, time_attno); if (!create_table_info.with_clauses[CreateTableFlagChunkTimeInterval].is_default) { - AttrNumber time_attno = get_attnum(table_relid, time_column); - Oid time_type = get_atttype(table_relid, time_attno); - interval = ts_create_table_parse_chunk_time_interval(create_table_info.with_clauses [CreateTableFlagChunkTimeInterval], @@ -3894,6 +3896,14 @@ process_create_table_end(Node *parsetree) &interval_type); } + if (!create_table_info.with_clauses[CreateTableFlagOrigin].is_default) + { + origin = + ts_create_table_parse_origin(create_table_info.with_clauses[CreateTableFlagOrigin], + time_type, + &origin_type); + } + if (!create_table_info.with_clauses[CreateTableFlagCreateDefaultIndexes].is_default) { if (!DatumGetBool( @@ -3918,13 +3928,16 @@ process_create_table_end(Node *parsetree) .parsed)); } + bool has_origin = !create_table_info.with_clauses[CreateTableFlagOrigin].is_default; DimensionInfo *open_dim_info = ts_dimension_info_create_open(table_relid, &time_column_name, /* column name */ interval, /* interval */ interval_type, /* interval type */ - InvalidOid /* partitioning func */ - ); + InvalidOid, /* partitioning func */ + origin, /* origin */ + origin_type, /* origin_type */ + has_origin); ChunkSizingInfo *csi = ts_chunk_sizing_info_get_default_disabled(table_relid); csi->colname = time_column; diff --git a/src/ts_catalog/catalog.h b/src/ts_catalog/catalog.h index 890655d2ca2..3a078b13161 100644 --- a/src/ts_catalog/catalog.h +++ b/src/ts_catalog/catalog.h @@ -184,6 +184,8 @@ enum Anum_dimension Anum_dimension_num_slices, Anum_dimension_partitioning_func_schema, Anum_dimension_partitioning_func, + Anum_dimension_interval_origin, + Anum_dimension_interval, Anum_dimension_interval_length, Anum_dimension_compress_interval_length, Anum_dimension_integer_now_func_schema, @@ -200,11 +202,25 @@ typedef struct FormData_dimension NameData column_name; Oid column_type; bool aligned; - /* closed (space) columns */ + /* Closed (space) dimension information */ int16 num_slices; NameData partitioning_func_schema; NameData partitioning_func; - /* open (time) columns */ + /* + * Open (time) dimension information + * + * An open dimension is divided into intervals that can either be plain + * integer intervals or calendar-based intervals (days, months, years). An + * interval has an origin, which for integer intervals is an integer while + * calendar-based intervals have a TimestampTz origin (but encoded into + * the same int64). + * + * Integer intervals are encoded into interval_length. + * + * Calendar-based intervals are encoded into a PostgreSQL interval. + */ + int64 interval_origin; + Interval interval; int64 interval_length; int64 compress_interval_length; NameData integer_now_func_schema; diff --git a/src/utils.c b/src/utils.c index e739829adf2..24e2f98e43a 100644 --- a/src/utils.c +++ b/src/utils.c @@ -148,6 +148,9 @@ ts_time_value_to_internal(Datum time_val, Oid type_oid) if (ts_type_is_int8_binary_compatible(type_oid)) return DatumGetInt64(time_val); + /* Check for InvalidOid first to provide a better error message */ + Ensure(OidIsValid(type_oid), + "ts_time_value_to_internal called with invalid type (InvalidOid)"); elog(ERROR, "unknown time type \"%s\"", format_type_be(type_oid)); } @@ -379,6 +382,9 @@ ts_internal_to_time_value(int64 value, Oid type) default: if (ts_type_is_int8_binary_compatible(type)) return Int64GetDatum(value); + /* Check for InvalidOid first to provide a better error message */ + Ensure(OidIsValid(type), + "ts_internal_to_time_value called with invalid type (InvalidOid)"); elog(ERROR, "unknown time type \"%s\" in ts_internal_to_time_value", format_type_be(type)); diff --git a/src/with_clause/create_table_with_clause.c b/src/with_clause/create_table_with_clause.c index 2b9403b1af9..4ebd77ed86c 100644 --- a/src/with_clause/create_table_with_clause.c +++ b/src/with_clause/create_table_with_clause.c @@ -23,6 +23,7 @@ static const WithClauseDefinition create_table_with_clauses_def[] = { [CreateTableFlagSegmentBy] = { .arg_names = {"segmentby", "segment_by", "compress_segmentby", NULL}, .type_id = TEXTOID,}, [CreateTableFlagOrderBy] = { .arg_names = {"orderby", "order_by", "compress_orderby", NULL}, .type_id = TEXTOID,}, [CreateTableFlagIndex] = { .arg_names = {"compress_index", "compress_sparse_index", "index", "sparse_index", NULL}, .type_id = TEXTOID,}, + [CreateTableFlagOrigin] = { .arg_names = { "chunk_origin", "partition_origin", "partitioning_origin", "origin", NULL}, .type_id = TEXTOID,}, }; WithClauseResult * @@ -77,3 +78,54 @@ ts_create_table_parse_chunk_time_interval(WithClauseResult option, Oid column_ty *interval_type = InvalidOid; return UnassignedDatum; } + +Datum +ts_create_table_parse_origin(WithClauseResult option, Oid column_type, Oid *origin_type) +{ + if (option.is_default == false) + { + Datum textarg = option.parsed; + switch (column_type) + { + case INT2OID: + { + *origin_type = INT2OID; + return DirectFunctionCall1(int2in, CStringGetDatum(TextDatumGetCString(textarg))); + } + case INT4OID: + { + *origin_type = INT4OID; + return DirectFunctionCall1(int4in, CStringGetDatum(TextDatumGetCString(textarg))); + } + case INT8OID: + { + *origin_type = INT8OID; + return DirectFunctionCall1(int8in, CStringGetDatum(TextDatumGetCString(textarg))); + } + case TIMESTAMPOID: + { + *origin_type = TIMESTAMPOID; + return DirectFunctionCall3(timestamp_in, + CStringGetDatum(TextDatumGetCString(textarg)), + InvalidOid, + -1); + } + case TIMESTAMPTZOID: + case UUIDOID: + { + *origin_type = TIMESTAMPTZOID; + return DirectFunctionCall3(timestamptz_in, + CStringGetDatum(TextDatumGetCString(textarg)), + InvalidOid, + -1); + } + case DATEOID: + { + *origin_type = DATEOID; + return DirectFunctionCall1(date_in, CStringGetDatum(TextDatumGetCString(textarg))); + } + } + } + *origin_type = InvalidOid; + return UnassignedDatum; +} diff --git a/src/with_clause/create_table_with_clause.h b/src/with_clause/create_table_with_clause.h index 3ecb232939a..0722405690b 100644 --- a/src/with_clause/create_table_with_clause.h +++ b/src/with_clause/create_table_with_clause.h @@ -20,10 +20,13 @@ typedef enum CreateTableFlags CreateTableFlagAssociatedTablePrefix, CreateTableFlagOrderBy, CreateTableFlagSegmentBy, - CreateTableFlagIndex + CreateTableFlagIndex, + CreateTableFlagOrigin } CreateTableFlags; WithClauseResult *ts_create_table_with_clause_parse(const List *defelems); Datum ts_create_table_parse_chunk_time_interval(WithClauseResult option, Oid column_type, Oid *interval_type); + +Datum ts_create_table_parse_origin(WithClauseResult option, Oid column_type, Oid *origin_type); diff --git a/test/expected/calendar_chunking.out b/test/expected/calendar_chunking.out new file mode 100644 index 00000000000..c909e5e1c04 --- /dev/null +++ b/test/expected/calendar_chunking.out @@ -0,0 +1,2207 @@ +-- This file and its contents are licensed under the Apache License 2.0. +-- Please see the included NOTICE for copyright information and +-- LICENSE-APACHE for a copy of the license. +-- +-- Test calendar-based chunking +-- +-- Calendar-based chunking aligns chunks with calendar boundaries +-- (e.g., start of day, week, month, year) based on a user-specified origin +-- and the current session timezone. +-- +\c :TEST_DBNAME :ROLE_SUPERUSER +CREATE OR REPLACE FUNCTION calc_range(ts TIMESTAMPTZ, chunk_interval INTERVAL, origin TIMESTAMPTZ DEFAULT NULL, force_general BOOL DEFAULT NULL) +RETURNS TABLE(start_ts TIMESTAMPTZ, end_ts TIMESTAMPTZ) AS :MODULE_PATHNAME, 'ts_dimension_calculate_open_range_calendar' LANGUAGE C; +-- C unit tests for chunk_range.c +CREATE OR REPLACE FUNCTION test_chunk_range() +RETURNS VOID AS :MODULE_PATHNAME, 'ts_test_chunk_range' LANGUAGE C; +SET ROLE :ROLE_DEFAULT_PERM_USER; +\set VERBOSITY terse +SET timescaledb.enable_calendar_chunking = true; +--------------------------------------------------------------- +-- CALC_RANGE TESTS +-- Test the calc_range() function with various intervals and timestamps +--------------------------------------------------------------- +-- Helper function to verify ranges +CREATE OR REPLACE FUNCTION test_ranges( + timestamps TIMESTAMPTZ[], + intervals INTERVAL[] +) RETURNS TABLE(ts TIMESTAMPTZ, inv INTERVAL, start_ts TIMESTAMPTZ, end_ts TIMESTAMPTZ, dur INTERVAL, in_range BOOLEAN) AS $$ + SELECT t.ts, i.inv, r.start_ts, r.end_ts, r.end_ts - r.start_ts, t.ts >= r.start_ts AND t.ts < r.end_ts + FROM unnest(timestamps) AS t(ts) + CROSS JOIN unnest(intervals) AS i(inv) + CROSS JOIN LATERAL calc_range(t.ts, i.inv) r + ORDER BY t.ts, i.inv; +$$ LANGUAGE SQL; +-- Helper function to verify ranges with custom origin +CREATE OR REPLACE FUNCTION test_ranges_with_origin( + timestamps TIMESTAMPTZ[], + intervals INTERVAL[], + origin TIMESTAMPTZ +) RETURNS TABLE(ts TIMESTAMPTZ, inv INTERVAL, start_ts TIMESTAMPTZ, end_ts TIMESTAMPTZ, dur INTERVAL, in_range BOOLEAN) AS $$ + SELECT t.ts, i.inv, r.start_ts, r.end_ts, r.end_ts - r.start_ts, t.ts >= r.start_ts AND t.ts < r.end_ts + FROM unnest(timestamps) AS t(ts) + CROSS JOIN unnest(intervals) AS i(inv) + CROSS JOIN LATERAL calc_range(t.ts, i.inv, origin) r + ORDER BY t.ts, i.inv; +$$ LANGUAGE SQL; +-- Helper function to show CHECK constraints with chunk size for time-based hypertables +-- Uses CASE to handle infinite timestamps (older PG versions can't subtract them) +CREATE OR REPLACE FUNCTION show_chunk_constraints(ht_name text) +RETURNS TABLE(chunk text, constraint_def text, chunk_size interval) AS $$ + SELECT c.table_name::text, + pg_get_constraintdef(con.oid), + CASE WHEN ch.range_start = '-infinity'::timestamptz + OR ch.range_end = 'infinity'::timestamptz + THEN NULL + ELSE ch.range_end - ch.range_start + END + FROM _timescaledb_catalog.chunk c + JOIN pg_constraint con ON con.conrelid = format('%I.%I', c.schema_name, c.table_name)::regclass + JOIN timescaledb_information.chunks ch ON ch.chunk_name = c.table_name AND ch.hypertable_name = ht_name + WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = ht_name) + AND con.contype = 'c' + ORDER BY c.table_name; +$$ LANGUAGE SQL; +-- Helper function to show dimension slices with chunk size for integer hypertables +CREATE OR REPLACE FUNCTION show_int_chunk_slices(ht_name text) +RETURNS TABLE(chunk_name text, range_start bigint, range_end bigint, chunk_size bigint) AS $$ + SELECT c.table_name::text, ds.range_start, ds.range_end, + ds.range_end - ds.range_start + FROM _timescaledb_catalog.chunk c + JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id + JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id + WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = ht_name) + ORDER BY ds.range_start; +$$ LANGUAGE SQL; +-- Helper function to show CHECK constraints only (for integer hypertables) +CREATE OR REPLACE FUNCTION show_check_constraints(ht_name text) +RETURNS TABLE(chunk text, constraint_def text) AS $$ + SELECT c.table_name::text, + pg_get_constraintdef(con.oid) + FROM _timescaledb_catalog.chunk c + JOIN pg_constraint con ON con.conrelid = format('%I.%I', c.schema_name, c.table_name)::regclass + WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = ht_name) + AND con.contype = 'c' + ORDER BY c.table_name; +$$ LANGUAGE SQL; +-- Basic interval tests in UTC +SET timezone = 'UTC'; +SELECT * FROM test_ranges( + ARRAY[ + '2024-01-15 12:30:45 UTC'::timestamptz, + '2024-06-15 00:00:00 UTC', + '2024-06-15 23:59:59.999999 UTC' + ], + ARRAY[ + '1 minute'::interval, '5 minutes', '15 minutes', '30 minutes', + '1 hour', '2 hours', '4 hours', '6 hours', '12 hours', + '1 day', '1 week', '1 month', '3 months', '6 months', '1 year' + ] +); + ts | inv | start_ts | end_ts | dur | in_range +-------------------------------------+------------+------------------------------+------------------------------+------------+---------- + Mon Jan 15 12:30:45 2024 UTC | @ 1 min | Mon Jan 15 12:30:00 2024 UTC | Mon Jan 15 12:31:00 2024 UTC | @ 1 min | t + Mon Jan 15 12:30:45 2024 UTC | @ 5 mins | Mon Jan 15 12:30:00 2024 UTC | Mon Jan 15 12:35:00 2024 UTC | @ 5 mins | t + Mon Jan 15 12:30:45 2024 UTC | @ 15 mins | Mon Jan 15 12:30:00 2024 UTC | Mon Jan 15 12:45:00 2024 UTC | @ 15 mins | t + Mon Jan 15 12:30:45 2024 UTC | @ 30 mins | Mon Jan 15 12:30:00 2024 UTC | Mon Jan 15 13:00:00 2024 UTC | @ 30 mins | t + Mon Jan 15 12:30:45 2024 UTC | @ 1 hour | Mon Jan 15 12:00:00 2024 UTC | Mon Jan 15 13:00:00 2024 UTC | @ 1 hour | t + Mon Jan 15 12:30:45 2024 UTC | @ 2 hours | Mon Jan 15 12:00:00 2024 UTC | Mon Jan 15 14:00:00 2024 UTC | @ 2 hours | t + Mon Jan 15 12:30:45 2024 UTC | @ 4 hours | Mon Jan 15 12:00:00 2024 UTC | Mon Jan 15 16:00:00 2024 UTC | @ 4 hours | t + Mon Jan 15 12:30:45 2024 UTC | @ 6 hours | Mon Jan 15 12:00:00 2024 UTC | Mon Jan 15 18:00:00 2024 UTC | @ 6 hours | t + Mon Jan 15 12:30:45 2024 UTC | @ 12 hours | Mon Jan 15 12:00:00 2024 UTC | Tue Jan 16 00:00:00 2024 UTC | @ 12 hours | t + Mon Jan 15 12:30:45 2024 UTC | @ 1 day | Mon Jan 15 00:00:00 2024 UTC | Tue Jan 16 00:00:00 2024 UTC | @ 1 day | t + Mon Jan 15 12:30:45 2024 UTC | @ 7 days | Mon Jan 15 00:00:00 2024 UTC | Mon Jan 22 00:00:00 2024 UTC | @ 7 days | t + Mon Jan 15 12:30:45 2024 UTC | @ 1 mon | Mon Jan 01 00:00:00 2024 UTC | Thu Feb 01 00:00:00 2024 UTC | @ 31 days | t + Mon Jan 15 12:30:45 2024 UTC | @ 3 mons | Mon Jan 01 00:00:00 2024 UTC | Mon Apr 01 00:00:00 2024 UTC | @ 91 days | t + Mon Jan 15 12:30:45 2024 UTC | @ 6 mons | Mon Jan 01 00:00:00 2024 UTC | Mon Jul 01 00:00:00 2024 UTC | @ 182 days | t + Mon Jan 15 12:30:45 2024 UTC | @ 1 year | Mon Jan 01 00:00:00 2024 UTC | Wed Jan 01 00:00:00 2025 UTC | @ 366 days | t + Sat Jun 15 00:00:00 2024 UTC | @ 1 min | Sat Jun 15 00:00:00 2024 UTC | Sat Jun 15 00:01:00 2024 UTC | @ 1 min | t + Sat Jun 15 00:00:00 2024 UTC | @ 5 mins | Sat Jun 15 00:00:00 2024 UTC | Sat Jun 15 00:05:00 2024 UTC | @ 5 mins | t + Sat Jun 15 00:00:00 2024 UTC | @ 15 mins | Sat Jun 15 00:00:00 2024 UTC | Sat Jun 15 00:15:00 2024 UTC | @ 15 mins | t + Sat Jun 15 00:00:00 2024 UTC | @ 30 mins | Sat Jun 15 00:00:00 2024 UTC | Sat Jun 15 00:30:00 2024 UTC | @ 30 mins | t + Sat Jun 15 00:00:00 2024 UTC | @ 1 hour | Sat Jun 15 00:00:00 2024 UTC | Sat Jun 15 01:00:00 2024 UTC | @ 1 hour | t + Sat Jun 15 00:00:00 2024 UTC | @ 2 hours | Sat Jun 15 00:00:00 2024 UTC | Sat Jun 15 02:00:00 2024 UTC | @ 2 hours | t + Sat Jun 15 00:00:00 2024 UTC | @ 4 hours | Sat Jun 15 00:00:00 2024 UTC | Sat Jun 15 04:00:00 2024 UTC | @ 4 hours | t + Sat Jun 15 00:00:00 2024 UTC | @ 6 hours | Sat Jun 15 00:00:00 2024 UTC | Sat Jun 15 06:00:00 2024 UTC | @ 6 hours | t + Sat Jun 15 00:00:00 2024 UTC | @ 12 hours | Sat Jun 15 00:00:00 2024 UTC | Sat Jun 15 12:00:00 2024 UTC | @ 12 hours | t + Sat Jun 15 00:00:00 2024 UTC | @ 1 day | Sat Jun 15 00:00:00 2024 UTC | Sun Jun 16 00:00:00 2024 UTC | @ 1 day | t + Sat Jun 15 00:00:00 2024 UTC | @ 7 days | Mon Jun 10 00:00:00 2024 UTC | Mon Jun 17 00:00:00 2024 UTC | @ 7 days | t + Sat Jun 15 00:00:00 2024 UTC | @ 1 mon | Sat Jun 01 00:00:00 2024 UTC | Mon Jul 01 00:00:00 2024 UTC | @ 30 days | t + Sat Jun 15 00:00:00 2024 UTC | @ 3 mons | Mon Apr 01 00:00:00 2024 UTC | Mon Jul 01 00:00:00 2024 UTC | @ 91 days | t + Sat Jun 15 00:00:00 2024 UTC | @ 6 mons | Mon Jan 01 00:00:00 2024 UTC | Mon Jul 01 00:00:00 2024 UTC | @ 182 days | t + Sat Jun 15 00:00:00 2024 UTC | @ 1 year | Mon Jan 01 00:00:00 2024 UTC | Wed Jan 01 00:00:00 2025 UTC | @ 366 days | t + Sat Jun 15 23:59:59.999999 2024 UTC | @ 1 min | Sat Jun 15 23:59:00 2024 UTC | Sun Jun 16 00:00:00 2024 UTC | @ 1 min | t + Sat Jun 15 23:59:59.999999 2024 UTC | @ 5 mins | Sat Jun 15 23:55:00 2024 UTC | Sun Jun 16 00:00:00 2024 UTC | @ 5 mins | t + Sat Jun 15 23:59:59.999999 2024 UTC | @ 15 mins | Sat Jun 15 23:45:00 2024 UTC | Sun Jun 16 00:00:00 2024 UTC | @ 15 mins | t + Sat Jun 15 23:59:59.999999 2024 UTC | @ 30 mins | Sat Jun 15 23:30:00 2024 UTC | Sun Jun 16 00:00:00 2024 UTC | @ 30 mins | t + Sat Jun 15 23:59:59.999999 2024 UTC | @ 1 hour | Sat Jun 15 23:00:00 2024 UTC | Sun Jun 16 00:00:00 2024 UTC | @ 1 hour | t + Sat Jun 15 23:59:59.999999 2024 UTC | @ 2 hours | Sat Jun 15 22:00:00 2024 UTC | Sun Jun 16 00:00:00 2024 UTC | @ 2 hours | t + Sat Jun 15 23:59:59.999999 2024 UTC | @ 4 hours | Sat Jun 15 20:00:00 2024 UTC | Sun Jun 16 00:00:00 2024 UTC | @ 4 hours | t + Sat Jun 15 23:59:59.999999 2024 UTC | @ 6 hours | Sat Jun 15 18:00:00 2024 UTC | Sun Jun 16 00:00:00 2024 UTC | @ 6 hours | t + Sat Jun 15 23:59:59.999999 2024 UTC | @ 12 hours | Sat Jun 15 12:00:00 2024 UTC | Sun Jun 16 00:00:00 2024 UTC | @ 12 hours | t + Sat Jun 15 23:59:59.999999 2024 UTC | @ 1 day | Sat Jun 15 00:00:00 2024 UTC | Sun Jun 16 00:00:00 2024 UTC | @ 1 day | t + Sat Jun 15 23:59:59.999999 2024 UTC | @ 7 days | Mon Jun 10 00:00:00 2024 UTC | Mon Jun 17 00:00:00 2024 UTC | @ 7 days | t + Sat Jun 15 23:59:59.999999 2024 UTC | @ 1 mon | Sat Jun 01 00:00:00 2024 UTC | Mon Jul 01 00:00:00 2024 UTC | @ 30 days | t + Sat Jun 15 23:59:59.999999 2024 UTC | @ 3 mons | Mon Apr 01 00:00:00 2024 UTC | Mon Jul 01 00:00:00 2024 UTC | @ 91 days | t + Sat Jun 15 23:59:59.999999 2024 UTC | @ 6 mons | Mon Jan 01 00:00:00 2024 UTC | Mon Jul 01 00:00:00 2024 UTC | @ 182 days | t + Sat Jun 15 23:59:59.999999 2024 UTC | @ 1 year | Mon Jan 01 00:00:00 2024 UTC | Wed Jan 01 00:00:00 2025 UTC | @ 366 days | t + +-- Leap year tests +SELECT * FROM test_ranges( + ARRAY[ + '2024-02-28 12:00:00 UTC'::timestamptz, -- leap year + '2024-02-29 00:00:00 UTC', + '2024-02-29 23:59:59 UTC', + '2024-03-01 00:00:00 UTC', + '2025-02-28 12:00:00 UTC', -- non-leap year + '2025-02-28 23:59:59 UTC', + '2025-03-01 00:00:00 UTC' + ], + ARRAY['1 day'::interval, '1 month'] +); + ts | inv | start_ts | end_ts | dur | in_range +------------------------------+---------+------------------------------+------------------------------+-----------+---------- + Wed Feb 28 12:00:00 2024 UTC | @ 1 day | Wed Feb 28 00:00:00 2024 UTC | Thu Feb 29 00:00:00 2024 UTC | @ 1 day | t + Wed Feb 28 12:00:00 2024 UTC | @ 1 mon | Thu Feb 01 00:00:00 2024 UTC | Fri Mar 01 00:00:00 2024 UTC | @ 29 days | t + Thu Feb 29 00:00:00 2024 UTC | @ 1 day | Thu Feb 29 00:00:00 2024 UTC | Fri Mar 01 00:00:00 2024 UTC | @ 1 day | t + Thu Feb 29 00:00:00 2024 UTC | @ 1 mon | Thu Feb 01 00:00:00 2024 UTC | Fri Mar 01 00:00:00 2024 UTC | @ 29 days | t + Thu Feb 29 23:59:59 2024 UTC | @ 1 day | Thu Feb 29 00:00:00 2024 UTC | Fri Mar 01 00:00:00 2024 UTC | @ 1 day | t + Thu Feb 29 23:59:59 2024 UTC | @ 1 mon | Thu Feb 01 00:00:00 2024 UTC | Fri Mar 01 00:00:00 2024 UTC | @ 29 days | t + Fri Mar 01 00:00:00 2024 UTC | @ 1 day | Fri Mar 01 00:00:00 2024 UTC | Sat Mar 02 00:00:00 2024 UTC | @ 1 day | t + Fri Mar 01 00:00:00 2024 UTC | @ 1 mon | Fri Mar 01 00:00:00 2024 UTC | Mon Apr 01 00:00:00 2024 UTC | @ 31 days | t + Fri Feb 28 12:00:00 2025 UTC | @ 1 day | Fri Feb 28 00:00:00 2025 UTC | Sat Mar 01 00:00:00 2025 UTC | @ 1 day | t + Fri Feb 28 12:00:00 2025 UTC | @ 1 mon | Sat Feb 01 00:00:00 2025 UTC | Sat Mar 01 00:00:00 2025 UTC | @ 28 days | t + Fri Feb 28 23:59:59 2025 UTC | @ 1 day | Fri Feb 28 00:00:00 2025 UTC | Sat Mar 01 00:00:00 2025 UTC | @ 1 day | t + Fri Feb 28 23:59:59 2025 UTC | @ 1 mon | Sat Feb 01 00:00:00 2025 UTC | Sat Mar 01 00:00:00 2025 UTC | @ 28 days | t + Sat Mar 01 00:00:00 2025 UTC | @ 1 day | Sat Mar 01 00:00:00 2025 UTC | Sun Mar 02 00:00:00 2025 UTC | @ 1 day | t + Sat Mar 01 00:00:00 2025 UTC | @ 1 mon | Sat Mar 01 00:00:00 2025 UTC | Tue Apr 01 00:00:00 2025 UTC | @ 31 days | t + +-- Week alignment (should align to Monday) +SELECT * FROM test_ranges( + ARRAY[ + '2024-06-10 12:00:00 UTC'::timestamptz, -- Monday + '2024-06-11 12:00:00 UTC', -- Tuesday + '2024-06-14 12:00:00 UTC', -- Friday + '2024-06-16 12:00:00 UTC', -- Sunday + '2024-06-17 00:00:00 UTC' -- Monday (next week) + ], + ARRAY['1 week'::interval, '2 weeks'] +); + ts | inv | start_ts | end_ts | dur | in_range +------------------------------+-----------+------------------------------+------------------------------+-----------+---------- + Mon Jun 10 12:00:00 2024 UTC | @ 7 days | Mon Jun 10 00:00:00 2024 UTC | Mon Jun 17 00:00:00 2024 UTC | @ 7 days | t + Mon Jun 10 12:00:00 2024 UTC | @ 14 days | Mon Jun 03 00:00:00 2024 UTC | Mon Jun 17 00:00:00 2024 UTC | @ 14 days | t + Tue Jun 11 12:00:00 2024 UTC | @ 7 days | Mon Jun 10 00:00:00 2024 UTC | Mon Jun 17 00:00:00 2024 UTC | @ 7 days | t + Tue Jun 11 12:00:00 2024 UTC | @ 14 days | Mon Jun 03 00:00:00 2024 UTC | Mon Jun 17 00:00:00 2024 UTC | @ 14 days | t + Fri Jun 14 12:00:00 2024 UTC | @ 7 days | Mon Jun 10 00:00:00 2024 UTC | Mon Jun 17 00:00:00 2024 UTC | @ 7 days | t + Fri Jun 14 12:00:00 2024 UTC | @ 14 days | Mon Jun 03 00:00:00 2024 UTC | Mon Jun 17 00:00:00 2024 UTC | @ 14 days | t + Sun Jun 16 12:00:00 2024 UTC | @ 7 days | Mon Jun 10 00:00:00 2024 UTC | Mon Jun 17 00:00:00 2024 UTC | @ 7 days | t + Sun Jun 16 12:00:00 2024 UTC | @ 14 days | Mon Jun 03 00:00:00 2024 UTC | Mon Jun 17 00:00:00 2024 UTC | @ 14 days | t + Mon Jun 17 00:00:00 2024 UTC | @ 7 days | Mon Jun 17 00:00:00 2024 UTC | Mon Jun 24 00:00:00 2024 UTC | @ 7 days | t + Mon Jun 17 00:00:00 2024 UTC | @ 14 days | Mon Jun 17 00:00:00 2024 UTC | Mon Jul 01 00:00:00 2024 UTC | @ 14 days | t + +-- Year boundary tests +SELECT * FROM test_ranges( + ARRAY[ + '2024-12-31 23:59:59 UTC'::timestamptz, + '2025-01-01 00:00:00 UTC' + ], + ARRAY['1 day'::interval, '1 week', '1 month', '1 year'] +); + ts | inv | start_ts | end_ts | dur | in_range +------------------------------+----------+------------------------------+------------------------------+------------+---------- + Tue Dec 31 23:59:59 2024 UTC | @ 1 day | Tue Dec 31 00:00:00 2024 UTC | Wed Jan 01 00:00:00 2025 UTC | @ 1 day | t + Tue Dec 31 23:59:59 2024 UTC | @ 7 days | Mon Dec 30 00:00:00 2024 UTC | Mon Jan 06 00:00:00 2025 UTC | @ 7 days | t + Tue Dec 31 23:59:59 2024 UTC | @ 1 mon | Sun Dec 01 00:00:00 2024 UTC | Wed Jan 01 00:00:00 2025 UTC | @ 31 days | t + Tue Dec 31 23:59:59 2024 UTC | @ 1 year | Mon Jan 01 00:00:00 2024 UTC | Wed Jan 01 00:00:00 2025 UTC | @ 366 days | t + Wed Jan 01 00:00:00 2025 UTC | @ 1 day | Wed Jan 01 00:00:00 2025 UTC | Thu Jan 02 00:00:00 2025 UTC | @ 1 day | t + Wed Jan 01 00:00:00 2025 UTC | @ 7 days | Mon Dec 30 00:00:00 2024 UTC | Mon Jan 06 00:00:00 2025 UTC | @ 7 days | t + Wed Jan 01 00:00:00 2025 UTC | @ 1 mon | Wed Jan 01 00:00:00 2025 UTC | Sat Feb 01 00:00:00 2025 UTC | @ 31 days | t + Wed Jan 01 00:00:00 2025 UTC | @ 1 year | Wed Jan 01 00:00:00 2025 UTC | Thu Jan 01 00:00:00 2026 UTC | @ 365 days | t + +-- Custom origin: days starting at noon +SELECT * FROM test_ranges_with_origin( + ARRAY[ + '2024-06-15 11:59:59 UTC'::timestamptz, + '2024-06-15 12:00:00 UTC', + '2024-06-15 23:59:59 UTC', + '2024-06-16 00:00:00 UTC', + '2024-06-16 11:59:59 UTC', + '2024-06-16 12:00:00 UTC' + ], + ARRAY['1 day'::interval], + '2020-01-01 12:00:00 UTC' +); + ts | inv | start_ts | end_ts | dur | in_range +------------------------------+---------+------------------------------+------------------------------+---------+---------- + Sat Jun 15 11:59:59 2024 UTC | @ 1 day | Fri Jun 14 12:00:00 2024 UTC | Sat Jun 15 12:00:00 2024 UTC | @ 1 day | t + Sat Jun 15 12:00:00 2024 UTC | @ 1 day | Sat Jun 15 12:00:00 2024 UTC | Sun Jun 16 12:00:00 2024 UTC | @ 1 day | t + Sat Jun 15 23:59:59 2024 UTC | @ 1 day | Sat Jun 15 12:00:00 2024 UTC | Sun Jun 16 12:00:00 2024 UTC | @ 1 day | t + Sun Jun 16 00:00:00 2024 UTC | @ 1 day | Sat Jun 15 12:00:00 2024 UTC | Sun Jun 16 12:00:00 2024 UTC | @ 1 day | t + Sun Jun 16 11:59:59 2024 UTC | @ 1 day | Sat Jun 15 12:00:00 2024 UTC | Sun Jun 16 12:00:00 2024 UTC | @ 1 day | t + Sun Jun 16 12:00:00 2024 UTC | @ 1 day | Sun Jun 16 12:00:00 2024 UTC | Mon Jun 17 12:00:00 2024 UTC | @ 1 day | t + +-- Custom origin: 15 minutes past midnight +SELECT * FROM test_ranges_with_origin( + ARRAY[ + '2024-06-15 00:00:00 UTC'::timestamptz, + '2024-06-15 00:14:59 UTC', + '2024-06-15 00:15:00 UTC', + '2024-06-15 12:00:00 UTC', + '2024-06-16 00:14:59 UTC', + '2024-06-16 00:15:00 UTC' + ], + ARRAY['1 day'::interval], + '2020-01-01 00:15:00 UTC' +); + ts | inv | start_ts | end_ts | dur | in_range +------------------------------+---------+------------------------------+------------------------------+---------+---------- + Sat Jun 15 00:00:00 2024 UTC | @ 1 day | Fri Jun 14 00:15:00 2024 UTC | Sat Jun 15 00:15:00 2024 UTC | @ 1 day | t + Sat Jun 15 00:14:59 2024 UTC | @ 1 day | Fri Jun 14 00:15:00 2024 UTC | Sat Jun 15 00:15:00 2024 UTC | @ 1 day | t + Sat Jun 15 00:15:00 2024 UTC | @ 1 day | Sat Jun 15 00:15:00 2024 UTC | Sun Jun 16 00:15:00 2024 UTC | @ 1 day | t + Sat Jun 15 12:00:00 2024 UTC | @ 1 day | Sat Jun 15 00:15:00 2024 UTC | Sun Jun 16 00:15:00 2024 UTC | @ 1 day | t + Sun Jun 16 00:14:59 2024 UTC | @ 1 day | Sat Jun 15 00:15:00 2024 UTC | Sun Jun 16 00:15:00 2024 UTC | @ 1 day | t + Sun Jun 16 00:15:00 2024 UTC | @ 1 day | Sun Jun 16 00:15:00 2024 UTC | Mon Jun 17 00:15:00 2024 UTC | @ 1 day | t + +-- Custom origin: hourly chunks starting at 30 minutes +SELECT * FROM test_ranges_with_origin( + ARRAY[ + '2024-06-15 12:00:00 UTC'::timestamptz, + '2024-06-15 12:29:59 UTC', + '2024-06-15 12:30:00 UTC', + '2024-06-15 13:29:59 UTC', + '2024-06-15 13:30:00 UTC' + ], + ARRAY['1 hour'::interval], + '2020-01-01 00:30:00 UTC' +); + ts | inv | start_ts | end_ts | dur | in_range +------------------------------+----------+------------------------------+------------------------------+----------+---------- + Sat Jun 15 12:00:00 2024 UTC | @ 1 hour | Sat Jun 15 11:30:00 2024 UTC | Sat Jun 15 12:30:00 2024 UTC | @ 1 hour | t + Sat Jun 15 12:29:59 2024 UTC | @ 1 hour | Sat Jun 15 11:30:00 2024 UTC | Sat Jun 15 12:30:00 2024 UTC | @ 1 hour | t + Sat Jun 15 12:30:00 2024 UTC | @ 1 hour | Sat Jun 15 12:30:00 2024 UTC | Sat Jun 15 13:30:00 2024 UTC | @ 1 hour | t + Sat Jun 15 13:29:59 2024 UTC | @ 1 hour | Sat Jun 15 12:30:00 2024 UTC | Sat Jun 15 13:30:00 2024 UTC | @ 1 hour | t + Sat Jun 15 13:30:00 2024 UTC | @ 1 hour | Sat Jun 15 13:30:00 2024 UTC | Sat Jun 15 14:30:00 2024 UTC | @ 1 hour | t + +--------------------------------------------------------------- +-- DST TRANSITION TESTS +--------------------------------------------------------------- +-- America/Los_Angeles DST spring forward (Mar 10) +SET timezone = 'America/Los_Angeles'; +SELECT * FROM test_ranges( + ARRAY[ + '2024-03-09 23:59:59 America/Los_Angeles'::timestamptz, + '2024-03-10 00:00:00 America/Los_Angeles', + '2024-03-10 01:59:59 America/Los_Angeles', + '2024-03-10 03:00:00 America/Los_Angeles', -- 2 AM doesn't exist + '2024-03-11 00:00:00 America/Los_Angeles' + ], + ARRAY['1 hour'::interval, '1 day', '1 week'] +); + ts | inv | start_ts | end_ts | dur | in_range +------------------------------+----------+------------------------------+------------------------------+-------------------+---------- + Sat Mar 09 23:59:59 2024 PST | @ 1 hour | Sat Mar 09 23:00:00 2024 PST | Sun Mar 10 00:00:00 2024 PST | @ 1 hour | t + Sat Mar 09 23:59:59 2024 PST | @ 1 day | Sat Mar 09 00:00:00 2024 PST | Sun Mar 10 00:00:00 2024 PST | @ 1 day | t + Sat Mar 09 23:59:59 2024 PST | @ 7 days | Mon Mar 04 00:00:00 2024 PST | Mon Mar 11 00:00:00 2024 PDT | @ 6 days 23 hours | t + Sun Mar 10 00:00:00 2024 PST | @ 1 hour | Sun Mar 10 00:00:00 2024 PST | Sun Mar 10 01:00:00 2024 PST | @ 1 hour | t + Sun Mar 10 00:00:00 2024 PST | @ 1 day | Sun Mar 10 00:00:00 2024 PST | Mon Mar 11 00:00:00 2024 PDT | @ 23 hours | t + Sun Mar 10 00:00:00 2024 PST | @ 7 days | Mon Mar 04 00:00:00 2024 PST | Mon Mar 11 00:00:00 2024 PDT | @ 6 days 23 hours | t + Sun Mar 10 01:59:59 2024 PST | @ 1 hour | Sun Mar 10 01:00:00 2024 PST | Sun Mar 10 03:00:00 2024 PDT | @ 1 hour | t + Sun Mar 10 01:59:59 2024 PST | @ 1 day | Sun Mar 10 00:00:00 2024 PST | Mon Mar 11 00:00:00 2024 PDT | @ 23 hours | t + Sun Mar 10 01:59:59 2024 PST | @ 7 days | Mon Mar 04 00:00:00 2024 PST | Mon Mar 11 00:00:00 2024 PDT | @ 6 days 23 hours | t + Sun Mar 10 03:00:00 2024 PDT | @ 1 hour | Sun Mar 10 03:00:00 2024 PDT | Sun Mar 10 04:00:00 2024 PDT | @ 1 hour | t + Sun Mar 10 03:00:00 2024 PDT | @ 1 day | Sun Mar 10 00:00:00 2024 PST | Mon Mar 11 00:00:00 2024 PDT | @ 23 hours | t + Sun Mar 10 03:00:00 2024 PDT | @ 7 days | Mon Mar 04 00:00:00 2024 PST | Mon Mar 11 00:00:00 2024 PDT | @ 6 days 23 hours | t + Mon Mar 11 00:00:00 2024 PDT | @ 1 hour | Mon Mar 11 00:00:00 2024 PDT | Mon Mar 11 01:00:00 2024 PDT | @ 1 hour | t + Mon Mar 11 00:00:00 2024 PDT | @ 1 day | Mon Mar 11 00:00:00 2024 PDT | Tue Mar 12 00:00:00 2024 PDT | @ 1 day | t + Mon Mar 11 00:00:00 2024 PDT | @ 7 days | Mon Mar 11 00:00:00 2024 PDT | Mon Mar 18 00:00:00 2024 PDT | @ 7 days | t + +-- America/Los_Angeles DST fall back (Nov 3) +SELECT * FROM test_ranges( + ARRAY[ + '2024-11-02 23:59:59 America/Los_Angeles'::timestamptz, + '2024-11-03 00:00:00 America/Los_Angeles', + '2024-11-03 01:30:00 PDT', -- first 1:30 AM + '2024-11-03 01:30:00 PST', -- second 1:30 AM + '2024-11-03 02:00:00 America/Los_Angeles' + ], + ARRAY['1 hour'::interval, '1 day'] +); + ts | inv | start_ts | end_ts | dur | in_range +------------------------------+----------+------------------------------+------------------------------+----------------+---------- + Sat Nov 02 23:59:59 2024 PDT | @ 1 hour | Sat Nov 02 23:00:00 2024 PDT | Sun Nov 03 00:00:00 2024 PDT | @ 1 hour | t + Sat Nov 02 23:59:59 2024 PDT | @ 1 day | Sat Nov 02 00:00:00 2024 PDT | Sun Nov 03 00:00:00 2024 PDT | @ 1 day | t + Sun Nov 03 00:00:00 2024 PDT | @ 1 hour | Sun Nov 03 00:00:00 2024 PDT | Sun Nov 03 01:00:00 2024 PDT | @ 1 hour | t + Sun Nov 03 00:00:00 2024 PDT | @ 1 day | Sun Nov 03 00:00:00 2024 PDT | Mon Nov 04 00:00:00 2024 PST | @ 1 day 1 hour | t + Sun Nov 03 01:30:00 2024 PDT | @ 1 hour | Sun Nov 03 01:00:00 2024 PDT | Sun Nov 03 01:00:00 2024 PST | @ 1 hour | t + Sun Nov 03 01:30:00 2024 PDT | @ 1 day | Sun Nov 03 00:00:00 2024 PDT | Mon Nov 04 00:00:00 2024 PST | @ 1 day 1 hour | t + Sun Nov 03 01:30:00 2024 PST | @ 1 hour | Sun Nov 03 01:00:00 2024 PST | Sun Nov 03 02:00:00 2024 PST | @ 1 hour | t + Sun Nov 03 01:30:00 2024 PST | @ 1 day | Sun Nov 03 00:00:00 2024 PDT | Mon Nov 04 00:00:00 2024 PST | @ 1 day 1 hour | t + Sun Nov 03 02:00:00 2024 PST | @ 1 hour | Sun Nov 03 02:00:00 2024 PST | Sun Nov 03 03:00:00 2024 PST | @ 1 hour | t + Sun Nov 03 02:00:00 2024 PST | @ 1 day | Sun Nov 03 00:00:00 2024 PDT | Mon Nov 04 00:00:00 2024 PST | @ 1 day 1 hour | t + +-- Europe/London DST +SET timezone = 'Europe/London'; +SELECT * FROM test_ranges( + ARRAY[ + '2024-03-31 00:59:59 Europe/London'::timestamptz, -- before spring forward + '2024-03-31 02:00:00 Europe/London', -- after spring forward + '2024-10-27 01:30:00 BST', -- before fall back + '2024-10-27 01:30:00 GMT' -- after fall back + ], + ARRAY['1 hour'::interval, '1 day'] +); + ts | inv | start_ts | end_ts | dur | in_range +------------------------------+----------+------------------------------+------------------------------+----------------+---------- + Sun Mar 31 00:59:59 2024 GMT | @ 1 hour | Sun Mar 31 00:00:00 2024 GMT | Sun Mar 31 02:00:00 2024 BST | @ 1 hour | t + Sun Mar 31 00:59:59 2024 GMT | @ 1 day | Sun Mar 31 00:00:00 2024 GMT | Mon Apr 01 00:00:00 2024 BST | @ 23 hours | t + Sun Mar 31 02:00:00 2024 BST | @ 1 hour | Sun Mar 31 02:00:00 2024 BST | Sun Mar 31 03:00:00 2024 BST | @ 1 hour | t + Sun Mar 31 02:00:00 2024 BST | @ 1 day | Sun Mar 31 00:00:00 2024 GMT | Mon Apr 01 00:00:00 2024 BST | @ 23 hours | t + Sun Oct 27 01:30:00 2024 BST | @ 1 hour | Sun Oct 27 01:00:00 2024 BST | Sun Oct 27 01:00:00 2024 GMT | @ 1 hour | t + Sun Oct 27 01:30:00 2024 BST | @ 1 day | Sun Oct 27 00:00:00 2024 BST | Mon Oct 28 00:00:00 2024 GMT | @ 1 day 1 hour | t + Sun Oct 27 01:30:00 2024 GMT | @ 1 hour | Sun Oct 27 01:00:00 2024 GMT | Sun Oct 27 02:00:00 2024 GMT | @ 1 hour | t + Sun Oct 27 01:30:00 2024 GMT | @ 1 day | Sun Oct 27 00:00:00 2024 BST | Mon Oct 28 00:00:00 2024 GMT | @ 1 day 1 hour | t + +-- Use a timezone with 45-minute offset for hypertable tests +SET timezone = 'Pacific/Chatham'; +--------------------------------------------------------------- +-- HYPERTABLE TESTS: UUID v7 +--------------------------------------------------------------- +CREATE TABLE uuid_events( + id uuid PRIMARY KEY, + device_id int, + temp float +); +SELECT create_hypertable('uuid_events', 'id', chunk_time_interval => interval '1 day'); + create_hypertable +-------------------------- + (1,public,uuid_events,t) + +-- UUIDs for known timestamps: +-- 2025-01-01 02:00 UTC, 2025-01-01 09:00 UTC +-- 2025-01-02 03:00 UTC, 2025-01-02 04:00 UTC +-- 2025-01-03 05:00 UTC, 2025-01-03 12:00 UTC +INSERT INTO uuid_events VALUES + ('01942117-de80-7000-8121-f12b2b69dd96', 1, 1.0), + ('0194214e-cd00-7000-a9a7-63f1416dab45', 2, 2.0), + ('0194263e-3a80-7000-8f40-82c987b1bc1f', 3, 3.0), + ('01942675-2900-7000-8db1-a98694b18785', 4, 4.0), + ('01942bd2-7380-7000-9bc4-5f97443907b8', 5, 5.0), + ('01942d52-f900-7000-866e-07d6404d53c1', 6, 6.0); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'uuid_events' ORDER BY range_start; + chunk_name | range_start | range_end +------------------+--------------------------------+-------------------------------- + _hyper_1_1_chunk | Wed Jan 01 00:00:00 2025 +1345 | Thu Jan 02 00:00:00 2025 +1345 + _hyper_1_2_chunk | Thu Jan 02 00:00:00 2025 +1345 | Fri Jan 03 00:00:00 2025 +1345 + _hyper_1_3_chunk | Sat Jan 04 00:00:00 2025 +1345 | Sun Jan 05 00:00:00 2025 +1345 + +DROP TABLE uuid_events; +--------------------------------------------------------------- +-- HYPERTABLE TESTS: TIMESTAMPTZ +--------------------------------------------------------------- +-- Daily chunks +CREATE TABLE tz_daily(time timestamptz NOT NULL, value int); +SELECT create_hypertable('tz_daily', 'time', chunk_time_interval => interval '1 day'); + create_hypertable +----------------------- + (2,public,tz_daily,t) + +INSERT INTO tz_daily VALUES + ('2025-01-01 00:00:00 UTC', 1), + ('2025-01-01 23:59:59.999999 UTC', 2), + ('2025-01-02 00:00:00 UTC', 3); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_daily' ORDER BY range_start; + chunk_name | range_start | range_end +------------------+--------------------------------+-------------------------------- + _hyper_2_4_chunk | Wed Jan 01 00:00:00 2025 +1345 | Thu Jan 02 00:00:00 2025 +1345 + _hyper_2_5_chunk | Thu Jan 02 00:00:00 2025 +1345 | Fri Jan 03 00:00:00 2025 +1345 + +DROP TABLE tz_daily; +-- Weekly chunks +CREATE TABLE tz_weekly(time timestamptz NOT NULL, value int); +SELECT create_hypertable('tz_weekly', 'time', chunk_time_interval => interval '1 week'); + create_hypertable +------------------------ + (3,public,tz_weekly,t) + +INSERT INTO tz_weekly VALUES + ('2025-01-06 00:00:00 UTC', 1), -- Monday + ('2025-01-12 23:59:59 UTC', 2), -- Sunday + ('2025-01-13 00:00:00 UTC', 3); -- Monday (next week) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_weekly' ORDER BY range_start; + chunk_name | range_start | range_end +------------------+--------------------------------+-------------------------------- + _hyper_3_6_chunk | Mon Jan 06 00:00:00 2025 +1345 | Mon Jan 13 00:00:00 2025 +1345 + _hyper_3_7_chunk | Mon Jan 13 00:00:00 2025 +1345 | Mon Jan 20 00:00:00 2025 +1345 + +DROP TABLE tz_weekly; +-- Monthly chunks +CREATE TABLE tz_monthly(time timestamptz NOT NULL, value int); +SELECT create_hypertable('tz_monthly', 'time', chunk_time_interval => interval '1 month'); + create_hypertable +------------------------- + (4,public,tz_monthly,t) + +INSERT INTO tz_monthly VALUES + ('2025-01-15 12:00:00 UTC', 1), + ('2025-02-15 12:00:00 UTC', 2), + ('2025-03-15 12:00:00 UTC', 3); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_monthly' ORDER BY range_start; + chunk_name | range_start | range_end +-------------------+--------------------------------+-------------------------------- + _hyper_4_8_chunk | Wed Jan 01 00:00:00 2025 +1345 | Sat Feb 01 00:00:00 2025 +1345 + _hyper_4_9_chunk | Sat Feb 01 00:00:00 2025 +1345 | Sat Mar 01 00:00:00 2025 +1345 + _hyper_4_10_chunk | Sat Mar 01 00:00:00 2025 +1345 | Tue Apr 01 00:00:00 2025 +1345 + +DROP TABLE tz_monthly; +-- Yearly chunks +CREATE TABLE tz_yearly(time timestamptz NOT NULL, value int); +SELECT create_hypertable('tz_yearly', 'time', chunk_time_interval => interval '1 year'); + create_hypertable +------------------------ + (5,public,tz_yearly,t) + +INSERT INTO tz_yearly VALUES + ('2024-06-15 12:00:00 UTC', 1), + ('2025-06-15 12:00:00 UTC', 2); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_yearly' ORDER BY range_start; + chunk_name | range_start | range_end +-------------------+--------------------------------+-------------------------------- + _hyper_5_11_chunk | Mon Jan 01 00:00:00 2024 +1345 | Wed Jan 01 00:00:00 2025 +1345 + _hyper_5_12_chunk | Wed Jan 01 00:00:00 2025 +1345 | Thu Jan 01 00:00:00 2026 +1345 + +DROP TABLE tz_yearly; +-- DST spring forward test: 23-hour chunk +-- America/New_York DST 2025: Mar 9 at 2:00 AM clocks spring forward to 3:00 AM +SET timezone = 'America/New_York'; +CREATE TABLE tz_dst_spring(time timestamptz NOT NULL, value int); +SELECT create_hypertable('tz_dst_spring', 'time', chunk_time_interval => interval '1 day'); + create_hypertable +---------------------------- + (6,public,tz_dst_spring,t) + +INSERT INTO tz_dst_spring VALUES + ('2025-03-08 12:00:00', 1), -- day before DST (24 hours) + ('2025-03-09 12:00:00', 2), -- DST day (23 hours - spring forward) + ('2025-03-10 12:00:00', 3); -- day after DST (24 hours) +SELECT chunk_name, range_start, range_end, + round(extract(epoch FROM (range_end - range_start)) / 3600, 1) AS hours +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_dst_spring' ORDER BY range_start; + chunk_name | range_start | range_end | hours +-------------------+------------------------------+------------------------------+------- + _hyper_6_13_chunk | Sat Mar 08 00:00:00 2025 EST | Sun Mar 09 00:00:00 2025 EST | 24.0 + _hyper_6_14_chunk | Sun Mar 09 00:00:00 2025 EST | Mon Mar 10 00:00:00 2025 EDT | 23.0 + _hyper_6_15_chunk | Mon Mar 10 00:00:00 2025 EDT | Tue Mar 11 00:00:00 2025 EDT | 24.0 + +DROP TABLE tz_dst_spring; +-- DST fall back test: 25-hour chunk +-- America/New_York 2024: Nov 3 at 2:00 AM clocks fall back to 1:00 AM +CREATE TABLE tz_dst_fall(time timestamptz NOT NULL, value int); +SELECT create_hypertable('tz_dst_fall', 'time', chunk_time_interval => interval '1 day'); + create_hypertable +-------------------------- + (7,public,tz_dst_fall,t) + +INSERT INTO tz_dst_fall VALUES + ('2024-11-02 12:00:00', 1), -- day before DST (24 hours) + ('2024-11-03 12:00:00', 2), -- DST day (25 hours - fall back) + ('2024-11-04 12:00:00', 3); -- day after DST (24 hours) +SELECT chunk_name, range_start, range_end, + round(extract(epoch FROM (range_end - range_start)) / 3600, 1) AS hours +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_dst_fall' ORDER BY range_start; + chunk_name | range_start | range_end | hours +-------------------+------------------------------+------------------------------+------- + _hyper_7_16_chunk | Sat Nov 02 00:00:00 2024 EDT | Sun Nov 03 00:00:00 2024 EDT | 24.0 + _hyper_7_17_chunk | Sun Nov 03 00:00:00 2024 EDT | Mon Nov 04 00:00:00 2024 EST | 25.0 + _hyper_7_18_chunk | Mon Nov 04 00:00:00 2024 EST | Tue Nov 05 00:00:00 2024 EST | 24.0 + +DROP TABLE tz_dst_fall; +-- Same test in UTC - all chunks should be exactly 24 hours +SET timezone = 'UTC'; +CREATE TABLE tz_utc(time timestamptz NOT NULL, value int); +SELECT create_hypertable('tz_utc', 'time', chunk_time_interval => interval '1 day'); + create_hypertable +--------------------- + (8,public,tz_utc,t) + +INSERT INTO tz_utc VALUES + ('2025-03-08 12:00:00', 1), + ('2025-03-09 12:00:00', 2), + ('2025-03-10 12:00:00', 3); +-- All chunks are 24 hours in UTC (no DST) +SELECT chunk_name, range_start, range_end, + round(extract(epoch FROM (range_end - range_start)) / 3600, 1) AS hours +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_utc' ORDER BY range_start; + chunk_name | range_start | range_end | hours +-------------------+------------------------------+------------------------------+------- + _hyper_8_19_chunk | Sat Mar 08 00:00:00 2025 UTC | Sun Mar 09 00:00:00 2025 UTC | 24.0 + _hyper_8_20_chunk | Sun Mar 09 00:00:00 2025 UTC | Mon Mar 10 00:00:00 2025 UTC | 24.0 + _hyper_8_21_chunk | Mon Mar 10 00:00:00 2025 UTC | Tue Mar 11 00:00:00 2025 UTC | 24.0 + +DROP TABLE tz_utc; +-- Create in UTC, then switch to DST timezone +-- Origin is UTC midnight, but chunk duration depends on session timezone when chunk is created +SET timezone = 'UTC'; +CREATE TABLE tz_utc_then_dst(time timestamptz NOT NULL, value int); +SELECT create_hypertable('tz_utc_then_dst', 'time', chunk_time_interval => interval '1 day'); + create_hypertable +------------------------------ + (9,public,tz_utc_then_dst,t) + +-- Now switch to NY and insert around DST transition +SET timezone = 'America/New_York'; +INSERT INTO tz_utc_then_dst VALUES + ('2025-03-08 12:00:00', 1), + ('2025-03-09 12:00:00', 2), + ('2025-03-10 12:00:00', 3); +-- Chunks are 23 hours around DST because interval addition uses session timezone +SELECT chunk_name, range_start, range_end, + round(extract(epoch FROM (range_end - range_start)) / 3600, 1) AS hours +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_utc_then_dst' ORDER BY range_start; + chunk_name | range_start | range_end | hours +-------------------+------------------------------+------------------------------+------- + _hyper_9_22_chunk | Fri Mar 07 19:00:00 2025 EST | Sat Mar 08 19:00:00 2025 EST | 24.0 + _hyper_9_23_chunk | Sat Mar 08 19:00:00 2025 EST | Sun Mar 09 19:00:00 2025 EDT | 23.0 + _hyper_9_24_chunk | Sun Mar 09 19:00:00 2025 EDT | Mon Mar 10 19:00:00 2025 EDT | 24.0 + +-- Now change origin to local timezone (NY) midnight +-- This means new chunks will align to NY midnight instead of UTC midnight +SELECT set_chunk_time_interval('tz_utc_then_dst', interval '1 day', + chunk_time_origin => '2001-01-01 00:00:00 America/New_York'::timestamptz); + set_chunk_time_interval +------------------------- + + +-- Insert data that creates chunks aligned to NY midnight (not UTC midnight) +-- These chunks should align to local midnight +INSERT INTO tz_utc_then_dst VALUES + ('2025-03-15 12:00:00', 4), -- New chunk aligned to NY midnight + ('2025-03-16 12:00:00', 5); -- Another new chunk +-- Show all chunks - old ones aligned to UTC, new ones aligned to NY +SELECT chunk_name, range_start, range_end, + round(extract(epoch FROM (range_end - range_start)) / 3600, 1) AS hours +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_utc_then_dst' ORDER BY range_start; + chunk_name | range_start | range_end | hours +-------------------+------------------------------+------------------------------+------- + _hyper_9_22_chunk | Fri Mar 07 19:00:00 2025 EST | Sat Mar 08 19:00:00 2025 EST | 24.0 + _hyper_9_23_chunk | Sat Mar 08 19:00:00 2025 EST | Sun Mar 09 19:00:00 2025 EDT | 23.0 + _hyper_9_24_chunk | Sun Mar 09 19:00:00 2025 EDT | Mon Mar 10 19:00:00 2025 EDT | 24.0 + _hyper_9_25_chunk | Sat Mar 15 00:00:00 2025 EDT | Sun Mar 16 00:00:00 2025 EDT | 24.0 + _hyper_9_26_chunk | Sun Mar 16 00:00:00 2025 EDT | Mon Mar 17 00:00:00 2025 EDT | 24.0 + +-- Now insert data that falls into the gap between old UTC-aligned and new NY-aligned chunks +-- This chunk will be "cut" to fit between existing chunks +INSERT INTO tz_utc_then_dst VALUES ('2025-03-10 23:00:00', 6); -- Between existing chunks +-- Show all chunks with range size - the transition chunk should be smaller +SELECT chunk_name, range_start, range_end, + round(extract(epoch FROM (range_end - range_start)) / 3600, 1) AS hours +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_utc_then_dst' ORDER BY range_start; + chunk_name | range_start | range_end | hours +-------------------+------------------------------+------------------------------+------- + _hyper_9_22_chunk | Fri Mar 07 19:00:00 2025 EST | Sat Mar 08 19:00:00 2025 EST | 24.0 + _hyper_9_23_chunk | Sat Mar 08 19:00:00 2025 EST | Sun Mar 09 19:00:00 2025 EDT | 23.0 + _hyper_9_24_chunk | Sun Mar 09 19:00:00 2025 EDT | Mon Mar 10 19:00:00 2025 EDT | 24.0 + _hyper_9_27_chunk | Mon Mar 10 19:00:00 2025 EDT | Tue Mar 11 00:00:00 2025 EDT | 5.0 + _hyper_9_25_chunk | Sat Mar 15 00:00:00 2025 EDT | Sun Mar 16 00:00:00 2025 EDT | 24.0 + _hyper_9_26_chunk | Sun Mar 16 00:00:00 2025 EDT | Mon Mar 17 00:00:00 2025 EDT | 24.0 + +DROP TABLE tz_utc_then_dst; +-- Reset to Chatham for remaining tests +SET timezone = 'Pacific/Chatham'; +--------------------------------------------------------------- +-- HYPERTABLE TESTS: TIMESTAMP (without timezone) +--------------------------------------------------------------- +-- Daily chunks +CREATE TABLE ts_daily(time timestamp NOT NULL, value int); +SELECT create_hypertable('ts_daily', 'time', chunk_time_interval => interval '1 day'); +WARNING: column type "timestamp without time zone" used for "time" does not follow best practices + create_hypertable +------------------------ + (10,public,ts_daily,t) + +INSERT INTO ts_daily VALUES + ('2025-01-01 00:00:00', 1), + ('2025-01-01 23:59:59.999999', 2), + ('2025-01-02 00:00:00', 3); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'ts_daily' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+--------------------------------+-------------------------------- + _hyper_10_28_chunk | Wed Jan 01 13:45:00 2025 +1345 | Thu Jan 02 13:45:00 2025 +1345 + _hyper_10_29_chunk | Thu Jan 02 13:45:00 2025 +1345 | Fri Jan 03 13:45:00 2025 +1345 + +DROP TABLE ts_daily; +-- Weekly chunks +CREATE TABLE ts_weekly(time timestamp NOT NULL, value int); +SELECT create_hypertable('ts_weekly', 'time', chunk_time_interval => interval '1 week'); +WARNING: column type "timestamp without time zone" used for "time" does not follow best practices + create_hypertable +------------------------- + (11,public,ts_weekly,t) + +INSERT INTO ts_weekly VALUES + ('2025-01-06 00:00:00', 1), -- Monday + ('2025-01-12 23:59:59', 2), -- Sunday + ('2025-01-13 00:00:00', 3); -- Monday (next week) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'ts_weekly' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+--------------------------------+-------------------------------- + _hyper_11_30_chunk | Mon Jan 06 13:45:00 2025 +1345 | Mon Jan 13 13:45:00 2025 +1345 + _hyper_11_31_chunk | Mon Jan 13 13:45:00 2025 +1345 | Mon Jan 20 13:45:00 2025 +1345 + +DROP TABLE ts_weekly; +-- Monthly chunks +CREATE TABLE ts_monthly(time timestamp NOT NULL, value int); +SELECT create_hypertable('ts_monthly', 'time', chunk_time_interval => interval '1 month'); +WARNING: column type "timestamp without time zone" used for "time" does not follow best practices + create_hypertable +-------------------------- + (12,public,ts_monthly,t) + +INSERT INTO ts_monthly VALUES + ('2025-01-15 12:00:00', 1), + ('2025-02-15 12:00:00', 2), + ('2025-03-15 12:00:00', 3); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'ts_monthly' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+--------------------------------+-------------------------------- + _hyper_12_32_chunk | Wed Jan 01 13:45:00 2025 +1345 | Sat Feb 01 13:45:00 2025 +1345 + _hyper_12_33_chunk | Sat Feb 01 13:45:00 2025 +1345 | Sat Mar 01 13:45:00 2025 +1345 + _hyper_12_34_chunk | Sat Mar 01 13:45:00 2025 +1345 | Tue Apr 01 13:45:00 2025 +1345 + +DROP TABLE ts_monthly; +-- Yearly chunks +CREATE TABLE ts_yearly(time timestamp NOT NULL, value int); +SELECT create_hypertable('ts_yearly', 'time', chunk_time_interval => interval '1 year'); +WARNING: column type "timestamp without time zone" used for "time" does not follow best practices + create_hypertable +------------------------- + (13,public,ts_yearly,t) + +INSERT INTO ts_yearly VALUES + ('2024-06-15 12:00:00', 1), + ('2025-06-15 12:00:00', 2); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'ts_yearly' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+--------------------------------+-------------------------------- + _hyper_13_35_chunk | Mon Jan 01 13:45:00 2024 +1345 | Wed Jan 01 13:45:00 2025 +1345 + _hyper_13_36_chunk | Wed Jan 01 13:45:00 2025 +1345 | Thu Jan 01 13:45:00 2026 +1345 + +DROP TABLE ts_yearly; +-- Custom origin: days starting at noon (timestamp without timezone) +CREATE TABLE ts_noon_days(time timestamp NOT NULL, value int); +SELECT create_hypertable('ts_noon_days', 'time', + chunk_time_interval => interval '1 day', + chunk_time_origin => '2024-06-01 12:00:00'::timestamp); +WARNING: column type "timestamp without time zone" used for "time" does not follow best practices + create_hypertable +---------------------------- + (14,public,ts_noon_days,t) + +INSERT INTO ts_noon_days VALUES + ('2024-06-01 00:00:00', 1), + ('2024-06-01 11:59:59', 2), + ('2024-06-01 12:00:00', 3), + ('2024-06-02 11:59:59', 4); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'ts_noon_days' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+--------------------------------+-------------------------------- + _hyper_14_37_chunk | Sat Jun 01 00:45:00 2024 +1245 | Sun Jun 02 00:45:00 2024 +1245 + _hyper_14_38_chunk | Sun Jun 02 00:45:00 2024 +1245 | Mon Jun 03 00:45:00 2024 +1245 + +DROP TABLE ts_noon_days; +-- Custom origin: fiscal year starting July 1 (timestamp without timezone) +CREATE TABLE ts_fiscal_year(time timestamp NOT NULL, value int); +SELECT create_hypertable('ts_fiscal_year', + by_range('time', interval '1 year', partition_origin => '2020-07-01 00:00:00'::timestamp)); +WARNING: column type "timestamp without time zone" used for "time" does not follow best practices + create_hypertable +------------------- + (15,t) + +INSERT INTO ts_fiscal_year VALUES + ('2024-06-30 23:59:59', 1), -- FY2024 + ('2024-07-01 00:00:00', 2), -- FY2025 + ('2025-06-30 23:59:59', 3); -- FY2025 +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'ts_fiscal_year' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+--------------------------------+-------------------------------- + _hyper_15_39_chunk | Sat Jul 01 12:45:00 2023 +1245 | Mon Jul 01 12:45:00 2024 +1245 + _hyper_15_40_chunk | Mon Jul 01 12:45:00 2024 +1245 | Tue Jul 01 12:45:00 2025 +1245 + +DROP TABLE ts_fiscal_year; +--------------------------------------------------------------- +-- HYPERTABLE TESTS: DATE +--------------------------------------------------------------- +-- Daily chunks +CREATE TABLE date_daily(day date NOT NULL, value int); +SELECT create_hypertable('date_daily', 'day', chunk_time_interval => interval '1 day'); + create_hypertable +-------------------------- + (16,public,date_daily,t) + +INSERT INTO date_daily VALUES + ('2025-01-01', 1), + ('2025-01-02', 2), + ('2025-01-03', 3); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'date_daily' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+--------------------------------+-------------------------------- + _hyper_16_41_chunk | Wed Jan 01 13:45:00 2025 +1345 | Thu Jan 02 13:45:00 2025 +1345 + _hyper_16_42_chunk | Thu Jan 02 13:45:00 2025 +1345 | Fri Jan 03 13:45:00 2025 +1345 + _hyper_16_43_chunk | Fri Jan 03 13:45:00 2025 +1345 | Sat Jan 04 13:45:00 2025 +1345 + +DROP TABLE date_daily; +-- Weekly chunks +CREATE TABLE date_weekly(day date NOT NULL, value int); +SELECT create_hypertable('date_weekly', 'day', chunk_time_interval => interval '1 week'); + create_hypertable +--------------------------- + (17,public,date_weekly,t) + +INSERT INTO date_weekly VALUES + ('2025-01-06', 1), -- Monday + ('2025-01-12', 2), -- Sunday + ('2025-01-13', 3); -- Monday (next week) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'date_weekly' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+--------------------------------+-------------------------------- + _hyper_17_44_chunk | Mon Jan 06 13:45:00 2025 +1345 | Mon Jan 13 13:45:00 2025 +1345 + _hyper_17_45_chunk | Mon Jan 13 13:45:00 2025 +1345 | Mon Jan 20 13:45:00 2025 +1345 + +DROP TABLE date_weekly; +-- Monthly chunks +CREATE TABLE date_monthly(day date NOT NULL, value int); +SELECT create_hypertable('date_monthly', 'day', chunk_time_interval => interval '1 month'); + create_hypertable +---------------------------- + (18,public,date_monthly,t) + +INSERT INTO date_monthly VALUES + ('2025-01-15', 1), + ('2025-02-15', 2), + ('2025-03-15', 3); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'date_monthly' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+--------------------------------+-------------------------------- + _hyper_18_46_chunk | Wed Jan 01 13:45:00 2025 +1345 | Sat Feb 01 13:45:00 2025 +1345 + _hyper_18_47_chunk | Sat Feb 01 13:45:00 2025 +1345 | Sat Mar 01 13:45:00 2025 +1345 + _hyper_18_48_chunk | Sat Mar 01 13:45:00 2025 +1345 | Tue Apr 01 13:45:00 2025 +1345 + +DROP TABLE date_monthly; +-- Yearly chunks +CREATE TABLE date_yearly(day date NOT NULL, value int); +SELECT create_hypertable('date_yearly', 'day', chunk_time_interval => interval '1 year'); + create_hypertable +--------------------------- + (19,public,date_yearly,t) + +INSERT INTO date_yearly VALUES + ('2024-06-15', 1), + ('2025-06-15', 2); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'date_yearly' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+--------------------------------+-------------------------------- + _hyper_19_49_chunk | Mon Jan 01 13:45:00 2024 +1345 | Wed Jan 01 13:45:00 2025 +1345 + _hyper_19_50_chunk | Wed Jan 01 13:45:00 2025 +1345 | Thu Jan 01 13:45:00 2026 +1345 + +DROP TABLE date_yearly; +-- Custom origin: weeks starting on Wednesday +CREATE TABLE date_wed_weeks(day date NOT NULL, value int); +SELECT create_hypertable('date_wed_weeks', 'day', + chunk_time_interval => interval '1 week', + chunk_time_origin => '2025-01-01'::date); -- Wednesday + create_hypertable +------------------------------ + (20,public,date_wed_weeks,t) + +INSERT INTO date_wed_weeks VALUES + ('2024-12-31', 1), -- Tuesday (before origin week boundary) + ('2025-01-01', 2), -- Wednesday (at origin) + ('2025-01-07', 3), -- Tuesday + ('2025-01-08', 4); -- Wednesday (next week) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'date_wed_weeks' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+--------------------------------+-------------------------------- + _hyper_20_51_chunk | Wed Dec 25 13:45:00 2024 +1345 | Wed Jan 01 13:45:00 2025 +1345 + _hyper_20_52_chunk | Wed Jan 01 13:45:00 2025 +1345 | Wed Jan 08 13:45:00 2025 +1345 + _hyper_20_53_chunk | Wed Jan 08 13:45:00 2025 +1345 | Wed Jan 15 13:45:00 2025 +1345 + +DROP TABLE date_wed_weeks; +-- Custom origin: fiscal year starting April 1 (date) +CREATE TABLE date_fiscal_year(day date NOT NULL, value int); +SELECT create_hypertable('date_fiscal_year', + by_range('day', interval '1 year', partition_origin => '2020-04-01'::date)); + create_hypertable +------------------- + (21,t) + +INSERT INTO date_fiscal_year VALUES + ('2024-03-31', 1), -- FY2024 + ('2024-04-01', 2), -- FY2025 + ('2025-03-31', 3); -- FY2025 +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'date_fiscal_year' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+--------------------------------+-------------------------------- + _hyper_21_54_chunk | Sat Apr 01 13:45:00 2023 +1345 | Mon Apr 01 13:45:00 2024 +1345 + _hyper_21_55_chunk | Mon Apr 01 13:45:00 2024 +1345 | Tue Apr 01 13:45:00 2025 +1345 + +DROP TABLE date_fiscal_year; +-- Quarterly chunks (date) +CREATE TABLE date_quarterly(day date NOT NULL, value int); +SELECT create_hypertable('date_quarterly', 'day', chunk_time_interval => interval '3 months'); + create_hypertable +------------------------------ + (22,public,date_quarterly,t) + +INSERT INTO date_quarterly VALUES + ('2025-01-15', 1), -- Q1 + ('2025-04-15', 2), -- Q2 + ('2025-07-15', 3), -- Q3 + ('2025-10-15', 4); -- Q4 +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'date_quarterly' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+--------------------------------+-------------------------------- + _hyper_22_56_chunk | Wed Jan 01 13:45:00 2025 +1345 | Tue Apr 01 13:45:00 2025 +1345 + _hyper_22_57_chunk | Tue Apr 01 13:45:00 2025 +1345 | Tue Jul 01 12:45:00 2025 +1245 + _hyper_22_58_chunk | Tue Jul 01 12:45:00 2025 +1245 | Wed Oct 01 13:45:00 2025 +1345 + _hyper_22_59_chunk | Wed Oct 01 13:45:00 2025 +1345 | Thu Jan 01 13:45:00 2026 +1345 + +DROP TABLE date_quarterly; +--------------------------------------------------------------- +-- CUSTOM ORIGIN TESTS +--------------------------------------------------------------- +-- Fiscal year starting July 1 +CREATE TABLE fiscal_year(time timestamptz NOT NULL, value int); +SELECT create_hypertable('fiscal_year', + by_range('time', interval '1 year', partition_origin => '2020-07-01 00:00:00 UTC'::timestamptz)); + create_hypertable +------------------- + (23,t) + +INSERT INTO fiscal_year VALUES + ('2024-06-30 23:59:59 UTC', 1), -- FY2024 + ('2024-07-01 00:00:00 UTC', 2), -- FY2025 + ('2025-06-30 23:59:59 UTC', 3); -- FY2025 +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'fiscal_year' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+--------------------------------+-------------------------------- + _hyper_23_60_chunk | Sat Jul 01 12:45:00 2023 +1245 | Mon Jul 01 12:45:00 2024 +1245 + _hyper_23_61_chunk | Mon Jul 01 12:45:00 2024 +1245 | Tue Jul 01 12:45:00 2025 +1245 + +DROP TABLE fiscal_year; +-- Quarters starting April 1 +CREATE TABLE fiscal_quarter(time timestamptz NOT NULL, value int); +SELECT create_hypertable('fiscal_quarter', + by_range('time', interval '3 months', partition_origin => '2024-04-01 00:00:00 UTC'::timestamptz)); + create_hypertable +------------------- + (24,t) + +INSERT INTO fiscal_quarter VALUES + ('2024-03-31 23:59:59 UTC', 1), -- Q4 FY2024 + ('2024-04-01 00:00:00 UTC', 2), -- Q1 FY2025 + ('2024-07-01 00:00:00 UTC', 3), -- Q2 FY2025 + ('2024-10-01 00:00:00 UTC', 4); -- Q3 FY2025 +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'fiscal_quarter' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+--------------------------------+-------------------------------- + _hyper_24_62_chunk | Mon Jan 01 13:45:00 2024 +1345 | Mon Apr 01 13:45:00 2024 +1345 + _hyper_24_63_chunk | Mon Apr 01 13:45:00 2024 +1345 | Mon Jul 01 13:45:00 2024 +1245 + _hyper_24_64_chunk | Tue Oct 01 13:45:00 2024 +1345 | Wed Jan 01 13:45:00 2025 +1345 + +DROP TABLE fiscal_quarter; +-- Days starting at noon +CREATE TABLE noon_days(time timestamptz NOT NULL, value int); +SELECT create_hypertable('noon_days', 'time', + chunk_time_interval => interval '1 day', + chunk_time_origin => '2024-06-01 12:00:00 UTC'::timestamptz); + create_hypertable +------------------------- + (25,public,noon_days,t) + +INSERT INTO noon_days VALUES + ('2024-06-01 00:00:00 UTC', 1), + ('2024-06-01 11:59:59 UTC', 2), + ('2024-06-01 12:00:00 UTC', 3), + ('2024-06-02 11:59:59 UTC', 4); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'noon_days' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+--------------------------------+-------------------------------- + _hyper_25_65_chunk | Sat Jun 01 00:45:00 2024 +1245 | Sun Jun 02 00:45:00 2024 +1245 + _hyper_25_66_chunk | Sun Jun 02 00:45:00 2024 +1245 | Mon Jun 03 00:45:00 2024 +1245 + +DROP TABLE noon_days; +-- Partitioning function with interval and origin (int epoch seconds to timestamptz) +CREATE OR REPLACE FUNCTION epoch_sec_to_timestamptz(epoch_sec int) +RETURNS timestamptz LANGUAGE SQL IMMUTABLE AS $$ + SELECT to_timestamp(epoch_sec); +$$; +CREATE TABLE events_epoch(epoch_sec int NOT NULL, value int); +SELECT create_hypertable('events_epoch', + by_range('epoch_sec', interval '1 month', + partition_func => 'epoch_sec_to_timestamptz', + partition_origin => '2024-07-01 00:00:00 UTC'::timestamptz)); + create_hypertable +------------------- + (26,t) + +-- Epoch values in seconds: +-- 2024-06-30 23:59:59 UTC = 1719791999 +-- 2024-07-01 00:00:00 UTC = 1719792000 +-- 2024-07-15 12:00:00 UTC = 1721044800 +-- 2024-08-01 00:00:00 UTC = 1722470400 +INSERT INTO events_epoch VALUES + (1719791999, 1), + (1719792000, 2), + (1721044800, 3), + (1722470400, 4); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'events_epoch' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+-------------+----------- + _hyper_26_67_chunk | | + _hyper_26_68_chunk | | + _hyper_26_69_chunk | | + +DROP TABLE events_epoch; +DROP FUNCTION epoch_sec_to_timestamptz(int); +--------------------------------------------------------------- +-- ADD_DIMENSION TESTS +--------------------------------------------------------------- +-- Test add_dimension() with origin parameter (deprecated API) +-- Add a time dimension to a table with integer primary dimension +CREATE TABLE add_dim_old_api(id int NOT NULL, time timestamptz NOT NULL, value int); +SELECT create_hypertable('add_dim_old_api', 'id', chunk_time_interval => 100); + create_hypertable +------------------------------- + (27,public,add_dim_old_api,t) + +SELECT add_dimension('add_dim_old_api', 'time', + chunk_time_interval => interval '1 month', + chunk_time_origin => '2024-04-01 00:00:00 UTC'::timestamptz); + add_dimension +------------------------------------ + (28,public,add_dim_old_api,time,t) + +INSERT INTO add_dim_old_api VALUES + (1, '2024-03-31 23:59:59 UTC', 1), -- March (before origin month) + (1, '2024-04-15 12:00:00 UTC', 2), -- April + (1, '2024-05-15 12:00:00 UTC', 3); -- May +-- Verify the origin was stored correctly in the dimension +SELECT d.column_name, + _timescaledb_functions.to_timestamp(d.interval_origin) AS origin +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'add_dim_old_api' AND d.column_name = 'time'; + column_name | origin +-------------+-------------------------------- + time | Mon Apr 01 13:45:00 2024 +1345 + +DROP TABLE add_dim_old_api; +-- Test add_dimension() with by_range and partition_origin (new API) +CREATE TABLE add_dim_new_api(id int NOT NULL, time timestamptz NOT NULL, value int); +SELECT create_hypertable('add_dim_new_api', 'id', chunk_time_interval => 100); + create_hypertable +------------------------------- + (28,public,add_dim_new_api,t) + +SELECT add_dimension('add_dim_new_api', + by_range('time', interval '3 months', partition_origin => '2024-07-01 00:00:00 UTC'::timestamptz)); + add_dimension +--------------- + (30,t) + +INSERT INTO add_dim_new_api VALUES + (1, '2024-06-30 23:59:59 UTC', 1), -- Q2 (before origin) + (1, '2024-07-01 00:00:00 UTC', 2), -- Q3 (at origin) + (1, '2024-10-01 00:00:00 UTC', 3); -- Q4 +-- Verify the origin was stored correctly +SELECT d.column_name, + _timescaledb_functions.to_timestamp(d.interval_origin) AS origin +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'add_dim_new_api' AND d.column_name = 'time'; + column_name | origin +-------------+-------------------------------- + time | Mon Jul 01 12:45:00 2024 +1245 + +DROP TABLE add_dim_new_api; +--------------------------------------------------------------- +-- SET_CHUNK_TIME_INTERVAL TESTS +--------------------------------------------------------------- +-- Test set_chunk_time_interval() with origin parameter +SET timezone = 'UTC'; +CREATE TABLE set_interval_test(time timestamptz NOT NULL, value int); +SELECT create_hypertable('set_interval_test', 'time', chunk_time_interval => interval '1 day'); + create_hypertable +--------------------------------- + (29,public,set_interval_test,t) + +-- Insert to create initial chunk aligned to default origin (midnight) +INSERT INTO set_interval_test VALUES ('2024-06-15 12:00:00 UTC', 1); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'set_interval_test' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+------------------------------+------------------------------ + _hyper_29_76_chunk | Sat Jun 15 00:00:00 2024 UTC | Sun Jun 16 00:00:00 2024 UTC + +-- Verify current origin (default: 2001-01-01 midnight) +SELECT d.column_name, + _timescaledb_functions.to_timestamp(d.interval_origin) AS origin +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'set_interval_test'; + column_name | origin +-------------+------------------------------ + time | Mon Jan 01 00:00:00 2001 UTC + +-- Verify calendar chunking is still enabled +SHOW timescaledb.enable_calendar_chunking; + timescaledb.enable_calendar_chunking +-------------------------------------- + on + +-- Change interval and origin to noon +SELECT set_chunk_time_interval('set_interval_test', interval '1 day', chunk_time_origin => '2024-01-01 12:00:00 UTC'::timestamptz); + set_chunk_time_interval +------------------------- + + +-- Verify the origin was updated to noon +SELECT d.column_name, + _timescaledb_functions.to_timestamp(d.interval_origin) AS origin +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'set_interval_test'; + column_name | origin +-------------+------------------------------ + time | Mon Jan 01 12:00:00 2024 UTC + +-- Insert to create transition chunk from midnight to noon +-- This data falls after the existing chunk's end (June 16 00:00) but before +-- the next noon boundary (June 16 12:00), creating a 12-hour transition chunk +INSERT INTO set_interval_test VALUES ('2024-06-16 06:00:00 UTC', 2); +-- Insert to create new chunk aligned to noon origin +INSERT INTO set_interval_test VALUES ('2024-07-15 18:00:00 UTC', 3); +-- Old chunk starts at midnight, transition chunk is 12 hours, new chunk starts at noon +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'set_interval_test' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+------------------------------+------------------------------ + _hyper_29_76_chunk | Sat Jun 15 00:00:00 2024 UTC | Sun Jun 16 00:00:00 2024 UTC + _hyper_29_77_chunk | Sun Jun 16 00:00:00 2024 UTC | Sun Jun 16 12:00:00 2024 UTC + _hyper_29_78_chunk | Mon Jul 15 12:00:00 2024 UTC | Tue Jul 16 12:00:00 2024 UTC + +DROP TABLE set_interval_test; +-- Test set_chunk_time_interval() changing from days to months with fiscal origin +CREATE TABLE set_interval_fiscal(time timestamptz NOT NULL, value int); +SELECT create_hypertable('set_interval_fiscal', 'time', chunk_time_interval => interval '1 day'); + create_hypertable +----------------------------------- + (30,public,set_interval_fiscal,t) + +-- Insert initial data +INSERT INTO set_interval_fiscal VALUES ('2024-06-15 12:00:00 UTC', 1); +-- Change to monthly interval with fiscal year origin (July 1) +SELECT set_chunk_time_interval('set_interval_fiscal', interval '1 month', chunk_time_origin => '2024-07-01 00:00:00 UTC'::timestamptz); + set_chunk_time_interval +------------------------- + + +-- Verify the origin was updated +SELECT d.column_name, + _timescaledb_functions.to_timestamp(d.interval_origin) AS origin +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'set_interval_fiscal'; + column_name | origin +-------------+------------------------------ + time | Mon Jul 01 00:00:00 2024 UTC + +-- Insert more data to create monthly chunks aligned to fiscal year +INSERT INTO set_interval_fiscal VALUES + ('2024-07-15 12:00:00 UTC', 2), + ('2024-08-15 12:00:00 UTC', 3); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'set_interval_fiscal' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+------------------------------+------------------------------ + _hyper_30_79_chunk | Sat Jun 15 00:00:00 2024 UTC | Sun Jun 16 00:00:00 2024 UTC + _hyper_30_80_chunk | Mon Jul 01 00:00:00 2024 UTC | Thu Aug 01 00:00:00 2024 UTC + _hyper_30_81_chunk | Thu Aug 01 00:00:00 2024 UTC | Sun Sep 01 00:00:00 2024 UTC + +DROP TABLE set_interval_fiscal; +-- Reset timezone +SET timezone = 'Pacific/Chatham'; +--------------------------------------------------------------- +-- COMPARISON: CALENDAR VS NON-CALENDAR CHUNKING +--------------------------------------------------------------- +-- Show what the UTC values look like in Chatham time zone +-- Chatham is UTC+13:45 in January (daylight saving), so noon UTC is 01:45 next day +SELECT '2025-01-01 12:00:00 UTC'::timestamptz AS "UTC noon Jan 1", + '2025-01-02 12:00:00 UTC'::timestamptz AS "UTC noon Jan 2"; + UTC noon Jan 1 | UTC noon Jan 2 +--------------------------------+-------------------------------- + Thu Jan 02 01:45:00 2025 +1345 | Fri Jan 03 01:45:00 2025 +1345 + +-- Non-calendar chunking +SET timescaledb.enable_calendar_chunking = false; +CREATE TABLE non_calendar(time timestamptz NOT NULL, value int); +SELECT create_hypertable('non_calendar', 'time', chunk_time_interval => interval '1 day'); + create_hypertable +---------------------------- + (31,public,non_calendar,t) + +INSERT INTO non_calendar VALUES + ('2025-01-01 12:00:00 UTC', 1), + ('2025-01-02 12:00:00 UTC', 2); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'non_calendar' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+--------------------------------+-------------------------------- + _hyper_31_82_chunk | Wed Jan 01 13:45:00 2025 +1345 | Thu Jan 02 13:45:00 2025 +1345 + _hyper_31_83_chunk | Thu Jan 02 13:45:00 2025 +1345 | Fri Jan 03 13:45:00 2025 +1345 + +DROP TABLE non_calendar; +-- Calendar chunking +SET timescaledb.enable_calendar_chunking = true; +CREATE TABLE calendar(time timestamptz NOT NULL, value int); +SELECT create_hypertable('calendar', 'time', chunk_time_interval => interval '1 day'); + create_hypertable +------------------------ + (32,public,calendar,t) + +INSERT INTO calendar VALUES + ('2025-01-01 12:00:00 UTC', 1), + ('2025-01-02 12:00:00 UTC', 2); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'calendar' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+--------------------------------+-------------------------------- + _hyper_32_84_chunk | Thu Jan 02 00:00:00 2025 +1345 | Fri Jan 03 00:00:00 2025 +1345 + _hyper_32_85_chunk | Fri Jan 03 00:00:00 2025 +1345 | Sat Jan 04 00:00:00 2025 +1345 + +DROP TABLE calendar; +--------------------------------------------------------------- +-- SET_CHUNK_TIME_INTERVAL WITH NON-INTERVAL TYPE +--------------------------------------------------------------- +-- Test that passing integer (microseconds) instead of Interval +-- to set_chunk_time_interval() on a calendar-based hypertable errors. +SET timezone = 'UTC'; +SET timescaledb.enable_calendar_chunking = true; +CREATE TABLE calendar_no_integer(time timestamptz NOT NULL, value int); +SELECT create_hypertable('calendar_no_integer', 'time', chunk_time_interval => interval '1 day'); + create_hypertable +----------------------------------- + (33,public,calendar_no_integer,t) + +-- Verify calendar chunking is active +SELECT d.column_name, + d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'calendar_no_integer'; + column_name | has_integer_interval | has_time_interval +-------------+----------------------+------------------- + time | f | t + +-- Try to change to integer interval - this should error +\set ON_ERROR_STOP 0 +SELECT set_chunk_time_interval('calendar_no_integer', 86400000000::bigint); +ERROR: cannot use integer interval on calendar-based hypertable +\set ON_ERROR_STOP 1 +-- Verify calendar chunking is still active (unchanged) +SELECT d.column_name, + d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'calendar_no_integer'; + column_name | has_integer_interval | has_time_interval +-------------+----------------------+------------------- + time | f | t + +-- But changing to another Interval value should work +SELECT set_chunk_time_interval('calendar_no_integer', interval '1 week'); + set_chunk_time_interval +------------------------- + + +SELECT d.column_name, + d.interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'calendar_no_integer'; + column_name | interval +-------------+---------- + time | @ 7 days + +DROP TABLE calendar_no_integer; +--------------------------------------------------------------- +-- GUC CHANGE AFTER HYPERTABLE CREATION +-- Test that chunking mode is sticky (doesn't change with GUC) +-- Test all combinations of: +-- - Hypertable type: calendar vs non-calendar +-- - GUC state: ON vs OFF +-- - Interval input: INTERVAL vs integer +--------------------------------------------------------------- +SET timezone = 'UTC'; +--------------------------------------------------------------- +-- NON-CALENDAR HYPERTABLE TESTS +-- Created with calendar_chunking = OFF +-- Should stay non-calendar regardless of GUC or input type +--------------------------------------------------------------- +SET timescaledb.enable_calendar_chunking = false; +CREATE TABLE non_calendar_ht(time timestamptz NOT NULL, value int); +SELECT create_hypertable('non_calendar_ht', 'time', chunk_time_interval => interval '1 day'); + create_hypertable +------------------------------- + (34,public,non_calendar_ht,t) + +-- Verify initial state: non-calendar mode (has_integer_interval=t, has_time_interval=f) +SELECT d.column_name, + d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval_length +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'non_calendar_ht'; + column_name | has_integer_interval | has_time_interval | interval_length +-------------+----------------------+-------------------+----------------- + time | t | f | 86400000000 + +-- GUC OFF + integer input → stays non-calendar +SET timescaledb.enable_calendar_chunking = false; +SELECT set_chunk_time_interval('non_calendar_ht', 172800000000::bigint); -- 2 days in microseconds + set_chunk_time_interval +------------------------- + + +SELECT d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval_length +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'non_calendar_ht'; + has_integer_interval | has_time_interval | interval_length +----------------------+-------------------+----------------- + t | f | 172800000000 + +-- GUC OFF + INTERVAL input → stays non-calendar (converted to microseconds) +SET timescaledb.enable_calendar_chunking = false; +SELECT set_chunk_time_interval('non_calendar_ht', interval '3 days'); + set_chunk_time_interval +------------------------- + + +SELECT d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval_length +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'non_calendar_ht'; + has_integer_interval | has_time_interval | interval_length +----------------------+-------------------+----------------- + t | f | 259200000000 + +-- GUC ON + integer input → stays non-calendar +SET timescaledb.enable_calendar_chunking = true; +SELECT set_chunk_time_interval('non_calendar_ht', 345600000000::bigint); -- 4 days in microseconds + set_chunk_time_interval +------------------------- + + +SELECT d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval_length +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'non_calendar_ht'; + has_integer_interval | has_time_interval | interval_length +----------------------+-------------------+----------------- + t | f | 345600000000 + +-- GUC ON + INTERVAL input → stays non-calendar (converted to microseconds) +SET timescaledb.enable_calendar_chunking = true; +SELECT set_chunk_time_interval('non_calendar_ht', interval '5 days'); + set_chunk_time_interval +------------------------- + + +SELECT d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval_length +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'non_calendar_ht'; + has_integer_interval | has_time_interval | interval_length +----------------------+-------------------+----------------- + t | f | 432000000000 + +DROP TABLE non_calendar_ht; +--------------------------------------------------------------- +-- CALENDAR HYPERTABLE TESTS +-- Created with calendar_chunking = ON +-- Should stay calendar regardless of GUC +-- Integer input should always error +--------------------------------------------------------------- +SET timescaledb.enable_calendar_chunking = true; +CREATE TABLE calendar_ht(time timestamptz NOT NULL, value int); +SELECT create_hypertable('calendar_ht', 'time', chunk_time_interval => interval '1 day'); + create_hypertable +--------------------------- + (35,public,calendar_ht,t) + +-- Verify initial state: calendar mode (interval_length IS NULL, interval IS NOT NULL) +SELECT d.column_name, + d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'calendar_ht'; + column_name | has_integer_interval | has_time_interval | interval +-------------+----------------------+-------------------+---------- + time | f | t | @ 1 day + +-- GUC ON + INTERVAL input → stays calendar +SET timescaledb.enable_calendar_chunking = true; +SELECT set_chunk_time_interval('calendar_ht', interval '2 days'); + set_chunk_time_interval +------------------------- + + +SELECT d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'calendar_ht'; + has_integer_interval | has_time_interval | interval +----------------------+-------------------+---------- + f | t | @ 2 days + +-- GUC OFF + INTERVAL input → stays calendar +SET timescaledb.enable_calendar_chunking = false; +SELECT set_chunk_time_interval('calendar_ht', interval '3 days'); + set_chunk_time_interval +------------------------- + + +SELECT d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'calendar_ht'; + has_integer_interval | has_time_interval | interval +----------------------+-------------------+---------- + f | t | @ 3 days + +-- GUC ON + integer input → ERROR (cannot use integer on calendar hypertable) +SET timescaledb.enable_calendar_chunking = true; +\set ON_ERROR_STOP 0 +SELECT set_chunk_time_interval('calendar_ht', 86400000000::bigint); +ERROR: cannot use integer interval on calendar-based hypertable +\set ON_ERROR_STOP 1 +-- Verify unchanged after error +SELECT d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'calendar_ht'; + has_integer_interval | has_time_interval | interval +----------------------+-------------------+---------- + f | t | @ 3 days + +-- GUC OFF + integer input → ERROR (cannot use integer on calendar hypertable) +SET timescaledb.enable_calendar_chunking = false; +\set ON_ERROR_STOP 0 +SELECT set_chunk_time_interval('calendar_ht', 172800000000::bigint); +ERROR: cannot use integer interval on calendar-based hypertable +\set ON_ERROR_STOP 1 +-- Verify unchanged after error +SELECT d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'calendar_ht'; + has_integer_interval | has_time_interval | interval +----------------------+-------------------+---------- + f | t | @ 3 days + +DROP TABLE calendar_ht; +--------------------------------------------------------------- +-- MODE SWITCHING TESTS +-- Test switching between calendar and non-calendar modes +-- using the calendar_chunking parameter +--------------------------------------------------------------- +-- Use a non-UTC timezone to make calendar vs non-calendar differences visible +-- In UTC, chunks would align nicely even without calendar chunking +SET timezone = 'America/New_York'; +SET timescaledb.enable_calendar_chunking = false; +CREATE TABLE mode_switch(time timestamptz NOT NULL, value int); +SELECT create_hypertable('mode_switch', 'time', chunk_time_interval => interval '1 day'); + create_hypertable +--------------------------- + (36,public,mode_switch,t) + +-- 1. Initial state: non-calendar mode +SELECT d.column_name, + d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'mode_switch'; + column_name | has_integer_interval | has_time_interval +-------------+----------------------+------------------- + time | t | f + +INSERT INTO mode_switch VALUES + ('2025-01-01 12:00:00 UTC', 1), + ('2025-01-02 12:00:00 UTC', 2); +-- Non-calendar: chunk boundaries at 19:00 EST (midnight UTC), not local midnight +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'mode_switch' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+------------------------------+------------------------------ + _hyper_36_86_chunk | Tue Dec 31 19:00:00 2024 EST | Wed Jan 01 19:00:00 2025 EST + _hyper_36_87_chunk | Wed Jan 01 19:00:00 2025 EST | Thu Jan 02 19:00:00 2025 EST + +-- 2. Switch to calendar mode using set_chunk_time_interval +SELECT set_chunk_time_interval('mode_switch', '1 week'::interval, calendar_chunking => true); + set_chunk_time_interval +------------------------- + + +SELECT d.column_name, + d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'mode_switch'; + column_name | has_integer_interval | has_time_interval | interval +-------------+----------------------+-------------------+---------- + time | f | t | @ 7 days + +INSERT INTO mode_switch VALUES + ('2025-02-10 12:00:00 UTC', 3), + ('2025-02-17 12:00:00 UTC', 4); +-- Calendar: new chunks aligned to local Monday midnight (00:00 EST) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'mode_switch' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+------------------------------+------------------------------ + _hyper_36_86_chunk | Tue Dec 31 19:00:00 2024 EST | Wed Jan 01 19:00:00 2025 EST + _hyper_36_87_chunk | Wed Jan 01 19:00:00 2025 EST | Thu Jan 02 19:00:00 2025 EST + _hyper_36_88_chunk | Mon Feb 10 00:00:00 2025 EST | Mon Feb 17 00:00:00 2025 EST + _hyper_36_89_chunk | Mon Feb 17 00:00:00 2025 EST | Mon Feb 24 00:00:00 2025 EST + +-- 3. Switch back to non-calendar using set_partitioning_interval +SELECT set_partitioning_interval('mode_switch', '2 days'::interval, calendar_chunking => false); + set_partitioning_interval +--------------------------- + + +SELECT d.column_name, + d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval_length +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'mode_switch'; + column_name | has_integer_interval | has_time_interval | interval_length +-------------+----------------------+-------------------+----------------- + time | t | f | 172800000000 + +INSERT INTO mode_switch VALUES + ('2025-06-15 12:00:00 UTC', 5), + ('2025-06-18 12:00:00 UTC', 6); +-- Non-calendar: fixed 2-day intervals, boundaries at odd hours +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'mode_switch' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+------------------------------+------------------------------ + _hyper_36_86_chunk | Tue Dec 31 19:00:00 2024 EST | Wed Jan 01 19:00:00 2025 EST + _hyper_36_87_chunk | Wed Jan 01 19:00:00 2025 EST | Thu Jan 02 19:00:00 2025 EST + _hyper_36_88_chunk | Mon Feb 10 00:00:00 2025 EST | Mon Feb 17 00:00:00 2025 EST + _hyper_36_89_chunk | Mon Feb 17 00:00:00 2025 EST | Mon Feb 24 00:00:00 2025 EST + _hyper_36_90_chunk | Sat Jun 14 01:00:00 2025 EDT | Mon Jun 16 01:00:00 2025 EDT + _hyper_36_91_chunk | Wed Jun 18 01:00:00 2025 EDT | Fri Jun 20 01:00:00 2025 EDT + +-- 4. Switch back to calendar using set_chunk_time_interval +SELECT set_chunk_time_interval('mode_switch', '1 month'::interval, calendar_chunking => true); + set_chunk_time_interval +------------------------- + + +SELECT d.column_name, + d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'mode_switch'; + column_name | has_integer_interval | has_time_interval | interval +-------------+----------------------+-------------------+---------- + time | f | t | @ 1 mon + +INSERT INTO mode_switch VALUES + ('2025-09-15 12:00:00 UTC', 7), + ('2025-10-15 12:00:00 UTC', 8); +-- Calendar: new chunks aligned to local month start (00:00 EST on 1st) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'mode_switch' ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+------------------------------+------------------------------ + _hyper_36_86_chunk | Tue Dec 31 19:00:00 2024 EST | Wed Jan 01 19:00:00 2025 EST + _hyper_36_87_chunk | Wed Jan 01 19:00:00 2025 EST | Thu Jan 02 19:00:00 2025 EST + _hyper_36_88_chunk | Mon Feb 10 00:00:00 2025 EST | Mon Feb 17 00:00:00 2025 EST + _hyper_36_89_chunk | Mon Feb 17 00:00:00 2025 EST | Mon Feb 24 00:00:00 2025 EST + _hyper_36_90_chunk | Sat Jun 14 01:00:00 2025 EDT | Mon Jun 16 01:00:00 2025 EDT + _hyper_36_91_chunk | Wed Jun 18 01:00:00 2025 EDT | Fri Jun 20 01:00:00 2025 EDT + _hyper_36_92_chunk | Mon Sep 01 00:00:00 2025 EDT | Wed Oct 01 00:00:00 2025 EDT + _hyper_36_93_chunk | Wed Oct 01 00:00:00 2025 EDT | Sat Nov 01 00:00:00 2025 EDT + +DROP TABLE mode_switch; +-- Test error: calendar_chunking => true with integer interval +CREATE TABLE calendar_switch_error(time timestamptz NOT NULL, value int); +SELECT create_hypertable('calendar_switch_error', 'time', chunk_time_interval => interval '1 day'); + create_hypertable +------------------------------------- + (37,public,calendar_switch_error,t) + +\set ON_ERROR_STOP 0 +SELECT set_chunk_time_interval('calendar_switch_error', 86400000000::bigint, calendar_chunking => true); +ERROR: cannot enable calendar chunking with an integer interval +\set ON_ERROR_STOP 1 +SELECT d.column_name, + d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'calendar_switch_error'; + column_name | has_integer_interval | has_time_interval +-------------+----------------------+------------------- + time | t | f + +DROP TABLE calendar_switch_error; +RESET timescaledb.enable_calendar_chunking; +--------------------------------------------------------------- +-- CHUNK POSITION RELATIVE TO ORIGIN +-- Test chunks before, enclosing, and after the origin +--------------------------------------------------------------- +SET timezone = 'UTC'; +SET timescaledb.enable_calendar_chunking = true; +-- Test with origin in the middle of the data range +-- Origin: 2020-01-15 (middle of January) +CREATE TABLE origin_position(time timestamptz NOT NULL, value int); +SELECT create_hypertable('origin_position', + by_range('time', interval '1 month', partition_origin => '2020-01-15 00:00:00 UTC'::timestamptz)); + create_hypertable +------------------- + (38,t) + +-- Insert data: before origin, enclosing origin, after origin +INSERT INTO origin_position VALUES + -- Chunks BEFORE origin (historical data) + ('2019-11-20 12:00:00 UTC', 1), -- Nov 15 - Dec 15, 2019 + ('2019-12-20 12:00:00 UTC', 2), -- Dec 15 - Jan 15, 2020 + -- Chunk ENCLOSING origin (origin is at chunk boundary) + ('2020-01-15 00:00:00 UTC', 3), -- Exactly at origin + ('2020-01-20 12:00:00 UTC', 4), -- Jan 15 - Feb 15, 2020 + -- Chunks AFTER origin + ('2020-02-20 12:00:00 UTC', 5), -- Feb 15 - Mar 15, 2020 + ('2020-03-20 12:00:00 UTC', 6); -- Mar 15 - Apr 15, 2020 +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'origin_position' +ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+------------------------------+------------------------------ + _hyper_38_94_chunk | Fri Nov 15 00:00:00 2019 UTC | Sun Dec 15 00:00:00 2019 UTC + _hyper_38_95_chunk | Sun Dec 15 00:00:00 2019 UTC | Wed Jan 15 00:00:00 2020 UTC + _hyper_38_96_chunk | Wed Jan 15 00:00:00 2020 UTC | Sat Feb 15 00:00:00 2020 UTC + _hyper_38_97_chunk | Sat Feb 15 00:00:00 2020 UTC | Sun Mar 15 00:00:00 2020 UTC + _hyper_38_98_chunk | Sun Mar 15 00:00:00 2020 UTC | Wed Apr 15 00:00:00 2020 UTC + +-- Verify data is in correct chunks +SELECT tableoid::regclass as chunk, time, value +FROM origin_position ORDER BY time; + chunk | time | value +------------------------------------------+------------------------------+------- + _timescaledb_internal._hyper_38_94_chunk | Wed Nov 20 12:00:00 2019 UTC | 1 + _timescaledb_internal._hyper_38_95_chunk | Fri Dec 20 12:00:00 2019 UTC | 2 + _timescaledb_internal._hyper_38_96_chunk | Wed Jan 15 00:00:00 2020 UTC | 3 + _timescaledb_internal._hyper_38_96_chunk | Mon Jan 20 12:00:00 2020 UTC | 4 + _timescaledb_internal._hyper_38_97_chunk | Thu Feb 20 12:00:00 2020 UTC | 5 + _timescaledb_internal._hyper_38_98_chunk | Fri Mar 20 12:00:00 2020 UTC | 6 + +DROP TABLE origin_position; +-- Test with daily chunks and origin at noon +-- This tests chunks before/after the origin time-of-day boundary +CREATE TABLE origin_daily(time timestamptz NOT NULL, value int); +SELECT create_hypertable('origin_daily', + by_range('time', interval '1 day', partition_origin => '2020-06-15 12:00:00 UTC'::timestamptz)); + create_hypertable +------------------- + (39,t) + +INSERT INTO origin_daily VALUES + -- Before origin date (chunks before origin) + ('2020-06-13 18:00:00 UTC', 1), -- Jun 13 noon - Jun 14 noon + ('2020-06-14 18:00:00 UTC', 2), -- Jun 14 noon - Jun 15 noon + -- At and after origin + ('2020-06-15 12:00:00 UTC', 3), -- Exactly at origin + ('2020-06-15 18:00:00 UTC', 4), -- Jun 15 noon - Jun 16 noon + ('2020-06-16 06:00:00 UTC', 5), -- Same chunk (before noon) + ('2020-06-16 18:00:00 UTC', 6); -- Jun 16 noon - Jun 17 noon +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'origin_daily' +ORDER BY range_start; + chunk_name | range_start | range_end +---------------------+------------------------------+------------------------------ + _hyper_39_99_chunk | Sat Jun 13 12:00:00 2020 UTC | Sun Jun 14 12:00:00 2020 UTC + _hyper_39_100_chunk | Sun Jun 14 12:00:00 2020 UTC | Mon Jun 15 12:00:00 2020 UTC + _hyper_39_101_chunk | Mon Jun 15 12:00:00 2020 UTC | Tue Jun 16 12:00:00 2020 UTC + _hyper_39_102_chunk | Tue Jun 16 12:00:00 2020 UTC | Wed Jun 17 12:00:00 2020 UTC + +DROP TABLE origin_daily; +--------------------------------------------------------------- +-- EXTREME VALUES AND OVERFLOW CLAMPING TESTS +-- Test timestamps near the boundaries of the valid range +--------------------------------------------------------------- +-- Test calc_range with ancient timestamps +-- PostgreSQL timestamp range: 4713 BC to 294276 AD +-- Ancient timestamp tests +SELECT *, end_ts - start_ts AS range_size FROM calc_range('4700-01-15 00:00:00 BC'::timestamptz, '1 year'::interval); + start_ts | end_ts | range_size +---------------------------------+---------------------------------+------------ + Sat Jan 01 00:00:00 4700 UTC BC | Sun Jan 01 00:00:00 4699 UTC BC | @ 365 days + +SELECT *, end_ts - start_ts AS range_size FROM calc_range('4700-01-15 00:00:00 BC'::timestamptz, '1 month'::interval); + start_ts | end_ts | range_size +---------------------------------+---------------------------------+------------ + Sat Jan 01 00:00:00 4700 UTC BC | Tue Feb 01 00:00:00 4700 UTC BC | @ 31 days + +SELECT *, end_ts - start_ts AS range_size FROM calc_range('4700-01-15 00:00:00 BC'::timestamptz, '1 day'::interval); + start_ts | end_ts | range_size +---------------------------------+---------------------------------+------------ + Sat Jan 15 00:00:00 4700 UTC BC | Sun Jan 16 00:00:00 4700 UTC BC | @ 1 day + +-- Far future timestamp tests (but within valid range) +SELECT *, end_ts - start_ts AS range_size FROM calc_range('100000-01-15 00:00:00'::timestamptz, '1 year'::interval); + start_ts | end_ts | range_size +--------------------------------+--------------------------------+------------ + Sat Jan 01 00:00:00 100000 UTC | Mon Jan 01 00:00:00 100001 UTC | @ 366 days + +SELECT *, end_ts - start_ts AS range_size FROM calc_range('100000-01-15 00:00:00'::timestamptz, '1 month'::interval); + start_ts | end_ts | range_size +--------------------------------+--------------------------------+------------ + Sat Jan 01 00:00:00 100000 UTC | Tue Feb 01 00:00:00 100000 UTC | @ 31 days + +SELECT *, end_ts - start_ts AS range_size FROM calc_range('100000-01-15 00:00:00'::timestamptz, '1 day'::interval); + start_ts | end_ts | range_size +--------------------------------+--------------------------------+------------ + Sat Jan 15 00:00:00 100000 UTC | Sun Jan 16 00:00:00 100000 UTC | @ 1 day + +-- Test hypertable with data spanning ancient times +CREATE TABLE extreme_ancient(time timestamptz NOT NULL, value int); +SELECT create_hypertable('extreme_ancient', 'time', chunk_time_interval => interval '100 years'); + create_hypertable +------------------------------- + (40,public,extreme_ancient,t) + +INSERT INTO extreme_ancient VALUES + ('4700-06-15 00:00:00 BC', 1), + ('4600-06-15 00:00:00 BC', 2); +-- Verify chunks are created for ancient dates +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'extreme_ancient' +ORDER BY range_start; + chunk_name | range_start | range_end +---------------------+---------------------------------+--------------------------------- + _hyper_40_103_chunk | Sat Jan 01 00:00:00 4700 UTC BC | Thu Jan 01 00:00:00 4600 UTC BC + _hyper_40_104_chunk | Thu Jan 01 00:00:00 4600 UTC BC | Tue Jan 01 00:00:00 4500 UTC BC + +-- Show CHECK constraints on chunks with chunk size +-- Chunk size should be close to 100 years when not clamped +SELECT * FROM show_chunk_constraints('extreme_ancient'); + chunk | constraint_def | chunk_size +---------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------+-------------- + _hyper_40_103_chunk | CHECK ((("time" >= 'Sat Jan 01 00:00:00 4700 UTC BC'::timestamp with time zone) AND ("time" < 'Thu Jan 01 00:00:00 4600 UTC BC'::timestamp with time zone))) | @ 36524 days + _hyper_40_104_chunk | CHECK ((("time" >= 'Thu Jan 01 00:00:00 4600 UTC BC'::timestamp with time zone) AND ("time" < 'Tue Jan 01 00:00:00 4500 UTC BC'::timestamp with time zone))) | @ 36524 days + +DROP TABLE extreme_ancient; +-- Test hypertable with data in far future +CREATE TABLE extreme_future(time timestamptz NOT NULL, value int); +SELECT create_hypertable('extreme_future', 'time', chunk_time_interval => interval '100 years'); + create_hypertable +------------------------------ + (41,public,extreme_future,t) + +INSERT INTO extreme_future VALUES + ('100000-06-15 00:00:00', 1), + ('100100-06-15 00:00:00', 2); +-- Verify chunks are created for far future dates +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'extreme_future' +ORDER BY range_start; + chunk_name | range_start | range_end +---------------------+--------------------------------+-------------------------------- + _hyper_41_105_chunk | Tue Jan 01 00:00:00 99901 UTC | Mon Jan 01 00:00:00 100001 UTC + _hyper_41_106_chunk | Mon Jan 01 00:00:00 100001 UTC | Sat Jan 01 00:00:00 100101 UTC + +-- Show CHECK constraints on chunks with chunk size +-- Chunk size should be close to 100 years when not clamped +SELECT * FROM show_chunk_constraints('extreme_future'); + chunk | constraint_def | chunk_size +---------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------+-------------- + _hyper_41_105_chunk | CHECK ((("time" >= 'Tue Jan 01 00:00:00 99901 UTC'::timestamp with time zone) AND ("time" < 'Mon Jan 01 00:00:00 100001 UTC'::timestamp with time zone))) | @ 36525 days + _hyper_41_106_chunk | CHECK ((("time" >= 'Mon Jan 01 00:00:00 100001 UTC'::timestamp with time zone) AND ("time" < 'Sat Jan 01 00:00:00 100101 UTC'::timestamp with time zone))) | @ 36524 days + +DROP TABLE extreme_future; +-- Test chunks with open-ended constraints (clamped to MIN/MAX) +-- Use large intervals that cause boundaries to overflow +CREATE TABLE open_ended_chunks(time timestamptz NOT NULL, value int); +SELECT create_hypertable('open_ended_chunks', 'time', chunk_time_interval => interval '10000 years'); + create_hypertable +--------------------------------- + (42,public,open_ended_chunks,t) + +-- Insert at boundaries - should create open-ended constraints +INSERT INTO open_ended_chunks VALUES + ('4700-06-15 00:00:00 BC', 1), -- Near min boundary, chunk start overflows + ('294000-06-15 00:00:00', 2); -- Near max boundary, chunk end overflows +-- Show CHECK constraints - should see open-ended constraints +-- Chunk size should be smaller than 10000 years when clamped +SELECT * FROM show_chunk_constraints('open_ended_chunks'); + chunk | constraint_def | chunk_size +---------------------+--------------------------------------------------------------------------------+------------ + _hyper_42_107_chunk | CHECK (("time" < 'Mon Jan 01 00:00:00 2001 UTC'::timestamp with time zone)) | + _hyper_42_108_chunk | CHECK (("time" >= 'Mon Jan 01 00:00:00 292001 UTC'::timestamp with time zone)) | + +DROP TABLE open_ended_chunks; +-- Test with origin far in the past - chunks before and after +CREATE TABLE origin_ancient(time timestamptz NOT NULL, value int); +SELECT create_hypertable('origin_ancient', + by_range('time', interval '1000 years', partition_origin => '2000-01-01 00:00:00 BC'::timestamptz)); + create_hypertable +------------------- + (43,t) + +INSERT INTO origin_ancient VALUES + ('4000-06-15 00:00:00 BC', 1), -- Before origin + ('1500-06-15 00:00:00 BC', 2), -- After origin (closer to present) + ('500-06-15 00:00:00', 3), -- After origin (AD) + ('2020-06-15 00:00:00', 4); -- Modern times +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'origin_ancient' +ORDER BY range_start; + chunk_name | range_start | range_end +---------------------+---------------------------------+--------------------------------- + _hyper_43_109_chunk | Mon Jan 01 00:00:00 4000 UTC BC | Thu Jan 01 00:00:00 3000 UTC BC + _hyper_43_110_chunk | Mon Jan 01 00:00:00 2000 UTC BC | Thu Jan 01 00:00:00 1000 UTC BC + _hyper_43_111_chunk | Mon Jan 01 00:00:00 0001 UTC | Thu Jan 01 00:00:00 1001 UTC + _hyper_43_112_chunk | Mon Jan 01 00:00:00 2001 UTC | Thu Jan 01 00:00:00 3001 UTC + +DROP TABLE origin_ancient; +-- Test with origin far in the future - all data before origin +CREATE TABLE origin_future(time timestamptz NOT NULL, value int); +SELECT create_hypertable('origin_future', + by_range('time', interval '100 years', partition_origin => '50000-01-01 00:00:00'::timestamptz)); + create_hypertable +------------------- + (44,t) + +INSERT INTO origin_future VALUES + ('2020-06-15 00:00:00', 1), + ('2120-06-15 00:00:00', 2), + ('2220-06-15 00:00:00', 3); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'origin_future' +ORDER BY range_start; + chunk_name | range_start | range_end +---------------------+------------------------------+------------------------------ + _hyper_44_113_chunk | Sat Jan 01 00:00:00 2000 UTC | Fri Jan 01 00:00:00 2100 UTC + _hyper_44_114_chunk | Fri Jan 01 00:00:00 2100 UTC | Wed Jan 01 00:00:00 2200 UTC + _hyper_44_115_chunk | Wed Jan 01 00:00:00 2200 UTC | Mon Jan 01 00:00:00 2300 UTC + +DROP TABLE origin_future; +--------------------------------------------------------------- +-- MIXED INTERVAL TESTS WITH EXTREME VALUES +-- Test intervals with month+day+time components near boundaries +--------------------------------------------------------------- +-- Mixed interval (month + day) with values spanning origin +SELECT * FROM calc_range('2020-01-15 00:00:00 UTC'::timestamptz, '1 month 15 days'::interval, + '2020-02-01 00:00:00 UTC'::timestamptz); + start_ts | end_ts +------------------------------+------------------------------ + Tue Dec 17 00:00:00 2019 UTC | Sat Feb 01 00:00:00 2020 UTC + +SELECT * FROM calc_range('2020-02-01 00:00:00 UTC'::timestamptz, '1 month 15 days'::interval, + '2020-02-01 00:00:00 UTC'::timestamptz); + start_ts | end_ts +------------------------------+------------------------------ + Sat Feb 01 00:00:00 2020 UTC | Mon Mar 16 00:00:00 2020 UTC + +SELECT * FROM calc_range('2020-03-01 00:00:00 UTC'::timestamptz, '1 month 15 days'::interval, + '2020-02-01 00:00:00 UTC'::timestamptz); + start_ts | end_ts +------------------------------+------------------------------ + Sat Feb 01 00:00:00 2020 UTC | Mon Mar 16 00:00:00 2020 UTC + +-- Mixed interval with ancient dates +SELECT *, end_ts - start_ts AS range_size FROM calc_range('4700-01-15 00:00:00 BC'::timestamptz, '1 month 15 days'::interval); + start_ts | end_ts | range_size +---------------------------------+---------------------------------+------------ + Sun Dec 19 00:00:00 4701 UTC BC | Thu Feb 03 00:00:00 4700 UTC BC | @ 46 days + +SELECT *, end_ts - start_ts AS range_size FROM calc_range('50000-01-15 00:00:00'::timestamptz, '1 month 15 days'::interval); + start_ts | end_ts | range_size +-------------------------------+-------------------------------+------------ + Sat Jan 01 00:00:00 50000 UTC | Wed Feb 16 00:00:00 50000 UTC | @ 46 days + +-- Create hypertable with mixed interval +CREATE TABLE mixed_interval(time timestamptz NOT NULL, value int); +SELECT create_hypertable('mixed_interval', + by_range('time', interval '1 month 15 days', partition_origin => '2020-01-01 00:00:00 UTC'::timestamptz)); + create_hypertable +------------------- + (45,t) + +INSERT INTO mixed_interval VALUES + ('2019-12-01 00:00:00 UTC', 1), -- Before origin + ('2020-01-01 00:00:00 UTC', 2), -- At origin + ('2020-01-20 00:00:00 UTC', 3), -- Same chunk as origin + ('2020-02-20 00:00:00 UTC', 4), -- Next chunk + ('2020-04-01 00:00:00 UTC', 5); -- Two chunks after +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'mixed_interval' +ORDER BY range_start; + chunk_name | range_start | range_end +---------------------+------------------------------+------------------------------ + _hyper_45_116_chunk | Sat Nov 16 00:00:00 2019 UTC | Wed Jan 01 00:00:00 2020 UTC + _hyper_45_117_chunk | Wed Jan 01 00:00:00 2020 UTC | Sun Feb 16 00:00:00 2020 UTC + _hyper_45_118_chunk | Sun Feb 16 00:00:00 2020 UTC | Tue Mar 31 00:00:00 2020 UTC + _hyper_45_119_chunk | Tue Mar 31 00:00:00 2020 UTC | Fri May 15 00:00:00 2020 UTC + +DROP TABLE mixed_interval; +--------------------------------------------------------------- +-- INTEGER DIMENSION OVERFLOW TESTS +-- Test integer dimensions with values near INT64 boundaries +--------------------------------------------------------------- +-- Test bigint with large positive values +CREATE TABLE int_extreme_max(time bigint NOT NULL, value int); +SELECT create_hypertable('int_extreme_max', by_range('time', 1000000000000, partition_origin => 0)); + create_hypertable +------------------- + (46,t) + +INSERT INTO int_extreme_max VALUES + (9223372036854775000, 1), -- Near INT64_MAX + (9223372036854774000, 2); +SELECT * FROM show_int_chunk_slices('int_extreme_max'); + chunk_name | range_start | range_end | chunk_size +---------------------+---------------------+---------------------+------------- + _hyper_46_120_chunk | 9223372000000000000 | 9223372036854775807 | 36854775807 + +-- Show CHECK constraints - should see open-ended constraint (no upper bound) +SELECT * FROM show_check_constraints('int_extreme_max'); + chunk | constraint_def +---------------------+--------------------------------------------------- + _hyper_46_120_chunk | CHECK (("time" >= '9223372000000000000'::bigint)) + +DROP TABLE int_extreme_max; +-- Test bigint with large negative values +CREATE TABLE int_extreme_min(time bigint NOT NULL, value int); +SELECT create_hypertable('int_extreme_min', by_range('time', 1000000000000, partition_origin => 0)); + create_hypertable +------------------- + (47,t) + +INSERT INTO int_extreme_min VALUES + (-9223372036854775000, 1), -- Near INT64_MIN + (-9223372036854774000, 2); +SELECT * FROM show_int_chunk_slices('int_extreme_min'); + chunk_name | range_start | range_end | chunk_size +---------------------+----------------------+----------------------+------------- + _hyper_47_121_chunk | -9223372036854775808 | -9223372000000000000 | 36854775808 + +-- Show CHECK constraints - should see open-ended constraint (no lower bound) +SELECT * FROM show_check_constraints('int_extreme_min'); + chunk | constraint_def +---------------------+--------------------------------------------------- + _hyper_47_121_chunk | CHECK (("time" < '-9223372000000000000'::bigint)) + +DROP TABLE int_extreme_min; +-- Test integer origin with values on both sides +CREATE TABLE int_origin_sides(time bigint NOT NULL, value int); +SELECT create_hypertable('int_origin_sides', by_range('time', 100, partition_origin => 1000)); + create_hypertable +------------------- + (48,t) + +INSERT INTO int_origin_sides VALUES + (850, 1), -- Chunk [800, 900) + (950, 2), -- Chunk [900, 1000) + (1000, 3), -- At origin, chunk [1000, 1100) + (1050, 4), -- Same chunk as origin + (1100, 5), -- Chunk [1100, 1200) + (1150, 6); -- Same chunk +SELECT * FROM show_int_chunk_slices('int_origin_sides'); + chunk_name | range_start | range_end | chunk_size +---------------------+-------------+-----------+------------ + _hyper_48_122_chunk | 800 | 900 | 100 + _hyper_48_123_chunk | 900 | 1000 | 100 + _hyper_48_124_chunk | 1000 | 1100 | 100 + _hyper_48_125_chunk | 1100 | 1200 | 100 + +-- Verify data placement +SELECT tableoid::regclass as chunk, time, value +FROM int_origin_sides ORDER BY time; + chunk | time | value +-------------------------------------------+------+------- + _timescaledb_internal._hyper_48_122_chunk | 850 | 1 + _timescaledb_internal._hyper_48_123_chunk | 950 | 2 + _timescaledb_internal._hyper_48_124_chunk | 1000 | 3 + _timescaledb_internal._hyper_48_124_chunk | 1050 | 4 + _timescaledb_internal._hyper_48_125_chunk | 1100 | 5 + _timescaledb_internal._hyper_48_125_chunk | 1150 | 6 + +DROP TABLE int_origin_sides; +RESET timezone; +RESET timescaledb.enable_calendar_chunking; +--------------------------------------------------------------- +-- TIMESTAMP WITHOUT TIMEZONE AND GENERAL ALGORITHM TESTS +--------------------------------------------------------------- +-- Test ts_chunk_range_calculate_general() with timestamp (without timezone) +-- and boundary/overflow values for improved test coverage +\c :TEST_DBNAME :ROLE_SUPERUSER +-- Create wrapper function for timestamp without timezone +CREATE OR REPLACE FUNCTION calc_range_ts(ts TIMESTAMP, chunk_interval INTERVAL, origin TIMESTAMP DEFAULT NULL, force_general BOOL DEFAULT NULL) +RETURNS TABLE(start_ts TIMESTAMP, end_ts TIMESTAMP) AS :MODULE_PATHNAME, 'ts_dimension_calculate_open_range_calendar' LANGUAGE C; +SET ROLE :ROLE_DEFAULT_PERM_USER; +SET timescaledb.enable_calendar_chunking = true; +-- Test timestamp without timezone using general algorithm (force_general=true) +-- Covers various intervals and boundary values in a single query +SELECT t.ts, i.inv, r.start_ts, r.end_ts, r.end_ts - r.start_ts AS range_size +FROM unnest(ARRAY[ + '2025-03-15 12:00:00'::timestamp, + '4700-01-15 00:00:00 BC'::timestamp, + '100000-01-15 00:00:00'::timestamp +]) AS t(ts) +CROSS JOIN unnest(ARRAY[ + '1 day'::interval, + '1 month'::interval, + '1 year'::interval, + '100 years'::interval, + '1 month 15 days'::interval +]) AS i(inv) +CROSS JOIN LATERAL calc_range_ts(t.ts, i.inv, NULL, true) r +ORDER BY t.ts, i.inv; + ts | inv | start_ts | end_ts | range_size +-----------------------------+-----------------+-----------------------------+-----------------------------+-------------- + Sat Jan 15 00:00:00 4700 BC | @ 1 day | Sat Jan 15 00:00:00 4700 BC | Sun Jan 16 00:00:00 4700 BC | @ 1 day + Sat Jan 15 00:00:00 4700 BC | @ 1 mon | Sat Jan 01 00:00:00 4700 BC | Tue Feb 01 00:00:00 4700 BC | @ 31 days + Sat Jan 15 00:00:00 4700 BC | @ 1 mon 15 days | Sun Dec 19 00:00:00 4701 BC | Thu Feb 03 00:00:00 4700 BC | @ 46 days + Sat Jan 15 00:00:00 4700 BC | @ 1 year | Sat Jan 01 00:00:00 4700 BC | Sun Jan 01 00:00:00 4699 BC | @ 365 days + Sat Jan 15 00:00:00 4700 BC | @ 100 years | Sat Jan 01 00:00:00 4700 BC | Thu Jan 01 00:00:00 4600 BC | @ 36524 days + Sat Mar 15 12:00:00 2025 | @ 1 day | Sat Mar 15 00:00:00 2025 | Sun Mar 16 00:00:00 2025 | @ 1 day + Sat Mar 15 12:00:00 2025 | @ 1 mon | Sat Mar 01 00:00:00 2025 | Tue Apr 01 00:00:00 2025 | @ 31 days + Sat Mar 15 12:00:00 2025 | @ 1 mon 15 days | Mon Feb 17 00:00:00 2025 | Tue Apr 01 00:00:00 2025 | @ 43 days + Sat Mar 15 12:00:00 2025 | @ 1 year | Wed Jan 01 00:00:00 2025 | Thu Jan 01 00:00:00 2026 | @ 365 days + Sat Mar 15 12:00:00 2025 | @ 100 years | Mon Jan 01 00:00:00 2001 | Sat Jan 01 00:00:00 2101 | @ 36524 days + Sat Jan 15 00:00:00 100000 | @ 1 day | Sat Jan 15 00:00:00 100000 | Sun Jan 16 00:00:00 100000 | @ 1 day + Sat Jan 15 00:00:00 100000 | @ 1 mon | Sat Jan 01 00:00:00 100000 | Tue Feb 01 00:00:00 100000 | @ 31 days + Sat Jan 15 00:00:00 100000 | @ 1 mon 15 days | Fri Dec 31 00:00:00 99999 | Tue Feb 15 00:00:00 100000 | @ 46 days + Sat Jan 15 00:00:00 100000 | @ 1 year | Sat Jan 01 00:00:00 100000 | Mon Jan 01 00:00:00 100001 | @ 366 days + Sat Jan 15 00:00:00 100000 | @ 100 years | Tue Jan 01 00:00:00 99901 | Mon Jan 01 00:00:00 100001 | @ 36525 days + +-- Test timestamptz with general algorithm for comparison +SELECT t.ts, i.inv, r.start_ts, r.end_ts, r.end_ts - r.start_ts AS range_size +FROM unnest(ARRAY[ + '2025-03-15 12:00:00 UTC'::timestamptz, + '4700-01-15 00:00:00 BC'::timestamptz, + '100000-01-15 00:00:00'::timestamptz +]) AS t(ts) +CROSS JOIN unnest(ARRAY[ + '1 day'::interval, + '1 month'::interval, + '1 year'::interval, + '100 years'::interval, + '1 month 15 days'::interval +]) AS i(inv) +CROSS JOIN LATERAL calc_range(t.ts, i.inv, NULL, true) r +ORDER BY t.ts, i.inv; + ts | inv | start_ts | end_ts | range_size +---------------------------------+-----------------+---------------------------------+---------------------------------+-------------------- + Sat Jan 15 00:00:00 4700 LMT BC | @ 1 day | Sat Jan 15 00:00:00 4700 LMT BC | Sun Jan 16 00:00:00 4700 LMT BC | @ 1 day + Sat Jan 15 00:00:00 4700 LMT BC | @ 1 mon | Sat Jan 01 00:00:00 4700 LMT BC | Tue Feb 01 00:00:00 4700 LMT BC | @ 31 days + Sat Jan 15 00:00:00 4700 LMT BC | @ 1 mon 15 days | Sun Dec 19 00:00:00 4701 LMT BC | Thu Feb 03 00:00:00 4700 LMT BC | @ 46 days + Sat Jan 15 00:00:00 4700 LMT BC | @ 1 year | Sat Jan 01 00:00:00 4700 LMT BC | Sun Jan 01 00:00:00 4699 LMT BC | @ 365 days + Sat Jan 15 00:00:00 4700 LMT BC | @ 100 years | Sat Jan 01 00:00:00 4700 LMT BC | Thu Jan 01 00:00:00 4600 LMT BC | @ 36524 days + Sat Mar 15 05:00:00 2025 PDT | @ 1 day | Sat Mar 15 00:00:00 2025 PDT | Sun Mar 16 00:00:00 2025 PDT | @ 1 day + Sat Mar 15 05:00:00 2025 PDT | @ 1 mon | Sat Mar 01 00:00:00 2025 PST | Tue Apr 01 00:00:00 2025 PDT | @ 30 days 23 hours + Sat Mar 15 05:00:00 2025 PDT | @ 1 mon 15 days | Mon Feb 17 00:00:00 2025 PST | Tue Apr 01 00:00:00 2025 PDT | @ 42 days 23 hours + Sat Mar 15 05:00:00 2025 PDT | @ 1 year | Wed Jan 01 00:00:00 2025 PST | Thu Jan 01 00:00:00 2026 PST | @ 365 days + Sat Mar 15 05:00:00 2025 PDT | @ 100 years | Mon Jan 01 00:00:00 2001 PST | Sat Jan 01 00:00:00 2101 PST | @ 36524 days + Sat Jan 15 00:00:00 100000 PST | @ 1 day | Sat Jan 15 00:00:00 100000 PST | Sun Jan 16 00:00:00 100000 PST | @ 1 day + Sat Jan 15 00:00:00 100000 PST | @ 1 mon | Sat Jan 01 00:00:00 100000 PST | Tue Feb 01 00:00:00 100000 PST | @ 31 days + Sat Jan 15 00:00:00 100000 PST | @ 1 mon 15 days | Fri Dec 31 00:00:00 99999 PST | Tue Feb 15 00:00:00 100000 PST | @ 46 days + Sat Jan 15 00:00:00 100000 PST | @ 1 year | Sat Jan 01 00:00:00 100000 PST | Mon Jan 01 00:00:00 100001 PST | @ 366 days + Sat Jan 15 00:00:00 100000 PST | @ 100 years | Tue Jan 01 00:00:00 99901 PST | Mon Jan 01 00:00:00 100001 PST | @ 36525 days + +-- Large interval test (1000 years) - separate due to potential overflow +SELECT *, end_ts - start_ts AS range_size FROM calc_range_ts('2025-03-15 12:00:00'::timestamp, '1000 years'::interval, NULL, true); + start_ts | end_ts | range_size +--------------------------+--------------------------+--------------- + Mon Jan 01 00:00:00 2001 | Thu Jan 01 00:00:00 3001 | @ 365242 days + +SELECT *, end_ts - start_ts AS range_size FROM calc_range('2025-03-15 00:00:00 UTC'::timestamptz, '1000 years'::interval, NULL, true); + start_ts | end_ts | range_size +------------------------------+------------------------------+--------------- + Mon Jan 01 00:00:00 2001 PST | Thu Jan 01 00:00:00 3001 PST | @ 365242 days + +-- Cleanup +\c :TEST_DBNAME :ROLE_SUPERUSER +DROP FUNCTION calc_range_ts(TIMESTAMP, INTERVAL, TIMESTAMP, BOOL); +SET ROLE :ROLE_DEFAULT_PERM_USER; +RESET timescaledb.enable_calendar_chunking; +--------------------------------------------------------------- +-- ERROR PATH TESTS +-- Test error handling for invalid timestamp values +-- These test the timestamp_datum_to_tm error paths in calc_month_chunk_range +-- and calc_day_chunk_range which trigger "timestamp out of range" errors. +--------------------------------------------------------------- +-- Test month interval with infinity timestamp (triggers timestamp out of range) +\set ON_ERROR_STOP 0 +SELECT * FROM calc_range('infinity'::timestamptz, '1 month'::interval); +ERROR: timestamp out of range +SELECT * FROM calc_range('-infinity'::timestamptz, '1 month'::interval); +ERROR: timestamp out of range +-- Test month interval with infinity origin (triggers origin timestamp out of range) +SELECT * FROM calc_range('2025-01-15'::timestamptz, '1 month'::interval, 'infinity'::timestamptz); +ERROR: origin timestamp out of range +SELECT * FROM calc_range('2025-01-15'::timestamptz, '1 month'::interval, '-infinity'::timestamptz); +ERROR: origin timestamp out of range +-- Test day interval with infinity timestamp +SELECT * FROM calc_range('infinity'::timestamptz, '1 day'::interval); +ERROR: timestamp out of range +SELECT * FROM calc_range('-infinity'::timestamptz, '1 day'::interval); +ERROR: timestamp out of range +-- Test day interval with infinity origin +SELECT * FROM calc_range('2025-01-15'::timestamptz, '1 day'::interval, 'infinity'::timestamptz); +ERROR: origin timestamp out of range +SELECT * FROM calc_range('2025-01-15'::timestamptz, '1 day'::interval, '-infinity'::timestamptz); +ERROR: origin timestamp out of range +-- Test sub-day interval with infinity values (these go through calc_sub_day_chunk_range) +SELECT * FROM calc_range('infinity'::timestamptz, '1 hour'::interval); +ERROR: timestamp out of range +SELECT * FROM calc_range('-infinity'::timestamptz, '1 hour'::interval); +ERROR: timestamp out of range +SELECT * FROM calc_range('2025-01-15'::timestamptz, '1 hour'::interval, 'infinity'::timestamptz); +ERROR: origin timestamp out of range +SELECT * FROM calc_range('2025-01-15'::timestamptz, '1 hour'::interval, '-infinity'::timestamptz); +ERROR: origin timestamp out of range +\set ON_ERROR_STOP 1 +-- Test large intervals that produce chunk boundaries outside PostgreSQL's timestamp range +-- These verify that tm2timestamp failures are handled by clamping to -infinity/+infinity +-- Large month interval: 2 billion months (~166 million years) - chunk end exceeds max timestamp +SELECT * FROM calc_range('2025-01-15'::timestamptz, '2000000000 months'::interval); + start_ts | end_ts +------------------------------+---------- + Mon Jan 01 00:00:00 2001 PST | infinity + +-- Large day interval: 2 billion days (~5.4 million years) - chunk end exceeds max timestamp +SELECT * FROM calc_range('2025-01-15'::timestamptz, '2000000000 days'::interval); + start_ts | end_ts +------------------------------+---------- + Mon Jan 01 00:00:00 2001 PST | infinity + +-- Large month interval with ancient origin - chunk end exceeds max timestamp +SELECT * FROM calc_range('290000-01-15'::timestamptz, '200000000 months'::interval, '4000-01-01 BC'::timestamptz); + start_ts | end_ts +---------------------------------+---------- + Mon Jan 01 00:00:00 4000 LMT BC | infinity + +-- Large day interval with future origin - chunk start precedes min timestamp +SELECT * FROM calc_range('4000-01-15 BC'::timestamptz, '100000000 days'::interval, '290000-01-01'::timestamptz); + start_ts | end_ts +-----------+------------------------------- + -infinity | Thu Apr 20 00:00:00 16209 PDT + +-- Test INT32 overflow in end_julian calculation: start_julian (~108M) + interval->day (~2.1B) > INT32_MAX +-- This triggers pg_add_s32_overflow in calc_day_chunk_range for end_julian +SELECT * FROM calc_range('290000-01-15'::timestamptz, '2100000000 days'::interval, '290000-01-01'::timestamptz); + start_ts | end_ts +--------------------------------+---------- + Sat Jan 01 00:00:00 290000 PST | infinity + +-- Test INT32 overflow in end_total_months_from_jan: total_months_from_jan + interval->month > INT32_MAX +-- This triggers pg_add_s32_overflow in calc_month_chunk_range for end_total_months_from_jan +-- Use origin in December so total_months_from_jan starts at 11, making 11 + 2147483647 overflow +SELECT * FROM calc_range('2025-01-15'::timestamptz, '2147483647 months'::interval, '2001-12-01'::timestamptz); + start_ts | end_ts +------------------------------+---------- + Sat Dec 01 00:00:00 2001 PST | infinity + +-- Test sub-day interval overflow: ts_val - origin_val can overflow INT64 when timestamps are at opposite extremes +-- This triggers pg_sub_s64_overflow in calc_sub_day_chunk_range +\set ON_ERROR_STOP 0 +SELECT * FROM calc_range('4700-01-15 BC'::timestamptz, '1 hour'::interval, '294000-01-01'::timestamptz); +ERROR: interval out of range +\set ON_ERROR_STOP 1 +--------------------------------------------------------------- +-- C UNIT TESTS +-- Test saturating arithmetic and chunk range calculation +--------------------------------------------------------------- +\c :TEST_DBNAME :ROLE_SUPERUSER +SELECT test_chunk_range(); + test_chunk_range +------------------ + + +SET ROLE :ROLE_DEFAULT_PERM_USER; +--------------------------------------------------------------- +-- CLEANUP +--------------------------------------------------------------- +DROP FUNCTION test_ranges(TIMESTAMPTZ[], INTERVAL[]); +DROP FUNCTION test_ranges_with_origin(TIMESTAMPTZ[], INTERVAL[], TIMESTAMPTZ); +DROP FUNCTION show_chunk_constraints(text); +DROP FUNCTION show_int_chunk_slices(text); +DROP FUNCTION show_check_constraints(text); +RESET timezone; +RESET timescaledb.enable_calendar_chunking; +\set VERBOSITY default diff --git a/test/expected/chunk_adaptive.out b/test/expected/chunk_adaptive.out index e4317c3cb2c..0168b88d351 100644 --- a/test/expected/chunk_adaptive.out +++ b/test/expected/chunk_adaptive.out @@ -83,9 +83,9 @@ FROM _timescaledb_catalog.hypertable; -- Check that adaptive chunking sets a 1 day default chunk time -- interval => 86400000000 microseconds SELECT * FROM _timescaledb_catalog.dimension; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+--------------------------+---------+------------+--------------------------+-------------------+-----------------+--------------------------+-------------------------+------------------ - 2 | 2 | time | timestamp with time zone | t | | | | 86400000000 | | | + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+--------------------------+---------+------------+--------------------------+-------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ + 2 | 2 | time | timestamp with time zone | t | | | | | | 86400000000 | | | -- Change the target size SELECT * FROM set_adaptive_chunking('test_adaptive', '2MB'); @@ -617,6 +617,8 @@ column_type | timestamp with time zone dimension_type | Time time_interval | @ 19 days 5 hours 6 mins 50.695333 secs integer_interval | +time_origin | +integer_origin | integer_now_func | num_partitions | -[ RECORD 2 ]-----+---------------------------------------- @@ -628,6 +630,8 @@ column_type | integer dimension_type | Space time_interval | integer_interval | +time_origin | +integer_origin | integer_now_func | num_partitions | 2 diff --git a/test/expected/chunk_origin.out b/test/expected/chunk_origin.out new file mode 100644 index 00000000000..2c29da86067 --- /dev/null +++ b/test/expected/chunk_origin.out @@ -0,0 +1,775 @@ +-- This file and its contents are licensed under the Apache License 2.0. +-- Please see the included NOTICE for copyright information and +-- LICENSE-APACHE for a copy of the license. +-- +-- Test chunk origin parameter functionality +-- +-- The origin parameter allows specifying a reference point for aligning +-- chunk boundaries. Chunks are aligned to the origin instead of Unix epoch. +-- +\c :TEST_DBNAME :ROLE_SUPERUSER +SET ROLE :ROLE_DEFAULT_PERM_USER; +\set VERBOSITY terse +SET timezone = 'UTC'; +--------------------------------------------------------------- +-- ORIGIN WITH FIXED-SIZE CHUNKS (MICROSECONDS) +--------------------------------------------------------------- +-- Test that origin can be specified for fixed-size chunk alignment. +-- Create hypertable with origin (chunks aligned to noon instead of midnight) +CREATE TABLE origin_noon(time timestamptz NOT NULL, value int); +SELECT create_hypertable('origin_noon', 'time', + chunk_time_interval => 86400000000, -- 1 day in microseconds + chunk_time_origin => '2020-01-01 12:00:00 UTC'::timestamptz); + create_hypertable +-------------------------- + (1,public,origin_noon,t) + +-- Verify origin is stored in catalog +SELECT d.column_name, + _timescaledb_functions.to_timestamp(d.interval_origin) as origin +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'origin_noon'; + column_name | origin +-------------+------------------------------ + time | Wed Jan 01 12:00:00 2020 UTC + +-- Verify dimensions view shows origin +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'origin_noon'; + hypertable_name | time_interval | time_origin +-----------------+---------------+------------------------------ + origin_noon | @ 1 day | Wed Jan 01 12:00:00 2020 UTC + +-- Insert data around the origin boundary (noon) +INSERT INTO origin_noon VALUES + ('2020-01-01 11:00:00 UTC', 1), -- Before origin, previous chunk + ('2020-01-01 12:00:00 UTC', 2), -- At origin, starts new chunk + ('2020-01-01 18:00:00 UTC', 3), -- Same chunk as origin + ('2020-01-02 11:59:59 UTC', 4), -- Still same chunk (ends at noon) + ('2020-01-02 12:00:00 UTC', 5); -- Next chunk starts at noon +-- Verify chunks are aligned to noon (origin), not midnight +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'origin_noon' +ORDER BY range_start; + chunk_name | range_start | range_end +------------------+------------------------------+------------------------------ + _hyper_1_1_chunk | Tue Dec 31 12:00:00 2019 UTC | Wed Jan 01 12:00:00 2020 UTC + _hyper_1_2_chunk | Wed Jan 01 12:00:00 2020 UTC | Thu Jan 02 12:00:00 2020 UTC + _hyper_1_3_chunk | Thu Jan 02 12:00:00 2020 UTC | Fri Jan 03 12:00:00 2020 UTC + +-- Verify data is in correct chunks +SELECT tableoid::regclass as chunk, time, value +FROM origin_noon ORDER BY time; + chunk | time | value +----------------------------------------+------------------------------+------- + _timescaledb_internal._hyper_1_1_chunk | Wed Jan 01 11:00:00 2020 UTC | 1 + _timescaledb_internal._hyper_1_2_chunk | Wed Jan 01 12:00:00 2020 UTC | 2 + _timescaledb_internal._hyper_1_2_chunk | Wed Jan 01 18:00:00 2020 UTC | 3 + _timescaledb_internal._hyper_1_2_chunk | Thu Jan 02 11:59:59 2020 UTC | 4 + _timescaledb_internal._hyper_1_3_chunk | Thu Jan 02 12:00:00 2020 UTC | 5 + +DROP TABLE origin_noon; +--------------------------------------------------------------- +-- SET_CHUNK_TIME_INTERVAL WITH ORIGIN +--------------------------------------------------------------- +-- Test set_chunk_time_interval with origin parameter +CREATE TABLE origin_update(time timestamptz NOT NULL, value int); +SELECT create_hypertable('origin_update', 'time', + chunk_time_interval => 86400000000); -- 1 day in microseconds + create_hypertable +---------------------------- + (2,public,origin_update,t) + +-- Initially no origin +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'origin_update'; + hypertable_name | time_interval | time_origin +-----------------+---------------+------------- + origin_update | @ 1 day | + +-- Update with origin (chunks start at 6:00) +SELECT set_chunk_time_interval('origin_update', 43200000000, -- 12 hours + chunk_time_origin => '2020-01-01 06:00:00 UTC'::timestamptz); + set_chunk_time_interval +------------------------- + + +-- Verify origin is now set +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'origin_update'; + hypertable_name | time_interval | time_origin +-----------------+---------------+------------------------------ + origin_update | @ 12 hours | Wed Jan 01 06:00:00 2020 UTC + +-- Insert data and verify chunks align to 6:00 +INSERT INTO origin_update VALUES + ('2020-01-01 05:00:00 UTC', 1), -- Before 6:00, previous chunk + ('2020-01-01 06:00:00 UTC', 2), -- At origin + ('2020-01-01 17:59:59 UTC', 3), -- Same chunk + ('2020-01-01 18:00:00 UTC', 4); -- Next chunk (6:00 + 12 hours) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'origin_update' +ORDER BY range_start; + chunk_name | range_start | range_end +------------------+------------------------------+------------------------------ + _hyper_2_4_chunk | Tue Dec 31 18:00:00 2019 UTC | Wed Jan 01 06:00:00 2020 UTC + _hyper_2_5_chunk | Wed Jan 01 06:00:00 2020 UTC | Wed Jan 01 18:00:00 2020 UTC + _hyper_2_6_chunk | Wed Jan 01 18:00:00 2020 UTC | Thu Jan 02 06:00:00 2020 UTC + +DROP TABLE origin_update; +--------------------------------------------------------------- +-- INTEGER COLUMNS WITH INTEGER ORIGIN (OLD API) +--------------------------------------------------------------- +-- Test integer columns with integer origin using create_hypertable old API +-- Test smallint with origin (old API) +CREATE TABLE smallint_origin_old(id smallint NOT NULL, value int); +SELECT create_hypertable('smallint_origin_old', 'id', + chunk_time_interval => 100, + chunk_time_origin => 50); + create_hypertable +---------------------------------- + (3,public,smallint_origin_old,t) + +SELECT hypertable_name, integer_interval, integer_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'smallint_origin_old'; + hypertable_name | integer_interval | integer_origin +---------------------+------------------+---------------- + smallint_origin_old | 100 | 50 + +INSERT INTO smallint_origin_old VALUES (40, 1); -- chunk [-50, 50) +INSERT INTO smallint_origin_old VALUES (50, 2); -- chunk [50, 150) +INSERT INTO smallint_origin_old VALUES (150, 3); -- chunk [150, 250) +SELECT c.table_name AS chunk_name, ds.range_start, ds.range_end +FROM _timescaledb_catalog.chunk c +JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id +JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id +WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = 'smallint_origin_old') +ORDER BY ds.range_start; + chunk_name | range_start | range_end +------------------+-------------+----------- + _hyper_3_7_chunk | -50 | 50 + _hyper_3_8_chunk | 50 | 150 + _hyper_3_9_chunk | 150 | 250 + +DROP TABLE smallint_origin_old; +-- Test int (int4) with origin (old API) +CREATE TABLE int4_origin_old(id int NOT NULL, value int); +SELECT create_hypertable('int4_origin_old', 'id', + chunk_time_interval => 1000, + chunk_time_origin => 500); + create_hypertable +------------------------------ + (4,public,int4_origin_old,t) + +SELECT hypertable_name, integer_interval, integer_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'int4_origin_old'; + hypertable_name | integer_interval | integer_origin +-----------------+------------------+---------------- + int4_origin_old | 1000 | 500 + +INSERT INTO int4_origin_old VALUES (400, 1); -- chunk [-500, 500) +INSERT INTO int4_origin_old VALUES (500, 2); -- chunk [500, 1500) +INSERT INTO int4_origin_old VALUES (1500, 3); -- chunk [1500, 2500) +SELECT c.table_name AS chunk_name, ds.range_start, ds.range_end +FROM _timescaledb_catalog.chunk c +JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id +JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id +WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = 'int4_origin_old') +ORDER BY ds.range_start; + chunk_name | range_start | range_end +-------------------+-------------+----------- + _hyper_4_10_chunk | -500 | 500 + _hyper_4_11_chunk | 500 | 1500 + _hyper_4_12_chunk | 1500 | 2500 + +DROP TABLE int4_origin_old; +-- Test bigint with origin (old API) +CREATE TABLE int_origin(time bigint NOT NULL, value int); +SELECT create_hypertable('int_origin', 'time', + chunk_time_interval => 1000, + chunk_time_origin => 500); + create_hypertable +------------------------- + (5,public,int_origin,t) + +-- Verify dimensions view shows integer_origin (not time_origin) +SELECT hypertable_name, integer_interval, integer_origin, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'int_origin'; + hypertable_name | integer_interval | integer_origin | time_interval | time_origin +-----------------+------------------+----------------+---------------+------------- + int_origin | 1000 | 500 | | + +-- Insert data to verify chunk alignment relative to origin=500 +-- Chunks should be: [..., -500 to 500, 500 to 1500, 1500 to 2500, ...] +INSERT INTO int_origin VALUES (400, 1); -- chunk [-500, 500) +INSERT INTO int_origin VALUES (500, 2); -- chunk [500, 1500) +INSERT INTO int_origin VALUES (1200, 3); -- chunk [500, 1500) +INSERT INTO int_origin VALUES (1500, 4); -- chunk [1500, 2500) +SELECT c.table_name AS chunk_name, ds.range_start, ds.range_end +FROM _timescaledb_catalog.chunk c +JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id +JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id +WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = 'int_origin') +ORDER BY ds.range_start; + chunk_name | range_start | range_end +-------------------+-------------+----------- + _hyper_5_13_chunk | -500 | 500 + _hyper_5_14_chunk | 500 | 1500 + _hyper_5_15_chunk | 1500 | 2500 + +DROP TABLE int_origin; +-- Test smallint columns with integer origin using by_range syntax +CREATE TABLE smallint_origin(id smallint NOT NULL, value int); +SELECT create_hypertable('smallint_origin', + by_range('id', 100, partition_origin => 50)); + create_hypertable +------------------- + (6,t) + +-- Verify dimensions view shows integer_origin +SELECT hypertable_name, integer_interval, integer_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'smallint_origin'; + hypertable_name | integer_interval | integer_origin +-----------------+------------------+---------------- + smallint_origin | 100 | 50 + +-- Insert data to verify chunk alignment relative to origin=50 +-- Chunks should be: [..., -50 to 50, 50 to 150, 150 to 250, ...] +INSERT INTO smallint_origin VALUES (40, 1); -- chunk [-50, 50) +INSERT INTO smallint_origin VALUES (50, 2); -- chunk [50, 150) +INSERT INTO smallint_origin VALUES (120, 3); -- chunk [50, 150) +INSERT INTO smallint_origin VALUES (150, 4); -- chunk [150, 250) +SELECT c.table_name AS chunk_name, ds.range_start, ds.range_end +FROM _timescaledb_catalog.chunk c +JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id +JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id +WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = 'smallint_origin') +ORDER BY ds.range_start; + chunk_name | range_start | range_end +-------------------+-------------+----------- + _hyper_6_16_chunk | -50 | 50 + _hyper_6_17_chunk | 50 | 150 + _hyper_6_18_chunk | 150 | 250 + +DROP TABLE smallint_origin; +-- Test int (int4) columns with integer origin using by_range syntax +CREATE TABLE int4_origin(id int NOT NULL, value int); +SELECT create_hypertable('int4_origin', + by_range('id', 1000, partition_origin => 500)); + create_hypertable +------------------- + (7,t) + +-- Verify dimensions view shows integer_origin +SELECT hypertable_name, integer_interval, integer_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'int4_origin'; + hypertable_name | integer_interval | integer_origin +-----------------+------------------+---------------- + int4_origin | 1000 | 500 + +-- Insert data to verify chunk alignment relative to origin=500 +-- Chunks should be: [..., -500 to 500, 500 to 1500, 1500 to 2500, ...] +INSERT INTO int4_origin VALUES (400, 1); -- chunk [-500, 500) +INSERT INTO int4_origin VALUES (500, 2); -- chunk [500, 1500) +INSERT INTO int4_origin VALUES (1200, 3); -- chunk [500, 1500) +INSERT INTO int4_origin VALUES (1500, 4); -- chunk [1500, 2500) +SELECT c.table_name AS chunk_name, ds.range_start, ds.range_end +FROM _timescaledb_catalog.chunk c +JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id +JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id +WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = 'int4_origin') +ORDER BY ds.range_start; + chunk_name | range_start | range_end +-------------------+-------------+----------- + _hyper_7_19_chunk | -500 | 500 + _hyper_7_20_chunk | 500 | 1500 + _hyper_7_21_chunk | 1500 | 2500 + +DROP TABLE int4_origin; +-- Test bigint columns with integer origin using by_range syntax +CREATE TABLE bigint_origin(id bigint NOT NULL, value int); +SELECT create_hypertable('bigint_origin', + by_range('id', 10000, partition_origin => 5000)); + create_hypertable +------------------- + (8,t) + +-- Verify dimensions view shows integer_origin +SELECT hypertable_name, integer_interval, integer_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'bigint_origin'; + hypertable_name | integer_interval | integer_origin +-----------------+------------------+---------------- + bigint_origin | 10000 | 5000 + +-- Insert data to verify chunk alignment relative to origin=5000 +-- Chunks should be: [..., -5000 to 5000, 5000 to 15000, 15000 to 25000, ...] +INSERT INTO bigint_origin VALUES (4000, 1); -- chunk [-5000, 5000) +INSERT INTO bigint_origin VALUES (5000, 2); -- chunk [5000, 15000) +INSERT INTO bigint_origin VALUES (12000, 3); -- chunk [5000, 15000) +INSERT INTO bigint_origin VALUES (15000, 4); -- chunk [15000, 25000) +SELECT c.table_name AS chunk_name, ds.range_start, ds.range_end +FROM _timescaledb_catalog.chunk c +JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id +JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id +WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = 'bigint_origin') +ORDER BY ds.range_start; + chunk_name | range_start | range_end +-------------------+-------------+----------- + _hyper_8_22_chunk | -5000 | 5000 + _hyper_8_23_chunk | 5000 | 15000 + _hyper_8_24_chunk | 15000 | 25000 + +DROP TABLE bigint_origin; +--------------------------------------------------------------- +-- ADD_DIMENSION WITH ORIGIN +--------------------------------------------------------------- +-- Test add_dimension() with origin parameter (deprecated API) +CREATE TABLE add_dim_origin(id int NOT NULL, time timestamptz NOT NULL, value int); +SELECT create_hypertable('add_dim_origin', 'id', chunk_time_interval => 100); + create_hypertable +----------------------------- + (9,public,add_dim_origin,t) + +SELECT add_dimension('add_dim_origin', 'time', + chunk_time_interval => 86400000000, -- 1 day in microseconds + chunk_time_origin => '2024-04-01 00:00:00 UTC'::timestamptz); + add_dimension +----------------------------------- + (10,public,add_dim_origin,time,t) + +-- Verify the origin was stored correctly in the dimension +SELECT d.column_name, + _timescaledb_functions.to_timestamp(d.interval_origin) AS origin +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'add_dim_origin' AND d.column_name = 'time'; + column_name | origin +-------------+------------------------------ + time | Mon Apr 01 00:00:00 2024 UTC + +DROP TABLE add_dim_origin; +--------------------------------------------------------------- +-- TYPE VALIDATION TESTS +--------------------------------------------------------------- +-- Test type validation: timestamp origin with integer column (should fail) +\set ON_ERROR_STOP 0 +CREATE TABLE int_origin_ts_error(time bigint NOT NULL, value int); +SELECT create_hypertable('int_origin_ts_error', 'time', + chunk_time_interval => 1000, + chunk_time_origin => '2001-01-01'::timestamptz); +ERROR: origin type timestamp with time zone is not compatible with integer time column "time" +DROP TABLE IF EXISTS int_origin_ts_error; +-- Test type validation: integer origin with timestamp column (should fail) +CREATE TABLE ts_origin_int_error(time timestamptz NOT NULL, value int); +SELECT create_hypertable('ts_origin_int_error', 'time', + chunk_time_interval => 86400000000, + chunk_time_origin => 0); +ERROR: origin type integer is not compatible with timestamp with time zone column "time" +DROP TABLE IF EXISTS ts_origin_int_error; +\set ON_ERROR_STOP 1 +--------------------------------------------------------------- +-- BY_RANGE WITH PARTITION_ORIGIN +--------------------------------------------------------------- +-- Test by_range() with partition_origin parameter +CREATE TABLE by_range_origin(time timestamptz NOT NULL, value int); +SELECT create_hypertable('by_range_origin', + by_range('time', '1 day'::interval, partition_origin => '2020-01-01 06:00:00 UTC'::timestamptz)); + create_hypertable +------------------- + (10,t) + +-- Verify origin is stored correctly +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'by_range_origin'; + hypertable_name | time_interval | time_origin +-----------------+---------------+------------------------------ + by_range_origin | @ 1 day | Wed Jan 01 06:00:00 2020 UTC + +-- Verify chunks align to 6:00 +INSERT INTO by_range_origin VALUES + ('2020-01-01 05:00:00 UTC', 1), -- Before 6:00, previous chunk + ('2020-01-01 06:00:00 UTC', 2), -- At origin + ('2020-01-02 05:59:59 UTC', 3), -- Same chunk (ends at 6:00) + ('2020-01-02 06:00:00 UTC', 4); -- Next chunk starts at 6:00 +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'by_range_origin' +ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+------------------------------+------------------------------ + _hyper_10_25_chunk | Tue Dec 31 06:00:00 2019 UTC | Wed Jan 01 06:00:00 2020 UTC + _hyper_10_26_chunk | Wed Jan 01 06:00:00 2020 UTC | Thu Jan 02 06:00:00 2020 UTC + _hyper_10_27_chunk | Thu Jan 02 06:00:00 2020 UTC | Fri Jan 03 06:00:00 2020 UTC + +DROP TABLE by_range_origin; +--------------------------------------------------------------- +-- CREATE TABLE WITH (ORIGIN = ...) SYNTAX +--------------------------------------------------------------- +-- Test CREATE TABLE WITH syntax for origin parameter +-- Test with timestamptz column +CREATE TABLE with_origin_ts(time timestamptz NOT NULL, value int) + WITH (tsdb.hypertable, tsdb.partition_column='time', + tsdb.chunk_interval='1 day', + tsdb.origin='2020-01-01 12:00:00 UTC'); +-- Verify origin is stored correctly +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'with_origin_ts'; + hypertable_name | time_interval | time_origin +-----------------+---------------+------------------------------ + with_origin_ts | @ 1 day | Wed Jan 01 12:00:00 2020 UTC + +-- Insert data to verify chunk alignment to noon +INSERT INTO with_origin_ts VALUES + ('2020-01-01 11:00:00 UTC', 1), -- Before origin, previous chunk + ('2020-01-01 12:00:00 UTC', 2), -- At origin, starts new chunk + ('2020-01-02 11:59:59 UTC', 3), -- Same chunk (ends at noon) + ('2020-01-02 12:00:00 UTC', 4); -- Next chunk starts at noon +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'with_origin_ts' +ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+------------------------------+------------------------------ + _hyper_11_28_chunk | Tue Dec 31 12:00:00 2019 UTC | Wed Jan 01 12:00:00 2020 UTC + _hyper_11_29_chunk | Wed Jan 01 12:00:00 2020 UTC | Thu Jan 02 12:00:00 2020 UTC + _hyper_11_30_chunk | Thu Jan 02 12:00:00 2020 UTC | Fri Jan 03 12:00:00 2020 UTC + +DROP TABLE with_origin_ts; +-- Test with partition_origin alias +CREATE TABLE with_partition_origin(time timestamptz NOT NULL, value int) + WITH (tsdb.hypertable, tsdb.partition_column='time', + tsdb.chunk_interval='12 hours', + tsdb.partition_origin='2020-01-01 06:00:00 UTC'); +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'with_partition_origin'; + hypertable_name | time_interval | time_origin +-----------------------+---------------+------------------------------ + with_partition_origin | @ 12 hours | Wed Jan 01 06:00:00 2020 UTC + +DROP TABLE with_partition_origin; +-- Test with integer column and origin +CREATE TABLE with_origin_int(id bigint NOT NULL, value int) + WITH (tsdb.hypertable, tsdb.partition_column='id', + tsdb.chunk_interval=1000, + tsdb.origin='500'); +SELECT hypertable_name, integer_interval, integer_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'with_origin_int'; + hypertable_name | integer_interval | integer_origin +-----------------+------------------+---------------- + with_origin_int | 1000 | 500 + +-- Verify chunks align to origin=500 +INSERT INTO with_origin_int VALUES (400, 1); -- chunk [-500, 500) +INSERT INTO with_origin_int VALUES (500, 2); -- chunk [500, 1500) +INSERT INTO with_origin_int VALUES (1500, 3); -- chunk [1500, 2500) +SELECT c.table_name AS chunk_name, ds.range_start, ds.range_end +FROM _timescaledb_catalog.chunk c +JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id +JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id +WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = 'with_origin_int') +ORDER BY ds.range_start; + chunk_name | range_start | range_end +--------------------+-------------+----------- + _hyper_13_31_chunk | -500 | 500 + _hyper_13_32_chunk | 500 | 1500 + _hyper_13_33_chunk | 1500 | 2500 + +DROP TABLE with_origin_int; +-- Test with smallint column and origin +CREATE TABLE with_origin_smallint(id smallint NOT NULL, value int) + WITH (tsdb.hypertable, tsdb.partition_column='id', + tsdb.chunk_interval='100', + tsdb.origin='50'); +SELECT hypertable_name, integer_interval, integer_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'with_origin_smallint'; + hypertable_name | integer_interval | integer_origin +----------------------+------------------+---------------- + with_origin_smallint | 100 | 50 + +INSERT INTO with_origin_smallint VALUES (40, 1); -- chunk [-50, 50) +INSERT INTO with_origin_smallint VALUES (50, 2); -- chunk [50, 150) +SELECT c.table_name AS chunk_name, ds.range_start, ds.range_end +FROM _timescaledb_catalog.chunk c +JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id +JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id +WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = 'with_origin_smallint') +ORDER BY ds.range_start; + chunk_name | range_start | range_end +--------------------+-------------+----------- + _hyper_14_34_chunk | -50 | 50 + _hyper_14_35_chunk | 50 | 150 + +DROP TABLE with_origin_smallint; +-- Test with int column and origin +CREATE TABLE with_origin_int4(id int NOT NULL, value int) + WITH (tsdb.hypertable, tsdb.partition_column='id', + tsdb.chunk_interval='1000', + tsdb.origin='500'); +SELECT hypertable_name, integer_interval, integer_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'with_origin_int4'; + hypertable_name | integer_interval | integer_origin +------------------+------------------+---------------- + with_origin_int4 | 1000 | 500 + +INSERT INTO with_origin_int4 VALUES (400, 1); -- chunk [-500, 500) +INSERT INTO with_origin_int4 VALUES (500, 2); -- chunk [500, 1500) +SELECT c.table_name AS chunk_name, ds.range_start, ds.range_end +FROM _timescaledb_catalog.chunk c +JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id +JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id +WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = 'with_origin_int4') +ORDER BY ds.range_start; + chunk_name | range_start | range_end +--------------------+-------------+----------- + _hyper_15_36_chunk | -500 | 500 + _hyper_15_37_chunk | 500 | 1500 + +DROP TABLE with_origin_int4; +-- Test with timestamp (without timezone) column and origin +CREATE TABLE with_origin_timestamp(time timestamp NOT NULL, value int) + WITH (tsdb.hypertable, tsdb.partition_column='time', + tsdb.chunk_interval='1 day', + tsdb.origin='2020-01-01 12:00:00'); +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'with_origin_timestamp'; + hypertable_name | time_interval | time_origin +-----------------------+---------------+------------------------------ + with_origin_timestamp | @ 1 day | Wed Jan 01 12:00:00 2020 UTC + +INSERT INTO with_origin_timestamp VALUES + ('2020-01-01 11:00:00', 1), -- Before origin, previous chunk + ('2020-01-01 12:00:00', 2), -- At origin, starts new chunk + ('2020-01-02 11:59:59', 3); -- Same chunk +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'with_origin_timestamp' +ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+------------------------------+------------------------------ + _hyper_16_38_chunk | Tue Dec 31 12:00:00 2019 UTC | Wed Jan 01 12:00:00 2020 UTC + _hyper_16_39_chunk | Wed Jan 01 12:00:00 2020 UTC | Thu Jan 02 12:00:00 2020 UTC + +DROP TABLE with_origin_timestamp; +-- Test with date column and origin +CREATE TABLE with_origin_date(day date NOT NULL, value int) + WITH (tsdb.hypertable, tsdb.partition_column='day', + tsdb.chunk_interval='7 days', + tsdb.origin='2020-01-01'); +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'with_origin_date'; + hypertable_name | time_interval | time_origin +------------------+---------------+------------------------------ + with_origin_date | @ 7 days | Wed Jan 01 00:00:00 2020 UTC + +INSERT INTO with_origin_date VALUES + ('2019-12-30', 1), -- Before origin + ('2020-01-01', 2), -- At origin + ('2020-01-07', 3); -- Next week +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'with_origin_date' +ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+------------------------------+------------------------------ + _hyper_17_40_chunk | Wed Dec 25 00:00:00 2019 UTC | Wed Jan 01 00:00:00 2020 UTC + _hyper_17_41_chunk | Wed Jan 01 00:00:00 2020 UTC | Wed Jan 08 00:00:00 2020 UTC + +DROP TABLE with_origin_date; +-- Test with chunk_origin alias (alternative name) +CREATE TABLE with_chunk_origin(time timestamptz NOT NULL, value int) + WITH (tsdb.hypertable, tsdb.partition_column='time', + tsdb.chunk_interval='1 day', + tsdb.chunk_origin='2020-01-01 06:00:00 UTC'); +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'with_chunk_origin'; + hypertable_name | time_interval | time_origin +-------------------+---------------+------------------------------ + with_chunk_origin | @ 1 day | Wed Jan 01 06:00:00 2020 UTC + +DROP TABLE with_chunk_origin; +--------------------------------------------------------------- +-- SET_CHUNK_TIME_INTERVAL: PRESERVE ORIGIN WHEN NOT SPECIFIED +--------------------------------------------------------------- +-- Test that a custom origin is preserved when calling set_chunk_time_interval +-- with a new interval but without specifying an origin (NULL origin). +-- The existing origin should NOT be reset to default. +CREATE TABLE preserve_origin(time timestamptz NOT NULL, value int); +SELECT create_hypertable('preserve_origin', 'time', + chunk_time_interval => 86400000000, -- 1 day in microseconds + chunk_time_origin => '2020-01-01 06:00:00 UTC'::timestamptz); + create_hypertable +------------------------------- + (19,public,preserve_origin,t) + +-- Verify custom origin is set to 6:00 +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'preserve_origin'; + hypertable_name | time_interval | time_origin +-----------------+---------------+------------------------------ + preserve_origin | @ 1 day | Wed Jan 01 06:00:00 2020 UTC + +-- Create a chunk with the custom origin +INSERT INTO preserve_origin VALUES ('2020-01-01 12:00:00 UTC', 1); +-- Verify chunk is aligned to 6:00 (not midnight) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'preserve_origin' +ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+------------------------------+------------------------------ + _hyper_19_42_chunk | Wed Jan 01 06:00:00 2020 UTC | Thu Jan 02 06:00:00 2020 UTC + +-- Now change only the interval, NOT specifying origin (should preserve 6:00 origin) +SELECT set_chunk_time_interval('preserve_origin', 43200000000); -- 12 hours, no origin + set_chunk_time_interval +------------------------- + + +-- Verify origin is still 6:00 (should NOT be reset to midnight/default) +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'preserve_origin'; + hypertable_name | time_interval | time_origin +-----------------+---------------+------------------------------ + preserve_origin | @ 12 hours | Wed Jan 01 06:00:00 2020 UTC + +-- Insert more data to create new chunks with new interval +INSERT INTO preserve_origin VALUES ('2020-01-02 12:00:00 UTC', 2); +-- Verify new chunks still align to 6:00 origin (not midnight) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'preserve_origin' +ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+------------------------------+------------------------------ + _hyper_19_42_chunk | Wed Jan 01 06:00:00 2020 UTC | Thu Jan 02 06:00:00 2020 UTC + _hyper_19_43_chunk | Thu Jan 02 06:00:00 2020 UTC | Thu Jan 02 18:00:00 2020 UTC + +DROP TABLE preserve_origin; +--------------------------------------------------------------- +-- SET_CHUNK_TIME_INTERVAL: PRESERVE ORIGIN WITH CALENDAR CHUNKING +--------------------------------------------------------------- +-- Test origin preservation with calendar-based chunking (INTERVAL type) +SET timescaledb.enable_calendar_chunking = true; +CREATE TABLE preserve_origin_calendar(time timestamptz NOT NULL, value int); +SELECT create_hypertable('preserve_origin_calendar', 'time', + chunk_time_interval => interval '1 day', + chunk_time_origin => '2020-01-01 06:00:00 UTC'::timestamptz); + create_hypertable +---------------------------------------- + (20,public,preserve_origin_calendar,t) + +-- Verify custom origin is set to 6:00 +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'preserve_origin_calendar'; + hypertable_name | time_interval | time_origin +--------------------------+---------------+------------------------------ + preserve_origin_calendar | @ 1 day | Wed Jan 01 06:00:00 2020 UTC + +-- Create a chunk with the custom origin +INSERT INTO preserve_origin_calendar VALUES ('2020-01-01 12:00:00 UTC', 1); +-- Verify chunk is aligned to 6:00 +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'preserve_origin_calendar' +ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+------------------------------+------------------------------ + _hyper_20_44_chunk | Wed Jan 01 06:00:00 2020 UTC | Thu Jan 02 06:00:00 2020 UTC + +-- Now change only the interval, NOT specifying origin (should preserve 6:00 origin) +SELECT set_chunk_time_interval('preserve_origin_calendar', interval '12 hours'); + set_chunk_time_interval +------------------------- + + +-- Verify origin is still 6:00 (should NOT be reset to default midnight) +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'preserve_origin_calendar'; + hypertable_name | time_interval | time_origin +--------------------------+---------------+------------------------------ + preserve_origin_calendar | @ 12 hours | Wed Jan 01 06:00:00 2020 UTC + +-- Insert more data to create new chunks with new interval +INSERT INTO preserve_origin_calendar VALUES ('2020-01-02 12:00:00 UTC', 2); +-- Verify new chunks still align to 6:00 origin +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'preserve_origin_calendar' +ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+------------------------------+------------------------------ + _hyper_20_44_chunk | Wed Jan 01 06:00:00 2020 UTC | Thu Jan 02 06:00:00 2020 UTC + _hyper_20_45_chunk | Thu Jan 02 06:00:00 2020 UTC | Thu Jan 02 18:00:00 2020 UTC + +DROP TABLE preserve_origin_calendar; +RESET timescaledb.enable_calendar_chunking; +--------------------------------------------------------------- +-- SET_CHUNK_TIME_INTERVAL: CHANGE ONLY ORIGIN (NULL INTERVAL) +--------------------------------------------------------------- +-- Test that origin can be changed without re-specifying the interval. +-- Currently this errors - test to document the behavior. +CREATE TABLE change_origin_only(time timestamptz NOT NULL, value int); +SELECT create_hypertable('change_origin_only', 'time', + chunk_time_interval => 86400000000); -- 1 day in microseconds + create_hypertable +---------------------------------- + (21,public,change_origin_only,t) + +-- Verify initial state (default origin) +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'change_origin_only'; + hypertable_name | time_interval | time_origin +--------------------+---------------+------------- + change_origin_only | @ 1 day | + +-- Try to change only the origin without specifying interval +-- This errors due to PostgreSQL polymorphic type inference with NULL +\set ON_ERROR_STOP 0 +SELECT set_chunk_time_interval('change_origin_only', NULL, + chunk_time_origin => '2020-01-01 06:00:00 UTC'::timestamptz); +ERROR: could not determine polymorphic type because input has type unknown +-- Even with explicit cast, NULL interval is rejected +SELECT set_chunk_time_interval('change_origin_only', NULL::bigint, + chunk_time_origin => '2020-01-01 06:00:00 UTC'::timestamptz); +ERROR: invalid interval: an explicit interval must be specified +\set ON_ERROR_STOP 1 +-- Verify origin (check if it changed or remained the same) +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'change_origin_only'; + hypertable_name | time_interval | time_origin +--------------------+---------------+------------- + change_origin_only | @ 1 day | + +DROP TABLE change_origin_only; +--------------------------------------------------------------- +-- CLEANUP +--------------------------------------------------------------- +RESET timezone; +\set VERBOSITY default diff --git a/test/expected/create_chunks.out b/test/expected/create_chunks.out index c37bc21c583..05fe8bb9c09 100644 --- a/test/expected/create_chunks.out +++ b/test/expected/create_chunks.out @@ -75,10 +75,10 @@ SELECT set_chunk_time_interval('chunk_test', 5::bigint); SELECT * FROM _timescaledb_catalog.dimension; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+-------------+---------+------------+--------------------------+--------------------+-----------------+--------------------------+-------------------------+------------------ - 2 | 1 | tag | integer | f | 3 | _timescaledb_functions | get_partition_hash | | | | - 1 | 1 | time | integer | t | | | | 5 | | | + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+-------------+---------+------------+--------------------------+--------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ + 2 | 1 | tag | integer | f | 3 | _timescaledb_functions | get_partition_hash | | | | | | + 1 | 1 | time | integer | t | | | | | | 5 | | | INSERT INTO chunk_test VALUES (7, 24.3, 79669, 1); INSERT INTO chunk_test VALUES (8, 24.3, 79669, 1); diff --git a/test/expected/create_hypertable.out b/test/expected/create_hypertable.out index b5d7df246c6..b7624586815 100644 --- a/test/expected/create_hypertable.out +++ b/test/expected/create_hypertable.out @@ -112,13 +112,13 @@ select * from _timescaledb_catalog.hypertable where table_name = 'test_table'; 2 | test_schema | test_table | chunk_schema | _hyper_2 | 3 | _timescaledb_functions | calculate_chunk_interval | 0 | 0 | | 0 select * from _timescaledb_catalog.dimension; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+-------------+---------+------------+--------------------------+--------------------+-----------------+--------------------------+-------------------------+------------------ - 1 | 1 | time | bigint | t | | | | 2592000000000 | | | - 2 | 1 | device_id | text | f | 2 | _timescaledb_functions | get_partition_hash | | | | - 3 | 2 | time | bigint | t | | | | 2592000000000 | | | - 4 | 2 | device_id | text | f | 2 | _timescaledb_functions | get_partition_hash | | | | - 5 | 2 | location | text | f | 4 | _timescaledb_functions | get_partition_hash | | | | + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+-------------+---------+------------+--------------------------+--------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ + 1 | 1 | time | bigint | t | | | | | | 2592000000000 | | | + 2 | 1 | device_id | text | f | 2 | _timescaledb_functions | get_partition_hash | | | | | | + 3 | 2 | time | bigint | t | | | | | | 2592000000000 | | | + 4 | 2 | device_id | text | f | 2 | _timescaledb_functions | get_partition_hash | | | | | | + 5 | 2 | location | text | f | 4 | _timescaledb_functions | get_partition_hash | | | | | | --test that we can change the number of partitions and that 1 is allowed SELECT set_number_partitions('test_schema.test_table', 1, 'location'); @@ -127,9 +127,9 @@ SELECT set_number_partitions('test_schema.test_table', 1, 'location'); select * from _timescaledb_catalog.dimension WHERE column_name = 'location'; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+-------------+---------+------------+--------------------------+--------------------+-----------------+--------------------------+-------------------------+------------------ - 5 | 2 | location | text | f | 1 | _timescaledb_functions | get_partition_hash | | | | + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+-------------+---------+------------+--------------------------+--------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ + 5 | 2 | location | text | f | 1 | _timescaledb_functions | get_partition_hash | | | | | | SELECT set_number_partitions('test_schema.test_table', 2, 'location'); set_number_partitions @@ -137,9 +137,9 @@ SELECT set_number_partitions('test_schema.test_table', 2, 'location'); select * from _timescaledb_catalog.dimension WHERE column_name = 'location'; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+-------------+---------+------------+--------------------------+--------------------+-----------------+--------------------------+-------------------------+------------------ - 5 | 2 | location | text | f | 2 | _timescaledb_functions | get_partition_hash | | | | + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+-------------+---------+------------+--------------------------+--------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ + 5 | 2 | location | text | f | 2 | _timescaledb_functions | get_partition_hash | | | | | | \set ON_ERROR_STOP 0 --must give an explicit dimension when there are multiple space dimensions @@ -167,14 +167,14 @@ select * from _timescaledb_catalog.hypertable where table_name = 'test_table'; 2 | test_schema | test_table | chunk_schema | _hyper_2 | 4 | _timescaledb_functions | calculate_chunk_interval | 0 | 0 | | 0 select * from _timescaledb_catalog.dimension; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+-------------+---------+------------+--------------------------+--------------------+-----------------+--------------------------+-------------------------+------------------ - 1 | 1 | time | bigint | t | | | | 2592000000000 | | | - 2 | 1 | device_id | text | f | 2 | _timescaledb_functions | get_partition_hash | | | | - 3 | 2 | time | bigint | t | | | | 2592000000000 | | | - 4 | 2 | device_id | text | f | 2 | _timescaledb_functions | get_partition_hash | | | | - 5 | 2 | location | text | f | 2 | _timescaledb_functions | get_partition_hash | | | | - 6 | 2 | id | integer | t | | | | 1000 | | | + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+-------------+---------+------------+--------------------------+--------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ + 1 | 1 | time | bigint | t | | | | | | 2592000000000 | | | + 2 | 1 | device_id | text | f | 2 | _timescaledb_functions | get_partition_hash | | | | | | + 3 | 2 | time | bigint | t | | | | | | 2592000000000 | | | + 4 | 2 | device_id | text | f | 2 | _timescaledb_functions | get_partition_hash | | | | | | + 5 | 2 | location | text | f | 2 | _timescaledb_functions | get_partition_hash | | | | | | + 6 | 2 | id | integer | t | | | | | | 1000 | | | -- Test add_dimension: can use interval types for TIMESTAMPTZ columns CREATE TABLE dim_test_time(time TIMESTAMPTZ, time2 TIMESTAMPTZ, time3 BIGINT, temp float8, device int, location int); @@ -699,9 +699,9 @@ select set_integer_now_func('test_table_int', 'dummy_now'); select * from _timescaledb_catalog.dimension WHERE hypertable_id = :TEST_TABLE_INT_HYPERTABLE_ID; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+-------------+---------+------------+--------------------------+-------------------+-----------------+--------------------------+-------------------------+------------------ - 29 | 17 | time | bigint | t | | | | 1 | | public | dummy_now + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+-------------+---------+------------+--------------------------+-------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ + 29 | 17 | time | bigint | t | | | | | | 1 | | public | dummy_now -- show chunks works with "created_before" and errors out with time used in "older_than" SELECT SHOW_CHUNKS('test_table_int', older_than => 10); @@ -733,9 +733,9 @@ select set_integer_now_func('test_table_int', 'my_user_schema.dummy_now4', repla \c :TEST_DBNAME :ROLE_SUPERUSER ALTER SCHEMA my_user_schema RENAME TO my_new_schema; select * from _timescaledb_catalog.dimension WHERE hypertable_id = :TEST_TABLE_INT_HYPERTABLE_ID; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+-------------+---------+------------+--------------------------+-------------------+-----------------+--------------------------+-------------------------+------------------ - 29 | 17 | time | bigint | t | | | | 1 | | my_new_schema | dummy_now4 + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+-------------+---------+------------+--------------------------+-------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ + 29 | 17 | time | bigint | t | | | | | | 1 | | my_new_schema | dummy_now4 -- github issue #4650 CREATE TABLE sample_table ( diff --git a/test/expected/create_table_with.out b/test/expected/create_table_with.out index 4227408c94a..37fc984b8bc 100644 --- a/test/expected/create_table_with.out +++ b/test/expected/create_table_with.out @@ -51,10 +51,10 @@ HINT: To access all features and the best time-series experience, try out Times -- Test error hint for invalid timescaledb options during CREATE TABLE CREATE TABLE t2(time timestamptz, device text, value float) WITH (tsdb.invalid_option = true); ERROR: unrecognized parameter "tsdb.invalid_option" -HINT: Valid timescaledb parameters are: hypertable, columnstore, partition_column, chunk_interval, create_default_indexes, associated_schema, associated_table_prefix, orderby, segmentby, compress_index +HINT: Valid timescaledb parameters are: hypertable, columnstore, partition_column, chunk_interval, create_default_indexes, associated_schema, associated_table_prefix, orderby, segmentby, compress_index, chunk_origin CREATE TABLE t2(time timestamptz, device text, value float) WITH (timescaledb.nonexistent_param = false); ERROR: unrecognized parameter "timescaledb.nonexistent_param" -HINT: Valid timescaledb parameters are: hypertable, columnstore, partition_column, chunk_interval, create_default_indexes, associated_schema, associated_table_prefix, orderby, segmentby, compress_index +HINT: Valid timescaledb parameters are: hypertable, columnstore, partition_column, chunk_interval, create_default_indexes, associated_schema, associated_table_prefix, orderby, segmentby, compress_index, chunk_origin \set ON_ERROR_STOP 1 \set VERBOSITY terse BEGIN; diff --git a/test/expected/ddl.out b/test/expected/ddl.out index 5a8a0ee8243..270123c155a 100644 --- a/test/expected/ddl.out +++ b/test/expected/ddl.out @@ -401,10 +401,10 @@ SELECT * FROM test.show_columnsp('_timescaledb_internal._hyper_9_%chunk'); -- show the column name and type of the partitioning dimension in the -- metadata table SELECT * FROM _timescaledb_catalog.dimension WHERE hypertable_id = 9; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+--------------------------+---------+------------+--------------------------+--------------------+-----------------+--------------------------+-------------------------+------------------ - 15 | 9 | color | character varying | f | 2 | _timescaledb_functions | get_partition_hash | | | | - 14 | 9 | time | timestamp with time zone | t | | | | 2628000000000 | | | + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+--------------------------+---------+------------+--------------------------+--------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ + 15 | 9 | color | character varying | f | 2 | _timescaledb_functions | get_partition_hash | | | | | | + 14 | 9 | time | timestamp with time zone | t | | | | | | 2628000000000 | | | EXPLAIN (buffers off, costs off) SELECT * FROM alter_test WHERE time > '2017-05-20T10:00:01'; @@ -450,10 +450,10 @@ SELECT * FROM test.show_columnsp('_timescaledb_internal._hyper_9_%chunk'); -- show that the metadata has been updated SELECT * FROM _timescaledb_catalog.dimension WHERE hypertable_id = 9; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+-----------------------------+---------+------------+--------------------------+--------------------+-----------------+--------------------------+-------------------------+------------------ - 15 | 9 | colorname | character varying | f | 2 | _timescaledb_functions | get_partition_hash | | | | - 14 | 9 | time_us | timestamp without time zone | t | | | | 2628000000000 | | | + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+-----------------------------+---------+------------+--------------------------+--------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ + 15 | 9 | colorname | character varying | f | 2 | _timescaledb_functions | get_partition_hash | | | | | | + 14 | 9 | time_us | timestamp without time zone | t | | | | | | 2628000000000 | | | -- constraint exclusion should still work with updated column EXPLAIN (buffers off, costs off) diff --git a/test/expected/ddl_errors.out b/test/expected/ddl_errors.out index 971d0da188d..898140ab1b5 100644 --- a/test/expected/ddl_errors.out +++ b/test/expected/ddl_errors.out @@ -107,7 +107,7 @@ ERROR: hypertables do not support rules \set ON_ERROR_STOP 1 \set ON_ERROR_STOP 0 SELECT add_dimension(NULL,NULL); -ERROR: hypertable cannot be NULL +ERROR: column_name cannot be NULL \set ON_ERROR_STOP 1 \set ON_ERROR_STOP 0 SELECT attach_tablespace(NULL,NULL); diff --git a/test/expected/drop_hypertable.out b/test/expected/drop_hypertable.out index 0a2019b639d..3897c59ff7a 100644 --- a/test/expected/drop_hypertable.out +++ b/test/expected/drop_hypertable.out @@ -6,8 +6,8 @@ SELECT * from _timescaledb_catalog.hypertable; ----+-------------+------------+------------------------+-------------------------+----------------+--------------------------+------------------------+-------------------+-------------------+--------------------------+-------- SELECT * from _timescaledb_catalog.dimension; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+-------------+---------+------------+--------------------------+-------------------+-----------------+--------------------------+-------------------------+------------------ + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+-------------+---------+------------+--------------------------+-------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ CREATE TABLE should_drop (time timestamp, temp float8); SELECT create_hypertable('should_drop', 'time'); @@ -70,9 +70,9 @@ SELECT * from _timescaledb_catalog.hypertable; 1 | public | should_drop | _timescaledb_internal | _hyper_1 | 1 | _timescaledb_functions | calculate_chunk_interval | 0 | 0 | | 0 SELECT * from _timescaledb_catalog.dimension; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+-----------------------------+---------+------------+--------------------------+-------------------+-----------------+--------------------------+-------------------------+------------------ - 1 | 1 | time | timestamp without time zone | t | | | | 604800000000 | | | + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+-----------------------------+---------+------------+--------------------------+-------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ + 1 | 1 | time | timestamp without time zone | t | | | | | | 604800000000 | | | DROP TABLE should_drop; CREATE TABLE should_drop (time timestamp, temp float8); @@ -89,9 +89,9 @@ SELECT * from _timescaledb_catalog.hypertable; 4 | public | should_drop | _timescaledb_internal | _hyper_4 | 1 | _timescaledb_functions | calculate_chunk_interval | 0 | 0 | | 0 SELECT * from _timescaledb_catalog.dimension; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+-----------------------------+---------+------------+--------------------------+-------------------+-----------------+--------------------------+-------------------------+------------------ - 4 | 4 | time | timestamp without time zone | t | | | | 604800000000 | | | + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+-----------------------------+---------+------------+--------------------------+-------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ + 4 | 4 | time | timestamp without time zone | t | | | | | | 604800000000 | | | -- test dropping multiple objects at once CREATE TABLE t1 (time timestamptz) WITH (tsdb.hypertable); diff --git a/test/expected/drop_owned-15.out b/test/expected/drop_owned-15.out index 1f996e482b5..ca134cf8a02 100644 --- a/test/expected/drop_owned-15.out +++ b/test/expected/drop_owned-15.out @@ -54,8 +54,8 @@ SELECT id, hypertable_id, schema_name, table_name, compressed_chunk_id, dropped, ----+---------------+-------------+------------+---------------------+---------+--------+----------- SELECT * FROM _timescaledb_catalog.dimension; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+-------------+---------+------------+--------------------------+-------------------+-----------------+--------------------------+-------------------------+------------------ + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+-------------+---------+------------+--------------------------+-------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ SELECT * FROM _timescaledb_catalog.dimension_slice; id | dimension_id | range_start | range_end diff --git a/test/expected/drop_owned-16.out b/test/expected/drop_owned-16.out index 1f996e482b5..ca134cf8a02 100644 --- a/test/expected/drop_owned-16.out +++ b/test/expected/drop_owned-16.out @@ -54,8 +54,8 @@ SELECT id, hypertable_id, schema_name, table_name, compressed_chunk_id, dropped, ----+---------------+-------------+------------+---------------------+---------+--------+----------- SELECT * FROM _timescaledb_catalog.dimension; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+-------------+---------+------------+--------------------------+-------------------+-----------------+--------------------------+-------------------------+------------------ + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+-------------+---------+------------+--------------------------+-------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ SELECT * FROM _timescaledb_catalog.dimension_slice; id | dimension_id | range_start | range_end diff --git a/test/expected/drop_owned-17.out b/test/expected/drop_owned-17.out index 824330f2b64..12daf6daa80 100644 --- a/test/expected/drop_owned-17.out +++ b/test/expected/drop_owned-17.out @@ -54,8 +54,8 @@ SELECT id, hypertable_id, schema_name, table_name, compressed_chunk_id, dropped, ----+---------------+-------------+------------+---------------------+---------+--------+----------- SELECT * FROM _timescaledb_catalog.dimension; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+-------------+---------+------------+--------------------------+-------------------+-----------------+--------------------------+-------------------------+------------------ + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+-------------+---------+------------+--------------------------+-------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ SELECT * FROM _timescaledb_catalog.dimension_slice; id | dimension_id | range_start | range_end diff --git a/test/expected/drop_owned-18.out b/test/expected/drop_owned-18.out index 824330f2b64..12daf6daa80 100644 --- a/test/expected/drop_owned-18.out +++ b/test/expected/drop_owned-18.out @@ -54,8 +54,8 @@ SELECT id, hypertable_id, schema_name, table_name, compressed_chunk_id, dropped, ----+---------------+-------------+------------+---------------------+---------+--------+----------- SELECT * FROM _timescaledb_catalog.dimension; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+-------------+---------+------------+--------------------------+-------------------+-----------------+--------------------------+-------------------------+------------------ + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+-------------+---------+------------+--------------------------+-------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ SELECT * FROM _timescaledb_catalog.dimension_slice; id | dimension_id | range_start | range_end diff --git a/test/expected/drop_schema.out b/test/expected/drop_schema.out index c00a67c27ff..5f3d48b4948 100644 --- a/test/expected/drop_schema.out +++ b/test/expected/drop_schema.out @@ -85,8 +85,8 @@ SELECT id, hypertable_id, schema_name, table_name, compressed_chunk_id, dropped, ----+---------------+-------------+------------+---------------------+---------+--------+----------- SELECT * FROM _timescaledb_catalog.dimension; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+-------------+---------+------------+--------------------------+-------------------+-----------------+--------------------------+-------------------------+------------------ + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+-------------+---------+------------+--------------------------+-------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ SELECT * FROM _timescaledb_catalog.dimension_slice; id | dimension_id | range_start | range_end diff --git a/test/expected/information_views.out b/test/expected/information_views.out index e30c5ec942f..f732a6d82c0 100644 --- a/test/expected/information_views.out +++ b/test/expected/information_views.out @@ -234,6 +234,8 @@ column_type | timestamp with time zone dimension_type | Time time_interval | @ 7 days integer_interval | +time_origin | +integer_origin | integer_now_func | num_partitions | -[ RECORD 2 ]-----+------------------------- @@ -245,6 +247,8 @@ column_type | timestamp with time zone dimension_type | Time time_interval | @ 7 days integer_interval | +time_origin | +integer_origin | integer_now_func | num_partitions | -[ RECORD 3 ]-----+------------------------- @@ -256,6 +260,8 @@ column_type | timestamp with time zone dimension_type | Time time_interval | @ 7 days integer_interval | +time_origin | +integer_origin | integer_now_func | num_partitions | -[ RECORD 4 ]-----+------------------------- @@ -267,6 +273,8 @@ column_type | timestamp with time zone dimension_type | Time time_interval | @ 7 days integer_interval | +time_origin | +integer_origin | integer_now_func | num_partitions | -[ RECORD 5 ]-----+------------------------- @@ -278,6 +286,8 @@ column_type | bigint dimension_type | Time time_interval | integer_interval | 10 +time_origin | +integer_origin | integer_now_func | table_int_now num_partitions | diff --git a/test/expected/partition.out b/test/expected/partition.out index f11f933a080..caebe26680b 100644 --- a/test/expected/partition.out +++ b/test/expected/partition.out @@ -9,10 +9,10 @@ SELECT create_hypertable('part_legacy', 'time', 'device', 2, partitioning_func = -- Show legacy partitioning function is used SELECT * FROM _timescaledb_catalog.dimension; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+--------------------------+---------+------------+--------------------------+-----------------------+-----------------+--------------------------+-------------------------+------------------ - 1 | 1 | time | timestamp with time zone | t | | | | 604800000000 | | | - 2 | 1 | device | integer | f | 2 | _timescaledb_functions | get_partition_for_key | | | | + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+--------------------------+---------+------------+--------------------------+-----------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ + 1 | 1 | time | timestamp with time zone | t | | | | | | 604800000000 | | | + 2 | 1 | device | integer | f | 2 | _timescaledb_functions | get_partition_for_key | | | | | | INSERT INTO part_legacy VALUES ('2017-03-22T09:18:23', 23.4, 1); INSERT INTO part_legacy VALUES ('2017-03-22T09:18:23', 23.4, 76); @@ -46,12 +46,12 @@ SELECT create_hypertable('part_new', 'time', 'device', 2); (2,public,part_new,t) SELECT * FROM _timescaledb_catalog.dimension; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+--------------------------+---------+------------+--------------------------+-----------------------+-----------------+--------------------------+-------------------------+------------------ - 1 | 1 | time | timestamp with time zone | t | | | | 604800000000 | | | - 2 | 1 | device | integer | f | 2 | _timescaledb_functions | get_partition_for_key | | | | - 3 | 2 | time | timestamp with time zone | t | | | | 604800000000 | | | - 4 | 2 | device | integer | f | 2 | _timescaledb_functions | get_partition_hash | | | | + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+--------------------------+---------+------------+--------------------------+-----------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ + 1 | 1 | time | timestamp with time zone | t | | | | | | 604800000000 | | | + 2 | 1 | device | integer | f | 2 | _timescaledb_functions | get_partition_for_key | | | | | | + 3 | 2 | time | timestamp with time zone | t | | | | | | 604800000000 | | | + 4 | 2 | device | integer | f | 2 | _timescaledb_functions | get_partition_hash | | | | | | INSERT INTO part_new VALUES ('2017-03-22T09:18:23', 23.4, 1); INSERT INTO part_new VALUES ('2017-03-22T09:18:23', 23.4, 2); @@ -115,17 +115,17 @@ SELECT add_dimension('part_add_dim', 'location', 2, partitioning_func => '_times (9,public,part_add_dim,location,t) SELECT * FROM _timescaledb_catalog.dimension; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+-----------------------------+---------+------------+--------------------------+-----------------------+-----------------+--------------------------+-------------------------+------------------ - 1 | 1 | time | timestamp with time zone | t | | | | 604800000000 | | | - 2 | 1 | device | integer | f | 2 | _timescaledb_functions | get_partition_for_key | | | | - 3 | 2 | time | timestamp with time zone | t | | | | 604800000000 | | | - 4 | 2 | device | integer | f | 2 | _timescaledb_functions | get_partition_hash | | | | - 6 | 3 | temp | double precision | f | 2 | _timescaledb_functions | get_partition_hash | | | | - 5 | 3 | time | timestamp without time zone | t | | | | 604800000000 | | | - 7 | 4 | time | timestamp with time zone | t | | | | 604800000000 | | | - 8 | 4 | temp | double precision | f | 2 | _timescaledb_functions | get_partition_hash | | | | - 9 | 4 | location | integer | f | 2 | _timescaledb_functions | get_partition_for_key | | | | + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+-----------------------------+---------+------------+--------------------------+-----------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ + 1 | 1 | time | timestamp with time zone | t | | | | | | 604800000000 | | | + 2 | 1 | device | integer | f | 2 | _timescaledb_functions | get_partition_for_key | | | | | | + 3 | 2 | time | timestamp with time zone | t | | | | | | 604800000000 | | | + 4 | 2 | device | integer | f | 2 | _timescaledb_functions | get_partition_hash | | | | | | + 6 | 3 | temp | double precision | f | 2 | _timescaledb_functions | get_partition_hash | | | | | | + 5 | 3 | time | timestamp without time zone | t | | | | | | 604800000000 | | | + 7 | 4 | time | timestamp with time zone | t | | | | | | 604800000000 | | | + 8 | 4 | temp | double precision | f | 2 | _timescaledb_functions | get_partition_hash | | | | | | + 9 | 4 | location | integer | f | 2 | _timescaledb_functions | get_partition_for_key | | | | | | -- Test that we support custom SQL-based partitioning functions and -- that our native partitioning function handles function expressions diff --git a/test/sql/CMakeLists.txt b/test/sql/CMakeLists.txt index a7157d37d6e..0a6d83a63f4 100644 --- a/test/sql/CMakeLists.txt +++ b/test/sql/CMakeLists.txt @@ -8,6 +8,7 @@ set(TEST_FILES chunks.sql chunk_adaptive.sql chunk_publication.sql + chunk_origin.sql chunk_utils.sql cluster.sql create_chunks.sql @@ -110,6 +111,7 @@ if(CMAKE_BUILD_TYPE MATCHES Debug) bgw_launcher.sql c_unit_tests.sql copy_memory_usage.sql + calendar_chunking.sql metadata.sql multi_transaction_index.sql net.sql diff --git a/test/sql/calendar_chunking.sql b/test/sql/calendar_chunking.sql new file mode 100644 index 00000000000..02cd599e5dd --- /dev/null +++ b/test/sql/calendar_chunking.sql @@ -0,0 +1,1519 @@ +-- This file and its contents are licensed under the Apache License 2.0. +-- Please see the included NOTICE for copyright information and +-- LICENSE-APACHE for a copy of the license. + +-- +-- Test calendar-based chunking +-- +-- Calendar-based chunking aligns chunks with calendar boundaries +-- (e.g., start of day, week, month, year) based on a user-specified origin +-- and the current session timezone. +-- + +\c :TEST_DBNAME :ROLE_SUPERUSER +CREATE OR REPLACE FUNCTION calc_range(ts TIMESTAMPTZ, chunk_interval INTERVAL, origin TIMESTAMPTZ DEFAULT NULL, force_general BOOL DEFAULT NULL) +RETURNS TABLE(start_ts TIMESTAMPTZ, end_ts TIMESTAMPTZ) AS :MODULE_PATHNAME, 'ts_dimension_calculate_open_range_calendar' LANGUAGE C; + +-- C unit tests for chunk_range.c +CREATE OR REPLACE FUNCTION test_chunk_range() +RETURNS VOID AS :MODULE_PATHNAME, 'ts_test_chunk_range' LANGUAGE C; +SET ROLE :ROLE_DEFAULT_PERM_USER; + +\set VERBOSITY terse +SET timescaledb.enable_calendar_chunking = true; + +--------------------------------------------------------------- +-- CALC_RANGE TESTS +-- Test the calc_range() function with various intervals and timestamps +--------------------------------------------------------------- + +-- Helper function to verify ranges +CREATE OR REPLACE FUNCTION test_ranges( + timestamps TIMESTAMPTZ[], + intervals INTERVAL[] +) RETURNS TABLE(ts TIMESTAMPTZ, inv INTERVAL, start_ts TIMESTAMPTZ, end_ts TIMESTAMPTZ, dur INTERVAL, in_range BOOLEAN) AS $$ + SELECT t.ts, i.inv, r.start_ts, r.end_ts, r.end_ts - r.start_ts, t.ts >= r.start_ts AND t.ts < r.end_ts + FROM unnest(timestamps) AS t(ts) + CROSS JOIN unnest(intervals) AS i(inv) + CROSS JOIN LATERAL calc_range(t.ts, i.inv) r + ORDER BY t.ts, i.inv; +$$ LANGUAGE SQL; + +-- Helper function to verify ranges with custom origin +CREATE OR REPLACE FUNCTION test_ranges_with_origin( + timestamps TIMESTAMPTZ[], + intervals INTERVAL[], + origin TIMESTAMPTZ +) RETURNS TABLE(ts TIMESTAMPTZ, inv INTERVAL, start_ts TIMESTAMPTZ, end_ts TIMESTAMPTZ, dur INTERVAL, in_range BOOLEAN) AS $$ + SELECT t.ts, i.inv, r.start_ts, r.end_ts, r.end_ts - r.start_ts, t.ts >= r.start_ts AND t.ts < r.end_ts + FROM unnest(timestamps) AS t(ts) + CROSS JOIN unnest(intervals) AS i(inv) + CROSS JOIN LATERAL calc_range(t.ts, i.inv, origin) r + ORDER BY t.ts, i.inv; +$$ LANGUAGE SQL; + +-- Helper function to show CHECK constraints with chunk size for time-based hypertables +-- Uses CASE to handle infinite timestamps (older PG versions can't subtract them) +CREATE OR REPLACE FUNCTION show_chunk_constraints(ht_name text) +RETURNS TABLE(chunk text, constraint_def text, chunk_size interval) AS $$ + SELECT c.table_name::text, + pg_get_constraintdef(con.oid), + CASE WHEN ch.range_start = '-infinity'::timestamptz + OR ch.range_end = 'infinity'::timestamptz + THEN NULL + ELSE ch.range_end - ch.range_start + END + FROM _timescaledb_catalog.chunk c + JOIN pg_constraint con ON con.conrelid = format('%I.%I', c.schema_name, c.table_name)::regclass + JOIN timescaledb_information.chunks ch ON ch.chunk_name = c.table_name AND ch.hypertable_name = ht_name + WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = ht_name) + AND con.contype = 'c' + ORDER BY c.table_name; +$$ LANGUAGE SQL; + +-- Helper function to show dimension slices with chunk size for integer hypertables +CREATE OR REPLACE FUNCTION show_int_chunk_slices(ht_name text) +RETURNS TABLE(chunk_name text, range_start bigint, range_end bigint, chunk_size bigint) AS $$ + SELECT c.table_name::text, ds.range_start, ds.range_end, + ds.range_end - ds.range_start + FROM _timescaledb_catalog.chunk c + JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id + JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id + WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = ht_name) + ORDER BY ds.range_start; +$$ LANGUAGE SQL; + +-- Helper function to show CHECK constraints only (for integer hypertables) +CREATE OR REPLACE FUNCTION show_check_constraints(ht_name text) +RETURNS TABLE(chunk text, constraint_def text) AS $$ + SELECT c.table_name::text, + pg_get_constraintdef(con.oid) + FROM _timescaledb_catalog.chunk c + JOIN pg_constraint con ON con.conrelid = format('%I.%I', c.schema_name, c.table_name)::regclass + WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = ht_name) + AND con.contype = 'c' + ORDER BY c.table_name; +$$ LANGUAGE SQL; + +-- Basic interval tests in UTC +SET timezone = 'UTC'; + +SELECT * FROM test_ranges( + ARRAY[ + '2024-01-15 12:30:45 UTC'::timestamptz, + '2024-06-15 00:00:00 UTC', + '2024-06-15 23:59:59.999999 UTC' + ], + ARRAY[ + '1 minute'::interval, '5 minutes', '15 minutes', '30 minutes', + '1 hour', '2 hours', '4 hours', '6 hours', '12 hours', + '1 day', '1 week', '1 month', '3 months', '6 months', '1 year' + ] +); + +-- Leap year tests +SELECT * FROM test_ranges( + ARRAY[ + '2024-02-28 12:00:00 UTC'::timestamptz, -- leap year + '2024-02-29 00:00:00 UTC', + '2024-02-29 23:59:59 UTC', + '2024-03-01 00:00:00 UTC', + '2025-02-28 12:00:00 UTC', -- non-leap year + '2025-02-28 23:59:59 UTC', + '2025-03-01 00:00:00 UTC' + ], + ARRAY['1 day'::interval, '1 month'] +); + +-- Week alignment (should align to Monday) +SELECT * FROM test_ranges( + ARRAY[ + '2024-06-10 12:00:00 UTC'::timestamptz, -- Monday + '2024-06-11 12:00:00 UTC', -- Tuesday + '2024-06-14 12:00:00 UTC', -- Friday + '2024-06-16 12:00:00 UTC', -- Sunday + '2024-06-17 00:00:00 UTC' -- Monday (next week) + ], + ARRAY['1 week'::interval, '2 weeks'] +); + +-- Year boundary tests +SELECT * FROM test_ranges( + ARRAY[ + '2024-12-31 23:59:59 UTC'::timestamptz, + '2025-01-01 00:00:00 UTC' + ], + ARRAY['1 day'::interval, '1 week', '1 month', '1 year'] +); + +-- Custom origin: days starting at noon +SELECT * FROM test_ranges_with_origin( + ARRAY[ + '2024-06-15 11:59:59 UTC'::timestamptz, + '2024-06-15 12:00:00 UTC', + '2024-06-15 23:59:59 UTC', + '2024-06-16 00:00:00 UTC', + '2024-06-16 11:59:59 UTC', + '2024-06-16 12:00:00 UTC' + ], + ARRAY['1 day'::interval], + '2020-01-01 12:00:00 UTC' +); + +-- Custom origin: 15 minutes past midnight +SELECT * FROM test_ranges_with_origin( + ARRAY[ + '2024-06-15 00:00:00 UTC'::timestamptz, + '2024-06-15 00:14:59 UTC', + '2024-06-15 00:15:00 UTC', + '2024-06-15 12:00:00 UTC', + '2024-06-16 00:14:59 UTC', + '2024-06-16 00:15:00 UTC' + ], + ARRAY['1 day'::interval], + '2020-01-01 00:15:00 UTC' +); + +-- Custom origin: hourly chunks starting at 30 minutes +SELECT * FROM test_ranges_with_origin( + ARRAY[ + '2024-06-15 12:00:00 UTC'::timestamptz, + '2024-06-15 12:29:59 UTC', + '2024-06-15 12:30:00 UTC', + '2024-06-15 13:29:59 UTC', + '2024-06-15 13:30:00 UTC' + ], + ARRAY['1 hour'::interval], + '2020-01-01 00:30:00 UTC' +); + +--------------------------------------------------------------- +-- DST TRANSITION TESTS +--------------------------------------------------------------- + +-- America/Los_Angeles DST spring forward (Mar 10) +SET timezone = 'America/Los_Angeles'; + +SELECT * FROM test_ranges( + ARRAY[ + '2024-03-09 23:59:59 America/Los_Angeles'::timestamptz, + '2024-03-10 00:00:00 America/Los_Angeles', + '2024-03-10 01:59:59 America/Los_Angeles', + '2024-03-10 03:00:00 America/Los_Angeles', -- 2 AM doesn't exist + '2024-03-11 00:00:00 America/Los_Angeles' + ], + ARRAY['1 hour'::interval, '1 day', '1 week'] +); + +-- America/Los_Angeles DST fall back (Nov 3) +SELECT * FROM test_ranges( + ARRAY[ + '2024-11-02 23:59:59 America/Los_Angeles'::timestamptz, + '2024-11-03 00:00:00 America/Los_Angeles', + '2024-11-03 01:30:00 PDT', -- first 1:30 AM + '2024-11-03 01:30:00 PST', -- second 1:30 AM + '2024-11-03 02:00:00 America/Los_Angeles' + ], + ARRAY['1 hour'::interval, '1 day'] +); + +-- Europe/London DST +SET timezone = 'Europe/London'; + +SELECT * FROM test_ranges( + ARRAY[ + '2024-03-31 00:59:59 Europe/London'::timestamptz, -- before spring forward + '2024-03-31 02:00:00 Europe/London', -- after spring forward + '2024-10-27 01:30:00 BST', -- before fall back + '2024-10-27 01:30:00 GMT' -- after fall back + ], + ARRAY['1 hour'::interval, '1 day'] +); + +-- Use a timezone with 45-minute offset for hypertable tests +SET timezone = 'Pacific/Chatham'; + +--------------------------------------------------------------- +-- HYPERTABLE TESTS: UUID v7 +--------------------------------------------------------------- + +CREATE TABLE uuid_events( + id uuid PRIMARY KEY, + device_id int, + temp float +); + +SELECT create_hypertable('uuid_events', 'id', chunk_time_interval => interval '1 day'); + +-- UUIDs for known timestamps: +-- 2025-01-01 02:00 UTC, 2025-01-01 09:00 UTC +-- 2025-01-02 03:00 UTC, 2025-01-02 04:00 UTC +-- 2025-01-03 05:00 UTC, 2025-01-03 12:00 UTC +INSERT INTO uuid_events VALUES + ('01942117-de80-7000-8121-f12b2b69dd96', 1, 1.0), + ('0194214e-cd00-7000-a9a7-63f1416dab45', 2, 2.0), + ('0194263e-3a80-7000-8f40-82c987b1bc1f', 3, 3.0), + ('01942675-2900-7000-8db1-a98694b18785', 4, 4.0), + ('01942bd2-7380-7000-9bc4-5f97443907b8', 5, 5.0), + ('01942d52-f900-7000-866e-07d6404d53c1', 6, 6.0); + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'uuid_events' ORDER BY range_start; + +DROP TABLE uuid_events; + +--------------------------------------------------------------- +-- HYPERTABLE TESTS: TIMESTAMPTZ +--------------------------------------------------------------- + +-- Daily chunks +CREATE TABLE tz_daily(time timestamptz NOT NULL, value int); +SELECT create_hypertable('tz_daily', 'time', chunk_time_interval => interval '1 day'); +INSERT INTO tz_daily VALUES + ('2025-01-01 00:00:00 UTC', 1), + ('2025-01-01 23:59:59.999999 UTC', 2), + ('2025-01-02 00:00:00 UTC', 3); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_daily' ORDER BY range_start; +DROP TABLE tz_daily; + +-- Weekly chunks +CREATE TABLE tz_weekly(time timestamptz NOT NULL, value int); +SELECT create_hypertable('tz_weekly', 'time', chunk_time_interval => interval '1 week'); +INSERT INTO tz_weekly VALUES + ('2025-01-06 00:00:00 UTC', 1), -- Monday + ('2025-01-12 23:59:59 UTC', 2), -- Sunday + ('2025-01-13 00:00:00 UTC', 3); -- Monday (next week) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_weekly' ORDER BY range_start; +DROP TABLE tz_weekly; + +-- Monthly chunks +CREATE TABLE tz_monthly(time timestamptz NOT NULL, value int); +SELECT create_hypertable('tz_monthly', 'time', chunk_time_interval => interval '1 month'); +INSERT INTO tz_monthly VALUES + ('2025-01-15 12:00:00 UTC', 1), + ('2025-02-15 12:00:00 UTC', 2), + ('2025-03-15 12:00:00 UTC', 3); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_monthly' ORDER BY range_start; +DROP TABLE tz_monthly; + +-- Yearly chunks +CREATE TABLE tz_yearly(time timestamptz NOT NULL, value int); +SELECT create_hypertable('tz_yearly', 'time', chunk_time_interval => interval '1 year'); +INSERT INTO tz_yearly VALUES + ('2024-06-15 12:00:00 UTC', 1), + ('2025-06-15 12:00:00 UTC', 2); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_yearly' ORDER BY range_start; +DROP TABLE tz_yearly; + +-- DST spring forward test: 23-hour chunk +-- America/New_York DST 2025: Mar 9 at 2:00 AM clocks spring forward to 3:00 AM +SET timezone = 'America/New_York'; + +CREATE TABLE tz_dst_spring(time timestamptz NOT NULL, value int); +SELECT create_hypertable('tz_dst_spring', 'time', chunk_time_interval => interval '1 day'); + +INSERT INTO tz_dst_spring VALUES + ('2025-03-08 12:00:00', 1), -- day before DST (24 hours) + ('2025-03-09 12:00:00', 2), -- DST day (23 hours - spring forward) + ('2025-03-10 12:00:00', 3); -- day after DST (24 hours) + +SELECT chunk_name, range_start, range_end, + round(extract(epoch FROM (range_end - range_start)) / 3600, 1) AS hours +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_dst_spring' ORDER BY range_start; + +DROP TABLE tz_dst_spring; + +-- DST fall back test: 25-hour chunk +-- America/New_York 2024: Nov 3 at 2:00 AM clocks fall back to 1:00 AM +CREATE TABLE tz_dst_fall(time timestamptz NOT NULL, value int); +SELECT create_hypertable('tz_dst_fall', 'time', chunk_time_interval => interval '1 day'); + +INSERT INTO tz_dst_fall VALUES + ('2024-11-02 12:00:00', 1), -- day before DST (24 hours) + ('2024-11-03 12:00:00', 2), -- DST day (25 hours - fall back) + ('2024-11-04 12:00:00', 3); -- day after DST (24 hours) + +SELECT chunk_name, range_start, range_end, + round(extract(epoch FROM (range_end - range_start)) / 3600, 1) AS hours +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_dst_fall' ORDER BY range_start; + +DROP TABLE tz_dst_fall; + +-- Same test in UTC - all chunks should be exactly 24 hours +SET timezone = 'UTC'; + +CREATE TABLE tz_utc(time timestamptz NOT NULL, value int); +SELECT create_hypertable('tz_utc', 'time', chunk_time_interval => interval '1 day'); + +INSERT INTO tz_utc VALUES + ('2025-03-08 12:00:00', 1), + ('2025-03-09 12:00:00', 2), + ('2025-03-10 12:00:00', 3); + +-- All chunks are 24 hours in UTC (no DST) +SELECT chunk_name, range_start, range_end, + round(extract(epoch FROM (range_end - range_start)) / 3600, 1) AS hours +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_utc' ORDER BY range_start; + +DROP TABLE tz_utc; + +-- Create in UTC, then switch to DST timezone +-- Origin is UTC midnight, but chunk duration depends on session timezone when chunk is created +SET timezone = 'UTC'; + +CREATE TABLE tz_utc_then_dst(time timestamptz NOT NULL, value int); +SELECT create_hypertable('tz_utc_then_dst', 'time', chunk_time_interval => interval '1 day'); + +-- Now switch to NY and insert around DST transition +SET timezone = 'America/New_York'; + +INSERT INTO tz_utc_then_dst VALUES + ('2025-03-08 12:00:00', 1), + ('2025-03-09 12:00:00', 2), + ('2025-03-10 12:00:00', 3); + +-- Chunks are 23 hours around DST because interval addition uses session timezone +SELECT chunk_name, range_start, range_end, + round(extract(epoch FROM (range_end - range_start)) / 3600, 1) AS hours +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_utc_then_dst' ORDER BY range_start; + +-- Now change origin to local timezone (NY) midnight +-- This means new chunks will align to NY midnight instead of UTC midnight +SELECT set_chunk_time_interval('tz_utc_then_dst', interval '1 day', + chunk_time_origin => '2001-01-01 00:00:00 America/New_York'::timestamptz); + +-- Insert data that creates chunks aligned to NY midnight (not UTC midnight) +-- These chunks should align to local midnight +INSERT INTO tz_utc_then_dst VALUES + ('2025-03-15 12:00:00', 4), -- New chunk aligned to NY midnight + ('2025-03-16 12:00:00', 5); -- Another new chunk + +-- Show all chunks - old ones aligned to UTC, new ones aligned to NY +SELECT chunk_name, range_start, range_end, + round(extract(epoch FROM (range_end - range_start)) / 3600, 1) AS hours +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_utc_then_dst' ORDER BY range_start; + +-- Now insert data that falls into the gap between old UTC-aligned and new NY-aligned chunks +-- This chunk will be "cut" to fit between existing chunks +INSERT INTO tz_utc_then_dst VALUES ('2025-03-10 23:00:00', 6); -- Between existing chunks + +-- Show all chunks with range size - the transition chunk should be smaller +SELECT chunk_name, range_start, range_end, + round(extract(epoch FROM (range_end - range_start)) / 3600, 1) AS hours +FROM timescaledb_information.chunks WHERE hypertable_name = 'tz_utc_then_dst' ORDER BY range_start; + +DROP TABLE tz_utc_then_dst; + +-- Reset to Chatham for remaining tests +SET timezone = 'Pacific/Chatham'; + +--------------------------------------------------------------- +-- HYPERTABLE TESTS: TIMESTAMP (without timezone) +--------------------------------------------------------------- + +-- Daily chunks +CREATE TABLE ts_daily(time timestamp NOT NULL, value int); +SELECT create_hypertable('ts_daily', 'time', chunk_time_interval => interval '1 day'); +INSERT INTO ts_daily VALUES + ('2025-01-01 00:00:00', 1), + ('2025-01-01 23:59:59.999999', 2), + ('2025-01-02 00:00:00', 3); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'ts_daily' ORDER BY range_start; +DROP TABLE ts_daily; + +-- Weekly chunks +CREATE TABLE ts_weekly(time timestamp NOT NULL, value int); +SELECT create_hypertable('ts_weekly', 'time', chunk_time_interval => interval '1 week'); +INSERT INTO ts_weekly VALUES + ('2025-01-06 00:00:00', 1), -- Monday + ('2025-01-12 23:59:59', 2), -- Sunday + ('2025-01-13 00:00:00', 3); -- Monday (next week) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'ts_weekly' ORDER BY range_start; +DROP TABLE ts_weekly; + +-- Monthly chunks +CREATE TABLE ts_monthly(time timestamp NOT NULL, value int); +SELECT create_hypertable('ts_monthly', 'time', chunk_time_interval => interval '1 month'); +INSERT INTO ts_monthly VALUES + ('2025-01-15 12:00:00', 1), + ('2025-02-15 12:00:00', 2), + ('2025-03-15 12:00:00', 3); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'ts_monthly' ORDER BY range_start; +DROP TABLE ts_monthly; + +-- Yearly chunks +CREATE TABLE ts_yearly(time timestamp NOT NULL, value int); +SELECT create_hypertable('ts_yearly', 'time', chunk_time_interval => interval '1 year'); +INSERT INTO ts_yearly VALUES + ('2024-06-15 12:00:00', 1), + ('2025-06-15 12:00:00', 2); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'ts_yearly' ORDER BY range_start; +DROP TABLE ts_yearly; + +-- Custom origin: days starting at noon (timestamp without timezone) +CREATE TABLE ts_noon_days(time timestamp NOT NULL, value int); +SELECT create_hypertable('ts_noon_days', 'time', + chunk_time_interval => interval '1 day', + chunk_time_origin => '2024-06-01 12:00:00'::timestamp); + +INSERT INTO ts_noon_days VALUES + ('2024-06-01 00:00:00', 1), + ('2024-06-01 11:59:59', 2), + ('2024-06-01 12:00:00', 3), + ('2024-06-02 11:59:59', 4); + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'ts_noon_days' ORDER BY range_start; +DROP TABLE ts_noon_days; + +-- Custom origin: fiscal year starting July 1 (timestamp without timezone) +CREATE TABLE ts_fiscal_year(time timestamp NOT NULL, value int); +SELECT create_hypertable('ts_fiscal_year', + by_range('time', interval '1 year', partition_origin => '2020-07-01 00:00:00'::timestamp)); + +INSERT INTO ts_fiscal_year VALUES + ('2024-06-30 23:59:59', 1), -- FY2024 + ('2024-07-01 00:00:00', 2), -- FY2025 + ('2025-06-30 23:59:59', 3); -- FY2025 + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'ts_fiscal_year' ORDER BY range_start; +DROP TABLE ts_fiscal_year; + +--------------------------------------------------------------- +-- HYPERTABLE TESTS: DATE +--------------------------------------------------------------- + +-- Daily chunks +CREATE TABLE date_daily(day date NOT NULL, value int); +SELECT create_hypertable('date_daily', 'day', chunk_time_interval => interval '1 day'); +INSERT INTO date_daily VALUES + ('2025-01-01', 1), + ('2025-01-02', 2), + ('2025-01-03', 3); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'date_daily' ORDER BY range_start; +DROP TABLE date_daily; + +-- Weekly chunks +CREATE TABLE date_weekly(day date NOT NULL, value int); +SELECT create_hypertable('date_weekly', 'day', chunk_time_interval => interval '1 week'); +INSERT INTO date_weekly VALUES + ('2025-01-06', 1), -- Monday + ('2025-01-12', 2), -- Sunday + ('2025-01-13', 3); -- Monday (next week) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'date_weekly' ORDER BY range_start; +DROP TABLE date_weekly; + +-- Monthly chunks +CREATE TABLE date_monthly(day date NOT NULL, value int); +SELECT create_hypertable('date_monthly', 'day', chunk_time_interval => interval '1 month'); +INSERT INTO date_monthly VALUES + ('2025-01-15', 1), + ('2025-02-15', 2), + ('2025-03-15', 3); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'date_monthly' ORDER BY range_start; +DROP TABLE date_monthly; + +-- Yearly chunks +CREATE TABLE date_yearly(day date NOT NULL, value int); +SELECT create_hypertable('date_yearly', 'day', chunk_time_interval => interval '1 year'); +INSERT INTO date_yearly VALUES + ('2024-06-15', 1), + ('2025-06-15', 2); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'date_yearly' ORDER BY range_start; +DROP TABLE date_yearly; + +-- Custom origin: weeks starting on Wednesday +CREATE TABLE date_wed_weeks(day date NOT NULL, value int); +SELECT create_hypertable('date_wed_weeks', 'day', + chunk_time_interval => interval '1 week', + chunk_time_origin => '2025-01-01'::date); -- Wednesday + +INSERT INTO date_wed_weeks VALUES + ('2024-12-31', 1), -- Tuesday (before origin week boundary) + ('2025-01-01', 2), -- Wednesday (at origin) + ('2025-01-07', 3), -- Tuesday + ('2025-01-08', 4); -- Wednesday (next week) + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'date_wed_weeks' ORDER BY range_start; +DROP TABLE date_wed_weeks; + +-- Custom origin: fiscal year starting April 1 (date) +CREATE TABLE date_fiscal_year(day date NOT NULL, value int); +SELECT create_hypertable('date_fiscal_year', + by_range('day', interval '1 year', partition_origin => '2020-04-01'::date)); + +INSERT INTO date_fiscal_year VALUES + ('2024-03-31', 1), -- FY2024 + ('2024-04-01', 2), -- FY2025 + ('2025-03-31', 3); -- FY2025 + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'date_fiscal_year' ORDER BY range_start; +DROP TABLE date_fiscal_year; + +-- Quarterly chunks (date) +CREATE TABLE date_quarterly(day date NOT NULL, value int); +SELECT create_hypertable('date_quarterly', 'day', chunk_time_interval => interval '3 months'); +INSERT INTO date_quarterly VALUES + ('2025-01-15', 1), -- Q1 + ('2025-04-15', 2), -- Q2 + ('2025-07-15', 3), -- Q3 + ('2025-10-15', 4); -- Q4 +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'date_quarterly' ORDER BY range_start; +DROP TABLE date_quarterly; + +--------------------------------------------------------------- +-- CUSTOM ORIGIN TESTS +--------------------------------------------------------------- + +-- Fiscal year starting July 1 +CREATE TABLE fiscal_year(time timestamptz NOT NULL, value int); +SELECT create_hypertable('fiscal_year', + by_range('time', interval '1 year', partition_origin => '2020-07-01 00:00:00 UTC'::timestamptz)); + +INSERT INTO fiscal_year VALUES + ('2024-06-30 23:59:59 UTC', 1), -- FY2024 + ('2024-07-01 00:00:00 UTC', 2), -- FY2025 + ('2025-06-30 23:59:59 UTC', 3); -- FY2025 + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'fiscal_year' ORDER BY range_start; +DROP TABLE fiscal_year; + +-- Quarters starting April 1 +CREATE TABLE fiscal_quarter(time timestamptz NOT NULL, value int); +SELECT create_hypertable('fiscal_quarter', + by_range('time', interval '3 months', partition_origin => '2024-04-01 00:00:00 UTC'::timestamptz)); + +INSERT INTO fiscal_quarter VALUES + ('2024-03-31 23:59:59 UTC', 1), -- Q4 FY2024 + ('2024-04-01 00:00:00 UTC', 2), -- Q1 FY2025 + ('2024-07-01 00:00:00 UTC', 3), -- Q2 FY2025 + ('2024-10-01 00:00:00 UTC', 4); -- Q3 FY2025 + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'fiscal_quarter' ORDER BY range_start; +DROP TABLE fiscal_quarter; + +-- Days starting at noon +CREATE TABLE noon_days(time timestamptz NOT NULL, value int); +SELECT create_hypertable('noon_days', 'time', + chunk_time_interval => interval '1 day', + chunk_time_origin => '2024-06-01 12:00:00 UTC'::timestamptz); + +INSERT INTO noon_days VALUES + ('2024-06-01 00:00:00 UTC', 1), + ('2024-06-01 11:59:59 UTC', 2), + ('2024-06-01 12:00:00 UTC', 3), + ('2024-06-02 11:59:59 UTC', 4); + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'noon_days' ORDER BY range_start; +DROP TABLE noon_days; + +-- Partitioning function with interval and origin (int epoch seconds to timestamptz) +CREATE OR REPLACE FUNCTION epoch_sec_to_timestamptz(epoch_sec int) +RETURNS timestamptz LANGUAGE SQL IMMUTABLE AS $$ + SELECT to_timestamp(epoch_sec); +$$; + +CREATE TABLE events_epoch(epoch_sec int NOT NULL, value int); +SELECT create_hypertable('events_epoch', + by_range('epoch_sec', interval '1 month', + partition_func => 'epoch_sec_to_timestamptz', + partition_origin => '2024-07-01 00:00:00 UTC'::timestamptz)); + +-- Epoch values in seconds: +-- 2024-06-30 23:59:59 UTC = 1719791999 +-- 2024-07-01 00:00:00 UTC = 1719792000 +-- 2024-07-15 12:00:00 UTC = 1721044800 +-- 2024-08-01 00:00:00 UTC = 1722470400 +INSERT INTO events_epoch VALUES + (1719791999, 1), + (1719792000, 2), + (1721044800, 3), + (1722470400, 4); + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'events_epoch' ORDER BY range_start; +DROP TABLE events_epoch; +DROP FUNCTION epoch_sec_to_timestamptz(int); + +--------------------------------------------------------------- +-- ADD_DIMENSION TESTS +--------------------------------------------------------------- + +-- Test add_dimension() with origin parameter (deprecated API) +-- Add a time dimension to a table with integer primary dimension +CREATE TABLE add_dim_old_api(id int NOT NULL, time timestamptz NOT NULL, value int); +SELECT create_hypertable('add_dim_old_api', 'id', chunk_time_interval => 100); +SELECT add_dimension('add_dim_old_api', 'time', + chunk_time_interval => interval '1 month', + chunk_time_origin => '2024-04-01 00:00:00 UTC'::timestamptz); + +INSERT INTO add_dim_old_api VALUES + (1, '2024-03-31 23:59:59 UTC', 1), -- March (before origin month) + (1, '2024-04-15 12:00:00 UTC', 2), -- April + (1, '2024-05-15 12:00:00 UTC', 3); -- May + +-- Verify the origin was stored correctly in the dimension +SELECT d.column_name, + _timescaledb_functions.to_timestamp(d.interval_origin) AS origin +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'add_dim_old_api' AND d.column_name = 'time'; + +DROP TABLE add_dim_old_api; + +-- Test add_dimension() with by_range and partition_origin (new API) +CREATE TABLE add_dim_new_api(id int NOT NULL, time timestamptz NOT NULL, value int); +SELECT create_hypertable('add_dim_new_api', 'id', chunk_time_interval => 100); +SELECT add_dimension('add_dim_new_api', + by_range('time', interval '3 months', partition_origin => '2024-07-01 00:00:00 UTC'::timestamptz)); + +INSERT INTO add_dim_new_api VALUES + (1, '2024-06-30 23:59:59 UTC', 1), -- Q2 (before origin) + (1, '2024-07-01 00:00:00 UTC', 2), -- Q3 (at origin) + (1, '2024-10-01 00:00:00 UTC', 3); -- Q4 + +-- Verify the origin was stored correctly +SELECT d.column_name, + _timescaledb_functions.to_timestamp(d.interval_origin) AS origin +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'add_dim_new_api' AND d.column_name = 'time'; + +DROP TABLE add_dim_new_api; + +--------------------------------------------------------------- +-- SET_CHUNK_TIME_INTERVAL TESTS +--------------------------------------------------------------- + +-- Test set_chunk_time_interval() with origin parameter +SET timezone = 'UTC'; + +CREATE TABLE set_interval_test(time timestamptz NOT NULL, value int); +SELECT create_hypertable('set_interval_test', 'time', chunk_time_interval => interval '1 day'); + +-- Insert to create initial chunk aligned to default origin (midnight) +INSERT INTO set_interval_test VALUES ('2024-06-15 12:00:00 UTC', 1); + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'set_interval_test' ORDER BY range_start; + +-- Verify current origin (default: 2001-01-01 midnight) +SELECT d.column_name, + _timescaledb_functions.to_timestamp(d.interval_origin) AS origin +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'set_interval_test'; + +-- Verify calendar chunking is still enabled +SHOW timescaledb.enable_calendar_chunking; + +-- Change interval and origin to noon +SELECT set_chunk_time_interval('set_interval_test', interval '1 day', chunk_time_origin => '2024-01-01 12:00:00 UTC'::timestamptz); + +-- Verify the origin was updated to noon +SELECT d.column_name, + _timescaledb_functions.to_timestamp(d.interval_origin) AS origin +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'set_interval_test'; + +-- Insert to create transition chunk from midnight to noon +-- This data falls after the existing chunk's end (June 16 00:00) but before +-- the next noon boundary (June 16 12:00), creating a 12-hour transition chunk +INSERT INTO set_interval_test VALUES ('2024-06-16 06:00:00 UTC', 2); + +-- Insert to create new chunk aligned to noon origin +INSERT INTO set_interval_test VALUES ('2024-07-15 18:00:00 UTC', 3); + +-- Old chunk starts at midnight, transition chunk is 12 hours, new chunk starts at noon +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'set_interval_test' ORDER BY range_start; +DROP TABLE set_interval_test; + +-- Test set_chunk_time_interval() changing from days to months with fiscal origin +CREATE TABLE set_interval_fiscal(time timestamptz NOT NULL, value int); +SELECT create_hypertable('set_interval_fiscal', 'time', chunk_time_interval => interval '1 day'); + +-- Insert initial data +INSERT INTO set_interval_fiscal VALUES ('2024-06-15 12:00:00 UTC', 1); + +-- Change to monthly interval with fiscal year origin (July 1) +SELECT set_chunk_time_interval('set_interval_fiscal', interval '1 month', chunk_time_origin => '2024-07-01 00:00:00 UTC'::timestamptz); + +-- Verify the origin was updated +SELECT d.column_name, + _timescaledb_functions.to_timestamp(d.interval_origin) AS origin +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'set_interval_fiscal'; + +-- Insert more data to create monthly chunks aligned to fiscal year +INSERT INTO set_interval_fiscal VALUES + ('2024-07-15 12:00:00 UTC', 2), + ('2024-08-15 12:00:00 UTC', 3); + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'set_interval_fiscal' ORDER BY range_start; +DROP TABLE set_interval_fiscal; + +-- Reset timezone +SET timezone = 'Pacific/Chatham'; + +--------------------------------------------------------------- +-- COMPARISON: CALENDAR VS NON-CALENDAR CHUNKING +--------------------------------------------------------------- + +-- Show what the UTC values look like in Chatham time zone +-- Chatham is UTC+13:45 in January (daylight saving), so noon UTC is 01:45 next day +SELECT '2025-01-01 12:00:00 UTC'::timestamptz AS "UTC noon Jan 1", + '2025-01-02 12:00:00 UTC'::timestamptz AS "UTC noon Jan 2"; + +-- Non-calendar chunking +SET timescaledb.enable_calendar_chunking = false; + +CREATE TABLE non_calendar(time timestamptz NOT NULL, value int); +SELECT create_hypertable('non_calendar', 'time', chunk_time_interval => interval '1 day'); +INSERT INTO non_calendar VALUES + ('2025-01-01 12:00:00 UTC', 1), + ('2025-01-02 12:00:00 UTC', 2); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'non_calendar' ORDER BY range_start; +DROP TABLE non_calendar; + +-- Calendar chunking +SET timescaledb.enable_calendar_chunking = true; + +CREATE TABLE calendar(time timestamptz NOT NULL, value int); +SELECT create_hypertable('calendar', 'time', chunk_time_interval => interval '1 day'); +INSERT INTO calendar VALUES + ('2025-01-01 12:00:00 UTC', 1), + ('2025-01-02 12:00:00 UTC', 2); +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'calendar' ORDER BY range_start; +DROP TABLE calendar; + +--------------------------------------------------------------- +-- SET_CHUNK_TIME_INTERVAL WITH NON-INTERVAL TYPE +--------------------------------------------------------------- +-- Test that passing integer (microseconds) instead of Interval +-- to set_chunk_time_interval() on a calendar-based hypertable errors. + +SET timezone = 'UTC'; +SET timescaledb.enable_calendar_chunking = true; + +CREATE TABLE calendar_no_integer(time timestamptz NOT NULL, value int); +SELECT create_hypertable('calendar_no_integer', 'time', chunk_time_interval => interval '1 day'); + +-- Verify calendar chunking is active +SELECT d.column_name, + d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'calendar_no_integer'; + +-- Try to change to integer interval - this should error +\set ON_ERROR_STOP 0 +SELECT set_chunk_time_interval('calendar_no_integer', 86400000000::bigint); +\set ON_ERROR_STOP 1 + +-- Verify calendar chunking is still active (unchanged) +SELECT d.column_name, + d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'calendar_no_integer'; + +-- But changing to another Interval value should work +SELECT set_chunk_time_interval('calendar_no_integer', interval '1 week'); +SELECT d.column_name, + d.interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'calendar_no_integer'; + +DROP TABLE calendar_no_integer; + +--------------------------------------------------------------- +-- GUC CHANGE AFTER HYPERTABLE CREATION +-- Test that chunking mode is sticky (doesn't change with GUC) +-- Test all combinations of: +-- - Hypertable type: calendar vs non-calendar +-- - GUC state: ON vs OFF +-- - Interval input: INTERVAL vs integer +--------------------------------------------------------------- + +SET timezone = 'UTC'; + +--------------------------------------------------------------- +-- NON-CALENDAR HYPERTABLE TESTS +-- Created with calendar_chunking = OFF +-- Should stay non-calendar regardless of GUC or input type +--------------------------------------------------------------- + +SET timescaledb.enable_calendar_chunking = false; + +CREATE TABLE non_calendar_ht(time timestamptz NOT NULL, value int); +SELECT create_hypertable('non_calendar_ht', 'time', chunk_time_interval => interval '1 day'); + +-- Verify initial state: non-calendar mode (has_integer_interval=t, has_time_interval=f) +SELECT d.column_name, + d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval_length +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'non_calendar_ht'; + +-- GUC OFF + integer input → stays non-calendar +SET timescaledb.enable_calendar_chunking = false; +SELECT set_chunk_time_interval('non_calendar_ht', 172800000000::bigint); -- 2 days in microseconds + +SELECT d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval_length +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'non_calendar_ht'; + +-- GUC OFF + INTERVAL input → stays non-calendar (converted to microseconds) +SET timescaledb.enable_calendar_chunking = false; +SELECT set_chunk_time_interval('non_calendar_ht', interval '3 days'); + +SELECT d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval_length +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'non_calendar_ht'; + +-- GUC ON + integer input → stays non-calendar +SET timescaledb.enable_calendar_chunking = true; +SELECT set_chunk_time_interval('non_calendar_ht', 345600000000::bigint); -- 4 days in microseconds + +SELECT d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval_length +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'non_calendar_ht'; + +-- GUC ON + INTERVAL input → stays non-calendar (converted to microseconds) +SET timescaledb.enable_calendar_chunking = true; +SELECT set_chunk_time_interval('non_calendar_ht', interval '5 days'); + +SELECT d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval_length +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'non_calendar_ht'; + +DROP TABLE non_calendar_ht; + +--------------------------------------------------------------- +-- CALENDAR HYPERTABLE TESTS +-- Created with calendar_chunking = ON +-- Should stay calendar regardless of GUC +-- Integer input should always error +--------------------------------------------------------------- + +SET timescaledb.enable_calendar_chunking = true; + +CREATE TABLE calendar_ht(time timestamptz NOT NULL, value int); +SELECT create_hypertable('calendar_ht', 'time', chunk_time_interval => interval '1 day'); + +-- Verify initial state: calendar mode (interval_length IS NULL, interval IS NOT NULL) +SELECT d.column_name, + d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'calendar_ht'; + +-- GUC ON + INTERVAL input → stays calendar +SET timescaledb.enable_calendar_chunking = true; +SELECT set_chunk_time_interval('calendar_ht', interval '2 days'); + +SELECT d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'calendar_ht'; + +-- GUC OFF + INTERVAL input → stays calendar +SET timescaledb.enable_calendar_chunking = false; +SELECT set_chunk_time_interval('calendar_ht', interval '3 days'); + +SELECT d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'calendar_ht'; + +-- GUC ON + integer input → ERROR (cannot use integer on calendar hypertable) +SET timescaledb.enable_calendar_chunking = true; +\set ON_ERROR_STOP 0 +SELECT set_chunk_time_interval('calendar_ht', 86400000000::bigint); +\set ON_ERROR_STOP 1 + +-- Verify unchanged after error +SELECT d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'calendar_ht'; + +-- GUC OFF + integer input → ERROR (cannot use integer on calendar hypertable) +SET timescaledb.enable_calendar_chunking = false; +\set ON_ERROR_STOP 0 +SELECT set_chunk_time_interval('calendar_ht', 172800000000::bigint); +\set ON_ERROR_STOP 1 + +-- Verify unchanged after error +SELECT d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'calendar_ht'; + +DROP TABLE calendar_ht; + +--------------------------------------------------------------- +-- MODE SWITCHING TESTS +-- Test switching between calendar and non-calendar modes +-- using the calendar_chunking parameter +--------------------------------------------------------------- + +-- Use a non-UTC timezone to make calendar vs non-calendar differences visible +-- In UTC, chunks would align nicely even without calendar chunking +SET timezone = 'America/New_York'; +SET timescaledb.enable_calendar_chunking = false; + +CREATE TABLE mode_switch(time timestamptz NOT NULL, value int); +SELECT create_hypertable('mode_switch', 'time', chunk_time_interval => interval '1 day'); + +-- 1. Initial state: non-calendar mode +SELECT d.column_name, + d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'mode_switch'; + +INSERT INTO mode_switch VALUES + ('2025-01-01 12:00:00 UTC', 1), + ('2025-01-02 12:00:00 UTC', 2); + +-- Non-calendar: chunk boundaries at 19:00 EST (midnight UTC), not local midnight +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'mode_switch' ORDER BY range_start; + +-- 2. Switch to calendar mode using set_chunk_time_interval +SELECT set_chunk_time_interval('mode_switch', '1 week'::interval, calendar_chunking => true); + +SELECT d.column_name, + d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'mode_switch'; + +INSERT INTO mode_switch VALUES + ('2025-02-10 12:00:00 UTC', 3), + ('2025-02-17 12:00:00 UTC', 4); + +-- Calendar: new chunks aligned to local Monday midnight (00:00 EST) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'mode_switch' ORDER BY range_start; + +-- 3. Switch back to non-calendar using set_partitioning_interval +SELECT set_partitioning_interval('mode_switch', '2 days'::interval, calendar_chunking => false); + +SELECT d.column_name, + d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval_length +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'mode_switch'; + +INSERT INTO mode_switch VALUES + ('2025-06-15 12:00:00 UTC', 5), + ('2025-06-18 12:00:00 UTC', 6); + +-- Non-calendar: fixed 2-day intervals, boundaries at odd hours +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'mode_switch' ORDER BY range_start; + +-- 4. Switch back to calendar using set_chunk_time_interval +SELECT set_chunk_time_interval('mode_switch', '1 month'::interval, calendar_chunking => true); + +SELECT d.column_name, + d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval, + d.interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'mode_switch'; + +INSERT INTO mode_switch VALUES + ('2025-09-15 12:00:00 UTC', 7), + ('2025-10-15 12:00:00 UTC', 8); + +-- Calendar: new chunks aligned to local month start (00:00 EST on 1st) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks WHERE hypertable_name = 'mode_switch' ORDER BY range_start; + +DROP TABLE mode_switch; + +-- Test error: calendar_chunking => true with integer interval +CREATE TABLE calendar_switch_error(time timestamptz NOT NULL, value int); +SELECT create_hypertable('calendar_switch_error', 'time', chunk_time_interval => interval '1 day'); + +\set ON_ERROR_STOP 0 +SELECT set_chunk_time_interval('calendar_switch_error', 86400000000::bigint, calendar_chunking => true); +\set ON_ERROR_STOP 1 + +SELECT d.column_name, + d.interval_length IS NOT NULL AS has_integer_interval, + d.interval IS NOT NULL AS has_time_interval +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'calendar_switch_error'; + +DROP TABLE calendar_switch_error; + +RESET timescaledb.enable_calendar_chunking; + +--------------------------------------------------------------- +-- CHUNK POSITION RELATIVE TO ORIGIN +-- Test chunks before, enclosing, and after the origin +--------------------------------------------------------------- + +SET timezone = 'UTC'; +SET timescaledb.enable_calendar_chunking = true; + +-- Test with origin in the middle of the data range +-- Origin: 2020-01-15 (middle of January) +CREATE TABLE origin_position(time timestamptz NOT NULL, value int); +SELECT create_hypertable('origin_position', + by_range('time', interval '1 month', partition_origin => '2020-01-15 00:00:00 UTC'::timestamptz)); + +-- Insert data: before origin, enclosing origin, after origin +INSERT INTO origin_position VALUES + -- Chunks BEFORE origin (historical data) + ('2019-11-20 12:00:00 UTC', 1), -- Nov 15 - Dec 15, 2019 + ('2019-12-20 12:00:00 UTC', 2), -- Dec 15 - Jan 15, 2020 + -- Chunk ENCLOSING origin (origin is at chunk boundary) + ('2020-01-15 00:00:00 UTC', 3), -- Exactly at origin + ('2020-01-20 12:00:00 UTC', 4), -- Jan 15 - Feb 15, 2020 + -- Chunks AFTER origin + ('2020-02-20 12:00:00 UTC', 5), -- Feb 15 - Mar 15, 2020 + ('2020-03-20 12:00:00 UTC', 6); -- Mar 15 - Apr 15, 2020 + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'origin_position' +ORDER BY range_start; + +-- Verify data is in correct chunks +SELECT tableoid::regclass as chunk, time, value +FROM origin_position ORDER BY time; + +DROP TABLE origin_position; + +-- Test with daily chunks and origin at noon +-- This tests chunks before/after the origin time-of-day boundary +CREATE TABLE origin_daily(time timestamptz NOT NULL, value int); +SELECT create_hypertable('origin_daily', + by_range('time', interval '1 day', partition_origin => '2020-06-15 12:00:00 UTC'::timestamptz)); + +INSERT INTO origin_daily VALUES + -- Before origin date (chunks before origin) + ('2020-06-13 18:00:00 UTC', 1), -- Jun 13 noon - Jun 14 noon + ('2020-06-14 18:00:00 UTC', 2), -- Jun 14 noon - Jun 15 noon + -- At and after origin + ('2020-06-15 12:00:00 UTC', 3), -- Exactly at origin + ('2020-06-15 18:00:00 UTC', 4), -- Jun 15 noon - Jun 16 noon + ('2020-06-16 06:00:00 UTC', 5), -- Same chunk (before noon) + ('2020-06-16 18:00:00 UTC', 6); -- Jun 16 noon - Jun 17 noon + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'origin_daily' +ORDER BY range_start; + +DROP TABLE origin_daily; + +--------------------------------------------------------------- +-- EXTREME VALUES AND OVERFLOW CLAMPING TESTS +-- Test timestamps near the boundaries of the valid range +--------------------------------------------------------------- + +-- Test calc_range with ancient timestamps +-- PostgreSQL timestamp range: 4713 BC to 294276 AD + +-- Ancient timestamp tests +SELECT *, end_ts - start_ts AS range_size FROM calc_range('4700-01-15 00:00:00 BC'::timestamptz, '1 year'::interval); +SELECT *, end_ts - start_ts AS range_size FROM calc_range('4700-01-15 00:00:00 BC'::timestamptz, '1 month'::interval); +SELECT *, end_ts - start_ts AS range_size FROM calc_range('4700-01-15 00:00:00 BC'::timestamptz, '1 day'::interval); + +-- Far future timestamp tests (but within valid range) +SELECT *, end_ts - start_ts AS range_size FROM calc_range('100000-01-15 00:00:00'::timestamptz, '1 year'::interval); +SELECT *, end_ts - start_ts AS range_size FROM calc_range('100000-01-15 00:00:00'::timestamptz, '1 month'::interval); +SELECT *, end_ts - start_ts AS range_size FROM calc_range('100000-01-15 00:00:00'::timestamptz, '1 day'::interval); + +-- Test hypertable with data spanning ancient times +CREATE TABLE extreme_ancient(time timestamptz NOT NULL, value int); +SELECT create_hypertable('extreme_ancient', 'time', chunk_time_interval => interval '100 years'); + +INSERT INTO extreme_ancient VALUES + ('4700-06-15 00:00:00 BC', 1), + ('4600-06-15 00:00:00 BC', 2); + +-- Verify chunks are created for ancient dates +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'extreme_ancient' +ORDER BY range_start; + +-- Show CHECK constraints on chunks with chunk size +-- Chunk size should be close to 100 years when not clamped +SELECT * FROM show_chunk_constraints('extreme_ancient'); + +DROP TABLE extreme_ancient; + +-- Test hypertable with data in far future +CREATE TABLE extreme_future(time timestamptz NOT NULL, value int); +SELECT create_hypertable('extreme_future', 'time', chunk_time_interval => interval '100 years'); + +INSERT INTO extreme_future VALUES + ('100000-06-15 00:00:00', 1), + ('100100-06-15 00:00:00', 2); + +-- Verify chunks are created for far future dates +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'extreme_future' +ORDER BY range_start; + +-- Show CHECK constraints on chunks with chunk size +-- Chunk size should be close to 100 years when not clamped +SELECT * FROM show_chunk_constraints('extreme_future'); + +DROP TABLE extreme_future; + +-- Test chunks with open-ended constraints (clamped to MIN/MAX) +-- Use large intervals that cause boundaries to overflow +CREATE TABLE open_ended_chunks(time timestamptz NOT NULL, value int); +SELECT create_hypertable('open_ended_chunks', 'time', chunk_time_interval => interval '10000 years'); + +-- Insert at boundaries - should create open-ended constraints +INSERT INTO open_ended_chunks VALUES + ('4700-06-15 00:00:00 BC', 1), -- Near min boundary, chunk start overflows + ('294000-06-15 00:00:00', 2); -- Near max boundary, chunk end overflows + +-- Show CHECK constraints - should see open-ended constraints +-- Chunk size should be smaller than 10000 years when clamped +SELECT * FROM show_chunk_constraints('open_ended_chunks'); + +DROP TABLE open_ended_chunks; + +-- Test with origin far in the past - chunks before and after +CREATE TABLE origin_ancient(time timestamptz NOT NULL, value int); +SELECT create_hypertable('origin_ancient', + by_range('time', interval '1000 years', partition_origin => '2000-01-01 00:00:00 BC'::timestamptz)); + +INSERT INTO origin_ancient VALUES + ('4000-06-15 00:00:00 BC', 1), -- Before origin + ('1500-06-15 00:00:00 BC', 2), -- After origin (closer to present) + ('500-06-15 00:00:00', 3), -- After origin (AD) + ('2020-06-15 00:00:00', 4); -- Modern times + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'origin_ancient' +ORDER BY range_start; + +DROP TABLE origin_ancient; + +-- Test with origin far in the future - all data before origin +CREATE TABLE origin_future(time timestamptz NOT NULL, value int); +SELECT create_hypertable('origin_future', + by_range('time', interval '100 years', partition_origin => '50000-01-01 00:00:00'::timestamptz)); + +INSERT INTO origin_future VALUES + ('2020-06-15 00:00:00', 1), + ('2120-06-15 00:00:00', 2), + ('2220-06-15 00:00:00', 3); + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'origin_future' +ORDER BY range_start; + +DROP TABLE origin_future; + +--------------------------------------------------------------- +-- MIXED INTERVAL TESTS WITH EXTREME VALUES +-- Test intervals with month+day+time components near boundaries +--------------------------------------------------------------- + +-- Mixed interval (month + day) with values spanning origin +SELECT * FROM calc_range('2020-01-15 00:00:00 UTC'::timestamptz, '1 month 15 days'::interval, + '2020-02-01 00:00:00 UTC'::timestamptz); +SELECT * FROM calc_range('2020-02-01 00:00:00 UTC'::timestamptz, '1 month 15 days'::interval, + '2020-02-01 00:00:00 UTC'::timestamptz); +SELECT * FROM calc_range('2020-03-01 00:00:00 UTC'::timestamptz, '1 month 15 days'::interval, + '2020-02-01 00:00:00 UTC'::timestamptz); + +-- Mixed interval with ancient dates +SELECT *, end_ts - start_ts AS range_size FROM calc_range('4700-01-15 00:00:00 BC'::timestamptz, '1 month 15 days'::interval); +SELECT *, end_ts - start_ts AS range_size FROM calc_range('50000-01-15 00:00:00'::timestamptz, '1 month 15 days'::interval); + +-- Create hypertable with mixed interval +CREATE TABLE mixed_interval(time timestamptz NOT NULL, value int); +SELECT create_hypertable('mixed_interval', + by_range('time', interval '1 month 15 days', partition_origin => '2020-01-01 00:00:00 UTC'::timestamptz)); + +INSERT INTO mixed_interval VALUES + ('2019-12-01 00:00:00 UTC', 1), -- Before origin + ('2020-01-01 00:00:00 UTC', 2), -- At origin + ('2020-01-20 00:00:00 UTC', 3), -- Same chunk as origin + ('2020-02-20 00:00:00 UTC', 4), -- Next chunk + ('2020-04-01 00:00:00 UTC', 5); -- Two chunks after + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'mixed_interval' +ORDER BY range_start; + +DROP TABLE mixed_interval; + +--------------------------------------------------------------- +-- INTEGER DIMENSION OVERFLOW TESTS +-- Test integer dimensions with values near INT64 boundaries +--------------------------------------------------------------- + +-- Test bigint with large positive values +CREATE TABLE int_extreme_max(time bigint NOT NULL, value int); +SELECT create_hypertable('int_extreme_max', by_range('time', 1000000000000, partition_origin => 0)); + +INSERT INTO int_extreme_max VALUES + (9223372036854775000, 1), -- Near INT64_MAX + (9223372036854774000, 2); + +SELECT * FROM show_int_chunk_slices('int_extreme_max'); + +-- Show CHECK constraints - should see open-ended constraint (no upper bound) +SELECT * FROM show_check_constraints('int_extreme_max'); + +DROP TABLE int_extreme_max; + +-- Test bigint with large negative values +CREATE TABLE int_extreme_min(time bigint NOT NULL, value int); +SELECT create_hypertable('int_extreme_min', by_range('time', 1000000000000, partition_origin => 0)); + +INSERT INTO int_extreme_min VALUES + (-9223372036854775000, 1), -- Near INT64_MIN + (-9223372036854774000, 2); + +SELECT * FROM show_int_chunk_slices('int_extreme_min'); + +-- Show CHECK constraints - should see open-ended constraint (no lower bound) +SELECT * FROM show_check_constraints('int_extreme_min'); + +DROP TABLE int_extreme_min; + +-- Test integer origin with values on both sides +CREATE TABLE int_origin_sides(time bigint NOT NULL, value int); +SELECT create_hypertable('int_origin_sides', by_range('time', 100, partition_origin => 1000)); + +INSERT INTO int_origin_sides VALUES + (850, 1), -- Chunk [800, 900) + (950, 2), -- Chunk [900, 1000) + (1000, 3), -- At origin, chunk [1000, 1100) + (1050, 4), -- Same chunk as origin + (1100, 5), -- Chunk [1100, 1200) + (1150, 6); -- Same chunk + +SELECT * FROM show_int_chunk_slices('int_origin_sides'); + +-- Verify data placement +SELECT tableoid::regclass as chunk, time, value +FROM int_origin_sides ORDER BY time; + +DROP TABLE int_origin_sides; + +RESET timezone; +RESET timescaledb.enable_calendar_chunking; + +--------------------------------------------------------------- +-- TIMESTAMP WITHOUT TIMEZONE AND GENERAL ALGORITHM TESTS +--------------------------------------------------------------- +-- Test ts_chunk_range_calculate_general() with timestamp (without timezone) +-- and boundary/overflow values for improved test coverage + +\c :TEST_DBNAME :ROLE_SUPERUSER + +-- Create wrapper function for timestamp without timezone +CREATE OR REPLACE FUNCTION calc_range_ts(ts TIMESTAMP, chunk_interval INTERVAL, origin TIMESTAMP DEFAULT NULL, force_general BOOL DEFAULT NULL) +RETURNS TABLE(start_ts TIMESTAMP, end_ts TIMESTAMP) AS :MODULE_PATHNAME, 'ts_dimension_calculate_open_range_calendar' LANGUAGE C; + +SET ROLE :ROLE_DEFAULT_PERM_USER; +SET timescaledb.enable_calendar_chunking = true; + +-- Test timestamp without timezone using general algorithm (force_general=true) +-- Covers various intervals and boundary values in a single query +SELECT t.ts, i.inv, r.start_ts, r.end_ts, r.end_ts - r.start_ts AS range_size +FROM unnest(ARRAY[ + '2025-03-15 12:00:00'::timestamp, + '4700-01-15 00:00:00 BC'::timestamp, + '100000-01-15 00:00:00'::timestamp +]) AS t(ts) +CROSS JOIN unnest(ARRAY[ + '1 day'::interval, + '1 month'::interval, + '1 year'::interval, + '100 years'::interval, + '1 month 15 days'::interval +]) AS i(inv) +CROSS JOIN LATERAL calc_range_ts(t.ts, i.inv, NULL, true) r +ORDER BY t.ts, i.inv; + +-- Test timestamptz with general algorithm for comparison +SELECT t.ts, i.inv, r.start_ts, r.end_ts, r.end_ts - r.start_ts AS range_size +FROM unnest(ARRAY[ + '2025-03-15 12:00:00 UTC'::timestamptz, + '4700-01-15 00:00:00 BC'::timestamptz, + '100000-01-15 00:00:00'::timestamptz +]) AS t(ts) +CROSS JOIN unnest(ARRAY[ + '1 day'::interval, + '1 month'::interval, + '1 year'::interval, + '100 years'::interval, + '1 month 15 days'::interval +]) AS i(inv) +CROSS JOIN LATERAL calc_range(t.ts, i.inv, NULL, true) r +ORDER BY t.ts, i.inv; + +-- Large interval test (1000 years) - separate due to potential overflow +SELECT *, end_ts - start_ts AS range_size FROM calc_range_ts('2025-03-15 12:00:00'::timestamp, '1000 years'::interval, NULL, true); +SELECT *, end_ts - start_ts AS range_size FROM calc_range('2025-03-15 00:00:00 UTC'::timestamptz, '1000 years'::interval, NULL, true); + +-- Cleanup +\c :TEST_DBNAME :ROLE_SUPERUSER +DROP FUNCTION calc_range_ts(TIMESTAMP, INTERVAL, TIMESTAMP, BOOL); +SET ROLE :ROLE_DEFAULT_PERM_USER; + +RESET timescaledb.enable_calendar_chunking; + +--------------------------------------------------------------- +-- ERROR PATH TESTS +-- Test error handling for invalid timestamp values +-- These test the timestamp_datum_to_tm error paths in calc_month_chunk_range +-- and calc_day_chunk_range which trigger "timestamp out of range" errors. +--------------------------------------------------------------- + +-- Test month interval with infinity timestamp (triggers timestamp out of range) +\set ON_ERROR_STOP 0 +SELECT * FROM calc_range('infinity'::timestamptz, '1 month'::interval); +SELECT * FROM calc_range('-infinity'::timestamptz, '1 month'::interval); + +-- Test month interval with infinity origin (triggers origin timestamp out of range) +SELECT * FROM calc_range('2025-01-15'::timestamptz, '1 month'::interval, 'infinity'::timestamptz); +SELECT * FROM calc_range('2025-01-15'::timestamptz, '1 month'::interval, '-infinity'::timestamptz); + +-- Test day interval with infinity timestamp +SELECT * FROM calc_range('infinity'::timestamptz, '1 day'::interval); +SELECT * FROM calc_range('-infinity'::timestamptz, '1 day'::interval); + +-- Test day interval with infinity origin +SELECT * FROM calc_range('2025-01-15'::timestamptz, '1 day'::interval, 'infinity'::timestamptz); +SELECT * FROM calc_range('2025-01-15'::timestamptz, '1 day'::interval, '-infinity'::timestamptz); + +-- Test sub-day interval with infinity values (these go through calc_sub_day_chunk_range) +SELECT * FROM calc_range('infinity'::timestamptz, '1 hour'::interval); +SELECT * FROM calc_range('-infinity'::timestamptz, '1 hour'::interval); +SELECT * FROM calc_range('2025-01-15'::timestamptz, '1 hour'::interval, 'infinity'::timestamptz); +SELECT * FROM calc_range('2025-01-15'::timestamptz, '1 hour'::interval, '-infinity'::timestamptz); +\set ON_ERROR_STOP 1 + +-- Test large intervals that produce chunk boundaries outside PostgreSQL's timestamp range +-- These verify that tm2timestamp failures are handled by clamping to -infinity/+infinity + +-- Large month interval: 2 billion months (~166 million years) - chunk end exceeds max timestamp +SELECT * FROM calc_range('2025-01-15'::timestamptz, '2000000000 months'::interval); + +-- Large day interval: 2 billion days (~5.4 million years) - chunk end exceeds max timestamp +SELECT * FROM calc_range('2025-01-15'::timestamptz, '2000000000 days'::interval); + +-- Large month interval with ancient origin - chunk end exceeds max timestamp +SELECT * FROM calc_range('290000-01-15'::timestamptz, '200000000 months'::interval, '4000-01-01 BC'::timestamptz); + +-- Large day interval with future origin - chunk start precedes min timestamp +SELECT * FROM calc_range('4000-01-15 BC'::timestamptz, '100000000 days'::interval, '290000-01-01'::timestamptz); + +-- Test INT32 overflow in end_julian calculation: start_julian (~108M) + interval->day (~2.1B) > INT32_MAX +-- This triggers pg_add_s32_overflow in calc_day_chunk_range for end_julian +SELECT * FROM calc_range('290000-01-15'::timestamptz, '2100000000 days'::interval, '290000-01-01'::timestamptz); + +-- Test INT32 overflow in end_total_months_from_jan: total_months_from_jan + interval->month > INT32_MAX +-- This triggers pg_add_s32_overflow in calc_month_chunk_range for end_total_months_from_jan +-- Use origin in December so total_months_from_jan starts at 11, making 11 + 2147483647 overflow +SELECT * FROM calc_range('2025-01-15'::timestamptz, '2147483647 months'::interval, '2001-12-01'::timestamptz); + +-- Test sub-day interval overflow: ts_val - origin_val can overflow INT64 when timestamps are at opposite extremes +-- This triggers pg_sub_s64_overflow in calc_sub_day_chunk_range +\set ON_ERROR_STOP 0 +SELECT * FROM calc_range('4700-01-15 BC'::timestamptz, '1 hour'::interval, '294000-01-01'::timestamptz); +\set ON_ERROR_STOP 1 + +--------------------------------------------------------------- +-- C UNIT TESTS +-- Test saturating arithmetic and chunk range calculation +--------------------------------------------------------------- +\c :TEST_DBNAME :ROLE_SUPERUSER +SELECT test_chunk_range(); +SET ROLE :ROLE_DEFAULT_PERM_USER; + +--------------------------------------------------------------- +-- CLEANUP +--------------------------------------------------------------- + +DROP FUNCTION test_ranges(TIMESTAMPTZ[], INTERVAL[]); +DROP FUNCTION test_ranges_with_origin(TIMESTAMPTZ[], INTERVAL[], TIMESTAMPTZ); +DROP FUNCTION show_chunk_constraints(text); +DROP FUNCTION show_int_chunk_slices(text); +DROP FUNCTION show_check_constraints(text); +RESET timezone; +RESET timescaledb.enable_calendar_chunking; +\set VERBOSITY default diff --git a/test/sql/chunk_origin.sql b/test/sql/chunk_origin.sql new file mode 100644 index 00000000000..e064f87cab8 --- /dev/null +++ b/test/sql/chunk_origin.sql @@ -0,0 +1,617 @@ +-- This file and its contents are licensed under the Apache License 2.0. +-- Please see the included NOTICE for copyright information and +-- LICENSE-APACHE for a copy of the license. + +-- +-- Test chunk origin parameter functionality +-- +-- The origin parameter allows specifying a reference point for aligning +-- chunk boundaries. Chunks are aligned to the origin instead of Unix epoch. +-- + +\c :TEST_DBNAME :ROLE_SUPERUSER +SET ROLE :ROLE_DEFAULT_PERM_USER; +\set VERBOSITY terse + +SET timezone = 'UTC'; + +--------------------------------------------------------------- +-- ORIGIN WITH FIXED-SIZE CHUNKS (MICROSECONDS) +--------------------------------------------------------------- +-- Test that origin can be specified for fixed-size chunk alignment. + +-- Create hypertable with origin (chunks aligned to noon instead of midnight) +CREATE TABLE origin_noon(time timestamptz NOT NULL, value int); +SELECT create_hypertable('origin_noon', 'time', + chunk_time_interval => 86400000000, -- 1 day in microseconds + chunk_time_origin => '2020-01-01 12:00:00 UTC'::timestamptz); + +-- Verify origin is stored in catalog +SELECT d.column_name, + _timescaledb_functions.to_timestamp(d.interval_origin) as origin +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'origin_noon'; + +-- Verify dimensions view shows origin +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'origin_noon'; + +-- Insert data around the origin boundary (noon) +INSERT INTO origin_noon VALUES + ('2020-01-01 11:00:00 UTC', 1), -- Before origin, previous chunk + ('2020-01-01 12:00:00 UTC', 2), -- At origin, starts new chunk + ('2020-01-01 18:00:00 UTC', 3), -- Same chunk as origin + ('2020-01-02 11:59:59 UTC', 4), -- Still same chunk (ends at noon) + ('2020-01-02 12:00:00 UTC', 5); -- Next chunk starts at noon + +-- Verify chunks are aligned to noon (origin), not midnight +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'origin_noon' +ORDER BY range_start; + +-- Verify data is in correct chunks +SELECT tableoid::regclass as chunk, time, value +FROM origin_noon ORDER BY time; + +DROP TABLE origin_noon; + +--------------------------------------------------------------- +-- SET_CHUNK_TIME_INTERVAL WITH ORIGIN +--------------------------------------------------------------- +-- Test set_chunk_time_interval with origin parameter + +CREATE TABLE origin_update(time timestamptz NOT NULL, value int); +SELECT create_hypertable('origin_update', 'time', + chunk_time_interval => 86400000000); -- 1 day in microseconds + +-- Initially no origin +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'origin_update'; + +-- Update with origin (chunks start at 6:00) +SELECT set_chunk_time_interval('origin_update', 43200000000, -- 12 hours + chunk_time_origin => '2020-01-01 06:00:00 UTC'::timestamptz); + +-- Verify origin is now set +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'origin_update'; + +-- Insert data and verify chunks align to 6:00 +INSERT INTO origin_update VALUES + ('2020-01-01 05:00:00 UTC', 1), -- Before 6:00, previous chunk + ('2020-01-01 06:00:00 UTC', 2), -- At origin + ('2020-01-01 17:59:59 UTC', 3), -- Same chunk + ('2020-01-01 18:00:00 UTC', 4); -- Next chunk (6:00 + 12 hours) + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'origin_update' +ORDER BY range_start; + +DROP TABLE origin_update; + +--------------------------------------------------------------- +-- INTEGER COLUMNS WITH INTEGER ORIGIN (OLD API) +--------------------------------------------------------------- +-- Test integer columns with integer origin using create_hypertable old API + +-- Test smallint with origin (old API) +CREATE TABLE smallint_origin_old(id smallint NOT NULL, value int); +SELECT create_hypertable('smallint_origin_old', 'id', + chunk_time_interval => 100, + chunk_time_origin => 50); + +SELECT hypertable_name, integer_interval, integer_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'smallint_origin_old'; + +INSERT INTO smallint_origin_old VALUES (40, 1); -- chunk [-50, 50) +INSERT INTO smallint_origin_old VALUES (50, 2); -- chunk [50, 150) +INSERT INTO smallint_origin_old VALUES (150, 3); -- chunk [150, 250) + +SELECT c.table_name AS chunk_name, ds.range_start, ds.range_end +FROM _timescaledb_catalog.chunk c +JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id +JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id +WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = 'smallint_origin_old') +ORDER BY ds.range_start; + +DROP TABLE smallint_origin_old; + +-- Test int (int4) with origin (old API) +CREATE TABLE int4_origin_old(id int NOT NULL, value int); +SELECT create_hypertable('int4_origin_old', 'id', + chunk_time_interval => 1000, + chunk_time_origin => 500); + +SELECT hypertable_name, integer_interval, integer_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'int4_origin_old'; + +INSERT INTO int4_origin_old VALUES (400, 1); -- chunk [-500, 500) +INSERT INTO int4_origin_old VALUES (500, 2); -- chunk [500, 1500) +INSERT INTO int4_origin_old VALUES (1500, 3); -- chunk [1500, 2500) + +SELECT c.table_name AS chunk_name, ds.range_start, ds.range_end +FROM _timescaledb_catalog.chunk c +JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id +JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id +WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = 'int4_origin_old') +ORDER BY ds.range_start; + +DROP TABLE int4_origin_old; + +-- Test bigint with origin (old API) +CREATE TABLE int_origin(time bigint NOT NULL, value int); +SELECT create_hypertable('int_origin', 'time', + chunk_time_interval => 1000, + chunk_time_origin => 500); + +-- Verify dimensions view shows integer_origin (not time_origin) +SELECT hypertable_name, integer_interval, integer_origin, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'int_origin'; + +-- Insert data to verify chunk alignment relative to origin=500 +-- Chunks should be: [..., -500 to 500, 500 to 1500, 1500 to 2500, ...] +INSERT INTO int_origin VALUES (400, 1); -- chunk [-500, 500) +INSERT INTO int_origin VALUES (500, 2); -- chunk [500, 1500) +INSERT INTO int_origin VALUES (1200, 3); -- chunk [500, 1500) +INSERT INTO int_origin VALUES (1500, 4); -- chunk [1500, 2500) + +SELECT c.table_name AS chunk_name, ds.range_start, ds.range_end +FROM _timescaledb_catalog.chunk c +JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id +JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id +WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = 'int_origin') +ORDER BY ds.range_start; + +DROP TABLE int_origin; + +-- Test smallint columns with integer origin using by_range syntax +CREATE TABLE smallint_origin(id smallint NOT NULL, value int); +SELECT create_hypertable('smallint_origin', + by_range('id', 100, partition_origin => 50)); + +-- Verify dimensions view shows integer_origin +SELECT hypertable_name, integer_interval, integer_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'smallint_origin'; + +-- Insert data to verify chunk alignment relative to origin=50 +-- Chunks should be: [..., -50 to 50, 50 to 150, 150 to 250, ...] +INSERT INTO smallint_origin VALUES (40, 1); -- chunk [-50, 50) +INSERT INTO smallint_origin VALUES (50, 2); -- chunk [50, 150) +INSERT INTO smallint_origin VALUES (120, 3); -- chunk [50, 150) +INSERT INTO smallint_origin VALUES (150, 4); -- chunk [150, 250) + +SELECT c.table_name AS chunk_name, ds.range_start, ds.range_end +FROM _timescaledb_catalog.chunk c +JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id +JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id +WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = 'smallint_origin') +ORDER BY ds.range_start; + +DROP TABLE smallint_origin; + +-- Test int (int4) columns with integer origin using by_range syntax +CREATE TABLE int4_origin(id int NOT NULL, value int); +SELECT create_hypertable('int4_origin', + by_range('id', 1000, partition_origin => 500)); + +-- Verify dimensions view shows integer_origin +SELECT hypertable_name, integer_interval, integer_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'int4_origin'; + +-- Insert data to verify chunk alignment relative to origin=500 +-- Chunks should be: [..., -500 to 500, 500 to 1500, 1500 to 2500, ...] +INSERT INTO int4_origin VALUES (400, 1); -- chunk [-500, 500) +INSERT INTO int4_origin VALUES (500, 2); -- chunk [500, 1500) +INSERT INTO int4_origin VALUES (1200, 3); -- chunk [500, 1500) +INSERT INTO int4_origin VALUES (1500, 4); -- chunk [1500, 2500) + +SELECT c.table_name AS chunk_name, ds.range_start, ds.range_end +FROM _timescaledb_catalog.chunk c +JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id +JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id +WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = 'int4_origin') +ORDER BY ds.range_start; + +DROP TABLE int4_origin; + +-- Test bigint columns with integer origin using by_range syntax +CREATE TABLE bigint_origin(id bigint NOT NULL, value int); +SELECT create_hypertable('bigint_origin', + by_range('id', 10000, partition_origin => 5000)); + +-- Verify dimensions view shows integer_origin +SELECT hypertable_name, integer_interval, integer_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'bigint_origin'; + +-- Insert data to verify chunk alignment relative to origin=5000 +-- Chunks should be: [..., -5000 to 5000, 5000 to 15000, 15000 to 25000, ...] +INSERT INTO bigint_origin VALUES (4000, 1); -- chunk [-5000, 5000) +INSERT INTO bigint_origin VALUES (5000, 2); -- chunk [5000, 15000) +INSERT INTO bigint_origin VALUES (12000, 3); -- chunk [5000, 15000) +INSERT INTO bigint_origin VALUES (15000, 4); -- chunk [15000, 25000) + +SELECT c.table_name AS chunk_name, ds.range_start, ds.range_end +FROM _timescaledb_catalog.chunk c +JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id +JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id +WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = 'bigint_origin') +ORDER BY ds.range_start; + +DROP TABLE bigint_origin; + +--------------------------------------------------------------- +-- ADD_DIMENSION WITH ORIGIN +--------------------------------------------------------------- +-- Test add_dimension() with origin parameter (deprecated API) + +CREATE TABLE add_dim_origin(id int NOT NULL, time timestamptz NOT NULL, value int); +SELECT create_hypertable('add_dim_origin', 'id', chunk_time_interval => 100); +SELECT add_dimension('add_dim_origin', 'time', + chunk_time_interval => 86400000000, -- 1 day in microseconds + chunk_time_origin => '2024-04-01 00:00:00 UTC'::timestamptz); + +-- Verify the origin was stored correctly in the dimension +SELECT d.column_name, + _timescaledb_functions.to_timestamp(d.interval_origin) AS origin +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'add_dim_origin' AND d.column_name = 'time'; + +DROP TABLE add_dim_origin; + +--------------------------------------------------------------- +-- TYPE VALIDATION TESTS +--------------------------------------------------------------- +-- Test type validation: timestamp origin with integer column (should fail) + +\set ON_ERROR_STOP 0 +CREATE TABLE int_origin_ts_error(time bigint NOT NULL, value int); +SELECT create_hypertable('int_origin_ts_error', 'time', + chunk_time_interval => 1000, + chunk_time_origin => '2001-01-01'::timestamptz); +DROP TABLE IF EXISTS int_origin_ts_error; + +-- Test type validation: integer origin with timestamp column (should fail) +CREATE TABLE ts_origin_int_error(time timestamptz NOT NULL, value int); +SELECT create_hypertable('ts_origin_int_error', 'time', + chunk_time_interval => 86400000000, + chunk_time_origin => 0); +DROP TABLE IF EXISTS ts_origin_int_error; +\set ON_ERROR_STOP 1 + +--------------------------------------------------------------- +-- BY_RANGE WITH PARTITION_ORIGIN +--------------------------------------------------------------- +-- Test by_range() with partition_origin parameter + +CREATE TABLE by_range_origin(time timestamptz NOT NULL, value int); +SELECT create_hypertable('by_range_origin', + by_range('time', '1 day'::interval, partition_origin => '2020-01-01 06:00:00 UTC'::timestamptz)); + +-- Verify origin is stored correctly +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'by_range_origin'; + +-- Verify chunks align to 6:00 +INSERT INTO by_range_origin VALUES + ('2020-01-01 05:00:00 UTC', 1), -- Before 6:00, previous chunk + ('2020-01-01 06:00:00 UTC', 2), -- At origin + ('2020-01-02 05:59:59 UTC', 3), -- Same chunk (ends at 6:00) + ('2020-01-02 06:00:00 UTC', 4); -- Next chunk starts at 6:00 + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'by_range_origin' +ORDER BY range_start; + +DROP TABLE by_range_origin; + +--------------------------------------------------------------- +-- CREATE TABLE WITH (ORIGIN = ...) SYNTAX +--------------------------------------------------------------- +-- Test CREATE TABLE WITH syntax for origin parameter + +-- Test with timestamptz column +CREATE TABLE with_origin_ts(time timestamptz NOT NULL, value int) + WITH (tsdb.hypertable, tsdb.partition_column='time', + tsdb.chunk_interval='1 day', + tsdb.origin='2020-01-01 12:00:00 UTC'); + +-- Verify origin is stored correctly +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'with_origin_ts'; + +-- Insert data to verify chunk alignment to noon +INSERT INTO with_origin_ts VALUES + ('2020-01-01 11:00:00 UTC', 1), -- Before origin, previous chunk + ('2020-01-01 12:00:00 UTC', 2), -- At origin, starts new chunk + ('2020-01-02 11:59:59 UTC', 3), -- Same chunk (ends at noon) + ('2020-01-02 12:00:00 UTC', 4); -- Next chunk starts at noon + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'with_origin_ts' +ORDER BY range_start; + +DROP TABLE with_origin_ts; + +-- Test with partition_origin alias +CREATE TABLE with_partition_origin(time timestamptz NOT NULL, value int) + WITH (tsdb.hypertable, tsdb.partition_column='time', + tsdb.chunk_interval='12 hours', + tsdb.partition_origin='2020-01-01 06:00:00 UTC'); + +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'with_partition_origin'; + +DROP TABLE with_partition_origin; + +-- Test with integer column and origin +CREATE TABLE with_origin_int(id bigint NOT NULL, value int) + WITH (tsdb.hypertable, tsdb.partition_column='id', + tsdb.chunk_interval=1000, + tsdb.origin='500'); + +SELECT hypertable_name, integer_interval, integer_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'with_origin_int'; + +-- Verify chunks align to origin=500 +INSERT INTO with_origin_int VALUES (400, 1); -- chunk [-500, 500) +INSERT INTO with_origin_int VALUES (500, 2); -- chunk [500, 1500) +INSERT INTO with_origin_int VALUES (1500, 3); -- chunk [1500, 2500) + +SELECT c.table_name AS chunk_name, ds.range_start, ds.range_end +FROM _timescaledb_catalog.chunk c +JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id +JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id +WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = 'with_origin_int') +ORDER BY ds.range_start; + +DROP TABLE with_origin_int; + +-- Test with smallint column and origin +CREATE TABLE with_origin_smallint(id smallint NOT NULL, value int) + WITH (tsdb.hypertable, tsdb.partition_column='id', + tsdb.chunk_interval='100', + tsdb.origin='50'); + +SELECT hypertable_name, integer_interval, integer_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'with_origin_smallint'; + +INSERT INTO with_origin_smallint VALUES (40, 1); -- chunk [-50, 50) +INSERT INTO with_origin_smallint VALUES (50, 2); -- chunk [50, 150) + +SELECT c.table_name AS chunk_name, ds.range_start, ds.range_end +FROM _timescaledb_catalog.chunk c +JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id +JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id +WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = 'with_origin_smallint') +ORDER BY ds.range_start; + +DROP TABLE with_origin_smallint; + +-- Test with int column and origin +CREATE TABLE with_origin_int4(id int NOT NULL, value int) + WITH (tsdb.hypertable, tsdb.partition_column='id', + tsdb.chunk_interval='1000', + tsdb.origin='500'); + +SELECT hypertable_name, integer_interval, integer_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'with_origin_int4'; + +INSERT INTO with_origin_int4 VALUES (400, 1); -- chunk [-500, 500) +INSERT INTO with_origin_int4 VALUES (500, 2); -- chunk [500, 1500) + +SELECT c.table_name AS chunk_name, ds.range_start, ds.range_end +FROM _timescaledb_catalog.chunk c +JOIN _timescaledb_catalog.chunk_constraint cc ON c.id = cc.chunk_id +JOIN _timescaledb_catalog.dimension_slice ds ON cc.dimension_slice_id = ds.id +WHERE c.hypertable_id = (SELECT id FROM _timescaledb_catalog.hypertable WHERE table_name = 'with_origin_int4') +ORDER BY ds.range_start; + +DROP TABLE with_origin_int4; + +-- Test with timestamp (without timezone) column and origin +CREATE TABLE with_origin_timestamp(time timestamp NOT NULL, value int) + WITH (tsdb.hypertable, tsdb.partition_column='time', + tsdb.chunk_interval='1 day', + tsdb.origin='2020-01-01 12:00:00'); + +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'with_origin_timestamp'; + +INSERT INTO with_origin_timestamp VALUES + ('2020-01-01 11:00:00', 1), -- Before origin, previous chunk + ('2020-01-01 12:00:00', 2), -- At origin, starts new chunk + ('2020-01-02 11:59:59', 3); -- Same chunk + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'with_origin_timestamp' +ORDER BY range_start; + +DROP TABLE with_origin_timestamp; + +-- Test with date column and origin +CREATE TABLE with_origin_date(day date NOT NULL, value int) + WITH (tsdb.hypertable, tsdb.partition_column='day', + tsdb.chunk_interval='7 days', + tsdb.origin='2020-01-01'); + +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'with_origin_date'; + +INSERT INTO with_origin_date VALUES + ('2019-12-30', 1), -- Before origin + ('2020-01-01', 2), -- At origin + ('2020-01-07', 3); -- Next week + +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'with_origin_date' +ORDER BY range_start; + +DROP TABLE with_origin_date; + +-- Test with chunk_origin alias (alternative name) +CREATE TABLE with_chunk_origin(time timestamptz NOT NULL, value int) + WITH (tsdb.hypertable, tsdb.partition_column='time', + tsdb.chunk_interval='1 day', + tsdb.chunk_origin='2020-01-01 06:00:00 UTC'); + +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'with_chunk_origin'; + +DROP TABLE with_chunk_origin; + +--------------------------------------------------------------- +-- SET_CHUNK_TIME_INTERVAL: PRESERVE ORIGIN WHEN NOT SPECIFIED +--------------------------------------------------------------- +-- Test that a custom origin is preserved when calling set_chunk_time_interval +-- with a new interval but without specifying an origin (NULL origin). +-- The existing origin should NOT be reset to default. + +CREATE TABLE preserve_origin(time timestamptz NOT NULL, value int); +SELECT create_hypertable('preserve_origin', 'time', + chunk_time_interval => 86400000000, -- 1 day in microseconds + chunk_time_origin => '2020-01-01 06:00:00 UTC'::timestamptz); + +-- Verify custom origin is set to 6:00 +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'preserve_origin'; + +-- Create a chunk with the custom origin +INSERT INTO preserve_origin VALUES ('2020-01-01 12:00:00 UTC', 1); + +-- Verify chunk is aligned to 6:00 (not midnight) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'preserve_origin' +ORDER BY range_start; + +-- Now change only the interval, NOT specifying origin (should preserve 6:00 origin) +SELECT set_chunk_time_interval('preserve_origin', 43200000000); -- 12 hours, no origin + +-- Verify origin is still 6:00 (should NOT be reset to midnight/default) +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'preserve_origin'; + +-- Insert more data to create new chunks with new interval +INSERT INTO preserve_origin VALUES ('2020-01-02 12:00:00 UTC', 2); + +-- Verify new chunks still align to 6:00 origin (not midnight) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'preserve_origin' +ORDER BY range_start; + +DROP TABLE preserve_origin; + +--------------------------------------------------------------- +-- SET_CHUNK_TIME_INTERVAL: PRESERVE ORIGIN WITH CALENDAR CHUNKING +--------------------------------------------------------------- +-- Test origin preservation with calendar-based chunking (INTERVAL type) + +SET timescaledb.enable_calendar_chunking = true; + +CREATE TABLE preserve_origin_calendar(time timestamptz NOT NULL, value int); +SELECT create_hypertable('preserve_origin_calendar', 'time', + chunk_time_interval => interval '1 day', + chunk_time_origin => '2020-01-01 06:00:00 UTC'::timestamptz); + +-- Verify custom origin is set to 6:00 +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'preserve_origin_calendar'; + +-- Create a chunk with the custom origin +INSERT INTO preserve_origin_calendar VALUES ('2020-01-01 12:00:00 UTC', 1); + +-- Verify chunk is aligned to 6:00 +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'preserve_origin_calendar' +ORDER BY range_start; + +-- Now change only the interval, NOT specifying origin (should preserve 6:00 origin) +SELECT set_chunk_time_interval('preserve_origin_calendar', interval '12 hours'); + +-- Verify origin is still 6:00 (should NOT be reset to default midnight) +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'preserve_origin_calendar'; + +-- Insert more data to create new chunks with new interval +INSERT INTO preserve_origin_calendar VALUES ('2020-01-02 12:00:00 UTC', 2); + +-- Verify new chunks still align to 6:00 origin +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'preserve_origin_calendar' +ORDER BY range_start; + +DROP TABLE preserve_origin_calendar; + +RESET timescaledb.enable_calendar_chunking; + +--------------------------------------------------------------- +-- SET_CHUNK_TIME_INTERVAL: CHANGE ONLY ORIGIN (NULL INTERVAL) +--------------------------------------------------------------- +-- Test that origin can be changed without re-specifying the interval. +-- Currently this errors - test to document the behavior. + +CREATE TABLE change_origin_only(time timestamptz NOT NULL, value int); +SELECT create_hypertable('change_origin_only', 'time', + chunk_time_interval => 86400000000); -- 1 day in microseconds + +-- Verify initial state (default origin) +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'change_origin_only'; + +-- Try to change only the origin without specifying interval +-- This errors due to PostgreSQL polymorphic type inference with NULL +\set ON_ERROR_STOP 0 +SELECT set_chunk_time_interval('change_origin_only', NULL, + chunk_time_origin => '2020-01-01 06:00:00 UTC'::timestamptz); + +-- Even with explicit cast, NULL interval is rejected +SELECT set_chunk_time_interval('change_origin_only', NULL::bigint, + chunk_time_origin => '2020-01-01 06:00:00 UTC'::timestamptz); +\set ON_ERROR_STOP 1 + +-- Verify origin (check if it changed or remained the same) +SELECT hypertable_name, time_interval, time_origin +FROM timescaledb_information.dimensions +WHERE hypertable_name = 'change_origin_only'; + +DROP TABLE change_origin_only; + +--------------------------------------------------------------- +-- CLEANUP +--------------------------------------------------------------- +RESET timezone; +\set VERBOSITY default diff --git a/test/src/CMakeLists.txt b/test/src/CMakeLists.txt index 62e4d0dcbbf..ea5f83dabfd 100644 --- a/test/src/CMakeLists.txt +++ b/test/src/CMakeLists.txt @@ -2,6 +2,7 @@ set(SOURCES adt_tests.c metadata.c symbol_conflict.c + test_chunk_range.c test_scanner.c test_time_to_internal.c test_time_utils.c diff --git a/test/src/test_chunk_range.c b/test/src/test_chunk_range.c new file mode 100644 index 00000000000..8ecab1c796f --- /dev/null +++ b/test/src/test_chunk_range.c @@ -0,0 +1,481 @@ +/* + * This file and its contents are licensed under the Apache License 2.0. + * Please see the included NOTICE for copyright information and + * LICENSE-APACHE for a copy of the license. + */ +#include +#include +#include +#include +#include + +#include "chunk_range.h" +#include "test_utils.h" + +/* Unix epoch as PostgreSQL TimestampTz */ +#define UNIX_EPOCH_TIMESTAMPTZ ((UNIX_EPOCH_JDATE - POSTGRES_EPOCH_JDATE) * USECS_PER_DAY) + +/* + * Test saturating timestamp arithmetic with various overflow scenarios. + */ +static void +test_saturating_arithmetic(void) +{ + TimestampTz result; + bool success; + Interval interval; + + pg_tz *utc_tz = pg_tzset("UTC"); + TestAssertTrue(utc_tz != NULL); + + pg_tz *timezones[] = { NULL, utc_tz }; + + /* + * INFINITY INPUT TESTS + * The function returns immediately for infinite timestamps, + * so we only need to verify each infinity is preserved once. + */ + memset(&interval, 0, sizeof(Interval)); + interval.month = 1; + + success = ts_test_timestamp_interval_add_saturating(DT_NOEND, &interval, NULL, &result); + TestAssertTrue(success); + TestAssertInt64Eq(result, DT_NOEND); + + success = ts_test_timestamp_interval_add_saturating(DT_NOBEGIN, &interval, NULL, &result); + TestAssertTrue(success); + TestAssertInt64Eq(result, DT_NOBEGIN); + + /* + * OVERFLOW TESTS + * Test all overflow cases × both timezones + */ + TimestampTz ts_2020 = 20 * 365 * USECS_PER_DAY; /* ~2020 from PG epoch */ + + /* Test each overflow code path: month, day, time × positive, negative */ + struct + { + int32 month; + int32 day; + int64 time; + TimestampTz expected; + } overflow_cases[] = { + { INT32_MAX, 0, 0, DT_NOEND }, /* month overflow */ + { INT32_MIN, 0, 0, DT_NOBEGIN }, { 0, INT32_MAX, 0, DT_NOEND }, /* day overflow */ + { 0, INT32_MIN, 0, DT_NOBEGIN }, { 0, 0, INT64_MAX, DT_NOEND }, /* time overflow */ + { 0, 0, INT64_MIN, DT_NOBEGIN }, + }; + + for (size_t tz = 0; tz < lengthof(timezones); tz++) + { + for (size_t i = 0; i < lengthof(overflow_cases); i++) + { + memset(&interval, 0, sizeof(Interval)); + interval.month = overflow_cases[i].month; + interval.day = overflow_cases[i].day; + interval.time = overflow_cases[i].time; + success = ts_test_timestamp_interval_add_saturating(ts_2020, + &interval, + timezones[tz], + &result); + TestAssertTrue(!success); + TestAssertInt64Eq(result, overflow_cases[i].expected); + } + } + + /* + * INVALID INPUT TIMESTAMP TESTS + * Test timestamp2tm failure path - input outside valid range but not infinity + */ + TimestampTz invalid_high = END_TIMESTAMP + 1; + TimestampTz invalid_low = MIN_TIMESTAMP - 1; + + for (size_t tz = 0; tz < lengthof(timezones); tz++) + { + memset(&interval, 0, sizeof(Interval)); + interval.day = 1; + + /* High invalid timestamp should saturate to DT_NOEND */ + success = ts_test_timestamp_interval_add_saturating(invalid_high, + &interval, + timezones[tz], + &result); + TestAssertTrue(!success); + TestAssertInt64Eq(result, DT_NOEND); + + /* Low invalid timestamp should saturate to DT_NOBEGIN */ + success = ts_test_timestamp_interval_add_saturating(invalid_low, + &interval, + timezones[tz], + &result); + TestAssertTrue(!success); + TestAssertInt64Eq(result, DT_NOBEGIN); + } + + /* + * tm2timestamp FAILURE TESTS + * Test when adding months/days produces year outside valid range (~294276 AD) + * but doesn't trigger INT32_MAX year overflow check + */ + for (size_t tz = 0; tz < lengthof(timezones); tz++) + { + /* Add months to push year past valid range (without triggering INT32_MAX check) */ + memset(&interval, 0, sizeof(Interval)); + interval.month = 12 * 300000; /* ~300,000 years worth of months */ + + success = + ts_test_timestamp_interval_add_saturating(ts_2020, &interval, timezones[tz], &result); + TestAssertTrue(!success); + TestAssertInt64Eq(result, DT_NOEND); + + /* Negative months to push year before valid range */ + memset(&interval, 0, sizeof(Interval)); + interval.month = -12 * 300000; + + success = + ts_test_timestamp_interval_add_saturating(ts_2020, &interval, timezones[tz], &result); + TestAssertTrue(!success); + TestAssertInt64Eq(result, DT_NOBEGIN); + } + + /* + * FINAL RANGE CHECK TESTS + * Test IS_VALID_TIMESTAMP failure - result outside valid range after time addition + */ + for (size_t tz = 0; tz < lengthof(timezones); tz++) + { + memset(&interval, 0, sizeof(Interval)); + interval.time = USECS_PER_DAY * 10; /* Add 10 days worth of microseconds */ + + /* Start near END_TIMESTAMP, push past it with time addition */ + success = ts_test_timestamp_interval_add_saturating(END_TIMESTAMP - USECS_PER_DAY, + &interval, + timezones[tz], + &result); + TestAssertTrue(!success); + TestAssertInt64Eq(result, DT_NOEND); + + /* Start near MIN_TIMESTAMP, push below it with negative time */ + memset(&interval, 0, sizeof(Interval)); + interval.time = -USECS_PER_DAY * 10; + success = ts_test_timestamp_interval_add_saturating(MIN_TIMESTAMP + USECS_PER_DAY, + &interval, + timezones[tz], + &result); + TestAssertTrue(!success); + TestAssertInt64Eq(result, DT_NOBEGIN); + } +} + +/* + * Test ts_chunk_range_calculate_general edge cases. + * Normal functionality is covered by SQL regression tests in calendar_chunking.sql. + */ +static void +test_chunk_range_general(void) +{ + ChunkRange range; + int64 iterations; + + TimestampTz origin = 20 * 365 * USECS_PER_DAY; /* ~2020 from PG epoch */ + + /* + * JUMP OPTIMIZATION - Extreme timestamps should use few iterations + */ + Interval mixed = { .time = 0, .day = 15, .month = 1 }; + TimestampTz extreme[] = { origin + USECS_PER_DAY * 36500, origin - USECS_PER_DAY * 36500 }; + + for (size_t i = 0; i < lengthof(extreme); i++) + { + range = ts_chunk_range_calculate_general(TimestampTzGetDatum(extreme[i]), + TIMESTAMPTZOID, + &mixed, + TimestampTzGetDatum(origin), + &iterations); + TestAssertTrue(DatumGetTimestampTz(range.range_start) <= extreme[i]); + TestAssertTrue(DatumGetTimestampTz(range.range_end) > extreme[i]); + TestAssertTrue(iterations < 1000); + } + + /* + * OVERFLOW TO INFINITY TESTS + * Test forward iteration overflow (range_end → DT_NOEND) + * and backward iteration overflow (bucket_start → DT_NOBEGIN) + */ + Interval large_interval = { .time = 0, .day = 0, .month = 12 * 1000 }; /* 1000 years */ + + /* Forward: timestamp near END_TIMESTAMP should produce range_end = DT_NOEND */ + range = + ts_chunk_range_calculate_general(TimestampTzGetDatum(END_TIMESTAMP - USECS_PER_DAY), + TIMESTAMPTZOID, + &large_interval, + TimestampTzGetDatum(END_TIMESTAMP - USECS_PER_DAY * 365), + &iterations); + TestAssertTrue(range.type == TIMESTAMPTZOID); + TestAssertInt64Eq(DatumGetTimestampTz(range.range_end), DT_NOEND); + + /* Backward: timestamp near MIN_TIMESTAMP should produce range_start = DT_NOBEGIN */ + range = + ts_chunk_range_calculate_general(TimestampTzGetDatum(MIN_TIMESTAMP + USECS_PER_DAY), + TIMESTAMPTZOID, + &large_interval, + TimestampTzGetDatum(MIN_TIMESTAMP + USECS_PER_DAY * 365), + &iterations); + TestAssertTrue(range.type == TIMESTAMPTZOID); + TestAssertInt64Eq(DatumGetTimestampTz(range.range_start), DT_NOBEGIN); + + /* + * JUMP OPTIMIZATION DEFENSIVE PATHS + * Test scaled interval overflow and jump saturation + */ + + /* + * Scaled interval overflow: timestamp very far from origin causes + * jump_intervals to be huge, making scaled_month overflow INT32. + * With 1-month interval and ~200k years distance, jump_intervals ≈ 2.4M + * and scaled_month = 1 * 2.4M overflows when multiplied further. + */ + Interval small_interval = { .time = 0, .day = 0, .month = 1 }; + TimestampTz very_far = END_TIMESTAMP - USECS_PER_DAY * 100; /* Near end */ + TimestampTz very_early_origin = MIN_TIMESTAMP + USECS_PER_DAY * 100; /* Near start */ + + /* This should still work - jump optimization handles overflow gracefully */ + range = ts_chunk_range_calculate_general(TimestampTzGetDatum(very_far), + TIMESTAMPTZOID, + &small_interval, + TimestampTzGetDatum(very_early_origin), + &iterations); + TestAssertTrue(range.type == TIMESTAMPTZOID); + TestAssertTrue(DatumGetTimestampTz(range.range_start) <= very_far); + TestAssertTrue(DatumGetTimestampTz(range.range_end) > very_far); + + /* Backward version */ + range = ts_chunk_range_calculate_general(TimestampTzGetDatum(very_early_origin + USECS_PER_DAY), + TIMESTAMPTZOID, + &small_interval, + TimestampTzGetDatum(very_far), + &iterations); + TestAssertTrue(range.type == TIMESTAMPTZOID); + TestAssertTrue(DatumGetTimestampTz(range.range_start) <= very_early_origin + USECS_PER_DAY); +} + +/* + * Test ts_chunk_range_calculate overflow cases for calendar-aligned intervals. + * + * These test the overflow handling in calc_month_chunk_range, calc_day_chunk_range, + * and calc_sub_day_chunk_range which use saturating arithmetic and clamp out-of-range + * results to DT_NOBEGIN/DT_NOEND. + */ +static void +test_chunk_range_overflow(void) +{ + ChunkRange range; + Oid types[] = { TIMESTAMPTZOID, TIMESTAMPOID }; + + /* + * MONTH INTERVAL OVERFLOW TESTS + * + * Test calc_month_chunk_range overflow paths: + * - Result year exceeds valid timestamp range → DT_NOBEGIN/DT_NOEND + * + * PostgreSQL valid timestamp range: 4713 BC to 294276 AD + * (year -4713 to 294276 in pg_tm terms) + */ + for (size_t t = 0; t < lengthof(types); t++) + { + Interval month_interval = { .time = 0, .day = 0, .month = 1 }; + + /* + * Forward overflow: timestamp near END_TIMESTAMP with origin far in past + * causes range_end to overflow → DT_NOEND + */ + TimestampTz ts_near_end = END_TIMESTAMP - USECS_PER_DAY * 30; + TimestampTz origin_far_past = 0; /* PG epoch 2000-01-01 */ + + range = ts_chunk_range_calculate(TimestampTzGetDatum(ts_near_end), + types[t], + &month_interval, + TimestampTzGetDatum(origin_far_past)); + TestAssertTrue(range.type == types[t]); + TestAssertTrue(DatumGetTimestampTz(range.range_start) <= ts_near_end); + /* range_end should be DT_NOEND since adding 1 month to a date near END_TIMESTAMP overflows + */ + TestAssertInt64Eq(DatumGetTimestampTz(range.range_end), DT_NOEND); + + /* + * Backward case: timestamp near MIN_TIMESTAMP. + * Tests the backward month calculation path. + * range_start should be <= ts and range_end > ts (bucket contains timestamp) + * If bucket start would be before MIN_TIMESTAMP, it gets clamped to DT_NOBEGIN. + */ + TimestampTz ts_near_begin = MIN_TIMESTAMP + USECS_PER_DAY; + TimestampTz origin_slightly_after = MIN_TIMESTAMP + USECS_PER_DAY * 15; + + range = ts_chunk_range_calculate(TimestampTzGetDatum(ts_near_begin), + types[t], + &month_interval, + TimestampTzGetDatum(origin_slightly_after)); + TestAssertTrue(range.type == types[t]); + /* Bucket must contain the timestamp */ + TestAssertTrue(DatumGetTimestampTz(range.range_start) <= ts_near_begin); + TestAssertTrue(DatumGetTimestampTz(range.range_end) > ts_near_begin); + + /* + * Test bucket_num * interval->month overflow path. + * With a 1000-year interval near END_TIMESTAMP (~294276 AD), adding 1000 years + * to find range_end would exceed the valid timestamp range → DT_NOEND. + */ + Interval large_month_interval = { .time = 0, .day = 0, .month = 12000 }; /* 1000 years */ + TimestampTz ts_far_future = END_TIMESTAMP - USECS_PER_DAY * 365; + TimestampTz origin_far_past2 = MIN_TIMESTAMP + USECS_PER_DAY * 365; + + range = ts_chunk_range_calculate(TimestampTzGetDatum(ts_far_future), + types[t], + &large_month_interval, + TimestampTzGetDatum(origin_far_past2)); + TestAssertTrue(range.type == types[t]); + TestAssertTrue(DatumGetTimestampTz(range.range_start) <= ts_far_future); + /* Adding 1000 years to year ~294276 overflows → DT_NOEND */ + TestAssertInt64Eq(DatumGetTimestampTz(range.range_end), DT_NOEND); + + /* + * Test backward case with large interval - origin after timestamp. + * Timestamp near MIN_TIMESTAMP (~4713 BC), origin near END_TIMESTAMP (~294276 AD). + * Going back ~300 buckets of 1000 years each underflows → DT_NOBEGIN. + */ + range = ts_chunk_range_calculate(TimestampTzGetDatum(origin_far_past2), + types[t], + &large_month_interval, + TimestampTzGetDatum(ts_far_future)); + TestAssertTrue(range.type == types[t]); + /* Subtracting ~300,000 years from year ~294276 underflows → DT_NOBEGIN */ + TestAssertInt64Eq(DatumGetTimestampTz(range.range_start), DT_NOBEGIN); + TestAssertTrue(DatumGetTimestampTz(range.range_end) > origin_far_past2); + } + + /* + * DAY INTERVAL OVERFLOW TESTS + * + * Test calc_day_chunk_range overflow paths: + * - Result date exceeds valid timestamp range → DT_NOBEGIN/DT_NOEND + */ + for (size_t t = 0; t < lengthof(types); t++) + { + Interval day_interval = { .time = 0, .day = 1, .month = 0 }; + + /* + * Forward overflow: timestamp near END_TIMESTAMP + * Adding 1 day to range_start near end should overflow → DT_NOEND + */ + TimestampTz ts_near_end = END_TIMESTAMP - USECS_PER_HOUR; + TimestampTz origin = 0; + + range = ts_chunk_range_calculate(TimestampTzGetDatum(ts_near_end), + types[t], + &day_interval, + TimestampTzGetDatum(origin)); + TestAssertTrue(range.type == types[t]); + TestAssertTrue(DatumGetTimestampTz(range.range_start) <= ts_near_end); + TestAssertInt64Eq(DatumGetTimestampTz(range.range_end), DT_NOEND); + + /* + * Backward case: timestamp near MIN_TIMESTAMP. + * Tests the backward day calculation path. + * If bucket start would be before MIN_TIMESTAMP, it gets clamped to DT_NOBEGIN. + */ + TimestampTz ts_near_begin = MIN_TIMESTAMP + USECS_PER_HOUR; + TimestampTz origin_slightly_after = MIN_TIMESTAMP + USECS_PER_DAY * 2; + + range = ts_chunk_range_calculate(TimestampTzGetDatum(ts_near_begin), + types[t], + &day_interval, + TimestampTzGetDatum(origin_slightly_after)); + TestAssertTrue(range.type == types[t]); + /* Bucket must contain the timestamp */ + TestAssertTrue(DatumGetTimestampTz(range.range_start) <= ts_near_begin); + TestAssertTrue(DatumGetTimestampTz(range.range_end) > ts_near_begin); + + /* + * Test bucket_num * interval->day overflow path. + * With a 100-year day interval near END_TIMESTAMP (~294276 AD), adding 100 years + * to find range_end would exceed the valid timestamp range → DT_NOEND. + */ + Interval large_day_interval = { .time = 0, .day = 36500, .month = 0 }; /* ~100 years */ + TimestampTz ts_far_future = END_TIMESTAMP - USECS_PER_DAY * 365; + TimestampTz origin_far_past = MIN_TIMESTAMP + USECS_PER_DAY * 365; + + range = ts_chunk_range_calculate(TimestampTzGetDatum(ts_far_future), + types[t], + &large_day_interval, + TimestampTzGetDatum(origin_far_past)); + TestAssertTrue(range.type == types[t]); + TestAssertTrue(DatumGetTimestampTz(range.range_start) <= ts_far_future); + /* Adding ~100 years to year ~294276 overflows → DT_NOEND */ + TestAssertInt64Eq(DatumGetTimestampTz(range.range_end), DT_NOEND); + + /* + * Test backward case with large interval - exercises negative bucket_num path. + * Timestamp near MIN_TIMESTAMP (~4713 BC), origin near END_TIMESTAMP (~294276 AD). + * Going back ~3000 buckets of 100 years each underflows → DT_NOBEGIN. + */ + range = ts_chunk_range_calculate(TimestampTzGetDatum(origin_far_past), + types[t], + &large_day_interval, + TimestampTzGetDatum(ts_far_future)); + TestAssertTrue(range.type == types[t]); + /* Subtracting ~300,000 years from year ~294276 underflows → DT_NOBEGIN */ + TestAssertInt64Eq(DatumGetTimestampTz(range.range_start), DT_NOBEGIN); + TestAssertTrue(DatumGetTimestampTz(range.range_end) > origin_far_past); + + /* + * Test with very large day interval (1000 years) near END_TIMESTAMP. + * Adding 1000 years to year ~294276 overflows → DT_NOEND. + */ + Interval very_large_day = { .time = 0, .day = 365000, .month = 0 }; /* ~1000 years */ + range = ts_chunk_range_calculate(TimestampTzGetDatum(ts_far_future), + types[t], + &very_large_day, + TimestampTzGetDatum(origin_far_past)); + TestAssertTrue(range.type == types[t]); + TestAssertTrue(DatumGetTimestampTz(range.range_start) <= ts_far_future); + TestAssertInt64Eq(DatumGetTimestampTz(range.range_end), DT_NOEND); + } + + /* + * SUB-DAY (TIME) INTERVAL OVERFLOW TESTS + * + * Test calc_sub_day_chunk_range overflow path: + * - range_end = result + interval->time overflows → DT_NOEND + */ + for (size_t t = 0; t < lengthof(types); t++) + { + Interval time_interval = { .time = USECS_PER_HOUR, .day = 0, .month = 0 }; + + /* + * Forward overflow: timestamp near END_TIMESTAMP + * Adding 1 hour to range_start should overflow → DT_NOEND + */ + TimestampTz ts_near_end = END_TIMESTAMP - USECS_PER_HOUR / 2; + TimestampTz origin = 0; + + range = ts_chunk_range_calculate(TimestampTzGetDatum(ts_near_end), + types[t], + &time_interval, + TimestampTzGetDatum(origin)); + TestAssertTrue(range.type == types[t]); + TestAssertTrue(DatumGetTimestampTz(range.range_start) <= ts_near_end); + TestAssertInt64Eq(DatumGetTimestampTz(range.range_end), DT_NOEND); + } +} + +/* + * Main entry point for chunk_range unit tests. + */ +TS_TEST_FN(ts_test_chunk_range) +{ + test_saturating_arithmetic(); + test_chunk_range_general(); + test_chunk_range_overflow(); + + PG_RETURN_VOID(); +} diff --git a/tsl/src/compression/create.c b/tsl/src/compression/create.c index fd20a1b8b35..a5a3168cc1e 100644 --- a/tsl/src/compression/create.c +++ b/tsl/src/compression/create.c @@ -901,7 +901,12 @@ update_compress_chunk_time_interval(Hypertable *ht, WithClauseResult *with_claus } int64 compress_interval_usec = ts_interval_value_to_internal(IntervalPGetDatum(compress_interval), INTERVALOID); - if (compress_interval_usec % time_dim->fd.interval_length > 0) + /* + * For non-calendar chunking, warn if compress interval is not a multiple of chunk interval. + * Skip this check for calendar chunking since interval_length is 0 and chunk sizes vary. + */ + if (!IS_CALENDAR_CHUNKING(time_dim) && time_dim->fd.interval_length > 0 && + compress_interval_usec % time_dim->fd.interval_length > 0) elog(WARNING, "compress chunk interval is not a multiple of chunk interval, you should use a " "factor of chunk interval to merge as much as possible"); diff --git a/tsl/src/continuous_aggs/common.c b/tsl/src/continuous_aggs/common.c index 82a86914c51..dbb700cf6e7 100644 --- a/tsl/src/continuous_aggs/common.c +++ b/tsl/src/continuous_aggs/common.c @@ -16,7 +16,7 @@ static Const *check_time_bucket_argument(Node *arg, char *position, bool process static void caggtimebucketinfo_init(ContinuousAggTimeBucketInfo *src, int32 hypertable_id, Oid hypertable_oid, AttrNumber hypertable_partition_colno, Oid hypertable_partition_coltype, - int64 hypertable_partition_col_interval, + const ChunkInterval *chunk_interval, int32 parent_mat_hypertable_id); static void process_additional_timebucket_parameter(ContinuousAggBucketFunction *bf, Const *arg, bool *custom_origin); @@ -65,7 +65,7 @@ check_time_bucket_argument(Node *arg, char *position, bool process_checks) static void caggtimebucketinfo_init(ContinuousAggTimeBucketInfo *src, int32 hypertable_id, Oid hypertable_oid, AttrNumber hypertable_partition_colno, Oid hypertable_partition_coltype, - int64 hypertable_partition_col_interval, int32 parent_mat_hypertable_id) + const ChunkInterval *chunk_interval, int32 parent_mat_hypertable_id) { src->htid = hypertable_id; src->parent_mat_hypertable_id = parent_mat_hypertable_id; @@ -73,7 +73,7 @@ caggtimebucketinfo_init(ContinuousAggTimeBucketInfo *src, int32 hypertable_id, O src->htoidparent = InvalidOid; src->htpartcolno = hypertable_partition_colno; src->htpartcoltype = hypertable_partition_coltype; - src->htpartcol_interval_len = hypertable_partition_col_interval; + src->htpartcol_interval = *chunk_interval; /* Initialize bucket function data structure */ src->bf = palloc0(sizeof(ContinuousAggBucketFunction)); @@ -888,7 +888,7 @@ cagg_validate_query(const Query *query, const char *cagg_schema, const char *cag ht->main_table_relid, part_dimension->column_attno, part_dimension->fd.column_type, - part_dimension->fd.interval_length, + &part_dimension->chunk_interval, parent_mat_hypertable_id); if (is_hierarchical) @@ -900,7 +900,7 @@ cagg_validate_query(const Query *query, const char *cagg_schema, const char *cag ht_parent->main_table_relid, part_dimension_parent->column_attno, part_dimension_parent->fd.column_type, - part_dimension_parent->fd.interval_length, + &part_dimension_parent->chunk_interval, INVALID_HYPERTABLE_ID); } diff --git a/tsl/src/continuous_aggs/common.h b/tsl/src/continuous_aggs/common.h index acd1f038536..6519802814f 100644 --- a/tsl/src/continuous_aggs/common.h +++ b/tsl/src/continuous_aggs/common.h @@ -29,6 +29,7 @@ #include #include +#include "dimension.h" #include "errors.h" #include "func_cache.h" #include "hypertable_cache.h" @@ -62,14 +63,14 @@ typedef struct MaterializationHypertableColumnInfo typedef struct ContinuousAggTimeBucketInfo { - int32 htid; /* hypertable id */ - int32 parent_mat_hypertable_id; /* parent materialization hypertable id */ - Oid htoid; /* hypertable oid */ - Oid htoidparent; /* parent hypertable oid in case of hierarchical */ - AttrNumber htpartcolno; /* primary partitioning column of raw hypertable */ - /* This should also be the column used by time_bucket */ - Oid htpartcoltype; /* The collation type */ - int64 htpartcol_interval_len; /* interval length setting for primary partitioning column */ + int32 htid; /* hypertable id */ + int32 parent_mat_hypertable_id; /* parent materialization hypertable id */ + Oid htoid; /* hypertable oid */ + Oid htoidparent; /* parent hypertable oid in case of hierarchical */ + AttrNumber htpartcolno; /* primary partitioning column of raw hypertable */ + /* This should also be the column used by time_bucket */ + Oid htpartcoltype; /* The collation type */ + ChunkInterval htpartcol_interval; /* chunk interval for primary partitioning column */ /* General bucket information */ ContinuousAggBucketFunction *bf; diff --git a/tsl/src/continuous_aggs/create.c b/tsl/src/continuous_aggs/create.c index f5e7b46e2f0..441acde6b05 100644 --- a/tsl/src/continuous_aggs/create.c +++ b/tsl/src/continuous_aggs/create.c @@ -48,6 +48,7 @@ #include #include #include +#include #include #include #include @@ -93,7 +94,7 @@ static void create_bucket_function_catalog_entry(int32 matht_id, Oid bucket_func const char *offset, const char *timezone, const bool bucket_fixed_width); static void cagg_create_hypertable(int32 hypertable_id, Oid mat_tbloid, const char *matpartcolname, - int64 mat_tbltimecol_interval); + const ChunkInterval *chunk_interval); static void mattablecolumninfo_add_mattable_index(MaterializationHypertableColumnInfo *matcolinfo, Hypertable *ht); static ObjectAddress create_view_for_query(Query *selquery, RangeVar *viewrel); @@ -119,7 +120,7 @@ makeMaterializedTableName(char *buf, const char *prefix, int hypertable_id) static int32 mattablecolumninfo_create_materialization_table( MaterializationHypertableColumnInfo *matcolinfo, int32 hypertable_id, RangeVar *mat_rel, ContinuousAggTimeBucketInfo *bucket_info, bool create_addl_index, char *tablespacename, - char *table_access_method, int64 matpartcol_interval, ObjectAddress *mataddress); + char *table_access_method, const ChunkInterval *matpartcol_interval, ObjectAddress *mataddress); static Query * mattablecolumninfo_get_partial_select_query(MaterializationHypertableColumnInfo *mattblinfo, Query *userview_query); @@ -267,11 +268,11 @@ create_bucket_function_catalog_entry(int32 matht_id, Oid bucket_function, const /* * Create hypertable for the table referred by mat_tbloid * matpartcolname - partition column for hypertable - * timecol_interval - is the partitioning column's interval for hypertable partition + * chunk_interval - chunk interval for hypertable partition (preserves calendar vs fixed type) */ static void cagg_create_hypertable(int32 hypertable_id, Oid mat_tbloid, const char *matpartcolname, - int64 mat_tbltimecol_interval) + const ChunkInterval *chunk_interval) { bool created; int flags = 0; @@ -281,9 +282,13 @@ cagg_create_hypertable(int32 hypertable_id, Oid mat_tbloid, const char *matpartc namestrcpy(&mat_tbltimecol, matpartcolname); time_dim_info = ts_dimension_info_create_open(mat_tbloid, &mat_tbltimecol, - Int64GetDatum(mat_tbltimecol_interval), - INT8OID, - InvalidOid); + chunk_interval_get_datum(chunk_interval), + chunk_interval->type, + InvalidOid, + chunk_interval_get_origin(chunk_interval), + chunk_interval->origin_type, + chunk_interval->has_origin); + /* * Ideally would like to change/expand the API so setting the column name manually is * unnecessary, but not high priority. @@ -380,10 +385,13 @@ mattablecolumninfo_add_mattable_index(MaterializationHypertableColumnInfo *matco * materialization table */ static int32 -mattablecolumninfo_create_materialization_table( - MaterializationHypertableColumnInfo *matcolinfo, int32 hypertable_id, RangeVar *mat_rel, - ContinuousAggTimeBucketInfo *bucket_info, bool create_addl_index, char *const tablespacename, - char *const table_access_method, int64 matpartcol_interval, ObjectAddress *mataddress) +mattablecolumninfo_create_materialization_table(MaterializationHypertableColumnInfo *matcolinfo, + int32 hypertable_id, RangeVar *mat_rel, + ContinuousAggTimeBucketInfo *bucket_info, + bool create_addl_index, char *const tablespacename, + char *const table_access_method, + const ChunkInterval *matpartcol_interval, + ObjectAddress *mataddress) { Oid uid, saved_uid; int sec_ctx; @@ -620,19 +628,45 @@ cagg_create(const CreateTableAsStmt *create_stmt, ViewStmt *stmt, Query *panquer bool materialized_only = DatumGetBool(with_clause_options[CreateMaterializedViewFlagMaterializedOnly].parsed); - int64 matpartcol_interval = 0; + /* + * Build the chunk interval for the materialization hypertable. + * + * Start with a copy of the source hypertable's chunk interval. The origin + * is inherited to keep chunk boundaries aligned. Note that changes to the + * source hypertable's origin after CAgg creation are NOT propagated to the + * materialization hypertable - each stores its origin independently. + * This could be enhanced in the future to cascade origin changes. + */ + /* + * Copy the source hypertable's chunk interval. Simple struct assignment works + * because ChunkInterval stores values directly in unions. + */ + ChunkInterval matpartcol_interval = bucket_info->htpartcol_interval; + if (!with_clause_options[CreateMaterializedViewFlagChunkTimeInterval].is_default) { - matpartcol_interval = interval_to_usec(DatumGetIntervalP( + /* + * User specified an explicit chunk_time_interval - use it as a fixed + * microsecond interval (not calendar-based), but keep the origin. + */ + matpartcol_interval.type = INT8OID; + matpartcol_interval.integer_interval = interval_to_usec(DatumGetIntervalP( with_clause_options[CreateMaterializedViewFlagChunkTimeInterval].parsed)); } - else + else if (bucket_info->parent_mat_hypertable_id == INVALID_HYPERTABLE_ID) { - matpartcol_interval = bucket_info->htpartcol_interval_len; - - /* Apply the factor just for non-Hierachical CAggs */ - if (bucket_info->parent_mat_hypertable_id == INVALID_HYPERTABLE_ID) - matpartcol_interval *= MATPARTCOL_INTERVAL_FACTOR; + /* Apply the interval factor for non-Hierarchical CAggs */ + if (matpartcol_interval.type == INTERVALOID) + { + Interval *interval = &matpartcol_interval.interval; + matpartcol_interval.interval.month = interval->month * MATPARTCOL_INTERVAL_FACTOR; + matpartcol_interval.interval.day = interval->day * MATPARTCOL_INTERVAL_FACTOR; + matpartcol_interval.interval.time = interval->time * MATPARTCOL_INTERVAL_FACTOR; + } + else + { + matpartcol_interval.integer_interval *= MATPARTCOL_INTERVAL_FACTOR; + } } /* @@ -669,7 +703,7 @@ cagg_create(const CreateTableAsStmt *create_stmt, ViewStmt *stmt, Query *panquer is_create_mattbl_index, create_stmt->into->tableSpaceName, create_stmt->into->accessMethod, - matpartcol_interval, + &matpartcol_interval, &mataddress); /* diff --git a/tsl/test/expected/bgw_policy.out b/tsl/test/expected/bgw_policy.out index 08341d3f14e..c020f25b7c5 100644 --- a/tsl/test/expected/bgw_policy.out +++ b/tsl/test/expected/bgw_policy.out @@ -524,14 +524,14 @@ select remove_retention_policy('part_time_now_func'); alter function dummy_now() rename to dummy_now_renamed; alter schema public rename to new_public; select * from _timescaledb_catalog.dimension; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+-----------------------------+---------+------------+--------------------------+--------------------+-----------------+--------------------------+-------------------------+------------------ - 1 | 1 | time | timestamp with time zone | t | | | | 604800000000 | | | - 2 | 1 | chunk_id | integer | f | 2 | _timescaledb_functions | get_partition_hash | | | | - 5 | 4 | time | timestamp without time zone | t | | | | 1 | | | - 6 | 5 | time | double precision | t | | new_public | time_partfunc | 1 | | new_public | dummy_now - 3 | 2 | time | bigint | t | | | | 1 | | new_public | nowstamp - 4 | 3 | time | smallint | t | | | | 1 | | new_public | overflow_now + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+-----------------------------+---------+------------+--------------------------+--------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ + 1 | 1 | time | timestamp with time zone | t | | | | | | 604800000000 | | | + 2 | 1 | chunk_id | integer | f | 2 | _timescaledb_functions | get_partition_hash | | | | | | + 5 | 4 | time | timestamp without time zone | t | | | | | | 1 | | | + 6 | 5 | time | double precision | t | | new_public | time_partfunc | | | 1 | | new_public | dummy_now + 3 | 2 | time | bigint | t | | | | | | 1 | | new_public | nowstamp + 4 | 3 | time | smallint | t | | | | | | 1 | | new_public | overflow_now alter schema new_public rename to public; \c :TEST_DBNAME :ROLE_DEFAULT_PERM_USER_2 diff --git a/tsl/test/expected/calendar_chunking_integration.out b/tsl/test/expected/calendar_chunking_integration.out new file mode 100644 index 00000000000..0c8566be603 --- /dev/null +++ b/tsl/test/expected/calendar_chunking_integration.out @@ -0,0 +1,510 @@ +-- 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. +-- +-- Test calendar-based chunking with continuous aggregates, compression, +-- and retention policies. +-- +SET timescaledb.enable_calendar_chunking = true; +SET timezone TO 'Europe/Stockholm'; +--------------------------------------------------------------- +-- SETUP: Create hypertables with calendar-based chunking +--------------------------------------------------------------- +-- Monthly chunks with calendar alignment +CREATE TABLE metrics_monthly( + time timestamptz NOT NULL, + device_id int NOT NULL, + value float NOT NULL +); +SELECT create_hypertable('metrics_monthly', 'time', chunk_time_interval => INTERVAL '1 month'); + create_hypertable +------------------------------ + (1,public,metrics_monthly,t) + +-- Weekly chunks with calendar alignment +CREATE TABLE metrics_weekly( + time timestamptz NOT NULL, + device_id int NOT NULL, + value float NOT NULL +); +SELECT create_hypertable('metrics_weekly', 'time', chunk_time_interval => INTERVAL '7 days'); + create_hypertable +----------------------------- + (2,public,metrics_weekly,t) + +-- Daily chunks with calendar alignment +CREATE TABLE metrics_daily( + time timestamptz NOT NULL, + device_id int NOT NULL, + value float NOT NULL +); +SELECT create_hypertable('metrics_daily', 'time', chunk_time_interval => INTERVAL '1 day'); + create_hypertable +---------------------------- + (3,public,metrics_daily,t) + +-- Create table with custom origin (fiscal year starting April 1) +CREATE TABLE metrics_fiscal( + time timestamptz NOT NULL, + value float NOT NULL +); +SELECT create_hypertable('metrics_fiscal', + by_range('time', INTERVAL '1 month', partition_origin => '2024-04-01'::timestamptz)); + create_hypertable +------------------- + (4,t) + +-- Create a hypertable for CAgg tests +CREATE TABLE cagg_test_monthly( + time timestamptz NOT NULL, + device_id int NOT NULL, + value float NOT NULL +); +SELECT create_hypertable('cagg_test_monthly', 'time', chunk_time_interval => INTERVAL '1 month'); + create_hypertable +-------------------------------- + (5,public,cagg_test_monthly,t) + +--------------------------------------------------------------- +-- INSERT DATA spanning multiple chunks +--------------------------------------------------------------- +-- Use setseed for deterministic random values +SELECT setseed(0.1); + setseed +--------- + + +-- Insert data for monthly table (spanning 3 months) +INSERT INTO metrics_monthly +SELECT ts, device_id, random() * 100 +FROM generate_series('2024-01-15'::timestamptz, '2024-03-15'::timestamptz, '1 day'::interval) ts +CROSS JOIN generate_series(1, 3) device_id; +-- Insert data for weekly table (spanning 4 weeks) +INSERT INTO metrics_weekly +SELECT ts, device_id, random() * 100 +FROM generate_series('2024-01-01'::timestamptz, '2024-01-28'::timestamptz, '6 hours'::interval) ts +CROSS JOIN generate_series(1, 3) device_id; +-- Insert data for daily table (spanning 10 days) +INSERT INTO metrics_daily +SELECT ts, device_id, random() * 100 +FROM generate_series('2024-01-01'::timestamptz, '2024-01-10'::timestamptz, '1 hour'::interval) ts +CROSS JOIN generate_series(1, 3) device_id; +-- Insert data for fiscal table (reset seed for deterministic values) +SELECT setseed(0.2); + setseed +--------- + + +INSERT INTO metrics_fiscal +SELECT ts, random() * 100 +FROM generate_series('2024-03-15'::timestamptz, '2024-05-15'::timestamptz, '1 day'::interval) ts; +-- Insert data for CAgg test table +INSERT INTO cagg_test_monthly +SELECT ts, device_id, (EXTRACT(epoch FROM ts) % 100)::float +FROM generate_series('2024-01-15'::timestamptz, '2024-04-15'::timestamptz, '1 hour'::interval) ts +CROSS JOIN generate_series(1, 3) device_id; +--------------------------------------------------------------- +-- CONTINUOUS AGGREGATES on calendar-chunked hypertables +-- +-- Test that continuous aggregates work correctly on calendar-chunked +-- hypertables. The materialization hypertable should also use +-- calendar-based chunking, inheriting the interval type and origin +-- from the source hypertable. +--------------------------------------------------------------- +-- Test 1: CAgg with month bucket (variable-width) on calendar-chunked hypertable +CREATE MATERIALIZED VIEW cagg_monthly +WITH (timescaledb.continuous, timescaledb.materialized_only = true) +AS SELECT + time_bucket('1 month', time, timezone => 'Europe/Stockholm') AS bucket, + device_id, + count(*) as cnt +FROM cagg_test_monthly +GROUP BY 1, 2 +WITH NO DATA; +-- Test 2: CAgg with day bucket (fixed-width) on calendar-chunked hypertable +CREATE MATERIALIZED VIEW cagg_daily +WITH (timescaledb.continuous, timescaledb.materialized_only = true) +AS SELECT + time_bucket('1 day', time, timezone => 'Europe/Stockholm') AS bucket, + device_id, + count(*) as cnt +FROM cagg_test_monthly +GROUP BY 1, 2 +WITH NO DATA; +-- Test 3: CAgg with hour bucket (fixed-width) on calendar-chunked hypertable +CREATE MATERIALIZED VIEW cagg_hourly +WITH (timescaledb.continuous, timescaledb.materialized_only = true) +AS SELECT + time_bucket('1 hour', time, timezone => 'Europe/Stockholm') AS bucket, + device_id, + count(*) as cnt +FROM cagg_test_monthly +GROUP BY 1, 2 +WITH NO DATA; +-- Test 4: CAgg on metrics_fiscal (calendar-chunked with custom origin) +CREATE MATERIALIZED VIEW cagg_fiscal +WITH (timescaledb.continuous, timescaledb.materialized_only = true) +AS SELECT + time_bucket('1 day', time, timezone => 'Europe/Stockholm') AS bucket, + count(*) as cnt +FROM metrics_fiscal +GROUP BY 1 +WITH NO DATA; +-- Verify CAggs were created successfully +SELECT view_name, materialized_only, compression_enabled +FROM timescaledb_information.continuous_aggregates +WHERE view_name LIKE 'cagg_%' +ORDER BY view_name; + view_name | materialized_only | compression_enabled +--------------+-------------------+--------------------- + cagg_daily | t | f + cagg_fiscal | t | f + cagg_hourly | t | f + cagg_monthly | t | f + +-- Refresh the CAggs and verify data +CALL refresh_continuous_aggregate('cagg_monthly', NULL, NULL); +CALL refresh_continuous_aggregate('cagg_daily', NULL, NULL); +SELECT bucket, device_id, cnt +FROM cagg_monthly +ORDER BY bucket, device_id +LIMIT 12; + bucket | device_id | cnt +-------------------------------+-----------+----- + Mon Jan 01 00:00:00 2024 CET | 1 | 408 + Mon Jan 01 00:00:00 2024 CET | 2 | 408 + Mon Jan 01 00:00:00 2024 CET | 3 | 408 + Thu Feb 01 00:00:00 2024 CET | 1 | 696 + Thu Feb 01 00:00:00 2024 CET | 2 | 696 + Thu Feb 01 00:00:00 2024 CET | 3 | 696 + Fri Mar 01 00:00:00 2024 CET | 1 | 743 + Fri Mar 01 00:00:00 2024 CET | 2 | 743 + Fri Mar 01 00:00:00 2024 CET | 3 | 743 + Mon Apr 01 00:00:00 2024 CEST | 1 | 337 + Mon Apr 01 00:00:00 2024 CEST | 2 | 337 + Mon Apr 01 00:00:00 2024 CEST | 3 | 337 + +--------------------------------------------------------------- +-- Verify CAgg materialization hypertables use calendar-based chunking +-- +-- When a CAgg is created on a calendar-chunked hypertable, the +-- materialization hypertable should also use calendar-based chunking. +-- This is indicated by interval IS NOT NULL in the dimension catalog. +--------------------------------------------------------------- +-- Check that materialization hypertables have calendar-based dimensions +-- (interval IS NOT NULL means calendar chunking, interval_length IS NULL) +SELECT + h.table_name, + d.column_name, + d.interval_length IS NULL as is_calendar_chunking, + d.interval IS NOT NULL as has_interval +FROM _timescaledb_catalog.hypertable h +JOIN _timescaledb_catalog.dimension d ON h.id = d.hypertable_id +WHERE h.table_name LIKE '_materialized_hypertable_%' +ORDER BY h.table_name; + table_name | column_name | is_calendar_chunking | has_interval +----------------------------+-------------+----------------------+-------------- + _materialized_hypertable_6 | bucket | t | t + _materialized_hypertable_7 | bucket | t | t + _materialized_hypertable_8 | bucket | t | t + _materialized_hypertable_9 | bucket | t | t + +-- Show chunk ranges for cagg_monthly to verify calendar alignment +-- Chunks should align to month boundaries (10 months interval due to MATPARTCOL_INTERVAL_FACTOR) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = '_materialized_hypertable_6' +ORDER BY range_start; + chunk_name | range_start | range_end +-------------------+-------------------------------+------------------------------- + _hyper_6_25_chunk | Sat Jul 01 00:00:00 2023 CEST | Wed May 01 00:00:00 2024 CEST + +-- Show chunk ranges for cagg_daily to verify calendar alignment +-- (10 day chunks due to MATPARTCOL_INTERVAL_FACTOR applied to source's 1 month interval) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = '_materialized_hypertable_7' +ORDER BY range_start; + chunk_name | range_start | range_end +-------------------+-------------------------------+------------------------------- + _hyper_7_26_chunk | Sat Jul 01 00:00:00 2023 CEST | Wed May 01 00:00:00 2024 CEST + +--------------------------------------------------------------- +-- COMPRESSION with calendar-chunked hypertables +--------------------------------------------------------------- +-- Enable compression on the tables +ALTER TABLE metrics_monthly SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'device_id', + timescaledb.compress_orderby = 'time' +); +ALTER TABLE metrics_weekly SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'device_id', + timescaledb.compress_orderby = 'time' +); +ALTER TABLE metrics_daily SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'device_id', + timescaledb.compress_orderby = 'time' +); +-- Compress specific chunks +SELECT compress_chunk(chunk) FROM show_chunks('metrics_monthly', older_than => '2024-03-01') chunk; + compress_chunk +---------------------------------------- + _timescaledb_internal._hyper_1_1_chunk + _timescaledb_internal._hyper_1_2_chunk + +SELECT compress_chunk(chunk) FROM show_chunks('metrics_weekly', older_than => '2024-01-21') chunk; + compress_chunk +---------------------------------------- + _timescaledb_internal._hyper_2_4_chunk + _timescaledb_internal._hyper_2_5_chunk + +SELECT compress_chunk(chunk) FROM show_chunks('metrics_daily', older_than => '2024-01-08') chunk; + compress_chunk +----------------------------------------- + _timescaledb_internal._hyper_3_8_chunk + _timescaledb_internal._hyper_3_9_chunk + _timescaledb_internal._hyper_3_10_chunk + _timescaledb_internal._hyper_3_11_chunk + _timescaledb_internal._hyper_3_12_chunk + _timescaledb_internal._hyper_3_13_chunk + _timescaledb_internal._hyper_3_14_chunk + +-- Verify compression status +SELECT chunk_name, is_compressed +FROM timescaledb_information.chunks +WHERE hypertable_name = 'metrics_monthly' +ORDER BY range_start; + chunk_name | is_compressed +------------------+--------------- + _hyper_1_1_chunk | t + _hyper_1_2_chunk | t + _hyper_1_3_chunk | f + +SELECT chunk_name, is_compressed +FROM timescaledb_information.chunks +WHERE hypertable_name = 'metrics_weekly' +ORDER BY range_start; + chunk_name | is_compressed +------------------+--------------- + _hyper_2_4_chunk | t + _hyper_2_5_chunk | t + _hyper_2_6_chunk | f + _hyper_2_7_chunk | f + +-- Query compressed data to verify it still works +SELECT time_bucket('1 week', time, current_setting('timezone')) as week, count(*), round(avg(value)::numeric, 2) as avg_val +FROM metrics_monthly +GROUP BY 1 +ORDER BY 1; + week | count | avg_val +------------------------------+-------+--------- + Mon Jan 15 00:00:00 2024 CET | 21 | 60.63 + Mon Jan 22 00:00:00 2024 CET | 21 | 44.71 + Mon Jan 29 00:00:00 2024 CET | 21 | 54.96 + Mon Feb 05 00:00:00 2024 CET | 21 | 35.87 + Mon Feb 12 00:00:00 2024 CET | 21 | 45.96 + Mon Feb 19 00:00:00 2024 CET | 21 | 48.08 + Mon Feb 26 00:00:00 2024 CET | 21 | 49.33 + Mon Mar 04 00:00:00 2024 CET | 21 | 52.00 + Mon Mar 11 00:00:00 2024 CET | 15 | 40.67 + +--------------------------------------------------------------- +-- VERIFY DATA INTEGRITY after compression +--------------------------------------------------------------- +-- Count rows in original table (should include compressed chunks) +SELECT count(*) as total_rows FROM metrics_monthly; + total_rows +------------ + 183 + +-- Verify aggregation still works correctly +SELECT device_id, count(*), round(avg(value)::numeric, 2) as avg_val +FROM metrics_monthly +GROUP BY device_id +ORDER BY device_id; + device_id | count | avg_val +-----------+-------+--------- + 1 | 61 | 50.99 + 2 | 61 | 50.86 + 3 | 61 | 42.94 + +--------------------------------------------------------------- +-- COMPRESSION POLICY with calendar-chunked hypertables +--------------------------------------------------------------- +-- Add compression policy with 1 month threshold +SELECT add_compression_policy('metrics_monthly', INTERVAL '1 month', schedule_interval => INTERVAL '1 year') as monthly_compress_job \gset +-- Decompress all chunks so the policy has work to do +SELECT decompress_chunk(c.chunk_schema || '.' || c.chunk_name) +FROM timescaledb_information.chunks c +WHERE c.hypertable_name = 'metrics_monthly' AND c.is_compressed; + decompress_chunk +---------------------------------------- + _timescaledb_internal._hyper_1_1_chunk + _timescaledb_internal._hyper_1_2_chunk + +-- Mock time to 2024-03-01 - with 1 month policy, Jan chunk will be compressed, Feb and Mar won't +SET timescaledb.current_timestamp_mock = '2024-03-01 00:00:00+01'; +-- Show chunks before running compression policy +SELECT chunk_name, is_compressed +FROM timescaledb_information.chunks +WHERE hypertable_name = 'metrics_monthly' +ORDER BY range_start; + chunk_name | is_compressed +------------------+--------------- + _hyper_1_1_chunk | f + _hyper_1_2_chunk | f + _hyper_1_3_chunk | f + +-- Run the compression policy job +CALL run_job(:monthly_compress_job); +-- Verify only Jan chunk was compressed (older than 1 month from mock time) +SELECT chunk_name, is_compressed +FROM timescaledb_information.chunks +WHERE hypertable_name = 'metrics_monthly' +ORDER BY range_start; + chunk_name | is_compressed +------------------+--------------- + _hyper_1_1_chunk | t + _hyper_1_2_chunk | f + _hyper_1_3_chunk | f + +RESET timescaledb.current_timestamp_mock; +--------------------------------------------------------------- +-- CHUNK MERGING (compress_chunk_time_interval) with calendar chunks +--------------------------------------------------------------- +-- Create a new table for testing chunk merging with calendar chunks +CREATE TABLE merge_test(time timestamptz NOT NULL, device_id int, value float); +SELECT create_hypertable('merge_test', 'time', chunk_time_interval => INTERVAL '1 month'); + create_hypertable +-------------------------- + (13,public,merge_test,t) + +-- Insert data spanning 4 months (Jan-Apr 2024), creating 4 monthly chunks +-- Use deterministic values: value = day of month +INSERT INTO merge_test +SELECT ts, device_id, extract(day from ts)::float +FROM generate_series('2024-01-15'::timestamptz, '2024-04-15'::timestamptz, INTERVAL '1 day') ts +CROSS JOIN generate_series(1, 2) device_id; +-- Show chunks before compression (4 monthly chunks with variable sizes) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'merge_test' +ORDER BY range_start; + chunk_name | range_start | range_end +--------------------+-------------------------------+------------------------------- + _hyper_13_39_chunk | Mon Jan 01 00:00:00 2024 CET | Thu Feb 01 00:00:00 2024 CET + _hyper_13_40_chunk | Thu Feb 01 00:00:00 2024 CET | Fri Mar 01 00:00:00 2024 CET + _hyper_13_41_chunk | Fri Mar 01 00:00:00 2024 CET | Mon Apr 01 00:00:00 2024 CEST + _hyper_13_42_chunk | Mon Apr 01 00:00:00 2024 CEST | Wed May 01 00:00:00 2024 CEST + +-- Enable compression with chunk merging +-- compress_chunk_time_interval = '90 days' means merge chunks until total interval exceeds 90 days +-- This tests that the modulo-by-zero bug is fixed (calendar chunks have interval_length=0) +ALTER TABLE merge_test SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'device_id', + timescaledb.compress_orderby = 'time', + timescaledb.compress_chunk_time_interval = '90 days' +); +-- Verify the compress_interval_length was set (should be 90 days in microseconds) +SELECT d.compress_interval_length / 86400000000 as compress_interval_days +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'merge_test'; + compress_interval_days +------------------------ + 90 + +-- Compress all chunks +-- Jan (31 days) + Feb (29 days, leap year) = 60 days < 90 days -> merge into Jan +-- 60 days + Mar (31 days) = 91 days > 90 days -> Mar starts new chunk +-- Mar (31 days) + Apr (30 days) = 61 days < 90 days -> merge into Mar +SELECT compress_chunk(chunk, true) FROM show_chunks('merge_test') chunk; + compress_chunk +------------------------------------------ + _timescaledb_internal._hyper_13_39_chunk + _timescaledb_internal._hyper_13_39_chunk + _timescaledb_internal._hyper_13_41_chunk + _timescaledb_internal._hyper_13_41_chunk + +-- Show chunks after compression - Jan+Feb merged, Mar+Apr merged (2 chunks total) +SELECT c.chunk_name, c.is_compressed, c.range_start, c.range_end +FROM timescaledb_information.chunks c +WHERE c.hypertable_name = 'merge_test' +ORDER BY c.range_start; + chunk_name | is_compressed | range_start | range_end +--------------------+---------------+------------------------------+------------------------------- + _hyper_13_39_chunk | t | Mon Jan 01 00:00:00 2024 CET | Fri Mar 01 00:00:00 2024 CET + _hyper_13_41_chunk | t | Fri Mar 01 00:00:00 2024 CET | Wed May 01 00:00:00 2024 CEST + +-- Verify data integrity after merge +SELECT count(*) as total_rows FROM merge_test; + total_rows +------------ + 184 + +-- Clean up merge test table +DROP TABLE merge_test; +--------------------------------------------------------------- +-- RETENTION POLICY with calendar-chunked hypertables +--------------------------------------------------------------- +-- Add retention policy with 2 week threshold +SELECT add_retention_policy('metrics_weekly', INTERVAL '2 weeks', schedule_interval => INTERVAL '1 year') as weekly_retention_job \gset +-- Mock time to 2024-02-01 - with 2 week policy, first two chunks will be dropped +SET timescaledb.current_timestamp_mock = '2024-02-01 00:00:00+01'; +-- Show chunks before running retention policy (4 chunks: Jan 1-7, 8-14, 15-21, 22-28) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'metrics_weekly' +ORDER BY range_start; + chunk_name | range_start | range_end +------------------+------------------------------+------------------------------ + _hyper_2_4_chunk | Mon Jan 01 00:00:00 2024 CET | Mon Jan 08 00:00:00 2024 CET + _hyper_2_5_chunk | Mon Jan 08 00:00:00 2024 CET | Mon Jan 15 00:00:00 2024 CET + _hyper_2_6_chunk | Mon Jan 15 00:00:00 2024 CET | Mon Jan 22 00:00:00 2024 CET + _hyper_2_7_chunk | Mon Jan 22 00:00:00 2024 CET | Mon Jan 29 00:00:00 2024 CET + +-- Run the retention policy job +CALL run_job(:weekly_retention_job); +-- Verify first two chunks were dropped (older than 2 weeks from mock time) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'metrics_weekly' +ORDER BY range_start; + chunk_name | range_start | range_end +------------------+------------------------------+------------------------------ + _hyper_2_6_chunk | Mon Jan 15 00:00:00 2024 CET | Mon Jan 22 00:00:00 2024 CET + _hyper_2_7_chunk | Mon Jan 22 00:00:00 2024 CET | Mon Jan 29 00:00:00 2024 CET + +RESET timescaledb.current_timestamp_mock; +--------------------------------------------------------------- +-- CLEANUP +--------------------------------------------------------------- +-- Drop CAggs first (they depend on the tables) +DROP MATERIALIZED VIEW cagg_monthly; +NOTICE: drop cascades to table _timescaledb_internal._hyper_6_25_chunk +DROP MATERIALIZED VIEW cagg_daily; +NOTICE: drop cascades to table _timescaledb_internal._hyper_7_26_chunk +DROP MATERIALIZED VIEW cagg_hourly; +DROP MATERIALIZED VIEW cagg_fiscal; +DROP TABLE cagg_test_monthly; +-- Remove policies +SELECT remove_retention_policy('metrics_weekly', if_exists => true); + remove_retention_policy +------------------------- + + +SELECT remove_compression_policy('metrics_monthly', if_exists => true); + remove_compression_policy +--------------------------- + t + +-- Drop objects +DROP TABLE metrics_monthly; +DROP TABLE metrics_weekly; +DROP TABLE metrics_daily; +DROP TABLE metrics_fiscal; +RESET timescaledb.enable_calendar_chunking; diff --git a/tsl/test/expected/tsl_tables.out b/tsl/test/expected/tsl_tables.out index fe9a5a152e6..b49c2693d5e 100644 --- a/tsl/test/expected/tsl_tables.out +++ b/tsl/test/expected/tsl_tables.out @@ -166,10 +166,10 @@ select remove_retention_policy('test_table'); -- Test set_integer_now_func and add_retention_policy with -- hypertables that have integer time dimension select * from _timescaledb_catalog.dimension; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+--------------------------+---------+------------+--------------------------+-------------------+-----------------+--------------------------+-------------------------+------------------ - 1 | 1 | time | timestamp with time zone | t | | | | 604800000000 | | | - 2 | 2 | time | bigint | t | | | | 1 | | | + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+--------------------------+---------+------------+--------------------------+-------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ + 1 | 1 | time | timestamp with time zone | t | | | | | | 604800000000 | | | + 2 | 2 | time | bigint | t | | | | | | 1 | | | \c :TEST_DBNAME :ROLE_SUPERUSER CREATE SCHEMA IF NOT EXISTS my_new_schema; @@ -183,10 +183,10 @@ select set_integer_now_func('test_table_int', 'my_new_schema.dummy_now2'); \c :TEST_DBNAME :ROLE_DEFAULT_PERM_USER select * from _timescaledb_catalog.dimension; - id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func -----+---------------+-------------+--------------------------+---------+------------+--------------------------+-------------------+-----------------+--------------------------+-------------------------+------------------ - 1 | 1 | time | timestamp with time zone | t | | | | 604800000000 | | | - 2 | 2 | time | bigint | t | | | | 1 | | my_new_schema | dummy_now2 + id | hypertable_id | column_name | column_type | aligned | num_slices | partitioning_func_schema | partitioning_func | interval_origin | interval | interval_length | compress_interval_length | integer_now_func_schema | integer_now_func +----+---------------+-------------+--------------------------+---------+------------+--------------------------+-------------------+-----------------+----------+-----------------+--------------------------+-------------------------+------------------ + 1 | 1 | time | timestamp with time zone | t | | | | | | 604800000000 | | | + 2 | 2 | time | bigint | t | | | | | | 1 | | my_new_schema | dummy_now2 SELECT * FROM _timescaledb_catalog.bgw_job WHERE proc_name = 'policy_retention' ORDER BY id; id | application_name | schedule_interval | max_runtime | max_retries | retry_period | proc_schema | proc_name | owner | scheduled | fixed_schedule | initial_start | hypertable_id | config | check_schema | check_name | timezone diff --git a/tsl/test/shared/expected/extension.out b/tsl/test/shared/expected/extension.out index 5fd4c23d971..b73c0d07262 100644 --- a/tsl/test/shared/expected/extension.out +++ b/tsl/test/shared/expected/extension.out @@ -191,7 +191,7 @@ ORDER BY pronamespace::regnamespace::text COLLATE "C", p.oid::regprocedure::text add_compression_policy(regclass,"any",boolean,interval,timestamp with time zone,text,interval) add_continuous_aggregate_policy(regclass,"any","any",interval,boolean,timestamp with time zone,text,boolean,integer,integer,boolean) add_dimension(regclass,_timescaledb_internal.dimension_info,boolean) - add_dimension(regclass,name,integer,anyelement,regproc,boolean) + add_dimension(regclass,name,integer,anyelement,regproc,boolean,"any") add_job(regproc,interval,jsonb,timestamp with time zone,boolean,regproc,boolean,text,text) add_process_hypertable_invalidations_policy(regclass,interval,boolean,timestamp with time zone,text) add_reorder_policy(regclass,name,boolean,timestamp with time zone,text) @@ -201,7 +201,7 @@ ORDER BY pronamespace::regnamespace::text COLLATE "C", p.oid::regprocedure::text attach_chunk(regclass,regclass,jsonb) attach_tablespace(name,regclass,boolean) by_hash(name,integer,regproc) - by_range(name,anyelement,regproc) + by_range(name,anyelement,regproc,"any") chunk_columnstore_stats(regclass) chunk_compression_stats(regclass) chunks_detailed_size(regclass) @@ -209,7 +209,7 @@ ORDER BY pronamespace::regnamespace::text COLLATE "C", p.oid::regprocedure::text convert_to_columnstore(regclass,boolean,boolean) convert_to_rowstore(regclass,boolean) create_hypertable(regclass,_timescaledb_internal.dimension_info,boolean,boolean,boolean) - create_hypertable(regclass,name,name,integer,name,name,anyelement,boolean,boolean,regproc,boolean,text,regproc,regproc) + create_hypertable(regclass,name,name,integer,name,name,anyelement,boolean,boolean,regproc,boolean,text,regproc,regproc,"any") decompress_chunk(regclass,boolean) delete_job(integer) detach_chunk(regclass) @@ -250,10 +250,10 @@ ORDER BY pronamespace::regnamespace::text COLLATE "C", p.oid::regprocedure::text reorder_chunk(regclass,regclass,boolean) run_job(integer) set_adaptive_chunking(regclass,text,regproc) - set_chunk_time_interval(regclass,anyelement,name) + set_chunk_time_interval(regclass,anyelement,name,"any",boolean) set_integer_now_func(regclass,regproc,boolean) set_number_partitions(regclass,integer,name) - set_partitioning_interval(regclass,anyelement,name) + set_partitioning_interval(regclass,anyelement,name,"any",boolean) show_chunks(regclass,"any","any","any","any") show_tablespaces(regclass) split_chunk(regclass,"any") diff --git a/tsl/test/sql/CMakeLists.txt b/tsl/test/sql/CMakeLists.txt index e2802fd10b2..5bff069f375 100644 --- a/tsl/test/sql/CMakeLists.txt +++ b/tsl/test/sql/CMakeLists.txt @@ -104,6 +104,7 @@ if(CMAKE_BUILD_TYPE MATCHES Debug) TEST_FILES attach_chunk.sql bgw_custom.sql + calendar_chunking_integration.sql bgw_db_scheduler.sql bgw_job_stat_history.sql bgw_job_stat_history_errors.sql diff --git a/tsl/test/sql/calendar_chunking_integration.sql b/tsl/test/sql/calendar_chunking_integration.sql new file mode 100644 index 00000000000..5b5b57173e5 --- /dev/null +++ b/tsl/test/sql/calendar_chunking_integration.sql @@ -0,0 +1,394 @@ +-- 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. + +-- +-- Test calendar-based chunking with continuous aggregates, compression, +-- and retention policies. +-- + +SET timescaledb.enable_calendar_chunking = true; +SET timezone TO 'Europe/Stockholm'; + +--------------------------------------------------------------- +-- SETUP: Create hypertables with calendar-based chunking +--------------------------------------------------------------- + +-- Monthly chunks with calendar alignment +CREATE TABLE metrics_monthly( + time timestamptz NOT NULL, + device_id int NOT NULL, + value float NOT NULL +); + +SELECT create_hypertable('metrics_monthly', 'time', chunk_time_interval => INTERVAL '1 month'); + +-- Weekly chunks with calendar alignment +CREATE TABLE metrics_weekly( + time timestamptz NOT NULL, + device_id int NOT NULL, + value float NOT NULL +); + +SELECT create_hypertable('metrics_weekly', 'time', chunk_time_interval => INTERVAL '7 days'); + +-- Daily chunks with calendar alignment +CREATE TABLE metrics_daily( + time timestamptz NOT NULL, + device_id int NOT NULL, + value float NOT NULL +); + +SELECT create_hypertable('metrics_daily', 'time', chunk_time_interval => INTERVAL '1 day'); + +-- Create table with custom origin (fiscal year starting April 1) +CREATE TABLE metrics_fiscal( + time timestamptz NOT NULL, + value float NOT NULL +); + +SELECT create_hypertable('metrics_fiscal', + by_range('time', INTERVAL '1 month', partition_origin => '2024-04-01'::timestamptz)); + +-- Create a hypertable for CAgg tests +CREATE TABLE cagg_test_monthly( + time timestamptz NOT NULL, + device_id int NOT NULL, + value float NOT NULL +); + +SELECT create_hypertable('cagg_test_monthly', 'time', chunk_time_interval => INTERVAL '1 month'); + +--------------------------------------------------------------- +-- INSERT DATA spanning multiple chunks +--------------------------------------------------------------- + +-- Use setseed for deterministic random values +SELECT setseed(0.1); + +-- Insert data for monthly table (spanning 3 months) +INSERT INTO metrics_monthly +SELECT ts, device_id, random() * 100 +FROM generate_series('2024-01-15'::timestamptz, '2024-03-15'::timestamptz, '1 day'::interval) ts +CROSS JOIN generate_series(1, 3) device_id; + +-- Insert data for weekly table (spanning 4 weeks) +INSERT INTO metrics_weekly +SELECT ts, device_id, random() * 100 +FROM generate_series('2024-01-01'::timestamptz, '2024-01-28'::timestamptz, '6 hours'::interval) ts +CROSS JOIN generate_series(1, 3) device_id; + +-- Insert data for daily table (spanning 10 days) +INSERT INTO metrics_daily +SELECT ts, device_id, random() * 100 +FROM generate_series('2024-01-01'::timestamptz, '2024-01-10'::timestamptz, '1 hour'::interval) ts +CROSS JOIN generate_series(1, 3) device_id; + +-- Insert data for fiscal table (reset seed for deterministic values) +SELECT setseed(0.2); + +INSERT INTO metrics_fiscal +SELECT ts, random() * 100 +FROM generate_series('2024-03-15'::timestamptz, '2024-05-15'::timestamptz, '1 day'::interval) ts; + +-- Insert data for CAgg test table +INSERT INTO cagg_test_monthly +SELECT ts, device_id, (EXTRACT(epoch FROM ts) % 100)::float +FROM generate_series('2024-01-15'::timestamptz, '2024-04-15'::timestamptz, '1 hour'::interval) ts +CROSS JOIN generate_series(1, 3) device_id; + +--------------------------------------------------------------- +-- CONTINUOUS AGGREGATES on calendar-chunked hypertables +-- +-- Test that continuous aggregates work correctly on calendar-chunked +-- hypertables. The materialization hypertable should also use +-- calendar-based chunking, inheriting the interval type and origin +-- from the source hypertable. +--------------------------------------------------------------- + +-- Test 1: CAgg with month bucket (variable-width) on calendar-chunked hypertable +CREATE MATERIALIZED VIEW cagg_monthly +WITH (timescaledb.continuous, timescaledb.materialized_only = true) +AS SELECT + time_bucket('1 month', time, timezone => 'Europe/Stockholm') AS bucket, + device_id, + count(*) as cnt +FROM cagg_test_monthly +GROUP BY 1, 2 +WITH NO DATA; + +-- Test 2: CAgg with day bucket (fixed-width) on calendar-chunked hypertable +CREATE MATERIALIZED VIEW cagg_daily +WITH (timescaledb.continuous, timescaledb.materialized_only = true) +AS SELECT + time_bucket('1 day', time, timezone => 'Europe/Stockholm') AS bucket, + device_id, + count(*) as cnt +FROM cagg_test_monthly +GROUP BY 1, 2 +WITH NO DATA; + +-- Test 3: CAgg with hour bucket (fixed-width) on calendar-chunked hypertable +CREATE MATERIALIZED VIEW cagg_hourly +WITH (timescaledb.continuous, timescaledb.materialized_only = true) +AS SELECT + time_bucket('1 hour', time, timezone => 'Europe/Stockholm') AS bucket, + device_id, + count(*) as cnt +FROM cagg_test_monthly +GROUP BY 1, 2 +WITH NO DATA; + +-- Test 4: CAgg on metrics_fiscal (calendar-chunked with custom origin) +CREATE MATERIALIZED VIEW cagg_fiscal +WITH (timescaledb.continuous, timescaledb.materialized_only = true) +AS SELECT + time_bucket('1 day', time, timezone => 'Europe/Stockholm') AS bucket, + count(*) as cnt +FROM metrics_fiscal +GROUP BY 1 +WITH NO DATA; + +-- Verify CAggs were created successfully +SELECT view_name, materialized_only, compression_enabled +FROM timescaledb_information.continuous_aggregates +WHERE view_name LIKE 'cagg_%' +ORDER BY view_name; + +-- Refresh the CAggs and verify data +CALL refresh_continuous_aggregate('cagg_monthly', NULL, NULL); +CALL refresh_continuous_aggregate('cagg_daily', NULL, NULL); + +SELECT bucket, device_id, cnt +FROM cagg_monthly +ORDER BY bucket, device_id +LIMIT 12; + +--------------------------------------------------------------- +-- Verify CAgg materialization hypertables use calendar-based chunking +-- +-- When a CAgg is created on a calendar-chunked hypertable, the +-- materialization hypertable should also use calendar-based chunking. +-- This is indicated by interval IS NOT NULL in the dimension catalog. +--------------------------------------------------------------- + +-- Check that materialization hypertables have calendar-based dimensions +-- (interval IS NOT NULL means calendar chunking, interval_length IS NULL) +SELECT + h.table_name, + d.column_name, + d.interval_length IS NULL as is_calendar_chunking, + d.interval IS NOT NULL as has_interval +FROM _timescaledb_catalog.hypertable h +JOIN _timescaledb_catalog.dimension d ON h.id = d.hypertable_id +WHERE h.table_name LIKE '_materialized_hypertable_%' +ORDER BY h.table_name; + +-- Show chunk ranges for cagg_monthly to verify calendar alignment +-- Chunks should align to month boundaries (10 months interval due to MATPARTCOL_INTERVAL_FACTOR) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = '_materialized_hypertable_6' +ORDER BY range_start; + +-- Show chunk ranges for cagg_daily to verify calendar alignment +-- (10 day chunks due to MATPARTCOL_INTERVAL_FACTOR applied to source's 1 month interval) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = '_materialized_hypertable_7' +ORDER BY range_start; + +--------------------------------------------------------------- +-- COMPRESSION with calendar-chunked hypertables +--------------------------------------------------------------- + +-- Enable compression on the tables +ALTER TABLE metrics_monthly SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'device_id', + timescaledb.compress_orderby = 'time' +); + +ALTER TABLE metrics_weekly SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'device_id', + timescaledb.compress_orderby = 'time' +); + +ALTER TABLE metrics_daily SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'device_id', + timescaledb.compress_orderby = 'time' +); + +-- Compress specific chunks +SELECT compress_chunk(chunk) FROM show_chunks('metrics_monthly', older_than => '2024-03-01') chunk; +SELECT compress_chunk(chunk) FROM show_chunks('metrics_weekly', older_than => '2024-01-21') chunk; +SELECT compress_chunk(chunk) FROM show_chunks('metrics_daily', older_than => '2024-01-08') chunk; + +-- Verify compression status +SELECT chunk_name, is_compressed +FROM timescaledb_information.chunks +WHERE hypertable_name = 'metrics_monthly' +ORDER BY range_start; + +SELECT chunk_name, is_compressed +FROM timescaledb_information.chunks +WHERE hypertable_name = 'metrics_weekly' +ORDER BY range_start; + +-- Query compressed data to verify it still works +SELECT time_bucket('1 week', time, current_setting('timezone')) as week, count(*), round(avg(value)::numeric, 2) as avg_val +FROM metrics_monthly +GROUP BY 1 +ORDER BY 1; + +--------------------------------------------------------------- +-- VERIFY DATA INTEGRITY after compression +--------------------------------------------------------------- + +-- Count rows in original table (should include compressed chunks) +SELECT count(*) as total_rows FROM metrics_monthly; + +-- Verify aggregation still works correctly +SELECT device_id, count(*), round(avg(value)::numeric, 2) as avg_val +FROM metrics_monthly +GROUP BY device_id +ORDER BY device_id; + +--------------------------------------------------------------- +-- COMPRESSION POLICY with calendar-chunked hypertables +--------------------------------------------------------------- + +-- Add compression policy with 1 month threshold +SELECT add_compression_policy('metrics_monthly', INTERVAL '1 month', schedule_interval => INTERVAL '1 year') as monthly_compress_job \gset + +-- Decompress all chunks so the policy has work to do +SELECT decompress_chunk(c.chunk_schema || '.' || c.chunk_name) +FROM timescaledb_information.chunks c +WHERE c.hypertable_name = 'metrics_monthly' AND c.is_compressed; + +-- Mock time to 2024-03-01 - with 1 month policy, Jan chunk will be compressed, Feb and Mar won't +SET timescaledb.current_timestamp_mock = '2024-03-01 00:00:00+01'; + +-- Show chunks before running compression policy +SELECT chunk_name, is_compressed +FROM timescaledb_information.chunks +WHERE hypertable_name = 'metrics_monthly' +ORDER BY range_start; + +-- Run the compression policy job +CALL run_job(:monthly_compress_job); + +-- Verify only Jan chunk was compressed (older than 1 month from mock time) +SELECT chunk_name, is_compressed +FROM timescaledb_information.chunks +WHERE hypertable_name = 'metrics_monthly' +ORDER BY range_start; + +RESET timescaledb.current_timestamp_mock; + +--------------------------------------------------------------- +-- CHUNK MERGING (compress_chunk_time_interval) with calendar chunks +--------------------------------------------------------------- + +-- Create a new table for testing chunk merging with calendar chunks +CREATE TABLE merge_test(time timestamptz NOT NULL, device_id int, value float); +SELECT create_hypertable('merge_test', 'time', chunk_time_interval => INTERVAL '1 month'); + +-- Insert data spanning 4 months (Jan-Apr 2024), creating 4 monthly chunks +-- Use deterministic values: value = day of month +INSERT INTO merge_test +SELECT ts, device_id, extract(day from ts)::float +FROM generate_series('2024-01-15'::timestamptz, '2024-04-15'::timestamptz, INTERVAL '1 day') ts +CROSS JOIN generate_series(1, 2) device_id; + +-- Show chunks before compression (4 monthly chunks with variable sizes) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'merge_test' +ORDER BY range_start; + +-- Enable compression with chunk merging +-- compress_chunk_time_interval = '90 days' means merge chunks until total interval exceeds 90 days +-- This tests that the modulo-by-zero bug is fixed (calendar chunks have interval_length=0) +ALTER TABLE merge_test SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'device_id', + timescaledb.compress_orderby = 'time', + timescaledb.compress_chunk_time_interval = '90 days' +); + +-- Verify the compress_interval_length was set (should be 90 days in microseconds) +SELECT d.compress_interval_length / 86400000000 as compress_interval_days +FROM _timescaledb_catalog.dimension d +JOIN _timescaledb_catalog.hypertable h ON d.hypertable_id = h.id +WHERE h.table_name = 'merge_test'; + +-- Compress all chunks +-- Jan (31 days) + Feb (29 days, leap year) = 60 days < 90 days -> merge into Jan +-- 60 days + Mar (31 days) = 91 days > 90 days -> Mar starts new chunk +-- Mar (31 days) + Apr (30 days) = 61 days < 90 days -> merge into Mar +SELECT compress_chunk(chunk, true) FROM show_chunks('merge_test') chunk; + +-- Show chunks after compression - Jan+Feb merged, Mar+Apr merged (2 chunks total) +SELECT c.chunk_name, c.is_compressed, c.range_start, c.range_end +FROM timescaledb_information.chunks c +WHERE c.hypertable_name = 'merge_test' +ORDER BY c.range_start; + +-- Verify data integrity after merge +SELECT count(*) as total_rows FROM merge_test; + +-- Clean up merge test table +DROP TABLE merge_test; + +--------------------------------------------------------------- +-- RETENTION POLICY with calendar-chunked hypertables +--------------------------------------------------------------- + +-- Add retention policy with 2 week threshold +SELECT add_retention_policy('metrics_weekly', INTERVAL '2 weeks', schedule_interval => INTERVAL '1 year') as weekly_retention_job \gset + +-- Mock time to 2024-02-01 - with 2 week policy, first two chunks will be dropped +SET timescaledb.current_timestamp_mock = '2024-02-01 00:00:00+01'; + +-- Show chunks before running retention policy (4 chunks: Jan 1-7, 8-14, 15-21, 22-28) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'metrics_weekly' +ORDER BY range_start; + +-- Run the retention policy job +CALL run_job(:weekly_retention_job); + +-- Verify first two chunks were dropped (older than 2 weeks from mock time) +SELECT chunk_name, range_start, range_end +FROM timescaledb_information.chunks +WHERE hypertable_name = 'metrics_weekly' +ORDER BY range_start; + +RESET timescaledb.current_timestamp_mock; + +--------------------------------------------------------------- +-- CLEANUP +--------------------------------------------------------------- + +-- Drop CAggs first (they depend on the tables) +DROP MATERIALIZED VIEW cagg_monthly; +DROP MATERIALIZED VIEW cagg_daily; +DROP MATERIALIZED VIEW cagg_hourly; +DROP MATERIALIZED VIEW cagg_fiscal; +DROP TABLE cagg_test_monthly; + +-- Remove policies +SELECT remove_retention_policy('metrics_weekly', if_exists => true); +SELECT remove_compression_policy('metrics_monthly', if_exists => true); + +-- Drop objects +DROP TABLE metrics_monthly; +DROP TABLE metrics_weekly; +DROP TABLE metrics_daily; +DROP TABLE metrics_fiscal; + +RESET timescaledb.enable_calendar_chunking;