콘텐츠로 이동

CUBRID Heap Manager — 코드 수준 심층 분석

이 문서의 위치: 상위 분석 문서인 cubrid-heap-manager.md는 설계 의도와 이론적 배경을 다룬다. 이 문서는 모든 분기와 필드를 코드 수준에서 추적한다. 각 챕터는 독립적으로 읽을 수 있지만, 순서대로 읽으면 커널 내부에서 힙 레코드 하나가 겪는 전체 생애 주기를 따라갈 수 있다.

목차:

Ch제목상태
1자료구조 지도
2슬롯 페이지 기본 요소와 페이지 초기화
3레코드 타입과 MVCC 레코드 헤더
4삽입 흐름과 OID 할당
5읽기 경로 — 가시성 판정과 포워딩 체인 추적
6업데이트 흐름과 레코드 타입 전이
7삭제 흐름과 툼스톤(Tombstone)
8Vacuum 회수와 페이지 Vacuum 상태
9Best Space와 빈 공간 관리
10크래시 복구와 Redo/Undo 로그 경로

이 장은 나머지 문서 전체가 참조하는 필드 단위 레퍼런스다. MVCC 가시성, slotted-page 이론, forwarding/overflow 근거는 여기서 재도출하지 않는다 — 상위 문서(cubrid-heap-manager.md)를 참고하라. 세 계층은 레코드 OID로 연결된다: 페이지 계층(slotted_page.h), 힙 파일 계층(heap_file.c), 그리고 일시적인 연산/읽기 번들(heap_file.h).

graph TB
  subgraph disk["디스크 상 힙 페이지"]
    HDR["SPAGE_HEADER"]
    SLOT0["SPAGE_SLOT[0]"]
    SLOTN["SPAGE_SLOT[1..n]"]
    HDR --> SLOT0
    HDR --> SLOTN
  end
  STATS["HEAP_HDR_STATS<br/>헤더 페이지 슬롯 0"]
  CHAIN["HEAP_CHAIN<br/>데이터 페이지 슬롯 0"]
  HFID["HFID<br/>vfid + hpgid"] -->|hpgid| STATS
  STATS -->|next_vpid| CHAIN
  CHAIN -->|prev/next_vpid| CHAIN
  subgraph mem["메모리 내 번들"]
    OPCTX["HEAP_OPERATION_CONTEXT<br/>4x PGBUF_WATCHER"]
    GETCTX["HEAP_GET_CONTEXT<br/>2x PGBUF_WATCHER"]
    SCAN["HEAP_SCANCACHE<br/>스냅샷 / page_latch"]
    NODE["HEAP_SCANCACHE_NODE"]
    SCAN --> NODE
    OPCTX -->|scan_cache_p| SCAN
    GETCTX -->|scan_cache| SCAN
  end
  OPCTX -->|hfid| HFID
  NODE -->|hfid| HFID

Figure 1-1. 디스크 구조(상단), 힙 파일 척추(HFID → 헤더 페이지 HEAP_HDR_STATSHEAP_CHAIN 이중 연결 리스트), 그리고 하나의 연산 동안 해당 페이지에 latch를 잡는 메모리 내 번들.

불변 조건: HEAP_HEADER_AND_CHAIN_SLOTID == 0. 모든 힙 페이지의 슬롯 0은 예약된 메타데이터다: 헤더 페이지에는 HEAP_HDR_STATS, 나머지 모든 페이지에는 HEAP_CHAIN이 들어간다. 이 조건은 두 곳에서 강제된다 — 슬롯 0은 페이지 초기화 시 이미 할당되어 있으므로 일반적인 spage_insert가 이를 반환하지 않으며, HEAP_ISJUNK_OIDslotid == 0인 OID를 모두 거부한다. 슬롯 0에 사용자 레코드가 들어가면 체인 탐색이 레코드 바이트를 chain/stats 구조체로 읽어 힙을 손상시킨다.

// HEAP_HEADER_AND_CHAIN_SLOTID -- src/storage/heap_file.h
#define HEAP_HEADER_AND_CHAIN_SLOTID 0 /* Slot for chain and header */
// HEAP_ISJUNK_OID -- src/storage/heap_file.h
#define HEAP_ISJUNK_OID(oid) \
((oid)->slotid == HEAP_HEADER_AND_CHAIN_SLOTID \
|| (oid)->slotid < 0 || (oid)->volid < 0 || (oid)->pageid < 0)

1.2 페이지 계층 — SPAGE_HEADERSPAGE_SLOT

섹션 제목: “1.2 페이지 계층 — SPAGE_HEADER와 SPAGE_SLOT”

고정 헤더, 앞쪽에서 자라는 슬롯 배열, 뒤쪽에서 자라는 레코드. 세부 메커니즘은 Chapter 2에서 다룬다.

// spage_header -- src/storage/slotted_page.h (comments in table)
struct spage_header
{
PGNSLOTS num_slots, num_records;
INT16 anchor_type; /* ANCHORED / ANCHORED_DONT_REUSE_SLOTS / UNANCHORED_* */
unsigned short alignment; /* char, short, int, double */
int total_free, cont_free, offset_to_free_area;
int reserved1;
int flags; /* always SPAGE_HEADER_FLAG_NONE */
unsigned int is_saving:1; /* save-for-undo */
unsigned int need_update_best_hint:1; /* best-hint refresh */
unsigned int reserved_bits:30;
};
필드역할 / 존재 이유
num_slots, num_records슬롯 배열의 길이와 활성 레코드 수. 삭제 후에도 슬롯은 남으므로 num_slots >= num_records.
anchor_typeANCHORED/ANCHORED_DONT_REUSE_SLOTS/UNANCHORED_*. 힙은 anchored 방식을 사용하므로 슬롯 id가 안정적으로 유지되어 OID가 이동하지 않는다.
alignment바이트 단위 레코드 정렬 (힙은 INT_ALIGNMENT 사용).
total_free전체 여유 바이트. 컴팩션 후 레코드+슬롯 크기를 수용할 수 있는지 검사할 때 사용.
cont_free단일 연속 공백의 바이트 수. 이 값이 부족하지만 total_free가 충분하면 먼저 컴팩션을 수행한다.
offset_to_free_area새 레코드를 기록하는 bump pointer 위치.
reserved1예약 int; 헤더를 8바이트 정렬로 유지.
flags페이지 플래그; SPAGE_HEADER_FLAG_NONE만 사용.
is_saving1비트: undo를 위한 공간 절약 (Ch 10).
need_update_best_hint1비트: best hint가 오래됐으니 estimates를 갱신하라는 신호 (Ch 9).
reserved_bits30비트 패딩; bitfield 워드 레이아웃을 고정.
// spage_slot -- src/storage/slotted_page.h (4-byte disk slot)
struct spage_slot
{
unsigned int offset_to_record:14; /* Byte offset to start of record */
unsigned int record_length:14; /* Length of record */
unsigned int record_type:4; /* REC_HOME, REC_NEWHOME, ... */
};
필드역할 / 존재 이유
offset_to_record (14b)레코드 바이트의 오프셋. 슬롯 id를 안정적으로 유지하는 간접 참조 역할을 하며, 컴팩션 시 이 값만 재기록한다. 최대값 16383.
record_length (14b)레코드 바이트 길이. 복사/peek의 범위를 한정한다. 페이지 내 레코드의 상한이 16383바이트이며, 초과 시 overflow로 이어진다.
record_type (4b)REC_HOME/NEWHOME/RELOCATION/BIGONE/DELETED_WILL_REUSE/MARKDELETED/ASSIGN_ADDRESS/… — 모든 흐름에서 분기의 기준 키 (Ch 3).

슬롯 전체가 정확히 4바이트 (14+14+4 = 32비트)다.

1.3 힙 파일 계층 — HFID, HEAP_HDR_STATS, HEAP_CHAIN

섹션 제목: “1.3 힙 파일 계층 — HFID, HEAP_HDR_STATS, HEAP_CHAIN”

HFID는 힙의 이름이다: 파일 id와 헤더 페이지 id의 조합.

// hfid -- src/storage/storage_common.h
struct hfid
{
VFID vfid; /* Volume and file identifier */
INT32 hpgid; /* First page identifier (the header page) */
};
필드역할 / 존재 이유
vfid힙 파일의 (volid, fileid). 모든 페이지 할당/해제에 사용된다.
hpgid헤더 페이지의 page id — 힙의 진입점. vfid.volid와 결합하면 next_vpid 탐색을 시작하는 헤더 VPID가 된다.

HEAP_HDR_STATS는 힙의 전역 제어 블록으로, 헤더 페이지의 슬롯 0에 저장된다. 내부의 estimates 블록은 best-space hint 캐시(Chapter 9)이며, 로그를 남기지 않는다.

// heap_hdr_stats -- src/storage/heap_file.c (comments elided; see table)
struct heap_hdr_stats
{
OID class_oid; /* the first field MUST be class_oid */
VFID ovf_vfid;
VPID next_vpid; /* the 2nd page of the heap file */
int unfill_space;
struct
{
int num_pages, num_recs;
float recs_sumlen;
int num_other_high_best, num_high_best, num_substitutions;
int num_second_best, head_second_best, tail_second_best, head;
VPID last_vpid; /* todo: move out of estimates */
VPID full_search_vpid;
VPID second_best[HEAP_NUM_BEST_SPACESTATS]; /* 10 hints */
HEAP_BESTSPACE best[HEAP_NUM_BEST_SPACESTATS]; /* 10 hints */
} estimates;
int reserve0_for_future, reserve1_for_future, reserve2_for_future;
};
필드역할 / 존재 이유
class_oid저장된 클래스의 OID. 반드시 첫 번째 필드여야 한다 — 슬롯 0의 선두 OID가 class id이며, 유효성 검사를 위해 HEAP_CHAIN과 공유하는 규약이다.
ovf_vfidoverflow 파일 id (REC_BIGONE 본체를 저장). 첫 overflow 전까지는 null.
next_vpid두 번째 페이지의 VPID — 페이지 체인의 head.
unfill_space삽입의 여유 공간 하한선; 제자리 갱신이 가능하도록 여유분을 남긴다.
estimates.num_pages, num_recs, recs_sumlen예상 페이지/레코드 수와 전체 레코드 바이트 합계. recs_sumlen으로 평균 길이를 계산한다.
estimates.num_other_high_bestbest[]에 없는 양질의 페이지 수. 파일을 확장하기 전에 더 넓은 탐색을 유도한다.
estimates.num_high_bestbest[] 항목 중 HEAP_DROP_FREE_SPACE 이상인 수. 0이 되면 재스캔한다.
estimates.num_substitutions교체 횟수. second-best 승격 판단에 활용된다.
estimates.num_second_best, head_second_best, tail_second_bestsecond_best[] 링의 크기, 읽기 인덱스(가장 오래된 항목), 쓰기 인덱스.
estimates.headbest[] 링의 head 인덱스. 할당/스캔의 시작점.
estimates.last_vpid마지막/추가 페이지. estimates 외부로 이전하라는 todo 주석이 붙어 있다.
estimates.full_search_vpid호출 간 점진적 스캔의 재개 지점.
estimates.second_best[10]여유 공간이 적당한 VPID 10개의 링. best[]가 소진됐을 때 사용한다.
estimates.best[10]HEAP_BESTSPACE 10개의 링. 1차 hint 세트. HEAP_NUM_BEST_SPACESTATS == 10.
reserve0/1/2_for_future예약 int.

불변 조건: estimates는 hint일 뿐, 진실이 아니다. 변경 사항은 로그를 남기지 않는다 — “힌트 용도로만 쓰인다”, “정확하지 않을 수 있다”, “중복 페이지가 포함될 수 있다”는 주석이 붙어 있다. 사용 전에 후보 페이지의 실제 total_free를 재검증해야 한다. best[]를 맹목적으로 신뢰하면 이미 꽉 찬 페이지를 대상으로 삼을 수 있다 (Chapter 9).

HEAP_CHAIN은 모든 비헤더 페이지의 슬롯 0에 위치하며, 이중 연결 리스트를 구성한다.

// heap_chain -- src/storage/heap_file.c (Double-linked)
struct heap_chain
{
OID class_oid; /* the first must be class_oid */
VPID prev_vpid; /* Previous page */
VPID next_vpid; /* Next page */
MVCCID max_mvccid; /* Max MVCCID of any MVCC operation on this page */
INT32 flags; /* 2 high bits encode vacuum state */
};
필드역할 / 존재 이유
class_oid클래스 OID (선두 필드 규약 동일). 헤더 페이지와 데이터 페이지를 구분하지 않고 페이지 유효성을 검사할 수 있다.
prev_vpid이전 페이지 VPID. 역방향 탐색 및 체인 복구에 사용.
next_vpid다음 페이지 VPID. 전방 탐색 — HEAP_HDR_STATS.next_vpid가 공급하는 척추.
max_mvccid이 페이지에서 수행된 임의 연산 중 가장 큰 MVCCID. Vacuum 예측기: vacuum의 oldest MVCCID보다 앞서면 페이지가 완전히 vacuum됐다고 판단한다. MVCCID_NULL로 초기화 (Ch 8).
flagsINT32. 상위 2비트 = vacuum 상태 (HEAP_PAGE_VACUUM_NONE/ONCE/UNKNOWN, 마스크 0xC0000000). 나머지는 예약.

Vacuum 상태는 flags의 상위 2비트에 압축되며, HEAP_PAGE_SET_VACUUM_STATUS / HEAP_PAGE_GET_VACUUM_STATUS로만 접근한다:

// HEAP_PAGE_FLAG_VACUUM_STATUS_MASK -- src/storage/heap_file.c
#define HEAP_PAGE_FLAG_VACUUM_STATUS_MASK 0xC0000000
#define HEAP_PAGE_FLAG_VACUUM_ONCE 0x80000000
#define HEAP_PAGE_FLAG_VACUUM_UNKNOWN 0x40000000
// status == NONE => both bits clear /* <- the HEAP_PAGE_VACUUM_NONE encoding */

불변 조건: max_mvccid는 페이지 단위로 단조 증가한다. 모든 MVCC 연산은 if (MVCC_ID_PRECEDES (chain->max_mvccid, mvccid)) chain->max_mvccid = mvccid;를 수행한다(디버그 단언 포함). 이 값이 역행하면 아직 대기 중인 버전을 가진 페이지를 vacuum이 해제할 수 있다.

1.4 연산 번들 — HEAP_OPERATION_CONTEXT와 관련 열거형

섹션 제목: “1.4 연산 번들 — HEAP_OPERATION_CONTEXT와 관련 열거형”

모든 쓰기 연산에 인자로 전달되는 단일 구조체: typeupdate_in_place가 분기를 결정하고, 최대 네 개의 watcher, 홈 레코드 스택 버퍼, 출력 OID가 함께 실린다.

// HEAP_OPERATION_TYPE -- src/storage/heap_file.h
typedef enum
{
HEAP_OPERATION_NONE = 0,
HEAP_OPERATION_INSERT,
HEAP_OPERATION_DELETE,
HEAP_OPERATION_UPDATE
} HEAP_OPERATION_TYPE;
// update_inplace_style -- src/storage/heap_file.h
enum update_inplace_style
{
UPDATE_INPLACE_NONE = 0, /* None */
UPDATE_INPLACE_CURRENT_MVCCID = 1, /* non-MVCC in-place update with current MVCC ID */
UPDATE_INPLACE_OLD_MVCCID = 2 /* non-MVCC in-place update, preserves old MVCC ID */
};
typedef enum update_inplace_style UPDATE_INPLACE_STYLE;
#define HEAP_IS_UPDATE_INPLACE(update_inplace_style) \
((update_inplace_style) != UPDATE_INPLACE_NONE)

HEAP_OPERATION_TYPE은 쓰기 경로를 태그한다. UPDATE_INPLACE_STYLE은 직교하는 개념이다: MVCC 갱신은 물리적으로 제자리에서 수행되지만 UPDATE_INPLACE_NONE에 해당한다(소스: “mvcc update is also executed inplace, but coresponds to UPDATE_INPLACE_NONE”) — 따라서 UPDATE_INPLACE_NONE새로운 논리 버전 생성을 의미하고, HEAP_IS_UPDATE_INPLACE(스타일 1/2)는 진정한 비MVCC 덮어쓰기다. Chapter 6에서 분기 흐름을 상세히 다룬다.

// heap_operation_context -- src/storage/heap_file.h (condensed)
struct heap_operation_context
{
HEAP_OPERATION_TYPE type;
UPDATE_INPLACE_STYLE update_in_place;
HFID hfid; OID oid; OID class_oid; RECDES *recdes_p; HEAP_SCANCACHE *scan_cache_p;
RECDES map_recdes; OID ovf_oid; /* overflow transient */
RECDES home_recdes;
char home_recdes_buffer[IO_MAX_PAGE_SIZE + MAX_ALIGNMENT];
INT16 record_type; FILE_TYPE file_type;
PGBUF_WATCHER home_page_watcher, overflow_page_watcher, header_page_watcher, forward_page_watcher;
PGBUF_WATCHER *home_page_watcher_p, *overflow_page_watcher_p, *header_page_watcher_p, *forward_page_watcher_p; /* the handles */
OID res_oid; bool is_logical_old; /* logical output */
bool is_redistribute_insert_with_delid;
bool is_bulk_op; bool use_bulk_logging;
bool do_supplemental_log; LOG_LSA supp_undo_lsa, supp_redo_lsa;
PERF_UTIME_TRACKER *time_track; /* perf stat dump */
};
필드역할 / 존재 이유
type쓰기 연산 종류 (INSERT/DELETE/UPDATE/NONE). 분기 결정.
update_in_place갱신 스타일. 제자리 변경 vs. 새 버전 재배치 (Ch 6).
hfid대상 힙. 파일/헤더 페이지를 특정해 할당과 best-space 조회에 사용.
oid입력 OID (삭제/갱신 대상. 삽입 시 무시됨) — 홈 주소.
class_oid클래스. 잠금, MVCC 헤더 구성, 인덱스 유지에 활용.
recdes_p호출자의 레코드 디스크립터 — 새 바이트.
scan_cache_platch된 페이지와 스냅샷 재사용 (선택).
map_recdesoverflow 삽입 중 구성된 map 레코드 (overflow 객체를 가리킴).
ovf_oidoverflow 객체 위치. REC_BIGONE 시 설정.
home_recdes변경 전에 읽은 홈 레코드 디스크립터.
home_recdes_bufferhome_recdes를 백업하는 인라인 스택 버퍼. 힙 할당을 회피한다.
record_type변경 전 원본 레코드의 타입.
file_typeFILE_HEAP/FILE_HEAP_REUSE_SLOTS. 슬롯 재사용 결정 (Ch 7).
home/overflow/header/forward_page_watcher내장된 PGBUF_WATCHER 저장 슬롯 네 개 (overflow → REC_BIGONE, header → best-space 갱신, forward → REC_RELOCATION/REC_NEWHOME).
*_watcher_p (×4)네 watcher에 대한 포인터 — 실제 핸들. null이면 해당 페이지 미관여.
res_oid출력 OID. 삽입 시 할당된 주소.
is_logical_old출력: 초기 레코드가 REC_ASSIGN_ADDRESS가 아니었음. 로깅에 사용.
is_redistribute_insert_with_delid유효한 delid를 가진 파티션 재분배로부터 온 삽입.
is_bulk_op, use_bulk_logging, do_supplemental_log, supp_undo_lsa, supp_redo_lsa로깅/벌크 제어: 벌크 삽입 플래그(MVCC 연산도 비활성화), 벌크 로그 경로, 보조 로그 활성화, 보조 undo/redo 이미지 LSA.
time_trackPERF_UTIME_TRACKER * — 성능 통계 덤프.

불변 조건: 페이지는 반드시 _p watcher 포인터로 접근한다. 내장된 네 개의 PGBUF_WATCHER는 저장 공간이고, *_watcher_p 포인터가 핸들이다(“직접 참조해서는 안 된다”). 코드는 latch할 페이지를 가리키도록 내장 watcher 하나를 설정하고, 나머지는 NULL로 둔다. 정리 시 NULL이 아닌 것만 unfix한다. 내장 watcher를 직접 건드리면 이중 unfix 또는 latch 누수가 발생한다.

읽기 측 대응 구조체. 쓰기와 달리 헤더 페이지나 overflow 페이지를 건드릴 일이 없으므로 watcher가 두 개 — 홈과 forward — 만 필요하다.

// heap_get_context -- src/storage/heap_file.h (comments in table)
struct heap_get_context
{
INT16 record_type;
const OID *oid_p;
OID forward_oid; /* of REC_RELOCATION or REC_BIGONE */
OID *class_oid_p;
RECDES *recdes_p;
HEAP_SCANCACHE *scan_cache;
PGBUF_WATCHER home_page_watcher, fwd_page_watcher;
bool ispeeking; /* PEEK or COPY */
int old_chn;
PGBUF_LATCH_MODE latch_mode;
};
필드역할 / 존재 이유
record_typeoid_p 위치의 타입. forwarding 분기 키 (Ch 5): REC_HOME이면 제자리, REC_RELOCATION/REC_BIGONE이면 forward_oid를 추적.
oid_p요청된 OID (입력, const). 읽기의 시작점.
forward_oid홈 슬롯이 가리키는 OID. 두 번째 hop. record_type이 요구할 때 채워진다.
class_oid_p클래스 OID (입/출력). 스냅샷/CHN에 필요.
recdes_p바이트가 반환되는 위치. 페이지 내(PEEK) 또는 복사 버퍼(COPY).
scan_cache지배 HEAP_SCANCACHE. mvcc_snapshot을 공급.
home_page_watcher, fwd_page_watcher홈 및 forward 페이지 watcher. forward는 두 번째 hop에서 latch되며, forwarding 전까지는 null.
ispeekingPEEK(zero-copy, latch 유지) vs COPY(latch를 더 빨리 해제).
old_chn호출자의 캐시된 CHN. 레코드 CHN과 일치하면 복사를 건너뜀 (상위 문서 참고).
latch_mode기본적으로 READ. serial increment 등에서는 WRITE.

1.6 스캔 번들 — HEAP_SCANCACHEHEAP_SCANCACHE_NODE

섹션 제목: “1.6 스캔 번들 — HEAP_SCANCACHE와 HEAP_SCANCACHE_NODE”

가장 오래 살아 있는 번들: 여러 번의 get/next 호출에 걸쳐 고정된 페이지, 스냅샷, latch/lock 정책을 캐시한다. 현재 힙을 위한 HEAP_SCANCACHE_NODE 하나를 내장하고, 파티션 스캔을 위한 추가 노드 리스트도 가진다.

// heap_scancache_node -- src/storage/heap_file.h
struct heap_scancache_node
{
HFID hfid; /* Heap file of scan */
OID class_oid; /* Class oid of scanned instances */
const char *classname;
};
// heap_scancache -- src/storage/heap_file.h (condensed C++ class; comments in table)
struct heap_scancache
{
int debug_initpattern;
HEAP_SCANCACHE_NODE node;
LOCK page_latch; /* NULL_LOCK to skip per-page lock */
bool cache_last_fix_page, mvcc_disabled_class;
PGBUF_WATCHER page_watcher;
int num_btids;
multi_index_unique_stats *m_index_stats;
FILE_TYPE file_type;
MVCC_SNAPSHOT *mvcc_snapshot;
HEAP_SCANCACHE_NODE_LIST *partition_list;
private:
cubmem::single_block_allocator *m_area; /* the one private member */
};
필드역할 / 존재 이유
node.hfid스캔 대상 힙 — 스캔이 탐색하는 파일.
node.class_oid스캔하는 인스턴스의 클래스. 잠금 및 가시성 판단에 사용.
node.classname캐시된 클래스 이름. 로깅/진단 용도로, 레코드마다 재조회를 피한다.
debug_initpattern초기화 sentinel. 디버그 빌드에서 미초기화 scancache 사용을 감지한다.
node현재 HEAP_SCANCACHE_NODE. HEAP_SCANCACHE_SET_NODE로 설정.
page_latch힙 페이지에 대한 LOCK. 클래스가 이미 S/SIX/X로 잠겨 있으면 NULL_LOCK.
cache_last_fix_page마지막 fix 페이지와 area 메모리를 유지. 한 페이지에 레코드가 많을 때 재fix를 피한다.
mvcc_disabled_class비MVCC 클래스 여부. 카탈로그/비MVCC 클래스에 대한 스냅샷 가시성 검사를 건너뜀.
page_watcher캐시된 fix 페이지를 잡는 watcher — cache_last_fix_page의 핸들.
num_btids클래스의 인덱스 수. 스캔 기반 갱신의 인덱스 유지 작업 범위를 결정.
m_index_stats인덱스별 유니크 통계 (소스 주석은 이 위치가 적절한지 의문을 제기함).
file_typeFILE_HEAP/FILE_HEAP_REUSE_SLOTS. 슬롯 재사용 결정이 스캔에서도 사용 가능.
mvcc_snapshot지배 MVCC 스냅샷. 가시성의 단일 소스로, get context에 전달된다.
partition_list서브 힙을 위한 HEAP_SCANCACHE_NODE 리스트. 파티션 스캔은 여러 힙을 가로지른다.
(private) m_areaCOPY-area 메서드를 백업하는 cubmem::single_block_allocator *.

불변 조건: 페이지 잠금을 커버하는 클래스 잠금이 없다면 page_latch는 반드시 non-NULL이어야 한다. “힙 페이지에 대한 잠금을 안전하게 생략할 수 있을 때, 즉 클래스가 S/SIX/X로 유지될 때 NULL_LOCK이 될 수 있다”고 명시되어 있다. 해당 커버 잠금 없이 NULL_LOCK을 사용하면 두 트랜잭션이 직렬화 없이 동일 페이지를 건드릴 수 있다.

best-space 기계(Chapter 9)가 거래하는 단위. estimates.best[]의 각 항목이 이 구조체 하나이며, 전역 best-space 해시도 이를 캐시한다.

// heap_bestspace -- src/storage/heap_file.h
struct heap_bestspace
{
VPID vpid; /* Vpid of one of the best pages */
int freespace; /* Estimated free space in this page */
};
필드역할 / 존재 이유
vpid여유 공간이 충분한 페이지의 (volid, pageid). 삽입이 먼저 시도하는 페이지.
freespace추정 여유 바이트. 후보를 순위 매기는 데 쓰이며, 실제 사용 전에 SPAGE_HEADER.total_free와 재검증한다 (§1.3).
  1. 세 계층, 하나의 OID 실. slotted page가 바이트를 저장하고, 힙 파일 계층(HFIDHEAP_HDR_STATS → 연결된 HEAP_CHAIN)이 정체성과 순서를 부여하며, 번들이 latch와 정책을 하나의 호출 동안 운반한다.
  2. 슬롯 0은 신성하다. HEAP_HEADER_AND_CHAIN_SLOTID == 0HEAP_HDR_STATS(헤더)와 HEAP_CHAIN(데이터)을 위해 예약하며, HEAP_ISJUNK_OID가 사용자 OID를 차단한다.
  3. SPAGE_SLOT은 4바이트 14/14/4 비트필드다. offset_to_record는 안정적인 주소 간접 참조이고, record_length는 범위를 한정하며, 4비트 record_type이 모든 흐름의 분기 키다.
  4. HEAP_HDR_STATS.estimatesHEAP_BESTSPACE는 조언적이며 로그를 남기지 않는다. best/second-best 링(각 10개)은 hint로, 실제 여유 공간과 재검증한다.
  5. HEAP_CHAINflags에 vacuum 상태를 압축한다. 상위 두 비트가 VACUUM_NONE/ONCE/UNKNOWN을 인코딩하고, max_mvccid(단조 증가)가 페이지의 청결 여부를 예측한다.
  6. HEAP_OPERATION_CONTEXT는 간접 참조로 네 개의 watcher를 소유한다. *_watcher_p 포인터만이 유효한 핸들이며, type + update_in_place가 경로를 선택한다. HEAP_GET_CONTEXT는 더 가벼운 읽기 쌍둥이(홈 + forward만)이고, HEAP_SCANCACHE(HEAP_SCANCACHE_NODE 포함)는 여러 호출에 걸쳐 스냅샷, 캐시된 페이지, page_latch 정책을 유지한다.

Chapter 2: 슬롯 페이지 기본 요소와 페이지 초기화

섹션 제목: “Chapter 2: 슬롯 페이지 기본 요소와 페이지 초기화”

모든 힙 레코드 — REC_HOME, REC_NEWHOME, REC_BIGONE, REC_RELOCATION — 는 슬롯 페이지(slotted page) 안에 저장된다. 힙 레이어는 페이로드를 직접 다루지 않고, slotted_page.c에 가변 길이 영역의 할당·크기 조정·반환을 위임한 뒤 안정적인 슬롯 ID를 돌려받는다. 이 챕터에서는 그 기반 구조를 해부한다: 레이아웃, 여유 공간 불변식, 네 가지 앵커 타입, 그리고 4~8장이 호출하는 슬롯 기본 연산들. 상위 문서(cubrid-heap-manager.md)는 CUBRID가 안정적인 OID와 움직이는 바이트 오프셋을 분리하는 이유를 다룬다. 여기서는 오프셋이 바뀌는 동안에도 슬롯 ID가 어떻게 고정되는지 그 방법을 추적한다.

슬롯 페이지는 고정 버퍼(SPAGE_DB_PAGESIZE)이며, 오프셋 0에 SPAGE_HEADER가 위치한다. 레코드 페이로드는 헤더 바로 다음부터 아래쪽으로 자라고, 슬롯 배열은 페이지 끝에서 위쪽으로 자란다. 두 영역 사이의 간격이 연속 여유 공간이다. SPAGE_SLOT은 4바이트짜리 간접 참조 단위로, 세 개의 비트 필드가 압축되어 있다:

// spage_slot -- src/storage/slotted_page.h
struct spage_slot
{
unsigned int offset_to_record:14; /* byte offset of record start */
unsigned int record_length:14; /* current record length */
unsigned int record_type:4; /* REC_HOME, REC_NEWHOME, ... */
};

14비트 필드는 페이지 크기를 최대 16 KB로 제한하며, 이는 CUBRID의 최대 페이지 크기에 해당한다. record_type은 3장에서 해석 방식을 결정할 때 읽는 값이다. 슬롯 N은 마지막 4바이트(spage_find_slot: slot_p = page_p + SPAGE_DB_PAGESIZE - sizeof(SPAGE_SLOT); slot_p -= slot_id;)에서 역순으로 세어 찾는다. 이 기하 구조 덕분에 슬롯 배열(위쪽으로 증가, slotN..slot0)과 레코드 영역(아래쪽으로 증가)이 여유 간격에서 만나게 되며, 그 간격의 첫 번째 바이트가 offset_to_free_area이다.

