(KO) PostgreSQL CustomScan — 플러그인 플랜 노드용 프로바이더 API
목차
- 이론적 배경
- DBMS 공통 설계 관례
- PostgreSQL의 접근 방식
- 소스 워크스루
- 소스 검증 (2026-06-05 기준)
- PostgreSQL 너머 — 비교 설계와 연구 전선
- 출처
이론적 배경
섹션 제목: “이론적 배경”관계형 질의 플랜은 물리 연산자의 트리다. 순차 스캔, 인덱스 스캔, 해시 조인, 정렬, 집계가 각각 이터레이터(Volcano) 모델의 인스턴스다. 모든 연산자는 동일한 open() / next() / close() 인터페이스를 노출하며 자식 노드에서 튜플 하나씩 당겨 온다. 이 균일성 덕분에 연산자들은 서로의 구체 타입을 모르고도 트리로 결합된다. Graefe의 Volcano — An Extensible and Parallel Query Evaluation System(1994)은 이 균일성이 왜 중요한지를 설명한다. 이터레이터 인터페이스가 연산자 사이의 유일한 계약이므로, 기존 코드를 건드리지 않고 새 연산자나 기존 논리 연산의 새 구현을 추가할 수 있다. 또한 어떤 두 연산자 사이에도 병렬성을 표현하는 교환(exchange) 연산자를 투명하게 삽입할 수 있다. 이터레이터 인터페이스는 객체지향 용어로 추상 기반 클래스이고, 각 연산자는 가상 메서드를 채운 서브클래스다.
같은 논문은 CustomScan API가 의존하는 두 번째 포인트를 짚는다. 질의 최적화와 질의 실행은 두 개의 서로 다른 표현 위에서 진행되는 두 단계다. 옵티마이저는 **패스(path)**를 다룬다. 패스는 수천 개씩 생성·소거되는 비용 주석 달린 경량 기술자다. 가장 저렴한 패스 하나만 익스큐터가 걸어가는 플랜 트리로 낮춰진다(lowered). 익스텐서블 엔진은 두 표현 모두에 확장 지점이 필요하다. 최적화 중에 커스텀 패스를 제안하는 지점(비용 기반 탐색이 내장 대안과 비교해 더 비싸면 기각할 수 있도록)과, 선택된 커스텀 패스를 커스텀 익스큐터 노드로 구체화하는 지점이다. Graefe의 후속 Cascades 프레임워크(1995)는 이를 논리 연산자를 물리 구현 알고리즘에 매핑하는 규칙으로 형식화한다. PostgreSQL에는 완전한 Cascades 규칙 엔진이 없지만, CustomPath → CustomScan → CustomScanState 파이프라인은 같은 세 표현 발상(논리 릴레이션 → 물리 패스 → 실행 가능 플랜 → 런타임 상태)에 각 전환마다 확장 솔기를 낸 것이다.
더 깊은 아키텍처 교훈은 Hellerstein, Stonebraker & Hamilton의 Architecture of a Database System(2007)과 원래 POSTGRES 설계 논문(Stonebraker & Rowe, 1986)에서 온다. 수십 년을 살아남으려는 DBMS는 코어 외부에서 확장 가능해야 한다. POSTGRES는 타입 시스템과 액세스 메서드를 확장 가능하게 만들었다. 현대 PostgreSQL은 그 철학을 익스큐터 연산자 자체까지 확장했다. 동기가 되는 사용 사례는 Graefe가 예상했던 것들이다. GPU 가속 스캔/조인(PG-Strom), 컬럼형 또는 인메모리 캐시 스캔, FDW 틀에 맞지 않는 외부 데이터 푸시다운. 이들은 모두 기존 논리 연산(릴레이션 스캔, 릴레이션 조인)의 새 물리 구현이다. 핵심 제약은 nodeSeqscan.c, createplan.c, 또는 노드 복사·직렬화 기계를 패치하지 않아야 한다는 것이다. 익스텐션은 안정된 솔기에 꽂혀야 한다. CustomScan API는 “로드 가능 모듈에서 물리 연산자를 추가”하는 일을 가능하게 하는 솔기의 집합이다.
마지막 이론적 쟁점은 직렬화를 거친 동일성이다. PostgreSQL의 플랜 트리는 순간적인 인메모리 객체가 아니다. 복사(copyObject)되고, 텍스트로 직렬화됐다가 읽혀 오며(nodeToString / stringToNode, 병렬 워커에 플랜을 전달하고 캐싱에 사용), 이 변환을 거쳐도 동작이 온전히 보존돼야 한다. 그런데 커스텀 노드의 “동작”은 C 함수 포인터에 있다. 함수 포인터는 fork() 경계나 텍스트 왕복에서 의미 있게 복사·직렬화되지 않는 프로세스 로컬 주소다. PostgreSQL이 채택한 고전적 해법은 이름 키 메서드 레지스트리다. 노드는 안정된 문자열 이름을 저장하고, 프로바이더를 로드한 모든 프로세스가 같은 이름 → vtable 매핑을 등록하며, 복사·직렬화 기계는 반대편에서 이름으로 vtable을 재분해한다. 언어 런타임이 vtable 포인터에 쓰는 것과 같은 간접참조를 직렬화 생존까지 끌어올린 것이다.
DBMS 공통 설계 관례
섹션 제목: “DBMS 공통 설계 관례”확장 가능 엔진 대부분은 “익스텐션이 물리 연산자를 추가하게 하는” 문제에서 몇 가지 반복 패턴으로 수렴한다. PostgreSQL의 선택은 이 공통 공간에 비춰 읽어야 한다.
1. 메서드 vtable(프로바이더) 구조체. 연산자 확장 API의 보편적 형태는 익스텐션이 채워 코어에 넘기는 함수 포인터 struct, 즉 손으로 만든 vtable이다. 코어는 익스텐션 함수를 이름으로 호출하지 않고 vtable을 경유해 호출한다. 연산 집합은 코어(구조체 레이아웃)가 고정하고 구현은 익스텐션이 소유한다. PostgreSQL은 이를 수명이 다른 세 표현에 맞춰 세 vtable로 나눈다. 패스 vtable은 후보 패스당 한 번, 스캔 메서드 vtable은 선택된 플랜당 한 번, 익스큐터 vtable은 실행 중 여러 번 조회된다.
2. 비용 기반 수락. 잘 설계된 확장 가능 옵티마이저는 익스텐션이 연산자를 강제하게 두지 않는다. 익스텐션이 비용과 함께 연산자를 제안하면 코어의 기존 비용 비교 기계가 승패를 결정한다. PostgreSQL의 add_path()가 바로 이 게이트다. CustomPath는 total_cost로 순차 스캔, 인덱스 스캔 등과 경쟁하고, 지배당하면 가지치기된다. 잘못 비용을 추산한 커스텀 연산자는 단순히 선택되지 않는다. 익스텐션 저자는 비용을 추산하면 되고 패스 가지치기 로직을 재구현할 필요가 없다.
3. 훅 솔기. 플래닝 중 제어권을 얻으려면 코어가 적절한 시점에 호출하는 콜백이 필요하다. 경량 산업 패턴은 NULL이 기본값인 전역 함수 포인터 “훅”이다. PostgreSQL은 set_rel_pathlist_hook(코어가 기반 릴레이션의 내장 패스를 모두 생성한 후 호출)과 set_join_pathlist_hook(조인에 대한 대응 지점)을 노출한다. 프로바이더는 코어가 패스 목록을 확정하려는 정확한 위치에 CustomPath를 추가할 수 있다. (일반 훅 메커니즘은 postgres-hooks.md에서 다룬다. 여기서는 입구 역할만 한다.)
4. 불투명 사설 데이터 채널. 커스텀 연산자는 플래닝에서 실행까지 임의 상태를 운반해야 한다. 푸시다운한 프레디케이트, 선택한 알고리즘 매개변수, GPU 커널 식별자가 그 예다. 엔진은 void * 블롭(불투명하지만 복사·직렬화 불가)이나 엔진 자체 노드 프레임워크(복사·직렬화 가능하지만 엔진이 타입을 알아야 함) 중 하나를 강제하는 것이 보통이다. PostgreSQL은 둘 다 제공한다. custom_private는 일반적 경우를 위한 복사·직렬화 가능 노드의 List이고, 익스텐서블 노드 프레임워크(T_ExtensibleNode)는 프로바이더 제공 콜백으로 copyObject와 stringToNode 왕복을 처리하는 완전히 새로운 노드 타입을 정의하게 해준다.
5. 팻 인터페이스 대신 기능 플래그. 모든 커스텀 연산자가 역방향 스캔, mark/restore, 병렬성을 지원하지는 않는다. 모든 프로바이더에 모든 메서드 구현을 요구하는 대신, API는 기능 플래그 비트마스크(CUSTOMPATH_SUPPORT_BACKWARD_SCAN, _MARK_RESTORE, _PROJECTION)와 NULL이 될 수 있는 선택적 메서드 포인터 슬롯을 쓴다. 익스큐터 shim은 파견 전에 포인터(또는 플래그)를 확인하고, 지원하지 않는 기능 호출 시 명확한 ERRCODE_FEATURE_NOT_SUPPORTED 오류를 발생시킨다. “좁은 필수 코어 + 넓은 선택적 표면” 인터페이스 설계의 전형이다. 최소한의 프로바이더는 네 개의 필수 익스큐터 콜백만 채우면 되고, 의욕적인 프로바이더는 병렬 인식 DSM 조율까지 선택할 수 있다.
flowchart TD
subgraph plan["플래닝 (패스)"]
H["set_rel_pathlist_hook /<br/>set_join_pathlist_hook"] -->|add_path| CP["CustomPath<br/>flags + custom_private<br/>methods: CustomPathMethods"]
CP -->|최저 비용 승리| WIN["선택된 CustomPath"]
end
subgraph lower["플랜 생성 (낮추기)"]
WIN --> CCP["create_customscan_plan"]
CCP -->|PlanCustomPath| CS["CustomScan 플랜 노드<br/>custom_exprs / custom_private<br/>methods: CustomScanMethods"]
end
subgraph exec["실행 (상태)"]
CS -->|ExecInitCustomScan| CSS["CustomScanState<br/>methods: CustomExecMethods"]
CSS -->|ExecCustomScan 루프| ROWS["TupleTableSlots"]
end
REG["이름 레지스트리<br/>RegisterCustomScanMethods /<br/>RegisterExtensibleNodeMethods"] -.이름으로 재분해.-> CS
PostgreSQL의 접근 방식
섹션 제목: “PostgreSQL의 접근 방식”PostgreSQL은 연산자 확장 API를 src/include/nodes/extensible.h에 함께 선언된 세 개의 메서드 구조체로 구현한다. 세 구조체는 의도적으로 비대칭적이다. 각각이 얼마나 자주 조회되는지를 반영한다.
패스 수준 vtable은 하나의 역할, 즉 CustomPath를 플랜으로 바꾸는 방법을 아는 일을 담당한다. 파티션 방식 조인을 위한 선택적 재매개변수화 도우미도 있다.
// CustomPathMethods — src/include/nodes/extensible.htypedef struct CustomPathMethods{ const char *CustomName;
/* Convert Path to a Plan */ struct Plan *(*PlanCustomPath) (PlannerInfo *root, RelOptInfo *rel, struct CustomPath *best_path, List *tlist, List *clauses, List *custom_plans); struct List *(*ReparameterizeCustomPathByChild) (PlannerInfo *root, List *custom_private, RelOptInfo *child_rel);} CustomPathMethods;스캔 메서드 vtable은 더 얇다. 플랜 노드에서 익스큐터 상태 객체를 생성하는 콜백 하나뿐이다.
// CustomScanMethods — src/include/nodes/extensible.htypedef struct CustomScanMethods{ const char *CustomName;
/* Create execution state (CustomScanState) from a CustomScan plan node */ Node *(*CreateCustomScanState) (CustomScan *cscan);} CustomScanMethods;실행 시점 vtable이 가장 무겁다. 처음 네 콜백은 필수(Volcano의 open/next/close와 rescan)다. 나머지는 선택적이며 기능 플래그나 NULL 확인으로 제어된다. Merge Join 아래 위치하는 플랜을 위한 mark/restore, 병렬 인식 프로바이더를 위한 DSM 조율 사중주, shutdown 훅, EXPLAIN 훅이 포함된다.
// CustomExecMethods — src/include/nodes/extensible.htypedef struct CustomExecMethods{ const char *CustomName;
/* Required executor methods */ void (*BeginCustomScan) (CustomScanState *node, EState *estate, int eflags); TupleTableSlot *(*ExecCustomScan) (CustomScanState *node); void (*EndCustomScan) (CustomScanState *node); void (*ReScanCustomScan) (CustomScanState *node);
/* Optional methods: needed if mark/restore is supported */ void (*MarkPosCustomScan) (CustomScanState *node); void (*RestrPosCustomScan) (CustomScanState *node);
/* Optional methods: needed if parallel execution is supported */ Size (*EstimateDSMCustomScan) (CustomScanState *node, ParallelContext *pcxt); void (*InitializeDSMCustomScan) (CustomScanState *node, ParallelContext *pcxt, void *coordinate); void (*ReInitializeDSMCustomScan) (CustomScanState *node, ParallelContext *pcxt, void *coordinate); void (*InitializeWorkerCustomScan) (CustomScanState *node, shm_toc *toc, void *coordinate); void (*ShutdownCustomScan) (CustomScanState *node);
/* Optional: print additional information in EXPLAIN */ void (*ExplainCustomScan) (CustomScanState *node, List *ancestors, ExplainState *es);} CustomExecMethods;세 구조체는 세 노드 타입에서 포인터로 참조된다. 핵심 설계 결정은 헤더의 명시적 주석에 선언된 대로 methods 필드가 코어가 절대 복사하지 않는 정적 테이블의 포인터라는 것이다. CustomScan 플랜 노드가 이를 명시한다.
// CustomScan — src/include/nodes/plannodes.htypedef struct CustomScan{ Scan scan; uint32 flags; /* mask of CUSTOMPATH_* flags */ List *custom_plans; /* list of child Plan nodes, if any */ List *custom_exprs; /* expressions that custom code may evaluate */ List *custom_private; /* private data for custom code */ List *custom_scan_tlist; /* optional tlist describing scan tuple */ Bitmapset *custom_relids; /* RTIs generated by this scan */
/* * NOTE: The method field of CustomScan is required to be a pointer to a * static table of callback functions. So we don't copy the table itself, * just reference the original one. */ const struct CustomScanMethods *methods;} CustomScan;vtable은 복사되지 않고 주소이므로, 병렬 워커로의 직렬화나 텍스트 왕복에서 살아남지 못한다. PostgreSQL은 프로세스 로컬 이름 키 레지스트리로 이 공백을 메운다. 프로바이더는 모듈 로드 시 RegisterCustomScanMethods(methods)를 호출하고, 레지스트리는 methods->CustomName을 키로 vtable을 저장한다. 노드는 이름만 직렬화한다. 반대편에서 copyfuncs/readfuncs가 GetCustomScanMethods(name)을 호출해 vtable을 재분해한다. 세 표현 전체를 관통하는 기능 플래그는 단일 비트마스크다.
// capability flags — src/include/nodes/extensible.h#define CUSTOMPATH_SUPPORT_BACKWARD_SCAN 0x0001#define CUSTOMPATH_SUPPORT_MARK_RESTORE 0x0002#define CUSTOMPATH_SUPPORT_PROJECTION 0x0004프로바이더가 구동하는 끝에서 끝까지의 흐름은 다음과 같다.
flowchart TD
A["_PG_init: RegisterCustomScanMethods(&scan_methods)<br/>install set_rel_pathlist_hook"] --> B["플래너가 기반 릴레이션 도달<br/>set_rel_pathlist() 코어 패스 완료"]
B --> C["훅 실행: 프로바이더가 CustomPath 구성<br/>flags, custom_private, methods=CustomPathMethods<br/>add_path(rel, custompath)"]
C --> D{"최저 total_cost?"}
D -->|아니오| X["CustomPath 기각"]
D -->|예| E["create_plan_recurse → create_customscan_plan"]
E -->|methods->PlanCustomPath| F["CustomScan 플랜 노드<br/>methods=CustomScanMethods"]
F --> G["ExecInitNode → ExecInitCustomScan<br/>methods->CreateCustomScanState"]
G --> H["CustomScanState<br/>methods=CustomExecMethods"]
H -->|BeginCustomScan| I["튜플별: ExecCustomScan 루프"]
I -->|EndCustomScan| J["해제"]
프로바이더 저자의 작업 범위는 작고 명확하다. 두 vtable(스캔 + 익스큐터)을 이름으로 등록하고, 선택적으로 사설 노드 타입을 위한 세 번째(익스텐서블 노드)를 등록하며, 플래너 훅 하나를 설치하고, 필수 익스큐터 콜백을 채운다. 그 외 모든 것, 비용 비교, 플랜 복사, 병렬 워커 플랜 전송, EXPLAIN 트리 순회는 다음에서 설명하는 솔기에서 코어가 담당한다.
소스 워크스루
섹션 제목: “소스 워크스루”CustomScan 기계는 질의 처리의 세 단계에 걸쳐 얇게 분산돼 있다. 이 워크스루는 튜플 시점의 여정을 따른다. 프로바이더가 vtable을 등록하는 방법, 플래닝 중 CustomPath가 삽입되는 방법, CustomScan 플랜 노드로 낮춰지는 방법, nodeCustom.c의 익스큐터 shim이 노드를 인스턴스화하고 구동하는 방법, 병렬 조율이 작동하는 방법, EXPLAIN이 노드를 렌더링하는 방법, 마지막으로 익스텐서블 노드 레지스트리가 사설 노드 타입의 복사/직렬화 왕복을 처리하는 방법 순서다.
1. 등록 — 이름 키 vtable 레지스트리 (extensible.c)
섹션 제목: “1. 등록 — 이름 키 vtable 레지스트리 (extensible.c)”프로바이더는 보통 _PG_init()에서 vtable을 등록하는 것으로 시작한다. 두 등록 경로 모두 문자열 키 해시를 지연 생성하고 중복 이름을 거부하는 내부 헬퍼 하나로 모인다.
// RegisterExtensibleNodeEntry — src/backend/nodes/extensible.cstatic voidRegisterExtensibleNodeEntry(HTAB **p_htable, const char *htable_label, const char *extnodename, const void *extnodemethods){ ExtensibleNodeEntry *entry; bool found;
if (*p_htable == NULL) { HASHCTL ctl; ctl.keysize = EXTNODENAME_MAX_LEN; ctl.entrysize = sizeof(ExtensibleNodeEntry); *p_htable = hash_create(htable_label, 100, &ctl, HASH_ELEM | HASH_STRINGS); }
if (strlen(extnodename) >= EXTNODENAME_MAX_LEN) elog(ERROR, "extensible node name is too long");
entry = (ExtensibleNodeEntry *) hash_search(*p_htable, extnodename, HASH_ENTER, &found); if (found) ereport(ERROR, (errcode(ERRCODE_DUPLICATE_OBJECT), errmsg("extensible node type \"%s\" already exists", extnodename)));
entry->extnodemethods = extnodemethods;}독립적인 해시 테이블이 두 개 있다. custom_scan_methods와 extensible_node_methods로, 둘 다 NULL로 초기화되는 파일 정적 HTAB *다. RegisterCustomScanMethods는 methods->CustomName을 키로 쓰고, 조회 측 GetCustomScanMethods가 재분해한다. GetExtensibleNodeEntry의 missing_ok 기본값에 주목해야 한다. missing_ok == false일 때 미스가 발생하면 NULL 반환이 아닌 ERRCODE_UNDEFINED_OBJECT를 발생시킨다. 반대편이 등록한 적 없는 이름을 직렬화한 노드는 명확하게 실패한다.
// GetExtensibleNodeEntry / GetCustomScanMethods — src/backend/nodes/extensible.cstatic const void *GetExtensibleNodeEntry(HTAB *htable, const char *extnodename, bool missing_ok){ ExtensibleNodeEntry *entry = NULL;
if (htable != NULL) entry = (ExtensibleNodeEntry *) hash_search(htable, extnodename, HASH_FIND, NULL); if (!entry) { if (missing_ok) return NULL; ereport(ERROR, (errcode(ERRCODE_UNDEFINED_OBJECT), errmsg("ExtensibleNodeMethods \"%s\" was not registered", extnodename))); } return entry->extnodemethods;}레지스트리는 프로세스 로컬이다. 병렬 질의에서 각 워커는 프로바이더의 _PG_init을 재실행하므로(라이브러리가 shared_preload에 나열되거나 온디맨드 로드), 플랜을 역직렬화할 모든 백엔드에 동일한 이름 → vtable 매핑이 존재한다. 이것이 다음 단계의 “이름만 직렬화” 전략을 올바르게 만드는 이유다.
2. 삽입 — 플래닝 중 제어권 획득 (allpaths.c)
섹션 제목: “2. 삽입 — 플래닝 중 제어권 획득 (allpaths.c)”프로바이더는 플래너를 직접 호출하지 않는다. 플래너가 전역 훅으로 프로바이더를 호출한다. 코어가 기반 릴레이션의 모든 내장 패스를 생성한 직후 실행된다.
// set_rel_pathlist (excerpt) — src/backend/optimizer/path/allpaths.c/* * Allow a plugin to editorialize on the set of Paths for this base * relation. It could add new paths (such as CustomPaths) by calling * add_path(), or add_partial_path() if parallel aware. */if (set_rel_pathlist_hook) (*set_rel_pathlist_hook) (root, rel, rti, rte);훅 시그니처는 프로바이더가 CustomPath를 구성하고 비용을 산정하는 데 필요한 모든 것을 전달한다. PlannerInfo, 대상 RelOptInfo, 범위 테이블 인덱스, RTE다.
// set_rel_pathlist_hook_type — src/include/optimizer/paths.htypedef void (*set_rel_pathlist_hook_type) (PlannerInfo *root, RelOptInfo *rel, Index rti, RangeTblEntry *rte);extern PGDLLIMPORT set_rel_pathlist_hook_type set_rel_pathlist_hook;조인 대응 훅인 set_join_pathlist_hook은 add_paths_to_joinrel() 안에서 실행되며 외부/내부 릴레이션과 조인 타입을 받는다. 프로바이더는 GPU 해시 조인 같은 조인 구현을 교체할 수 있다. 프로바이더가 구성하는 CustomPath는 자식 패스, restrict-info, 프로바이더 사설 목록을 담는 경량 패스 노드다.
// CustomPath — src/include/nodes/pathnodes.htypedef struct CustomPath{ Path path; uint32 flags; /* mask of CUSTOMPATH_* flags */ List *custom_paths; /* list of child Path nodes, if any */ List *custom_restrictinfo; List *custom_private; const struct CustomPathMethods *methods;} CustomPath;프로바이더는 이것을 add_path(rel, (Path *) custompath)에 전달한다. 여기서부터 CustomPath는 다른 후보와 같다. 비용으로 경쟁하고, 더 저렴한 패스가 지배하면 가지치기된다. 프로바이더는 path.total_cost를 정직하게 설정하기만 하면 된다.
3. 낮추기 — CustomPath에서 CustomScan으로 (createplan.c)
섹션 제목: “3. 낮추기 — CustomPath에서 CustomScan으로 (createplan.c)”CustomPath가 이기면, create_plan_recurse가 create_customscan_plan으로 파견한다. 이 함수는 자식 패스를 재귀적으로 낮추고, 스캔 절을 정렬한 뒤, 실제 플랜 노드를 생성하기 위해 프로바이더의 PlanCustomPath를 호출한다. 코어는 CustomScan 자체를 구성하지 않는다.
// create_customscan_plan — src/backend/optimizer/plan/createplan.cstatic CustomScan *create_customscan_plan(PlannerInfo *root, CustomPath *best_path, List *tlist, List *scan_clauses){ CustomScan *cplan; RelOptInfo *rel = best_path->path.parent; List *custom_plans = NIL; ListCell *lc;
/* Recursively transform child paths. */ foreach(lc, best_path->custom_paths) { Plan *plan = create_plan_recurse(root, (Path *) lfirst(lc), CP_EXACT_TLIST); custom_plans = lappend(custom_plans, plan); }
scan_clauses = order_qual_clauses(root, scan_clauses);
/* Invoke custom plan provider to create the Plan node. */ cplan = castNode(CustomScan, best_path->methods->PlanCustomPath(root, rel, best_path, tlist, scan_clauses, custom_plans));
/* Copy cost data from Path to Plan ... */ copy_generic_path_info(&cplan->scan.plan, &best_path->path); cplan->custom_relids = best_path->path.parent->relids;
if (best_path->path.param_info) { cplan->scan.plan.qual = (List *) replace_nestloop_params(root, (Node *) cplan->scan.plan.qual); cplan->custom_exprs = (List *) replace_nestloop_params(root, (Node *) cplan->custom_exprs); } return cplan;}역할 분담에서 두 가지를 짚어야 한다. 첫째, 코어는 프로바이더가 반환한 후 일반 비용 필드(copy_generic_path_info)와 relids를 채운다. 프로바이더의 PlanCustomPath는 커스텀 전용 필드만 채우면 된다. 둘째, replace_nestloop_params는 qual과 custom_exprs 양쪽에서 외부 릴레이션 Var를 nestloop 파라미터로 재작성한다. 파라미터화된 nestloop의 내부 측에 위치한 커스텀 스캔은 아무것도 하지 않아도 파라미터가 연결된다. 단, 코어는 custom_scan_tlist에 그런 Var가 없다고 가정한다.
4. 인스턴스화와 익스큐터 shim (nodeCustom.c)
섹션 제목: “4. 인스턴스화와 익스큐터 shim (nodeCustom.c)”실행 시작 시 ExecInitNode가 T_CustomScan을 ExecInitCustomScan으로 파견한다. nodeCustom.c에서 가장 실질적인 함수이며, 프로바이더가 할당한 상태 객체가 표준 ScanState 프레임워크에 엮이는 곳이다. 프로바이더가 상태를 할당하고(더 큰 struct의 첫 필드로 CustomScanState를 내포할 수 있도록), shim이 표준 필드를 채운다.
// ExecInitCustomScan (condensed) — src/backend/executor/nodeCustom.cCustomScanState *ExecInitCustomScan(CustomScan *cscan, EState *estate, int eflags){ CustomScanState *css; const TupleTableSlotOps *slotOps; Relation scan_rel = NULL; Index scanrelid = cscan->scan.scanrelid; int tlistvarno;
/* Provider does the palloc and sets node tag + methods. */ css = castNode(CustomScanState, cscan->methods->CreateCustomScanState(cscan)); css->flags = cscan->flags;
css->ss.ps.plan = &cscan->scan.plan; css->ss.ps.state = estate; css->ss.ps.ExecProcNode = ExecCustomScan; ExecAssignExprContext(estate, &css->ss.ps);
/* open the scan relation, if any */ if (scanrelid > 0) { scan_rel = ExecOpenScanRelation(estate, scanrelid, eflags); css->ss.ss_currentRelation = scan_rel; }
/* Use a custom slot if specified, else a virtual slot. */ slotOps = css->slotOps; if (!slotOps) slotOps = &TTSOpsVirtual;
if (cscan->custom_scan_tlist != NIL || scan_rel == NULL) { TupleDesc scan_tupdesc = ExecTypeFromTL(cscan->custom_scan_tlist); ExecInitScanTupleSlot(estate, &css->ss, scan_tupdesc, slotOps); tlistvarno = INDEX_VAR; /* Vars carry varno = INDEX_VAR */ } else { ExecInitScanTupleSlot(estate, &css->ss, RelationGetDescr(scan_rel), slotOps); tlistvarno = scanrelid; }
ExecInitResultTupleSlotTL(&css->ss.ps, &TTSOpsVirtual); ExecAssignScanProjectionInfoWithVarno(&css->ss, tlistvarno); css->ss.ps.qual = ExecInitQual(cscan->scan.plan.qual, (PlanState *) css);
/* Provider finishes initialization. */ css->methods->BeginCustomScan(css, estate, eflags); return css;}여기에 인코딩된 세 가지 동작이 프로바이더 계약의 일부다. (a) 프로바이더가 CreateCustomScanState 안에서 노드 태그와 methods를 설정한다. shim은 castNode로만 단언한다. (b) 스캔 튜플 타입은 custom_scan_tlist가 있으면 거기서, 아니면 기반 릴레이션의 행 타입에서 가져온다. 단일 기반 릴레이션 없이 scanrelid == 0인 조인 스타일 커스텀 스캔이 여기 해당한다. targetlist의 varno는 그에 맞춰 INDEX_VAR 또는 scanrelid로 설정된다. (c) 프로바이더는 css->slotOps로 자체 TupleTableSlotOps를 설치할 수 있다. 그렇지 않으면 shim이 가상 슬롯을 기본값으로 쓴다.
튜플별 드라이버는 표준 익스큐터 리듬에서 인터럽트를 확인하고 프로바이더의 필수 ExecCustomScan으로 전달하는 한 줄짜리다.
// ExecCustomScan — src/backend/executor/nodeCustom.cstatic TupleTableSlot *ExecCustomScan(PlanState *pstate){ CustomScanState *node = castNode(CustomScanState, pstate); CHECK_FOR_INTERRUPTS(); Assert(node->methods->ExecCustomScan != NULL); return node->methods->ExecCustomScan(node);}ExecEndCustomScan과 ExecReScanCustomScan은 마찬가지로 얇은 전달자다. 각각 파견 전에 필수 콜백이 NULL이 아님을 Assert한다. CustomScanState 구조체는 프로바이더 상태가 걸리는 곳이다.
// CustomScanState — src/include/nodes/execnodes.htypedef struct CustomScanState{ ScanState ss; uint32 flags; /* mask of CUSTOMPATH_* flags */ List *custom_ps; /* list of child PlanState nodes, if any */ Size pscan_len; /* size of parallel coordination information */ const struct CustomExecMethods *methods; const struct TupleTableSlotOps *slotOps;} CustomScanState;5. 선택적 기능 — mark/restore와 NULL 확인 가드
섹션 제목: “5. 선택적 기능 — mark/restore와 NULL 확인 가드”선택적 메서드는 게이트가 있다. mark/restore는 Merge Join 아래서만 의미가 있다. 구현하지 않은 프로바이더는 NULL 포인터로 크래시하는 것이 아닌 명확한 오류로 실패해야 한다. shim은 누락된 콜백을 적절한 SQL 오류로 바꾼다.
// ExecCustomMarkPos — src/backend/executor/nodeCustom.cvoidExecCustomMarkPos(CustomScanState *node){ if (!node->methods->MarkPosCustomScan) ereport(ERROR, (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), errmsg("custom scan \"%s\" does not support MarkPos", node->methods->CustomName))); node->methods->MarkPosCustomScan(node);}이것은 CUSTOMPATH_SUPPORT_MARK_RESTORE 플래그의 런타임 절반이다. 플래너는 해당 플래그가 설정된 경우에만 커스텀 스캔을 Merge Join 아래에 놓는다. 익스큐터는 플래그를 광고했지만 콜백을 NULL로 남긴 잘못된 프로바이더가 세그폴트가 아닌 이해 가능한 오류를 받도록 이 가드로 뒷받침한다.
6. 병렬 조율 — DSM 사중주
섹션 제목: “6. 병렬 조율 — DSM 사중주”병렬 인식 프로바이더는 병렬 설정 중 shim이 호출하는 네 개의 선택적 콜백을 구현한다. 추산/초기화 쌍이 대표적이다. 프로바이더가 콜백을 NULL로 남기면 no-op이고, 그렇지 않으면 shim이 공유 메모리 TOC 부기를 관리하면서 프로바이더가 청크를 채운다.
// ExecCustomScanEstimate / ExecCustomScanInitializeDSM — src/backend/executor/nodeCustom.cvoidExecCustomScanEstimate(CustomScanState *node, ParallelContext *pcxt){ const CustomExecMethods *methods = node->methods; if (methods->EstimateDSMCustomScan) { node->pscan_len = methods->EstimateDSMCustomScan(node, pcxt); shm_toc_estimate_chunk(&pcxt->estimator, node->pscan_len); shm_toc_estimate_keys(&pcxt->estimator, 1); }}
voidExecCustomScanInitializeDSM(CustomScanState *node, ParallelContext *pcxt){ const CustomExecMethods *methods = node->methods; if (methods->InitializeDSMCustomScan) { int plan_node_id = node->ss.ps.plan->plan_node_id; void *coordinate = shm_toc_allocate(pcxt->toc, node->pscan_len); methods->InitializeDSMCustomScan(node, pcxt, coordinate); shm_toc_insert(pcxt->toc, plan_node_id, coordinate); }}청크는 TOC에서 plan_node_id를 키로 한다. 워커 측 ExecCustomScanInitializeWorker도 같은 id로 조회하므로, 리더와 모든 워커가 커스텀 스캔 노드당 하나의 조율 영역을 공유한다. ExecShutdownCustomScan은 DSM 세그먼트가 해제되기 전에 프로바이더가 워커의 결과를 수거할 수 있게 한다. 네 콜백 모두 NULL을 허용하므로, 비병렬 프로바이더는 설정하지 않아도 병렬 기계가 건너뛴다.
7. EXPLAIN 통합 (explain.c)
섹션 제목: “7. EXPLAIN 통합 (explain.c)”EXPLAIN이 플랜 트리를 순회하다 CustomScan을 만나면 표준 스캔 qual을 보여 준 후 추가 세부 정보를 선택적 프로바이더 훅에 위임한다.
// ExplainNode (T_CustomScan case) — src/backend/commands/explain.ccase T_CustomScan: { CustomScanState *css = (CustomScanState *) planstate; show_scan_qual(plan->qual, "Filter", planstate, ancestors, es); if (plan->qual) show_instrumentation_count("Rows Removed by Filter", 1, planstate, es); if (css->methods->ExplainCustomScan) css->methods->ExplainCustomScan(css, ancestors, es); } break;노드 레이블 자체는 vtable의 CustomName에서 온다(ExplainNode의 노드 이름 switch에서 ((CustomScan *) plan)->methods->CustomName). custom_ps에 저장된 자식 플랜은 ExplainCustomChildren으로 재귀하며 “child”/“children” 레이블을 붙이고 ExplainNode로 다시 들어간다. 서브플랜이 있는 프로바이더는 자동으로 올바르게 중첩된 EXPLAIN 트리를 얻는다.
8. 익스텐서블 노드 — 사설 타입의 복사/직렬화 왕복 (copyfuncs/outfuncs/readfuncs)
섹션 제목: “8. 익스텐서블 노드 — 사설 타입의 복사/직렬화 왕복 (copyfuncs/outfuncs/readfuncs)”익스텐서블 노드 프레임워크는 이름 레지스트리의 두 번째 소비자다. 일반 노드를 custom_private에 채워 넣는 것을 넘어 완전히 새로운 노드 타입을 원하는 프로바이더는 노드에 T_ExtensibleNode와 extnodename을 태그하고, 네 개의 직렬화 콜백을 가진 ExtensibleNodeMethods vtable을 등록한다.
// ExtensibleNodeMethods — src/include/nodes/extensible.htypedef struct ExtensibleNodeMethods{ const char *extnodename; Size node_size; void (*nodeCopy) (struct ExtensibleNode *newnode, const struct ExtensibleNode *oldnode); bool (*nodeEqual) (const struct ExtensibleNode *a, const struct ExtensibleNode *b); void (*nodeOut) (struct StringInfoData *str, const struct ExtensibleNode *node); void (*nodeRead) (struct ExtensibleNode *node);} ExtensibleNodeMethods;코어의 copyObject, nodeToString, stringToNode는 각각 T_ExtensibleNode를 특별 처리한다. 이름으로 vtable을 재분해하고 프로바이더 콜백으로 파견한다. Copy는 node_size 바이트를 할당하고(프로바이더의 더 큰 struct), 이름 필드를 일반적으로 복사한 뒤 사설 필드를 넘긴다.
// _copyExtensibleNode — src/backend/nodes/copyfuncs.cstatic ExtensibleNode *_copyExtensibleNode(const ExtensibleNode *from){ ExtensibleNode *newnode; const ExtensibleNodeMethods *methods;
methods = GetExtensibleNodeMethods(from->extnodename, false); newnode = (ExtensibleNode *) newNode(methods->node_size, T_ExtensibleNode); COPY_STRING_FIELD(extnodename); methods->nodeCopy(newnode, from); /* copy the private fields */ return newnode;}Read는 대칭 연산이다. :extnodename 토큰을 꺼내고, vtable을 분해하며, node_size를 할당한 후 프로바이더가 토큰 스트림에서 사설 필드를 재구성하게 한다.
// _readExtensibleNode — src/backend/nodes/readfuncs.cstatic ExtensibleNode *_readExtensibleNode(void){ const ExtensibleNodeMethods *methods; ExtensibleNode *local_node; const char *extnodename; READ_TEMP_LOCALS();
token = pg_strtok(&length); /* skip :extnodename */ token = pg_strtok(&length); /* get extnodename */ extnodename = nullable_string(token, length); if (!extnodename) elog(ERROR, "extnodename has to be supplied"); methods = GetExtensibleNodeMethods(extnodename, false);
local_node = (ExtensibleNode *) newNode(methods->node_size, T_ExtensibleNode); local_node->extnodename = extnodename; methods->nodeRead(local_node); /* deserialize the private fields */ READ_DONE();}_outExtensibleNode(EXTENSIBLENODE + 이름 + methods->nodeOut 출력)와 _equalExtensibleNode(이름 비교 후 methods->nodeEqual)가 사중주를 완성한다. 헤더 주석은 네 콜백 모두 필수라고 강조한다. 코어가 아무것도 모르는 타입에는 기본 직렬화가 없다. 이것이 CustomScan이 임의의 복잡한 프로바이더 사설 상태를 가진 채로 병렬 워커에 전달되는 메커니즘이다. 상태는 custom_private 안의 익스텐서블 노드이며, custom_private 목록 전체가 표준 노드 직렬화기를 거쳐 왕복하며, 각 원소는 워커 측에서 이름으로 vtable을 분해한다.
위치 힌트 (2026-06-05 기준, REL_18 273fe94)
섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”| 심볼 | 파일 | 줄 |
|---|---|---|
EXTNODENAME_MAX_LEN | src/include/nodes/extensible.h | 24 |
ExtensibleNodeMethods | src/include/nodes/extensible.h | 62 |
CUSTOMPATH_SUPPORT_BACKWARD_SCAN | src/include/nodes/extensible.h | 84 |
CustomPathMethods | src/include/nodes/extensible.h | 92 |
CustomScanMethods | src/include/nodes/extensible.h | 112 |
CustomExecMethods | src/include/nodes/extensible.h | 124 |
RegisterCustomScanMethods (선언) | src/include/nodes/extensible.h | 160 |
RegisterExtensibleNodeEntry | src/backend/nodes/extensible.c | 39 |
RegisterExtensibleNodeMethods | src/backend/nodes/extensible.c | 76 |
RegisterCustomScanMethods | src/backend/nodes/extensible.c | 88 |
GetExtensibleNodeEntry | src/backend/nodes/extensible.c | 100 |
GetExtensibleNodeMethods | src/backend/nodes/extensible.c | 125 |
GetCustomScanMethods | src/backend/nodes/extensible.c | 137 |
ExecInitCustomScan | src/backend/executor/nodeCustom.c | 26 |
ExecCustomScan (드라이버) | src/backend/executor/nodeCustom.c | 114 |
ExecEndCustomScan | src/backend/executor/nodeCustom.c | 125 |
ExecReScanCustomScan | src/backend/executor/nodeCustom.c | 132 |
ExecCustomMarkPos | src/backend/executor/nodeCustom.c | 139 |
ExecCustomScanEstimate | src/backend/executor/nodeCustom.c | 161 |
ExecCustomScanInitializeDSM | src/backend/executor/nodeCustom.c | 174 |
ExecCustomScanInitializeWorker | src/backend/executor/nodeCustom.c | 205 |
ExecShutdownCustomScan | src/backend/executor/nodeCustom.c | 221 |
CustomScan (구조체) | src/include/nodes/plannodes.h | 864 |
CustomPath (구조체) | src/include/nodes/pathnodes.h | 2038 |
CustomScanState (구조체) | src/include/nodes/execnodes.h | 2125 |
set_rel_pathlist_hook (호출) | src/backend/optimizer/path/allpaths.c | 538 |
set_join_pathlist_hook (호출) | src/backend/optimizer/path/joinpath.c | 342 |
create_customscan_plan | src/backend/optimizer/plan/createplan.c | 4269 |
_copyExtensibleNode | src/backend/nodes/copyfuncs.c | 147 |
_outExtensibleNode | src/backend/nodes/outfuncs.c | 490 |
_readExtensibleNode | src/backend/nodes/readfuncs.c | 537 |
_equalExtensibleNode | src/backend/nodes/equalfuncs.c | 117 |
EXPLAIN T_CustomScan 케이스 | src/backend/commands/explain.c | 2146 |
소스 검증 (2026-06-05 기준)
섹션 제목: “소스 검증 (2026-06-05 기준)”이 문서의 모든 주장은 /data/hgryoo/references/postgres의 REL_18_STABLE 작업 트리에서 커밋 273fe94852b3a7e34fd171e8abdf1481beb302fa(PostgreSQL 18.x) 기준으로 확인했다.
-
세 메서드 구조체와 콜백 —
CustomPathMethods,CustomScanMethods,CustomExecMethods를src/include/nodes/extensible.h에서 전부 읽었다.CustomPathMethods는 정확히 두 콜백(PlanCustomPath,ReparameterizeCustomPathByChild),CustomScanMethods는 정확히 하나(CreateCustomScanState),CustomExecMethods는 네 개의 필수(BeginCustomScan,ExecCustomScan,EndCustomScan,ReScanCustomScan)와 여덟 개의 선택적 콜백을 가진다. “모든 콜백이 필수”라는 헤더 주석은ExtensibleNodeMethods에 적용되며 선택적 슬롯이 있는CustomExecMethods에는 적용되지 않는다. 두 구조체 주석을 읽어 확인했다. -
기능 플래그 — 정확히 세 개(
CUSTOMPATH_SUPPORT_BACKWARD_SCAN = 0x0001,_MARK_RESTORE = 0x0002,_PROJECTION = 0x0004).EXTNODENAME_MAX_LEN은64. 문자 그대로 확인했다. -
“정적 vtable, 절대 복사 안 함” 계약 — 주석이
src/include/nodes/plannodes.h의CustomScan구조체에 그대로 있으며 레지스트리가 존재하는 명시된 이유다. 확인했다. -
두 개의 독립적 레지스트리 해시 —
extensible.c는 두 개의 파일 정적HTAB *(extensible_node_methods,custom_scan_methods)를 선언하며, 둘 다RegisterExtensibleNodeEntry/GetExtensibleNodeEntry로 라우팅된다. 중복 이름 확인은ERRCODE_DUPLICATE_OBJECT를 발생시키고,missing_ok == false인 미스 조회는ERRCODE_UNDEFINED_OBJECT를 발생시킨다. 확인했다. -
훅 호출 지점 —
set_rel_pathlist_hook은allpaths.c의set_rel_pathlist()에서,set_join_pathlist_hook은joinpath.c의add_paths_to_joinrel()에서 호출된다. 둘 다 NULL을 기본값으로 하는PGDLLIMPORT전역이다. 두 호출 지점과src/include/optimizer/paths.h의 선언을 읽어 확인했다. -
낮추기 —
create_customscan_plan(createplan.c)은best_path->methods->PlanCustomPath(...)를 호출한 후copy_generic_path_info와qual및custom_exprs의replace_nestloop_params재작성을 수행한다. 함수는static이며create_scan_plan의T_CustomScan파견으로 도달한다. 확인했다. -
익스큐터 shim —
nodeCustom.c에서 인용한 모든 함수(ExecInitCustomScan, 정적ExecCustomScan드라이버,ExecEndCustomScan,ExecReScanCustomScan,ExecCustomMarkPos, DSM 사중주,ExecShutdownCustomScan)를 한 줄씩 읽었다.INDEX_VAR대scanrelidtargetlist-varno 분기,css->slotOps의TTSOpsVirtual기본값, 프로바이더가 상태를 할당하는 계약이 모두 그대로다. -
EXPLAIN —
explain.c의ExplainNodeT_CustomScan케이스는 Filter qual을 보여 주고 선택적ExplainCustomScan으로 파견한다. 노드 이름은methods->CustomName이며custom_ps의 자식은ExplainCustomChildren으로 재귀한다. 확인했다. -
익스텐서블 노드 직렬화 —
_copyExtensibleNode(copyfuncs.c),_outExtensibleNode(outfuncs.c),_readExtensibleNode(readfuncs.c),_equalExtensibleNode(equalfuncs.c)가 각각GetExtensibleNodeMethods(name, false)로 vtable을 분해하고 프로바이더 콜백으로 파견한다. 확인했다. -
범위 가드(REL_18, PG19 전용 주장 없음) — 이 문서는 REL_18에 존재하는 CustomScan/익스텐서블 노드 표면만 다룬다. PG19 전용 항목(XLOG2 rmgr, 온라인 체크섬 BackendTypes 등)은 참조하지 않는다.
contrib/는 범위 밖이다. PG-Strom 등은 API의 의도된 사용 예시로만 언급했다.
PostgreSQL 너머 — 비교 설계와 연구 전선
섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 전선”FDW 형제와 둘이 공존하는 이유. PostgreSQL에는 “코어 외부에서 스캔”하는 두 메커니즘이 있다. 외부 데이터 래퍼(postgres-fdw.md)와 CustomScan이다. 두 메커니즘은 의도적으로 형태가 평행하다. CustomScan 구조체 헤더 주석 자체가 custom_exprs / custom_private / custom_scan_tlist / custom_relids 필드가 ForeignScan의 fdw_* 필드와 “동등하게” 작동한다고 밝힌다. 차이는 범위다. FDW는 SQL DDL(CREATE FOREIGN TABLE, FdwRoutine을 반환하는 핸들러)로 외부 테이블에 바인딩되며 플래너는 해당 테이블의 RTE에만 호출한다. “이 릴레이션은 다른 곳에 산다”에 적합한 도구다. CustomScan은 카탈로그에 아무것도 바인딩되지 않는다. 플래너 훅으로 삽입되며 임의의 스캔 또는 조인 노드를 교체할 수 있고, scanrelid == 0(여러 기반 릴레이션에 대한 조인 표현)을 가질 수 있으며 자식 플랜을 운반할 수 있다. 경험 규칙은 이렇다. 외부에 홈이 있는 릴레이션에는 FDW, 평범한 로컬 릴레이션에 대한 연산의 새 구현(GPU 실행, 컬럼형 캐시, 벡터화 조인)에는 CustomScan이다. PG-Strom은 정확히 이 이유로 CustomScan을 쓴다. 일반 힙 테이블에 대한 스캔/조인/집계를 GPU에서 재구현하며 “외부” 릴레이션이 없기 때문이다.
Cascades와 규칙 주도 대안. SQL Server, Greenplum의 ORCA, CockroachDB는 Graefe의 Cascades 모델에 옵티마이저를 구축한다. 물리 연산자 추가는 메모 주도 탐색이 적용하는 구현 규칙을 추가하는 것이며, 새 연산자는 균일한 규칙 프레임워크 안에서 경쟁한다. PostgreSQL 옵티마이저는 Cascades가 아닌 상향식 동적 프로그래밍 조인 탐색이다. 규칙 레지스트리가 없으므로 CustomScan 훅이 실용적 대체다. “규칙을 등록”하는 대신 “훅을 설치하고 add_path를 호출”한다. 트레이드오프는 명확하다. add_path 게이트가 지배당한 커스텀 패스를 Cascades 비용 경계처럼 가지치기하는 비용 정직성 대 CustomScan이 표현할 수 없는 진정한 논리 재작성 규칙의 표현력이다. 커스텀 노드는 코어가 이미 식별한 릴레이션의 물리적 대안만 될 수 있고, 질의의 논리적 변환은 될 수 없다.
코드젠과 벡터화 실행, 현대 연구 전선. Graefe가 확장성에 동기를 부여한 “아직 꿈도 꾸지 못한 어떤 논리”는 실제로 2014년 이후 두 가지를 의미했다. (1) 질의 컴파일(Neumann의 Efficiently Compiling Efficient Query Plans for Modern Hardware, VLDB 2011; HyPer/Umbra 계보). 연산자를 이터레이터 파견으로 해석하는 대신 타이트한 루프로 JIT 컴파일한다. (2) 벡터화 실행(MonetDB/X100과 Vectorwise 계열). 연산자가 next() 하나당 튜플 하나 대신 컬럼 배치를 처리한다. CustomScan은 PostgreSQL 익스텐션이 이 두 모델 중 하나를 기존 튜플-앳-어-타임 해석 익스큐터에 몰래 들여오는 솔기다. 프로바이더의 ExecCustomScan은 내부적으로 컴파일 또는 벡터화 커널을 실행하고 경계에서 일반 TupleTableSlot을 돌려주면 된다. 주변 플랜 트리는 알 필요가 없다. 선택적 slotOps 필드와 DSM 조율 사중주는 정확히 그런 프로바이더가 필요한 것이다. 컬럼형 배치용 커스텀 슬롯 타입과 병렬 커널용 공유 메모리 조율이다.
한계와 전선 마찰. CustomScan의 경계 비용은 실재한다. 모든 튜플이 노드 가장자리에서 TupleTableSlot 인터페이스를 여전히 교차하므로, 벡터화 프로바이더는 부모가 일반 연산자일 때마다 재튜플화 비용을 낸다. 엔드-투-엔드 벡터화를 원하는 프로젝트(컬럼 스토어 익스텐션 등)는 배치가 연산자 경계를 넘어 컬럼형 형태로 유지되도록 여러 개의 인접한 커스텀 노드를 원한다. CustomScan은 이것을 허용하지만(custom_plans 자식으로) 편하게 만들지는 않는다. 다른 마찰 지점은 플래너가 훅이 실행되는 곳에서만 커스텀 패스를 제안한다는 점이다. 코어가 한 번도 고려하지 않은 플랜 위치에 커스텀 노드를 도입하는 방법(예: 새로운 두 단계 집계 형태)은, 상위 패스 생성에도 영향을 미치지 않고는 없다. 이것이 PostgreSQL의 훅+비용 확장성 모델이 완전한 Cascades 규칙 엔진보다 명백히 덜 일반적인 개방 경계다. POSTGRES 계열 확장성의 반복 주제이기도 하다. 타입과 액세스 메서드 계층에서는 최대 도달 범위, 옵티마이저 탐색 자체에서는 더 제한된 도달 범위.
- PostgreSQL 소스, REL_18_STABLE @
273fe94(/data/hgryoo/references/postgres):src/backend/executor/nodeCustom.c— 익스큐터 shim (init/exec/end/rescan, mark-restore 가드, DSM 사중주, shutdown).src/backend/nodes/extensible.c— 이름 키 vtable 레지스트리 (커스텀 스캔 메서드와 익스텐서블 노드 메서드 양쪽의 등록/조회).src/include/nodes/extensible.h— 세 메서드 구조체,ExtensibleNodeMethods, 기능 플래그,EXTNODENAME_MAX_LEN.src/include/nodes/plannodes.h—CustomScan플랜 노드와 “정적 vtable, 절대 복사 안 함” 계약.src/include/nodes/pathnodes.h—CustomPath.src/include/nodes/execnodes.h—CustomScanState.src/backend/optimizer/plan/createplan.c—create_customscan_plan(낮추기).src/backend/optimizer/path/allpaths.c,.../path/joinpath.c—set_rel_pathlist_hook/set_join_pathlist_hook호출.src/include/optimizer/paths.h— 훅 타입 선언.src/backend/commands/explain.c—T_CustomScanEXPLAIN 케이스와ExplainCustomChildren.src/backend/nodes/copyfuncs.c,outfuncs.c,readfuncs.c,equalfuncs.c— 익스텐서블 노드 copy/out/read/equal.
- 이론 앵커 (
dbms-papers/및research/dbms-general/참조):- Graefe, Volcano — An Extensible and Parallel Query Evaluation System (IEEE TKDE, 1994) — 이터레이터 모델, 패스-플랜 분리, 확장성.
- Graefe, The Cascades Framework for Query Optimization (1995) — 규칙 주도 물리 연산자 구현, 비교 프레임.
- Hellerstein, Stonebraker & Hamilton, Architecture of a Database System (2007) — 장수 DBMS의 요건으로서의 확장성.
- Stonebraker & Rowe, The Design of POSTGRES (1986) — CustomScan이 계승한 확장성 철학.
- Neumann, Efficiently Compiling Efficient Query Plans for Modern Hardware (VLDB 2011) — 코드젠 전선.
- 형제 문서 (교차 참조, 이 문서에서 중복 없음):
postgres-fdw.md(FDW 메커니즘),postgres-executor.md(주변 익스큐터/ScanState 프레임워크),postgres-planner-overview.md와postgres-path-generation.md(패스 생성과 add_path),postgres-hooks.md(일반 플래너 훅 메커니즘),postgres-node-trees.md(복사/직렬화 인프라),postgres-parallel-query.md(DSM 사중주가 연결되는 DSM/병렬 워커 기계).