Skip to content

tsl/compression: fix stale sparse-index entries after rebuild_sparse_index - #10324

Merged
Poroma-Banerjee merged 1 commit into
timescale:mainfrom
tureba:fix/rebuild-sparse-index-stale-indexes
Jul 27, 2026
Merged

tsl/compression: fix stale sparse-index entries after rebuild_sparse_index#10324
Poroma-Banerjee merged 1 commit into
timescale:mainfrom
tureba:fix/rebuild-sparse-index-stale-indexes

Conversation

@tureba

@tureba tureba commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

What type of bug is this?

Data corruption / wrong query results

What subsystems and features are affected?

Compression (sparse indexes / columnstore), rebuild_sparse_index()

What happened?

After changing a hypertable's compress_index setting and calling rebuild_sparse_index() (or recompressing) on an already-compressed chunk, the btree index that TimescaleDB maintains on the compressed table's sparse index metadata columns (segmentby column plus _ts_meta_v2_first_time/_ts_meta_v2_last_time, and any configured minmax/bloom columns) can end up pointing at superseded, dead tuples instead of the row's new location.

Once that happens, any query plan that uses an index scan or bitmap scan against the compressed table (chosen by the planner, or forced via enable_seqscan = off) silently returns fewer rows than exist, or the wrong rows, for the affected compressed batch — while a sequential scan over the same table returns the correct data. This is a genuine data-correctness bug: two scan strategies over the same committed data return different results.

The bug does not always surface immediately after the metadata rewrite, which is why it can look intermittent: PostgreSQL's HOT-chain "redirect" mechanism transparently follows a stale index entry to the tuple's current location, as long as the whole update chain stays on the same heap page. The wrong results only become visible once that redirect chain is broken — either by ordinary autovacuum/pruning activity, or immediately, whenever the metadata rewrite doesn't fit back onto its original page and the tuple must relocate.

Root cause

populate_sparse_index_columns() in tsl/src/compression/recompress.c rewrites each compressed batch's sparse-index metadata columns in place:

HeapTuple new_tuple = heap_modify_tuple(compressed_tuple, ...);
ExecStoreHeapTuple(new_tuple, update_slot, false);

/*
 * Sparse index metadata columns are not covered by any index.
 * If indexes on metadata columns are added in the future,
 * this will need to handle index updates via update_indexes.
 */
TU_UpdateIndexes update_indexes;
simple_table_tuple_update(compressed_rel, &tid, update_slot,
                          GetActiveSnapshot(), &update_indexes);
ExecClearTuple(update_slot);

The comment's premise is stale: the sparse index metadata columns are covered by a btree index (<chunk>_compressed_<segmentby>__ts_meta_v2_first_time___idx, covering the segmentby column, _ts_meta_v2_first_time, _ts_meta_v2_last_time, plus any minmax/bloom columns configured via compress_index). update_indexes is computed correctly by simple_table_tuple_update()/heap_update() on every call, but the result is discarded — no new index entry is ever inserted for a non-HOT update, leaving the old entry (now pointing at a dead tuple) as the only index entry for that row.

How can we reproduce the bug?

This reproduces deterministically on stock PostgreSQL — no special extension or vendor build required, and confirmed on PostgreSQL 16, 17, and 18. The key is to make each compressed row wide enough that the sparse-index metadata rewrite can't fit back onto its original heap page, forcing a non-HOT update:

CREATE EXTENSION IF NOT EXISTS timescaledb;

CREATE TABLE rsi_bug(
    ts timestamptz NOT NULL,
    device text NOT NULL,
    val int,
    filler text DEFAULT repeat('x', 500)  -- pads rows so pages fill up
);
SELECT create_hypertable('rsi_bug', 'ts');

INSERT INTO rsi_bug(ts, device, val)
SELECT '2024-01-01'::timestamptz + (i * interval '1 second'), 'd' || (i % 20), i
FROM generate_series(1, 4000) i;

SELECT format('%I.%I', chunk_schema, chunk_name)::regclass AS chunk1
FROM timescaledb_information.chunks
WHERE hypertable_name = 'rsi_bug' ORDER BY range_start LIMIT 1 \gset