SPAGE_HEADER는 이 챕터가 조작하는 관리 정보를 담는다: num_slots(배열 길이 / 반복 한계)와 num_records(살아 있는 레코드 수, 따라서 num_slots - num_records가 재사용 가능한 슬롯 풀); anchor_type(§2.3)과 alignment; 아래에서 설명할 세 가지 여유 공간 카운터; 그리고 §2.7의 undo 예약을 활성화하는 is_saving.

불변식 (헤더 일관성). 모든 기본 연산의 진입·퇴출 시점마다: total_free >= 0, 0 <= cont_free <= total_free, offset_to_free_area < SPAGE_DB_PAGESIZE이고 alignment 정렬됨, 0 <= num_records <= num_slots. spage_verify_header / SPAGE_VERIFY_HEADER가 이를 강제한다. 위반 시 여유 공간 산술이 깨지며, 이후 삽입이 살아 있는 레코드를 덮어쓸 수 있다.

2.2 페이지 초기화: spage_initialize와 spage_verify_header

섹션 제목: “2.2 페이지 초기화: spage_initialize와 spage_verify_header”

spage_initialize는 분기 없이 처음 불변식을 확립하는 유일한 함수다(디버그 전용 assert (spage_is_valid_anchor_type (slot_type))이 앵커 범위 1..4를 확인한다). num_slots/num_records를 0으로 초기화하고, is_savinganchor_type을 저장한 뒤, total_free = DB_ALIGN (SPAGE_DB_PAGESIZE - sizeof (SPAGE_HEADER), alignment), cont_free = total_free, offset_to_free_area = DB_ALIGN (sizeof (SPAGE_HEADER), alignment)로 설정한다. 표준 빈 페이지 상태: cont_free == total_free, 두 카운터 모두 0, 커서는 정렬된 헤더 바로 다음. total_free는 헤더를 제외하지만, 슬롯 배열 공간은 아직 차감하지 않는다 — 슬롯 바이트는 할당 시점마다 지연 청구된다.

spage_verify_header는 §2.1의 런타임 감사자다: 모든 경계 조건을 AND로 검사하고, 실패 시 헤더를 포맷팅한 뒤 ER_SP_INVALID_HEADER를 발생시키고 assert (false)를 호출한다. SPAGE_VERIFY_HEADER는 그 매크로 형태로, 거의 모든 기본 연산 경계에서 쓰인다.

2.3 네 가지 앵커 타입과 슬롯 재사용

섹션 제목: “2.3 네 가지 앵커 타입과 슬롯 재사용”
앵커 타입삭제 시 슬롯 ID는…재사용
ANCHORED (1)유지됨; REC_DELETED_WILL_REUSE로 표시이후 삽입이 같은 ID를 재사용 가능
ANCHORED_DONT_REUSE_SLOTS (2)유지됨; REC_MARKDELETED로 표시spage_reclaim 전까지 재사용 불가; OID가 묵시적으로 재할당되지 않도록 힙이 선택
UNANCHORED_ANY_SEQUENCE (3)제거됨; 마지막 슬롯이 빈 자리로 이동ID 불안정, 순서 보장 없음
UNANCHORED_KEEP_SEQUENCE (4)제거됨; 상위 슬롯들 memmove 이동ID 불안정, 순서 보장

힙 데이터 페이지는 ANCHORED_DONT_REUSE_SLOTS를 사용한다: OID가 (volid, pageid, slotid)이므로, slotid는 레코드보다 오래 살아 있어야 한다. spage_is_valid_anchor_type은 1..4 범위 밖의 값을 거부한다.

2.4 여유 공간 검사: spage_has_enough_total_space, spage_has_enough_contiguous_space, spage_check_space

섹션 제목: “2.4 여유 공간 검사: spage_has_enough_total_space, spage_has_enough_contiguous_space, spage_check_space”

spage_has_enough_total_space는 “공간이 있는가”를 답한다: space <= 0이면 true, 그 외에는 space <= total_free이되 is_saving 페이지에서는 spage_get_total_saved_spaces(...)를 차감한다(undo 예약분). spage_has_enough_contiguous_spacespace <= cont_free || spage_compact(...) == NO_ERROR — 부족하면 압축(compaction)을 부작용으로 실행한다. spage_check_space는 두 검사를 조합한다: 전체 공간 부족 → SP_DOESNT_FIT; 연속 공간 부족(압축 오류) → SP_ERROR; 그 외 → SP_SUCCESS.

리턴 코드 계약. SP_SUCCESS (1) = 완료; SP_DOESNT_FIT (3) = 압축 후에도 너무 꽉 차 있으므로 호출자가 다른 페이지를 찾아야 함; SP_ERROR (-1) = 하드 내부 실패(압축 오류, 손상된 슬롯, 앵커 위반), “다른 곳을 시도하라”는 의미가 아님. 혼동하면 정확성 버그가 된다: SP_DOESNT_FITSP_ERROR로 잘못 처리하면 페이지를 옮겼어야 할 트랜잭션이 중단된다.

2.5 슬롯 할당: spage_find_free_slot과 spage_find_empty_slot

섹션 제목: “2.5 슬롯 할당: spage_find_free_slot과 spage_find_empty_slot”

spage_find_free_slot은 ID를 선택한다: 모든 슬롯이 살아 있으면(num_slots == num_records) 구멍이 없으므로 slot_id = num_slots(추가); 그 외에는 start_slot부터 순방향으로 record_type == REC_DELETED_WILL_REUSE인 첫 번째 슬롯을 찾아 재사용한다. REC_DELETED_WILL_REUSE만 재사용 가능하다 — REC_MARKDELETED(ANCHORED_DONT_REUSE_SLOTS 툼스톤)는 의도적으로 매칭하지 않으므로, 힙 OID는 vacuum이 회수하기 전까지 재할당되지 않는다.

spage_find_empty_slot은 할당-예약 기본 연산으로, 분기별로 진행된다: (1) spage_has_enough_total_space 실패 → SP_DOESNT_FIT; (2) spage_find_free_slotSP_ERROR를 반환하거나 num_slots를 초과하는 ID → SP_ERROR; (3) 새 ID(slot_id == num_slots)인 경우 spacesizeof(SPAGE_SLOT)을 더하고 spage_check_space를 실행(배열이 간격을 소비), SP_SUCCESS가 아니면 그 상태 반환, 성공 시 num_slots++; (4) 재사용 구멍은 이미 전체 공간을 검증했으므로 cont_free만 재확인(실패 → SP_ERROR); (5) 두 경로 모두 예약 블록에서 수렴 — 슬롯을 설정하고, num_records++, 두 카운터에서 space를 차감하고, offset_to_free_area를 전진 → SP_SUCCESS.

2.6 삽입, 제자리 갱신, 그리고 2단계 갱신 경로

섹션 제목: “2.6 삽입, 제자리 갱신, 그리고 2단계 갱신 경로”

spage_insertspage_find_slot_for_insert(...)를 호출하고, SP_SUCCESSspage_insert_data(...)를 호출하는 방식으로 구성된다. spage_find_slot_for_insertspage_check_record_for_insert를 호출한다 — 초과 크기는 SP_DOESNT_FIT으로 거부하고, REC_MARKDELETED/REC_DELETED_WILL_REUSE 디스크립터 타입은 REC_HOME으로 재기록한다(툼스톤 삽입 불가). spage_insert_dataREC_ASSIGN_ADDRESS(TRANID 플레이스홀더)와 일반 memcpy로 분기하며, 각각 오버플로우를 검사한다.

spage_insert_atUNANCHORED 페이지와 복구를 위한 명시적 ID 변형이다: slot_id <= num_slots 검증(SP_ERROR/ER_SP_UNKNOWN_SLOTID), 이후 spage_find_empty_slot_at을 거쳐 spage_add_new_slot(추가) 또는 spage_take_slot_in_use(재사용, ANCHORED* 페이지에서 사용 중인 슬롯을 재대상으로 지정하면 ER_SP_BAD_INSERTION_SLOT, 그 외엔 배열을 위로 밀어올림)로 분기한다.

갱신. spage_updatetotal_free_save를 저장하고, 공간 여부를 확인(spage_check_updatable → 여유 없으면 SP_DOESNT_FIT)한 뒤 크기에 따라 분기한다: length <= slot_p->record_length이면 spage_update_record_in_place(빠른 경로), 그 외에는 spage_update_record_after_compact(증가). is_saving이면 순 변화량을 spage_save_space(..., total_free - total_free_save)로 예약하고, 실패 시 SP_ERROR를 반환한다.

빠른 경로(spage_update_record_in_place)는 slot_p->record_length를 설정하고, 기존 offset_to_recordmemcpy한 뒤, total_free -= space를 수행한다. 크기가 줄거나 같은 갱신에서는 space <= 0이므로 이 차감이 실제로 여유 공간을 늘린다. spage_is_record_located_at_end(...)is_located_end를 분기한다: 영역 끝에 위치한 레코드만 cont_free -= spaceoffset_to_free_area += space도 수행한다(커서가 뒤로 물러남); 그 외에는 해제된 바이트가 단편화된 상태로 남는다. 증가 경로(spage_update_record_after_compact, 6장)는 꼬리 압축, 전체 spage_compact, 또는 롤백을 수행한다.

불변식 (savings 대칭). is_saving이면 순 여유 공간 변화량을 total_free - total_free_savespage_save_space에 전달해야 한다. 생략하면 동시 트랜잭션이 이 트랜잭션이 롤백을 위해 필요한 바이트를 소비할 수 있어 undo가 깨진다.

2.7 삭제, 툼스톤, 그리고 savings 메커니즘

섹션 제목: “2.7 삭제, 툼스톤, 그리고 savings 메커니즘”

spage_delete는 성공 시 슬롯 ID를, 실패 시 NULL_SLOTID를 반환하며 anchor_type에 따라 분기한다. Figure 2-1은 분기를 완전히 나타낸다:

Figure 2-1 — spage_delete 제어 흐름

flowchart TD
  B{"슬롯이 NULL?"} -->|예| R0["UNKNOWN_SLOTID; NULL_SLOTID"]
  B -->|아니오| C["num_records--; total_free += freed"]
  C --> G{"앵커?"}
  G -->|ANCHORED| H["EMPTY; WILL_REUSE"]
  G -->|DONT_REUSE| I["EMPTY; MARKDELETED"]
  G -->|UNANCHORED| J["shift_down; freed += slot sz"]
  G -->|default| R1["assert; NULL_SLOTID"]
  H --> K{"is_saving?"}
  I --> K
  J --> K
  K -->|예| L{"save_space 성공?"}
  L -->|아니오| R2["NULL_SLOTID"]
  L -->|예| N["dirty; slot_id 반환"]
  K -->|아니오| N

total_free += free_space 크레딧은 무조건 적용된다; cont_free/커서 조정은 삭제된 레코드가 영역 끝에 있을 때만 적용된다(spage_is_record_located_at_end, 그 외에는 구멍이 압축을 기다린다). UNANCHORED 케이스는 슬롯도 회수하고(free_space += sizeof(SPAGE_SLOT)) is_saving == false를 단언한다.

spage_mark_deleted_slot_as_reusable은 vacuum 쪽에서 호출하는 flip 함수다(8장): 빈 툼스톤을 REC_DELETED_WILL_REUSE로 다운그레이드하여 spage_find_free_slot이 그 ID를 다시 내줄 수 있게 한다. 실패 분기는 두 가지로 구별된 에러 코드를 발생시킨다: 범위를 벗어난 slot_id(< 0 또는 >= num_slots) → SP_ERROR/ER_SP_UNKNOWN_SLOTID; 빈 툼스톤이 아닌 슬롯(offset_to_record == SPAGE_EMPTY_OFFSET이고 REC_MARKDELETED/REC_DELETED_WILL_REUSE 타입이어야 함), 즉 살아 있는 레코드 → SP_ERROR/ER_SP_BAD_INSERTION_SLOT(두 경우 모두 assert (false)를 먼저 호출). 툼스톤 경로만 record_type = REC_DELETED_WILL_REUSE로 설정하고 SP_SUCCESS를 반환한다.

savings 메커니즘. is_saving 페이지에서 해제된 바이트는 즉시 사용 가능하지 않다 — 롤백 시 더 큰 이전 레코드를 다시 삽입해야 할 수 있기 때문이다. spage_save_space는 해제된 바이트를 lock-free spage_Saving_hashmap(VPID를 키로, SPAGE_SAVE_HEAD 아래에 트랜잭션별 SPAGE_SAVE_ENTRY)에 예약한다. space == 0, 크래시 복구, vacuum 워커, space < 0, 또는 비활성 트랜잭션이면 NO_ERROR를 조기 반환하고; 활성 트랜잭션의 양수 savings만 엔트리를 할당하며 head->total_saved를 증가시킨다. spage_free_saved_spaces는 커밋/중단 시 tran_next_save 체인을 따라가며, first가 NULL이 되면 head를 삭제한다 — 따라서 §2.4에서 total_saved를 차감한다.

2.8 압축과 회수: ID는 고정, 오프셋은 이동

섹션 제목: “2.8 압축과 회수: ID는 고정, 오프셋은 이동”

압축은 슬롯 ID를 바꾸지 않고 레코드를 붙여 모은다. spage_compact는 살아 있는 슬롯 배열을 구성하고(SPAGE_EMPTY_OFFSET 구멍 건너뜀), offset_to_record로 정렬한 뒤, 오프셋 순서로 각 레코드를 다음 정렬된 to_offset으로 memmove하고 해당 슬롯의 오프셋을 재기록한다:

// spage_compact -- src/storage/slotted_page.c
memmove ((char *) page_p + to_offset,
(char *) page_p + slot_array[i]->offset_to_record,
slot_array[i]->record_length);
slot_array[i]->offset_to_record = to_offset; /* <- offset moves, slot id stays */
to_offset += slot_array[i]->record_length;
...
page_header_p->total_free = SPAGE_DB_PAGESIZE - to_offset - (num_slots * sizeof (SPAGE_SLOT));
page_header_p->cont_free = page_header_p->total_free; /* <- all free now contiguous */

분기: num_records == 0이면 배열을 건너뛰고 to_offset을 헤더 크기로 리셋; calloc 실패 → ER_FAILED; record_type > REC_4BIT_USED_TYPE_MAX이거나 슬롯 카운트 불일치(num_records != j)는 치명적 — 후자는 ER_SP_WRONG_NUM_SLOTS를 발생시키고 logpb_fatal_error_exit_immediately_wo_flush를 호출한다.

불변식 (압축 시 슬롯 ID 보전). 압축은 어떤 슬롯의 offset_to_record도 재기록할 수 있지만, 슬롯의 인덱스절대 바꾸면 안 된다. OID (…, slotid)를 들고 있는 읽기 연산은 spage_find_slot으로 그 인덱스를 신뢰하고 재접근한다; 인덱스를 재정렬하면 OID가 다른 행을 가리키게 된다.

spage_need_compact는 정책 게이트다: 단편화된 여유 공간이 페이지의 5% 이상일 때만 true를 반환한다(total_free - cont_free >= SPAGE_DB_PAGESIZE / 20).

spage_reclaimANCHORED_DONT_REUSE_SLOTS 페이지에서 슬롯 배열을 축소한다. num_slots - 1에서 역순으로 slot_id를 순회하여(후미의 빈 슬롯이 깔끔하게 축소되도록); 빈 툼스톤(offset_to_record == SPAGE_EMPTY_OFFSET이고 REC_MARKDELETED/REC_DELETED_WILL_REUSE 타입)마다 분기한다 — 현재 마지막 슬롯(slot_id + 1 == num_slots)은 spage_reduce_a_slot로 제거하고, 그 외에는 REC_DELETED_WILL_REUSE로 다운그레이드 — is_reclaim = true로 설정한다. 루프 후 회수된 것이 있고 num_slots == 0이면 spage_initialize를 재실행한다. 회수된 것이 있으면 true를 반환한다.

  1. 슬롯은 4바이트 간접 참조다. SPAGE_SLOToffset_to_record/record_length/record_type을 14/14/4비트로 압축하며, 페이지 끝에서 역순으로 주소를 지정해 레코드 영역과 슬롯 배열이 서로를 향해 자란다.
  2. 세 가지 카운터가 여유 공간 상태를 인코딩한다. total_free(전체), cont_free(압축 없이 사용 가능, <= total_free), offset_to_free_area(쓰기 커서) — spage_verify_header가 모든 경계에서 불변식을 강제한다.
  3. anchor_type은 슬롯 ID 정책의 핵심 설정이다. 힙 페이지는 ANCHORED_DONT_REUSE_SLOTS를 사용하므로, 삭제는 REC_MARKDELETED 툼스톤이 되고, vacuum이 처리하기 전까지 해당 ID는 재할당되지 않는다.
  4. 리턴 코드는 상호 교환 불가다. SP_DOESNT_FIT = 다른 페이지를 시도하라, SP_ERROR = 하드 실패, SP_SUCCESS = 완료; spage_has_enough_contiguous_space는 실패를 보고하기 전에 압축을 시도한다.
  5. 압축은 바이트를 움직이고, 슬롯 ID는 움직이지 않는다. spage_compactcont_free == total_free가 되도록 offset_to_record를 재기록하지만, 슬롯 인덱스는 불변으로 유지된다 — 안정적인 OID의 토대이다.
  6. 삭제와 축소 갱신은 undo 공간을 빚질 수 있다. is_saving 페이지에서 해제된 바이트는 spage_save_space에 예약되고, 전체 공간 검사에서 차감되며, 트랜잭션 종료 시 spage_free_saved_spaces가 해제한다.
  7. 회수는 앵커드 페이지 정리 작업이다. spage_reclaim은 후미 툼스톤을 축소하고 나머지를 재사용 가능 상태로 다운그레이드한다; spage_need_compact는 5% 단편화 기준으로 압축을 게이팅한다.

Chapter 3: 레코드 타입과 MVCC 레코드 헤더

섹션 제목: “Chapter 3: 레코드 타입과 MVCC 레코드 헤더”

Chapter 2에서 불투명한 바이트 덩어리를 담는 슬롯을 살펴봤다. 힙 레코드는 물리적으로 어떤 구조인가? 두 가지 어휘 체계가 이 역할을 나눠 맡는다. 슬롯 안의 4비트 record_type(Chapter 2의 spage_slot)은 바이트를 어떻게 해석할지를 지시한다 — 일반 레코드, forwarding 포인터, overflow 포인터, 또는 tombstone. 바디 안의 가변 크기 MVCC 레코드 헤더는 읽기 경로(Chapter 5)와 vacuum(Chapter 8)이 소비하는 버전 메타데이터(삽입/삭제 id, 이전 버전 포인터)를 담는다. MVCC가 레코드마다 stamp를 필요로 하는 이유는 동반 문서 cubrid-heap-manager.md의 “MVCC and the heap” 절을 참고하라.

슬롯의 record_type:4 비트필드(Chapter 2)는 storage_common.h에 정의된 열거형이다. 의미 있는 값은 아홉 개이며, 나머지 4비트 공간(REC_RESERVED_TYPE_8.._15)은 예약되어 있다.

// enum record_type — src/storage/storage_common.h
enum
{
/* Unknown record type */
REC_UNKNOWN = 0,
/* Record without content, just the address */
REC_ASSIGN_ADDRESS = 1,
/* Home of record */
REC_HOME = 2,
/* No the original home of record. part of relocation process */
REC_NEWHOME = 3,
/* Record describe new home of record */
REC_RELOCATION = 4,
/* Record describe location of big record */
REC_BIGONE = 5,
// ... condensed: REC_MARKDELETED = 6, REC_DELETED_WILL_REUSE = 7 ...
// ... condensed: REC_RESERVED_TYPE_8 .. 15 ...
REC_4BIT_USED_TYPE_MAX = REC_DELETED_WILL_REUSE, // highest live value is 7
REC_4BIT_TYPE_MAX = REC_RESERVED_TYPE_15
};

(열거형 주석은 소스 원문 그대로이며, 원문 문체를 보존한다.) 인코딩 내용을 기준으로 분류하면 다음과 같다(열 3 = 슬롯이 바디를 포함하는지 여부):

타입바디?인코딩 내용
REC_HOME있음레코드 전체가 이 슬롯에 존재 — 일반적인 경우
REC_NEWHOME있음REC_RELOCATION이 가리키는 재배치된 바디; OID로 직접 주소 지정 불가
REC_RELOCATIONforwarding바디가 홈 페이지를 벗어났음; 슬롯은 REC_NEWHOME을 가리키는 forward OID를 보유
REC_BIGONEoverflow레코드가 한 페이지를 초과; 슬롯은 overflow 파일의 overflow OID를 보유
REC_ASSIGN_ADDRESS없음OID는 예약됐지만 내용은 아직 기록되지 않음; MVCC stamping 우회(3.4)
REC_MARKDELETED없음슬롯 재사용이 불가한 tombstone
REC_DELETED_WILL_REUSE없음이후 삽입에 의해 슬롯이 재사용될 tombstone
REC_UNKNOWN없음센티넬 / 미초기화(RECDES_INITIALIZER); 실제 슬롯 타입으로는 절대 기록되지 않음

공개 OID 조회 대상으로 “live”한 타입은 네 가지다: REC_HOME, REC_RELOCATION, REC_BIGONE, REC_ASSIGN_ADDRESS. REC_NEWHOMEREC_RELOCATION forward OID를 역참조할 때만 도달할 수 있는 live 데이터이며, 이 타입에 OID가 직접 도달하면 버그다. REC_*DELETED* 타입은 tombstone이고(Chapter 7), REC_UNKNOWN은 실제 슬롯에 기록되지 않는 센티넬이다.

flowchart TB
  OID["공개 OID가 슬롯에 도달"] --> T{record_type}
  T -->|REC_HOME| H["바디 여기에 존재, 헤더 인라인"]
  T -->|REC_RELOCATION| R["forward OID"] --> NH["REC_NEWHOME"]
  T -->|REC_BIGONE| B["overflow OID"]
  T -->|REC_ASSIGN_ADDRESS| A["주소만 존재"]
  T -->|MARKDELETED / WILL_REUSE| D["tombstone"]

Figure 3-1 — 슬롯 fetch 후 record-type 분기; 실제 순회는 Chapter 5에서 설명한다. Forwarding(REC_RELOCATION/REC_NEWHOME)과 overflow(REC_BIGONE)는 헤더 저장 방식이 다르다: 재배치된 REC_NEWHOME은 일반 홈 레코드처럼 인라인 가변 크기 헤더를 가지며, REC_BIGONE은 고정 최대 크기 헤더를 가진다(3.5).

메모리 상의 디코딩된 형태는 mvcc.h의 struct이다. 디스크 상에서는 flag 바이트(3.3)에 따라 or_mvcc_set_header / or_mvcc_get_header(src/base/object_representation_sr.c)로 pack/unpack되며, 디스크 바이트는 이 struct의 레이아웃과 동일하지 않다.

// struct mvcc_rec_header — src/transaction/mvcc.h
struct mvcc_rec_header
{
INT32 mvcc_flag:8; /* MVCC flags */
INT32 repid:24; /* representation id */
int chn; /* cache coherency number */
MVCCID mvcc_ins_id; /* MVCC insert id */
MVCCID mvcc_del_id; /* MVCC delete id */
LOG_LSA prev_version_lsa; /* log address of previous version */
};
// MVCC_REC_HEADER_INITIALIZER zeroes flag/repid and sets NULL_CHN, MVCCID_NULL x2, LSA_INITIALIZER
필드역할존재 이유
mvcc_flag:8OR_MVCC_FLAG_VALID_INSID (0x01), _VALID_DELID (0x02), _VALID_PREV_VERSION (0x04)의 비트 집합; 하위 5비트 사용 가능(OR_MVCC_FLAG_MASK = 0x1f).자기 기술적(self-describing): 어떤 선택적 필드가 존재하는지를 결정하여 전체 크기를 결정한다. word 0을 repid와 공유한다.
repid:24표현(스키마 버전) id.구 스키마 행이 올바른 레이아웃으로 읽히도록 repid를 유지한다. OR_MVCC_REPID_MASK = 0x00FFFFFF로 pack된다.
chn캐시 일관성(cache coherency) 번호.비-MVCC 클래스에서는 유일한 버전 관리 정보로, 업데이트마다 증가하여 클라이언트 캐시가 오래된 데이터를 감지하게 한다; MVCC 클래스에서는 디코딩된 struct에 항상 존재하지만 가시성(visibility) 키는 아니다(3.6).
mvcc_ins_id삽입자의 MVCCID.가시성 하한(Chapter 5). 디스크에는 _VALID_INSID가 설정된 경우에만 존재; 없으면 MVCCID_ALL_VISIBLE.
mvcc_del_id삭제자의 MVCCID.가시성 상한 / tombstone 마커(Chapter 7). 디스크에는 _VALID_DELID가 설정된 경우에만 존재; 없으면 MVCCID_NULL.
prev_version_lsa이전 버전의 로그 LSA.읽기 경로가 TOO_NEW_FOR_SNAPSHOT 시 따라가는 역방향 포인터이며, vacuum이 정리에 활용한다. 디스크에는 _VALID_PREV_VERSION이 설정된 경우에만 존재.

디스크 상에서 chn 다음 word는 delete id 또는 아무것도 없다는 것이 OR_MVCC_FLAG_VALID_DELID에 의해 결정된다 — object_representation_constants.h:168의 해당 플래그 주석에 따르면: “The record contains MVCC delete id. If not set, the record contains chn”. 이 chn/del_id 중첩은 순전히 디스크 인코딩 수준의 이야기이며(CUBRID의 MVCC_* 매크로는 delid_chn 뷰를 사용한다, 예: mvcc.hMVCC_IS_REC_DELETED_BY), chnmvcc_del_id를 별도의 항상-존재하는 필드로 담는 메모리 상의 디코딩된 struct와는 구별된다.

불변: flag 바이트 하나가 헤더 크기를 결정한다. or_header_sizemvcc_header_size_lookup[OR_GET_MVCC_FLAG(ptr)]이다 — 길이 필드나 종료자가 없으므로, 읽는 쪽과 쓰는 쪽은 반드시 flag→size 매핑에 합의해야 한다. mvcc_ins_id를 설정하면서 OR_MVCC_FLAG_VALID_INSID를 빠뜨린 writer는 다음 reader가 헤더를 8바이트 짧게 계산하여 attribute 데이터를 insert id로 잘못 읽게 만든다.

3.3 Flag 기반 가변 크기 인코딩: mvcc_header_size_lookup

섹션 제목: “3.3 Flag 기반 가변 크기 인코딩: mvcc_header_size_lookup”

선택적 id는 flag가 설정된 경우에만 존재하므로 헤더 크기는 여덟 가지다; 테이블은 flag→size 매핑을 O(1)로 만든다:

// mvcc_header_size_lookup — src/object/object_representation.c
int mvcc_header_size_lookup[8] = {
OR_MVCC_REP_SIZE + OR_CHN_SIZE, // index 0
OR_MVCC_REP_SIZE + OR_CHN_SIZE + OR_MVCCID_SIZE, // index 1 (+INSID)
// ... condensed: indices 2..6 sum REP+CHN with the flagged MVCCID/LSA terms ...
OR_MVCC_REP_SIZE + OR_CHN_SIZE + OR_MVCCID_SIZE + OR_MVCCID_SIZE + OR_MVCC_PREV_VERSION_LSA_SIZE
};

필수 rep+chn 접두사는 8바이트이며, 각 MVCCID와 prev-version LSA는 8바이트를 추가한다. 따라서 flag 인덱스 0..7에 대한 여덟 항목의 값은 순서대로 8, 16, 16, 24, 16, 24, 24, 32바이트다. 이 테이블은 카운트가 아닌 비트마스크를 인덱스로 사용한다: 인덱스 1과 2는 둘 다 16바이트(어느 쪽이든 MVCCID 하나), 4/6도 마찬가지다. 오프셋은 위치 기반으로 계산된다 — OR_MVCC_DELETE_ID_OFFSET(flags)VALID_INSID가 설정된 경우에만 OR_MVCC_INSERT_ID_SIZE를 더한다 — 최솟값은 OR_MVCC_MIN_HEADER_SIZE = 8, 최댓값은 OR_MVCC_MAX_HEADER_SIZE = 32. 디스크에서 8바이트 접두사(repid+flags, 이어서 chn) 다음에 mvcc_ins_id, mvcc_del_id, prev_version_lsa(각 8B)가 flagged된 것만 이 순서로 온다; attribute 데이터는 or_header_size(ptr) 위치에서 시작된다.

3.4 heap_insert_adjust_recdes_header — 배치 전 헤더 stamping

섹션 제목: “3.4 heap_insert_adjust_recdes_header — 배치 전 헤더 stamping”

heap_insert_adjust_recdes_header는 클라이언트가 제공한 레코드(bare 헤더)를 stamped 레코드로 변환한다: MVCC 클래스에는 insert-id 추가, 비-MVCC 클래스에는 flag 제거, prev-version 초기화, 길이 조정. 두 가지 경로가 있다. 빠른 경로는 use_optimization을 게이트로 사용하며 조건은 is_mvcc_class && update_in_place == UPDATE_INPLACE_NONE && !VALID_PREV_VERSION && !heap_is_big_length(record_size + OR_MVCCID_SIZE) && !is_bulk_op (SERVER_MODE 전용)이다.

Branch A — use_optimization. unpack/repack 왕복을 건너뛰고 INSID를 바디에 직접 기록하는 단순 MVCC 삽입 경로다.

// heap_insert_adjust_recdes_header (Branch A) — src/storage/heap_file.c
assert (!(mvcc_flags & OR_MVCC_FLAG_VALID_DELID)); /* <- a fresh insert is never pre-deleted */
mvcc_id = logtb_get_current_mvccid (thread_p);
new_ins_mvccid_pos_p = start_p + OR_MVCC_INSERT_ID_OFFSET;
if (!(mvcc_flags & OR_MVCC_FLAG_VALID_INSID))
{
repid_and_flag_bits |= (OR_MVCC_FLAG_VALID_INSID << OR_MVCC_FLAG_SHIFT_BITS);
OR_PUT_INT (start_p, repid_and_flag_bits); /* <- set flag in word 0 */
memmove (new_ins_mvccid_pos_p + OR_MVCCID_SIZE, new_ins_mvccid_pos_p,
insert_context->recdes_p->length - OR_MVCC_INSERT_ID_OFFSET); /* <- open 8-byte gap */
insert_context->recdes_p->length += OR_MVCCID_SIZE;
}
OR_PUT_BIGINT (new_ins_mvccid_pos_p, &mvcc_id); /* <- write INSID into the gap */
return NO_ERROR;

