Skip to content

[CBRD-26922] Support a remote UPDATE that takes its values or its condition from a local subquery - #7955

Draft
kwangsoochae wants to merge 22 commits into
CUBRID:developfrom
kwangsoochae:CBRD-26922_dblink_update_local_subquery
Draft

kwangsoochae wants to merge 22 commits into
CUBRID:developfrom
kwangsoochae:CBRD-26922_dblink_update_local_subquery

Conversation

@kwangsoochae

@kwangsoochae kwangsoochae commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

http://jira.cubrid.org/browse/CBRD-26922

Purpose

원격 UPDATE 는 문장 하나를 원격에 그대로 보낸다. 그 문장이 로컬 테이블을 읽으면 성립하지 않는다 —
원격에는 그런 테이블이 없다 — 그래서 DBLink 는 이 형태를 통째로 거절해 왔다.

같은 제약을 INSERT ... SELECTDELETE 는 이미 풀었다 — 문장을 보내지 않고 값을 보낸다. INSERT SELECT(CBRD-26796)는 로컬 행을
원격 INSERT 로 흘려보내고, DELETE(CBRD-26921)는 로컬 서브쿼리가 낸 값마다 원격 DELETE 를 한 번씩 보낸다.
UPDATE 는 남은 하나이고, 앞의 둘에 없는 요구가 하나 있다. 어느 행을 고를지에 더해 무엇으로 바꿀지
말해야 하고, 그 값 역시 로컬에서 올 수 있다.

이 PR 은 UPDATE 를 같은 sink 에 얹는다. 로컬 서브쿼리는 WHERE 에, SET 에, 또는 둘 다에 올 수 있다.

UPDATE remote_t@srv SET name = 'x'  WHERE id IN (SELECT id FROM local_t);   -- 조건이 로컬
UPDATE remote_t@srv SET name = (SELECT name FROM local_t WHERE id = 2);     -- 값이 로컬
UPDATE remote_t@srv SET name = (SELECT name FROM local_t WHERE id = 2)
                      WHERE id IN (SELECT id FROM local_t);                 -- 둘 다

Implementation

읽는 순서. 커밋 19개가 세 묶음으로 서 있다. 커밋 단위로 보시면 기계적인 부분과 판단이 필요한 부분이 섞이지 않는다.

묶음 커밋 무엇
정리 (앞 5개) 9ceccff cd21137 8c6fc9c 5600e3a 3e6d15a 동작 변화 없음 — USING INDEX 프린터 추출, DELETE carve-out 헬퍼 공유화(DELETE 메시지는 바이트까지 그대로), 원격 WHERE 키·연산자를 공용 구조체로, 두 빌더가 복제할 뻔한 WHERE 번역 추출. 3e6d15a 만 결함 수정이다 — XASL 이 락 목록을 aptr 체인의 머리에서만 모으고 있었다
기능 (7개) b2a962d 이후 게이트 → SET 판정 → UPDATE_PROC 필드 → 플랜 → 원격 문장 조립 → 실행 → 개방 순으로 sink 를 한 겹씩 얹는다
결정 (뒤 7개) 782a78a d04a533 3c3fe89 d4f0a8b a383ff9 751ef94 8e4b1e0 판단이 들어간 자리다 — USING INDEX 전달(DELETE 동작도 함께 바뀐다), ORDER BY 거절, 행을 한 번만 갱신, 같은 서버 혼합 개방, 거절 문구를 종류별·원인별로

파서 게이트. pt_convert_dblink_update_query () 가 두 절을 각각 판정하고, 둘 다 통과하면서 로컬
서브쿼리가 하나라도 있을 때만 받는다. WHERE 는 두 형태다 — 없거나(그러면 서브쿼리가 SET 에 있고 원격
전 행이 대상이다), push 할 수 있는 술어 하나(DELETE sink 와 같은 연산자 집합). SET 은 각 대입이 맨
스칼라 서브쿼리이거나 원격이 스스로 계산할 수 있는 식일 때 받는다. 이 낙관적 판정은 공용 settle 단계가
다듬는다 — 같은 서버 서브쿼리를 변환하고, 원격 spec 이 남으면 sink 를 거둔다.

원격 SET 절에 실을 수 있는 것. pt_dblink_dml_is_remote_only_expr () 가 리터럴, 원격 타깃의 맨 컬럼,
그리고 그것들에 대한 + - * /(단항 - 포함)를 받는다. 로컬 스칼라 서브쿼리는 실행 시 바인드되는
플레이스홀더가 되고, 위 목록에 드는 것은 이쪽에서 값을 구할 수 없으므로 SET 텍스트에 그대로 deparse 한다.
|| 는 일부러 뺐다 — CUBRID·Oracle 은 연결이지만 MySQL 은 PIPES_AS_CONCAT 이 없으면 OR 로 읽는다 —
함수 호출은 검증되지 않는 텍스트라 받지 않는다.