ALTER TABLE rsi_bug SET (
    timescaledb.compress,
    timescaledb.compress_segmentby = 'device',
    timescaledb.compress_orderby = 'ts',
    timescaledb.compress_index = 'bloom("val")'
);
SELECT compress_chunk(:'chunk1');

-- Drop and restore the sparse index config, rebuilding it each time --
-- this is a supported, documented workflow, not an edge case.
ALTER TABLE rsi_bug SET (
    timescaledb.compress,
    timescaledb.compress_segmentby = 'device',
    timescaledb.compress_orderby = 'ts',
    timescaledb.compress_index = ''
);
SELECT _timescaledb_functions.rebuild_sparse_index(:'chunk1'::regclass);

ALTER TABLE rsi_bug SET (
    timescaledb.compress,
    timescaledb.compress_segmentby = 'device',
    timescaledb.compress_orderby = 'ts',
    timescaledb.compress_index = 'bloom("val")'
);
SELECT _timescaledb_functions.rebuild_sparse_index(:'chunk1'::regclass);

-- Ground truth: sequential scan, one row per device
SET enable_indexscan = off;
SET enable_bitmapscan = off;
SELECT device, count(*) FROM <compressed_table> WHERE device = 'd5';
RESET enable_indexscan;
RESET enable_bitmapscan;
-- -> 1 row

-- Forced index/bitmap scan over the SAME committed data
SET enable_seqscan = off;
EXPLAIN (analyze, costs off)
SELECT device FROM <compressed_table> WHERE device = 'd5';
SELECT device FROM <compressed_table> WHERE device = 'd5';
RESET enable_seqscan;
-- -> 0 rows (WRONG: should match the seq scan above)

