Repository: timescale/timescaledb (2026-06-07T06:58:54Z)

Issue:
A user can trigger asserion failure by creating a function named `time_bucket_gapfill`
While this is not some useful case, it's valid and our code should be robust to it.

```
CREATE TABLE temperature_readings (sensor_id int);
INSERT INTO temperature_readings VALUES (1), (2), (3);

CREATE FUNCTION public.time_bucket_gapfill(
    bucket_width int,
    reading_time int,
    label        text
) RETURNS int AS $$
BEGIN
    RETURN reading_time / bucket_width * bucket_width;
END;
$$ LANGUAGE plpgsql IMMUTABLE;

EXPLAIN SELECT time_bucket_gapfill(10, sensor_id, 'label'::text)
FROM temperature_readings
WHERE sensor_id >= 0 AND sensor_id < 100
GROUP BY 1;
```

```
#5  0x00005570ffa566fd in ExceptionalCondition (conditionName=0x7f207ec9c840 <str> "n >= 0 && n < list->length", fileName=0x7f207ec9c7c0 <str> "/home/akuzm/ts1/install/include/postgresql/server/nodes/pg_list.h", lineNumber=<optimized out>) at /home/akuzm/ts1/build-pg-asan/../pg/src/backend/utils/error/assert.c:412
#6  0x00007f207f41bc62 in list_nth_cell (list=<optimized out>, n=<optimized out>) at /home/akuzm/ts1/install/include/postgresql/server/nodes/pg_list.h:280
#7  0x00007f207f41bc62 in gapfill_estimate_boundary (gapfill_plan_ctx=gapfill_plan_ctx@entry=0x7f227eb61e60, boundary=boundary@entry=GAPFILL_END) from /home/akuzm/ts1/install/lib/postgresql/timescaledb-tsl-2.28.0-dev.so
#8  0x00007f207f4184f7 in check_args_and_estimate_gapfill_rows (root=0x525000087798, func=0x525000087d88) at /home/akuzm/ts1/ts/tsl/src/nodes/gapfill/gapfill_plan.c:370
#9  gapfill_path_create (root=0x525000087798, subpath=0x52500008c7b8, func=0x525000087d88) at /home/akuzm/ts1/ts/tsl/src/nodes/gapfill/gapfill_plan.c:632
#10 plan_add_gapfill (root=0x525000087798, group_rel=<optimized out>) at /home/akuzm/ts1/ts/tsl/src/nodes/gapfill/gapfill_plan.c:799
#11 0x00007f207ecd8968 in tsl_create_upper_paths_hook (root=<optimized out>, stage=UPPERREL_GROUP_AGG, input_rel=<optimized out>, output_rel=<optimized out>, input_reltype=<optimized out>, ht=<optimized out>, extra=<optimized out>) at /home/akuzm/ts1/ts/tsl/src/planner.c:68
#12 0x00007f207f7ea61f in timescaledb_create_upper_paths_hook (root=0x525000087798, stage=<optimized out>, input_rel=<optimized out>, output_rel=<optimized out>, extra=<optimized out>) at /home/akuzm/ts1/ts/src/planner/planner.c:1876
#13 0x00005570ff46833d in create_ordinary_grouping_paths (root=<optimized out>, input_rel=0x525000087138, grouped_rel=<optimized out>, agg_costs=<optimized out>, gd=<optimized out>, extra=<optimized out>, partially_grouped_rel_p=<optimized out>) at /home/akuzm/ts1/build-pg-asan/../pg/src/backend/optimizer/plan/planner.c:4210
#14 0x00005570ff459ba6 in create_grouping_paths (root=0x525000087798, input_rel=0x525000087138, target=0x52500008c2a8, target_parallel_safe=false, gd=0x0) at /home/akuzm/ts1/build-pg-asan/../pg/src/backend/optimizer/plan/planner.c:3926
#15 grouping_planner (root=0x525000087798, tuple_fraction=<optimized out>, setops=<optimized out>) at /home/akuzm/ts1/build-pg-asan/../pg/src/backend/optimizer/plan/planner.c:1708
#16 0x00005570ff451fb4 in subquery_planner (glob=glob@entry=0x521000021680, parse=parse@entry=0x519000033ac8, parent_root=parent_root@entry=0x0, hasRecursion=false, tuple_fraction=<error reading variable: That operation is not available on integers of more than 8 bytes.>, setops=setops@entry=0x0) at /home/akuzm/ts1/build-pg-asan/../pg/src/backend/optimizer/plan/planner.c:1133
#17 0x00005570ff44d10a in standard_planner (parse=<optimized out>, query_string=<optimized out>, cursorOptions=<optimized out>, boundParams=<optimized out>) at /home/akuzm/ts1/build-pg-asan/../pg/src/backend/optimizer/plan/planner.c:416
#18 0x00007f207f7e83cb in timescaledb_planner (parse=0x519000033ac8, query_string=0x525000007a00 "EXPLAIN SELECT time_bucket_gapfill(10, sensor_id, 'label'::text)", ' ' <repeats 115 times>, "\n  FROM temperature_r"..., cursor_opts=<optimized out>, bound_params=0x0) at /home/akuzm/ts1/ts/src/planner/planner.c:712
#19 0x00005570ff7096cd in pg_plan_query (querytree=querytree@entry=0x519000033ac8, query_string=query_string@entry=0x525000007a00 "EXPLAIN SELECT time_bucket_gapfill(10, sensor_id, 'label'::text)", ' ' <repeats 115 times>, "\n  FROM temperature_r"..., cursorOptions=cursorOptions@entry=2048, boundParams=boundParams@entry=0x0) at /home/akuzm/ts1/build-pg-asan/../pg/src/backend/tcop/postgres.c:2257
#20 0x00005570ff008f6f in standard_ExplainOneQuery (query=<optimized out>, cursorOptions=<optimized out>, into=<optimized out>, es=0x5190000323c8, queryString=<optimized out>, params=<optimized out>, queryEnv=<optimized out>) at /home/akuzm/ts1/build-pg-asan/../pg/src/backend/commands/explain.c:490
```



Discussion:

Patch:
diff --git a/.unreleased/fix_9988 b/.unreleased/fix_9988
new file mode 100644
index 00000000000..cb11daf7b44
--- /dev/null
+++ b/.unreleased/fix_9988
@@ -0,0 +1,1 @@
+Fixes: #9988 Fix time_bucket_gapfill function detection
diff --git a/src/func_cache.c b/src/func_cache.c
index ad9c0881922..2d2407fa06d 100644
--- a/src/func_cache.c
+++ b/src/func_cache.c
@@ -505,6 +505,68 @@ static FuncInfo funcinfo[] = {
 		.sort_transform = NULL,
 	},
 
+	/* gapfill marker functions */
+	{
+		.origin = ORIGIN_TIMESCALE,
+		.is_bucketing_func = false,
+		.allowed_in_cagg_definition = false,
+		.funcname = "locf",
+		.nargs = 3,
+		.arg_types = { ANYELEMENTOID, ANYELEMENTOID, BOOLOID },
+		.group_estimate = NULL,
+		.sort_transform = NULL,
+	},
+	{
+		.origin = ORIGIN_TIMESCALE,
+		.is_bucketing_func = false,
+		.allowed_in_cagg_definition = false,
+		.funcname = "interpolate",
+		.nargs = 3,
+		.arg_types = { INT2OID, RECORDOID, RECORDOID },
+		.group_estimate = NULL,
+		.sort_transform = NULL,
+	},
+	{
+		.origin = ORIGIN_TIMESCALE,
+		.is_bucketing_func = false,
+		.allowed_in_cagg_definition = false,
+		.funcname = "interpolate",
+		.nargs = 3,
+		.arg_types = { INT4OID, RECORDOID, RECORDOID },
+		.group_estimate = NULL,
+		.sort_transform = NULL,
+	},
+	{
+		.origin = ORIGIN_TIMESCALE,
+		.is_bucketing_func = false,
+		.allowed_in_cagg_definition = false,
+		.funcname = "interpolate",
+		.nargs = 3,
+		.arg_types = { INT8OID, RECORDOID, RECORDOID },
+		.group_estimate = NULL,
+		.sort_transform = NULL,
+	},
+	{
+		.origin = ORIGIN_TIMESCALE,
+		.is_bucketing_func = false,
+		.allowed_in_cagg_definition = false,
+		.funcname = "interpolate",
+		.nargs = 3,
+		.arg_types = { FLOAT4OID, RECORDOID, RECORDOID },
+		.group_estimate = NULL,
+		.sort_transform = NULL,
+	},
+	{
+		.origin = ORIGIN_TIMESCALE,
+		.is_bucketing_func = false,
+		.allowed_in_cagg_definition = false,
+		.funcname = "interpolate",
+		.nargs = 3,
+		.arg_types = { FLOAT8OID, RECORDOID, RECORDOID },
+		.group_estimate = NULL,
+		.sort_transform = NULL,
+	},
+
 	{
 		.origin = ORIGIN_POSTGRES,
 		.is_bucketing_func = true,
diff --git a/tsl/src/nodes/gapfill/gapfill.h b/tsl/src/nodes/gapfill/gapfill.h
index 205d0113421..1f31aa62bee 100644
--- a/tsl/src/nodes/gapfill/gapfill.h
+++ b/tsl/src/nodes/gapfill/gapfill.h
@@ -14,6 +14,14 @@
 #define GAPFILL_LOCF_FUNCTION "locf"
 #define GAPFILL_INTERPOLATE_FUNCTION "interpolate"
 
+/*
+ * Check whether funcid is the gapfill function with the given name. Resolved
+ * through the function cache, whose entries are keyed by our functions' fixed
+ * signatures, so a user-defined function that merely shares the name is not
+ * mistaken for ours.
+ */
+extern bool gapfill_is_extension_function(Oid funcid, const char *name);
+
 /*
  * Indices into CustomScan->custom_private for GapFill node.
  */
diff --git a/tsl/src/nodes/gapfill/gapfill_exec.c b/tsl/src/nodes/gapfill/gapfill_exec.c
index 92946170e6c..727fc7ffa50 100644
--- a/tsl/src/nodes/gapfill/gapfill_exec.c
+++ b/tsl/src/nodes/gapfill/gapfill_exec.c
@@ -1448,9 +1448,8 @@ gapfill_state_initialize_columns(GapFillState *state, List *exec_tlist)
 			 * if there is time_bucket_gapfill function call this is our time
 			 * column
 			 */
-			if (IsA(expr, FuncExpr) && strncmp(get_func_name(castNode(FuncExpr, expr)->funcid),
-											   GAPFILL_FUNCTION,
-											   NAMEDATALEN) == 0)
+			if (IsA(expr, FuncExpr) &&
+				gapfill_is_extension_function(castNode(FuncExpr, expr)->funcid, GAPFILL_FUNCTION))
 			{
 				state->columns[i] =
 					gapfill_column_state_create(TIME_COLUMN, TupleDescAttr(tupledesc, i)->atttypid);
@@ -1467,10 +1466,9 @@ gapfill_state_initialize_columns(GapFillState *state, List *exec_tlist)
 		}
 		else if (IsA(expr, FuncExpr))
 		{
+			Oid funcid = castNode(FuncExpr, expr)->funcid;
 			/* locf and interpolate will be toplevel function calls in the gapfill node */
-			if (strncmp(get_func_name(castNode(FuncExpr, expr)->funcid),
-						GAPFILL_LOCF_FUNCTION,
-						NAMEDATALEN) == 0)
+			if (gapfill_is_extension_function(funcid, GAPFILL_LOCF_FUNCTION))
 			{
 				state->columns[i] =
 					gapfill_column_state_create(LOCF_COLUMN, TupleDescAttr(tupledesc, i)->atttypid);
@@ -1479,9 +1477,7 @@ gapfill_state_initialize_columns(GapFillState *state, List *exec_tlist)
 										(FuncExpr *) expr);
 				continue;
 			}
-			if (strncmp(get_func_name(castNode(FuncExpr, expr)->funcid),
-						GAPFILL_INTERPOLATE_FUNCTION,
-						NAMEDATALEN) == 0)
+			if (gapfill_is_extension_function(funcid, GAPFILL_INTERPOLATE_FUNCTION))
 			{
 				state->columns[i] =
 					gapfill_column_state_create(INTERPOLATE_COLUMN,
diff --git a/tsl/src/nodes/gapfill/gapfill_plan.c b/tsl/src/nodes/gapfill/gapfill_plan.c
index 329ad9b46e2..3cb54b1a17c 100644
--- a/tsl/src/nodes/gapfill/gapfill_plan.c
+++ b/tsl/src/nodes/gapfill/gapfill_plan.c
@@ -21,11 +21,20 @@
 #include "compat/compat.h"
 
 #include "estimate.h"
+#include "func_cache.h"
 #include "gapfill.h"
 #include "gapfill_internal.h"
 #include "import/list.h"
 #include "utils.h"
 
+bool
+gapfill_is_extension_function(Oid funcid, const char *name)
+{
+	FuncInfo *finfo = ts_func_cache_get(funcid);
+
+	return finfo != NULL && strncmp(finfo->funcname, name, NAMEDATALEN) == 0;
+}
+
 static CustomScanMethods gapfill_plan_methods = {
 	.CustomName = "GapFill",
 	.CreateCustomScanState = gapfill_state_create,
@@ -119,8 +128,7 @@ contains_nonconstant_expr(Node *node)
 static inline bool
 is_gapfill_function_call(FuncExpr *call)
 {
-	char *func_name = get_func_name(call->funcid);
-	return strncmp(func_name, GAPFILL_FUNCTION, NAMEDATALEN) == 0;
+	return gapfill_is_extension_function(call->funcid, GAPFILL_FUNCTION);
 }
 
 /*
@@ -129,9 +137,8 @@ is_gapfill_function_call(FuncExpr *call)
 static inline bool
 is_marker_function_call(FuncExpr *call)
 {
-	char *func_name = get_func_name(call->funcid);
-	return strncmp(func_name, GAPFILL_LOCF_FUNCTION, NAMEDATALEN) == 0 ||
-		   strncmp(func_name, GAPFILL_INTERPOLATE_FUNCTION, NAMEDATALEN) == 0;
+	return gapfill_is_extension_function(call->funcid, GAPFILL_LOCF_FUNCTION) ||
+		   gapfill_is_extension_function(call->funcid, GAPFILL_INTERPOLATE_FUNCTION);
 }
 
 /*


Test patch:
diff --git a/tsl/test/shared/expected/gapfill_bug.out b/tsl/test/shared/expected/gapfill_bug.out
index 364273b3b0f..d204d04e189 100644
--- a/tsl/test/shared/expected/gapfill_bug.out
+++ b/tsl/test/shared/expected/gapfill_bug.out
@@ -510,3 +510,78 @@ GROUP BY 1, 2, 3;
                            ->  Seq Scan on gf_t1 t2
 
 drop table gf_t1 cascade;
+-- Issue #9971: a user-defined function named time_bucket_gapfill is not treated as ours
+CREATE TABLE gf9971(sensor_id int);
+INSERT INTO gf9971 VALUES (1), (2), (3);
+CREATE FUNCTION public.time_bucket_gapfill(bucket_width int, reading_time int, label text)
+RETURNS int AS $$
+BEGIN
+    RETURN reading_time / bucket_width * bucket_width;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE;
+EXPLAIN (costs off) SELECT time_bucket_gapfill(10, sensor_id, 'label'::text)
+FROM gf9971
+WHERE sensor_id >= 0 AND sensor_id < 100
+GROUP BY 1;
+--- QUERY PLAN ---
+ Group
+   Group Key: (time_bucket_gapfill(10, sensor_id, 'label'::text))
+   ->  Sort
+         Sort Key: (time_bucket_gapfill(10, sensor_id, 'label'::text))
+         ->  Seq Scan on gf9971
+               Filter: ((sensor_id >= 0) AND (sensor_id < 100))
+
+DROP FUNCTION public.time_bucket_gapfill(int, int, text);
+DROP TABLE gf9971;
+-- Issue #9971: user functions sharing a gapfill function name are not ours
+CREATE SCHEMA gf_userfn;
+CREATE FUNCTION gf_userfn.time_bucket_gapfill(v int, p text) RETURNS text
+  AS $$ BEGIN RETURN p || v::text; END; $$ LANGUAGE plpgsql IMMUTABLE;
+CREATE FUNCTION gf_userfn.locf(v int) RETURNS text
+  AS $$ BEGIN RETURN 'l' || v::text; END; $$ LANGUAGE plpgsql IMMUTABLE;
+CREATE FUNCTION gf_userfn.interpolate(v int) RETURNS text
+  AS $$ BEGIN RETURN 'i' || v::text; END; $$ LANGUAGE plpgsql IMMUTABLE;
+CREATE TABLE gf_userfn_t(ts timestamptz, val int);
+INSERT INTO gf_userfn_t VALUES ('2024-01-01 00:30', 1), ('2024-01-01 02:30', 2);
+-- same-named time_bucket_gapfill grouped on
+SELECT time_bucket_gapfill('1 hour', ts, '2024-01-01'::timestamptz, '2024-01-01 04:00'::timestamptz),
+       gf_userfn.time_bucket_gapfill(val, 'sensor_')
+FROM gf_userfn_t
+GROUP BY 1, 2;
+     time_bucket_gapfill      | time_bucket_gapfill 
+------------------------------+---------------------
+ Mon Jan 01 00:00:00 2024 PST | sensor_1
+ Mon Jan 01 01:00:00 2024 PST | sensor_1
+ Mon Jan 01 02:00:00 2024 PST | sensor_1
+ Mon Jan 01 03:00:00 2024 PST | sensor_1
+ Mon Jan 01 00:00:00 2024 PST | sensor_2
+ Mon Jan 01 01:00:00 2024 PST | sensor_2
+ Mon Jan 01 02:00:00 2024 PST | sensor_2
+ Mon Jan 01 03:00:00 2024 PST | sensor_2
+
+-- same-named locf and interpolate
+SELECT time_bucket_gapfill('1 hour', ts, '2024-01-01'::timestamptz, '2024-01-01 04:00'::timestamptz),
+       gf_userfn.locf(max(val))
+FROM gf_userfn_t GROUP BY 1;
+     time_bucket_gapfill      | locf 
+------------------------------+------
+ Mon Jan 01 00:00:00 2024 PST | l1
+ Mon Jan 01 01:00:00 2024 PST | 
+ Mon Jan 01 02:00:00 2024 PST | l2
+ Mon Jan 01 03:00:00 2024 PST | 
+
+SELECT time_bucket_gapfill('1 hour', ts, '2024-01-01'::timestamptz, '2024-01-01 04:00'::timestamptz),
+       gf_userfn.interpolate(max(val))
+FROM gf_userfn_t GROUP BY 1;
+     time_bucket_gapfill      | interpolate 
+------------------------------+-------------
+ Mon Jan 01 00:00:00 2024 PST | i1
+ Mon Jan 01 01:00:00 2024 PST | 
+ Mon Jan 01 02:00:00 2024 PST | i2
+ Mon Jan 01 03:00:00 2024 PST | 
+
+DROP TABLE gf_userfn_t;
+DROP FUNCTION gf_userfn.time_bucket_gapfill(int, text);
+DROP FUNCTION gf_userfn.locf(int);
+DROP FUNCTION gf_userfn.interpolate(int);
+DROP SCHEMA gf_userfn;
diff --git a/tsl/test/shared/sql/gapfill_bug.sql b/tsl/test/shared/sql/gapfill_bug.sql
index 5349a037fd3..e7413e4a0fc 100644
--- a/tsl/test/shared/sql/gapfill_bug.sql
+++ b/tsl/test/shared/sql/gapfill_bug.sql
@@ -292,3 +292,53 @@ FROM gf_t1 t1, gf_t1 t2
 GROUP BY 1, 2, 3;
 
 drop table gf_t1 cascade;
+
+-- Issue #9971: a user-defined function named time_bucket_gapfill is not treated as ours
+CREATE TABLE gf9971(sensor_id int);
+INSERT INTO gf9971 VALUES (1), (2), (3);
+
+CREATE FUNCTION public.time_bucket_gapfill(bucket_width int, reading_time int, label text)
+RETURNS int AS $$
+BEGIN
+    RETURN reading_time / bucket_width * bucket_width;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE;
+
+EXPLAIN (costs off) SELECT time_bucket_gapfill(10, sensor_id, 'label'::text)
+FROM gf9971
+WHERE sensor_id >= 0 AND sensor_id < 100
+GROUP BY 1;
+
+DROP FUNCTION public.time_bucket_gapfill(int, int, text);
+DROP TABLE gf9971;
+
+-- Issue #9971: user functions sharing a gapfill function name are not ours
+CREATE SCHEMA gf_userfn;
+CREATE FUNCTION gf_userfn.time_bucket_gapfill(v int, p text) RETURNS text
+  AS $$ BEGIN RETURN p || v::text; END; $$ LANGUAGE plpgsql IMMUTABLE;
+CREATE FUNCTION gf_userfn.locf(v int) RETURNS text
+  AS $$ BEGIN RETURN 'l' || v::text; END; $$ LANGUAGE plpgsql IMMUTABLE;
+CREATE FUNCTION gf_userfn.interpolate(v int) RETURNS text
+  AS $$ BEGIN RETURN 'i' || v::text; END; $$ LANGUAGE plpgsql IMMUTABLE;
+CREATE TABLE gf_userfn_t(ts timestamptz, val int);
+INSERT INTO gf_userfn_t VALUES ('2024-01-01 00:30', 1), ('2024-01-01 02:30', 2);
+
+-- same-named time_bucket_gapfill grouped on
+SELECT time_bucket_gapfill('1 hour', ts, '2024-01-01'::timestamptz, '2024-01-01 04:00'::timestamptz),
+       gf_userfn.time_bucket_gapfill(val, 'sensor_')
+FROM gf_userfn_t
+GROUP BY 1, 2;
+
+-- same-named locf and interpolate
+SELECT time_bucket_gapfill('1 hour', ts, '2024-01-01'::timestamptz, '2024-01-01 04:00'::timestamptz),
+       gf_userfn.locf(max(val))
+FROM gf_userfn_t GROUP BY 1;
+SELECT time_bucket_gapfill('1 hour', ts, '2024-01-01'::timestamptz, '2024-01-01 04:00'::timestamptz),
+       gf_userfn.interpolate(max(val))
+FROM gf_userfn_t GROUP BY 1;
+
+DROP TABLE gf_userfn_t;
+DROP FUNCTION gf_userfn.time_bucket_gapfill(int, text);
+DROP FUNCTION gf_userfn.locf(int);
+DROP FUNCTION gf_userfn.interpolate(int);
+DROP SCHEMA gf_userfn;