[CBRD-27401] Add ALTER INDEX ... COMPACT for online overflow OID chain compaction - #7899
[CBRD-27401] Add ALTER INDEX ... COMPACT for online overflow OID chain compaction#7899shparkcubrid wants to merge 4 commits into
Conversation
…n compaction CBRD-24094 (CUBRID#7447) turned the overflow OID chains of non-unique indexes into OID-ordered chains with a separator directory. A full data page now splits in half, so a chain that receives scattered (reused) OIDs can end up 50~100% full, up to ~2x the legacy first-fit size. The only remedy so far was ALTER INDEX ... REBUILD, which locks the table and sorts/reloads everything. Add ALTER INDEX idx ON tbl COMPACT [WITH FILL_FACTOR = n] (n = 50..100, default 90): walk the leaf level and, for every key with a directory overflow chain, fill each data page up to the fill factor from its right neighbor by appending the neighbor's lowest objects to it (both are OID-ordered, so the result stays ordered). When all of the neighbor's objects fit, the neighbor is merged away: unlinked from the chain, its directory entry dropped and the page deallocated -- separators are lower bounds, so removing an entry extends the left page's range without rewriting the directory. When only a prefix fits, the neighbor keeps the rest and its separator is raised to its new first OID. Each merge is one system operation under the key's leaf write latch, logged with the generic RVBT_RECORD_MODIFY_UNDOREDO record modifications (no new recovery handler), and committed independently of the outer transaction since only the physical layout changes. The leaf latch is released after every key that had work done and the position is re-located by key, so concurrent DML is not blocked beyond the key being compacted; the class is held under SCH_S only. Unique indexes (legacy chains), single-page chains and pages already above the fill factor are no-ops. Partitioned classes compact every partition's index. Compaction also required the mid-key range scan resume to change. A scan that interrupts inside one key (more objects than the scan OID buffer holds) saved its position as the last overflow page that had a visible object, which relies on such a page never being deallocated. Compaction moves live objects and frees pages, so the saved page can disappear or lose objects to an already processed page, silently skipping rows. Since CBRD-24094 chains are globally OID-ordered, the resume point is now the last OID processed: on resume the chain is routed by that OID through the directory and processing continues with the first greater object. This is correct across merges, splits and page frees, and also removes the duplicate-row exposure a concurrent split had. Legacy (unique) chains are not OID-ordered and keep the page-based resume; they are not compacted either. New pieces: xbtree_compact_overflow () and helpers in btree.c, the NET_SERVER_BTREE_COMPACT_OVERFLOW request, PT_COMPACT_INDEX with the COMPACT / FILL_FACTOR keywords (both remain usable as identifiers), and do_alter_index_compact () in execute_schema.c. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
/run all |
❌ TC Merge Gate — Merge BlockedOne or more TC PRs are still open. Please merge or close them before merging this PR. TC Repositories & Branches:
Steps to unblock:
|
🧪 TC Test Environment ReadyCircleCI Testing:
TC Repositories & Branches:
Next Steps:
|
|
| BTREE_GET_OID (record.data + (num_objects - 1) * obj_size, &cur_oid); | ||
| if (OID_GT (&cur_oid, anchor)) | ||
| { | ||
| /* Leftmost object greater than the anchor. */ | ||
| idx = num_objects - 1; | ||
| min = 0; | ||
| max = num_objects - 2; | ||
| while (min <= max) | ||
| { | ||
| mid = (min + max) / 2; | ||
| BTREE_GET_OID (record.data + mid * obj_size, &cur_oid); | ||
| if (OID_GT (&cur_oid, anchor)) |
There was a problem hiding this comment.
동일 OID의 MVCC 객체는 여러 overflow page에 걸쳐 존재할 수 있습니다. 그러나 이 코드는 재개 시 anchor보다 큰 OID만 찾습니다. 이전 iteration에서 처리한 페이지의 마지막 OID와 같은 OID가 다음 페이지에도 남아 있으면, 이를 이미 처리된 것으로 판단하여 현재 snapshot에서 visible한 객체까지 range scan 결과에서 조용히 누락합니다. 페이지 이동에도 안전하게 재개하려면 OID뿐 아니라 처리한 physical version까지 식별하거나, 경계의 동일 OID 객체를 visibility 규칙에 맞게 다시 확인해야 합니다.
There was a problem hiding this comment.
맞는 지적입니다. 수정했습니다.
코드로 확인한 내용부터 정리합니다.
- 키 중간 중단은 페이지 경계에서만 일어납니다(
btree_range_scan_select_visible_oids ()는 버퍼가 부족하면 그 페이지를 아예 처리하지 않고 멈춥니다). 수정 전 앵커는 그렇게 처리된 마지막 페이지의 마지막 객체였습니다. - 재사용 OID의 여러 미청소 버전이 한 체인에 공존하는 상태는
btree_insert_object_ordered_by_oid ()의 주석이 명시하는 실제 상태입니다("vacuum이 heap 엔트리는 청소했지만 b-tree 엔트리는 청소하지 않은" 창). 같은 OID는 이분탐색이 일치한 위치에 삽입되므로 버전들이 인접해 런을 만듭니다. - 스냅샷에 보이는 버전은 런 안에서 어느 위치든 될 수 있는데(어떤 버전이 보이는지는 스냅샷이 결정), 앵커가 런의 OID인 상태에서
OID_GT로 재개하면 런 전체를 건너뜁니다. 경계가 런 안에 떨어지고 보이는 버전이 그 뒤에 있으면 조용히 누락됩니다.
수정: 앵커를 "처리 구간의 마지막 객체"에서 **"스냅샷이 받아들인 마지막 객체"**로 바꿨습니다(bts->O_last_visible_oid, btree_select_visible_object_for_range_scan ()에서 기록). 이렇게 하면 OID_GT 재개가 런과 무관하게 정확합니다. 한 OID의 버전 중 스냅샷에 보이는 것은 최대 하나이고 그것이 바로 앵커 자신이므로, 보이는 객체를 건너뛰거나 두 번 반환하는 일이 원리적으로 불가능합니다. 앵커와 옛 경계 사이의 객체들은 다시 검사되지만 모두 비가시라 그대로 걸러집니다. 리프 레코드 객체는 체인과 정렬 관계가 없으므로 체인 순회 시작 시 앵커를 초기화해 절대 앵커가 되지 않게 했습니다.
검증에 대해서는 한계를 그대로 적습니다. 이 상태는 vacuum이 heap 슬롯은 해제하고 b-tree 엔트리는 아직 지우지 않은 짧은 창에서만 생기고, vacuum을 막으면 OID 재사용 자체가 일어나지 않아(재사용 슬롯은 vacuum 이후에 나옵니다) 의도적으로 만들어내지 못했습니다. 그래서 이 수정의 근거는 재현이 아니라 위 코드 논거입니다. 대신 재개 경로 자체는 다음으로 확인했습니다.
- reuse_oid 테이블에서 한 키에 30,000 객체(오버플로 83페이지)를 만들고 delete+reinsert 처른을 돌리며 인덱스 경로와 heap 경로를 같은 문장의 스냅샷으로 비교(
k = 7대k + 0 = 7): 30/30 일치, checkdb rc=0. - 미청소 버전을 138,000 객체(오버플로 344페이지)까지 쌓아 스캔당 중단·재개가 여러 번 일어나게 한 스트레스: 20/20 일치, checkdb rc=0.
푸시: b438be9
| if (freed == 0) | ||
| { | ||
| /* Already compact; nothing was held for long. Keep the latch and move on. */ | ||
| btree_clear_key_value (&clear_key, &key); | ||
| slot++; | ||
| continue; |
There was a problem hiding this comment.
freed == 0은 변경이 없었다는 의미가 아닙니다. 재분배만 수행한 chain은 여러 sysop과 페이지 수정을 완료해도 0을 반환합니다. 이 분기는 leaf WRITE latch를 해제하거나 interrupt를 확인하지 않고 다음 key로 진행하므로, 페이지 해제 없이 재분배되는 긴 chain이나 그런 key가 한 leaf에 몰리면 해당 leaf의 DML이 전체 작업 동안 차단되고 interrupt 처리도 다음 leaf까지 지연됩니다. 실제 변경 여부를 별도로 반환해 변경된 key마다 latch를 양보하고, chain 순회에도 interrupt 지점을 두어야 합니다.
There was a problem hiding this comment.
맞는 지적입니다. 수정했습니다.
freed == 0을 "할 일이 없었다"로 쓴 것이 잘못이었습니다. 재분배만 일어난 체인은 sysop을 여러 번 커밋하고 페이지를 고쳐도 pages_freed가 0이라, 그 분기가 리프 WRITE 래치를 쥔 채로 다음 키로 넘어가고 인터럽트 확인도 건너뛰었습니다.
btree_ovf_compact_chain ()이pages_freed와 별도로work_done을 돌려주고, 병합이든 재분배든 sysop을 커밋할 때마다 참으로 설정합니다.- 호출부는
!work_done, 즉 체인을 읽기만 한 경우에만 래치를 유지합니다. 무엇이든 쓴 경우에는 예외 없이 래치를 놓고, 인터럽트를 확인한 뒤 키로 위치를 다시 찾습니다. - 체인 순회 루프 안(병합 사이)에도 인터럽트 확인을 넣었습니다. 병합 하나가 각각 하나의 시스템 연산이므로 그 지점에서 빠져나가도 체인은 일관된 상태입니다.
keys_compacted는 문서화된 의미("페이지가 하나 이상 줄어든 키 수")를 유지했습니다.
푸시: b438be9
| ptr = or_unpack_btid (request, &btid); | ||
| ptr = or_unpack_int (ptr, &fill_factor); | ||
|
|
||
| error = xbtree_compact_overflow (thread_p, &btid, fill_factor, &keys_compacted, &pages_freed); |
There was a problem hiding this comment.
새 RPC는 클라이언트가 전달한 BTID를 객체 권한 확인 없이 물리 compaction에 사용합니다. 인증된 사용자가 protocol request를 직접 구성하면 SQL 경로의 AU_INDEX 검사를 우회하여 권한이 없는 인덱스의 overflow page를 수정·해제하고 I/O 및 latch 부하를 유발할 수 있습니다. CHECK_DB_MODIFICATION은 transaction의 수정 가능 여부만 검사하므로, 서버에서 BTID의 소유 class/index를 조회해 AU_INDEX 권한을 검증하거나 이 요청을 신뢰된 관리 주체로 제한해야 합니다.
How this was verified: 요청 payload의 BTID가 객체 권한 검사 없이 storage compaction 함수까지 전달되고, 해당 함수도 BTID 소유 객체에 대한 권한을 확인하지 않는 경로를 확인했습니다.
There was a problem hiding this comment.
지적은 사실이지만 이 PR이 만든 취약점은 아니라고 판단합니다. 근거를 남깁니다.
CUBRID의 서버측 B-tree 요청은 클라이언트가 보낸 BTID를 객체 권한 검사 없이 그대로 사용하는 것이 기존 설계입니다. 권한은 클라이언트에서 강제합니다.
sbtree_delete_index(NET_SERVER_BTREE_DELINDEX)는or_unpack_btid후xbtree_delete_index를 호출해 인덱스 전체를 파괴하며,au_*검사가 없습니다.NET_SERVER_BTREE_ADDINDEX/LOADINDEX,sbtree_get_statistics,sbtree_get_key_type도 동일합니다.- 즉 프로토콜을 직접 구성할 수 있는 사용자는 이 PR 이전에도 임의 BTID의 인덱스를 드롭할 수 있었고, 그것은 오버플로 페이지 병합보다 훨씬 파괴적입니다. 새 요청은 같은 패턴을 따르면서 파괴력은 더 낮습니다.
SQL 경로에서는 권한을 확인합니다. do_alter_index_compact ()가 au_fetch_class (obj, &smcls, AU_FETCH_READ, AU_INDEX)를 호출하고, 이때 잡히는 SCH_S 잠금이 트랜잭션 종료까지 유지되어 컴팩션 중 인덱스 DDL과 직렬화됩니다.
서버에서 BTID로 소유 class/index를 역조회해 권한을 검사하는 것은 이 요청 하나가 아니라 BTID를 받는 모든 요청에 걸친 프로젝트 차원의 변경이므로, 별도 이슈로 다루는 것이 맞다고 봅니다. 이 PR에서는 기존 규약을 따르겠습니다.
There was a problem hiding this comment.
설명 감사합니다. 이 맥락에서는 해당 지적을 이 PR의 신규 취약점으로 유지하지 않겠습니다. 서버 측 BTID 기반 B-tree RPC가 기존부터 클라이언트 권한 검사를 신뢰하는 설계이고, NET_SERVER_BTREE_DELINDEX 등 기존 요청이 같은 경계를 가지며 더 큰 파괴력을 갖는다면, sbtree_compact_overflow의 권한 역조회 부재는 이 PR에서 새로 도입된 권한 우회가 아니라 기존 프로토콜 설계의 공통 문제입니다. SQL 경로의 do_alter_index_compact()에서 AU_INDEX 검사를 수행한다는 점도 확인 기준에 부합합니다. 이 코멘트는 철회하고, BTID를 받는 서버 요청 전반의 권한 검증은 별도 프로젝트 이슈로 분리하는 것이 적절하겠습니다.
Review follow-up on two defects. 1. The resume anchor was the last object of the last fully processed overflow page. A reusable OID can have several un-vacuumed versions in one chain -- btree_insert_object_ordered_by_oid () documents the state and inserts equal OIDs adjacently -- and which version a snapshot sees is decided by the snapshot, so it can sit anywhere in that run. Resuming with OID_GT on the run's OID skips the whole run, silently dropping a visible object when the interrupt boundary falls inside one. Anchor on the last object accepted by the snapshot instead (bts->O_last_visible_oid, recorded in btree_select_visible_object_for_range_scan ()): at most one version of an OID is visible and it is the anchor itself, so a strictly-greater resume can neither skip nor repeat a visible object, and the invisible objects re-examined in between are filtered again. The anchor is reset when the chain walk starts, so a leaf record object can never become one. 2. btree_ovf_compact_chain () reported only freed pages, so a chain that was redistributed without freeing anything looked like "no work" to the driver, which then kept the leaf write latch and skipped the interrupt check while moving to the next key. Report work_done separately and yield the latch whenever anything was written; also check for interrupts between merges inside the chain, which is safe because each merge is its own system operation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
/run all |
Self-review follow-up, two items. The grammar action read the int member of the value union unconditionally, but unsigned_integer keeps a literal that does not fit an int in the bigint member and tags the node BIGINT. Reading the int member (a long) and narrowing it to int kept the low 32 bits, so FILL_FACTOR = 4294967386 was accepted and behaved as 90, while 4294967345 was rejected as 49. Accept only PT_TYPE_INTEGER and report the same range error otherwise. Note that ONLINE PARALLEL = n has the same pattern; that one is pre-existing and left alone here. btree_ovf_compact_pull () built the grown left record in a third page-sized stack buffer. Append into the copy already read instead: the old image stays as its prefix and serves as the undo image, and the right page's remainder is a suffix of its own copy, so it is written and logged straight from there. One page-sized buffer and two memcpy of a page each are gone, cutting the frame from ~48KB to ~32KB, in line with the delete path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
/run all |
…list NET_SERVER_BTREE_COMPACT_OVERFLOW was placed with the other b-tree requests, in the middle of NET_SERVER_REQUEST_LIST, which renumbers every request after it. The last four commits that added a request (CBRD-26936, CBRD-26527, CBRD-23700, CBRD-26123) all appended at the end of the list instead, so that the numbers already in use never move. Follow that and put the new item last. No behaviour change within a build: the enum and the dispatch table are both generated from this list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
http://jira.cubrid.org/browse/CBRD-27401
Purpose
CBRD-24094(#7447)로 비유니크 인덱스의 오버플로 OID 체인이 OID 정렬 + 디렉터리 구조로 바뀌면서, 만원인 데이터 페이지가 반반 split 되어 충전율이 50~100% 사이가 될 수 있습니다. 시리얼 삽입(OID 단조 증가)은 rightmost 채움이라 legacy와 같지만,
reuse_oid테이블에서 삭제·재삽입이 반복되어 재사용 OID가 흩어져 들어오면 오버플로 페이지가 최대 2배까지 늘 수 있습니다(CBRD-27324 코멘트 실측 1.7배). legacy의 first-fit은 100% 충전이었으므로 11.5 업그레이드 후 "중복이 많은 인덱스 크기가 커졌다"는 문의가 나올 수 있습니다.현재 유일한 해소 수단인
ALTER INDEX … REBUILD는 테이블을 잠그고 전체를 정렬·재적재하므로 대형 테이블에서 부담이 큽니다.CREATE INDEX … WITH ONLINE은 잠금은 없으나 행 단위 삽입이라 더 느리고 로그를 많이 씁니다. 리빌드보다 가벼운, 오버플로 체인만 정리하는 온라인 컴팩션 명령을 추가합니다. (OracleALTER INDEX … COALESCE, SQL ServerALTER INDEX … REORGANIZE대응)Implementation
구문:
ALTER INDEX index_name ON table_name COMPACT [WITH FILL_FACTOR = n](n = 50~100, 기본 90). 100%로 채우지 않는 이유는 직후 랜덤 삽입에서 바로 split이 재발하는 것을 막기 위함입니다.핵심 연산 —
btree_ovf_compact_pull()(src/storage/btree.c): split의 역연산입니다. 체인을 왼쪽부터 훑으며 각 데이터 페이지를 목표 충전율까지 오른쪽 이웃에서 채웁니다. 양쪽 모두 OID 정렬이고 left < right이므로 이웃의 앞쪽 객체를 왼쪽 레코드 뒤에 이어붙이면 정렬이 유지됩니다.btree_ovf_dir_write_header) →file_dealloc→ 디렉터리 엔트리 삭제(btree_ovf_dir_remove_entry). separator가 하한이므로 엔트리 하나를 지우면 왼쪽 페이지 구간이 자동 확장되어 디렉터리 재작성이 불필요합니다.btree_ovf_dir_set_separator, 신규). 갱신 값은(왼쪽 페이지 최대 OID, 이 페이지 최소 OID]이내라 디렉터리 정렬과 라우팅이 유지됩니다. 동일 OID 런(vacuum 안 된 재사용 OID 중복)은 경계에서 쪼개지 않도록 뒤로 물러섭니다. 헤드 디렉터리 페이지의 엔트리 0은 캐치올(−∞ 취급)이므로 separator를 올리지 않고 왼쪽 이웃으로만 쓰입니다.온라인성: 병합/재분배 1회 = 시스템 연산(sysop) 1개, 해당 키의 리프 WRITE 래치 아래에서 수행 — 삽입·삭제·vacuum과 동일한 직렬화 규약입니다. 로깅은 기존
RVBT_RECORD_MODIFY_UNDOREDO(UPDATE_ALL / UPDATE_PARTIAL)만 사용하므로 신규 복구 핸들러가 없습니다. 물리 배치만 바뀌므로 sysop은 호출 트랜잭션과 무관하게 즉시 commit 합니다. 작업이 있었던 키마다 리프 래치를 놓고 키로 위치를 다시 찾으므로(btree_compact_fix_leaf) 동시 DML이 컴팩션 중인 키 외에는 막히지 않고, 클래스는AU_FETCH_READ(SCH_S)만 잡습니다. 인터럽트 체크 포함.동반 변경 — 키 중간 스캔 재개를 OID 기준으로(
btree_ovf_scan_locate_resume()): 이 부분이 이번 작업에서 가장 주의가 필요했던 지점입니다.btree_range_scan은 한 키의 객체가 스캔 OID 버퍼(기본 4페이지 = 8,192개)보다 많으면 키 중간에서 중단하고, 재개 위치를 "마지막으로 가시 객체가 있던 오버플로 페이지"로 저장합니다. 이 방식은 "가시 객체가 있는 페이지는 반납되지 않는다"는 전제(코드 주석의 3번)에 의존하는데, 컴팩션은 살아 있는 객체를 옮기고 페이지를 반납하므로 그 전제를 깹니다. 그대로 두면 중단된 스캔이 반납된 페이지를 fix 하려다 실패하거나, 이미 지나간 페이지로 옮겨진 객체를 건너뛰어 행이 조용히 누락됩니다. CBRD-24094로 체인이 전역 OID 정렬이 되었으므로, 재개 위치를 페이지가 아니라 마지막으로 처리한 OID로 바꿨습니다. 재개 시 디렉터리로 라우팅해 해당 페이지를 찾고 페이지 내에서 그 OID보다 큰 첫 객체부터 처리합니다. 페이지가 병합·분할·반납되어도 정확하며, 삽입 split으로 인한 중복 반환 가능성도 함께 없어집니다. legacy(유니크) 체인은 OID 정렬이 아니므로 기존 페이지 기준 재개를 그대로 씁니다(컴팩션 대상도 아님).대상 제외(no-op, 오류 아님): 유니크 인덱스(legacy 체인), 1페이지 이하 체인, 이미 충전율 이상인 페이지. 파티션 테이블은 각 파티션의 인덱스를 순회합니다.
배선:
PT_COMPACT_INDEX+COMPACT/FILL_FACTOR토큰(둘 다 식별자로도 계속 사용 가능) →do_alter_index_compact()(execute_schema.c) →NET_SERVER_BTREE_COMPACT_OVERFLOW→xbtree_compact_overflow().Remarks
신규 기능이며 기존
ALTER INDEX … REBUILD,SHOW INDEX CAPACITY동작 변경은 없습니다. 스캔 재개 방식 변경은 비유니크 인덱스에서 한 키가 스캔 버퍼를 넘을 때만 타는 경로입니다.진단 기준(매뉴얼 예정):
SHOW INDEX CAPACITY OF tbl.idx의Total_free_space_ovf/ (Total_used_space_ovf+Total_free_space_ovf)가 30% 이상이고Num_ovf_page가 의미 있는 크기일 때 COMPACT 권고, 실행 후 같은 명령으로 확인.실측 1 — 긴 체인(3키 × 30,000행
reuse_oid, 절반 삭제 + vacuum 후 재사용 OID로 재삽입):실측 2 — 짧은 체인(200키 × 약 2,250행, 키당 데이터가 2.3페이지분): 800 → 600 페이지. 키당 이론 최소치(3페이지)와 일치하며, 더 채울 수 없어
fill_factor = 100으로도 동일합니다. 즉 짧은 체인은 페이지 단위 꼬리 때문에 감소율이 낮습니다.실행 시간(실측 2와 같은 인덱스): COMPACT 0.07초 vs 같은 인덱스 REBUILD 5.94초. 체인이 이미 정리된 상태에서의 재실행은 변화 없음(멱등).
검증 결과
SELECT count(*), 키별 카운트 전부 동일,cubrid checkdb --check-btree/--check-btree-entriesrc=0.FILL_FACTOR = 49/101→ "FILL_FACTOR argument must be between 50 and 100.", 없는 인덱스/클래스 → 기존 에러.compact,fill_factor를 테이블명·컬럼명으로 사용 가능함을 확인(예약어화 아님).count(distinct)도 동일(누락·중복 0), checkdb rc=0.kill -9후 재기동: 복구 성공, 키별 카운트 동일, checkdb rc=0, 재실행으로 컴팩션 정상 완료(아래 "복구" 항목).후속: vacuum 시 자동 병합(InnoDB
MERGE_THRESHOLD방식)은 vacuum 경로에 쓰기가 추가되어 리스크가 크므로 이번 범위에서 제외하고 별도 이슈로 검토합니다. 매뉴얼(ALTER INDEX 절, 인덱스 크기 증가 시 조치)과 11.5 릴리스 노트 항목, 테스트케이스(TC PR)는 별도로 진행합니다.🤖 Generated with Claude Code