플랜. UPDATE_PROC 는 공용 sink 필드에 더해 원격이 받을 SET 절("c1 = ?, c2 = c2 + 1")과 그중 몇
개가 바인드되는지를 담는다. aptr 체인은 WHERE 가 있으면 그 서브쿼리를 맨 앞에 두고, 이어서 바인드되는
SET 값마다 단일행 서브쿼리를 하나씩 단다. 원격 문장은 자기 순서로 바인드한다 — SET 값이 먼저, WHERE 값이
뒤 — 그래서 체인의 머리가 마지막 플레이스홀더를 채운다. 두 순서가 만나는 자리마다 그 사실을 적었다.

런타임. 실행기가 체인의 모든 aptr 를 평가해 SET 값을 모으고 공용 sink 루프에 넘긴다. WHERE 가 있으면
WHERE 서브쿼리의 리스트 파일을 훑어 행마다 원격 문장을 한 번씩 보내며 SET 값과 그 행의 키를 바인드하고,
WHERE 가 없으면 훑을 것이 없어 문장 하나를 보낸다.

공용 코드. DELETE carve-out 헬퍼를 두 문장이 함께 쓰도록 일반화했다(DELETE 쪽 메시지는 바이트까지
그대로다). 원격 WHERE 키 컬럼과 연산자를 공용 sink 구조체로 옮겼고, 두 XASL 빌더가 복제할 뻔한 WHERE
번역을 떼어냈다. 그 과정에서 결함 하나가 드러나 함께 고쳤다 — XASL 이 클래스 OID·락·tcard 목록을 aptr
체인의 머리에서만 모으고 있었다. aptr 가 하나뿐일 때는 맞는 코드였지만, UPDATE 체인에서는 SET
서브쿼리가 읽는 테이블이 락 목록에서 빠진다.

Remarks

거절하는 형태. 게이트는 위 두 형태 밖을 모두 거절한다(전체 목록은 이슈 본문에 있다). 그중 판단이
필요했던 것은 WHERE 한정자다 — 갱신 타깃을 가리키지 않는 한정자도 거절한다. 원격 spec 에서는 이름
해석이 걸러 주지 못해, 검증되지 않은 한정자는 뒤에 붙은 속성 이름이 가리키는 무엇에든 작용하게 된다.

로컬과 타깃의 같은 서버 원격을 함께 읽는 서브쿼리는 DELETE 처럼 그대로 나른다. settle 단계가 그 spec
들을 변환하고 sink 를 유지하며, 다른 서버에 남은 spec 이 있으면 sink 를 거둔다. UPDATE 는 이 변환을 건너뛰고
형태를 거절해 왔는데, 두 트랙을 쓴 순서 말고는 문장 종류마다 다르게 답할 이유가 없었다. 건너뛴 이유로 적혀
있던 것이 aptr 체인이다 — 변환된 spec 은 WHERE 쪽 aptr 뿐 아니라 어느 aptr 에나 앉을 수 있다 — 그래서 체인
조합을 모두 확인했다. WHERE 가 변환되고 SET 이 로컬 스칼라를 바인드하는 형태, SET 만 변환되고
WHERE 가 없는 형태, 둘 다인 형태, SET 대입 둘 중 하나만 변환되는 형태. 서브쿼리가 갱신 타깃 자신을 읽는
형태도 포함했다 — DELETE 가 이미 그 형태를 나르고 있어 새로 생기는 노출이 아니다.

원격 전용 WHERE(col op 리터럴)는 이 PR 밖이다. 원격 문장이 그 조건을 그대로 실어야 하는데, 그
deparse 가 이 sink 에 없다.

원격 행은 한 번만 갱신된다. sink 는 WHERE 서브쿼리가 내는 값마다 문장을 하나씩 보내고 그 문장들은
서로 독립이라, 둘이 같은 행에 닿으면 그 행을 두 번 쓰게 된다 — 보고 건수가 실제로 바뀐 행 수를 넘어
부풀고, 행의 제 값을 읽는 SET 식이 두 번 적용되며, 원격 트리거가 두 번 발화한다. 겹침의 원인이 둘이라 답도
둘로 나뉜다. 로컬 값은 중복을 제거해 같은 문장이 다시 나가는 것을 없앴고, 값마다 대상 범위가 겹치는 술어는
기본형에서 뺐다(<> < > <= >= ANYcol < 2col < 3 은 2 미만을 모두 공유한다). 서로 다른 값이
서로 다른 행을 맞히는 IN·= ANY·스칼라 비교는 그대로 둔다. DELETE 는 둘 다 유지한다 — 같은 행을 두 번
지울 수 없어 건수가 이미 맞고, 중복 제거는 정렬 비용만 물게 된다.

그래도 건수가 무엇에 달려 있는지. 이 숫자는 원격이 보고한 값이다. MySQL 은 바뀐 행을 세고 CUBRID·
Oracle 은 맞은 행을 세므로, 행이 이미 가진 값으로 갱신하면 한쪽은 0, 다른 쪽은 1 로 보고한다.

