Skip to content

Commit 4e24375

Browse files
committed
Add decompress_batch SQL function
Introduce _timescaledb_functions.decompress_batch(record), which expands a single compressed-chunk row back into the individual rows it represents. The function is intended for use from a custom logical decoding plugin that needs to materialize the original tuples from a compressed batch. The input descriptor is recovered from the record's own type info (typeId/typmod in the HeapTupleHeader) via lookup_rowtype_tupdesc, so callers can pass a row from a compressed chunk directly. The output descriptor is taken from the call site's column definition list through get_call_result_type, letting the caller specify the shape of the decompressed rows in the AS t(...) clause. Internally the function is a value-per-call SRF that calls build_decompressor, deforms the input record into the decompressor's compressed_datums/_is_nulls arrays, runs decompress_batch once, and yields each tuple from decompressed_slots. row_decompressor_close runs on SRF_RETURN_DONE. Example: SELECT x.* FROM _timescaledb_internal.compress_hyper_2_2_chunk t LIMIT 1 CROSS JOIN LATERAL _timescaledb_functions.decompress_batch(t) AS x(time timestamptz, device_id int, value float);
1 parent 2c3afc0 commit 4e24375

13 files changed

Lines changed: 273 additions & 0 deletions

File tree

.unreleased/pr_9684

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Implements: #9684 Add `decompress_batch` SQL function

sql/compression.sql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,8 @@ CREATE OR REPLACE FUNCTION _timescaledb_functions.compressed_data_column_size(_t
1212
AS '@MODULE_PATHNAME@', 'ts_compressed_data_column_size'
1313
LANGUAGE C IMMUTABLE PARALLEL SAFE;
1414

15+
CREATE OR REPLACE FUNCTION _timescaledb_functions.decompress_batch(record)
16+
RETURNS SETOF record
17+
AS '@MODULE_PATHNAME@', 'ts_decompress_batch'
18+
LANGUAGE C STRICT;
19+

sql/updates/latest-dev.sql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,3 +188,8 @@ SET index = COALESCE(index, '[]'::jsonb) ||
188188
)
189189
WHERE cs.orderby IS NOT NULL;
190190

191+
CREATE OR REPLACE FUNCTION _timescaledb_functions.decompress_batch(record)
192+
RETURNS SETOF record
193+
AS '@MODULE_PATHNAME@', 'ts_update_placeholder'
194+
LANGUAGE C STRICT;
195+

sql/updates/reverse-dev.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ DROP FUNCTION IF EXISTS _timescaledb_functions.bloom1_hash(anyelement);
99
-- Drop BIGINT-returning version so the downgrade script can recreate the
1010
-- INTEGER-returning version of compressed_data_column_size.
1111
DROP FUNCTION IF EXISTS _timescaledb_functions.compressed_data_column_size(_timescaledb_internal.compressed_data, ANYELEMENT);
12+
13+
DROP FUNCTION IF EXISTS _timescaledb_functions.decompress_batch(record);

src/cross_module_fn.c

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ CROSSMODULE_WRAPPER(compressed_data_decompress_forward);
6060
CROSSMODULE_WRAPPER(compressed_data_decompress_reverse);
6161
CROSSMODULE_WRAPPER(compressed_data_column_size);
6262
CROSSMODULE_WRAPPER(compressed_data_to_array);
63+
CROSSMODULE_WRAPPER(decompress_batch);
6364
CROSSMODULE_WRAPPER(compressed_data_send);
6465
CROSSMODULE_WRAPPER(compressed_data_recv);
6566
CROSSMODULE_WRAPPER(compressed_data_in);
@@ -358,6 +359,7 @@ TSDLLEXPORT CrossModuleFunctions ts_cm_functions_default = {
358359
.compressed_data_decompress_reverse = error_no_default_fn_pg_community,
359360
.compressed_data_column_size = error_no_default_fn_pg_community,
360361
.compressed_data_to_array = error_no_default_fn_pg_community,
362+
.decompress_batch = error_no_default_fn_pg_community,
361363
.deltadelta_compressor_append = error_no_default_fn_pg_community,
362364
.deltadelta_compressor_finish = error_no_default_fn_pg_community,
363365
.gorilla_compressor_append = error_no_default_fn_pg_community,

src/cross_module_fn.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ typedef struct CrossModuleFunctions
149149
PGFunction compressed_data_decompress_reverse;
150150
PGFunction compressed_data_column_size;
151151
PGFunction compressed_data_to_array;
152+
PGFunction decompress_batch;
152153
PGFunction deltadelta_compressor_append;
153154
PGFunction deltadelta_compressor_finish;
154155
PGFunction gorilla_compressor_append;

tsl/src/compression/compression.c

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include <access/attmap.h>
88
#include <access/attnum.h>
99
#include <access/detoast.h>
10+
#include <access/htup_details.h>
1011
#include <access/skey.h>
1112
#include <access/tupdesc.h>
1213
#include <catalog/heap.h>
@@ -2680,6 +2681,87 @@ tsl_compressed_data_decompress_reverse(PG_FUNCTION_ARGS)
26802681
;
26812682
}
26822683