서브 분기: INSID flag가 이미 설정되어 있으면 8바이트 공간이 이미 존재하므로, memmove와 길이 증가를 건너뛰고 id만 덮어쓴다(Chapter 4에서 여분의 8바이트를 미리 버퍼에 확보해 둔다).

Branch B — 일반 경로. 최적화 전제조건 중 하나라도 충족되지 않을 때 진입한다(비-MVCC 클래스, in-place 업데이트, prev-version 존재, big 레코드가 될 경우, bulk 작업, 클라이언트 모드); 먼저 헤더를 완전히 디코딩한다.

// heap_insert_adjust_recdes_header (Branch B) — src/storage/heap_file.c
or_mvcc_get_header (insert_context->recdes_p, &mvcc_rec_header); // ... err check condensed ...
if (insert_context->update_in_place != UPDATE_INPLACE_OLD_MVCCID)
{
if (is_mvcc_class && !insert_context->is_bulk_op) /* B1: MVCC class */
{
mvcc_id = logtb_get_current_mvccid (thread_p);
if (!MVCC_IS_FLAG_SET (&mvcc_rec_header, OR_MVCC_FLAG_VALID_INSID))
{ MVCC_SET_FLAG (&mvcc_rec_header, OR_MVCC_FLAG_VALID_INSID); record_size += OR_MVCCID_SIZE; }
MVCC_SET_INSID (&mvcc_rec_header, mvcc_id);
}
else /* B2: non-MVCC / client */
{
curr_header_size = mvcc_header_size_lookup[mvcc_rec_header.mvcc_flag];
MVCC_CLEAR_ALL_FLAG_BITS (&mvcc_rec_header); /* <- strip all optional ids */
new_header_size = mvcc_header_size_lookup[mvcc_rec_header.mvcc_flag];
record_size -= (curr_header_size - new_header_size); /* <- shrink length to match */
}
}
else if (MVCC_IS_HEADER_DELID_VALID (&mvcc_rec_header)) /* B3: redistribute keeps DELID */
insert_context->is_redistribute_insert_with_delid = true;
MVCC_CLEAR_FLAG_BITS (&mvcc_rec_header, OR_MVCC_FLAG_VALID_PREV_VERSION); /* always: new row has no prev */
if (is_mvcc_class && heap_is_big_length (record_size))
HEAP_MVCC_SET_HEADER_MAXIMUM_SIZE (&mvcc_rec_header); /* <- big record -> full 32B header */
or_mvcc_set_header (insert_context->recdes_p, &mvcc_rec_header); // ... err check condensed ...

상호 배타적인 네 가지 결과: B1은 INSID를 stamp하고 MVCCID 하나만큼 크기를 늘린다; B2는 모든 선택적 id를 제거하고 lookup 테이블 차이만큼 정확히 줄인다; B3(파티션 redistribute)은 기존 DELID를 보존해야 함을 기록하기만 한다; UPDATE_INPLACE_OLD_MVCCID는 세 경우 모두를 그대로 통과한다. 이후 분기와 무관하게, prev-version이 초기화되고 big 레코드는 최대 크기로 승격된다(3.5).

SM_CLASS root-class 경로 vs ATTRINFO 일반 테이블 경로. 이 함수는 일반 테이블 행에 대해서만 실행된다. 삽입 드라이버는 !OID_ISNULL(class_oid) && !OID_IS_ROOTOID(class_oid) && recdes_p->type != REC_ASSIGN_ADDRESS 게이트 아래서 호출하므로 두 종류가 조정을 건너뛴다: root-class 행(OID_IS_ROOTOID — 자체 객체 헤더를 가진 raw 직렬화된 SM_CLASS 레코드는 MVCC 버전 관리 대상이 아니므로, insert-id를 삽입하면 데이터가 손상된다)과 REC_ASSIGN_ADDRESS 플레이스홀더. ATTRINFO 경로(테이블 행, heap_attrinfo 빌드 descriptor)는 비-root, 비-플레이스홀더 분기로 조정자가 실제로 호출된다.

flowchart TB
  IN["삽입 드라이버"] --> Q{"root class?\n또는 REC_ASSIGN_ADDRESS?"}
  Q -->|"예: SM_CLASS raw 레코드"| SKIP["조정 건너뜀"]
  Q -->|"아니오: ATTRINFO 테이블 행"| OPT{use_optimization?}
  OPT -->|예| A["Branch A: 빠른 INSID stamp"]
  OPT -->|아니오| B["Branch B: 완전한 get/set 헤더"]

Figure 3-2 — 호출자 게이트와 헤더 조정의 내부 분기.

3.5 Overflow 레코드는 고정 크기 헤더를 유지한다

섹션 제목: “3.5 Overflow 레코드는 고정 크기 헤더를 유지한다”

REC_BIGONE 바디는 overflow 파일에 존재하며, MVCC 헤더는 첫 번째 overflow 페이지에 항상 최대 크기로 기록되어 in-place로 업데이트된다. 두 헬퍼 함수가 이를 강제한다. heap_get_mvcc_rec_header_from_overflowOR_MVCC_MAX_HEADER_SIZE 크기의 recdes로 읽어 온다:

// heap_get_mvcc_rec_header_from_overflow — src/storage/heap_file.c
peek_recdes->data = overflow_get_first_page_data (ovf_page);
peek_recdes->length = OR_MVCC_MAX_HEADER_SIZE; /* <- always read 32B */
return or_mvcc_get_header (peek_recdes, mvcc_header);

getter의 분기는 하나뿐이다: peek_recdes == NULL이면 로컬 ovf_recdes를 사용하고, 그렇지 않으면 호출자의 recdes에 채워서 overflow 바디까지 접근할 수 있게 한다. heap_set_mvcc_rec_header_on_overflow는 슬롯 길이가 변하지 않도록 최대 크기를 강제하여 다시 기록한다:

// heap_set_mvcc_rec_header_on_overflow — src/storage/heap_file.c
ovf_recdes.area_size = ovf_recdes.length = OR_HEADER_SIZE (ovf_recdes.data);
assert (ovf_recdes.length == OR_MVCC_MAX_HEADER_SIZE); /* <- existing header must already be 32B */
if (!MVCC_IS_FLAG_SET (mvcc_header, OR_MVCC_FLAG_VALID_INSID))
{ MVCC_SET_FLAG_BITS (mvcc_header, OR_MVCC_FLAG_VALID_INSID);
MVCC_SET_INSID (mvcc_header, MVCCID_ALL_VISIBLE); } /* <- force INSID present */
if (!MVCC_IS_FLAG_SET (mvcc_header, OR_MVCC_FLAG_VALID_DELID))
{ MVCC_SET_FLAG_BITS (mvcc_header, OR_MVCC_FLAG_VALID_DELID);
MVCC_SET_DELID (mvcc_header, MVCCID_NULL); } /* <- force DELID present */
assert (mvcc_header_size_lookup[MVCC_GET_FLAG (mvcc_header)] == OR_MVCC_MAX_HEADER_SIZE);
return or_mvcc_set_header (&ovf_recdes, mvcc_header);

if 블록이 유일한 분기다: 이미 설정된 flag는 그대로 두고, 없는 INSID/DELID flag는 중립적인 값(MVCCID_ALL_VISIBLE, MVCCID_NULL)으로 추가된다. HEAP_MVCC_SET_HEADER_MAXIMUM_SIZE(3.4)는 삽입 시점에 동일한 작업을 수행한다.

불변: overflow 헤더는 항상 정확히 OR_MVCC_MAX_HEADER_SIZE(32바이트)다. 두 헬퍼 모두 이를 assert로 검증한다. DELID를 stamp하는 등 헤더를 in-place로 업데이트할 때 바디가 이동해서는 안 된다; 고정 32바이트 헤더 덕분에 레코드 길이가 변하지 않는다. 가변 크기 헤더라면 DELID stamp 때마다 수 메가바이트에 달할 수 있는 레코드의 모든 바이트를 이동시켜야 한다.

이미 앞에서 언급했다(3.2의 chn 행, 3.4의 Branch B2): 비-MVCC 클래스는 모든 MVCC flag를 제거하여 8바이트 rep+chn 형태로 저장하며, chn만이 일관성을 담당한다(MVCC_IS_CHN_UPTODATE로 확인); MVCC 클래스는 mvcc_ins_id를 가시성 키로 stamp하는 반면 chn은 존재하지만 비활성 상태다. 디코더(or_mvcc_get_header)는 양쪽 모두를 처리한다.

  1. 4비트 record_type이 첫 번째 분기 기준이다: 네 가지 타입(REC_HOME, REC_RELOCATION, REC_BIGONE, REC_ASSIGN_ADDRESS)이 공개 OID가 도달할 수 있는 대상이며, REC_NEWHOMEREC_RELOCATION forward를 거쳐야만 도달 가능하고, REC_*DELETED*는 tombstone이며, REC_UNKNOWN은 센티넬이다.
  2. Forwarding(REC_NEWHOME)은 인라인 가변 크기 헤더를 가지며, overflow(REC_BIGONE)는 고정 32바이트 헤더를 가진다.
  3. mvcc_rec_header는 flags+repid를 하나의 word에 pack하고, 항상 존재하는 chn과 8비트 mvcc_flag로 제어되는 세 가지 선택적 디스크 필드(mvcc_ins_id, mvcc_del_id, prev_version_lsa)를 담는다.
  4. 헤더 크기는 flag 바이트만으로 결정되는 순수 함수다or_header_sizemvcc_header_size_lookup[flag]를 인덱싱한다; 길이 필드가 없으므로 writer와 reader는 반드시 flag에 합의해야 하며, 그렇지 않으면 파싱 오류가 발생한다.
  5. heap_insert_adjust_recdes_header는 빠른 경로(Branch A: memmove로 INSID stamp)와 일반 경로(Branch B: B1 MVCC-stamp, B2 비-MVCC를 위한 strip, B3 redistribute-keep-DELID)를 가지며, 항상 prev-version을 초기화하고 big 레코드를 최대 크기로 승격한다. Root-class SM_CLASS 레코드와 REC_ASSIGN_ADDRESS 플레이스홀더는 조정을 건너뛰며, non-root ATTRINFO 행만 stamp된다.
  6. chn비-MVCC 클래스(8바이트 헤더)의 일관성 키이고, mvcc_ins_idMVCC 클래스의 가시성 키다.

이 챕터는 레코드 한 건의 탄생 과정을 추적한다 — 호출자의 RECDES에서 출발해 OID가 발급되고 X-락이 걸린 뒤 바이트가 슬롯 페이지에 기록되기까지: OID는 어떻게 발급되고, 홈 페이지는 어떻게 선택되며, 크기를 초과한 레코드는 삽입 시점에 어떻게 오버플로로 분리되는가? 배치 알고리즘 개요는 고수준 동반 문서의 ### Insert flow 절을 참고한다. 여기서는 모든 분기를 따라간다. 레코드 타입은 챕터 3, HEAP_OPERATION_CONTEXT는 챕터 1, best-space 선택은 챕터 9, 로깅은 챕터 10에서 다룬다.

4.1 컨텍스트 구성 — heap_create_insert_context

섹션 제목: “4.1 컨텍스트 구성 — heap_create_insert_context”

모든 논리적 힙 연산은 HEAP_OPERATION_CONTEXT 하나를 매개로 실행된다. 삽입용 생성자는 컨텍스트를 초기화한 뒤 입력값을 채운다:

// heap_create_insert_context -- src/storage/heap_file.c
heap_clear_operation_context (context, hfid_p); /* <- reset ALL fields; flag defaults below */
if (class_oid_p != NULL) { COPY_OID (&context->class_oid, class_oid_p); } /* <- may stay NULL */
context->recdes_p = recdes_p; /* <- caller's record bytes + type */
context->scan_cache_p = scancache_p; /* <- optional page-caching hint */
context->type = HEAP_OPERATION_INSERT;

heap_clear_operation_contextres_oid, ovf_oid, map_recdes와 아래 세 가지 동작 플래그를 모두 (= false로) 초기화한다:

플래그설정 위치삽입에 미치는 효과
is_logical_old삽입 시에는 false; heap_update_logical에서 재배치 원본에만 truefalse = 순수하게 새로운 논리 객체; REC_NEWHOME 재배치는 heap_insert_newhome을 사용하므로 이 경로에서는 절대 true가 되지 않음
is_redistribute_insert_with_delidheap_insert_adjust_recdes_header, 입력 헤더에 유효한 DELID가 이미 있을 때로깅을 heap_mvcc_log_redistribute로 전환 (§4.7)
is_bulk_opheap_insert_logical 호출 전 bulk-load 호출자가 설정INSID 스탬핑 억제, X_LOCK 대신 NULL_LOCK, 사전 보유 BU_LOCK 단언

불변식 — 컨텍스트는 단일 사용이며 완전히 초기화된다. heap_create_insert_context는 항상 초기화를 먼저 수행하므로, 이전 연산에서 남은 값이 남아 있지 않는다. 초기화 없이 재사용하면 낡은 ovf_oid/res_oid가 남아 포워딩 맵이 손상된다.

heap_insert_logical은 단일 진입점이다 (Figure 4-1, 모든 분기가 error:로 귀결). 그림에 없는 결정이 하나 있다: MVCC 연산 분류is_mvcc_opSERVER_MODE이며 MVCC가 활성화된 클래스, REC_ASSIGN_ADDRESS가 아닌 레코드, 그리고 bulk가 아닌 경우에만 true가 된다. 이 값은 로깅(§4.7) 에서만 소비되며 배치 결정에는 영향을 주지 않는다. 헤더 조정 게이트(§4.3)는 루트 클래스, NULL 클래스 OID, REC_ASSIGN_ADDRESS에 대해서는 건너뛴다.

flowchart TB
  A["heap_insert_logical"] --> B{"scancache_check OK?"}
  B -- "아니오" --> RF1["ER_FAILED 반환 (페이지 미고정)"]
  B -- "예" --> G{"class != NULL, ROOT 아님,\ntype != REC_ASSIGN_ADDRESS?"}
  G -- "예, 실패" --> RF1
  G -- "예, 성공 / 아니오" --> J["heap_insert_handle_multipage_record"]
  J -- "ER_FAILED" --> ERR["goto error"]
  J -- "ok" --> M["클래스 락: bulk → BU_LOCK 단언,\n그 외 → IX_LOCK UNCOND"]
  M -- "미승인" --> RF1
  M -- "승인" --> Q["heap_get_insert_location_with_lock"]
  Q -- "실패" --> RF1
  Q -- "ok, res_oid 설정 + 락 획득" --> O["heap_insert_physical"]
  O -- "실패" --> ERR
  O -- "ok" --> U["bulk 아니면 로그 기록; set_dirty;\ncache 또는 pgbuf_ordered_unfix; perfmon"]
  U --> ERR
  ERR["error:"] --> RET["rc 반환"]

Figure 4-1 — 분기 완전 제어 흐름. 초기 실패는 직접 반환 (페이지 미고정); 고정 이후 실패는 goto error (공유 SystemTap 종료점). error:는 정상 종료(성공) 경로이기도 하다.

4.3 MVCC INSID 스탬핑 — heap_insert_adjust_recdes_header

섹션 제목: “4.3 MVCC INSID 스탬핑 — heap_insert_adjust_recdes_header”

MVCC 클래스의 경우 여기서 행에 삽입 MVCCID가 찍힌다. 빠른 경로 (아래 가드 조건)와 일반 경로가 있다:

// heap_insert_adjust_recdes_header -- src/storage/heap_file.c
use_optimization = (is_mvcc_class && update_in_place == UPDATE_INPLACE_NONE
&& !(mvcc_flags & OR_MVCC_FLAG_VALID_PREV_VERSION)
&& !heap_is_big_length (record_size + OR_MVCCID_SIZE) && !is_bulk_op);
if (use_optimization) { /* <- in-place: OR_PUT_INT INSID flag; memmove 8-byte gap; */
return NO_ERROR; /* length += OR_MVCCID_SIZE; OR_PUT_BIGINT INSID; one memmove, no pack/unpack */
}

빠른 경로는 일반적인 경우(새로운, 큰 레코드가 아닌, prev-version 없는 삽입)에 해당한다. 일반 경로(or_mvcc_get_header → 변형 → or_mvcc_set_header)에는 세 가지 서브 분기가 있다: MVCC, 비-bulkOR_MVCC_FLAG_VALID_INSID가 없으면 설정, OR_MVCCID_SIZE만큼 확장, MVCC_SET_INSID; 비-MVCC 또는 bulk — 모든 플래그 제거 (MVCC_CLEAR_ALL_FLAG_BITS) 후 축소; UPDATE_INPLACE_OLD_MVCCID — 기존 MVCCID 유지, DELID가 존재하면 is_redistribute_insert_with_delid 설정 (파티션 재배분). 이후 큰 MVCC 레코드는 헤더를 최대 크기로 강제 설정(HEAP_MVCC_SET_HEADER_MAXIMUM_SIZE)하여, 이후 오버플로 맵의 in-place 삭제/갱신 시 홈 슬롯이 커지지 않도록 한다.

불변식 — INSID 폭은 배치 전에 예약된다. §4.5의 홈 페이지 크기는 OR_MVCCID_SIZE가 추가된 이후recdes_p->length를 기준으로 산정하며, 빠른 경로의 area_size >= length + OR_MVCCID_SIZE 단언이 memmove 범위를 보장한다.

4.4 크기 초과 분리 — heap_insert_handle_multipage_record

섹션 제목: “4.4 크기 초과 분리 — heap_insert_handle_multipage_record”

이 함수는 “페이지에 들어간다”와 “오버플로로 간다” 사이의 게이트다:

// heap_insert_handle_multipage_record -- src/storage/heap_file.c
if (!heap_is_big_length (context->recdes_p->length)) { return NO_ERROR; } /* <- normal: untouched */
if (heap_ovf_insert (thread_p, &context->hfid, &context->ovf_oid, context->recdes_p) == NULL)
{ return ER_FAILED; } /* <- overflow insert failed */
heap_build_forwarding_recdes (&context->map_recdes, REC_BIGONE, &context->ovf_oid);
context->recdes_p = &context->map_recdes; /* <- home page now receives the 8-byte map */

heap_is_big_length (length > heap_Maxslotted_reclength)는 “크기 초과” 여부의 단일 판정 기준이다. 크지 않으면 이 함수는 아무것도 하지 않는다. 크면 heap_build_forwarding_recdes가 8바이트 REC_BIGONE 맵을 만들고 context->recdes_p를 해당 맵으로 교체한다. 이후 heap_insert_logical의 나머지 부분은 이 작은 레코드를 일반 레코드처럼 배치한다.

불변식 — MVCC 헤더는 오버플로 레코드에 존재하며 맵에는 없다. 소스 코드 주석에 명시되어 있다 (“MVCC information is held in overflow record”). 이것이 §4.3에서 오버플로 레코드 헤더를 최대 크기로 강제한 이유다. 홈 페이지의 REC_BIGONE 맵은 MVCCID를 갖지 않는다. 가시성 (챕터 5)은 OID를 따라 오버플로로 이동한다.

// heap_ovf_insert -- src/storage/heap_file.c
if (heap_ovf_find_vfid (thread_p, hfid, &ovf_vfid, true, PGBUF_UNCONDITIONAL_LATCH) == NULL
|| overflow_insert (thread_p, &ovf_vfid, &ovf_vpid, recdes, FILE_MULTIPAGE_OBJECT_HEAP) != NO_ERROR)
{ return NULL; } /* <- either branch fails -> NULL */
ovf_oid->pageid = ovf_vpid.pageid; ovf_oid->volid = ovf_vpid.volid;
ovf_oid->slotid = NULL_SLOTID; /* <- overflow has no slot */

true 인수는 오버플로 파일이 없으면 생성한다. ovf_oid첫 번째 오버플로 페이지를 식별한다 (슬롯 필드는 무의미 — 오버플로 페이지는 페이지 체인 구조이며 슬롯 방식이 아니다).

recdes_p가 준비되고 클래스 IX 락이 획득되면, heap_insert_logicalheap_get_insert_location_with_lock을 호출한다 — OID가 탄생하는 지점:

  1. 페이지 선택. home_hint == NULLheap_stats_find_best_page (isnew_rec = true, 챕터 9의 블랙박스 — 고정된 페이지 또는 §4.6 폴백을 통한 오류 반환, “공간 없음”은 없음; NULLreturn error_code). 힌트가 있으면 → 너무 작으면 ER_SP_NOSPACE_IN_PAGE, 그렇지 않으면 채택. 어느 경우든 res_oid.volid/pageid가 설정된다.
  2. 락 모드: SCH_M_LOCK (루트), NULL_LOCK (bulk), 그 외 X_LOCK.
  3. 슬롯 루프 (slot 0..slot_count): spage_find_free_slotslot_id를 반환하거나 (REC_DELETED_WILL_REUSE 슬롯 재활용 포함, 챕터 7; == slot_count는 “추가”), SP_ERROR → break. res_oid.slotid 설정; NULL_LOCK이면 즉시 반환, 그 외 lock_object 조건부 시도: LK_GRANTED → 반환; LK_NOTGRANTED_DUE_TIMEOUT → 다음 슬롯; 그 외 오류 → break.
  4. Break 경로: res_oid 무효화, 페이지 unfix, assert(false), return ER_FAILED.

불변식 — OID는 바이트가 기록되기 전에 완전히 결정되고 X-락이 걸린다. res_oid는 성공적인 lock_object (또는 NULL_LOCK bulk의 경우 즉시) 이후에만 완성된다. 아직 기록되지 않은 슬롯에 대한 락이 먼저 걸리므로, 행 락은 INSERT 내부에서 획득된다. 3단계의 조건부 재시도 덕분에 두 삽입자가 임시 슬롯을 두고 데드락에 빠지는 일은 없다.

4.6 물리적 배치 — heap_insert_physicalheap_alloc_new_page

섹션 제목: “4.6 물리적 배치 — heap_insert_physical과 heap_alloc_new_page”

heap_insert_physical은 미리 예약된 슬롯에 정확히 기록한다 — 슬롯을 직접 선택하지 않는다:

// heap_insert_physical -- src/storage/heap_file.c
assert (context->res_oid.slotid != NULL_SLOTID); /* <- slot chosen in §4.5 */
if (spage_insert_at (thread_p, context->home_page_watcher_p->pgptr, context->res_oid.slotid,
context->recdes_p) != SP_SUCCESS)
{ er_set (ER_FATAL_ERROR_SEVERITY, ...); OID_SET_NULL (&context->res_oid); return ER_FAILED; }

spage_insert_at은 챕터 2의 기본 연산이다. 실패는 치명적이다 (슬롯이 방금 여유 있고 락 가능함을 검증했으므로 SP_DOESNT_FIT은 페이지 손상을 의미한다).

heap_alloc_new_page는 best-space 뒤의 새 페이지 폴백으로, 여유 공간이 있는 페이지가 없을 때 heap_stats_find_best_page(와 bulk 로더)가 호출한다. heap_insert_logical에서 직접 호출하지 않는다:

// heap_alloc_new_page -- src/storage/heap_file.c
HEAP_PAGE_SET_VACUUM_STATUS (&new_page_chain, HEAP_PAGE_VACUUM_NONE); /* <- clean page, links nulled */
error_code = file_alloc (thread_p, &hfid->vfid, heap_vpid_init_new, &new_page_chain, new_page_vpid, &page_ptr);
if (error_code != NO_ERROR) { ASSERT_ERROR (); return error_code; } /* <- no watcher attached yet */
pgbuf_attach_watcher (thread_p, page_ptr, PGBUF_LATCH_WRITE, hfid, home_hint_p);

새로운 HEAP_CHAIN 헤더(챕터 8)를 초기화한 뒤 쓰기 latch 상태의 watcher를 붙이고 페이지를 고정한 채로 반환한다. 오류 경로는 §4.2의 비대칭성을 그대로 따른다: file_alloc 실패 시 watcher가 붙기 전에 ASSERT_ERROR()로 오류를 반환하여 고정된 페이지가 남지 않는다. (HEAP_CHAIN 필드: 챕터 1.)

성공적인 삽입 이후 (use_bulk_logging이 아닌 경우), heap_log_insert_physical이 레코드 타입과 연산 종류(is_mvcc_opis_redistribute_insert_with_delid 사용)에 따라 분기한다:

  • MVCC + redistributeheap_mvcc_log_redistribute.
  • MVCC, 일반heap_mvcc_log_insert.
  • 비-MVCC, REC_ASSIGN_ADDRESSRVHF_INSERT undoredo, 2바이트 예약 길이 페이로드 (본문 없음).
  • 비-MVCC, REC_NEWHOMERVHF_INSERT_NEWHOME.
  • 비-MVCC, 그 외 → 단순 RVHF_INSERT.

RVHF_* 의미론은 챕터 10에서 다룬다. Perfmon 카운터 (PSTAT_HEAP_HOME_INSERTS / BIG_INSERTS / ASSIGN_INSERTS)는 최종 recdes_p->type을 기준으로 분류된다 (§4.4에서 분리된 경우 REC_BIGONE).

4.8 사전 발급 경로 — heap_assign_address

섹션 제목: “4.8 사전 발급 경로 — heap_assign_address”

heap_assign_address는 행의 바이트가 존재하기 전에 OID를 예약한다 (변환기가 순환 참조를 끊는 데 사용한다).

// heap_assign_address -- src/storage/heap_file.c
if (expected_length <= 0) { /* ... heap_estimate_avg_length ... */ }
recdes.length = /* <- clamp to [OID_SIZE, non-big] */
((expected_length > SSIZEOF (OID) && !heap_is_big_length (expected_length)) ? expected_length : SSIZEOF (OID));
recdes.data = NULL; recdes.type = REC_ASSIGN_ADDRESS; /* <- placeholder type */
heap_create_insert_context (&insert_context, (HFID *) hfid, class_oid, &recdes, NULL);
rc = heap_insert_logical (thread_p, &insert_context, NULL);
COPY_OID (oid, &insert_context.res_oid); /* <- hand back the minted OID */

길이를 알 수 없을 때는 힙의 평균 객체 길이로 폴백한다 (큰 예약은 OID_SIZE만 예약하며 내용은 나중에 오버플로로 이동). REC_ASSIGN_ADDRESS 타입은 삽입이 헤더 조정(§4.2)을 건너뛰고 비-MVCC(§4.7)로 로그를 남기게 한다. 슬롯은 이후 UPDATE(챕터 6)가 채운다.

4.9 공유 재배치 헬퍼 — heap_insert_newhome

섹션 제목: “4.9 공유 재배치 헬퍼 — heap_insert_newhome”

heap_insert_newhomeREC_NEWHOME 본문 — 홈 페이지를 벗어난 레코드의 재배치 복사본 — 을 배치한다. 논리적 삽입 경로에서는 절대 호출되지 않으며, 갱신(챕터 6)과 삭제 재배치가 재사용한다.

// heap_insert_newhome -- src/storage/heap_file.c
assert (parent_context->type == HEAP_OPERATION_DELETE || parent_context->type == HEAP_OPERATION_UPDATE);
heap_create_insert_context (&ins_context, &parent_context->hfid, &parent_context->class_oid, recdes_p, NULL);
error_code = heap_find_location_and_insert_rec_newhome (thread_p, &ins_context); /* <- find page + spage_insert */
heap_log_insert_physical (thread_p, ..., &ins_context.res_oid, ins_context.recdes_p, false, false); /* <- always non-MVCC */
if (out_oid_p != NULL) { COPY_OID (out_oid_p, &ins_context.res_oid); } /* <- give caller the OID */
if (newhome_pg_watcher != NULL) /* <- optional: hand page back */
pgbuf_replace_watcher (thread_p, ins_context.home_page_watcher_p, newhome_pg_watcher);

수정자 관점에서 중요한 차이점: 자체 자식 ins_context를 구성한다 (§4.1의 단일 사용 불변식); 행 락을 걸지 않는다 (REC_NEWHOMEREC_RELOCATION 포인터로만 접근되므로, 가시적인 OID와 X-락은 거기에 있다. 배치는 isnew_rec = falseheap_stats_find_best_page를 사용한 뒤 spage_insert — 페이지 선택 방식, 사전 락 없음); 로깅은 항상 비-MVCC(RVHF_INSERT_NEWHOME, vacuum이 REC_NEWHOME을 검사하지 않으므로); newhome_pg_watcher가 NULL이 아니면 pgbuf_replace_watcher로 페이지를 고정 상태로 유지하여 호출자가 재고정 없이 prev-version LSA를 설정할 수 있다(챕터 6).

  1. 삽입은 하나의 단일 경로다. heap_insert_logical이 단일 사용 컨텍스트를 헤더 조정 → 분리 → 페이지 선택+락 → 물리적 기록 → 로그 순서로 진행한다. 초기 실패는 직접 반환, 고정 이후 실패는 goto error.
  2. MVCCID는 배치 전에 스탬핑된다. heap_insert_adjust_recdes_header가 8바이트 INSID를 추가(빠른 in-place 경로)하므로 페이지 크기 산정이 최종 길이를 기준으로 이루어진다. 비-MVCC/bulk는 모든 플래그를 제거한다.
  3. 크기 초과는 작은 홈 레코드를 의미한다. heap_insert_handle_multipage_recordheap_ovf_insert로 바이트를 오버플로에 쓰고, MVCC 헤더가 오버플로 레코드에 있는 8바이트 REC_BIGONE 맵을 남긴다.
  4. OID는 바이트가 기록되기 전에 발급되고 X-락이 걸린다. heap_get_insert_location_with_lock이 페이지를 고정하고 res_oid를 완성하며 임시 슬롯을 조건부 X-락으로 잠근다. 경합 시 다음 슬롯으로 이동.
  5. 물리적 기록은 예약을 신뢰한다. heap_insert_physical이 사전 락된 슬롯에 spage_insert_at을 호출한다 (실패는 치명적). heap_alloc_new_page는 폴백으로, file_alloc 실패 시 고정된 페이지를 남기지 않는다.
  6. 두 가지 특수 탄생 경로. heap_assign_address는 본문 없는 OID를 발급한다 (REC_ASSIGN_ADDRESS, 이후 UPDATE가 채움). heap_insert_newhome은 재배치 전용 쌍둥이 — 자체 컨텍스트, 행 락 없음, 항상 비-MVCC RVHF_INSERT_NEWHOME, 선택적 페이지 watcher 반환.

Chapter 5: 읽기 경로 — 가시성 판정과 포워딩 체인 추적

섹션 제목: “Chapter 5: 읽기 경로 — 가시성 판정과 포워딩 체인 추적”

읽기 경로가 답해야 할 질문은 하나다: 주어진 OID(또는 순차 커서)에서 이 트랜잭션이 읽을 수 있는 바이트는 무엇인가? 해당 OID의 레코드가 직접 데이터를 담고 있지 않을 수도 있다 — REC_NEWHOME 본문을 가리키는 REC_RELOCATION 포인터이거나, 오버플로우 페이지를 가리키는 REC_BIGONE 포인터일 수 있다. 위치를 찾았더라도 MVCC 스냅샷이 현재 버전을 비가시 상태로 판정하면 로그를 역방향으로 따라가야 한다. 이 챕터는 두 경로를 분기별로 추적한다.

동반 문서의 ### Read flow### MVCC integration — record header에서 개념을 다루고 있으며, 스냅샷 술어(mvcc_satisfies_snapshot)는 이론적 재도출 없이 SNAPSHOT_SATISFIED / TOO_OLD_FOR_SNAPSHOT / TOO_NEW_FOR_SNAPSHOT이 이미 이해된 것으로 가정한다.

모든 읽기는 HEAP_GET_CONTEXT(heap_file.hstruct heap_get_context)로 집약된다. 이 구조체는 OID, latch된 페이지, 레코드 타입을 헬퍼 함수 간에 전달하는 스크래치패드다. 각 필드:

필드역할존재 이유
record_type디스패처 출력값; REC_HOME/REC_RELOCATION/REC_BIGONE/…타입별 분기 구동; 슬롯은 한 번만 읽힘.
oid_p홈 OID (입력값, const).홈 페이지+슬롯; REC_NEWHOME은 절대 아님.
forward_oidREC_RELOCATION/REC_BIGONE일 때 채워짐.REC_NEWHOME 본문 슬롯, 또는 첫 번째 오버플로우 페이지.
class_oid_p클래스 OID; NULL일 수 있으며 페이지 체인에서 채워짐.해당 클래스의 MVCC 비활성화 여부 결정.
recdes_p출력 바이트; 헤더만 필요할 경우 NULL.데이터 복사 없이 가시성 판정 수행 가능.
scan_cache소유자인 HEAP_SCANCACHE, 또는 NULL.복사 영역 + cache_last_fix_page latch 유지.
home_page_watcher홈 페이지에 대한 순서 지정 watcher.get 전반에 걸쳐 유지; 정리 시 반환됨.
fwd_page_watcher포워드/오버플로우 페이지에 대한 순서 지정 watcher.relocation/bigone에서만 fix됨.
ispeekingPEEK(별칭) 또는 COPY(영역으로 복사).데이터가 latch 해제 이후에도 유효해야 하는지 여부 — 5.7 참조.
old_chn호출자가 캐시한 CHN, 또는 NULL_CHN.”클라이언트가 이미 이 버전을 갖고 있음” 단락 경로.
latch_modeREAD, 또는 scan cache가 X를 요구할 때 WRITE.연속 증가 읽기 시 X 모드로 re-fix를 피함.

불변 조건 (포워드 일관성). heap_prepare_get_contextS_SUCCESS를 반환할 때, 모든 호출자는 record_type == REC_HOME 또는 (!OID_ISNULL(&forward_oid) 이고 fwd_page_watcher.pgptr != NULL)을 단언한다 — heap_get_visible_version_internal/heap_get_last_version 상단의 assert 쌍이 이를 강제한다. 이 조건이 위반되면 heap_get_mvcc_header가 relocation/bigone 레코드의 NULL 포워드 페이지를 역참조하여 크래시가 발생한다.

5.2 홈 페이지 fix — heap_prepare_object_page

섹션 제목: “5.2 홈 페이지 fix — heap_prepare_object_page”

성공 시 watcher는 해당 OID가 위치한 페이지를 보유한다.

// heap_prepare_object_page -- src/storage/heap_file.c
VPID_GET_FROM_OID (&object_vpid, oid);
if (page_watcher_p->pgptr != NULL && !VPID_EQ (pgbuf_get_vpid_ptr (page_watcher_p->pgptr), &object_vpid))
pgbuf_ordered_unfix (thread_p, page_watcher_p); /* <- 잘못된 페이지가 latch됨; 해제 */
if (page_watcher_p->pgptr == NULL)
{
ret = pgbuf_ordered_fix (thread_p, &object_vpid, OLD_PAGE, latch_mode, page_watcher_p);
if (ret == ER_PB_BAD_PAGEID) ret = ER_HEAP_UNKNOWN_OBJECT; /* <- 잘못된 페이지 = "객체 없음" */
if (ret == ER_LK_PAGE_TIMEOUT && er_errid () == NO_ERROR) ret = ER_PAGE_LATCH_ABORTED;
}
return ret;

세 가지 분기: 올바른 페이지가 이미 보유됨 → fix 생략; 다른 페이지가 latch됨 → 먼저 unfix; 페이지 없음 → fix. ER_PB_BAD_PAGEIDER_HEAP_UNKNOWN_OBJECT(호출자는 S_DOESNT_EXIST로 매핑 — 끊어진 OID는 정상 케이스); ER_LK_PAGE_TIMEOUTER_PAGE_LATCH_ABORTED, 순서 지정 fix 재시도 신호.

5.3 디스패처 — heap_prepare_get_context

섹션 제목: “5.3 디스패처 — heap_prepare_get_context”

홈 페이지를 fix하고(5.2), 레코드 타입을 읽고, 간접 타입의 경우 포워드 페이지도 fix한다 — 읽기 경로의 분기 핵심부:

flowchart TD
    A[heap_prepare_get_context] --> B[heap_prepare_object_page home]
    B -->|ER_HEAP_UNKNOWN_OBJECT| Z1[S_DOESNT_EXIST]
    B -->|기타 오류| ERR[goto error: 정리 + S_ERROR]
    B -->|성공| E{슬롯 record_type}
    E -->|NULL 슬롯 / ASSIGN_ADDRESS / MARKDELETED / DELETED_WILL_REUSE| Z2[S_DOESNT_EXIST 또는 오류]
    E -->|REC_HOME| H[S_SUCCESS, 홈만]
    E -->|REC_RELOCATION| R[forward_oid peek, fwd 페이지 fix]
    E -->|REC_BIGONE| G[forward_oid peek, 오버플로우 페이지 fix]
    E -->|REC_NEWHOME 직접 읽기| ERR2[ER_HEAP_BAD_OBJECT_TYPE]
    R -->|홈 unfix됨| RT[한 번 재시도: try_again]
    R -->|안정적| H
    G -->|fix 성공| H
    G -->|page_was_unfixed| AS[assert false]

Figure 5-2. heap_prepare_get_context의 모든 분기.

직관적이지 않은 분기들:

  • REC_RELOCATION 재시도. 포워드 fix 이후 home_page_watcher.page_was_unfixed가 순서 지정 fix가 홈을 다시 확보했음을 나타내면, 재배치 링크가 변경되었을 수 있으므로 try_again으로 한 번 루프된다(try_max == 1); 두 번째 unfix는 ER_PAGE_LATCH_ABORTED를 발생시킨다. REC_BIGONE의 경우 포워드 watcher가 PGBUF_ORDERED_HEAP_OVERFLOW로 재순위되며 홈의 page_was_unfixed기대되지 않는다(오버플로우는 불변) → assert(false).
  • 엣지 케이스. 공급되었지만 NULL인 class_oid_p는 체인 레코드에서 채워진다; REC_NEWHOME 직접 읽기는 불법 → ER_HEAP_BAD_OBJECT_TYPE. error: 레이블은 heap_clean_get_context + S_ERROR를 실행하며; S_DOESNT_EXIST/S_SUCCESS 경로는 정리하지 않는다 — 호출자가 latch를 소유한다.

5.4 헤더 읽기 — heap_get_mvcc_header

섹션 제목: “5.4 헤더 읽기 — heap_get_mvcc_header”

페이지가 latch되고 타입이 확인되면 heap_get_mvcc_header는 순수한 3-way switch (context->record_type)이다: REC_HOME은 홈 슬롯을 peek하고 or_mvcc_get_header로 파싱; REC_RELOCATIONforward_oid에서 동일하게 수행; REC_BIGONEheap_get_mvcc_rec_header_from_overflow를 호출; defaultassert(false)S_ERROR. 사전 조건 단언(홈이 OID와 일치, 포워드 페이지가 forward_oid와 일치)이 5.1의 포워드 일관성 불변 조건의 실질적 보호 장치다.

5.5 가시성 판정 — heap_get_visible_version_internal

섹션 제목: “5.5 가시성 판정 — heap_get_visible_version_internal”

heap_get_visible_version은 얇은 래퍼다. 내부 함수는 컨텍스트를 준비하고(5.3; S_SUCCESS가 아니면 → goto exit), 스냅샷 또는 old_chn이 있을 때 헤더를 읽은 후(5.4), 판정 결과를 매핑한다:

// heap_get_visible_version_internal -- src/storage/heap_file.c
snapshot_res = mvcc_snapshot->snapshot_fnc (thread_p, &mvcc_header, mvcc_snapshot);
if (snapshot_res == TOO_NEW_FOR_SNAPSHOT) /* 원하는 버전이 더 오래됨, 로그 안에 있음 */
{ scan = heap_get_visible_version_from_log (..., &MVCC_GET_PREV_VERSION_LSA (&mvcc_header), ...); goto exit; }
else if (snapshot_res == TOO_OLD_FOR_SNAPSHOT) /* 우리에게 죽은 버전; 미스 */
{ scan = S_SNAPSHOT_NOT_SATISFIED; goto exit; }
/* 그 외 SNAPSHOT_SATISFIED는 CHN 검사로 넘어감, 이후 copy (5.7) */
if (MVCC_IS_CHN_UPTODATE (&mvcc_header, context->old_chn)) /* <- 스냅샷 없이도 실행 */
{ scan = S_SUCCESS_CHN_UPTODATE; goto exit; }

비대칭성은 의미론적으로 중요하다: TOO_OLD는 미스이지만, TOO_NEW는 그렇지 않다 — 원하는 버전이 undo 로그에 더 오래된 형태로 존재하므로 MVCC_GET_PREV_VERSION_LSA를 따른다(5.6). CHN 단락 경로는 스냅샷 블록 이후에 무조건 실행되며, old_chn이 전달된 경우 스냅샷 없이도 동작한다(### CHN). heap_get_last_version은 이 골격에서 스냅샷 블록을 제거한 것으로, 업데이터/로커를 위한 스냅샷 없는 형제 함수다.

5.6 로그로의 추적 — heap_get_visible_version_from_log

섹션 제목: “5.6 로그로의 추적 — heap_get_visible_version_from_log”

TOO_NEW가 발생하면 prev_version_lsa로 연결된 undo 레코드 체인에서 이전 버전을 재구성한다:

// heap_get_visible_version_from_log -- src/storage/heap_file.c
for (LSA_COPY (&process_lsa, previous_version_lsa); !LSA_ISNULL (&process_lsa);)
{ /* 페이지 fetch + log_get_undo_record 생략 */
if (scan_code == S_DOESNT_FIT && scan_cache->is_recdes_assigned_to_area (*recdes))
{ scan_cache->assign_recdes_to_area (*recdes, (size_t) (-recdes->length)); continue; } /* 확장 + 재시도 */
or_mvcc_get_header (recdes, &mvcc_header);
snapshot_res = scan_cache->mvcc_snapshot->snapshot_fnc (...);
if (snapshot_res == SNAPSHOT_SATISFIED)
return MVCC_IS_CHN_UPTODATE (&mvcc_header, has_chn) ? S_SUCCESS_CHN_UPTODATE : S_SUCCESS;
else if (snapshot_res == TOO_OLD_FOR_SNAPSHOT) { assert (false); return S_ERROR; } /* <- 불가능: 여기선 더 오래된 버전만 존재 */
else /* TOO_NEW */ LSA_COPY (&process_lsa, &MVCC_GET_PREV_VERSION_LSA (&mvcc_header)); /* 한 단계 더 후퇴 */
}
return S_DOESNT_EXIST; /* 체인 소진, 가시 버전 없음 */

TOO_OLD는 여기서 불가능하므로(로그에는 더 오래된 버전만 존재) assert로 처리되며; 체인 소진(LSA_ISNULL) → S_DOESNT_EXIST.

5.7 바이트 복사 — heap_get_record_data_when_all_ready와 PEEK vs COPY

섹션 제목: “5.7 바이트 복사 — heap_get_record_data_when_all_ready와 PEEK vs COPY”

이 헬퍼는 타입을 spage_get_record 호출에 매핑한다 — ispeeking을 실제로 처리하는 유일한 지점이다:

// heap_get_record_data_when_all_ready -- src/storage/heap_file.c
switch (context->record_type)
{
case REC_RELOCATION: /* 절대 별칭 금지 -- 강제 COPY */
return spage_get_record (..., fwd_page_watcher.pgptr, forward_oid.slotid, recdes_p, COPY);
case REC_BIGONE: return heap_get_bigone_content (..., ispeeking, &forward_oid, recdes_p);
case REC_HOME: /* context->ispeeking 준수 */
return spage_get_record (..., home_page_watcher.pgptr, oid_p->slotid, recdes_p, context->ispeeking);
default: break;
}
return S_ERROR;

PEEK vs COPY (latch 수명 계약). PEEKrecdes->datalatch된 페이지 내부로 반환한다 — 제로 복사이며 latch가 유지되는 동안에만 유효하다; COPY는 사전 할당된 영역으로 memcpy한다. 진입 단언: PEEK, 또는 scan_cache 혹은 호출자가 제공한 recdes->data가 있는 COPY.

불변 조건 (REC_RELOCATION은 절대 peek되지 않음). relocation 케이스는 COPY를 하드코딩한다; context->ispeeking을 사용하면 PEEK 스캔이 포워드 페이지 메모리를 별칭으로 참조하게 되어 heap_clean_get_context가 포워드 watcher를 unfix한 이후 쓰레기 값을 읽게 된다.

5.8 오버플로우 fetch — heap_get_bigone_content

섹션 제목: “5.8 오버플로우 fetch — heap_get_bigone_content”

REC_BIGONE 데이터는 오버플로우 파일에 존재하며(### Overflow file); S_DOESNT_FIT 발생 시 영역을 확장한다:

// heap_get_bigone_content -- src/storage/heap_file.c
if (scan_cache != NULL && (ispeeking == PEEK || recdes->data == NULL || scan_cache->is_recdes_assigned_to_area (*recdes)))
{
scan_cache->assign_recdes_to_area (*recdes);
while ((scan = heap_ovf_get (thread_p, forward_oid, recdes, NULL_CHN, NULL)) == S_DOESNT_FIT)
{ assert (recdes->length < 0); scan_cache->assign_recdes_to_area (*recdes, (size_t) (-recdes->length)); } /* 확장 + 재시도 */
if (scan != S_SUCCESS) recdes->data = NULL; /* <- 댕글링 포인터 방지 */
}
else scan = heap_ovf_get (thread_p, forward_oid, recdes, NULL_CHN, NULL);

스냅샷은 호출자가 이미 검증했으므로 재검사하지 않는다. 재시도 시 -recdes->length(heap_ovf_get이 반환하는 음수 크기)로 재할당한다; 성공이 아닌 경우 recdes->data를 NULL로 설정한다.

5.9 레거시 CHN 패스트 패스 — heap_get_if_diff_chn

섹션 제목: “5.9 레거시 CHN 패스트 패스 — heap_get_if_diff_chn”

heap_get_if_diff_chn은 컨텍스트 리팩터링 이전의 원시 함수로, #if defined(ENABLE_UNUSED_FUNCTION) 가드로 보호되어 프로덕션 빌드에 포함되지 않는다. 그 로직 — 헤더만 peek하고 scan == S_SUCCESS_CHN_UPTODATE일 때 데이터 COPY를 건너뜀 — 은 현재 heap_get_visible_version_internal(5.5)의 CHN 단락 경로로 이전되었으며, 이것이 기준 구현이다.

5.10 스캔 변형 — heap_nextheap_next_internal

섹션 제목: “5.10 스캔 변형 — heap_next와 heap_next_internal”

스캔은 페이지 체인을 순회하며, 슬롯을 반복하고, 비객체 슬롯을 필터링하고, 생존한 슬롯을 동일한 가시성 판정 기계(heap_scan_get_visible_version)로 통과시킨다. heap_next, heap_prev, heap_next_sampling, 그리고 *_record_info 변형들은 모두 heap_next_internal 위의 한 줄 래퍼다.

flowchart TD
    A[heap_next_internal: hpgid/slot0에서 시작, heap_get_last_vpid, 또는 next_oid에서 재개] --> P[외부 루프: 페이지 단위]
    P --> PG{캐시 페이지가 올바른 VPID?}
    PG -->|오래됨| SW[old_page_watcher에 stash 후 fetch]
    PG -->|없음| FX[heap_scan_pb_lock_and_fetch]
    PG -->|히트| IT
    SW --> IT
    FX --> IT[내부 루프: spage_next_record PEEK]
    IT --> T{슬롯 타입}
    T -->|slot0 / REC_NEWHOME / REC_ASSIGN_ADDRESS / REC_UNKNOWN| IT
    T -->|S_END| NX[heap_vpid_next]
    T -->|객체| QV[heap_scan_get_visible_version]
    NX -->|NULL_PAGEID| END[S_END 반환]
    NX -->|더 있음| P
    QV -->|S_SUCCESS, 올바른 클래스| RET[next_oid 설정, S_SUCCESS 반환]
    QV -->|S_SUCCESS 잘못된 클래스 / NOT_SATISFIED / DOESNT_EXIST| IT
    QV -->|S_ERROR| ERR[오류 반환]

Figure 5-4. heap_next_internal 페이지 체인 + 슬롯 반복.

플로우차트 이외의 내용: latch 유지 stash는 다음 fix가 성공할 때까지 이전 페이지를 old_page_watcher에 고정 상태로 유지한다(스래싱 방지); 이미 캐시된 올바른 페이지는 fix를 생략한다. 필터링은 REC_NEWHOME(자신의 relocation을 거쳐야만 도달), REC_ASSIGN_ADDRESS/REC_UNKNOWN, 그리고 슬롯 HEAP_HEADER_AND_CHAIN_SLOTID를 건너뛴다 — 단, 모든 슬롯이 필요한 get_rec_info 변형(spage_next_record_dont_skip_empty)은 예외다. cache_last_fix_pageheap_scan_get_visible_version 주변에서 강제로 true로 설정되어 다음 호출을 위해 홈 페이지가 반환되도록 한다; 해당 한정자는 REC_HOME+PEEK 단축 경로를 갖는다(MVCC_IS_HEADER_ALL_VISIBLE 또는 스냅샷 만족 → peek된 recdes, 전체 컨텍스트 불필요).

5.11 페이지 체인 진행 — heap_vpid_next

섹션 제목: “5.11 페이지 체인 진행 — heap_vpid_next”

heap_vpid_next는 슬롯 HEAP_HEADER_AND_CHAIN_SLOTID를 읽고 후계 VPID를 반환한다:

// heap_vpid_next -- src/storage/heap_file.c
if (spage_get_record (thread_p, pgptr, HEAP_HEADER_AND_CHAIN_SLOTID, &recdes, PEEK) != S_SUCCESS)
{ VPID_SET_NULL (next_vpid); ret = ER_FAILED; }
else
{
pgbuf_get_vpid (pgptr, next_vpid);
if (next_vpid->pageid == hfid->hpgid && next_vpid->volid == hfid->vfid.volid)
*next_vpid = ((HEAP_HDR_STATS *) recdes.data)->next_vpid; /* <- 헤더 페이지 */
else
*next_vpid = ((HEAP_CHAIN *) recdes.data)->next_vpid; /* <- 일반 페이지 */
}

직관적이지 않은 분기 하나: 첫 번째 페이지는 링크를 HEAP_HDR_STATS 내부(체인을 내장)에 저장하고, 다른 모든 페이지는 순수 HEAP_CHAIN에 저장한다 — 현재 VPID를 hfid->hpgid와 비교하여 결정된다(두 구조체는 챕터 1에서 설명). NULL_PAGEID가 순회를 종료시킨다.

  1. 컨텍스트 하나, 데이터 보유 타입 셋. 데이터를 실제로 담는 것은 REC_HOME/REC_RELOCATION/REC_BIGONE뿐이다; 나머지 모든 타입은 S_DOESNT_EXIST(스캔) 또는 오류(포인트 읽기)로 귀결된다.
  2. heap_prepare_get_context가 분기 허브다 — 홈을 fix하고, 타입을 읽고, relocation/bigone의 경우 포워드 페이지도 fix한다. 비행 중 홈 unfix 발생 시 단 한 번try_again 재시도가 있으며, 불변 BIGONE 케이스에 대해서는 assert(false)가 적용된다.
  3. 포워드 일관성 불변 조건 (REC_HOME 또는 비 null 포워드 OID + 포워드 페이지)은 모든 소비자에서 단언되어, heap_get_mvcc_header와 복사 함수가 포워드 페이지를 맹목적으로 역참조할 수 있게 한다.
  4. 스냅샷 판정은 비대칭이다. TOO_OLDS_SNAPSHOT_NOT_SATISFIED; SNAPSHOT_SATISFIED → 복사; TOO_NEWprev_version_lsa를 따라 undo 로그로 진입, 만족되거나 소진될 때까지(S_DOESNT_EXIST).
  5. PEEK는 페이지를 별칭으로 참조하고, COPY는 영역에 복제한다REC_RELOCATION항상 COPY; CHN 단락 경로는 스냅샷 블록 이후 무조건 실행된다.
  6. 스캔 경로는 포인트 읽기 기계를 재사용한다heap_vpid_next 체인 순회, 슬롯 필터링, cache_last_fix_page latch 유지를 더하여. heap_get_if_diff_chn은 레거시이며; heap_get_last_version은 업데이터/로커를 위한 스냅샷 없는 형제 함수다.

Chapter 6: 업데이트 흐름과 레코드 타입 전이

섹션 제목: “Chapter 6: 업데이트 흐름과 레코드 타입 전이”

힙 업데이트는 힙 매니저에서 가장 분기가 많은 연산이다. 새 이미지는 크기가 줄거나, 동일하게 유지되거나, 늘어날 수 있으며, 원본 슬롯은 REC_HOME이거나, REC_RELOCATION + REC_NEWHOME 쌍이거나, REC_BIGONE 오버플로우일 수 있다. CUBRID의 계약은 OID는 절대 이동하지 않는다 (res_oidoid로 재설정)는 것이므로, 크기 증가는 안정적인 홈 슬롯 뒤편의 물리적 표현을 바꾸는 방식으로 흡수된다. 이 챕터는 heap_update_logical이 현재 타입에 따라 어떻게 디스패치하고, 각 워커가 제자리(in-place) / 재배치(relocate) / 오버플로우 중 어느 경로를 선택하는지를 추적한다. 레코드 타입 분류 체계와 MVCC 헤더 레이아웃은 챕터 3을, 삽입 측의 배치 방식은 챕터 4를, 설계 근거는 cubrid-heap-manager.md를 참고한다.

6.1 진입점: 컨텍스트 생성과 MVCC/in-place 분기

섹션 제목: “6.1 진입점: 컨텍스트 생성과 MVCC/in-place 분기”

heap_create_update_context는 순수 초기화 함수로, I/O 없이 OID, 클래스 OID, 이미지(recdes_p), 그리고 update_in_place 스타일을 기록한다.

// heap_create_update_context -- src/storage/heap_file.c
COPY_OID (&context->oid, oid_p);
// ... condensed: COPY_OID (&context->class_oid, ...); context->scan_cache_p = scancache_p; ...
context->recdes_p = recdes_p; /* the new image */
context->type = HEAP_OPERATION_UPDATE;
context->update_in_place = in_place; /* <- in-place vs MVCC switch */

UPDATE_INPLACE_STYLE (heap_file.h에 정의)은 세 가지 값을 가진다. UPDATE_INPLACE_NONE은 기본 MVCC 경로로, 새 INSID와 prev_version_lsa 체인 항목을 가진 새 버전을 생성한다. UPDATE_INPLACE_CURRENT_MVCCID는 현재 MVCCID를 스탬핑하는 파괴적 제자리 덮어쓰기로 새 버전을 만들지 않는다. UPDATE_INPLACE_OLD_MVCCID는 기존 MVCC ID를 보존하는 파괴적 덮어쓰기로, 복제(replication)나 재분배(redistribution)에 사용된다. 연산 전체에 적용되는 이 토글은 heap_update_logical 내에서 is_mvcc_op = HEAP_UPDATE_IS_MVCC_OP (is_mvcc_class, update_in_place)로 한 번 계산된다. 여기서 is_mvcc_class = !mvcc_is_mvcc_disabled_class (&class_oid)이며, 매크로는 is_mvcc_class && !HEAP_IS_UPDATE_INPLACE(style)이다.

불변 조건 — MVCC와 in-place는 상호 배타적이다. is_mvcc_op는 해당 클래스가 MVCC 활성화 상태이고 스타일이 UPDATE_INPLACE_NONE일 때만 true다. SERVER_MODE가 아닌 빌드에서는 HEAP_UPDATE_IS_MVCC_OPfalse로 하드코딩되므로, 독립 실행 도구는 항상 in-place 경로를 따른다.

6.2 heap_update_logical: 위치 확인, 페치, 조정, 디스패치

섹션 제목: “6.2 heap_update_logical: 위치 확인, 페치, 조정, 디스패치”

스캔 캐시, 파일 타입(FILE_HEAP / FILE_HEAP_REUSE_SLOTS 한정), heap_is_valid_oid 검증 이후, 함수는 홈 페이지를 고정하고(heap_get_record_location), record_type = spage_get_record_type으로 레코드 타입을 읽는다(REC_UNKNOWN 슬롯은 ER_HEAP_UNKNOWN_OBJECT). 이어서 home_recdes에 홈 레코드를 복사하고(언두 소스 — PEEK이 아닌 COPY), 실제 사용자 클래스(!OID_IS_ROOTOID)이면 새 이미지에 heap_update_adjust_recdes_header(6.7절)를 호출하고, do_supplemental_log(CDC)를 결정한 후 디스패치한다.

// heap_update_logical -- src/storage/heap_file.c
switch (context->record_type)
{
case REC_RELOCATION: rc = heap_update_relocation (thread_p, context, is_mvcc_op); break;
case REC_BIGONE: rc = heap_update_bigone (thread_p, context, is_mvcc_op); break;
case REC_ASSIGN_ADDRESS:
context->is_logical_old = false; /* <- inserted this tran, not an old version */
[[fallthrough]];
case REC_HOME: rc = heap_update_home (thread_p, context, is_mvcc_op); break;
default:
rc = ER_HEAP_BAD_OBJECT_TYPE; goto exit;
}

REC_ASSIGN_ADDRESS 폴스루(fallthrough)는 방금 예약된 주소 슬롯을 논리적으로 오래된 버전이 아님으로 표시하여 heap_update_home으로 라우팅한다. exit에서 홈 watcher는 스캔 캐시로 넘겨지거나(cache_last_fix_page인 경우) 고정 해제되며, heap_unfix_watchers가 나머지를 해제한다.

6.3 heap_update_home: REC_HOME이 소스 타입인 경우

섹션 제목: “6.3 heap_update_home: REC_HOME이 소스 타입인 경우”

heap_update_homeREC_HOME(과 REC_ASSIGN_ADDRESS)을 처리하며, 오버플로우 → 제자리 → 재배치의 엄격한 우선순위로 세 가지 목적지 중 하나를 선택한다.

// heap_update_home -- src/storage/heap_file.c
if (heap_is_big_length (context->recdes_p->length)) { /* 1. overflow */
heap_ovf_insert (..., &forward_oid, context->recdes_p);
heap_build_forwarding_recdes (&forwarding_recdes, REC_BIGONE, &forward_oid);
home_page_updated_recdes_p = &forwarding_recdes;
}
else if (!spage_is_updatable (..., context->recdes_p->length)) { /* 3. relocate */
context->recdes_p->type = REC_NEWHOME;
heap_insert_newhome (..., context->recdes_p, &forward_oid, newhome_pg_watcher_p);
heap_build_forwarding_recdes (&forwarding_recdes, REC_RELOCATION, &forward_oid);
home_page_updated_recdes_p = &forwarding_recdes;
}
else { /* 2. in place: stays REC_HOME */
context->recdes_p->type = REC_HOME;
home_page_updated_recdes_p = context->recdes_p;
}

이 세 가지는 6.8절 전이 행렬의 행 1~3에 해당한다. 목적지 선택 후, 목적지가 포워더(forwarder)이고 home_page_watcher_p->page_was_unfixed인 경우(정렬된 두 번째 페이지 고정 중 페이지가 해제되어 vacuum/compaction이 발생했을 수 있는 상황) 재-peek 가드가 발동한다. 이 가드는 로깅된 언두 이미지가 현재 바이트와 일치하도록 home_recdes를 다시 읽는다. 그런 다음 로깅(heap_log_update_physical, MVCC인 경우 RVHF_UPDATE_NOTIFY_VACUUM, 그 외에는 RVHF_UPDATE; 살아있는 REC_ASSIGN_ADDRESS도 NOTIFY_VACUUM 사용), prev_version_lsa 캡처, heap_update_physical 호출, MVCC 연산이면 heap_update_set_prev_version 호출이 이어진다.

불변 조건 — REC_ASSIGN_ADDRESS는 MVCC 업데이트 대상이 아니다. 초기 가드(!HEAP_IS_UPDATE_INPLACE && home_recdes.type == REC_ASSIGN_ADDRESS)는 assert(false)와 함께 ER_FAILED를 반환한다. 예약(reservation)에는 버전 관리할 MVCC 헤더가 없으므로, 비-MVCC in-place 경로만이 이를 채운다.

flowchart TD
  A["heap_update_home"] --> Z{REC_ASSIGN_ADDRESS\n and MVCC op?}
  Z -->|yes| ZF["assert false; ER_FAILED"]
  Z -->|no| B{heap_is_big_length?}
  B -->|yes| C["REC_BIGONE 포워더"]
  B -->|no| D{spage_is_updatable home?}
  D -->|no| E["REC_RELOCATION + REC_NEWHOME"]
  D -->|yes| F["REC_HOME 제자리"]
  C --> H["재-peek(unfixed 시); 로그; 물리 갱신; mvcc면 set_prev_version"]
  E --> H
  F --> H

Figure 6-1: heap_update_home 분기 트리.

6.4 heap_update_relocation: REC_RELOCATION + REC_NEWHOME이 소스 타입인 경우

섹션 제목: “6.4 heap_update_relocation: REC_RELOCATION + REC_NEWHOME이 소스 타입인 경우”

세 워커 중 가장 복잡하다. 홈 슬롯은 포워드 페이지의 REC_NEWHOME을 가리키는 REC_RELOCATION OID를 담고 있다. 함수는 forward_oid를 읽고, 포워드 페이지를 고정하며(heap_fix_forward_page), 두 가지 술어를 계산한다.

// heap_update_relocation -- src/storage/heap_file.c
fits_in_home = spage_is_updatable (... home slot ..., context->recdes_p->length);
fits_in_forward = spage_is_updatable (... forward slot ..., context->recdes_p->length);
if (heap_is_big_length (context->recdes_p->length) || (!fits_in_forward && !fits_in_home))
heap_fix_header_page (thread_p, context); /* header page needed for overflow or a new newhome */

이어서 4방향 결정이 세 가지 불리언(update_old_home, update_old_forward, remove_old_forward)과 새 홈 이미지를 설정한다 — 6.8절 행 4~7, 그리고 불가능한 else 분기(assert(false); ER_FAILED). fits_in_homefits_in_forward보다 우선하지만, 어디에도 맞지 않는 경우가 새 재배치를 생성한 이후에만 그렇다. 임의의 I/O 전에, 두 assert가 부기 불변 조건을 인코딩한다: assert (remove_old_forward != update_old_forward)assert (remove_old_forward == update_old_home).

불변 조건 — 낡은 REC_NEWHOME은 정확히 한 번만 정리된다. 기존 REC_NEWHOME삭제되거나(remove_old_forward, 홈 슬롯의 의미가 바뀔 때) 덮어쓰여진다(update_old_forward, 재배치 유지) — 둘 다이거나 둘 다 아닌 경우는 없다. 위의 두 assert는 디버그 빌드에서 위반 시 크래시를 일으킨다. 삭제 XOR 갱신이며, 홈이 재작성될 때만 삭제된다.

이후 최대 세 개의 조건부 블록이 실행된다(홈 재작성은 heap_log_update_physical; 기존 newhome 해제는 heap_log_delete_physical + heap_delete_physical; 또는 제자리 포워드 재작성 로깅 heap_mvcc_log_home_no_change). 재배치 유지(stay-relocated) 브랜치는 prev_version_lsa를 포워드 갱신 LSA로 설정하고, 나머지는 삭제된 기존 newhome의 heap_log_delete_physical 언두 LSA를 사용한다 — 그 레코드 자체가 이전 버전이기 때문이다. MVCC 연산은 이후 newhome_pg_watcher_p(새 newhome) 또는 기존 포워드 watcher와 함께 heap_update_set_prev_version을 호출한다.

6.5 heap_update_bigone: REC_BIGONE이 소스 타입인 경우

섹션 제목: “6.5 heap_update_bigone: REC_BIGONE이 소스 타입인 경우”

홈 슬롯은 REC_BIGONE 포워더를 담고 있고, 본문은 오버플로우 파일에 존재한다. ovf_oidhome_recdes.data에서 읽고, 헤더 페이지를 고정하며, MVCC 연산인 경우 변경 전에 기존 오버플로우 내용을 읽고 RVHF_MVCC_UPDATE_OVERFLOW로 로깅한다.

// heap_update_bigone -- src/storage/heap_file.c
context->ovf_oid = *((OID *) context->home_recdes.data);
heap_fix_header_page (thread_p, context);
if (is_mvcc_op) {
heap_get_bigone_content (... &context->ovf_oid, &ovf_recdes); /* old version image */
log_append_undo_recdes2 (thread_p, RVHF_MVCC_UPDATE_OVERFLOW, &ovf_vfid, first_pgptr, -1, &ovf_recdes);
or_mvcc_set_log_lsa_to_record (context->recdes_p, logtb_find_current_tran_lsa (thread_p)); /* prev_version_lsa */
}

불변 조건 — 오버플로우 경로는 본문이 변경되기 전에 prev_version을 스탬핑한다. REC_BIGONE의 경우 이전 버전 LSA는 RVHF_MVCC_UPDATE_OVERFLOW 언두 레코드 LSA이며, 여기서 새 이미지 헤더에 직접 기록된다. heap_update_set_prev_version(6.7)은 이 워커에서는 호출되지 않는다.

본문 업데이트는 세 가지로 분기한다 — 6.8절 행 8~10:

// heap_update_bigone -- src/storage/heap_file.c
if (heap_is_big_length (context->recdes_p->length)) { /* overflow -> overflow */
is_old_home_updated = false;
heap_ovf_update (..., &context->ovf_oid, context->recdes_p);
if (is_mvcc_op) heap_mvcc_log_home_no_change (...); /* vacuum must still reach new overflow */
}
else if (spage_update (..., context->recdes_p) == SP_SUCCESS) { /* overflow -> home */
is_old_home_updated = true;
context->record_type = context->recdes_p->type = REC_HOME;
spage_update_record_type (..., REC_HOME); new_home_recdes = *context->recdes_p;
}
else { /* overflow -> relocation */
context->recdes_p->type = REC_NEWHOME;
heap_insert_newhome (..., context->recdes_p, &newhome_oid, NULL); /* + REC_RELOCATION forwarder */
is_old_home_updated = true;
}

정리는 is_old_home_updated에 따라 결정된다. 오버플로우 유지(stay-overflow) 브랜치에서는 false(MVCC인 경우 heap_mvcc_log_home_no_change만 수행). 축소 브랜치 둘 다에서는 true이며, 홈 변경 로깅(RVHF_UPDATE_NOTIFY_VACUUM / RVHF_UPDATE)과 heap_ovf_delete로 고아 오버플로우 체인을 회수하는 후속 블록이 실행된다. heap_ovf_update에서 NULL이 반환되면 ASSERT_ERROR_AND_SET; goto exit로 전파된다.

6.6 heap_insert_newhome과 heap_ovf_update — 배치 헬퍼

섹션 제목: “6.6 heap_insert_newhome과 heap_ovf_update — 배치 헬퍼”

heap_insert_newhome은 세 워커 모두(그리고 삭제 경로)가 재배치된 REC_NEWHOME을 구체화하는 데 공유한다. 이 함수는 부모의 HFID/클래스를 바탕으로 새 삽입 컨텍스트를 만들고, best-space 검색(챕터 9)으로 레코드를 배치하며, 일반 RVHF_INSERT를 로깅하고(vacuum은 REC_NEWHOME을 스캔하지 않으므로 알리지 않음), OID를 복사해 반환한다.

// heap_insert_newhome -- src/storage/heap_file.c
heap_create_insert_context (&ins_context, &parent_context->hfid, &parent_context->class_oid, recdes_p, NULL);
heap_find_location_and_insert_rec_newhome (thread_p, &ins_context);
heap_log_insert_physical (... ins_context.recdes_p, false, false); /* RVHF_INSERT, not MVCC */
if (out_oid_p != NULL) COPY_OID (out_oid_p, &ins_context.res_oid);
if (newhome_pg_watcher != NULL) /* hand fixed page back */
pgbuf_replace_watcher (thread_p, ins_context.home_page_watcher_p, newhome_pg_watcher);

newhome_pg_watcher가 NULL이 아닌 경우(MVCC 재배치 브랜치), 새로 고정된 페이지는 고정 해제 대신 pgbuf_replace_watcher반환되어, heap_update_set_prev_version이 나중에 이전 버전 필드를 제자리에서 패치할 수 있도록 한다. heap_ovf_update는 오버플로우 VFID를 해석하고(heap_ovf_find_vfid) overflow_update에 위임하는 얇은 래퍼로, ovf_oid 또는 NULL을 반환한다.

6.7 헤더 스탬핑: heap_update_adjust_recdes_header와 heap_update_set_prev_version

섹션 제목: “6.7 헤더 스탬핑: heap_update_adjust_recdes_header와 heap_update_set_prev_version”

이 두 함수는 MVCC 버전 체인 작업을 연산의 타임라인에 걸쳐 분담한다. heap_update_adjust_recdes_header앞서(heap_update_logical에서) 실행되어, 새 이미지의 헤더를 NULL prev-version LSA로 덮어쓴다(언두 LSA는 로깅 전까지 알 수 없다). 최적화 경로(MVCC 연산, 이미지가 크지 않음, 소스 헤더에 DELID 없음)는 하나의 memmove로 INSID + prev-version LSA를 위한 공간을 확보하고, 새 MVCCID를 스탬핑하며, 플레이스홀더 NULL LSA를 기록한다.

// heap_update_adjust_recdes_header -- src/storage/heap_file.c
update_mvcc_flags = OR_MVCC_FLAG_VALID_INSID | OR_MVCC_FLAG_VALID_PREV_VERSION;
mvcc_id = logtb_get_current_mvccid (thread_p);
if ((mvcc_flags & update_mvcc_flags) != update_mvcc_flags) {
repid_and_flag_bits |= (update_mvcc_flags << OR_MVCC_FLAG_SHIFT_BITS);
memmove (new_data_p, existing_data_p, ...); /* room for INSID + LSA; ... condensed ... */
}
OR_PUT_BIGINT (new_ins_mvccid_pos_p, &mvcc_id); /* fresh INSID */
memcpy (new_ins_mvccid_pos_p + OR_MVCCID_SIZE, &null_lsa, ...); /* placeholder LSA */

느린 경로(or_mvcc_get_header + 플래그별 편집)는 나머지 케이스를 처리한다. UPDATE_INPLACE_OLD_MVCCID는 기존 INSID를 유지하고, 비-MVCC 경로는 모든 MVCC 플래그를 제거하며(MVCC_CLEAR_ALL_FLAG_BITS), 큰 레코드는 OR_MVCC_MAX_HEADER_SIZE를 강제한다. 모든 MVCC 경로는 prev_version_lsa를 NULL로 남긴다.

heap_update_set_prev_version은 물리적 업데이트와 로깅 이후에 실행되어, PEEK으로 이제 알려진 언두 LSA를 레코드에 패치한다(spage_update 없이). 현재 홈 슬롯 타입에 따라 디스패치한다.

// heap_update_set_prev_version -- src/storage/heap_file.c
spage_get_record (... home_pg_watcher->pgptr, oid->slotid, &recdes, PEEK);
if (recdes.type == REC_HOME) { /* patch home record */
or_mvcc_set_log_lsa_to_record (&recdes, prev_version_lsa);
} else if (recdes.type == REC_RELOCATION) { /* follow to REC_NEWHOME, patch it */
forward_oid = *((OID *) recdes.data);
spage_get_record (... fwd_pg_watcher->pgptr, forward_oid.slotid, &forward_recdes, PEEK);
or_mvcc_set_log_lsa_to_record (&forward_recdes, prev_version_lsa);
} else if (recdes.type == REC_BIGONE) { /* patch overflow OR header */
forward_recdes.data = overflow_get_first_page_data (overflow_pg_watcher.pgptr);
or_mvcc_set_log_lsa_to_record (&forward_recdes, prev_version_lsa);
} else { assert (false); error_code = ER_FAILED; } /* each arm pgbuf_set_dirty's its page */

불변 조건 — prev_version_lsa는 직전 버전의 언두 이미지를 가리킨다. heap_update_adjust_recdes_header가 슬롯을 예약하고, heap_update_set_prev_version(홈/재배치 경우) 또는 heap_update_bigone의 인라인 스탬핑이 로깅 시점 LSA로 채운다. REC_RELOCATION 경로는 포워드 페이지에 fwd_pg_watcher가 필요하므로, 6.6절의 pgbuf_replace_watcher 핸드오프가 그 이유다.

6.8 소스 타입 × 새 크기 전이 행렬

섹션 제목: “6.8 소스 타입 × 새 크기 전이 행렬”

세 워커를 합쳐서 도달 가능한 모든 전이를 정리하면 다음과 같다.

소스 타입크기 조건결과 홈 타입본문 처리
REC_HOMEbigREC_BIGONE새 오버플로우 삽입
REC_HOMEnot big, 홈 공간 부족REC_RELOCATIONREC_NEWHOME
REC_HOMEnot big, 홈 공간 충분REC_HOME제자리 갱신
REC_RELOCATIONbigREC_BIGONE새 오버플로우; 기존 newhome 삭제
REC_RELOCATIONnot big, 둘 다 불가REC_RELOCATION새 newhome; 기존 newhome 삭제
REC_RELOCATIONnot big, 홈 공간 충분REC_HOME이미지를 홈에; 기존 newhome 삭제
REC_RELOCATIONnot big, 포워드 공간 충분REC_RELOCATION (유지)기존 newhome 제자리 갱신
REC_BIGONEbigREC_BIGONE (유지)heap_ovf_update 제자리
REC_BIGONEnot big, spage_update 성공REC_HOME오버플로우 삭제
REC_BIGONEnot big, spage_update 실패REC_RELOCATION새 newhome; 오버플로우 삭제

세 소스 전체에 걸친 통합 우선순위는 오버플로우 > 홈 > 재배치이며, heap_update_relocation만의 특수화로 이미 고정된 포워드 슬롯을 재사용하는 것(재배치 유지)이 새 newhome 할당보다 우선한다.

  1. heap_update_logical은 현재 홈 슬롯 타입에 따라 세 워커 중 하나로 디스패치한다. OID는 절대 이동하지 않으며(res_oid = oid), 오직 그 뒤편의 물리적 표현만 바뀐다.
  2. MVCC 대 in-place 분기는 하나의 불리언(is_mvcc_op = is_mvcc_class && update_in_place == UPDATE_INPLACE_NONE)으로 결정되며, 상호 배타적임이 assert로 보장된다. in-place 스타일은 현재 또는 기존 MVCCID를 스탬핑한다.
  3. 각 워커는 오버플로우 > 홈 > 재배치 우선순위로 목적지를 선택한다. heap_update_relocation은 이미 고정된 포워드 슬롯을 선호하는 재배치 유지 특수화를 추가한다.
  4. heap_update_relocation의 세 불리언은 낡은 REC_NEWHOME을 정확히 한 번 정리함을 보장하는 두 assert로 지켜진다 — 홈의 의미가 바뀔 때 삭제, 재배치가 유지될 때 갱신, 둘 다이거나 둘 다 아닌 경우는 없다.
  5. 헤더 스탬핑은 시간적으로 분리된다. heap_update_adjust_recdes_header가 앞서 INSID + NULL prev-version LSA를 위한 공간을 확보하고, LSA는 나중에(heap_update_set_prev_version으로, 또는 bigone의 RVHF_MVCC_UPDATE_OVERFLOW 인라인으로) 패치되어, 챕터 5의 읽기 경로 버전 체인이 유지된다.

Chapter 7: 삭제 흐름과 툼스톤(Tombstone)

섹션 제목: “Chapter 7: 삭제 흐름과 툼스톤(Tombstone)”

CUBRID에서 DELETE는 “바이트를 지우는” 연산이 아니다. MVCC 하에서는 삭제가 커밋되기 전에 시작된 스냅샷이 여전히 이전 행을 볼 수 있어야 하므로 레코드 본체가 살아있어야 한다. 따라서 MVCC 삭제는 Chapter 6의 제자리 업데이트와 거의 동일하다 — 헤더에 삭제 MVCCID를 찍고 페이로드는 그대로 둔다. spage_delete를 호출하는 물리적 삭제는 비-MVCC 테이블이나 포워딩된 본체를 최종적으로 해제할 때에만 발생한다. 이 챕터는 세 가지 질문에 답한다: DELETE가 독자를 위해 레코드를 어떻게 제자리에 남기는가, 어느 지점에서 UPDATE와 갈라지는가, 그리고 슬롯이 실제로 제거되는 경우는 언제인가? (찍힌 mvcc_del_id를 다시 읽는 경로는 Chapter 5에서 다룬다.)

7.1 진입점: heap_create_delete_context와 삭제 디스패처

섹션 제목: “7.1 진입점: heap_create_delete_context와 삭제 디스패처”

DELETEheap_create_delete_contextHEAP_OPERATION_CONTEXT를 구성하여 진입한다. 업데이트 컨텍스트 빌더(Chapter 6)와 달리 의도적으로 단출하다 — recdes_p가 없다: 새 레코드는 이미 페이지에 있는 레코드로부터 삭제 도중에 합성되므로, 호출자는 OID만 넘기면 된다.

// heap_create_delete_context -- src/storage/heap_file.c
heap_clear_operation_context (context, hfid_p);
COPY_OID (&context->oid, oid_p); COPY_OID (&context->class_oid, class_oid_p);
context->scan_cache_p = scancache_p;
context->type = HEAP_OPERATION_DELETE; /* <- no recdes_p */

heap_delete_logical이 Figure 7-1에 나타난 분기를 주도한다: 입력 유효성 검사(heap_is_valid_oid / heap_scancache_check_with_hfid, 페이지를 건드리기 전에 ER_FAILED로 실패); 파일 타입 검사(FILE_HEAP/FILE_HEAP_REUSE_SLOTS가 아니면 치명적 오류); 루트 클래스 처리(heap_mark_class_as_modified); MVCC 결정(아래 불변식 참조); X_LOCK 하에서 heap_get_record_location + spage_get_record_type으로 위치 결정(REC_UNKNOWN 슬롯은 ER_HEAP_UNKNOWN_OBJECT 발생); COPY 스냅샷으로 context->home_recdes 채우기; 마지막으로 record_type에 따른 분기:

// heap_delete_logical -- src/storage/heap_file.c
#if defined (SERVER_MODE)
if (mvcc_is_mvcc_disabled_class (&context->class_oid))
is_mvcc_op = false; /* <- catalog/system class */
else
is_mvcc_op = true; /* <- ordinary user table */
#else
is_mvcc_op = false; /* <- standalone (SA) mode */
#endif
// ... condensed ...
switch (context->record_type)
{
case REC_BIGONE: rc = heap_delete_bigone (thread_p, context, is_mvcc_op); break;
case REC_RELOCATION: rc = heap_delete_relocation (thread_p, context, is_mvcc_op); break;
case REC_HOME:
case REC_ASSIGN_ADDRESS: rc = heap_delete_home (thread_p, context, is_mvcc_op); break;
default: /* REC_NEWHOME, REC_MARKDELETED, ... reached directly => bug */
er_set (..., ER_HEAP_BAD_OBJECT_TYPE, ...); rc = ER_FAILED; goto error;
}
flowchart TD
  A["heap_delete_logical"] --> B{"유효한 oid &\nFILE_HEAP?"}
  B -- no --> ERR["ER_FAILED / 치명적 오류"]
  B -- yes --> D{"mvcc 비활성화 클래스\n또는 SA 모드?"}
  D -- yes --> E["is_mvcc_op=false"]
  D -- no --> F["is_mvcc_op=true"]
  E --> H{"record_type"}
  F --> H
  H -- REC_HOME / REC_ASSIGN_ADDRESS --> I["heap_delete_home"]
  H -- REC_RELOCATION --> J["heap_delete_relocation"]
  H -- REC_BIGONE --> K["heap_delete_bigone"]
  H -- other --> L["ER_HEAP_BAD_OBJECT_TYPE"]

Figure 7-1 — heap_delete_logical 분기 맵 (rootclass + X_LOCK 위치 결정은 생략). 업데이트와 삭제는 동일한 세 개의 워커를 공유한다.

불변식 7-A — MVCC/물리적 삭제 결정은 클래스로부터 한 번만 이루어지며, 변경 없이 아래로 전달된다. is_mvcc_op클래스 OID를 !mvcc_is_mvcc_disabled_class에 적용하여 한 번만 계산되고(standalone은 false) 모든 워커에 그대로 전달된다. 이 값이 잘못되면 사용자 행이 물리적으로 삭제되어 여전히 보여야 할 스냅샷에서 사라진다.

7.2 heap_delete_home — REC_HOME / REC_ASSIGN_ADDRESS 경로

섹션 제목: “7.2 heap_delete_home — REC_HOME / REC_ASSIGN_ADDRESS 경로”

heap_delete_home은 업데이트 워커 heap_update_home(Chapter 6)의 골격을 공유한다 — 플래그 읽기, 빠른/느린 경로를 통한 빌드, 분류, 크기 초과 시 재배치 — 하지만 다음 지점에서 갈라진다:

항목UPDATE 워커 (Ch 6)DELETE 워커 (이 챕터)
새 페이로드호출자의 context->recdes_p기존 레코드로부터 합성됨
헤더 스탬프repid/데이터, prev_version_lsa 재기록mvcc_del_id 설정; prev_version_lsa 미기록
크기 변화임의적 (많이 줄거나 늘 수 있음)DELID가 없었을 때 최대 OR_MVCCID_SIZE(8바이트) 증가
본체 바이트교체됨그대로 복사
포워더/오버플로생성되거나 해제될 수 있음반드시 필요한 경우에만 접근 (REC_BIGONE: 제자리 편집)

불변식 7-B — MVCC 삭제는 prev_version_lsa를 기록하지 않는다. 삭제는 새 버전을 만들지 않으므로 heap_delete_adjust_headerOR_MVCC_FLAG_VALID_DELID와 DELID만 설정한다. 빠른 경로는 delid_offset 이후 바이트를 복사하여 기존 prev-version 바이트를 보존한다. 이를 어기면 이후 버전에서 체인을 따라가는 독자가 죽은 레코드로 되돌아가는 루프에 빠진다.

페이지가 언픽스된 경우 레코드를 다시 가져온 후(vacuum이 축소시켰을 수 있는 경우를 대비한 가드), heap_delete_homeis_mvcc_op로 분기한다. MVCC 분기use_optimization으로 제어되는 두 경로 중 하나로 삭제 스탬프가 찍힌 레코드를 만든다:

// heap_delete_home -- src/storage/heap_file.c
repid_and_flag_bits = OR_GET_MVCC_REPID_AND_FLAG (context->home_recdes.data);
mvcc_flags = (repid_and_flag_bits >> OR_MVCC_FLAG_SHIFT_BITS) & OR_MVCC_FLAG_MASK;
adjusted_size = context->home_recdes.length;
use_optimization = true;
if (!(mvcc_flags & OR_MVCC_FLAG_VALID_DELID))
{
adjusted_size += OR_MVCCID_SIZE; /* <- 8 more bytes for DELID */
is_adjusted_size_big = heap_is_big_length (adjusted_size);
if (is_adjusted_size_big) use_optimization = false; /* rare: spills to overflow */
}
else
{ /* DELID already set: re-delete of vacuum-pending row */
is_adjusted_size_big = false;
use_optimization = false;
}

빠른 경로 (use_optimization; DELID 없음, 결과 크기 작음)는 순수한 바이트 수술이다 — [0, delid_offset) 구간을 복사하고, 선두 워드에 OR_MVCC_FLAG_VALID_DELID를 OR 연산으로 설정하고, 8바이트 MVCCID를 끼워 넣고, 나머지를 복사한다 — 헤더 파싱 없음. 느린 경로 (DELID 이미 있거나 레코드가 big 크기가 될 경우)는 or_mvcc_get_header, heap_delete_adjust_header, or_mvcc_add_header로 재직렬화한 뒤 memcpy로 본체를 붙인다.

// heap_delete_adjust_header -- src/storage/heap_file.c
MVCC_SET_FLAG_BITS (header_p, OR_MVCC_FLAG_VALID_DELID);
MVCC_SET_DELID (header_p, mvcc_id); /* <- the death stamp, nothing else */
if (need_mvcc_header_max_size)
HEAP_MVCC_SET_HEADER_MAXIMUM_SIZE (header_p); /* <- only when spilling to REC_BIGONE */

만들어진 레코드는 분류된다is_adjusted_size_big이면 REC_BIGONE, !spage_is_updatable(... built_recdes.length)이면 REC_NEWHOME, 그 외에는 REC_HOME — 그리고 처리된다(Figure 7-2). REC_HOME(일반적인 경우)은 재배치 없이 heap_mvcc_log_delete(..., RVHF_MVCC_DELETE_REC_HOME)를 로깅한 뒤 heap_update_physical로 슬롯을 제자리에서 덮어쓴다. OID와 본체는 변하지 않는다. REC_NEWHOME(슬롯에 더 이상 맞지 않는 경우)은 heap_insert_newhome을 호출하고, heap_build_forwarding_recdesREC_RELOCATION 포워더를 만들고, heap_mvcc_log_home_change_on_delete를 로깅한 뒤 heap_update_physical로 홈을 기록한다. REC_BIGONE(big-length 임계값을 넘는 경우)은 오버플로에 heap_ovf_insert를 사용한다는 점만 다르고 동일하다.

비-MVCC 분기 (is_mvcc_op == false): 헤더 조작 없이 is_reusable = heap_is_reusable_oid(context->file_type)을 구한 뒤 heap_log_delete_physical(..., is_reusable, ...) + heap_delete_physical을 호출한다. 슬롯 재사용 여부는 플래그가 결정하며, 페이지가 아니다(§7.5).

flowchart TD
  A["heap_delete_home"] --> B{"is_mvcc_op?"}
  B -- no --> P["로그 + heap_delete_physical"]
  B -- yes --> C{"DELID 존재\n또는 크기 big?"}
  C -- no, small --> D["빠른 경로: DELID 플래그 + MVCCID 설정"]
  C -- yes/rare --> E["느린 경로: get/adjust/add header"]
  D --> F{"분류"}
  E --> F
  F -- REC_HOME --> G["RVHF_MVCC_DELETE_REC_HOME\n제자리 업데이트"]
  F -- REC_NEWHOME --> H["heap_insert_newhome\n홈에 REC_RELOCATION 기록"]
  F -- REC_BIGONE --> I["heap_ovf_insert\n홈에 REC_BIGONE 기록"]

Figure 7-2 — heap_delete_home. MVCC 분기에서 8바이트 DELID가 슬롯을 초과하면 홈 레코드가 relocated/big으로 승격될 수 있다.

7.3 heap_delete_relocation — 이미 포워딩된 경로

섹션 제목: “7.3 heap_delete_relocation — 이미 포워딩된 경로”

홈 슬롯이 REC_RELOCATION 포워더인 경우, 본체는 포워드 페이지의 REC_NEWHOME 슬롯에 있다. heap_delete_relocation은 포워드 페이지를 픽스하고(heap_fix_forward_page), 포워드 레코드를 peek하고, (MVCC 분기에서) §7.2의 빠른/느린 경로로 삭제 스탬프가 찍힌 레코드를 만든다. 세 개의 불리언이 어디에 저장될지를 결정한다:

분기remove_old_forwardupdate_old_forwardupdate_old_home새 홈 레코드
is_adjusted_size_bigyesnoyesREC_BIGONE 포워더
fits_in_homeyesnoyesREC_HOME (본체가 홈으로 접힘)
fits_in_forwardnoyesno변경 없음 (포워드 제자리 업데이트)
elseyesnoyes새 NEWHOME으로 REC_RELOCATION

후처리: update_old_home은 홈 슬롯을 재기록한다(heap_mvcc_log_home_change_on_delete + heap_update_physical); else/fits_in_forward 경우는 vacuum 상태를 진행시키기 위해 heap_mvcc_log_home_no_change를 로깅한다; update_old_forward는 포워드 슬롯을 제자리에서 재기록한다(heap_mvcc_log_delete(..., RVHF_MVCC_DELETE_REC_NEWHOME)); remove_old_forward는 기존 포워드 슬롯을 해제하고(log_append_undoredo_recdes(RVHF_DELETE, ...) + heap_delete_physical), 재배치된 본체는 인덱스에서 참조되지 않으므로 재사용 가능한 OID 힙에는 RVHF_MARK_REUSABLE_SLOT postpone을 추가한다.

비-MVCC 분기: 두 슬롯을 모두 물리적으로 삭제한다(홈 먼저, 그 다음 포워드, 각각 heap_log_delete_physical + heap_delete_physical). 포워드 슬롯은 힙 타입에 관계없이 항상 mark_reusable = true로 로깅된다 — 재배치된 레코드는 인덱스에서 직접 참조되지 않기 때문이다.

불변식 7-C — 홈 슬롯을 재기록하지 않는 모든 MVCC 삭제에서 heap_mvcc_log_home_no_change가 실행된다. Vacuum은 페이지의 vacuum 체인(Chapter 8)을 최대 MVCCID를 기준으로 순회한다. 포워드/오버플로 페이지만 건드리는 삭제는 홈 체인을 갱신하지 않아 vacuum이 다시 방문하지 않게 된다. 이 호출은 heap_page_update_chain_after_mvcc_op로 체인을 업데이트하고, 상태 전환이 발생하면 로그 오프셋에 HEAP_RV_FLAG_VACUUM_STATUS_CHANGE를 OR 연산으로 설정하여 복구 시 재구성이 가능하게 한다.

7.4 heap_delete_bigone — 고정 크기 오버플로 헤더 편집

섹션 제목: “7.4 heap_delete_bigone — 고정 크기 오버플로 헤더 편집”

REC_BIGONE 홈 슬롯에는 오버플로 OID만 있고, 실제 레코드(전체 크기 MVCC 헤더 포함)는 오버플로 페이지에 있다. 본체를 이동하는 대신, MVCC 분기는 오버플로 페이지의 헤더를 제자리에서 편집한다:

// heap_delete_bigone -- src/storage/heap_file.c
overflow_oid = *((OID *) context->home_recdes.data); /* home holds only the OID */
// ... fix overflow page WRITE, check PAGE_OVERFLOW ptype ...
heap_get_mvcc_rec_header_from_overflow (... &overflow_header ...);
heap_mvcc_log_delete (thread_p, &log_addr, RVHF_MVCC_DELETE_OVERFLOW);
heap_delete_adjust_header (&overflow_header, mvcc_id, false); /* <- false: already max size */
rc = heap_set_mvcc_rec_header_on_overflow (context->overflow_page_watcher_p->pgptr, &overflow_header);

heap_set_mvcc_rec_header_on_overflow는 전용 변경자(mutator)다. 오버플로 헤더는 항상 최대 크기로 저장되어 있으므로(모든 플래그 존재, 슬롯 미리 예약), DELID를 쓸 때 페이로드 바이트를 하나도 이동시키지 않는다:

// heap_set_mvcc_rec_header_on_overflow -- src/storage/heap_file.c
ovf_recdes.data = overflow_get_first_page_data (ovf_page);
ovf_recdes.area_size = ovf_recdes.length = OR_HEADER_SIZE (ovf_recdes.data);
// force INSID slot present (MVCCID_ALL_VISIBLE) if absent ...
if (!MVCC_IS_FLAG_SET (mvcc_header, OR_MVCC_FLAG_VALID_DELID)) { /* force DELID slot present */
MVCC_SET_FLAG_BITS (mvcc_header, OR_MVCC_FLAG_VALID_DELID);
MVCC_SET_DELID (mvcc_header, MVCCID_NULL); }
return or_mvcc_set_header (&ovf_recdes, mvcc_header); /* <- overwrites only the header region */

오버플로에 스탬프를 찍은 후 홈 슬롯은 재기록되지 않지만, heap_mvcc_log_home_no_change는 여전히 홈 슬롯을 로깅하여 홈 페이지의 vacuum 체인이 진행된다(불변식 7-C). 포워더는 여전히 오버플로 레코드를 가리키며, 비-MVCC 분기에서만 해제된다.

비-MVCC 분기: 두 곳 모두 해제한다 — heap_log_delete_physical + heap_delete_physical로 홈 포워더 슬롯을 제거하고, heap_ovf_delete로 오버플로 체인을 해제한다(이를 호출하는 유일한 삭제 경로).

불변식 7-D — 오버플로 헤더는 OR_MVCC_MAX_HEADER_SIZE로 고정되어 있으므로 REC_BIGONE 삭제는 재배치가 발생하지 않는다. 컴팩트한 홈/재배치 헤더는 8바이트 DELID가 추가될 때 슬롯/big-length 경계를 넘을 수 있다(§7.2, §7.3). 반면 오버플로 헤더는 항상 모든 슬롯을 포함하므로(변경자가 INSID와 DELID를 강제로 존재하게 함) DELID는 미리 예약된 공간에 쓰인다 — 따라서 need_mvcc_header_max_sizefalse다. 컴팩트하게 저장된 경우 제자리 스탬프는 본체를 덮어쓰게 된다.

7.5 heap_delete_physical, spage_delete, 그리고 OID 재사용 결정 방식

섹션 제목: “7.5 heap_delete_physical, spage_delete, 그리고 OID 재사용 결정 방식”

모든 물리적 삭제는 heap_delete_physical을 통한다. 이는 얇은 래퍼로서 spage_get_free_space_without_saving으로 스냅샷을 찍고, spage_delete를 호출하고(NULL_SLOTID이면 ER_FAILED), heap_stats_update(최적 공간 캐시, Ch 9)와 pgbuf_set_dirty를 처리한다. spage_delete는 페이지 헤더의 anchor_type에 따라 툼스톤 형태를 결정한다:

// spage_delete -- src/storage/slotted_page.c
switch (page_header_p->anchor_type) /* ANCHORED arms set offset_to_record = SPAGE_EMPTY_OFFSET */
{
case ANCHORED: /* non-heap callers only */
slot_p->record_type = REC_DELETED_WILL_REUSE; break;
case ANCHORED_DONT_REUSE_SLOTS: /* <- ALWAYS taken by heap deletes */
slot_p->record_type = REC_MARKDELETED; break;
// UNANCHORED_* (not used by heap files) remove the slot entry; default asserts
}

anchor 타입만으로는 힙을 구별할 수 없다: 모든 힙 페이지는 ANCHORED_DONT_REUSE_SLOTS. heap_get_spage_type이 (FILE_HEAPFILE_HEAP_REUSE_SLOTS 양쪽 모두) 무조건 해당 타입을 반환하고, 모든 힙의 spage_initialize가 이 타입을 전달한다. 따라서 힙 삭제는 항상 해당 분기로 가서 REC_MARKDELETED를 찍는다 — 본체 바이트는 회수되지만 SPAGE_SLOT 엔트리는 유지된다. ANCHORED 분기는 비-힙 전용이다.

OID 재사용 여부는 호출자가 결정한다: FILE_HEAP_REUSE_SLOTS 테이블의 경우 heap_is_reusable_oid(file_type)true이므로, 워커는 물리적 삭제 후 RVHF_MARK_REUSABLE_SLOT postpone을 추가하고(heap_log_delete_physical에서 mark_reusable일 때, 그리고 재배치 포워드 슬롯 경로에서), redo 핸들러 heap_rv_redo_mark_reusable_slot이 커밋 시와 복구 재생 시에 업그레이더를 실행한다:

// spage_mark_deleted_slot_as_reusable -- src/storage/slotted_page.c
// ... asserts slot is empty and REC_MARKDELETED/REC_DELETED_WILL_REUSE ...
slot_p->record_type = REC_DELETED_WILL_REUSE; /* <- REC_MARKDELETED -> reusable */

불변식 7-E — 재사용 가능성은 페이지 anchor 타입이 아닌 호출자(heap_is_reusable_oid + RVHF_MARK_REUSABLE_SLOT postpone)가 결정한다. 모든 힙 페이지는 ANCHORED_DONT_REUSE_SLOTS이므로 spage_delete는 항상 REC_MARKDELETED로 툼스톤을 찍는다. FILE_HEAP_REUSE_SLOTS 삭제만(heap_is_reusable_oid로 게이팅) postpone을 추가하며, 그 핸들러가 spage_mark_deleted_slot_as_reusable을 실행해 REC_MARKDELETED -> REC_DELETED_WILL_REUSE로 업그레이드한다. 재사용 불가능한 힙이 이 postpone을 추가하면 그 OID가 낡은 인덱스 키 아래에서 재활용된다. 방증: spage_delete_for_recoveryANCHORED_DONT_REUSE_SLOTS만 특수 처리한다(인서트를 undo하는 동안 새 REC_MARKDELETEDREC_DELETED_WILL_REUSE로 다운그레이드 — 커밋된 적 없는 레코드는 외부 OID 참조가 없다) — 힙 페이지가 해당 타입이 아니라면 무의미한 처리다.

  1. DELETE는 업데이트 워커를 재사용한다heap_delete_logicalheap_delete_home/heap_delete_relocation/heap_delete_bigone으로 분기하는데, 이는 Chapter 6의 업데이트와 정확히 동일하다. 차이는 워커 내부에 있으며, 구조에 있지 않다.
  2. MVCC 삭제 = 제거가 아닌 스탬프 — 워커는 heap_delete_adjust_headerOR_MVCC_FLAG_VALID_DELID와 MVCCID를 설정하고 제자리에 기록한다. 본체는 살아남고 prev_version_lsa는 기록되지 않는다(불변식 7-B). 8바이트 스탬프가 컴팩트한 REC_HOMEREC_NEWHOME이나 REC_BIGONE으로 밀어낼 수 있으므로, 워커는 빌드 후 재분류한다.
  3. REC_BIGONE은 저렴한 경우다 — 오버플로 헤더는 OR_MVCC_MAX_HEADER_SIZE로 저장되므로 heap_set_mvcc_rec_header_on_overflow가 본체를 이동하지 않고 미리 예약된 공간에 DELID를 찍는다(불변식 7-D).
  4. Vacuum은 항상 통보된다 — 홈 슬롯이 변경되지 않는 경우에도 heap_mvcc_log_home_no_change가 vacuum 체인을 진행시킨다(불변식 7-C). 물리적 삭제는 비-MVCC 경로(카탈로그, standalone)와 포워딩/오버플로 본체를 해제할 때에만 발생한다.
  5. 페이지 anchor 타입은 OID 재사용을 결정하지 않는다 (불변식 7-E) — 모든 힙 페이지는 ANCHORED_DONT_REUSE_SLOTS(heap_get_spage_type)이므로 spage_delete는 항상 REC_MARKDELETED를 찍는다. FILE_HEAP_REUSE_SLOTS 삭제(heap_is_reusable_oid로 게이팅)만 RVHF_MARK_REUSABLE_SLOT postpone을 추가하며, 그 핸들러가 spage_mark_deleted_slot_as_reusable을 실행해 REC_MARKDELETED -> REC_DELETED_WILL_REUSE로 업그레이드한다.

Chapter 8: Vacuum 회수와 페이지 Vacuum 상태

섹션 제목: “Chapter 8: Vacuum 회수와 페이지 Vacuum 상태”

6장과 7장은 죽은 튜플을 페이지에 그대로 남겨뒀다. 삭제된 레코드의 mvcc_del_id, prev_version_lsa 체인 끝의 superseded 버전이 그것이다. 당시에는 더 오래된 스냅샷이 이전 버전을 여전히 필요로 할 수 있었기 때문에 즉각 해제하지 않았다. 그 지연된 회수자가 vacuum이다. 이 챕터는 “누가 물리적으로 slot을 회수하는가, 그리고 페이지는 어떻게 해제해도 안전하다고 판단하는가?”라는 물음에 답한다. MVCC 가시성 이론과 active/vacuum 분리에 대해서는 cubrid-heap-manager.md의 “Vacuum and the dead-version problem” 절을 참고하라. 여기서는 코드 흐름을 추적한다.

8.1 임계값 계약과 페이지 순회 드라이버

섹션 제목: “8.1 임계값 계약과 페이지 순회 드라이버”

Vacuum은 개별 스냅샷을 직접 따지지 않는다. 전달받는 것은 단 하나, threshold_mvccid — 아직 활성 트랜잭션에 가시적일 수 있는 가장 오래된 MVCCID다. 이 임계값보다 앞선 mvcc_del_id(삭제) 또는 insert MVCCID(superseded 버전)를 가진 버전은 누구에게도 보이지 않으므로 파기할 수 있다. heap_vacuum_all_objects는 대량 진입점(compactdb, 클래스 purge)으로, 파일을 페이지 단위로 순회한다.

// heap_vacuum_all_objects -- src/storage/heap_file.c
next_vpid.pageid = upd_scancache->node.hfid.hpgid; /* <- start at header page */
reusable = heap_is_reusable_oid (upd_scancache->file_type); /* <- slot reuse policy */
while (!VPID_ISNULL (&next_vpid)) {
vpid = next_vpid;
error_code = pgbuf_ordered_fix (thread_p, &vpid, OLD_PAGE, PGBUF_LATCH_WRITE, &pg_watcher);
if (error_code != NO_ERROR) { goto exit; } /* <- fix failed */
// ... unfix old_pg_watcher (previous page in the rolling double-watcher) ...
error_code = heap_vpid_next (thread_p, &upd_scancache->node.hfid, pg_watcher.pgptr, &next_vpid);
if (error_code != NO_ERROR) { assert (false); goto exit; } /* <- corrupt next_vpid chain */
worker.n_heap_objects = spage_number_of_slots (pg_watcher.pgptr) - 1; /* <- minus header slot */
if (worker.n_heap_objects > 0
&& heap_page_get_vacuum_status (thread_p, pg_watcher.pgptr) != HEAP_PAGE_VACUUM_NONE) {
// ... fill worker.heap_objects[i].oid.slotid = 1..n; skip already-clean pages ...
error_code = vacuum_heap_page (thread_p, worker.heap_objects, worker.n_heap_objects,
threshold_mvccid, &upd_scancache->node.hfid, &reusable, false);
if (error_code != NO_ERROR) { goto exit; }
}
pgbuf_replace_watcher (thread_p, &pg_watcher, &old_pg_watcher);
}
exit: // ... unfix both watchers, free worker.heap_objects ...

오류 분기는 인라인으로 주석 처리했다(모든 분기는 Figure 8-1에서 확인 가능). 설계의 핵심은 HEAP_PAGE_VACUUM_NONE 건너뜀 분기다 — vacuum이 필요 없는 페이지는 스캔 자체를 하지 않는다. 나머지 경우에는 드라이버가 OID 배열 [1..n]을 구성한 뒤 vacuum_heap_page(query/vacuum.c)에 위임한다. spage_vacuum_slot, heap_page_set_vacuum_status_none, heap_remove_page_on_vacuum을 유일하게 호출하는 곳이 바로 이 함수다: heap_vacuum_all_objects는 후보 페이지를 선택하고, vacuum_heap_page는 후보 slot을 선택한다.

불변 조건 — vacuum은 반드시 죽었다고 증명된 버전만 해제한다. 하나의 slot이 spage_vacuum_slot에 도달하려면 vacuum_heap_recordvacuum_is_mvccid_vacuumed로 해당 MVCCID가 threshold_mvccid보다 앞선다는 것을 먼저 확인해야 한다. != HEAP_PAGE_VACUUM_NONE 조건은 거친 사전 필터이며, 레코드 단위 검사가 실제 방어선이다. 이를 위반하면 활성 스냅샷이 해제된 slot을 가리키는 상황이 발생한다.

flowchart TD
  A["heap_vacuum_all_objects\nnext_vpid = 헤더 페이지"] --> B{"VPID_ISNULL(next_vpid)?"}
  B -- yes --> Z["unfix, free, return"]
  B -- no --> C["pgbuf_ordered_fix(WRITE)"]
  C --> D{"fix 성공?"}
  D -- no --> Z
  D -- yes --> E["heap_vpid_next -> next_vpid"]
  E --> F{"n_objects>0 AND status != NONE?"}
  F -- no --> G["watcher 교체, 루프"]
  F -- yes --> H["OID[1..n] 구성, vacuum_heap_page"]
  H --> I{"오류?"}
  I -- yes --> Z
  I -- no --> G
  G --> B

Figure 8-1. heap_vacuum_all_objects 페이지 순회, 모든 분기.

8.2 spage_vacuum_slot: 살아있는 slot을 회수 가능한 공간으로 전환

섹션 제목: “8.2 spage_vacuum_slot: 살아있는 slot을 회수 가능한 공간으로 전환”

vacuum_heap_record는 죽은 버전 하나당 spage_vacuum_slot을 한 번씩 호출한다. 해제된 바이트를 total_free에 반환하지만 compact는 수행하지 않는다.

// spage_vacuum_slot -- src/storage/slotted_page.c
SPAGE_SLOT *slot_p = spage_find_slot (page_p, page_header_p, slotid, false);
if (slot_p->record_type == REC_MARKDELETED || slot_p->record_type == REC_DELETED_WILL_REUSE)
vacuum_er_log_error (..., "... was already vacuumed", ...); /* <- double-vacuum: log, continue */
page_header_p->num_records--; /* <- one fewer live record */
waste = DB_WASTED_ALIGN (slot_p->record_length, page_header_p->alignment);
page_header_p->total_free += slot_p->record_length + waste; /* <- bytes returned to the page */
slot_p->offset_to_record = SPAGE_EMPTY_OFFSET; /* <- slot now points at nothing */
if (reusable)
slot_p->record_type = REC_DELETED_WILL_REUSE;/* <- no refs: OID can be recycled */
else
slot_p->record_type = REC_MARKDELETED; /* <- refs may exist: keep slot id reserved */

reusable == false이면 slot은 REC_MARKDELETED가 되고 slot id는 예약된 채로 유지된다. 외부 참조(이전 버전의 prev_version_lsa 체인, 또는 relocation)가 재활용된 레코드가 아닌 tombstone으로 여전히 resolve되어야 하기 때문이다. 이미 vacuum된 분기는 중단-후-재실행 시나리오(8.4절)를 방어적으로 허용한다. 이 함수가 남기는 것: offset_to_recordSPAGE_EMPTY_OFFSET이지만 slot 배열 항목은 제거되지 않고 레코드 바이트도 여전히 존재한다 — total_free만 증가시켰을 뿐이다. slot 배열을 줄이는 것은 spage_reclaim(8.5절)의 역할이며, 데이터 영역의 빈 공간을 회수하는 것은 spage_compact(9장)의 역할이다.

Vacuum 워커가 여전히 방문할 수 있는 힙 페이지는 해제하면 안 된다. CUBRID는 HEAP_CHAIN.flags의 상위 2비트에 세 가지 상태 플래그를 두어 이를 예측한다 (chain 구조체 전체는 1장에서 표로 정리했다).

// HEAP_PAGE_VACUUM_STATUS enum -- src/storage/heap_file.h
HEAP_PAGE_VACUUM_NONE, /* Heap page is completely vacuumed. */
HEAP_PAGE_VACUUM_ONCE, /* Heap page requires one vacuum action. */
HEAP_PAGE_VACUUM_UNKNOWN /* Heap page requires an unknown number of vacuum actions. */
// flag bits -- src/storage/heap_file.c
#define HEAP_PAGE_FLAG_VACUUM_STATUS_MASK 0xC0000000
#define HEAP_PAGE_FLAG_VACUUM_ONCE 0x80000000
#define HEAP_PAGE_FLAG_VACUUM_UNKNOWN 0x40000000

HEAP_PAGE_GET_VACUUM_STATUS는 해당 비트들을 디코딩한다(둘 다 0이면 NONE); HEAP_PAGE_SET_VACUUM_STATUS는 마스크를 지운 뒤 해당 비트를 OR한다. flags의 하위 30비트는 다른 속성을 담는다. 상태 머신을 구동하는 HEAP_CHAIN 필드는 두 가지다:

필드역할존재 이유
flags (상위 2비트)현재 vacuum 상태 (NONE/ONCE/UNKNOWN)레코드를 스캔하지 않고도 남은 vacuum 방문 횟수를 알림
max_mvccid페이지에 적용된 MVCC 연산의 최대 MVCCIDUNKNOWN에서 벗어나는 유일한 탈출구: vacuum 기준선보다 오래됐으면 ⇒ 모든 owed vacuum 완료

max_mvccid의 의미는 상태마다 다르다: NONE에서는 낡은 값(다음 연산이 새 id보다 앞선다고 assert한 뒤 갱신); ONCE에서는 owed vacuum이 <= max_mvccid 버전을 대상으로 함; UNKNOWN에서는 탈출 조건(vacuum_is_mvccid_vacuumed(max_mvccid)가 true이면 owed vacuum 전부 완료). MVCC 연산이 상태를 앞으로 밀고, vacuum이 뒤로 당긴다:

// heap_page_update_chain_after_mvcc_op -- src/storage/heap_file.c
switch (vacuum_status) {
case HEAP_PAGE_VACUUM_NONE:
assert (MVCC_ID_PRECEDES (chain->max_mvccid, mvccid));
HEAP_PAGE_SET_VACUUM_STATUS (chain, HEAP_PAGE_VACUUM_ONCE); break; /* <- first op since clean */
case HEAP_PAGE_VACUUM_ONCE:
HEAP_PAGE_SET_VACUUM_STATUS (chain, HEAP_PAGE_VACUUM_UNKNOWN); break; /* <- future unpredictable */
case HEAP_PAGE_VACUUM_UNKNOWN:
if (vacuum_is_mvccid_vacuumed (chain->max_mvccid)) /* <- all prior owed vacuum ran */
HEAP_PAGE_SET_VACUUM_STATUS (chain, HEAP_PAGE_VACUUM_ONCE);
break; /* <- else: stays UNKNOWN */
}
if (MVCC_ID_PRECEDES (chain->max_mvccid, mvccid)) chain->max_mvccid = mvccid; /* <- track the max */
// heap_page_set_vacuum_status_none -- src/storage/heap_file.c
assert (HEAP_PAGE_GET_VACUUM_STATUS (chain) == HEAP_PAGE_VACUUM_ONCE); /* <- only ONCE -> NONE */
HEAP_PAGE_SET_VACUUM_STATUS (chain, HEAP_PAGE_VACUUM_NONE);
stateDiagram-v2
  [*] --> NONE
  NONE --> ONCE: mvcc 연산 \n max_mvccid가 새 id보다 앞선다고 assert
  ONCE --> UNKNOWN: 두 번째 mvcc 연산 \n 이후 예측 불가
  ONCE --> NONE: vacuum 방문 \n set_vacuum_status_none
  UNKNOWN --> ONCE: mvcc 연산 AND max_mvccid 이미 vacuumed
  UNKNOWN --> UNKNOWN: mvcc 연산 AND max_mvccid 아직 미완료
  NONE --> [*]: 페이지 해제 가능

Figure 8-2. HEAP_PAGE_VACUUM_STATUS 전이. 해제로 가는 유일한 탈출 엣지는 NONE에서만 나온다.

UNKNOWNNONE으로 직접 붕괴하지 않는다: 두 MVCC 연산이 vacuum 사이에 누적되면 CUBRID는 owed 방문 횟수 계산을 포기한다. 오직 새로운 연산이 max_mvccid가 vacuum 기준선을 넘겼다고 관측할 때만 ONCE로 내려오며, ONCE에서 vacuum 방문 한 번으로 NONE으로 돌아온다.

불변 조건 — 페이지는 NONE 상태에서만 해제할 수 있다. Vacuum은 ONCE에서만 페이지를 NONE으로 만들며, UNKNOWN에서 직접 NONE으로 전환하지 않는다. heap_page_set_vacuum_status_none은 이전 상태가 ONCE임을 assert한다. 따라서 NONE은 남은 vacuum 방문이 없다는 의미 — 이것이 heap_remove_page_on_vacuum이 의존하는 전제 조건이다. 다른 상태에서 NONE에 도달하면 미래의 워커가 여전히 접근할 slot이 해제되어 use-after-free가 발생한다.

8.4 상태와 회수의 연결: vacuum_heap_page 결정 로직

섹션 제목: “8.4 상태와 회수의 연결: vacuum_heap_page 결정 로직”

vacuum_heap_page는 레코드 단위 작업을 마친 상태를 읽어, NONE으로 전환할지 그리고 페이지 제거를 시도할지 결정한다:

// vacuum_heap_page (decision tail) -- src/query/vacuum.c
page_vacuum_status = heap_page_get_vacuum_status (thread_p, helper.home_page);
assert (page_vacuum_status != HEAP_PAGE_VACUUM_NONE || (was_interrupted && helper.n_vacuumed == 0));
if ((page_vacuum_status == HEAP_PAGE_VACUUM_ONCE && !was_interrupted)
|| (page_vacuum_status == HEAP_PAGE_VACUUM_NONE && was_interrupted))
{
if (page_vacuum_status == HEAP_PAGE_VACUUM_ONCE)
heap_page_set_vacuum_status_none (thread_p, helper.home_page); /* <- ONCE -> NONE, logged */
// ... pgbuf_set_dirty (home_page) ...
if (spage_number_of_records (helper.home_page) <= 1 && helper.reusable)
{ /* <- only header slot, reusable heap */
if (pgbuf_has_prevent_dealloc (helper.home_page) == false
&& heap_remove_page_on_vacuum (thread_p, &helper.home_page, &helper.hfid))
{ /* page gone */ goto end; }
}
}

두 분기가 조건에 맞는다: ONCE && !was_interrupted(정상 경로 — NONE으로 전환 후 제거 시도)와 NONE && was_interrupted(크래시 이전 이미 vacuum이 완료된 재실행 태스크 — 이미 NONE이므로 재전환 없음). ONCE && was_interrupted의도적으로 제외된다: 재실행된 워커는 해당 ONCE가 자신의 태스크인지 크래시 이후에 기록된 새로운 삭제인지 판별할 수 없으므로, 전환하면 owed vacuum이 사라질 수 있다. UNKNOWN은 절대 매칭되지 않는다. 제거는 헤더 slot만 남은 페이지, reusable 힙, 스캐너가 pin하지 않은 경우에만 시도된다.

8.5 spage_reclaim: don’t-reuse 페이지의 slot 배열 압축

섹션 제목: “8.5 spage_reclaim: don’t-reuse 페이지의 slot 배열 압축”

spage_vacuum_slot은 죽은 slot 배열 항목을 그대로 남긴다. spage_reclaimANCHORED_DONT_REUSE_SLOTS 페이지의 slot 배열을 줄이는 역할로, steady-state vacuum 루프가 아닌 reclaim-addresses 경로 (xheap_reclaim_addresses, compactdb가 진입)에서 호출된다.

// spage_reclaim -- src/storage/slotted_page.c
if (page_header_p->num_slots > 0) {
first_slot_p = spage_find_slot (page_p, page_header_p, 0, false);
for (slot_id = page_header_p->num_slots - 1; slot_id >= 0; slot_id--) { /* <- backwards */
slot_p = first_slot_p - slot_id;
if (slot_p->offset_to_record == SPAGE_EMPTY_OFFSET
&& (slot_p->record_type == REC_MARKDELETED || slot_p->record_type == REC_DELETED_WILL_REUSE)) {
assert (page_header_p->anchor_type == ANCHORED_DONT_REUSE_SLOTS);
if ((slot_id + 1) == page_header_p->num_slots)
spage_reduce_a_slot (page_p); /* <- trailing dead slot: drop array entry */
else
slot_p->record_type = REC_DELETED_WILL_REUSE; /* <- interior: mark recyclable */
is_reclaim = true;
}
}
}
if (is_reclaim == true) {
if (page_header_p->num_slots == 0)
spage_initialize (thread_p, page_p, ...); /* <- fully empty: reset page to pristine layout */
pgbuf_set_dirty (thread_p, page_p, DONT_FREE);
}
return is_reclaim;

핵심 분기는 역방향 반복이다. 끝에 있는 죽은 slot만 spage_reduce_a_slot (배열 길이를 줄임)으로 제거할 수 있으므로, 마지막 slot부터 0을 향해 거꾸로 순회하면 제거할 때마다 다음 끝 죽은 slot이 드러난다. 내부의 죽은 slot은 뒤에 있는 살아있는 slot을 재번호매기지 않고서는 제거할 수 없기 때문에(OID가 무효화됨) REC_DELETED_WILL_REUSE로 재표시하여 미래의 insert가 재사용하게 한다. 종료 분기: 진입 시 num_slots == 0이면 false 반환; 무언가 회수되어 페이지가 완전히 비었으면 spage_initialize가 초기화; 아무것도 회수되지 않았으면 페이지는 그대로 유지된다.

spage_vacuum_slot과의 비대칭이 핵심 포인트다: vacuum은 spage_vacuum_slot으로 공간을 지속적으로 해제하고 vacuum_log_vacuum_heap_page로 기록한다. 반면 spage_reclaim은 더 무거운 reclaim-addresses 경로에서, don’t-reuse anchor에 대해서만 slot 배열 항목을 회수한다. 이 경로에서는 xheap_reclaim_addresses가 reclaim 로깅을 의도적으로 건너뛴다(log_skip_logging, if (spage_reclaim (...) == true) 블록 내) — 참조되지 않는 죽은 OID를 재사용해도 데이터베이스를 논리적으로 변경하지 않으므로 REDO도 UNDO도 불필요하기 때문이다.

8.6 heap_remove_page_on_vacuum: 빈 페이지 연결 해제

섹션 제목: “8.6 heap_remove_page_on_vacuum: 빈 페이지 연결 해제”

페이지가 NONE 상태에 도달하고 헤더 slot만 남으면, vacuum은 해당 페이지를 파일 관리자에게 반환하려 시도한다. 이 함수는 최소 침습적으로 설계되어 있다: 모든 이웃 페이지는 ordered watcher로 조건부로 fix되며, 실패하면 시도를 포기한다(return false).

// heap_remove_page_on_vacuum -- src/storage/heap_file.c
assert (spage_number_of_records (*page_ptr) <= 1); /* <- precondition: page is empty */
pgbuf_get_vpid (*page_ptr, &page_vpid);
if (page_vpid.pageid == hfid->hpgid && page_vpid.volid == hfid->vfid.volid)
return false; /* <- never remove the header page */
if (pgbuf_ordered_fix (thread_p, &header_vpid, OLD_PAGE, PGBUF_LATCH_WRITE, &header_watcher) != NO_ERROR)
goto error; /* <- give up if header busy */
// ... heap_vpid_prev/next, conditional fix of prev (unless == header) and next ...
if (crt_watcher.page_was_unfixed) {
*page_ptr = crt_watcher.pgptr; /* <- ordered fix may have refixed home */
if (spage_number_of_records (crt_watcher.pgptr) > 1) goto error; /* <- re-filled while we waited */
}
if (pgbuf_has_prevent_dealloc (crt_watcher.pgptr)) goto error; /* <- a scanner reached us */
if (pgbuf_has_any_waiters (crt_watcher.pgptr)) { assert (false); goto error; }
log_sysop_start (thread_p); is_system_op_started = true; /* <- atomic unlink begins */
// ... scrub vpid from estimates.best[]/second_best[]/last_vpid/full_search_vpid,
// splice prev.next_vpid=next and next.prev_vpid=prev (RVHF_STATS / RVHF_CHAIN logs) ...
pgbuf_ordered_unfix_and_init (thread_p, *page_ptr, &crt_watcher);
if (file_dealloc (thread_p, &hfid->vfid, &page_vpid, FILE_HEAP) != NO_ERROR) goto error;
(void) heap_stats_del_bestspace_by_vpid (thread_p, &page_vpid); /* <- evict from cache */
log_sysop_commit (thread_p); is_system_op_started = false;
return true;
error:
if (is_system_op_started) log_sysop_abort (thread_p); /* <- roll back half-done unlink */
// ... unfix any fixed watchers; return false ...

모든 분기는 “안전하게 포기” 경로다(Figure 8-3). 이 함수는 페이지를 절반만 연결 해제하는 일이 없다. 이웃 페이지가 바쁘면 제거를 지연시킬 뿐이고, page_was_unfixed에서 레코드가 1개 초과이면 watcher를 VPID 순서로 획득하는 사이 동시 insert가 페이지를 다시 채운 것이며, has_any_waiters는 불가능한 상황이다(assert(false)). 정상 경로는 헤더의 best-space 추정치에서 페이지를 제거하고, 체인을 이어붙이며(prev.next_vpid = next, next.prev_vpid = prev, 이 페이지가 파일의 첫 번째 데이터 페이지였다면 heap_hdr.next_vpid = next), 하나의 system op 안에서 file_dealloc을 호출한다.

불변 조건 — 체인 splice와 dealloc은 원자적이다. 헤더 stats, prev 체인, next 체인의 spage_updatefile_dealloc은 하나의 log_sysop_start/log_sysop_commit op 안에서 실행된다(RVHF_STATS/ RVHF_CHAIN). 크래시가 splice 도중 발생하면 전부 아니면 전무로 복구된다; 절반만 연결된 체인은 순회를 망가뜨리는데, 이를 sysop가 막는다.

flowchart TD
  A["remove_page_on_vacuum\nassert 레코드 <=1"] --> B{"헤더 페이지?"}
  B -- yes --> R0["return false"]
  B -- no --> C["ordered_fix 헤더"]
  C --> D{"fix 성공?"}
  D -- no --> E["goto error -> false"]
  D -- yes --> F["vpid_prev / vpid_next"]
  F --> G["prev, next 조건부 fix"]
  G --> H{"home 재충전, >1 레코드?"}
  H -- yes --> E
  H -- no --> I{"prevent_dealloc 또는 waiters?"}
  I -- yes --> E
  I -- no --> J["sysop_start"]
  J --> K["bestspace 제거, prev/next splice, file_dealloc"]
  K --> L{"모두 성공?"}
  L -- no --> M["sysop_abort -> false"]
  L -- yes --> N["sysop_commit, bestspace 캐시 삭제, return true"]

Figure 8-3. heap_remove_page_on_vacuum — 모든 경로는 실패 안전(fail-safe)이다.

8.7 Best-space 캐시와 HEAP_DROP_FREE_SPACE 임계값

섹션 제목: “8.7 Best-space 캐시와 HEAP_DROP_FREE_SPACE 임계값”

Best-space 메커니즘의 상세는 9장에서 다룬다. Vacuum은 두 접점에서 이를 건드린다. HEAP_DROP_FREE_SPACE(heap_file.h, (int)(DB_PAGESIZE * 0.3))는 페이지를 insert 대상으로 광고할 만큼 충분한 여유 공간 수준이다; spage_vacuum_slot이 이 임계값을 넘을 만큼 바이트를 반환하면 페이지가 best-space 힌트에 다시 올라올 수 있다. 반대로 heap_remove_page_on_vacuum이 페이지를 해제하면 캐시에서 제거해야 한다(estimates.best[]/second_best[]의 해당 항목을 null로 만들고 heap_stats_del_bestspace_by_vpid를 호출), 미래의 insert가 해제된 VPID를 받지 않도록 한다.

8.8 REC_MVCC_NEXT_VERSION(레거시) 대 prev_version_lsa

섹션 제목: “8.8 REC_MVCC_NEXT_VERSION(레거시) 대 prev_version_lsa”

과거 CUBRID는 레코드의 후속 버전을 가리키는 forward pointer를 힙 내부에 REC_MVCC_NEXT_VERSION slot으로 저장했고, vacuum이 이를 따라가야 했다. 현재 코드는 이를 prev_version_lsa로 대체했다: update가 새 레코드의 MVCC 헤더에 이전 버전의 로그 LSA를 기록한다(heap_update_set_prev_version 및 5-6장 참고). 덕분에 버전 체인이 힙이 아닌 로그에 존재한다. 따라서 vacuum은 이제 후속 버전을 회수하기 위해 forwarding link를 따라갈 필요가 없다 — 죽은 버전의 slot을 spage_vacuum_slot으로 제자리에서 tombstone 처리하여 바이트를 회수하고, 이전 이미지가 필요한 reader는 prev_version_lsa를 따라 로그로 이동한다. 이것이 reusable 힙에서 spage_vacuum_slot이 즉시 REC_DELETED_WILL_REUSE를 선택할 수 있는 이유이기도 하다: 힙 내부에 next-version 링크가 없으므로 해당 slot을 참조하는 것이 없다.

  1. heap_vacuum_all_objects는 페이지를, vacuum_heap_page는 slot을 선택한다 — 드라이버는 next_vpid 체인을 순회하며 HEAP_PAGE_VACUUM_NONE 페이지를 건너뛰고, 레코드 단위 threshold_mvccid 사망 테스트를 아래로 위임한다.
  2. spage_vacuum_slot은 바이트를 해제하지 slot 항목을 해제하지 않는다total_free를 증가시키고, offset_to_record = SPAGE_EMPTY_OFFSET으로 설정하며, REC_DELETED_WILL_REUSE(reusable) 또는 REC_MARKDELETED(참조 가능) 도장을 찍는다. compact는 수행하지 않는다.
  3. NONE → ONCE → UNKNOWN 머신은 owed vacuum 방문을 예측한다 — MVCC 연산이 앞으로 밀고, vacuum 방문만이 뒤로 당긴다(ONCE → NONE만), UNKNOWNmax_mvccid가 vacuum 기준선 아래로 떨어질 때만 탈출한다.
  4. 페이지는 NONE 상태에서만 해제할 수 있다heap_remove_page_on_vacuum이 미래 워커가 해제된 slot을 건드리는 상황을 피하기 위해 의존하는 전제 조건이다.
  5. spage_reclaimANCHORED_DONT_REUSE_SLOTS 전용으로 slot 배열을 역방향으로 압축한다, reclaim 경로에서 xheap_reclaim_addresses가 로깅을 건너뛴다; 끝에 있는 tombstone은 spage_reduce_a_slot으로 제거하고, 내부 것은 재사용 가능으로 표시한다.
  6. heap_remove_page_on_vacuum은 실패 안전하고 원자적이다 — 모든 경합 케이스는 중단(return false)하며, 체인 splice와 file_dealloc은 하나의 로깅된 system op (all-or-nothing 복구)으로 실행된다.
  7. prev_version_lsa가 힙 내부의 REC_MVCC_NEXT_VERSION 링크를 대체했다, vacuum은 죽은 버전의 slot을 제자리에서 회수한다; 해제된 공간이 HEAP_DROP_FREE_SPACE(30%)를 넘으면 best-space 캐시에 다시 공급된다.

Chapter 9: Best Space와 빈 공간 관리

섹션 제목: “Chapter 9: Best Space와 빈 공간 관리”

4장(삽입)과 6장(갱신)에서 미뤄 둔 함수가 하나 있다. “여유 공간이 충분한 페이지를 골라라” — heap_stats_find_best_page가 그것이다. 이 장에서는 그 기저 구조를 해부한다. 온디스크 힌트(HEAP_HDR_STATS.estimates), 인메모리 캐시(heap_Bestspace), 그리고 전체 힙을 스캔하지 않고도 그림을 최신 상태로 유지하는 지연 조정(heap_stats_sync_bestspace)을 차례로 다룬다.

핵심 아이디어: 빈 공간 회계는 의도적으로 근사치다. 여기서 다루는 내용 중 정확성을 위해 로깅되는 것은 없다(log_skip_logging 호출이 그 증거다). 항목은 낡을 수 있고, 힌트가 틀려도 기껏해야 페이지 fix를 한 번 헛되이 할 뿐 데이터 오류는 일어나지 않는다. 힙이 빈 공간 힌트를 유지하는 이유cubrid-heap-manager.md의 “Free-space management” 절을 보라. 이 장은 그 구현이다.

두 저장소를 순서대로 조회한다. (1) 인메모리 캐시 heap_Bestspace(HEAP_STATS_BESTSPACE_CACHE) — HFID와 VPID를 키로 삼는 프로세스 전역 해시, PRM_ID_HF_MAX_BESTSPACE_ENTRIES로 상한을 지정한다(<= 0이면 비활성화). (2) 힙 헤더 HEAP_HDR_STATS.estimates온디스크 힌트best[10] 링, second_best[10] 링, 그리고 재시작 이후에도 유지되는 집계 카운터. heap_stats_find_best_page는 두 계층을 모두 읽고, heap_stats_sync_bestspace는 두 계층을 모두 재구성한다.

9.2 heap_bestspaceestimates 블록 — 모든 필드

섹션 제목: “9.2 heap_bestspace와 estimates 블록 — 모든 필드”

heap_bestspace(heap_file.h)는 힌팅의 원자 단위로, 후보 페이지 하나와 그 페이지의 예상 여유 공간을 담는다.

// heap_bestspace -- src/storage/heap_file.h
struct heap_bestspace
{
VPID vpid; /* Vpid of one of the best pages */
int freespace; /* Estimated free space in this page */
};
필드역할존재 이유
vpid후보 페이지 ID디렉터리 탐색 없이 페이지를 직접 fix
freespace예상 여유 바이트사전 필터: < needed이면 건너뜀; 지연 평가이므로 부정확할 수 있음

이 구조체는 세 가지 형태로 나타난다. 온디스크 best[] 링의 원소, 인메모리 HEAP_STATS_ENTRY.best, 그리고 선택 함수 내 지역 변수. 온디스크 링은 heap_hdr_stats(heap_file.c)의 익명 estimates 부분 구조체 안에 있으며, 그 모든 필드를 정리하면 다음과 같다.

필드역할존재 이유
num_pages대략적인 페이지 수유계 스캔 min(20%, 100) 크기를 결정
num_recs대략적인 레코드 수heap_stats_get_min_freespace에서 평균 레코드 크기 계산에 사용
recs_sumlen레코드 길이 합 근사값평균 레코드 크기 추정의 분자
num_other_high_bestbest[]에 없는 양호 페이지 수재동기화 게이트: /num_pages >= 0.1일 때만 스캔
num_high_best아직 유효한 best[] 슬롯 수0이면 소진; 동기화가 필요함을 의미
num_substitutionsbest 슬롯 퇴거 횟수% 1000 == 0일 때 second_best[]에 하나 추가
num_second_best유효한 second_best[] 항목 수링이 비었는지 가득 찼는지 구분 (head==tail이면 두 상태 모두 해당)
head_second_bestsecond_best[] 소비 인덱스heap_stats_get_second_best가 여기서 팝
tail_second_bestsecond_best[] 생산 인덱스heap_stats_put_second_best가 여기에 푸시
headbest[] 소비/삽입 인덱스다음 치환이 이 위치에 기록됨
last_vpid힙의 마지막 페이지heap_vpid_alloc이 새 페이지를 이 뒤에 연결
full_search_vpid유계 스캔 재개 커서호출 간 스캔을 분산시킴
second_best[10]퇴거된 양호 페이지 저수지 링예비 풀; 한 번에 전부 재사용하지 않음
best[10]1차 후보 링핫 패스에서 가장 먼저 읽힘

불변 조건 — 링 일관성. head[0, 10) 범위를 유지하며 HEAP_STATS_NEXT_BEST_INDEX(i)=(i+1)%10으로만 전진한다. head가 범위를 벗어나면 배열 밖을 읽게 된다. second_best(tail - head + 10) % 10 == num_second_best가 성립해야 한다(put/get 헬퍼에서 단언). num_second_best는 링이 가득 찼는지 비었는지 구분하는 유일한 수단이다(두 상태 모두 head==tail이 될 수 있기 때문). 이 값들은 로깅되지 않으므로 순수하게 실행 중 상태다.

9.3 임계값: HEAP_DROP_FREE_SPACE, unfill_space, 최소 여유 공간

섹션 제목: “9.3 임계값: HEAP_DROP_FREE_SPACE, unfill_space, 최소 여유 공간”

HEAP_DROP_FREE_SPACE(heap_file.h)는 (int)(DB_PAGESIZE * 0.3) — 30% 허용 하한선이다. 여유 공간이 페이지의 ~30% 이상인 경우에만 캐시에 등록되고, 그 이하면 버려진다(hence “drop free space”). unfill_space는 페이지당 예비 여유 공간(DB_PAGESIZE * PRM_ID_HF_UNFILL_FACTOR, 힙 생성 시 결정)이다. 선택자는 이 값을 요청 크기에 더해서, 현재 레코드뿐 아니라 미래의 제자리(in-place) 갱신 성장분까지 페이지가 수용할 수 있도록 한다(6장: 커지는 갱신은 같은 페이지에 머물기를 선호). heap_stats_get_min_freespace는 두 값을 결합한다. 평균 레코드 크기(recs_sumlen/num_recs, num_recs == 0이면 header_size + 20)에 unfill_space를 더한 뒤, MIN(..., HEAP_DROP_FREE_SPACE)로 상한을 제한한다.

불변 조건 — 최소 여유 공간은 drop 하한을 초과하지 않는다. 마지막의 MIN(..., HEAP_DROP_FREE_SPACE) 처리가 30%를 넘기는 모든 페이지를 후보로 유지시킨다. 이 처리가 없으면 대형 레코드가 min-freespace를 30%보다 높게 밀어올려, 동기화(30%만 확인)가 기록한 페이지를 updater가 거부하는 영구적인 불일치가 생긴다.

9.4 heap_stats_find_page_in_bestspace — 힌트 스캔

섹션 제목: “9.4 heap_stats_find_page_in_bestspace — 힌트 스캔”

내부 루프: best[](호출자 제공)와 캐시를 주어진 상태에서, LK_FORCE_ZERO_WAIT를 사용해 >= needed_space를 만족하는 X-latch 고정 페이지를 반환하거나 HEAP_FINDSPACE_NOTFOUND/_ERROR를 반환한다. 바쁜 페이지는 대기하지 않고 건너뛴다.

// heap_stats_find_page_in_bestspace -- src/storage/heap_file.c
while (notfound_cnt < BEST_PAGE_SEARCH_MAX_COUNT /* cap self-heal at 100 misses */
&& (ent = mht_get2 (heap_Bestspace->hfid_ht, hfid, NULL)) != NULL)
{
if (ent->best.freespace >= needed_space) { best = ent->best; break; } /* hit */
mht_rem2 (...); mht_rem (...); /* stale: evict from both ht */
heap_stats_entry_free (thread_p, ent, NULL);
heap_Bestspace->num_stats_entries--; notfound_cnt++;
}

캐시 조회는 자기 치유(self-heal)한다 — 공간이 부족한 힌트는 두 해시 테이블에서 모두 퇴거된다. 캐시 미스(또는 캐시 비활성화) 시에는 best.freespace-1로 남고, 코드는 best_array_index부터 온디스크 best[]를 스캔한다. 이때 best_hint_is_used를 설정해, 수정된 freespace를 나중에 다시 기록한다. 각 후보는 한 번의 fix-and-recheck 사이클을 거친다. 수정자가 반드시 지켜야 할 분기 규칙:

  • 미스 시에도 재확인과 힌트 갱신을 수행한다. spage_max_space_for_new_record가 진실이다. 공간이 부족하면 unfix하고 계속 진행하되, 힌트/캐시는 갱신한다.
  • 반환되는 freespace에는 unfill이 포함되지 않는다record_length + heap_Slotted_overhead만 차감한다. 예비 공간은 이미 needed_space에 포함되어 있다.
  • 오류 vs. 타임아웃. er_errid()==NO_ERRORNULL fix는 제로 대기 타임아웃이다(다음으로 넘어감). ER_INTERRUPTED는 중단을 의미한다. 그 밖의 오류는 힌트를 버리고 assert(false)와 함께 _ERROR를 반환한다.
  • idx_badspace 출력 파라미터는 여유 공간이 가장 적은 슬롯을 돌려준다. 호출자는 이를 새 head로 설정해 다음 치환이 최악의 슬롯을 덮어쓰도록 한다.

9.5 heap_stats_find_best_page — 오케스트레이터

섹션 제목: “9.5 heap_stats_find_best_page — 오케스트레이터”

4장과 6장에서 호출한 함수. 레코드 예상치를 먼저 갱신한다(삽입 시 num_recs += 1; 항상 recs_sumlen += needed_space). 그런 다음 힌트 스캔, 동기화, 할당을 루프한다. 코드 안에 미묘한 점 두 가지가 있다.

// heap_stats_find_best_page -- src/storage/heap_file.c
total_space = needed_space + heap_Slotted_overhead + heap_hdr->unfill_space;
if (heap_is_big_length (total_space)) /* unfill would overflow page */
total_space = needed_space + heap_Slotted_overhead; /* -> drop the reserve */
if (try_find >= 2 || other_high_best_ratio < HEAP_BESTSPACE_SYNC_THRESHOLD) /*0.1f*/
break; /* sync-admission gate */

모든 분기: (1) 헤더 fix 실패 → goto error, NULL 반환. (2) 그 외 루프(try_find++). (3) heap_stats_find_page_in_bestspace_ERROR이면 unfix 후 goto error; 페이지를 찾으면 완료. (4) 페이지 없음 → 게이트 평가(num_pages<=0 또는 num_other_high_best<=0이면 ratio 0으로 강제, assert(num_pages>0); try_find>=2; ratio<0.1) 통과 시 할당으로 이동. (5) 게이트 통과 → syncloop에서 heap_stats_sync_bestspace(false,true)를 found가 0인 동안 최대 3회 수행 후 재스캔; num_pages_found<0은 하드 오류, 재시도 후에도 <=0이면 alloc. (6) 여전히 NULL → heap_vpid_alloc(실패 시 goto error); 그 외 종료: log_skip_logging 헤더, dirty + free 설정 후 pgptr 반환.

불변 조건 — 헤더는 선택 전 과정에서 WRITE로 유지된다. 예상치를 처음 건드리기 전에 PGBUF_LATCH_WRITE로 fix되며, 단 하나의 dirty-and-free 종료 지점(또는 각 goto error)에서만 해제된다. 예상치 변경, 동기화, 할당이 모두 이 단일 latch 아래서 이루어지므로, 두 삽입자가 head/num_high_best를 동시에 오염시킬 수 없다. 종료 시점의 log_skip_logging은 변경 사항을 로깅하지 않으므로 자기 치유 방식으로 복구된다.

9.6 heap_stats_update — 삭제/갱신 이후 페이지를 양호 상태로 표시

섹션 제목: “9.6 heap_stats_update — 삭제/갱신 이후 페이지를 양호 상태로 표시”

삭제 또는 제자리 축소로 공간이 확보되면 해당 페이지는 재사용 후보가 될 수 있다. heap_stats_update는 이를 알리는 경량 알리미다(삭제/갱신 경로와 복구 redo에서 호출, 10장).

// heap_stats_update -- src/storage/heap_file.c
freespace = spage_get_free_space_without_saving (thread_p, pgptr, &need_update);
if (PRM_ID_HF_MAX_BESTSPACE_ENTRIES > 0 && prev_freespace < freespace)
heap_stats_add_bestspace (thread_p, hfid, vpid, freespace); /* room grew: cache */
if (need_update || prev_freespace <= HEAP_DROP_FREE_SPACE)
if (freespace > HEAP_DROP_FREE_SPACE) { /* now genuinely good */
error = heap_stats_update_internal (...); /* try to write best[] */
if (error != NO_ERROR) spage_set_need_update_best_hint (..., true); /* defer */
else if (need_update) spage_set_need_update_best_hint (..., false); /* clear */
} else if (need_update) spage_set_need_update_best_hint (..., false); /* obsolete */

분기: 캐시를 먼저 시도한다(활성화 상태이고 공간이 증가한 경우 — latch 불필요). 온디스크 갱신은 need_update이거나 prev_freespace <= 30%인 경우에만 수행한다. heap_stats_update_internal은 CONDITIONAL 헤더 latch를 시도한다 — 실패하면 need_update_best_hint = true로 표시(“양호한 페이지이지만 헤더에 알릴 수 없었음”). 이 페이지는 미래의 삭제나 전체 동기화가 재발견할 때까지 보이지 않는다(근사 비용). 성공하고 need_update이면 플래그를 지운다. 30% 이하로 내려간 플래그 페이지도 플래그를 지운다(더 이상 유효하지 않기 때문).

heap_stats_update_internal 내부에서, 퇴거된 30% 초과 슬롯은 heap_stats_put_second_best로 저수지에 추가될 수 있다. 여기에 주기가 있다: if (heap_hdr->estimates.num_substitutions++ % 1000 == 0) 가드가 1000번의 퇴거 중 한 번만 허용한다 — tail_second_best*vpid를 기록하고 tail(가득 찼으면 head도 함께)을 전진시키며 num_second_best를 증가시키고 카운터를 1로 초기화한다.

1-in-1000인 이유 (쓰기 분산). 대량 비우기 직후 모든 페이지를 캐시에 넣으면 다음 삽입들이 그 페이지들에 다시 몰려들어, 방금 비운 페이지를 다시 더럽히게 된다. 1000번에 하나씩 샘플링하면 재사용이 파일 전체에 분산되어, 비워진 익스텐트가 빈 상태로 남아 OS에 반환될 수 있다.

9.7 heap_stats_sync_bestspace — 유계 재구성

섹션 제목: “9.7 heap_stats_sync_bestspace — 유계 재구성”

힌트가 고갈되면 이 함수가 체인을 따라 걷되 일부만 스캔함으로써 best[]를 재구성하고 카운터를 갱신한다 — “전체 힙을 스캔하지 않고도”라는 요건에 대한 답이다. 상한은 max_iterations = MAX(MIN((int)(num_pages*0.2), heap_Find_best_page_limit /*100*/), HEAP_NUM_BEST_SPACESTATS /*10*/): 최대 min(페이지의 20%, 100), 최소 10. 시드(seed) 캐스케이드: (1) 캐시 활성화 → search_all=true이고 full_search_vpid(재개 커서, 각 단계마다 전진하여 동기화가 파일을 20%-per-call로 순회)부터 시작; (2) 아니면 num_high_best > 0head 바로 앞; (3) 아니면 num_second_best > 0 → 저수지에서 팝(heap_stats_get_second_best); (4) 아니면 search_all=true이고 full_search_vpid부터, NULL이면 첫 페이지(hfid->hpgid)이고 can_cycle=false.

stateDiagram-v2
  [*] --> Seed
  Seed --> Walk: 캐스케이드로 start_vpid 결정
  Walk --> Inspect: 페이지 READ fix, 해제 방지
  Inspect --> Walk: 여유 공간 30% 이하, 건너뜀
  Inspect --> Record: 여유 공간 30% 초과
  Record --> Walk: best[] 미충족, 저장 후 num_high_best++
  Record --> Walk: best[] 충족, num_other_best++
  Walk --> Commit: 반복 횟수 초과 OR num_high_best == 10 OR stopat_vpid 도달
  Commit --> [*]: head, num_high_best, 카운터 재구성

Figure 9-3: heap_stats_sync_bestspace, 유계(scan_all=false) 동기화. 상한 도달 시 iterate_all을 설정하고 break. 시드 슬롯에서 아무것도 찾지 못한 경우(start_pos != -1 && num_high_best == 0)에는 NULL로 초기화해 다음 호출이 죽은 지점에서 다시 시드하지 않도록 한다. 페이지마다 spage_collect_statistics로 카운터를 누적하고, search_all이면 full_search_vpid를 갱신하며, can_cycle이 활성이면 힙 시작으로 wrap-around한다.

커밋 단계: 유계 스캔에서 아무것도 찾지 못하고 second_best도 비어 있으면 goto end로 조기 종료. 그 외에는 사용하지 않은 best[] 후미를 NULL로 초기화하고 head/num_high_best를 설정한다. scan_all이거나 num_pages >= stored이면 모든 카운터를 전면 덮어쓴다. 그 외의 보수적 병합(num_other_high_best -= num_high_best, num_other_best보다 크면 그 값으로 대체, 부분 스캔이 더 많이 보았을 경우에만 레코드 카운트 덮어쓰기).

불변 조건 — 동기화는 절대 로깅하지 않는다. 헤더 주석에 명시되어 있으며 호출자가 log_skip_logging으로 래핑한다. 동기화 도중 크래시가 나면 오래된 예상치가 남지만, 다음 삽입이 재동기화한다 — 따라서 undo 데이터가 없고, 체인 탐색에서는 reader가 레코드 잠금 없이 OLD_PAGE_PREVENT_DEALLOC, PGBUF_LATCH_READ로 접근한다.

9.8 할당 폴백: heap_vpid_alloc vs heap_alloc_new_page

섹션 제목: “9.8 할당 폴백: heap_vpid_alloc vs heap_alloc_new_page”

재사용이 실패하면 오케스트레이터는 heap_vpid_alloc — 통계 인식 할당자 — 를 호출한다. file_alloc으로 페이지를 확보하고 last_vpid 뒤에 연결(RVHF_CHAIN 로깅)한 뒤 last_vpid/num_pages를 증가시키고, head 위치에 설치하며 head를 전진시킨다. 설치 분기 세 가지:

// heap_vpid_alloc -- src/storage/heap_file.c
best = heap_hdr->estimates.head;
heap_hdr->estimates.head = HEAP_STATS_NEXT_BEST_INDEX (best);
if (VPID_ISNULL (&heap_hdr->estimates.best[best].vpid))
heap_hdr->estimates.num_high_best++; /* slot was empty */
else if (heap_hdr->estimates.best[best].freespace > HEAP_DROP_FREE_SPACE)
{ heap_hdr->estimates.num_other_high_best++; /* evict good page to reservoir */
heap_stats_put_second_best (heap_hdr, &heap_hdr->estimates.best[best].vpid); }
heap_hdr->estimates.best[best].vpid = vpid;
heap_hdr->estimates.best[best].freespace = DB_PAGESIZE; /* fresh page is all free */

예상치 변경과 달리 체인 연결RVHF_STATSlog_sysop_start/commit 안에서 로깅된다 — 체인에서 새 페이지를 잃으면 스토리지가 누수된다(10장). heap_alloc_new_page베어(bare) 할당자다(7번 핵심 정리): prev/next 링크 없이 file_alloc하고 watcher를 붙이되 estimates는 전혀 건드리지 않는다.

  1. 두 계층, 순서대로: heap_stats_find_page_in_bestspace는 인메모리 heap_Bestspace 캐시를 먼저, 그 다음 온디스크 best[10] 링을 탐색한다. 두 계층 모두 접촉 시 낡은 항목을 자기 치유한다.
  2. heap_stats_find_best_page는 헤더 WRITE latch를 유지한 채로 오케스트레이션한다: 예상치 증가, 힌트 스캔, 조건부 동기화, 그래도 없으면 heap_vpid_alloc. 게이트(try_find >= 2 또는 ratio < 0.1)는 스토리지 대비 CPU 비용의 조정 손잡이다.
  3. 여기서 정확성을 위해 로깅되는 것은 없다 — 예상치 변경은 log_skip_logging 하에, 동기화도 로깅하지 않는다. heap_vpid_alloc만이 체인 연결과 RVHF_STATS를 로깅한다.
  4. **HEAP_DROP_FREE_SPACE(30%)는 허용 하한선, unfill_space**는 갱신 여유 공간으로 needed_space에 접혀 들어가고 반환되는 freespace에서는 제외된다.
  5. 유계 재개 가능 스캔: 동기화는 min(페이지의 20%, 100)만 검사하고, full_search_vpid를 시드로 삼아 그 커서를 전진시키므로, 삽입을 막지 않고 파일을 순회할 수 있다.
  6. second-best 저수지는 쓰기를 분산시킨다: 퇴거된 양호 페이지는 1000번에 하나(num_substitutions % 1000)만 허용되므로, 대량 비우기 직후에도 해당 영역이 드물게 재사용된다.
  7. heap_vpid_alloc(통계 인식) vs heap_alloc_new_page(베어): 전자는 페이지를 best[head]로 설치하고 last_vpid/num_pages를 유지한다. 후자는 예상치를 건드리지 않고, 페이지 체인을 직접 관리하는 호출자를 위해 존재한다.

Chapter 10: 크래시 복구와 Redo/Undo 로그 경로

섹션 제목: “Chapter 10: 크래시 복구와 Redo/Undo 로그 경로”

이 챕터는 앞선 모든 흐름이 미뤄 두었던 질문에 답한다: 크래시 이후에는 무슨 일이 벌어지는가? 각 연산이 고정하는 redo/undo 핸들러를 추적하고, UPDATE의 undo 이미지가 prev_version_lsa가 가리키는 정확한 바이트임을 보여(챕터 6과 복구를 연결), 복구 전용 slotted-page 프리미티브를 해부한다. 이 경로는 **엣지 경로(edge path)**다: 새로운 레코드 상태를 만들지 않고, 순방향 경로가 생산한 상태를 재구성할 뿐이다. WAL / ARIES 이론은 cubrid-heap-manager.md의 §“Durability and the Write-Ahead Log”를 참고한다.

recovery.cRV_fun[]은 각 LOG_RCVINDEX{undofun, redofun, ...} 튜플에 매핑한다. 드라이버는 rcv->pgptr(고정된 페이지), rcv->offset(slotid, vacuum 플래그와 OR된 경우 있음), rcv->length / rcv->data(페이로드), rcv->mvcc_id를 담은 LOG_RCV와 함께 매핑된 함수를 호출한다. 모든 핸들러를 규정하는 두 가지 사실이 있다: 물리적으로 동일한 연산에는 하나의 redo 핸들러가 대응한다(heap_rv_redo_insertRVHF_INSERTRVHF_INSERT_NEWHOME 모두를 redo), undo는 역 연산의 redo다 — heap은 insert/delete에 대한 별도 undo 이미지를 로그에 남기지 않으므로, slotid와 redo 페이로드만으로 되돌린다.

flowchart TB
  LOG["로그 레코드\nrcvindex + LOG_RCV{pgptr, offset, data, mvcc_id}"]
  DISP["복구 드라이버\nRV_fun[rcvindex] 조회"]
  LOG --> DISP
  DISP -->|redo 패스| REDO["redofun\nheap_rv_redo_* / heap_rv_mvcc_redo_*"]
  DISP -->|undo 패스| UNDO["undofun\nheap_rv_undo_* / heap_rv_mvcc_undo_*"]
  REDO --> SPI["spage_insert_for_recovery /\nspage_update / spage_delete"]
  UNDO --> SPD["spage_delete_for_recovery /\nspage_update"]
  SPI --> DIRTY["pgbuf_set_dirty"]
  SPD --> DIRTY

Figure 10-1 — 로그 레코드가 slotted-page 프리미티브에 도달하는 경로.

순방향 경로(heap_mvcc_log_insert, heap_mvcc_log_delete, heap_mvcc_log_home_change_on_delete)는 연산이 페이지의 vacuum 상태를 함께 변경했을 때 p_addr->offsetHEAP_RV_FLAG_VACUUM_STATUS_CHANGE(0x8000)를 OR한다. 따라서 모든 MVCC 핸들러는 동일한 마스킹 구문으로 시작하며, 이후에는 // ... mask vacuum flag, see 10.2 ...로 생략한다:

// shared preamble in every MVCC handler -- src/storage/heap_file.c
if (slotid & HEAP_RV_FLAG_VACUUM_STATUS_CHANGE) { vacuum_status_change = true; }
slotid = slotid & (~HEAP_RV_FLAG_VACUUM_STATUS_CHANGE); /* recover real slotid */

이 비트는 페이지 체인에 상태 변경을 전파할지 여부를 결정하고(챕터 8), 마스크를 적용해 실제 slotid를 복원한다.

불변 조건 — 복구된 slotid는 vacuum 플래그를 slotted-page 호출로 전달하지 않는다. 마스킹을 빠뜨리면 slotid가 32768 이상이 되어 spage_find_slot이 실패한다. 레코드 재구성 핸들러(heap_rv_mvcc_redo_insert, heap_rv_undoredo_update, heap_rv_redo_update_and_update_chain, heap_rv_mvcc_undo_delete)는 assert (slotid > 0)을 경계 조건으로 사용하며, heap_rv_undo_insert는 삭제만 수행하므로 assertion 없이 마스킹만 하고 spage_delete_for_recovery가 잘못된 slotid를 자체적으로 거부한다.

10.3 INSERT 복구 — heap_rv_redo_insertheap_rv_mvcc_redo_insert

섹션 제목: “10.3 INSERT 복구 — heap_rv_redo_insert와 heap_rv_mvcc_redo_insert”

비 MVCC redo (heap_rv_redo_insert). 페이로드 형식은 [INT16 record_type][레코드 바이트]다:

// heap_rv_redo_insert -- src/storage/heap_file.c
recdes.type = *(INT16 *) (rcv->data); /* <- type 접두사; recdes는 그 뒤를 가리킴 */
if (recdes.type == REC_ASSIGN_ADDRESS) /* <- 예약 전용 슬롯: data가 바이트 수 */
{ recdes.area_size = recdes.length = *(INT16 *) recdes.data; recdes.data = NULL; }
sp_success = spage_insert_for_recovery (thread_p, rcv->pgptr, slotid, &recdes); /* fail: fatal er_set */

일반 레코드는 바이트를 복사하고, REC_ASSIGN_ADDRESS(챕터 4)는 복사 없이 슬롯만 예약한다. RVHF_INSERT / RVHF_INSERT_NEWHOME의 redo에 사용된다.

MVCC redo (heap_rv_mvcc_redo_insert). insert MVCCID는 페이로드에 없고 rcv->mvcc_id에 있으므로, 핸들러는 가시성 데이터(챕터 3)가 올바르도록 헤더를 재구성한다:

// heap_rv_mvcc_redo_insert -- src/storage/heap_file.c
// ... mask vacuum flag, see 10.2 ...
if (record_type == REC_BIGONE) /* <- overflow: 인라인 헤더 재구성 불필요 */
{ HEAP_SET_RECORD (&recdes, ..., REC_BIGONE, rcv->data + sizeof (record_type)); }
else /* 인라인 레코드: 헤더 재구성 */
{ MVCC_SET_INSID (&mvcc_rec_header, rcv->mvcc_id); /* <- INSID는 로그 헤더에서, 페이지 아님 */
or_mvcc_add_header (&recdes, &mvcc_rec_header, ...); }
spage_insert_for_recovery (thread_p, rcv->pgptr, slotid, &recdes);
heap_page_rv_chain_update (thread_p, rcv->pgptr, rcv->mvcc_id, vacuum_status_change); /* 체인 재적용 */

두 분기 모두 insert 후 heap_page_rv_chain_update로 페이지 체인 MVCCID와 저장된 vacuum 상태 변경을 재적용한다.

불변 조건 — redo된 MVCC insert는 크래시 이전과 동일한 INSID를 가진다. INSID는 페이지 바이트가 아니라 rcv->mvcc_id에서 가져오므로, redo가 멱등(idempotent)이고 스냅샷 일관성을 유지한다. 오래된 바이트를 사용하면 커밋된 히스토리와 어긋난다.

10.4 INSERT undo — heap_rv_undo_insert는 삭제다

섹션 제목: “10.4 INSERT undo — heap_rv_undo_insert는 삭제다”

커밋되지 않은 insert는 롤백 시 사라져야 한다: 슬롯을 삭제하고, 시스템이 완전히 재시작된 이후에만 여유 공간 통계를 복구한다:

// heap_rv_undo_insert -- src/storage/heap_file.c
if (LOG_ISRESTARTED ()) /* <- 재시작 이후에만 해제된 공간 측정 */
{ free_space = spage_get_free_space_without_saving (thread_p, rcv->pgptr, NULL); }
slotid = rcv->offset & (~HEAP_RV_FLAG_VACUUM_STATUS_CHANGE);
(void) spage_delete_for_recovery (thread_p, rcv->pgptr, slotid); /* <- 슬롯 재사용 */
pgbuf_set_dirty (thread_p, rcv->pgptr, DONT_FREE);
if (LOG_ISRESTARTED ()) /* <- HFID 조회(best-effort), 통계 수정 */
{ if (heap_get_class_oid_from_page (...) != NO_ERROR || heap_get_class_info (...) != NO_ERROR) goto end;
heap_stats_update (thread_p, rcv->pgptr, &hfid, free_space); }
end: ; /* NO_ERROR 반환으로 fall-through */

크래시 복구 중에는 슬롯 삭제만 수행된다(best-space 통계, 챕터 9는 아직 신뢰할 수 없음). 재시작 이후에는 heap_get_class_* 실패 시 goto end로 오류를 흡수한다(통계는 힌트에 불과). OID는 재사용된다 — 커밋되지 않은 insert는 영구적이지 않았다. RVHF_INSERT, RVHF_MVCC_INSERT, RVHF_INSERT_NEWHOME, RVHF_MVCC_REDISTRIBUTE의 undo에 사용된다.

10.5 UPDATE 복구 — heap_rv_undoredo_update와 prev_version 연결

섹션 제목: “10.5 UPDATE 복구 — heap_rv_undoredo_update와 prev_version 연결”

UPDATE 복구는 독특하다: 하나의 함수가 undo와 redo 모두를 처리한다. 어느 방향이든 슬롯 slotid를 페이로드 바이트로 덮어쓰기 때문이다. heap_rv_redo_update는 단순한 one-line 래퍼이고, heap_rv_undo_update는 동일한 코어를 호출한 뒤 vacuum 검사를 추가한다.

// heap_rv_undoredo_update -- src/storage/heap_file.c
// ... mask vacuum flag (10.2); assert (slotid > 0); type 접두사 이후로 recdes 이동 ...
if (recdes.area_size <= 0) { sp_success = SP_SUCCESS; } /* <- 빈 이미지: 헤더만 변경 */
else if (heap_update_physical (thread_p, rcv->pgptr, slotid, &recdes) != NO_ERROR)
{ assert_release (false); return ER_FAILED; } /* heap_update_physical = spage_update + type 보정 */

동일한 페이로드가 양 방향에 작동하는 이유 — prev_version_lsa 링크. 순방향 UPDATE 경로에서 로그를 기록하기 전에, CUBRID는 레코드의 헤더에 이전 레코드의 undo 로그 레코드 LSA를 스탬핑한다:

// heap_update_set_prev_version -- src/storage/heap_file.c
if (recdes.type == REC_HOME)
{ or_mvcc_set_log_lsa_to_record (&recdes, prev_version_lsa); } /* <- 이전 레코드 undo 로그의 LSA */

따라서 UPDATE의 undo 이미지(이전 바이트)는 새 레코드의 prev_version_lsa가 가리키는 정확한 LSA에 위치한다. 버전 체인 리더(챕터 5)는 거기서 undo 레코드를 가져오는데, 이는 heap_rv_undoredo_update가 롤백 시 재생할 내용과 정확히 같다: 복구와 MVCC 시간 여행이 동일한 물리적 undo 레코드를 읽으며, 두 번째 복사본은 없다. (heap_update_bigone도 overflow에 동일한 링크를 연결한다.)

불변 조건 — prev_version_lsa는 전임자 바이트를 담은 undo 로그 레코드의 LSA와 같다. heap_update_set_prev_versionheap_update_bigone이 이를 강제한다. 불일치가 발생하면 롤백이 잘못된 바이트를 복원하거나 스냅샷 읽기가 쓰레기 값으로 이동한다 — 크래시가 아니라 조용한 데이터 손상이다.

10.6 MVCC 삭제 redo — heap_rv_redo_update_and_update_chain

섹션 제목: “10.6 MVCC 삭제 redo — heap_rv_redo_update_and_update_chain”

MVCC 삭제는 레코드를 지우는 대신 헤더에 delete-MVCCID를 스탬핑한다(챕터 7) — 물리적으로는 업데이트다. 따라서 vacuum 플래그 마스킹(§10.2) 후, heap_rv_redo_update_and_update_chain은 문자 그대로 heap_rv_redo_update (thread_p, rcv)(§10.5 코어) 실행 후 heap_page_rv_chain_update (..., rcv->mvcc_id, vacuum_status_change)를 호출한다. 내부 오류는 ASSERT_ERROR ()로 전파된다. 이 핸들러는 RVHF_MVCC_DELETE_MODIFY_HOMERVHF_UPDATE_NOTIFY_VACUUM 모두의 redo에 쓰인다. undo 측인 heap_rv_undo_update는 이전 헤더 바이트를 복원하고 vacuum_rv_check_at_undo(REC_HOME / REC_NEWHOME)를 실행해, 롤백된 삭제가 vacuum에 거짓으로 visible하게 남지 않도록 한다.

10.7 DELETE 복구 — 비 MVCC와 MVCC undo

섹션 제목: “10.7 DELETE 복구 — 비 MVCC와 MVCC undo”

비 MVCC 삭제. Redo(heap_rv_redo_delete)는 단순한 spage_delete이고, undo(heap_rv_undo_delete)는 insert의 mirror다:

// heap_rv_undo_delete -- src/storage/heap_file.c
error_code = heap_rv_redo_insert (thread_p, rcv); /* <- 삭제된 레코드 재삽입 */
if (error_code != NO_ERROR) { return error_code; }
recdes_type = *(INT16 *) (rcv->data);
if (recdes_type == REC_NEWHOME) /* <- REC_NEWHOME만 guard 필요 */
{ vacuum_rv_check_at_undo (thread_p, rcv->pgptr, slotid, recdes_type); } /* fail: assert_release+ER_FAILED */

redo 페이로드에 type과 바이트가 남아 있으므로 재삽입이 가능하다. REC_NEWHOME만 vacuum 원자성 검사를 추가로 실행한다.

MVCC 삭제 undo (heap_rv_mvcc_undo_delete)는 레코드를 삭제하는 대신 delete-MVCCID 플래그를 제거한다:

// heap_rv_mvcc_undo_delete -- src/storage/heap_file.c
slotid = rcv->offset & (~HEAP_RV_FLAG_VACUUM_STATUS_CHANGE);
spage_get_record (..., slotid, &rebuild_record, COPY); /* 현재 페이지 바이트 읽기 */
or_mvcc_get_header (&rebuild_record, &mvcc_rec_header);
assert (MVCC_IS_FLAG_SET (&mvcc_rec_header, OR_MVCC_FLAG_VALID_DELID)); /* 삭제되었어야 함 */
MVCC_CLEAR_FLAG_BITS (&mvcc_rec_header, OR_MVCC_FLAG_VALID_DELID); /* <- 삭제 취소 후 or_mvcc_set_header */
spage_update (..., slotid, &rebuild_record); /* 각 단계 실패 시: assert_release(false)+ER_FAILED */

redo 페이로드가 없다 — undo 이미지는 “DELID 플래그를 제거한 페이지”다. RVHF_MVCC_DELETE_REC_HOME / ..._REC_NEWHOME의 undo에 사용된다.

10.8 페이지 수준 복구 — heap_rv_redo_newpageheap_rv_redo_reuse_page

섹션 제목: “10.8 페이지 수준 복구 — heap_rv_redo_newpage와 heap_rv_redo_reuse_page”

신규 페이지 (heap_rv_redo_newpage)는 페이지의 첫 번째 상태를 redo한다 — type 설정, 복구 공간 예약 활성화 상태로 spage_initialize, 예약 슬롯에 header/chain 레코드 삽입:

// heap_rv_redo_newpage -- src/storage/heap_file.c
spage_initialize (thread_p, rcv->pgptr, heap_get_spage_type (), HEAP_MAX_ALIGN, SAFEGUARD_RVSPACE);
sp_success = spage_insert (thread_p, rcv->pgptr, &recdes, &slotid); /* recdes.type = REC_HOME */
if (sp_success != SP_SUCCESS || slotid != HEAP_HEADER_AND_CHAIN_SLOTID)
{ er_set (ER_FATAL_ERROR_SEVERITY, ...); return er_errid (); } /* header/chain은 반드시 슬롯 0, 아니면 fatal */

페이지 재사용 (heap_rv_redo_reuse_page)은 페이지가 다른 클래스로 재활용될 때 모든 레코드를 일괄 삭제한다:

// heap_rv_redo_reuse_page -- src/storage/heap_file.c
const bool is_header_page = ((rcv->offset != 0) ? true : false);
(void) heap_delete_all_page_records (thread_p, &vpid, rcv->pgptr); /* 멱등: redo는 두 번 실행될 수 있음 */
if (!is_header_page) /* 헤더 페이지는 RVHF_STATS로 나중에 처리 */
{ COPY_OID (&(chain->class_oid), (OID *) (rcv->data)); ... } /* <- class 재스탬핑, max_mvccid 초기화, vacuum=NONE */

heap_delete_all_page_records는 redo가 두 번 실행될 수 있으므로 멱등이다. 헤더 페이지는 체인 재작성을 건너뛰며(RVHF_STATS로 나중에 처리), 비 헤더 페이지는 class OID를 재스탬핑하고 max_mvccid를 초기화한다.

이 프리미티브들은 복구를 위해 존재한다: 순방향 spage_insert는 슬롯을 선택하지만, 복구는 OID가 크래시 이후에도 안정적이도록 레코드를 특정, 이전에 할당된 slotid에 배치해야 한다.

// spage_insert_for_recovery -- src/storage/slotted_page.c
if (anchor_type != ANCHORED && anchor_type != ANCHORED_DONT_REUSE_SLOTS)
{ return spage_insert_at (thread_p, page_p, slot_id, record_descriptor_p); } /* unanchored: shift */
if (slot_id < page_header_p->num_slots) /* 슬롯 존재: 비어 있음을 assert, 재채움 */
{ slot_p->record_type = REC_DELETED_WILL_REUSE; } /* <- 이 slotid에서 OID 유지 */
spage_find_empty_slot_at (thread_p, page_p, slot_id, ...);
if (record_descriptor_p->type != REC_ASSIGN_ADDRESS) /* <- ASSIGN_ADDRESS는 예약만, 복사 없음 */
{ memcpy ((char *) page_p + slot_p->offset_to_record, record_descriptor_p->data, ...); }
// spage_delete_for_recovery -- src/storage/slotted_page.c
if (spage_delete (thread_p, page_p, slot_id) != slot_id) { return NULL_SLOTID; }
if (page_header_p->anchor_type == ANCHORED_DONT_REUSE_SLOTS) /* 일반 삭제는 REC_MARKDELETED 남김 */
{
slot_p = spage_find_slot (page_p, page_header_p, slot_id, false);
if (slot_p->offset_to_record == SPAGE_EMPTY_OFFSET && slot_p->record_type == REC_MARKDELETED)
{ slot_p->record_type = REC_DELETED_WILL_REUSE; pgbuf_set_dirty (...); } /* <- no-reuse 재정의 */
}

불변 조건 — 복구는 커밋되지 않은 OID를 위해 슬롯을 소모하지 않는다. spage_delete_for_recoveryANCHORED_DONT_REUSE_SLOTS 페이지에서도 강제로 REC_DELETED_WILL_REUSE로 설정한다. REC_MARKDELETED를 남겨두면 롤백된 insert마다 슬롯이 누수되고, 크래시 이후 OID가 불필요하게 팽창한다.

10.10 is_saving / spage_save_space undo 공간 예약

섹션 제목: “10.10 is_saving / spage_save_space undo 공간 예약”

SPAGE_HEADER::is_saving(챕터 2; SAFEGUARD_RVSPACE = truespage_initialize에서 설정)은 순전히 복구를 위해 존재한다: 트랜잭션이 공간을 해제할 때 나중에 롤백이 레코드를 다시 키울 수 있도록 그 공간을 예약해야 한다. spage_deleteis_saving일 때만 spage_save_space를 호출하며, 이 함수는 세 가지 경우에 조기 종료된다:

// spage_save_space -- src/storage/slotted_page.c
if (space == 0 || log_is_in_crash_recovery ()) { return NO_ERROR; }
if (VACUUM_IS_THREAD_VACUUM_WORKER (thread_p)) { return NO_ERROR; } /* vacuum은 롤백하지 않음 */
if (space < 0 || !logtb_is_active (thread_p, tranid)) { return NO_ERROR; }
// ... 그 외: VPID로 SPAGE_SAVE_HEAD를 find_or_insert, tranid로 SPAGE_SAVE_ENTRY 확장 ...

양수 공간을 해제하는 활성 순방향 트랜잭션만 spage_Saving_hashmap에서 VPID를 키로 항목을 기록하고, TDES에 연결해 트랜잭션 종료 시 해제된다(§10.4의 heap_rv_undo_insert가 이 예약값을 읽는다).

불변 조건 — 해제된 공간은 커밋 또는 취소 전까지 해제 트랜잭션을 위해 예약되지만, 복구 중에는 작동하지 않는다. is_saving 게이트와 log_is_in_crash_recovery 단락 회로(short-circuit)가 이를 강제한다. 그렇지 않으면 hashmap이 죽은 트랜잭션의 유령 항목으로 가득 찬다.

  1. 복구는 테이블 기반이며 대칭이다. RV_fun[]은 각 RVHF_*를 undo/redo 쌍에 고정한다. undo-insert는 삭제하고, undo-delete는 삽입하며, UPDATE는 heap_rv_undoredo_update 코어 하나를 양방향으로 공유한다.
  2. vacuum 비트는 rcv->offset에 실려 온다. 핸들러는 HEAP_RV_FLAG_VACUUM_STATUS_CHANGE(0x8000)를 마스킹하며, 레코드 재구성 핸들러는 assert (slotid > 0)도 수행한다. heap_rv_undo_insert는 마스킹만 한다.
  3. MVCC redo는 로그에서 헤더를 재구성한다. INSID는 페이지 바이트가 아니라 rcv->mvcc_id에서 가져오므로, redo가 멱등이고 스냅샷 일관성을 유지한다.
  4. UPDATE undo 이미지는 곧 버전 체인 전임자다. heap_update_set_prev_version / heap_update_bigone은 새 레코드의 prev_version_lsa에 이전 레코드의 undo 레코드 LSA를 스탬핑하므로, MVCC 읽기(챕터 5)와 롤백이 동일한 물리적 레코드를 재생한다.
  5. 복구에는 자체 slotted-page 프리미티브가 있다. spage_insert_for_recovery는 OID를 특정 slotid에 안정적으로 유지하고, spage_delete_for_recoveryREC_DELETED_WILL_REUSE를 강제해 롤백된 insert가 슬롯을 누수하지 않도록 한다.
  6. is_saving은 롤백을 위해 해제된 공간을 예약하지만, 복구 중에는 비활성화된다. log_is_in_crash_recovery 단락 회로가 이를 보장한다.
  7. 이 경로는 상태를 재구성할 뿐, 새 상태를 만들지 않는다 — 모든 핸들러는 챕터 4~9 연산의 기계적 역연산 또는 재생이다.

아래 줄 번호는 2026-06-23 시점에 관찰한 값이다. 심볼 이름이 정식 앵커이며 줄 번호는 시간이 지나면 어긋나는 힌트다.

SymbolFileLine
OR_MVCC_DELETE_ID_OFFSETsrc/base/object_representation.h486
OR_MVCC_MAX_HEADER_SIZEsrc/base/object_representation_constants.h142
OR_MVCC_MIN_HEADER_SIZEsrc/base/object_representation_constants.h145
OR_MVCC_FLAG_MASKsrc/base/object_representation_constants.h160
OR_MVCC_FLAG_VALID_INSIDsrc/base/object_representation_constants.h165
OR_MVCC_FLAG_VALID_DELIDsrc/base/object_representation_constants.h168
OR_MVCC_FLAG_VALID_PREV_VERSIONsrc/base/object_representation_constants.h171
OR_MVCC_REPID_MASKsrc/base/object_representation_constants.h173
or_mvcc_get_headersrc/base/object_representation_sr.c4237
or_mvcc_set_headersrc/base/object_representation_sr.c4296
mvcc_header_size_lookupsrc/object/object_representation.c70
or_header_sizesrc/object/object_representation.c5757
vacuum_heap_pagesrc/query/vacuum.c1577
vacuum_is_mvccid_vacuumedsrc/query/vacuum.c7463
HEAP_BESTSPACE_SYNC_THRESHOLDsrc/storage/heap_file.c90
HEAP_MVCC_SET_HEADER_MAXIMUM_SIZEsrc/storage/heap_file.c129
HEAP_UPDATE_IS_MVCC_OPsrc/storage/heap_file.c151
HEAP_NUM_BEST_SPACESTATSsrc/storage/heap_file.c182
HEAP_STATS_NEXT_BEST_INDEXsrc/storage/heap_file.c185
HEAP_STATS_PREV_BEST_INDEXsrc/storage/heap_file.c187
heap_hdr_statssrc/storage/heap_file.c191
HEAP_PAGE_FLAG_VACUUM_STATUS_MASKsrc/storage/heap_file.c240
HEAP_PAGE_SET_VACUUM_STATUSsrc/storage/heap_file.c244
HEAP_PAGE_GET_VACUUM_STATUSsrc/storage/heap_file.c262
heap_chainsrc/storage/heap_file.c270
struct heap_chainsrc/storage/heap_file.c270
heap_stats_bestspace_cachesrc/storage/heap_file.c469
heap_Find_best_page_limitsrc/storage/heap_file.c488
heap_Bestspacesrc/storage/heap_file.c499
HEAP_RV_FLAG_VACUUM_STATUS_CHANGEsrc/storage/heap_file.c514
heap_stats_add_bestspacesrc/storage/heap_file.c1024
heap_is_big_lengthsrc/storage/heap_file.c1330
heap_get_spage_typesrc/storage/heap_file.c1353
heap_is_reusable_oidsrc/storage/heap_file.c1364
heap_stats_get_min_freespacesrc/storage/heap_file.c2917
heap_stats_updatesrc/storage/heap_file.c2966
heap_stats_update_internalsrc/storage/heap_file.c3020
heap_stats_put_second_bestsrc/storage/heap_file.c3142
heap_stats_get_second_bestsrc/storage/heap_file.c3184
heap_stats_find_page_in_bestspacesrc/storage/heap_file.c3272
heap_stats_find_best_pagesrc/storage/heap_file.c3519
heap_stats_sync_bestspacesrc/storage/heap_file.c3728
heap_vpid_allocsrc/storage/heap_file.c4284
heap_remove_page_on_vacuumsrc/storage/heap_file.c4698
heap_vpid_nextsrc/storage/heap_file.c5038
heap_assign_addresssrc/storage/heap_file.c6015
xheap_reclaim_addressessrc/storage/heap_file.c6227
heap_ovf_insertsrc/storage/heap_file.c6569
heap_ovf_updatesrc/storage/heap_file.c6597
heap_get_if_diff_chnsrc/storage/heap_file.c7400
heap_prepare_get_contextsrc/storage/heap_file.c7512
heap_get_mvcc_headersrc/storage/heap_file.c7747
heap_get_record_data_when_all_readysrc/storage/heap_file.c7834
heap_next_internalsrc/storage/heap_file.c7902
heap_rv_redo_newpagesrc/storage/heap_file.c16203
heap_rv_redo_insertsrc/storage/heap_file.c16321
heap_mvcc_log_insertsrc/storage/heap_file.c16371
heap_rv_mvcc_redo_insertsrc/storage/heap_file.c16442
heap_rv_undo_insertsrc/storage/heap_file.c16536
heap_rv_redo_deletesrc/storage/heap_file.c16589
heap_mvcc_log_deletesrc/storage/heap_file.c16610
heap_rv_mvcc_undo_deletesrc/storage/heap_file.c16663
heap_rv_redo_mark_reusable_slotsrc/storage/heap_file.c16929
heap_rv_undo_deletesrc/storage/heap_file.c16946
heap_rv_undo_updatesrc/storage/heap_file.c16981
heap_rv_redo_updatesrc/storage/heap_file.c17018
heap_rv_undoredo_updatesrc/storage/heap_file.c17029
heap_rv_redo_reuse_pagesrc/storage/heap_file.c17065
heap_nextsrc/storage/heap_file.c19427
heap_get_mvcc_rec_header_from_overflowsrc/storage/heap_file.c19540
heap_set_mvcc_rec_header_on_overflowsrc/storage/heap_file.c19566
heap_set_mvcc_rec_header_on_overflowsrc/storage/heap_file.c19567
heap_get_bigone_contentsrc/storage/heap_file.c19610
heap_mvcc_log_home_change_on_deletesrc/storage/heap_file.c19689
heap_mvcc_log_home_no_changesrc/storage/heap_file.c19724
heap_rv_redo_update_and_update_chainsrc/storage/heap_file.c19745
heap_clear_operation_contextsrc/storage/heap_file.c20231
heap_build_forwarding_recdessrc/storage/heap_file.c20516
heap_insert_adjust_recdes_headersrc/storage/heap_file.c20539
heap_insert_adjust_recdes_headersrc/storage/heap_file.c20540
heap_update_adjust_recdes_headersrc/storage/heap_file.c20671
heap_insert_handle_multipage_recordsrc/storage/heap_file.c20834
heap_get_insert_location_with_locksrc/storage/heap_file.c20885
heap_find_location_and_insert_rec_newhomesrc/storage/heap_file.c21022
heap_insert_newhomesrc/storage/heap_file.c21105
heap_insert_physicalsrc/storage/heap_file.c21169
heap_log_insert_physicalsrc/storage/heap_file.c21229
heap_delete_adjust_headersrc/storage/heap_file.c21290
heap_delete_bigonesrc/storage/heap_file.c21389
heap_delete_relocationsrc/storage/heap_file.c21570
heap_delete_homesrc/storage/heap_file.c22067
heap_delete_physicalsrc/storage/heap_file.c22388
heap_log_delete_physicalsrc/storage/heap_file.c22428
heap_update_bigonesrc/storage/heap_file.c22484
heap_update_relocationsrc/storage/heap_file.c22700
heap_update_homesrc/storage/heap_file.c23026
heap_update_physicalsrc/storage/heap_file.c23257
heap_create_insert_contextsrc/storage/heap_file.c23358
heap_create_delete_contextsrc/storage/heap_file.c23385
heap_create_update_contextsrc/storage/heap_file.c23412
heap_insert_logicalsrc/storage/heap_file.c23460
heap_delete_logicalsrc/storage/heap_file.c23676
heap_update_logicalsrc/storage/heap_file.c23867
heap_vacuum_all_objectssrc/storage/heap_file.c24408
heap_page_update_chain_after_mvcc_opsrc/storage/heap_file.c24785
heap_page_set_vacuum_status_nonesrc/storage/heap_file.c24939
heap_page_get_vacuum_statussrc/storage/heap_file.c25014
heap_get_visible_version_from_logsrc/storage/heap_file.c25329
heap_get_visible_versionsrc/storage/heap_file.c25456
heap_scan_get_visible_versionsrc/storage/heap_file.c25494
heap_get_visible_version_internalsrc/storage/heap_file.c25577
heap_update_set_prev_versionsrc/storage/heap_file.c25689
heap_get_last_versionsrc/storage/heap_file.c25793
heap_prepare_object_pagesrc/storage/heap_file.c25856
heap_clean_get_contextsrc/storage/heap_file.c25904
heap_init_get_contextsrc/storage/heap_file.c25944
heap_alloc_new_pagesrc/storage/heap_file.c26241
HEAP_HEADER_AND_CHAIN_SLOTIDsrc/storage/heap_file.h62
HEAP_ISJUNK_OIDsrc/storage/heap_file.h66
HEAP_SCANCACHE_SET_NODEsrc/storage/heap_file.h83
HEAP_DROP_FREE_SPACEsrc/storage/heap_file.h103
heap_bestspacesrc/storage/heap_file.h120
heap_scancache_nodesrc/storage/heap_file.h127
heap_scancachesrc/storage/heap_file.h143
HEAP_OPERATION_TYPEsrc/storage/heap_file.h251
update_inplace_stylesrc/storage/heap_file.h253
HEAP_IS_UPDATE_INPLACEsrc/storage/heap_file.h262
heap_operation_contextsrc/storage/heap_file.h267
HEAP_PAGE_VACUUM_STATUSsrc/storage/heap_file.h354
heap_get_contextsrc/storage/heap_file.h362
spage_verify_headersrc/storage/slotted_page.c346
spage_is_valid_anchor_typesrc/storage/slotted_page.c375
spage_free_saved_spacessrc/storage/slotted_page.c393
spage_save_spacesrc/storage/slotted_page.c488
spage_initializesrc/storage/slotted_page.c1094
spage_compactsrc/storage/slotted_page.c1174
spage_find_free_slotsrc/storage/slotted_page.c1294
spage_check_spacesrc/storage/slotted_page.c1347
spage_find_empty_slotsrc/storage/slotted_page.c1396
spage_add_new_slotsrc/storage/slotted_page.c1568
spage_take_slot_in_usesrc/storage/slotted_page.c1608
spage_find_empty_slot_atsrc/storage/slotted_page.c1674
spage_check_record_for_insertsrc/storage/slotted_page.c1745
spage_insertsrc/storage/slotted_page.c1769
spage_find_slot_for_insertsrc/storage/slotted_page.c1801
spage_insert_datasrc/storage/slotted_page.c1841
spage_insert_atsrc/storage/slotted_page.c1902
spage_insert_for_recoverysrc/storage/slotted_page.c1962
spage_is_record_located_at_endsrc/storage/slotted_page.c2039
spage_reduce_a_slotsrc/storage/slotted_page.c2057
spage_deletesrc/storage/slotted_page.c2084
spage_delete_for_recoverysrc/storage/slotted_page.c2177
spage_check_updatablesrc/storage/slotted_page.c2223
spage_update_record_in_placesrc/storage/slotted_page.c2409
spage_update_record_after_compactsrc/storage/slotted_page.c2465
spage_updatesrc/storage/slotted_page.c2556
spage_reclaimsrc/storage/slotted_page.c2719
spage_mark_deleted_slot_as_reusablesrc/storage/slotted_page.c4022
spage_find_slotsrc/storage/slotted_page.c4609
spage_has_enough_total_spacesrc/storage/slotted_page.c4639
spage_has_enough_contiguous_spacesrc/storage/slotted_page.c4679
spage_vacuum_slotsrc/storage/slotted_page.c4857
spage_need_compactsrc/storage/slotted_page.c5275
ANCHOREDsrc/storage/slotted_page.h38
SP_ERRORsrc/storage/slotted_page.h49
SP_SUCCESSsrc/storage/slotted_page.h50
SP_DOESNT_FITsrc/storage/slotted_page.h51
SAFEGUARD_RVSPACEsrc/storage/slotted_page.h53
SPAGE_HEADER_FLAG_NONEsrc/storage/slotted_page.h57
spage_headersrc/storage/slotted_page.h64
spage_slotsrc/storage/slotted_page.h88
hfidsrc/storage/storage_common.h193
record_typesrc/storage/storage_common.h1145
REC_UNKNOWNsrc/storage/storage_common.h1148
REC_ASSIGN_ADDRESSsrc/storage/storage_common.h1151
REC_HOMEsrc/storage/storage_common.h1154
REC_NEWHOMEsrc/storage/storage_common.h1157
REC_RELOCATIONsrc/storage/storage_common.h1160
REC_BIGONEsrc/storage/storage_common.h1163
REC_MARKDELETEDsrc/storage/storage_common.h1168
REC_DELETED_WILL_REUSEsrc/storage/storage_common.h1173
REC_4BIT_USED_TYPE_MAXsrc/storage/storage_common.h1185
mvcc_rec_headersrc/transaction/mvcc.h38
MVCC_REC_HEADER_INITIALIZERsrc/transaction/mvcc.h47
MVCC_IS_REC_DELETED_BYsrc/transaction/mvcc.h130
MVCC_IS_CHN_UPTODATEsrc/transaction/mvcc.h137
RVHF_INSERTsrc/transaction/recovery.c279
  • cubrid-heap-manager.md — 설계 의도와 이론적 배경을 다루는 상위 동반 문서.
  • raw/code-analysis/cubrid/storage/heap_manager/ 하위 원시 분석 자료.
  • 코드: src/storage/heap_file.{c,h}, src/storage/slotted_page.{c,h}.
  • 방법론: knowledge/methodology/code-analysis-detail-doc.md.