원격에 닿지 못하는 절은 버리지 않고 거절한다. LIMIT 은 이미 그랬다 — 제한을 걸 단일 문장이 없다.
ORDER BY 도 이제 거절한다. 한 문장 안의 갱신 순서를 정하는 절이고 원격 트리거가 그 순서로 발화하며 로컬
UPDATE 는 그 순서를 지키므로, 조용히 사라지게 두면 지킨 것처럼 읽히고 실제로는 아니게 된다. USING INDEX
는 반대다 — 원격 타깃의 인덱스를 지목하는 절이므로 버리지 않고 쓰인 그대로 실어 보내고, 그 문법을 받지
않는 벤더에서는 원격 prepare 가 실패한다.

거절 문구가 문장 종류가 받는 형태를 이름한다. 종전에는 한 문장이 모든 종류의 지원 형태를 열거했고,
그래서 파서에서 가장 긴 메시지이면서도 UPDATE 는 빠져 있었다. 거절하는 자리는 문장 종류를 알고 있으니 그
종류의 형태만 말하게 했고, 말하기 전에 그 종류의 게이트를 다시 묻는다 — 멀쩡한 절을 틀렸다고 말하지 않기
위해서다. UPDATE 는 WHERE·SET·겹치는 술어 중 하나를, DELETE 는 WHERE 를, INSERT 는 받는 형태나 그것을
배제한 수식어를 이름한다. 어느 게이트도 설명하지 못하는 형태는 꼬리 없는 기본 문장을 받는다 — 수식어를
쓰지 않은 INSERT ... SELECT 가 교차 서버 때문에 sink 를 잃는 경우가 그 자리다. 옛 문구를 담고 있던
기대 출력은 TC 브랜치에서 함께 맞췄다.

벤더. 위 형태들을 게이트웨이를 통해 MySQL·Oracle 원격에 대고 돌렸다. 바인드되는 로컬 값과 원격 산술
식을 한 SET 절에 섞은 형태도 포함한다.

예약어 컬럼은 쓸 수 없다. 식별자를 따옴표 없이 붙이므로 SET [desc] = ... 는 원격 prepare 에서
실패한다. 이 sink 만의 문제가 아니다 — 머지된 DELETE sink 도, 그런 컬럼을 그냥 원격 SELECT 해도 같은
방식으로 실패한다 — DELETE 빌더에 남아 있는 TODO 가 따옴표를 미룬 이유를 적어 두었다(따옴표를 붙이면
Oracle 에서 식별자가 대소문자를 가리게 되고, MySQL 의 기본 따옴표는 백틱이다).

The DELETE + local-subquery carve-out keeps one helper per concern: the shape gate, the
qualifier check, the same-server conversion, the diagnostics. The UPDATE sink has to make
the same decisions on a statement whose clauses live in different PT_NODE union members,
so the helpers now take the predicate and the spec instead of a DELETE statement node, and
every walk covers the SET assignments alongside WHERE.

DELETE behavior is unchanged, messages included. The decline diagnostics stay DELETE-only
on purpose: widening them here would change which message an unsupported UPDATE gets, with
nothing yet routing an UPDATE to the sink. The "WHERE subquery" spelling is likewise kept,
DELETE's subquery having nowhere else to sit. The 18 WHERE shapes were run against this
branch to confirm the base's outcome -- the 9 supported ones remove the same rows, the
other 9 are refused with the base's own messages.

- pt_dblink_delete_* -> pt_dblink_dml_*: is_pushable_pred, where_is_inscope (now takes the
  predicate), check_qualifier (the predicate and the spec), target_range_name,
  has_remote_spec, subq_servers, convert_specs, reject_confirmed, reject_declined,
  settle_sink
- pt_dblink_dml_sink_trees / _search_cond: the subtrees a sink walk has to cover, named in
  one place so a new walk cannot miss the SET side
- pt_dblink_dml_stmt_name / _is_local_subq_sink: the statement word the messages use, and
  the sink kinds the settle step accepts
- the qualifier check is skipped when there is no WHERE, a shape only the UPDATE sink can
  reach; without it a missing predicate would be reported as a multi-part qualifier
- the sink kind enum gains DBLINK_REMOTE_SINK_UPDATE_LOCAL_SUBQ, unused until the carve-out
  lands

Literal-message PT_ERROR calls in the carve-out become PT_ERRORc for the same reason the
qualifier message already did: they now carry a formatted statement word, and PT_ERROR
would take the buffer as its format string.
An UPDATE whose target is remote and whose local references sit only in subqueries is a
value-push shape, the same as the DELETE sink: the local subqueries evaluate here and the
UPDATE goes to the remote server one row at a time. The gate now recognises it, so the
statement stops being serialized into a pushdown qstr -- which would have shipped the
local subquery to the remote server -- and reaches XASL generation with its WHERE and SET
trees intact.

The sink itself is not built yet, so pt_to_update_xasl refuses the shape with an "under
construction" message rather than emitting a plan that cannot run. The refusal is part of
this commit on purpose: without it the recognised shape would fall through to a plan whose
remote statement was never assembled.