2684+
/*
2685+
* decompress_batch(compressed_tuple record) RETURNS SETOF record
2686+
*
2687+
* Decompresses a single compressed batch (one row of a compressed chunk) into
2688+
* the individual rows it represents. The shape of the input compressed tuple
2689+
* is taken from the record's own type info (typeId/typmod in the header).
2690+
* The shape of the output rows is taken from the call site's column
2691+
* definition list (the AS t(...) clause).
2692+
*/
2693+
typedef struct DecompressBatchSRFContext
2694+
{
2695+
RowDecompressor decompressor;
2696+
int next_row;
2697+
int total_rows;
2698+
} DecompressBatchSRFContext;
2699+
2700+
Datum
2701+
tsl_decompress_batch(PG_FUNCTION_ARGS)
2702+
{
2703+
FuncCallContext *funcctx;
2704+
DecompressBatchSRFContext *decompress_ctx;
2705+
2706+
if (SRF_IS_FIRSTCALL())
2707+
{
2708+
funcctx = SRF_FIRSTCALL_INIT();
2709+
MemoryContext oldcontext = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
2710+
2711+
TupleDesc out_desc;
2712+
if (get_call_result_type(fcinfo, NULL, &out_desc) != TYPEFUNC_COMPOSITE)
2713+
{
2714+
ereport(ERROR,
2715+
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
2716+
errmsg("function returning record called in context "
2717+
"that cannot accept type record")));
2718+
}
2719+
BlessTupleDesc(out_desc);
2720+
2721+
HeapTupleHeader td = PG_GETARG_HEAPTUPLEHEADER(0);
2722+
TupleDesc in_desc =
2723+
lookup_rowtype_tupdesc(HeapTupleHeaderGetTypeId(td), HeapTupleHeaderGetTypMod(td));
2724+
2725+
decompress_ctx = palloc0(sizeof(DecompressBatchSRFContext));
2726+
decompress_ctx->decompressor = build_decompressor(in_desc, out_desc);
2727+
2728+
HeapTupleData tmp;
2729+
tmp.t_len = HeapTupleHeaderGetDatumLength(td);
2730+
ItemPointerSetInvalid(&tmp.t_self);
2731+
tmp.t_tableOid = InvalidOid;
2732+
tmp.t_data = td;
2733+
2734+
heap_deform_tuple(&tmp,
2735+
in_desc,
2736+
decompress_ctx->decompressor.compressed_datums,
2737+
decompress_ctx->decompressor.compressed_is_nulls);
2738+
2739+
ReleaseTupleDesc(in_desc);
2740+
2741+
decompress_ctx->total_rows = decompress_batch(&decompress_ctx->decompressor);
2742+
decompress_ctx->next_row = 0;
2743+
2744+
funcctx->user_fctx = decompress_ctx;
2745+
funcctx->tuple_desc = decompress_ctx->decompressor.out_desc;
2746+
MemoryContextSwitchTo(oldcontext);
2747+
}
2748+
2749+
funcctx = SRF_PERCALL_SETUP();
2750+
decompress_ctx = (DecompressBatchSRFContext *) funcctx->user_fctx;
2751+
2752+
if (decompress_ctx->next_row >= decompress_ctx->total_rows)
2753+
{
2754+
row_decompressor_close(&decompress_ctx->decompressor);
2755+
SRF_RETURN_DONE(funcctx);
2756+
}
2757+
2758+
TupleTableSlot *slot =
2759+
decompress_ctx->decompressor.decompressed_slots[decompress_ctx->next_row++];
2760+
bool should_free;
2761+
HeapTuple tuple = ExecFetchSlotHeapTuple(slot, false, &should_free);
2762+
SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(tuple));
2763+
}
2764+
26832765
/*
26842766
* compressed_data_to_array(compressed_data, element_type) -> anyarray
26852767
*/

tsl/src/compression/compression.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ extern Datum tsl_compressed_data_info(PG_FUNCTION_ARGS);
338338
extern Datum tsl_compressed_data_has_nulls(PG_FUNCTION_ARGS);
339339
extern Datum tsl_compressed_data_column_size(PG_FUNCTION_ARGS);
340340
extern Datum tsl_compressed_data_to_array(PG_FUNCTION_ARGS);
341+
extern Datum tsl_decompress_batch(PG_FUNCTION_ARGS);
341342

342343
static void
343344
pg_attribute_unused() assert_num_compression_algorithms_sane(void)

