Skip to content

Add firstlast sparse index type for compression - #9580

Merged
antekresic merged 1 commit into
timescale:mainfrom
antekresic:add_first_last_sparse
May 6, 2026
Merged

Add firstlast sparse index type for compression#9580
antekresic merged 1 commit into
timescale:mainfrom
antekresic:add_first_last_sparse

Conversation

@antekresic

Copy link
Copy Markdown
Member

Add a new sparse index type that stores the actual first and last values of each compressed batch in sort order, including NULLs. Unlike minmax, which skips NULLs and tracks statistical extremes, firstlast tracks positional boundary values. This enables tracking batch ordering and NULL presence at batch boundaries.

@antekresic antekresic added this to the v2.27.0 milestone Apr 13, 2026
@antekresic antekresic self-assigned this Apr 13, 2026
@antekresic antekresic added the enhancement An enhancement to an existing feature for functionality label Apr 13, 2026
@antekresic
antekresic requested a review from a team April 13, 2026 12:11
@github-actions

Copy link
Copy Markdown

@melihmutlu, @natalya-aksman: please review this pull request.

Powered by pull-review

@antekresic
antekresic force-pushed the add_first_last_sparse branch 2 times, most recently from 636e04c to a3ef185 Compare April 13, 2026 12:22
@dbeck

dbeck commented Apr 13, 2026

Copy link
Copy Markdown
Member

How about dropping and renaming columns? What happens to the corresponding sparse indexes?

@codecov

codecov Bot commented Apr 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.52459% with 14 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...src/compression/batch_metadata_builder_firstlast.c 87.32% 5 Missing and 4 partials ⚠️
tsl/src/compression/compression.c 81.25% 0 Missing and 3 partials ⚠️
tsl/src/compression/create.c 89.47% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

settings->fd.relid,
attr->attnum,
settings->fd.compress_relid,
"first");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like "first" and "last" as the names for the individual boundaries, but maybe we should rename the index to "range" or something?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think first last are descriptive enough. Range is particularly deceptive since it only makes sense for the first orderby column, everything else is not really range like.

@svenklemm svenklemm Apr 28, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

current first/last can only order by a single column, does the firstindex sparse index share that limitation?

break;
case _SparseIndexTypeEnumFirstLast:
{
FirstLastIndexColumnConfig *firstlast_config = (FirstLastIndexColumnConfig *) config;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are they per column? I remember we went over various aspects that influence this decision, i.e. how to plug them into a btree index then. Could you please write a short note of what you ultimately decided and why?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just the first step of introducing these sparse indexes. After this will go into adding them to the btree index which I'm not quite sure how to approach given we use minmax for filtering and we can't really use this for the same purpose.

Thus, this PR is solely focused for the initial step off adding the sparse indexes themselves.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm mostly thinking about the btree we use for ordering. There are other options for storing this, e.g. storing column values, or storing values of record, or even a range of records. But we decided that we want to store per-column values, because we need a btree index on this that matches our "orderby" configuration. And that configuration can use desc/asc nulls first/last, which you can't configure for record fields. And also there's a thing that we can't get away with only storing the "first" or the "last" in the btree, because there are batches where "first" and "last" are the same, and we can't distinguish them by a single boundary. And so on.

This is all non-obvious and we have to get this straight before we release this, so it might make sense to document for the other people looking at the PR.

@antekresic

Copy link
Copy Markdown
Member Author

@dbeck looks like renaming/dropping isn't fully covered on the existing implementation. Should probably handle that correctly in a separate PR.

@dbeck

dbeck commented Apr 13, 2026

Copy link
Copy Markdown
Member

@dbeck looks like renaming/dropping isn't fully covered on the existing implementation. Should probably handle that correctly in a separate PR.

I think the full lifecycle should be in the same PR, otherwise we may never get the rest implemented.

@antekresic

Copy link
Copy Markdown
Member Author

I don't think its smart for every sparse index we add to need to manually handle dropping/renaming of columns, it should be part of the generic codebase that handles it automatically based on the compression settings.

@akuzm

akuzm commented Apr 13, 2026

Copy link
Copy Markdown
Member

I don't think its smart for every sparse index we add to need to manually handle dropping/renaming of columns, it should be part of the generic codebase that handles it automatically based on the compression settings.

I thought this worked... At the moment changing the indexes just requires full recompression, and this just recreates the sparse indexes, right? And @Poroma-Banerjee is working on a smarter approach that would allow us to change this more granularly.

@dbeck

dbeck commented Apr 13, 2026

Copy link
Copy Markdown
Member

I don't think its smart for every sparse index we add to need to manually handle dropping/renaming of columns, it should be part of the generic codebase that handles it automatically based on the compression settings.

Both for dropping and renaming columns we need to update the compression settings and we need to handle the metadata column changes as well (rename the metadata column or dropping it).

In the absence of generic handling we can't just YOLO new metdata columns and new settings field. I do not thin this is right.

@dbeck

dbeck commented Apr 13, 2026

Copy link
Copy Markdown
Member

I don't think its smart for every sparse index we add to need to manually handle dropping/renaming of columns, it should be part of the generic codebase that handles it automatically based on the compression settings.

I thought this worked... At the moment changing the indexes just requires full recompression, and this just recreates the sparse indexes, right? And @Poroma-Banerjee is working on a smarter approach that would allow us to change this more granularly.

@akuzm : do you mean the case when the change is triggered by an ALTER table SET command? I think we also need to handle the the ALTER .. DROP and ALTER .. RENAME cases. These don't need a recompression.

@Poroma-Banerjee

Poroma-Banerjee commented Apr 13, 2026

Copy link
Copy Markdown
Member

https://github.com/timescale/eng-database/issues/772 -> first(value, time) and last(value, time) using ColumnarIndexScan

Based on above naming might probably clash with other sparse indexes that are to be introduced. Actually I'm confused with whether it is the same thing.

  • minmax of column x. will store the min(x) and max(x) regardless of batch sort order. excludes NULLS
  • firstlast of column x. will store be value of x at min(time) and value of x at max(time). This will be batch boundary and can be NULL.
  • what if orderby is NULLABLE?
    Then, firstlast of column 'time'. will store first as min(time) and last as max(time) or NULL if last tuple of the Batch was NULL.

In that case firstlast could be leveraged to support first(value, time) and last(value, time) using ColumnarIndexScan because it sounds like the same thing

#include <utils/palloc.h>

TSDLLEXPORT const char *ts_sparse_index_type_names[] = { "bloom", "minmax" };
TSDLLEXPORT const char *ts_sparse_index_type_names[] = { "bloom", "minmax", "firstlast" };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there value in having first/last be separate? user might only need one of the two

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devils advocate: you might say the same for minmax. I don't think the increased complexity of separating them out warrants the storage savings.

@natalya-aksman

natalya-aksman commented May 1, 2026

Copy link
Copy Markdown
Member

I wonder if we could consider optionally saving has_nulls info which we obtain while creating BatchMetadataBuilderMinMax builder here.

We can save it in a index like _ts_meta_hasnulls_1 to go with _ts_meta_min_1 and _ts_meta_max_1 if the (1st in this case) orderby column is not guaranteed to be NOT NULL.
This is all the info we need to correctly compare tuples when recompressing, as match_tuple_batch has access to nulls_is_first info for each orderby column and can correctly merge if it knows that orderby column has NULLs and knows whether they come first or last.

We can add this metadata column automatically along with minmax metadata columns if orderby column is not guaranteed to be NOT NULL. It'll be just an extra boolean column in compressed chunk schema.

@philkra philkra modified the milestones: v2.27.0, 2.28.0 May 5, 2026
@antekresic
antekresic force-pushed the add_first_last_sparse branch 3 times, most recently from ec5ceec to e4f7774 Compare May 6, 2026 11:07
Add a new sparse index type that stores the actual first and last
values of each compressed batch in sort order, including NULLs.
Unlike minmax, which skips NULLs and tracks statistical extremes,
firstlast tracks positional boundary values. This enables tracking
batch ordering and NULL presence at batch boundaries.
@antekresic
antekresic merged commit 714f840 into timescale:main May 6, 2026
53 checks passed
@surister surister mentioned this pull request Jun 9, 2026
surister pushed a commit that referenced this pull request Jun 16, 2026
## 2.28.0 (2026-06-16)

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

**Highlighted features in TimescaleDB v2.28.0**
* **Faster `first()` and `last()` queries on compressed data.**
TimescaleDB derives `first(value, time)` and `last(value, time)`
aggregates straight from the columnstore's batch metadata, skipping
batch decompression entirely. For the "latest reading per series"
lookups that time-series workloads run constantly, that means
meaningfully faster recency queries with no changes to your SQL queries.
* **Lighter, less disruptive continuous aggregate refreshes.**
`refresh_continuous_aggregate()` can now run incrementally in batches —
the same behavior refresh policies already use — enabling breaking large
manual refreshes into smaller chunks (tunable via `buckets_per_batch`,
`max_batches_per_execution`, and `refresh_newest_first`) instead of one
heavy operation. Refreshes also now take a lighter lock while processing
the invalidation log, so they no longer block unrelated concurrent
operations on the same continuous aggregate, improving behavior for
concurrent workloads.
* **Vectorized execution now covers `CASE` expressions.** TimescaleDB's
columnar executor can now evaluate `CASE ... WHEN` expressions directly
on compressed data, so queries using conditional logic stay on the fast
vectorized path instead of falling back to slower row-by-row
decompression. This speeds up a common pattern — conditional
aggregations and computed columns over compressed history — with no
query changes needed.
* **Add new aggregations to a continuous aggregate without rebuilding
it.** You can now run `ALTER MATERIALIZED VIEW <cagg> ADD COLUMN <name>
<type> GENERATED ALWAYS AS (<aggregate>) STORED` to add a new computed
aggregate to an existing continuous aggregate in place — no more
dropping and recreating the whole aggregate just to track one more
metric. New data populates the column going forward, letting your
rollups evolve alongside your application. (Existing rows start as
`NULL`; a forced refresh backfills them when you need historical
values.)

**Deprecation Notice: PostgreSQL 15 Support**
This release marks the final minor version of TimescaleDB that will
support PostgreSQL 15. Starting with our next release, version 2.29.0,
we will officially drop support for Postgres 15, and only support
Postgres 16, 17, and 18; however, all future patch releases within the
current 2.28 version cycle will continue to fully support it. We
recommend planning your PostgreSQL upgrades accordingly to ensure a
smooth transition.

**Deprecation Notice: `chunk_constraint` Catalog Table**
Please note that the `_timescaledb_catalog.chunk_constraint` table has
been dropped and temporarily replaced by a view, which introduces a
change to the underlying objects while maintaining current query
behavior. However, this compatibility view will be completely removed in
a future release. To ensure your queries remain compatible moving
forward, we strongly advise transitioning to the stable contracts
provided by our [informational
views](https://www.tigerdata.com/docs/reference/timescaledb/informational-views).

**Backward-Incompatible Changes**
* [#9934](#9934) Remove
adaptive chunking

**Features**
* [#4054](#4054) Support
`ANALYZE` and `VACUUM` on continuous aggregates by redirecting to the
underlying materialization hypertable
* [#9125](#9125) Increase
the parallelism of `SELECT` queries over compressed hypertables to
approximately match the uncompressed data size
* [#9410](#9410) Mark
`hypertable` and `chunk` as user catalog tables
* [#9416](#9416) Support
some forms of `CASE` expression in columnar aggregation and grouping
* [#9580](#9580) Add
`first` / `last` sparse indexes to compression
* [#9784](#9784) Use
`first` / `last` sparse index for `orderby` metadata on new compressed
chunks
* [#9668](#9668) Allow
database owner to configure hypertables and policies
* [#9701](#9701) Relax lock
during continuous aggregate invalidation log processing
* [#9730](#9730) Add
in-memory observability for compressed chunks
* [#9735](#9735) Improve
`GapFill` row count estimate
* [#9821](#9821) Allow
subquery results which are exec params as GapFill arguments
* [#9825](#9825) Support
`ADD COLUMN` on continuous aggregates
* [#9842](#9842) Suppress
continuous aggregate invalidation tracking during bulk loads
* [#9878](#9878) Remove
`chunk_constraint` catalog tracking for foreign keys
* [#9893](#9893) Remove
`chunk_constraint` catalog tracking for non-dimensional constraints
* [#9903](#9903)
Incremental refresh for `refresh_continuous_aggregate()`
* [#9915](#9915) Remove
`_timescaledb_catalog.chunk_constraint` table
* [#9938](#9938) Add
`rebuild_sparse_index` function
* [#9964](#9964) Add a
function to lock OSM chunk's dimension slice
* [#9980](#9980) Support
`first/last(value, time)` in `ColumnarIndexScan`

**Bugfixes**
* [#9708](#9708) Guard time
bucket parameter handling against bad input
* [#9745](#9745) Check
constraints when adding unique constraints to chunks
* [#9890](#9890) Fix
incremental refresh batch boundaries to align with variable-width
buckets and start only where a chunk and an invalidation overlap
* [#9914](#9914) Fix
use-after-free in segmentwise recompression
* [#9929](#9929) Fix
background jobs being bumped in the queue forever and never running
* [#9955](#9955) Fix wrong
results when using Batch Sorted Merge with no first-last index on a
non-leading order by column
* [#9967](#9967) Block
upgrade after downgrade with first/last indexes present
* [#9976](#9976) Fix wrong
results when comparing a date column to a `timestamptz` value
* [#9977](#9977) Fix `COPY
WHERE` into a hypertable with dropped columns
* [#9981](#9981) Fix
set-returning functions in the sort key of `ColumnarScan`
* [#9982](#9982) Reject
`ALTER TABLE ... INHERIT` when the parent is a hypertable
* [#9984](#9984) Fix
handling of `NOT VALID NOT NULL` constraint for query optimization
* [#9986](#9986) Handle
`MERGE WHEN NOT MATCHED BY SOURCE` on hypertables
* [#9988](#9988) Fix
`time_bucket_gapfill` function detection
* [#10003](#10003) Block
unsafe updates of unique columns on compressed chunks
* [#10024](#10024) Fix
`approximate_row_count` handling of Infinity
* [#10025](#10025) Fix
rename on compressed continuous aggregates
* [#10026](#10026) Fix
chunk skipping near `PG_INT64_MAX`


**New Settings**
* `skip_cagg_invalidation`: skip continuous aggregate invalidation
tracking for DML and DDL in the current session/transaction. Off by
default.
* `stats_max_chunks`: set the per-database compressed chunk statistics
cache capacity. Defaults to 1024 chunks; set to 0 to disable the
feature.

**Thanks**
* @Fabian-2596 for suggesting more accurate GapFill row count estimate
* @otjdiepluong for fixing spelling mistakes in timescaledb source code
comments
* @scimad and @Nosfistis for suggesting expanding coverage for gapfill
arguments
@timescale-automation timescale-automation added the released-2.28.0 Released in 2.28.0 label Jun 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement An enhancement to an existing feature for functionality released-2.28.0 Released in 2.28.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants