CUBRID OOS (Out-of-Line Storage) — 코드 수준 심층 분석
이 문서의 위치: 상위 분석 문서
cubrid-oos.md는 설계 의도와 이론적 배경을 다룬다. 이 문서는 모든 분기와 필드를 코드 수준에서 추적한다. 각 장은 독립적으로 읽을 수 있지만, 순서대로 읽으면 커널 내부에서 단일 out-of-line 컬럼 값의 전체 생명주기를 따라갈 수 있다.개발 중: OOS는
feat/oos브랜치(@fa26eff3a, M1 완료 / M2 진행 중)에 있다. 심볼이 안정적인 기준점이며, position-hint 테이블의 줄 번호는 브랜치가 진행될수록 빠르게 낡는다.
목차:
Chapter 1: 자료 구조 지도
섹션 제목: “Chapter 1: 자료 구조 지도”이 챕터는 CUBRID Out-Of-row Storage(OOS) 서브시스템에서 “컬럼이 외부화(externalized)되었다”는 사실을 표현하는 모든 struct, 플래그, 식별자를 목록화한다. 이후 챕터들은 여기서 정의한 필드명을 그대로 인용한다. OOS의 이유 — 언제 값이 외부화되는지, 강등 임계값, 스토리지 트레이드오프 — 는 cubrid-oos.md의 “OOS가 해결하는 문제”와 “강등 결정” 절을 읽기 바란다. 이 챕터에서는 그 이론을 다시 유도하지 않는다.
OOS 값은 분산된(distributed) 표현으로서 세 계층에 걸쳐 존재한다: heap 레코드 내부의 16바이트 인라인 stub, 전용 OOS 파일의 chunk 레코드 체인, 배치를 안내하는 파일별 통계 헤더. 아래 식별자들은 이 계층들이 서로를 가리키는 수단이다.
1.1 식별 플래그 — 엔진이 컬럼의 외부화 여부를 파악하는 방법
섹션 제목: “1.1 식별 플래그 — 엔진이 컬럼의 외부화 여부를 파악하는 방법”외부화는 서로 다른 두 범위에서 각각 독립적인 플래그 비트 하나씩으로 표시된다. 이 둘을 혼동하는 것이 코드를 읽을 때 가장 흔한 실수이므로, 먼저 명확히 짚고 넘어간다.
// OR_MVCC_FLAG_HAS_OOS -- src/base/object_representation_constants.h#define OR_MVCC_FLAG_HAS_OOS 0x08 // 0b00001000OR_MVCC_FLAG_HAS_OOS는 레코드 헤더의 MVCC 플래그 니블(nibble)에 있는 행(row) 단위 비트다. variable-offset table(VOT)을 순회하지 않고도 “이 레코드에 외부화된 컬럼이 하나라도 있는가?”라는 질문에 빠르게 답하는 fast-path 게이트다. 같은 MVCC 니블 아래 OR_MVCC_FLAG_VALID_INSID 0x01, OR_MVCC_FLAG_VALID_DELID 0x02, OR_MVCC_FLAG_VALID_PREV_VERSION 0x04와 나란히 위치하며 OR_MVCC_FLAG_MASK 0x1f로 마스킹된다. 헤더 주석은 이 배치를 솔직하게 설명한다: “OOS is not related to MVCC, move to another place when POC ends.” 현재는 MVCC 니블에 임시로 탑승해 있다.
// OR_VAR_BIT_OOS -- src/base/object_representation.h#define OR_VAR_BIT_OOS 0x1#define OR_VAR_BIT_LAST_ELEMENT 0x2#define OR_VAR_FLAG_MASK 0x3#define OR_SET_VAR_OOS(length) ((int) (length) | OR_VAR_BIT_OOS)#define OR_SET_VAR_LAST_ELEMENT(length) ((int) (length) | OR_VAR_BIT_LAST_ELEMENT)#define OR_GET_VAR_FLAG(length) ((int) (length) & OR_VAR_FLAG_MASK)#define OR_GET_VAR_OFFSET(length) ((int) (length) & (~OR_VAR_FLAG_MASK)) /* strip flags, recover offset */#define OR_IS_OOS(length) (OR_GET_VAR_FLAG (length) & OR_VAR_BIT_OOS)#define OR_IS_LAST_ELEMENT(length) (OR_GET_VAR_FLAG (length) & OR_VAR_BIT_LAST_ELEMENT)OR_VAR_BIT_OOS는 VOT 엔트리 단위 비트다. 가변 속성마다 VOT 엔트리가 하나씩 있으며, 해당 오프셋 워드의 하위 비트에 플래그가 실린다. OR_VAR_BIT_OOS (0x1)은 “이 컬럼이 out-of-row이므로, 오프셋이 가리키는 바이트는 실제 값이 아니라 16바이트 stub이다”를 의미한다. 형제 비트 OR_VAR_BIT_LAST_ELEMENT (0x2)는 테이블의 끝을 나타내는 VOT sentinel이다. 두 비트 모두 2비트 OR_VAR_FLAG_MASK (0x3) 아래에 있으며, OR_GET_VAR_OFFSET은 이를 마스킹해 실제 오프셋을 복원한다. 두 비트는 독립적이어서 한 엔트리가 OOS 이면서 동시에 마지막 엔트리일 수 있다. 세터 OR_SET_VAR_OOS / OR_SET_VAR_LAST_ELEMENT는 비트를 OR로 합치므로 형제 비트를 절대 지우지 않는다.
관계는 계층적이다: 행의 OR_MVCC_FLAG_HAS_OOS는 적어도 하나의 VOT 엔트리에 OR_VAR_BIT_OOS가 세팅되어 있음을 보장한다. heap_oos_parse_vot이 이를 강제한다 — sentinel까지 순회한 후 n_var <= 0이면 assert_release를 발동하고 “OOS flag set without variable attributes”(7장의 레코드 손상 경로)를 출력한다.
OOS 파일 자체는 일급(first-class) 파일 타입이다:
// FILE_TYPE -- src/storage/file_manager.htypedef enum { FILE_TRACKER, FILE_HEAP, /* ... condensed ... */ FILE_TEMP, FILE_OOS, FILE_UNKNOWN_TYPE, FILE_LAST = FILE_UNKNOWN_TYPE } FILE_TYPE;FILE_OOS는 클래스별 overflow 파일에 찍히는 타입으로, FILE_HEAP 및 레거시 FILE_MULTIPAGE_OBJECT_HEAP과 구별된다. FILE_UNKNOWN_TYPE 바로 앞 두 번째 마지막 열거자다. file_manager와 vacuum은 이 값으로 분기한다.
INVARIANT (일대일 소유권). 어떤 OOS head OID도 정확히 하나의 heap 레코드만이 참조한다. Chunk는 공유, 중복제거, 참조 카운팅되지 않는다 — 인라인 stub만이 head chunk를 가리키는 유일한 포인터다. 왜 중요한가: 모든 회수 경로(9장 eager cleanup, 10장 vacuum REMOVE 및 forward-walk)는 소유자가 삭제될 때 chunk를 무조건 삭제하며 “다른 참조자가 있는가?”를 확인하지 않는다. 존재할 수 없기 때문이다. 위반 시 무슨 일이 생기나: 두 번째 참조자가 있다면 첫 번째 소유자가 chunk를 해제한 후 dangling OID를 보게 되고,
oos_read는 해제된 페이지에 착지한다.oos_chunk_exists프로브는 단일 소유자가 복구 과정에서 자신의 chunk를 재확인할 수 있게 해주는 것이지, 공유를 지원하는 것이 아니다.
1.2 인라인 stub과 chunk 헤더 — 디스크 상의 골격
섹션 제목: “1.2 인라인 stub과 chunk 헤더 — 디스크 상의 골격”heap 레코드에 남겨지는 16바이트가 stub이다:
// OR_OOS_INLINE_SIZE -- src/base/object_representation.h/* OOS inline size: OOS OID (8 bytes) + OOS length (8 bytes) */#define OR_OOS_INLINE_SIZE (OR_OID_SIZE + OR_BIGINT_SIZE) /* 8 + 8 = 16 */heap_oos_read_blobs(7장)가 이를 읽는다: or_get_oid가 첫 8바이트를 head-chunk OID로 소비하고, or_get_bigint가 다음 8바이트를 재조립 후 전체 길이로 소비한다. 길이를 인라인으로 보관하는 이유는 OOS 파일을 건드리기 전에 독자가 목적지 버퍼의 크기를 잡을 수 있게 하기 위함이다 — oos_read는 정확히 dest.size() 바이트만 쓰며 길이를 스스로 발견하지 않는다. 또한 경계 검사도 수행한다: 값 오프셋에 OR_OOS_INLINE_SIZE를 더한 값이 src_length를 초과하면 assert_release *“OOS inline slot extends past record bounds”*를 발동한다.
Stub 필드 레이아웃 (16바이트):
| 바이트 | 필드 | 역할 | 존재 이유 |
|---|---|---|---|
| 0–7 | OID | OOS 파일 내 head-chunk OID | chunk 체인으로의 단일 포인터 (1.1 invariant) |
| 8–15 | BIGINT length | 재조립 후 전체 값의 길이 | 첫 번째 oos_read 전에 dest 크기 결정; oos_get_length가 반환하는 값 |
첫 8바이트는 평범한 CUBRID OID(OR_OID_SIZE)이며, 디스크 바이트 순서는 OR_OID_* 오프셋 상수로 고정된다 — 인메모리 struct 선언 순서가 아니다. 인메모리 OID는 { int pageid; short slotid; short volid; }(db_identifier in src/compat/dbtype_def.h)로 선언되어 있지만, OR_GET_OID / OR_PUT_OID는 이 디스크 순서로 직렬화한다:
Head-chunk OID 레이아웃 (8바이트, stub의 바이트 0–7):
| 바이트 | 필드 | 상수 | 역할 |
|---|---|---|---|
| 0–3 | pageid (INT, 4B) | OR_OID_PAGEID = 0 | OOS 파일 내 head chunk의 페이지 |
| 4–5 | slotid (SHORT, 2B) | OR_OID_SLOTID = 4 | 해당 페이지에서 head chunk의 slot 인덱스 |
| 6–7 | volid (SHORT, 2B) | OR_OID_VOLID = 6 | OOS 페이지가 위치한 볼륨 |
따라서 전체 16바이트 stub은 pageid(4) | slotid(2) | volid(2) | full_length(8 BIGINT)다. 뒤쪽의 full_length는 BIGINT 절반으로, OID의 일부가 아니다; OID는 앞쪽 8바이트만이다. (태스크 레벨 약어 “volid2/pageid4/slotid2/full_length8”은 네 물리 필드를 크기 기준으로 나열한 것이며, 디스크 상의 바이트 순서는 OR_OID_PAGEID = 0에 따라 pageid가 먼저다.)
OOS 파일 쪽에서는 모든 chunk 레코드가 고정 헤더로 시작한다:
// oos_record_header -- src/storage/oos_file.hppstruct oos_record_header{ int total_data_length; /* total length of user data across all chunks (excluding OOS headers) */ int chunk_index; /* 0-based index of this chunk in the chain */ OID next_chunk_oid; /* OID of next chunk, or NULL OID if this is the last */};using OOS_RECORD_HEADER = struct oos_record_header;#define OOS_RECORD_HEADER_SIZE ((int) sizeof (OOS_RECORD_HEADER))oos_record_header 필드:
| 필드 | 역할 | 존재 이유 |
|---|---|---|
total_data_length | 체인 전체에 걸친 사용자 데이터 총 바이트 수 (chunk 헤더 제외) | stub의 BIGINT와 중복되지만 OOS 쪽에 위치하므로 heap 없이도 oos_get_length가 답할 수 있다 |
chunk_index | 체인 내 이 chunk의 0-based 위치 | 복구/읽기 시 순서를 검증하고 잘린 체인을 감지한다 |
next_chunk_oid | 다음 chunk의 OID; 마지막이면 NULL | 체인 링크; NULL이 순회를 종료한다 (6장) |
OOS_RECORD_HEADER_SIZE는 모든 chunk가 payload 앞에 예약하는 접두 크기이며, slot당 사용 가능한 용량은 slot_size - OOS_RECORD_HEADER_SIZE다.
두 개의 문서용 별칭이 함께 정의되어 있으나 컴파일 타임에 기저 타입과 구분되지 않는다:
// OOS_RECDES -- src/storage/oos_file.hppusing OOS_RECDES = RECDES; /* a RECDES whose first OOS_RECORD_HEADER_SIZE bytes are the header */using oos_buffer = cubbase::span<char>; /* caller-owned payload span; size() is authoritative */oos_buffer는 payload 계약이다: oos_insert는 오직 읽고, oos_read는 오직 쓰며, .size()가 양방향에서 권위를 가진다. 헤더 주석에 따르면 이 별칭은 .c 파일 포매터가 raw cubbase::span<char> 템플릿 인자를 망가뜨리는 것도 방지한다.
Figure 1-1. 포인터 골격: 행 단위 플래그가 엔트리 단위 플래그를 게이팅하고, 엔트리 단위 플래그가 stub을 게이팅하며, stub의 OID가 head chunk를 가리키고, 각 chunk의
next_chunk_oid가 NULL에 도달할 때까지 체인을 연결한다.
1.3 배치 통계 — oos_bestspace와 oos_hdr_stats
섹션 제목: “1.3 배치 통계 — oos_bestspace와 oos_hdr_stats”모든 페이지를 스캔하지 않고 chunk를 배치하기 위해 OOS 파일은 헤더에 여유 공간 추정치를 캐싱한다. 기본 단위는 (page, free-bytes) 쌍이다:
// oos_bestspace -- src/storage/oos_file.hpptypedef struct oos_bestspace OOS_BESTSPACE;struct oos_bestspace { VPID vpid; int freespace; };oos_bestspace 필드:
| 필드 | 역할 | 존재 이유 |
|---|---|---|
vpid | 후보 페이지의 volume+page id | chunk가 들어갈 수 있는 페이지 |
freespace | 해당 페이지의 추정 여유 바이트 | 후보 순위를 결정; 필요한 chunk 크기와 비교 |
이 쌍들은 헤더 상주 통계 블록에 각각 OOS_NUM_BEST_SPACESTATS(10)개 상한의 링으로 묶인다:
// oos_hdr_stats -- src/storage/oos_file.hppstruct oos_hdr_stats{ VFID oos_vfid; struct { int num_pages; int num_recs; float recs_sumlen; int num_other_high_best; int num_high_best; int num_substitutions; int num_second_best; int head_second_best; int tail_second_best; int head; VPID full_search_vpid; VPID second_best[OOS_NUM_BEST_SPACESTATS]; OOS_BESTSPACE best[OOS_NUM_BEST_SPACESTATS]; } estimates; int reserve0_for_future; int reserve1_for_future;};oos_hdr_stats 필드:
| 필드 | 역할 | 존재 이유 |
|---|---|---|
oos_vfid | 이 헤더가 속한 파일 | 자기 식별; fix 시 온전성 검사 |
estimates.num_pages | OOS 파일의 물리 사용자 페이지 수 | 밀도 분모; 통계로 노출 (11장) |
estimates.num_recs | 살아 있는 OOS 레코드(chunk) 수 | 밀도 분자; OOS_STATS_INFO의 num_recs로 노출 |
estimates.recs_sumlen | 살아 있는 레코드 본문 바이트의 누적 합 (float) | 평균 레코드 크기 추정; recs_sumlen으로 노출 |
estimates.num_other_high_best | best[]에 없는 high-free 페이지 수 | 캐시 링 너머에도 여유가 있음을 시사 |
estimates.num_high_best | high-free 임계값 이상인 best[] 슬롯 수 | 현재 “양호”한 캐시 후보 개수 |
estimates.num_substitutions | best[] 슬롯이 교체된 횟수 | 교란 신호; 재스캔 빈도를 조절 |
estimates.num_second_best | second_best[]의 유효 엔트리 수 | best[]에 공급하는 백로그 크기 |
estimates.head_second_best | second_best[] 링의 head 인덱스 | 생산자 커서 (OOS_STATS_NEXT_BEST_INDEX) |
estimates.tail_second_best | second_best[] 링의 tail 인덱스 | 소비자 커서 |
estimates.head | best[] 링의 head 인덱스 | 가장 먼저 시도하는 페이지를 순환 |
estimates.full_search_vpid | 마지막 전수 탐색이 멈춘 위치 | 0페이지부터 다시 시작하지 않도록 증분 탐색 재개 위치 저장 |
estimates.second_best[10] | 잠재적 best 후보 VPID 링 | 필요 시 best[]로 승격되는 공급 풀 |
estimates.best[10] | OOS_BESTSPACE(page + free) 후보 링 | 배치 fast path가 참조하는 핫셋 (5장) |
reserve0_for_future / reserve1_for_future | 미사용 패딩 int | 레이아웃 변경 없이 확장할 수 있는 온디스크 포맷 안전 여유 |
두 링 인덱스 매크로가 버퍼를 순환 구조로 만든다:
// OOS_STATS_NEXT_BEST_INDEX -- src/storage/oos_file.hpp#define OOS_STATS_NEXT_BEST_INDEX(i) (((i) + 1) % OOS_NUM_BEST_SPACESTATS)#define OOS_STATS_PREV_BEST_INDEX(i) (((i) == 0) ? (OOS_NUM_BEST_SPACESTATS - 1) : ((i) - 1))INVARIANT (추정치는 힌트, 권위가 아니다).
estimates의 모든 필드는 실제 페이지 여유 공간에 비해 뒤처질 수 있는 근사치다;best[i].freespace는 헤더가 생각하는 값이지 실제 페이지의 값이 아니다. 코드가 안전한 이유: 배치(5장)는 커밋하기 전에 후보 페이지를 다시 fix하여 실제 여유 공간을 재확인하므로, 오래된 과대 추정치는 페이지 fix 낭비를 일으킬 뿐 손상을 야기하지 않는다. 신뢰할 경우 무슨 일이 생기나: 헤더가 공간이 있다고 주장하지만 실제로는 꽉 찬 페이지에 쓰면 slot이 넘친다. 추정치는 후보를 정렬하고 탐색을 한정할 뿐, 검증 후 사용해야 한다.
배치 탐색은 “공간 없음”과 “탐색 실패”를 구분하는 삼원 상태를 반환한다:
// OOS_FINDSPACE -- src/storage/oos_file.hpptypedef enum { OOS_FINDSPACE_FOUND = 0, OOS_FINDSPACE_NOTFOUND, OOS_FINDSPACE_ERROR } OOS_FINDSPACE;OOS_FINDSPACE_NOTFOUND는 “탐색은 성공했지만 공간이 없으니 새 페이지를 할당하라”는 의미이고, OOS_FINDSPACE_ERROR는 “fix/읽기에 실패했으니 오류를 전파하라”는 의미다. 이 둘을 합치면 I/O 오류가 은폐되거나 불필요한 페이지 할당이 발생한다.
flowchart TD hdr["oos_hdr_stats.estimates"] hdr --> best["best[0..9]: OOS_BESTSPACE"] hdr --> sb["second_best[0..9]: VPID"] best --> bp0["페이지 A — vpid+freespace"] best --> bp1["페이지 B — vpid+freespace"] sb -.승격.-> best bp0 -.쓰기 전 재검증.-> pageA["실제 페이지 A"]
Figure 1-2. 헤더의 best[] 링은 page+free 후보를 캐싱하고, second_best[]가 이를 공급하며, 쓰기 전에 모든 후보는 실제 페이지와 대조하여 재검증된다.
1.4 통계 노출 구조체 — oos_stats_info와 db_oos_stats
섹션 제목: “1.4 통계 노출 구조체 — oos_stats_info와 db_oos_stats”거의 쌍둥이인 두 struct가 OOS 메트릭을 노출한다. 하나는 서버 측, 하나는 클라이언트 측으로 분리되어 있는데, 클라이언트 struct는 C-linkable이어야 하고 OID/VFID 타입을 포함하면 안 되기 때문이다.
// oos_stats_info -- src/storage/oos_file.hppstruct oos_stats_info { int has_oos_file; VFID oos_vfid; int num_user_pages; int page_size; int num_recs; INT64 recs_sumlen;};using OOS_STATS_INFO = struct oos_stats_info;oos_stats_info 필드 (서버 측):
| 필드 | 역할 | 존재 이유 |
|---|---|---|
has_oos_file | 0 = OOS 파일 없음, 1 = 있음 | oos_vfid를 건드리기 전에 호출자를 조기 종료 |
oos_vfid | OOS 파일 id | 서버 내부용; 클라이언트에 노출되지 않음 |
num_user_pages | OOS 파일의 물리 사용자 페이지 수 | 용량 지표 |
page_size | DB_PAGESIZE | 용량 계산 분모 |
num_recs | OOS_HDR_STATS에서 읽은 살아 있는 OOS 레코드 수 | 모집단 지표 |
recs_sumlen | 살아 있는 레코드 본문 바이트 합 (INT64) | 총 외부화 바이트 |
// db_oos_stats -- src/compat/db_oos.hstruct db_oos_stats { int has_oos_file; int oos_vfid_volid; int oos_vfid_fileid; int num_user_pages; int page_size; int num_recs; int64_t recs_sumlen;};db_oos_stats 필드 (클라이언트 측):
| 필드 | 역할 | 존재 이유 |
|---|---|---|
has_oos_file | 0/1, 위와 동일 | 클라이언트 게이트 |
oos_vfid_volid | OOS 파일의 volume id | VFID를 일반 int 둘로 분해 — 클라이언트 헤더에서 서버 VFID 타입 회피 |
oos_vfid_fileid | OOS 파일의 file id | 분해된 VFID의 두 번째 절반 |
num_user_pages | 물리 사용자 페이지 수 | 서버 필드 미러 |
page_size | DB_PAGESIZE | 서버 필드 미러 |
num_recs | 살아 있는 OOS 레코드(slot) 수 | 서버 필드 미러 |
recs_sumlen | 본문 바이트 합 (int64_t) | 서버 INT64 필드 미러 |
구조적 차이는 하나뿐이다: oos_stats_info는 VFID oos_vfid를 가지고, db_oos_stats는 이를 oos_vfid_volid + oos_vfid_fileid로 분해하여 db_oos.h가 서버 전용 타입 없이 C-linkable을 유지한다. 11장에서 xoos_get_stats_by_class_oid → oos_get_stats_by_vfid → db_get_oos_stats 마샬링 경로를 상세히 다룬다.
1.5 임시 작업 구조체 — 확장 상태와 vacuum 메모
섹션 제목: “1.5 임시 작업 구조체 — 확장 상태와 vacuum 메모”HEAP_OOS_EXPAND_STATE는 re-inlining 헬퍼(7장)를 관통하는 스크래치 백이다. heap_record_replace_oos_oids의 스택에서 영값 초기화로 선언되고(HEAP_OOS_EXPAND_STATE state = { };), heap_oos_parse_vot / heap_oos_read_blobs / heap_oos_compute_layout에 의해 채워진 다음 포인터로 heap_oos_build_record에 전달된다. 영속화되지 않는다.
// HEAP_OOS_EXPAND_STATE -- src/storage/heap_oos.cppstruct HEAP_OOS_EXPAND_STATE{ const char *src; int src_length; int src_offset_size; int src_header_size; int n_var; int new_length; int src_vot_bytes; int dst_vot_bytes; int fixed_bitmap_bytes; std::vector<int> vot_raw; std::vector<std::vector<char>> oos_blobs;};HEAP_OOS_EXPAND_STATE 필드:
| 필드 | 역할 | 존재 이유 |
|---|---|---|
src | 소스(stub을 담은) 레코드 바이트 포인터 | 읽기 입력 (rec->data가 이동할 수 있으므로 스냅샷 복사본 사용) |
src_length | 소스 레코드 길이 | 모든 오프셋의 경계 검사 상한 |
src_offset_size | 소스 VOT 엔트리 하나의 너비 (1/2/4) | heap_oos_parse_vot의 OR_BYTE/SHORT/INT 분기를 결정 |
src_header_size | 소스 레코드 헤더 바이트 수 | 각 VOT 상대 오프셋에 더해지는 기준 오프셋 |
n_var | LAST_ELEMENT sentinel 인덱스 (= 가변 속성 수) | 루프 한계; 플래그가 설정된 상태에서 <= 0이면 레코드 손상 |
new_length | 재조립(re-inlined) 레코드의 길이 | 목적지 버퍼 크기 결정 |
src_vot_bytes | 소스 VOT의 바이트 크기 | 입력 레이아웃 회계 |
dst_vot_bytes | 목적지 VOT의 바이트 크기 (4바이트 BIG_VAR_OFFSET_SIZE) | 출력 레이아웃; 목적지는 항상 4바이트 오프셋 사용 |
fixed_bitmap_bytes | VOT 끝에서 첫 번째 값 사이의 바이트 수 (고정 속성 + null bitmap) | 출력에 그대로 복사; < 0이면 손상 |
vot_raw | 플래그 비트가 그대로인 파싱된 VOT 엔트리 | 엔트리별 OR_IS_OOS / OR_IS_LAST_ELEMENT / 오프셋 판단 |
oos_blobs | 엔트리별 재조립 payload (vector<vector<char>>, n_var 크기) | oos_read 후 인라인으로 접합할 준비가 된 각 OOS 컬럼의 전체 바이트 |
vacuum 쪽에는 훨씬 작은 작업 구조체가 있다 — 단일 엔트리 메모:
// vacuum_oos_vfid_memo -- src/query/vacuum_oos.hpptypedef struct vacuum_oos_vfid_memo VACUUM_OOS_VFID_MEMO;struct vacuum_oos_vfid_memo{ bool valid = false; /* false until the first successful lookup */ VFID heap_vfid; /* key; meaningful only when valid */ VFID oos_vfid; /* value; meaningful only when valid (VFID_NULL = "no OOS file") */};vacuum_oos_vfid_memo 필드:
| 필드 | 역할 | 존재 이유 |
|---|---|---|
valid | 첫 번째 성공적인 조회가 있기 전까지 false | ”아직 미해결”과 “VFID_NULL로 해결됨”을 구분 |
heap_vfid | 캐시된 조회 키 | 가장 최근에 해결된 heap |
oos_vfid | 캐시된 결과; VFID_NULL = “heap에 OOS 파일 없음” | 한 heap에 대한 연속 레코드 처리 중 반복적인 file_descriptor_get + page fix를 생략 |
INVARIANT (메모는 워커별, 블록별, 공유 없음).
VACUUM_OOS_VFID_MEMO는vacuum_process_log_block의 스택에 선언되어 워커별·블록별 수명을 가지므로 조회에 동기화가 불필요하다. 이유: 대량 UPDATE는 하나의 heap에 대한RVHF_UPDATE_NOTIFY_VACUUM레코드를 연속으로 방출한다; 단일 슬롯 메모는 이 연속 구간의 반복적인 VFID 해결을 하나로 접는다. 위반 시 무슨 일이 생기나:static이었다면 경쟁 조건이 발생한다 — vacuum은 여러 워커 스레드에서 실행되므로 공유 static은 캐시된 키/값을 손상시킨다. 일시적 조회 실패는 절대 메모화되지 않으므로 이후 레코드는 재시도할 수 있다;valid == true인 결과만 캐싱된다.
메모를 채우는 조회는 배치 enum의 정신을 반영하는 삼원 상태를 반환한다:
// VACUUM_OOS_VFID_LOOKUP_RESULT -- src/query/vacuum_oos.cpptypedef enum { VACUUM_OOS_VFID_FOUND, /* found a real OOS file; its id is in out_oos_vfid */ VACUUM_OOS_VFID_NONE, /* the heap simply has no OOS file (normal) */ VACUUM_OOS_VFID_ERROR /* lookup failed; error left set, answer not cached */} VACUUM_OOS_VFID_LOOKUP_RESULT;FOUND는 캐시하고 진행하며, NONE은 “회수할 것이 없다”는 정상 종료(역시 oos_vfid = VFID_NULL로 캐시됨)이고, ERROR는 엔진 에러 상태를 남기고 의도적으로 캐시하지 않아 다음 레코드가 재시도한다 — OOS 바이트는 디스크에 소규모 기록된 누수로 남으며, 전체 vacuum 블록을 실패시키지 않는다. 10장에서 vacuum_oos_vfid_lookup이 각 분기를 메모에 어떻게 매핑하는지 추적한다.
stateDiagram-v2 [*] --> Unresolved Unresolved --> Cached_real : FOUND — oos_vfid 캐시 Unresolved --> Cached_null : NONE — VFID_NULL 캐시 Unresolved --> Unresolved : ERROR — 캐시 안 함\n다음 레코드 재시도 Cached_real --> Cached_real : 같은 heap_vfid — 메모 재사용 Cached_null --> Cached_null : 같은 heap_vfid — 메모 재사용
Figure 1-3. 메모 상태 머신: FOUND와 NONE만 캐시 상태로 전이하며, ERROR는 메모를 미해결로 두어 다음 레코드가 재시도할 수 있게 한다.
1.6 챕터 요약 — 핵심 정리
섹션 제목: “1.6 챕터 요약 — 핵심 정리”- 외부화는 두 범위에서 표시된다.
OR_MVCC_FLAG_HAS_OOS (0x08)는 행 단위 fast-path 게이트이고,OR_VAR_BIT_OOS (0x1)는 어느 컬럼이 out-of-row인지를 지정하는 VOT 엔트리 단위 비트다. 행 플래그는 적어도 하나의 엔트리에 VOT 비트가 있음을 보장한다. - Stub은 16바이트: OID(8) + BIGINT length(8),
OR_OOS_INLINE_SIZE로 크기가 정해진다. 앞쪽 OID는 디스크에서pageid(4) | slotid(2) | volid(2)순서로 직렬화되며(OR_OID_PAGEID/SLOTID/VOLID), struct 선언 순서가 아니다. 인라인 길이 덕분에 독자는 OOS 파일을 건드리기 전에 버퍼 크기를 잡을 수 있다.oos_read는 정확히dest.size()바이트만 쓰고 길이를 스스로 발견하지 않기 때문이다. oos_record_header가 chunk를 체인으로 연결한다.next_chunk_oid가 각 chunk를 다음과 연결하고 마지막에서 NULL이 된다;total_data_length와chunk_index는 heap 없이도 복구가 체인을 검증할 수 있게 한다.- 하나의 OOS OID는 정확히 하나의 heap 레코드가 소유한다 — 공유도, 참조 카운팅도 없다. 모든 회수 경로는 소유자 삭제 시 chunk를 무조건 제거한다; 두 번째 참조자가 있다면 dangling이 된다.
oos_hdr_stats.estimates는 힌트 캐시이지 권위가 아니다.best[]/second_best[]는oos_bestspace.freespace기준으로 정렬된 순환 후보 링이며, 배치는 쓰기 전에 실제 페이지를 재검증한다. 오래된 추정치는 fix 낭비를 일으킬 뿐 손상을 야기하지 않는다.- 통계는 서버/클라이언트로 분리된다.
oos_stats_info는VFID를 가지고,db_oos_stats는 이를 int 두 개로 분해하여 클라이언트 헤더가 C-linkable이자 서버 타입 무관하게 유지된다. - 임시 구조체는 범위를 좁게 유지한다.
HEAP_OOS_EXPAND_STATE는heap_record_replace_oos_oids에서 스택 로컬로 선언된 스크래치이고,VACUUM_OOS_VFID_MEMO는 워커별·블록별 단일 슬롯 캐시(스레드 경쟁 회피를 위해 절대 static이 아님)이며, 해당VACUUM_OOS_VFID_LOOKUP_RESULT삼원 상태는FOUND/NONE만 캐시하고ERROR는 캐시하지 않는다.
Chapter 2: 초기화와 메모리
섹션 제목: “Chapter 2: 초기화와 메모리”이 챕터가 답하는 독자 질문: 컬럼이 외부화되기 전에, 클래스별 OOS 파일과 프로세스 전역 bestspace 캐시는 언제, 어떻게 만들어지는가? 상위 문서(cubrid-oos.md)의 “Per-class file pairing”과 “Bestspace” 절이 설계 의도를 설명한다면, 이 챕터는 초기화 경로를 분기별로 따라간다. 초기화는 두 단계로 나뉘며 서로 독립적이다:
- 프로세스 수준 (eager, 서버당 1회): 전역 인메모리 bestspace 캐시
oos_Bestspace. 힙 매니저 부팅 시 구성된다. 순수한 RAM 가속 구조이며, 재시작을 거치지 않고 로그에도 기록되지 않는다. - 파일 수준 (lazy, 클래스별 최초 외부화 시 1회): 클래스별
FILE_OOS파일. 실제로 컬럼을 out-of-line으로 저장해야 할 때 처음 생성된다. OOS 파일을 한 번도 생성하지 않은 클래스에 OOS 파일이 없는 것은 정상 초기 상태이지 오류가 아니다.
이 챕터에서 다루는 두 구조체의 필드별 해부는 Chapter 1에서 이미 이루어졌다. 아래 표는 부트스트랩 경로에서 실질적으로 의미를 갖는 필드만 추린 것이다.
| 필드 | 역할 | 여기서 중요한 이유 |
|---|---|---|
oos_stats_bestspace_cache.vpid_ht | page(best.vpid)를 키로 하는 엔트리 해시 | 삽입 시점의 “이 페이지에 여유 공간이 있는가” 조회에 사용; finalize 시 이 테이블에만 free 콜백을 매핑 |
oos_stats_bestspace_cache.vfid_ht | 동일한 엔트리를 파일(vfid)을 키로 색인 | 드롭 시점의 “이 파일의 모든 페이지를 캐시에서 제거” 일괄 축출; double-free를 방지하기 위해 per-entry 콜백 없이 파괴 |
oos_stats_bestspace_cache.free_list / free_list_count | 분리된 OOS_STATS_ENTRY의 재활용 풀 | oos_stats_entry_free가 free 대신 재활용할 수 있게 해줌; finalize는 두 번째 패스에서 이 리스트를 드레인 |
oos_stats_bestspace_cache.bestspace_mutex | 위 네 필드 전체를 보호 | 분기 B에서 초기화, finalize의 마지막에 파괴 |
heap_hdr_stats.oos_vfid | 클래스의 OOS 파일 VFID를 영속적으로 저장 | 클래스의 OOS 파일이 어디에 기록/조회되는가의 정답; VFID_ISNULL이면 “아직 파일 없음” |
oos_hdr_stats.estimates.best[10] / second_best[10] | 파일 내 bestspace 힌트 | oos_create_file이 20개 슬롯 전체를 NULL-set하여 최초 sync scan이 비어 있는 것으로 읽도록 함 |
2.1 프로세스 수준 부트스트랩 — oos_bestspace_initialize
섹션 제목: “2.1 프로세스 수준 부트스트랩 — oos_bestspace_initialize”캐시는 정적으로 할당된 단일 구조체 oos_Bestspace_cache_area에 살고, 파일-정적 포인터 oos_Bestspace로 접근한다. heap_manager_initialize(heap_file.c)가 힙 자체의 bestspace 캐시를 올린 직후 oos_bestspace_initialize를 호출하며, finalize는 대칭적으로 heap_manager_finalize에서 수행된다:
// heap_manager_initialize -- src/storage/heap_file.c ret = heap_stats_bestspace_initialize (); /* heap's own cache first */ if (ret != NO_ERROR) { return ret; } ret = oos_bestspace_initialize (); /* <- OOS cache piggybacks on heap boot */ if (ret != NO_ERROR) { return ret; }OOS 캐시는 힙 매니저와 정확히 같은 수명을 공유한다 — 별도의 OOS 서브시스템 부팅은 없다. finalize 순서는 역방향이다: heap_manager_finalize는 힙 자체의 캐시(heap_stats_bestspace_finalize)보다 OOS 캐시(oos_bestspace_finalize)를 먼저 해제하며, 이는 표준 LIFO 해체 방식을 따른다. oos_bestspace_initialize 자체는 네 가지 분기를 갖는다:
// oos_bestspace_initialize -- src/storage/oos_file.cpp if (oos_Bestspace != NULL) /* branch A: double-init guard */ { assert (false); /* <- a bug if it fires in a debug build */ (void) oos_bestspace_finalize (); /* recover in release: tear down old, re-init */ } oos_Bestspace = &oos_Bestspace_cache_area; /* point at the static area */ if (pthread_mutex_init (&oos_Bestspace->bestspace_mutex, NULL) != 0) /* branch B */ { er_set_with_oserror (...); return ER_CSS_PTHREAD_MUTEX_INIT; } oos_Bestspace->num_stats_entries = 0; oos_Bestspace->free_list_count = 0; oos_Bestspace->free_list = NULL; oos_Bestspace->vpid_ht = mht_create ("OOS best-space vpid hash table", OOS_BESTSPACE_CACHE_CAPACITY, /* 1000 */ oos_hash_vpid, oos_compare_vpid); if (oos_Bestspace->vpid_ht == NULL) /* branch C: OOM on first table */ { er_set (... ER_OUT_OF_VIRTUAL_MEMORY ...); return ER_OUT_OF_VIRTUAL_MEMORY; } oos_Bestspace->vfid_ht = mht_create ("OOS best-space vfid hash table", OOS_BESTSPACE_CACHE_CAPACITY, oos_hash_vfid, oos_compare_vfid); if (oos_Bestspace->vfid_ht == NULL) /* branch D: OOM on second table */ { mht_destroy (oos_Bestspace->vpid_ht); /* <- roll back the first table */ oos_Bestspace->vpid_ht = NULL; er_set (...); return ER_OUT_OF_VIRTUAL_MEMORY; } return NO_ERROR;불변 조건 — 두 해시 테이블은 모두 성공하거나 모두 존재하지 않아야 한다. 정상 반환 이후 vpid_ht와 vfid_ht는 둘 다 non-NULL이고 OOS_BESTSPACE_CACHE_CAPACITY(1000) 크기로 생성된다. 분기 D는 OOM 경로에서 이 불변 조건을 강제한다: 두 번째 mht_create가 실패하면 첫 번째 테이블을 파괴하고 포인터를 NULL로 설정하여, 호출자가 한 테이블만 살아 있는 반쪽짜리 캐시를 보는 일이 없도록 한다. 이 불변 조건이 깨지면 이후의 mht_put/mht_get이 NULL을 역참조하게 된다. 분기 B는 두 포인터를 모두 NULL 상태로 남기는데(아직 건드리지 않았으므로), 이 역시 일관된 — 비어 있지만 — 상태다.
같은 엔트리에 테이블이 두 개인 이유는 무엇인가? 각 OOS_STATS_ENTRY는 동시에 두 가지 방식으로 색인된다: 삽입 시점의 “이 페이지에 여유 공간이 있는가” 조회를 위해 page(vpid_ht, best.vpid 키)로, 드롭 시점의 “이 파일에 속한 모든 페이지를 제거” 일괄 축출을 위해 file(vfid_ht, vfid 키)로. 엔트리는 두 테이블이 공유하는 단일 할당 메모리다; 축출 시에는 양쪽에서 모두 제거해야 한다(Chapter 5에서 oos_stats_del_bestspace_by_vfid를 다룬다).
flowchart TD
A["oos_bestspace_initialize"] --> B{"oos_Bestspace != NULL?"}
B -- "예 (재진입 버그)" --> C["assert false; 기존 finalize"]
B -- "아니오" --> D["정적 캐시 영역 포인터 설정"]
C --> D
D --> E{"pthread_mutex_init 성공?"}
E -- "아니오" --> F["ER_CSS_PTHREAD_MUTEX_INIT 반환"]
E -- "예" --> G["카운터 초기화; mht_create vpid_ht"]
G --> H{"vpid_ht != NULL?"}
H -- "아니오" --> I["ER_OUT_OF_VIRTUAL_MEMORY 반환"]
H -- "예" --> J["mht_create vfid_ht"]
J --> K{"vfid_ht != NULL?"}
K -- "아니오" --> L["mht_destroy vpid_ht; OOM 반환"]
K -- "예" --> M["NO_ERROR 반환"]
Figure 2-1: oos_bestspace_initialize 분기 흐름.
해시/비교 콜백은 단순하지만 어디서나 쓰이는 키 동일성을 정의하므로 짚어 둘 필요가 있다. VPID 해시는 1바이트 volid를 4바이트 pageid의 상위 바이트로 접어서 모듈로를 취하고, VFID 해시는 fileid에 동일한 방식을 쓴다. 동등성 비교는 표준 VPID_EQ / VFID_EQ 매크로에 위임된다:
// oos_hash_vpid -- src/storage/oos_file.cpp return ((vpid->pageid | ((unsigned int) vpid->volid) << 24) % htsize);// oos_compare_vpid -- src/storage/oos_file.cpp return VPID_EQ (vpid1, vpid2); /* <- mht "compare" returns nonzero on EQUAL */mht 관례에 주의: oos_compare_*는 C strcmp 방식의 순서가 아니라 동등성 결과를 반환한다 — VPID_EQ는 키가 일치할 때 true(nonzero)다. 이를 반대로 구현하면 모든 조회가 실패한다.
2.2 프로세스 수준 해체 — oos_bestspace_finalize와 oos_stats_entry_free
섹션 제목: “2.2 프로세스 수준 해체 — oos_bestspace_finalize와 oos_stats_entry_free”oos_bestspace_finalize는 initialize를 역순으로 수행하며 멱등성을 갖는다:
// oos_bestspace_finalize -- src/storage/oos_file.cpp if (oos_Bestspace == NULL) { return NO_ERROR; } /* branch A: never inited / already gone */ if (oos_Bestspace->vpid_ht != NULL) { (void) mht_map_no_key (NULL, oos_Bestspace->vpid_ht, oos_stats_entry_free, NULL); /* <- recycle every live entry */ mht_destroy (oos_Bestspace->vpid_ht); oos_Bestspace->vpid_ht = NULL; } if (oos_Bestspace->vfid_ht != NULL) { mht_destroy (oos_Bestspace->vfid_ht); /* NO map_no_key here — see note */ oos_Bestspace->vfid_ht = NULL; } OOS_STATS_ENTRY *ent = oos_Bestspace->free_list; /* drain the recycle pool */ while (ent != NULL) { OOS_STATS_ENTRY *next = ent->next; free_and_init (ent); ent = next; } oos_Bestspace->free_list = NULL; oos_Bestspace->free_list_count = 0; oos_Bestspace->num_stats_entries = 0; pthread_mutex_destroy (&oos_Bestspace->bestspace_mutex); oos_Bestspace = NULL;불변 조건 — 모든 살아 있는 엔트리는 두 테이블에 존재하더라도 정확히 한 번만 해제된다. 워크는 oos_stats_entry_free를 vpid_ht에만 매핑(mht_map_no_key)한다. vfid_ht는 per-entry 콜백 없이 파괴되는데, 두 테이블이 동일한 OOS_STATS_ENTRY 객체를 가리키기 때문이다. 양쪽 테이블에 free 콜백을 적용하면 double-free가 발생한다. vpid_ht에만 map-then-destroy, vfid_ht는 plain destroy — 이것이 해제 횟수를 하나로 유지하는 방식이다.
oos_stats_entry_free에는 여기서 중요한 미묘한 점이 있다. 이 함수는 free-list 재활용자로서 일반적으로 메모리를 해제하지 않는다 — 엔트리를 free_list에 넣어 재사용한다:
// oos_stats_entry_free -- src/storage/oos_file.cpp if (ent != NULL) { if (oos_Bestspace->free_list_count < OOS_BESTSPACE_CACHE_CAPACITY) /* room to recycle */ { ent->next = oos_Bestspace->free_list; oos_Bestspace->free_list = ent; oos_Bestspace->free_list_count++; } else { free_and_init (ent); } /* pool full: actually release */ } return NO_ERROR;따라서 finalize 과정에서 mht_map_no_key 패스는 살아 있는 엔트리를 free_list로 옮기거나(풀이 1000개를 넘으면 직접 free), 이어지는 명시적 while 루프가 재활용자가 free_list에 쌓아 둔 것들을 free한다. 결과적으로 두 패스에 걸쳐 모든 메모리가 해제된다. oos_stats_entry_free가 oos_Bestspace를 직접 읽는 이유가 이 이중 역할에 있다 — 일반 운영 중의 per-entry 축출 콜백(Chapters 5, 9, 10)이자 finalize 스위퍼로도 동작한다.
2.3 파일 수준 페어링 — heap_oos_find_vfid
섹션 제목: “2.3 파일 수준 페어링 — heap_oos_find_vfid”클래스의 OOS 파일은 컬럼이 처음으로 필요로 할 때까지 존재하지 않는다. 페어링 지점은 heap_oos_find_vfid(heap_file.c)로, 힙의 HFID를 OOS VFID로 resolve하고 필요하면 파일을 생성한다. 이 함수는 의도적으로 heap_ovf_find_vfid(기존 overflow 파일 페어링)를 거울처럼 모방한다. oos_vfid는 힙 헤더 레코드 HEAP_HDR_STATS.oos_vfid에 영속 저장된다 — 이 필드가 “클래스의 OOS 파일이 어디에 기록되어 있는가”의 정답이다. HEAP_HDR_STATS 구조체에서 이 필드는 next_vpid 바로 다음에 위치한다(필드 순서: class_oid, ovf_vfid, next_vpid, oos_vfid, unfill_space). 헤더를 재배열하지 않고 기존 ovf_vfid/next_vpid 쌍 옆에 새로운 OOS 포인터를 추가한 것이다.
함수 주석에서 명시하는 헤더 계약은 가장 먼저 내재화해야 할 가장 중요한 사항이다:
false반환은 항상 실제 오류(er_errid()설정됨)를 의미하며, “OOS 파일 없음”을 뜻하지 않는다.docreate == false로 호출하는 경우 OOS 파일이 존재하는지 확인하려면VFID_ISNULL(oos_vfid)를 검사해야 한다.
함수는 힙 헤더 페이지를 fix하고 헤더 레코드를 peek한 다음, 파일이 이미 존재하는지 여부에 따라 분기한다:
// heap_oos_find_vfid -- src/storage/heap_file.c success = true; VFID_SET_NULL (oos_vfid); /* <- default: "no file" */ mode = (docreate == true ? PGBUF_LATCH_WRITE : PGBUF_LATCH_READ); /* write latch only if we may stamp */ addr_hdr.pgptr = pgbuf_fix (thread_p, &vpid, OLD_PAGE, mode, PGBUF_UNCONDITIONAL_LATCH); if (addr_hdr.pgptr == NULL) { goto exit_on_error; } if (spage_get_record (thread_p, addr_hdr.pgptr, HEAP_HEADER_AND_CHAIN_SLOTID, &hdr_recdes, PEEK) != S_SUCCESS) { goto exit_on_error; } heap_hdr = (HEAP_HDR_STATS *) hdr_recdes.data; if (VFID_ISNULL (&heap_hdr->oos_vfid)) /* no file recorded yet */ { if (docreate == true) { /* ... create + stamp, see below ... */ } else { goto end; } /* <- branch: leave oos_vfid NULL, NOT an error */ } else { VFID_COPY (oos_vfid, &heap_hdr->oos_vfid); /* file exists: just hand it back */ } goto end;latch 모드는 미리 결정된다: 읽기 전용 resolve(docreate == false)는 PGBUF_LATCH_READ를 취하고, 생성 resolve는 헤더 레코드를 수정해야 하므로 PGBUF_LATCH_WRITE를 취한다. 생성 경로는 top system operation으로 래핑되어 파일 생성, TDE 적용, 헤더 스탬프가 하나의 단위로 커밋 또는 어보트된다:
// heap_oos_find_vfid -- src/storage/heap_file.c (docreate == true, VFID was NULL) TDE_ALGORITHM tde_algo = TDE_ALGORITHM_NONE; log_sysop_start (thread_p); /* TOP SYSTEM OPERATION */ if (oos_create_file (thread_p, *oos_vfid) != NO_ERROR) /* path A: create failed */ { log_sysop_abort (thread_p); goto exit_on_error; } if (heap_get_class_tde_algorithm (thread_p, &heap_hdr->class_oid, &tde_algo) != NO_ERROR) /* path B */ { log_sysop_abort (thread_p); goto exit_on_error; } if (file_apply_tde_algorithm (thread_p, oos_vfid, tde_algo) != NO_ERROR) /* path C */ { log_sysop_abort (thread_p); goto exit_on_error; } log_append_undo_data (thread_p, RVHF_STATS, &addr_hdr, sizeof (*heap_hdr), heap_hdr); /* undo: old hdr (NULL vfid) */ VFID_COPY (&heap_hdr->oos_vfid, oos_vfid); /* stamp the new vfid into the header */ log_append_redo_data (thread_p, RVHF_STATS, &addr_hdr, sizeof (*heap_hdr), heap_hdr); /* redo: new hdr */ pgbuf_set_dirty (thread_p, addr_hdr.pgptr, DONT_FREE); log_sysop_commit (thread_p);불변 조건 — 힙 헤더의 oos_vfid와 디스크 상의 OOS 파일은 원자적으로 일관성을 유지한다. undo 이미지는 스탬프 전의 헤더(VFID가 여전히 NULL)를, redo 이미지는 스탬프 후의 헤더를 캡처한다. 트랜잭션이 롤백되면 RVHF_STATS undo가 NULL VFID를 복원하고, 동일 sysop 내에서 생성된 파일도 sysop의 자체 file-creation undo로 되돌려진다. 크래시 복구는 “파일 없음, NULL 헤더” 또는 “파일 존재, 헤더가 그것을 가리킴” 중 하나만을 보며 — 생성되지 않은 파일을 가리키는 스탬프된 헤더나, 헤더가 참조하지 않는 고아 파일은 결코 볼 수 없다. TDE 알고리즘 적용도 sysop 내부(경로 C)에서 이루어지므로, 암호화된 클래스가 반만 암호화된 OOS 파일을 얻는 일은 없다.
flowchart TD
A["heap_oos_find_vfid"] --> B["VFID_SET_NULL(oos_vfid)"]
B --> C["pgbuf_fix 헤더 (READ 또는 WRITE)"]
C --> D{"fix + peek 성공?"}
D -- "아니오" --> E["exit_on_error: success=false"]
D -- "예" --> F{"heap_hdr.oos_vfid NULL?"}
F -- "아니오" --> G["기존 VFID -> oos_vfid 복사"]
F -- "예" --> H{"docreate?"}
H -- "false" --> I["end: oos_vfid NULL 유지, success=true"]
H -- "true" --> J["log_sysop_start"]
J --> K{"oos_create_file 성공?"}
K -- "아니오" --> L["sysop_abort; exit_on_error"]
K -- "예" --> M{"TDE 획득 + 적용 성공?"}
M -- "아니오" --> L
M -- "예" --> N["undo 이전 헤더; vfid 스탬프; redo 새 헤더; set_dirty"]
N --> O["log_sysop_commit"]
G --> P["end: 헤더 unfix; success 반환"]
I --> P
O --> P
E --> P
Figure 2-2: heap_oos_find_vfid — 오류가 아닌 NULL 경로를 포함한 모든 분기.
end: 레이블은 항상 fix된 헤더 페이지를 unfix하므로(if (addr_hdr.pgptr) pgbuf_unfix_and_init (...)), 성공과 exit_on_error 경로 모두 latch를 해제한다. 함수는 success(오류가 false로 설정하지 않는 한 true)를 반환한다.
2.4 파일 생성 — oos_create_file
섹션 제목: “2.4 파일 생성 — oos_create_file”oos_create_file은 물리적 아티팩트를 구성한다: numerable FILE_OOS 파일과, slot 0에 OOS_HDR_STATS bestspace 헤더를 영구적으로 보유하는 sticky 첫 번째 페이지를 만든다.
// oos_create_file -- src/storage/oos_file.cpp tablespace.initial_size = DB_PAGESIZE; /* one page to start */ tablespace.expand_ratio = (float) 0.01; tablespace.expand_min_size = DISK_SECTOR_NPAGES * DB_PAGESIZE; tablespace.expand_max_size = DISK_SECTOR_NPAGES * DB_PAGESIZE * 1024; err = file_create (thread_p, FILE_OOS, &tablespace, &des, false /* is_temp */, true /* is_numerable */, &oos_vfid); /* <- numerable */ if (err != NO_ERROR) { oos_error (...); assert_release_error (er_errid () != NO_ERROR); assert (false); return err; } /* branch A */파일은 numerable(is_numerable == true)이다: 페이지를 서수로 주소 지정할 수 있어 bestspace sync scan이 이를 활용한다(Chapter 5). 임시 파일이 아니므로 WAL로 보호되며 재시작 후에도 유지된다. 파일이 생성된 후 sticky 헤더 페이지 할당은 sysop으로 래핑되어 실패 시 깔끔하게 롤백된다:
// oos_create_file -- src/storage/oos_file.cpp PAGE_TYPE page_type = PAGE_OOS; log_sysop_start (thread_p); err = file_alloc_sticky_first_page (thread_p, &oos_vfid, oos_vpid_init_new, &page_type, &hdr_vpid, &hdr_page); if (err != NO_ERROR || hdr_page == NULL) /* branch B */ { oos_error (...); log_sysop_abort (thread_p); return (err != NO_ERROR) ? err : ER_FAILED; }헤더 페이지는 sticky다 — file_alloc_sticky_first_page는 이를 파일의 영구적인 첫 번째 페이지로 고정하므로 bestspace 헤더는 항상 고정된 위치에서 찾을 수 있고, vacuum에 의해 회수되지 않는다. 페이지는 공유 콜백 oos_vpid_init_new로 초기화된다(2.5). 다음으로 스택 버퍼에 헤더 레코드를 생성하고, zeroing 후 내장된 best-space 힌트 배열을 명시적으로 NULL-set한다:
// oos_create_file -- src/storage/oos_file.cpp OOS_HDR_STATS hdr_stats; memset (&hdr_stats, 0, sizeof (OOS_HDR_STATS)); hdr_stats.oos_vfid = oos_vfid; /* self-reference */ VPID_SET_NULL (&hdr_stats.estimates.full_search_vpid); for (int i = 0; i < OOS_NUM_BEST_SPACESTATS; i++) /* 10 slots */ { VPID_SET_NULL (&hdr_stats.estimates.best[i].vpid); VPID_SET_NULL (&hdr_stats.estimates.second_best[i]); } RECDES hdr_recdes; hdr_recdes.area_size = hdr_recdes.length = sizeof (OOS_HDR_STATS); hdr_recdes.type = REC_HOME; hdr_recdes.data = (char *) &hdr_stats; PGSLOTID slotid; int sp_status = spage_insert (thread_p, hdr_page, &hdr_recdes, &slotid); if (sp_status != SP_SUCCESS) /* branch C */ { oos_error (...); pgbuf_unfix_and_init (thread_p, hdr_page); log_sysop_abort (thread_p); er_set (... ER_GENERIC_ERROR ...); return ER_GENERIC_ERROR; } assert (slotid == 0); /* header MUST land at slot 0 */불변 조건 — 헤더 레코드는 sticky 첫 번째 페이지의 slot 0을 차지한다. 페이지는 방금 초기화되어 비어 있으므로 첫 번째 spage_insert는 결정론적으로 slotid == 0을 반환하며, 이를 명시적으로 assert한다. 모든 헤더 리더(예: oos_get_header_stats_ptr, Chapter 1)는 slot 0을 하드코딩하여 헤더를 가져온다. memset으로 0을 채운 후 명시적 VPID_SET_NULL 루프를 돌리는 이유는, NULL VPID가 모든 빌드에서 all-zero bytes가 아닐 수 있기 때문이다 — 이 루프로 10개의 best[]와 10개의 second_best[] 힌트가 진짜 NULL VPID로 시작함을 보장하여, 최초 sync scan이 이를 비어 있는 것으로 읽도록 한다.
삽입 후 로그를 남기고 sysop을 커밋한다:
// oos_create_file -- src/storage/oos_file.cpp log_addr.vfid = &oos_vfid; log_addr.pgptr = hdr_page; log_addr.offset = slotid; log_append_undoredo_recdes (thread_p, RVOOS_INSERT, &log_addr, NULL, &hdr_recdes); /* undo NULL, redo the header */ pgbuf_set_dirty (thread_p, hdr_page, FREE); /* FREE: unfix as part of set_dirty */ log_sysop_commit (thread_p); return NO_ERROR;pgbuf_set_dirty(..., FREE)는 여기서 헤더 페이지를 unfix하는 반면, page-init 콜백은 DONT_FREE를 사용했다(2.5). 그 이유는 호출자가 페이지를 아직 필요로 했기 때문이다. 분기 B/분기 C의 오류 경로는 heap_oos_find_vfid(2.3)가 열어 둔 외부 top-sysop 내에서 sysop을 어보트하므로, 생성 실패는 중첩된 두 연산을 모두 되감는다.
2.5 페이지 초기화 — oos_vpid_init_new와 oos_file_alloc_new
섹션 제목: “2.5 페이지 초기화 — oos_vpid_init_new와 oos_file_alloc_new”oos_vpid_init_new는 file_alloc_sticky_first_page(헤더 페이지)와 file_alloc(데이터 페이지) 양쪽에 전달되는 per-page 초기화 함수다. PAGE_OOS 타입의 ANCHORED slotted page를 설정한다:
// oos_vpid_init_new -- src/storage/oos_file.cpp PAGE_TYPE ptype = * (PAGE_TYPE *) args; /* always PAGE_OOS in practice */ pgbuf_set_page_ptype (thread_p, page, ptype); spage_initialize (thread_p, page, ANCHORED, OOS_ALIGNMENT, false); /* ANCHORED = slot ids stay fixed */ log_append_undoredo_data2 (thread_p, RVPGBUF_NEW_PAGE, NULL, page, (PGLENGTH) ptype, 0, SPAGE_HEADER_SIZE, NULL, (SPAGE_HEADER *) page); /* ptype carried in redo */ pgbuf_set_dirty (thread_p, page, DONT_FREE); /* DONT_FREE: caller keeps the fix */ return err;두 가지 설계 선택이 핵심이다. ANCHORED slot 소유권은 한번 할당된 slot id가 절대 이동하지 않음을 의미한다 — 힙 레코드와 multi-chunk next_chunk_oid 링크(Chapter 6)에 내장된 OOS OID들이 (vpid, slotid)를 영구적으로 참조하기 때문에 필수적이다. compaction 시 슬롯 번호를 재지정하는 non-anchored 페이지는 저장된 모든 OID를 무효화한다. OOS_ALIGNMENT는 MAX_ALIGNMENT로, OOS 레코드 헤더가 가정하는 정렬과 일치한다. RVPGBUF_NEW_PAGE 로그 레코드는 ptype을 redo 길이로 저장하여, 복구 중 pgbuf_rv_new_page_redo가 페이지 타입을 복원할 수 있도록 한다.
oos_file_alloc_new는 데이터 페이지 할당자다(이후 챕터의 삽입 경로에서 사용되며, sticky-first-page 변형을 쓰는 oos_create_file에서는 사용되지 않음). file_alloc을 자체 sysop으로 래핑하고 auto-unfix 페이지 핸들을 반환한다:
// oos_file_alloc_new -- src/storage/oos_file.cpp (returns auto_unfix_page_ptr) PAGE_TYPE page_type = PAGE_OOS; log_sysop_start (thread_p); err = file_alloc (thread_p, &oos_vfid, oos_vpid_init_new, &page_type, &vpid_out, nullptr); if (err != NO_ERROR) /* branch: alloc failed */ { oos_error (...); assert_release_error (er_errid () != NO_ERROR); assert (false); log_sysop_abort (thread_p); return nullptr; } log_sysop_commit (thread_p); return pgbuf_fix_auto_unfix (thread_p, &vpid_out, OLD_PAGE, PGBUF_LATCH_WRITE, PGBUF_UNCONDITIONAL_LATCH);반환 타입은 auto_unfix_page_ptr(page_buffer_util.hpp의 std::unique_ptr<char, page_auto_unfix>)다: 실패 분기에서는 nullptr를 반환하고, 성공 시에는 방금 할당된 페이지를 write latch와 함께 다시 fix하여 스코프 종료 시 자동 unfix되는 RAII 핸들로 반환한다. 페이지 할당은 즉시 내구성을 갖는 독립적인 sysop이다 — 둘러싼 트랜잭션이 나중에 커밋하지 않더라도 페이지는 존재하는데, 이는 사용되지 않은 OOS 데이터 페이지는 vacuum이 회수할 수 있기 때문에 허용된다(Chapter 10).
graph TD CF["oos_create_file"] -->|"file_alloc_sticky_first_page"| INIT["oos_vpid_init_new"] AN["oos_file_alloc_new"] -->|"file_alloc"| INIT INIT --> P["PAGE_OOS, ANCHORED slotted page, RVPGBUF_NEW_PAGE로 로그"] CF --> SLOT0["spage_insert OOS_HDR_STATS at slot 0"]
Figure 2-3: 페이지를 초기화하는 주체와 헤더가 slot 0에 자리잡는 방식.
2.6 file_manager의 FILE_OOS 파일 타입 등록
섹션 제목: “2.6 file_manager의 FILE_OOS 파일 타입 등록”oos_create_file은 전용 파일 타입 FILE_OOS로 file_create를 호출하며, 이는 file_manager.h의 FILE_TYPE enum에서 정식 멤버다. 새로운 타입을 등록한다는 것은 이름을 붙이는 것 이상이다 — 특정 동작을 분기 처리하는 file-manager의 switch 구문들에 타입을 연결해야 한다. 그 중 정확성을 위해 중요한 switch가 둘 있고, 나머지는 의도적으로 미완성 상태인데, 이것이 이 챕터의 핵심 feat/oos 주의 사항이다.
FILE_OOS가 완전히 연결된 유일한 지점은 file_create_with_npages / file_perm_alloc의 예약 단계에 있는 durable-file VFID 유일성 보장으로, OOS를 다른 영구적이고 복구 가능한 타입들과 함께 분류한다:
// file_create internals -- src/storage/file_manager.c (~line 3448) if (file_type == FILE_BTREE || file_type == FILE_HEAP || file_type == FILE_HEAP_REUSE_SLOTS || file_type == FILE_OOS) { /* we need to consider dropped files in vacuum's list. If we create a file with a * duplicate VFID, we can run into problems. */ ... }OOS가 이 집합에 속하는 이유는 heap, btree 파일처럼 영구적(임시가 아님)이고, 재시작 후에도 유지되며, VFID가 vacuum의 dropped-file 목록에 있는 대기 항목과 충돌할 수 있기 때문이다. 사람이 읽을 수 있는 이름도 등록된다: file_type_to_string은 FILE_OOS에서 "OUT_OF_LINE_OVERFLOW_STORAGE"를 반환한다.
불변 조건 (개발 중) — OOS 파일은 file-manager의 class-OID / tracker-protection / spacedb 경로에 아직 연결되지 않았으며, 해당 분기들은 의도적인 assert(false) 스텁이다. FILE_HEAP/FILE_BTREE에 존재하는 여러 switch (... type ...) 분기가 FILE_OOS에 대해서는 assert(false); break;만 갖는다:
// file_header_dump_descriptor -- src/storage/file_manager.c case FILE_OOS: { assert (false); /* <- reached only if OOS enters this path; not yet expected */ break; }이런 분기가 다섯 개 존재한다. 네 개는 순수 assert(false) 스텁이다: (1) file_header_dump_descriptor의 descriptor 출력, 그리고 file_tracker_get_and_protect 내부의 세 곳 — (2) desired_type 허용 필터, (3) item->type lock-protection switch, (4) 클래스별 파일 이터레이션에 사용되는 class-OID 추출 switch. 다섯 번째는 spacedb 파일 타입 분류에 있으며 약간 다르다 — assert_release(false) 후 명시적 workaround가 있다:
// file_tracker_item_spacedb -- src/storage/file_manager.c case FILE_OOS: assert_release (false); // TODO: spacedb_ftype = SPACEDB_OOS_FILE; spacedb_ftype = SPACEDB_HEAP_FILE; /* workaround: no SPACEDB_OOS_FILE enum yet */ break;주석이 설명하는 임시 방편은 이렇다: 전용 SPACEDB_OOS_FILE 카테고리가 추가되지 않았고, spacedb_ftype을 미초기화 상태로 두면 CI 빌드가 깨지므로, OOS는 spacedb 출력에서 임시로 heap 파일로 집계된다. 독자에게 의미하는 바는 구체적이다: feat/oos에서 OOS 파일은 생성되고, 힙 헤더에 스탬프되며, OOS 레이어가 읽고 쓰지만, 파일을 클래스별로 이터레이션하는 범용 machinery에는 참여하지 않으며 spacedb에서는 잘못 보고된다 — 이는 2.7의 해체 스텁(oos_remove_page)과 Chapter 10의 불완전한 vacuum 통합과 일관된다. debug 빌드에서는 네 개의 assert(false) switch가 채워지기 전에 어떤 미래 호출자가 OOS 파일을 해당 경로로 라우팅하면 프로세스가 중단된다.
2.7 해체 스텁 — oos_remove_file과 oos_remove_page
섹션 제목: “2.7 해체 스텁 — oos_remove_file과 oos_remove_page”두 가지 해체 primitive가 존재한다. oos_remove_file은 이제 힙 drop 경로에 연결되어 있다 — xheap_destroy와 xheap_destroy_newly_created 모두 heap_oos_find_vfid (..., docreate == false)를 호출한 후 반환된 VFID가 non-NULL이면 oos_remove_file을 호출한다. 반면 페이지 수준 회수 훅(oos_remove_page)은 vacuum을 기다리며 아직 연결되지 않은 상태다.
oos_remove_file은 파일의 bestspace 엔트리를 캐시에서 축출하고 물리적 파일 파괴를 커밋 시점으로 지연한다:
// oos_remove_file -- src/storage/oos_file.cpp (void) oos_stats_del_bestspace_by_vfid (thread_p, &oos_vfid); /* drop cache entries via vfid_ht */ file_postpone_destroy (thread_p, &oos_vfid); /* <- deferred: runs at transaction commit */ return NO_ERROR;file_postpone_destroy는 파괴를 postpone 액션으로 등록하므로, 파일은 drop 트랜잭션이 커밋될 때만 실제로 제거된다 — 그 전까지는 롤백으로 파일을 유지할 수 있다. 캐시 축출은 2.1에서 설명한 vfid_ht의 활용이다: 한 번의 호출로 해당 파일의 모든 페이지를 한꺼번에 잊는다.
oos_remove_page는 페이지 수준 dealloc으로, 향후 vacuum 진입점으로 명시적으로 주석 처리되어 있으며 아직 호출자에 연결되지 않았다:
// oos_remove_page -- src/storage/oos_file.cpp // TODO: will be called by vacuum when OOS vacuum is implemented int err = file_dealloc (thread_p, &oos_vfid, &vpid, FILE_OOS); if (err != NO_ERROR) { oos_error (...); return err; } return NO_ERROR;Chapter 10(Vacuum Reclamation)에서 이것이 여전히 스텁인 이유를 다시 다룬다: OOS 페이지 회수는 feat/oos에서 불완전한 vacuum 통합에 달려 있으며, 이는 2.6의 file_manager 분기들이 assert(false) 상태인 것과 같은 미완성이다.
2.8 챕터 요약 — 핵심 정리
섹션 제목: “2.8 챕터 요약 — 핵심 정리”- 두 가지 독립적인 부트스트랩. 프로세스 전역 bestspace 캐시(
oos_Bestspace)는 힙 매니저 부팅 시 eager하게 구성되고, 클래스별FILE_OOS파일은 최초 컬럼 외부화 시 lazy하게 구성된다. 둘은 서로 의존하지 않으며, finalize는 LIFO 순서로 해체한다(OOS가 힙 보다 먼저). - 캐시의 두 테이블은 all-or-nothing 쌍이다. VPID(페이지 조회)와 VFID(파일 전체 축출)를 키로 공유된 엔트리들을 색인한다. 그렇기 때문에 finalize는 free 콜백을
vpid_ht에만 매핑하고vfid_ht는 plain-destroy하여 double-free를 방지한다. oos_stats_entry_free는 일반 운영 중에 deallocator가 아닌 recycler다. 엔트리를 1000-slotfree_list에 보관하며, finalize는 두 번째 명시적 패스에서 그 리스트를 드레인하여 모든 메모리를 해제한다.- 클래스의 OOS 파일은
HEAP_HDR_STATS.oos_vfid에서 조회하며, OOS 파일이 없는 것은 정상 초기 상태이지 오류가 아니다.heap_oos_find_vfid는 실제 오류일 때만false를 반환한다;docreate == false로 호출하는 경우 “파일 없음”을 감지하려면VFID_ISNULL을 검사해야 한다. - 파일 생성과 헤더 스탬프는 원자적이다. 생성 경로는 top system operation 내에서 실행되며, VFID 스탬프 주변에
RVHF_STATSundo/redo가 있고, TDE도 동일 sysop 내에서 적용된다. 복구는 고아 파일이나 dangling 헤더 참조를 결코 보지 않는다. - bestspace 헤더는 numerable하고 임시가 아닌
FILE_OOS파일의 sticky 첫 번째 페이지 slot 0에 영구적으로 고정된다.slotid == 0assert가 모든 헤더 리더의 의존 기반이다. FILE_OOS는 등록된 파일 타입이지만 부분적으로만 통합되어 있다. durable-file VFID 유일성 보장과 타입명 테이블에는 포함되었으나, file-manager의 다섯 가지 switch 분기 — descriptor 출력, trackerdesired_type필터, tracker lock 보호, class-OID 추출(모두assert(false)),spacedb분류(assert_release(false)+ “heap으로 집계” workaround) — 는 미완성 스텁으로,oos_remove_page의 연결되지 않은 vacuum 훅 및 Chapter 10의 불완전한feat/oosvacuum 경로와 일관된다.
Chapter 3: 강등 결정과 인라인 스텁
섹션 제목: “Chapter 3: 강등 결정과 인라인 스텁”힙 레이어에 도착한 행은 HEAP_CACHE_ATTRINFO 형태를 띤다 — 어느 컬럼이 어디에 물리적으로 저장될지 아직 결정되지 않은, 메모리상의 DB_VALUE 묶음이다. 이 행을 RECDES로 직렬화해 페이지의 슬롯에 삽입하기 전에, 엔진은 반드시 한 가지 질문에 답해야 한다: 전체가 페이지에 들어맞는가, 그렇지 않다면 어느 컬럼을 OOS 파일로 내보낼 것인가? 그 결정은 정확히 한 곳 — heap_attrinfo_determine_disk_layout — 에서 이루어진다. 이 챕터는 그 함수를 분기별로 추적한다. “왜 외부화가 필요한가”에 관한 배경(페이지 크기 상한, 기존 TEXT/CLOB overflow와의 차이, PG-TOAST 유사성)은 동반 문서 cubrid-oos.md의 CUBRID’s Approach 절과 Trigger heuristic differs from the design intent 크로스체크 노트를 참조하라. 그 이론은 여기서 반복하지 않는다.
이 함수의 계약은 좁다: *순수한 크기 계산 패스(pure sizing pass)*다. 어떤 페이지도 변경하지 않고, OID를 할당하지 않으며, 바이트를 기록하지 않는다. 예측된 디스크 크기를 반환하고, 두 개의 출력 매개변수(oos_columns와 has_oos)에 강등 판정을 담아 나중에 직렬화기(Ch 4)가 따르도록 한다.
3.1 세 가지 크기 구성 요소
섹션 제목: “3.1 세 가지 크기 구성 요소”함수는 행을 세 개의 정수로 축약하는 것으로 시작한다.
// heap_attrinfo_determine_disk_layout -- src/storage/heap_file.c std::vector<int> column_size (attr_info->num_values); int payload_size, header_size; int mvcc_extra;
*has_oos = false;
/* calcuate the entire size of columns */ payload_size = heap_attrinfo_get_record_payload_size (attr_info, &column_size); header_size = heap_attrinfo_get_record_header_size (attr_info, payload_size, is_mvcc_class, offset_size_ptr); mvcc_extra = is_mvcc_class ? OR_MVCC_MAX_HEADER_SIZE - OR_MVCC_INSERT_HEADER_SIZE : 0;각 항목은 전용 헬퍼로 계산되며, column_size의 부수효과(side effect)가 이후 후보 루프를 가능하게 한다 — 컬럼별 바이트 원장(ledger) 역할을 한다.
payload_size 는 heap_attrinfo_get_record_payload_size에서 나온다. 이 함수는 모든 값을 순회하면서 column_size[i]에 해당 컬럼의 디스크 바이트 수를 채우며, 컬럼이 고정 길이인지 여부에 따라 분기한다:
// heap_attrinfo_get_record_payload_size -- src/storage/heap_file.c if (value->last_attrepr->is_fixed != 0) { (*column_size)[i] = tp_domain_disk_size (value->last_attrepr->domain); /* <- fixed: domain dictates width */ size += (*column_size)[i]; } else { (*column_size)[i] = pr_data_writeval_disk_size (&value->dbvalue); /* <- variable: actual value width */ size += (*column_size)[i]; }이 is_fixed 분기 자체가 적격성을 결정하지는 않는다 — 두 분기 모두 size에 값을 더하고 column_size[i]를 채운다. 이 코드가 하는 일은 나중에 CHAR를 제외할 플래그를 기록하는 것이다: 고정 길이 CHAR(n)은 tp_domain_disk_size로 도메인 기반 폭을 보고하는 반면, VARCHAR/STRING/BLOB 계열 컬럼은 실제 직렬화된 값의 폭을 보고한다. 동일한 is_fixed 플래그가 후보 루프(§3.3)의 게이트 역할을 하므로, 고정 컬럼은 도메인 크기가 임계값을 아무리 초과해도 절대 강등될 수 없다. 제외는 거기서 발생한다, 여기가 아니라; 이 헬퍼는 크기만 잰다.
header_size 는 heap_attrinfo_get_record_header_size에서 나온다. 기반은 MVCC 클래스의 경우 OR_MVCC_INSERT_HEADER_SIZE (16), 그렇지 않은 경우 OR_NON_MVCC_HEADER_SIZE (8)이며, 여기에 variable-offset table (VOT)과 bound-bit 바이트가 추가된다. 중요한 것은, 이 헬퍼가 레코드가 커질수록 offset 폭을 상향 조정한다는 점이다 — OR_BYTE_SIZE (오프셋당 1바이트)에서 시작해, header_size + payload_size가 OR_MAX_BYTE (127) 또는 OR_MAX_SHORT (32767)를 초과하면 OR_SHORT_SIZE (2), 이어서 OR_INT_SIZE (4)바이트로 올라간다:
// heap_attrinfo_get_record_header_size -- src/storage/heap_file.c *offset_size_ptr = OR_BYTE_SIZE; header_size = is_mvcc_class ? OR_MVCC_INSERT_HEADER_SIZE : OR_NON_MVCC_HEADER_SIZE; header_size += OR_VAR_TABLE_SIZE_INTERNAL (attr_info->last_classrepr->n_variable, *offset_size_ptr); header_size += OR_BOUND_BIT_BYTES (attr_info->last_classrepr->n_attributes - attr_info->last_classrepr->n_variable);
if (*offset_size_ptr == OR_BYTE_SIZE && header_size + payload_size > OR_MAX_BYTE) { /* ... rebuild VOT at OR_SHORT_SIZE ... */ } if (*offset_size_ptr == OR_SHORT_SIZE && header_size + payload_size > OR_MAX_SHORT) { /* ... rebuild VOT at OR_INT_SIZE ... */ }바로 이 때문에 강등 이후(§3.5) 헤더를 재계산해야 한다: payload_size가 줄어들면 offset 폭이 다시 내려갈 수 있고, 이는 다시 헤더를 줄인다. 두 값은 서로 피드백 관계에 있다.
mvcc_extra 는 헤드룸 예약이다. MVCC 행은 처음에는 OR_MVCC_INSERT_HEADER_SIZE (16: repid/chn + INSID)만으로 기록되지만, 업데이트 시 DELID와 이전 버전 LSA가 찍히면 인플레이스(in-place)로 OR_MVCC_MAX_HEADER_SIZE (32)까지 커질 수 있다. 그 차이인 32 - 16 = 16바이트를 지금 예약함으로써, 나중에 인플레이스 헤더 확장이 페이지를 넘치지 않도록 보장한다. 비-MVCC 클래스는 나중에 커지는 일이 없으므로 mvcc_extra = 0이다.
Invariant — 크기 계산은 행의 현재 크기가 아니라 최대 크기를 고려해야 한다. 임계값 테스트는
header_size + payload_size만이 아닌header_size + payload_size + mvcc_extra를 사용한다.mvcc_extra를 빠뜨리면, 정확히DB_PAGESIZE/4에 맞춰 허용된 행이 나중에 MVCC 업데이트로 헤더가 16바이트 늘어날 때 슬롯을 넘칠 수 있다. 이 예약이 나중의 인플레이스 DELID 스탬핑(Ch 9)을 재배치 없는 연산으로 만드는 핵심이다.
3.2 임계값 테스트 — DB_PAGESIZE/4
섹션 제목: “3.2 임계값 테스트 — DB_PAGESIZE/4”세 구성 요소가 갖춰지면, 전체 강등 기계는 하나의 비교에 의해 게이트된다:
// heap_attrinfo_determine_disk_layout -- src/storage/heap_file.c /* TODO: change the statistics */ /* push the largest variable column to OOS one by one until the heap record * fits within DB_PAGESIZE/4 (PG TOAST style), instead of pushing every eligible column */ if (header_size + payload_size + mvcc_extra > DB_PAGESIZE / 4) { /* ... build candidates, sort, externalize-largest-first ... */ } return header_size + payload_size;행이 이미 쿼터 페이지(quarter page) 이내에 들어맞으면, if 본문 전체가 건너뛰어진다: *has_oos는 false로 유지되고, *oos_columns는 그대로(모두 false)이며, 함수는 header_size + payload_size를 반환한다. 이 조기 스킵이 일반적인 경우다; 대부분의 행은 OOS 경로에 진입하지 않는다.
반환값은 의도적으로 mvcc_extra를 제외한다. 그 항목은 오직 허용 결정을 보수적으로 만들기 위해 존재할 뿐이다; 호출자가 레코드 디스크립터 길이로 저장하는 값은 실제 직렬화된 인라인 크기다. 예약된 16바이트는 MVCC 헤더 기계에 의해 다른 곳에서 처리되며, 반환된 크기에 이중 계산되지 않는다.
Condition — 실제 트리거 조건은
header_size + payload_size + mvcc_extra > DB_PAGESIZE / 4다. 이 리비전의heap_attrinfo_determine_disk_layout에서 확인됨. 이것은 OOS 설계 에픽에 명시된 규칙이 아니다.cubrid-oos.md의 Trigger heuristic differs from the design intent 노트에 따르면, 에픽은 행 크기 >DB_PAGESIZE/8(16 KB 페이지에서 2 KB) AND 컬럼 크기 > 512 B를 명시한다. 실제 코드는 평탄한DB_PAGESIZE/4행 테스트와OR_OOS_INLINE_SIZE(16 B)의 컬럼별 적격성 하한을 사용하며, 512 B가 아니다. 두 조건은 동등하지 않다: 16 KB 페이지에서 에픽의 행 규칙은 ~2048바이트에서 발동하고 코드는 4096바이트에서 발동하므로, 구현이 에픽보다 덜 공격적으로 외부화하며 더 큰 행을 인라인으로 유지한다. 테스트 바로 위의/* TODO: change the statistics */주석이 임계값(과 이를 결정해야 할 비용 모델)이 잠정적임을 시사하는 현재진행형 인정이다. 코드에서 단일 진실의 원천은DB_PAGESIZE / 4이고, 에픽의/8 + 512B공식은 구식으로 취급하라.
3.3 후보 집합 구성
섹션 제목: “3.3 후보 집합 구성”행이 예산을 초과하면, 함수는 외부화할 가치가 있는 컬럼들을 {size, attr-index} 쌍의 벡터로 수집한다:
// heap_attrinfo_determine_disk_layout -- src/storage/heap_file.c std::vector<std::pair<int, int>> oos_candidates; /* {column_size, attr index} */
for (i = 0; i < attr_info->num_values; i++) { /* a variable column is OOS-eligible only if externalizing it shrinks the inline record: * its value must be larger than the OOS stub (OID + length) it is replaced with */ if (!attr_info->values[i].last_attrepr->is_fixed && column_size[i] > OR_OOS_INLINE_SIZE) { oos_candidates.emplace_back (column_size[i], i); } }두 조건, 모두 필수(&&):
!is_fixed— 컬럼은 가변 길이여야 한다. 이것이 CHAR를 배제하는 유일하고 결정적인 지점이다(§3.1은 플래그만 기록했을 뿐이고, 적격성 결정은 여기서 내려진다). 고정 길이CHAR(n),INT,DATE등은 절대 강등될 수 없다; OOS는 순수하게 가변 길이 페이로드 메커니즘이다. 도메인 폭이 임계값을 크게 초과하는CHAR(8000)조차 부적격이다 — 그 바이트는 인라인으로 남고, 행은 §3.1의 offset 폭 상향 조정 쪽으로 밀릴 뿐이다.column_size[i] > OR_OOS_INLINE_SIZE— 값이 그것을 대체할 16바이트 스텁보다 엄격히 커야 한다. 16바이트 이하의 값을 강등하면 인라인 레코드가 오히려 커진다 (column_size[i]바이트를 제거하지만 16바이트를 다시 추가하므로), 그래서 그런 컬럼은 걸러진다. 이 조건이 모든 강등이 순 전진(net forward progress)을 보장한다.
두 테스트 중 하나라도 실패하는 컬럼은 절대 추가되지 않으므로, §3.4의 루프는 레코드를 줄이기만 한다.
3.4 가장 큰 것 먼저 외부화
섹션 제목: “3.4 가장 큰 것 먼저 외부화”후보들은 크기 내림차순으로 정렬된 다음, 레코드가 들어맞을 때까지 소진된다:
// heap_attrinfo_determine_disk_layout -- src/storage/heap_file.c std::sort (oos_candidates.begin (), oos_candidates.end (), std::greater<std::pair<int, int>> ());
for (auto & cand : oos_candidates) { if (header_size + payload_size + mvcc_extra <= DB_PAGESIZE / 4) { break; /* <- fits now: stop, leave the rest inline */ } (*oos_columns)[cand.second] = true; /* <- mark this attr index for demotion */ payload_size -= cand.first; /* <- remove the value's inline bytes ... */ payload_size += OR_OOS_INLINE_SIZE; /* <- ... and add the 16-byte stub */ *has_oos = true; }std::pair는 사전순으로 정렬되고 첫 번째 요소가 크기이므로, std::greater는 크기 내림차순으로 정렬한다(동률은 attr index 내림차순으로 깨지지만, 정확성에는 무관하다). 가장 큰 컬럼을 먼저 내보내면 강등당 제거되는 인라인 바이트가 최대화되므로, 루프는 최소 강등 횟수로 수렴한다 — PostgreSQL TOAST가 사용하는 것과 동일한 탐욕적 휴리스틱이다.
적합성 재검사는 각 반복의 처음에 위치하므로, 첫 번째 내보내기 이전에도 실행된다. 이 순서가 중요한 이유: §3.2의 가드가 이미 레코드가 예산 초과임을 증명했으므로, 첫 번째 반복의 검사는 항상 false이고 본문에 진입하면 적어도 하나의 컬럼이 반드시 강등된다. 이후 반복에서는 검사가 단락(short-circuit)될 수 있다 — payload_size가 충분히 줄어드는 즉시 루프가 break하고 나머지 후보들은 모두 인라인으로 남는다. 이것이 PG-TOAST 스타일의 요점이다: 적격 컬럼 전부가 아닌 최소한의 컬럼만 강등한다. 각 강등은 컬럼의 전체 인라인 폭(cand.first)을 빼고, 대체 스텁을 위해 OR_OOS_INLINE_SIZE를 더하며, *has_oos를 설정한다; *oos_columns는 어느 attr index가 강등되었는지 기록한다.
Invariant — 루프 이후
payload_size는 직렬화기가 실제로 내보낼 바이트와 정확히 일치한다. 각 반복의-= cand.first; += OR_OOS_INLINE_SIZE는 Ch 4의 직렬화기가 값 대신 스텁을 기록할 때 하는 동작을 바이트 단위로 그대로 반영한다. 만약 두 값이 어긋나면(예: 루프가 오래된 크기를 빼거나, 직렬화기가 다른 크기의 스텁을 기록하면), 여기서 예약된RECDES.length가 실제 기록된 바이트와 맞지 않아 슬롯이 손상된다. 공유 상수OR_OOS_INLINE_SIZE가 크기 계산 패스와 기록 패스를 동기화하는 열쇠다.
소진 케이스는 묵시적이고 의도적이다: 레코드가 여전히 DB_PAGESIZE/4를 초과하는 상태에서 후보가 소진되면(예: 대부분 고정 컬럼인 경우) 에러가 없다 — 함수는 최선의 축소된 크기를 반환한다. 슬롯 페이지 레이어의 하드 상한은 페이지 크기 자체이지 DB_PAGESIZE/4가 아니다; 쿼터 페이지 테스트는 배치 목표이지 정확성 한계가 아니다. 쿼터 페이지 아래로 줄일 수 없지만 전체 페이지에는 들어맞는 행은 있는 그대로 허용된다.
flowchart TD
A["진입: payload_size,<br/>header_size, mvcc_extra 계산"] --> B{"hdr + payload + mvcc_extra<br/>> DB_PAGESIZE/4 ?"}
B -- 아니오 --> Z["hdr + payload 반환<br/>(has_oos = false)"]
B -- 예 --> C["oos_candidates 구성:<br/>!is_fixed AND size > 16"]
C --> D["후보 크기 내림차순 정렬"]
D --> E{"다음 후보?"}
E -- 없음 --> H["새 payload_size로<br/>header_size 재계산"]
E -- 있음 --> F{"hdr + payload + mvcc_extra<br/><= DB_PAGESIZE/4 ?"}
F -- 예, 적합 --> H
F -- 아니오 --> G["oos_columns[idx]=true 표시<br/>payload -= size; payload += 16<br/>has_oos = true"]
G --> E
H --> Z2["hdr + payload 반환"]
Figure 3-1. heap_attrinfo_determine_disk_layout의 분기 완전 제어 흐름.
3.5 헤더 재계산
섹션 제목: “3.5 헤더 재계산”루프 이후 헤더는 축소된 payload로 재계산된다:
// heap_attrinfo_determine_disk_layout -- src/storage/heap_file.c /* re-calculate the header size */ header_size = heap_attrinfo_get_record_header_size (attr_info, payload_size, is_mvcc_class, offset_size_ptr);
return header_size + payload_size;이 두 번째 호출은 단순한 형식이 아니다. §3.1에서 이 헬퍼가 VOT offset 폭(1/2/4바이트)을 header_size + payload_size로부터 선택하며, OR_MAX_BYTE (127)과 OR_MAX_SHORT (32767)에서 상향 조정한다고 언급했다. 강등 전 payload가 예를 들어 40000바이트였다면 OR_MAX_SHORT를 초과해 4바이트 offset을 강제했을 것이다; 38000바이트 컬럼을 강등한 뒤 payload가 수천 바이트로 떨어지면 — OR_MAX_SHORT 아래로 내려가므로 offset 폭이 2바이트로 줄고(OR_MAX_BYTE 아래면 1바이트까지), 헤더가 더 줄어든다. 재계산은 또한 *offset_size_ptr도 갱신하므로 호출자가 올바른 폭으로 VOT를 직렬화할 수 있다. 최종 반환값 — header_size + payload_size — 이 호출자가 레코드 디스크립터에 예약하는 권위 있는 인라인 크기다.
강등에 의해 VOT 엔트리 수는 변하지 않는다: 강등된 컬럼도 여전히 그 offset 슬롯을 차지한다. 변하는 것은 해당 엔트리의 종류로, 이것이 §3.6의 주제다.
3.6 스텁의 구조와 두 가지 마커
섹션 제목: “3.6 스텁의 구조와 두 가지 마커”이 함수가 생성하는 판정은 나중에(Ch 4) 값이 있던 자리에 16바이트 인라인 스텁으로 실현된다. 그 크기는 이 챕터 전체가 의존하는 상수다:
// OR_OOS_INLINE_SIZE -- src/base/object_representation.h/* OOS inline size: OOS OID (8 bytes) + OOS length (8 bytes) */#define OR_OOS_INLINE_SIZE (OR_OID_SIZE + OR_BIGINT_SIZE) /* 8 + 8 = 16 */즉 스텁은 8바이트의 OOS OID + 8바이트의 BIGINT 길이 = 16바이트다. OID는 OOS 파일의 외부화된 청크(혹은 체인 헤드)를 가리키고; BIGINT는 전체 논리 길이를 담고 있어 독자가 OOS 파일을 먼저 건드리지 않고도 버퍼 크기를 알 수 있다. (청크 체인 메커니즘과 길이 소비 방법은 Ch 6-7에서 다룬다.)
두 독립적인 마커가 강등 발생을 두 가지 세분성(granularity)으로 엔진 나머지에 알린다. 두 마커 모두 Ch 4의 직렬화기가 두 개의 서로 다른 헬퍼에서 찍는다.
컬럼별 마커 — VOT 엔트리의 OR_SET_VAR_OOS. 직렬화기(heap_attrinfo_transform_variable_to_disk)가 강등된 컬럼의 offset 테이블 엔트리를 기록할 때, 해당 엔트리의 하위 비트에 태그를 달아 독자에게 “이 offset은 일반 인라인 값이 아닌 16바이트 스텁을 가리킨다”를 알린다:
// OR_SET_VAR_OOS / OR_VAR_BIT_OOS -- src/base/object_representation.h#define OR_VAR_BIT_OOS 0x1#define OR_SET_VAR_OOS(length) ((int) (length) | OR_VAR_BIT_OOS)이것이 가능한 이유는 VOT offset이 INT_ALIGNMENT-정렬되어 있어 하위 2비트가 플래그용으로 여유가 있기 때문이다(OR_VAR_BIT_OOS = 0x1과 OR_VAR_BIT_LAST_ELEMENT = 0x2가 OR_VAR_FLAG_MASK = 0x3으로 함께 마스크된다). 헬퍼는 강등된 컬럼에 정확하게 태그를 적용한 다음 16바이트 스텁 자체를 기록한다:
// heap_attrinfo_transform_variable_to_disk -- src/storage/heap_file.c length = CAST_BUFLEN (*ptr_varvals - buf->buffer - header_size); if (is_oos) { assert (dbvalue != NULL && db_value_is_null (dbvalue) != true); /* see Implementation in CBRD-26352 for details on why this design is possible. */ /* use 2-bit of offset value as flags */ length = OR_SET_VAR_OOS (length); /* <- tag THIS offset entry as an OOS stub */ } or_put_offset_internal (buf, length, offset_size); /* ... then, when is_oos, the stub itself: */ buf->ptr = *ptr_varvals; or_put_oid (buf, oos_oid); /* 8 bytes: the OOS OID */ or_put_bigint (buf, oos_length); /* 8 bytes: the logical length */독자는 OR_IS_OOS(length)로 플래그를 복원하고 OR_GET_VAR_OFFSET(length)(하위 2비트를 ~OR_VAR_FLAG_MASK로 마스킹)로 깨끗한 offset을 얻는다. NULL 값은 절대 OOS가 되지 않는다 — assert가 강등은 오직 비-NULL 값, 즉 외부화할 페이로드 바이트가 있는 값에만 발생한다는 것을 강제한다.
행별 마커 — 레코드 헤더의 OR_MVCC_FLAG_HAS_OOS. 독립적으로, 행의 repid/flag 워드가 찍혀서(heap_attrinfo_transform_header_to_disk에서) 스캔이 외부화된 컬럼이 전혀 없는 행의 OOS-인식 재인라인화 경로(Ch 7)를 저렴하게 건너뛸 수 있다:
// heap_attrinfo_transform_header_to_disk -- src/storage/heap_file.c if (has_oos) { repid_bits |= (OR_MVCC_FLAG_HAS_OOS << OR_MVCC_FLAG_SHIFT_BITS); }플래그는 INSID/DELID 플래그들과 나란히 별도의 비트로 정의된다(OR_MVCC_FLAG_SHIFT_BITS = 24이 이를 repid/flag 워드의 상위 바이트로 이동시킨다):
// OR_MVCC_FLAG_HAS_OOS -- src/base/object_representation_constants.h#define OR_MVCC_FLAG_VALID_INSID 0x01#define OR_MVCC_FLAG_VALID_DELID 0x02#define OR_MVCC_FLAG_VALID_PREV_VERSION 0x04// TODO: OOS is not related to MVCC, move to another place when POC ends#define OR_MVCC_FLAG_HAS_OOS 0x08 // 0b00001000두 마커는 상호 보완적이며, 어느 쪽도 중복이 아니다. 행별 플래그는 빠른 게이트다 — (mvcc_flags & OR_MVCC_FLAG_HAS_OOS) != 0 하나의 비트 연산으로 해당 행이 OOS 처리를 필요로 하는지 결정한다. 그런 다음 컬럼별 비트가 정확히 어느 offset 엔트리가 스텁인지 식별하므로, 열 개 중 하나만 강등된 행이라면 나머지 아홉 개에 대한 재인라인화 비용을 전혀 치르지 않는다. 이 챕터의 루프에서 설정된 *has_oos가 행별 플래그로 흘러들어가는 boolean이고; *oos_columns 비트맵이 컬럼별 OR_SET_VAR_OOS 태깅을 구동한다. (소스 주석은 OR_MVCC_FLAG_HAS_OOS가 MVCC와 무관하며 기능이 POC를 벗어나면 MVCC 플래그 블록 밖으로 이동 예정임을 표시한다 — 비트의 위치는 변할 수 있지만 역할은 변하지 않는다.)
3.7 챕터 요약 — 핵심 정리
섹션 제목: “3.7 챕터 요약 — 핵심 정리”heap_attrinfo_determine_disk_layout은 순수한 크기 계산 패스다:payload_size,header_size,mvcc_extra를 계산하고, 강등을 결정하고, 예측된 인라인 크기를 반환하지만, 어떤 바이트도 기록하지 않고 OID도 할당하지 않는다. 실제 스텁 기록은 Ch 4의 역할이다.- 강등 트리거는 단일 테스트,
header_size + payload_size + mvcc_extra > DB_PAGESIZE/4다.mvcc_extra(MVCC 클래스는 16, 즉OR_MVCC_MAX_HEADER_SIZE - OR_MVCC_INSERT_HEADER_SIZE예약값)는 허용을 보수적으로 만들어 나중의 인플레이스 DELID 스탬핑이 슬롯을 넘치지 않도록 한다. - CHAR / 고정 컬럼은 절대 강등되지 않는다. 결정적인 배제는
!is_fixed후보 게이트(§3.3)에서 이루어진다; §3.1의is_fixed분기는 플래그를 기록하고 크기 공식을 선택할 뿐이다. OOS는 엄격하게 가변 길이 페이로드 메커니즘이다. - 적격성은 또한
column_size[i] > OR_OOS_INLINE_SIZE를 요구한다: 값이 그것을 대체하는 16바이트 스텁보다 커야 하며, 모든 강등이 순 축소임을 보장한다. - 외부화 루프는 가장 큰 것 먼저이며 최소한적이다: 후보는 내림차순으로 정렬되어 레코드가 맞을 때까지만 소진되며, 각 내보내기 전에 재검사하므로 가능한 최소한의 컬럼을 강등한다(PG-TOAST 스타일). 이후 헤더 크기는 재계산된다. payload를 줄이면 VOT offset 폭이 내려갈 수 있기 때문이다(
OR_MAX_BYTE= 127과OR_MAX_SHORT= 32767에서 상향 조정). - 스텁은
OR_OOS_INLINE_SIZE=OR_OID_SIZE(8) +OR_BIGINT_SIZE(8) = 16바이트: OOS OID 하나와 BIGINT 논리 길이 하나. 루프의payload -= size; payload += 16은 크기 계산 패스와 직렬화기를 바이트 단위로 동기화한다. - 두 마커가 판정을 기록하며, 두 개의 서로 다른 Ch 4 헬퍼가 기록한다: 컬럼별
OR_SET_VAR_OOS(VOT offset 엔트리의 하위 비트,heap_attrinfo_transform_variable_to_disk에서 설정)는 어느 offset이 스텁인지 식별하고, 행별OR_MVCC_FLAG_HAS_OOS(0x08,heap_attrinfo_transform_header_to_disk에서 설정)는 스캔이 OOS 처리 여부를 고려할지 결정하는 빠른 게이트다. 출하된DB_PAGESIZE/4임계값은 에픽의DB_PAGESIZE/8행 규칙과 다르며,/* TODO: change the statistics */주석으로 현재진행형으로 표시되어 있다.
Chapter 4: 단일 청크 값의 INSERT
섹션 제목: “Chapter 4: 단일 청크 값의 INSERT”이 챕터는 하나의 질문에 답한다: 3장의 demotion 결정이 어떤 컬럼을 out-of-space(OOS)로 표시한 이후, 그 컬럼의 직렬화된 값이 클래스별 OOS 파일에 어떻게 기록되며, 그 결과로 생성된 head-chunk OID가 힙 레코드의 16바이트 인라인 스텁으로 어떻게 역전달되는가? 이 챕터의 범위는 단일 청크 케이스 — 직렬화 형태가 한 페이지에 맞는 값 — 에 한정된다. 멀티 청크 분할(oos_insert_across_pages)과 그 복제 규율은 6장으로 미룬다. 페이지 선택(oos_find_best_page)은 5장에서 다루는 블랙 박스다. 인라인 스텁 레이아웃(OR_OOS_INLINE_SIZE = 16)과 demotion 휴리스틱에 대해서는 상위 레벨 companion 문서의 INSERT 및 The trigger heuristic 절을 참조하라.
4.1 두 계층의 분리: 힙 오케스트레이션과 스토리지 프리미티브
섹션 제목: “4.1 두 계층의 분리: 힙 오케스트레이션과 스토리지 프리미티브”경로에는 명확한 이음새(seam)가 있다. 힙 계층은 컬럼 루프, 스크래치 직렬화 버퍼, 그리고 이후 디스크 변환·복제 경로가 소비하는 북키핑 벡터를 소유한다. 스토리지 계층(oos_insert)은 컬럼을 전혀 모른다 — 불투명한 바이트 범위를 받아 OID를 반환할 뿐이다.
flowchart TD A["heap_attrinfo_transform_to_disk_internal\nhas_oos == true"] --> B["heap_attrinfo_insert_to_oos"] B --> C["OOS VFID 확인\nheap_oos_find_vfid docreate=true"] C --> D["tdes oos_insert_lsa_queue 초기화\nthread_p oos_oids 초기화"] D --> E["컬럼별 루프"] E -->|oos_columns i| F["dbvalue 직렬화\nheap_attrinfo_dbvalue_to_recdes"] F --> G["oos_insert\nsrc = recdes.data, recdes.length"] G --> H["thread_p oos_oids에 oid 추가\n벡터에 oid + length 저장"] H --> E E -->|완료| I["S_SUCCESS\n컬럼 외부화 완료"]
Figure 4-1: 최상위 INSERT 외부화 흐름. oos_oids / oos_lengths 벡터는 여기서 채워지고, 이후 heap_attrinfo_transform_columns_to_disk가 소비하여 레코드의 variable-offset 슬롯에 OID+length 스텁을 기록한다.
호출자 heap_attrinfo_transform_to_disk_internal의 구조적 세부사항: if (has_oos) 블록이 heap_attrinfo_insert_to_oos를 한 번, 힙 레코드를 빌드하는 do { ... } while (status == S_DOESNT_FIT) 재시도 루프보다 앞서 호출한다(스텁을 기록하는 heap_attrinfo_transform_columns_to_disk도 그 루프 안에 있다). 따라서 외부화는 인-페이지 레코드 빌드가 오버플로우되어 재시도하더라도 반복되지 않는다 — OID는 한 번만 발급되며, 루프는 이미 작아진 인라인 스텁만 재직렬화한다.
4.2 heap_attrinfo_insert_to_oos — resolve, reset, loop
섹션 제목: “4.2 heap_attrinfo_insert_to_oos — resolve, reset, loop”시그니처는 // *INDENT-OFF* / *INDENT-ON*으로 감싸져 있는데, std::vector 템플릿 인자가 C 포매터에 의해 훼손될 수 있기 때문이다. 함수는 recdes.data를 스택 스크래치 버퍼에 겨냥한 다음 세 단계를 수행한다.
Step 1 — docreate = true로 OOS 파일 확인:
// heap_attrinfo_insert_to_oos -- src/storage/heap_file.cchar recbuf[IO_MAX_PAGE_SIZE + MAX_ALIGNMENT]; /* <- 직렬화 값을 위한 스택 스크래치 */recdes.area_size = IO_MAX_PAGE_SIZE;recdes.data = PTR_ALIGN (recbuf, MAX_ALIGNMENT);recdes.type = REC_HOME;// ... condensed ...if (heap_get_class_info (thread_p, &attr_info->class_oid, &oos_hfid, NULL, NULL) != NO_ERROR) return S_ERROR; /* <- branch: class lookup failed, nothing allocated */if (!heap_oos_find_vfid (thread_p, &oos_hfid, &oos_vfid, true)) /* docreate=true */ return S_ERROR; /* <- branch: file create/lookup failed */docreate = true는 핵심 인자다. heap_oos_find_vfid는 힙 헤더의 oos_vfid를 확인하고, VFID_ISNULL이면 oos_create_file + TDE 적용 + 헤더의 RVHF_STATS undo/redo를 top system operation(log_sysop_start / log_sysop_commit) 안에서 수행한다. 따라서 힙에서 처음으로 외부화되는 컬럼은 트랜잭션 abort와 무관하게 독립적으로 살아남는 일회성 파일 생성 비용을 치른다. docreate == false(7장의 읽기 경로)에서는 null oos_vfid가 success == true와 함께 반환되며, 이는 오류가 아니다.
불변식 —
false반환은 항상 실제 오류를 의미한다.heap_oos_find_vfid는 시작 시VFID_SET_NULL (oos_vfid)를 수행하고, 파일이 없는 경우(읽기 경로)에도true를 반환한다. 계약:false는 오직er_errid()가 설정된 경우에만 발생한다. insert 경로는docreate = true를 전달하므로 null VFID가 success와 함께 돌아올 수 없다 — 생성 실패는 반드시S_ERROR를 강제한다. 호출자가false를 “파일 없음”으로 취급하면 외부화된 데이터가 조용히 유실된다.
Step 2 — 루프 전에 트랜잭션별 추적 초기화:
// heap_attrinfo_insert_to_oos -- src/storage/heap_file.ctdes->oos_insert_lsa_queue.clear (); /* <- 복제 LSA 경계 큐 */thread_p->oos_oids.clear (); /* <- 복제 로그용 per-thread head-OID 목록 */LOG_TDES는 LOG_FIND_THREAD_TRAN_INDEX / LOG_FIND_TDES로 조회한다. null tdes는 치명적인 ER_LOG_UNKNOWN_TRANINDEX를 발생시켜 S_ERROR를 반환한다. 양쪽을 초기화함으로써 이 행의 외부화를 위한 깨끗한 상태를 확립한다. 단일 청크 케이스에서는 각 삽입된 컬럼이 정확히 하나의 OID를 push하고 WAL 메커니즘이 정확히 하나의 대응 LSA를 push한다(4.7의 step 7 참조). 복제 applier는 나중에 이 둘을 한 쌍씩 소비한다.
Step 3 — 컬럼별 루프는 플래그가 설정된 컬럼만 외부화한다:
// heap_attrinfo_insert_to_oos -- src/storage/heap_file.cfor (i = 0; i < attr_info->num_values; i++) { if ((*oos_columns)[i]) /* <- branch: skip non-OOS columns entirely */ { assert (!attr_info->values[i].last_attrepr->is_fixed); /* <- fixed cols never demote */ if (heap_attrinfo_dbvalue_to_recdes (thread_p, &attr_info->values[i], attr_info->class_oid, lob_create_flag, &recdes) != S_SUCCESS) goto error_oos; /* <- branch: serialization failed */ if (oos_insert (thread_p, oos_vfid, oos_buffer (recdes.data, (size_t) recdes.length), oos_oid) != NO_ERROR) goto error_oos; /* <- branch: storage insert failed */
thread_p->oos_oids.push_back (oos_oid); /* for replication log */ (*oos_oids)[i] = oos_oid; /* <- head-OID for column i */ (*oos_lengths)[i] = (DB_BIGINT) recdes.length; /* <- full payload length */ if (recdes.data != PTR_ALIGN (recbuf, MAX_ALIGNMENT)) /* <- malloc fallback was taken */ { free_and_init (recdes.data); recdes.area_size = IO_MAX_PAGE_SIZE; recdes.data = PTR_ALIGN (recbuf, MAX_ALIGNMENT); /* <- restore stack scratch */ } } }return S_SUCCESS;error_oos: if (recdes.data != PTR_ALIGN (recbuf, MAX_ALIGNMENT)) free_and_init (recdes.data); /* <- malloc'd this iteration; free before bailing */ return S_ERROR;브랜치 분석: 건너뛴 컬럼은 초기화된 oos_oids[i]/oos_lengths[i]를 유지하고 디스크 변환에 의해 인라인으로 기록된다. 직렬화나 insert 실패는 error_oos로 점프한다. 루프 중간 실패 이전에 이미 삽입된 컬럼들은 여기서 되돌리지 않는다 — 각 oos_insert는 자체 undo를 로그에 기록했으므로, 트랜잭션이 abort될 때 회수된다(소스 주석은 두 실패 브랜치 모두 정리 경로에 도달해야 함을 명시한다).
불변식 — 스크래치 버퍼는 매 이터레이션 후 스택 포인터로 복원된다.
heap_attrinfo_dbvalue_to_recdes는 더 큰 영역을malloc하고recdes.data를 덮어쓸 수 있다(4.3). insert를 소비한 후 루프는recdes.data != PTR_ALIGN (recbuf, ...)를 검사하고, 힙 버퍼가 사용됐으면 이를 해제하고area_size를 초기화한 뒤recdes.data를 스택 스크래치로 다시 가리킨다. 이 복원을 빠뜨리면 다음 컬럼이 오래된 힙 포인터를 스택 기준점과 비교하게 되어 — 결코 재성장하지 못하고 너무 작은 영역에 쓰거나 누수를 일으킨다.error_oos레이블도 동일한 free를 반복하여 이터레이션 중간 탈출 시 누수를 방지한다.
4.3 heap_attrinfo_dbvalue_to_recdes — 직렬화, malloc 폴백 포함
섹션 제목: “4.3 heap_attrinfo_dbvalue_to_recdes — 직렬화, malloc 폴백 포함”이 헬퍼는 하나의 DB_VALUE를 디스크 온-디스크 바이트로 변환한다. OOS 관련 핵심 메커니즘은 크기 계산과 폴백 할당이다:
// heap_attrinfo_dbvalue_to_recdes -- src/storage/heap_file.clength = pr_type->get_disk_size_of_value (dbvalue);if (length > recdes->area_size) /* <- value larger than the stack scratch */ { recdes->area_size = length; recdes->data = (char *) malloc (length); /* <- fallback heap buffer; freed by caller */ }buf.ptr = buf.buffer = recdes->data;buf.endptr = recdes->data + length;pr_type->data_writeval (&buf, dbvalue);recdes->length = length; /* <- serialized byte count */return S_SUCCESS;length > recdes->area_size 브랜치는 정확히 4.2에서 호출자의 malloc 폴백 정리 코드와 쌍을 이룬다. LOB 사전 처리(lob_create_flag == LOB_FLAG_INCLUDE_LOB에서 DB_TYPE_BLOB/DB_TYPE_CLOB)는 ELO를 복사하고 value->state를 HEAP_WRITTEN_LOB_ATTRVALUE로 바꾸어 레코드 빌드 재시도 시 이중 복사를 방지한다. 이는 단일 청크 크기 계산과 직교한다. 종료 시 recdes.length는 oos_lengths[i]가 되고, 헤더에 기록되어 체인의 total_data_length가 되는 권위 있는 페이로드 크기다.
4.4 oos_insert — 진입 가드와 단일/멀티 브랜치
섹션 제목: “4.4 oos_insert — 진입 가드와 단일/멀티 브랜치”oos_insert는 컬럼에 무관한 스토리지 진입점이다. oos_buffer src(cubbase::span<char>)와 출력 OID &만 받는다.
// oos_insert -- src/storage/oos_file.cppassert (src.data () != nullptr);assert (src.size () > 0);/* Guards the narrowing cast below against wrap-around from a corrupt caller. */if (src.data () == nullptr || src.size () == 0 || src.size () > (std::size_t) INT_MAX) { oos_error ("oos_insert rejected invalid src (data=%p, size=%zu)", src.data (), src.size ()); er_set (ER_ERROR_SEVERITY, ARG_FILE_LINE, ER_GENERIC_ERROR, 0); return ER_GENERIC_ERROR; /* <- branch: reject null / zero / oversized */ }const int src_len = static_cast<int> (src.size ());if (src_len <= oos_get_max_chunk_size_within_page ()) { const OOS_RECORD_HEADER header{src_len, 0, OID_INITIALIZER}; /* <- single chunk: idx 0, no next */ err = oos_insert_within_page (thread_p, oos_vfid, src, header, oid); }else { err = oos_insert_across_pages (thread_p, oos_vfid, src, oid); /* <- Chapter 6 */ }return err;세 가드가 하나의 if로 합쳐져, 뒤따르는 static_cast<int>를 각각 방어한다. null 스팬, 0바이트 페이로드(호출자의 debug assert가 차단하지만, oos_insert는 공개 스토리지 API이므로 릴리즈에서도 재확인), 그리고 src.size () > INT_MAX(캐스트가 음수로 wrap되어 모든 다운스트림 크기 비교를 무력화). 세 경우 모두 er_set으로 ER_GENERIC_ERROR를 반환한다.
단일 청크 브랜치가 이 챕터의 전체 범위다. src_len <= oos_get_max_chunk_size_within_page ()일 때, 헤더는 {src_len, 0, OID_INITIALIZER}로 인라인 빌드된다: 전체 길이는 이 청크의 길이, 청크 인덱스 0(head), next_chunk_oid는 null(연속 없음). else 브랜치는 초과 크기 값을 6장으로 보낸다.
OOS_RECORD_HEADER(src/storage/oos_file.hpp에 oos_record_header로 선언)는 모든 청크가 페이지에 기록될 때 앞에 붙는 온-페이지 prefix다. 여기서 사용되는 세 필드:
| 필드 | 역할 | 존재 이유 |
|---|---|---|
total_data_length | 모든 청크에 걸친 사용자 데이터 바이트 합계(각 청크 헤더 제외). 단일 청크: 이 청크의 src_len과 동일. | 읽기 경로가 목적지 버퍼 크기를 결정하고 체인을 교차 검증할 수 있게 한다(oos_read_across_pages는 각 청크가 동일한 값을 반복하는지 확인). |
chunk_index | 체인 내 0-기반 위치. 단일 청크 값에서는 항상 0(head). | applier가 누락되거나 순서가 바뀐 청크를 탐지할 수 있게 한다. head(인덱스 0)의 OID가 힙 스텁에 저장된다. |
next_chunk_oid | 다음 청크의 OID, 또는 마지막/단일 청크의 경우 null OID. 여기서는 항상 OID_INITIALIZER. | 읽기 경로가 순회하는 단방향 연결 체인을 형성한다. null이 순회를 종료한다. |
4.5 oos_get_max_chunk_size_within_page — 적합 임계값
섹션 제목: “4.5 oos_get_max_chunk_size_within_page — 적합 임계값”단일/멀티 결정은 이 하나의 값을 기준으로 한다:
// oos_get_max_chunk_size_within_page -- src/storage/oos_file.cppSTATIC_INLINE __attribute__ ((ALWAYS_INLINE)) intoos_get_max_chunk_size_within_page (){ const int actual_upper_limit = DB_ALIGN_BELOW (spage_max_record_size (), OOS_ALIGNMENT); return actual_upper_limit - (int) sizeof (OOS_RECORD_HEADER);}페이지가 수용할 수 있는 가장 큰 슬롯 레코드(spage_max_record_size ())를 OOS_ALIGNMENT(= MAX_ALIGNMENT)로 내림 정렬한 값에서 헤더를 뺀 것이다. sizeof (OOS_RECORD_HEADER)를 빼는 것은 oos_insert의 비교를 사과 대 사과의 페이로드 비교로 만든다. 정확히 이 크기의 값은 헤더와 함께 정렬된 슬롯을 꽉 채운다. TODO 주석은 이 값이 상수이므로 부팅 시 메모이제이션할 수 있다고 밝히지만, 기능적으로는 매 호출 시 재계산된다.
4.6 oos_prepend_header — [헤더 | 페이로드] 조립
섹션 제목: “4.6 oos_prepend_header — [헤더 | 페이로드] 조립”oos_insert_within_page는 사용자의 스팬을 직접 쓰지 않는다. 먼저 첫 OOS_RECORD_HEADER_SIZE 바이트가 체인 헤더인 결합 레코드를 빌드한다:
// oos_prepend_header -- src/storage/oos_file.cppconst int src_len = static_cast<int> (src.size ());err = recdes_allocate_data_area (&oos_recdes, src_len + OOS_RECORD_HEADER_SIZE);if (err != NO_ERROR) { oos_error ("recdes_allocate_data_area failed in oos_prepend_header"); assert_release_error (er_errid () != NO_ERROR); assert (false); return err; /* <- branch: allocation failed */ }oos_recdes.type = REC_HOME;oos_recdes.length = src_len + OOS_RECORD_HEADER_SIZE;std::memcpy (oos_recdes.data, &oos_header, OOS_RECORD_HEADER_SIZE); /* <- header first */std::memcpy (oos_recdes.data + OOS_RECORD_HEADER_SIZE, src.data (), src.size ()); /* <- then payload */return NO_ERROR;src_len + OOS_RECORD_HEADER_SIZE 크기의 새 데이터 영역이 oos_recdes(OOS_RECDES, 즉 RECDES의 별칭)에 할당된다. 헤더가 오프셋 0에, 페이로드는 바로 그 뒤에 위치한다. 영역이 새로 할당되기 때문에 온-페이지 레코드는 복사본이다 — 4.2의 recdes.data를 절대 참조하지 않으며, 이 덕분에 heap_attrinfo_insert_to_oos가 컬럼 간에 스크래치 버퍼를 재활용할 수 있다. 단일 실패 브랜치는 할당 오류가 설정되었음을 assert한 뒤 전파한다.
불변식 — 온-페이지 레코드는 정확히
header_size + payload, 오프셋 0에 헤더. 모든 읽기 경로(oos_read_within_page, vacuum 및 stats 워커)는 첫OOS_RECORD_HEADER_SIZE바이트를OOS_RECORD_HEADER로memcpy하고 나머지를 페이로드로 취급한다. prepend가 페이로드를 먼저 쓰거나 할당을 부족하게 했다면,oos_read_within_page의assert (oos_recdes.length >= OOS_RECORD_HEADER_SIZE)가 발동하고 체인 순회에서 쓰레기next_chunk_oid를 읽게 된다.
4.7 oos_insert_within_page — 단일 페이지 핵심 함수
섹션 제목: “4.7 oos_insert_within_page — 단일 페이지 핵심 함수”레코드가 디스크 페이지에 도달하고 OID가 발급되는 지점이다.
flowchart TD A["oos_insert_within_page\nsrc, header, oid"] --> B["required_length =\nsrc_len + OOS_RECORD_HEADER_SIZE"] B --> C["oos_find_best_page\nrequired_length -> page_ptr, vpid"] C --> D["oos_prepend_header\n헤더|페이로드를 oos_recdes로 빌드"] D -->|err| E["return err\n할당된 것 없음, 해제 불필요"] D -->|ok| F["scope_exit: 모든 반환 시 oos_recdes 해제"] F --> G["spage_insert\npage_ptr, oos_recdes -> slotid"] G -->|sp_status != SP_SUCCESS| H["er_set ER_GENERIC_ERROR\n반환, scope_exit가 해제"] G -->|SP_SUCCESS| I["oid = vpid.volid, vpid.pageid, slotid"] I --> J["oos_log_insert_physical\nRVOOS_INSERT undoredo: undo 데이터 NULL, redo recdes"] J --> K["freespace = spage_max_space_for_new_record"] K --> L["oos_stats_add_bestspace\nvfid, vpid, freespace"] L --> M["return NO_ERROR\nscope_exit가 oos_recdes 해제"]
Figure 4-2: oos_insert_within_page 제어 흐름. scope_exit 가드는 등록 이후의 모든 종료 경로에서 prepend 버퍼를 해제한다. spage_insert 실패 경로도 포함된다.
// oos_insert_within_page -- src/storage/oos_file.cppconst int src_len = static_cast<int> (src.size ());assert (src_len <= oos_get_max_chunk_size_within_page ());int required_length = src_len + OOS_RECORD_HEADER_SIZE;assert (required_length <= DB_ALIGN_BELOW (spage_max_record_size (), OOS_ALIGNMENT));
auto auto_page_ptr = oos_find_best_page (thread_p, oos_vfid, required_length, vpid); /* <- Chapter 5 */
OOS_RECDES oos_recdes{};{ err = oos_prepend_header (src, header, oos_recdes); if (err != NO_ERROR) { // ... condensed: oos_error + assert ... return err; /* <- branch: prepend failed; nothing inserted, nothing to free */ } scope_exit defer_oos_recdes_free ([&]() { recdes_free_data_area (&oos_recdes); }); /* <- frees on every path */
PGSLOTID slotid = NULL_SLOTID; PAGE_PTR page_ptr = auto_page_ptr.get(); int sp_status = spage_insert (thread_p, page_ptr, &oos_recdes, &slotid); if (sp_status != SP_SUCCESS) { oos_error ("spage_insert failed with status %d", sp_status); er_set (ER_ERROR_SEVERITY, ARG_FILE_LINE, ER_GENERIC_ERROR, 0); return ER_GENERIC_ERROR; /* <- branch: page insert rejected; scope_exit still frees */ } assert (slotid != NULL_SLOTID);
oid.pageid = vpid.pageid; /* <- mint the OID from page VPID + slot */ oid.slotid = slotid; oid.volid = vpid.volid;
oos_log_insert_physical (thread_p, page_ptr, const_cast<VFID *> (&oos_vfid), &oid, &oos_recdes);
int freespace_after = spage_max_space_for_new_record (thread_p, page_ptr); (void) oos_stats_add_bestspace (thread_p, &oos_vfid, &vpid, freespace_after); /* <- refresh cache */}assert (oos_recdes.data == nullptr); /* <- scope_exit must have nulled it */return NO_ERROR;브랜치별 분석:
- 크기 assert — debug 가드 전용.
oos_insert가 이미 초과 크기 페이로드를 다른 경로로 돌렸으므로 두 조건 모두 생성 시점에 성립한다. - 페이지 획득.
oos_find_best_page는required_length를 수용할 공간이 보장된, write-latch가 걸린 페이지와 그vpid를 가진 스마트 포인터를 반환한다. 블랙 박스 — 5장. - 레코드 빌드.
oos_prepend_header가oos_recdes를 할당한다. 실패하면scope_exit등록 전에 반환하므로 해제할 것이 없다. - 해제 가드 등록. 버퍼가 존재하면
scope_exit defer_oos_recdes_free가 이후 모든 반환에서recdes_free_data_area를 실행한다 —spage_insert실패 경로와 성공 경로 모두. 마지막assert (oos_recdes.data == nullptr)가 실행됐음을 증명한다. spage_insert는oos_recdes를 빈 슬롯에 복사하고slotid를 반환한다.SP_SUCCESS이외의 상태는 →er_set (ER_GENERIC_ERROR)후 반환. 페이지는auto_page_ptr의 소멸자가, 버퍼는scope_exit가 각각 해제한다.- OID 발급. 페이지의
vpid(volid,pageid)와 새로 얻은slotid로부터 OID를 조립한다. 이것이 4.2의oos_oid가 되어oos_oids[i]에 들어가고, 디스크 변환이 16바이트 인라인 스텁에 기록한다.
불변식 — 스텁에 반환되는 OID는
{vpid.volid, vpid.pageid, slotid}이며, head 청크의 위치다. 단일 청크 값에서 head가 곧 전체 레코드이므로 이 OID 하나로 값의 위치를 알 수 있다. 스텁은 이 OID와 4.2에서 캡처한recdes.length를 저장한다. SELECT 경로(7장)는 그 length로 읽기 버퍼 크기를 결정하고 이 OID에서 체인 순회를 시작한다. 실제 삽입된 페이지가 아닌 곳에서 OID를 조립하면 스텁이 dangling 상태가 된다.
- WAL로 insert 기록.
oos_log_insert_physical은RVOOS_INSERT레코드를 emit하여 crash 후에도 redo가 가능하고 abort 시 되돌릴 수 있게 한다:
// oos_log_insert_physical -- src/storage/oos_file.cppLOG_DATA_ADDR log_addr;log_addr.vfid = vfid_p;log_addr.offset = oid_p->slotid; /* <- slot is the in-page address */log_addr.pgptr = page_p;log_append_undoredo_recdes (thread_p, RVOOS_INSERT, &log_addr, NULL, recdes_p);호출은 log_append_undoredo_recdes이지만 undo 데이터는 NULL 이다 — redo 측만 [헤더 | 페이로드] 바이트를 가진다. insert의 역연산은 delete이며, 슬롯만 있으면 되고 log_addr.offset = oid_p->slotid에 이미 기록되어 있다. 복구 테이블의 연결: {RVOOS_INSERT, "RVOOS_INSERT", oos_rv_redo_delete, oos_rv_redo_insert, NULL, NULL}(src/transaction/recovery.c)은 oos_rv_redo_delete를 undo 함수로(슬롯을 spage_delete하고 stale bestspace 항목을 제거), oos_rv_redo_insert를 redo로 등록한다. 따라서 트랜잭션이 abort되면 단일 oos_insert는 이 레코드를 통해 슬롯 단위로 되돌려진다 — 8장에서 두 핸들러와 대칭적인 RVOOS_DELETE를 다룬다.
이 append는 복제도 구동하며, 단일 청크 경로에서도 그렇게 한다 — 6장의 멀티 청크 케이스에서만이 아니라. log_manager.c가 RVOOS_INSERT를 append할 때, tdes->oos_suppress_insert_lsa_queueing이 설정되지 않는 한 tdes->tail_lsa를 tdes->oos_insert_lsa_queue에 자동으로 push한다 — 그 플래그는 오직 oos_insert_across_pages 내부에서만 설정된다. 따라서 단일 청크 insert는 여기서 정확히 하나의 LSA를 push하며, 4.2에서 push된 단일 head OID(thread_p->oos_oids)와 짝을 이룬다. 복제 applier는 나중에 이 둘을 함께 소비한다. 6장의 규율은 동일한 메커니즘의 멀티 청크 확장이다(청크별 자동 push를 억제하고, 경계 LSA와 tail-chunk LSA를 수동으로 큐에 넣는다).
불변식 — 단일 청크 insert 하나에서 LSA 큐와 OID 목록은 각각 정확히 하나의 항목을 얻으며, 둘은 쌍을 이룬다. 4.2가 행 시작 시 둘 다 초기화한다. 이 step이 하나의 LSA를 push하고, 4.2의 루프가 하나의 OID를 push한다. 불일치(예: LSA push는 억제하면서 OID는 push)는
replication.c의 applier pop 루프를 비동기화한다.
- bestspace 캐시 갱신. insert가 공간을 소비한 후,
spage_max_space_for_new_record가 페이지의 남은 용량을 읽고oos_stats_add_bestspace가 이를 다시 기록한다. 여기서도, 그리고 조회 시(oos_stats_find_page_in_bestspace, 5장)에서도 동일한spage_max_space_for_new_record가 의도적으로 사용된다 — 그래야 write-back과 read-back 양쪽에서 동일한 회계를 사용하게 되고, 캐시가 페이지가 실제로 허용할 수 없는 공간을 가진 것으로 표시하는 일을 방지한다.(void)캐스트는 반환값을 버린다. 캐시 업데이트 실패는 이후 페이지 탐색 품질을 저하시킬 뿐이므로 insert를 abort시키지 않는다.
4.8 호출자가 결과를 처리하는 방식
섹션 제목: “4.8 호출자가 결과를 처리하는 방식”heap_attrinfo_insert_to_oos로 돌아오면, 발급된 oos_oid와 recdes.length가 thread_p->oos_oids(복제), (*oos_oids)[i], (*oos_lengths)[i]에 커밋된다. 이 벡터들은 heap_attrinfo_transform_columns_to_disk로 흘러들어, 레코드의 variable-offset 슬롯에 원래 값 대신 16바이트 스텁(OID + 8바이트 length)을 기록한다 — 스텁 레이아웃에 대해서는 companion 문서의 INSERT 절을 참조하라. 스토리지 계층의 관점에서 작업은 step 8에서 끝난다: 값은 페이지에 있고, 복구 가능하고, OID로 주소 지정 가능하며, bestspace 캐시는 소비된 공간을 반영한다.
4.9 챕터 요약 — 핵심 정리
섹션 제목: “4.9 챕터 요약 — 핵심 정리”- 두 계층, 하나의 이음새.
heap_attrinfo_insert_to_oos는 컬럼 루프, 스크래치 버퍼,oos_oids/oos_lengths/thread_p->oos_oids북키핑을 소유한다.oos_insert는 스팬 입력, OID 출력만 담당한다. 이 이음새 덕분에 힙 계층은 모든 컬럼에 걸쳐 하나의 스택 버퍼를 재활용할 수 있고, 루프는 레코드 빌드 재시도 루프 전에 한 번만 실행된다. docreate = true는 top system operation 내에서 OOS 파일을 지연 생성한다. 첫 번째 외부화 컬럼에서 발생하며,heap_oos_find_vfid계약은false반환이 항상 실제 오류임을 보장한다 — 결코 “파일 없음”이 아니다.- 스크래치 버퍼는 malloc 폴백과 함께 재활용된다.
heap_attrinfo_dbvalue_to_recdes는 값이 스택 스크래치를 초과할 때recdes.data를 힙 버퍼로 교체할 수 있다. 루프(와error_oos)는 매 이터레이션마다 이를 해제하고 스택 기준점으로 되돌린다. 그렇지 않으면 다음 컬럼이 손상된다. oos_insert는 가드 후 하나의 임계값으로 분기한다. null / 0 /> INT_MAX스팬은 거부된다.src_len <= oos_get_max_chunk_size_within_page ()이면 헤더{src_len, 0, OID_INITIALIZER}로 단일 청크 경로가 선택되고, 나머지는 6장으로 간다.oos_prepend_header는 독립적인[헤더 | 페이로드]복사본을 빌드한다. 온-페이지 레코드는 호출자의 버퍼를 절대 참조하지 않으며, 헤더는 항상 오프셋 0에 위치한다 — 모든 읽기 경로가 의존하는 불변식이다.oos_insert_within_page는scope_exit규율을 따른다. prepend 버퍼는 등록 이후의 모든 반환 경로에서 해제된다(spage_insert실패 포함). head-chunk OID는{vpid.volid, vpid.pageid, slotid}로 발급되며, 이것이 정확히 힙 스텁에 들어가는 값이다.- WAL, 복제, bestspace는 성공적인 insert의 부작용이다.
oos_log_insert_physical은 완전한 redo recdes와 null undo 데이터로RVOOS_INSERT를 로그에 기록한다 — abort 시 되돌리기는 복구 테이블이oos_rv_redo_delete를 undo로 등록해 두었으므로(log_addr.offset에 기록된 슬롯을 삭제) 여전히 동작한다. 동일한 append가 단일 청크 경로에서도oos_insert_lsa_queue에 하나의 LSA를 자동 push하며(억제는 멀티 청크에서만), 단일 OID와 짝을 이룬다.oos_stats_add_bestspace는 조회 측과 동일한 회계를 사용하여 여유 공간 캐시를 갱신한다.
Chapter 5: Bestspace 배치 전략
섹션 제목: “Chapter 5: Bestspace 배치 전략”4장은 oos_insert_within_page 내부의 페이지 선택 단계를 블랙박스로 다뤘다.
oos_find_best_page를 호출하면 청크를 수용할 공간이 있는 write-latch 상태의 페이지가
반환되고, 삽입이 이루어진다. 이 장에서는 그 내부를 열어본다. 핵심 질문은 다음과 같다:
삽입자는 파일 전체를 스캔하지 않고도 공간이 있는 페이지를 어떻게 찾는가? 그리고 그
과정에서 latch 경합을 어떻게 피하는가?
고수준 동반 문서인 cubrid-oos.md의 ### Bestspace 절은 설계 의도를 설명한다(sticky
header에 저장되는 최대 10개의 추정값과 1000개 항목을 담는 전역 인메모리 해시 캐시, 그리고
3단계 탐색). 이 메커니즘 전체는 heap manager의 bestspace 기계를 의도적으로 복제한 것이다.
유사한 코드(heap_stats_find_page_in_bestspace, heap_stats_sync_bestspace,
HEAP_BESTSPACE 캐시)는 cubrid-heap-manager-detail.md §9.2/9.4/9.7에서 분석되며,
필드별 대응 관계를 이 장과 함께 읽어볼 것을 권장한다. 이 장에서는 그 설계 근거를 반복하지
않는다. OOS 드라이버와 두 핵심 함수의 모든 분기, 그리고 추정값을 갱신하는 변경 헬퍼들을
추적한다.
5.1 이중 계층 추정 구조
섹션 제목: “5.1 이중 계층 추정 구조”“어느 페이지에 공간이 있는가”라는 질문에 답하는 두 가지 저장소가 존재한다:
- Header
best[]—oos_hdr->estimates.best[OOS_NUM_BEST_SPACESTATS](OOS_NUM_BEST_SPACESTATS= 10). sticky한 첫 번째 페이지의 slot 0 레코드에 상주하며 재시작 후에도 유지된다. 단,freespace값은 페이지가 채워짐에 따라 낡아지는 힌트에 불과하다. - 전역 해시 캐시 —
oos_Bestspace. 프로세스 전체에 걸친OOS_STATS_BESTSPACE_CACHE로, 두 가지 방식으로 키가 지정된다(vfid_ht는 파일별로 항목을 묶고,vpid_ht는 페이지 단위로 키를 구성). 하나의bestspace_mutex아래 관리되며 용량은OOS_BESTSPACE_CACHE_CAPACITY= 1000이다.
oos_bestspace는 배치의 최소 단위로, (page, free-bytes) 쌍을 나타내는 vpid와
freespace 필드로 구성된다. oos_hdr_stats.estimates는 이 장에서 변경 대상이 되는
내구성 있는 예약 블록이다. 두 링은 원소 타입이 다르다는 점에 주의하라:
best[OOS_NUM_BEST_SPACESTATS]는 OOS_BESTSPACE 배열(여유 바이트 포함)인 반면,
second_best[OOS_NUM_BEST_SPACESTATS]는 bare VPID[](페이지 ID만 — 해당 페이지가
참조될 경우 여유 공간을 다시 측정)이다. 배치 경로에서 실제로 읽거나 쓰이는 필드들:
| 필드 | 역할 | 배치 경로에서 중요한 이유 |
|---|---|---|
oos_bestspace.vpid | 후보 페이지 ID | 청크가 놓일 수 있는 페이지; Phase C에서 재검증 |
oos_bestspace.freespace | 해당 페이지의 추정 여유 바이트 | needed_space와 비교하여 순위를 매김; 낡으면 현재값으로 교정 |
estimates.best[10] | OOS_BESTSPACE[] — (vpid, freespace) 후보의 핫 링 | Phase B에서 스캔; idx_badspace가 가장 약한 슬롯을 보고 |
estimates.second_best[10] | VPID[] — best[]에서 밀려난 여유 페이지 ID의 피더 링(freespace 없음) | best[]에서 퇴출된 페이지를 보관(§5.6) |
estimates.num_pages | 마지막 스캔에서 집계된 페이지 수 | sync 결정 비율의 분모(§5.2) |
estimates.num_recs | 살아있는 OOS 레코드 수 | 밀도 입력; sync로 갱신 |
estimates.recs_sumlen | 본문 바이트의 누적 합계(float) | 평균 크기 계산 입력; sync로 갱신 |
estimates.num_other_high_best | best[] 범위 밖에서 알려진 여유 페이지 수 | 비율의 분자; “재스캔이 의미 있는가?” |
estimates.num_high_best | 현재 유효한 best[] 슬롯 수 | 각 sync 후 best_count로 설정; 부분 스캔 델타 기준 |
estimates.num_substitutions | best[] 퇴출 카운터 | 1000번 중 1번의 second_best[] 샘플링을 제어(§5.6) |
estimates.num_second_best | second_best[] 링의 유효 항목 수 | 링의 범위를 한정; [0, 10] 불변 조건(§5.6) |
estimates.head_second_best | 링 소비자 커서 | get_second_best가 팝하는 위치 |
estimates.tail_second_best | 링 생산자 커서 | put_second_best가 쓰는 위치 |
estimates.head | best[]의 head 인덱스 | 예약된 회전 커서; 이 장의 어떤 코드도 손대지 않음 |
estimates.full_search_vpid | 마지막 스캔이 멈춘 페이지 | 재개 지점 의도 — 현재는 TODO 스텁(§5.4) |
Figure 5-1은 두 저장소 위에 배치 드라이버가 어떻게 위치하는지를 보여준다.
flowchart TD A["oos_insert_within_page (4장)"] --> B["oos_find_best_page<br/>3단계 드라이버"] B -->|sticky header 고정| H["OOS_HDR_STATS.estimates<br/>(첫 번째 페이지 slot 0)"] B --> C["oos_stats_find_page_in_bestspace<br/>Phase A 해시 + Phase B best[]"] C -->|읽기/정리| G["oos_Bestspace<br/>전역 해시 캐시"] C -->|읽기| H B -->|에스컬레이션| S["oos_stats_sync_bestspace<br/>페이지 스캔, 재충전"] S -->|쓰기| G B -->|최후 수단| N["oos_file_alloc_new"] H -.변경됨.- U["oos_stats_update / _internal"] G -.변경됨.- M["oos_stats_add_bestspace<br/>del_by_vpid / del_by_vfid"]
Figure 5-1. 이중 계층 추정 구조 위에 위치한 배치 드라이버.
불변 조건 — 모든 힌트 쓰기는 로그를 남기지 않는다. header
best[]와 전역 캐시는 모두 파일을 재스캔(oos_stats_sync_bestspace)하여 재구성 가능하다. 따라서 코드는 힌트 변경에 WAL 로그를 남기지 않는다. header 쓰기는log_skip_logging+pgbuf_set_dirty(..., DONT_FREE)로 이루어지고, 전역 캐시는 순수한 프로세스 메모리다. 크래시로 최신 힌트가 사라지더라도 정확성에는 영향이 없으며, 재시작 후 첫 번째 삽입에서 스캔 비용이 한 번 더 발생할 뿐이다. 힌트에 로그를 남기면 복구 관리자가 이미 재구성할 수 있는 데이터 때문에 redo 스트림이 불필요하게 팽창한다.
5.2 oos_find_best_page — 3단계 드라이버
섹션 제목: “5.2 oos_find_best_page — 3단계 드라이버”시그니처는 oos_find_best_page (thread_p, oos_vfid, rec_length, vpid)이며
auto_unfix_page_ptr을 반환한다. 3단계로 구성된다: (1) oos_stats_find_page_in_bestspace로
두 추정 저장소를 조회한다; (2) 미스 시 oos_stats_sync_bestspace로 에스컬레이션한다;
(3) 여전히 미스이면 oos_file_alloc_new를 호출한다.
Header 획득 — 세 가지 alloc 낙하 경로. 탐색 전 드라이버는 sticky한 첫 번째 페이지를
fix한다. 세 가지 별개의 실패가 각각 oos_file_alloc_new로 곧장 낙하한다: header VPID
없음(file_get_sticky_first_page 실패 또는 NULL 반환), header pgbuf_fix가 NULL 반환,
또는 slot 0을 읽을 수 없음(oos_get_header_stats_ptr이 NULL 반환 — 이 경우 header를
먼저 unfix).
header는 무조건 WRITE latch로 fix된다 — 드라이버가 best[]를 변경하며 탐색 전반에
걸쳐 이를 보유해야 하기 때문이다(아래의 sync 중 제외). total_space = rec_length + sizeof (SPAGE_SLOT)은 spage_max_space_for_new_record가 보고하는 값과 일치하도록
레코드 본문 외에 slot 디렉터리 항목을 함께 예산에 반영한다.
while (try_find < 2) 루프. 각 패스는 oos_stats_find_page_in_bestspace (..., oos_hdr->estimates.best, &idx_badspace, total_space, &vpid, &found_page)를 호출하고
세 가지 반환값에 따라 분기한다: OOS_FINDSPACE_FOUND이면 found_page가 설정된 채로
루프를 빠져나온다; OOS_FINDSPACE_ERROR이면 header를 unfix하고 nullptr을 반환한다
(4장으로 전파되는 인터럽트); OOS_FINDSPACE_NOTFOUND이면 sync 결정 단계로 넘어간다.
Sync 결정 — num_other_high_best / num_pages 비율. NOTFOUND 시:
// oos_find_best_page -- src/storage/oos_file.cppfloat ratio = 0;if (oos_hdr->estimates.num_pages > 0) ratio = (float) oos_hdr->estimates.num_other_high_best / (float) oos_hdr->estimates.num_pages;
if (ratio >= OOS_BESTSPACE_SYNC_THRESHOLD || try_find == 0) /* 0.1f, or always pass 0 */ { ...sync... }else break; /* not worth scanning; leave loop, will alloc new */num_other_high_best는 best[]의 10개 항목 이외에 공간이 충분한 것으로 알려진 페이지
수다. 비율이 높으면 캐시에 올라오지 않은 여유 페이지가 많다는 의미이므로 스캔이 하나를
찾아낼 가능성이 높다. try_find == 0은 비율과 무관하게 첫 번째 미스에서 강제로
sync를 실행하여, 포기하기 전에 최소한 한 번의 재스캔이 보장된다. 두 조건 모두 충족되지
않으면 루프가 break되고 드라이버는 거의 꽉 찬 파일을 스캔하는 대신 새 페이지를 할당한다.
Sync 전 header latch 해제. 스캔 중(많은 데이터 페이지를 fix) header WRITE latch를
보유하면 모든 동시 삽입자가 직렬화된다. 따라서 드라이버는 먼저 이를 해제한다 —
log_skip_logging + pgbuf_set_dirty (..., DONT_FREE)로 대기 중인 best[] 변경을
비로그 방식으로 flush한 뒤, pgbuf_unfix_and_init과 oos_hdr = NULL을 호출한다. 그런
다음 스택에 OOS_HDR_STATS tmp_hdr를 memset으로 0으로 초기화하고
oos_stats_sync_bestspace (..., &tmp_hdr, &hdr_vpid, false)를 호출한다 — 일회용
header다. sync가 이 구조체에 쓰는 내용은 버려지며, 함수는 전역 해시 캐시만 채우면 되므로
latch를 해제해도 안전하다. 스캔은 실제 best[]에 접근하지 않는다. sync를 확장하여
header를 변경하려는 개발자는 tmp_hdr가 살아남는다고 가정해서는 안 된다.
Sync 후 header 재획득. re-fix 시 두 가지 추가 낙하 경로가 나타난다: pgbuf_fix가
ER_INTERRUPTED와 함께 실패하면 드라이버가 nullptr을 반환하고, 그 외 fix 실패나
slot 0 읽기 불가 시에는 oos_file_alloc_new로 낙하한다. 성공하면 try_find++되고 루프가
한 번 더 실행된다 — 전역 캐시가 더 따뜻해졌으므로 Phase A의 히트 가능성이 훨씬 높다.
found_page re-fix 경쟁 구간. 루프 종료 후 후보가 발견되었다면, 드라이버는
oos_stats_find_page_in_bestspace가 보유하던 조건부 WRITE latch를 해제하고
auto_unfix 페이지로 다시 fix한다. 의도적으로 unfix 후 무조건 re-fix하므로,
이 간격은 다른 삽입자가 페이지를 채울 수 있는 경쟁 구간이다:
// oos_find_best_page -- src/storage/oos_file.cpppgbuf_unfix_and_init (thread_p, found_page); /* drop conditional latch */auto result_page = pgbuf_fix_auto_unfix (thread_p, &vpid, OLD_PAGE, PGBUF_LATCH_WRITE, PGBUF_UNCONDITIONAL_LATCH);if (result_page == nullptr) { if (er_errid () == ER_INTERRUPTED) return nullptr; /* interrupt: bail */ /* benign latch failure -> fall through to alloc */ }else { int actual_free = spage_max_space_for_new_record (thread_p, result_page.get ()); if (actual_free >= total_space) return result_page; /* still has room: done */ result_page.reset (); /* raced away; fall through to alloc */ }무조건 re-fix 이후의 재검증이 경쟁 구간에 대한 방어막이다. 다른 스레드가 간격 사이에
페이지를 채웠다면 actual_free < total_space이고, 페이지가 reset되며 마지막 분기로 이동한다
— pgbuf_unfix_and_init_after_check (hdr_page)(header가 이미 unfix된 경우 no-op) 후
oos_file_alloc_new를 호출하고, 새 페이지를 측정하여 oos_stats_add_bestspace로 전역
캐시에 시드한다. 이렇게 하면 다음 삽입자가 Phase A에서 이를 히트할 수 있다. Figure 5-2는
드라이버 전체 분기를 추적한다.
flowchart TD
S["sticky header fix<br/>(WRITE, 무조건)"] -->|vpid 없음 / fix 실패 / slot0 불량| ALC["oos_file_alloc_new"]
S -->|성공| L{"try_find < 2?"}
L -->|find_page_in_bestspace| R{"결과?"}
R -->|FOUND| FP["found_page 설정,<br/>루프 탈출"]
R -->|ERROR| ERR["hdr unfix,<br/>nullptr 반환"]
R -->|NOTFOUND| RT{"ratio >= 0.1<br/>OR try_find==0?"}
RT -->|no| FP
RT -->|yes| SY["hdr latch 해제 (비로그)<br/>sync_bestspace -> tmp_hdr<br/>hdr re-fix"]
SY -->|refix 실패, 인터럽트| ERR
SY -->|refix 실패, 기타| ALC
SY -->|성공, try_find++| L
FP -->|found_page != NULL| RF["조건부 latch 해제<br/>무조건 re-fix"]
RF -->|인터럽트| ERR
RF -->|성공, actual_free >= 필요| DONE["페이지 반환"]
RF -->|경쟁 / latch 실패| ALC
FP -->|found_page == NULL| ALC
ALC --> SEED["새 페이지 캐시에 시드,<br/>반환"]
Figure 5-2. 3단계 드라이버의 모든 분기.
5.3 oos_stats_find_page_in_bestspace — 탐색자
섹션 제목: “5.3 oos_stats_find_page_in_bestspace — 탐색자”드라이버가 루프에서 반복 호출하는 함수다. OOS_FINDSPACE를 반환하며, 성공 시
조건부 WRITE-latch 상태의 페이지를 *out_pgptr에 담아 전달한다. 외부 루프는
while (found == OOS_FINDSPACE_NOTFOUND) 형태로 실행된다.
Zero-wait 설정. xlogtb_reset_wait_msecs (thread_p, LK_FORCE_ZERO_WAIT)는
바쁜 페이지에서 탐색자가 절대 블록되지 않도록 lock 대기를 0으로 강제한다. 이전 값은
old_wait_msecs에 저장되어 단일 종료 지점에서 복원된다. header VPID는
(file_get_sticky_first_page, 실패 시 NULL 설정) 오직 건너뛸 목적으로만 가져온다 —
header에는 청크 데이터가 아닌 통계 레코드가 있기 때문이다.
Phase A — 전역 해시 스캔. bestspace_mutex 아래에서, mht_get2로 이 파일의
vfid_ht 버킷을 순회하고(notfound_cnt < BEST_PAGE_SEARCH_MAX_COUNT(= 100) 예산으로
한정), 항목마다: best.freespace >= needed_space이고 header VPID가 아니면
candidate_vpid로 취하고 found_in_hash = true로 설정한 뒤 break; header VPID이면
두 해시 테이블에서 제거하고 continue; 그 외 공간 부족 항목은 파괴적으로 정리된다
(vfid_ht에서 mht_rem2, vpid_ht에서 mht_rem, free, num_stats_entries--,
notfound_cnt++). 캐시는 읽는 과정에서 스스로 정리된다.
Phase B — best[] 스캔. Phase A에서 후보를 찾지 못하고 bestspace 배열이 전달된
경우에만 실행. best_array_index를 먼저 증가시킨 뒤 앞으로 스캔하므로(이전에 시도한
슬롯을 지나쳐 재개), NULL이 아니고 freespace >= needed_space이며 header VPID가 아닌
첫 번째 슬롯을 취한다. 두 Phase 모두 candidate_vpid를 NULL로 남기면 외부 루프가
NOTFOUND로 break된다.
Phase C — 조건부 fix + 실제 여유 공간 재측정. 후보 VPID는 단지 힌트일 뿐이며, 페이지를 fix하고 다시 측정해야 한다:
// oos_stats_find_page_in_bestspace -- src/storage/oos_file.cpp*out_pgptr = pgbuf_fix (thread_p, &candidate_vpid, OLD_PAGE, PGBUF_LATCH_WRITE, PGBUF_CONDITIONAL_LATCH);if (*out_pgptr == NULL) /* busy or error */ { int err = er_errid (); if (err == ER_INTERRUPTED) { found = OOS_FINDSPACE_ERROR; break; } /* propagate */ if (err != NO_ERROR) { oos_trace (...); er_clear (); } /* skip-on-busy */ notfound_cnt++; continue; }int actual_free = spage_max_space_for_new_record (thread_p, *out_pgptr);if (actual_free >= needed_space) { *out_vpid = candidate_vpid; found = OOS_FINDSPACE_FOUND; if (found_in_hash) (void) oos_stats_add_bestspace (thread_p, vfid, &candidate_vpid, actual_free); /* refresh */ }else /* stale hint */ { pgbuf_unfix_and_init (thread_p, *out_pgptr); *out_pgptr = NULL; if (found_in_hash) (void) oos_stats_del_bestspace_by_vpid (thread_p, &candidate_vpid); if (best_array_index >= 0 && best_array_index < OOS_NUM_BEST_SPACESTATS) bestspace[best_array_index].freespace = actual_free; /* correct the hint in place */ notfound_cnt++; }이것이 skip-on-busy 경합 회피의 핵심이다. 실패한 CONDITIONAL_LATCH(다른 삽입자가
write-latch를 보유 중인 페이지)는 대기하지 않고 — err을 clear하고 notfound_cnt++
한 뒤 다음 후보를 시도한다. ER_INTERRUPTED만이 OOS_FINDSPACE_ERROR로 에스컬레이션된다.
낡은 힌트(페이지는 존재하지만 너무 꽉 참)는 캐시 항목을 제거하고 best[] 슬롯의
freespace를 현재값으로 교정하여 다음 호출자가 건너뛸 수 있게 한다.
불변 조건 — FOUND로 반환되는 페이지는 여전히 WRITE-latch 상태다. 함수가
OOS_FINDSPACE_FOUND를 반환할 때*out_pgptr은 호출자가 소유하는 조건부 WRITE latch 상태다.oos_find_best_page는 이를 기반으로 즉시 무조건 re-fix를 수행한다(§5.2). 만약 탐색자가*out_pgptr == NULL인 채로 FOUND를 반환하면, 드라이버는 해제/unlatch된 페이지를 역참조하게 된다. 코드는 페이지가 여전히 fix된 상태인actual_free >= needed_space분기 내에서만found = FOUND를 설정함으로써 이를 강제한다.
idx_badspace 계산. 대기 모드를 복원하기 전에, 함수는 어느 best[] 슬롯이 가장
약하거나 첫 번째 빈 슬롯인지를 보고하여 호출자의 oos_stats_update가 삽입 위치를 알 수
있게 한다. 첫 번째 NULL 슬롯이 우선순위를 가지며, 없을 경우 freespace가 가장 작은 슬롯을
선택한다.
5.4 oos_stats_sync_bestspace — 재스캔
섹션 제목: “5.4 oos_stats_sync_bestspace — 재스캔”드라이버가 에스컬레이션 시 호출한다. numerable 이터레이터(file_numerable_find_nth)로
파일을 순회하고 캐시를 재충전하며, scan_all이 부분 스캔과 전체 스캔을 선택한다.
- 가드:
file_get_num_user_pages; 실패하거나total_pages <= 1이면(header만 있을 경우) 즉시 0을 반환한다. - 반복 상한: 전체 스캔은
total_pages를 순회; 부분 스캔은total_pages * 0.2를 순회하되[10, oos_Find_best_page_limit]로 클램핑된다(oos_Find_best_page_limit= 100). 이 설계는 latch-and-measure 비용을 제한한다. - 재개 지점 — 알려진 TODO: 코드는
estimates.full_search_vpid에서 재개하려는 의도가 있으나 현재는 단순히start_idx = 1에서 재시작한다(페이지 0은 header). 소스 주석이 명확히 설명한다:/* TODO: ideally find the index of full_search_vpid; for now start from 1 */. 개발자는 재개 기능이 아직 동작한다고 가정해서는 안 된다.
각 페이지마다 조건부 READ latch 아래(바쁘면 건너뜀: er_clear () 후 continue),
spage_max_space_for_new_record와 spage_collect_statistics를 측정하고
num_pages / num_recs / recs_sumlen을 누적한 뒤:
// oos_stats_sync_bestspace -- src/storage/oos_file.cppif (free_space > OOS_DROP_FREE_SPACE) /* 0.3 * DB_PAGESIZE */ { (void) oos_stats_add_bestspace (thread_p, vfid, &scan_vpid, free_space); if (best_count < OOS_NUM_BEST_SPACESTATS) /* room in best[] */ { ...fill first NULL slot; best_count++; num_high_best++... } else num_other_high_best++; /* roomy but best[] full */ }oos_hdr->estimates.full_search_vpid = scan_vpid; /* resume hint (into tmp_hdr here) */OOS_DROP_FREE_SPACE는 “추적할 가치가 있는” 최소 여유 공간 기준이다. 전체 스캔 정리
(scan_all인 경우에만)는 낡은 freespace가 이제 <= OOS_DROP_FREE_SPACE인 best[]
슬롯을 null로 만든다. 추정 업데이트 분기는 scan_all || estimates.num_pages <= num_pages
조건으로 분기된다 — 즉 전체 스캔, 또는 저장된 추정치 이상의 페이지를 관찰한 부분 스캔:
- 해당 분기(리셋):
num_other_high_best,num_pages,num_recs,recs_sumlen을 새로 집계된 값으로 덮어쓴다. - 그 외(더 적은 페이지를 본 부분 스캔): heap 방식으로 누적 지식을 보존한다.
num_other_high_best에서 이전에 저장된estimates.num_high_best를 빼고, 새로 집계된num_other_high_best를 하한으로 clamp한다.num_pages / num_recs / recs_sumlen은num_recs또는recs_sumlen이 실제로 증가했을 때만 채택한다.
마지막으로 estimates.num_high_best = best_count가 설정되고, 함수는
num_high_best + num_other_high_best(로컬에서 집계된 값)를 반환한다. 드라이버의 에스컬레이션
경로에서 이 쓰기들은 일회용 tmp_hdr에 저장되며, 전역 캐시에 대한 add 호출만 지속된다
— §5.2 참조.
5.5 oos_stats_update / oos_stats_update_internal — 연산 후 갱신
섹션 제목: “5.5 oos_stats_update / oos_stats_update_internal — 연산 후 갱신”삭제/삽입이 페이지에서 공간을 해방하거나 소비한 후 호출된다(4장, 8장, 9장).
oos_stats_update는 spage_get_free_space_without_saving으로 페이지의 현재 여유 공간을
측정한 뒤:
freespace > prev_freespace이면(공간이 해방됨),oos_stats_add_bestspace를 호출해 전역 캐시에 push한다 — 삭제자가 여유 공간을 광고하는 것이다.freespace > OOS_DROP_FREE_SPACE이면, headerbest[]업데이트를 시도한다 — defer-on-busy 방식의PGBUF_CONDITIONAL_LATCHWRITE latch로 header를 fix한다:pgbuf_fix가 NULL을 반환하면er_clear ()하고 반환한다(힌트는 선택적이므로 절대 대기할 가치가 없다). 성공하면oos_stats_update_internal을 호출하고, 비로그 방식으로 변경을 flush(log_skip_logging+pgbuf_set_dirty (..., DONT_FREE))한 뒤 unfix한다.
oos_stats_update_internal은 best[] 내 배치를 결정한다:
// oos_stats_update_internal -- src/storage/oos_file.cppfor (idx = 0; idx < OOS_NUM_BEST_SPACESTATS; idx++) { if (VPID_ISNULL (&best[idx].vpid)) { worst_idx = idx; break; } /* empty slot */ if (VPID_EQ (&best[idx].vpid, page_vpid)) { best[idx].freespace = freespace; return; } /* already tracked */ if (best[idx].freespace < worst_freespace) { worst_freespace = ...; worst_idx = idx; } }if (worst_idx >= 0) { if (!VPID_ISNULL (&best[worst_idx].vpid) && best[worst_idx].freespace > OOS_DROP_FREE_SPACE) { oos_stats_put_second_best (oos_hdr, &best[worst_idx].vpid); /* evict, don't lose */ oos_hdr->estimates.num_other_high_best++; } best[worst_idx].vpid = *page_vpid; best[worst_idx].freespace = freespace; }세 가지 결과가 있다: 이미 존재하는 경우(제자리 업데이트 후 반환), 빈 슬롯(해당 슬롯 점유),
또는 최악 항목 퇴출. worst_freespace가 들어오는 freespace로 시드된다는 점에 주목하라
— 새 페이지가 기존 슬롯의 현재 점유자보다 더 여유로울 때만 슬롯을 대체한다. 퇴출되었지만
여전히 공간이 있는 페이지는 버려지지 않고 second_best[] 링에 보관되며
num_other_high_best에 집계된다.
5.6 second_best[] 링, header 리더, 캐시 변경자
섹션 제목: “5.6 second_best[] 링, header 리더, 캐시 변경자”**oos_get_header_stats_ptr**은 모든 header 리더가 사용하는 한 줄짜리 함수다: slot 0을
peek하고 레코드 데이터를 OOS_HDR_STATS *로 캐스팅한다. slot 0을 읽을 수 없으면
assert(false)하고 NULL을 반환한다 — 앞서 설명한 “slot0 불량” 낙하 경로의 트리거다.
oos_stats_put_second_best — 샘플링 링. 1000번의 퇴출 중 하나만 기록한다
(++num_substitutions % 1000 == 0). tail_second_best에 쓰고
OOS_STATS_NEXT_BEST_INDEX((i+1) % 10)로 진행한다. 용량 미만이면 num_second_best를
증가시키고, 꽉 차면(== OOS_NUM_BEST_SPACESTATS) 가장 오래된 항목을 덮어쓰기 위해
head_second_best를 대신 전진시킨다. hpp에는 대칭 역방향 스텝을 위한
OOS_STATS_PREV_BEST_INDEX((i==0) ? 9 : i-1)도 정의되어 있지만,
이 장의 어떤 함수도 이를 호출하지 않는다 — estimates.head처럼 예약된 헬퍼다.
**oos_stats_get_second_best**는 head_second_best에서 pop하고 num_second_best를
감소시키며, 비어있으면 *vpid를 NULL로 설정하고 false를 반환한다.
불변 조건 — 링의
num_second_best는[0, 10]내에 유지되며 head/tail이 애매하게 충돌하지 않는다.put은 용량 미만일 때만 카운트를 증가시키고, 그 외에는head를 전진시킨다.get은 비어있지 않을 때만 감소시킨다. 두 인덱스 이동은 모두OOS_STATS_NEXT_BEST_INDEX를 통하므로 동일하게 wrap된다.num_second_best가OOS_NUM_BEST_SPACESTATS를 초과하면get이 생산자가 곧 덮어쓸 슬롯을 반환하게 되어 torn VPID가 발생한다.
전역 캐시 변경자. oos_stats_add_bestspace는 전역 캐시에 대한 단일 삽입/업데이트
경로다. bestspace_mutex 아래에서: VPID가 vpid_ht에 이미 존재하면 freespace만
덮어쓴다; OOS_BESTSPACE_CACHE_CAPACITY에 도달하면 거부한다(NULL 반환 — 호출자는
best-effort로 처리); 그 외에는 free_list에서 항목을 가져오거나 malloc하고, 채운 뒤
vpid_ht에 mht_put하고 vfid_ht에 mht_put_new한다. 두 번째가 실패하면 첫 번째를
롤백한다. oos_stats_del_bestspace_by_vpid는 두 테이블에서 페이지 하나를 제거하고,
oos_stats_del_bestspace_by_vfid는 mht_get(vfid_ht)를 반복하며 파일 전체 항목을
제거한다(파일 소멸 / 테이블 drop 시 사용). 세 함수 모두 순수 프로세스 메모리이며 —
§5.1과 일치하게 — 절대 로그를 남기지 않는다.
5.7 챕터 요약 — 핵심 정리
섹션 제목: “5.7 챕터 요약 — 핵심 정리”oos_find_best_page는 3단계 드라이버다 — 두 추정 저장소를 조회하고 (oos_stats_find_page_in_bestspace), 한정된 재스캔으로 에스컬레이션하며 (oos_stats_sync_bestspace), 그 이후에야oos_file_alloc_new를 호출한다. 세 가지 header 획득 실패와 sync 후 두 가지 re-fix 실패 모두 alloc으로 낙하하며,ER_INTERRUPTED만이nullptr을 반환한다.- Latch 규율이 모든 단계에서 경합을 회피한다. 탐색자는
LK_FORCE_ZERO_WAIT아래 실행되며PGBUF_CONDITIONAL_LATCH로 후보를 fix하여 바쁜 페이지를 대기 없이 건너뛰고;oos_stats_update는 바쁜 header에서 defer하며; 드라이버는 sync 스캔 전에 header WRITE latch를 해제한다. num_other_high_best / num_pages비율이 재스캔을 제어한다. 스캔은 비율이OOS_BESTSPACE_SYNC_THRESHOLD(0.1) 이상이거나 첫 번째 미스(try_find == 0)일 때만 실행된다 — 거의 꽉 찬 파일에서의 저렴한 삽입은 직접 할당으로 건너뛴다.- 힌트는 사용 전 항상 재검증된다. 캐시/
best[]VPID는 힌트일 뿐이며, Phase C에서spage_max_space_for_new_record로 re-fix하고 재측정한다. 드라이버는 다른 스레드가 간격 사이에 페이지를 채운 경쟁 구간을 닫기 위해 조건부-무조건 re-fix 이후 다시 확인한다. - 추정 저장소는 읽기 시 및 스캔 시 스스로 정리된다. Phase A는 낡거나 header이거나
공간 부족한 캐시 항목을 파괴적으로 정리하며; 낡은
best[]슬롯은freespace가 제자리 교정되거나 전체 스캔 시 null로 만들어지고; 퇴출되었지만 여유 있는 페이지는second_best[]링에 샘플링된다. - 모든 힌트 쓰기는 비로그 방식이다 (header는
log_skip_logging+DONT_FREE; 전역 캐시는 순수 메모리). 두 저장소 모두oos_stats_sync_bestspace로 완전히 재구성 가능하기 때문이다. 크래시는 기껏해야 스캔 한 번의 비용만 발생시키며, 정확성에는 영향이 없다. - 개발자를 위한 알려진 주의점:
oos_stats_sync_bestspace의full_search_vpid재개 지점은 TODO 스텁(항상 인덱스 1에서 재시작)이고,oos_stats_put_second_best는 1000번 중 하나의 퇴출만 샘플링하며,estimates.head는 이 장의 어떤 코드도 건드리지 않는 예약된best[]회전 커서다 — 모두 의도적인 근사/예약이며 버그가 아니다.
Chapter 6: 멀티 청크 체인과 HA 복제
섹션 제목: “Chapter 6: 멀티 청크 체인과 HA 복제”하나의 OOS 페이지에 들어가지 않는 값은 청크 체인으로 분할된다. 이 챕터는 멀티 청크 경로를 다루려는 독자가 반드시 짚어야 할 두 가지 질문에 답한다: oos_insert_across_pages가 디스크에 무엇을 어떤 순서로 쓰는지, 그리고 그 이유 — 그리고 HA 슬레이브가 복제 스트림으로부터 바이트 단위로 동일한 체인을 재조립하는 방법. 단일 청크 프리미티브(oos_insert_within_page)와 bestspace 배치는 Chapter 4·5에서 상세히 다뤘으므로 여기서는 “청크 하나를 삽입하고 그 OID를 반환하는” 블랙박스로 취급한다. 체인 헤더 oos_record_header, 인라인 힙 스텁, 읽기 측 워크(oos_read_across_pages)는 Chapter 1·7에서 다루므로 교차 참조만 한다. 배경 개념은 cubrid-oos.md의 Multi-chunk chain 및 WAL and logging 절과 cubrid-ha-replication.md의 마스터/슬레이브 메커니즘을 참고하라.
6.1 across-pages 경로의 시작점
섹션 제목: “6.1 across-pages 경로의 시작점”oos_insert(공개 진입점, Chapter 4)는 페이로드를 한 페이지에 들어가는 최대 청크 크기와 비교해 경로를 선택한다:
// oos_insert -- src/storage/oos_file.cppconst int src_len = static_cast<int> (src.size ());if (src_len <= oos_get_max_chunk_size_within_page ()) { const OOS_RECORD_HEADER header{src_len, 0, OID_INITIALIZER}; /* single chunk: idx 0, next = NULL */ err = oos_insert_within_page (thread_p, oos_vfid, src, header, oid); }else { err = oos_insert_across_pages (thread_p, oos_vfid, src, oid); /* this chapter */ }oos_get_max_chunk_size_within_page는 청크 크기의 상한을 계산한다. slotted page의 최대 레코드 크기를 OOS_ALIGNMENT(= MAX_ALIGNMENT)로 내림 정렬한 뒤 체인 헤더 크기를 빼서 반환값이 한 청크에 담을 수 있는 순수 페이로드 공간이 되도록 한다:
// oos_get_max_chunk_size_within_page -- src/storage/oos_file.cppconst int actual_upper_limit = DB_ALIGN_BELOW (spage_max_record_size (), OOS_ALIGNMENT);return actual_upper_limit - (int)sizeof (OOS_RECORD_HEADER); /* header eats into every chunk */따라서 청크 하나는 최대 max_chunk_size 바이트의 페이로드와 하나의 OOS_RECORD_HEADER를 담는다. 이 함수에는 알려진 spage_max_record_size 반올림 버그(범위 외 처리로 명시)와 oos_boot()에서 값을 한 번만 계산하도록 최적화하겠다는 TODO가 달려 있지만, 주어진 페이지 크기에서 값은 상수다.
6.2 체인 크기 계산 — required_page_nums
섹션 제목: “6.2 체인 크기 계산 — required_page_nums”oos_insert_across_pages 내부에서 가장 먼저 수행하는 작업은 청크 수를 계산하고, across-pages 경로 진입이 타당함을 단언(assert)하는 것이다:
// oos_insert_across_pages -- src/storage/oos_file.cppconst int max_chunk_size = oos_get_max_chunk_size_within_page ();const int total_data_length = static_cast<int> (src.size ());/* expressions below never add to total_data_length, which can sit near INT_MAX */assert (total_data_length > max_chunk_size - OOS_RECORD_HEADER_SIZE);
int required_page_nums = total_data_length / max_chunk_size;if (total_data_length % max_chunk_size != 0) { ++required_page_nums; /* ceil division: a partial tail chunk still needs a page */ }assert (required_page_nums > 1); /* across-pages means at least 2 chunks */이 산술식은 의도적으로 total_data_length에 값을 더하지 않는 방식으로 작성돼 있다. 인소스 주석이 근거를 명시한다: 호출자가 INT_MAX에 근접한 값을 합법적으로 전달할 수 있으므로(oos_insert 진입 가드는 > INT_MAX만 거부), 진입 assert와 루프의 i * max_chunk_size 오프셋 모두 total_data_length를 직접 증가시키는 표현을 쓰지 않아 signed overflow를 방지한다.
INVARIANT — across-pages는
required_page_nums > 1을 함의한다. 올림 나눗셈(ceil division)과assert (required_page_nums > 1)이 루프가 최소 두 번 실행됨을 보장한다. 호출자가 이 경로로 진입하는 조건은src_len > max_chunk_size이고,max_chunk_size는 이미 헤더를 제외한 순수 페이로드 크기이므로 라우팅 조건이 함수 내 진입 assert보다 엄격하다. 만약 단일 청크 값이 이 경로에 도달했다면, 체인은next_chunk_oid가 NULL이지만chunk_index기록은 꼬리가 있다고 예상하는 헤드를 가진 채로 구성되어 —oos_read_across_pages(Chapter 7)가 인덱스 순서에서 오류를 발생시킬 것이다. 단일 청크 fast path가 존재하는 이유가 바로 이 상황을 막기 위해서다.
6.3 역순 삽입 루프
섹션 제목: “6.3 역순 삽입 루프”설계상 핵심 선택은 청크를 꼬리 먼저, 헤드 마지막(i = N-1에서 0으로)으로 삽입한다는 점이다. 이유는 구조적이다: 각 청크의 헤더에는 읽기 순서상 다음 청크의 OID인 next_chunk_oid가 담겨 있다. 역순으로 쓰면 다음 청크의 OID가 현재 청크를 쓰기 전에 이미 확보돼 있으므로, 어떤 청크도 사후에 패치할 필요가 없다.
디스크에 기록된 체인을 읽기 순서로 나타내면 다음과 같다:
Figure 6-1. 읽기 순서로 표현한 온디스크 청크 체인. 힙에는 헤드 청크 OID만 저장되며, 모든 청크는 total_data_length를 반복 기록한다. 꼬리 청크의 next_chunk_oid는 NULL OID다.
INVARIANT — OID 하나, 레코드 하나: 힙 스텁에는 HEAD 청크 OID만 저장된다.
oos_insert_across_pages는 마지막 (i == 0) 반복 후next_chunk_oid를 반환하는데, 이것이 헤드 청크의 OID다.heap_attrinfo_insert_to_oos(Chapter 1)는 이 단 하나의 OID를 인라인 힙 스텁에 기록한다. N개의 물리적 청크는 그 헤드 OID에서next_chunk_oid를 순방향으로 따라가야만 접근할 수 있으므로, 힙 관점에서 1-청크 값과 N-청크 값은 구별 불가능하다: OID 하나가 논리적 레코드 하나를 지명한다. 읽기 측(oos_read_across_pages)과 슬레이브 applier 모두 이 원칙에 의존하며, 각각 헤드 OID 하나를 전달받아 체인 워크로 나머지를 재조립한다.
함수 전체의 제어 흐름:
flowchart TD
E2["크기 계산, required_page_nums 올림 나눗셈, assert > 1"] --> E3{"track_repl ?"}
E3 -->|"yes"| E4["LOG_DUMMY_OOS_RECORD 추가\ndummy_lsa = tail_lsa\nsuppress 플래그 = true"]
E3 -->|"no"| E5
E4 --> E5["scope_exit 설정 — 어떤 종료 경로든 suppress 플래그 해제"]
E5 --> L1["루프 i = N-1 .. 0: subspan 청크, 꼬리 자동 클램프\n헤더: total_data_length, i, next_chunk_oid"]
L1 --> L4["oos_insert_within_page → current_chunk_oid"]
L4 -->|"오류"| LE["error_code 반환, 호출자 반드시 abort"]
L4 -->|"ok, i == N-1"| L6["tail_chunk_lsa = tail_lsa"]
L6 --> L7["next_chunk_oid = current_chunk_oid; --i"]
L4 -->|"ok, i > 0"| L7
L7 --> L1
L4 -->|"ok, i == 0"| D0["assert total_inserted_length == total_data_length"]
D0 -->|"track_repl"| D2["dummy_lsa, tail_chunk_lsa 푸시; oos_oids.push_back NULL"]
D0 -->|"공통"| D3["oid = next_chunk_oid(HEAD OID); NO_ERROR 반환"]
D2 --> D3
Figure 6-2. oos_insert_across_pages의 분기 완전 제어 흐름.
루프 본문:
// oos_insert_across_pages -- src/storage/oos_file.cppint total_inserted_length = 0;OID next_chunk_oid = OID_INITIALIZER; // the last chunk has null OID as next_chunk_oid// ... condensed: replication preamble (6.4) ...for (int i = required_page_nums - 1; i >= 0; --i) // tail (N-1) down to head (0) { // subspan clamps count to the remaining size, so the tail chunk is automatic. oos_buffer chunk = src.subspan (static_cast<std::size_t> (i * max_chunk_size), static_cast<std::size_t> (max_chunk_size)); total_inserted_length += static_cast<int> (chunk.size ());
// Keep total_data_length in each chunk so the applier can validate before reassembly. OOS_RECORD_HEADER header{total_data_length, i, next_chunk_oid};
OID current_chunk_oid; error_code = oos_insert_within_page (thread_p, oos_vfid, chunk, header, current_chunk_oid); if (error_code != NO_ERROR) { oos_error ("could not insert chunk index=%d of length %zu.", i, chunk.size ()); assert_release_error (er_errid () != NO_ERROR); return error_code; // partial chunks reclaimed by transaction abort, not here }
if (track_repl && i == required_page_nums - 1) // the FIRST iteration is the tail { LSA_COPY (&tail_chunk_lsa, &tdes->tail_lsa); // capture tail's RVOOS_INSERT LSA }
next_chunk_oid = current_chunk_oid; // becomes the NEXT chunk's next_chunk_oid }assert (total_inserted_length == total_data_length);수정자가 반드시 지켜야 할 세 가지 세부 사항:
- 꼬리 청크의 더 짧은 길이는 자동으로 처리된다.
subspan(offset, count)는count를 남은 바이트 수로 클램프한다(max_len = _size - offset;span.hpp의span<T>::subspan참조, 기본count는(size_type)-1). 최고 인덱스i = N-1에서는 오프셋i * max_chunk_size이후 바이트가max_chunk_size보다 적으므로 반환된 청크는 정확히 나머지 데이터가 된다. 꼬리에 대한 특별 처리는 없으며 span이 이를 처리한다. next_chunk_oid는 구성 과정에서 항상 올바르다. NULL OID(꼬리의 “next”)로 시작해서 각 반복의 끝에서 방금 쓴 OID가 된다. 루프가 내림 방향으로 진행되므로 다음 반복(더 낮은 인덱스, 더 앞쪽 청크)은 읽기 순서상 그 뒤에 오는 청크의 OID를 볼 수 있다. 역방향 루프의 존재 이유가 바로 이것이다.- 모든 청크는 자신의 길이가 아닌
total_data_length를 기록한다. 헤더의 첫 번째 필드는 전체 값의 길이이며, 모든 청크에 반복된다. 읽기 측과 슬레이브 applier 모두 체인을 신뢰하기 전에 조립된 값을 검증하는 데 이 값을 사용한다(6.6, Chapter 7).chunk_index(i)는 청크별 식별자다.
INVARIANT — 성공 시
total_inserted_length == total_data_length. 마지막assert로 강제된다. 각 반복이chunk.size()를 더하고,subspan이 꼬리를 클램프하며 루프가 정확히 인덱스0..N-1을 커버하므로 합계는 반드시src.size()와 일치해야 한다. 향후 청킹 수식 변경으로 커버리지가 깨진다면 이 assert가 잘리거나 중복된 체인이 디스크에 도달하기 전에 발화한다.
오류 경로. oos_insert_within_page가 실패하면 함수는 즉시 오류를 반환한다. 이미 쓴 청크는 여기서 롤백하지 않는다 — 각 oos_insert_within_page는 전체 undo 데이터를 담은 자체 RVOOS_INSERT를 기록했으므로(Chapter 4/8), 트랜잭션 abort가 해당 undo를 역순으로 재실행해 부분 체인을 제거한다. 인소스 주석은 이 오류 이후 호출자가 트랜잭션을 계속해서는 안 된다고 명시한다. scope_exit(6.4)는 이 경로에서도 실행되어 suppression 플래그를 해제한다.
6.4 HA 복제 프리앰블과 oos_needs_repl_tracking
섹션 제목: “6.4 HA 복제 프리앰블과 oos_needs_repl_tracking”멀티 청크 값은 하나의 논리적 컬럼 값이지만 N개의 물리적 RVOOS_INSERT 로그 레코드다. 슬레이브가 각각을 독립적인 OOS insert로 재실행하면 하나의 체인 대신 N개의 무관한 OOS OID를 할당하게 된다. 마스터는 이를 경계 마커로 실행 구간을 묶고 헤드 OID만 복제 대기열에 넣음으로써 방지한다.
이 전체 장치는 oos_needs_repl_tracking으로 제어되며 — 마스터 전용이다:
// oos_needs_repl_tracking -- src/storage/oos_file.cppstatic booloos_needs_repl_tracking (THREAD_ENTRY *thread_p){ return !LOG_CHECK_LOG_APPLIER (thread_p) && log_does_allow_replication () == true;}LOG_CHECK_LOG_APPLIER는 이 스레드가 슬레이브의 log applier일 때 true가 된다; 슬레이브는 복제 데이터를 재실행하는 동안 자체 OOS 마커를 발행해서는 안 된다. log_does_allow_replication()은 standalone 서버에서 false다. 복제가 활성화된 마스터이면서 applier 자신이 아닌 경우에만 true를 반환한다. 이 두 조건 게이트는 locator_sr.c가 OOS repl 로그를 발행하기 전에 사용하는 것과 동일하며, OOS 경로에도 필요하므로 oos_file.cpp 내 헬퍼로 분리됐다.
tracking이 켜져 있으면, 루프 전에 프리앰블이 실행된다:
// oos_insert_across_pages -- src/storage/oos_file.cpptrack_repl = oos_needs_repl_tracking (thread_p);if (track_repl) { const int tran_index = LOG_FIND_THREAD_TRAN_INDEX (thread_p); tdes = LOG_FIND_TDES (tran_index); if (tdes == NULL) { er_set (ER_FATAL_ERROR_SEVERITY, ARG_FILE_LINE, ER_LOG_UNKNOWN_TRANINDEX, 1, tran_index); return ER_LOG_UNKNOWN_TRANINDEX; // no tdes: cannot track; bail before any chunk } log_append_empty_record (thread_p, LOG_DUMMY_OOS_RECORD, NULL); // boundary marker, no payload LSA_COPY (&dummy_lsa, &tdes->tail_lsa); // remember WHERE the marker landed tdes->oos_suppress_insert_lsa_queueing = true; // intermediate chunks: do NOT auto-queue }
scope_exit clear_oos_repl_state ([&](){ if (track_repl && tdes != NULL) { tdes->oos_suppress_insert_lsa_queueing = false; // ALWAYS cleared, every exit path }});각 구성 요소의 역할:
-
LOG_DUMMY_OOS_RECORD(log_record.hpp에서= 53)는 빈 WAL 레코드다 —log_append_empty_record는 데이터 없이 헤더만 기록한다. 유일한 목적은 슬레이브에게 “다음 OOS insert는 독립적인 단일 청크 insert가 아니라 멀티 청크 레코드의 헤드”라는 마커를 제공하는 것이다.dummy_lsa는 append 직후tdes->tail_lsa에서 마커의 LSA를 캡처한다. -
**
tdes->oos_suppress_insert_lsa_queueing = true**는RVOOS_INSERT당 자동 LSA 큐잉을 끈다. 이 자동 push는 구조적으로 동일한 두 곳 —log_manager.c의log_append_undoredo_crumbs와log_append_redo_crumbs— 에 있다:// log_append_undoredo_crumbs (RVOOS_INSERT auto-queue site) -- src/transaction/log_manager.celse if (rcvindex == RVOOS_INSERT && !tdes->oos_suppress_insert_lsa_queueing){tdes->oos_insert_lsa_queue.push (tdes->tail_lsa); // skipped for chunks while flag is setassert (tdes->is_active_worker_transaction ());}플래그가 설정된 상태에서는 N개의 청크 insert 중 어느 것도 자신의 LSA를 push하지 않는다; 함수는 원하는 두 LSA만 직접 큐에 넣는다(6.5).
-
**
scope_exit**는 suppression 플래그가 모든 반환 경로 — 루프 중간의 오류 반환, 정상 반환, 스택 unwind — 에서 해제됨을 보장한다. 이로써 플래그가 다음 log append로 누출되는 것을 막는다.
INVARIANT —
oos_suppress_insert_lsa_queueing은 정확히 하나의 멀티 청크 insert 동안만 true다. 루프 직전에 설정되고 어떤 종료 경로에서도scope_exit에 의해 해제된다. 이 값이 true로 누출되면 동일 트랜잭션 내 이후의 단일 청크 OOS insert들이 소리 없이 LSA 큐잉에 실패해 슬레이브가 해당 값들을 놓치게 된다. 루프 이후 단순 대입이 아닌scope_exit— 바로 이것이 6.3의 오류 반환에서도 이 불변식을 지키게 하는 장치다.
6.5 꼬리 LSA 캡처와 최종 핸드오프
섹션 제목: “6.5 꼬리 LSA 캡처와 최종 핸드오프”applier는 꼬리 청크의 LSA가 필요하다: 마스터는 꼬리 먼저 기록하므로 꼬리의 RVOOS_INSERT가 마커 이후 첫 번째 청크 레코드이며, 슬레이브가 재조립의 기준으로 삼는 WAL 앵커다. 루프 내에서 가장 첫 번째 반복은 i == required_page_nums - 1 — 꼬리 — 이며, 그 시점에 tail_chunk_lsa는 꼬리 청크의 RVOOS_INSERT가 추가된 직후 tdes->tail_lsa에서 캡처된다.
루프 이후 tracking이 켜져 있으면, 함수는 두 LSA를 큐에 넣고 NULL OID 플레이스홀더를 push한다:
// oos_insert_across_pages -- src/storage/oos_file.cppif (track_repl) { tdes->oos_insert_lsa_queue.push (dummy_lsa); // pairs with the dummy marker tdes->oos_insert_lsa_queue.push (tail_chunk_lsa); // pairs with the head-OID insert thread_p->oos_oids.push_back (oid_Null_oid); // placeholder; caller pushes real head OID }oid = next_chunk_oid; // after the loop next_chunk_oid holds the HEAD (idx 0) chunk OIDreturn NO_ERROR;출력 파라미터 oid는 next_chunk_oid이며, 마지막 반복(i == 0) 이후에는 헤드 청크의 OID를 담는다 — 힙 스텁에 기록되는 바로 그 값이다(Chapter 1).
이 비대칭성은 의도된 것이며 프로토콜의 핵심이다: 이 함수는 thread_p->oos_oids에 실제 헤드 OID가 아닌 oid_Null_oid를 push한다. 직접 호출자인 heap_attrinfo_insert_to_oos(heap_file.c)가 oos_insert가 반환된 직후에 실제 헤드 OID를 push한다:
// heap_attrinfo_insert_to_oos -- src/storage/heap_file.cif (oos_insert (thread_p, oos_vfid, oos_buffer (recdes.data, (size_t) recdes.length), oos_oid) != NO_ERROR) { goto error_oos; }thread_p->oos_oids.push_back (oos_oid); /* for replication log */(*oos_oids)[i] = oos_oid; // also recorded in the per-column out-vectoroos_push_oos_oid는 동일한 연산(thread_p->oos_oids.push_back (*oid))의 standalone 접근자로, 이 인라인 append 밖에서 OID를 갖는 호출자가 사용한다. heap_attrinfo_insert_to_oos는 행 단위 작업 시작 시 tdes->oos_insert_lsa_queue와 thread_p->oos_oids 모두를 리셋하므로, 두 벡터는 정확히 하나의 행에 대한 OOS insert를 기술한다.
따라서 하나의 멀티 청크 값에 대한 트랜잭션 단위 상태 최종 모습은:
oos_oids : [ ..., oid_Null_oid, real_head_oid ]oos_insert_lsa_queue : [ ..., dummy_lsa, tail_chunk_lsa ]두 구조는 위치 기반으로 쌍을 이룬다. 단일 청크 값은 NULL이 아닌 OID 하나와 LSA 하나(suppression이 설정되지 않았으므로 자동 큐잉에서 발생)를 기여한다; 멀티 청크 값은 (null, real) OID 쌍과 (dummy, tail) LSA 쌍을 기여한다. 이 쌍 대응 관계는 oos_insert_across_pages 위 헤더 주석에 그대로 문서화돼 있다.
6.6 큐잉된 상태에서 RVREPL 항목으로, 그리고 슬레이브 재조립
섹션 제목: “6.6 큐잉된 상태에서 RVREPL 항목으로, 그리고 슬레이브 재조립”복제 로그 발행 시점(locator_sr.c에서 primary-key 힙 insert 이후), 마스터는 thread_p->oos_oids를 순회하며 OID가 NULL인지 검사해 항목 유형을 결정한다:
// oos repl emission loop (INSERT) -- src/transaction/locator_sr.cfor (int i = 0; i < (int) thread_p->oos_oids.size (); i++) { LOG_RCVINDEX oos_repl_rcvindex = OID_ISNULL (&thread_p->oos_oids[i]) ? RVREPL_DUMMY_OOS_RECORD : RVREPL_OOS_INSERT; // null -> marker error_code = repl_log_insert (thread_p, class_oid, &thread_p->oos_oids[i], LOG_REPLICATION_DATA, oos_repl_rcvindex, key_dbvalue, REPL_INFO_TYPE_RBR_NORMAL); // ... condensed: error check ... }repl_log_insert(replication.c 경유)는 정확히 이 두 항목 유형에 **팝(pop)**으로 큐에서 LSA를 꺼내 첨부한다:
// repl_log_insert oos lsa attach -- src/transaction/replication.ccase RVREPL_OOS_INSERT:case RVREPL_DUMMY_OOS_RECORD: if (!tdes->oos_insert_lsa_queue.is_empty ()) { LOG_LSA oos_lsa = tdes->oos_insert_lsa_queue.pop (); // FIFO: dummy first, then tail LSA_COPY (&repl_rec->lsa, &oos_lsa); } else { assert (false); // queue/vector desync = bug } break;큐에 [dummy_lsa, tail_chunk_lsa] 순으로 push됐고 OID 벡터도 [null, real] 순으로 담겨 있으므로, FIFO pop은 dummy_lsa를 RVREPL_DUMMY_OOS_RECORD 항목과, tail_chunk_lsa를 RVREPL_OOS_INSERT 항목과 올바르게 짝짓는다. 하나의 멀티 청크 값에 대한 최종 발행:
flowchart LR M0["마스터의<br/>멀티 청크 값"] --> M1["RVREPL_DUMMY_OOS_RECORD<br/>lsa = dummy_lsa<br/>oid = NULL"] M1 --> M2["RVREPL_OOS_INSERT<br/>lsa = tail_chunk_lsa<br/>oid = real head OID"] M2 --> M3["힙 행 복제 항목"] M3 -.->|"슬레이브로 전송"| S0 S0["la_apply_dummy_oos_log<br/>need_oos_rebuild = true"] --> S1["la_apply_oos_insert_log<br/>rebuild_oos = true"] S1 --> S2["la_rebuild_oos_recdes: target_lsa 기준\nN개 청크 재조립\ntotal_data_length 검증"] S2 --> S3["슬레이브에 하나의 OOS 체인\n마스터와 바이트 동일"]
Figure 6-3. 멀티 청크 값 하나가 DUMMY + INSERT 복제 항목 하나씩을 생성하고, 슬레이브는 단일 체인으로 재조립한다.
슬레이브(log_applier.c)에서 la_apply_dummy_oos_log는 apply->need_oos_rebuild = true로 설정하고, 이미 설정돼 있었다면 오류를 발생시킨다 — 중간에 insert 없이 두 마커가 연속되면 데이터 오염이다. 다음 RVREPL_OOS_INSERT 항목은 rebuild_oos = apply->need_oos_rebuild로 la_apply_oos_insert_log를 실행하며, 이는 la_rebuild_oos_recdes (&item->target_lsa, recdes, &oos_head_oid)를 호출한다. 해당 워크는 target_lsa(마스터의 꼬리 청크 LSA)에서 앵커된 WAL 청크 레코드들로부터 OOS RECDES 하나를 재조립하며, 반복되는 total_data_length 값을 기준으로 세 가지 검사를 수행한다: 모든 청크의 값이 일치해야 하고(mismatched multi-chunk OOS total size), 누적 본문 합계가 초과하지 않아야 하며(body length exceeds total size), 최종 합계가 정확히 일치해야만(incomplete multi-chunk OOS record) 병합된 RECDES를 구성한다. 또한 마스터의 chunk_index == 0 헤드 OID(oos_head_oid)를 캡처하지만, 이는 sql.log 캐시 키로만 사용되며 슬롯 할당에는 쓰이지 않는다; 슬레이브는 자체적으로 새 slotid를 할당한다. apply->need_oos_rebuild는 la_apply_oos_insert_log의 end: 레이블에서 false로 리셋된다. 반면 단일 청크 값은 need_oos_rebuild가 false인 단독 RVREPL_OOS_INSERT로 도착하며 재조립 없이 target_lsa에서 직접 읽힌다. 따라서 슬레이브는 물리적 청크 수에 관계없이 값 하나당 정확히 하나의 논리적 OOS-insert를 처리한다 — 이것이 이 챕터의 핵심 결론이다.
동일한 (null, real) / (dummy, tail) 쌍 방식은 UPDATE 복제 경로에서도 재사용된다(locator_sr.c의 두 번째 thread_p->oos_oids 순회, 동일한 OID_ISNULL 삼항 표현), 따라서 대형 컬럼을 다시 쓰는 UPDATE도 동일하게 복제된다 — RVREPL_OOS_INSERT 항목은 la_apply_oos_insert_log의 applier 주석이 명시하듯 INSERT와 UPDATE 모두에서 힙 행 항목 앞에 위치한다.
6.7 청크 헤더 스펙 TODO
섹션 제목: “6.7 청크 헤더 스펙 TODO”oos_insert에는 within-page / across-pages 분기 바로 위에 상시 TODO가 있다:
// oos_insert -- src/storage/oos_file.cpp// TODO: Once the OOS_RECORD_HEADER spec is finalized (first segment header and rest segment header),// review whether it is possible to generate the segment headers inside the oos_insert_within_page() and// oos_insert_across_pages() functions.의도는 체인이 최종적으로 first-segment 헤더(헤드 청크, 체인 전체 메타데이터 포함)와 rest-segment 헤더(내부/꼬리 청크)를 구별할 수 있게 될 것이라는 점이다. 현재 리비전에서는 모든 청크가 동일한 oos_record_header 레이아웃(total_data_length, chunk_index, next_chunk_oid)을 사용하며 — Chapter 1 참조 — 헤더는 호출자(oos_insert: 단일 청크, oos_insert_across_pages: 체인)가 구성해 oos_insert_within_page에 전달한다. 수정자는 헤더 형식을 아직 확정되지 않은 것으로 취급해야 한다: 스펙이 두 종류의 헤더로 나뉜다면 6.1·6.3의 구성 지점, oos_read_across_pages(Chapter 7)의 검증, la_rebuild_oos_recdes가 모두 함께 변경된다. (설계 참조: Notion CBRD-26628.)
6.8 챕터 요약 — 핵심 정리
섹션 제목: “6.8 챕터 요약 — 핵심 정리”- across-pages 경로는 단일 청크 프리미티브 위의 블랙박스 루프다.
oos_insert_across_pages는oos_get_max_chunk_size_within_page(페이지 최대값, 내림 정렬, 체인 헤더 차감)로required_page_nums를 계산하고,> 1을 단언한 뒤, 청크당 한 번씩oos_insert_within_page를 호출한다 — 자체적인 디스크 레이아웃 로직은 없다. - 역방향 루프(
i = N-1 .. 0)는 청크를 쓰기 전에next_chunk_oid를 항상 알 수 있도록 존재한다. 꼬리에서 헤드로 진행하면 앞선 청크의 헤더에 읽기 순서상 다음 청크의 OID를 사후 패치 없이 삽입할 수 있다.subspan이 꼬리 청크의 길이를 자동으로 클램프한다. - OID 하나, 레코드 하나: 헤드 청크 OID만 힙에 도달한다. 모든 청크는
total_data_length를 반복하고,chunk_index만 다르다. 헤드 OID가 전체 체인을 지명하며,oos_read_across_pages(Chapter 7)와 슬레이브 applier 모두 그 OID에서 체인 워크를 시작한다. 마지막assert (total_inserted_length == total_data_length)가 마스터 측 커버리지 검사다. - HA tracking은 마스터 전용이며 구간이 명확하다.
oos_needs_repl_tracking이!LOG_CHECK_LOG_APPLIER && log_does_allow_replication()으로 모든 것을 제어한다.LOG_DUMMY_OOS_RECORD마커가 먼저 추가되고(그 LSA가dummy_lsa로 저장),oos_suppress_insert_lsa_queueing이log_append_undoredo_crumbs/log_append_redo_crumbs의 청크별 자동 큐잉을 끄며,scope_exit이 오류 반환을 포함한 모든 종료 시 플래그를 해제함을 보장한다. - 손으로 큐에 넣는 것은 두 LSA와 NULL-OID 플레이스홀더뿐이다. 루프 이후 함수는
dummy_lsa,tail_chunk_lsa를oos_insert_lsa_queue에 push하고oid_Null_oid를oos_oids에 push한다; 호출자(heap_attrinfo_insert_to_oos)가 이어서 실제 헤드 OID를 추가해 위치 기반 쌍 벡터oos_oids=[…,null,real]/queue=[…,dummy,tail]을 완성한다. - 마스터는 값 하나당
RVREPL_DUMMY_OOS_RECORD하나와RVREPL_OOS_INSERT하나를 발행한다.locator_sr.c는OID_ISNULL로 항목 유형을 결정하고,replication.c는 FIFO pop으로dummy_lsa를 마커에,tail_chunk_lsa를 실OID insert에 첨부한다. INSERT·UPDATE 발행 루프는 동일하다. - 슬레이브는 값 하나당 하나의 체인을 재조립한다.
la_apply_dummy_oos_log가need_oos_rebuild를 올리고; 이어지는la_apply_oos_insert_log가la_rebuild_oos_recdes를 호출해 청크별total_data_length를 검증(일치, 오버플로 없음, 정확한 합계)한 뒤 병합 RECDES를 구성한다 — 결과적으로 1-청크 값과 N-청크 값은 applier에게 동일하게 보인다(논리적 insert 하나). 청크 헤더 형식은oos_insert의 열린 스펙 TODO로 아직 확정되지 않았다.
Chapter 7: SELECT와 재인라인화
섹션 제목: “Chapter 7: SELECT와 재인라인화”독자 질문: 읽기 시 외부화된 값은 어떻게 가져와서, OOS를 쓴 적 없는 레코드처럼 보이도록 다시 끼워 넣는가? 이 챕터는 읽기 측 재구성 경로를 처음부터 끝까지 추적한다 — 옵트인 fetch 래퍼에서 시작해 heap_oos.cpp의 4단계 재빌드를 거쳐 oos_file.cpp의 체인 워크까지 내려간 뒤, int를 반환하는 두 읽기 헬퍼(oos_get_length와 attrinfo 옵트아웃 경로)와 그 실패가 어떻게 전파되는지를 마무리로 다룬다. 역방향(INSERT) 경로는 챕터 4를 참고하고, 인라인 스텁 레이아웃과 두 개의 플래그 비트(HAS_OOS, IS_OOS)는 상위 동반 문서 cubrid-oos.md의 Two flags 및 The inline stub 절을 참고하라 — 이 챕터는 두 내용 모두를 이미 알고 있다고 가정한다.
재구성 전체는 하나의 설계 약속을 따른다: 출력 레코드는 OOS를 전혀 쓰지 않은 레코드와 바이트 단위로 구별할 수 없다. 클래스 표현식을 전혀 참조하지 않고 oos_read와 레코드 내 가변 오프셋 테이블(VOT)만으로 구성되므로, 스키마 변경에 안전하고 오버헤드가 낮다.
7.1 옵트인: heap_get_visible_version_expand_oos와 두 개의 디스패치 지점
섹션 제목: “7.1 옵트인: heap_get_visible_version_expand_oos와 두 개의 디스패치 지점”스캔 기계는 기본적으로 OOS를 펼치지 않는다. 실제 바이트가 필요한 읽기는 컨텍스트 플래그 하나를 설정하는 fetch 래퍼를 거쳐야 한다:
// heap_get_visible_version_expand_oos -- src/storage/heap_file.cheap_init_get_context (thread_p, &context, oid, class_oid, recdes, scan_cache, ispeeking, old_chn);context.expand_oos = true; /* <- the only difference vs the plain wrapper */scan = heap_get_visible_version_internal (thread_p, &context, false);expand_oos는 HEAP_GET_CONTEXT(heap_file.h)의 bool 필드로, *“true이면 인라인 OOS OID 슬롯을 실제 값으로 교체한다”*는 주석이 붙어 있다. 이를 펼치지 않는 형제 함수 heap_get_visible_version은 heap_init_get_context 기본값(false)을 그대로 두어, 인라인 스텁이 fetch를 통과한 뒤에도 손대지 않은 채 남는다 — 챕터 8–9의 스캔/인덱스 경로와 eager-cleanup 경로가 바로 이 동작에 의존한다.
래퍼 위에는 살아 있는
TODO (CBRD-26847)가 있다: 많은 호출자가heap_get_visible_version에서 기계적으로 이관된 것으로, 실제로 펼치기가 필요하지 않을 수도 있다. 호출 지점들은 아직 감사가 완료되지 않은 것으로 간주해야 한다.
이 플래그는 heap_get_record_data_when_all_ready 내부에서 소비된다. 이 함수에는 raw spage_get_record 이후 heap_record_replace_oos_oids를 호출하는 두 개의 디스패치 지점이 있으며, 각각 인라인 컬럼을 보유하는 데이터 레코드 타입에 해당한다:
// heap_get_record_data_when_all_ready -- src/storage/heap_file.cswitch (context->record_type) { case REC_RELOCATION: /* <- forward (REC_NEWHOME) data lives on fwd page */ // ... condensed: allocate recdes area, spage_get_record from fwd_page_watcher (COPY) ... scan = spage_get_record (thread_p, context->fwd_page_watcher.pgptr, context->forward_oid.slotid, context->recdes_p, COPY); if (scan != S_SUCCESS) { return scan; } return heap_record_replace_oos_oids (thread_p, context); /* <- dispatch #1 */
case REC_BIGONE: return heap_get_bigone_content (...); /* <- no OOS expansion: overflow is a different path */ case REC_HOME: // ... condensed: allocate recdes area when COPY ... scan = spage_get_record (thread_p, context->home_page_watcher.pgptr, context->oid_p->slotid, context->recdes_p, context->ispeeking); if (scan != S_SUCCESS) { return scan; } return heap_record_replace_oos_oids (thread_p, context); /* <- dispatch #2 */ default: break; }return S_ERROR; /* <- only data-bearing types are expected to arrive */REC_BIGONE(기존 OVF 오버플로 레코드)은 펼치지 않는다 — OOS와 OVF는 서로 분리된 메커니즘이다(동반 문서 Theoretical Background 참고). default 폴스루는 S_ERROR를 반환한다.
flowchart TD
W["heap_get_visible_version_expand_oos\ncontext.expand_oos = true 설정"] --> GVI["heap_get_visible_version_internal"]
GVI --> ALL["heap_get_record_data_when_all_ready"]
ALL --> T{"record_type"}
T -- REC_RELOCATION --> R1["fwd 페이지에서 spage_get_record COPY"] --> RR["heap_record_replace_oos_oids"]
T -- REC_HOME --> R2["home 페이지에서 spage_get_record"] --> RR
T -- REC_BIGONE --> BIG["heap_get_bigone_content\nOOS 펼치기 없음"]
T -- default --> ERR["S_ERROR"]
Figure 7-1. fetch 래퍼에서 두 OOS 디스패치 지점까지.
7.2 heap_record_replace_oos_oids: 게이트와 스냅샷
섹션 제목: “7.2 heap_record_replace_oos_oids: 게이트와 스냅샷”이것이 읽기 측 진입점이다. 어떤 작업도 시작하기 전에 세 개의 빠른 탈출 경로가 있고, 그 뒤에 try로 감싸진 4단계 본문이 온다:
// heap_record_replace_oos_oids -- src/storage/heap_oos.cppRECDES *rec = context->recdes_p;if (!context->expand_oos) { return S_SUCCESS; } /* <- opt-out: caller handles OOS itself */assert (rec != NULL && rec->data != NULL && rec->length > 0);if (rec == NULL || rec->data == NULL || rec->length <= 0) { er_set (...); return S_ERROR; } /* <- release-build guard mirroring the assert */if (!heap_recdes_contains_oos (rec)) { return S_SUCCESS; } /* <- HAS_OOS not set: record already final */try { std::vector<char> src_buf (rec->data, rec->data + rec->length); /* <- snapshot BEFORE realloc */ HEAP_OOS_EXPAND_STATE state = { }; state.src = src_buf.data (); state.src_length = (int) src_buf.size (); state.src_offset_size = OR_GET_OFFSET_SIZE (state.src); /* <- honor the record's own offset width */ state.src_header_size = OR_HEADER_SIZE ((char *) state.src); if (heap_oos_parse_vot (&state) != NO_ERROR) { return S_ERROR; } /* phase 1 */ state.oos_blobs.resize (state.n_var); if (heap_oos_read_blobs (thread_p, &state) != NO_ERROR) { return S_ERROR; } /* phase 2 */ if (heap_oos_compute_layout (&state) != NO_ERROR) { return S_ERROR; } /* phase 3 */ return heap_oos_build_record (thread_p, context, &state); /* phase 4 */} catch (std::bad_alloc &) { er_set (..., ER_OUT_OF_VIRTUAL_MEMORY, 1, (size_t) rec->length); return S_ERROR;}두 개의 저렴한 탈출 경로 — !expand_oos와 !heap_recdes_contains_oos — 덕분에 일반적인 경우(OOS 없음, 또는 호출자가 옵트아웃)는 플래그 테스트 한 번으로 끝난다. heap_recdes_contains_oos는 단락 검사다:
// heap_recdes_contains_oos -- src/storage/heap_file.cint flag = (INT32) OR_GET_MVCC_FLAG (record->data);return flag & OR_MVCC_FLAG_HAS_OOS; /* <- the HAS_OOS bit of the MVCC header flags */불변 조건 — 소스 바이트는 재할당 이전에 스냅샷된다.
src_buf는 단계 4가 스캔 캐시로rec->data를realloc하거나 재할당하기 전에rec->data의 전체 복사본으로 만들어진다. 모든 단계는state.src(=src_buf.data())에서만 읽고, 단계 4는 (재할당될 수도 있는)rec->data에만 쓴다. 스냅샷을 건너뛰었다면assign_recdes_to_area가 재빌드 도중에도 읽고 있는 버퍼를 해제할 수 있으며, 이는 데이터 의존적이어서 재현이 거의 불가능한 use-after-free 손상을 낳는다.std::vector는bad_alloc경로에서 RAII 정리도 보장한다. 대칭되는 불변 조건은 단계 4의 목적지에도 적용된다: dst 오프셋 크기는src_offset_size와 무관하게 항상 4바이트로 강제된다.
HEAP_OOS_EXPAND_STATE 구조체는 모든 단계 간 데이터를 연결한다. 읽기 경로에서 각 필드의 역할은 다음과 같다:
| 필드 | 읽기 경로에서의 역할 |
|---|---|
src / src_length | 스냅샷을 가리키는 포인터와 바이트 길이; 모든 VOT/슬롯 읽기의 경계 검사 상한 |
src_offset_size | OR_GET_OFFSET_SIZE로 구한 각 소스 VOT 항목의 너비(1/2/4바이트); 파서가 이 값에 맞춰 읽어야 함 |
src_header_size | 소스의 OR_HEADER_SIZE; 모든 VOT 및 값 오프셋의 기준 |
n_var | LAST_ELEMENT 센티넬의 인덱스 = 가변 속성의 개수; 모든 단계의 반복 상한 |
new_length | 재빌드된 레코드의 총 길이; realloc 결정과 최종 rec->length 계산에 사용 |
src_vot_bytes / dst_vot_bytes | src_offset_size 기준 소스 VOT 크기; 4바이트 너비 기준 목적지 VOT 크기 |
fixed_bitmap_bytes | VOT와 첫 번째 가변 값 사이의 고정 속성 + 바운드 비트 비트맵; 그대로 복사됨 |
vot_raw | 플래그 비트를 포함한 모든 raw VOT 항목(센티넬 포함); 한 번 파싱 후 모든 단계에서 재사용 |
oos_blobs | 인덱스별 펼쳐진 바이트; OOS 인덱스에서만 채워지며, 단계 2와 단계 4 사이에서 가져온 값을 보관 |
7.3 단계 1 — heap_oos_parse_vot: VOT 순회, 센티넬 탐색
섹션 제목: “7.3 단계 1 — heap_oos_parse_vot: VOT 순회, 센티넬 탐색”파서는 src_offset_size를 존중하며 소스 VOT를 한 항목씩 읽고, 각 raw 항목(플래그 비트 포함)을 수집하다가 OR_IS_LAST_ELEMENT 센티넬에서 멈춘다:
// heap_oos_parse_vot -- src/storage/heap_oos.cppconst char *src_vot = (const char *) OR_GET_OBJECT_VAR_TABLE (state->src);const int capacity = (state->src_length - state->src_header_size) / state->src_offset_size;state->vot_raw.reserve (capacity + 1);state->n_var = -1;for (int i = 0; i <= capacity; ++i) { if (i == capacity) { /* <- ran off the end without a sentinel */ assert_release (false && "VOT sentinel (LAST_ELEMENT) not found within record bounds"); er_set (...); return ER_FAILED; } int raw; const char *ep = src_vot + i * state->src_offset_size; switch (state->src_offset_size) { /* <- decode at the record's own width */ case OR_BYTE_SIZE: raw = OR_GET_BYTE (ep); break; case OR_SHORT_SIZE: raw = OR_GET_SHORT (ep); break; case OR_INT_SIZE: raw = OR_GET_INT (ep); break; default: assert_release (false); er_set (...); return ER_FAILED; /* <- impossible width */ } state->vot_raw.push_back (raw); /* <- keep flag bits, do NOT mask them off */ if (OR_IS_LAST_ELEMENT (raw)) { state->n_var = i; break; }}if (state->n_var <= 0) { /* <- HAS_OOS set but zero variable attrs */ assert_release (false && "OOS flag set without variable attributes"); er_set (...); return ER_FAILED;}return NO_ERROR;분기 목록: i == capacity 분기(바이트 경계 안에 센티넬 없음), default switch 분기(오프셋 너비가 1/2/4가 아님), n_var <= 0(HAS_OOS는 설정됐으나 가변 영역 없음)는 모두 assert_release + ER_GENERIC_ERROR — 정형적인 레코드에서는 발생할 수 없으므로 일반 오류가 아닌 손상으로 처리한다. vot_raw는 최종적으로 n_var + 1개의 항목을 가진다: 인덱스 0 .. n_var-1은 실제 속성, 인덱스 n_var는 센티넬이다(단계 3/4에서 “다음 오프셋” 상한으로 사용).
7.4 단계 2 — heap_oos_read_blobs: 각 스텁 디코딩 및 fetch
섹션 제목: “7.4 단계 2 — heap_oos_read_blobs: 각 스텁 디코딩 및 fetch”모든 가변 인덱스를 루프하면서 OOS가 아닌 항목을 건너뛰고, OOS 항목은 16바이트 인라인 슬롯을 경계 검사한 뒤 OID + 길이를 디코딩하고, 길이를 검증하여 목적지 크기를 잡고 oos_read를 호출한다:
// heap_oos_read_blobs -- src/storage/heap_oos.cppfor (int i = 0; i < state->n_var; ++i) { if (!OR_IS_OOS (state->vot_raw[i])) { continue; } /* <- inline column, untouched */ const int value_offset = state->src_header_size + OR_GET_VAR_OFFSET (state->vot_raw[i]); if (value_offset + OR_OOS_INLINE_SIZE > state->src_length) { /* <- 16B slot past record end */ assert_release (false && "OOS inline slot extends past record bounds"); er_set (...); return ER_FAILED; } OID oos_oid = OID_INITIALIZER; DB_BIGINT oos_len = 0; int rc = NO_ERROR; OR_BUF buf; or_init (&buf, (char *) state->src + value_offset, OR_OOS_INLINE_SIZE); if (or_get_oid (&buf, &oos_oid) != NO_ERROR || OID_ISNULL (&oos_oid)) { /* <- bad/null OID */ assert_release (false && "failed to read OOS OID from inline slot"); er_set (...); return ER_FAILED; } oos_len = or_get_bigint (&buf, &rc); if (rc != NO_ERROR || oos_len <= 0 || oos_len > (DB_BIGINT) DB_MAX_STRING_LENGTH) { /* <- length sanity */ assert_release (false && "invalid OOS inline length"); er_set (...); return ER_FAILED; } state->oos_blobs[i].resize ((std::size_t) oos_len); /* <- size the dest from the stub */ if (oos_read (thread_p, oos_oid, oos_buffer (state->oos_blobs[i].data (), (std::size_t) oos_len)) != NO_ERROR) { oos_error ("oos_read failed for OID %d|%d|%d", OID_AS_ARGS (&oos_oid)); er_set (...); return ER_FAILED; }}return NO_ERROR;인라인 슬롯은 정확히 OR_OOS_INLINE_SIZE(OR_OID_SIZE + OR_BIGINT_SIZE = 16) 바이트에 걸친 OR_BUF로 읽는다: or_get_oid가 8바이트 OID를 소비하고, or_get_bigint가 8바이트 길이를 소비한다 — INSERT 측이 기록한 레이아웃 그대로다(동반 문서 The inline stub 참고). (0, DB_MAX_STRING_LENGTH] 범위 검증은 resize 이전에 0, 음수, 비정상적 값을 거부하므로, 손상된 스텁이 수 기가바이트 할당을 유도할 수 없다. 목적지는 정확히 oos_len 크기로 잡힌다 — 이것이 oos_read가 체인 헤더와 교차 검증하는 버퍼 크기 계약의 읽기 측 절반이다(§7.7). 분기: 네 개의 손상 assert(슬롯 경계 초과, 잘못된 OID, 잘못된 길이, oos_read 실패) + 인라인 컬럼을 위한 continue; oos_read 실패는 실제 운영 중 오류(예: OOS 페이지 누락)에서 발생하는 경우로, 실제 오류 코드를 전파한다.
7.5 단계 3 — heap_oos_compute_layout: 4바이트 오프셋과 INT_MAX 가드
섹션 제목: “7.5 단계 3 — heap_oos_compute_layout: 4바이트 오프셋과 INT_MAX 가드”이 단계는 아무것도 쓰지 않고 목적지 지오메트리만 계산한다. 목적지 VOT는 항상 4바이트(BIG_VAR_OFFSET_SIZE)다:
// heap_oos_compute_layout -- src/storage/heap_oos.cppconst int dst_offset_size = BIG_VAR_OFFSET_SIZE;state->src_vot_bytes = OR_VAR_TABLE_SIZE_INTERNAL (state->n_var, state->src_offset_size);state->dst_vot_bytes = OR_VAR_TABLE_SIZE_INTERNAL (state->n_var, dst_offset_size);state->fixed_bitmap_bytes = OR_GET_VAR_OFFSET (state->vot_raw[0]) - state->src_vot_bytes;if (state->fixed_bitmap_bytes < 0) { assert_release (false); er_set (...); return ER_FAILED; }int64_t new_values_bytes = 0;for (int i = 0; i < state->n_var; ++i) { const int this_off = OR_GET_VAR_OFFSET (state->vot_raw[i]); const int next_off = OR_GET_VAR_OFFSET (state->vot_raw[i + 1]); /* <- sentinel supplies last next_off */ const int src_val_len = next_off - this_off; if (src_val_len < 0 || state->src_header_size + next_off > state->src_length) { /* <- offsets disordered/past */ assert_release (false && "VOT offsets out of order or past record"); er_set (...); return ER_FAILED; } if (OR_IS_OOS (state->vot_raw[i])) { assert_release (src_val_len == OR_OOS_INLINE_SIZE); /* <- OOS slot must be exactly 16B */ new_values_bytes += (int64_t) state->oos_blobs[i].size (); /* <- expanded length replaces 16B */ } else { new_values_bytes += src_val_len; /* <- inline column carried over */ }}const int64_t new_length_64 = (int64_t) state->src_header_size + state->dst_vot_bytes + state->fixed_bitmap_bytes + new_values_bytes;if (new_length_64 > (int64_t) INT_MAX) { /* <- RECDES.length is a 4-byte int */ assert_release (false && "OOS-expanded record size exceeds INT_MAX"); er_set (...); return ER_FAILED;}state->new_length = (int) new_length_64;fixed_bitmap_bytes는 소스 VOT 끝과 첫 번째 가변 값 오프셋 사이의 갭으로 도출된다 — 즉, 고정 속성과 바운드 비트 비트맵이며, 펼치기는 이 부분을 건드리지 않는다. 누적 합계는 int64_t로 관리되기 때문에 INT_MAX 가드가 의미를 가진다: RECDES.length는 부호 있는 4바이트 int로, 하나의 펼쳐진 레코드를 최대 약 2GB로 제한한다(동반 문서 Cross-check Notes 참고). 목적지 오프셋을 4바이트로 강제함으로써, 소스가 얼마나 압축돼 있었든 새로운 VOT는 임의로 큰 펼치기를 처리할 수 있다. 분기: fixed_bitmap_bytes < 0(VOT가 고정 영역과 겹침 — 손상), 항목별 순서/경계 검사, OOS 슬롯 크기 assert, INT_MAX 오버플로 가드.
7.6 단계 4 — heap_oos_build_record: 조립, HAS_OOS 제거, 4바이트 오프셋 강제
섹션 제목: “7.6 단계 4 — heap_oos_build_record: 조립, HAS_OOS 제거, 4바이트 오프셋 강제”마지막 단계에서 레코드를 실체화한다. 먼저 realloc 결정:
// heap_oos_build_record -- src/storage/heap_oos.cppconst bool need_realloc = (context->ispeeking == PEEK) || (rec->area_size < state->new_length);if (need_realloc) { if (context->scan_cache == NULL) { /* <- no buffer to grow into */ rec->length = - (state->new_length); /* <- negative length: caller must re-call with space */ return S_DOESNT_FIT; } context->scan_cache->assign_recdes_to_area (*rec, (size_t) state->new_length); /* <- may free old rec->data */ if (rec->data == NULL || rec->area_size < state->new_length) { er_set (..., ER_OUT_OF_VIRTUAL_MEMORY, 1, (size_t) state->new_length); return S_ERROR; } context->ispeeking = COPY; /* <- expanded record is a copy, never a peek */}char *dst = rec->data;세 가지 분기 결과: (a) 영역이 이미 충분하고 ispeeking != PEEK — realloc 없음; (b) realloc 필요하지만 scan cache 없음 — S_DOESNT_FIT을 반환하고 rec->length를 음수로 설정하여 호출자가 크기를 맞춰 재시도하게 함; (c) assign_recdes_to_area를 통한 realloc — §7.2에서 소스를 스냅샷한 이유가 바로 이것이다: assign_recdes_to_area가 원래 rec->data를 해제할 수 있다. PEEK는 펼쳐진 레코드가 페이지를 alias할 수 없으므로 COPY로 강제된다.
그 다음 헤더 복사와 플래그 재작성:
// heap_oos_build_record -- src/storage/heap_oos.cppstd::memcpy (dst, state->src, state->src_header_size); /* <- header verbatim first */unsigned int repid_bits = (unsigned int) OR_GET_INT (dst + OR_REP_OFFSET);repid_bits &= ~ ((unsigned int) OR_MVCC_FLAG_HAS_OOS << OR_MVCC_FLAG_SHIFT_BITS); /* <- clear HAS_OOS */repid_bits &= ~ (unsigned int) OR_OFFSET_SIZE_FLAG; /* <- clear old offset-size bits */repid_bits |= OR_OFFSET_SIZE_4BYTE; /* <- force 4-byte offsets */OR_PUT_INT (dst + OR_REP_OFFSET, (int) repid_bits);불변 조건 — 재빌드된 레코드에는 OOS 흔적이 남지 않는다.
HAS_OOS는 제거되고 오프셋 크기 비트는 4바이트로 재작성된다. 출력은 이제 일반 레코드다: 두 번째heap_recdes_contains_oos는 false를 반환하므로 재펼치기는 no-op이 되고,OR_GET_OFFSET_SIZE를 읽는 하위 코드는 단계 3이 계산한 4바이트 VOT와 일치하는 값을 본다.HAS_OOS를 그대로 뒀다면 레코드가 재펼치기 시도를 당할 수 있고 — 16바이트 슬롯이 이미 사라졌으므로 보장된 손상 읽기가 된다; 오프셋 크기 비트가 VOT 너비와 불일치하면 하위의 모든 오프셋 읽기가 잘못된 스케일로 처리된다.
VOT는 누적 방식으로 재작성되고, 뒤따르는 정렬 패딩은 0으로 채워지고, 고정+비트맵은 그대로 복사되고, 각 값이 제자리에 인라인된다:
// heap_oos_build_record -- src/storage/heap_oos.cppconst int dst_first_value_rel = state->dst_vot_bytes + state->fixed_bitmap_bytes;char *dst_vot = dst + state->src_header_size;int cumulative = 0;for (int i = 0; i < state->n_var; ++i) { OR_PUT_INT (dst_vot + i * dst_offset_size, dst_first_value_rel + cumulative); const int val_len = (OR_IS_OOS (state->vot_raw[i]) ? (int) state->oos_blobs[i].size () /* <- expanded length */ : (OR_GET_VAR_OFFSET (state->vot_raw[i + 1]) - OR_GET_VAR_OFFSET (state->vot_raw[i]))); cumulative += val_len;}OR_PUT_INT (dst_vot + state->n_var * dst_offset_size, OR_SET_VAR_LAST_ELEMENT (dst_first_value_rel + cumulative)); /* <- sentinel, no OOS bit */// ... condensed: memset any padding past the (n_var+1) entries ...if (state->fixed_bitmap_bytes > 0) std::memcpy (dst + state->src_header_size + state->dst_vot_bytes, state->src + state->src_header_size + state->src_vot_bytes, state->fixed_bitmap_bytes);int dst_pos = state->src_header_size + state->dst_vot_bytes + state->fixed_bitmap_bytes;for (int i = 0; i < state->n_var; ++i) { if (OR_IS_OOS (state->vot_raw[i])) { std::memcpy (dst + dst_pos, state->oos_blobs[i].data (), state->oos_blobs[i].size ()); /* <- inline blob */ dst_pos += (int) state->oos_blobs[i].size (); } else { const int src_off = state->src_header_size + OR_GET_VAR_OFFSET (state->vot_raw[i]); const int len = OR_GET_VAR_OFFSET (state->vot_raw[i + 1]) - OR_GET_VAR_OFFSET (state->vot_raw[i]); std::memcpy (dst + dst_pos, state->src + src_off, len); /* <- carry inline column */ dst_pos += len; }}assert (dst_pos == state->new_length); /* <- write cursor must land exactly on new_length */rec->length = state->new_length;return S_SUCCESS;새 VOT 오프셋은 누적 방식으로 계산된다: 각 항목이 자신의 값이 시작하는 위치를 가리키며, 재작성된 센티넬(OR_SET_VAR_LAST_ELEMENT)은 LAST 비트는 유지하되 OOS 비트는 가지지 않는다 — 재빌드된 레코드의 VOT는 깔끔하게 종료되고 어떤 컬럼도 외부화된 것으로 표시하지 않는다. OOS 재작성은 속성 순서상 제자리에서 일어난다 — blob은 16바이트 스텁이 있던 속성 위치 그대로 들어가므로 속성 정체성이 보존된다. 마지막의 assert (dst_pos == state->new_length)는 단계 3과 단계 4를 연결한다: 레이아웃 계산과 복사 루프가 달라졌다면 이것이 발동된다.
flowchart TD
A["단계 4 진입"] --> B{"need_realloc?\nPEEK 또는 영역 부족"}
B -- no --> H["dst = rec->data"]
B -- yes --> C{"scan_cache == NULL?"}
C -- yes --> D["rec->length = -new_length\nS_DOESNT_FIT 반환"]
C -- no --> E["assign_recdes_to_area"]
E --> F{"할당 성공?"}
F -- no --> G["S_ERROR ER_OUT_OF_VIRTUAL_MEMORY"]
F -- yes --> H
H --> I["헤더 복사, HAS_OOS 제거, 4B 오프셋 강제"]
I --> J["VOT 누적 재작성, 센티넬"]
J --> K["고정+비트맵 그대로 복사"]
K --> L["각 blob 인라인 / 인라인 컬럼 이전"]
L --> M["assert dst_pos == new_length\nrec->length = new_length\nS_SUCCESS"]
Figure 7-2. heap_oos_build_record 분기 맵.
7.7 oos_read: 단계 2가 호출하는 체인 워크
섹션 제목: “7.7 oos_read: 단계 2가 호출하는 체인 워크”단계 2의 각 oos_read는 청크 체인에서 컬럼 하나의 값을 재조립한다. 공개 진입점은 헤드를 검증하고 총 길이를 교차 검사한 뒤 나머지를 순회한다:
// oos_read -- src/storage/oos_file.cppassert (dest.data () != nullptr && dest.size () > 0);const int expected_length = static_cast<int> (dest.size ()); /* <- the stub's full_length */cubbase::byte_span_writer writer (dest);OOS_RECORD_HEADER first_header;int err = oos_read_within_page (thread_p, oid, writer, first_header);if (err != NO_ERROR) { return err; }assert (first_header.chunk_index == 0);if (first_header.chunk_index != 0) { /* <- stub OID points mid-chain = corruption */ oos_error (...); er_set (...); return ER_GENERIC_ERROR;}if (first_header.total_data_length != expected_length) { /* <- stub length vs chain header */ oos_error (...); er_set (...); return ER_GENERIC_ERROR;}if (!OID_ISNULL (&first_header.next_chunk_oid)) { /* <- multi-chunk: follow the tail */ err = oos_read_across_pages (thread_p, first_header.next_chunk_oid, expected_length, writer); if (err != NO_ERROR) { return err; }}if (!writer.full ()) { /* <- wrote fewer bytes than dest.size() */ oos_error (...); er_set (...); return ER_GENERIC_ERROR;}return NO_ERROR;expected_length는 dest.size(), 즉 단계 2가 스텁에서 읽은 oos_len이다. 세 가지 정확성 검사: (1) chunk_index == 0 — 호출자의 OID는 체인 헤드여야 하며, 중간 청크여선 안 된다; (2) total_data_length == expected_length — 체인이 자체 선언한 총 길이가 인라인 스텁의 길이와 일치해야 하며, 스텁/체인 불일치를 잡는다; (3) writer.full() — 전체 순회 후 정확히 dest.size() 바이트가 쓰여야 하며, 초과나 부족도 허용되지 않는다. 단일 청크인 경우 oos_read_across_pages 호출을 완전히 건너뛴다(next_chunk_oid가 NULL).
불변 조건 —
dest.size()는 읽기 길이의 단일 진실 공급원이다. 단계 2는 인라인 스텁의 8바이트 길이로dest의 크기를 잡았다;oos_read는 헤더total_data_length가 그 값과 다른 체인을 거부하며, writer가 정확히full()이 아니면 성공을 반환하지 않는다. 스텁 길이, 체인 헤더, 실제 추가된 바이트가 이렇게 삼각 검증된다 — 어떤 경로도 잘못된 크기의 값을 돌려줄 수 없다.
oos_read_within_page는 경계가 있는 byte_span_writer로 청크 하나의 페이로드를 가져온다:
// oos_read_within_page -- src/storage/oos_file.cppPAGE_PTR page_ptr = pgbuf_fix (thread_p, &vpid, OLD_PAGE, PGBUF_LATCH_READ, PGBUF_UNCONDITIONAL_LATCH);if (page_ptr == nullptr) { oos_error (...); assert_release_error (er_errid () != NO_ERROR); return er_errid (); }// ... condensed: scope_exit unfixes the page ...OOS_RECDES oos_recdes;SCAN_CODE code = spage_get_record (thread_p, page_ptr, slotid, &oos_recdes, PEEK);if (code != S_SUCCESS) { /* <- slot gone; force an errid */ if (er_errid () == NO_ERROR) { er_set (...); } return er_errid ();}assert (oos_recdes.length >= OOS_RECORD_HEADER_SIZE);if (oos_recdes.length < OOS_RECORD_HEADER_SIZE) { oos_error (...); er_set (...); return ER_GENERIC_ERROR; } /* <- too short */std::memcpy (&header_out, oos_recdes.data, OOS_RECORD_HEADER_SIZE); /* <- hand the chain header back */const int payload_len = oos_recdes.length - OOS_RECORD_HEADER_SIZE;if (!writer.append (oos_recdes.data + OOS_RECORD_HEADER_SIZE, (std::size_t) payload_len)) { /* <- overflow guard */ oos_error (...); er_set (...); return ER_GENERIC_ERROR;}return NO_ERROR;페이지는 무조건 읽기 latch로 고정되고(읽기 시 대기 가능), scope_exit으로 해제된다. 슬롯은 최소 OOS_RECORD_HEADER_SIZE 이상이어야 하고, 헤더는 호출자가 next_chunk_oid를 추적할 수 있도록 복사해서 전달된다. writer.append는 모든 청크를 호출자 소유의 목적지 span과 경계 검사하고, 초과 시 진행 없이 false를 반환하므로 — 청크가 dest보다 많은 바이트를 요구하더라도 오버플로가 아니라 거부된다. spage_get_record 실패 경로는 S_…-but-no-errid 케이스를 실제 오류로 정규화하므로, 호출자는 실패한 fetch 이후에 NO_ERROR를 받는 일이 없다.
oos_read_across_pages는 청크 인덱스 1부터 시작해 체인이 일관성을 유지하는지 assert하고 빈 청크를 거부하면서 순회한다:
// oos_read_across_pages -- src/storage/oos_file.cppint idx = 1; OID current = next_oid;while (!OID_ISNULL (¤t)) { OOS_RECORD_HEADER header; const std::size_t before = writer.written (); int err = oos_read_within_page (thread_p, current, writer, header); if (err != NO_ERROR) { return err; } assert (idx == header.chunk_index); assert (header.total_data_length == total_data_length); if (idx != header.chunk_index || header.total_data_length != total_data_length) { /* <- chain skew */ oos_error (...); er_set (...); return ER_GENERIC_ERROR; } if (writer.written () == before) { /* <- 0-byte chunk: cyclic next would loop */ oos_error (...); er_set (...); return ER_GENERIC_ERROR; } current = header.next_chunk_oid; idx++;}return NO_ERROR;청크마다 두 가지 불변 조건이 적용된다: chunk_index는 단조 증가하는 idx와 일치해야 하고(청크가 순서대로 도착함), 모든 청크의 total_data_length는 헤드의 값과 일치해야 한다(전체 값의 크기를 모든 청크가 동의함). writer.written() == before 검사는 0바이트 청크를 거부한다 — 아무것도 기여하지 않는 청크와 순환 next_chunk_oid가 결합되면 무한 루프가 될 수 있으므로, 빈 청크를 거부해 순회를 제한한다. 루프는 NULL next_chunk_oid에서 종료되고, 이후 oos_read의 writer.full() 검사로 조립 길이가 확인된다.
flowchart TD
R["oos_read(oid, dest)"] --> RW["oos_read_within_page (헤드)"]
RW --> H0{"chunk_index == 0?"}
H0 -- no --> E1["ER_GENERIC_ERROR"]
H0 -- yes --> TL{"total_data_length == dest.size()?"}
TL -- no --> E2["ER_GENERIC_ERROR"]
TL -- yes --> NX{"next_chunk_oid NULL?"}
NX -- yes --> FULL{"writer.full()?"}
NX -- no --> AP["oos_read_across_pages (idx=1부터)"]
AP --> CK{"청크별: idx 일치,\ntotal 일치, 비어있지 않음?"}
CK -- no --> E3["ER_GENERIC_ERROR"]
CK -- yes, 청크 더 있음 --> AP
CK -- yes, NULL next --> FULL
FULL -- no --> E4["ER_GENERIC_ERROR"]
FULL -- yes --> OK["NO_ERROR, dest 채움 완료"]
Figure 7-3. oos_read 헤드 검증과 체인 워크.
7.8 oos_get_length와 int 반환 실패 전파 계약
섹션 제목: “7.8 oos_get_length와 int 반환 실패 전파 계약”oos_read는 SCAN_CODE가 아닌 int 오류 코드를 반환하고, 단계 2는 NO_ERROR가 아닌 값을 자신의 ER_FAILED로 매핑하며 디스패치 체인이 이를 S_ERROR로 변환한다. 세 개의 형제 읽기 헬퍼도 모두 일반 int를 반환한다 — oos_get_length, heap_attrvalue_read_oos_inline, heap_midxkey_get_oos_extra_size — 각각 실패를 다르게 노출하는데, 마지막 함수가 CBRD-26769에서 수정된 영역이다.
oos_get_length — 길이 프로브 (조립 없음). 단계 2는 인라인 스텁에서 길이를 읽지만, OOS 헤드 OID만 가진 호출자(주로 테스트 코드 — oos_file.hpp의 “or oos_get_length in tests” 주석 참고)는 전체 값을 순회하지 않고 체인의 총 길이만 가져올 수 있다:
// oos_get_length -- src/storage/oos_file.cppPAGE_PTR page_ptr = pgbuf_fix (thread_p, &vpid, OLD_PAGE, PGBUF_LATCH_READ, PGBUF_UNCONDITIONAL_LATCH);if (page_ptr == nullptr) { oos_error (...); assert_release_error (er_errid () != NO_ERROR); assert (false); return -1; /* <- sentinel, NOT an error code */}// ... scope_exit unfixes the page ...SCAN_CODE code = spage_get_record (thread_p, page_ptr, slotid, &oos_recdes, PEEK);if (code != S_SUCCESS) { oos_error (...); er_set (...); return -1; }assert (oos_recdes.length >= OOS_RECORD_HEADER_SIZE);OOS_RECORD_HEADER header;std::memcpy (&header, oos_recdes.data, sizeof (OOS_RECORD_HEADER));return header.total_data_length; /* <- only the head chunk is read */헤드 청크만 고정해서 읽고 total_data_length를 바로 반환한다. 펼치기 경로는 이 함수를 호출하지 않는다 — 길이가 이미 인라인으로 있으므로 — 하지만 이 함수는 정식 “이 값의 길이는 얼마인가?” 프로브이며, §7.7에서 dest.size()와 교차 검증하는 바로 그 OOS_RECORD_HEADER.total_data_length를 읽는다.
불변 조건 —
oos_get_length는 실패를 오류 코드가 아닌-1로 보고한다.oos_read의int가NO_ERROR/오류 코드인 것과 달리, 이 함수는 성공 시 길이를, fix/fetch 실패 시(er_set완료 후) 센티넬-1을 반환한다. 호출자는 반드시< 0을 검사해야 하며, 검사 없이 결과를 버퍼 크기에 사용해선 안 된다 —-1을 size로 캐스팅하면 재앙이다. 이것이 바로 CBRD-26769가 heap 측에서 강화한 실패 전파 규율이다.
attrinfo 옵트아웃 읽기. §7.2의 !expand_oos 빠른 탈출 경로는 heap_attrinfo_read_dbvalues 같은 호출자를 위한 다른 읽기 경로 — heap_attrvalue_read_oos_inline(heap_file.c) — 가 OOS를 처리함을 의미한다. 이 헬퍼도 int를 반환하지만, 아래의 midxkey 프로브와 달리 센티넬을 사용하지 않는다 — 모든 손상된 인라인 조건은 ER_HEAP_OOS_BAD_INLINE_HEADER로 즉시 반환된다:
// heap_attrvalue_read_oos_inline -- src/storage/heap_file.cbuf.ptr = raw->data; buf.endptr = recdes->data + recdes->length;/* Cases 1-3: header too short / bad-or-null OID / length outside (0, INT_MAX] -- all the same error. */if (buf.endptr - buf.ptr < OR_OID_SIZE + OR_BIGINT_SIZE) { er_set (..., ER_HEAP_OOS_BAD_INLINE_HEADER, 3, ...); raw->data = NULL; *oos_owned_buffer = false; return ER_HEAP_OOS_BAD_INLINE_HEADER; }or_get_oid (&buf, &oos_oid); oos_len = or_get_bigint (&buf, &rc);if (rc != NO_ERROR || OID_ISNULL (&oos_oid)) { /* er_set */ return ER_HEAP_OOS_BAD_INLINE_HEADER; }if (oos_len <= 0 || oos_len > (DB_BIGINT) INT_MAX) { /* er_set */ return ER_HEAP_OOS_BAD_INLINE_HEADER; }// ... condensed: point raw->data at oos_scratch if it fits, else recdes_allocate_data_area (Case 4) .../* Case 5: oos_read failed -- it already er_set; free the heap buffer and propagate. */error = oos_read (thread_p, oos_oid, oos_buffer (raw->data, (std::size_t) oos_len));if (error != NO_ERROR) { ASSERT_ERROR (); if (raw->data != oos_scratch) recdes_free_data_area (raw); raw->data = NULL; return error; }다섯 개의 실패 지점: 세 개의 헤더 검사(Case 1–3)는 모두 ER_HEAP_OOS_BAD_INLINE_HEADER를 발생시키고; Case 4는 할당 실패이며; Case 5는 oos_read 자신의 코드를 전달한다. 값이 들어맞으면 호출자가 제공한 oos_scratch에 raw->data를 직접 가리키는 빠른 경로가 있어 행별 힙 할당을 피하며, *oos_owned_buffer로 호출자에게 recdes_free_data_area 필요 여부를 알린다.
CBRD-26769 — midxkey 크기 계산 경로의 int 반환 전파. 별도의 헬퍼 heap_midxkey_get_oos_extra_size(heap_file.c)는 OOS 속성을 펼쳤을 때 midxkey가 얼마나 커지는지를 사전에 계산한다. 일반 int 크기를 반환하므로, CBRD-26769는 손상된 인라인 처리를 오류 코드 대신 0 센티넬로 처리해야 했다:
// heap_midxkey_get_oos_extra_size (CBRD-26769) -- src/storage/heap_file.c/* CBRD-26769: validate the inline header exactly as heap_attrvalue_read_oos_inline does. * A corrupt header must never be cast into a midxkey size: a negative/huge (int) length * would mis-size midxkey.buf and let the legitimate columns overrun it before the read path * raises ER_HEAP_OOS_BAD_INLINE_HEADER. Return 0 so the buffer is sized from recdes->length * alone; the corruption is then surfaced when the value is actually read. */if (buf.endptr - buf.ptr < OR_OID_SIZE + OR_BIGINT_SIZE) { return 0; }or_get_oid (&buf, &oos_oid);DB_BIGINT length = or_get_bigint (&buf, &rc);if (rc != NO_ERROR || OID_ISNULL (&oos_oid) || length <= 0 || length > (DB_BIGINT) INT_MAX) { return 0; }return (int) length;펼치기 경로가 물려받는 교훈: 인라인 길이는 결코 맹목적으로 신뢰하지 않는다. expand_oos 경로에서는 (0, DB_MAX_STRING_LENGTH] 검사와 oos_read의 헤더 교차 검증(§7.4, §7.7)이 그 역할을 하고; attrinfo 경로에서는 위의 명시적 ER_HEAP_OOS_BAD_INLINE_HEADER 케이스들이 그 역할을 하며; midxkey 크기 계산 경로에서는 return 0-then-size-from-recdes->length 패턴이 손상을 실제 읽기 시점으로 미룬다. 세 가지 반환 규율, 하나의 원칙 — 손상된 인라인 헤더는 경계가 있는 전파된 실패가 되며, 결코 야생의 할당이 되지 않는다.
네 번째 읽기 측 헬퍼 oos_chunk_exists가 oos_file.cpp에 존재하지만 SELECT 경로에는 없다: 이것은 멱등적 vacuum/delete 호출자(챕터 8–9)를 위한 읽기 전용 “이 청크가 아직 존재하는가?” 프로브로, 정상적으로 할당 해제된 청크(NO_ERROR, *out_exists = false)와 실제 I/O 실패(전파)를 구별한다. oos_file.cpp의 읽기 측 목록을 완성하기 위해 여기서 언급한다.
7.9 챕터 요약 — 핵심 정리
섹션 제목: “7.9 챕터 요약 — 핵심 정리”- 펼치기는 옵트인이다.
heap_get_visible_version_expand_oos만이context.expand_oos를 설정한다; 스캔 및 eager-cleanup 경로는 인라인 스텁을 그대로 둔다. 플래그는heap_get_record_data_when_all_ready의REC_HOME과REC_RELOCATION디스패치 지점에서 소비되며,REC_BIGONE(OVF)은 OOS 펼치기 대상이 아니다. - 두 개의 저렴한 게이트, 그 다음 스냅샷 하나.
heap_record_replace_oos_oids는 어떤 작업도 하기 전에!expand_oos와!heap_recdes_contains_oos에서 탈출하고, 단계 4가 realloc할 수 있기 전에 소스 바이트를src_buf로 복사한다 — 재빌드가 해제된 메모리를 읽는 일이 없음을 보장한다. - 네 단계 모두 VOT를 기반으로 한다. parse_vot(센티넬 탐색, 플래그 비트 유지) → read_blobs(각 16바이트 스텁 디코딩,
(0, DB_MAX_STRING_LENGTH]길이 검증, 크기가 잡힌 버퍼에oos_read) → compute_layout(4바이트 dst 오프셋,int64_t누적,INT_MAX가드) → build_record(조립). 클래스 표현식을 참조하지 않으므로 경로가 스키마 변경에 안전하다. - 출력은 모든 OOS 흔적을 지운다. build_record는 헤더를 그대로 복사하고,
OR_MVCC_FLAG_HAS_OOS를 제거하며,OR_OFFSET_SIZE_4BYTE를 강제하고, 깔끔한 센티넬로 VOT를 누적 재작성한다 — 재펼치기는 no-op이 되고 하위 오프셋 읽기는 일관성을 유지한다. oos_read는 길이를 삼각 검증한다.chunk_index == 0,total_data_length == dest.size(), 최종writer.full()을 강제하며; 체인 워크는 단조 증가하는chunk_index와 일치하는total_data_length를 assert하고 빈 청크를 거부해 순환 체인을 경계 짓는다.dest.size()(인라인 스텁 길이)가 읽기 크기의 단일 진실 공급원이다.byte_span_writer가 오버플로 방화벽이다. 모든 청크의 페이로드는 호출자 소유의 목적지 span을 초과하지 않는 경계 있는 커서로 추가되므로, 손상된 청크 길이가 펼치기 버퍼를 덮어쓸 수 없다.int반환 헬퍼들은 전파하며, 덮어쓰지 않는다.oos_get_length는 fetch 실패 시 (er_set후) 센티넬-1을 반환하며 사용 전에< 0검사가 필요하다;heap_attrvalue_read_oos_inline(attrinfo 옵트아웃 읽기)는 손상된 헤더에서ER_HEAP_OOS_BAD_INLINE_HEADER를 발생시킨다; CBRD-26769의heap_midxkey_get_oos_extra_size크기 계산 헬퍼는 손상된 헤더에서0을 반환해 버퍼가recdes->length로 크기를 잡히게 하고 손상은 값이 실제로 읽힐 때 표출된다. 세 가지 반환 규율, 하나의 원칙 — 인라인 길이는 결코 맹목적으로 신뢰하지 않는다.- 대부분의 assert는 손상 탐지 장치다. 모든 함수에 걸쳐
assert_release검사(센티넬 누락, 경계 초과 슬롯, 잘못된 OID/길이, 순서가 뒤바뀐 오프셋, 잘못된 청크 인덱스)는 정형적인 레코드에서는 발동되지 않으며; 조용한 손상을 시끄럽고 디버깅 가능한 실패로 변환한다.
Chapter 8: 물리적 삭제 프리미티브와 복구
섹션 제목: “Chapter 8: 물리적 삭제 프리미티브와 복구”이 챕터는 하나의 질문에 답한다: OOS 레코드를 제거하는 단일 저수준 연산은 무엇이며, 왜 시스템 연산(sysop) 없이도 크래시-안전하고 어보트-안전한가? 이후에 다루는 두 회수 드라이버 — 적극적 비-MVCC 정리(Chapter 9)와 vacuum 회수(Chapter 10) — 모두 이 프리미티브를 호출한다. 호출 정책을 결정하는 드라이버보다 먼저 여기서 해부한다.
OOS 체인 모델(head OID, next_chunk_oid 연결, 단일/다중 청크 레코드)과 WAL/MVCC 이론은 cubrid-oos.md를 참고한다. 범용 WAL undo/redo 재생 기계(RV_fun[] 디스패치, undo vs redo 패스)는 cubrid-recovery-manager-detail.md에 있다. 이 챕터는 둘 다 재유도하지 않으며, OOS 삭제 코드를 분기별로 추적하고 OOS 전용 복구 재생 경로를 분석한다.
8.1 호출 깔때기: oos_delete에서 oos_delete_chain으로
섹션 제목: “8.1 호출 깔때기: oos_delete에서 oos_delete_chain으로”oos_delete는 공개 진입점으로, 인자 OID를 트레이싱용으로 로그에 남기고 내부 핵심 함수 oos_delete_chain에 위임하는 얇은 래퍼다. 유효성 검사도, sysop 오픈도, 락 획득도 없다.
// oos_delete -- src/storage/oos_file.cppintoos_delete (THREAD_ENTRY *thread_p, const VFID &oos_vfid, const OID &oid){ oos_debug ("arguments: oid={vol=%d,page=%d,slot=%d}", OID_AS_ARGS (&oid));
return oos_delete_chain (thread_p, oos_vfid, oid); /* <- all real work is in the chain walker */}이 분리는 의도적이다: oos_delete는 호출자가 바인딩하는 안정적인 이름(oos_file.hpp에서 유일하게 내보내는 삭제 심볼)이고, oos_delete_chain은 static으로 선언되어 자유롭게 변경할 수 있다. 두 회수 드라이버 모두 이 단일 깔때기로 청크 제거에 도달하므로, 크래시/어보트-안전성 논증은 여기서 한 번만 성립한다.
flowchart TD C9["9장 적극적 정리"] --> D["oos_delete()"] C10["10장 vacuum"] --> D D --> CH["oos_delete_chain()<br/>next_chunk_oid 순회"] CH --> LOG["oos_log_delete_physical()<br/>RVOOS_DELETE undo only"] CH --> SP["spage_delete()"] CH --> ST["oos_stats_update()<br/>여유 공간 캐시 갱신"]
Figure 8-1. 두 회수 드라이버 모두 oos_delete를 거쳐 체인 워커로 집약된다.
8.2 oos_delete_chain: 분기 완전 체인 워커
섹션 제목: “8.2 oos_delete_chain: 분기 완전 체인 워커”oos_delete_chain은 head OID에서 시작하여 next_chunk_oid를 따라가며 각 청크 슬롯을 순서대로 삭제한다. 본문 전체는 하나의 while (!OID_ISNULL (¤t_oid)) 루프이며, 단일 청크 레코드는 head가 next_chunk_oid == NULL_OID를 가지는 길이 1짜리 체인에 불과하다.
각 반복마다 함수는 고정된 단계 순서를 수행하며, 실패할 수 있는 단계 뒤에는 조기 반환 오류 경로가 있다.
// oos_delete_chain -- src/storage/oos_file.cppOID current_oid = oid;while (!OID_ISNULL (¤t_oid)) { VPID vpid = {current_oid.pageid, current_oid.volid}; PAGE_PTR page_ptr = pgbuf_fix (thread_p, &vpid, OLD_PAGE, PGBUF_LATCH_WRITE, PGBUF_UNCONDITIONAL_LATCH); /* (1) write latch; branch A */ if (page_ptr == nullptr) { ASSERT_ERROR_AND_SET (error); /* oos_error */ return error; } scope_exit page_unfixer ([&] () { pgbuf_unfix_and_init_after_check (thread_p, page_ptr); }); /* RAII unfix */
OOS_RECDES oos_recdes = RECDES_INITIALIZER; SCAN_CODE code = spage_get_record (thread_p, page_ptr, current_oid.slotid, &oos_recdes, PEEK); /* (2) PEEK */ if (code != S_SUCCESS) { ASSERT_ERROR_AND_SET (error); /* oos_error */ return error; } /* branch B */ if (oos_recdes.length < (int) sizeof (OOS_RECORD_HEADER)) /* (3) corruption guard, branch C */ { assert_release (false); er_set (ER_ERROR_SEVERITY, ARG_FILE_LINE, ER_GENERIC_ERROR, 0); return ER_GENERIC_ERROR; }
OOS_RECORD_HEADER header; std::memcpy (&header, oos_recdes.data, sizeof (OOS_RECORD_HEADER)); OID next_chunk_oid = header.next_chunk_oid; /* (4) read NEXT before delete */ oos_log_delete_physical (thread_p, page_ptr, const_cast<VFID *> (&oos_vfid), current_oid.slotid, &oos_recdes); /* (5) WAL undo append */ PGSLOTID deleted_slotid = spage_delete (thread_p, page_ptr, current_oid.slotid); if (deleted_slotid == NULL_SLOTID) { ASSERT_ERROR_AND_SET (error); return error; } /* branch D */ pgbuf_set_dirty (thread_p, page_ptr, DONT_FREE); /* (6) mark dirty, keep fixed */ oos_stats_update (thread_p, page_ptr, &oos_vfid, 0); /* (7) free-space cache update */ current_oid = next_chunk_oid; /* advance; NULL ends loop */ }return NO_ERROR;단계 (4)가 단계 (5)/(6)보다 먼저 오는 순서가 이 함수에서 가장 중요한 세부 사항이다:
불변 조건 — 삭제 전 next 읽기.
next_chunk_oid는spage_delete가 슬롯을 회수하기 전에 청크 헤더에서 복사해야 한다. 코드는std::memcpy로 스택 변수header에 저장하고, 그 다음 줄에서oos_log_delete_physical/spage_delete쌍을 호출하는 방식으로 이를 강제한다. 위반 시 (예: 삭제 후header.next_chunk_oid읽기), 워커는 해제된 슬롯의 페이로드를 역참조하게 되고, 체인 꼬리를 잃어 하위 모든 청크가 영구적인 고아(orphan)가 된다 — 링크 정보를 기록하는 곳이 다른 어디에도 없기 때문이다.
네 개의 실패 가능 단계 각각은 조기 반환한다: branch A pgbuf_fix가 nullptr 반환; branch B spage_get_record peek이 S_SUCCESS 아님; branch C 길이 가드(8.3); branch D spage_delete가 NULL_SLOTID 반환. A/B/D는 모두 ASSERT_ERROR_AND_SET 후 반환하고, C는 assert_release와 er_set 후 ER_GENERIC_ERROR를 반환한다. 단계 (4) 헤더 복사, (5) WAL 추가, (6) dirty 표시, (7) 통계 갱신은 항상 실행된다. 두 가지 설계 선택에 주목할 필요가 있다:
- 무조건 write latch.
PGBUF_LATCH_WRITE+PGBUF_UNCONDITIONAL_LATCH: 워커가 슬롯을 변경하므로 독점 latch가 필요하고, 블로킹을 허용한다 — 조건부 latch로 백오프하지 않는다. 호출자는 이 체인의 삭제자를 직렬화하는 행-수준 락을 보유하는 것으로 가정한다(8.9 참고). COPY가 아닌PEEK.oos_recdes.data는 페이지 버퍼를 직접 가리키며oos_log_delete_physical에 undo 이미지로 그대로 전달된다. peek은 latch보다 오래 살아남으면 안 된다:scope_exit page_unfixer가 모든 종료 경로에서 unfix를 보장하고, WAL 추가(단계 5)는spage_delete가 슬롯을 해제하기 전에 peek을 소비한다.
불변 조건 — 삭제 전 로그 기록(WAL).
oos_log_delete_physical(단계 5)은 매 반복마다spage_delete보다 먼저 실행된다. 슬롯의 전체 pre-image를 담은 undo 레코드는 슬롯이 페이지에서 회수되기 전에 로그 버퍼에 도달한다. 위반 시 (삭제 후 로그 기록), 두 단계 사이의 크래시는 undo 이미지 없이 슬롯을 잃는다.
stateDiagram-v2 [*] --> CheckOID CheckOID --> Done: current_oid NULL CheckOID --> Fix: not NULL Fix --> Err: nullptr, branch A Fix --> Peek: latched Peek --> Err: not S_SUCCESS, branch B Peek --> Guard: got record Guard --> Err: short record, branch C Guard --> Del: length ok, save next then WAL Del --> Err: NULL_SLOTID, branch D Del --> CheckOID: slot removed, advance to next Err --> [*]: caller MUST abort txn Done --> [*]: NO_ERROR
Figure 8-2. 네 가지 오류 분기(A-D)를 포함한 oos_delete_chain 반복 1회의 상태 기계.
8.3 손상 가드와 assert_release를 사용하는 이유
섹션 제목: “8.3 손상 가드와 assert_release를 사용하는 이유”Branch C는 일반적인 런타임 조건이 아니다 — 올바르게 형성된 OOS 청크는 항상 전체 OOS_RECORD_HEADER(oos_record_header 구조체: total_data_length, chunk_index, next_chunk_oid)로 시작한다. 가드 oos_recdes.length < sizeof(OOS_RECORD_HEADER)는 구조적으로 불가능한 레코드를 잡아낸다. 디버그 빌드에서는 트랩하고 릴리스 빌드에서는 로그만 남기는 assert_release (false)를 실행한 뒤, ER_ERROR_SEVERITY로 ER_GENERIC_ERROR를 설정하고 반환한다 — 이렇게 해서 이후 std::memcpy에서 버퍼 범위를 벗어나는 읽기를 막는다. 이 가드는 peek과 헤더 복사 사이에 위치하여 sizeof(OOS_RECORD_HEADER) 바이트의 memcpy가 항상 범위 내에 있도록 한다. 심각도에 주목: 정방향 가드는 ER_ERROR_SEVERITY(호출자가 어보트하는 복구 가능 트랜잭션 오류)로 처리하지만, 8.6의 복구 시 대응 코드는 ER_FATAL_ERROR_SEVERITY로 에스컬레이션한다 — 복구가 재생 중이라면 우아한 저하란 없다.
8.4 oos_log_delete_physical: undo를 담은 WAL 레코드
섹션 제목: “8.4 oos_log_delete_physical: undo를 담은 WAL 레코드”이 헬퍼 함수가 sysop 없는 설계를 작동하게 만드는 핵심이다. LOG_DATA_ADDR(.offset = slotid, 삭제될 슬롯)을 구성하고, 피킹한 디스크립터를 undo 데이터로, NULL을 redo로 삼아 RVOOS_DELETE 타입의 레코드를 추가한다. 삽입 측과 대조해보면, 삽입은 레코드를 RVOOS_INSERT의 redo로 기록하고 undo를 NULL로 설정한다:
// oos_log_delete_physical -- src/storage/oos_file.cpp log_append_undoredo_recdes (thread_p, RVOOS_DELETE, &log_addr, recdes_p, NULL); /* undo = full pre-delete record */// oos_log_insert_physical -- src/storage/oos_file.cpp log_append_undoredo_recdes (thread_p, RVOOS_INSERT, &log_addr, NULL, recdes_p); /* redo = the record */삭제 undo 이미지는 청크 전체(헤더 + 페이로드)이므로, 롤백 시 동일한 slotid에 그대로 재삽입할 수 있다. 이 두 추가 호출의 거울 대칭 — 삭제는 레코드를 undo로, 삽입은 redo로 기록 — 이 바로 복구 디스패치 테이블이 활용하는 구조다(8.6).
8.5 sysop 없음: 어보트와 크래시 복구가 안전한 이유
섹션 제목: “8.5 sysop 없음: 어보트와 크래시 복구가 안전한 이유”oos_delete는 시스템 연산을 전혀 열지 않는다. sysop을 사용하면 다중 청크 삭제 전체가 하나의 원자적 복구 단위가 된다. OOS는 의도적으로 이를 하지 않으며, 대신 각 청크 삭제가 자신만의 완전한 undo 데이터를 가진다 — 일관성은 표준 트랜잭션 undo 경로로 보장된다(oos_delete 헤더 주석에 명시):
- 트랜잭션 어보트. Undo 레코드가 역순으로 재생된다. 각
RVOOS_DELETEundo는 재삽입(8.6)으로 재생되어 청크를 원래slotid에 복원한다. 삭제는 head에서 tail 순으로 기록되므로, undo는 tail에서 head 순으로 복원하여 체인을 완전히 재구성한다. - 크래시 복구. 삭제 도중에 잡힌 미커밋 트랜잭션도 동일한 undo 레코드를 가진다; 복구의 undo 패스가 이를 재생하여 제거된 모든 청크를 복원한다.
- 체인 도중 오류. Branch A/B/C/D가 일부 청크 삭제 후 발생하면, 해당 삭제들은 영속적인 undo 레코드로 남는다. 함수는 오류를 반환하고 호출자는 반드시 트랜잭션을 어보트해야 한다.
불변 조건 — 오류 시 호출자 어보트.
oos_delete가NO_ERROR가 아닌 값을 반환하면 호출자가 트랜잭션을 어보트해야 한다는 것은 강한 계약이다. 위반 시 (호출자가 커밋을 강행하면) 삭제된 청크는 영구적으로 사라지고 꼬리 청크는 고아가 된다. 소스 주석은 스토리지 계층 오류가 항상 트랜잭션 어보트로 전파되기 때문에 이것이 허용된다고 명시한다.
sysop 없는 설계의 두 번째 결과 — 8.7의 멱등성 프로브가 지원하는 속성:
불변 조건 — 삭제는 멱등적이다. 각 청크 삭제는 sysop 없는 독립적으로 기록된 슬롯 제거이므로, 크래시 후 재생하거나 재시도해도 안전하다: 이미 사라진 청크는 단순히
S_DOESNT_EXIST를 반환한다. Vacuum(10장)은 이에 의존한다 — 크래시 후 이미 커밋된 실행이 일부 청크를 제거한 체인을 다시 순회할 수 있다. 프로브oos_chunk_exists는 그런 재시도가 이미 사라진 OID를 실패 없이 건너뛸 수 있게 한다.
8.6 WAL 핸들러와 교차 연결된 복구 테이블
섹션 제목: “8.6 WAL 핸들러와 교차 연결된 복구 테이블”두 핸들러가 복구 중 OOS 슬롯 연산을 재생한다: oos_rv_redo_insert(레코드를 재삽입)와 oos_rv_redo_delete(슬롯을 제거). 흥미로운 부분은 이들이 복구 디스패치 테이블 RV_fun[]에 연결되는 방식이다 — 각 항목은 undo 열과 redo 열에 별도의 함수를 지정한다.
// RV_fun[] -- src/transaction/recovery.c {RVOOS_INSERT, "RVOOS_INSERT", oos_rv_redo_delete, oos_rv_redo_insert, NULL, NULL}, {RVOOS_DELETE, "RVOOS_DELETE", oos_rv_redo_insert, oos_rv_redo_delete, NULL, NULL},RV_fun[]에서 세 번째 필드가 undo 함수, 네 번째가 redo 함수다. 따라서 RVOOS_DELETE 레코드를 undo(롤백 / 미완료 트랜잭션의 크래시 복구)할 때 oos_rv_redo_insert가 실행되어 undo 이미지로 슬롯을 재생성한다 — 체인이 복원되는 정확한 방식이다. 대칭적으로 RVOOS_INSERT undo는 oos_rv_redo_delete를 실행한다. 두 개의 물리적 프리미티브가 네 방향 모두에서 재사용된다. (일반적인 undo-vs-redo 패스 메커니즘은 cubrid-recovery-manager-detail.md에 있다.)
// oos_rv_redo_insert -- src/storage/oos_file.cpp slotid = rcv->offset; recdes.type = * (INT16 *) (rcv->data); /* <- record type prefix */ recdes.data = (char *) (rcv->data) + sizeof (recdes.type); recdes.area_size = recdes.length = rcv->length - sizeof (recdes.type); assert (recdes.type == REC_HOME); sp_success = spage_insert_for_recovery (thread_p, rcv->pgptr, slotid, &recdes); /* <- exact slotid restore */ pgbuf_set_dirty (thread_p, rcv->pgptr, DONT_FREE); if (sp_success != SP_SUCCESS) { if (sp_success != SP_ERROR) { er_set (ER_FATAL_ERROR_SEVERITY, ARG_FILE_LINE, ER_GENERIC_ERROR, 0); } /* <- non-ERROR failure is fatal */ assert (er_errid () != NO_ERROR); return er_errid (); } return NO_ERROR;spage_insert_for_recovery는 일반 spage_insert가 아니라 — rcv->offset에 담긴 정확한 원래 slotid에 레코드를 강제로 복원한다. 이는 나머지 체인의 next_chunk_oid 포인터가 여전히 참조하는 OID 정체성을 보존한다. SP_SUCCESS도, SP_ERROR도 아닌 결과는 ER_FATAL_ERROR_SEVERITY로 에스컬레이션된다 — 복구 중에는 우아한 저하가 없다.
// oos_rv_redo_delete -- src/storage/oos_file.cpp slotid = rcv->offset; PGSLOTID deleted_slotid = spage_delete (thread_p, rcv->pgptr, slotid); if (deleted_slotid == NULL_SLOTID) { assert (false); er_set (ER_FATAL_ERROR_SEVERITY, ARG_FILE_LINE, ER_GENERIC_ERROR, 0); return er_errid (); } pgbuf_set_dirty (thread_p, rcv->pgptr, DONT_FREE);
VPID page_vpid; /* <- evict stale bestspace hint on rollback */ pgbuf_get_vpid (rcv->pgptr, &page_vpid); (void) oos_stats_del_bestspace_by_vpid (thread_p, &page_vpid); return NO_ERROR;oos_rv_redo_delete는 정방향 spage_delete를 반영하며, 추가로 oos_stats_del_bestspace_by_vpid를 호출해 페이지의 bestspace 캐시 항목을 축출한다. 정방향 경로와의 비대칭에 주목: 정방향 oos_delete_chain은 prev_freespace = 0으로 oos_stats_update를 호출하므로 삭제 후 여유 공간이 항상 제공된 추정치를 초과하여 페이지가 캐시에 추가되는 반면, 롤백 redo-delete는 오래된 항목을 단순히 제거한다 — 복구는 낙관적 bestspace 캐시를 유지하지 않고 무효화한다.
flowchart LR
subgraph forward["정방향 연산 (oos_delete_chain)"]
F1["RVOOS_DELETE 기록<br/>undo=전체 레코드"]
end
subgraph undo["UNDO 열"]
U1["oos_rv_redo_insert<br/>spage_insert_for_recovery"]
end
subgraph redo["REDO 열"]
R1["oos_rv_redo_delete<br/>spage_delete"]
end
F1 -->|롤백 / 크래시 undo| U1
F1 -->|roll-forward redo| R1
Figure 8-3. RVOOS_DELETE 레코드는 두 프리미티브를 undo와 redo에서 반대 방향으로 재사용한다.
8.7 oos_chunk_exists: 멱등성 프로브
섹션 제목: “8.7 oos_chunk_exists: 멱등성 프로브”재시도가 가능한 회수 드라이버(vacuum의 정방향 블록 재시도, 커밋된 sysop과 경쟁하는 적극적 정리)는 oos_delete_chain 내부의 S_DOESNT_EXIST 하드 오류를 유발하지 않고 “이 청크가 이미 사라졌는가?”를 물을 방법이 필요하다. oos_chunk_exists는 그를 위한 읽기 전용 프로브다 — 8.5의 멱등성 불변 조건을 실제로 가능하게 하는 구체적 수단이다.
// oos_chunk_exists -- src/storage/oos_file.cpp *out_exists = false; ... VPID from oid ... PAGE_PTR page_ptr = NULL; int error_code = pgbuf_fix_if_not_deallocated (thread_p, &vpid, PGBUF_LATCH_READ, PGBUF_UNCONDITIONAL_LATCH, &page_ptr); if (error_code != NO_ERROR) { ASSERT_ERROR (); return error_code; } /* branch 1: I/O/interrupt -> propagate */ if (page_ptr == NULL) { return NO_ERROR; } /* branch 2: page deallocated -> gone */
RECDES probe = RECDES_INITIALIZER; SCAN_CODE code = spage_get_record (thread_p, page_ptr, oid.slotid, &probe, PEEK); pgbuf_unfix_and_init (thread_p, page_ptr); if (code == S_SUCCESS) { *out_exists = true; return NO_ERROR; } /* branch 3: slot present -> exists=true */ if (code == S_DOESNT_EXIST) { return NO_ERROR; } /* branch 4: slot removed -> gone */ ASSERT_ERROR (); int errid = er_errid (); return errid != NO_ERROR ? errid : ER_FAILED; /* branch 5: S_ERROR -> propagate */이 함수는 read latch를 사용하며 pgbuf_fix_if_not_deallocated를 호출한다 — 일반 pgbuf_fix와 달리 페이지가 합법적으로 해제된 경우 오류를 내지 않고 NO_ERROR에 page_ptr == NULL을 반환한다. 인라인 표시된 다섯 분기는 빠짐없이 망라된다: branch 2(페이지 해제됨)와 branch 4(S_DOESNT_EXIST, 슬롯 제거되었지만 페이지 살아있음)만 NO_ERROR && *out_exists = false를 보고한다. Branch 3은 활성 히트(exists=true); branch 1(fix 오류)과 5(S_ERROR)는 오류를 전파한다. “이미 사라짐”은 따라서 정확히 branch 2와 4로만 정의된다.
불변 조건 — 사라짐은 오류가 아니지만, 딱 두 경우만. 재시도 호출자는
NO_ERROR && !*out_exists를 “이 OID를 안전하게 건너뜀”으로 처리할 수 있다. 코드는 정확히 branch 2와 4에서만NO_ERROR를 보고하고 나머지는 모두 전파하는 방식으로 이 좁은 정의를 강제한다. 위반 시 (예:S_ERROR를 “사라짐”으로 접어버리면), 재시도 루프는 실제 손상이나 I/O 오류를 조용히 삼키고 실패했어야 할 레코드를 건너뛸 것이다.
8.8 페이지 및 파일 회수: oos_remove_page와 oos_remove_file
섹션 제목: “8.8 페이지 및 파일 회수: oos_remove_page와 oos_remove_file”페이지 해제는 의도적으로 삭제 프리미티브에서 분리된다. oos_delete_chain은 슬롯만 제거하며, 마지막 슬롯이 사라지더라도 절대 페이지를 해제하지 않는다. 인라인 해제에는 sysop이 필요한데(파일 수준 해제는 슬롯 삭제처럼 undo 친화적이지 않다), 이것이 바로 이 설계가 피하는 것이다 — oos_delete 헤더 주석은 빈 페이지가 “트랜잭션 커밋 후 vacuum에 의해 회수될 것”이라고 명시한다. 해제는 별도의, 아직 연결되지 않은 함수 oos_remove_page에 있으며, 미래 vacuum 기계(Chapter 10)로 표시되어 있다:
// oos_remove_page -- src/storage/oos_file.cpp// TODO: will be called by vacuum when OOS vacuum is implementedintoos_remove_page (THREAD_ENTRY *thread_p, const VFID &oos_vfid, const VPID &vpid){ int err = file_dealloc (thread_p, &oos_vfid, &vpid, FILE_OOS); if (err != NO_ERROR) { oos_error ("file_dealloc failed for vpid={...}", ...); return err; } /* only branch: dealloc-failed propagate */ return NO_ERROR;}oos_remove_page는 file_dealloc(..., FILE_OOS) 위에 단 하나의 실패 분기를 가진 래퍼다. 슬롯 반복도, bestspace 북키핑도 없다 — vacuum이 호출할 때쯤이면 페이지는 이미 비어있다고 가정된다.
전체 파일 회수 — 소유 테이블이 드롭될 때 OOS 파일 전체를 삭제하는 것 — 는 oos_remove_file이며, 두 단계로 이루어진다:
// oos_remove_file -- src/storage/oos_file.cppintoos_remove_file (THREAD_ENTRY *thread_p, const VFID &oos_vfid){ (void) oos_stats_del_bestspace_by_vfid (thread_p, &oos_vfid); /* drop in-memory free-space cache for this file */ file_postpone_destroy (thread_p, &oos_vfid); /* schedule physical file destroy at commit */ return NO_ERROR;}oos_stats_del_bestspace_by_vfid는 해당 파일의 모든 cached bestspace 힌트를 인메모리 캐시에서 축출하고(파일이 곧 사라지므로 힌트가 댕글링 포인터가 된다), file_postpone_destroy는 실제 파일 파기를 postpone 액션으로 예약한다 — 트랜잭션 커밋 후에 실행되므로, 커밋 전 롤백이 발생하면 파일은 그대로 남는다. oos_remove_page(슬롯 수준 해제, vacuum)와 oos_remove_file(전체 파일 파기, drop-table) 사이의 분리는 8.2의 레코드별 삭제 경로를 sysop-free로 유지하면서 무거운 회수는 표준 postpone/commit 기계로 처리한다.
8.9 행-수준 락 동시성 TODO
섹션 제목: “8.9 행-수준 락 동시성 TODO”oos_delete_chain 바로 위에 소스에는 해결되지 않은 동시성 주석이 있다:
// oos_delete_chain (preceding comment) -- src/storage/oos_file.cpp// TODO: concurrency — this function assumes the caller holds a row-level lock (e.g., X_LOCK from heap layer)// to prevent concurrent deletion of the same OOS chain. Verify this assumption when wiring callers.8.2의 페이지별 write-latch 전략은 단일 페이지를 직렬화하지만, 두 트랜잭션이 동일한 체인을 동시에 순회하는 것을 막지 않는다(각 페이지를 차례로 latch할 뿐, 체인 전체를 원자적으로 latch하지 않는다). 정확성은 따라서 외부 가정에 의존한다 — 호출자가 소유 레코드의 heap 계층 행 락을 보유한다는 것 — 이는 코드에서 강제되지 않고 TODO로만 기록되어 있다. 회수 드라이버를 연결하는 개발자는 이 가정을 보존해야 한다.
8.10 챕터 요약 — 핵심 정리
섹션 제목: “8.10 챕터 요약 — 핵심 정리”- 단일 깔때기. 모든 OOS 삭제(적극적 정리, vacuum)는
oos_delete를 통과하며, 이는static워커oos_delete_chain에 그대로 위임한다. - 워커는
next_chunk_oid루프로, 네 가지 오류 분기(fix, peek, 길이 가드, delete)와 핵심 규칙이 있다 —next_chunk_oid는 슬롯이 삭제되기 전에 헤더에서 복사해야 한다. 그렇지 않으면 체인 꼬리가 누출된다. - sysop 없음, 의도적 설계. 각 청크 삭제는 전체 pre-image를 담은 독립적인
RVOOS_DELETEundo 레코드를 가지므로, 어보트와 크래시 복구는spage_insert_for_recovery로 undo를 역순으로 재생하여 체인을 복원한다. 이 속성은 삭제를 멱등적으로 만들어 vacuum이 크래시 후 체인을 다시 순회하며 이미 사라진 청크를 건너뛸 수 있다. - 오류 시 호출자 어보트는 강한 계약이다; 체인 도중 오류를 무시하고 커밋하면 영구적인 부분 삭제와 고아 꼬리 청크가 남는다. 정방향 손상 가드는
ER_ERROR_SEVERITY로 처리하고, 복구 시 대응 코드는ER_FATAL_ERROR_SEVERITY로 에스컬레이션한다. - 복구 테이블
RV_fun[]은 교차 연결되어 있다:RVOOS_DELETEundo는oos_rv_redo_insert를, redo는oos_rv_redo_delete를 실행한다(RVOOS_INSERT는 반대). 두 프리미티브가 네 방향 모두에서 재사용된다. - 회수는 분리되어 있다:
oos_remove_page(아직 연결되지 않은file_dealloc(..., FILE_OOS)래퍼, 10장 vacuum 예정)와oos_remove_file(drop-table 회수:oos_stats_del_bestspace_by_vfid후file_postpone_destroy). 삭제 프리미티브는 슬롯만 제거하여 sysop-free를 유지한다. oos_chunk_exists는 멱등성 프로브로, 좁은 “사라짐” 정의를 가진다 — 페이지 해제됨 또는 슬롯 제거됨 둘 다NO_ERROR && !exists를 반환하며, 나머지 모든 실패는 전파된다. 전체 체인 직렬화는 여전히 외부 행-락 TODO에 의존한다.
Chapter 9: UPDATE, DELETE와 즉시(Eager) Non-MVCC 정리
섹션 제목: “Chapter 9: UPDATE, DELETE와 즉시(Eager) Non-MVCC 정리”행이 변경되거나 삭제될 때, 그 행이 가리키던 OOS 레코드 일부는 가비지가 된다. 이 챕터는 어떤 OOS 레코드가 가비지가 되는지, 그리고 vacuum이 없는 코드 경로에서 누가 그것을 삭제하는지를 다룬다. 회수 정책 — OOS 체인이 죽었을 때 MVCC 버전이 왜 잔존하는지 — 은 cubrid-oos.md(UPDATE / DELETE와 회수)를 참고하고, 주변 힙 기계구조(REC_HOME vs REC_RELOCATION, forwarding OID, 물리적 업데이트 서브 경로)는 cubrid-heap-manager-detail.md를 참고한다. 이 챕터는 네 개의 힙 호출 지점과 정리 프리미티브를 추적하되, 어느 것도 재유도하지 않는다. Vacuum의 절반(MVCC 경로)은 챕터 10에서 다룬다.
9.1 회수 정책 분기
섹션 제목: “9.1 회수 정책 분기”회수는 단일 boolean인 is_mvcc_op에 의해 분기되며, 이 값은 heap_update / heap_delete의 상단에서 HEAP_UPDATE_IS_MVCC_OP로 계산된다:
// HEAP_UPDATE_IS_MVCC_OP -- src/storage/heap_file.c#if defined (SERVER_MODE)#define HEAP_UPDATE_IS_MVCC_OP(is_mvcc_class, update_style) \ ((is_mvcc_class) && (!HEAP_IS_UPDATE_INPLACE (update_style)) ? (true) : (false))#else#define HEAP_UPDATE_IS_MVCC_OP(is_mvcc_class, update_style) (false) /* <- SA_MODE: never MVCC */#endif이 매크로에서 챕터 전체를 지배하는 두 가지 사실이 도출된다:
- SA_MODE에서 이 매크로는 항상
false로 고정된다. 독립 실행 프로세스에는 동시 읽기 독자와 vacuum 데몬이 없으므로, 여기서 아무도 삭제하지 않는 낡은 OOS 레코드는 영구적으로 누수된다. 따라서 정리는 즉시(eager) 수행되어야 한다. - SERVER_MODE에서도 이 매크로는 여전히
false가 된다 — 클래스가 MVCC 미지원(!is_mvcc_class— 시스템 카탈로그 클래스)이거나 업데이트가 in-place인 경우. 따라서!is_mvcc_op는 “SA_MODE”의 동의어가 아니다; 카탈로그 클래스에서는 서버 측에서도 발동된다. 진단 문자열이 여전히"SA_MODE eager OOS cleanup"이라고 표기하는 것은 역사적 오기(misnomer)로, 실제 게이트 조건은 레이블보다 넓다.
| 모드 | is_mvcc_op | 낡은 OOS 회수 주체 | 위치 |
|---|---|---|---|
| SA_MODE (임의 클래스) | false | 즉시(eager), 인라인 | 이 챕터 |
| SERVER_MODE, MVCC 미지원 클래스(카탈로그) | false | 즉시(eager), 인라인 | 이 챕터 |
| SERVER_MODE, MVCC 클래스, 일반 update/delete | true | vacuum, 지연 | 챕터 10 |
MVCC 분기는 delete/update 시점에 낡은 OOS를 건드리지 않는다: 이전 버전의 스냅샷을 보유한 동시 독자가 여전히 인라인 OOS OID를 역참조할 수 있도록 낡은 레코드를 살려두고(챕터 7의 re-inlining 경로 참고), WAL 레코드에 태그를 달아 vacuum의 forward-walk가 나중에 pre-image를 찾을 수 있게 한다. 해당 태그(RVHF_DELETE_NEWHOME_NOTIFY_VACUUM, RVHF_UPDATE_NOTIFY_VACUUM)는 동일한 함수의 is_mvcc_op == true 측에 설정된다; 그 중 하나는 9.5절에 나온다.
INVARIANT (레코드당 하나의 OID). 모든 OOS OID는 정확히 하나의 힙 레코드에 의해 참조된다; OOS 레코드는 행들 사이에서 절대 공유되지 않는다. 이것이 즉시 삭제를 올바르게 만드는 토대이며, 코드는 생산자 측에서 이를 강제한다: 변경된 OOS 컬럼의 M1 UPDATE는 낡은 OID를 재사용하는 대신 항상 새로운 OOS OID를 할당한다(챕터 4의 쓰기 경로는 in-place 재작성이 아닌
oos_insert를 호출한다). 따라서heap_oos_delete_unreferenced가 어떤 낡은 OID를 “새 이미지에서 참조되지 않는다”고 판단하면, 참조 카운트 없이 무조건oos_delete를 호출할 수 있다 — 그것을 가리키는 다른 누구도 없기 때문이다. 이 불변 조건을 위반하면(두 행이 하나의 OID를 공유하면) 이 코드는 다른 행이 여전히 참조 중인 체인을 삭제하게 된다.
9.2 네 개의 호출 지점에서의 구조체 관계
섹션 제목: “9.2 네 개의 호출 지점에서의 구조체 관계”이 챕터에는 새로운 구조체가 도입되지 않는다. 정리 프리미티브는 RECDES(낡은, 그리고 UPDATE의 경우 새로운 힙 레코드 이미지)와 HEAP_OPERATION_CONTEXT(hfid와 힙 oid 조회에 사용 — OOS 파일 해석과 진단에 활용)를 읽는다. Figure 9-1은 네 개의 가드된 호출 지점이 하나의 프리미티브에 어떻게 연결되는지를 보여준다.
flowchart TB
subgraph DELETE
dh["heap_delete_home<br/>old=home_recdes (REC_HOME)<br/>new=NULL"]
dr["heap_delete_relocation<br/>old=forward_recdes (REC_NEWHOME)<br/>new=NULL"]
end
subgraph UPDATE
uh["heap_update_home<br/>old=home_recdes (REC_HOME)<br/>new=context->recdes_p"]
ur["heap_update_relocation<br/>old=forward_recdes (REC_NEWHOME)<br/>new=context->recdes_p"]
end
dh --> G{"!is_mvcc_op<br/>&& type matches<br/>&& heap_recdes_contains_oos"}
dr --> G
uh --> G
ur --> G
G -->|yes| F["heap_oos_delete_unreferenced"]
G -->|no| skip["skip: MVCC는 vacuum으로 지연,<br/>또는 레코드에 OOS 없음"]
F --> ggo["heap_recdes_get_oos_oids (old)"]
F --> ggn["heap_recdes_get_oos_oids (new) if UPDATE"]
F --> vfid["heap_oos_find_vfid (OOS VFID 해석)"]
F --> del["oos_delete (남은 OID별)"]
핵심 세부사항은 어떤 레코드가 old_recdes로 전달되는가이다. REC_HOME 행의 경우 데이터(및 인라인 OOS OID)는 home 슬롯에 존재하므로, old_recdes는 context->home_recdes다. 재배치된 행의 경우 home 슬롯에는 8바이트 forwarding OID만 있고, 실제 속성 바이트와 OOS 슬롯은 forward REC_NEWHOME 페이지에 존재한다. 따라서 재배치 호출 지점은 forward 페이지에서 spage_get_record로 방금 읽어온 forward_recdes를 전달한다(delete 지점은 PEEK으로, update 지점은 스택 버퍼로 COPY하여 — 어느 쪽이든 슬롯이 파괴되기 전에 forward 바이트를 확보한다). 거기에 home 슬롯을 전달하면 VOT도 OOS도 찾지 못할 것이다.
9.3 heap_oos_delete_unreferenced — 즉시 프리미티브, 모든 분기
섹션 제목: “9.3 heap_oos_delete_unreferenced — 즉시 프리미티브, 모든 분기”이것이 비-vacuum 경로에서 실제로 oos_delete를 발행하는 유일한 함수다. 계약: old_recdes에서 참조되고 new_recdes에서 참조되지 않는 모든 OOS OID를 삭제한다. new_recdes == NULL은 “더 이상 아무것도 참조되지 않는다”(DELETE)를 의미하므로 모든 낡은 OID가 삭제된다; 비-NULL new_recdes(UPDATE)는 집합 차이를 취함을 의미한다.
// heap_oos_delete_unreferenced -- src/storage/heap_oos.cppstd::vector<OID> old_oos_oids;std::vector<OID> new_oos_oids;VFID oos_vfid;int error_code;
error_code = heap_recdes_get_oos_oids (old_recdes, old_oos_oids);if (error_code != NO_ERROR) { ASSERT_ERROR (); er_log_debug (...); /* <- 엄격한 실패: 플래그가 설정되어 있으면 추출이 성공해야 함 */ return error_code; }if (old_oos_oids.empty ()) { return NO_ERROR; /* <- 낡은 이미지에 OOS 태그 없음; 할 일 없음 */ }
if (new_recdes != NULL) /* <- UPDATE만 해당; DELETE는 건너뛰어 new_oos_oids를 비워둠 */ { error_code = heap_recdes_get_oos_oids (new_recdes, new_oos_oids); if (error_code != NO_ERROR) { ASSERT_ERROR (); er_log_debug (...); return error_code; } }
if (!heap_oos_find_vfid (thread_p, &context->hfid, &oos_vfid, false)) /* <- false = 생성하지 않음 */ { er_log_debug (...); assert_release (false); /* <- OOS 플래그 설정됐지만 OOS 파일 없음 => 손상 */ return ER_FAILED; }
for (const OID &old_oid : old_oos_oids) { if (oos_oid_in_vector (new_oos_oids, &old_oid)) { continue; /* <- 양쪽 이미지에 동일한 물리적 OOS: 보존 (M3 재사용) */ } error_code = oos_delete (thread_p, oos_vfid, old_oid); if (error_code != NO_ERROR) { ASSERT_ERROR (); er_log_debug (...); return error_code; } }return NO_ERROR;분기별 완전 설명:
- 낡은 OID 추출. 오류 발생 시,
op_ctx태그와 힙 식별자로 로깅하고 반환; 호출자는 abort해야 한다(9.6절). - 낡은 이미지에 OOS OID 없음 (
old_oos_oids.empty ()):NO_ERROR반환. 호출 지점이 이미heap_recdes_contains_oos를 검사했으므로 비어있는 결과는 예상 밖이지만 여기서는 무해하다. (대조적으로,heap_recdes_get_oos_oids내부의assert는 “플래그 설정됐지만 OID 없음”을 손상으로 취급한다; 9.4절 참고.) new_recdes를 기준으로 UPDATE vs DELETE 분기. 비-NULL(UPDATE)이면 새 이미지의 OID를 추출한다 —heap_recdes_contains_oos가드가 불필요한 이유는, 새 레코드에 OOS 플래그가 없으면heap_recdes_get_oos_oids가 빈 벡터와 함께NO_ERROR를 반환하여(9.3 3단계의 “낡은 것 모두 삭제” 동작과 정확히 일치) 처리되기 때문이다.new_recdes == NULL(DELETE)이면new_oos_oids는 비어있으므로 모든 낡은 OID가 소속 검사에 실패하여 삭제된다.- OOS 파일 해석.
heap_oos_find_vfid (..., false)(생성 플래그false). false 반환은 헤더가 OOS를 표시하지만 클래스별 OOS 파일이 없다는 의미 — 실제 손상: 로깅,assert_release (false),ER_FAILED. - OID별 집합 차이. 각
old_oid를 순회하며:oos_oid_in_vector (new_oos_oids, &old_oid)이면 변경 전후 모두 동일한 물리적 체인이 참조되는 것 —continue하여 보존(M3 변경 없는 컬럼 재사용 훅; M1에서는 생산자가 항상 재할당하므로 이 분기는 발동하지 않지만, 코드는 이미 올바르다). 그 외에는oos_delete호출; 오류 시 전체 식별자 트리플을 로깅하고 반환 — 루프 중간 실패는 앞서 삭제된 청크들이 이미 처리된 채로 남으므로 abort 계약이 필요하다. - 성공:
NO_ERROR반환.
Figure 9-2는 DELETE-vs-UPDATE의 비대칭성을 명시적으로 보여준다:
flowchart TD
A["진입"] --> B["낡은 OID 수집"]
B -->|오류| E1["로그 + 오류 반환"]
B -->|비어있음| R0["NO_ERROR 반환"]
B -->|비어있지 않음| C{"new_recdes == NULL?"}
C -->|yes DELETE| D0["new_oos_oids 비어있음 유지"]
C -->|no UPDATE| D1["새 OID 수집"]
D1 -->|오류| E1
D0 --> V["heap_oos_find_vfid create=false"]
D1 --> V
V -->|찾지 못함| E2["로그 + assert + ER_FAILED"]
V -->|찾음| L["각 old_oid에 대해"]
L --> M{"old_oid가 new_oos_oids에 있는가?"}
M -->|yes| K["보존, continue"]
M -->|no| X["oos_delete"]
X -->|오류| E1
X -->|성공| L
L -->|완료| R1["NO_ERROR 반환"]
9.4 heap_recdes_get_oos_oids — OOS 항목을 위한 VOT 순회
섹션 제목: “9.4 heap_recdes_get_oos_oids — OOS 항목을 위한 VOT 순회”이 수집기(heap_file.c 내)는 온디스크 레코드 형식과 프리미티브가 동작하는 OID 벡터 사이를 잇는 다리 역할을 한다. VOT(variable-offset table)를 순회하면서, offset에 OOS 플래그 비트가 설정된 항목마다 인라인 OID를 읽는다(OR_OID_SIZE 바이트만 — 인라인 스텁에는 길이도 포함되지만, 삭제를 위해 체인 주소를 지정하는 데는 OID만 필요하다).
// heap_recdes_get_oos_oids -- src/storage/heap_file.coos_oids.clear ();if (!heap_recdes_contains_oos (recdes)) return NO_ERROR; /* <- 헤더 플래그 미설정: 비어있는 결과, 성공 */const int offset_size = OR_GET_OFFSET_SIZE (recdes->data);const int max_var_count = (recdes->length - OR_HEADER_SIZE (recdes->data)) / offset_size;
for (int index = 0; index <= max_var_count; ++index) { if (index == max_var_count) { assert_release (false /*LAST_ELEMENT not found in bounds*/); return ER_FAILED; } int offset; /* read per offset_size: BYTE/SHORT/INT; default -> assert_release(false), ER_FAILED */
if (OR_IS_OOS (offset)) { OID oid = OID_INITIALIZER; const char *oid_ptr = (char *) recdes->data + OR_VAR_OFFSET (recdes->data, index); if (oid_ptr + OR_OID_SIZE > (char *) recdes->data + recdes->length) { assert (false); return ER_FAILED; } /* <- OID 읽기가 레코드를 초과함 */ OR_BUF buf; or_init (&buf, (char *) oid_ptr, OR_OID_SIZE); if (or_get_oid (&buf, &oid) != NO_ERROR) { assert (false); return ER_FAILED; } if (OID_ISNULL (&oid)) { assert (false); return ER_FAILED; } /* <- OOS 슬롯에 null */ oos_oids.emplace_back (oid); /* oos_debug() in NDEBUG-off builds */ } if (OR_IS_LAST_ELEMENT (offset)) { if (oos_oids.empty ()) { assert (false); return ER_FAILED; } /* <- 플래그가 OOS를 표시했는데 VOT에 없음 */ return NO_ERROR; /* <- 정상 종료: 센티넬 도달 */ } }assert (false /*unreachable: there must be last element*/); /* <- 루프가 fall-through 되면 안 됨 */return ER_FAILED;분기 커버리지:
- 헤더 플래그 미설정 (
!heap_recdes_contains_oos):NO_ERROR반환, 빈 벡터 — UPDATE 경로가 새 이미지에서 기대하는 분기(9.3 3단계). max_var_count개 항목 이전에 센티넬 미발견: VOT가 경계 내에서OR_IS_LAST_ELEMENT종료자를 갖지 않음 —assert_release,ER_FAILED.max_var_count는 느슨한 상한값(length - header) / offset_size로; last-element 플래그가 없는 레거시 레코드의 경우 정렬/고정 속성 바이트를 포함할 수 있어(소스 주석에 “아직 완전히 지원되지 않음”으로 표시됨) 안전망 역할을 하되 정확한 카운트는 아니다.offset_sizeswitch는 BYTE / SHORT / INT 케이스와assert_release (false),ER_FAILED를 수행하는default를 가진다.- 항목이 OOS임 (
OR_IS_OOS (offset)): 인라인 OID 읽기. 세 가지 가드가 엄격하게 실패 — 경계 초과,or_get_oid실패, OOS 슬롯의 null OID — 헤더 플래그가 이미 실제 OID를 약속했기 때문이다. - 항목이 센티넬임 (
OR_IS_LAST_ELEMENT): 플래그에도 불구하고 OID가 수집되지 않은 것은 불일치(assert (false),ER_FAILED); 그 외에는 수집된 벡터 반환(디버그 빌드에서는oos_debug로 덤프). - 루프 fall-through (루프 이후): 내부의 모든 경로는 반환하거나 continue하므로, 후미의
assert (false)/ER_FAILED는 절대 실행되어서는 안 되는 방어적 안전망이다.
OR_IS_OOS와 OR_IS_LAST_ELEMENT는 동일한 offset 워드의 독립된 비트이므로 — 항목이 두 비트를 모두 가질 수 있어, 한 번의 반복에서 수집과 반환이 모두 발동된다. 비트 인코딩은 챕터 1과 cubrid-oos.md의 ### Multi-chunk chain이 소유한다; 이 챕터는 재유도하지 않는다.
INVARIANT (플래그는 추출 가능한 OID를 의미한다).
heap_recdes_contains_oos가 true이면, VOT 순회 시 레코드 경계 내에서 최소 하나의 non-null OID가 산출되어야 한다. 위의assert/assert_release엄격 실패 종료로 강제된다. 위반(플래그 설정됐지만 실제 OID 없음) 시, 즉시 경로는 조용히 누수를 발생시키는 대신ER_FAILED를 반환하고, 호출자가 abort한다.
9.5 네 개의 힙 호출 지점
섹션 제목: “9.5 네 개의 힙 호출 지점”네 개의 가드는 모두 !is_mvcc_op && <레코드 타입 일치> && heap_recdes_contains_oos (<recdes>) 형태를 공유한다. 레코드 타입 절과 전달되는 recdes는 지점마다 다르다. heap_recdes_contains_oos는 단일 라인 플래그 테스트다:
// heap_recdes_contains_oos -- src/storage/heap_file.cint flag = (INT32) OR_GET_MVCC_FLAG (record->data);return flag & OR_MVCC_FLAG_HAS_OOS;DELETE 지점은 new_recdes == NULL(낡은 OOS 모두 삭제)과 소멸하는 레코드의 낡은 이미지를 전달한다. heap_delete_home은 non-MVCC else 분기에서 context->home_recdes(REC_HOME)를 전달하며 실행된다; heap_delete_relocation은 재조회된 forward_recdes(REC_NEWHOME; home 슬롯이 forwarding OID에 불과하므로)를 전달한다. 두 지점 모두 heap_log_delete_physical / heap_delete_physical 이전에 실행된다 — 슬롯이 사라지면 레코드 바이트와 인라인 OOS OID도 사라지기 때문이다:
// heap_delete_home (non-mvcc else branch) -- src/storage/heap_file.cif (!is_mvcc_op && context->record_type == REC_HOME && heap_recdes_contains_oos (&context->home_recdes)) { error_code = heap_oos_delete_unreferenced (thread_p, context, &context->home_recdes, NULL, "delete home"); if (error_code != NO_ERROR) { ASSERT_ERROR (); return error_code; } }// heap_delete_relocation (non-mvcc branch) -- src/storage/heap_file.cif (!is_mvcc_op && forward_recdes.type == REC_NEWHOME && heap_recdes_contains_oos (&forward_recdes)) { rc = heap_oos_delete_unreferenced (thread_p, context, &forward_recdes, NULL, "delete relocation"); if (rc != NO_ERROR) { ASSERT_ERROR (); return rc; } }UPDATE — heap_update_home (낡은 home 타입 REC_HOME). new_recdes는 비-NULL — context->recdes_p, 새 이미지 — 이므로 프리미티브가 집합 차이를 계산한다. heap_update_physical 이후에 실행된다; 낡은 OID는 지금 덮어써진 슬롯이 아닌 앞서 캡처된 인메모리 context->home_recdes에서 추출되므로 여전히 추출 가능하다:
// heap_update_home -- src/storage/heap_file.cif (!is_mvcc_op && context->home_recdes.type == REC_HOME && heap_recdes_contains_oos (&context->home_recdes)) { error_code = heap_oos_delete_unreferenced (thread_p, context, &context->home_recdes, context->recdes_p, "update home"); if (error_code != NO_ERROR) { ASSERT_ERROR (); goto exit; } }UPDATE — heap_update_relocation (낡은 forward 타입 REC_NEWHOME). old_recdes는 forward_recdes, new_recdes는 context->recdes_p다. 이 가드는 네 개의 업데이트 서브 경로(relocation-to-home, to-bigone, to-relocation, in-place) 이전에 위치한다 — 네 경로 모두 forward 슬롯을 덮어쓰거나 제거하므로, 서브 경로에 관계없이 낡은 forward의 OOS는 접근 불가가 된다:
// heap_update_relocation -- src/storage/heap_file.cif (!is_mvcc_op && forward_recdes.type == REC_NEWHOME && heap_recdes_contains_oos (&forward_recdes)) { rc = heap_oos_delete_unreferenced (thread_p, context, &forward_recdes, context->recdes_p, "update relocation"); if (rc != NO_ERROR) { ASSERT_ERROR (); goto exit; } }대조적으로, heap_update_relocation의 MVCC 절반은 삭제하지 않는다; 나중에 vacuum이 pre-image를 찾을 수 있도록 WAL에만 태그를 다시 붙인다. 동일한 forward_recdes / heap_recdes_contains_oos 테스트가 remove_old_forward 로그 호출에서 반대 is_mvcc_op 극성으로 나타난다:
// heap_update_relocation (mvcc remove_old_forward) -- src/storage/heap_file.cLOG_RCVINDEX delete_rcvindex = RVHF_DELETE;if (is_mvcc_op && forward_recdes.type == REC_NEWHOME && heap_recdes_contains_oos (&forward_recdes)) { delete_rcvindex = RVHF_DELETE_NEWHOME_NOTIFY_VACUUM; /* <- vacuum의 forward-walk가 낡은 OOS 회수 */ }heap_log_delete_physical (..., &forward_recdes, true, &prev_version_lsa, delete_rcvindex);heap_update_home의 MVCC 대응 함수도 undo를 RVHF_UPDATE_NOTIFY_VACUUM으로 유사하게 태그한다. 이 notify-vacuum 태그들은 챕터 10의 진입점이다; 여기서는 극성 분기를 가시화하기 위해서만 언급된다 — !is_mvcc_op에서는 즉시 삭제, is_mvcc_op에서는 notify-vacuum.
9.6 무조건 삭제가 안전한 이유, 그리고 abort 계약
섹션 제목: “9.6 무조건 삭제가 안전한 이유, 그리고 abort 계약”두 가지 속성이 결합하여 즉시 경로를 올바르게 만든다:
- **레코드당 하나의 OID 불변 조건(9.1절)**은 새 이미지에서 제거된 OID가 다른 누구에 의해서도 참조되지 않음을 의미한다. 참조 카운트를 참조하지 않아도 된다;
oos_delete는 명시적인 “양쪽 이미지에 존재하는” 보존 케이스를 제외하면 무조건적이다. oos_delete는 각 청크를 개별적으로 로깅한다 — 전체 레코드를 undo 데이터로 가지는RVOOS_DELETE를 포함하는 sysop 없이. Abort나 충돌 복구 시 해당 undo 레코드를 역순으로 재실행하여 모든 청크를 원래 슬롯으로 복원한다. (챕터 8이 이 프리미티브를 해부하며; 동작하는 체인 모델은cubrid-oos.md를 참고.)
INVARIANT (오류 시 abort).
heap_oos_delete_unreferenced에서 비-NO_ERROR반환을 받은 호출자는 반드시 트랜잭션을 abort해야 한다. 모든 호출 지점이 반환값을 확인하고 탈출(return/goto exit)하는 방식으로 구조적으로 강제되며, 스토리지 레이어 관례에 따라 오류는 트랜잭션 abort까지 전파된다. 호출자가 오류를 무시하고 체인 중간에 커밋하면, 부분적으로 삭제된 OOS 청크가 영구화되는 반면 살아있는 힙 레코드는 여전히 (이제 깨진) 체인을 참조하게 된다 — 청크별 undo 레코드가 존재하는 이유인 바로 그 손상. 이것이 정리가 비동기적으로가 아닌 delete/update 시점에 실행되는 이유다: 살아있는 트랜잭션의 undo를 공유하므로 롤백이 자동이다.
oos_oid_in_vector는 남은 유일한 헬퍼 — 의도적인 선형 탐색으로, 벡터가 매우 작기 때문에 정당화된다(OOS 컬럼당 OOS OID 하나, 실제로는 한 자릿수):
// oos_oid_in_vector -- src/storage/oos_util.cppfor (const OID &candidate : oids) if (OID_EQ (&candidate, oid)) return true;return false;9.7 챕터 요약 — 핵심 정리
섹션 제목: “9.7 챕터 요약 — 핵심 정리”- 하나의 boolean이 모든 것을 결정한다.
is_mvcc_op(HEAP_UPDATE_IS_MVCC_OP에서 계산)가 낡은 OOS 회수를 라우팅한다:!is_mvcc_op는 여기서 즉시 회수;is_mvcc_op는 vacuum으로 지연(챕터 10).!is_mvcc_op는"SA_MODE"진단 레이블에도 불구하고 SA_MODE 와 MVCC 미지원 카탈로그 클래스에서의 SERVER_MODE를 모두 커버한다. - 새로운 OID 규칙이 즉시 삭제를 안전하게 만드는 것이다. M1 UPDATE는 변경된 컬럼에 항상 새로운 OOS OID를 할당하므로 레코드당 하나의 OID 불변 조건이 유지되며,
heap_oos_delete_unreferenced는 참조 카운트 없이 무조건 삭제할 수 있다. - DELETE는 모두 삭제, UPDATE는 집합 차이를 삭제한다.
new_recdes == NULL(DELETE)은 모든 낡은 OID를 제거하고; 비-NULLnew_recdes(UPDATE)는oos_oid_in_vector로 양쪽 이미지에 존재하는 OID를 보존한다 — M1에서는 비활성인 M3 재사용 훅. - 네 개의 호출 지점, 하나의 프리미티브, 올바른 recdes.
heap_delete_home/heap_update_home은context->home_recdes(REC_HOME)를 전달;heap_delete_relocation/heap_update_relocation은 방금 재조회된forward_recdes(REC_NEWHOME)를 전달 — 재배치된 데이터가 forward 페이지에 존재하기 때문이다. 네 개 모두!is_mvcc_op && <타입> && heap_recdes_contains_oos로 가드된다. - 추출은 엄격하다.
heap_recdes_get_oos_oids는 VOT를 순회하며 모든 불일치(센티넬 미발견, null OID, 경계 초과, 플래그-설정-비어있음, 도달 불가 fall-through)에assert한다 — OOS를 주장하는 헤더는 추출 가능한 OID를 산출해야 하며, 그렇지 않으면 연산이 실패하고 트랜잭션이 abort된다. - 정확성은 abort 계약에 달려있다.
oos_delete는 sysop 없이 청크별로 로깅한다; 즉시 경로의 오류는 호출자가 abort 의무를 지도록 하여 undo 레코드가 재실행되고 부분적으로 삭제된 체인이 복원된다. 모든 호출 지점은return/goto exit으로 이를 준수한다. - MVCC 극성은 동일한 함수 내에서 가시화된다. 비-MVCC 분기가 삭제하는 곳에서, MVCC 분기는 대신 WAL에 태그를 달아(
RVHF_DELETE_NEWHOME_NOTIFY_VACUUM,RVHF_UPDATE_NOTIFY_VACUUM) vacuum의 forward-walk가 나중에 pre-image를 회수하도록 한다 — 챕터 10으로의 인계.
Chapter 10: Vacuum 회수(Reclamation)
섹션 제목: “Chapter 10: Vacuum 회수(Reclamation)”MVCC 경로에서 UPDATE나 DELETE는 이전 row의 OOS 청크를 인라인으로 해제하지 않는다 — 이전 버전은 진행 중인 스냅샷에서 여전히 가시적일 수 있으므로, 청크는 해당 연산보다 더 오래 살아 있어야 한다. 9장에서 다룬 즉시 비-MVCC 정리는 해당 row가 다른 누구에게도 MVCC-visible이었던 적이 없을 때에만 동작한다. 그 외 모든 경우의 OOS 바이트는 수정 시점에 의도적으로 버려진(abandoned) 채로 남겨지며, 회수는 vacuum의 책임이 된다.
왜 이전 버전이 살아남는지에 대한 MVCC 이론은 cubrid-oos.md (§ “UPDATE / DELETE and reclamation”)를 참고하라. 워커 루프 안에서 이 훅들이 어디에 위치하는지 — vacuum_process_log_block(메모 소유), vacuum_heap_record, 그리고 종료 시에만 동작하는 assert인 vacuum_finished_block_vacuum — 는 cubrid-vacuum-detail.md (Chapters 8–9)를 참고하라. 이 챕터에서는 src/query/vacuum_oos.cpp의 회수 코드만을 해부한다. 해당 파일에는 vacuum이 OOS 청크가 죽었음을 인식하는 두 가지 방식을 반영하는 두 개의 진입점이 있다:
- REMOVE 경로 —
vacuum_heap_oos_delete_within_sysop. Vacuum이 OOS 참조를 가진 죽은 heap 레코드(REC_HOME/REC_NEWHOME)를 물리적으로 제거한다. 레코드 본문이 바로 앞에 있으므로, 이미 열린 heap-vacuum sysop 안에서 해당 청크를 삭제한다. - Forward-walk 경로 —
vacuum_forward_walk_reclaim_oos. 죽은 버전이 더 이상 살아있는 슬롯으로 존재하지 않는다. 로그의 undo pre-image로만 살아남아 있다. Vacuum은 이전 이미지를 재구성하고, 직접 열어놓은 sysop 안에서 해당 이미지가 가리키는 OOS를 해제한다.
두 경로는 하나의 철학을 공유한다: OOS 회수 실패가 vacuum 블록 자체를 실패시켜서는 안 된다. 포기하면 디스크 위에 약간의 고아 청크라는 작고, 기록되며, 한정된 누수가 생기지만; 블록을 실패시키면 vacuum_finished_block_vacuum의 종료 전용 assert가 발동되어 vacuum이 완전히 멈춰버릴 수 있다.
10.1 VFID 조회 메모와 FOUND/NONE/ERROR 삼중 상태
섹션 제목: “10.1 VFID 조회 메모와 FOUND/NONE/ERROR 삼중 상태”두 경로 모두 삭제를 시작하기 전에 heap이 사용하는 OOS 파일이 어느 것인지 알아야 한다. heap과 OOS 파일은 1:1 관계이며, heap이 생성된 이후에는 이 매핑이 변경되지 않으므로 답을 캐시할 수 있다. 그 캐시가 VACUUM_OOS_VFID_MEMO이며, vacuum_process_log_block의 스택 위에 단일 슬롯 구조체로 선언된다.
// vacuum_oos_vfid_memo -- src/query/vacuum_oos.hppstruct vacuum_oos_vfid_memo{ bool valid = false; /* false until the first successful lookup */ VFID heap_vfid; /* key; meaningful only when valid */ VFID oos_vfid; /* value; meaningful only when valid (VFID_NULL = "no OOS file") */};| 필드 | 역할 | 존재 이유 |
|---|---|---|
valid | 게이트. 첫 번째 성공적인 (FOUND 또는 NONE) 조회가 슬롯을 채울 때까지 false. | 영-초기화된 메모가 캐시된 답처럼 보여서는 안 된다. valid는 “조회한 적 없음”과 “조회했더니 OOS 파일 없음”을 구분한다. |
heap_vfid | 캐시 키 — 캐시된 답이 어떤 heap의 것인지; VFID_EQ로 요청된 heap과 비교. | 한 워커 블록은 여러 heap을 건드릴 수 있다. 키가 없으면 heap B를 물어볼 때 heap A의 OOS 파일을 돌려줄 수 있다. |
oos_vfid | 캐시 값 — heap의 OOS 파일 id. VFID_NULL일 수도 있음 = “이 heap에는 합법적으로 OOS 파일이 없음.” | 음수 답도 캐시할 수 있어서, OOS 파일이 없는 heap을 매 레코드마다 재탐색하지 않아도 된다. |
불변 조건 — 단일 슬롯, 워커-블록 단위, 동기화 없음. 메모는
vacuum_process_log_block의 스택에 살며, 각 워커 스레드가 자신만의 사본을 가지고 블록이 끝나면 소멸된다. 헤더 주석은 단호하다:static으로 교체하지 말 것 — vacuum은 여러 워커 스레드에서 실행되며, 공유 static은 잠금 없이 레이스가 된다. 단일 슬롯으로 충분한 이유는 대량UPDATE가 동일한 heap에 대한RVHF_UPDATE_NOTIFY_VACUUM레코드를 연속으로 방출하기 때문이다. 단일 슬롯은 그 전체 런을 하나의 실제 조회와 N번의 저렴한VFID_EQ히트로 압축한다.
불변 조건 — 일시적 오류는 절대 메모에 기록되지 않는다. FOUND와 NONE만 슬롯에 쓴다. ERROR는
valid/oos_vfid를 그대로 두어 다음 레코드가 처음부터 재시도할 수 있게 한다. 이를 위반하면 페이지 읽기 오류 한 번 이후 잘못된VFID_NULL을 캐시하고, 블록의 나머지 레코드 OOS를 모두 조용히 누수시키게 된다.
vacuum_oos_vfid_lookup이 명시적인 enum을 반환하는 이유가 바로 이것이다 — 호출자가 “OOS 파일 없음”과 “조회 실패”를 구분할 수 있어야 하기 때문이다:
// VACUUM_OOS_VFID_LOOKUP_RESULT -- src/query/vacuum_oos.cpptypedef enum{ VACUUM_OOS_VFID_FOUND, /* found a real OOS file; its id is in out_oos_vfid */ VACUUM_OOS_VFID_NONE, /* the heap simply has no OOS file (normal) */ VACUUM_OOS_VFID_ERROR /* lookup failed; error left set, answer not cached */} VACUUM_OOS_VFID_LOOKUP_RESULT;flowchart TD
A["vacuum_oos_vfid_lookup(memo, heap_vfid)"] --> B{"memo.valid이고 VFID_EQ 키 일치?"}
B -- yes --> C{"캐시된 값이 VFID_ISNULL?"}
C -- yes --> R_NONE["NONE 반환, 캐시된 음수 답"]
C -- no --> R_FOUND["FOUND 반환, 캐시 히트"]
B -- no --> F{"file_descriptor_get 성공?"}
F -- no --> R_ERR1["ERROR 반환, 오류 세트, 캐시 안 함"]
F -- yes --> G{"HFID_IS_NULL?"}
G -- yes --> R_ERR2["ERROR 반환, 캐시 안 함"]
G -- no --> H{"heap_oos_find_vfid true?"}
H -- no --> I{"er_errid != NO_ERROR?"}
I -- yes --> R_ERR3["ERROR 반환, 실제 읽기 실패, 캐시 안 함"]
I -- no --> J["정상적 OOS 파일 없음, VFID_NULL 캐시"]
H -- yes --> K["메모 쓰기: valid, key, value"]
J --> K
K --> L{"값이 VFID_ISNULL?"}
L -- yes --> R_NONE2["NONE 반환"]
L -- no --> R_FOUND2["FOUND 반환"]
Figure 10-1: vacuum_oos_vfid_lookup의 분기 완전 제어 흐름 — 메모 히트(NONE/FOUND), 두 개의 조기 ERROR, false-return 분기 처리, 그리고 메모에 기록되지 않는 ERROR.
미묘한 분기는 heap_oos_find_vfid false 반환이다: 이는 실제 읽기 실패를 의미할 수도 있고, heap 헤더에 OOS 파일 id가 없는 정상적인 경우일 수도 있다. er_errid () != NO_ERROR 검사가 이 둘을 구분한다.
// vacuum_oos_vfid_lookup -- src/query/vacuum_oos.cppif (!heap_oos_find_vfid (thread_p, &hfid, out_oos_vfid, false)) { VFID_SET_NULL (out_oos_vfid); if (er_errid () != NO_ERROR) { /* A real failure happened while reading the heap header ... Do not cache it. */ return VACUUM_OOS_VFID_ERROR; } /* No error was raised, so this is the honest "no OOS file" case ... cache that VFID_NULL. */ }
memo->valid = true;VFID_COPY (&memo->heap_vfid, heap_vfid);VFID_COPY (&memo->oos_vfid, out_oos_vfid);세 개의 return VACUUM_OOS_VFID_ERROR 분기(file_descriptor_get 이후, HFID_IS_NULL 이후, 위의 실제 실패 분기)는 모두 의도적으로 데이터베이스 오류를 세트된 상태로 유지한다 — 주석은 호출자가 이를 로그에 남기길 원하기 때문에 여기서 er_clear()를 하지 않는다고 명시한다. false-return 분기 처리 이후에만 세 메모 필드가 함께 쓰이므로, 영-초기화되거나 ERROR로 중단된 메모는 절대 캐시된 답처럼 보이지 않는다.
10.2 Forward-walk 경로: 죽은 pre-image 재구성
섹션 제목: “10.2 Forward-walk 경로: 죽은 pre-image 재구성”vacuum_forward_walk_reclaim_oos는 더 어려운 경로다. 죽은 버전이 살아있는 슬롯으로 존재하지 않는다. RVHF_UPDATE_NOTIFY_VACUUM (update_old_home) 또는 RVHF_DELETE_NEWHOME_NOTIFY_VACUUM (remove_old_forward) 로그 레코드의 undo pre-image로만 존재한다. 원시 undo 바이트는 INT16 레코드 타입 다음에 pre-image 본문이 이어진다.
flowchart TD
A["vacuum_forward_walk_reclaim_oos(undo_data, size, heap_vfid, memo)"] --> B{"undo_data NULL 또는 size 너무 작음?"}
B -- yes --> R0["반환, 회수할 것 없음"]
B -- no --> C["undo_recdes 구성: type, data+2, len-2"]
C --> D{"REC_HOME/REC_NEWHOME이고 contains_oos?"}
D -- no --> R1["반환, forwarding ptr이거나 OOS 없음"]
D -- yes --> E["db_private_alloc stable_copy"]
E --> F{"alloc NULL?"}
F -- yes --> R2["한정된 누수 로그, er_clear, 반환"]
F -- no --> G["memcpy, parse_recdes.data = stable_copy"]
G --> H["vacuum_oos_vfid_lookup"]
H --> I{"lookup_result"}
I -- ERROR --> J["한정된 누수 로그, er_clear, 전파 안 함"]
I -- FOUND --> K["heap_recdes_get_oos_oids 후 delete_atomic"]
K --> L{"둘 다 성공?"}
L -- no --> M["한정된 누수 로그, er_clear, 전파 안 함"]
L -- yes --> N["삭제 완료"]
I -- NONE --> O["불변 조건 위반 로그, abort"]
J --> Z["db_private_free_and_init stable_copy"]
M --> Z
N --> Z
O -.->|프로세스 종료| X(("abort"))
Figure 10-2: vacuum_forward_walk_reclaim_oos의 분기 완전 제어 흐름. abort 경로를 제외한 모든 비-abort 결과는 db_private_free_and_init으로 수렴한다; NONE 분기만이 abort()로 빠져나간다.
분기 1 — 너무 작음. undo_data == NULL이거나 undo_data_size <= sizeof(INT16)이면 pre-image 본문을 담을 공간이 없으므로 즉시 반환한다.
분기 2 — 잘못된 레코드 타입이거나 OOS 없음. undo 이미지는 RECDES로 감싸지고 복합 검사로 게이트된다:
// vacuum_forward_walk_reclaim_oos -- src/query/vacuum_oos.cppRECDES undo_recdes;undo_recdes.type = * (INT16 *) undo_data;undo_recdes.data = undo_data + sizeof (INT16);undo_recdes.length = undo_data_size - (int) sizeof (INT16);
if (! ((undo_recdes.type == REC_HOME || undo_recdes.type == REC_NEWHOME) && heap_recdes_contains_oos (&undo_recdes))) { return; }이 가드는 부하를 지탱하는 필수 코드다. heap_update_bigone / update_old_home은 이전 REC_BIGONE / REC_RELOCATION 슬롯, 즉 8바이트 OID만을 로그에 남긴다 — row 레코드가 아니다. 이런 이미지가 heap_recdes_contains_oos에 도달하면, OID의 pageid를 MVCC 헤더인 것처럼 읽게 된다. bit 27이 세트된 pageid는 “OOS 있음” 플래그로 위장하고, heap_recdes_get_oos_oids는 쓰레기 참조 리스트를 assert_release로 내몰 것이다. 따라서 실제 row 레코드만 통과하도록 한다 — 즉시 삭제 경로에서 사용하는 것과 동일한 REC_HOME / REC_NEWHOME 가드다.
분기 3 — 안정적이고 정렬된 복사본. 파일에서 가장 미묘한 방어적 조치다. 어떤 페이지도 수정하기 전에 pre-image를 로그 버퍼에서 복사해 낸다:
// vacuum_forward_walk_reclaim_oos -- src/query/vacuum_oos.cppRECDES parse_recdes = undo_recdes;char *stable_copy = (char *) db_private_alloc (thread_p, undo_recdes.length);if (stable_copy == NULL) { vacuum_er_log_error (VACUUM_ER_LOG_HEAP, "forward-walk oos cleanup: failed to allocate %d bytes ... (bounded leak) ...", undo_recdes.length, VFID_AS_ARGS (heap_vfid)); er_clear (); return; }memcpy (stable_copy, undo_recdes.data, undo_recdes.length);parse_recdes.data = stable_copy; /* parse from the copy, never the log page */복사 한 번으로 두 가지 위험을 해결한다:
- 페이지 교체(page rotation).
undo_recdes.data는 보통 워커의 현재 로그 페이지를 직접 가리킨다.vacuum_oos_vfid_lookup안의 페이지 읽기(다음 단계)는 로그 활동을 일으켜 그 페이지를 교체할 수 있다. 주석에는 실제 관찰 사례가 기록되어 있다: 이 주소의 flags 바이트가 조회 전후로 0x69에서 0x00으로 바뀌었다. 교체된 바이트를 파싱하면 아무것도 찾지 못하고 조용히 지나친다. - 정렬(alignment). 이미지는
undo_data + sizeof(INT16)위치에서 시작하는데, 이는 2바이트 비정렬 포인터다.heap_recdes_get_oos_oids안의OR_BUF리더는 디버그 빌드에서 비정렬 포인터에 assert를 발동한다.db_private_alloc은 정렬된 메모리를 반환한다. 할당 실패 분기는 한정된 누수를 로그에 남기고er_clear()하며, 블록을 실패시키지 않는다.
분기 4 — 조회 삼중 상태. 안정적인 복사본을 확보한 뒤 vacuum_oos_vfid_lookup을 호출하고, 세 가지 결과가 흐름을 나눈다:
- ERROR → 한정된 누수 메시지를 로그에 남기고,
er_clear(), 복사본 해제로 이어진다. 주석은 단호하다:DO NOT propagate; the block must complete. - FOUND →
heap_recdes_get_oos_oids로 OID 목록을 추출하고, 성공 시 (std::move로)vacuum_forward_walk_oos_delete_atomic에 넘긴다. 어느 단계에서의 실패든 한정된 누수로 로그에 남기고er_clear()된다. atomic 헬퍼는 자신의 sysop을 이미 abort했으므로 부분 삭제는 살아남지 않는다. - NONE → 라이브
abort().
// vacuum_forward_walk_reclaim_oos -- src/query/vacuum_oos.cpp (NONE branch)else { /* VACUUM_OOS_VFID_NONE. TODO(do not review, remove before develop merge): the guard above already * confirmed this undo image is REC_HOME/REC_NEWHOME and heap_recdes_contains_oos, so reaching here * means the record's OOS flag is set but its heap has no OOS file - an invariant violation ... * CI runs the release build where assert_release only logs, so abort() to crash and surface the bug. */ vacuum_er_log_error (VACUUM_ER_LOG_HEAP, "forward-walk oos cleanup: undo image has OOS flag but heap has no OOS file; ...", VFID_AS_ARGS (heap_vfid)); abort (); }주의 — 라이브
abort()와 임시 include. 이abort()는 의도적이지만 임시적이다. 분기 2에서 이미지가 row 레코드임을 확인했고heap_recdes_contains_oos가 true를 반환한 상황에서 NONE이 나오면, 레코드가 OOS를 가진다고 주장하지만 그 heap에 OOS 파일이 없다는 의미 — 진정한 불변 조건 위반이다. CI가 릴리즈 빌드(NDEBUG)로 실행되기 때문에assert_release는 알림만 로그에 남기고,er_clear()가 이를 지워버려 버그가 절대 표면화되지 않는다.abort()는 이를 강제 크래시로 잡아낸다. 임시#include <cstdlib>(/* abort - TODO: remove before develop merge (temporary CI crash) */주석 포함)와TODO(do not review, remove before develop merge)마커가 함께 있다. develop 머지를 준비하는 독자는abort()와 임시 include를 모두 제거해야 한다 — 프로덕션에서 이 분기는 프로세스를 죽이는 것이 아니라 한정된 누수로 대체되어야 한다.
db_private_free_and_init (thread_p, stable_copy)는 abort하지 않는 모든 경로(ERROR, FOUND-성공, FOUND-실패)에서 도달하므로, 스냅샷 버퍼는 정확히 한 번 해제된다. NONE 분기는 절대 여기에 도달하지 않지만 abort()가 어차피 프로세스를 종료시킨다.
10.3 원자적 다중 청크 삭제: 소유권, 정렬, 멱등성
섹션 제목: “10.3 원자적 다중 청크 삭제: 소유권, 정렬, 멱등성”vacuum_forward_walk_oos_delete_atomic이 실제 forward-walk 삭제가 일어나는 곳이다. 시그니처가 첫 번째 단서다 — OID 벡터가 값으로 전달된다:
// vacuum_forward_walk_oos_delete_atomic -- src/query/vacuum_oos.cppstatic intvacuum_forward_walk_oos_delete_atomic (THREAD_ENTRY *thread_p, const VFID *oos_vfid, std::vector<OID> oos_oids){ int error_code = NO_ERROR; std::sort (oos_oids.begin (), oos_oids.end (), [] (const OID &a, const OID &b) { return oid_compare (&a, &b) < 0; });불변 조건 — 헬퍼가 OID 벡터를 완전히 소유한다. 값으로 전달하여 인플레이스 정렬이 가능하고, 더 중요하게는 OID들이
oos_delete가 교체할 수 있는 로그 버퍼에 살지 않도록 한다 — 10.2의 stable copy와 동일한 페이지 교체 위험이 OID 목록에도 적용된다. 벡터가 호출자의undo_data를 앨리어스하면, 루프 중 페이지 교체로 이터레이션 중인 OID가 바뀔 수 있다.
oid_compare를 사용한 std::sort는 OID를 (volid, pageid, slotid) 순서로 정렬한다. 이 순서로 삭제하면 연속된 oos_delete 호출이 인접한 페이지를 건드리게 되어, 방금 로드된 OOS 페이지가 버퍼 풀에 뜨거운 상태로 남는다 — heap 스캔이 사용하는 것과 동일한 지역성 원칙이다. 또한 미실현 TODO(perf)를 위한 준비이기도 하다: oos_delete는 매 호출마다 OOS 페이지를 pgbuf_fix하고 unfix하므로, 같은 페이지의 OID는 페이지당 한 번이 아니라 OID당 한 번씩 fix된다. 정렬은 장차 페이지 그룹화 최적화를 국소적인 변경으로 만든다.
flowchart TD
A["oos_oids를 volid,pageid,slotid로 정렬"] --> B["log_sysop_start"]
B --> C{"OID 더 있음?"}
C -- no --> H{"error_code == NO_ERROR?"}
C -- yes --> D["oos_chunk_exists"]
D --> E{"오류?"}
E -- yes --> F["break"]
E -- no --> G{"존재?"}
G -- no --> C2["continue, 이미 삭제됨"]
C2 --> C
G -- yes --> I["oos_delete"]
I --> J{"오류?"}
J -- yes --> F
J -- no --> C
F --> H
H -- yes --> K["log_sysop_commit"]
H -- no --> L["log_sysop_abort"]
K --> M["error_code 반환"]
L --> M
Figure 10-3: vacuum_forward_walk_oos_delete_atomic의 분기 완전 제어 흐름 — 자체 소유 sysop, break-to-abort 실패 두 경로, 그리고 !exists 멱등성 skip continue.
불변 조건 — 모든 삭제가 정확히 하나의 자체 소유 sysop에서 커밋된다. 이 헬퍼는 외부 sysop 없이 실행되므로,
log_sysop_start로 직접 열고 전체 성공 시log_sysop_commit, 오류 시log_sysop_abort로 닫는다. 다중 청크 삭제 전체가 크래시 복구 관점에서 all-or-nothing이 된다 — REMOVE 경로(10.4)의 의도적인 대조: 같은 규칙인 “OOS 삭제는 정확히 하나의 sysop에서 일어난다”이지만, 중첩 수준이 다르다. 따라서 하나의 헬퍼는 반드시 sysop을 시작해야 하고, 다른 헬퍼는 절대 시작해서는 안 된다.
루프는 멱등성 재시도를 위해 설계되었다. vacuum 블록은 재시도될 수 있다. 같은 블록의 앞선 forward-walk가 이미 삭제를 커밋했을 수 있으므로, 특정 OID의 청크는 이미 사라졌을 수 있다. oos_chunk_exists 탐사가 정확히 이를 처리한다:
// vacuum_forward_walk_oos_delete_atomic -- src/query/vacuum_oos.cpp (loop body)bool exists;error_code = oos_chunk_exists (thread_p, oid, &exists);if (error_code != NO_ERROR) { break; /* real failure (I/O, interrupt): abort the sysop */ }if (!exists) { continue; /* already deleted on a prior block attempt: skip, not fail */ }error_code = oos_delete (thread_p, *oos_vfid, oid);if (error_code != NO_ERROR) { break; /* real delete failure: abort the sysop */ }세 가지 루프 종료 방식이 구분된다: oos_chunk_exists 오류와 oos_delete 오류는 모두 abort로 break하지만, !exists는 continue한다 — 이중 삭제를 오류가 아닌 no-op으로 만든다. oos_chunk_exists 없이는 블록 재시도가 이미 해제된 슬롯에 oos_delete를 보내고, 상위 레이어가 실제로는 없는 누수로 기록하는 가짜 실패를 만들어낼 것이다.
10.4 REMOVE 경로: 호출자 sysop 내에서 삭제
섹션 제목: “10.4 REMOVE 경로: 호출자 sysop 내에서 삭제”vacuum_heap_oos_delete_within_sysop은 더 단순한 인플레이스 경로다. 유일한 호출자인 vacuum_heap_record가 OOS 참조를 여전히 가진 죽은 heap 슬롯을 물리적으로 vacuum 중이다. 레코드 본문이 바로 앞에 있으므로, undo 재구성도 VFID 조회도 없다(호출자가 이미 OOS 파일을 vacuum_oos_find_vfid_for_heap_record로 확인했다).
// vacuum_heap_oos_delete_within_sysop -- src/query/vacuum_oos.cppintvacuum_heap_oos_delete_within_sysop (THREAD_ENTRY *thread_p, const VFID *oos_vfid, const RECDES *record){ assert (!VFID_ISNULL (oos_vfid)); std::vector<OID> oos_oids; int error_code = heap_recdes_get_oos_oids (record, oos_oids); if (error_code != NO_ERROR) { assert_release (false); /* malformed OOS reference list */ return error_code; } for (const OID &oos_oid : oos_oids) { error_code = oos_delete (thread_p, *oos_vfid, oos_oid); if (error_code != NO_ERROR) { vacuum_er_log_error (VACUUM_ER_LOG_HEAP, "Failed to delete OOS record %d|%d|%d.", oos_oid.volid, oos_oid.pageid, oos_oid.slotid); return error_code; /* propagate to caller's sysop */ } } return NO_ERROR;}불변 조건 — 호출자의 sysop 안에서 실행된다; 직접 sysop을 열어서는 안 된다. 헤더는 명확하다: 호출자(
vacuum_heap_record)가 이미 sysop을 열었으며, heap-슬롯 vacuum 레코드와 이 OOS 삭제들이 같은 sysop에서 커밋되어야 한다. 분리되면, 크래시 시 heap 슬롯은 vacuum되었지만 OOS 청크가 여전히 참조되어 있거나, 그 반대의 찢어진 상태를 복구가 수리할 수 없다. 따라서 forward-walk 헬퍼와 달리 이 함수는 sysop을 시작하지 않고 오류를 전파한다: 호출자의 sysop이 abort되어 heap-슬롯 vacuum도 함께 롤백되므로 두 연산이 일관성을 유지한다.
두 삭제 프리미티브 간의 오류 처리 방식 차이는 중첩 구조에서 비롯된다. Forward-walk 경로는 오류를 한정된 누수로 삼켜버리는데, 롤백할 외부 트랜잭션이 없기 때문이다 — pre-image의 기회주의적 정리다. REMOVE 경로는 원자적 heap-레코드 vacuum의 일부이므로 오류를 반환하는 것이 올바르다.
| 항목 | vacuum_heap_oos_delete_within_sysop (REMOVE) | vacuum_forward_walk_oos_delete_atomic (forward-walk) |
|---|---|---|
| Sysop | 호출자의 기존 sysop 사용; 직접 열지 않음 | 자체 sysop 열고 commit/abort |
| OID 목록 | 빌린 const RECDES *record, 한 번 파싱 | 값으로 소유한 std::vector<OID> |
| 정렬 | 없음 (작고 동일 레코드 목록) | 지역성을 위해 (volid, pageid, slotid)로 정렬 |
| 멱등성 | 없음 — 레코드는 정확히 한 번 삭제됨 | 블록 재시도 안전을 위한 oos_chunk_exists 가드 |
| 오류 시 | 호출자의 sysop이 abort되도록 전파 | 한정된 누수 로그, er_clear, 전파 안 함 |
두 함수 모두 oos_delete의 OID당 페이지 re-fix에 대한 동일한 TODO(perf)를 가진다.
10.5 최초 접촉 VFID 리졸버와 임시 abort
섹션 제목: “10.5 최초 접촉 VFID 리졸버와 임시 abort”vacuum_oos_find_vfid_for_heap_record는 heap-vacuum 루프에서 사용하는 지연(lazy) 리졸버다(메모를 사용하는 forward walk가 아니라). 레코드별로 heap의 hfid, record, slotid, record_type(마지막 두 개는 로그용)과 함께 호출되며, OOS 파일이 이미 알려졌거나 레코드에 OOS가 없으면 단락(short-circuit)된다:
// vacuum_oos_find_vfid_for_heap_record -- src/query/vacuum_oos.cppif (!VFID_ISNULL (oos_vfid) || !heap_recdes_contains_oos (record)) { return NO_ERROR; /* already resolved, or no OOS on this record */ }if (heap_oos_find_vfid (thread_p, hfid, oos_vfid, false)) { return NO_ERROR; /* resolved successfully */ }두 조건이 모두 통과하면, 레코드는 OOS가 있다고 주장하지만 그 heap에 OOS 파일이 없는 상황이다. 주석에 따르면 이는 아직 생성되지 않은 파일의 문제가 아닌 실제 불변 조건 위반이다: OOS 파일은 플래그가 커밋되기 전에 항상(docreate=true) 생성되므로, 파일이 없다면 잘못 설정된 플래그, 삭제된 파일, 또는 이상한 복구 순서를 의미한다. 여기서 ER_FAILED를 반환하면 vacuum_heap_page 루프에서 릴리즈 빌드 스핀이 재시작된다(오류를 지우고 진행하지 않은 채 같은 페이지를 재시도한다 — 커밋 1bf7dda05 참고). 따라서 이 함수는 진단 덤프(repid_and_flags, mvcc_flags, offset_size)를 로그에 남기고 한정된 누수로 대체되도록 설계되었다.
하지만 forward-walk NONE 분기처럼, 현재는 강제 크래시한다:
// vacuum_oos_find_vfid_for_heap_record -- src/query/vacuum_oos.cpp (tail)/* TODO(do not review, remove before develop merge): force a hard crash in CI. ... */abort (); /* TEMPORARY: surface the bug in release CI */assert_release (false); /* debug: catch the bad flag the first time */er_clear ();VFID_SET_NULL (oos_vfid);return NO_ERROR;주의 — 라이브
abort()아래의 죽은 코드.abort()는assert_release(false),er_clear(),VFID_SET_NULL,return NO_ERROR를 현재 도달 불가능하게 만든다. 의도된 안정 상태 동작은 바로 그 코드다: 디버그 빌드에서는assert_release가 나쁜 플래그를 잡아내고, 릴리즈 빌드에서는 로그만 남긴 후(er_clear()가 지운 뒤)oos_vfid를 null로 설정해 호출자가 OOS 정리를 건너뛰게 하고,NO_ERROR로 vacuum이 진행하게 한다. develop 준비 독자는abort()를 제거해야 (forward-walk의 것도 제거한다면<cstdlib>include도) 한정된 누수 경로가 살아있게 된다. 파일에 있는 두 번째TODO(remove before develop merge)abort이며, 두 abort는 동일한 근거(릴리즈 CI에서NDEBUG가 assert를 침묵시킴)를 공유하고 둘 다 머지 전에 제거되어야 한다.
10.6 챕터 요약 — 핵심 정리
섹션 제목: “10.6 챕터 요약 — 핵심 정리”- 두 개의 회수 진입점이 존재하는 이유는 vacuum이 죽은 OOS를 인식하는 방식이 두 가지이기 때문이다: REMOVE 경로(
vacuum_heap_oos_delete_within_sysop)는 바로 앞에 있는 죽은 heap 레코드를 처리하고, forward-walk 경로(vacuum_forward_walk_reclaim_oos)는 undo pre-image로만 살아남은 이전 버전을 처리한다. MVCC 이론은cubrid-oos.md에, 주변 워커 루프는cubrid-vacuum-detail.md(Chapters 8–9)에 있다. - sysop 중첩이 핵심 정확성 구분이다. REMOVE 프리미티브는 호출자의 기존 sysop 안에서 실행되며 오류를 전파하여 heap-슬롯 vacuum과 OOS 삭제가 함께 커밋 또는 abort되게 한다. Forward-walk 프리미티브는 자체 sysop을 열고 오류를 한정된 누수로 삼킨다. 같은 규칙, 반대의 중첩이다.
VACUUM_OOS_VFID_MEMO는 의도적으로 단일 슬롯, 워커-블록 단위, 스택 상주 캐시이며 FOUND/NONE/ERROR 삼중 상태를 가진다. NONE(oos_vfid == VFID_NULL)은 캐시된 음수 답이다. ERROR는 절대 캐시되지 않으므로, 일시적 페이지 읽기 오류가 블록의 나머지를 오염시킬 수 없다.static이 되어서는 안 된다(워커 간 레이스가 생긴다).- Forward-walk 경로는 로그 페이지 교체에 대비해 두 개의 방어적 복사본을 만든다: pre-image는 어떤 페이지가 fix되기 전에 정렬된
db_private_alloc버퍼로memcpy되고, OID 벡터는 값으로 전달되어 로그 버퍼를 앨리어스하지 않는다. 실제 관찰 사례(조회 전후로 flags 바이트 0x69 → 0x00)가 이를 동기화했다. 정렬 복사는OR_BUF디버그 assert도 방지한다. - 방어적 설계 원칙은 블록 실패보다 한정된 기록된 누수를 선호한다. Forward-walk의 모든 오류 경로는 전파하지 않고 로그를 남기고
er_clear()한다. 왜냐하면 vacuum 블록을 실패시키면 종료 전용 assert가 발동되어 vacuum이 멈출 수 있기 때문이다.oos_chunk_exists는 원자적 삭제를 멱등성 있게 만들어 블록 재시도 시 이미 해제된 청크를 오류 없이 건너뛸 수 있게 한다. - 두 개의 라이브
abort()호출은 임시 CI 크래시 트랩이다 (vacuum_forward_walk_reclaim_oos의 NONE 분기와vacuum_oos_find_vfid_for_heap_record의 말미), 임시#include <cstdlib>및TODO(remove before develop merge)마커와 짝을 이룬다. 릴리즈 CI가NDEBUG로 실행되어 assert가 침묵하기 때문에 존재한다. 두 abort와 현재 그것들이 가리고 있는 안정 상태 한정된-누수 코드는 develop 머지 전에 정리되어야 한다. - 미실현
TODO(perf)가 두 삭제 프리미티브 모두에 반복된다:oos_delete는 OID당 OOS 페이지를pgbuf_fixre-fix한다. Forward-walk의(volid, pageid, slotid)정렬이 이미 OID를 페이지 그룹으로 정렬하여, 장차 페이지당 단일 fix 최적화를 국소적인 변경으로 만든다.
Chapter 11: 통계 진단과 엣지 경로
섹션 제목: “Chapter 11: 통계 진단과 엣지 경로”앞선 열 개 챕터는 값(value) 단위의 생명주기를 추적했다. 이 챕터는 그 주변부를 다룬다 — 유지보수 담당자가 조회하는 읽기 전용 진단 인터페이스, 의도적으로 WAL이 아닌 독립 로거, 절대 삭제해서는 안 되는 디버그 전용 VOT 감사기, 읽기 경로가 의존하는 트랜잭션별 OID 수집기, 그리고 모듈이 현재 진행 중임을 인식하고 있는 미완성 엣지들. 이 중 어느 것도 실제 OOS 값을 변경하지 않지만, 모두 지뢰와 같다 — 이 존재들을 모른 채 생명주기 코드를 손대면 QA 하네스가 깨지거나, divergence assert가 묻히거나, OOS가 불필요하게 확장될 수 있다. OOS가 무엇인지에 대한 배경은 companion 문서 cubrid-oos.md(Two flags, The inline stub, Client stats)를 참고한다. 이 챕터에서는 재유도하지 않는다.
11.1 통계 인터페이스: 구조체 둘, 왕복 한 번
섹션 제목: “11.1 통계 인터페이스: 구조체 둘, 왕복 한 번”OOS를 검증하는 유지보수 담당자는 세션 명령을 실행해 OOS 파일의 카운터를 읽는다 — 물리 페이지 수, 살아있는 레코드 수, 살아있는 온페이지 레코드 바이트 합계. 이 카운터를 담는 구조체는 두 개다 — 서버 측(oos_stats_info)과 클라이언트 측(db_oos_stats) — 그리고 네트워크 레이어가 필드 단위로 복사한다.
oos_stats_info — src/storage/oos_file.hpp, 서버 측 수집기의 출력.
| 필드 | 역할 | 존재 이유 |
|---|---|---|
has_oos_file | 클래스에 OOS 파일이 없으면 0, 있으면 1 | ”OOS 파일은 있지만 비어있음”(num_recs == 0)과 “클래스가 파일을 한 번도 생성하지 않음”을 구별한다. demotion이 없는 클래스는 VFID를 할당하지 않는다. |
oos_vfid | OOS 파일의 VFID{volid, fileid} | 어떤 물리 파일이 측정됐는지 호출자가 교차 검증할 수 있게 한다. 파일이 없으면 VFID_SET_NULL. |
num_user_pages | 파일에 할당된 물리 사용자 페이지 수 | 레코드를 순회하는 방식이 아닌, file_get_num_user_pages(할당 맵)에서 가져온다. |
page_size | DB_PAGESIZE | 클라이언트가 두 번째 왕복 없이 점유율을 계산할 수 있도록 에코된다. |
num_recs | 살아있는 OOS 레코드 수 (int) | 구조체 주석은 “OOS_HDR_STATS가 추적”이라고 표현하지만, 수집기는 지연 헤더 추정값을 신뢰하지 않고 슬롯을 직접 순회하며 재계산한다(§11.3 참고). |
recs_sumlen | 살아있는 온페이지 레코드 길이의 합 (INT64) | 각 레코드의 RECDES.length가 4바이트 int이더라도 합계가 2 GB를 초과할 수 있기 때문에 INT64를 사용한다. 구현은 slot_recdes.length — 즉 oos_prepend_header가 추가한 OOS_RECORD_HEADER까지 포함한 전체 온페이지 슬롯 — 을 누적한다. 사용자 페이로드 본문만이 아님에 주의하라(§11.3 교차 검증 참고). |
db_oos_stats — src/compat/db_oos.h, C 클라이언트 미러. 동일한 여섯 카운터를 응답에서 복사해 담는다. 클라이언트/서버 경계에서 오는 두 가지 차이점이 있다: 서버 VFID는 클라이언트 헤더가 서버 VFID 타입에 의존할 필요가 없도록 두 개의 일반 int(oos_vfid_volid / oos_vfid_fileid)로 평탄화되고, recs_sumlen은 int64_t(서버 INT64와 일치)로, 응답 필드 중 유일하게 int가 아닌 타입이다. has_oos_file, num_user_pages, page_size, num_recs는 그대로 에코된다.
flowchart TD A["db_get_oos_stats class_name -- db_admin.c"] --> B["db_find_class + ws_identifier -> class_oid"] B --> C["oos_get_stats_by_class_oid class_oid DB_OOS_STATS -- network_interface_cl.c"] C -->|CS_MODE| D["or_pack_oid -> NET_SERVER_OOS_STATS -> unpack 7 ints + 1 int64"] C -->|SA_MODE| E["enter_server -> xoos_get_stats_by_class_oid OOS_STATS_INFO"] D --> F["서버에서 xoos_get_stats_by_class_oid 실행"] E --> F F --> G["oos_get_stats_by_vfid -- 모든 페이지/슬롯 순회"]
Figure 11-1. 통계 왕복 흐름. 클라이언트 API는 얇은 마샬러이며, 실제 카운팅은 서버 측 oos_get_stats_by_vfid에서 이루어진다.
클라이언트 진입점 db_get_oos_stats는 클래스명을 해석하고 위임한다. 통계 인터페이스는 힙 레코드를 건드리지 않는 파일 레벨 진단이다.
// db_get_oos_stats -- src/compat/db_admin.cif (class_name == NULL || stats == NULL) return ER_OBJ_INVALID_ARGUMENTS; /* <- defensive null-arg guard */class_op = db_find_class (class_name);if (class_op == NULL) { ASSERT_ERROR (); return er_errid (); } /* <- class not found */class_oid = ws_identifier (class_op);if (class_oid == NULL || OID_ISNULL (class_oid)) { er_set (..., ER_LC_UNKNOWN_CLASSNAME, 1, class_name); return ER_LC_UNKNOWN_CLASSNAME; }return oos_get_stats_by_class_oid (class_oid, stats); /* <- client stub, marshals to server */클라이언트 스텁 oos_get_stats_by_class_oid(network_interface_cl.c)는 컴파일 타임 분기가 두 개다. CS_MODE에서는 OID를 pack하고 NET_SERVER_OOS_STATS를 전송한 뒤, leading 상태 int 다음에 int 여섯 개와 int64 하나(has_oos_file, volid, fileid, num_user_pages, page_size, num_recs, 그리고 recs_sumlen) 순서로 unpack해 db_oos_stats에 채운다. volid/fileid 정수는 oos_vfid_volid/oos_vfid_fileid로 재기록된다. non-CS_MODE 분기(standalone)는 xoos_get_stats_by_class_oid를 직접 호출하고 OOS_STATS_INFO 필드를 복사하며, oos_vfid.volid/oos_vfid.fileid를 int로 좁힌다. 응답 버퍼 크기는 OR_INT_SIZE * 7 + OR_BIGINT_SIZE다 — oos_stats_info에 필드를 추가한다면 응답 버퍼와 unpack 시퀀스를 반드시 함께 확장해야 한다. 그렇지 않으면 클라이언트가 묵묵히 쓰레기값을 읽는다.
11.2 xoos_get_stats_by_class_oid — 모든 분기
섹션 제목: “11.2 xoos_get_stats_by_class_oid — 모든 분기”이것이 서버 진입점이다. 클래스 OID를 OOS VFID로 해석하고 페이지 단위 walker에 넘긴다. 핵심 임무는 여러 종류의 정상적인 “OOS 파일 없음” 결과와 실제 오류를 구별하는 것이다.
// xoos_get_stats_by_class_oid -- src/storage/oos_file.cppif (class_oid == NULL || out == NULL || OID_ISNULL (class_oid)) return ER_OBJ_INVALID_ARGUMENTS; /* <- branch 1: bad args */memset (out, 0, sizeof (*out));out->page_size = DB_PAGESIZE;VFID_SET_NULL (&out->oos_vfid);HFID hfid; HFID_SET_NULL (&hfid);if (heap_get_class_info (thread_p, class_oid, &hfid, NULL, NULL) != NO_ERROR) { ASSERT_ERROR (); return er_errid (); } /* <- 2: catalog read error */if (HFID_IS_NULL (&hfid)) return NO_ERROR; /* <- 3: empty class, has_oos_file stays 0 */VFID oos_vfid; VFID_SET_NULL (&oos_vfid);if (!heap_oos_find_vfid (thread_p, &hfid, &oos_vfid, false)) { int errid = er_errid (); if (errid != NO_ERROR) return errid; /* <- 4a: real read error */ return NO_ERROR; /* <- 4b: legit no-OOS (docreate=false) */}if (VFID_ISNULL (&oos_vfid)) return NO_ERROR; /* <- 5: found-but-null */return oos_get_stats_by_vfid (thread_p, oos_vfid, out); /* <- 6: hand off */직관적이지 않은 분기는 4a/4b다: heap_oos_find_vfid가 false를 반환하는 이유는 두 가지 — 진짜 읽기 오류(er_errid를 설정)와, 힙 헤더에 OOS VFID가 없는 완전히 정상적인 경우(docreate=false는 “없으면 생성하지 말라”는 의미). 코드는 er_errid()를 읽어 이를 구별한다. 이를 잘못 처리하면 실제 I/O 오류가 삼켜지거나(4a를 4b로 취급), demotion-free 클래스마다 허위 오류가 반환된다(4b를 4a로 취급).
불변 조건:
has_oos_file == 0⟺ VFID가 측정되지 않았다. 모든 조기 반환 경로(분기 3, 4b, 5)는out을memset으로 초기화된 상태로,page_size만 설정된 채로 두므로has_oos_file은0으로 유지된다.has_oos_file을1로 설정하는 것은 오직oos_get_stats_by_vfid(분기 6)뿐이다. 클라이언트가has_oos_file == 1이지만num_recs == 0을 본다면 실제로 현재 비어있는 파일을 읽고 있는 것이고,has_oos_file == 0이라면 파일 자체가 발견되지 않은 것이다. 향후 수정이 분기 6 이전에has_oos_file = 1을 설정하면 이 구분이 무너진다.
11.3 oos_get_stats_by_vfid — 슬롯 순회와 두 가지 보정
섹션 제목: “11.3 oos_get_stats_by_vfid — 슬롯 순회와 두 가지 보정”이것이 실제 카운터다. oos_get_stats_by_vfid은 static이 아닌 것으로 노출되어 있는데, 이는 카탈로그 클래스 없이 VFID만 있는 단위 테스트 힙도 heap_get_class_info를 거치지 않고 측정할 수 있도록 하기 위해서다.
flowchart TD A["out==NULL 또는 VFID null?"] -->|예| B["return ER_OBJ_INVALID_ARGUMENTS"] A -->|아니오| C["memset out, has_oos_file=1 설정, page_size, oos_vfid"] C --> D["file_get_num_user_pages -> num_user_pages"] D -->|오류| E["ASSERT_ERROR; return er_errid"] D --> F["file_get_sticky_first_page -> hdr_vpid"] F -->|오류| E F --> G["i in 0..num_user_pages 반복"] G --> H["file_numerable_find_nth -> scan_vpid"] H -->|오류 또는 null vpid| I["er_clear; continue"] H --> J["scan_vpid == hdr_vpid?"] J -->|예| K["continue -- 헤더 페이지 건너뜀"] J -->|아니오| L["pgbuf_fix CONDITIONAL"] L -->|NULL busy| M["er_clear; continue -- 소폭 미달 허용"] L --> N["spage_next_record 루프: total_recs++, total_sumlen += 슬롯 길이"] N --> O["페이지 unfix"] O --> G G -->|완료| P["out->num_recs, out->recs_sumlen; return NO_ERROR"]
Figure 11-2. oos_get_stats_by_vfid 분기 맵. 모든 건너뜀(continue) 경로는 의도적인 최선 허용(best-effort tolerance)이며, 하드 오류가 아니다.
유지보수 담당자가 주목해야 할 분기는 네 개이며, 모두 허용 측에 있다:
// oos_get_stats_by_vfid -- src/storage/oos_file.cppif (file_numerable_find_nth (thread_p, &oos_vfid, i, false, NULL, NULL, &scan_vpid) != NO_ERROR || VPID_ISNULL (&scan_vpid)) { er_clear (); continue; } /* <- hole in numerable map: skip */if (!VPID_ISNULL (&hdr_vpid) && VPID_EQ (&scan_vpid, &hdr_vpid)) continue; /* <- header page has no user records */page_ptr = pgbuf_fix (..., PGBUF_LATCH_READ, PGBUF_CONDITIONAL_LATCH);if (page_ptr == NULL) { er_clear (); continue; } /* <- page busy: accept slight undercount */PGSLOTID slotid = -1; RECDES slot_recdes;while (spage_next_record (page_ptr, &slotid, &slot_recdes, PEEK) == S_SUCCESS) { total_recs++; total_sumlen += slot_recdes.length; } /* <- counts slot 0 too */slotid = -1 시드는 단순 spage_collect_statistics 호출과 이 walker를 구별하는 두 가지 보정을 담고 있다:
spage_collect_statistics는 슬롯 0을 건너뛴다(슬롯 0이 힙 페이지의 헤더 레코드를 담는다고 가정하기 때문). OOS 데이터 페이지는 슬롯 0부터 실제 레코드를 저장하므로, 캔드 헬퍼는 페이지당 한 건씩 미달 계산한다.slotid = -1부터 시드하면spage_next_record가 슬롯 0을 방문한다.- bestspace hint는 사용하지 않는다.
OOS_HDR_STATS.estimates는 sync scan(Chapter 5)에 의해서만 갱신되는 지연 best-space 캐시이며, 실시간 카운트에는 맞지 않는다. Walker는 물리 슬롯을 직접 읽는다 — 느리지만 정확하다. 누산기는INT64이며,out->num_recs는int로 좁혀지고recs_sumlen은 64비트 전폭을 유지한다.
교차 검증:
recs_sumlen은 사용자 페이로드 바이트가 아니라 전체 온페이지 슬롯 길이의 합이다. 구조체 주석은 “살아있는 OOS 레코드 본문 바이트의 합”이라 표현하지만, 루프는slot_recdes.length를 누적하는데, 이는oos_prepend_header(Chapter 4/6)가 앞에 추가한OOS_RECORD_HEADER를 포함한 전체 온페이지 레코드다. 따라서 보고된 합계는 청크당OOS_RECORD_HEADER_SIZE만큼 실제 사용자 페이로드보다 많다.recs_sumlen은 저장 점유율 수치로 취급해야 하며,Σ total_data_length로 해석해서는 안 된다.
불변 조건: 통계는 경합 상태에서 하한값이며, 절대 초과 계산되지 않는다.
PGBUF_CONDITIONAL_LATCH와 busy 시continue는 동시에 기록 중인 페이지를 단순히 생략한다는 의미다. 따라서num_recs와recs_sumlen은 바쁜 파일에서 약간 낮게 읽힐 수 있지만, 높거나 부분적으로 읽힌(torn) 값은 절대 없다. 유지보수 담당자는 동시 부하 하에서 이를 정확한 값으로 표시해서는 안 된다. 이는 진단 도구이지 회계 진실의 원천이 아니다.
11.4 oos_get_length — 테스트 전용 헤더 리더와 프로덕션 스텁
섹션 제목: “11.4 oos_get_length — 테스트 전용 헤더 리더와 프로덕션 스텁”oos_get_length는 “이 OID 뒤의 전체 페이로드 길이는?”이라는 질문에 답하기 위해 head 청크 페이지를 fix하고 OOS_RECORD_HEADER에서 total_data_length를 읽는다. 테스트와 assertion을 위해 존재한다. 프로덕션 코드는 핫 경로에서 이를 호출하지 않는다 — 프로덕션은 힙 레코드에 내장된 inline stub(companion The inline stub)에서 같은 길이를 읽어 페이지 fix를 완전히 피한다.
// oos_get_length -- src/storage/oos_file.cppPAGE_PTR page_ptr = pgbuf_fix (thread_p, &vpid, OLD_PAGE, PGBUF_LATCH_READ, PGBUF_UNCONDITIONAL_LATCH);if (page_ptr == nullptr) { oos_error (...); assert_release_error (er_errid () != NO_ERROR); assert (false); return -1; } /* <- fix fail */auto page_unfixer = make_scope_exit ([&]() { pgbuf_unfix_and_init_after_check (thread_p, page_ptr); });SCAN_CODE code = spage_get_record (thread_p, page_ptr, slotid, &oos_recdes, PEEK);if (code != S_SUCCESS) { oos_error (...); er_set (..., ER_GENERIC_ERROR, 0); return -1; } /* <- slot gone */assert (oos_recdes.length >= OOS_RECORD_HEADER_SIZE);OOS_RECORD_HEADER header;std::memcpy (&header, oos_recdes.data, sizeof (OOS_RECORD_HEADER));return header.total_data_length; /* <- length stored in the head chunk header (Ch 4/6) */분기는 세 개다: fix 실패(return -1, 오류 설정을 assert), 레코드 fetch 실패(er_set 후 return -1), 그리고 성공. make_scope_exit은 fix 성공 이후의 모든 반환 경로에서 페이지를 unfix하는 것을 보장한다 — spage_get_record 실패 경로도 포함하며, 그렇지 않으면 latch가 누출된다. total_data_length는 모든 청크 헤더에 기록된 체인 전체 페이로드 길이(Chapter 4, 6)이므로, head 청크에서 읽는 것으로 충분하다.
11.5 oos_push_oos_oid — 트랜잭션별 OID 수집기
섹션 제목: “11.5 oos_push_oos_oid — 트랜잭션별 OID 수집기”읽기 경로는 나중 단계에서 찾을 수 있도록 역참조한 OOS OID를 기록한다. 그 장소는 thread entry의 평범한 std::vector<OID>이며, oos_push_oos_oid는 한 줄짜리 append다:
// oos_push_oos_oid -- src/storage/oos_file.cppvoidoos_push_oos_oid (THREAD_ENTRY *thread_p, const OID *oid){ thread_p->oos_oids.push_back (*oid);}companion 함수 oos_oid_in_vector(oos_util.cpp)는 읽기 측 대응 함수다: “이 OID를 이미 수집했는가?”를 OID_EQ 순회로 확인한다. 의도적으로 선형 탐색을 사용하는데, 벡터가 설계상 작기 때문이다(한 트랜잭션이 방금 건드린 OOS 컬럼들이지, 테이블 전체가 아님). 소스 주석에 그 근거가 명시되어 있다: “linear scan; vector is small by design”.
// oos_oid_in_vector -- src/storage/oos_util.cppfor (const OID &candidate : oids) { if (OID_EQ (&candidate, oid)) return true; }return false;11.6 oos_log_internal — 의도적으로 WAL이 아닌 진단 로거
섹션 제목: “11.6 oos_log_internal — 의도적으로 WAL이 아닌 진단 로거”oos_log.hpp는 $CUBRID/log/oos.log에 기록하는 독립형 로깅 shim이다. 모듈에서 가장 중요한 “당신이 생각하는 것이 아니다” 파일이다: 이것은 write-ahead logging이 아니다. WAL은 recovery manager를 통과한다(companion WAL and logging, Chapter 8의 physical-delete recovery 참고). oos_log는 사람과 HA QA 하네스를 위한 printf-to-a-file이다. 이 둘을 혼동하는 것이 OOS 온보딩에서 가장 흔히 발생하는 실수다.
// oos_log_internal -- src/storage/oos_log.hppif (static_cast<int> (level) < static_cast<int> (oos_log_get_level())) return; /* <- level filter: below threshold, drop */// ... format "[HH:MM:SS] OOS [LEVEL](func:line): " header + vsnprintf body ...FILE *logfp = oos_log_get_file(); /* <- magic-static fopen("a") of $CUBRID/log/oos.log */bool file_written = false;if (logfp != nullptr) { fputs header; fputs body; fputc '\n'; fflush; file_written = true; }static const bool stderr_enabled = (std::getenv ("CUBRID_OOS_LOG_STDERR") != nullptr); /* <- presence-only */const bool force_stderr_fallback = !file_written && static_cast<int>(level) >= static_cast<int>(OosLogLevel::WARN); /* <- never drop WARN/ERROR */if (stderr_enabled || force_stderr_fallback) { fputs ...stderr...; fflush(stderr); }유지보수 담당자가 반드시 익혀야 할 네 가지 동작:
- 레벨 필터가 먼저다.
oos_current_level(기본값DEBUG, 런타임에oos_log_set_level로 설정 가능)보다 낮은 메시지는strftime/vsnprintf비용 없이 즉시 반환된다. 레벨 순서는TRACE < DEBUG < INFO < WARN < ERROR < FATAL(enum0..5). - 파일 sink는 C++11 magic static이다.
oos_log_get_file은 첫 호출 시 한 번만fopen(path, "a")를 수행하며(path는envvar_logdir_file(..., "oos.log")에서 가져옴), §6.7/4 보장에 의해 별도 mutex 없이 스레드 안전하다. 디렉터리가 쓰기 불가능하면fopen이nullptr를 반환하고file_written이false로 남는다. - stderr는 opt-in이며 값이 아닌 존재 여부로 제어된다.
CUBRID_OOS_LOG_STDERR는 값에 상관없이("0"이나""이더라도) stderr를 활성화한다. 비활성화하려면 unset해야 한다.csql/readline세션이나cgdbUI 같은 raw-mode tty에 출력하면 화면이 깨지기 때문에 기본은 비활성화다. 이 체크는static const bool로, 프로세스당 한 번 읽힌다. - WARN/ERROR는 절대 묵묵히 버려지지 않는다.
force_stderr_fallback분기는 파일 sink가 실패했을 때(!file_written) WARN 이상을 stderr로 라우팅한다.
불변 조건:
oos_error와oos_warn은 릴리즈 빌드에서도 활성화된다.oos_trace/oos_debug/oos_info는NDEBUG하에서 아무것도 컴파일되지 않는다. 세 개의 낮은 레벨은#if !defined(NDEBUG)조건부 컴파일 블록에 속하므로 릴리즈에서do {} while (0)으로 확장된다.oos_error/oos_warn은 조건 없이 정의된다. 릴리즈 빌드 유지보수 담당자는 어떤 런타임 레벨에서도 TRACE/DEBUG/INFO 출력을 볼 수 없다 —oos_debug(...)인수 안의 사이드 이펙트는 릴리즈에서 사라진다. 디버그 레벨 로그 호출 안에 로드 베어링 표현식(load-bearing expression)을 넣지 말라.
이 중 어느 것도 recovery에 참여하지 않기 때문에, oos.log 한 줄은 재실행되지 않고, commit 시 flush되지 않으며, 크래시 후에도 보장되지 않는다. 순수하게 관찰 목적이다.
11.7 heap_recdes_compute_oos_flag_debug — 절대 삭제해서는 안 되는 VOT 감사기
섹션 제목: “11.7 heap_recdes_compute_oos_flag_debug — 절대 삭제해서는 안 되는 VOT 감사기”이 디버그 전용 함수(oos_util.cpp, #if !defined(NDEBUG)로 가드됨)는 힙 레코드의 variable-offset table(VOT)을 순회하며 레코드가 OOS 컬럼을 담고 있는지 재계산한다. 그래서 assert가 상위 builder가 찍어 놓은 OR_MVCC_FLAG_HAS_OOS 비트와 비교할 수 있다. 유일한 호출자도 #if !defined(NDEBUG) 안에 있으므로(heap_update_adjust_recdes_header, heap_file.c), 릴리즈 빌드에는 이 심볼에 대한 참조가 전혀 없다 — 그것이 .cpp 정의와 .hpp 선언 모두에 DO NOT REMOVE THIS라는 직설적인 경고가 붙어 있는 이유다. 죽은 코드 sweep이 이를 표시할 것이기 때문이다.
// heap_recdes_compute_oos_flag_debug -- src/storage/oos_util.cppif (recdes == NULL || recdes->data == NULL) return false; /* <- branch: nothing to walk */const int offset_size = OR_GET_OFFSET_SIZE (recdes->data);const int header_size = OR_HEADER_SIZE (recdes->data);const int max_var_count = (recdes->length - header_size) / offset_size;if (max_var_count > 0) { /* <- first-entry sanity gate */ int first = /* OR_GET_BYTE/SHORT/INT per offset_size */; int clean = first & ~OR_VAR_FLAG_MASK; if (clean < 0 || clean > recdes->length - header_size) return false; /* <- not a real VOT */}for (int index = 0; index < max_var_count; ++index) { int offset = /* read per offset_size; default: assert(false); return false */; if (OR_IS_OOS (offset)) has_oos = true; if (OR_IS_LAST_ELEMENT (offset)) return has_oos; /* <- modern VOT: trust result */}return false; /* <- no LAST_ELEMENT sentinel: legacy VOT, refuse to guess */분기 구조가 이 함수의 핵심이다. 힙은 non-object-instance 레코드(클래스 레코드, root 레코드)를 담을 수 있으며, 그 바이트를 VOT로 읽으면 임의의 값이 된다. first-entry sanity gate는 인덱스 0의 마스킹된 offset이 [0, recdes->length - header_size] 범위에 들어오는지 검증한다(offset은 헤더 끝 기준 상대값). 범위를 벗어나면 garbage를 순회하는 대신 false를 반환한다. (doc 주석은 느슨하게 [0, recdes->length]라고 표현하지만, 코드는 header_size를 빼는 올바른 범위를 사용한다.) 인덱스 0만 이 검증이 필요하다. 일단 통과하면 루프는 OR_IS_LAST_ELEMENT에서 자기 종료되며, max_var_count가 최후 방어선이 된다.
불변 조건: 디버그 감사기와 실제 builder는 HAS_OOS 비트가 일치해야 한다. 호출자는
walked_has_oos == has_oos를 assert한다(classrepr->n_variable > 0가드 포함). 향후 recdes를 생성하는 경로가OR_MVCC_FLAG_HAS_OOS를 설정하거나 클리어하는 것을 잊으면, 이 assert가 디버그 빌드에서 터진다. 프로덕션 경로는 이 비트를 무조건 신뢰한다. 이 walker가 stale 비트를 SELECT의 scancache가 잘못된 데이터를 내놓기 전에 잡는 유일한 수단이다(companion Two flags). 삭제하면 divergence가 사용자가 잘못된 데이터를 보기 전까지 감지되지 않는다.
11.8 레거시 레코드 처리와 max_var_count 초과 계산
섹션 제목: “11.8 레거시 레코드 처리와 max_var_count 초과 계산”두 개의 VOT walker — 위의 디버그 heap_recdes_compute_oos_flag_debug와 프로덕션 heap_recdes_get_oos_oids(heap_file.c) — 는 공통된 문서화된 한계를 공유한다: OR_VAR_BIT_LAST_ELEMENT sentinel이 없는 레코드는 완전히 지원되지 않는다. 이 플래그는 variable-length 플래그 비트의 0x2이다(OR_VAR_FLAG_MASK = 0x3으로, OR_VAR_BIT_OOS = 0x1과 같은 필드를 공유).
// heap_recdes_get_oos_oids -- src/storage/heap_file.c/* NOTE: This upper bound may include VOT alignment padding and fixed-attribute bytes * for legacy records that lack the OR_VAR_BIT_LAST_ELEMENT flag. ... */const int max_var_count = (recdes->length - OR_HEADER_SIZE (recdes->data)) / offset_size;for (int index = 0; index <= max_var_count; ++index) { if (index == max_var_count) { assert_release (false && "LAST_ELEMENT flag not found ..."); return ER_FAILED; } // ... read offset; if OR_IS_OOS extract OID (bounds-checked); if OR_IS_LAST_ELEMENT break ...}유지보수 담당자를 위한 두 가지 구체적인 위험:
max_var_count는 초과 계산된다.(length - header) / offset_size는 레거시 VOT에서 실제 variable offset뿐만 아니라 alignment padding과 fixed-attribute 바이트까지 포함한다. 현대 레코드에서는OR_IS_LAST_ELEMENT가 먼저 break를 유발하기 때문에 루프가 이 값에 도달하지 않는다. 프로덕션 walker는max_var_count에 도달하는 것을assert_release를 통한 하드ER_FAILED로 처리하고, 디버그 walker는 조용한return false로 처리한다. 루프 범위도 이에 따라 다르다: 프로덕션은index <= max_var_count(sentinel 누락 가드에 도달하기 위해), 디버그 walker는index < max_var_count.- 구 레코드의 홀수 offset이
OR_IS_OOS에서 거짓 양성을 낸다.OR_IS_OOS(offset)는OR_GET_VAR_FLAG(offset) & OR_VAR_BIT_OOS— 사실상offset & 0x1이다. 플래그 스킴 이전의 레거시 VOT에서는 홀수 offset이 OOS 컬럼처럼 보인다. 디버그 감사기의 최종return false(LAST_ELEMENT미발견)는 레거시 VOT를 해석하지 않고 거부한다. 프로덕션 walker는 추가로 추출된 각 OID를recdes->length와 범위 검사한다.
이것이 companion의 Legacy-record handling 노트가 구 형식 VOT를 “아직 완전히 지원되지 않음”이라고 표현하는 이유다: LAST_ELEMENT 추가는 순방향 전용이며, 한 페이지에 구형/신형 레코드가 혼재하는 것은 미해결 정확성 문제다.
11.9 모듈을 수정하기 전에 반드시 알아야 할 진행 중인 엣지들
섹션 제목: “11.9 모듈을 수정하기 전에 반드시 알아야 할 진행 중인 엣지들”알려진 미완성 표면들 — 이 중 어느 것도 즉석에서 수정할 버그가 아니다. 각각 추적된 마일스톤이다(companion Milestone status, Not yet implemented / in flux).
| 엣지 | 위치 | 당신에게 의미하는 것 |
|---|---|---|
oos_remove_page 미연결 | oos_file.cpp, // TODO: will be called by vacuum when OOS vacuum is implemented | Vacuum(Chapter 10)은 슬롯을 해제하지, 페이지를 해제하지 않는다. 빈 데이터 페이지는 할당된 채로 남으며 num_user_pages는 줄어들지 않는다. 페이지 회수가 존재한다고 가정하지 말라. |
RECDES.length ~2 GB 한계 | RECDES.length는 4바이트 int. oos_insert는 src.size() > INT_MAX를 앞에서 거부함 | 단일 확장 레코드는 ~2 GB를 초과할 수 없다. oos_insert는 좁히기 src_len 캐스트 전에 src.size() > (std::size_t) INT_MAX를 가드한다. |
| M1에 페이로드 압축 없음 | (없음 — TOAST는 pglz/lz4 사용) | OOS는 raw 바이트를 저장한다. 압축은 열린 PR(CBRD-26881 / #7258)이다. recs_sumlen은 raw, 비압축 바이트다. |
| Expand-OOS 과잉 확장 감사 | heap_get_visible_version_expand_oos, CBRD-26847 | 많은 호출 사이트가 기계적으로 마이그레이션됐고 불필요하게 OOS를 확장할 수 있다(필요 없을 때 re-inlining — Chapter 7). 감사가 보류 중이다. 모든 expand 사이트가 최소한임을 신뢰하지 말라. |
| CDC / flashback이 OOS OID를 해석 불가 | (epic rough-edge 목록) | Change-data-capture와 flashback은 inline stub의 OID를 볼 수 있지만 out-of-row 페이로드를 역참조할 수 없다. CDC 관련 작업에서는 OOS 컬럼을 현재로서는 해석 불가능한 것으로 취급해야 한다. |
oos_remove_page 본문 자체는 사소하고 올바르다 — 단순히 FILE_OOS로 file_dealloc을 호출하고 실패 시 oos_error로 로깅한다 — 하지만 릴리즈 호출자가 없다. 그것과 §11.7의 미사용이지만 보존된 디버그 감사기, §11.6의 비-WAL 로거가 합쳐져 “존재하고, 컴파일되고, 살아있어 보이지만 프로덕션 핫 패스와는 무관한” 심볼의 세 쌍을 이룬다. 어느 것이 무엇인지 아는 것이 안전한 수정과 묵묵한 회귀(silent regression) 사이의 차이다.
11.10 챕터 요약 — 핵심 정리
섹션 제목: “11.10 챕터 요약 — 핵심 정리”- 통계 인터페이스는 서버 walker 하나 위의 얇은 마샬러다:
db_get_oos_stats→oos_get_stats_by_class_oid(CS / non-CS 분기) →xoos_get_stats_by_class_oid→oos_get_stats_by_vfid.oos_stats_info필드를 추가한다면OR_INT_SIZE*7 + OR_BIGINT_SIZE응답 버퍼와 unpack 시퀀스를 동시에 확장해야 한다. has_oos_file == 0⟺ VFID가 측정되지 않았다. 모든 조기 반환 분기는 zeroed 구조체를 남긴다.has_oos_file을1로 설정하는 것은oos_get_stats_by_vfid뿐이다.heap_oos_find_vfid의false는 두 가지 의미를 갖는다(실제 오류 vs. 정상적인 OOS 없음).er_errid()로 구별한다.oos_get_stats_by_vfid는 지연 bestspace hint가 아닌 물리 슬롯을 세며, 경합 상태에서 미달 계산한다.slotid = -1부터 순회해 슬롯 0을 포함하고(spage_collect_statistics는 건너뜀), 바쁜 페이지는continue로 처리 — 절대 초과 계산되지 않으며, 부분 읽기(torn read)도 없다.recs_sumlen은 전체 슬롯 길이(OOS_RECORD_HEADER포함)를 합산하므로 저장 점유율 수치이지 raw 페이로드 합계가 아니다.- 두 헬퍼 함수는 설계상 최소화되어 있다.
oos_get_length는 테스트/assertion 전용이다(프로덕션은 inline stub에서total_data_length를 읽음).make_scope_exit이 fix 이후 모든 반환 경로에서 페이지를 unfix한다.oos_push_oos_oid는 트랜잭션별 수집기(thread_p->oos_oids.push_back)이며,oos_oid_in_vector는 의도적으로 선형인 멤버십 검사다. oos_log는 WAL이 아니다.$CUBRID/log/oos.log에 대한printf이며, 레벨 필터링과CUBRID_OOS_LOG_STDERR로 opt-in하는 stderr를 사용한다.oos_error/oos_warn은 릴리즈에서도 활성화된다.oos_trace/oos_debug/oos_info는NDEBUG하에서do {} while (0)으로 컴파일된다 — 이 안에 로드 베어링 표현식을 절대 넣지 말라.heap_recdes_compute_oos_flag_debug는 삭제해서는 안 된다.OR_MVCC_FLAG_HAS_OOSdivergence를 잡는 디버그 전용 assert다. 릴리즈 빌드에는 참조가 없으므로 죽은 코드 sweep이 표시한다. first-entry sanity gate와 “LAST_ELEMENT없으면false반환” 가드가 비-VOT 레코드를 순회하는 것을 막는다.- 레거시 VOT와 여러 진행 중인 엣지들은 알려진 미완성 상태다:
OR_VAR_BIT_LAST_ELEMENT가 없는 레코드에서max_var_count초과 계산, 레거시 홀수 offset의OR_IS_OOS거짓 양성,oos_remove_page의 릴리즈 호출자 없음, 레코드 ~2 GB 한계, M1에 압축 없음, expand-OOS 감사(CBRD-26847) 보류, CDC/flashback의 OOS OID 해석 불가.
이 리비전 시점의 위치 힌트
섹션 제목: “이 리비전 시점의 위치 힌트”아래 줄 번호는 2026-06-23 시점에 관찰한 값이다. 심볼 이름이 정식 앵커이며 줄 번호는 시간이 지나면 어긋나는 힌트다.
| Symbol | File | Line |
|---|---|---|
byte_span_writer | src/base/byte_span_writer.hpp | 38 |
OR_GET_OID | src/base/object_representation.h | 274 |
OR_VAR_BIT_OOS | src/base/object_representation.h | 441 |
OR_VAR_BIT_LAST_ELEMENT | src/base/object_representation.h | 442 |
OR_VAR_FLAG_MASK | src/base/object_representation.h | 443 |
OR_SET_VAR_OOS | src/base/object_representation.h | 445 |
OR_SET_VAR_LAST_ELEMENT | src/base/object_representation.h | 446 |
OR_GET_VAR_FLAG | src/base/object_representation.h | 448 |
OR_GET_VAR_OFFSET | src/base/object_representation.h | 449 |
OR_IS_OOS | src/base/object_representation.h | 451 |
OR_IS_LAST_ELEMENT | src/base/object_representation.h | 452 |
OR_OOS_INLINE_SIZE | src/base/object_representation.h | 455 |
OR_BIGINT_SIZE | src/base/object_representation_constants.h | 45 |
OR_OID_SIZE | src/base/object_representation_constants.h | 67 |
OR_OID_PAGEID | src/base/object_representation_constants.h | 68 |
OR_OID_SLOTID | src/base/object_representation_constants.h | 69 |
OR_OID_VOLID | src/base/object_representation_constants.h | 70 |
OR_MAX_BYTE | src/base/object_representation_constants.h | 130 |
OR_MAX_SHORT | src/base/object_representation_constants.h | 134 |
OR_MVCC_MAX_HEADER_SIZE | src/base/object_representation_constants.h | 142 |
OR_MVCC_INSERT_HEADER_SIZE | src/base/object_representation_constants.h | 148 |
OR_NON_MVCC_HEADER_SIZE | src/base/object_representation_constants.h | 150 |
OR_MVCC_FLAG_SHIFT_BITS | src/base/object_representation_constants.h | 165 |
OR_MVCC_FLAG_HAS_OOS | src/base/object_representation_constants.h | 178 |
subspan | src/base/span.hpp | 112 |
oos_get_stats_by_class_oid | src/communication/network_interface_cl.c | 9928 |
db_get_oos_stats | src/compat/db_admin.c | 1581 |
db_oos_stats | src/compat/db_oos.h | 36 |
VACUUM_OOS_VFID_LOOKUP_RESULT | src/query/vacuum_oos.cpp | 51 |
VACUUM_OOS_VFID_LOOKUP_RESULT | src/query/vacuum_oos.cpp | 56 |
vacuum_oos_vfid_lookup | src/query/vacuum_oos.cpp | 85 |
vacuum_oos_vfid_lookup | src/query/vacuum_oos.cpp | 86 |
heap_oos_find_vfid | src/query/vacuum_oos.cpp | 116 |
vacuum_forward_walk_oos_delete_atomic | src/query/vacuum_oos.cpp | 153 |
oos_chunk_exists | src/query/vacuum_oos.cpp | 179 |
oos_delete | src/query/vacuum_oos.cpp | 188 |
vacuum_forward_walk_reclaim_oos | src/query/vacuum_oos.cpp | 222 |
heap_recdes_contains_oos | src/query/vacuum_oos.cpp | 245 |
heap_recdes_get_oos_oids | src/query/vacuum_oos.cpp | 286 |
vacuum_oos_find_vfid_for_heap_record | src/query/vacuum_oos.cpp | 338 |
vacuum_heap_oos_delete_within_sysop | src/query/vacuum_oos.cpp | 399 |
VACUUM_OOS_VFID_MEMO | src/query/vacuum_oos.hpp | 43 |
vacuum_oos_vfid_memo | src/query/vacuum_oos.hpp | 44 |
FILE_OOS (stub: file_header_dump_descriptor) | src/storage/file_manager.c | 1431 |
FILE_OOS (type name string) | src/storage/file_manager.c | 3062 |
FILE_OOS (vfid-uniqueness guard) | src/storage/file_manager.c | 3448 |
FILE_OOS (stub: tracker desired_type filter) | src/storage/file_manager.c | 10903 |
FILE_OOS (stub: tracker lock protection) | src/storage/file_manager.c | 10931 |
FILE_OOS (stub: tracker class-OID extraction) | src/storage/file_manager.c | 10975 |
FILE_OOS (stub: spacedb classification) | src/storage/file_manager.c | 12235 |
FILE_OOS | src/storage/file_manager.h | 53 |
FILE_OOS (enum) | src/storage/file_manager.h | 53 |
HEAP_UPDATE_IS_MVCC_OP | src/storage/heap_file.c | 160 |
heap_hdr_stats.oos_vfid | src/storage/heap_file.c | 206 |
heap_manager_initialize | src/storage/heap_file.c | 5150 |
oos_bestspace_initialize (call site) | src/storage/heap_file.c | 5180 |
heap_manager_finalize | src/storage/heap_file.c | 5197 |
heap_get_record_data_when_all_ready | src/storage/heap_file.c | 7859 |
heap_attrvalue_read_oos_inline | src/storage/heap_file.c | 10490 |
heap_midxkey_get_oos_extra_size | src/storage/heap_file.c | 10899 |
heap_attrinfo_get_record_payload_size | src/storage/heap_file.c | 12106 |
heap_attrinfo_get_record_header_size | src/storage/heap_file.c | 12143 |
heap_attrinfo_determine_disk_layout | src/storage/heap_file.c | 12183 |
heap_oos_find_vfid | src/storage/heap_file.c | 12258 |
heap_oos_find_vfid | src/storage/heap_file.c | 12259 |
heap_attrinfo_dbvalue_to_recdes | src/storage/heap_file.c | 12357 |
heap_attrinfo_insert_to_oos | src/storage/heap_file.c | 12435 |
heap_attrinfo_transform_header_to_disk | src/storage/heap_file.c | 12573 |
heap_attrinfo_transform_variable_to_disk | src/storage/heap_file.c | 12761 |
heap_attrinfo_transform_to_disk_internal | src/storage/heap_file.c | 13008 |
heap_update_adjust_recdes_header | src/storage/heap_file.c | 21508 |
heap_delete_relocation | src/storage/heap_file.c | 22461 |
heap_delete_relocation (eager OOS cleanup call site) | src/storage/heap_file.c | 22927 |
heap_delete_home | src/storage/heap_file.c | 22977 |
heap_delete_home (eager OOS cleanup call site) | src/storage/heap_file.c | 23273 |
heap_update_relocation | src/storage/heap_file.c | 23629 |
heap_update_relocation (eager OOS cleanup call site) | src/storage/heap_file.c | 23707 |
heap_update_relocation (mvcc remove_old_forward tag) | src/storage/heap_file.c | 23868 |
heap_update_home | src/storage/heap_file.c | 23985 |
heap_update_home (eager OOS cleanup call site) | src/storage/heap_file.c | 24192 |
heap_get_visible_version_expand_oos | src/storage/heap_file.c | 26450 |
heap_recdes_contains_oos | src/storage/heap_file.c | 27772 |
heap_recdes_get_oos_oids | src/storage/heap_file.c | 27779 |
expand_oos | src/storage/heap_file.h | 384 |
HEAP_OOS_EXPAND_STATE | src/storage/heap_oos.cpp | 48 |
heap_oos_parse_vot | src/storage/heap_oos.cpp | 68 |
heap_oos_parse_vot | src/storage/heap_oos.cpp | 69 |
heap_oos_read_blobs | src/storage/heap_oos.cpp | 128 |
heap_oos_read_blobs | src/storage/heap_oos.cpp | 129 |
heap_oos_compute_layout | src/storage/heap_oos.cpp | 183 |
heap_oos_compute_layout | src/storage/heap_oos.cpp | 184 |
heap_oos_build_record | src/storage/heap_oos.cpp | 241 |
heap_oos_build_record | src/storage/heap_oos.cpp | 242 |
heap_record_replace_oos_oids | src/storage/heap_oos.cpp | 337 |
heap_record_replace_oos_oids | src/storage/heap_oos.cpp | 338 |
heap_oos_delete_unreferenced | src/storage/heap_oos.cpp | 424 |
oid_Null_oid | src/storage/oid.h | 209 |
OOS_ALIGNMENT | src/storage/oos_file.cpp | 84 |
OOS_BESTSPACE_CACHE_CAPACITY | src/storage/oos_file.cpp | 86 |
OOS_DROP_FREE_SPACE | src/storage/oos_file.cpp | 87 |
OOS_BESTSPACE_SYNC_THRESHOLD | src/storage/oos_file.cpp | 88 |
BEST_PAGE_SEARCH_MAX_COUNT | src/storage/oos_file.cpp | 89 |
oos_stats_bestspace_cache | src/storage/oos_file.cpp | 104 |
oos_Bestspace_cache_area | src/storage/oos_file.cpp | 114 |
oos_Bestspace | src/storage/oos_file.cpp | 116 |
oos_Find_best_page_limit | src/storage/oos_file.cpp | 118 |
oos_hash_vpid | src/storage/oos_file.cpp | 125 |
oos_compare_vpid | src/storage/oos_file.cpp | 132 |
oos_hash_vfid | src/storage/oos_file.cpp | 140 |
oos_compare_vfid | src/storage/oos_file.cpp | 147 |
oos_stats_entry_free | src/storage/oos_file.cpp | 159 |
oos_bestspace_initialize | src/storage/oos_file.cpp | 184 |
oos_bestspace_finalize | src/storage/oos_file.cpp | 228 |
oos_stats_add_bestspace | src/storage/oos_file.cpp | 271 |
oos_stats_del_bestspace_by_vpid | src/storage/oos_file.cpp | 337 |
oos_stats_del_bestspace_by_vfid | src/storage/oos_file.cpp | 363 |
oos_get_header_stats_ptr | src/storage/oos_file.cpp | 395 |
oos_stats_put_second_best | src/storage/oos_file.cpp | 411 |
oos_stats_get_second_best | src/storage/oos_file.cpp | 433 |
oos_stats_find_page_in_bestspace | src/storage/oos_file.cpp | 454 |
oos_stats_sync_bestspace | src/storage/oos_file.cpp | 634 |
oos_stats_update_internal | src/storage/oos_file.cpp | 808 |
oos_stats_update | src/storage/oos_file.cpp | 853 |
oos_create_file | src/storage/oos_file.cpp | 909 |
oos_remove_file | src/storage/oos_file.cpp | 995 |
oos_remove_file | src/storage/oos_file.cpp | 996 |
oos_remove_page | src/storage/oos_file.cpp | 1007 |
oos_remove_page | src/storage/oos_file.cpp | 1008 |
oos_prepend_header | src/storage/oos_file.cpp | 1022 |
oos_insert | src/storage/oos_file.cpp | 1048 |
oos_insert_across_pages | src/storage/oos_file.cpp | 1109 |
oos_insert_within_page | src/storage/oos_file.cpp | 1205 |
oos_read_within_page | src/storage/oos_file.cpp | 1269 |
oos_read_across_pages | src/storage/oos_file.cpp | 1325 |
oos_read | src/storage/oos_file.cpp | 1372 |
oos_file_alloc_new | src/storage/oos_file.cpp | 1431 |
oos_file_alloc_new | src/storage/oos_file.cpp | 1432 |
oos_find_best_page | src/storage/oos_file.cpp | 1460 |
oos_vpid_init_new | src/storage/oos_file.cpp | 1622 |
oos_log_insert_physical | src/storage/oos_file.cpp | 1649 |
oos_log_delete_physical | src/storage/oos_file.cpp | 1669 |
oos_delete_chain | src/storage/oos_file.cpp | 1696 |
oos_chunk_exists | src/storage/oos_file.cpp | 1779 |
oos_delete | src/storage/oos_file.cpp | 1851 |
oos_get_max_chunk_size_within_page | src/storage/oos_file.cpp | 1861 |
oos_needs_repl_tracking | src/storage/oos_file.cpp | 1882 |
oos_rv_redo_delete | src/storage/oos_file.cpp | 1888 |
oos_rv_redo_insert | src/storage/oos_file.cpp | 1911 |
oos_get_length | src/storage/oos_file.cpp | 1942 |
oos_push_oos_oid | src/storage/oos_file.cpp | 1981 |
xoos_get_stats_by_class_oid | src/storage/oos_file.cpp | 1991 |
oos_get_stats_by_vfid | src/storage/oos_file.cpp | 2043 |
oos_record_header | src/storage/oos_file.hpp | 26 |
OOS_RECORD_HEADER | src/storage/oos_file.hpp | 26 |
OOS_RECORD_HEADER_SIZE | src/storage/oos_file.hpp | 34 |
OOS_RECDES | src/storage/oos_file.hpp | 38 |
oos_buffer | src/storage/oos_file.hpp | 43 |
OOS_NUM_BEST_SPACESTATS | src/storage/oos_file.hpp | 45 |
OOS_STATS_NEXT_BEST_INDEX | src/storage/oos_file.hpp | 47 |
OOS_STATS_PREV_BEST_INDEX | src/storage/oos_file.hpp | 49 |
oos_bestspace | src/storage/oos_file.hpp | 53 |
oos_bestspace (struct) | src/storage/oos_file.hpp | 53 |
OOS_HDR_STATS | src/storage/oos_file.hpp | 59 |
oos_hdr_stats | src/storage/oos_file.hpp | 60 |
oos_hdr_stats (struct) | src/storage/oos_file.hpp | 60 |
OOS_FINDSPACE | src/storage/oos_file.hpp | 101 |
oos_stats_info | src/storage/oos_file.hpp | 111 |
oos_push_oos_oid | src/storage/oos_file.hpp | 130 |
oos_log_get_file | src/storage/oos_log.hpp | 86 |
oos_log_internal | src/storage/oos_log.hpp | 97 |
oos_error | src/storage/oos_log.hpp | 168 |
oos_warn | src/storage/oos_log.hpp | 171 |
oos_trace | src/storage/oos_log.hpp | 175 |
oos_oid_in_vector | src/storage/oos_util.cpp | 34 |
oos_oid_in_vector | src/storage/oos_util.cpp | 35 |
heap_recdes_compute_oos_flag_debug | src/storage/oos_util.cpp | 83 |
oos repl emission loop (INSERT) | src/transaction/locator_sr.c | 8144 |
oos repl emission loop (UPDATE) | src/transaction/locator_sr.c | 8947 |
la_rebuild_oos_recdes | src/transaction/log_applier.c | 4872 |
la_apply_dummy_oos_log | src/transaction/log_applier.c | 6019 |
la_apply_oos_insert_log | src/transaction/log_applier.c | 6044 |
oos_insert_lsa_queue | src/transaction/log_impl.h | 529 |
oos_suppress_insert_lsa_queueing | src/transaction/log_impl.h | 530 |
RVOOS_INSERT auto-push to oos_insert_lsa_queue | src/transaction/log_manager.c | 2213 |
log_append_undoredo_crumbs (RVOOS_INSERT auto-queue) | src/transaction/log_manager.c | 2213 |
log_append_redo_crumbs (RVOOS_INSERT auto-queue) | src/transaction/log_manager.c | 2482 |
log_append_empty_record | src/transaction/log_manager.c | 3150 |
LOG_DUMMY_OOS_RECORD | src/transaction/log_record.hpp | 139 |
RVOOS_INSERT recovery entry | src/transaction/recovery.c | 845 |
RV_fun[] RVOOS entries | src/transaction/recovery.c | 845 |
RVOOS_INSERT | src/transaction/recovery.h | 188 |
RVOOS_DELETE | src/transaction/recovery.h | 189 |
RVREPL_OOS_INSERT | src/transaction/recovery.h | 190 |
RVREPL_DUMMY_OOS_RECORD | src/transaction/recovery.h | 198 |
repl_log_insert oos lsa attach | src/transaction/replication.c | 459 |
cubrid-oos.md— 상위 동반 문서 (설계 의도, JIRA 에픽, 마일스톤).- JIRA 에픽: http://jira.cubrid.org/browse/CBRD-26357 (M1 CBRD-26584, M2 CBRD-26583).
- 설계 문서: Notion CBRD-26628 multi-chunk insert; git CBRD-26668 vacuum 설명.
- 코드 (feat/oos):
src/storage/oos_file.{cpp,hpp},oos_log.hpp,oos_util.{cpp,hpp},heap_oos.{cpp,hpp},src/query/vacuum_oos.{cpp,hpp},src/compat/db_oos.h. - 방법론:
knowledge/methodology/code-analysis-detail-doc.md.