@@ -178,6 +178,7 @@ static bool ModifiesLocalTableWithRemoteCitusLocalTable(List *rangeTableList);
178178static DeferredErrorMessage * DeferErrorIfUnsupportedLocalTableJoin (List * rangeTableList );
179179static bool IsLocallyAccessibleCitusLocalTable (Oid relationId );
180180static bool ConvertToQueryOnShard (Query * query , Oid relationID , Oid shardRelationId );
181+ static void ReplaceModifyingCteWithEmptyResult (Query * outerQuery );
181182
182183/*
183184 * CreateRouterPlan attempts to create a router executor plan for the given
@@ -284,6 +285,16 @@ CreateSingleTaskRouterSelectPlan(DistributedPlan *distributedPlan, Query *origin
284285 return ;
285286 }
286287
288+ /*
289+ * RouterJob may rewrite a zero-shard modifying CTE inside a router SELECT
290+ * into a plain empty-result CTE (see ReplaceModifyingCteWithEmptyResult).
291+ * That clears originalQuery->hasModifyingCTE, so recompute modLevel from
292+ * the (possibly-transformed) job query -- otherwise the executor would
293+ * treat the plan as ROW_MODIFY_NONCOMMUTATIVE and attempt to acquire
294+ * shard-locks on the pruned-away shard.
295+ */
296+ distributedPlan -> modLevel = RowModifyLevelForQuery (job -> jobQuery );
297+
287298 ereport (DEBUG2 , (errmsg ("Creating router plan" )));
288299
289300 distributedPlan -> workerJob = job ;
@@ -2004,6 +2015,39 @@ RouterJob(Query *originalQuery, PlannerRestrictionContext *plannerRestrictionCon
20042015 }
20052016 }
20062017 }
2018+ else if (shardId == INVALID_SHARD_ID && originalQuery -> hasModifyingCTE )
2019+ {
2020+ /*
2021+ * Router SELECT that wraps a modifying CTE whose pruning
2022+ * yielded zero shards (e.g. WITH u AS (UPDATE t ... WHERE
2023+ * dist_key = X AND FALSE RETURNING ...) SELECT ... FROM u).
2024+ *
2025+ * PlanRouterQuery returned INVALID_SHARD_ID with a dummy
2026+ * placement, and UpdateRelationToShardNames has already
2027+ * flipped the CTE's target relation RTE to an empty-result
2028+ * RTE_SUBQUERY. The direct UPDATE/DELETE escape above only
2029+ * inspects the OUTER query's resultRelation (which is 0 for
2030+ * a SELECT), so absent this branch the query would fall
2031+ * through to SingleShardTaskList and be promoted to a
2032+ * MODIFY_TASK with anchorShardId = 0 -- which then fails at
2033+ * execution time in AcquireMetadataLocks ->
2034+ * LookupShardIdCacheEntry(0) with "could not find valid
2035+ * entry for shard 0".
2036+ *
2037+ * We cannot use the taskList = NIL contract that the direct
2038+ * UPDATE/DELETE escape above uses, because the outer SELECT
2039+ * has a consumer (e.g. SELECT count(*) FROM u must return
2040+ * one row containing 0, not the empty resultset that an empty
2041+ * task list would yield). Instead, rewrite each modifying CTE
2042+ * in-place to a plain SELECT that returns no rows but retains
2043+ * the CTE's output column shape, and clear hasModifyingCTE.
2044+ * The rewritten query then flows through the normal
2045+ * zero-shard router-SELECT machinery (READ_TASK on the dummy
2046+ * placement), producing the correct empty CTE and letting the
2047+ * outer aggregate emit the required single row.
2048+ */
2049+ ReplaceModifyingCteWithEmptyResult (originalQuery );
2050+ }
20072051
20082052 if (isMultiShardModifyQuery )
20092053 {
@@ -2134,6 +2178,27 @@ CheckAndBuildDelayedFastPathPlan(DistributedPlanningContext *planContext,
21342178 return ;
21352179 }
21362180
2181+ if (list_length (job -> taskList ) == 0 )
2182+ {
2183+ /*
2184+ * Planner-time shard pruning legitimately produced zero shards for a
2185+ * delayed fast-path modification (e.g. WHERE dist_key = X AND FALSE,
2186+ * or a fast-path fallback where the distribution-key type does not
2187+ * match the literal type and PruneShards short-circuits on
2188+ * ContainsFalseClause). GenerateSingleShardRouterTaskList already
2189+ * established the terminal state job->taskList = NIL for this case
2190+ * in the non-delayed fast-path route; mirror the deferred-pruning
2191+ * branch above so we build a placeholder plan (no single-task local
2192+ * shortcut, no deparse) and let the executor treat the empty task
2193+ * list as a silent no-op, matching the non-fast-path direct
2194+ * UPDATE/DELETE contract.
2195+ */
2196+ planContext -> plan = FastPathPlanner (planContext -> originalQuery ,
2197+ planContext -> query ,
2198+ planContext -> boundParams );
2199+ return ;
2200+ }
2201+
21372202 List * tasks = job -> taskList ;
21382203 Assert (list_length (tasks ) == 1 );
21392204 Task * task = (Task * ) linitial (tasks );
@@ -2290,6 +2355,94 @@ ConvertToQueryOnShard(Query *query, Oid citusTableOid, Oid shardId)
22902355}
22912356
22922357
2358+ /*
2359+ * ReplaceModifyingCteWithEmptyResult rewrites each modifying (UPDATE/DELETE)
2360+ * CTE in outerQuery->cteList so that its body becomes a plain SELECT whose
2361+ * jointree quals are constant FALSE and whose target list is a matching-shape
2362+ * list of NULL constants. This preserves the outer query's structural reference
2363+ * to the CTE (name, output column names/types/typmods/collations) while
2364+ * guaranteeing the CTE produces zero rows and requires no shard metadata.
2365+ *
2366+ * hasModifyingCTE is cleared only if no other modifying CTE (e.g. CMD_INSERT,
2367+ * CMD_MERGE) remains in the cteList after the rewrite; otherwise the flag is
2368+ * preserved so downstream routing (READ_TASK vs MODIFY_TASK selection,
2369+ * modLevel computation, lock acquisition) still treats the query as
2370+ * modifying. When the flag does end up cleared, the outer SELECT flows
2371+ * through the standard zero-shard router-SELECT machinery (READ_TASK on the
2372+ * dummy placement, no shard-metadata lookups, no MODIFY_TASK promotion in
2373+ * SingleShardTaskList).
2374+ *
2375+ * This helper mutates outerQuery in place. Callers must have already
2376+ * established that pruning yielded zero shards (shardId == INVALID_SHARD_ID);
2377+ * pruning-relevant fields (placementList, relationShardList, prunedShardIntervalListList)
2378+ * are unaffected.
2379+ */
2380+ static void
2381+ ReplaceModifyingCteWithEmptyResult (Query * outerQuery )
2382+ {
2383+ CommonTableExpr * cte = NULL ;
2384+ bool anyModifyingCteLeft = false;
2385+
2386+ foreach_declared_ptr (cte , outerQuery -> cteList )
2387+ {
2388+ Query * cteQuery = (Query * ) cte -> ctequery ;
2389+
2390+ if (cteQuery -> commandType != CMD_UPDATE &&
2391+ cteQuery -> commandType != CMD_DELETE )
2392+ {
2393+ /*
2394+ * Non-UPDATE/DELETE CTE: leave it alone. If it is still a
2395+ * modification (e.g. CMD_INSERT, CMD_MERGE) note that so we do
2396+ * not clear the outer query's hasModifyingCTE flag below.
2397+ */
2398+ if (cteQuery -> commandType != CMD_SELECT )
2399+ {
2400+ anyModifyingCteLeft = true;
2401+ }
2402+ continue ;
2403+ }
2404+
2405+ int columnCount = list_length (cte -> ctecoltypes );
2406+ List * targetList = NIL ;
2407+
2408+ for (int columnIndex = 0 ; columnIndex < columnCount ; columnIndex ++ )
2409+ {
2410+ Oid coltype = list_nth_oid (cte -> ctecoltypes , columnIndex );
2411+ int32 coltypmod = list_nth_int (cte -> ctecoltypmods , columnIndex );
2412+ Oid colcoll = list_nth_oid (cte -> ctecolcollations , columnIndex );
2413+ Node * colname = (Node * ) list_nth (cte -> ctecolnames , columnIndex );
2414+
2415+ Const * nullConst = makeNullConst (coltype , coltypmod , colcoll );
2416+
2417+ TargetEntry * targetEntry = makeNode (TargetEntry );
2418+ targetEntry -> expr = (Expr * ) nullConst ;
2419+ targetEntry -> resno = columnIndex + 1 ;
2420+ targetEntry -> resname = pstrdup (strVal (colname ));
2421+ targetEntry -> resorigtbl = InvalidOid ;
2422+ targetEntry -> resorigcol = 0 ;
2423+ targetEntry -> resjunk = false;
2424+
2425+ targetList = lappend (targetList , targetEntry );
2426+ }
2427+
2428+ FromExpr * joinTree = makeNode (FromExpr );
2429+ joinTree -> fromlist = NIL ;
2430+ joinTree -> quals = (Node * ) makeBoolConst (false, false);
2431+
2432+ Query * emptyCteQuery = makeNode (Query );
2433+ emptyCteQuery -> commandType = CMD_SELECT ;
2434+ emptyCteQuery -> querySource = cteQuery -> querySource ;
2435+ emptyCteQuery -> canSetTag = cteQuery -> canSetTag ;
2436+ emptyCteQuery -> targetList = targetList ;
2437+ emptyCteQuery -> jointree = joinTree ;
2438+
2439+ cte -> ctequery = (Node * ) emptyCteQuery ;
2440+ }
2441+
2442+ outerQuery -> hasModifyingCTE = anyModifyingCteLeft ;
2443+ }
2444+
2445+
22932446/*
22942447 * GenerateSingleShardRouterTaskList is a wrapper around other corresponding task
22952448 * list generation functions specific to single shard selects and modifications.
0 commit comments