(<compressed_table> is the chunk's internal compressed relation, found via _timescaledb_catalog.compression_settings.compress_relid for the chunk.)

Actual output observed on a clean vanilla PostgreSQL 17.9 build:

Bitmap Heap Scan on _hyper_3_5_chunk_compressed (actual time=0.011..0.012 rows=0 loops=1)
  Recheck Cond: (device = 'd5'::text)
  Heap Blocks: exact=1
  ->  Bitmap Index Scan on _hyper_3_5_chunk_compressed_device__ts_meta_v2_first_ts__ts_idx (actual time=0.007..0.008 rows=3 loops=1)
        Index Cond: (device = 'd5'::text)

 device
--------
(0 rows)

vs. the sequential-scan ground truth, which correctly returns d5.

Root-cause confirmation (via pageinspect): the btree index still contains entries pointing at the row's pre-rebuild ctid; the row's actual current location (after the metadata rewrite forced it onto a different heap page) has no corresponding index entry at all.

The fix

populate_sparse_index_columns() now opens the compressed table's indexes once (CatalogOpenIndexes) and inserts a new index entry after each update whenever it wasn't HOT-eligible, using the TU_UpdateIndexes result the code already computed but previously discarded (ts_catalog_index_insert(), TimescaleDB's own vendored copy of PostgreSQL's static CatalogIndexInsert()) — mirroring the same open-index/update/insert-index-entry sequence PostgreSQL's own CatalogTupleUpdate() uses for catalog tuples.

This also required switching from simple_table_tuple_update() to simple_heap_update(): the former funnels the update through a TupleTableSlot and materializes a throwaway copy of the tuple inside heapam_tuple_update(), so the caller's own HeapTuple is never updated with the tuple's real post-update location or HOT status — using it to drive ts_catalog_index_insert() silently inserted nothing. simple_heap_update() operates directly on the passed HeapTuple, exactly as CatalogTupleUpdate() relies on, and is consistent with the rest of this function, which already assumes a plain heap-backed relation (heap_modify_tuple, heap_deform_tuple).

Added a regression check in rebuild_sparse_index.sql that forces an index/bitmap scan after the compress_index drop-and-restore dance in Test 10 and asserts the per-device batch counts match a sequential scan; this fails against unpatched main and passes with the fix.

Verified against clean source builds of vanilla PostgreSQL 16.14, 17.9, and 18.4: compiles warning-free, and the full compress*/recompress*/*sparse* TSL regression suite (51 tests) passes on all three.

@github-actions
github-actions Bot requested a review from akuzm July 25, 2026 18:34
@github-actions

Copy link
Copy Markdown

@akuzm, @dbeck: please review this pull request.

Powered by pull-review

@github-actions
github-actions Bot requested a review from dbeck July 25, 2026 18:34
@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@tureba

tureba commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

At first glance, I don't see how failures in cagg_hierarchical_concurrent_refresh and cagg_concurrent_policy_register would be caused by my proposed changes. Those tests pass for me locally, and that is an independent feature, AFAIK.

EDIT: Actually, I see them fail in other PRs as well, with the same outputs: 10315 10313.
So it looks like they are just unstable tests overall, and their failures are unrelated to this PR.

@svenklemm svenklemm added this to the v2.29.0 milestone Jul 27, 2026
@Poroma-Banerjee

Copy link
Copy Markdown
Member

Please add a changelog entry.

@Poroma-Banerjee

Copy link
Copy Markdown
Member

Adding the changelog entry myself

…index

populate_sparse_index_columns() rewrites each compressed batch's sparse
index metadata columns (min/max, first/last, bloom) in place via
simple_table_tuple_update(), but discarded the TU_UpdateIndexes result
that call computes instead of acting on it. When the rewrite wasn't
HOT-eligible (typically because the row no longer fits on its
original page), the btree index covering those columns never got a
new entry for the row's new location, and the stale entry pointing at
the superseded tuple was left behind.

The stale entry usually goes unnoticed because PostgreSQL's HOT-chain
"redirect" mechanism transparently follows it to the live tuple, as
long as the chain stays on the same page. Once that chain is broken --
by ordinary heap pruning/vacuum, or whenever the rewrite must relocate
to a different page -- an index or bitmap scan using the stale entry
silently returns the wrong (or zero) rows for the affected batch,
while a sequential scan still returns the correct data. This can
surface as wrong query results for compressed hypertables after
compress_index changes (e.g. via rebuild_sparse_index() or an
ALTER TABLE compress_index change followed by recompression).

Fix by opening the compressed table's indexes once per
populate_sparse_index_columns() call and inserting the new entry
whenever the update actually wrote a new tuple, mirroring the same
open-index/simple-update/insert-index-entry sequence PostgreSQL's own
CatalogTupleUpdate() uses for catalog tuples. This requires switching
from simple_table_tuple_update() to simple_heap_update(): the former
funnels the update through a TupleTableSlot and materializes a
throwaway copy of the tuple inside heapam_tuple_update(), so the
caller's own HeapTuple is never updated with the tuple's real
post-update location or HOT status. simple_heap_update() operates
directly on the passed HeapTuple, exactly as CatalogTupleUpdate()
relies on -- consistent with the rest of this function, which already
assumes a plain heap-backed relation (heap_modify_tuple,
heap_deform_tuple).

Adds a regression check that forces an index/bitmap scan after the
rebuild_sparse_index() drop-and-restore dance in Test 10 and confirms
per-device batch counts match a sequential scan; previously this
would return fewer rows once the affected batch's index entry had
gone stale.
@Poroma-Banerjee
Poroma-Banerjee force-pushed the fix/rebuild-sparse-index-stale-indexes branch from edee827 to 5f6d5a1 Compare July 27, 2026 16:03
@svenklemm svenklemm added the force-auto-backport Automatically backport this PR or fix of this issue, even if it's not marked as "bug" label Jul 27, 2026
@Poroma-Banerjee
Poroma-Banerjee merged commit 8e8e1c5 into timescale:main Jul 27, 2026
67 checks passed
@surister surister mentioned this pull request Jul 28, 2026
surister pushed a commit that referenced this pull request Jul 28, 2026
## 2.29.0 (2026-07-28)

This release contains performance improvements and bug fixes since the
2.28.3 release. We recommend that you upgrade at the next available
opportunity.

**Release Highlights**
* **Chunk exclusion for DML operations** drastically improves the
performance of `UPDATE` and `DELETE` statements on hypertables. By
acquiring exclusive locks only on the specific chunks being modified
rather than the entire hypertable, this enhancement eliminates massive
lock contention and keeps high-concurrency workloads running smoothly
without unnecessary slowdowns.
* Intelligent **row-by-row decompression** enables the query planner to
decompress data row-by-row rather than in large batches when an
operation prioritizes a fast initial response (such as queries with
`LIMIT` clauses). This dramatically reduces memory overhead and query
latency, ensuring lightning-fast performance when you only need to
retrieve a small subset of records from your compressed hypertables.

**Important: PostgreSQL 15 Support Removed**
TimescaleDB 2.29.0 removes support for PostgreSQL 15. This release
supports PostgreSQL 16, 17, and 18. If you are still running PostgreSQL
15, upgrade PostgreSQL before upgrading to TimescaleDB 2.29.0.

**Backward-Incompatible Changes**
* [#10041](#10041) Remove
support for PostgreSQL 15

**Features**
* [#9315](#9315) Speed up
`DML` operations on hypertables by using the optimized TimescaleDB
hypertable expansion code instead of the generic PostgreSQL inheritance
hierarchy expansion
* [#9534](#9534) Speed up
expression evaluation in the columnar pipeline by caching common
subexpressions
* [#9684](#9684) Add
`_timescaledb_functions.decompress_batch()` SQL function
* [#9732](#9732) Speed up
some queries with small `LIMIT` by switching to row-by-row query
execution pipeline
* [#9917](#9917) Decompress
less data in `DML` on compressed hypertables by accounting for prepared
statement parameters
* [#9957](#9957) Add
`compact_chunk()` function
* [#10048](#10048) Support
concurrent refresh policies on hierarchical continuous aggregates
* [#10081](#10081) Add
`samplerate` argument to
`_timescaledb_functions.estimate_uncompressed_size()`
* [#10100](#10100) Skip
classifying compressed relations to speed up planning
* [#10118](#10118) Don't
track compressed relations as separate chunk
* [#10119](#10119) Reduce
memory usage of `INSERT` queries using direct compress and spanning
multiple chunks
* [#10163](#10163) Add a
compaction policy for unordered chunks
* [#10204](#10204) Don't
create separate hypertable catalog entry for hypertables with
compression
* [#10217](#10217) Initial
placeholder version of granular refresh API
* [#10225](#10225) Add
`config_merge` parameter to `alter_job()` for merging `jsonb` into the
existing job configuration
* [#10226](#10226) Add
`recompress_unordered` columnstore policy option
* [#10231](#10231) Use
`regclass` for storing relation reference in chunk table
* [#10237](#10237) Add
helper functions for decoding hypertable status
* [#10240](#10240) Add the
`tsdb.direct_compress` storage parameter that allows enabling direct
compress for a given hypertable independent of global settings
* [#10266](#10266) Add
`max_batches` to `compact_chunk()`
* [#10299](#10299) Add
`continuous_aggs_tenant_tracking` and `hypertable_cagg_settings`
catalogs

**Bugfixes**
* [#10013](#10013) Make
ownership error messages on continuous aggregates consistent
* [#10052](#10052) Result
of `MIN` / `MAX` aggregate functions in columnar aggregation pipeline
possibly inconsistent with plain PostgreSQL result
* [#10071](#10071) Prune
the real-time branch of hierarchical continuous aggregates at any
nesting depth
* [#10143](#10143) Fix
division by zero when planning `time_bucket` with zero width
* [#10199](#10199) Fix
`initial_start` handling in `build_job_info`
* [#10213](#10213) Cache
sort pathkeys per hypertable
* [#10221](#10221) Fix
incremental refresh skipping the last bucket
* [#10278](#10278) Drop
`job_errors` view in `bgw_job_stat_history` migration
* [#10280](#10280)
`RETURNING` clause returned no rows for `INSERT` using direct compress
* [#10281](#10281) Disable
direct compress when the destination table has an exclusion constraint
so the constraint is still enforced
* [#10282](#10282) Only
count directly compressed rows toward the command tag when the `INSERT`
sets it
* [#10286](#10286)
Propagate `VACUUM` on a chunk to the compressed relation when running on
the chunk directly
* [#10302](#10302) Fix
useless-join removal and self-join elimination for hypertables
* [#10313](#10313) Allow
running `ALTER EXTENSION timescaledb UPDATE` inside a transaction block
* [#10315](#10315) Fix
overlap detection with running max
* [#10324](#10324) Fix
stale index entries after `rebuild_sparse_index()` on compressed chunks

**GUCs**
* `timescaledb.enable_hypertable_expansion_for_dml`: allow using the
optimized TimescaleDB hypertable expansion code for `UPDATE` and
`DELETE` instead of the generic PostgreSQL inheritance hierarchy
expansion. On by default.

**Thanks**
* @FrancescEthon and @ManuelEthon for reporting the issue
* @h0rn3t for reporting a problem with `VACUUM` not propagating to the
compressed relation
* @MaximeEthon for reporting an issue with prepared statement parameters
in DML decompression
* @proddata for reporting a problem when upgrading from 2.15.3 to 2.28.2
* @tureba for reporting and fixing stale sparse-index entries after
rebuild
* @viniciusrsouza for reporting an issue with hierarchical continuous
aggregates
svenklemm pushed a commit that referenced this pull request Jul 28, 2026
This release contains performance improvements and bug fixes since the
2.28.3 release. We recommend that you upgrade at the next available
opportunity.

**Release Highlights**
* **Chunk exclusion for DML operations** drastically improves the
performance of `UPDATE` and `DELETE` statements on hypertables. By
acquiring exclusive locks only on the specific chunks being modified
rather than the entire hypertable, this enhancement eliminates massive
lock contention and keeps high-concurrency workloads running smoothly
without unnecessary slowdowns.
* Intelligent **row-by-row decompression** enables the query planner to
decompress data row-by-row rather than in large batches when an
operation prioritizes a fast initial response (such as queries with
`LIMIT` clauses). This dramatically reduces memory overhead and query
latency, ensuring lightning-fast performance when you only need to
retrieve a small subset of records from your compressed hypertables.

**Important: PostgreSQL 15 Support Removed**
TimescaleDB 2.29.0 removes support for PostgreSQL 15. This release
supports PostgreSQL 16, 17, and 18. If you are still running PostgreSQL
15, upgrade PostgreSQL before upgrading to TimescaleDB 2.29.0.

**Backward-Incompatible Changes**
* [#10041](#10041) Remove
support for PostgreSQL 15

**Features**
* [#9315](#9315) Speed up
`DML` operations on hypertables by using the optimized TimescaleDB
hypertable expansion code instead of the generic PostgreSQL inheritance
hierarchy expansion
* [#9534](#9534) Speed up
expression evaluation in the columnar pipeline by caching common
subexpressions
* [#9684](#9684) Add
`_timescaledb_functions.decompress_batch()` SQL function
* [#9732](#9732) Speed up
some queries with small `LIMIT` by switching to row-by-row query
execution pipeline
* [#9917](#9917) Decompress
less data in `DML` on compressed hypertables by accounting for prepared
statement parameters
* [#9957](#9957) Add
`compact_chunk()` function
* [#10048](#10048) Support
concurrent refresh policies on hierarchical continuous aggregates
* [#10081](#10081) Add
`samplerate` argument to
`_timescaledb_functions.estimate_uncompressed_size()`
* [#10100](#10100) Skip
classifying compressed relations to speed up planning
* [#10118](#10118) Don't
track compressed relations as separate chunk
* [#10119](#10119) Reduce
memory usage of `INSERT` queries using direct compress and spanning
multiple chunks
* [#10163](#10163) Add a
compaction policy for unordered chunks
* [#10204](#10204) Don't
create separate hypertable catalog entry for hypertables with
compression
* [#10217](#10217) Initial
placeholder version of granular refresh API
* [#10225](#10225) Add
`config_merge` parameter to `alter_job()` for merging `jsonb` into the
existing job configuration
* [#10226](#10226) Add
`recompress_unordered` columnstore policy option
* [#10231](#10231) Use
`regclass` for storing relation reference in chunk table
* [#10237](#10237) Add
helper functions for decoding hypertable status
* [#10240](#10240) Add the
`tsdb.direct_compress` storage parameter that allows enabling direct
compress for a given hypertable independent of global settings
* [#10266](#10266) Add
`max_batches` to `compact_chunk()`
* [#10299](#10299) Add
`continuous_aggs_tenant_tracking` and `hypertable_cagg_settings`
catalogs

**Bugfixes**
* [#10013](#10013) Make
ownership error messages on continuous aggregates consistent
* [#10052](#10052) Result
of `MIN` / `MAX` aggregate functions in columnar aggregation pipeline
possibly inconsistent with plain PostgreSQL result
* [#10071](#10071) Prune
the real-time branch of hierarchical continuous aggregates at any
nesting depth
* [#10143](#10143) Fix
division by zero when planning `time_bucket` with zero width
* [#10199](#10199) Fix
`initial_start` handling in `build_job_info`
* [#10213](#10213) Cache
sort pathkeys per hypertable
* [#10221](#10221) Fix
incremental refresh skipping the last bucket
* [#10278](#10278) Drop
`job_errors` view in `bgw_job_stat_history` migration
* [#10280](#10280)
`RETURNING` clause returned no rows for `INSERT` using direct compress
* [#10281](#10281) Disable
direct compress when the destination table has an exclusion constraint
so the constraint is still enforced
* [#10282](#10282) Only
count directly compressed rows toward the command tag when the `INSERT`
sets it
* [#10286](#10286)
Propagate `VACUUM` on a chunk to the compressed relation when running on
the chunk directly
* [#10302](#10302) Fix
useless-join removal and self-join elimination for hypertables
* [#10313](#10313) Allow
running `ALTER EXTENSION timescaledb UPDATE` inside a transaction block
* [#10315](#10315) Fix
overlap detection with running max
* [#10324](#10324) Fix
stale index entries after `rebuild_sparse_index()` on compressed chunks

**GUCs**
* `timescaledb.enable_hypertable_expansion_for_dml`: allow using the
optimized TimescaleDB hypertable expansion code for `UPDATE` and
`DELETE` instead of the generic PostgreSQL inheritance hierarchy
expansion. On by default.

**Thanks**
* @FrancescEthon and @ManuelEthon for reporting an issue with incremental refresh skipping the last bucket
* @h0rn3t for reporting a problem with `VACUUM` not propagating to the
* @igor2x for reporting an issue with locking during DML statements on hypertables
compressed relation
* @MaximeEthon for reporting an issue with prepared statement parameters
in DML decompression
* @proddata for reporting a problem when upgrading from 2.15.3 to 2.28.2
* @tureba for reporting and fixing stale sparse-index entries after
rebuild
* @viniciusrsouza for reporting an issue with hierarchical continuous
aggregates
surister pushed a commit to surister/timescaledb that referenced this pull request Jul 28, 2026
This release contains performance improvements and bug fixes since the
2.28.3 release. We recommend that you upgrade at the next available
opportunity.

**Release Highlights**
* **Chunk exclusion for DML operations** drastically improves the
performance of `UPDATE` and `DELETE` statements on hypertables. By
acquiring exclusive locks only on the specific chunks being modified
rather than the entire hypertable, this enhancement eliminates massive
lock contention and keeps high-concurrency workloads running smoothly
without unnecessary slowdowns.
* Intelligent **row-by-row decompression** enables the query planner to
decompress data row-by-row rather than in large batches when an
operation prioritizes a fast initial response (such as queries with
`LIMIT` clauses). This dramatically reduces memory overhead and query
latency, ensuring lightning-fast performance when you only need to
retrieve a small subset of records from your compressed hypertables.

**Important: PostgreSQL 15 Support Removed**
TimescaleDB 2.29.0 removes support for PostgreSQL 15. This release
supports PostgreSQL 16, 17, and 18. If you are still running PostgreSQL
15, upgrade PostgreSQL before upgrading to TimescaleDB 2.29.0.

**Backward-Incompatible Changes**
* [timescale#10041](timescale#10041) Remove
support for PostgreSQL 15

**Features**
* [timescale#9315](timescale#9315) Speed up
`DML` operations on hypertables by using the optimized TimescaleDB
hypertable expansion code instead of the generic PostgreSQL inheritance
hierarchy expansion
* [timescale#9534](timescale#9534) Speed up
expression evaluation in the columnar pipeline by caching common
subexpressions
* [timescale#9684](timescale#9684) Add
`_timescaledb_functions.decompress_batch()` SQL function
* [timescale#9732](timescale#9732) Speed up
some queries with small `LIMIT` by switching to row-by-row query
execution pipeline
* [timescale#9917](timescale#9917) Decompress
less data in `DML` on compressed hypertables by accounting for prepared
statement parameters
* [timescale#9957](timescale#9957) Add
`compact_chunk()` function
* [timescale#10048](timescale#10048) Support
concurrent refresh policies on hierarchical continuous aggregates
* [timescale#10081](timescale#10081) Add
`samplerate` argument to
`_timescaledb_functions.estimate_uncompressed_size()`
* [timescale#10100](timescale#10100) Skip
classifying compressed relations to speed up planning
* [timescale#10118](timescale#10118) Don't
track compressed relations as separate chunk
* [timescale#10119](timescale#10119) Reduce
memory usage of `INSERT` queries using direct compress and spanning
multiple chunks
* [timescale#10163](timescale#10163) Add a
compaction policy for unordered chunks
* [timescale#10204](timescale#10204) Don't
create separate hypertable catalog entry for hypertables with
compression
* [timescale#10217](timescale#10217) Initial
placeholder version of granular refresh API
* [timescale#10225](timescale#10225) Add
`config_merge` parameter to `alter_job()` for merging `jsonb` into the
existing job configuration
* [timescale#10226](timescale#10226) Add
`recompress_unordered` columnstore policy option
* [timescale#10231](timescale#10231) Use
`regclass` for storing relation reference in chunk table
* [timescale#10237](timescale#10237) Add
helper functions for decoding hypertable status
* [timescale#10240](timescale#10240) Add the
`tsdb.direct_compress` storage parameter that allows enabling direct
compress for a given hypertable independent of global settings
* [timescale#10266](timescale#10266) Add
`max_batches` to `compact_chunk()`
* [timescale#10299](timescale#10299) Add
`continuous_aggs_tenant_tracking` and `hypertable_cagg_settings`
catalogs

**Bugfixes**
* [timescale#10013](timescale#10013) Make
ownership error messages on continuous aggregates consistent
* [timescale#10052](timescale#10052) Result
of `MIN` / `MAX` aggregate functions in columnar aggregation pipeline
possibly inconsistent with plain PostgreSQL result
* [timescale#10071](timescale#10071) Prune
the real-time branch of hierarchical continuous aggregates at any
nesting depth
* [timescale#10143](timescale#10143) Fix
division by zero when planning `time_bucket` with zero width
* [timescale#10199](timescale#10199) Fix
`initial_start` handling in `build_job_info`
* [timescale#10213](timescale#10213) Cache
sort pathkeys per hypertable
* [timescale#10221](timescale#10221) Fix
incremental refresh skipping the last bucket
* [timescale#10278](timescale#10278) Drop
`job_errors` view in `bgw_job_stat_history` migration
* [timescale#10280](timescale#10280)
`RETURNING` clause returned no rows for `INSERT` using direct compress
* [timescale#10281](timescale#10281) Disable
direct compress when the destination table has an exclusion constraint
so the constraint is still enforced
* [timescale#10282](timescale#10282) Only
count directly compressed rows toward the command tag when the `INSERT`
sets it
* [timescale#10286](timescale#10286)
Propagate `VACUUM` on a chunk to the compressed relation when running on
the chunk directly
* [timescale#10302](timescale#10302) Fix
useless-join removal and self-join elimination for hypertables
* [timescale#10313](timescale#10313) Allow
running `ALTER EXTENSION timescaledb UPDATE` inside a transaction block
* [timescale#10315](timescale#10315) Fix
overlap detection with running max
* [timescale#10324](timescale#10324) Fix
stale index entries after `rebuild_sparse_index()` on compressed chunks

**GUCs**
* `timescaledb.enable_hypertable_expansion_for_dml`: allow using the
optimized TimescaleDB hypertable expansion code for `UPDATE` and
`DELETE` instead of the generic PostgreSQL inheritance hierarchy
expansion. On by default.

**Thanks**
* @FrancescEthon and @ManuelEthon for reporting an issue with incremental refresh skipping the last bucket
* @h0rn3t for reporting a problem with `VACUUM` not propagating to the
* @igor2x for reporting an issue with locking during DML statements on hypertables
compressed relation
* @MaximeEthon for reporting an issue with prepared statement parameters
in DML decompression
* @proddata for reporting a problem when upgrading from 2.15.3 to 2.28.2
* @tureba for reporting and fixing stale sparse-index entries after
rebuild
* @viniciusrsouza for reporting an issue with hierarchical continuous
aggregates
@timescale-automation timescale-automation added the released-2.29.0 Released in 2.29.0 label Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backported-2.29.x force-auto-backport Automatically backport this PR or fix of this issue, even if it's not marked as "bug" released-2.29.0 Released in 2.29.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants