Skip to content

Commit bff9fb7

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 68ec345 commit bff9fb7

13 files changed

Lines changed: 227 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
@@ -75,3 +75,8 @@ WHERE cc.chunk_id = c.id
7575
AND cc.dimension_slice_id IS NULL
7676
AND cc.hypertable_constraint_name IS NOT NULL;
7777

78+
CREATE OR REPLACE FUNCTION _timescaledb_functions.decompress_batch(record)
79+
RETURNS SETOF record
80+
AS '@MODULE_PATHNAME@', 'ts_update_placeholder'
81+
LANGUAGE C STRICT;
82+

sql/updates/reverse-dev.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,4 @@ WHERE d.classid = 'pg_constraint'::regclass
4747
AND child.conrelid = pg_catalog.format('%I.%I', c.schema_name, c.table_name)::regclass
4848
AND parent.conrelid = pg_catalog.format('%I.%I', ht.schema_name, ht.table_name)::regclass;
4949

50+
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);
@@ -357,6 +358,7 @@ TSDLLEXPORT CrossModuleFunctions ts_cm_functions_default = {
357358
.compressed_data_decompress_reverse = error_no_default_fn_pg_community,
358359
.compressed_data_column_size = error_no_default_fn_pg_community,
359360
.compressed_data_to_array = error_no_default_fn_pg_community,
361+
.decompress_batch = error_no_default_fn_pg_community,
360362
.deltadelta_compressor_append = error_no_default_fn_pg_community,
361363
.deltadelta_compressor_finish = error_no_default_fn_pg_community,
362364
.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
@@ -147,6 +147,7 @@ typedef struct CrossModuleFunctions
147147
PGFunction compressed_data_decompress_reverse;
148148
PGFunction compressed_data_column_size;
149149
PGFunction compressed_data_to_array;
150+
PGFunction decompress_batch;
150151
PGFunction deltadelta_compressor_append;
151152
PGFunction deltadelta_compressor_finish;
152153
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>
@@ -2723,6 +2724,87 @@ tsl_compressed_data_decompress_reverse(PG_FUNCTION_ARGS)
27232724
;
27242725
}
27252726

2727+
/*
2728+
* decompress_batch(compressed_tuple record) RETURNS SETOF record
2729+
*
2730+
* Decompresses a single compressed batch (one row of a compressed chunk) into
2731+
* the individual rows it represents. The shape of the input compressed tuple
2732+
* is taken from the record's own type info (typeId/typmod in the header).
2733+
* The shape of the output rows is taken from the call site's column
2734+
* definition list (the AS t(...) clause).
2735+
*/
2736+
typedef struct DecompressBatchSRFContext
2737+
{
2738+
RowDecompressor decompressor;
2739+
int next_row;
2740+
int total_rows;
2741+
} DecompressBatchSRFContext;
2742+
2743+
Datum
2744+
tsl_decompress_batch(PG_FUNCTION_ARGS)
2745+
{
2746+
FuncCallContext *funcctx;
2747+
DecompressBatchSRFContext *decompress_ctx;
2748+
2749+
if (SRF_IS_FIRSTCALL())
2750+
{
2751+
funcctx = SRF_FIRSTCALL_INIT();
2752+
MemoryContext oldcontext = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
2753+
2754+
TupleDesc out_desc;
2755+
if (get_call_result_type(fcinfo, NULL, &out_desc) != TYPEFUNC_COMPOSITE)
2756+
{
2757+
ereport(ERROR,
2758+
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
2759+
errmsg("function returning record called in context "
2760+
"that cannot accept type record")));
2761+
}
2762+
BlessTupleDesc(out_desc);
2763+
2764+
HeapTupleHeader td = PG_GETARG_HEAPTUPLEHEADER(0);
2765+
TupleDesc in_desc =
2766+
lookup_rowtype_tupdesc(HeapTupleHeaderGetTypeId(td), HeapTupleHeaderGetTypMod(td));
2767+
2768+
decompress_ctx = palloc0(sizeof(DecompressBatchSRFContext));
2769+
decompress_ctx->decompressor = build_decompressor(in_desc, out_desc);
2770+
2771+
HeapTupleData tmp;
2772+
tmp.t_len = HeapTupleHeaderGetDatumLength(td);
2773+
ItemPointerSetInvalid(&tmp.t_self);
2774+
tmp.t_tableOid = InvalidOid;
2775+
tmp.t_data = td;
2776+
2777+
heap_deform_tuple(&tmp,
2778+
in_desc,
2779+
decompress_ctx->decompressor.compressed_datums,
2780+
decompress_ctx->decompressor.compressed_is_nulls);
2781+
2782+
ReleaseTupleDesc(in_desc);
2783+
2784+
decompress_ctx->total_rows = decompress_batch(&decompress_ctx->decompressor);
2785+
decompress_ctx->next_row = 0;
2786+
2787+
funcctx->user_fctx = decompress_ctx;
2788+
funcctx->tuple_desc = decompress_ctx->decompressor.out_desc;
2789+
MemoryContextSwitchTo(oldcontext);
2790+
}
2791+
2792+
funcctx = SRF_PERCALL_SETUP();
2793+
decompress_ctx = (DecompressBatchSRFContext *) funcctx->user_fctx;
2794+
2795+
if (decompress_ctx->next_row >= decompress_ctx->total_rows)
2796+
{
2797+
row_decompressor_close(&decompress_ctx->decompressor);
2798+
SRF_RETURN_DONE(funcctx);
2799+
}
2800+
2801+
TupleTableSlot *slot =
2802+
decompress_ctx->decompressor.decompressed_slots[decompress_ctx->next_row++];
2803+
bool should_free;
2804+
HeapTuple tuple = ExecFetchSlotHeapTuple(slot, false, &should_free);
2805+
SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(tuple));
2806+
}
2807+
27262808
/*
27272809
* compressed_data_to_array(compressed_data, element_type) -> anyarray
27282810
*/

tsl/src/compression/compression.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@ extern Datum tsl_compressed_data_info(PG_FUNCTION_ARGS);
341341
extern Datum tsl_compressed_data_has_nulls(PG_FUNCTION_ARGS);
342342
extern Datum tsl_compressed_data_column_size(PG_FUNCTION_ARGS);
343343
extern Datum tsl_compressed_data_to_array(PG_FUNCTION_ARGS);
344+
extern Datum tsl_decompress_batch(PG_FUNCTION_ARGS);
344345

345346
static void
346347
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
@@ -140,6 +140,7 @@ CrossModuleFunctions tsl_cm_functions = {
140140
.compressed_data_decompress_reverse = tsl_compressed_data_decompress_reverse,
141141
.compressed_data_column_size = tsl_compressed_data_column_size,
142142
.compressed_data_to_array = tsl_compressed_data_to_array,
143+
.decompress_batch = tsl_decompress_batch,
143144
.compressed_data_send = tsl_compressed_data_send,
144145
.compressed_data_recv = tsl_compressed_data_recv,
145146
.compressed_data_in = tsl_compressed_data_in,
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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+
-- Populate with test data
12+
INSERT INTO metrics
13+
SELECT '2025-01-01'::timestamptz + (g || ' minute')::interval, g % 3, g::float
14+
FROM generate_series(1, 30) g;
15+
INSERT INTO metrics VALUES ('2025-01-01 02:00', NULL, NULL);
16+
SELECT compress_chunk(ch) FROM show_chunks('metrics') ch;
17+
compress_chunk
18+
----------------------------------------
19+
_timescaledb_internal._hyper_1_1_chunk
20+
21+
-- Capture the compressed chunk relation name
22+
SELECT format('%I.%I', cc.schema_name, cc.table_name) AS compressed_chunk
23+
FROM _timescaledb_catalog.chunk c
24+
JOIN _timescaledb_catalog.chunk cc ON c.compressed_chunk_id = cc.id
25+
JOIN _timescaledb_catalog.hypertable h ON c.hypertable_id = h.id
26+
WHERE h.table_name = 'metrics' \gset
27+
-- Verify set equality: no source row missing, no extra row introduced
28+
SELECT count(*) AS missing FROM (
29+
TABLE metrics
30+
EXCEPT ALL
31+
SELECT decomp.time, decomp.device_id, decomp.value
32+
FROM :compressed_chunk t,
33+
LATERAL _timescaledb_functions.decompress_batch(t)
34+
AS decomp(time timestamptz, device_id int, value float)
35+
) m;
36+
missing
37+
---------
38+
0
39+
40+
SELECT count(*) AS extras FROM (
41+
SELECT decomp.time, decomp.device_id, decomp.value
42+
FROM :compressed_chunk t,
43+
LATERAL _timescaledb_functions.decompress_batch(t)
44+
AS decomp(time timestamptz, device_id int, value float)
45+
EXCEPT ALL
46+
TABLE metrics
47+
) e;
48+
extras
49+
--------
50+
0
51+
52+
-- Decompressing a single batch yields exactly that batch.
53+
SELECT decomp.time, decomp.device_id, decomp.value
54+
FROM (SELECT t FROM :compressed_chunk t WHERE t.device_id = 1) comp,
55+
LATERAL _timescaledb_functions.decompress_batch(comp.t)
56+
AS decomp(time timestamptz, device_id int, value float)
57+
ORDER BY decomp.time;
58+
time | device_id | value
59+
------------------------+-----------+-------
60+
2025-01-01 00:01:00-08 | 1 | 1
61+
2025-01-01 00:04:00-08 | 1 | 4
62+
2025-01-01 00:07:00-08 | 1 | 7
63+
2025-01-01 00:10:00-08 | 1 | 10
64+
2025-01-01 00:13:00-08 | 1 | 13
65+
2025-01-01 00:16:00-08 | 1 | 16
66+
2025-01-01 00:19:00-08 | 1 | 19
67+
2025-01-01 00:22:00-08 | 1 | 22
68+
2025-01-01 00:25:00-08 | 1 | 25
69+
2025-01-01 00:28:00-08 | 1 | 28
70+
71+
DROP TABLE metrics CASCADE;

0 commit comments

Comments
 (0)