Skip to content

Commit e7aaf4c

Browse files
committed
Disable direct compress when RETURNING clause references system columns
Direct compress stores the tuple in compressed form instead of a regular heap tuple, so system columns like ctid or xmin never get a meaningful value. Fall back to the normal insert path when RETURNING references them. tableoid is fine because the returning projection sets it explicitly.
1 parent 93f9f87 commit e7aaf4c

3 files changed

Lines changed: 162 additions & 1 deletion

File tree

src/nodes/modify_hypertable.c

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
*/
66

77
#include <postgres.h>
8+
#include <access/sysattr.h>
89
#include <nodes/execnodes.h>
910
#include <nodes/makefuncs.h>
1011
#include <nodes/nodeFuncs.h>
12+
#include <optimizer/optimizer.h>
1113
#include <parser/parsetree.h>
1214
#include <utils/snapmgr.h>
1315

@@ -69,6 +71,34 @@ should_use_direct_compress(ModifyHypertableState *state)
6971
return false;
7072
}
7173

74+
/*
75+
* Direct compress stores the tuple in compressed form instead of a
76+
* regular heap tuple, so system columns like ctid or xmin never get a
77+
* meaningful value. Fall back to the normal insert path when RETURNING
78+
* references them. tableoid is fine because the returning projection
79+
* sets it explicitly.
80+
*/
81+
ModifyTable *mt = castNode(ModifyTable, mtstate->ps.plan);
82+
if (mt->returningLists)
83+
{
84+
Bitmapset *attnos = NULL;
85+
pull_varattnos((Node *) linitial(mt->returningLists),
86+
resultRelInfo->ri_RangeTableIndex,
87+
&attnos);
88+
int attno = -1;
89+
while ((attno = bms_next_member(attnos, attno)) >= 0)
90+
{
91+
AttrNumber sysattno = attno + FirstLowInvalidHeapAttributeNumber;
92+
if (sysattno < 0 && sysattno != TableOidAttributeNumber)
93+
{
94+
ereport(WARNING,
95+
(errmsg("disabling direct compress because the RETURNING clause "
96+
"references system columns")));
97+
return false;
98+
}
99+
}
100+
}
101+
72102
Plan *subplan = mtstate->ps.plan->lefttree;
73103
if (subplan->plan_rows < 10)
74104
{

tsl/test/expected/direct_compress_insert.out

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -997,11 +997,111 @@ SELECT count(*), min(label), max(label) FROM inserted;
997997
-------+-------+------
998998
20 | d0:10 | d1:9
999999

1000+
-- RETURNING a system column falls back to the normal insert path, except
1001+
-- tableoid which the returning projection sets explicitly.
1002+
EXPLAIN (COSTS OFF, SUMMARY OFF, TIMING OFF)
1003+
INSERT INTO dc_ret SELECT '2025-01-01'::timestamptz + (i * INTERVAL '1 minute'), 'd' || (i % 2)::text, i
1004+
FROM generate_series(1, 20) i RETURNING ctid;
1005+
WARNING: disabling direct compress because the RETURNING clause references system columns
1006+
--- QUERY PLAN ---
1007+
Custom Scan (ModifyHypertable)
1008+
Direct Compress: false
1009+
-> Insert on dc_ret
1010+
-> Function Scan on generate_series i
1011+
1012+
EXPLAIN (COSTS OFF, SUMMARY OFF, TIMING OFF)
1013+
INSERT INTO dc_ret SELECT '2025-01-01'::timestamptz + (i * INTERVAL '1 minute'), 'd' || (i % 2)::text, i
1014+
FROM generate_series(1, 20) i RETURNING xmin;
1015+
WARNING: disabling direct compress because the RETURNING clause references system columns
1016+
--- QUERY PLAN ---
1017+
Custom Scan (ModifyHypertable)
1018+
Direct Compress: false
1019+
-> Insert on dc_ret
1020+
-> Function Scan on generate_series i
1021+
1022+
EXPLAIN (COSTS OFF, SUMMARY OFF, TIMING OFF)
1023+
INSERT INTO dc_ret SELECT '2025-01-01'::timestamptz + (i * INTERVAL '1 minute'), 'd' || (i % 2)::text, i
1024+
FROM generate_series(1, 20) i RETURNING cmin;
1025+
WARNING: disabling direct compress because the RETURNING clause references system columns
1026+
--- QUERY PLAN ---
1027+
Custom Scan (ModifyHypertable)
1028+
Direct Compress: false
1029+
-> Insert on dc_ret
1030+
-> Function Scan on generate_series i
1031+
1032+
EXPLAIN (COSTS OFF, SUMMARY OFF, TIMING OFF)
1033+
INSERT INTO dc_ret SELECT '2025-01-01'::timestamptz + (i * INTERVAL '1 minute'), 'd' || (i % 2)::text, i
1034+
FROM generate_series(1, 20) i RETURNING xmax;
1035+
WARNING: disabling direct compress because the RETURNING clause references system columns
1036+
--- QUERY PLAN ---
1037+
Custom Scan (ModifyHypertable)
1038+
Direct Compress: false
1039+
-> Insert on dc_ret
1040+
-> Function Scan on generate_series i
1041+
1042+
EXPLAIN (COSTS OFF, SUMMARY OFF, TIMING OFF)
1043+
INSERT INTO dc_ret SELECT '2025-01-01'::timestamptz + (i * INTERVAL '1 minute'), 'd' || (i % 2)::text, i
1044+
FROM generate_series(1, 20) i RETURNING cmax;
1045+
WARNING: disabling direct compress because the RETURNING clause references system columns
1046+
--- QUERY PLAN ---
1047+
Custom Scan (ModifyHypertable)
1048+
Direct Compress: false
1049+
-> Insert on dc_ret
1050+
-> Function Scan on generate_series i
1051+
1052+
-- tableoid stays on the direct compress path
1053+
EXPLAIN (COSTS OFF, SUMMARY OFF, TIMING OFF)
1054+
INSERT INTO dc_ret SELECT '2025-01-01'::timestamptz + (i * INTERVAL '1 minute'), 'd' || (i % 2)::text, i
1055+
FROM generate_series(1, 20) i RETURNING tableoid;
1056+
--- QUERY PLAN ---
1057+
Custom Scan (ModifyHypertable)
1058+
Direct Compress: true
1059+
-> Insert on dc_ret
1060+
-> Function Scan on generate_series i
1061+
1062+
-- ctid falls back and returns real tuple ids
1063+
WITH inserted AS (
1064+
INSERT INTO dc_ret SELECT '2025-01-01'::timestamptz + (i * INTERVAL '1 day'), 'd' || (i % 2)::text, i
1065+
FROM generate_series(1, 20) i RETURNING ctid AS c, val
1066+
)
1067+
SELECT count(*), bool_and(c IS NOT NULL), min(val), max(val) FROM inserted;
1068+
WARNING: disabling direct compress because the RETURNING clause references system columns
1069+
count | bool_and | min | max
1070+
-------+----------+-----+-----
1071+
20 | t | 1 | 20
1072+
1073+
-- tableoid on the direct compress path holds the target chunk (all 20 rows
1074+
-- land in one chunk, so every RETURNING row reports the same chunk)
1075+
INSERT INTO dc_ret SELECT '2025-06-01'::timestamptz + (i * INTERVAL '1 minute'), 'd' || (i % 2)::text, i
1076+
FROM generate_series(1, 20) i RETURNING tableoid::regclass AS chunk;
1077+
chunk
1078+
------------------------------------------
1079+
_timescaledb_internal._hyper_16_67_chunk
1080+
_timescaledb_internal._hyper_16_67_chunk
1081+
_timescaledb_internal._hyper_16_67_chunk
1082+
_timescaledb_internal._hyper_16_67_chunk
1083+
_timescaledb_internal._hyper_16_67_chunk
1084+
_timescaledb_internal._hyper_16_67_chunk
1085+
_timescaledb_internal._hyper_16_67_chunk
1086+
_timescaledb_internal._hyper_16_67_chunk
1087+
_timescaledb_internal._hyper_16_67_chunk
1088+
_timescaledb_internal._hyper_16_67_chunk
1089+
_timescaledb_internal._hyper_16_67_chunk
1090+
_timescaledb_internal._hyper_16_67_chunk
1091+
_timescaledb_internal._hyper_16_67_chunk
1092+
_timescaledb_internal._hyper_16_67_chunk
1093+
_timescaledb_internal._hyper_16_67_chunk
1094+
_timescaledb_internal._hyper_16_67_chunk
1095+
_timescaledb_internal._hyper_16_67_chunk
1096+
_timescaledb_internal._hyper_16_67_chunk
1097+
_timescaledb_internal._hyper_16_67_chunk
1098+
_timescaledb_internal._hyper_16_67_chunk
1099+
10001100
-- rows were actually inserted
10011101
SELECT count(*) FROM dc_ret;
10021102
count
10031103
-------
1004-
40
1104+
80
10051105

10061106
RESET timescaledb.enable_direct_compress_insert;
10071107
DROP TABLE dc_ret;

tsl/test/sql/direct_compress_insert.sql

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,37 @@ WITH inserted AS (
590590
FROM generate_series(1, 20) i RETURNING device || ':' || val AS label
591591
)
592592
SELECT count(*), min(label), max(label) FROM inserted;
593+
-- RETURNING a system column falls back to the normal insert path, except
594+
-- tableoid which the returning projection sets explicitly.
595+
EXPLAIN (COSTS OFF, SUMMARY OFF, TIMING OFF)
596+
INSERT INTO dc_ret SELECT '2025-01-01'::timestamptz + (i * INTERVAL '1 minute'), 'd' || (i % 2)::text, i
597+
FROM generate_series(1, 20) i RETURNING ctid;
598+
EXPLAIN (COSTS OFF, SUMMARY OFF, TIMING OFF)
599+
INSERT INTO dc_ret SELECT '2025-01-01'::timestamptz + (i * INTERVAL '1 minute'), 'd' || (i % 2)::text, i
600+
FROM generate_series(1, 20) i RETURNING xmin;
601+
EXPLAIN (COSTS OFF, SUMMARY OFF, TIMING OFF)
602+
INSERT INTO dc_ret SELECT '2025-01-01'::timestamptz + (i * INTERVAL '1 minute'), 'd' || (i % 2)::text, i
603+
FROM generate_series(1, 20) i RETURNING cmin;
604+
EXPLAIN (COSTS OFF, SUMMARY OFF, TIMING OFF)
605+
INSERT INTO dc_ret SELECT '2025-01-01'::timestamptz + (i * INTERVAL '1 minute'), 'd' || (i % 2)::text, i
606+
FROM generate_series(1, 20) i RETURNING xmax;
607+
EXPLAIN (COSTS OFF, SUMMARY OFF, TIMING OFF)
608+
INSERT INTO dc_ret SELECT '2025-01-01'::timestamptz + (i * INTERVAL '1 minute'), 'd' || (i % 2)::text, i
609+
FROM generate_series(1, 20) i RETURNING cmax;
610+
-- tableoid stays on the direct compress path
611+
EXPLAIN (COSTS OFF, SUMMARY OFF, TIMING OFF)
612+
INSERT INTO dc_ret SELECT '2025-01-01'::timestamptz + (i * INTERVAL '1 minute'), 'd' || (i % 2)::text, i
613+
FROM generate_series(1, 20) i RETURNING tableoid;
614+
-- ctid falls back and returns real tuple ids
615+
WITH inserted AS (
616+
INSERT INTO dc_ret SELECT '2025-01-01'::timestamptz + (i * INTERVAL '1 day'), 'd' || (i % 2)::text, i
617+
FROM generate_series(1, 20) i RETURNING ctid AS c, val
618+
)
619+
SELECT count(*), bool_and(c IS NOT NULL), min(val), max(val) FROM inserted;
620+
-- tableoid on the direct compress path holds the target chunk (all 20 rows
621+
-- land in one chunk, so every RETURNING row reports the same chunk)
622+
INSERT INTO dc_ret SELECT '2025-06-01'::timestamptz + (i * INTERVAL '1 minute'), 'd' || (i % 2)::text, i
623+
FROM generate_series(1, 20) i RETURNING tableoid::regclass AS chunk;
593624
-- rows were actually inserted
594625
SELECT count(*) FROM dc_ret;
595626
RESET timescaledb.enable_direct_compress_insert;

0 commit comments

Comments
 (0)