Two UPDATE shapes are admitted, both with a single spec and no local update target:
- no WHERE, with at least one local subquery in SET
- WHERE is a pushable predicate carrying the local subquery, the DELETE sink's shape

A WHERE that only reads remote columns (col op literal) is left to the existing rejection.
Carrying it into the remote statement needs the same deparse the DELETE sink's remote-only
AND arms need, so it waits for that mechanism instead of growing a second one here.

pt_dblink_update_bind_subq binds the admitted subqueries in the semantic pass, where the
DELETE sink binds its own: a subquery correlated to the remote target is rejected there,
since the sink evaluates it before the remote statement runs and has no row to correlate
against. Standalone subqueries are bound in place.

pt_dblink_dml_subq_servers is deliberately not called for the UPDATE shapes. It rejects a
subquery that names the same server as the target, a restriction the DELETE sink needs and
the UPDATE sink does not: the UPDATE's local subqueries are gathered into the aptr chain
and executed before the remote statement, so a same-server subquery reads a committed
snapshot rather than the rows being written.

Measured on this branch: both admitted shapes reach the "under construction" message, a
SET subquery with a remote-only WHERE still gets the generic rejection, a literal-only
remote UPDATE is unaffected, and the 18 DELETE WHERE shapes keep their 9/9 outcome.
pt_finish_remote_dml_xasl copied the class OID, lock and tcard lists from the head of
xasl->aptr_list, which is all the INSERT SELECT and DELETE sinks need -- each carries a
single aptr. The UPDATE sink carries one aptr per SET scalar subquery on top of the WHERE
value stream, so every aptr but the first would contribute no locks and the tables it reads
would go unlocked for the statement. Walk the chain instead.

A class reached by two aptrs is registered once and its lock merged with lock_conv, the way
pt_spec_to_xasl_class_oid_list merges when two specs reach the same class. tcard belongs to
the class, so the first entry stands. dbval_cnt counts the parser's host variables rather
than anything per-aptr, so the chain's maximum is what the plan needs.

The XASL_INCLUDES_TDE_CLASS copy moves out of the OID-list branch and into the chain walk:
an aptr that reads no class cannot carry the flag, so the branch was never what decided it.

An assert states what the function has always assumed -- that it is handed a freshly
allocated node, the lists being built from empty rather than appended to.

Single-aptr behavior is unchanged, and that is still the only shape reaching here: the
UPDATE sink's XASL builder does not exist yet, so the multi-aptr walk has no caller until
it lands. The 18 DELETE WHERE shapes keep their 9/9 outcome, case for case.
… can carry

The SET gate took any right-hand side with no subquery in it. Two of those forms cannot be
sent to the remote server as they stand.

A host variable prints as "?". The sink deparses the SET clause and binds its own values to
the placeholders it put there, so a user's "?" would land in that clause as a placeholder
with no value behind it -- the binds would shift by one, silently writing the wrong column.

A function call would travel as text this side has reasoned nothing about. The remote may not
have the function, or may have it with other semantics; the text push path already lives with
that, but the sink has no reason to take it on.

So the accepted forms are now a list: a literal, a column of the remote target, or arithmetic
over those. Concatenation is deliberately not on it -- "||" concatenates on CUBRID and Oracle
but reads as OR on MySQL unless PIPES_AS_CONCAT is set, so shipping it there would compute a
different value rather than fail, and the sink supports MySQL targets.

pt_dblink_dml_is_remote_only_expr answers that question for a value expression, and is named
for the question rather than for the SET clause, so another DML that deparses a value into
its remote statement can reuse it.

pt_dblink_find_query and pt_dblink_has_query go away with this: the walk existed to catch a
subquery buried in an expression, which the list now turns away along with everything else
outside it. They were added in this branch's previous commit, so nothing else used them.

Measured: a literal, a remote-column arithmetic expression and a scalar subquery still reach
the "under construction" message; concatenation, SUBSTR, a subquery inside an expression and a
host variable in SET all get the generic rejection; the 18 DELETE WHERE shapes keep their 9/9
outcome.
The UPDATE sink needs four things on the server that the DELETE sink's fields do not cover:
the SET clause to send, how many of its values are bound, and the WHERE column and operator
when the statement has one. UPDATE_PROC gains them alongside the shared REMOTE_DML_SINK, and
the pack/sizeof/unpack of UPDATE_PROC carries them the way DELETE_PROC already carries its own.

remote_set_text is the SET clause with a placeholder per bound value ("c1 = ?, c2 = c2 + 1").
A local subquery becomes a placeholder, so the statement text is fixed at compile time and the
value travels as a bind -- the remote is never handed a literal this side quoted or transcoded.
The rest is deparsed into the text instead, its value not being computable here;
pt_dblink_dml_is_remote_only_expr is what decides which expressions those are.

remote_num_set_binds says how many placeholders the SET clause holds. The same number of aptrs
supply those values in chain order, after the driving aptr; nothing maps a placeholder to a
column, and the count is what a later phase checks the chain against.

remote_key_col and remote_op are NULL together when the statement has no WHERE -- the shape that
updates every remote row and has no driving aptr. That NULL is what tells the runtime which of
the two shapes it is holding.

REMOTE_DML_SINK moves above the three DML proc structs. It was defined between UPDATE_PROC and
INSERT_PROC, which was fine while only the two below it used it. Its pack and sizeof helpers now
name UPDATE too, as the unpack side already did.

No behavior change: nothing sets these fields yet, and a plain UPDATE leaves them as regu_init's
zeroed node has them (is_remote false, pointers NULL).
DELETE_PROC and UPDATE_PROC each carried remote_key_col and remote_op with the same names and
the same meaning: the column the per-row value is compared to in the remote statement, and the
operator to compare it with. Each proc also packed, sized and unpacked them on its own.

They move into REMOTE_DML_SINK, which the procs already share for the connection and the target
table. One definition, and one place in the stream helpers. INSERT SELECT carries them as NULL,
the shape it already has for a statement with no WHERE -- the same NULL the UPDATE shape that
updates every remote row will carry. pt_fill_remote_dml_sink says so where it fills the rest,
rather than assigning the NULLs the freshly allocated node already holds.

Behavior is unchanged, and the stream layout change is covered by the two sinks that exist: the
18 DELETE WHERE shapes keep their 9/9 outcome and remove the same rows, compared case by case
against the run before this commit, and a remote INSERT SELECT still lands its row.
The DELETE sink builder turns its WHERE predicate into three things the remote statement needs: the
key column, the operator as SQL text, and the subquery whose values the sink binds one at a time.
The UPDATE sink builder that follows asks the same of the same shape, so the translation moves into
pt_dblink_dml_xasl_where and the DELETE builder calls it.

The messages take the statement word as a parameter, the way the gate's diagnostics do, so DELETE's
read exactly as before. arg1 and arg2 stop being locals of the builder, and the subquery comes back
through the out parameter the skeleton already used.

DELETE behavior is unchanged: the 18 WHERE shapes keep their 9/9 outcome, remove the same rows, and
carry the same rejection messages, compared case by case against the run before this commit.
pt_to_update_xasl_remote_subquery turns an admitted statement into the UPDATE_PROC the sink will
run. It deparses the SET clause the remote receives, collects the subqueries whose values fill its
placeholders, and takes the WHERE apart through pt_dblink_dml_xasl_where when the statement has one.

Two orders meet in the plan and they are not the same one. The aptr chain is the driving subquery
first when there is a WHERE, then one aptr per bound SET value in SET order; the remote statement
binds in its own text order, the SET values and then the WHERE value. Each SET aptr is a single-tuple
XASL holding the one-value list a scalar subquery gets anywhere else, so a subquery returning more
than one row is the error it is anywhere else, one returning none leaves the value NULL, and a hidden
column does not widen it past the placeholder it feeds. One walk of the SET list builds both the text
and the chain, so the two cannot drift.

A SET value that is not a subquery is printed into the text with the pushdown's payload flags -- no
server name, no bracket quoting, no host variable index -- plus PT_SUPPRESS_RESOLVED, so the fragment
does not carry the owner prefix name resolution put on. It is re-checked against
pt_dblink_dml_is_remote_only_expr first: the gate accepted it already, and re-checking means a drift
between the two rejects the statement instead of shipping an expression nobody reasoned about. That
check becomes non-static for this.

Packing an UPDATE_PROC the sink built works now. It has no local class and no assignment array, and
both xts_save_upddel_class_info_array and xts_save_update_assignment_array assert nelements > 0, so
the pack writes offset 0 for each and the unpack reads them back as NULL. DELETE already had that
shape; UPDATE had it for neither.

The statement is still refused. The plan is built on every such statement -- so the builder runs, and
a mistake in it surfaces now rather than at the end -- but the per-row runtime that would execute it
is not there yet. The refusal is what the runtime commit removes. It reports through the parser only
when the builder left a parse-tree error, so the builder's own er_set is not replaced by "Internal
error- reporting semantic error".

Measured with a temporary probe over six shapes: a SET subquery with no WHERE gives "name = ?" with
one bind and one aptr; two SET subqueries with a WHERE give "name = ?, cnt = ?" with two binds and
three aptrs; a remote-column expression gives "cnt = cnt+1" with none; the WHERE key and operator come
out as id/= and id/<, including through an alias-qualified predicate. A SET subquery with two columns
is refused with the builder's own message. The 18 DELETE WHERE shapes keep their 9/9 outcome, remove
the same rows and carry the same messages.
dblink_dml_build_update_sql writes "UPDATE <table> SET <set_text>", and appends
" WHERE <key_col> <op> ?" when the statement has a WHERE. set_text arrives from XASL generation with a
placeholder per bound SET value already in it, so the statement's placeholders read SET first and then
the key -- the order the caller binds in.

key_col and op arrive together or not at all: an UPDATE that sends no WHERE updates every remote row,
and the two disagreeing means the plan and the runtime read the shape differently, so the builder says
so rather than sending half a statement.

dblink_dml_open takes the sink itself now instead of its four connection fields, and reads the WHERE
column and operator from it rather than from parameters of its own -- they moved into the sink earlier
in this branch. With the SET clause added it takes eight parameters where it took twelve. The header
forward-declares the struct, so it does not pull xasl.h in.

Identifiers are appended unquoted, as the DELETE builder appends its key column; the TODO there covers
both.

No behavior change: nothing asks for DBLINK_DML_UPDATE yet -- the parser still refuses the statement
before a plan reaches the server.
qexec_execute_remote_update_subquery evaluates the plan's local subqueries and hands off to the shared
sink loop, the way the DELETE wrapper does. It runs every aptr, not just the head: the chain holds the
driving subquery when the statement has a WHERE, and one single-tuple subquery per bound SET value.

The shared loop gains the UPDATE kind. Its bind array is the SET values first and then the WHERE value,
the order the statement's placeholders read in; qexec_collect_remote_update_set_vals points the first
slots at what the SET aptrs left in their single_tuple, and each row fills the last one the way DELETE
fills its only one -- including skipping a NULL key, which matches nothing remotely.

An UPDATE with no WHERE has nothing to scan. It is the one kind that reaches the loop without a spec,
so it sends its statement once with the SET values and returns before the scan opens. A plan that
carries no scan but still sends a WHERE is refused before the remote is touched -- binding the SET
values against a statement that still has a key placeholder is the mismatch that would follow.

Counting follows DELETE: the remote's own reported count per execute, accumulated. A key with no remote
match reports 0 and a non-unique one reports more than 1, and neither equals "one local subquery row".

Still not reachable: the parser refuses the statement before a plan reaches the server, so the DELETE
and INSERT sinks are what this commit is measured against -- the 18 DELETE WHERE shapes keep their 9/9
outcome and remove the same rows, and a remote INSERT SELECT still lands its row.
The refusal that stood in for the missing runtime goes away, and pt_to_update_xasl comes back with the
plan the sink builder makes. That plan and the runtime that executes it are the two commits before this
one; this is the line that connects them.

Measured end to end, against a remote table of three rows and a local table of two:
- SET subquery with no WHERE updates every remote row (3 affected, all three carry the local value)
- WHERE local subquery updates the rows it selects (2 affected, rows 2 and 3)
- two SET subqueries land in their own columns, in SET order
- a remote-column expression is evaluated remotely (cnt + 100 on the rows the subquery picked)
- a SET subquery returning no row sets NULL, the value a scalar subquery has anywhere else
- a WHERE subquery returning no row sends nothing to the remote (0 affected, remote unchanged)

One measured shape does not report what it changed. "WHERE id < ANY (SELECT ...)" over two local values
sends one statement per value, and the ranges overlap, so a remote row inside both is updated twice and
counted twice: three affected for two changed rows. The DELETE sink has no such gap, since a row can
only be deleted once and the second statement reports zero for it. What UPDATE should count instead is
open in CBRD-26922 and recorded in the EPIC's cross-DML notes; this commit does not decide it.

The other sinks are unchanged: the 18 DELETE WHERE shapes keep their 9/9 outcome and remove the same
rows, a remote INSERT SELECT still lands its row, and a literal-only remote UPDATE still takes the
pushdown path.
@kwangsoochae kwangsoochae self-assigned this Sep 16, 2026
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown

❌ TC Merge Gate — Merge Blocked

One or more TC PRs are still open. Please merge or close them before merging this PR.

TC Repositories & Branches:

  • cubrid-testcases: TC PR tc/pr-7955 is open (draft) — must be merged or closed first
  • cubrid-testcases-private-ex: TC PR tc/pr-7955 is open (draft) — must be merged or closed first

Steps to unblock:

  1. Merge or close all TC PRs listed above.
  2. Re-run this check: Actions tab → TC Merge Gate → Re-run failed jobs

@kwangsoochae

Copy link
Copy Markdown
Contributor Author

@greptileai review

@github-actions

Copy link
Copy Markdown

🧪 TC Test Environment Ready

CircleCI Testing:

  • CircleCI will automatically test using the branches below.

TC Repositories & Branches:

Next Steps:

  1. Wait for CircleCI tests to complete
  2. If CircleCI tests failed, please check the test results and fix the issues.
  3. When ready to merge this PR, please merge the TC PR first, then merge this PR.

@greptile-apps

greptile-apps Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Retrigger

동일 원격 행의 반복 갱신과 ORDER BY 의미 유실이 실제 UPDATE 결과 및 트리거 부수 효과를 바꿀 수 있어 현재 상태로는 병합하기 안전하지 않습니다.

Reviews (1) · Last reviewed commit: "Let the remote UPDATE sink run"


/* like DELETE, the remote's own count: a key with no match reports 0, a non-unique one more
* than 1, and neither equals "one local subquery row" */
if (dblink_dml_execute_row (thread_p, &dblink_state, upd_bindv, num_set + 1, &row_affected) != NO_ERROR)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 동일 행 반복 갱신