tsl/src/init.c

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ CrossModuleFunctions tsl_cm_functions = {
141141
.compressed_data_decompress_reverse = tsl_compressed_data_decompress_reverse,
142142
.compressed_data_column_size = tsl_compressed_data_column_size,
143143
.compressed_data_to_array = tsl_compressed_data_to_array,
144+
.decompress_batch = tsl_decompress_batch,
144145
.compressed_data_send = tsl_compressed_data_send,
145146
.compressed_data_recv = tsl_compressed_data_recv,
146147
.compressed_data_in = tsl_compressed_data_in,
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
-- This file and its contents are licensed under the Timescale License.
2+
-- Please see the included NOTICE for copyright information and
3+
-- LICENSE-TIMESCALE for a copy of the license.
4+
-- _timescaledb_functions.decompress_batch(record) RETURNS SETOF record
5+
-- expands a single row of a compressed chunk into the user-visible rows it
6+
-- was compressed from.
7+
SET datestyle TO ISO;
8+
CREATE TABLE metrics(time timestamptz NOT NULL, device_id int, value float)
9+
WITH (tsdb.hypertable, tsdb.orderby = 'time', tsdb.segmentby = 'device_id');
10+
NOTICE: using column "time" as partitioning column
11+
INSERT INTO metrics
12+
SELECT '2025-01-01'::timestamptz + (g || ' minute')::interval, g % 3, g::float
13+
FROM generate_series(1, 30) g;
14+
-- A row with NULLs in user columns to verify round-trip of NULLs.
15+
INSERT INTO metrics VALUES ('2025-01-01 02:00', NULL, NULL);
16+
SELECT count(*) AS source_rows FROM metrics;
17+
source_rows
18+
-------------
19+
31
20+
21+
SELECT count(compress_chunk(ch)) FROM show_chunks('metrics') ch;
22+
count
23+
-------
24+
1
25+
26+
-- Capture the compressed chunk relation name.
27+
SELECT format('%I.%I', cc.schema_name, cc.table_name) AS compressed_chunk
28+
FROM _timescaledb_catalog.chunk c
29+
JOIN _timescaledb_catalog.chunk cc ON c.compressed_chunk_id = cc.id
30+
JOIN _timescaledb_catalog.hypertable h ON c.hypertable_id = h.id
31+
WHERE h.table_name = 'metrics' \gset
32+
-- Expanding every compressed row reproduces the source rowcount.
33+
SELECT count(*) AS round_trip_count
34+
FROM :compressed_chunk t,
35+
LATERAL _timescaledb_functions.decompress_batch(t)
36+
AS r(time timestamptz, device_id int, value float);
37+
round_trip_count
38+
------------------
39+
31
40+
41+
-- Set equality: no source row missing, no extra row introduced.
42+
SELECT count(*) AS missing FROM (
43+
TABLE metrics
44+
EXCEPT ALL
45+
SELECT r.time, r.device_id, r.value
46+
FROM :compressed_chunk t,
47+
LATERAL _timescaledb_functions.decompress_batch(t)
48+
AS r(time timestamptz, device_id int, value float)
49+
) m;
50+
missing
51+
---------
52+
0
53+
54+
SELECT count(*) AS extras FROM (
55+
SELECT r.time, r.device_id, r.value
56+
FROM :compressed_chunk t,
57+
LATERAL _timescaledb_functions.decompress_batch(t)
58+
AS r(time timestamptz, device_id int, value float)
59+
EXCEPT ALL
60+
TABLE metrics
61+
) e;
62+
extras
63+
--------
64+
0
65+
66+
-- Per-batch counts: each compressed row is one segmentby batch.
67+
SELECT t.device_id AS segment, count(*) AS batch_rows
68+
FROM :compressed_chunk t,
69+
LATERAL _timescaledb_functions.decompress_batch(t)
70+
AS r(time timestamptz, device_id int, value float)
71+
GROUP BY t.device_id
72+
ORDER BY t.device_id NULLS LAST;
73+
segment | batch_rows
74+
---------+------------
75+
0 | 10
76+
1 | 10
77+
2 | 10
78+
| 1
79+
80+
-- Decompressing a single batch yields exactly that batch.
81+
SELECT r.time, r.device_id, r.value
82+
FROM (SELECT t FROM :compressed_chunk t WHERE t.device_id = 1) one,
83+
LATERAL _timescaledb_functions.decompress_batch(one.t)
84+
AS r(time timestamptz, device_id int, value float)
85+
ORDER BY r.time;
86+
time | device_id | value
87+
------------------------+-----------+-------
88+
2025-01-01 00:01:00-08 | 1 | 1
89+
2025-01-01 00:04:00-08 | 1 | 4
90+
2025-01-01 00:07:00-08 | 1 | 7
91+
2025-01-01 00:10:00-08 | 1 | 10
92+
2025-01-01 00:13:00-08 | 1 | 13
93+
2025-01-01 00:16:00-08 | 1 | 16
94+
2025-01-01 00:19:00-08 | 1 | 19
95+
2025-01-01 00:22:00-08 | 1 | 22
96+
2025-01-01 00:25:00-08 | 1 | 25
97+
2025-01-01 00:28:00-08 | 1 | 28
98+
99+
DROP TABLE metrics CASCADE;

0 commit comments

Comments
 (0)