로컬 서브쿼리의 각 결과마다 원격 UPDATE를 다시 실행하므로 SQL의 집합 의미가 깨집니다. 예를 들어 SET n = n + 1 WHERE id IN (SELECT id FROM local_t)에서 local_t가 같은 id를 두 번 반환하면 동일한 원격 행이 두 번 증가합니다. < ANY처럼 각 조건의 대상 범위가 겹치는 경우에도 같은 행이 반복 갱신됩니다. 일반 UPDATE처럼 대상 행을 한 번만 갱신하도록 중복·중첩 결과를 하나의 원격 predicate로 합치거나, 이러한 형태를 거부해야 합니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀 주신 대로 고쳤습니다(3c3fe89). 겹침의 원인이 둘이어서 답을 나눴습니다.

  • 값 중복은 구동 서브쿼리를 select distinct 로 만들어 없앴습니다 — 반복 자체가 사라지므로 합치는 쪽입니다.
  • 대상 범위가 겹치는 술어는 중복 제거로 막히지 않아 UPDATE 기본형에서 뺐습니다(<> < > <= >= ANY). 남는 것은 IN · = ANY · 스칼라 비교이고, 값이 서로 다르면 대상이 겹치지 않습니다. 뺀 형태는 확장 후보로 남겼습니다.

짚어 주신 < ANY 말고 <> ANY 도 같이 뺐습니다. 등치 계열로 보고 남기려 했는데 원격 r(1,2,3,4) · 로컬 l(2,3) 에서 보고 6 · 실제로 바뀐 행 4 로 겹침이 확인됐습니다. 값이 중복이 아니어도 겹치는 형태였습니다.

Comment on lines +12559 to +12562
if (pt_dblink_update_where_is_inscope (node, &where_driving_pred)
&& pt_dblink_update_set_is_inscope (parser, node, &num_set_subq) && (where_driving_pred || num_set_subq > 0))
{
snl->sink_kind = DBLINK_REMOTE_SINK_UPDATE_LOCAL_SUBQ;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 UPDATE 절이 조용히 유실됨

새 sink 선택 조건은 UPDATE의 ORDER BY와 index hint를 검사하지 않지만, sink XASL은 SET과 단일 WHERE key만 생성한 뒤 일반 UPDATE 경로를 우회하여 두 절을 모두 버립니다. 일반 경로는 order_by, orderby_for, using_index를 실행용 SELECT에 전달합니다. 특히 ORDER BY가 사라지면 원격 트리거 실행 순서나 반복 UPDATE의 관찰 가능한 부수 효과가 달라질 수 있습니다. sink에서 의미를 보존할 수 없다면 해당 절이 있는 문장을 명시적으로 거부해야 합니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

두 절을 갈라서 고쳤습니다 — 전달할 수단이 있는지가 달랐습니다.

  • ORDER BY 는 말씀 주신 대로 거절합니다(d04a533). 한 문장 안의 갱신 순서를 정하는 절인데 sink 는 문장을 값마다 쪼개므로 담을 자리가 없고, 같은 사유로 LIMIT 을 이미 거절하고 있어 한쪽만 통과시키면 기준이 갈립니다. 메시지는 LIMIT 과 같은 형태입니다.
  • index hint 는 거절하지 않고 원격 문장에 실어 보냅니다(782a78a). 지목한 인덱스가 원격 테이블의 것이고(문장의 대상이 원격입니다) 이쪽에는 맞춰 볼 스키마가 없어, 번역하지 않고 쓴 대로 보냅니다. 벤더가 그 문법을 받지 않으면 원격 prepare 오류가 사용자에게 그대로 갑니다.

일반 경로가 using_index 를 넘긴다고 알려 주신 것이 이 분류에 쓰였습니다. 확인해 보니 로컬 UPDATE 에서는 힌트가 계획을 바꾸고(USING INDEX NONE 을 붙이면 TABLE SCAN 이 됩니다), sink 는 전달만 하면 그 의미가 보존되므로 거절할 대상이 아니었습니다. ORDER BY 유실이 관측된다는 지적도 확인했습니다 — 로컬은 ORDER BY id DESC 에서 트리거가 3·2·1 로 뜨고 sink 는 1·2·3 이었습니다.

DELETE 도 힌트를 싣지 않고 있어 함께 고쳤습니다(절 인쇄가 세 sink 공용입니다). ORDER BY 는 CUBRID 문법이 DELETE ... ORDER BY 를 받지 않아 해당이 없습니다.

@kwangsoochae

Copy link
Copy Markdown
Contributor Author

/run all

pt_print_select(), pt_print_update() and pt_print_delete() each wrote out the same block for the
clause, differing only in the field path and the buffer variable. The four shapes the parser can
leave behind -- "none", a name list, "all except" a list, and a plain list -- were spelled three
times.

Move it to pt_print_using_index_clause(). The remote DML sink builders need the same text next, and
a four-way shape check in three places is where they drift.
The sink assembles its own statement, so the clause the user wrote was dropped. Nothing looked at
it: the gate did not check it and the plan carried only the SET text and one WHERE key. The
text-pushdown path does not drop it -- it deparses the whole statement, hint included.

The hint names an index on the remote target. The statement's target is remote and this side holds
no schema to resolve the name against, so it is sent as written rather than translated per vendor.
That also matches how the SET expressions already travel: what the remote accepts is the user's to
get right. A vendor that does not take the syntax fails the remote prepare and the user sees that
error, which is what the pushdown path does today.

Measured on the sink path with the clause as the only difference: a MySQL remote answers 1064 with
it and updates two rows without it; a CUBRID remote updates two rows either way.

The clause is filled in the shared sink filler, so DELETE carries it too. INSERT SELECT has no such
clause and passes NULL.
LIMIT is refused because the sink sends one statement per local value and there is no single
statement to limit. ORDER BY has the same problem and was passing the gate, then being dropped when
the plan kept only the SET text and one WHERE key.

Dropping it is observable: ORDER BY decides the order rows are updated in, and a remote trigger
fires in that order. A local UPDATE honors it -- the same statement on a local table fires 3, 2, 1
for ORDER BY id DESC where the sink fires 1, 2, 3.

Refusing beats a clause that reads as honored and is not. DELETE needs nothing here: its grammar
has no ORDER BY.
…e overlapping predicates

One statement goes out per value the driving subquery produces, and the statements are independent.
Two of them reaching the same remote row write it twice: the reported count grows past the rows that
changed, a SET expression reading the row's own value is applied twice, and a remote trigger fires
twice.

Overlap has two sources and they need different answers. A repeated value sends the same statement
again, and de-duplicating the values removes it. A predicate whose per-value targets overlap is not
fixed that way: "col < 2" and "col < 3" share every row below 2, and "col <> 2" and "col <> 3" share
every row that is neither. Those shapes leave this sink; IN, = ANY and the scalar comparisons stay,
where distinct values match disjoint rows.

DELETE keeps both. Its count comes out right without the flag -- the second statement finds the row
gone and reports 0 -- and adding it would only pay for a sort, so the flag is set in the UPDATE
builder rather than in the shared WHERE translation. The predicate check is shared for the same
reason: the exclusion sits on the UPDATE gate rather than inside it.
@kwangsoochae

Copy link
Copy Markdown
Contributor Author

/run all

@kwangsoochae

Copy link
Copy Markdown
Contributor Author

/run rerun 35189239154

DELETE converts those specs and keeps the sink; UPDATE skipped the conversion and
refused the shape, so sibling statements answered differently for no reason other
than the order the two tracks were written in.

Call the same helper from the UPDATE entry. It already walks the assignment list,
so nothing else changes. The comment that deferred this named the aptr chain as
what it was guarding -- a converted spec can sit in any aptr, not just the driving
one -- and the chain combinations now have cases: a converted spec in WHERE while
SET binds a local scalar, in SET with no WHERE, in both, and two SET assignments
where only one is converted.
The rejection listed the supported forms of every kind in one sentence, which made
it the longest message in the parser and still missed UPDATE.

Give it the voice the other declines in this file use -- "remote <kind> with a local
subquery supports only ..." -- and let each kind name its own form. Every kind asks
its own gate again before it speaks, so a statement is never told a clause is wrong
when that clause is fine: UPDATE names WHERE or SET, DELETE names WHERE, INSERT
names the accepted form or the modifier that excluded it, and a shape no gate
explains keeps the bare sentence -- a plain INSERT ... SELECT whose source crosses
servers lands there, having used no modifier at all.

The comment at the INSERT gate quoted a message CBRD-26921 removed, so it now points
at the rejection by role instead.
The gate's header still described a DELETE-only block: it listed this function as
UPDATE's entry, when UPDATE has its own gate and only reaches this one through the
decline step, and it named two downstream rejecters by names that moved -- the
correlated check is a walk callback pair, and the row/multi-column one now sits in
the WHERE translation the two sink builders share.

The claim about correlation_level keeps its DELETE scope: it was observed there and
the UPDATE path was not re-measured.
The decline step asked the shared WHERE check even for an UPDATE. DELETE accepts the
quantified comparisons, so that check passed and an UPDATE its own gate had already
turned away was told its subquery had to mix on the target's own server -- which it
did. Ask the statement's own gate there. The UPDATE gate takes a NULL WHERE, so a
statement that sets its values from another server now reaches that diagnosis too
instead of the bare sentence.

Reaching the kind's own sentence is not enough on its own: the UPDATE gate returns
one verdict for two reasons, so an overlapping predicate would have been told to use
a single local WHERE subquery, which it already had. Name the overlap ahead of the
clause, asked the way the gate asks it -- the overlap check reads the expression
union and leans on the pushable check having run first.
@kwangsoochae

Copy link
Copy Markdown
Contributor Author

/run all

@kwangsoochae

Copy link
Copy Markdown
Contributor Author

/run all

@kwangsoochae

Copy link
Copy Markdown
Contributor Author

/run all

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant