콘텐츠로 이동

CUBRID Vacuum — 코드 수준 심층 분석

이 문서의 위치: 상위 분석 문서인 cubrid-vacuum.md는 설계 의도와 이론적 배경을 다룬다. 이 문서는 모든 분기와 필드를 코드 수준에서 추적한다. 각 장은 독립적으로 읽을 수 있지만, 순서대로 읽으면 단일 vacuum 로그 블록이 커널 내에서 추가 시점 추적부터 힙·인덱스 정리까지 이어지는 전체 생명주기를 따라갈 수 있다.

목차:

Ch제목상태
1자료구조 지도
2초기화와 메모리 관리
3로그 어펜드 경로에서의 블록 생성
4블록 등록 — Vacuum Data로의 기록
5진공 가능 여부 판정과 최고령 가시 워터마크
6마스터 디스패치와 잡 커서
7워커 로그 패스와 레코드별 디스패치
8힙 실행
9블록 완료와 로그 회수
10삭제된 파일 원장(Ledger)
11크래시 복구와 독립 실행 경로

상위 개요 문서(cubrid-vacuum.md의 “전체 구조”와 “Vacuum data — 처리 대기 작업 목록” 절)는 네 가지 구성 요소를 제시한다: 로그 생산자, vacuum data, master, worker pool. 이 챕터는 그 아래에 놓인 현장 사전이다. 각 수명 단계에서 vacuum 블록을 표현하는 struct가 무엇이며, 이들이 서로 어떻게 연결되어 있는지를 정의한다. 이후 챕터들은 이 자료구조에 대한 연산을 다루며, 개별 필드를 다시 설명하지 않는다.

하나의 블록은 시간 순서대로 네 가지 표현을 가진다:

  1. 트랜잭션이 MVCC 로그 레코드를 추가하는 동안 log_header(log_Gl.hdr) 안에서 갱신되는 누산기(Ch. 3);
  2. 락-프리 vacuum_Block_data_buffer 안을 이동하는 vacuum_data_entry (in-flight)(Ch. 3 → Ch. 4);
  3. vacuum_data_page 안에 영속화된 vacuum_data_entry — AVAILABLE → IN_PROGRESS → VACUUMED 상태를 거침(Ch. 4–6, 9);
  4. vacuum_Finished_job_queue 안에 담기는 상태 플래그를 가진 순수 VACUUM_LOG_BLOCKID(Ch. 9).
flowchart LR
  subgraph logside["로그 쪽"]
    HDR["log_Gl.hdr 누산기"]
    VI["MVCC 레코드별 log_vacuum_info"]
  end
  subgraph queues["락-프리 큐"]
    BB["vacuum_Block_data_buffer<br/>1024 x vacuum_data_entry"]
    FQ["vacuum_Finished_job_queue<br/>2048 x VACUUM_LOG_BLOCKID"]
  end
  subgraph vdata["vacuum data 파일"]
    VD["vacuum_Data 전역"]
    VDP["vacuum_data_page 체인<br/>페이지당 entry 윈도우"]
  end
  subgraph exec["실행 쪽"]
    W["vacuum_Workers[]"]
    DF["dropped-files 페이지 체인"]
  end
  HDR -- "블록 경계" --> BB
  VI -. "worker가 역방향 체인 읽기" .- W
  BB -- "master가 소비" --> VDP
  VD --- VDP
  VDP -- "job 디스패치" --> W
  W -- "is_file_dropped" --> DF
  W -- "완료 / 중단" --> FQ
  FQ -- "master가 완료 표시" --> VDP

Figure 1-1 — 블록의 네 가지 표현. 두 큐는 트랜잭션 스레드, master, worker 사이의 유일한 접점이다.

트랜잭션이 로그 레코드를 추가하는 동안, 현재(아직 채워지고 있는) 블록은 log_header(log_storage.hpp)의 필드 다섯 개로만 존재한다:

필드역할존재 이유
does_block_need_vacuum현재 블록에 MVCC undo/undoredo 레코드가 기록되면 true”블록이 dirty” 비트; MVCC 연산이 없는 블록은 큐 엔트리를 생성하지 않음
mvcc_op_log_lsa현재까지 마지막 MVCC op 레코드의 LSAvacuum_data_entry::start_lsa가 됨 — worker가 체인을 역방향으로 탐색할 진입점(Ch. 7)
oldest_visible_mvccid블록의 첫 번째 MVCC op 시점에 채취한 전역 oldest-visible 스냅샷엔트리의 적격 판정 키(Ch. 5)
newest_block_mvccid블록 안 op들의 최대 MVCCID이 값이 oldest-visible 워터마크 아래로 내려간 후에야 블록이 처리됨; vacuum_master_task::is_cursor_entry_ready_to_vacuum이 확인
vacuum_last_blockid소비된 블록의 영속화된 high-water markSA 모드의 vacuum_sa_reflect_last_blockid가 아카이브 제거 전 기록; 복구 시 vacuum data와 MAX를 취함(Ch. 11)

앞의 네 필드는 prior_lsa_mutex를 잡은 상태에서 prior-LSA 경로의 단일 함수가 관리한다:

// prior_update_header_mvcc_info -- src/transaction/log_append.cpp
static void
prior_update_header_mvcc_info (const LOG_LSA &record_lsa, MVCCID mvccid)
{
if (!log_Gl.hdr.does_block_need_vacuum)
{
// first mvcc record for this block
log_Gl.hdr.oldest_visible_mvccid = log_Gl.mvcc_table.get_global_oldest_visible ();
log_Gl.hdr.newest_block_mvccid = mvccid;
}
else
{
// ... condensed: sanity asserts ...
assert (vacuum_get_log_blockid (log_Gl.hdr.mvcc_op_log_lsa.pageid)
== vacuum_get_log_blockid (record_lsa.pageid)); /* <- both records in same block */
if (log_Gl.hdr.newest_block_mvccid < mvccid)
{
log_Gl.hdr.newest_block_mvccid = mvccid;
}
}
log_Gl.hdr.mvcc_op_log_lsa = record_lsa;
log_Gl.hdr.does_block_need_vacuum = true;
}

불변 조건 1-A — 단일 블록 누산기. 네 개의 MVCC 누산기 필드는 정확히 하나의 블록, 즉 mvcc_op_log_lsa를 포함하는 블록만을 기술한다. 강제 방법: 재시작 후(LOG_ISRESTARTED), 누산기가 dirty 상태(does_block_need_vacuum)인 동안 prior_lsa_next_record_internal은 MVCC 타입 분기 이전에 — 모든 레코드 추가 시 — 새로 예약된 LSA가 mvcc_op_log_lsa와 다른 블록에 속하는지 확인한다. 그렇다면 prior_lsa_mutex 아래에서 vacuum_produce_log_block_data로 누산기를 먼저 플러시한다. 위반 시: 하나의 엔트리가 두 블록의 MVCCID를 합산하게 되어 oldest_visible_mvccid가 너무 최신이 될 수 있고(아직 visible한 버전을 제거), start_lsa가 블록 바깥을 가리킬 수 있다(worker가 잘못된 범위를 스캔하게 됨).

각 MVCC undo 레코드에는 log_vacuum_info(log_record.hpp)가 추가로 포함된다:

필드역할존재 이유
prev_mvcc_op_log_lsa이전 MVCC op 레코드의 LSA — 동일 mutex 아래 mvcc_op_log_lsa에서 복사된 역방향 단일 연결 리스트worker가 start_lsa에서 출발해 MVCC 레코드만 통해 체인을 역방향으로 이동(Ch. 7)
vfid해당 연산이 접촉한 파일worker가 dropped-files 원장을 확인하고, 페이지를 가져오지 않고도 파일별로 힙 객체를 그룹화

비대칭적 리셋에 주목할 것: vacuum_produce_log_block_data의 블록 경계 플러시는 does_block_need_vacuumnewest_block_mvccid를 초기화하지만, mvcc_op_log_lsa는 초기화하지 않는다. 덕분에 역방향 체인이 블록 경계를 넘어 끊김 없이 이어진다.

1.2 vacuum_data_entry — 비트 압축된 블록 레코드

섹션 제목: “1.2 vacuum_data_entry — 비트 압축된 블록 레코드”
// vacuum_data_entry -- src/query/vacuum.c
struct vacuum_data_entry
{
VACUUM_LOG_BLOCKID blockid; // blockid and flags
LOG_LSA start_lsa; // lsa of last mvcc op log record in block
MVCCID oldest_visible_mvccid; // oldest visible MVCCID while block was logged
MVCCID newest_mvccid; // newest MVCCID in log block
// ... condensed: constructors, mask-test accessors, setters ...
};
필드역할존재 이유
blockid61비트 id 더하기 상위 비트에 3개의 플래그 비트(2비트 status + interrupted)크래시 후에도 status가 엔트리와 함께 살아남아야 함; 상위 비트를 빌리면 레코드를 넓히지 않아도 됨(VACUUM_LOG_BLOCKIDstd::int64_t)
start_lsalog_Gl.hdr.mvcc_op_log_lsa에서 복사worker의 역방향 체인 탐색 진입점
oldest_visible_mvccid누산기에서 복사적격 판정 키; vacuum_Data.oldest_unvacuumed_mvccid에 반영(Ch. 5)
newest_mvccid누산기에서 복사oldest-visible 워터마크 아래로 내려간 후에야 블록 처리

blockid의 비트 레이아웃:

// VACUUM_DATA_ENTRY_FLAG_MASK -- src/query/vacuum.c
#define VACUUM_DATA_ENTRY_FLAG_MASK 0xE000000000000000 /* <- top 3 bits */
#define VACUUM_DATA_ENTRY_BLOCKID_MASK 0x1FFFFFFFFFFFFFFF /* <- low 61 bits */
#define VACUUM_BLOCK_STATUS_MASK 0xC000000000000000 /* <- top 2 bits = status */
#define VACUUM_BLOCK_STATUS_VACUUMED 0x8000000000000000
#define VACUUM_BLOCK_STATUS_IN_PROGRESS_VACUUM 0x4000000000000000
#define VACUUM_BLOCK_STATUS_AVAILABLE 0x0000000000000000
#define VACUUM_BLOCK_FLAG_INTERRUPTED 0x2000000000000000 /* <- bit 61 */
#define VACUUM_BLOCKID_WITHOUT_FLAGS(blockid) \
((blockid) & VACUUM_DATA_ENTRY_BLOCKID_MASK)

비트 63–62: status(10 VACUUMED, 01 IN_PROGRESS, 00 AVAILABLE, 11 미사용); 비트 61: 직교적인 INTERRUPTED 플래그; 비트 60–0: id. AVAILABLE이 전부 0이므로, 새로 계산된 blockid는 자동으로 AVAILABLE 상태로 태어난다. 접근자들(is_available 등)은 한 줄짜리 마스크 검사이며, 복합 세터 두 개가 핵심이다:

// vacuum_data_entry::set_vacuumed -- src/query/vacuum.c
void vacuum_data_entry::set_vacuumed ()
{
VACUUM_BLOCK_STATUS_SET_VACUUMED (blockid);
VACUUM_BLOCK_CLEAR_INTERRUPTED (blockid); /* <- success wipes the interrupted history */
}
void vacuum_data_entry::set_interrupted ()
{
VACUUM_BLOCK_STATUS_SET_AVAILABLE (blockid); /* <- back to AVAILABLE for re-dispatch */
VACUUM_BLOCK_SET_INTERRUPTED (blockid); /* <- but remember the scar */
}

set_interrupted는 네 번째 status를 추가하지 않는다. AVAILABLE로 돌아가면서 플래그를 올리기 때문에 11이 절대 나타나지 않는다.

status 비트 역할 매트릭스 — 동일한 두 비트가 주체마다 다른 의미를 갖는다:

Statusmaster에게worker에게복구에게
AVAILABLEjob 커서의 후보(Ch. 6)볼 일 없음job 시작 redo가 IN_PROGRESS로 재표시
IN_PROGRESS건너뜀; worker가 소유 중”이것이 내 job”크래시와 함께 죽은 job → interrupted(Ch. 11)
VACUUMED엔트리 제거, index_unvacuumed 전진(Ch. 9)종단; set_vacuumed가 설정페이지에서 엔트리 제거 가능
+ INTERRUPTED재디스패치, 이전 사망 플래그조심 모드: 반쯤 정리된 페이지 허용(Ch. 8)재시작 후에도 보존
stateDiagram-v2
    [*] --> AVAILABLE : master가 추가\nvacuum_consume_buffer_log_blocks
    AVAILABLE --> IN_PROGRESS : set_job_in_progress\nmaster가 job 디스패치
    IN_PROGRESS --> VACUUMED : set_vacuumed\nworker 성공, INTERRUPTED 클리어
    IN_PROGRESS --> AVAILABLE_INTERRUPTED : set_interrupted\n종료 또는 오류
    AVAILABLE_INTERRUPTED --> IN_PROGRESS : set_job_in_progress\n재디스패치, INTERRUPTED 플래그 유지
    VACUUMED --> [*] : 엔트리 제거\nvacuum_data_mark_finished

Figure 1-2 — blockid 상위 비트의 status 생명주기. AVAILABLE_INTERRUPTED는 AVAILABLE에 비트-61 플래그가 추가된 것이며, 독립적인 status가 아니다.

id는 로그 페이지 id에 대한 순수 산술 연산이다:

// vacuum_get_log_blockid -- src/query/vacuum.c
VACUUM_LOG_BLOCKID
vacuum_get_log_blockid (LOG_PAGEID pageid)
{
if (prm_get_bool_value (PRM_ID_DISABLE_VACUUM) || pageid == NULL_PAGEID)
{
return VACUUM_NULL_LOG_BLOCKID; /* <- -1; the only escape hatch */
}
// ... condensed ...
return pageid / vacuum_Data.log_block_npages;
}

log_block_npages는 기본값이 VACUUM_LOG_BLOCK_PAGES_DEFAULT(31, vacuum.h)이며, 역방향 연산 VACUUM_FIRST_LOG_PAGEID_IN_BLOCK(blockid)blockid * log_block_npages이다. const log_header & 생성자는 표현 1 → 2의 변환이다: 세 인자 생성자에 위임하며, 그 생성자는 oldest <= newest를 assert하고 blockid = vacuum_get_log_blockid(start_lsa.pageid)를 계산한다.

불변 조건 1-B — blockid/start_lsa 일관성. 모든 엔트리에서 VACUUM_BLOCKID_WITHOUT_FLAGS(blockid) == vacuum_get_log_blockid(start_lsa.pageid)가 성립한다. 강제 방법: 생성 시(세 인자 생성자). 위반 시: 프리페치 윈도우와 체인 탐색이 서로 다른 로그 범위를 대상으로 한다.

1.3 vacuum_data_page — 슬라이딩 윈도우가 있는 영속 배열

섹션 제목: “1.3 vacuum_data_page — 슬라이딩 윈도우가 있는 영속 배열”
// vacuum_data_page -- src/query/vacuum.c
struct vacuum_data_page
{
VPID next_page;
INT16 index_unvacuumed;
INT16 index_free;
VACUUM_DATA_ENTRY data[1]; /* <- flexible array; capacity computed at runtime */
static const INT16 INDEX_NOT_FOUND = -1;
// ... condensed: is_empty, is_index_valid, get_index_of_blockid, get_first_blockid ...
};
필드역할존재 이유
next_page다음 페이지로의 VPID 링크; 마지막 페이지에서는 NULL VPIDVacuum data는 페이지 큐: head에서 소비, tail에서 추가
index_unvacuumed아직 VACUUMED 상태가 아닌 첫 번째 엔트리완료된 엔트리는 이 값을 앞으로 밀어서 건너뜀 — 압축하지 않음(Ch. 9)
index_free미사용 첫 슬롯; 추가 위치[index_unvacuumed, index_free)가 유효한 윈도우
data[1]엔트리 배열, page_data_max_count 슬롯VACUUM_DATA_PAGE_HEADER_SIZE = offsetof(VACUUM_DATA_PAGE, data); 용량 = (DB_PAGESIZE - header) / sizeof(entry)
INDEX_NOT_FOUNDget_index_of_blockid의 센티널(-1)“이 페이지에 없음”과 유효한 슬롯을 구분

불변 조건 1-C — 조밀하고 연속적인 윈도우. 0 <= index_unvacuumed <= index_free <= page_data_max_count이고, 윈도우는 엄격히 연속적인 blockid를 담는다. 강제 방법: master가 index_free에 blockid 순서대로 추가하며(vacuum_consume_buffer_log_blocks, Ch. 4), MVCC 연산이 없어 gap이 생기는 블록에 대해서는 태어날 때부터 VACUUMED 상태인 placeholder 엔트리(page_free_data->set_vacuumed())를 합성한다. 중간에서 삭제하는 일은 없고(완료된 엔트리는 슬롯을 유지하되 status만 변경), VACUUMED 접두사 위로만 index_unvacuumed를 슬라이딩한다. vacuum_data_mark_finishedpage_free_blockid == data_page->data[data_page->index_free - 1].get_blockid() + 1을 assert한다. 위반 시: 아래의 O(1) 조회가 잘못된 슬롯을 반환하여 master가 엉뚱한 블록을 완료 처리한다.

// vacuum_data_page::get_index_of_blockid -- src/query/vacuum.c
INT16
vacuum_data_page::get_index_of_blockid (VACUUM_LOG_BLOCKID blockid) const
{
if (is_empty ())
{
return INDEX_NOT_FOUND;
}
VACUUM_LOG_BLOCKID first_blockid = data[index_unvacuumed].get_blockid ();
// ... condensed: return INDEX_NOT_FOUND if blockid before or after the window ...
INT16 index_of_blockid = (INT16) (blockid - first_blockid) + index_unvacuumed;
assert (data[index_of_blockid].get_blockid () == blockid); /* <- relies on Invariant 1-C */
return index_of_blockid; /* <- O(1), no loop */
}

is_empty()index_unvacuumed == index_free이며, 두 인덱스 모두 vacuum_data_initialize_new_page에서 0으로 초기화된다.

불변 조건 1-D — 빈 페이지도 last_blockid를 보관한다. vacuum_init_data_page_with_last_blockid는 새로 초기화된 페이지의 슬롯 0(data->blockid)에 blockid를 기록한다(윈도우는 비어 있어도). vacuum data가 비어 있을 때 복구는 vacuum_Data.last_page->data->blockid를 읽어 log_Gl.hdr.vacuum_last_blockidMAX를 취한다(Ch. 11). 슬롯 0은 이중 역할을 한다: 윈도우가 해당 슬롯을 포함할 때는 유효한 엔트리, 페이지가 비어 있을 때는 영속화된 high-water mark. 위반 시: 비울 때 슬롯 0을 초기화하면 재시작 시 이미 vacuumed된 블록을 다시 소비하게 된다.

단일 정적 인스턴스 vacuum_Data가 모든 것을 연결한다:

// vacuum_data -- src/query/vacuum.c
struct vacuum_data
{
VFID vacuum_data_file;
LOG_PAGEID keep_from_log_pageid; /* Smallest LOG_PAGEID that vacuum may still need for its jobs. */
MVCCID oldest_unvacuumed_mvccid; /* Global oldest MVCCID not vacuumed (yet). */
VACUUM_DATA_PAGE *first_page; /* Cached first vacuum data page. */
VACUUM_DATA_PAGE *last_page; /* Cached last vacuum data page. */
// ... condensed ...
private:
VACUUM_LOG_BLOCKID m_last_blockid; /* ... the id of last added block
* which may not even be in vacuum data (being already vacuumed). */
};
필드역할존재 이유
vacuum_data_file페이지 체인을 담는 디스크 파일의 VFID부트 시 파일 디스크립터로 head VPID를 찾음
keep_from_log_pageid첫 번째 미완료 블록의 첫 로그 페이지아카이브 제거기의 펜스; vacuum_min_log_pageid_to_keep이 이 값을 반환(Ch. 9)
oldest_unvacuumed_mvccid”이 아래는 완전히 정리됨” 워터마크무결성 검사, vacuum_is_mvccid_vacuumed; upgrade_oldest_unvacuumed가 단조 증가를 assert(Ch. 5)
first_page / last_page영구적으로 fix된 head/tail 페이지master는 head를 상시 읽고, 생산자는 tail에 추가; 한 번의 latch로 per-op pgbuf fix를 회피
page_data_max_count페이지당 엔트리 수vacuum_initialize에서 DB_PAGESIZE를 기준으로 한 번 계산
log_block_npages로그 페이지 단위의 블록 granularityvacuum_get_log_blockid의 제수; db 생성 시 고정, 기본값 31
is_loaded페이지가 fix되고 준비된 상태vacuum_data_load_first_and_last_page 재진입 방지
shutdown_sequencevacuum_shutdown_sequence 객체질서 있는 종료; worker들이 이를 set_interrupted 경로로 전환
is_archive_removal_safekeep_from_log_pageid가 처음 계산될 때까지 falsevacuum_is_safe_to_remove_archives — vacuum이 필요 범위를 파악하기 전에는 아카이브 제거 없음
recovery_lsa복구가 시작된 LSAvacuum_recover_lost_block_data의 역방향 스캔 앵커(Ch. 11)
is_restoredb_sessionrestoredb가 부팅복구 경로 동작 전환 플래그
is_vacuum_complete (SA_MODE)단독 실행 “모두 따라잡음” 플래그SA 모드에서 xvacuum 내부가 vacuum을 완료 상태까지 실행(Ch. 11)
m_last_blockid (private)지금까지 소비된 마지막 블록 id엔트리로 반드시 존재하는 것은 아님 — 역할 매트릭스 참조

m_last_blockid 역할 매트릭스 — vacuum data가 비어 있는지 여부에 따라 의미가 달라진다:

상태get_last_blockid() 의미get_first_blockid() 반환값
비어 있지 않음마지막으로 추가된 엔트리의 blockidfirst_page->get_first_blockid() — head 페이지의 첫 번째 윈도우 엔트리
비어 있음이미 제거된 마지막 소비 블록m_last_blockid와 동일 — 두 접근자가 같은 값으로 수렴

set_last_blockid는 플래그를 제거(VACUUM_BLOCKID_WITHOUT_FLAGS)하고, 디버그 빌드에서는 값이 log_Gl.prior_info.prior_lsa 블록보다 엄격히 작음을 assert한다 — 마지막 블록이 로그를 앞지르면 안 된다.

불변 조건 1-E — head와 tail 페이지는 fix 상태를 유지한다. first_pagelast_page는 로드 시 한 번 latch되며 연산 사이에 절대 unfix되지 않는다. 강제 방법: pgbuf 래퍼들 — vacuum_fix_data_page는 VPID가 양 끝과 일치하면 캐시된 포인터를 반환하고, vacuum_unfix_data_page는 이들을 조용히 건너뛰며, vacuum_unfix_first_and_last_data_page(종료 시)만이 실제로 해제한다. 위반 시: pgbuf_unfixfirst_page를 직접 unfix하면 다음 vacuum_fix_data_page 호출 시 버퍼 관리자가 victim으로 선택했을 수 있는 페이지에 대한 stale 포인터를 반환한다.

두 개의 보조 구조가 vacuum_Data 옆에 존재한다. vacuum_data_load struct(전역 vacuum_Data_load, 필드 vpid_first / vpid_last)는 체인 양 끝을 기록하여 vacuum_data_load_first_and_last_page가 체인을 걷지 않고도 양쪽 페이지를 fix할 수 있게 한다. 특수 케이스는 vpid_first == vpid_lastlast_page = first_page(페이지 하나를 두 번 fix해서는 안 됨). 그리고 vacuum_job_cursor는 master가 윈도우를 순회할 때 사용하는 영속 반복 상태이다:

// vacuum_job_cursor -- src/query/vacuum.c
class vacuum_job_cursor
{
// ... condensed: increment_blockid, readjust_to_vacuum_data_changes, load/unload ...
private:
VACUUM_LOG_BLOCKID m_blockid; // current cursor blockid
VACUUM_DATA_PAGE *m_page; // loaded page of blockid or null
INT16 m_index; // loaded index of blockid or INDEX_NOT_FOUND
};
필드역할존재 이유
m_blockid커서의 정식 위치Blockid는 안정적임; 블록이 제거/추가된 후에도 엔트리의 위치는 변하지만 id는 변하지 않음
m_page / m_indexm_blockid의 캐시된 물리적 위치단계마다 재탐색을 피함; vacuum data가 이동한 후 readjust_to_vacuum_data_changesget_index_of_blockid로 재계산

커서의 순회 로직은 Ch. 6의 주제이며, 여기서는 레이아웃만 확정한다.

// vacuum_Block_data_buffer -- src/query/vacuum.c
lockfree::circular_queue<vacuum_data_entry> *vacuum_Block_data_buffer = NULL;
#define VACUUM_BLOCK_DATA_BUFFER_CAPACITY 1024
lockfree::circular_queue<VACUUM_LOG_BLOCKID> *vacuum_Finished_job_queue = NULL;
#define VACUUM_FINISHED_JOB_QUEUE_CAPACITY 2048
원소생산자 → 소비자큐가 필요한 이유
vacuum_Block_data_buffer완전한 vacuum_data_entry(1024 용량)prior-LSA 경로의 임의 트랜잭션 스레드 → master(vacuum_consume_buffer_log_blocks, Ch. 4)트랜잭션은 vacuum data 페이지에 latch를 잡아서는 안 됨 — 소스 주석: “It is advisable to avoid synchronizing running transactions with vacuum threads”
vacuum_Finished_job_queuestatus 플래그가 붙은 순수 VACUUM_LOG_BLOCKID(2048 용량)worker(vacuum_finished_block_vacuum, Ch. 9) → master(vacuum_data_mark_finished)worker도 vacuum data 페이지를 쓰면 안 됨; 표현 3의 유일한 작성자는 master

첫 번째 큐의 생산자 쪽:

// vacuum_produce_log_block_data -- src/query/vacuum.c
void
vacuum_produce_log_block_data (THREAD_ENTRY * thread_p)
{
// ... condensed: PRM_ID_DISABLE_VACUUM early return ...
VACUUM_DATA_ENTRY block_data { log_Gl.hdr }; /* <- representation 1 -> 2 */
log_Gl.hdr.does_block_need_vacuum = false; /* <- reset accumulator for next block */
log_Gl.hdr.newest_block_mvccid = MVCCID_NULL;
// ... condensed: NULL-buffer guard, vacuum_er_log ...
if (!vacuum_Block_data_buffer->produce (block_data))
{
/* Push failed, the buffer must be full */
vacuum_er_log_error (VACUUM_ER_LOG_ERROR, "%s", "Cannot produce new log block data! The buffer is already full.");
assert (false);
return; /* <- block metadata LOST in release builds */
}
}

큐 가득 찬 분기는 알려진 약점이다(위의 TODO가 이를 인정한다): 릴리스 빌드에서 버퍼가 가득 차면 블록 메타데이터를 조용히 삭제한다. Ch. 11에서 안전망을 설명한다 — vacuum_recover_lost_block_datamvcc_op_log_lsa 역방향 체인을 스캔하여 엔트리를 재구성한다.

완료 쪽에서 원소 형식이 핵심이다: worker는 자신의 private 복사본에서 set_vacuumed()set_interrupted()를 호출한 data->blockid를 push하므로, 플래그가 상위 비트에 실려 전달된다. master는 VACUUM_BLOCKID_WITHOUT_FLAGS로 플래그를 제거하여 엔트리를 찾고(get_index_of_blockid), 플래그를 읽어 VACUUMED-제거 대 AVAILABLE+INTERRUPTED-재큐잉을 결정한다.

// vacuum_worker -- src/query/vacuum.h
struct vacuum_worker
{
VACUUM_WORKER_STATE state;
INT32 drop_files_version;
struct log_zip *log_zip_p;
VACUUM_HEAP_OBJECT *heap_objects;
int heap_objects_capacity;
int n_heap_objects;
char *undo_data_buffer;
int undo_data_buffer_capacity;
int private_lru_index; // page buffer private lru list
char *prefetch_log_buffer;
LOG_PAGEID prefetch_first_pageid;
LOG_PAGEID prefetch_last_pageid;
bool allocated_resources;
int idx; // -1 for vacuum_master; Otherwise, the sequence number of vacuum_worker
};
필드역할존재 이유
stateVACUUM_WORKER_STATE_INACTIVE / PROCESS_LOG / EXECUTE로그 읽기 코드가 vacuum 스레드를 다르게 취급함(vacuum_is_process_log_for_vacuum이 LOG_CS 처리를 제어)
drop_files_version마지막으로 관찰한 vacuum_Dropped_files_versionmaster의 worker 전체 최솟값이 오래된 원장 엔트리 정리 시점을 결정(Ch. 10)
log_zip_p영속적인 압축 해제 스크래치로그 undo 데이터가 압축될 수 있음; 레코드마다 재할당하면 thrash 발생
heap_objects / heap_objects_capacity / n_heap_objects로그 pass에서 수집한 growable 대상 배열(초기 VACUUM_DEFAULT_HEAP_OBJECT_BUFFER_SIZE = 4000)로그 pass는 수집만 함; 실행 시 VFID로 정렬하여 힙 페이지당 배치(Ch. 7→8)
undo_data_buffer / undo_data_buffer_capacity로그 페이지에 걸쳐 있는 undo 데이터 복사 버퍼(초기 IO_PAGESIZE)B-tree vacuum은 데이터가 연속적이어야 함
private_lru_index페이지 버퍼의 private LRU 리스트 idVacuum은 방대한 수의 페이지를 한 번씩 접근; 격리가 공유 LRU를 보호
prefetch_log_bufferVACUUM_PREFETCH_LOG_BLOCK_BUFFER_PAGES = 1 + log_block_npages(32) 페이지하나의 bulk read가 31번의 단건 fetch보다 빠름; +1은 블록의 start_lsa 페이지 이후 한 페이지 — start_lsa 페이지가 논리적으로 블록의 마지막 페이지이고, 마지막 레코드가 다음 페이지로 넘칠 수 있기 때문; vacuum_log_prefetch_vacuum_block 주석이 최대 그 한 페이지만 처리함을 경고
prefetch_first_pageid / prefetch_last_pageid버퍼 범위: first = VACUUM_FIRST_LOG_PAGEID_IN_BLOCK(entry->get_blockid()), last = first + VACUUM_PREFETCH_LOG_BLOCK_BUFFER_PAGES - 1(기본값에서 first + 31)vacuum_fetch_log_page가 pageid가 범위 안에 있으면 버퍼에서 서비스(Ch. 7)
allocated_resources지연 할당 플래그버퍼는 첫 job 시 malloc(vacuum_worker_allocate_resources), 부트 시가 아님
idxvacuum_Master이면 -1, 아니면 vacuum_Workers[] 내 슬롯 번호(최대 VACUUM_MAX_WORKER_COUNT = 50)로그와 min-version 스캔에서 스레드 식별

VACUUM_HEAP_OBJECT는 수집 버퍼가 담는 최소 단위이다:

필드역할존재 이유
vfid객체의 힙 파일기본 정렬 키: dropped-ledger 확인은 파일당 한 번
oid객체 id; pageid가 보조 그룹화 키힙 페이지당 배치 → 한 번의 fix, 페이지당 한 번의 로그 레코드(Ch. 8)

vacuum_Master 정의 위의 주석은 master가 VACUUM_WORKER이기도 한지를 설명한다: 페이지 할당을 위한 시스템 연산과 트랜잭션 디스크립터가 필요했기 때문에 struct를 재활용한다. vacuum_heap_helper(worker의 페이지별 스크래치 — home/forward 페이지, 슬롯과 결과 배열, MVCC 헤더)는 Ch. 8에서 해부한다.

Struct / 필드역할존재 이유
vacuum_dropped_file.vfid삭제된 파일worker가 log_vacuum_info.vfid와 대조
vacuum_dropped_file.mvccid삭제 시점에 기록된 MVCCID이 값 이하의 mvccid를 가진 레코드만 건너뜀 — 파일 id가 재사용될 수 있으므로, 더 새로운 파일의 레코드는 여전히 vacuum해야 함(Ch. 10)
vacuum_dropped_files_page.next_pageVPID 체인 링크원장이 한 페이지를 초과할 수 있음
vacuum_dropped_files_page.n_dropped_files유효한 엔트리 수엔트리는 페이지당 VFID 정렬 유지(vacuum_add_dropped_fileutil_bsearch로 이진 탐색 후 memmove로 위치에 삽입); 용량 VACUUM_DROPPED_FILES_PAGE_CAPACITY
vacuum_dropped_files_page.dropped_files[1]가변 엔트리 배열vacuum_data_page와 동일한 flexible-array 관용구

보조 전역 변수들: vacuum_Dropped_files_vfid / vacuum_Dropped_files_vpid(파일과 head 페이지), vacuum_Dropped_files_loaded, vacuum_Dropped_files_count, vacuum_Last_dropped_vfid, 그리고 vacuum_Dropped_files_version — 각 worker의 drop_files_version과 짝을 이루는 INT32 세대 카운터. vacuum_Dropped_files_mutexvacuum_notify_all_workers_dropped_file만이 잡는다: 각 drop의 notify-workers 단계를 직렬화하여(vacuum_Last_dropped_vfid++vacuum_Dropped_files_version 증가를 한 번에 하나씩 보호), 페이지 편집은 페이지 latch에 의존하며 읽는 worker는 이 mutex를 잡지 않는다. 디버그 빌드에서는 vacuum_Track_dropped_files로 페이지 체인을 메모리에 미러링한다. (VACUUM_DROPPED_FILE_FLAG_DUPLICATE는 정의되어 있으나 현재 소스에서는 사용되지 않음 — re-dropped vfid는 in-place로 처리된다: 이진 탐색이 기존 엔트리를 찾아 mvccid만 갱신한다.) 동작은 Ch. 10의 주제이며, 레이아웃은 여기서 확정한다.

Figure 1-3 — 모든 자료구조를 가로지르는 하나의 블록 생애

Figure 1-3 — Vacuum 자료구조 파노라마. 파란 화살표: 블록 탄생(Ch. 3–4). 초록: master의 추가 및 디스패치(Ch. 4, 6). 빨강: 완료 보고(Ch. 9). 보라 점선: worker가 볼륨에서 로그 페이지를 역방향으로 읽음(Ch. 7).

  1. Vacuum 블록은 네 가지 표현을 갖는다: log_Gl.hdr 누산기, vacuum_Block_data_buffer 안의 in-flight vacuum_data_entry, vacuum_data_page 안의 영속화된 엔트리, 그리고 vacuum_Finished_job_queue 안의 플래그를 가진 VACUUM_LOG_BLOCKID. 동일한 네 값 — start LSA, oldest/newest MVCCID, blockid — 이 모든 표현을 흐른다.
  2. vacuum_data_entry::blockid는 61비트 id 위에 2비트 status(AVAILABLE 00, IN_PROGRESS 01, VACUUMED 10)와 직교적인 비트-61 INTERRUPTED 플래그를 압축한다. set_interrupted는 플래그를 올리면서 status를 AVAILABLE로 되돌리고, set_vacuumed는 이를 클리어하므로 11은 절대 나타나지 않는다.
  3. vacuum_data_page는 엄격히 연속적인 blockid의 조밀한 윈도우 [index_unvacuumed, index_free)를 유지한다(MVCC 연산이 없는 gap 블록에는 태어날 때부터 VACUUMED인 placeholder 엔트리를 삽입). get_index_of_blockid는 이를 이용해 O(1) 조회를 제공하며, 페이지의 슬롯 0은 영속화된 last_blockid 역할도 한다(불변 조건 1-D).
  4. vacuum_Data.first_page / last_page는 영구적으로 fix되어 있으며, vacuum_fix_data_page / vacuum_unfix_data_page 래퍼가 두 VPID의 pgbuf를 우회한다. job 커서는 m_blockid(안정적)에 닻을 내리고 윈도우가 이동한 후 캐시된 m_page/m_index를 재계산한다.
  5. 두 개의 락-프리 큐는 트랜잭션 스레드가 vacuum data 페이지에 접촉하지 않고 worker도 이를 쓰지 않도록 한다 — master만이 영속 표현의 유일한 작성자다. vacuum_Block_data_buffer가 가득 차면 릴리스 빌드에서 블록 메타데이터가 삭제되며, 복구의 역방향 체인 스캔이 안전망이다(Ch. 11).
  6. VACUUM_WORKER는 영속적인 per-thread 버퍼들의 모음이다(프리페치 버퍼 1 + log_block_npages 페이지 — +1은 start_lsa 페이지를 넘어 넘치는 마지막 레코드를 위함 — 힙 객체 배열, undo 버퍼, zip 스크래치) 더하기 drop_files_version. worker 전체 최솟값이 dropped-files 정리 시점을 결정하며, master는 idx == -1로 같은 struct를 재활용한다.
  7. 블록 기하는 순수 산술이다: blockid = pageid / log_block_npages, 첫 번째 pageid = blockid * log_block_npages — 모든 펜스(keep_from_log_pageid, 프리페치 범위)가 이 두 공식에서 파생된다.

Chapter 1에서 다룬 구조체들 — vacuum_Data, 두 개의 lock-free 큐, vacuum_Master, vacuum_Workers, 온디스크 파일 쌍 — 이 어떻게 생성되는지, 스레드가 어떻게 vacuum 신원을 부여받는지, 그리고 어떤 순서로 해제되는지를 다룬다. master/worker 분리 설계의 근거는 상위 companion 문서(cubrid-vacuum.md)에 있으며, 이 챕터에서는 재도출하지 않는다.

시작은 두 단계로 나뉜다. vacuum_initializeboot_restart_server(boot_sr.c) 안에서 log_initialize 이전에 실행된다. 크래시 복구 단계가 이미 vacuum_Data.log_block_npages, 삭제된 파일의 VPID, 워커 컨텍스트를 필요로 하기 때문이다. vacuum_boot는 복구 이후에 실행된다. redo가 진행 중인 동안 master가 vacuum 데이터를 소비해서는 안 되기 때문이다.

2.1 vacuum_initialize — 파라미터 수집과 정적 배열

섹션 제목: “2.1 vacuum_initialize — 파라미터 수집과 정적 배열”

입력은 boot_Db_parm에서 가져온다: createdb 시점의 두 VFID(섹션 2.2)와 함께, log 페이지 단위의 블록 크기가 포함된다. is_restore 플래그는 boot_restart_server 안에서 r_args != NULL && r_args->is_restore_from_backup으로 결정되며, 다른 호출자인 xboot_emergency_patchfalse를 하드코딩한다. 이 플래그는 vacuum_Data.is_restoredb_session에 저장되고, SA 종료 시(섹션 2.7) 딱 한 번 소비된다.

// vacuum_initialize -- src/query/vacuum.c
if (prm_get_bool_value (PRM_ID_DISABLE_VACUUM))
return NO_ERROR; /* <- branch 1: vacuum disabled, nothing is built */
vacuum_Data.is_restoredb_session = is_restore;
vacuum_Data.log_block_npages = vacuum_log_block_npages;
vacuum_Data.page_data_max_count = (DB_PAGESIZE - VACUUM_DATA_PAGE_HEADER_SIZE) / sizeof (VACUUM_DATA_ENTRY);
// ... condensed: VFID copies; SA_MODE is_vacuum_complete = false; dropped-files globals, mutex init ...
if (vacuum_get_first_page_dropped_files (thread_p, &vacuum_Dropped_files_vpid) != NO_ERROR)
{ assert (false); goto error; } /* <- branch 2: sticky first page lookup failed */
vacuum_Block_data_buffer = new lockfree::circular_queue<vacuum_data_entry> (VACUUM_BLOCK_DATA_BUFFER_CAPACITY);
if (vacuum_Block_data_buffer == NULL) goto error; /* <- branches 3, 4: queue NULL checks
(vacuum_Finished_job_queue is identical) */
vacuum_Master.state = VACUUM_WORKER_STATE_EXECUTE; /* <- master is *always* in execute state */
vacuum_Master.idx = -1; /* private_lru_index = -1, buffers NULL */
for (i = 0; i < VACUUM_MAX_WORKER_COUNT; i++)
{
vacuum_Workers[i].state = VACUUM_WORKER_STATE_INACTIVE;
vacuum_Workers[i].private_lru_index = pgbuf_assign_private_lru (thread_p); /* <- eager */
vacuum_Workers[i].allocated_resources = false;
vacuum_Workers[i].idx = i; /* buffer pointers NULL, capacities 0 */
}
return NO_ERROR;
error:
vacuum_finalize (thread_p); /* <- error path reuses the full teardown */
return (error_code == NO_ERROR) ? ER_FAILED : error_code;

Branch (2)는 file_get_sticky_first_page의 얇은 래퍼이므로, 실패는 createdb 시점에 생성된 파일이 없다는 의미 — 부팅 불가능한 데이터베이스다. Branch (3)/(4)는 throwing new 환경에서는 사실상 도달하지 않지만, 반쯤 구성된 상태가 완전히 구성된 경우와 동일하게 vacuum_finalize로 해제될 수 있도록 공유 error: 레이블로 흐른다. 워커 루프의 비대칭성에 주목하자: PRM_ID_VACUUM_WORKER_COUNT가 실제 스레드 수를 훨씬 낮게 제한하더라도, VACUUM_MAX_WORKER_COUNT(50)개의 정적 슬롯 전체가 즉시 private 페이지 버퍼 LRU 리스트를 할당받는다 — LRU 인덱스는 페이지 버퍼 부트스트랩 시점에 선점해야 하기 때문이다. 그 외 모든 워커별 버퍼는 첫 번째 잡이 올 때까지 대기한다(섹션 2.5). 슬롯은 VACUUM_WORKER 구조체이며, 각 필드의 Field | Role | Why 표는 Chapter 1에 있다. vacuum_initialize는 포인터를 NULL로, 용량을 0으로 초기화하고 state = INACTIVE, allocated_resources = false로 남겨둔다.

2.2 createdb 파일 쌍 — 파일의 기원

섹션 제목: “2.2 createdb 파일 쌍 — 파일의 기원”

boot_create_all_volumes(createdb)는 두 생성 함수를 연달아 호출하고 VFID를 boot_Db_parm에 저장한다. vacuum_create_file_for_vacuum_data: file_create_with_npages(FILE_VACUUM_DATA, 1페이지), file_alloc, 그리고 첫 번째 페이지 VPID를 file_descriptor_update로 파일 디스크립터에 기록한다 — 이 디스크립터가 재시작 시 페이지를 찾는 수단이 된다. 그 후 페이지는 vacuum_init_data_page_with_last_blockid (..., 0)로 포맷된다: 빈 데이터, last_blockid 0. vacuum_create_file_for_dropped_files도 동일한 골격이지만 두 가지 차이가 있다: 디스크립터에 기록하는 대신 file_alloc_sticky_first_page로 할당하며(따라서 부트 시 file_get_sticky_first_page로 조회), 페이지를 인라인으로 포맷한다 — NULL next_page, n_dropped_files = 0 — 그 후 vacuum_set_dirty_dropped_entries_page (..., FREE). 함수별 종료 경로: create 오류, alloc 오류, NULL 페이지 가드, 성공 — vacuum 데이터의 경우 디스크립터 업데이트 오류로 다섯 번째 경로가 추가된다. 페이지 포맷 상세: Chapter 10.

2.3 vacuum_boot — 상태 적재 후 스레드 시작

섹션 제목: “2.3 vacuum_boot — 상태 적재 후 스레드 시작”
flowchart TD
    A["boot_restart_server"] --> B["vacuum_initialize<br/>(log_initialize 이전)"]
    B --> C["log_initialize -- 크래시 복구 실행"]
    C --> D["vacuum_boot"]
    D --> E["vacuum_data_load_and_recover<br/>엔트리 복구, VPID를 vacuum_Data_load에 저장, 언로드"]
    E --> F["vacuum_load_dropped_files_from_disk"]
    F --> G["new vacuum_master_entry_manager<br/>new vacuum_worker_entry_manager"]
    G --> H{"SERVER_MODE?"}
    H -- yes --> I["thread_create_stats_worker_pool<br/>vacuum-master 데몬 생성"]
    H -- no --> J["스레드 없음 -- xvacuum이 잡 구동"]
    I --> K["vacuum_Is_booted = true"]
    J --> K
    K --> L["첫 master 반복 / 첫 SA 패스\nvacuum_data_load_first_and_last_page가 페이지 쌍 재고정"]

Figure 2-1: 부트 시퀀스; vacuum_initialize와 vacuum_boot가 크래시 복구를 앞뒤로 감싼다.

vacuum_boot(assert (!vacuum_Is_booted) — 부팅은 한 번만)는 네 개의 런타임 분기와 컴파일 타임 분기를 가진다: vacuum 비활성화 분기는 log_Gl.mvcc_table.update_global_oldest_visible()를 여전히 호출한다(“디버그 전용” — Chapter 5 워터마크는 vacuum이 꺼져 있어도 전진해야 한다); thread_p == NULL이면 thread_get_thread_entry_info로 폴백; 그리고 두 로드 단계의 오류 반환 — vacuum_data_load_and_recover(Chapter 11)와 vacuum_load_dropped_files_from_disk(Chapter 10). 전자가 하지 않는 일에 주목하자: 페이지 체인을 걷는 동안에만 페이지를 fix하며, end: 레이블에서 첫/마지막 VPID를 vacuum_Data_load에 저장하고 두 페이지를 unfix한다 — “vacuum master가 fix해야 한다”(코드 내 주석). 부트 스레드가 아니라 master가 직접 fix해야 한다(섹션 2.6). 그 이후에야 두 엔트리 매니저가 할당된다 — SA 경로도 vacuum_Worker_entry_manager로 워커를 획득하므로 두 모드 모두에서 생성된다. SERVER_MODE에서는 워커 풀이 PRM_ID_VACUUM_WORKER_COUNT개 스레드와 하나의 태스크 큐를 갖고(thread_create_stats_worker_pool), master는 PRM_ID_VACUUM_MASTER_WAKEUP_INTERVAL ms 간격으로 vacuum_master_task를 실행하는 cubthread::daemon이 된다. SA 빌드에서는 스레드가 생성되지 않는다. 교차 검토: log_vacuum_worker_pool은 로깅 플래그로 계산되지만 유일한 사용처가 주석 처리(// m_log = log_vacuum_worker_pool)되어 있다 — 이 리비전에서는 죽은 설정이다.

2.4 스레드 신원 — 엔트리 매니저, 시스템 tdes, 풀 핸드오프

섹션 제목: “2.4 스레드 신원 — 엔트리 매니저, 시스템 tdes, 풀 핸드오프”

Vacuum 스레드는 엔트리 매니저 훅이 브랜딩하기 전까지는 일반 cubthread 워커다. 두 매니저 모두 하나의 헬퍼로 수렴한다:

// vacuum_init_thread_context -- src/query/vacuum.c
context.type = type; /* <- TT_VACUUM_MASTER or TT_VACUUM_WORKER */
context.vacuum_worker = worker;
context.check_interrupt = false; /* <- vacuum is immune to client interrupt checks */
assert (context.get_system_tdes () == NULL);
context.claim_system_worker (); /* <- new log_system_tdes; tran_index = LOG_SYSTEM_TRAN_INDEX */

재시작 후 claim_system_workerlog_system_tran.cpp의 공유 할당기에서 tdes를 가져온다(systdes_claim_tdes: systb_Next_tranidLOG_SYSTEM_WORKER_FIRST_TRANID = NULL_TRANID - 1로 시드되고, -1씩 감소하며, 프리 리스트에서 재사용). VACUUM_WORKER_INDEX_TO_TRANID / ..._TRANID_TO_INDEX 매크로는 여전히 vacuum.c 상단에 있지만 호출자가 없다 — 슬롯별 고정 TRANID 설계의 잔재다. 지금은 바인딩이 동적으로 이루어지며, 복구 시에는 로그의 TRANID에서 log_system_tdes::rv_get_or_alloc_tdes를 호출해 시스템 트랜잭션을 재구성한다.

vacuum_master_entry_manager (cubthread::daemon_entry_manager 확장) — 데이터 멤버 없음, 두 개의 final override:

멤버역할존재 이유
on_daemon_createvacuum_Master.state == VACUUM_WORKER_STATE_EXECUTE assert; vacuum_init_thread_context (..., TT_VACUUM_MASTER, &vacuum_Master)Master의 VACUUM_WORKER는 정적 싱글톤; 풀 claim 불필요
on_daemon_retirevacuum_finalize (&context) (// todo: is this the rightful place? 태그); retire_system_worker; vacuum_worker null 처리(&vacuum_Master였음을 assert)데몬 종료에 서브시스템 해제를 편승 — 섹션 2.7

vacuum_worker_entry_manager (cubthread::entry_manager 확장):

멤버역할존재 이유
m_pool정적 50슬롯 배열에 대한 resource_shared_pool<VACUUM_WORKER>*; 소멸자에서 삭제비소유: claim()은 mutex 보호 프리 스택에서 슬롯 포인터를 꺼내고, retire()는 되돌림; 고정 스레드-슬롯 바인딩 없음
claim_worker / retire_workerm_pool로의 public 전달vacuum_sa_run_job이 풀 훅 없이 워커를 claim할 수 있게 함
on_createtran_index = 0; vacuum_init_thread_context (..., TT_VACUUM_WORKER, m_pool->claim ()); vacuum_worker_allocate_resources (실패 시 assert); private_lru_index를 엔트리에 복사풀링된 스레드가 첫 태스크 전에: 신원, tdes, 버퍼, private LRU 핸드오프
on_retireretire_system_worker; 워커 state = INACTIVE; m_pool->retire; vacuum_worker null; private_lru_index = -1on_create의 역; 엔트리가 깨끗하게 글로벌 매니저로 반환됨
on_recycletran_index = LOG_SYSTEM_TRAN_INDEX재활용은 엔트리를 NULL_TRAN_INDEX로 초기화; vacuum은 시스템 트랜잭션처럼 보여야 함

불변식 — vacuum 스레드 신원은 원자적이다. 엔트리가 vacuum 스레드인 것은 typeTT_VACUUM_MASTER/TT_VACUUM_WORKER이고, vacuum_worker가 non-NULL이며, 시스템 tdes가 claim되어 있고, tran_index == LOG_SYSTEM_TRAN_INDEX일 때 성립한다. 훅들은 이 네 가지를 함께 설정하고 해제한다(vacuum_init_thread_context는 tdes가 미리 존재하지 않음을 assert; on_retirevacuum_worker != NULL을 assert). 이 불변식이 깨지면 vacuum_get_vacuum_worker가 assert하고 vacuum 로그 레코드가 클라이언트 TRANID를 담게 된다.

SA 모드는 동일한 신원을 메인 스레드에 임시로 덮어씌운다: vacuum_convert_thread_to_master(저장된 thread_p->type, master 신원 설정, tdes가 없을 때만 claim), vacuum_convert_thread_to_worker(동일, vacuum_worker_allocate_resources 추가, 실패 시 assert_release), vacuum_restore_thread(type 복원, vacuum_worker null 처리, retire_system_worker, tran_index = LOG_SYSTEM_TRAN_INDEX). 각각은 thread_p == NULL이면 자기 조회로 폴백한다. xvacuum은 SA 패스를 convert-to-master/restore로 감싸며, vacuum_sa_run_job은 블록별로 convert-to-worker/back-to-master를 중첩하고, 저장된 type이 일치하는지 assert한 뒤 retire_worker로 슬롯을 반환한다.

2.5 지연 버퍼 — vacuum_worker_allocate_resources

섹션 제목: “2.5 지연 버퍼 — vacuum_worker_allocate_resources”
// vacuum_worker_allocate_resources -- src/query/vacuum.c
assert (worker->state == VACUUM_WORKER_STATE::VACUUM_WORKER_STATE_INACTIVE);
if (worker->allocated_resources)
return NO_ERROR; /* <- idempotent: SA mode re-converts the same thread repeatedly */
worker->log_zip_p = log_zip_alloc (IO_PAGESIZE);
// ... condensed: NULL -> logpb_fatal_error + return ER_FAILED ...
worker->heap_objects = (VACUUM_HEAP_OBJECT *) malloc (worker->heap_objects_capacity * sizeof (VACUUM_HEAP_OBJECT));
// ... condensed: NULL -> goto error; same for undo_data_buffer (IO_PAGESIZE) ...
worker->prefetch_log_buffer = (char *) malloc (VACUUM_PREFETCH_LOG_BLOCK_BUFFER_PAGES * LOG_PAGESIZE);
// ... condensed: NULL -> goto error ... /* <- (1 + log_block_npages) log pages */
assert (logtb_get_system_tdes (thread_p) != NULL); /* <- tdes must already be claimed */
worker->allocated_resources = true;

여섯 개의 분기: 단락 평가, 네 개의 할당 실패(각각 logpb_fatal_error 호출 — 버퍼 없는 워커는 서버 치명 오류, vacuum이 지연되면 부채가 무한정 쌓이기 때문이다), 그리고 성공. 네 가지 할당은 vacuum_initialize가 NULL로 남겨둔 네 개의 VACUUM_WORKER 버퍼 필드를 채운다 — log_zip_p, heap_objects(VACUUM_DEFAULT_HEAP_OBJECT_BUFFER_SIZE), undo_data_buffer(IO_PAGESIZE), prefetch_log_buffer — 그리고 allocated_resources = true로 설정한다. 첫 번째 실패는 직접 반환하고, 나머지는 goto errorvacuum_finalize_worker가 존재하는 부분만 해제한다 — 네 개의 독립적이고 멱등한 해제(log_zip_free 및 세 개의 free_and_init)로 구성되어, 할당 여부에 관계없이 50개 슬롯 전체와 vacuum_Mastervacuum_finalize가 적용하는 범용 해제로도 쓰인다. prefetch 크기는 Chapter 3의 블록 기하학과 연결된다: VACUUM_PREFETCH_LOG_BLOCK_BUFFER_PAGES = 1 + log_block_npages, 로그 블록 전체 하나에 페이지 하나 추가.

2.6 first_page와 last_page가 영구적으로 고정 상태를 유지하는 이유

섹션 제목: “2.6 first_page와 last_page가 영구적으로 고정 상태를 유지하는 이유”

vacuum_Data.first_pagelast_page는 모든 master 반복과 블록 추가에서 빈번하게 접근되므로, vacuum은 master의 전체 실행 기간 동안 이 페이지들에 write latch를 유지한다. 페이지 버퍼 규율은 정확히 세 개의 래퍼에서만 구부러진다:

// vacuum_fix_data_page -- src/query/vacuum.c
#define vacuum_fix_data_page(thread_p, vpidp) \
(vacuum_Data.first_page != NULL && VPID_EQ (pgbuf_get_vpid_ptr ((PAGE_PTR) vacuum_Data.first_page), vpidp) ? \
vacuum_Data.first_page : /* <- short-circuit: reuse held latch */ \
/* ... same test against vacuum_Data.last_page ... */ \
(VACUUM_DATA_PAGE *) pgbuf_fix (thread_p, vpidp, OLD_PAGE, PGBUF_LATCH_WRITE, PGBUF_UNCONDITIONAL_LATCH))

vacuum_unfix_data_pagevacuum_set_dirty_data_page도 동일한 동일성 검사를 적용한다: 유지 중인 쌍과 동일한 페이지는 절대 unfix되지 않으며, DONT_FREE로 dirty 표시된다. 교차 검토 주의: vacuum_set_dirty_data_page는 현재 포인터를 값으로 받는 인라인 함수이므로, 끝의 data_page = NULL은 지역 복사본만 클리어한다 — 여전히 매크로인 vacuum_unfix_data_page와 달리, 호출자의 포인터를 null로 만들 수 없다.

불변식 — 캐시된 페이지당 정확히 하나의 추가 fix, vacuum이 유지. first/last VPID에 대한 모든 fix는 반드시 이 래퍼를 거쳐야 한다. 이를 우회해 직접 pgbuf_fix/pgbuf_unfix를 호출하면 fix 카운트가 어긋나며, 디버그 빌드에서는 vacuum_verify_vacuum_data_page_fix_count로 이를 검증한다. 불변식이 깨지면 latch가 누수되거나 캐시된 포인터가 댕글링된다.

이 고정은 항상 vacuum_data_load_first_and_last_page에서 이루어진다 — 부트에서 직접 하지 않는다. vacuum_data_load_and_recover는 의도적으로 종료 시 페이지 쌍을 unload한다: vacuum_master_task::execute의 코드 내 주석은 load가 “원래 boot_restart_server에 있었지만”, 부트의 commit이 스레드가 fix한 채로 남긴 모든 페이지를 unfix하므로 문제가 된다고 설명한다. “따라서 여기에서 데이터를 load해야 한다(vacuum master는 절대 commit하지 않는다).” 그래서 SERVER_MODE에서는 master의 첫 번째 반복이 페이지 쌍을 re-fix하고, SA 모드에서는 xvacuumvacuum_sa_reflect_last_blockid가 이를 수행한다. Load 분기: is_loaded 조기 반환; 첫 페이지 fix 실패(assert_release, 반환); vpid_first == vpid_last인 단일 페이지 케이스에서 last_pagefirst_page를 별칭으로 가리키는 경우(vacuum_unfix_first_and_last_data_page가 두 VPID가 다를 때만 last_page를 unfix하는 이유); 마지막 페이지 fix 실패(둘 다 unfix, assert_release). 역연산인 vacuum_data_unload_first_and_last_page는 로드되지 않았을 때 조기 반환하고, 두 VPID를 파일 스코프 vacuum_Data_load 구조체에 저장하고, 페이지 쌍을 unfix하고, is_loaded를 클리어한다. VACUUM_DATA_LOAD 전체:

필드역할존재 이유
vpid_first언로드 시 저장된 첫 페이지 VPID파일 디스크립터 재조회 없이 리로드; NULL이면 한 번도 로드되지 않은 것(vacuum_sa_reflect_last_blockid가 검사)
vpid_last언로드 시 저장된 마지막 페이지 VPID동일; 리로드 시 페이지 체인을 걷지 않고 tail을 직접 re-fix

2.7 종료 — 워커 먼저, master 나중, 내부에서 finalize

섹션 제목: “2.7 종료 — 워커 먼저, master 나중, 내부에서 finalize”

xboot_shutdown_server(boot_sr.c)는 주석으로 순서 계약을 명시한다:

// xboot_shutdown_server -- src/transaction/boot_sr.c
log_abort_all_active_transaction (thread_p);
vacuum_stop_workers (thread_p); /* <- 1: no new jobs, drain pool */
// ... condensed: stats reflection, caches, boot_remove_all_temp_volumes ...
// only after all logging is finished can this vacuum master be stopped; boot_remove_all_temp_volumes
// may add a final log entry
vacuum_stop_master (thread_p); /* <- 2: daemon dies, vacuum_finalize runs */

vacuum_stop_workers!vacuum_Is_booted이면 조기 반환하고, vacuum_notify_server_shutdownvacuum_Data.shutdown_sequence.request_shutdown()을 호출한다. 풀이 존재하면 풀 통계를 로깅하고, stop_execution(), destroy_worker_pool, vacuum_Worker_entry_manager 삭제 순으로 진행 — 리소스 풀을 소멸시키며, 소멸자는 모든 워커가 retire되었음을 assert한다. vacuum_stop_master(동일한 가드)는 데몬이 존재하면 소멸시킨다 — master 자신의 스레드에서 on_daemon_retirevacuum_finalize를 트리거한다 — master 엔트리 매니저를 삭제하고 vacuum_Is_booted를 클리어한다. boot_restart_server의 error 레이블도 동일한 두 함수를 순서대로 호출하므로, 부팅 실패 시에도 순서가 보장된 해제를 재사용한다.

vacuum_shutdown_sequence — 전체 필드:

필드역할존재 이유
m_stateNO_SHUTDOWN에서 SHUTDOWN_REQUESTED(SERVER_MODE만)를 거쳐 SHUTDOWN_REGISTERED”종료 요청”과 “master 확인”을 분리; is_shutdown_requested / check_shutdown_request로 폴링
m_state_mutexSERVER_MODE만; 상태 전이와 condvar 보호요청과 확인이 서로 다른 스레드에서 발생
m_condvarSERVER_MODE만; 요청자가 request_shutdown 안에서 대기request_shutdown을 동기화 — 등록이 완료된 후에야 반환

핸드셰이크: request_shutdown은 이미 SHUTDOWN_REGISTERED이면 즉시 반환한다(재요청은 no-op). 그렇지 않으면 SHUTDOWN_REQUESTED를 설정하고 m_state == SHUTDOWN_REGISTERED || vacuum_Master_daemon == NULL이 될 때까지 블록한다. 다음 wakeup에서 master의 vacuum_master_task::check_shutdowncheck_shutdown_request를 호출한다: NO_SHUTDOWN → false; SHUTDOWN_REGISTERED → true; SHUTDOWN_REQUESTED → mutex를 잡고, SHUTDOWN_REGISTERED를 설정하고, notify_one하고, true 반환(SA 빌드에서 이 분기는 assert(false) — 요청자 스레드가 존재하지 않는다). 데몬이 생성된 적 없으면 요청자가 직접 등록한다. SA 모드에서 request_shutdown은 바로 SHUTDOWN_REGISTERED로 진입한다.

stateDiagram-v2
    [*] --> NO_SHUTDOWN
    NO_SHUTDOWN --> SHUTDOWN_REQUESTED : request_shutdown\nSERVER_MODE, 요청자가 condvar에서 블록
    SHUTDOWN_REQUESTED --> SHUTDOWN_REGISTERED : master check_shutdown_request\n또는 데몬 NULL일 때 요청자가 직접 등록
    NO_SHUTDOWN --> SHUTDOWN_REGISTERED : SA_MODE에서 request_shutdown
    SHUTDOWN_REGISTERED --> [*]

Figure 2-2: vacuum_shutdown_sequence 상태; REQUESTED 상태는 SERVER_MODE에서만 존재한다.

불변식 — destroy 전에 request. request_shutdown은 master 데몬이 살아있는 동안(또는 생성되기 전에) 실행되어야 한다. master의 확인 또는 daemon-NULL 탈출만이 요청자를 unblock한다. workers-before-master 순서가 이를 보장하며, 순서가 뒤바뀌면 종료 스레드가 알려줄 사람이 없는 m_condvar에서 영원히 대기할 수 있다.

vacuum_finalize(init 오류 경로, master 종료, SA xvacuum 종료에서 도달)는 여섯 단계를 순서대로 수행한다: vacuum 비활성화 반환; assert (!vacuum_is_work_in_progress ...); vacuum_Finished_job_queuevacuum_data_mark_finished(Chapter 9)로 비우고, 비워졌음을 assert하고, 삭제; 루프로 vacuum_Block_data_buffervacuum_consume_buffer_log_blocks로 소비 — 소비가 vacuum 데이터에 추가하고, 이것이 로깅되어 또 다른 블록을 완료할 수 있으므로 루프가 필요하다 — vacuum_Data.is_loaded가 false이면 안전장치 assert/break; SA 빌드에서는 vacuum_data_empty_update_last_blockid; 그 후 vacuum_data_unload_first_and_last_page, 안전벨트로 pgbuf_unfix_all, 50개 슬롯 전체와 vacuum_Mastervacuum_finalize_worker, 삭제된 파일 mutex에 pthread_mutex_destroy.

SA 모드는 finalize 호출자가 뒤바뀐다. (xvacuum의 SERVER_MODE 빌드는 ER_VACUUM_CS_NOT_AVAILABLE을 반환하는 스텁 — 클라이언트가 발행한 VACUUM 문은 거부된다; 나머지는 SA 컴파일 분기다.) 데몬이 없으므로 vacuum_stop_master가 이를 트리거하지 않는다 — vacuum_finalizexvacuum 끝에서, convert-to-master/restore 괄호 안에서 실행된다. 그 후 vacuum_Data.is_vacuum_complete = true가 이후의 xvacuum 호출을 no-op으로 만든다(플래그는 vacuum_initialize가 부트 시 리셋한다). vacuum_stop_master 이후 SA xboot_shutdown_server는 한 단계를 더 추가한다: vacuum_sa_reflect_last_blockid. 이 함수는 vacuum_Data_load.vpid_first가 NULL이면 조기 반환하고(신선한 createdb나 중단된 부팅), vacuum_Data.is_restoredb_session이 설정되어 있으면 반환한다(“restoredb는 vacuum하지 않는다” — 섹션 2.1 플래그의 유일한 소비자). 그 외에는 페이지 쌍을 리로드하고, logpb_last_complete_blockid()를 가져오며(VACUUM_NULL_LOG_BLOCKID이면 조기 종료), vacuum_data_empty_update_last_blockid로 지속시키고, 다시 언로드한다.

  1. 시작은 크래시 복구를 기준으로 두 단계다: vacuum_initialize(파라미터 수집, 큐, 정적 vacuum_Master/vacuum_Workers[50])는 log_initialize 이전에 실행되고, vacuum_boot(데이터 로드 + 복구, 삭제된 파일 로드, 데몬 및 풀 생성)는 이후에 vacuum_Is_booted를 게이트로 실행된다.
  2. 온디스크 쌍은 createdb에서 탄생한다: vacuum_create_file_for_vacuum_data는 첫 페이지 VPID를 파일 디스크립터에 저장하고, vacuum_create_file_for_dropped_files는 sticky first page를 사용한다 — 두 부트 타임 조회 경로가 이를 그대로 반영한다.
  3. 스레드 신원은 엔트리 매니저 훅이나 SA 변환 함수로만 부여되며, 항상 묶음으로: 스레드 타입, vacuum_worker 포인터, 음수 TRANID를 가진 동적으로 claim된 시스템 tdes(VACUUM_WORKER_INDEX_TO_TRANID는 호출자 없는 잔재), LOG_SYSTEM_TRAN_INDEX, 그리고 private LRU 핸드오프.
  4. 워커 슬롯은 풀링되며 바인딩되지 않는다: 정적 배열에 대한 resource_shared_pool이 어느 스레드든 어느 슬롯이든 claim할 수 있게 한다. private_lru_index만 즉시 할당되고, 나머지는 vacuum_worker_allocate_resources에서 지연·멱등적으로 도착하며, 실패는 logpb_fatal_error로 에스컬레이션된다.
  5. first_page/last_page는 master 전체 실행 동안 write latch를 유지한다 — 부트가 아닌 master의 첫 번째 반복(또는 SA 패스)에서 설정된다. 부트의 commit이 스레드가 fix한 latch를 해제하기 때문이다. vacuum_fix_data_page, vacuum_unfix_data_page, vacuum_set_dirty_data_pageDONT_FREE 분기만이 유일한 합법적 접근 경로이며, 디버그 fix 카운트 검사로 검증된다.
  6. 종료 순서는 workers-then-master: vacuum_stop_workers가 데몬이 살아있는 동안 동기적 vacuum_shutdown_sequence 핸드셰이크를 실행하고, vacuum_stop_master가 데몬을 소멸시키면 on_daemon_retirevacuum_finalize를 실행한다. 부팅 오류 경로도 동일한 두 호출을 재사용한다.
  7. SA 모드에는 데몬이 없다: vacuum_finalizexvacuum 안에서 실행되고(is_vacuum_complete가 재진입을 차단), 종료 시 vacuum_sa_reflect_last_blockid가 추가된다 — vacuum_initialize에서 수집된 is_restoredb_session 플래그로 restoredb 세션에서는 건너뛴다.

Chapter 3: 로그 어펜드 경로에서의 블록 생성

섹션 제목: “Chapter 3: 로그 어펜드 경로에서의 블록 생성”

서브시스템이 부팅(2장)되어 유휴 상태다. 블록은 어디서 오는가? vacuum.c 내부가 아니다: 블록은 일반 트랜잭션이 로그 레코드를 어펜드할 때의 부수 효과로, log_append.cpp 안의 prior_lsa_next_record_internalvacuum_produce_log_block_data를 호출하는 유일한 지점 — 에서, prior-LSA 뮤텍스를 보유한 채 생성된다. Vacuum은 vacuum_Block_data_buffer에서 블록을 수신할 뿐이다. 이 장은 생산자(producer) 측의 모든 분기를 따라간다.

3.1 생산자 파이프라인 전체 조망

섹션 제목: “3.1 생산자 파이프라인 전체 조망”

모든 로그 레코드는 먼저 log_prior_node로 구체화되어 prior 리스트 — 로그 플러셔가 나중에 로그 페이지로 복사하는 인메모리 큐 — 에 어펜드된다. 레코드가 MVCC 계열이면, 동일한 뮤텍스 보유 하에 두 가지 작업이 추가로 일어난다: vacuum_info.prev_mvcc_op_log_lsa가 이전 MVCC 레코드를 가리키도록 패치되고, log_Gl.hdr의 블록별 어큐뮬레이터가 앞으로 접힌다. 레코드의 시작 LSA가 어큐뮬레이터와 다른 로그 블록에 속하면, 대기 중인 블록이 닫히고 vacuum_data_entry로 vacuum에 푸시된다.

flowchart LR
  NODE["log_prior_node"] -->|"prior_lsa_next_record\nprior_lsa_mutex 하에"| LIST["prior 리스트"]
  NODE -->|"MVCC 타입: vacuum_info 패치,\nmvccid를 헤더에 접기"| HDR["log_Gl.hdr 어큐뮬레이터\nmvcc_op_log_lsa\noldest_visible_mvccid\nnewest_block_mvccid\ndoes_block_need_vacuum"]
  HDR -->|"블록 경계 교차:\nvacuum_produce_log_block_data"| ENTRY["vacuum_data_entry"]
  ENTRY --> QUEUE["vacuum_Block_data_buffer\nlockfree circular_queue, 용량 1024"]
  QUEUE -->|"4장에서 소비"| VD["vacuum 데이터"]

Figure 3-1: 생산자 측. 큐 왼쪽의 모든 작업은 log_append.cpp 안에서 트랜잭션 스레드가 수행한다; vacuum은 소비만 한다.

3.2 log_prior_nodelog_Gl.hdr 어큐뮬레이터

섹션 제목: “3.2 log_prior_node와 log_Gl.hdr 어큐뮬레이터”
// log_prior_node -- src/transaction/log_append.hpp
struct log_prior_node
{
LOG_RECORD_HEADER log_header;
LOG_LSA start_lsa; /* for assertion */
// ... condensed: tde_encrypted ...
int data_header_length;
char *data_header;
// ... condensed: ulength/udata, rlength/rdata payload pointers ...
LOG_PRIOR_NODE *next;
};

세 필드가 중요하다. start_lsaprior_lsa_start_append에 의해 할당되며 경계 검사를 주도한다. log_header.type은 디스패치 분기를 선택한다. data_header는 타입별 고정 헤더(예: LOG_REC_MVCC_UNDOREDO)를 메모리상에 보유하는데, 어펜드 경로가 이를 캐스팅하고 vacuum_info를 제자리에서 패치한다 — 바이트가 로그 페이지에 기록되기 전에. 이것이 MVCC 역방향 체인을 가능하게 하는 핵심 메커니즘이다.

어큐뮬레이터는 log_header — 레코드별 헤더가 아니라, 파일 헤더인 log_Gl.hdr(log_storage.hpp) — 에 위치한다. 각 필드의 의미는 does_block_need_vacuum 값에 따라 달라진다:

log_Gl.hdr 필드does_block_need_vacuum == false== true
mvcc_op_log_lsa이미 생산된 블록의 마지막 MVCC 연산 LSA (유지됨 — 불변조건 3-E)대기 중인 블록의 마지막 MVCC 연산 LSA
oldest_visible_mvccid낡음; 다음 블록 생성 시 재샘플링블록의 첫 MVCC 레코드 시점에 고정된 수평선
newest_block_mvccidMVCCID_NULL (produce 시 리셋)블록 내 MVCCID의 누적 최댓값
does_block_need_vacuum경계 분기 비활성화대기 블록 존재; 경계 분기 활성화

3.3 prior_lsa_next_record_internal — 분기 완전 분석

섹션 제목: “3.3 prior_lsa_next_record_internal — 분기 완전 분석”

두 개의 공개 진입점이 모두 이 함수로 합류한다 — prior_lsa_next_record는 뮤텍스를 직접 획득 (LOG_PRIOR_LSA_WITHOUT_LOCK)하고, prior_lsa_next_record_with_lock은 이미 뮤텍스를 보유한 호출자용이다. 먼저 prior_lsa_start_appendlog_Gl.prior_info.prior_lsa를 기반으로 node->start_lsa를 스탬프하고 트랜잭션별 undo 체인을 연결한다 — 단, sysop 외부에서 실행되는 system-worker 트랜잭션(vacuum 워커 자체가 이 방식으로 로깅)은 체인 LSA가 null로 처리된다. 이후 세 구역이 순서대로 실행된다: (A) 블록 경계 검사, (B) 레코드 타입 디스패치, (C) 리스트 삽입 및 오버플로.

flowchart TD
  A1["prior_lsa_start_append\nstart_lsa 할당"] --> B0{"LOG_ISRESTARTED이고\ndoes_block_need_vacuum?"}
  B0 -->|아니오| C0
  B0 -->|"예, 그리고 hdr.mvcc_op_log_lsa의\nblockid != start_lsa의 blockid"| B2["vacuum_produce_log_block_data\n대기 블록 닫기"]
  B2 --> C0{"레코드 타입?"}
  C0 -->|"MVCC 계열 4종"| D1["vacuum_info + mvccid 추출\nprev_mvcc_op_log_lsa 패치\nprior_update_header_mvcc_info"]
  C0 -->|"복구 부기용\n타입 그룹 5종"| E1["LSA를\ntdes rcv 상태에 저장"]
  C0 -->|"그 외"| F0
  D1 --> F0["페이로드 어펜드\nprior_lsa_end_append\nprior 리스트 꼬리에 삽입"]
  E1 --> F0
  F0 --> I0{"WITHOUT_LOCK이고\nlist_size >= 로그 버퍼 크기?"}
  I0 -->|아니오| Z["start_lsa 반환"]
  I0 -->|"예, 서버, 크래시 복구 아님"| I3["로그 플러시 데몬 깨우기\n1ms 대기"]
  I0 -->|"예, 크래시 복구 또는 SA"| I4["prior 리스트 인라인 플러시\nLOG_CS 하에"]
  I3 --> Z
  I4 --> Z

Figure 3-2: prior_lsa_next_record_internal. Vacuum의 핵심 흐름은 B0에서 D1까지이며, E1은 non-vacuum 디스패치 분기를 담당한다.

구역 A는 모든 레코드 타입에서 타입 디스패치 이전에 실행된다:

// prior_lsa_next_record_internal -- src/transaction/log_append.cpp
if (LOG_ISRESTARTED () && log_Gl.hdr.does_block_need_vacuum)
{
assert (!LSA_ISNULL (&log_Gl.hdr.mvcc_op_log_lsa));
if (vacuum_get_log_blockid (log_Gl.hdr.mvcc_op_log_lsa.pageid) != vacuum_get_log_blockid (start_lsa.pageid))
{
assert (vacuum_get_log_blockid (log_Gl.hdr.mvcc_op_log_lsa.pageid)
<= (vacuum_get_log_blockid (start_lsa.pageid) - 1)); /* <- 대기 블록이 엄격히 더 오래됨 */
vacuum_produce_log_block_data (thread_p);
}
}

두 가지 결과가 따라온다. 첫째, 대기 블록은 경계를 넘은 다음 레코드가 어떤 타입이든 닫힌다 — 평범한 LOG_COMMIT으로 충분하다. 조용한 시스템에서는 트래픽이 재개될 때까지 블록이 열린 채로 남아 있으며, 소비자 측이 이를 허용해야 한다(4장, 9장). 둘째, LOG_ISRESTARTED() (log_Gl.rcv_phase == LOG_RESTARTED, log_impl.h)는 복구 중에 생산을 비활성화한다 — 불변조건 3-C.

구역 B는 정확히 네 가지 MVCC 형태를 인식하며, 페이로드 위치가 다르기 때문에 세 가지 추출 방식으로 처리된다:

// prior_lsa_next_record_internal -- src/transaction/log_append.cpp
if (node->log_header.type == LOG_MVCC_UNDO_DATA || node->log_header.type == LOG_MVCC_UNDOREDO_DATA
|| node->log_header.type == LOG_MVCC_DIFF_UNDOREDO_DATA
|| (node->log_header.type == LOG_SYSOP_END
&& ((LOG_REC_SYSOP_END *) node->data_header)->type == LOG_SYSOP_END_LOGICAL_MVCC_UNDO))
{
// ... condensed: vacuum_info / mvccid from LOG_REC_MVCC_UNDO (undo and
// sysop_end.mvcc_undo cases) or LOG_REC_MVCC_UNDOREDO (undoredo
// and diff-undoredo, which share the struct) ...
/* Save previous mvcc operation log lsa to vacuum info */
LSA_COPY (&vacuum_info->prev_mvcc_op_log_lsa, &log_Gl.hdr.mvcc_op_log_lsa);
prior_update_header_mvcc_info (start_lsa, mvccid);
}

LSA_COPYMVCC 역방향 체인을 용접한다: 새 레코드의 log_vacuum_info::prev_mvcc_op_log_lsa(log_record.hpp)에 이전 MVCC 레코드의 LSA가, 노드의 data_header 바이트에 직접 기록된다. 워커(7장)와 크래시 복구(11장)는 이 체인을 역방향으로 순회한다.

non-vacuum else if 분기 다섯 개: LOG_SYSOP_START_POSTPONE, non-MVCC LOG_SYSOP_END, LOG_COMMIT_WITH_POSTPONE(+_OBSOLETE), LOG_SYSOP_ATOMIC_START, LOG_COMMIT/LOG_ABORT — 모두 동일한 뮤텍스 보유 하에 복구 부기용 LSA를 tdes->rcv(또는 tdes 상태)에 저장하며, vacuum 어큐뮬레이터에는 전혀 손대지 않는다.

구역 C는 노드를 prior 리스트 꼬리에 삽입하고, list_size(바이트 단위)를 늘리며 — WITHOUT_LOCK 모드에서만, 뮤텍스 해제 후 — logpb_get_memsize() 대비 오버플로를 검사한다. 오버플로 시, 크래시 복구 외 서버 모드는 플러시 데몬을 깨우고 1ms 대기한다; 크래시 복구 및 standalone 모드는 LOG_CS 하에 리스트를 인라인으로 플러시한다. with-lock 호출자는 뮤텍스를 보유한 상태에서 플러시할 수 없으므로 이 단계를 건너뛴다.

3.4 prior_update_header_mvcc_info — 블록을 앞으로 접기

섹션 제목: “3.4 prior_update_header_mvcc_info — 블록을 앞으로 접기”
// prior_update_header_mvcc_info -- src/transaction/log_append.cpp
static void
prior_update_header_mvcc_info (const LOG_LSA &record_lsa, MVCCID mvccid)
{
if (!log_Gl.hdr.does_block_need_vacuum)
{
// first mvcc record for this block
log_Gl.hdr.oldest_visible_mvccid = log_Gl.mvcc_table.get_global_oldest_visible (); /* <- 단 한 번 샘플링 */
log_Gl.hdr.newest_block_mvccid = mvccid;
}
else
{
// ... condensed: sanity asserts ...
assert (vacuum_get_log_blockid (log_Gl.hdr.mvcc_op_log_lsa.pageid) == vacuum_get_log_blockid (record_lsa.pageid));
if (log_Gl.hdr.newest_block_mvccid < mvccid)
{
log_Gl.hdr.newest_block_mvccid = mvccid; /* <- 누적 최댓값; ID는 순서 없이 도착할 수 있음 */
}
}
log_Gl.hdr.mvcc_op_log_lsa = record_lsa;
log_Gl.hdr.does_block_need_vacuum = true;
}

블록은 글자 그대로 false 분기에서 탄생한다 — oldest_visible_mvccid가 (재)샘플링되는 유일한 지점이 바로 이곳이다.

불변조건 3-A — 워터마크는 로깅 시점에 고정된다. oldest_visible_mvccid는 블록의 MVCC 레코드가 로깅될 때 정확히 한 번 샘플링되며, 소비 시점에는 절대 재샘플링되지 않는다. 나중에 재샘플링하면 진보한 수평선이 블록의 undo가 생성될 당시 아직 활성 중이던 트랜잭션을 배제할 수 있어, 단조 증가해야 하는 oldest_unvacuumed 추적(5장)이 깨진다. 복구 경로조차 재건된 블록 사이에서 이 값을 리셋하기를 거부한다 (vacuum_recover_lost_block_data의 “we don’t reset data.oldest_visible_mvccid between blocks” 주석). 복구 경로는 여기서 라이브 경로와 동일하지 않다 — 교차 검증 노트(3.7) 참조.

불변조건 3-B — 하나의 어큐뮬레이터, 하나의 블록. 어큐뮬레이터에 접힌 모든 레코드는 동일한 로그 블록 안에 있다. 순서로 보장된다: 구역 A가 prior_lsa_mutex를 한 번 보유한 채 prior_update_header_mvcc_info 이전에 실행되며, else 분기에서 vacuum_get_log_blockid 동등성을 assert한다. 이를 어기면 엔트리의 blockid가 자신의 레코드 전체를 커버하지 못하게 된다.

불변조건 3-C — 복구 중에는 절대 생산하지 않는다. LOG_ISRESTARTED 가드가 redo 시점의 어펜드로 인해 엔트리가 푸시되는 것을 막는다; 크래시 시점에 대기 중이던 블록은 vacuum_recover_lost_block_data가 재건한다(11장). 이 가드가 없으면, 재생된 어펜드가 크래시 이전에 이미 vacuum 데이터에 등록된 블록의 중복을 만들어낼 것이다.

3.5 vacuum_get_log_blockid — 고정된 블록 구조

섹션 제목: “3.5 vacuum_get_log_blockid — 고정된 블록 구조”
// vacuum_get_log_blockid -- src/query/vacuum.c
VACUUM_LOG_BLOCKID
vacuum_get_log_blockid (LOG_PAGEID pageid)
{
if (prm_get_bool_value (PRM_ID_DISABLE_VACUUM) || pageid == NULL_PAGEID)
{
return VACUUM_NULL_LOG_BLOCKID;
}
assert (vacuum_Data.log_block_npages != 0);
return pageid / vacuum_Data.log_block_npages;
}
// VACUUM_FIRST_LOG_PAGEID_IN_BLOCK -- src/query/vacuum.c
#define VACUUM_FIRST_LOG_PAGEID_IN_BLOCK(blockid) \
((blockid) * vacuum_Data.log_block_npages)
#define VACUUM_LAST_LOG_PAGEID_IN_BLOCK(blockid) \
(VACUUM_FIRST_LOG_PAGEID_IN_BLOCK (blockid + 1) - 1)

블록은 순수한 산술이다; 경계 분기가 블록 하나가 끝났다고 판단하기 전까지는 블록 객체가 존재하지 않는다. disabled/NULL_PAGEID 조기 반환은 VACUUM_NULL_LOG_BLOCKID(-1, log_common_impl.h)를 산출해서, vacuum이 꺼진 상태에서 경계 비교를 무력화한다.

불변조건 3-D — 블록 경계는 고정된 로그 페이지 수에 정렬된다. 블록 b는 정확히 페이지 [b * log_block_npages, (b+1) * log_block_npages - 1]를 커버한다; 위의 나눗셈과 곱셈은 정확한 역함수 관계다. log_block_npagesvacuum_initialize(2장)에서 한 번 설정되고 다시는 변경되지 않는다 — vacuum 데이터에 저장된 모든 blockid는 이 구조를 인코딩한다. 워커의 페이지 범위 종료(7장)와 복구의 블록별 정지 페이지(11장)가 이에 의존한다.

3.6 vacuum_produce_log_block_data — 엔트리 발행

섹션 제목: “3.6 vacuum_produce_log_block_data — 엔트리 발행”
// vacuum_produce_log_block_data -- src/query/vacuum.c
void
vacuum_produce_log_block_data (THREAD_ENTRY * thread_p)
{
if (prm_get_bool_value (PRM_ID_DISABLE_VACUUM))
{
return; /* branch 1: vacuum 비활성화 */
}
assert (log_Gl.hdr.does_block_need_vacuum == true);
VACUUM_DATA_ENTRY block_data { log_Gl.hdr }; /* <- 어큐뮬레이터 스냅샷 */
// reset info for next block
log_Gl.hdr.does_block_need_vacuum = false;
log_Gl.hdr.newest_block_mvccid = MVCCID_NULL; /* <- mvcc_op_log_lsa는 의도적으로 리셋 안 함 */
if (vacuum_Block_data_buffer == NULL)
{
assert (false);
return; /* branch 2: 미부팅 상태, 디버그 전용 트랩 */
}
// ... condensed: vacuum_er_log of the new entry ...
if (!vacuum_Block_data_buffer->produce (block_data))
{
/* TODO: ... Make sure that we do not lose vacuum data ... */
vacuum_er_log_error (VACUUM_ER_LOG_ERROR, "%s", "Cannot produce new log block data! The buffer is already full.");
assert (false);
return; /* branch 3: 버퍼 가득 참 -- 릴리즈 빌드에서 엔트리 유실 */
}
perfmon_add_stat (thread_p, PSTAT_VAC_NUM_TO_VACUUM_LOG_PAGES, vacuum_Data.log_block_npages);
}

엔트리 생성자(log_header 오버로드가 이쪽으로 위임)는 어큐뮬레이터의 마지막 레코드 LSA에서 blockid를 도출한다:

// vacuum_data_entry::vacuum_data_entry -- src/query/vacuum.c
vacuum_data_entry::vacuum_data_entry (const log_lsa &lsa, MVCCID oldest, MVCCID newest)
: blockid (VACUUM_NULL_LOG_BLOCKID)
, start_lsa (lsa) /* <- 이름과 달리, 블록 내 마지막 MVCC 연산의 LSA */
, oldest_visible_mvccid (oldest)
, newest_mvccid (newest)
{
// ... condensed: asserts, incl. oldest <= newest ...
blockid = vacuum_get_log_blockid (start_lsa.pageid);
}

start_lsa는 워커가 역방향 순회를 시작하는 지점 — 마지막 MVCC 연산 — 이지, 블록의 첫 번째 페이지가 아님에 유의한다.

blockidVACUUM_LOG_BLOCKIDlog_storage.hppint64_t 별칭 — 이지만, 하위 61비트만 블록 번호를 담는다; 상위 3비트는 라이프사이클 플래그로, 마스터와 워커가 나중에 설정하며(5장-7장), 생산자는 절대 건드리지 않는다:

// VACUUM_DATA_ENTRY_FLAG_MASK -- src/query/vacuum.c
#define VACUUM_DATA_ENTRY_FLAG_MASK 0xE000000000000000
#define VACUUM_DATA_ENTRY_BLOCKID_MASK 0x1FFFFFFFFFFFFFFF
#define VACUUM_BLOCK_STATUS_MASK 0xC000000000000000
#define VACUUM_BLOCK_STATUS_VACUUMED 0x8000000000000000
#define VACUUM_BLOCK_STATUS_IN_PROGRESS_VACUUM 0x4000000000000000
#define VACUUM_BLOCK_STATUS_AVAILABLE 0x0000000000000000
#define VACUUM_BLOCK_FLAG_INTERRUPTED 0x2000000000000000
비트의미
63-62상태: 10 vacuumed, 01 in-progress, 00 available
61VACUUM_BLOCK_FLAG_INTERRUPTED — 작업이 중단됨 (6장)
60-0블록 번호, VACUUM_BLOCKID_WITHOUT_FLAGS로 추출

(마스크 위에 있는 소스 내 주석 “first bit will be used for this flag”는 3비트 현실이 되기 전에 작성된 것이다.) 새로 생성된 엔트리의 플래그는 모두 0 — VACUUM_BLOCK_STATUS_AVAILABLE — 인데, vacuum_get_log_blockid가 반환하는 순수 몫은 비트 61 훨씬 아래에 위치하기 때문이다.

분기 3은 강조할 필요가 있다: vacuum_Block_data_buffervacuum_initialize에서 할당된 용량 VACUUM_BLOCK_DATA_BUFFER_CAPACITY(1024) 엔트리의 lock-free 원형 큐다. 가득 차면 엔트리는 로깅되고 assert 후 릴리즈 빌드에서 유실된다 — 어큐뮬레이터는 두 줄 앞에서 이미 리셋되었으므로 재시도 기회가 없다. 소스 내 TODO가 이를 인정하고 있으며, 실제로는 소비자(vacuum_consume_buffer_log_blocks, 4장)가 1024블록 분량의 로그를 기록하는 속도보다 훨씬 빠르게 소비한다.

불변조건 3-E — MVCC 체인은 블록 경계를 넘어 연속된다. vacuum_produce_log_block_datadoes_block_need_vacuumnewest_block_mvccid를 리셋하지만, log_Gl.hdr.mvcc_op_log_lsa는 이전 블록의 마지막 MVCC 레코드를 가리킨 채로 유지된다. 그래서 다음 블록의 첫 MVCC 레코드가 경계를 넘어 이전으로 연결된다 — 구역 B의 LSA_COPYprior_update_header_mvcc_info가 값을 덮어쓰기 전에 이 낡은 값을 읽는다. 이 덕분에 vacuum_recover_lost_block_data가 한 번의 역방향 순회로 여러 유실 블록을 재건할 수 있으며(11장), 이 필드를 NULL로 리셋하면 복구가 단일 블록에 한정된다.

3.7 교차 검증 노트 — 생산자 대 복구 재건

섹션 제목: “3.7 교차 검증 노트 — 생산자 대 복구 재건”

라이브 생산자와 크래시 복구 재건자 (vacuum_recover_lost_block_data, 11장)는 두 가지 서로 다른 규칙으로 엔트리를 발행하므로, 혼동하기 쉽다:

  • oldest_visible_mvccid 출처. 라이브 경로는 get_global_oldest_visible로 블록당 한 번 전역 수평선을 샘플링한다(불변조건 3-A). 복구는 그럴 수 없다 — 그 수평선은 이미 사라졌다 — 그래서 블록의 레코드를 재생하는 동안 관찰한 MVCCID의 최솟값을 사용한다(MVCC_ID_PRECEDES); 더 나중에, 더 작은 MVCCID가 등장한다는 것은 블록 당시 활성 중이던 트랜잭션이 있었다는 뜻이므로, 최솟값이 안전한 대체값이 된다. 두 값이 같을 필요는 없다.
  • 방향. 라이브 경로는 현재 블록을 앞으로 진행하며 닫는다; 복구는 prev_mvcc_op_log_lsa를 따라 여러 블록을 역방향으로 순회한 뒤 std::stack에서 가장 오래된 것부터 꺼내 produce한다.
  • 마지막 블록은 재건이 아닌 재무장. 재건된 블록이 라이브 헤더가 여전히 소유한 것(blockid == vacuum_get_log_blockid(prior_lsa.pageid))이라면, 복구는 이를 큐에 밀어 넣는 대신 log_Gl.hdr에 복원한다 — 두 경로가 합류하는 이음매다.
  1. 블록은 vacuum.c가 아닌 log_append.cpp에서 태어난다: prior_lsa_next_record_internal이 MVCC 정보를 log_Gl.hdr에 접어 넣고, 레코드의 start_lsa가 블록 경계를 넘을 때 vacuum_data_entry를 발행한다 — 모두 prior_lsa_mutex 하에서, 어큐뮬레이터의 유일하고도 충분한 직렬화로.
  2. 어큐뮬레이터에 공급하는 레코드 형태는 네 가지다 — LOG_MVCC_UNDO_DATA, LOG_MVCC_UNDOREDO_DATA, LOG_MVCC_DIFF_UNDOREDO_DATA, 그리고 LOG_SYSOP_END_LOGICAL_MVCC_UNDO를 담은 LOG_SYSOP_END — 각각 MVCCID를 기여하고, 여전히 메모리상에 있는 data_headerprev_mvcc_op_log_lsa 역방향 링크를 패치받는다.
  3. 경계 검사는 모든 레코드 타입에서 실행된다; 조용한 시스템은 마지막 블록을 log_Gl.hdr에 열린 채로 두며, 트래픽이 재개될 때까지 대기한다(4장/9장에서 처리).
  4. 블록은 고정된 산술적 페이지 범위다(불변조건 3-D); 엔트리의 64비트 blockid는 비트 60-0에 블록 번호를 담고 비트 63-61을 상태/중단 플래그로 예약하며, 생성 시점에는 모두 0이다.
  5. oldest_visible_mvccid는 블록의 첫 MVCC 레코드에서 고정되고 (불변조건 3-A), newest_block_mvccid는 누적 최댓값이며, mvcc_op_log_lsa는 블록 생산 후에도 남아 역방향 체인을 이어준다 (불변조건 3-E).
  6. LOG_ISRESTARTED가 복구 중 생산을 비활성화하며(불변조건 3-C), 1024개 엔트리 핸드오프 큐는 오버플로 시 assert와 에러 로그만 남기고 엔트리를 유실한다 — 알려진, 허용된 취약 지점이다.

Chapter 4: 블록 등록 — Vacuum Data로의 기록

섹션 제목: “Chapter 4: 블록 등록 — Vacuum Data로의 기록”

3장은 vacuum_data_entry 하나가 vacuum_Block_data_buffer(휘발성 메모리 위의 lock-free 원형 큐)에 들어간 상태로 끝났다. 이 시점에 크래시가 발생하면 항목은 사라진다. 이 장에서는 그 문제를 해결하는 함수 vacuum_consume_buffer_log_blocks를 분기별로 살펴본다. 버퍼 소진, blockid 간격 채우기, last_page->index_free에 항목 추가, 페이지가 가득 찼을 때의 파일 확장, 그리고 그 배경에 있는 redo-only WAL 프로토콜까지 순서대로 설명한다.

4.1 consume 경로를 호출하는 주체와 시점

섹션 제목: “4.1 consume 경로를 호출하는 주체와 시점”

vacuum_consume_buffer_log_blocks의 직접 호출 지점은 정확히 세 곳이다.

호출자모드트리거
vacuum_data::updateboth정규 래퍼: 완료된 job을 마킹(9장)한 뒤 consume. vacuum_job_cursor::force_data_update에서 호출.
vacuum_recover_lost_block_databoot로그 테일을 버퍼로 재생한 뒤 즉시 consume(11장).
vacuum_finalizeshutdown루프에서 버퍼를 완전히 소진 — consume이 append를 유발하고, 그 append가 또 다른 블록을 완료시킬 수 있다.

SERVER_MODE에서는 vacuum_master_task::execute가 매 wakeup마다 m_cursor.force_data_update()를 한 번 호출하고, should_force_data_updatevacuum_Finished_job_queue 또는 vacuum_Block_data_bufferis_half_full()이 참일 때마다 추가로 호출한다(“가득 찰 때까지 기다리지 않는다”; 로그 appender가 큐가 꽉 찬 상황을 만나서는 안 된다). 두 큐의 크기는 독립적으로 결정된다. VACUUM_BLOCK_DATA_BUFFER_CAPACITY = 1024, VACUUM_FINISHED_JOB_QUEUE_CAPACITY = 2048.

SA 모드에서는 xvacuum이 커서 루프를 인라인으로 실행하며, 버퍼가 비어 있지 않을 때, vacuum_Finished_job_queue->is_full()일 때, 또는 커서가 끝을 벗어날 때 update를 강제한다. SA 셧다운 시에는 xboot_shutdown_server(boot_sr.c)가 vacuum_sa_reflect_last_blockid를 호출한다. 이 함수는 last_blockidlogpb_last_complete_blockid()로 점프시키고, log_Gl.hdr.vacuum_last_blockid에 반영한 뒤, vacuum_data_empty_update_last_blockid로 빈 페이지에 다시 기록한다. 덕분에 다음 부팅 시 SA 모드가 이미 vacuum한 로그 구간을 다시 스캔하지 않는다. vacuum_finalize도 drain 루프 이후 같은 방식으로 재기록한다.

vacuum_job_cursor::force_data_updatevacuum_data::updateunload() / readjust_to_vacuum_data_changes() + load()로 감싼다. consume이 커서가 fix해 둔 바로 그 페이지를 교체하거나 해제할 수 있기 때문이다(6장). update는 mark-finished → consume 순으로 실행하고, !vacuum_Data.is_empty()인 경우에 한해 upgrade_oldest_unvacuumed(get_first_entry().oldest_visible_mvccid)를 수행한다. 이는 oldest_visible_mvccid가 항목 순서에 따라 단조 증가한다는 조건(§4.3)이 성립할 때만 유효하다.

4.2 진입 조건 검사와 빈 버퍼 fast path

섹션 제목: “4.2 진입 조건 검사와 빈 버퍼 fast path”

함수는 두 개의 가드와 한 가지 미묘한 fast path로 시작한다.

  1. PRM_ID_DISABLE_VACUUM 설정됨 → NO_ERROR 반환.
  2. vacuum_Block_data_buffer == NULLassert(false), NO_ERROR 반환 (실제 운영 중에는 발생하지 않음).
  3. 버퍼가 비어 있는 경우. vacuum data까지 비어 있다면(vacuum_is_empty() — 단일 페이지이고 index_unvacuumed == index_free) 함수는 그럼에도 m_last_blockid를 전진시킨다. 아이들 상태의 시스템이 오래된 로그 아카이브를 계속 붙잡아 두는 것을 막기 위해서다.
// vacuum_consume_buffer_log_blocks -- src/query/vacuum.c
if (vacuum_Block_data_buffer->is_empty ())
{
if (vacuum_is_empty ())
{
if (log_Gl.hdr.does_block_need_vacuum)
{
return NO_ERROR; /* <- current block has MVCC ops; cannot skip it */
}
std::unique_lock<std::mutex> ulock { log_Gl.prior_info.prior_lsa_mutex };
// ... condensed: recheck does_block_need_vacuum under the mutex -> return;
// recheck buffer: non-empty -> fall through to consume ...
LOG_LSA log_lsa = log_Gl.prior_info.prior_lsa;
ulock.unlock (); // unlock after reading prior_lsa
const VACUUM_LOG_BLOCKID LOG_BLOCK_TRAILING_DIFF = 2;
VACUUM_LOG_BLOCKID log_blockid = vacuum_get_log_blockid (log_lsa.pageid);
if (log_blockid > vacuum_Data.get_last_blockid () + LOG_BLOCK_TRAILING_DIFF)
{
vacuum_Data.set_last_blockid (log_blockid - LOG_BLOCK_TRAILING_DIFF);
vacuum_data_empty_update_last_blockid (thread_p);
vacuum_update_keep_from_log_pageid (thread_p);
}
return NO_ERROR;
}
else
{
return NO_ERROR; /* <- data non-empty: last entry already defines last_blockid */
}
}

세 가지 세부 사항이 중요하다. log_Gl.hdr.does_block_need_vacuum에 대한 check–lock–recheck: 진행 중인 블록에 MVCC 연산이 있는데 이를 건너뛰면 해당 연산이 고아가 된다. prior_lsa_mutex 하에서의 재확인은 3장의 producer가 같은 mutex 하에서 플래그를 뒤집는 경쟁 조건을 막아 준다. LOG_BLOCK_TRAILING_DIFF = 2: log_blockidprior_lsa가 속한 블록, 즉 아직 쓰이는 중인 블록이다. 두 블록 뒤에 머무르면 catch-up 과정이 아직 MVCC 연산을 생산할 수 있는 블록을 소유했다고 주장하지 않는다. 그리고 vacuum_data_empty_update_last_blockidvacuum_init_data_page_with_last_blockid를 호출해 단일 빈 페이지를 재초기화한다 — “작업 없음” 경로도 WAL로 기록된다(§4.5).

fast path를 지나면, vacuum_Data.last_page == NULLassert_release + ER_FAILED로 처리된다(데이터가 로드되지 않은 상태 — 호출자 버그).

4.3 drain 루프와 밀집-단조 불변식

섹션 제목: “4.3 drain 루프와 밀집-단조 불변식”

consume 루프는 버퍼를 순회하며 캐시된 last page에 항목을 추가한다.

// vacuum_consume_buffer_log_blocks -- src/query/vacuum.c
data_page = vacuum_Data.last_page;
page_free_data = data_page->data + data_page->index_free;
save_page_free_data = page_free_data; /* <- start of the not-yet-logged run */
was_vacuum_data_empty = vacuum_is_empty ();
while (vacuum_Block_data_buffer->consume (consumed_data))
{
assert (vacuum_Data.get_last_blockid () < consumed_data.blockid);
for (next_blockid = vacuum_Data.get_last_blockid () + 1; next_blockid <= consumed_data.blockid; next_blockid++)
{
// ... page-full branch, see 4.4 ...
if (data_page->index_unvacuumed == data_page->index_free && next_blockid < consumed_data.blockid)
{
next_blockid = consumed_data.blockid - 1; // empty page: skip gaps; for will increment to target
continue;
}
page_free_data->blockid = next_blockid;
if (next_blockid == consumed_data.blockid)
{
LSA_COPY (&page_free_data->start_lsa, &consumed_data.start_lsa);
page_free_data->newest_mvccid = consumed_data.newest_mvccid;
page_free_data->oldest_visible_mvccid = consumed_data.oldest_visible_mvccid;
// ... condensed: NDEBUG asserts, er_log ...
}
else
{
page_free_data->set_vacuumed (); /* <- gap block: no MVCC ops */
LSA_SET_NULL (&page_free_data->start_lsa);
// ... condensed: oldest_visible_mvccid = newest_mvccid = MVCCID_NULL ...
}
vacuum_Data.set_last_blockid (next_blockid);
page_free_data++;
data_page->index_free++;
}
}

버퍼에는 MVCC 연산이 있는 블록만 담긴다(3장). 그러나 내부 for 루프는 m_last_blockid + 1부터 consumed_data.blockid까지 모든 blockid를 순회하면서, 누락된 것들을 NULL start_lsa와 NULL MVCCID를 가진 선-VACUUMED 간격 항목으로 구체화한다.

핵심 불변식: vacuum data의 blockid는 밀집하고 단조 증가한다. [get_first_blockid(), get_last_blockid()] 범위 내의 모든 blockid는 페이지 체인 전체에 걸쳐 연속 순서로 정확히 한 번 나타나며, oldest_visible_mvccid는 그 순서에 따라 단조 증가한다. 간격 채우기 루프가 밀집성을 강제하고, 디버그 assert (page_free_data - 1)->get_blockid() + 1 == page_free_data->get_blockid()oldest_visible_mvccid 비교 검사가 이를 확인한다. 이 불변식이 깨지면 job 커서의 blockid 산술(vacuum_job_cursor::change_blockid, 6장)이 잘못된 항목을 가리키고, vacuum_data_mark_finished(9장)가 잘못된 블록을 마킹하며, “첫 번째 항목이 가장 오래된 MVCCID를 가진다”는 단축 경로가 oldest_unvacuumed_mvccid(5장)를 오염시킨다.

하나의 최적화가 이 불변식을 저렴하게 유지한다. 현재 페이지가 비어 있고(index_unvacuumed == index_free) 아직 consumed_data.blockid에 도달하지 않은 경우, 루프는 next_blockidconsumed_data.blockid - 1로 텔레포트하고 continue한다. 빈 페이지에 간격 항목을 써봤자 낭비이기 때문이다. 페이지의 범위는 실제 블록부터 합법적으로 다시 시작할 수 있다. 새 실제 항목은 AVAILABLE 상태로 생애를 시작하고(flags는 blockid의 상위 비트에 있다, 1장), 간격 항목은 VACUUMED 상태로 태어나 워커가 보지 않고도 다음 mark-finished 패스에서 회수된다.

루프 이후: was_vacuum_data_empty가 참이었으면 vacuum_update_keep_from_log_pageid가 로그 제거 워터마크를 재계산한다(5장). 이 함수는 LSA 단위가 아니라 블록 단위로 동작한다. 빈 데이터는 last_blockid 다음 블록의 첫 페이지부터 로그를 보관하고, 비어 있지 않은 데이터는 get_first_blockid()의 블록에 해당하는 첫 로그 페이지부터 보관한다. 따라서 빈 데이터에 첫 번째 항목을 추가하면 워터마크가 해당 항목의 블록 경계로 당겨진다.

data_page->index_free == vacuum_Data.page_data_max_count이면 체인이 확장된다.

// vacuum_consume_buffer_log_blocks -- src/query/vacuum.c (page-full branch)
if (page_free_data > save_page_free_data)
{
log_append_redo_data2 (thread_p, RVVAC_DATA_APPEND_BLOCKS, NULL, (PAGE_PTR) data_page,
(PGLENGTH) (save_page_free_data - data_page->data),
/* ... condensed ... */); /* <- log the run appended so far */
vacuum_set_dirty_data_page (thread_p, data_page, DONT_FREE);
}
if (is_sysop)
{
log_sysop_commit (thread_p); /* <- second new page in one call: commit previous sysop */
}
log_sysop_start (thread_p);
is_sysop = true;
error_code = file_alloc (thread_p, &vacuum_Data.vacuum_data_file, file_init_page_type, &ptype,
&next_vpid, (PAGE_PTR *) (&data_page));
// ... condensed: on error or NULL page -> log_sysop_abort + return ...
vacuum_init_data_page_with_last_blockid (thread_p, data_page, vacuum_Data.get_last_blockid ());
VPID_COPY (&vacuum_Data.last_page->next_page, &next_vpid);
log_append_undoredo_data2 (thread_p, RVVAC_DATA_SET_LINK, NULL, (PAGE_PTR) vacuum_Data.last_page, 0,
0, sizeof (VPID), NULL, &next_vpid); /* <- undo data is NULL */
save_last_page = vacuum_Data.last_page;
vacuum_Data.last_page = data_page; /* <- swap the cached fixed page */
vacuum_set_dirty_data_page (thread_p, save_last_page, FREE);
// we cannot commit here. we should append some data blocks first.
page_free_data = data_page->data + data_page->index_free;
save_page_free_data = page_free_data;

분기를 순서대로 살펴보면, (a) 이전 페이지의 보류 중인 run은 비어 있지 않을 때만 로그에 기록된다. (b) 이전 page-full 반복에서 이미 열린 sysop이 있으면 먼저 커밋한다. (c) file_alloc 실패 또는 NULL 페이지는 sysop을 abort하고 반환한다 — abort가 할당 자체를 되돌린다. (d) 새 페이지를 초기화하고 현재 last_blockid를 기록한 뒤, 이전 last page의 next_page를 연결하고, 캐시된 last_page 포인터를 교체하며, 이전 페이지는 unfix(FREE)한다.

system operation(sysop)이 일관성 경계 역할을 한다. “여기서 커밋할 수 없다”는 의도적인 설계다 — sysop은 새 페이지에 실제 항목 run이 기록될 때까지 열려 있으며(§4.5), 따라서 복구 시 체인 끝에 할당됐지만 비어 있는 페이지가 노출되는 일이 없다. 그 페이지의 data[0].blockid 스탬프를 루프가 이미 지나쳐 버린 상황도 방지된다.

flowchart TD
    A["페이지 가득 참?"] -->|예| B["이전 페이지에 보류 run 있음?\nRVVAC_DATA_APPEND_BLOCKS 로그"]
    B --> C["이전 sysop 열려 있음?\n커밋"]
    C --> D["log_sysop_start + file_alloc"]
    D -->|실패| X["log_sysop_abort\n오류 반환"]
    D -->|성공| E["RVVAC_DATA_INIT_NEW_PAGE\nRVVAC_DATA_SET_LINK\n캐시된 last_page 교체"]
    A -->|아니오| G["간격 blockid?"]
    E --> G
    G -->|예| H["set_vacuumed\nNULL lsa와 mvccid"]
    G -->|아니오| I["start_lsa, oldest, newest 복사"]
    H --> J["index_free 슬롯 채움\nset_last_blockid; index_free++"]
    I --> J
    J -->|다음 blockid| A
    J -->|버퍼 소진| K["최종 run 로그\nsysop 열려 있으면 커밋"]

Figure 4-1. page-full 분기를 포함한 consume 루프. 오류 시 sysop을 abort하고, 그 외 모든 경로는 마지막 append 레코드에서 수렴한다.

마지막 분기는 page-full 진입부의 거울상이다. save_page_free_data < page_free_data이면 마지막 run을 RVVAC_DATA_APPEND_BLOCKS로 로그에 기록하고, 새 페이지에 데이터가 쌓인 지금에야 열려 있는 sysop을 커밋하고 페이지를 dirty 표시한다(DONT_FREE). else 분기는 불가능한 잔여 상황을 처리한다. sysop이 열려 있는데 run이 없는 경우는 assert(false)이지만 그래도 커밋한다 — “sysop을 누수시키지 않는다”. 전체 경로는 세 개의 복구 인덱스로 커버된다(recovery.cRV_fun 테이블에서 어느 쪽이 존재하는지 확인할 수 있다).

rcvindexundoredo
RVVAC_DATA_APPEND_BLOCKSvacuum_rv_redo_append_data: run을 data + rcv->offset에 bulk-copy하고 index_free 전진
RVVAC_DATA_INIT_NEW_PAGEvacuum_rv_redo_initialize_data_page: vacuum_data_initialize_new_page 재실행, data->blockid 복원
RVVAC_DATA_SET_LINKvacuum_rv_undoredo_data_set_link동일 함수: next_page를 설정하거나 NULL로 만듦
// vacuum_rv_redo_append_data -- src/query/vacuum.c
int n_blocks = rcv->length / sizeof (VACUUM_DATA_ENTRY);
// ... condensed: length sanity asserts ...
assert (rcv->offset == data_page->index_free); /* <- append-only: offset must equal index_free */
memcpy (data_page->data + rcv->offset, rcv->data, n_blocks * sizeof (VACUUM_DATA_ENTRY));
data_page->index_free += n_blocks;

핵심 불변식: vacuum data의 append는 위치 기반 멱등성을 가지므로 redo만으로 충분하며 undo는 필요하지 않다. 항목은 오직 index_free에만 기록되고, redo 레코드는 절대적인 슬롯 offset을 담으며, 페이지 LSA가 redo 적용 여부를 결정한다. 롤백이 필요하지 않다. 등록은 클라이언트 트랜잭션이 아닌 vacuum master의 system thread 아래에서 실행된다. undo가 존재한다면 롤백이 보류 중인 작업을 지워버리고, 해당 로그 구간은 영원히 vacuum되지 않을 것이다 — 이는 vacuum이 감당할 수 없는 단 하나의 실패 유형이다.

유일한 undoredo 레코드인 RVVAC_DATA_SET_LINK는 sysop을 위해 존재한다. abort(할당 실패) 시 undo가 rcv->data == NULL로 실행되고, vacuum_rv_undoredo_data_set_link는 이를 VPID_SET_NULL(&data_page->next_page) — 중단된 sysop이 동시에 해제하는 반쯤 태어난 페이지를 분리하는 것 — 으로 매핑한다. undo와 redo 양방향이 같은 함수를 공유하며, NULL-data 분기가 undo, 복사 분기가 redo다.

페이지 초기화는 단순히 페이지를 0으로 채우는 것 이상을 하기 때문에 별도의 redo 레코드를 가진다.

// vacuum_init_data_page_with_last_blockid -- src/query/vacuum.c
vacuum_data_initialize_new_page (thread_p, data_page); /* memset, NULL next_page, indexes = 0, ptype */
data_page->data->blockid = blockid; /* <- ghost slot: last_blockid survives in data[0] */
log_append_redo_data2 (thread_p, RVVAC_DATA_INIT_NEW_PAGE, NULL, (PAGE_PTR) data_page, 0, sizeof (blockid), &blockid);
vacuum_set_dirty_data_page (thread_p, data_page, DONT_FREE);

핵심 불변식: 빈 vacuum data 페이지도 data[0].blockidlast_blockid를 기억한다. index_free == 0이면 해당 슬롯은 논리적으로 죽은 상태이지만, 고수위 표시를 담고 있다. vacuum_data::set_last_blockid는 따라서 순수한 인메모리 setter로 남을 수 있다 — 플래그 비트만 제거하고(VACUUM_BLOCKID_WITHOUT_FLAGS) 값이 prior_lsa의 블록 아래에 있음을 디버그 assert한다. 부팅 시 vacuum_data_load_and_recoverm_last_blockid를 재구성한다. 비어 있지 않은 데이터라면 마지막 실제 항목인 last_page->data[index_free - 1].blockid에서 가져온다(index_free가 0이면 ghost slot, 즉 슬롯 0으로 방어적 폴백). 빈 데이터라면 MAX(log_Gl.hdr.vacuum_last_blockid, vacuum_Data.last_page->data->blockid) — ghost slot 값과, SA 모드가 아카이브 삭제 전에 전진시켜 둔 헤더 값 중 더 큰 것. (세부 분기: 아직 logpb_last_complete_blockid()가 음수인 새 로그는 값을 그대로 둔다. 10.1 시대 데이터베이스로 recovery_lsa/mvcc_op_log_lsa가 NULL인 경우에는 직접 값을 취한다.) ghost slot 규칙을 어기면, 아이들 후 크래시한 서버가 낡은 last_blockid로 재개하면서 이미 소진된 블록들에 대한 “간격”을 다시 채우고, 작업을 이중으로 등록하게 된다.

함수는 debug 빌드에서 VACUUM_VERIFY_VACUUM_DATA와 page fix count 검사로 끝나고, NO_ERROR를 반환한다.

  1. vacuum_consume_buffer_log_blocks는 새 vacuum data 항목의 유일한 작성자다. vacuum_data::update(master tick, SA 모드 xvacuum), vacuum_recover_lost_block_data(boot), 또는 vacuum_finalize의 drain 루프에서 호출되며, 버퍼나 finished-job 큐가 절반 이상 차면 master가 강제로 호출한다.
  2. drain 루프는 MVCC 연산 없는 모든 blockid에 간격 항목 — VACUUMED 상태로 태어나고 LSA는 NULL — 을 구체화함으로써 밀집-단조 blockid 불변식을 강제한다. 단, 빈 페이지에서는 범위가 합법적으로 재시작될 수 있다.
  3. 페이지 확장은 새 페이지에 실제 데이터가 기록될 때까지 열려 있는 system operation으로 감싸인다. 할당 실패 시 sysop을 abort하고, RVVAC_DATA_SET_LINK의 undo(NULL data → NULL link)가 반쯤 태어난 페이지를 분리한다.
  4. 그 외 모든 것은 redo-only다(RVVAC_DATA_APPEND_BLOCKS, RVVAC_DATA_INIT_NEW_PAGE). append는 index_free에 위치 기반으로 이루어지고, 등록은 클라이언트 트랜잭션에 속하지 않으며, undo는 vacuum 작업을 영원히 잃는 것을 의미한다.
  5. m_last_blockid는 휘발성이지만 복구 가능하다. 데이터가 비어 있지 않으면 마지막 항목에서, 비어 있으면 ghost slot data[0].blockidlog_Gl.hdr.vacuum_last_blockid의 최댓값에서 가져온다 — SA 모드 셧다운 시 vacuum_sa_reflect_last_blockid가 최신 상태를 유지한다.
  6. 빈 버퍼 fast path도 내구성을 갖는다. log_Gl.hdr.does_block_need_vacuum에 대한 check–lock–recheck를 거쳐 진행 중인 MVCC 연산이 있는 블록을 건너뛰지 않도록 한 뒤, last_blockidprior_lsa의 블록에서 LOG_BLOCK_TRAILING_DIFF(2)를 뺀 값으로 catch-up하면서 빈 페이지를 재기록한다.

Chapter 5: 진공 가능 여부 판정과 최고령 가시 워터마크

섹션 제목: “Chapter 5: 진공 가능 여부 판정과 최고령 가시 워터마크”

Chapter 4에서 블록은 AVAILABLE 상태로 등록되었다. 그러나 AVAILABLE이 안전함을 뜻하지는 않는다. 블록 안의 레코드가 아직 실행 중인 트랜잭션에 보일 수 있기 때문이다. 진공 가능 여부의 결정은 두 곳에 나뉘어 있다. MVCC 테이블(mvcctable, mvcc_table.hpp/.cpp)이 **최고령 가시 워터마크(oldest visible watermark)**를 소유한다. 이 값은 어떤 스냅샷이든 버전 이력이 완전히 확정되었다고 동의하는 경계 MVCCID이다. Vacuum master는 반복마다 한 번씩 이 워터마크를 조회해 각 엔트리의 newest_mvccid와 비교할 뿐이다. 워터마크 기계장치의 전체 도출은 MVCC 상세 문서 cubrid-mvcc-detail.md Chapter 9(§9.1–9.5)에 있다. 고수준 개요(cubrid-vacuum.md)는 전역 워터마크 하나로 충분한 이유를 설명한다. 여기 5.1–5.4는 refresh 호출을 읽는 데 필요한 생산자 측 최소 맥락만 다루고, 5.5–5.6은 MVCC 문서가 개략만 소개한 vacuum 측 소비 경로를 완전히 추적한다.

5.1 생산자 측 요약 — mvcctable의 워터마크 필드

섹션 제목: “5.1 생산자 측 요약 — mvcctable의 워터마크 필드”

mvcctablelog_Gl.mvcc_table에 위치한 서버 전역 MVCC 장부 객체다. 대부분의 필드는 스냅샷 구성에 쓰이며(cubrid-mvcc-detail.md §1.7, §2.5 필드 테이블 참조), vacuum 전용 필드는 두 개다. m_oldest_visiblestd::atomic<MVCCID>로, 발행된 워터마크를 캐시해 읽는 쪽이 원자적 로드 하나(get_global_oldest_visible)로 가져가게 한다. m_ov_lock_count는 원자적 동결 카운터로, 0이 아닌 동안 새 값 발행을 금지한다(5.4). 스캔은 두 입력을 읽는다. 전역 하한 m_current_status_lowest_active_mvccid(커밋 시 complete_mvcc를 거쳐 advance_oldest_active로 진전)와 트랜잭션별 슬롯 배열 m_transaction_lowest_visible_mvccids다. 슬롯 셀 하나는 세 가지 의미를 동시에 가진다.

슬롯 값상태설정 주체스캔 동작
MVCCID_NULL (0)활성 스냅샷 없음LOG_COMMIT 후 reset_transaction_lowest_active; 롤백 시 complete_mvcc무시.
MVCCID_ALL_VISIBLE (3, storage_common.h)핸드셰이크 센티널: 실제 값이 곧 옴build_mvcc_info, 순간적으로대기 — 실제 값이 현재까지 본 최솟값보다 낮을 수 있음.
정상 MVCCID스냅샷의 lowest_active_mvccid; 커밋 후에는 해당 트랜잭션 자신의 MVCCIDbuild_mvcc_info; 커밋 시 complete_mvcc가 올림최솟값 병합.
flowchart LR
    subgraph producers["생산자 (트랜잭션별)"]
        BMI["build_mvcc_info<br/>스냅샷 최저값 발행"]
        CM["complete_mvcc<br/>커밋: 자신의 mvccid로 올림<br/>롤백: NULL"]
        RST["reset_transaction_lowest_active<br/>LOG_COMMIT 후: NULL"]
    end
    SLOTS["m_transaction_lowest_visible_mvccids[ ]"]
    GLOW["m_current_status_lowest_active_mvccid"]
    COMP["compute_oldest_visible_mvccid"]
    UPD["update_global_oldest_visible"]
    OV["m_oldest_visible"]
    LCK["m_ov_lock_count"]
    BMI --> SLOTS
    CM --> SLOTS
    RST --> SLOTS
    CM -- "advance_oldest_active" --> GLOW
    SLOTS --> COMP
    GLOW --> COMP
    COMP --> UPD
    LCK -- "발행 차단" --> UPD
    UPD --> OV
    OV -- "get_global_oldest_visible" --> READERS["vacuum master / worker / locator DDL"]

Figure 5-1: mvcctable의 워터마크 필드 주변 생산자와 소비자.

불변 조건 (커밋 이후 슬롯 내구성). 커밋하는 트랜잭션의 슬롯은 complete_mvcc에서 지워지지 않는다. 슬롯은 해당 트랜잭션의 MVCCID로 올려진 뒤, LOG_COMMIT 레코드가 기록될 때 log_completereset_transaction_lowest_active를 호출할 때까지 유지된다. 코드 내 주석이 적시하는 실패 시나리오는 이렇다. 슬롯이 일찍 NULL이 되면 → vacuum이 해당 트랜잭션의 수정을 청소함 → LOG_COMMIT 이전에 크래시 → 이미 garbage가 사라진 트랜잭션을 recovery가 롤백 시도. 워터마크는 커밋 레코드가 아직 내구되지 않은 MVCCID를 절대 넘어갈 수 없다.

// mvcctable::complete_mvcc -- src/transaction/mvcc_table.cpp
if (committed)
{
/* be sure that transaction modifications can't be vacuumed up to LOG_COMMIT. ... */
if (tran_lowest_active == MVCCID_NULL || MVCC_ID_PRECEDES (tran_lowest_active, mvccid))
{
oldest_active_set (..., mvccid, ...); /* <- raise, do not clear */
}
}
else
{
oldest_active_set (..., MVCCID_NULL, ...); /* <- rollback clears immediately */
}

5.2 ALL_VISIBLE 핸드셰이크, 한 문단으로

섹션 제목: “5.2 ALL_VISIBLE 핸드셰이크, 한 문단으로”

build_mvcc_info는 전역 m_current_status_lowest_active_mvccid를 읽어 트랜잭션 슬롯에 저장해야 하는데, 이 두 단계는 원자적이지 않다. 스레드가 두 단계 사이에 중단되면 전역 값이 진전되고 vacuum이 워터마크를 갱신한 뒤, 더 낮은 오래된 값이 슬롯에 들어가 워터마크가 역행할 수 있다. 해결책은 사전 예고다. 슬롯을 먼저 MVCCID_ALL_VISIBLE로 설정한 뒤, “바로 다음 두 줄 코드 사이에 지연 없이” 전역 값을 읽어 실제 값을 저장한다(코드 내 주석). 따라서 센티널은 두 인접 문 사이에만 존재하며, I/O도 없고 락도 없다. 스캔이 단순히 바쁜 대기로 처리할 수 있는 이유가 여기 있다(5.3). 두 문 사이에 작업을 삽입하면 master의 refresh가 지연되지만 정확성은 깨지지 않는다. 전체 인터리빙 도출과 코드 발췌는 cubrid-mvcc-detail.md §5.3에 있으며, 동 문서 §9.2는 스캐너 관점에서 센티널을 분류한다. oldest_active_set/get 래퍼는 Oldest_active_tracker에도 값을 공급하는데, 이는 디버그 빌드의 8K 링 버퍼로 소스 태그가 달려 있어 워터마크 역행 assert가 발생할 때 첫 번째 확인 지점이다.

5.3 compute_oldest_visible_mvccid — 2단계 스캔

섹션 제목: “5.3 compute_oldest_visible_mvccid — 2단계 스캔”

이 private const 메서드(update_global_oldest_visible에서만 호출됨)는 전역 하한과 모든 슬롯에 걸쳐 최솟값을 취한다.

// mvcctable::compute_oldest_visible_mvccid -- src/transaction/mvcc_table.cpp
MVCCID lowest_active_mvccid = oldest_active_get (m_current_status_lowest_active_mvccid, ...);
for (size_t idx = 0; idx < m_transaction_lowest_visible_mvccids_size; idx++) /* phase 1 */
{
loaded_tran_mvccid = oldest_active_get (m_transaction_lowest_visible_mvccids[idx], ...);
if (loaded_tran_mvccid == MVCCID_ALL_VISIBLE)
{
waiting_mvccids_pos.append (idx); /* <- defer: real value imminent */
}
else if (loaded_tran_mvccid != MVCCID_NULL && MVCC_ID_PRECEDES (loaded_tran_mvccid, lowest_active_mvccid))
{
lowest_active_mvccid = loaded_tran_mvccid; /* <- new minimum */
}
}
size_t retry_count = 0;
while (waiting_mvccids_pos.get_size () > 0) /* phase 2: drain stragglers */
{
++retry_count;
if (retry_count % 20 == 0)
{
thread_sleep (10); /* <- back off 10 ms every 20 spins */
}
// ... condensed: re-read each waiting slot; still ALL_VISIBLE -> keep waiting;
// resolved -> min-merge like phase 1, erase from set ...
}
assert (MVCCID_IS_NORMAL (lowest_active_mvccid));
return lowest_active_mvccid;

1단계는 각 슬롯을 정확히 세 경로 중 하나로 분류한다. 센티널이면 지연, 유효하고 더 작으면 새 최솟값, MVCCID_NULL이거나 더 크면 무시. 2단계는 지연 집합을 스핀하며, 센티널의 수명이 짧으므로(5.2) 대기 시간도 짧다. 20번째 재시도마다 10 ms를 슬리핑해 중단된 스냅샷 스레드 하나가 코어를 독점하지 못하게 한다. 대기 중에 슬롯이 MVCCID_NULL로 해소되면 해당 트랜잭션이 완료된 것이므로 제약이 사라진다. 최종 assert는 시작 후보가 항상 정상 MVCCID이기 때문에 성립한다. (cubrid-mvcc-detail.md §9.2.1에서 역방향 반복 erase와 성능 카운터를 다룬다.)

5.4 update_global_oldest_visible과 동결 카운터

섹션 제목: “5.4 update_global_oldest_visible과 동결 카운터”

스캔 결과는 워터마크가 동결되지 않은 경우에만 발행된다.

// mvcctable::update_global_oldest_visible -- src/transaction/mvcc_table.cpp
if (m_ov_lock_count == 0) /* <- cheap pre-check: skip scan if frozen */
{
MVCCID oldest_visible = compute_oldest_visible_mvccid ();
if (m_ov_lock_count == 0) /* <- re-check: a locker may have arrived mid-scan */
{
assert (m_oldest_visible.load () <= oldest_visible); /* <- monotonicity */
m_oldest_visible.store (oldest_visible);
}
}
return m_oldest_visible.load (); /* <- always returns the published value */

결과는 세 가지다. 진입 시점에 이미 동결 → 캐시값 반환; 스캔 중간에 동결 → 계산된 값 버리고 캐시값 반환; 끝까지 동결 없음 → 단조성 assert 후 발행, 반환. get_global_oldest_visible은 읽기 전용 쌍둥이이며, lock_global_oldest_visible / unlock_global_oldest_visible은 단순히 m_ov_lock_count를 증감한다(전체 pin API는 cubrid-mvcc-detail.md §9.4).

잠금 주체와 이유. 두 잠금 주체는 모두 log_tdes::lock_global_oldest_visible_mvccid(log_tran_table.c)를 경유하며, TDES 플래그 block_global_oldest_active_until_commit으로 트랜잭션당 멱등성을 보장한다. 대응하는 잠금 해제는 MVCC 완료 경로가 아니라 로그 경로에 있다. log_completelog_complete_for_2pc(log_manager.c)가 완료 레코드 이후, 커밋 경로의 reset_transaction_lowest_active 바로 전에 커밋과 중단 모두에서 unlock_global_oldest_visible_mvccid를 호출한다. 카운트 누수는 없다. 트리에 존재하는 두 잠금 주체 xlocator_upgrade_instances_domainredistribute_partition_data(locator_sr.c)는 동일한 패턴을 따른다. 잠금 → get_global_oldest_visible()threshold_mvccid로 읽음 → 재기록할 페이지에 heap_vacuum_all_objects로 인라인 정리. 동결은 해당 임계 MVCCID를 캡처한 뒤 시스템 전체 워터마크가 그 값을 넘어 진전하는 것을 막는다. 그렇지 않으면 실제 vacuum이 더 새로운 임계값으로 같은 heap을 처리해 어떤 버전이 존재하는지에 대한 불일치가 생길 수 있다. (MVCC 문서 §9.7에서 비용을 논한다. DDL 하나가 vacuum을 전역적으로 핀한다.)

불변 조건 (워터마크 단조성). m_oldest_visible은 절대 감소하지 않는다. 커밋 슬롯 규칙(5.1), 핸드셰이크(5.2), 그리고 위 assert가 이를 강제한다. 값이 감소하면 이미 디스패치된 잡이 더 높은 값으로 적격 판단한 버전을 청소하게 되고, 그 버전이 아직 어떤 스냅샷에 필요할 수 있다.

미묘한 잔여 사항이 하나 있다. 확인–계산–재확인이 store와 원자적이지 않다. 두 번째 확인과 store 사이에 잠금 주체가 도착하면 워터마크가 한 번 이동하는데, 이 값은 잠금 전에 계산된 것이다. 트리 내 호출자들은 이를 허용한다(임계값을 잠금 이후에만 읽는다). 하지만 “잠금 반환 이후 store 없음”이라는 엄격한 fence를 가정하는 호출자라면 틀린 것이다.

5.5 소비자: master 스냅샷과 적격 게이트

섹션 제목: “5.5 소비자: master 스냅샷과 적격 게이트”

Master는 반복마다 정확히 한 번 워터마크를 갱신하고 멤버 m_oldest_visible_mvccid(“저장된 최고령 가시 mvccid, 매 반복마다 재계산”)에 스냅샷을 찍는다.

// vacuum_master_task::execute -- src/query/vacuum.c
m_oldest_visible_mvccid = log_Gl.mvcc_table.update_global_oldest_visible ();
// ... condensed: first-run data load, page flushes, force_data_update ...
for (; m_cursor.is_valid () && !should_interrupt_iteration (); m_cursor.increment_blockid ())
{
if (!is_cursor_entry_ready_to_vacuum ())
{
// next entries cannot be ready if current entry is not ready; stop this iteration
break; /* <- break, not continue */
}
if (!is_cursor_entry_available ())
{
continue; /* <- vacuumed or in-progress: try next */
}
start_job_on_cursor_entry ();
// ... condensed ...
}

Master는 정상 상태에서 update_global_oldest_visible을 호출하는 유일한 주체다. vacuum_data_load_and_recover는 부팅 시 한 번 호출하고, vacuum_boot 안의 또 다른 호출은 vacuum이 비활성화된 이른 반환 경로에서만 실행되므로 “디버그 전용”으로 표시되어 있다. 나머지는 모두 읽기만 한다. 반복당 스냅샷 하나로 한 패스의 모든 엔트리가 일관된 기준값을 공유한다. 게이트의 거부 분기는 정확히 둘이다.

// vacuum_master_task::is_cursor_entry_ready_to_vacuum -- src/query/vacuum.c
assert (m_cursor.is_valid ());
if (m_cursor.get_current_entry ().newest_mvccid >= m_oldest_visible_mvccid)
{
// if entry newest MVCCID is still visible, it cannot be vacuumed
// ... condensed: vacuum_er_log ...
return false; /* <- visibility gate */
}
if (m_cursor.get_current_entry ().start_lsa.pageid + 1 >= log_Gl.append.prev_lsa.pageid)
{
// too close to end of log; let more log be appended before trying to vacuum the block
// ... condensed: vacuum_er_log ...
return false; /* <- log-tail proximity gate */
}
return true;

첫 번째 분기가 적격 판단의 핵심이다. 블록은 로깅 시점에(Chapter 3) 해당 블록 내 모든 연산 중 가장 높은 MVCCID를 newest_mvccid로 기록한다. 그 값마저 워터마크보다 엄격히 낮아지면, 블록 내 모든 연산은 현재 및 미래의 모든 스냅샷에서 확정된 것이다. >= 조건은 newest가 워터마크와 같은 블록도 걸러낸다. 그 MVCCID 자체가 아직 활성일 수 있기 때문이다. 두 번째 분기는 MVCC와 무관하다. 아직 기록 중인 페이지가 있는 로그 append head에 근접한 블록을 거부한다(Chapter 7). 호출자의 break-not-continue는 등록 순서(Chapter 4)로 정당화된다. 블록은 로그 순서로 vacuum data에 진입하므로, 현재 블록이 준비되지 않았다면 그 이후 블록도 준비되지 않은 것이다. 세 번째 결정인 is_cursor_entry_available은 적격 판단이 아닌 상태 관리다. 이미 진공되었거나 진행 중인 엔트리를 건너뛴다(Chapter 6).

불변 조건 (삭제자 확정 보장). newest_mvccid가 블록에 기록된 모든 MVCCID의 상한이고, 워터마크가 모든 활성 스냅샷의 lowest_active_mvccid 하한이므로, vacuum은 어떤 스냅샷에도 삽입자나 삭제자가 아직 보이지 않을 수 있는 레코드를 처리하지 않는다. Worker가 승인된 블록에서 만나는 모든 MVCCID는 커밋–전역 가시 또는 롤백 상태다. 승인된 블록의 각 연산을 처리할 때 mvcc_satisfies_vacuum(Chapter 8)은 “아직 불확실한가?”를 물을 필요가 없다.

이 지점에서 두 개의 “oldest visible” 값이 공존하며, 이를 혼동하는 것이 전형적인 독자 실수다. 엔트리에 저장된 oldest_visible_mvccid로깅 시점의 워터마크다. vacuum_data_entry 헤더 생성자가 log_Gl.hdr.oldest_visible_mvccid에서 복사하며, 위임 생성자는 oldest <= newest를 assert한다. 이 값은 적격 판단에 아무 역할을 하지 않는다. 적격 판단은 newest_mvccid현재 워터마크와 비교한다. 저장된 값은 장부 관리에 쓰인다. vacuum_consume_buffer_log_blocks는 이 값이 get_global_oldest_visible()을 절대 초과하지 않음을 assert하고, vacuum_verify_vacuum_data_debug는 인접 엔트리 간 오름차순을 assert한다. 또한 후행 경계를 구동한다(5.6). Worker는 master의 스냅샷을 재사용하지도 않는다. vacuum_process_log_block은 잡 시작 시 get_global_oldest_visible()threshold_mvccid로 직접 읽는다. 단조성 덕분에 이 값은 블록을 적격으로 판단한 값보다 같거나 더 최신이다.

5.6 후행 경계: vacuum_Data.oldest_unvacuumed_mvccid

섹션 제목: “5.6 후행 경계: vacuum_Data.oldest_unvacuumed_mvccid”

워터마크는 선행 경계다. 그보다 최신인 것은 건드릴 수 없다. vacuum_data::oldest_unvacuumed_mvccid(“아직 진공되지 않은 전역 최고령 MVCCID”)는 후행 경계다. 이보다 엄격히 오래된 것은 완전히 청소되었음이 보장된다. 이 값은 계산되는 것이 아니라 유지 관리된다.

// vacuum_data::set_oldest_unvacuumed_on_boot -- src/query/vacuum.c
if (!log_Gl.hdr.does_block_need_vacuum)
{
// log_Gl.hdr.oldest_visible_mvccid may not remain uninitialized
log_Gl.hdr.oldest_visible_mvccid = log_Gl.hdr.mvcc_next_id; /* <- no pending block: seed */
}
if (vacuum_Data.is_empty ())
{
oldest_unvacuumed_mvccid = log_Gl.hdr.oldest_visible_mvccid;
}
else
{
oldest_unvacuumed_mvccid = first_page->data[0].oldest_visible_mvccid; /* <- first = oldest */
assert (oldest_unvacuumed_mvccid <= log_Gl.hdr.oldest_visible_mvccid);
}

vacuum_data_load_and_recover에서 부팅 시 한 번 호출되며(Chapter 11), 세 가지 부팅 형태를 모두 처리한다. 초기화되지 않은 헤더 워터마크를 시드하고, vacuum data가 비어 있으면(후행 경계 = 헤더 워터마크) 또는 첫 번째 — 가장 오래된 — 엔트리의 기록된 oldest_visible_mvccid가 미처리 범위의 경계가 된다. 이후 vacuum_data::update(master의 하우스키핑 패스, Chapter 9)가 완료된 잡을 표시하고 새 블록을 소비한 뒤 첫 번째 남은 엔트리에서 이 값을 진전시키는데, 의도적으로 단방향인 세터만 이를 허용한다.

// vacuum_data::upgrade_oldest_unvacuumed -- src/query/vacuum.c
assert (oldest_unvacuumed_mvccid <= mvccid); /* <- "upgrade": may only move forward */
oldest_unvacuumed_mvccid = mvccid;

불변 조건 (엔트리 oldest 오름차순). vacuum data의 엔트리는 oldest_visible_mvccid가 비내림차순이다(vacuum_verify_vacuum_data_debug가 인접 엔트리 간, 그리고 모든 활성 엔트리에서 oldest_unvacuumed_mvccid <= entry->oldest_visible_mvccid를 assert한다). 블록은 로그 순서로 소비되고 워터마크는 단조증가하므로, 첫 번째 엔트리가 항상 전역 최솟값이다. “첫 번째 엔트리의 oldest”는 전체 스캔 없이도 올바른 후행 경계다. 이 순서가 깨지면 upgrade_oldest_unvacuumed의 assert가 발생하거나, 후행 경계가 진공되지 않은 작업 범위를 조용히 추월한다.

정상 경로 밖의 기록자가 하나 있다. SA 모드 전체 진공(xvacuum, Chapter 11)은 모든 잡을 수행한 뒤 log_Gl.hdr.mvcc_next_id를 직접 대입하고 RVVAC_COMPLETE를 로그에 기록한다. 이 redo 핸들러 vacuum_rv_redo_vacuum_complete가 대입을 재현한다. 역방향 조회 함수로 그림이 완성된다.

// vacuum_is_mvccid_vacuumed -- src/query/vacuum.c
if (id < vacuum_Data.oldest_unvacuumed_mvccid) /* <- strictly older than trailing edge */
{
return true;
}
return false;

소비자는 스토리지 계층에 있으며 하나는 완전히 운영 중이다. xheap_reclaim_addresses(heap_file.c)는 페이지의 max MVCCID가 이미 진공되었을 때만 heap 페이지를 해제하고, heap_page_update_chain_after_mvcc_op는 동일 테스트로 페이지의 HEAP_PAGE_VACUUM_UNKNOWN 상태를 해소한다. 나머지는 진단 용도다. mvcc_satisfies_snapshotmvcc_satisfies_vacuum(mvcc.c)은 진공되었어야 할 레코드가 여전히 발견될 때 perfmon 카운터를 PERF_SNAPSHOT_..._LOST로 분류하고, btree_prepare_bts(btree.c)는 인덱스 생성자 MVCCID가 아직 진공되지 않은 동안 check_not_vacuumed 체커를 비활성화한다. 후행 경계는 래퍼 없이 직접 소비되기도 한다. vacuum_cleanup_dropped_filesMVCC_ID_PRECEDES(entry mvccid, oldest_unvacuumed_mvccid)로 원장 엔트리를 제거한다. 미래의 어떤 잡도 그것들을 물어볼 수 없기 때문이다(Chapter 10). 디버그 체커 is_not_vacuumed_and_lostmvcc_satisfies_vacuumoldest_unvacuumed_mvccid 기준으로 실행해 사라졌어야 할 버전이 아직 존재하는지 검사한다. 두 경계가 시스템을 괄호로 묶는다. oldest_unvacuumed_mvccid <= 엔트리 oldest <= m_oldest_visible. 첫 값 아래는 깨끗하고, 두 번째 값 위는 건드릴 수 없으며, 그 사이 밴드가 vacuum의 작업 영역 전부다.

  1. 적격 판단은 두 곳에서 이루어진다. mvcctable이 워터마크(m_oldest_visible)를 계산·발행하고, master가 반복당 한 번 스냅샷(m_oldest_visible_mvccid)을 찍어 각 엔트리의 newest_mvccid와 비교한다. mvcctable 내부는 cubrid-mvcc-detail.md Chapter 9에 완전히 도출되어 있고, 이 챕터는 vacuum 측 소비를 다룬다.
  2. 스캔의 입력은 트랜잭션별 슬롯으로, 세 상태를 인코딩한다. MVCCID_NULL(무시), MVCCID_ALL_VISIBLE(값이 곧 옴 — 대기), 실제 lowest-active MVCCID(최솟값 병합). compute_oldest_visible_mvccid는 해소된 슬롯을 최솟값 병합하고 센티널은 바쁜 대기로 소진한다(20번째 재시도마다 10 ms 백오프).
  3. 단조성은 부하를 지탱하는 핵심이다. 커밋된 트랜잭션은 LOG_COMMIT까지 슬롯을 유지하며(log_completereset_transaction_lowest_active로 해제), 핸드셰이크는 낮은 값의 잘못된 발행을 방지하고, update_global_oldest_visible은 값이 감소하지 않음을 assert한다.
  4. m_ov_lock_count는 완료까지 워터마크를 동결한다. xlocator_upgrade_instances_domainredistribute_partition_datalog_tdes::lock_global_oldest_visible_mvccid로 잠그고, log_complete / log_complete_for_2pc에서 커밋과 중단 모두에서 잠금을 해제한다. 그들의 인라인 heap_vacuum_all_objects 임계값이 유효하게 유지된다. 이중 확인은 동결을 존중하지만, 현재로서는 문제없는 store-after-lock 창이 남아 있다.
  5. 게이트 is_cursor_entry_ready_to_vacuum의 거부 분기는 정확히 둘이다. newest_mvccid >= 워터마크와 로그 말단 근접. 실패 시 반복을 break한다. 블록이 로그 순서로 등록되기 때문이다. 삭제자 확정 불변 조건이 뒤따른다. 승인된 블록에는 어떤 스냅샷도 아직 의심하는 MVCCID가 없다.
  6. 엔트리에 저장된 oldest_visible_mvccid(로깅 시점 캡처)는 적격 판단에 관여하지 않는다. 적격 판단은 newest_mvccid 대 현재 워터마크로 한다. 저장된 값은 오름차순 엔트리 불변 조건으로 후행 경계를 구동하며(set_oldest_unvacuumed_on_boot, upgrade_oldest_unvacuumed), vacuum_is_mvccid_vacuumed(id < oldest_unvacuumed_mvccid)는 heap 페이지 회수와 일관성 체커에 쓰인다. 두 경계가 vacuum 작업 밴드를 완성한다.

Chapter 6: 마스터 디스패치와 잡 커서

섹션 제목: “Chapter 6: 마스터 디스패치와 잡 커서”

블록이 vacuum data에 등록되고(4장) 워터마크 조건을 통과하면(5장), 이번 장에서는 마스터가 그 블록을 어떻게 찾아내고, 자신의 위치를 잃지 않은 채 워커에게 넘기는지를 추적한다. vacuum_boot는 두 절반을 연결한다: 워커 풀(vacuum_Worker_threads, 크기는 PRM_ID_VACUUM_WORKER_COUNT로 결정)과 vacuum_Master_daemon — 이 데몬의 루퍼는 PRM_ID_VACUUM_MASTER_WAKEUP_INTERVAL 밀리초마다 vacuum_master_task::execute 패스를 한 번씩 실행한다. 패스 사이에 vacuum_job_cursor가 반복 위치를 보존하여, vacuum data가 변경되더라도 다음 웨이크업에서 이어서 처리할 수 있다. 모든 코드는 src/query/vacuum.cSERVER_MODE 전용이다(standalone은 11장).

vacuum_master_task 는 매 웨이크업마다 실행되는 cubthread::entry_task다. vacuum_boot가 정확히 한 번(new vacuum_master_task ()) 생성하여 create_daemon에 전달하고, 동일한 인스턴스를 매 인터벌마다 재실행한다 — 따라서 멤버 변수들은 웨이크업 사이에도 상태를 유지한다.

필드역할
m_cursor (vacuum_job_cursor)반복 위치; 이전 웨이크업이 멈춘 지점부터 재개
m_oldest_visible_mvccid (MVCCID)워터마크 스냅샷, execute 한 번당 재계산되어 패스 동안 고정 유지 (5장)
m_outstanding_job_count (std::size_t)푸시됐지만 아직 완료 확인이 안 된 잡 수; 마스터 스레드 전용, 락 불필요; finished-queue 드레인 시 조정되는 추정값 (6.5)

vacuum_job_cursor 가 이번 장의 핵심 구조체다. 헤더 주석에 계약이 명시되어 있다: blockid가 실질적인 진행 지표이며, page/index 쌍은 다시 fix할 수 있는 캐시에 불과하다 — data maintenance가 항목을 다른 페이지로 이동시킬 수 있기 때문이다.

필드역할
m_blockid (VACUUM_LOG_BLOCKID)논리적이고 플래그가 없는 위치: 조밀하고 단조 증가하며, 페이지 unlink/append에도 안정적 — 단순한 (page, index) 쌍은 vacuum_Data.update()가 헤드 페이지를 제거할 때 dangling 포인터가 됨
m_page (VACUUM_DATA_PAGE *)m_blockid를 담고 있는 페이지, 또는 NULL (“unloaded” 상태); 핫 루프용 페이지 fix 캐시
m_index (INT16)m_page->data[] 에서 m_blockid의 슬롯, 또는 INDEX_NOT_FOUND (-1); 6.4에서 WAL 레코드의 offset으로도 사용됨

Unloaded(m_page == NULL) 상태에서는 blockid만 의미 있다: is_valid()는 false를 반환하고 디스패치 루프는 실행되지 않는다. Loaded 상태에서는 페이지가 fix되고 슬롯이 유효하며, get_current_entry()m_page->data[m_index]를 반환한다. 소멸자는 m_page == NULL을 assert한다: load한 쪽이 반드시 unload해야 한다.

vacuum_worker_task 는 풀에 전달되는 잡 디스크립터다. 유일한 필드인 m_data는 항목의 값 복사본이다 — 워커는 live data 페이지에 접근하면 안 되는데, 잡 실행 중에 그 페이지가 이동하거나 unlink될 수 있기 때문이다.

// vacuum_worker_task -- src/query/vacuum.c
class vacuum_worker_task : public cubthread::entry_task
{
public:
vacuum_worker_task (const VACUUM_DATA_ENTRY & entry_ref)
: m_data (entry_ref) /* <- copy, not reference */
{ }
void execute (cubthread::entry & thread_ref) final
{ vacuum_process_log_block (&thread_ref, &m_data, false); } /* <- Chapter 7; assert elided */
private:
vacuum_worker_task (); /* <- private: no entry, no task */
VACUUM_DATA_ENTRY m_data;
};

6.2 vacuum_master_task::execute — 웨이크업당 한 번의 패스

섹션 제목: “6.2 vacuum_master_task::execute — 웨이크업당 한 번의 패스”
// vacuum_master_task::execute -- src/query/vacuum.c
void
vacuum_master_task::execute (cubthread::entry &thread_ref)
{
if (prm_get_bool_value (PRM_ID_DISABLE_VACUUM)) { return; }
if (check_shutdown ()) { return; }
if (!BO_IS_SERVER_RESTARTED ()) { return; } /* <- boot not finished or aborted */
// ... condensed: perf tracker, pgbuf_thread_variables_init ...
m_oldest_visible_mvccid = log_Gl.mvcc_table.update_global_oldest_visible ();
if (!vacuum_Data.is_loaded)
{
vacuum_data_load_first_and_last_page (&thread_ref); /* <- lazy: master never commits */
m_cursor.set_on_vacuum_data_start ();
}
// ... condensed: pgbuf_flush_if_requested on first_page and last_page ...
decrease_outstanding_job (m_cursor.force_data_update ()); /* <- the unconditional tick */
for (; m_cursor.is_valid () && !should_interrupt_iteration (); m_cursor.increment_blockid ())
{
if (!is_cursor_entry_ready_to_vacuum ())
{ break; } /* <- entries are blockid-ordered; later ones cannot be ready either */
if (!is_cursor_entry_available ())
{ continue; } /* <- already vacuumed or in progress; skip */
start_job_on_cursor_entry ();
if (should_force_data_update ())
{ decrease_outstanding_job (m_cursor.force_data_update ()); }
}
m_cursor.unload ();
// ... condensed: NDEBUG fix-count verification, perf timer ...
}

각 분기별 설명:

  1. 조기 종료(early-out). vacuum 비활성화, 셧다운, boot 미완료(절반만 복구된 데이터를 대상으로 디스패치하면 안전하지 않음). check_shutdown()vacuum_shutdown_sequence::check_shutdown_request()에 위임하는데, 이는 세 상태의 핸드셰이크(NO_SHUTDOWNSHUTDOWN_REQUESTEDSHUTDOWN_REGISTERED)로 구현된다: 마스터는 m_state_mutex 아래에 등록하고 요청자에게 notify_one을 보내며, 요청자는 등록 완료까지 request_shutdown 내부에서 블록된다 — 슬리핑 중이거나 패스 도중인 마스터도 셧다운 요청을 절대 놓치지 않는다.
  2. 워터마크 갱신: 웨이크업당 한 번.
  3. 지연 데이터 로드: 첫 번째 패스에서만 실행. boot의 commit은 boundary 페이지들을 unfix하지만, 마스터는 절대 commit하지 않으므로 스스로 로드한다. set_on_vacuum_data_start는 페이지를 load하지 않은 채로 커서 blockid를 첫 번째 blockid에 맞춰 놓는다.
  4. 무조건 업데이트 틱(tick). m_cursor.force_data_update()(6.3)는 finished-job 큐를 드레인하고 block buffer(vacuum_Data.update())를 소비한 뒤 커서를 재위치·재로드한다; 반환값(완료로 표시된 잡 수)은 decrease_outstanding_job(6.5)으로 흘러간다.
  5. 커서 루프: 세 가지 탈출 조건과 하나의 스킵:
    • 준비 안 됨 → break: newest_mvccid >= m_oldest_visible_mvccid (아직 보임) 또는 start_lsa.pageid + 1 >= log_Gl.append.prev_lsa.pageid (로그 테일에 너무 가까움). 두 조건 모두 blockid 순서로 단조롭기 때문에 이후 항목도 통과할 수 없다; break는 for-increment를 건너뛰고, 다음 웨이크업에서 같은 blockid를 재시도한다. 이것이 nothing-eligible 유휴 경로: 조용한 패스는 워터마크 갱신 1회, 틱 1회, 항목 탐색 1회만 수행한다.
    • 이용 불가 → continue: for-increment를 실행한다(6.4).
    • 디스패치(6.4), 이후 should_force_data_update()가 참이면 — vacuum_Finished_job_queue 또는 vacuum_Block_data_bufferis_half_full()이면 — 루프 중간 틱을 실행하고, 커서는 readjust_to_vacuum_data_changes(6.3)로 살아남는다.
    • 루프 조건 거짓 → 탈출: 커서 무효(전체 데이터 소비, 또는 search가 아무것도 찾지 못함), 또는 should_interrupt_iteration() — 셧다운, 또는 m_outstanding_job_countVACUUM_MAX_TASKS_IN_WORKER_POOL = 3 * PRM_ID_VACUUM_WORKER_COUNT에 도달했을 때의 풀-풀(pool-full) 백오프; 워커당 세 개의 큐잉된 태스크가 백로그를 제한하고, 다음 틱에서 카운트를 조정한다.
  6. m_cursor.unload(): 커서 페이지를 unfix하지만 blockid는 유지 — 다음 웨이크업의 틱이 정확히 그 위치에서 재개된다. 슬리핑을 걸쳐 커서 위치가 보존된다.

불변 조건 — 커서 페이지는 슬립이나 data update를 걸쳐 절대 고정(fix) 상태로 유지되지 않는다. executeunload()로 끝나고; force_data_updatevacuum_Data.update() 전에 unload한다. 소멸자 및 searchassert (m_page == NULL), 그리고 debug 빌드에서 패스마다 실행되는 vacuum_verify_vacuum_data_page_fix_count로 강제된다. 그렇지 않으면 update()가 커서가 여전히 fix하고 있는 페이지를 unlink하거나 해제할 수 있다.

6.3 커서: 변화하는 데이터 속에서 위치 유지하기

섹션 제목: “6.3 커서: 변화하는 데이터 속에서 위치 유지하기”

모든 위치 변경은 change_blockid로 수행된다: 전진-전용 규칙을 assert(assert (m_blockid <= blockid))하고, 소진 감지(m_blockid > vacuum_Data.get_last_blockid(), last + 1과 정확히 같음을 assert) 후 unload()하거나, 아니면 reload한다. increment_blockidchange_blockid (m_blockid + 1)이다. reload는 저비용 경로: 페이지가 이미 fix되어 있고 get_index_of_blockid가 해당 페이지 안에서 새 blockid를 찾으면 m_index만 이동한다 — 한 페이지에 많은 연속 항목이 담겨 있으므로 이것이 일반적인 경우다. 그렇지 않으면 unload 후 search로 내려간다; load(업데이트 이후 사용)는 !is_loaded()를 assert하고 search를 직접 호출한다:

// vacuum_job_cursor::search -- src/query/vacuum.c
void
vacuum_job_cursor::search ()
{
assert (m_page == NULL);
vacuum_data_page *data_page = vacuum_Data.first_page;
while (true)
{
m_index = data_page->get_index_of_blockid (m_blockid);
if (m_index != vacuum_data_page::INDEX_NOT_FOUND)
{ m_page = data_page; return; } /* <- found: keep the fix */
VPID next_vpid = data_page->next_page;
vacuum_unfix_data_page (&cubthread::get_entry (), data_page);
if (VPID_ISNULL (&next_vpid))
{ return; } /* <- not found: cursor stays unloaded */
data_page = vacuum_fix_data_page (&cubthread::get_entry (), &next_vpid);
}
}

페이지별 탐색은 O(1)이다: 페이지 내 항목들은 연속된 blockid를 가지므로, vacuum_data_page::get_index_of_blockid는 공백 확인 및 두 번의 범위 확인 후 (blockid - first_blockid) + index_unvacuumed를 계산한다. 항목을 찾지 못하면 m_page == NULL이 되어 is_valid()가 false — vacuum data가 비어 있을 때 별도의 케이스 처리 없이 디스패치를 종료하는 것도 이 경로다. fix/unfix 호출은 vacuum 전용 매크로다: first_pagelast_page는 마스터가 살아있는 동안 항상 fix 상태를 유지하므로, vacuum_fix_data_page는 boundary 페이지에 캐시된 포인터를 반환하고 vacuum_unfix_data_page는 해당 페이지의 실제 pgbuf_unfix를 건너뛴다. 따라서 search는 fix 호출 없이 vacuum_Data.first_page에서 시작할 수 있고, debug fix-count 검사는 unload() 이후에도 정확히 두 개의 영구 fix를 기대한다.

force_data_update는 변경 작업을 괄호로 묶는다: unload()(업데이트 중에는 load된 상태일 수 없음), mark_finished = vacuum_Data.update(), readjust_to_vacuum_data_changes(), load(), 그리고 mark_finished 반환. 재조정은 업데이트가 커서의 blockid를 제거하는 경우를 처리한다: vacuum_data_mark_finished(9장)는 vacuumed 된 헤드 항목을 잘라내고 헤드 페이지를 unlink할 수 있어서, 첫 번째 blockid가 m_blockid를 지나쳐 앞으로 이동할 수 있다; 커서가 “뒤처지게” 되면 새로운 첫 번째 blockid로 점프한다(data가 비어 있으면 아무것도 하지 않음 — load()가 아무것도 찾지 못함). 같은 blockid가 다른 페이지로 이동하는 경우에는 별도의 처리 코드가 필요 없다: load()가 새 첫 번째 페이지부터 다시 search하면 된다.

불변 조건 — 커서 blockid는 단조 비감소다. change_blockid의 assert로 강제되며; readjust는 오직 앞으로만 이동한다. 뒤로 이동하면 이미 디스패치된 항목을 재방문하게 되고, 완료 후 제거된 blockid에 대한 get_index_of_blockid의 산술 가정이 깨진다.

flowchart TD
  A["change_blockid(b)"] --> B{"b > last_blockid?"}
  B -- 예 --> C["unload: 모든 데이터 소진"]
  B -- 아니오 --> D{"m_page fix됨 AND<br/>b가 이 페이지 내에 있음?"}
  D -- 예 --> G["m_index만 이동 (핫 패스)"]
  D -- 아니오 --> I["unload + search"]
  I --> J{"페이지 탐색: get_index_of_blockid"}
  J -- 발견 --> K["m_page = page, fix 유지"]
  J -- "미발견, next_page null" --> L["unloaded 유지 -> is_valid false"]
  J -- "미발견, 다음 페이지 있음" --> M["unfix, 다음 페이지 fix"] --> J

Figure 6-1: 커서 재위치 — change_blockid, reload, search 분기.

6.4 잡 시작: 플래그, WAL, 복사, 푸시

섹션 제목: “6.4 잡 시작: 플래그, WAL, 복사, 푸시”

is_cursor_entry_available은 64비트 항목 blockid 상위 비트에 패킹된 상태 비트를 확인한다(1장): is_available()이면 통과; 그렇지 않으면 is_vacuumed()(완료 후 제거 대기) 또는 is_job_in_progress()(워커가 소유하거나, 크래시 전 실행이 플래그를 남긴 경우)로 assert되어 건너뛴다. 이용 가능한 항목이면 start_job_on_cursor_entry가 커서 페이지의 항목을 표시하고, m_cursor.get_current_entry()로 만든 vacuum_worker_task를 푸시하며, increase_outstanding_job()을 호출한다. 표시 단계:

// vacuum_job_cursor::start_job_on_current_entry -- src/query/vacuum.c
void
vacuum_job_cursor::start_job_on_current_entry () const
{
assert (is_valid ());
cubthread::entry *thread_p = &cubthread::get_entry ();
vacuum_data_entry &entry = m_page->data[m_index];
entry.set_job_in_progress (); /* <- status bits -> IN_PROGRESS, in the page itself */
if (!entry.was_interrupted ())
{
/* Log that a new job is starting. After recovery, the system will then know this job was
* partially executed. */
LOG_DATA_ADDR addr { NULL, (PAGE_PTR) m_page, (PGLENGTH) m_index };
log_append_redo_data (thread_p, RVVAC_START_JOB, &addr, 0, NULL);
}
vacuum_set_dirty_data_page_dont_free (thread_p, m_page);
}

항목은 data 페이지 자체에 IN_PROGRESS로 플래그되고, RVVAC_START_JOB redo 레코드가 태스크 푸시 전에 추가된다. 이 레코드는 데이터 바이트 없이 페이지 포인터와 항목 인덱스(레코드의 offset)만을 페이로드로 가지며, redo 함수 vacuum_rv_redo_start_jobdata[rcv->offset]set_job_in_progress()를 재실행하고 페이지를 dirty로 표시한다.

불변 조건 — 잡은 어떤 워커도 작업을 시작하기 전에 WAL로 시작이 기록되며, 블록은 언제든지 워커 풀에 최대 한 번만 들어간다. 하나의 디스패치 호출 내에서 플래그 → 로그 → 푸시 순서로 강제되며(소스 주석에 따르면 나중에 vacuum data를 다시 latch하는 것을 피하기 위해 여기서 로깅함), 위의 is_job_in_progress() 스킵이 이를 보완한다. 잡 도중 크래시가 발생하면, 복구가 RVVAC_START_JOB을 재실행하고 11장의 복원 패스가 IN_PROGRESS를 AVAILABLE + INTERRUPTED로 변환하여 블록이 안전 모드로 재처리된다; !was_interrupted() 가드는 이미 재디스패치된 블록의 재로깅을 건너뛴다. 워커 태스크의 값 복사본은 방금 설정된 IN_PROGRESS와 INTERRUPTED 비트를 담아서, vacuum_process_log_block이 안전 모드 redo를 위해 참조한다(7장).

increase_outstanding_job은 단순히 ++m_outstanding_job_count이며, 마스터 스레드 전용이라 락이 필요 없다. 감소 측은 완료를 직접 관찰하지 않는다: 워커들은 lock-free vacuum_Finished_job_queue(9장)에 보고하고, 마스터는 force_data_update 내부의 vacuum_Data.update()가 해당 큐를 드레인할 때만 완료를 인지한다. 이때 반환값(즉, vacuum_data_mark_finished가 소비한 수)이 decrease_outstanding_job으로 흘러들어간다. 방어적인 두 분기 — 음수 카운트, 그리고 현재 총계를 초과하는 카운트 — 는 각각 assert (false)를 실행하고, 로그를 남긴 뒤 카운터를 래핑(wrap)하는 대신 0으로 클램핑한다: unsigned 언더플로우가 발생하면 “풀 항상 가득 참”으로 읽혀 조용히 모든 vacuum이 멈추기 때문이다. 감소는 업데이트 틱에서만 발생하므로, 틱 사이에는 카운트가 in-flight 잡보다 과대평가된다; 최악의 경우 보수적인 조기 is_task_queue_full 탈출이 발생하고, 다음 틱에서 수정된다. 또한 should_force_data_update절반 찼을 때(VACUUM_FINISHED_JOB_QUEUE_CAPACITY = 2048) 발동하는 이유이기도 하다: 다 찰 때까지 기다리면 마스터가 루프 중간에 있는 동안 워커들이 가득 찬 finished 큐에서 블록될 위험이 있다.

  1. vacuum_master_task::executePRM_ID_VACUUM_MASTER_WAKEUP_INTERVAL 웨이크업마다 한 번의 유한한 패스를 실행한다: 조기 종료 가드(disable 파라미터, 셧다운 핸드셰이크, boot 미완료), 워터마크 갱신, 무조건 force_data_update 틱, 그리고 커서 루프 — 항목 미준비(break; 동일 blockid를 다음 웨이크업에서 재시도, 유휴 경로), 커서 소진, 셧다운, 또는 3 * PRM_ID_VACUUM_WORKER_COUNT에서의 풀 포화 중 하나로 탈출.
  2. vacuum_job_cursor의 진실의 원천은 논리적 m_blockid이며; (m_page, m_index)는 다시 fix할 수 있는 캐시다: reload는 일반 경우에 인덱스만 이동하고, search는 페이지당 O(1) 산술 탐색으로 페이지 체인을 재탐색하며, readjust_to_vacuum_data_changes는 헤드 트리밍으로 커서의 blockid가 제거된 경우 앞으로 점프한다.
  3. 커서는 전진 전용(assert (m_blockid <= blockid))이며, 모든 vacuum_Data.update() 호출과 매 패스 종료 시에 unload 상태여야 한다 — update가 커서가 fix한 바로 그 페이지를 unlink할 수 있다; fix/unfix 매크로는 영구 fix 상태인 first/last 페이지를 특별 처리한다.
  4. 디스패치는 플래그 → WAL → 푸시 순서: RVVAC_START_JOB(페이로드 없음, 페이지 + 항목 인덱스로 주소 지정)은 워커 태스크가 생성되기 전에 로깅되어, 복구 시스템이 부분 실행된 잡을 파악할 수 있다; IN_PROGRESS 스킵으로 블록이 동시에 최대 한 번만 디스패치됨을 보장한다.
  5. vacuum_worker_task는 data 항목의 값 복사본을 담아 워커와 vacuum data 페이지를 완전히 분리한다; 기본 생성자는 private이다.
  6. m_outstanding_job_count는 마스터 전용이며 락 불필요, 업데이트 틱에서 finished-job 큐를 드레인할 때만 조정된다; 틱 사이에는 과대 계산되어(가끔 조기 백오프, 동기화 없음) 계산 오류 시 래핑 대신 0으로 클램핑된다.

Chapter 7: 워커 로그 패스와 레코드별 디스패치

섹션 제목: “Chapter 7: 워커 로그 패스와 레코드별 디스패치”

Chapter 6은 vacuum_worker_task::executeVACUUM_DATA_ENTRY복사본vacuum_process_log_block에 넘기는 시점에서 끝났다. 이 챕터는 PROCESS_LOG 단계를 추적한다: 블록의 로그 페이지를 한 번에 프리패치하고, start_lsa에서 시작해 MVCC 연산 체인을 역방향으로 순회하며, 각 레코드를 세 가지 동작 중 하나로 변환한다 — 힙 OID 수집, B-트리 항목 인라인 vacuum, 또는 외부 스토리지 파일 삭제. 수집된 힙 배열의 실행은 Chapter 8에서, 완료 보고는 Chapter 9에서 다룬다.

7.1 워커 로컬 상태와 단발성 프리패치

섹션 제목: “7.1 워커 로컬 상태와 단발성 프리패치”

이 챕터의 모든 상태는 VACUUM_WORKER에 담긴다. 이 구조체는 vacuum_worker_allocate_resources(Chapter 2)가 한 번 할당하고, 이후 여러 잡(job)에 걸쳐 재사용된다:

// vacuum_worker -- src/query/vacuum.h
struct vacuum_worker
{
VACUUM_WORKER_STATE state; /* INACTIVE / PROCESS_LOG / EXECUTE */
INT32 drop_files_version; /* last seen dropped-files version (Ch 10) */
struct log_zip *log_zip_p; /* unzip scratch */
VACUUM_HEAP_OBJECT *heap_objects; /* collected per-job heap targets */
int heap_objects_capacity; /* starts at 4000, doubles on demand */
int n_heap_objects; /* reset to 0 at job start */
char *undo_data_buffer; /* page-straddling undo reassembly */
int undo_data_buffer_capacity;
// ... condensed (private_lru_index) ...
char *prefetch_log_buffer; /* the block's log pages, fetched once */
LOG_PAGEID prefetch_first_pageid;
LOG_PAGEID prefetch_last_pageid;
// ... condensed (allocated_resources, idx) ...
};
// vacuum_heap_object -- src/query/vacuum.h
struct vacuum_heap_object
{
VFID vfid; /* File ID of heap file. */
OID oid; /* Object OID. */
};

프리패치 버퍼는 VACUUM_PREFETCH_LOG_BLOCK_BUFFER_PAGES = 1 + vacuum_Data.log_block_npages 개의 페이지를 담는다 — 블록 크기보다 하나 더 많은 이유는, 블록의 마지막 페이지에서 시작하는 레코드가 다음 페이지로 넘칠 수 있기 때문이다. vacuum_log_prefetch_vacuum_block의 헤더 주석도 스필(spill) 페이지는 딱 하나만 처리한다고 명시한다. 이 함수는 prefetch_first_pageid/prefetch_last_pageid를 설정하고, 해당 범위에서 logpb_fetch_page (.., LOG_CS_SAFE_READER, ..) 루프를 돈다. 분기는 fetch 실패(assert (false) + ER_FAILED) 하나뿐이다. sa_mode_partial_block이 true일 때는 프리패치를 건너뛴다 — Chapter 11에서 다루는 SA_MODE 테일 블록은 로그가 완전하지 않기 때문이다.

vacuum_process_log_block에는 두 가지 조기 반환 경로가 있다 — 진입 시 PRM_ID_DISABLE_VACUUM 가드와 프리패치 실패. 이 두 경우는 end: 레이블 이전return하므로, vacuum_finished_block_vacuum(Chapter 9)이 실행되지 않고 해당 블록 항목은 vacuum 데이터에서 진행 중 상태로 남는다. 이 서버의 수명 안에서는 재시도가 없다. 다음 재시작 시에만 vacuum_data_load_and_recover가 진행 중 항목들을 set_interrupted()로 처리하여 되살린다.

이후의 모든 페이지 접근은 vacuum_fetch_log_page를 통한다:

// vacuum_fetch_log_page -- src/query/vacuum.c
if (vacuum_is_thread_vacuum (thread_p))
{
perfmon_inc_stat (thread_p, PSTAT_VAC_NUM_PREFETCH_REQUESTS_LOG_PAGES);
if (worker->prefetch_first_pageid <= log_pageid && log_pageid <= worker->prefetch_last_pageid)
{
size_t page_index = log_pageid - worker->prefetch_first_pageid;
memcpy (log_page_p, worker->prefetch_log_buffer + page_index * LOG_PAGESIZE, LOG_PAGESIZE);
perfmon_inc_stat (thread_p, PSTAT_VAC_NUM_PREFETCH_HITS_LOG_PAGES);
return NO_ERROR;
}
// else: warning log, fall through
}
// need to fetch from log
error = logpb_fetch_page (thread_p, &req_lsa, LOG_CS_SAFE_READER, log_page_p);
if (error != NO_ERROR)
{ assert (false); logpb_fatal_error (thread_p, true, ARG_FILE_LINE, "vacuum_fetch_log_page"); }

워커가 합법적으로 미스(miss)할 수 있는 방향은 뿐이다 — 단 하나의 스필 페이지를 넘어 뻗은 테일 레코드. 뒤쪽으로는 절대 미스하지 않는다: 7.2의 루프 경계가 이전 블록의 LSA를 역참조하기 전에 멈추기 때문이다. 워커가 아닌 경로(non-worker arm)는 Chapter 11의 vacuum_recover_lost_block_data를 위한 것으로, 부트 스레드는 프리패치 버퍼가 없다. 어느 경로에서든 fetch 실패는 logpb_fatal_error로 처리된다 — 해당 페이지 없이 vacuum은 진행할 수 없고, 건너뛰면 죽은 버전이 영구적으로 누수되기 때문이다.

7.2 역방향 순회와 반복별 게이트

섹션 제목: “7.2 역방향 순회와 반복별 게이트”

Chapter 3에서 보았듯이, 모든 MVCC 연산 로그 레코드는 LOG_VACUUM_INFO를 내장하며 그 prev_mvcc_op_log_lsa가 이전 MVCC 연산 레코드를 가리킨다. 블록의 start_lsa는 체인의 가장 최신 끝점이고, 워커는 역방향으로 걷는다. 다음 위치는 스캔이 아니라 방금 파싱한 레코드에서 나온다:

// vacuum_process_log_block -- src/query/vacuum.c
for (LSA_COPY (&log_lsa, &data->start_lsa); !LSA_ISNULL (&log_lsa) && log_lsa.pageid >= first_block_pageid;
LSA_COPY (&log_lsa, &log_vacuum.prev_mvcc_op_log_lsa))

불변 조건 — 체인 완결, 블록 경계 순회. 블록 내의 모든 MVCC 연산 레코드는 start_lsa에서 prev_mvcc_op_log_lsa를 따라가면 순서대로 도달할 수 있으며, LSA는 엄격히 감소한다. 경계 조건 log_lsa.pageid >= first_block_pageid(VACUUM_FIRST_LOG_PAGEID_IN_BLOCK을 blockid로 계산)는 단일한 전역 체인을 블록 단위 잡으로 분할한다: 이 워커는 이전 블록의 잡이 담당하는 지점에서 멈춘다. MVCC 연산 레코드를 로그에 추가하면서 링크를 연결하지 않은 프로듀서가 있다면, 해당 레코드는 vacuum에게 영구적으로 보이지 않는다 — 폴백 스캔은 없다.

루프 시작 전에 잡은 get_global_oldest_visible()에서 threshold_mvccid를 스냅샷으로 가져오고, n_heap_objects를 0으로 초기화하며, was_interrupted = data->was_interrupted () || sa_mode_partial_block을 계산한다 — Chapter 8이 이전 실행이 일부 대상을 이미 vacuum했을 수 있을 때 안전 가드를 완화하는 용도다. 각 반복은 디스패치 전에 네 가지 게이트를 통과한다:

  1. 종료 / 인터럽트. SERVER_MODE에서 thread_p->shutdownerror_codeNO_ERROR인 채로 goto end한다 — 잡이 중단되고, Chapter 9의 vacuum_finished_block_vacuum이 블록을 재실행 대상 인터럽트로 표시한다. SA_MODE에서는 이에 상응하는 처리(logtb_get_check_interruptlogtb_is_interrupted)가 error_code = ER_INTERRUPTED를 설정한다: 독립 실행 vacuum은 사용자 가시 연산 내부에서 돌기 때문에 인터럽트가 오류로 표면화되어야 한다.
  2. 상태를 PROCESS_LOG로 전환, PERF_UTIME_TRACKER_TIME_AND_RESTART (..., PSTAT_VAC_WORKER_EXECUTE)와 쌍을 이룬다 — 전환 전까지의 시간이 execute 시간으로 기록된다.
  3. 페이지 캐시 확인. if (log_page_p->hdr.logical_pageid != log_lsa.pageid)이면 vacuum_fetch_log_page로 재fetch한다; 실패 시 assert_release + logpb_fatal_error + goto end.
  4. 레코드 파싱. vacuum_process_log_record (7.3). 오류 발생 시 vacuum_check_shutdown_interruption이 실패가 종료 허용 조건인지 확인(!vacuum_is_thread_vacuum_worker (thread_p) || (thread_p->shutdown && error_code == ER_INTERRUPTED))한 뒤 goto end.

파싱 이후 상태는 VACUUM_WORKER_STATE_EXECUTE로 전환되고(PSTAT_VAC_WORKER_PROCESS_LOG에 대한 perf 재시작과 대칭), 두 가지 게이트가 더 있다: 드롭된 파일에 대한 continue(레코드가 속한 파일 전체가 사라진 경우 — 7.3과 Chapter 10), 그리고 !NDEBUG 전용 envelope 검사 — 위반 시 assert (0) + logpb_fatal_error + goto end.

불변 조건 — MVCCID 봉투. 디버그 빌드에서, 순회 중 만나는 모든 MVCCID는 블록 생성 시(Chapter 3) 기록된 [data->oldest_visible_mvccid, data->newest_mvccid] 범위 안에 있어야 하며, 잡의 threshold_mvccid 스냅샷보다 엄격히 작아야 한다. 위반은 vacuum 데이터 또는 워터마크가 오염되었음을 의미하며, 진행하면 어떤 스냅샷이 아직 볼 수 있는 버전을 삭제하게 된다 — 따라서 건너뜀이 아니라 fatal 처리다.

flowchart TD
    A["log_lsa = start_lsa"] --> B{"LSA null 또는 pageid<br/>first_block_pageid 미만?"}
    B -- 예 --> Z["루프 종료 -> vacuum_heap<br/>Chapter 8, vacuum_complete=true"]
    B -- 아니오 --> C{"종료 SERVER_MODE /<br/>인터럽트 SA_MODE?"}
    C -- 예 --> END["goto end: 상태 INACTIVE,<br/>vacuum_finished_block_vacuum Ch 9"]
    C -- 아니오 --> D["state = PROCESS_LOG"] --> E{"페이지 캐시됨?"}
    E -- 아니오 --> F["vacuum_fetch_log_page"]
    F -- 오류 --> END
    E -- 예 --> G
    F -- 성공 --> G["vacuum_process_log_record"]
    G -- 오류 --> END
    G -- 성공 --> H["state = EXECUTE"] --> I{"is_file_dropped?"}
    I -- 예 --> N["continue"]
    I -- 아니오 --> V{"디버그 빌드: MVCCID<br/>봉투 밖?"}
    V -- "예: fatal" --> END
    V -- 아니오 --> J{"rcvindex?"}
    J -- "힙 연산" --> K["OID 수집, 7.4"]
    J -- "B-트리 연산" --> L["디코드 + 인라인 vacuum, 7.5"]
    J -- "RVES_NOTIFY_VACUUM" --> M["LOB 파일 삭제, 7.6"]
    J -- "기타" --> Q["assert 안전장치"]
    K --> N
    L --> N
    M --> N
    Q --> N
    N --> R["log_lsa = prev_mvcc_op_log_lsa"] --> B

Figure 7-1: vacuum_process_log_block PROCESS_LOG 루프의 분기 완전 반복 흐름.

stateDiagram-v2
    [*] --> INACTIVE
    INACTIVE --> PROCESS_LOG : 반복 시작 \n 로그 레코드 파싱
    PROCESS_LOG --> EXECUTE : 레코드 파싱 완료 \n 디스패치 실행
    EXECUTE --> PROCESS_LOG : 다음 체인 이동
    EXECUTE --> INACTIVE : 루프 종료 또는 goto end

Figure 7-2: 반복당 워커 상태 전환. 이 분리는 PSTAT_VAC_WORKER_PROCESS_LOG / PSTAT_VAC_WORKER_EXECUTE 성능 계측의 기준이 되며, INACTIVE가 아닌 상태는 해당 워커를 vacuum_get_worker_min_dropped_files_version(Chapter 10)에게 가시적으로 만든다 — 파일 드로퍼가 이 워커를 기다려야 함을 파악하는 방식이다.

7.3 vacuum_process_log_record 완전 해부

섹션 제목: “7.3 vacuum_process_log_record 완전 해부”

파서는 log_lsa_p를 레코드의 undo 데이터 위치에 남긴 채 다섯 가지 정보를 추출한다: MVCCID, 복구 대상(LOG_DATA: rcvindex, volid, pageid, offset), vacuum 체인 정보, 그리고 필요한 경우 사용 가능한 undo 데이터 포인터.

헤더 파싱. LOG_GET_LOG_RECORD_HEADER와 정렬 이동 후, 세 가지 파싱 암(arm)에서 네 가지 레코드 타입이 허용되며, 각각 다르게 생긴 바디에서 동일한 다섯 가지 정보를 추출한다:

// vacuum_process_log_record -- src/query/vacuum.c
if (log_rec_type == LOG_MVCC_UNDO_DATA)
{
vacuum_read_advance_when_doesnt_fit (thread_p, sizeof (*mvcc_undo), log_lsa_p, log_page_p);
mvcc_undo = (LOG_REC_MVCC_UNDO *) (log_page_p->area + log_lsa_p->offset);
*mvccid = mvcc_undo->mvccid;
*log_record_data = mvcc_undo->undo.data;
ulength = mvcc_undo->undo.length;
LSA_COPY (&vacuum_info->prev_mvcc_op_log_lsa, &mvcc_undo->vacuum_info.prev_mvcc_op_log_lsa);
VFID_COPY (&vacuum_info->vfid, &mvcc_undo->vacuum_info.vfid);
}
else if (log_rec_type == LOG_MVCC_UNDOREDO_DATA || log_rec_type == LOG_MVCC_DIFF_UNDOREDO_DATA)
{ /* same shape via LOG_REC_MVCC_UNDOREDO; ulength = undoredo.ulength */ }
else if (log_rec_type == LOG_SYSOP_END)
{
if (sysop_end->type != LOG_SYSOP_END_LOGICAL_MVCC_UNDO)
{ assert (false); return ER_FAILED; } /* <- only this flavor carries vacuum info */
mvcc_undo = &sysop_end->mvcc_undo; /* <- embedded LOG_REC_MVCC_UNDO, same extraction */
}
else
{ assert (false); /* ER_GENERIC_ERROR */ return ER_FAILED; } /* <- any other type = corrupt chain */

(구조체 형태 — LOG_VACUUM_INFO를 감싸는 LOG_REC_MVCC_UNDO/LOG_REC_MVCC_UNDOREDO — 는 log_record.hpp의 Chapter 3 내용이다; diff와 non-diff undoredo는 vacuum이 undo 쪽만 읽으므로 동일하게 파싱된다.)

정렬 읽기 클론. 네 가지 헬퍼가 존재하는 이유는, 기존 LOG_READ_* 매크로가 로그 페이지 버퍼로 fetch하는 반면 vacuum은 프리패치 버퍼를 거쳐 vacuum_fetch_log_page로 라우팅해야 하기 때문이다. vacuum_read_log_alignedoffsetDOUBLE_ALIGNMENT로 정렬하고, offset >= LOGAREA_SIZE인 동안 pageid를 진행하며 재fetch한다(fetch 실패: logpb_fatal_error). vacuum_read_log_add_aligned는 더하기 후 정렬, vacuum_read_advance_when_doesnt_fit은 요청한 구조체가 페이지 경계를 넘을 경우 강제로 다음 페이지를 fetch하여 위의 캐스트가 항상 연속된 구조체를 볼 수 있게 한다. vacuum_copy_data_from_log는 데이터가 페이지 안에 맞으면 memcpy 한 번, 아니면 여러 fetch에 걸쳐 청크 복사한다.

복구 조기 반환. stop_after_vacuum_info == true이면 헤더 암 직후 NO_ERROR를 반환한다 — 호출자가 prev_mvcc_op_log_lsa만 원하는 경우다. 진입 assert들이 계약을 표현한다: 이 모드에서 worker, undo_data_ptr, undo_data_size, is_file_dropped는 모두 NULL일 수 있다. 이 모드의 유일한 호출자는 vacuum_recover_lost_block_data(Chapter 11)다.

드롭된 파일 단락. 레코드가 NULL이 아닌 vacuum_info->vfid를 가질 때:

// vacuum_process_log_record -- src/query/vacuum.c
if (worker->drop_files_version != vacuum_Dropped_files_version)
{
/* But first, cleanup collected heap objects. */
VFID_COPY (&vfid, &vacuum_Last_dropped_vfid);
vacuum_cleanup_collected_by_vfid (worker, &vfid);
worker->drop_files_version = vacuum_Dropped_files_version;
}
error_code = vacuum_is_file_dropped (thread_p, is_file_dropped, &vacuum_info->vfid, *mvccid);
if (error_code != NO_ERROR) { vacuum_check_shutdown_interruption (...); return error_code; }
if (*is_file_dropped == true) { return NO_ERROR; }

핸드셰이크와 원장 조회는 Chapter 10의 주제다. 여기서 중요한 것은 순서다 — 워커는 새 버전을 공표하기 전에 자신의 heap_objects 배열을 정리해야 한다. 공표가 드로퍼를 해제하면 그 이후 파일의 페이지가 재사용될 수 있기 때문이다. vacuum_cleanup_collected_by_vfidvacuum_compare_heap_object로 배열을 qsort한 뒤 VFID가 일치하는 연속 구간을 제거한다. 드롭 판정은 *is_file_dropped = true와 함께 NO_ERROR를 반환하며, 블록 루프는 continue한다.

Undo 데이터 추출. 힙 레코드는 조기 반환한다 — if (!LOG_IS_MVCC_BTREE_OPERATION (rcvindex) && rcvindex != RVES_NOTIFY_VACUUM) return NO_ERROR; — 힙 패스(Chapter 8)는 로그 이미지가 아닌 현재 힙 페이지를 읽기 때문이다. 나머지의 경우, ZIP_CHECK (ulength)가 실제 크기(GET_ZIP_LEN)를 결정하고, 이후:

// vacuum_process_log_record -- src/query/vacuum.c
if (log_lsa_p->offset + *undo_data_size < (int) LOGAREA_SIZE)
{ *undo_data_ptr = (char *) log_page_p->area + log_lsa_p->offset; } /* <- zero-copy into the page */
else
{
if (worker->undo_data_buffer_capacity < *undo_data_size)
{ /* realloc; NULL -> fatal-logged ER_FAILED; capacity grows monotonically */ }
*undo_data_ptr = worker->undo_data_buffer;
vacuum_copy_data_from_log (thread_p, *undo_data_ptr, *undo_data_size, log_lsa_p, log_page_p);
}
if (is_zipped)
{
if (log_unzip (worker->log_zip_p, *undo_data_size, *undo_data_ptr))
{ *undo_data_size = (int) worker->log_zip_p->data_length;
*undo_data_ptr = (char *) worker->log_zip_p->log_data; } /* <- now into log_zip's buffer */
else { /* fatal-logged */ return ER_FAILED; }
}

따라서 undo_data는 세 가지 소유자 중 하나를 별칭(alias)할 수 있다 — 로그 페이지, worker->undo_data_buffer, 또는 log_unzip 이후 worker->log_zip_p. 이 세 가지 모두 다음 레코드가 파싱될 때까지 안정적이며, 디스패치 암이 필요로 하는 딱 그 시간만큼 유효하다.

7.4 Arm 1 — 힙: 지금 수집, 나중에 실행

섹션 제목: “7.4 Arm 1 — 힙: 지금 수집, 나중에 실행”

힙 rcvindex들(mvcc.hLOG_IS_MVCC_HEAP_OPERATION: RVHF_MVCC_DELETE_REC_HOME, RVHF_MVCC_INSERT, RVHF_UPDATE_NOTIFY_VACUUM, RVHF_MVCC_DELETE_MODIFY_HOME, RVHF_MVCC_NO_MODIFY_HOME, RVHF_MVCC_REDISTRIBUTE)은 레코드별로 실행되지 않는다. OID는 LOG_DATA에서 재조립되며, 한 가지 미묘한 점이 있다:

// vacuum_process_log_block -- src/query/vacuum.c
heap_object_oid.slotid = heap_rv_remove_flags_from_offset (log_record_data.offset);
/* <- offset & ~HEAP_RV_FLAG_VACUUM_STATUS_CHANGE (0x8000): the producer smuggled a recovery
flag into the slotid field (Chapter 3); strip it or vacuum targets a garbage slot */
error_code = vacuum_collect_heap_objects (thread_p, worker, &heap_object_oid, &log_vacuum.vfid);
if (error_code != NO_ERROR)
{
assert_release (false);
er_clear ();
error_code = NO_ERROR;
continue; /* <- one lost OID must not sink the block: release keeps going */
}

vacuum_collect_heap_objectsVACUUM_HEAP_OBJECT(7.1)를 worker->heap_objects에 추가하며, 꽉 찼을 때는 realloc으로 용량을 두 배로 늘린다(초기값 VACUUM_DEFAULT_HEAP_OBJECT_BUFFER_SIZE = 4000; 실패 모드는 ER_OUT_OF_VIRTUAL_MEMORY뿐). VFID도 함께 실어 나르는 이유는 Chapter 8이 슬롯 재사용 가능 여부를 파일에 물어봐야 하기 때문이다. vacuum_compare_heap_object — VFID fileid, volid 순, 이후 OID pageid, volid, slotid 순 — 는 배열을 파일별, 페이지별로 그룹화하며, Chapter 8의 vacuum_heap qsort와 7.3의 제거 작업 모두에 사용된다. 힙 작업을 미루면 한 페이지의 모든 레코드를 레코드당 하나의 fix/log 사이클 대신 하나의 fix/log 사이클로 묶을 수 있다.

7.5 Arm 2 — B-트리: 디코드 후 인라인 실행

섹션 제목: “7.5 Arm 2 — B-트리: 디코드 후 인라인 실행”

B-트리 레코드들(LOG_IS_MVCC_BTREE_OPERATION: RVBT_MVCC_DELETE_OBJECT, RVBT_MVCC_INSERT_OBJECT, RVBT_MVCC_INSERT_OBJECT_UNQ, RVBT_MVCC_NOTIFY_VACUUM)은 페이지 단위로 묶을 수 없다 — 키를 매번 위에서 아래로 검색해야 하기 때문이다 — 그래서 인라인으로 실행된다. Undo 페이로드는 두 가지 방식으로 디코드된다: RVBT_MVCC_INSERT_OBJECT_UNQbtree_rv_read_keybuf_two_objects를 거치는데, 이 함수는 BTID와 두 개의 BTREE_OBJECT_INFO를 언팩한다 — 유니크 인덱스의 MVCC insert는 리프의 첫 번째 슬롯에서 기존 항목을 밀어내므로, 로그에는 두 버전이 담기고 vacuum의 대상 OID/class-OID는 이전 것에서 온다. 그 외는 btree_rv_read_keybuf_nocopy를 거치며, 이 단일 객체 버전은 OID에 비트 팩된 플래그에서 mvcc_info도 채운다. 어느 쪽이든 key_buf는 아직 팩된 키를 감싸고, assert (!OID_ISNULL (&oid))가 디코드를 검증한다. 이후 목적별 디스패치는 네 갈래다:

// vacuum_process_log_block -- src/query/vacuum.c
if (log_record_data.rcvindex == RVBT_MVCC_NOTIFY_VACUUM)
{
if (MVCCID_IS_VALID (mvcc_info.delete_mvccid))
{ error_code = btree_vacuum_object (..., mvcc_info.delete_mvccid); }
else if (MVCCID_IS_VALID (mvcc_info.insert_mvccid) && mvcc_info.insert_mvccid != MVCCID_ALL_VISIBLE)
{ error_code = btree_vacuum_insert_mvccid (..., mvcc_info.insert_mvccid); }
else
{ /* impossible case */ assert_release (false); continue; }
}
else if (log_record_data.rcvindex == RVBT_MVCC_DELETE_OBJECT)
{ error_code = btree_vacuum_object (..., mvccid); } /* <- record's own MVCCID is the delid */
else if (log_record_data.rcvindex == RVBT_MVCC_INSERT_OBJECT
|| log_record_data.rcvindex == RVBT_MVCC_INSERT_OBJECT_UNQ)
{ error_code = btree_vacuum_insert_mvccid (..., mvccid); }
else
{ /* Unexpected. */ assert_release (false); }

RVBT_MVCC_NOTIFY_VACUUM은 양자택일 케이스다: 인덱스 로드는 vacuum이 죽은 삭제 객체(유효한 delete_mvccid — 제거)를 볼지 정착된 insert(유효하고 MVCCID_ALL_VISIBLE이 아닌 insert_mvccid — insid만 제거)를 볼지 알지 못한 채 객체당 하나의 알림을 기록한다. 둘 다 유효하지 않으면 오염으로 판단해 release에서 continue로 건너뛴다. 실행자들은 하나의 엔진 위에 얇게 래핑된다:

// btree_vacuum_object -- src/storage/btree.c
BTREE_MVCC_INFO_SET_DELID (&match_mvccinfo, delete_mvccid);
return btree_delete_internal (thread_p, btid, oid, class_oid, &mvcc_info, NULL, buffered_key, NULL,
SINGLE_ROW_MODIFY, NULL, &match_mvccinfo, NULL, NULL, BTREE_OP_DELETE_VACUUM_OBJECT);

btree_vacuum_insert_mvccidBTREE_MVCC_INFO_SET_INSIDBTREE_OP_DELETE_VACUUM_INSID로 대칭적이다. match_mvccinfo는 연산을 멱등(idempotent)으로 만든다: 이전에 인터럽트된 실행이 이미 항목을 정리했다면, 순회에서 일치 항목을 찾지 못하고 성공으로 반환한다 — 인터럽트된 블록은 start_lsa부터 재실행되므로(Chapter 9) 이 멱등성은 핵심적이다. 이 암의 에필로그: SERVER_MODE 전용 오버플로우 페이지 계측 블록(thread_p->read_ovfl_pages_count, 디스패치 전 0으로 초기화, g_ovfp_threshold_mgr와 비교), 그리고 오류 분기 — thread_p->shutdown이면 B-트리 오류는 허용되는 인터럽트로 처리(goto end); 아니면 assert되고, 로깅되고, er_clear()된 뒤 무력화(error_code = NO_ERROR)되어 블록이 계속된다. 힙 암과 동일한 견고성 정책이다.

7.6 Arm 3 — 외부 스토리지, 그리고 그것을 설명하는 nop

섹션 제목: “7.6 Arm 3 — 외부 스토리지, 그리고 그것을 설명하는 nop”

RVES_NOTIFY_VACUUM은 undo 데이터에 팩된 URI 문자열을 담는다:

// vacuum_process_log_block -- src/query/vacuum.c
(void) or_unpack_string (undo_data, &es_uri);
if (es_delete_file (es_uri) != NO_ERROR)
{ er_clear (); } /* <- file may already be gone; swallow */
else
{ ASSERT_NO_ERROR (); }
db_private_free_and_init (thread_p, es_uri);

프로듀서 쪽이 이 트릭을 설명한다. LOB 파일은 삭제-커밋 시점에 unlink할 수 없다 — 오래된 스냅샷이 아직 읽을 수 있기 때문이다 — 그래서 vacuum_notify_es_deleted는 아무에게도 주소가 가지 않는 undo 전용 레코드를 추가한다:

// vacuum_notify_es_deleted -- src/query/vacuum.c
/* This is not actually ever undone, but vacuum will process undo data of log entry. */
log_append_undo_data (thread_p, RVES_NOTIFY_VACUUM, &addr, length, data);

롤백이 해당 레코드의 undo 함수를 실행하기 때문에, vacuum_rv_es_nop이 등록된 핸들러로 존재하며 아무것도 하지 않는다: 이 레코드는 vacuum에 보내는 메시지 병(message in a bottle)이지 복구 가능한 변경이 아니다. Undo 데이터 채널이 재사용되는 것은, 모든 MVCC undo 레코드가 이 챕터에서 순회하는 체인에 자동으로 합류하기 때문이다. 세 암 이후의 마지막 else는 안전 장치 assert_release (false) — 힙도, B-트리도, ES도 아닌 rcvindex는 체인에 들어와선 안 된다.

각 반복은 worker->state == VACUUM_WORKER_STATE_EXECUTE이고 시스템 연산이 누수되지 않았음을 assert하며 닫힌다(!...is_under_sysop ()). 동일한 쌍이 루프 종료 직전에도 vacuum_heap 실행(Chapter 8) 전과 vacuum_complete = true 설정 전에 확인된다. end: 에필로그는 상태를 INACTIVE로 되돌리고 — sa_mode_partial_block 잡은 vacuum 데이터 항목이 없으므로 제외 — vacuum_finished_block_vacuum(Chapter 9)을 해당 플래그와 함께 호출한다. SERVER_MODE에서는 누수 방지를 위해 pgbuf_unfix_all도 실행한다.

  1. 워커는 각 블록의 로그 페이지를 자신의 prefetch_log_buffer에 정확히 한 번 읽어들인다(블록 크기 + 스필 페이지 1개). 이후 vacuum_fetch_log_page의 접근은 memcpy 히트다. 합법적인 미스는 앞 방향만 가능하다 — 단 하나의 여분 페이지를 넘어 뻗은 테일 레코드 — 뒤 방향은 없다.
  2. PROCESS_LOG 루프는 data->start_lsa에서 시작해 prev_mvcc_op_log_lsa 체인을 역방향으로 걷는다. pageid >= first_block_pageid 경계로 블록 단위 분할된다 — 스캔이 아니므로, 링크되지 않은 MVCC 레코드는 vacuum에 영구적으로 보이지 않는다.
  3. vacuum_process_log_record는 세 가지 파싱 암에서 네 가지 레코드 타입을 허용한다(LOG_MVCC_UNDO_DATA, 두 가지 MVCC undoredo 타입, LOG_SYSOP_END_LOGICAL_MVCC_UNDO 타입의 LOG_SYSOP_END). stop_after_vacuum_info 모드는 Chapter 11의 복구 순회를 위한 것이며, 드롭된 파일 단락(Chapter 10)은 새 드롭 버전을 확인하기 전에 이미 수집된 OID를 정리한다.
  4. Undo 데이터는 지연 구체화되며 로그 페이지, 가변 크기 undo_data_buffer, 또는 log_unzip 이후 log_zip_p 버퍼 중 하나를 별칭할 수 있다 — 다음 레코드 파싱까지만 유효하다. 힙 레코드는 추출 자체를 건너뛴다.
  5. 세 암은 비대칭적으로 실행된다: 힙 OID는 Chapter 8의 배치 패스를 위해 수집된다(플래그 제거된 slotid, 용량 두 배 배열). B-트리 항목은 match-MVCCID 멱등성을 가진 btree_delete_internal인라인으로 vacuum된다. ES 레코드는 메시지로만 존재하는 로그 레코드로 LOB 파일을 삭제하며, 복구 핸들러 vacuum_rv_es_nop은 아무것도 하지 않는다.
  6. Release 빌드의 레코드별 실패는 로그되고, 지워지고, 건너뛰어진다. 종료/인터럽트와 파싱 오염은 Chapter 9에서 블록을 재실행 대상으로 버리며, PRM_ID_DISABLE_VACUUM 가드와 프리패치 실패는 완료 경로 이전에 반환한다 — 해당 항목들은 다음 재시작 시 vacuum_data_load_and_recover가 인터럽트 처리할 때까지 진행 중 상태로 남는다.
  7. PROCESS_LOG/EXECUTE 상태 전환은 성능 계측을 괄호 짓고, 워커를 드롭 파일 버전 핸드셰이크에 가시적으로 유지한다. 루프 테일과 루프 종료 모두에서 상태 규율과 시스템 연산 누수 없음을 assert한다.

7장은 워커에게 worker->heap_objects 안의 (VFID, OID) 쌍 배열을 남겼다. 이 장에서는 두 번째 배치 패스를 따라간다. vacuum_heap이 배열을 힙 페이지 단위로 묶고, vacuum_heap_page가 각 배치를 VACUUM_HEAP_HELPER 작업대 위에서 처리하며, mvcc_satisfies_vacuum이 레코드별 판정을 내리고, 모든 변경은 11장을 위해 로그에 기록된다. 힙 페이지 구조, REC_* 슬롯 타입, heap_remove_page_on_vacuumcubrid-heap-manager-detail.md에서, MVCC 헤더 레이아웃과 가시성 계열 함수는 cubrid-mvcc-detail.md에서 다루며, 이 장에서는 그 결과를 사용할 뿐 재유도하지 않는다.

8.1 배칭: vacuum_heap의 정렬, 그룹화, 오류 생존 전략

섹션 제목: “8.1 배칭: vacuum_heap의 정렬, 그룹화, 오류 생존 전략”

VACUUM_HEAP_OBJECT(vacuum.h)는 vfidoid 두 필드만 보유한다. vacuum_heapvacuum_process_log_block에서 잡당 한 번 호출되며, 블록의 threshold_mvccidwas_interrupted 플래그(잡이 충돌이나 종료 후 재실행될 때 true — 6장)를 인자로 받는다. vacuum_compare_heap_object로 qsort하는데, 정렬 키는 vfid.fileid, vfid.volid, oid.pageid, oid.volid, oid.slotid 순이다. 그 결과 배열은 파일 그룹 안에 페이지 런 안에 슬롯 정렬된 중복들로 구성되며, 이후 페이지 런마다 vacuum_heap_page를 한 번씩 호출한다. 파일 경계마다 캐시된 HFID에 HFID_SET_NULL을 적용하고, 새 그룹의 첫 vacuum_heap_page가 지연(lazy) 방식으로 다시 채운다(8.3). 오류 처리는 빌드에 따라 비대칭이다. 디버그 빌드(또는 종료 중)에서는 잡을 중단하지만, 릴리스 빌드(#if defined (NDEBUG))에서는 오류를 지우고 실패한 페이지를 건너뛴다 — 블록은 여전히 vacuumed로 표시되므로(9장), 건너뛴 레코드는 이후 해당 레코드를 건드리는 삭제가 발생할 때까지 남아 있는다. 전진 우선 정책이다.

삭제된 파일은 이 루프에 도달하지 않는다. 필터는 수집 시점에 실행된다. vacuum_process_log_record(7장)는 vacuum_collect_heap_objects 호출 전에 vacuum_is_file_dropped(10장)를 확인하고, 블록 처리 도중 vacuum_Dropped_files_version이 진행됐을 경우 vacuum_cleanup_collected_by_vfid를 호출해 vacuum_Last_dropped_vfid의 수집 항목을 정리한다(정렬 → VFID 범위 탐색 → memmove로 꼬리 압축).

vacuum_heap_page는 모든 작업 상태를 스택 구조체 하나에 유지하므로, latch를 내려놓고 재시도할 때 컨텍스트를 저렴하게 재구성할 수 있다.

필드역할
home_page, home_vpid쓰기 latch를 잡은 PAGE_HEAP 포인터 + VPID (unfix/re-fix를 거쳐도 유지); NULL page = “latch 해제 상태”
forward_page, forward_oid두 번째로 fix한 페이지와 home 링크 레코드의 OID; 의미는 아래 매트릭스의 record_type에 따라 다름
crt_slotid현재 vacuum 중인 슬롯; 중복 필터로도 사용
record_type슬롯 타입 — 재시도마다 새로 읽음 (latch를 놓는 사이 레코드가 바뀔 수 있음)
record over rec_buf[IO_MAX_PAGE_SIZE + MAX_ALIGNMENT]현재 레코드의 COPY (PEEK 아님); insid strip이 인플레이스로 덮어쓰며, REC_RELOCATION에서는 NEWHOME 제거의 undo 이미지로도 쓰임
mvcc_header디코딩된 헤더 — mvcc_satisfies_vacuum의 입력
hfid, overflow_vfid, reusable파일 식별: 그룹별 HFID 캐시, 지연 해석되는 overflow 파일(heap_ovf_delete에 필요), FILE_HEAP_REUSE_SLOTS 플래그(슬롯 재활용, 8.6)
can_vacuum현재 레코드의 판정; 실행 분기를 결정
slots[MAX_SLOTS_IN_PAGE], results[MAX_SLOTS_IN_PAGE], n_bulk_vacuumed대기 중인 bulk 로깅 배치(8.7) — 단일 페이지 REC_HOME 변경만 해당
forward_recdes over forward_linkhome 링크 슬롯의 COPY; home 제거 작업의 undo 이미지로도 사용
n_vacuumed, initial_home_free_space, time_track상태 검증 입력, heap_stats_update 델타 기준값, prepare/execute/log 성능 구분
필드REC_HOMEREC_RELOCATIONREC_BIGONE
forward_page미사용 (NULL 보장)REC_NEWHOME을 보유한 페이지첫 번째 overflow 페이지
forward_oid미사용REC_NEWHOME 슬롯의 OID첫 번째 overflow VPID를 지칭하는 OID
recordhome 레코드 복사본REC_NEWHOME 레코드 복사본미사용
mvcc_headerrecord에서 추출record에서 추출heap_get_mvcc_rec_header_from_overflow 경유
  1. Fix, 중단 재실행 방식. was_interrupted가 true이면: pgbuf_fix_if_not_deallocated — 오류 → return; home_page == NULL (이전 부분 실행에서 이미 해제됨) → 경고 후 NO_ERROR 반환; 페이지 타입이 PAGE_FTAB(해제 후 파일 테이블 페이지로 재사용됨) → unfix 후 NO_ERROR 반환. 두 허용 케이스 모두 n_heap_objects == 1을 단언한다. 일반 실행에서는 단순 pgbuf_fix를 사용하며 실패 시 하드 오류로 처리한다. 불변 조건 — 재실행 잡은 사라진 페이지를 허용해야 하며, 최초 실행은 그래서는 안 된다. 완화 경로를 무조건 사용하면 실제 데이터의 fix 실패를 조용히 건너뛰게 된다.
  2. 파일 식별. 캐시된 hfid가 NULL이면 vacuum_heap_get_hfid_and_file_typeheap_get_class_oid_from_page, file_descriptor_get, file_get_type을 순서대로 실행한다 — 실패 시 단언 후 반환; HFID가 NULL이거나 타입이 {FILE_HEAP, FILE_HEAP_REUSE_SLOTS} 밖이면 ER_FAILED. 성공 시 helper.reusable = (ftype == FILE_HEAP_REUSE_SLOTS)로 설정하고 호출자의 그룹별 캐시로 복사한다.
  3. 슬롯별 루프. 중복은 crt_slotid로 건너뜀; vacuum_heap_prepare_record(8.4)에서 오류가 나면 forward 페이지를 unfix하고 end로 점프한다. 이후 REC_RELOCATION/REC_HOME/REC_BIGONE 각각에 mvcc_satisfies_vacuum(8.5)을 호출한다: REMOVEvacuum_heap_record; DELETE_INSID_PREV_VERvacuum_heap_record_insid_and_prev_version; CANNOT_VACUUM → 아무것도 하지 않음. 그 외 슬롯 타입은 default:로 처리되며(코드 주석에 따르면, 다른 워커가 이미 vacuum했거나 롤백 후 재사용된 경우) 무시된다. 실행 오류가 발생하면 assert_release (false) 후, home latch를 잃었으면 goto end, 그렇지 않으면 continue.
  4. 상태 다운그레이드. 각 오브젝트 처리 후 — vacuum-worker 스레드 한정 — heap_page_get_vacuum_status를 다시 읽는다. (ONCE && !was_interrupted) || (NONE && was_interrupted) 조건이 맞으면 n_heap_objects == 1을 단언한다. 첫 절반: 정상적인 “단일 예상 vacuum” — heap_page_set_vacuum_status_none을 호출한 뒤 all_vacuumed = true인 bulk 로그 레코드를 남겨 redo도 다운그레이드하게 한다. 두 번째 절반: 이전 잡이 재실행되는 편집증적 케이스로, 카운터만 초기화한다. 어느 쪽이든 페이지는 dirty 처리되고, spage_number_of_records <= 1 && helper.reusable이면 heap_remove_page_on_vacuum을 시도한다(pgbuf_has_prevent_dealloc으로 보호) — 성공하면 빈 페이지를 해제하고 실패하면 unfix. 항상 end로 빠져나간다.
  5. 양보 yield. pgbuf_has_any_non_vacuum_waiters이고 처리할 오브젝트가 남아 있으면, 배치를 vacuum_heap_page_log_and_reset(unfix 포함)으로 플러시하고 페이지를 re-fix한다(re-fix 실패 → end). 전경 스레드를 위한 공정성 양보이며, 대신 로그 레코드 하나가 추가된다.
  6. end: 남은 배치를 update_best_space_stat = true로 플러시한다.
flowchart TD
    A["home 페이지 fix<br/>(중단 재실행: dealloc/FTAB 허용)"] --> B{"hfid 캐시됨?"}
    B -- 아니오 --> C["vacuum_heap_get_hfid_and_file_type"]
    B -- 예 --> D
    C --> D["다음 오브젝트; dup slotid 건너뜀"]
    D --> E["vacuum_heap_prepare_record"]
    E -- 오류 --> Z["end: 배치 플러시, unfix"]
    E --> F{"record_type"}
    F -- "HOME / REL / BIG" --> G["mvcc_satisfies_vacuum"]
    F -- 기타 --> J
    G -- REMOVE --> H["vacuum_heap_record"]
    G -- DELETE_INSID_PREV_VER --> I["vacuum_heap_record_insid_and_prev_version"]
    G -- CANNOT_VACUUM --> J["페이지 vacuum 상태 확인"]
    H --> J
    I --> J
    J -- "ONCE이고 not interrupted<br/>또는 NONE이고 interrupted" --> K["상태 none 설정, all_vacuumed 로그<br/>필요 시 heap_remove_page_on_vacuum"] --> Z
    J -- "대기자 있고 오브젝트 남음" --> L["log_and_reset, re-fix"] --> D
    J -- "그 외" --> D

Figure 8-1: vacuum_heap_page 제어 흐름; 모든 출구는 end를 경유한다.

8.4 vacuum_heap_prepare_record: 레코드 타입별 분기

섹션 제목: “8.4 vacuum_heap_prepare_record: 레코드 타입별 분기”

prepare는 retry_prepare: 레이블 아래에서 latch를 내려놓은 후 슬롯을 다시 읽으며, 각 레코드 형태에 필요한 것을 수집한다. 진입 시 forward_page == NULL이 단언되어 있으며, switch 내부에서 forward_page가 NULL이 아닌 경우는 동일한 호출의 이전 재시도에서 남은 것으로, 타입이 맞지 않는 모든 분기에서 unfix한다.

  • 슬롯 소멸 (spage_get_slot이 NULL 반환): 타입을 REC_MARKDELETED로 강제하고 NO_ERROR 반환; 호출자의 default:가 이를 무시한다.
  • REC_RELOCATION: home 링크를 forward_recdes/forward_link로 COPY한다(실패 → ER_FAILED); VPID가 다른 재시도 잔여 forward 페이지는 unfix한다. forward fix는 pgbuf_get_condition_for_ordered_fix를 따른다: home-before-forward 순서면 무조건 latch; forward-before-home 순서면 조건부(conditional) 시도. 조건부 시도 실패 시: home을 플러시/unfix(vacuum_heap_page_log_and_reset), forward를 먼저 fix한 뒤 home을 fix하는 교착 방지 순서로 재시도하고 goto retry_prepare — home이 unlatch됐기 때문이다. 불변 조건 — 두 힙 페이지는 pgbuf의 ordered-fix 규칙에 어긋나게 latch해서는 안 된다; 위반하면 전경 writer와의 latch 교착이 감지되지 않을 수 있다. 두 페이지를 모두 보유한 상태에서, REC_NEWHOME 레코드를 rec_buf로 COPY 읽기(미래 undo 이미지)하고 or_mvcc_get_header로 디코딩한다.
  • REC_BIGONE: overflow_vfid가 아직 해석되지 않았으면 조건부 latch로 heap_ovf_find_vfid를 시도; 실패 시 home을 플러시/unfix하고 무조건 재시도(실패 → ER_FAILED), home을 re-fix한 뒤 goto retry_prepare. 이후 링크 레코드를 COPY하고, 첫 번째 overflow 페이지를 무조건 fix한다(overflow 페이지는 항상 home 페이지 다음에 fix — 순서 교환 없음), heap_get_mvcc_rec_header_from_overflow로 헤더를 읽는다.
  • REC_HOME: 레코드를 rec_buf로 COPY한다(코드 주석은 “Peek”이라 쓰지만 실제 호출은 COPY) — 이번에는 undo 때문이 아니라(REC_HOME 변경은 redo-only 로그, 8.7), insid strip이 spage_update 전에 버퍼를 인플레이스로 변경하기 때문이다. PEEK 포인터는 페이지 안을 직접 가리키므로 이 방식으로는 수정이 불가능하다. or_mvcc_get_header로 헤더를 디코딩한다.
  • default: (직접 참조되는 REC_NEWHOME, REC_MARKDELETED, REC_DELETED_WILL_REUSE 등) 타입만 보고한다.
// vacuum_heap_prepare_record -- src/query/vacuum.c
/* Assert forward page is fixed if and only if record type is either REC_RELOCATION or REC_BIGONE. */
assert ((helper->record_type == REC_RELOCATION
|| helper->record_type == REC_BIGONE) == (helper->forward_page != NULL));

불변 조건 — forward_page != NULL은 레코드 타입에 forward 컴포넌트가 있을 때에만 성립한다. 위반 시 이후 반복에서 엉뚱한 페이지의 latch를 잡은 채 쓰기가 발생한다.

힙 패스 전체가 MVCC 헤더와 블록의 threshold_mvccid(디스패치 시점에 스냅샷된 가장 오래된 가시 워터마크 — 5장)를 입력으로 받는 순수 함수 하나에 달려 있다. cubrid-mvcc-detail.md의 가시성 계열 함수 중 vacuum 측 대응체다.

// mvcc_satisfies_vacuum -- src/transaction/mvcc.c
if (!MVCC_IS_HEADER_DELID_VALID (rec_header) || MVCC_IS_REC_DELETED_SINCE_MVCCID (rec_header, oldest_mvccid))
{
/* The record was not deleted or was recently deleted and cannot be vacuumed completely. */
if (!MVCC_IS_HEADER_INSID_NOT_ALL_VISIBLE (rec_header)
|| MVCC_IS_REC_INSERTED_SINCE_MVCCID (rec_header, oldest_mvccid))
{
// ... condensed (perfmon) ...
return VACUUM_RECORD_CANNOT_VACUUM;
}
else
{
// ... condensed (perfmon) ...
return VACUUM_RECORD_DELETE_INSID_PREV_VER;
}
}
else
{
return VACUUM_RECORD_REMOVE; /* <- delete committed before every live snapshot */
}

매크로들은 정확한 비트 검사다: MVCC_IS_HEADER_DELID_VALID = DELID 플래그 설정 and MVCCID_IS_VALID (delid); MVCC_IS_HEADER_INSID_NOT_ALL_VISIBLE = INSID 플래그 설정 and 값이 != MVCCID_ALL_VISIBLE(상수 3); *_SINCE_MVCCID 매크로는 !MVCC_ID_PRECEDES (id, T), 즉 id >= T(임계값 이후에 건드린 버전이므로 아직 처리할 수 없음). 극성에 주목: 외부 조건이 !DELID_VALID || DELETED_SINCE_MVCCID이므로, REMOVE 판정은 DELETED_SINCE_MVCCID부정, 즉 레코드에 유효한 delid가 있고 delid < Telse 분기다 — 삭제가 모든 활성 스냅샷보다 먼저 커밋됐다는 의미다. 이 조건이 성립하지 않는 경우, 충분히 오래된 아직 strip되지 않은 insert id(insid < T, 플래그 설정, ALL_VISIBLE 아님)이면 DELETE_INSID_PREV_VER를 반환한다; 그 외 — 신선한 insert/delete이거나 이미 strip된 insid — 는 아무것도 하지 않는다.

flowchart TD
    S["MVCC 헤더 + 임계값 T"] --> D{"delid 유효하고<br/>delid < T ?"}
    D -- 예 --> R["VACUUM_RECORD_REMOVE<br/>활성 스냅샷이 볼 수 없음"]
    D -- 아니오 --> I{"insid 플래그 설정, ALL_VISIBLE 아님,<br/>그리고 insid < T ?"}
    I -- 예 --> P["VACUUM_RECORD_DELETE_INSID_PREV_VER<br/>행 유지, insid + prev_version_lsa strip"]
    I -- 아니오 --> C["VACUUM_RECORD_CANNOT_VACUUM<br/>이미 strip됐거나 너무 최신"]

Figure 8-2: 세 가지 판정.

두 가지 중요한 함의가 있다. 첫째, CANNOT_VACUUM은 오류가 아닌 정상 결과다. 동일한 OID는 그것을 건드리는 로그 레코드마다 한 번씩 수집되므로, 이 블록의 임계값 직후에 삭제된 레코드는 지금은 DELETE_INSID_PREV_VER를 받고, 임계값이 그 삭제를 지나친 이후 잡에서 REMOVE를 받는다. 둘째, MVCCID_ALL_VISIBLE이 이중 역할을 한다 — “insid 플래그 없음”과 “insid == 3”이 둘 다 이미 strip됨을 의미한다 — 이것이 REC_BIGONE 경로를 멱등으로 만드는 이유다(8.6).

vacuum_heap_record_insid_and_prev_version (판정 DELETE_INSID_PREV_VER)은 헤더를 편집하며 행은 살아남는다. REC_RELOCATIONREC_HOME은 복사된 레코드에 동일한 바이트 수술을 공유한다: mvcc_header_size_lookup[mvcc_flags]에서 현재 헤더 크기를 조회하고; DELID와 INSID가 모두 있으면 memcpy로 DELID를 INSID 슬롯 위에 복사해 살리고; OR_MVCC_FLAG_VALID_INSID | OR_MVCC_FLAG_VALID_PREV_VERSION을 clear하고; memmove로 공백을 닫는다. 축소된 레코드는 spage_update로 다시 기록된다 — NEWHOME은 forward_page/forward_oid.slotid로, HOME은 home_page/crt_slotid로. REC_RELOCATION은 이후 즉시 로그를 남기고(forward 페이지에 대한 단일 슬롯 vacuum_log_vacuum_heap_page) unfix한다; REC_HOME은 그냥 (crt_slotid, DELETE_INSID_PREV_VER)를 bulk 배치에 추가한다. REC_BIGONE은 크기 변경이 불가능하다 — overflow 헤더 영역은 고정 너비다 — 따라서 insid는 strip 대신 덮어쓰기된다:

// vacuum_heap_record_insid_and_prev_version -- src/query/vacuum.c
/* Replace current insert MVCCID with MVCCID_ALL_VISIBLE. Header must remain the same size. */
MVCC_SET_INSID (&helper->mvcc_header, MVCCID_ALL_VISIBLE);
LSA_SET_NULL (&helper->mvcc_header.prev_version_lsa);
error_code = heap_set_mvcc_rec_header_on_overflow (helper->forward_page, &helper->mvcc_header);
// ... condensed ...
vacuum_log_remove_ovf_insid (thread_p, helper->forward_page); /* <- redo-only, zero payload */

불변 조건 — overflow MVCC 헤더는 절대 크기가 바뀌지 않는다. 제거 대신 대체를 사용함으로써 보장된다; 위반하면 헤더 뒤에 이어지는 대용량 overflow 페이로드가 밀리게 된다.

vacuum_heap_record (판정 REMOVE)는 버전을 삭제한다. REC_HOME 제거는 bulk 배치에 합류하고, REC_RELOCATION/REC_BIGONE은 두 페이지 연산이므로 배치를 먼저 플러시한다(vacuum_heap_page_log_and_resetunlatch_page = false). 이후 쌍을 시스템 연산(log_sysop_startlog_sysop_commit)으로 감싸 복구 시 절반만 완료된 상태가 남지 않도록 한다. 세 경우 모두 home 슬롯에서 spage_vacuum_slot (… helper->reusable)을 실행하며, 슬롯의 사후 상태는 OID 안정성 계약을 따른다:

// spage_vacuum_slot -- src/storage/slotted_page.c
slot_p->offset_to_record = SPAGE_EMPTY_OFFSET;
if (reusable)
{
slot_p->record_type = REC_DELETED_WILL_REUSE; /* <- nothing references this OID; recycle slotid */
}
else
{
slot_p->record_type = REC_MARKDELETED; /* <- referable file: indexes may hold the OID; tombstone */
}

참조 가능한 힙(FILE_HEAP)에는 b-tree 항목이나 OID 참조가 여전히 슬롯을 가리킬 수 있으므로 slotid를 재사용하지 않는다; 재사용 가능 힙(FILE_HEAP_REUSE_SLOTS)은 그런 참조가 없음을 보장한다. REC_NEWHOME forward 슬롯은 항상 reusable = true로 vacuum된다 — NEWHOME을 직접 참조하는 것은 동일한 sysop에서 삭제되는 REC_RELOCATION home 뿐이기 때문이다. sysop 내부: REC_RELOCATION은 home 슬롯 제거를 로그에 남기고(vacuum_log_redoundo_vacuum_record, undo = 복사된 링크 forward_recdes), forward 슬롯을 vacuum하고, 조건부로 forward 페이지를 best-space 통계에 반영하며(PRM_ID_HF_MAX_BESTSPACE_ENTRIES > 0 && freespace > HEAP_DROP_FREE_SPACEheap_stats_update), forward 슬롯 제거를 로그에 남기고(undo = 복사된 NEWHOME record), forward를 dirty/unfix한 뒤 커밋한다. REC_BIGONE은 home 슬롯 제거를 로그에 남기고 overflow 페이지를 unfix한 다음 heap_ovf_delete로 전체 overflow 체인을 해제한다 — 실패 시 log_sysop_abort + ER_FAILED, 성공 시 commit.

vacuum_heap_page_log_and_reset은 배치 플러셔다: n_bulk_vacuumed == 0이면 unfix만 하고(요청 시); 그렇지 않으면 spage_need_compact이면 compact하고, update_best_space_stat && initial_home_free_space != -1이면 해제 공간을 best-space 통계에 반영하고, vacuum_log_vacuum_heap_page를 하나 내보내고, dirty 처리 후 선택적으로 unfix하고, 카운터를 0으로 초기화한다. 결과적으로 세 종류의 로그 계열이 생긴다:

  • Bulk RVVAC_HEAP_PAGE_VACUUM (redo-only): addr.offset에 슬롯 수와 두 개의 플래그 비트(VACUUM_LOG_VACUUM_HEAP_REUSABLE = 0x8000, VACUUM_LOG_VACUUM_HEAP_ALL_VACUUMED = 0x4000)를 packed하고, 각 slotid의 부호가 판정을 인코딩한다 — REMOVE는 음수, insid strip은 양수. redo vacuum_rv_redo_vacuum_heap_pagercv->offset & ~VACUUM_LOG_VACUUM_HEAP_MASK를 unpacking한다: n_slots == 0(all_vacuumed 단언) → heap_page_set_vacuum_status_none; 음수 slotid → 부호 반전 후 spage_vacuum_slot; 양수 → peek(반드시 REC_HOME/REC_NEWHOME이어야 하며 그렇지 않으면 ER_FAILED), 더 작은 플래그 cleared 헤더로 재구성. 이후 compact하고, all_vacuumed이면 다운그레이드하고, dirty 처리한다.
  • 레코드별 RVVAC_HEAP_RECORD_VACUUM — 두 페이지 제거에 사용하며, vacuum_log_redoundo_vacuum_record를 통한다: offset에 slotid + reusable 비트를 packed한 undoredo 레코드이고, undo 크럼은 레코드 타입 + pre-image이며, redo 페이로드는 비어 있다(“오브젝트 주소만으로 re-vacuum”). redo vacuum_rv_redo_vacuum_heap_record는 slotid/reusable을 재유도하고 spage_vacuum_slot + compaction을 재실행한다; undo vacuum_rv_undo_vacuum_heap_record는 플래그 비트를 strip하고 heap_rv_redo_insert로 위임해 pre-image를 재삽입한다. undo 절반이 존재하는 이유는 이 제거들이 sysop 안에 있기 때문이다: sysop이 abort되면(heap_ovf_delete 실패 분기), home 슬롯이 복구돼야 한다.
  • Overflow RVVAC_REMOVE_OVF_INSID, vacuum_log_remove_ovf_insid를 통함 — 0바이트(log_append_redo_data2 (… 0, 0, NULL)). redo vacuum_rv_redo_remove_ovf_insid는 페이지에서 재구성한다: 헤더를 읽고, MVCC_SET_INSID (… MVCCID_ALL_VISIBLE), LSA_SET_NULL (… prev_version_lsa), 다시 쓰고, dirty 처리 — 대체 트릭(8.6) 덕분에 멱등이다.

was_interrupted는 실행과 복구를 연결한다: 재실행된 잡은 힙 효과가 이미 디스크에 있을 수 있는 로그 레코드를 재생하므로, 완화 조치들 — pgbuf_fix_if_not_deallocated, 허용되는 PAGE_FTAB 재사용, 완화된 단언 (page_vacuum_status != HEAP_PAGE_VACUUM_NONE || (was_interrupted && helper.n_vacuumed == 0)), 이미 NONE인 페이지의 다운그레이드 거부 — 은 충돌이 만들어낼 수 있는 “이미 일어난 일” 증상들이다. 잡이 재시작 후 이 플래그를 되찾는 방법은 11장에서 다룬다.

  1. vacuum_heap은 수집된 (VFID, OID) 배열을 파일 우선/페이지 하위 순으로 정렬하고 페이지 런마다 vacuum_heap_page를 호출하며, HFID/재사용 가능 여부는 파일 그룹별로 캐시한다; 삭제된 파일은 수집 시점에 필터링됐고, 릴리스 빌드는 실패한 페이지를 건너뛰어 잡 전체가 중단되지 않게 한다.
  2. VACUUM_HEAP_HELPER가 전체 작업 세트다: forward_page는 REC_RELOCATION에서는 “NEWHOME 페이지”, REC_BIGONE에서는 “첫 번째 overflow 페이지”를 의미한다; rec_buf의 COPY-read 레코드는 strip 시 인플레이스로 덮어쓰이며; bulk 배치는 단일 페이지 REC_HOME 변경만 담는다.
  3. mvcc_satisfies_vacuum은 두 임계값 비교로 귀결된다: delid < TREMOVE; 그렇지 않고 insid 있고 MVCCID_ALL_VISIBLE이 아니고 < TDELETE_INSID_PREV_VER; 그 외 → CANNOT_VACUUM — 정상 결과이며, 이후 임계값이 대기 중인 삭제를 지나친 잡이 마무리한다.
  4. vacuum_heap_prepare_record는 latch를 내려놓은 후 retry_prepare: 아래에서 슬롯을 다시 읽고, 조건부 forward latch + 역순 re-fix로 pgbuf의 ordered-fix 규칙을 지킨다 — 결과적으로 forward_page가 fix된 상태인 것은 타입이 REC_RELOCATION이거나 REC_BIGONE일 때뿐이라는 불변 조건이 성립한다.
  5. 제거는 OID 안정성 계약을 따른다: 참조 가능 힙은 tombstone(REC_MARKDELETED), 재사용 가능 힙은 재활용(REC_DELETED_WILL_REUSE), NEWHOME forward 슬롯은 항상 재활용하며, 비워진 재사용 가능 페이지는 heap_remove_page_on_vacuum으로 해제한다.
  6. 로깅은 세 계층이다: 슬롯id 부호로 판정을 인코딩하고 offset에 reusable/all_vacuumed 비트를 담는 bulk redo-only RVVAC_HEAP_PAGE_VACUUM; 두 페이지 제거를 위해 sysop 내부에서 쓰는 레코드별 undoredo RVVAC_HEAP_RECORD_VACUUM; overflow insid를 strip이 아닌 대체로 처리하기 때문에 redo가 멱등인 zero-payload RVVAC_REMOVE_OVF_INSID.
  7. was_interrupted는 “불가능한” 상태들 — 해제된 페이지, FTAB 재사용, 이미 NONE인 상태 — 을 허용된 no-op으로 변환한다. 재실행된 잡은 자신의 과거 작업이 이미 적용된 상태를 발견할 것을 예상하기 때문이다.

vacuum 작업은 두 가지 방식 중 하나로 종료된다. 블록 내 모든 MVCC 연산을 처리했거나, 중간에 중단된 경우다(SERVER_MODE에서는 shutdown, SA_MODE에서는 인터럽트). 이 챕터는 두 결과 중 어느 쪽이든 vacuum 데이터로 어떻게 전달되는지, 완전히 vacuumed된 엔트리가 vacuum 데이터 파일에서 물리적으로 어떻게 제거되는지, 그리고 그 제거가 어떻게 로그 하한선(log floor) — 시스템이 반드시 보존해야 할 가장 오래된 로그 페이지 — 을 전진시켜 아카이브 정리가 진행될 수 있게 하는지를 추적한다. 가비지 컬렉터 설계 배경은 companion 문서(cubrid-vacuum.md)에 있으며, 여기서는 모든 분기를 빠짐없이 추적한다.

flowchart LR
    W["워커<br/>vacuum_process_log_block"] -->|"vacuum_finished_block_vacuum<br/>blockid + 상태 플래그"| Q["vacuum_Finished_job_queue"]
    Q -->|"배치로 소비"| M["마스터<br/>vacuum_data_mark_finished"]
    M -->|"set_vacuumed / set_interrupted"| VD["vacuum data 페이지"]
    VD -->|"페이지 완전 vacuumed"| EP["vacuum_data_empty_page"]
    M --> K["vacuum_update_keep_from_log_pageid"]
    K -->|"vacuum_min_log_pageid_to_keep"| AP["logpb_remove_archive_logs*"]

Figure 9-1. 완료 파이프라인: 워커의 결과가 완료 작업 큐를 거쳐 vacuum 데이터로 흐르고, 로그 하한선이 뒤를 따른다.

9.1 vacuum_finished_block_vacuum — 결과를 blockid에 인코딩하기

섹션 제목: “9.1 vacuum_finished_block_vacuum — 결과를 blockid에 인코딩하기”

vacuum_process_log_blockend: 레이블(Chapter 7)에 도달하면 vacuum_finished_block_vacuumvacuum_complete와 함께 호출한다 — 이 값은 vacuum_heapNO_ERROR를 반환한 이후에만 true이며, 오류 경로의 goto end는 모두 false를 남긴다. data 인자는 워커의 VACUUM_DATA_ENTRY 복사본이다. 헤더 주석은 작업이 실행되는 동안 실제 테이블 엔트리가 이동했을 수 있으므로 결과는 포인터가 아닌 blockid 값으로 전달된다고 명시한다.

// vacuum_finished_block_vacuum -- src/query/vacuum.c
if (is_vacuum_complete)
{
data->set_vacuumed (); /* VACUUMED status, INTERRUPTED flag cleared */
}
else
{
/* We expect that worker job is abandoned during shutdown. But all other cases are error cases. */
// ... condensed: SERVER_MODE warns iff thread_p->shutdown (asserted); SA_MODE iff ER_INTERRUPTED ...
data->set_interrupted (); /* AVAILABLE status + INTERRUPTED flag */
}
blockid = data->blockid; /* raw field: blockid WITH the flag bits just set */
if (!vacuum_Finished_job_queue->produce (blockid))
{
assert_release (false);
vacuum_er_log_error (..., "%s", "Finished job queue is full!!!");
}
// ... condensed, SERVER_MODE: is_half_full () -> vacuum_Master_daemon->wakeup ();

분기별 처리:

  1. 성공set_vacuumed ()는 2비트 상태 마스크(VACUUM_BLOCK_STATUS_MASK = 0xC000000000000000)에 VACUUM_BLOCK_STATUS_VACUUMED (0x8000...)를 기록하고 VACUUM_BLOCK_FLAG_INTERRUPTED (0x2000...)를 클리어한다 — 한 번 중단됐다가 재시도로 완료된 블록이 낡은 플래그를 유지하지 않도록 한다.
  2. 실패set_interrupted ()는 상태를 VACUUM_BLOCK_STATUS_AVAILABLE(전체 0)로 설정하고 INTERRUPTED를 올린다: 재디스패치 가능하며, 이력이 남는다. 심각도는 단계적으로 구분 — SERVER_MODE에서 thread_p->shutdown, SA_MODE에서 ER_INTERRUPTED만 정상적인 원인이며, 그 외는 ERROR를 로깅하면서도 동일하게 처리한다.
  3. 큐 produce 실패 — 릴리즈 모드의 “발생해서는 안 되는” 상황: 마스터는 미처리 작업을 VACUUM_MAX_TASKS_IN_WORKER_POOL (3 × PRM_ID_VACUUM_WORKER_COUNT)로 제한하므로 VACUUM_FINISHED_JOB_QUEUE_CAPACITY (2048)보다 훨씬 작다. 처리는 의도적인 데이터 손실과 큰 소리의 로그 — 엔트리는 IN_PROGRESS로 남으며, 부트 타임 복구(Chapter 11)에서만 구제된다.
  4. half-full 웨이크업 — produce 측이 vacuum_Finished_job_queue->is_half_full ()일 때 마스터를 깨운다. 마스터 자체의 vacuum_master_task::should_force_data_update는 같은 큐와 vacuum_Block_data_buffer를 폴링한다. produce 측이 깨우고 consume 측이 폴링하며, 어느 한쪽만으로는 부하를 감당할 수 없다.

불변 조건 9-A — 디스패치된 작업당 큐 엔트리 하나, 플래그는 이미 확정된 상태. 모든 디스패치된 작업은 vacuum_finished_block_vacuum을 정확히 한 번 호출한다 — end: 레이블이 if (!sa_mode_partial_block)으로 이를 보장하는데, partial-block 작업은 vacuum 데이터에 존재한 적이 없기 때문이다. 푸시된 blockid는 상위 비트에 최종 상태를 담고 있으며, 큐 엘리먼트가 곧 워커에서 마스터로 가는 전체 보고다. 작업이 produce 없이 종료될 수 있다면, 엔트리는 IN_PROGRESS로 남고 index_unvacuumed는 영원히 그것을 통과하지 못하며 keep_from_log_pageid는 동결 — 아카이브가 무한정 증가한다.

9.2 다음 시도에서 INTERRUPTED가 바꾸는 것

섹션 제목: “9.2 다음 시도에서 INTERRUPTED가 바꾸는 것”

비트는 vacuum 데이터 엔트리에 살아남고(9.3절), 재디스패치 시 두 곳에서 소비된다: vacuum_job_cursor::start_job_on_current_entryentry.was_interrupted ()일 때 RVVAC_START_JOB 추가를 건너뛰며 — 엔트리는 이미 부분 실행된 것으로 알려져 있다(Chapter 6) — vacuum_process_log_block은 이를 heap 패스로 전달한다:

// vacuum_process_log_block -- src/query/vacuum.c
was_interrupted = data->was_interrupted () || sa_mode_partial_block;
// ... condensed ...
error_code = vacuum_heap (thread_p, worker, threshold_mvccid, was_interrupted);

재실행은 블록의 전체 로그를 재생하여 첫 번째 시도에서 이미 정리된 heap 페이지를 다시 방문한다. was_interrupted == true일 때 vacuum_heap_page는 vacuum 상태가 “처리할 것 없음”을 나타내는 페이지를 허용한다. 플래그 없이는 그 상태들이 손상으로 처리될 것이다(Chapter 8).

9.3 vacuum_data_mark_finished — 마스터의 큐 소비

섹션 제목: “9.3 vacuum_data_mark_finished — 마스터의 큐 소비”

마스터의 vacuum_data::update 내에서 실행되며(vacuum_finalize에서 잔여분을 비우기 위해 한 번 더 실행됨), 런타임에서 엔트리를 IN_PROGRESS 밖으로 이동시키는 유일한 런타임 작성자다 — 복구는 동일한 전환을 재실행한다(9.5절). 구조: 큐를 스택 버퍼로 비운 다음(최대 VACUUM_FINISHED_JOB_QUEUE_CAPACITY개; 0개 소비 → 페이지 고정 없이 return 0 반환), vacuum_compare_blockidsqsort — 플래그 비트를 제거함 — 하여 배열 순서가 물리적 엔트리 순서와 일치하도록 만들고, vacuum 데이터 페이지와 정렬된 배열을 함께 lockstep으로 순회한다.

flowchart TD
    A["큐 비우기, qsort,<br/>첫 페이지에서 시작"] --> D["내부 루프: 현재 페이지 범위 내<br/>각 blockid 마킹"]
    D --> E{"이 페이지에서<br/>변경 있음?"}
    E -->|없음| H
    E -->|있음| F["앞에서 연속된 vacuumed 엔트리를<br/>넘어 index_unvacuumed 전진"]
    F --> G{"index_unvacuumed ==<br/>index_free?"}
    G -->|"예: 페이지 빔"| EP["vacuum_data_empty_page<br/>FINISHED_BLOCKS 로그 없음"]
    EP --> EPN{"data_page == NULL?"}
    EPN -->|"예, 미매칭 블록 남음"| ERR1["assert(false)<br/>로그 + 반환"]
    EPN -->|"예, 전체 매칭"| DONE
    EPN -->|"아니오: 다음 페이지로 이동"| D
    G -->|"아니오: 페이지에 데이터 있음"| CMP["마지막 페이지면 memmove 압축;\nRVVAC_DATA_FINISHED_BLOCKS 로그"]
    CMP --> H{"index ==<br/>n_finished_blocks?"}
    H -->|예| DONE["페이지 unfix"]
    H -->|"아니오, next_page가 NULL"| ERR2["assert(false)<br/>로그 + 반환"]
    H -->|"아니오, next_page 따라감"| NXT["다음 페이지 fix\n(fix 실패: 세 번째 조기 반환)"]
    NXT --> D
    DONE --> K["vacuum_update_keep_from_log_pageid"]

Figure 9-2. vacuum_data_mark_finished, 모든 분기 완성. 세 곳의 조기 반환 출구 모두 로그 하한선 갱신을 건너뛴다.

마킹 루프는 한 페이지 내의 blockid가 연속적임에 의존한다(page_free_blockid == data[index_free - 1].get_blockid () + 1로 어서트됨):

// vacuum_data_mark_finished -- src/query/vacuum.c
while ((index < n_finished_blocks)
&& ((blockid = VACUUM_BLOCKID_WITHOUT_FLAGS (finished_blocks[index])) < page_free_blockid))
{
data = page_unvacuumed_data + (blockid - page_unvacuumed_blockid); /* direct index, no search */
assert (data->get_blockid () == blockid);
assert (data->is_job_in_progress ()); /* only dispatched jobs may report back */
if (VACUUM_BLOCK_STATUS_IS_VACUUMED (finished_blocks[index]))
data->set_vacuumed ();
else
data->set_interrupted (); /* AVAILABLE + INTERRUPTED, redispatchable */
index++;
}

VACUUM_BLOCKID_WITHOUT_FLAGS는 주소 지정을 위해 플래그를 제거하고, 플래그가 있는 값이 VACUUMED 대 INTERRUPTED를 결정한다. 매칭 보고가 없는 페이지는 손대지 않고 로그도 없이 통과한다. 마킹 후 index_unvacuumed 전진은 앞에서 연속된 is_vacuumed () 엔트리만 넘어간다 — INTERRUPTED 엔트리가 전진을 막고, 재디스패치를 위해 표시된 상태로 남는다. 이후 두 가지 결과가 가능하다:

  • 페이지 비워짐 (index_unvacuumed == index_free): 페이지가 vacuum_data_empty_page에 전달된다(9.4절). 여기서는 RVVAC_DATA_FINISHED_BLOCKS 레코드를 작성하지 않는다 — 페이지의 마킹은 vacuum_data_empty_page가 내보내는 reset/splice 레코드로 간접적으로 복구되는데, 복구 후 페이지가 빈 상태로 재생성되거나 삭제되기 때문이다.
  • 페이지에 데이터 유지: 마지막 페이지만 memmove로 제자리에서 압축된다(새 블록이 거기에 추가됨, Chapter 4). 내부 페이지는 압축되지 않는다. 그런 다음 finished_blocks[page_start_index .. index) 슬라이스를 페이로드로 하는 RVVAC_DATA_FINISHED_BLOCKS redo 레코드가 한 개 추가된다.

“매칭 엔트리 없는 보고” 두 종료 경로와 페이지 fix 실패 경우 모두 로그 하한선 갱신 없이 조기 반환한다 — 잘못된 하한선보다 낡은 하한선이 낫다.

불변 조건 9-B — index_unvacuumed는 vacuumed되지 않은 엔트리를 절대 지나치지 않으며, 그 앞의 모든 것은 VACUUMED다. 엄격하게 지역화된 전진 루프(및 redo의 대응 루프)로 강제된다. 로그 하한선 도출과 작업 커서의 재시작 위치 모두 첫 번째 unvacuumed 엔트리를 읽는다. 워터마크 아래 vacuumed 엔트리가 있으면 로그 하한선이 영원히 고정되고, INTERRUPTED 엔트리를 건너뛰면 재vacuum이 영원히 이루어지지 않는다.

9.4 물리적 축소 — vacuum_data_empty_page

섹션 제목: “9.4 물리적 축소 — vacuum_data_empty_page”

이 함수는 완전히 vacuumed된 페이지를 받으며(index_unvacuumed == index_free 어서트), 소스 주석에서 동일하게 번호 매긴 세 가지 경우를 구분한다:

Case 1 (마지막 페이지 — first == last인 경우도 포함) 절대 할당 해제하지 않는다. 파일은 항상 최소 한 페이지를 유지한다. 페이지는 vacuum_init_data_page_with_last_blockid로 reset되는데, 이 함수는 헤더를 재초기화하면서 vacuum_Data.get_last_blockid ()를 슬롯 0에 저장한다(data_page->data->blockid = blockid). RVVAC_DATA_INIT_NEW_PAGE를 로깅하며, *data_page = NULL은 호출자에게 다음 페이지가 없음을 알린다. 빈 테이블이 마지막으로 보유했던 블록을 기억하는 방식이 이것이다 — 재시작 후에도 vacuum_data::get_last_blockid가 올바르게 유지되고, Chapter 4의 append 경로가 이미 소비된 블록을 감지하는 방법이다.

Case 2 (첫 번째 페이지, 더 페이지가 존재하는 경우) 할당 해제 전에 파일 디스크립터의 vpid_first를 재지정해야 한다. 둘 사이에서 크래시가 발생하면 부트 로더(Chapter 2)가 할당 해제된 페이지를 가리키게 된다. sysop 하에서의 순서: 다음 페이지 fix, sysop start, file_descriptor_update, vacuum_Data.first_page 교환, 이전 첫 페이지 file_dealloc, sysop commit. 오류는 단계적으로 처리 — 다음 페이지 fix 실패는 어떠한 변경도 없이 중단하고, file_descriptor_update 실패는 sysop를 중단하며 가시적인 변경 없음, file_dealloc 실패는 sysop를 중단하고 vacuum_Data.first_page / vacuum_Data_load.vpid_first를 수동으로 교환하여 되돌린다(디버그에서는 어서트되는 경로의 릴리즈 모드 손상 제어).

Case 3 (내부 페이지) prev_data_page 필요(NULL이면 어서트, unfix, 조기 반환). sysop 내에서 페이지를 할당 해제하고(dealloc 실패 → sysop abort, 반환), 이전 페이지에 undoredo RVVAC_DATA_SET_LINK로 목록을 splice한다 — 중단 시 undo가 이전 링크를 복원한다. 그런 다음 prev_data_page->next_page를 다시 fix하여 호출자의 스캔이 이후 페이지에서 계속되도록 한다.

vacuum_data_empty_update_last_blockid (companion 함수, vacuum_is_empty ()first_page == last_page 어서트)는 슬롯 0 초기화를 다시 실행하여 가장 최신 last_blockid를 영속화한다. vacuum_finalize(비-SERVER 빌드)와 vacuum_sa_reflect_last_blockid에서 호출되는데, 후자는 먼저 logpb_last_complete_blockid ()vacuum_Datalog_Gl.hdr.vacuum_last_blockid에 복사한다 — 오프라인으로 재시작된 서버가 이미 처리한 블록을 재소비하지 않도록 한다.

9.5 완료의 복구 — vacuum_rv_redo_data_finished와 관련 함수들

섹션 제목: “9.5 완료의 복구 — vacuum_rv_redo_data_finished와 관련 함수들”

RVVAC_DATA_FINISHED_BLOCKS redo는 로깅된 blockid 슬라이스로부터 9.3절의 페이지 변경을 재실행한다 — 워터마크 전진과 마지막 페이지 압축을 포함하며, 이는 별도로 로깅되지 않았는데 페이지 내용의 결정적 함수이기 때문이다:

// vacuum_rv_redo_data_finished -- src/query/vacuum.c
if (rcv_data_ptr != NULL)
{
// ... condensed: per logged blockid_with_flags, locate entry by
// (blockid - page_unvacuumed_blockid) and set_vacuumed () / set_interrupted () ...
}
while (data_page->index_unvacuumed < data_page->index_free
&& data_page->data[data_page->index_unvacuumed].is_vacuumed ())
{
data_page->index_unvacuumed++; /* same watermark advance as runtime */
}
if (VPID_ISNULL (&data_page->next_page) && data_page->index_unvacuumed > 0)
{
/* Remove all vacuumed blocks. */
// ... condensed: memmove compaction, index_free -= index_unvacuumed, index_unvacuumed = 0 ...
}

rcv_data_ptr != NULL 가드는 방어적이다 — 레코드의 유일한 추가자(9.3절)는 항상 비어있지 않은 슬라이스를 로깅한다. 중요한 점은 전진과 마지막 페이지 압축이 마킹 이후 무조건 실행되며, 페이로드가 아닌 페이지 내용으로부터 재계산된다는 것이다. vacuum_rv_redo_data_finished_dump는 페이로드를 가독성 있게 출력한다. 두 문자열(“vacuumed” vs “available and interrupted”)은 코드베이스에서 두 플래그 조합에 대한 가장 간결한 설명이다.

vacuum_rv_redo_vacuum_complete는 SA_MODE 전용 RVVAC_COMPLETE 레코드를 복구한다(xvacuum이 오프라인 전체 vacuum 후 추가하며, 페이로드는 log_Gl.hdr.mvcc_next_id). 로깅된 MVCCID를 vacuum_Data.oldest_unvacuumed_mvccid에 설치하고 logpb_vacuum_reset_log_header_cache를 호출하여 헤더의 partial-block 기록(Chapter 3)을 초기화한다 — 완전한 오프라인 vacuum 이후 vacuum이 기억해야 할 것은 없다.

9.6 루프 닫기 — 로그 하한선과 아카이브 게이트

섹션 제목: “9.6 루프 닫기 — 로그 하한선과 아카이브 게이트”

vacuum_data::update는 마스터의 반복당 한 번 통합 작업으로, vacuum_job_cursor::force_data_update에서 호출된다(커서를 먼저 언로드 — Chapter 6):

// vacuum_data::update -- src/query/vacuum.c
// first remove vacuumed blocks
mark_finished = vacuum_data_mark_finished (thread_p);
// then consume new generated blocks
vacuum_consume_buffer_log_blocks (thread_p); // Chapter 4
if (!vacuum_Data.is_empty ())
{
upgrade_oldest_unvacuumed (get_first_entry ().oldest_visible_mvccid);
}

upgrade_oldest_unvacuumed는 단조성을 어서트한다 — 엔트리가 MVCCID 순서로 추가되고 앞에서 제거되므로 유효하다. 빈 테이블에서는 추측 대신 워터마크를 그대로 둔다. 로그 하한선 자체는 성공적인 모든 vacuum_data_mark_finished의 끝에서, 그리고 vacuum_data_load_and_recover의 끝에서 부트 시 한 번 재계산된다:

// vacuum_update_keep_from_log_pageid -- src/query/vacuum.c
if (vacuum_is_empty ())
{
// keep starting with next after last_blockid ()
vacuum_Data.keep_from_log_pageid = VACUUM_FIRST_LOG_PAGEID_IN_BLOCK (vacuum_Data.get_last_blockid () + 1);
}
else
{
vacuum_Data.keep_from_log_pageid = VACUUM_FIRST_LOG_PAGEID_IN_BLOCK (vacuum_Data.get_first_blockid ());
}
// ... condensed: er_log; if (!is_archive_removal_safe) is_archive_removal_safe = true; /* set once */

VACUUM_FIRST_LOG_PAGEID_IN_BLOCK (b)는 단순히 b * vacuum_Data.log_block_npages다. 하한선은 항상 블록 경계 — 테이블에 엔트리가 있으면 첫 번째 unvacuumed 블록, 비어 있으면 기억된 last_blockid 다음 블록.

불변 조건 9-C — keep_from_log_pageid 아래의 로그 페이지는 vacuum이 다시 필요로 하지 않는다. 제거된 모든 엔트리(또는 삭제된 페이지)는 VACUUMED 블록을 보유했으며, INTERRUPTED 엔트리들 — 로그가 여전히 필요한 — 은 index_unvacuumed에 놓이거나 그 위에 위치해 하한선을 잡아둔다(불변 조건 9-B). 이것이 아카이브 삭제를 안전하게 만드는 계약이다.

로그 하한선을 로그 계층에 노출하는 두 개의 read-side 게이트:

  • vacuum_min_log_pageid_to_keep은 하한선을 반환하되, 두 가지 오버라이드가 있다: PRM_ID_DISABLE_VACUUM이면 0을 반환(모든 것 보존 — 디버그 보조), SA_MODE에서 xvacuumis_vacuum_complete를 설정했으면 NULL_PAGEID 반환(vacuum을 위한 것은 보존 불필요). 소비자: logpb_remove_archive_logs_exceed_limitlogpb_remove_archive_logs(log_page_buffer.c 내)가 삭제 가능한 아카이브 번호를 제한하고, logpb_backup은 이를 사용해 백업에 포함해야 할 아카이브를 결정한다.
  • vacuum_is_safe_to_remove_archivesis_archive_removal_safe를 반환한다. 두 정리 함수 모두 먼저 이를 확인하고 false인 동안은 삭제를 거부한다. 플래그와 하한선은 각각 false / NULL_PAGEID로 부팅된다. 플래그는 vacuum_update_keep_from_log_pageid 내부에서만 뒤집히며 — 보통 vacuum_data_load_and_recover의 끝에서 처음 — 따라서 vacuum 데이터가 로드되고 실제 하한선이 존재할 때까지 정리가 차단된다.

주기적 드라이버는 log_manager.c에 있다: “log-rm-archive” 데몬이 log_remove_log_archive_daemon_task::execute를 실행하며, 구성된 PRM_ID_REMOVE_LOG_ARCHIVES_INTERVAL에서 logpb_remove_archive_logs_exceed_limit를 호출한다(틱당 아카이브 하나, max_count = 1). 인터벌이 설정되지 않으면 제한 없이(max_count = 0) 호출한다.

마지막으로, vacuum_is_work_in_progress(임의의 vacuum_Workers[i].stateVACUUM_WORKER_STATE_INACTIVE가 아닌 상태; SA_MODE에서는 trivially false)는 shutdown 배리어다: vacuum_finalize는 비우기 전에 이를 어서트한다 — 아무 워커도 produce할 수 없는 상태에서만 마지막 vacuum_data_mark_finished와 큐 비어있음 어서트가 의미 있으며, 그때서야 큐가 삭제될 수 있다.

  1. 작업의 결과는 blockid 자체의 상위 비트에 인코딩된다set_vacuumed (VACUUMED, INTERRUPTED 클리어) 또는 set_interrupted (AVAILABLE + INTERRUPTED) — 그리고 vacuum_Finished_job_queue에 푸시된다. 큐 엘리먼트가 곧 워커에서 마스터로 가는 전체 보고다(불변 조건 9-A).
  2. INTERRUPTED는 죽은 것이 아니라 이력이 있는 AVAILABLE이다. 다음 디스패치는 RVVAC_START_JOB 로깅을 건너뛰고 was_interruptedvacuum_heap으로 전달하여, 그렇지 않으면 재vacuum된 페이지를 손상으로 처리할 안전장치를 완화한다.
  3. vacuum_data_mark_finished는 큐를 정렬된 배치로 비우고, 직접적인 연속 blockid 산술로 엔트리를 마킹하며, 앞에서 연속된 VACUUMED 엔트리만 넘어 index_unvacuumed를 전진시키고(불변 조건 9-B), 살아남은 각 터치 페이지마다 RVVAC_DATA_FINISHED_BLOCKS 하나를 로깅한다(비워진 페이지는 reset/splice 레코드로 복구됨). 매칭되지 않은 보고는 로그 하한선을 건드리지 않는 큰 소리의 조기 반환으로 처리한다.
  4. 물리적 축소는 세 가지 경우: 마지막 페이지는 슬롯 0에 last_blockid를 보존하며 제자리 reset, 첫 번째 페이지는 할당 해제 전 파일 디스크립터를 갱신하는 sysop 하에 교환, 내부 페이지는 undoredo 링크 갱신으로 splice.
  5. Redo는 로깅된 플래그 포함 blockid 슬라이스로 마킹을 재생하고 워터마크와 마지막 페이지 압축을 재계산한다 — 페이지 내용으로부터 결정적이므로 별도로 로깅되지 않는다.
  6. 로그 하한선 keep_from_log_pageid는 항상 블록 경계이며, 그 아래 페이지는 vacuum이 다시 필요로 하지 않는다(불변 조건 9-C). vacuum_min_log_pageid_to_keep이 아카이브 정리자(및 백업)에게 이를 제공하고, vacuum_is_safe_to_remove_archives는 하한선이 처음 계산될 때까지 정리를 차단한다 — 보통 부트 시 vacuum_data_load_and_recover에서. 주기적 정리는 log_manager.clog_remove_log_archive_daemon_task가 구동한다.
  7. vacuum_is_work_in_progress는 shutdown 비우기를 건전하게 만든다. 아무 워커도 살아서 produce할 수 없을 때만 vacuum_finalize가 마지막 vacuum_data_mark_finished를 수행하고 큐 비어있음을 어서트한다.

Chapter 10: 삭제된 파일 원장(Ledger)

섹션 제목: “Chapter 10: 삭제된 파일 원장(Ledger)”

7장과 8장에서 워커가 heap 페이지나 b-tree를 건드리기 전마다 모든 로그 레코드마다 vacuum_is_file_dropped를 호출하는 것을 살펴봤다. 이 챕터는 그 반대편, 즉 누가 원장을 작성하는지, 왜 작업 중간에 커밋되는 DROP TABLE이 해당 파일에 대한 수집된 OID를 보유한 워커를 충돌시킬 수 없는지, 그리고 원장이 어떻게 정리되는지를 추적한다. 설계 배경은 Common DBMS Design“Dropped-file table” 항목을 참고한다.

10.1 디스크 구조, 전역 변수, 지원 구조체

섹션 제목: “10.1 디스크 구조, 전역 변수, 지원 구조체”

원장은 vacuum_Dropped_files_vpid를 루트로 하는 PAGE_DROPPED_FILES 페이지의 체인이며, 각 페이지는 VFID 정렬된 엔트리 배열이다:

// vacuum_dropped_file -- src/query/vacuum.c
struct vacuum_dropped_file
{
VFID vfid;
MVCCID mvccid;
};
// vacuum_dropped_files_page -- src/query/vacuum.c
struct vacuum_dropped_files_page
{
VPID next_page; /* VPID of next dropped files page. */
INT16 n_dropped_files; /* Number of entries on page */
VACUUM_DROPPED_FILE dropped_files[1]; /* Dropped files. */
};
필드역할존재 이유
엔트리 vfid삭제된 파일 (heap 또는 b-tree).정렬/검색 키; VFID 재사용 시에도 엔트리는 하나 (10.3).
엔트리 mvccidnotify 시점에 log_Gl.hdr.mvcc_next_id에서 샘플링된 경계값.이보다 엄격히 작은 레코드는 죽은 파일 소속; >=이면 재사용 후계자 소속 (불변식 10-B).
페이지 next_page다음 원장 페이지로의 링크.단방향 체인; 링크 변경은 별도 로깅 (10.3).
페이지 n_dropped_files이 페이지의 엔트리 수.bsearch/memmove 범위 결정; VACUUM_DROPPED_FILES_PAGE_CAPACITY에서 가득 참.
페이지 dropped_files[1]DB_PAGESIZE를 채우는 유연한 엔트리 배열.vacuum_compare_dropped_files로 정렬되어 조회가 bsearch로 가능 (불변식 10-A).
rcv vfid, class_oidVACUUM_DROPPED_FILES_RCV_DATA, RVVAC_NOTIFY_DROPPED_FILE의 페이로드.MVCCID는 의도적으로 제외 — apply 시점에 샘플링 (10.5); class가 NULL이 아니면 heap_delete_hfid_from_cache 트리거.
track next_tracked_page, dropped_data_page디버그 전용 (!NDEBUG) VACUUM_TRACK_DROPPED_FILES: 디스크 페이지당 malloc된 복사본 하나.모든 변경 시 memcpy로 갱신되어 디버거가 페이지를 fix하지 않고도 원장을 순회 가능; 프로덕션 로직은 읽지 않음.

정적 전역 변수: vacuum_Dropped_files_vfid / _vpid, vacuum_Dropped_files_count (빠른 경로 필터, 10.6), vacuum_Dropped_files_loaded. 핸드셰이크 3인조 vacuum_Dropped_files_version / _mutex / vacuum_Last_dropped_vfid (10.5)는 non-static이지만 다른 곳에서 링크하지는 않는다. VACUUM_DROPPED_FILE_FLAG_DUPLICATE (0x8000)는 정의되어 있지만 미사용 — 중복은 replace 레코드(10.3)로 처리하며, 플래그로 처리하지 않는다.

flowchart LR
  subgraph DISK["디스크 체인 (PAGE_DROPPED_FILES)"]
    P1["페이지 1<br/>n_dropped_files, 정렬된 엔트리"] -->|next_page| P2["페이지 2"]
  end
  G["vacuum_Dropped_files_vpid"] --> P1
  C["vacuum_Dropped_files_count"] -.n_dropped_files 합산.- DISK
  subgraph HS["핸드셰이크 전역 변수"]
    V["vacuum_Dropped_files_version"]
    LV["vacuum_Last_dropped_vfid"]
    M["vacuum_Dropped_files_mutex"]
  end

Figure 10-1 — 원장 페이지와 이를 루트로 하는 전역 변수들.

vacuum_load_dropped_files_from_disk는 부트 시 또는 복구 중 지연(lazy) 방식으로 in-memory 측을 채운다(10.3): 이미 로드된 경우 → assert + NO_ERROR; 카운트가 0이 아닌 오래된 값 → assert (false) + 초기화; 그 외에는 read-latch로 체인을 순회하며 n_dropped_files를 전역 카운트에 합산하고 (디버그 빌드는 track 목록도 구성; malloc 실패 시 부분 목록을 해제하고 ER_OUT_OF_VIRTUAL_MEMORY), vacuum_Dropped_files_loaded를 설정한다.

10.2 등록 — vacuum_log_add_dropped_file과 POSTPONE/UNDO 선택자

섹션 제목: “10.2 등록 — vacuum_log_add_dropped_file과 POSTPONE/UNDO 선택자”

삭제 수행자는 원장 페이지를 직접 건드리지 않는다. vacuum_log_add_dropped_filePRM_ID_DISABLE_VACUUM 하에서 즉시 반환하며(원장을 쓰지도, 참조하지도 않음); 그렇지 않으면 VFID + class OID(class 없으면 NULL OID)를 패킹하여 RVVAC_NOTIFY_DROPPED_FILE을 두 가지 형태 중 하나로 추가한다:

  • 기존 파일이 삭제될 때는 postpone 레코드 (VACUUM_LOG_ADD_DROPPED_FILE_POSTPONE = true) — 파일은 트랜잭션이 커밋될 때만 죽는다;
  • 파일이 생성될 때는 undo 레코드 (VACUUM_LOG_ADD_DROPPED_FILE_UNDO) — abort 시 새 파일이 garbage가 된다.

호출자: heap_file.c의 heap destroy/create (xheap_destroyfile_postpone_destroy 전에 postpone를 추가하므로, 커밋 시 notify 핸드셰이크가 파일 파괴 전에 완료됨), btree.c / btree_load.c의 b-tree drop/create, 그리고 external_sort.c의 인덱스 로드 정렬 경로(구성 중인 b-tree 파일, undo 형태 — 임시 정렬 파일은 제외). 실제 삽입은 나중에 vacuum_rv_notify_dropped_file(10.5) 내부에서 발생한다.

10.3 페이지 순회 — vacuum_add_dropped_file 분기별 분석

섹션 제목: “10.3 페이지 순회 — vacuum_add_dropped_file 분기별 분석”
flowchart TD
  A["vacuum_add_dropped_file 진입"] --> B{"vacuum_Dropped_files_loaded?"}
  B -- 아니오 --> C["assert !LOG_ISRESTARTED<br/>디스크에서 로드; 실패 -> ER_FAILED"]
  B -- 예 --> D
  C --> D["페이지 write-latch로 fix, 체인 순회"]
  D --> E{"util_bsearch가 vfid를 찾았는가?"}
  E -- 예 --> F["mvccid 덮어쓰기<br/>RVVAC_DROPPED_FILE_REPLACE undoredo 로깅<br/>dirty 설정 FREE; NO_ERROR 반환"]
  E -- 아니오 --> G{"페이지가 가득 찼는가?"}
  G -- 예 --> H["next_page로 이동<br/>디버그 track 노드 이동"] --> D
  G -- 아니오 --> I["해당 위치에서 tail을 오른쪽으로 memmove<br/>n_dropped_files++; ATOMIC_INC count<br/>RVVAC_DROPPED_FILE_ADD undoredo 로깅<br/>dirty 설정 FREE; NO_ERROR 반환"]
  D --> J{"체인을 모두 소진했는가?"}
  J -- 예 --> K["file_alloc으로 새 PAGE_DROPPED_FILES 할당<br/>실패 -> unfix, ER_FAILED"]
  K --> L["초기화: next NULL, count 1, entry[0]<br/>RVPGBUF_NEW_PAGE redo 로깅; dirty 설정 FREE"]
  L --> M["이전 마지막 페이지에 vacuum_dropped_files_set_next_page 호출<br/>RVVAC_DROPPED_FILE_NEXT_PAGE undoredo 로깅"]
  M --> N["unfix; NO_ERROR 반환"]

Figure 10-2 — vacuum_add_dropped_file. 모든 종료 경로는 replace, 페이지 내 삽입, 새 페이지 추가, 또는 assert 기반 ER_FAILED 중 하나다.

replace 분기는 VFID 재사용 — 이전에 삭제됐다가 재활용된 후 다시 삭제된 경우; VFID당 엔트리 하나, 가장 최신 경계값이 우선한다:

// vacuum_add_dropped_file -- src/query/vacuum.c
undo_data = page->dropped_files[position];
save_mvccid = page->dropped_files[position].mvccid;
page->dropped_files[position].mvccid = mvccid;
assert_release (MVCC_ID_FOLLOW_OR_EQUAL (mvccid, save_mvccid));
log_append_undoredo_data (thread_p, RVVAC_DROPPED_FILE_REPLACE, &addr, /* ... before/after entry ... */);

삽입 분기는 RVVAC_DROPPED_FILE_ADDundo length 0으로 로깅한다 — add를 undo할 때는 위치(addr.offset)만 있으면 되며; redo는 전체 엔트리를 담는다. 새 페이지 분기는 vacuum_dropped_files_set_next_page로 체인에 연결되며, 이 함수는 page_p->next_page에 할당하기 전에 이전/새 VPID를 undoredo 쌍으로 로깅한다 (새 페이지 분기에서 track 노드 malloc이 실패하면 두 페이지를 모두 unfix하고 ER_FAILED).

불변식 10-A — 페이지 내 VFID 정렬 순서. 엔트리는 vacuum_compare_dropped_files(fileid 우선, 그 다음 volid)로 정렬된다; 삽입은 util_bsearch가 결정한 위치에 이루어지며, 복구도 같은 위치 기반 삽입을 재생한다. 이 불변식이 깨지면 vacuum_find_dropped_filebsearch가 엔트리를 놓치게 되고, 워커가 삭제된 파일의 페이지를 vacuum하다가 해제된 extent를 역참조하게 된다.

rcvindexundofunredofun페이로드
RVVAC_NOTIFY_DROPPED_FILEvacuum_rv_notify_dropped_file동일 (run-postpone로 실행)vfid + class_oid
RVVAC_DROPPED_FILE_ADDvacuum_rv_undo_add_dropped_filevacuum_rv_redo_add_dropped_fileredo: 엔트리; undo: 없음 (위치는 rcv->offset에)
RVVAC_DROPPED_FILE_REPLACEvacuum_rv_replace_dropped_file동일before/after 엔트리
RVVAC_DROPPED_FILE_NEXT_PAGEvacuum_rv_set_next_page_dropped_files동일이전/새 VPID
RVVAC_DROPPED_FILE_CLEANUP없음 — redo 전용vacuum_rv_redo_cleanup_dropped_filesn_indexes + 내림차순 인덱스 배열 (10.8)

(새 원장 페이지는 일반 RVPGBUF_NEW_PAGE redo를 이용한다, 10.3.) vacuum_rv_redo_add_dropped_file은 슬롯을 열고(position < nmemmove), 엔트리를 복사하며 n_dropped_files++; > n이면 assert 로깅 후 ER_FAILED. vacuum_rv_undo_add_dropped_file은 이를 미러링한다 (position >= nER_FAILED; 아니면 tail을 memmove로 내리고 n_dropped_files--). replace 함수는 대칭적 — undo/redo 모두 한 함수(undo는 before-image, redo는 after-image) — 로, position < n on-page 엔트리와의 VFID_EQ를 검증한다. 두 redo 측 함수 모두 동일한 가드로 끝난다:

// vacuum_rv_redo_add_dropped_file -- src/query/vacuum.c
if (!MVCC_ID_PRECEDES (dropped_file->mvccid, log_Gl.hdr.mvcc_next_id))
{
log_Gl.hdr.mvcc_next_id = dropped_file->mvccid;
MVCCID_FORWARD (log_Gl.hdr.mvcc_next_id); /* <- 경계값을 모든 원장 MVCCID보다 앞에 유지 */
}

불변식 10-B — 경계값 규칙. 원장의 모든 mvccidlog_Gl.hdr.mvcc_next_id보다 엄격히 작다: 죽은 파일에 대한 커밋된 변경은 더 작은 MVCCID를 갖고; VFID를 재사용하는 트랜잭션은 이후에 시작되어 >= 값을 가진다. Redo는 mvcc_next_id를 전진시켜 이를 재확인한다. 이 불변식이 깨지면 10.6의 테스트가 새 파일 레코드를 dead로(vacuum 손실), 또는 dead-file 레코드를 live로(use-after-free) 잘못 분류한다.

10.5 버전 핸드셰이크 — 워커를 멈추지 않고 삭제 공표하기

섹션 제목: “10.5 버전 핸드셰이크 — 워커를 멈추지 않고 삭제 공표하기”

vacuum_rv_notify_dropped_fileRVVAC_NOTIFY_DROPPED_FILE을 run-postpone 및 undo 모두에서 실행한다(recovery.c 테이블에서 동일 함수). 경계값을 샘플링하고, 삽입한 뒤, 워커들에게 알린다:

// vacuum_rv_notify_dropped_file -- src/query/vacuum.c
mvccid = ATOMIC_LOAD_64 (&log_Gl.hdr.mvcc_next_id); /* <- 경계값은 drop 구문 시점이 아닌 지금 샘플링 */
error = vacuum_add_dropped_file (thread_p, &rcv_data->vfid, mvccid);
// ... condensed: error -> return; then ...
vacuum_notify_all_workers_dropped_file (rcv_data->vfid, mvccid);
if (!OID_ISNULL (class_oid)) { (void) heap_delete_hfid_from_cache (thread_p, class_oid); }

notify 단계가 “트랜잭션을 멈추지 않고”를 실현하는 지점이다: 삭제 수행자가 비용을 지불하며, 워커는 절대 아니다. 함수 본체는 SERVER_MODE 전용 — SA 모드에서는 동시 워커가 없어 no-op으로 컴파일된다:

// vacuum_notify_all_workers_dropped_file -- src/query/vacuum.c
if (!LOG_ISRESTARTED ()) { return; } /* <- 복구 중에는 워커가 실행되지 않음 */
pthread_mutex_lock (&vacuum_Dropped_files_mutex);
VFID_COPY (&vacuum_Last_dropped_vfid, &vfid_dropped); /* <- 한 번에 하나의 삭제만 공표 */
my_version = ++vacuum_Dropped_files_version;
for (workers_min_version = vacuum_get_worker_min_dropped_files_version ();
workers_min_version != -1 && workers_min_version < my_version;
workers_min_version = vacuum_get_worker_min_dropped_files_version ())
{
thread_sleep (1); /* <- 삭제 수행자가 스핀; 워커는 절대 블록되지 않음 */
}
VFID_SET_NULL (&vacuum_Last_dropped_vfid);
pthread_mutex_unlock (&vacuum_Dropped_files_mutex);

vacuum_get_worker_min_dropped_files_versionvacuum_Workers[]를 스캔하여 state != VACUUM_WORKER_STATE_INACTIVE인 워커만 고려한다; -1(활성 워커 없음)이면 즉시 루프를 빠져나온다. 워커 측은 vacuum_process_log_record(7장) 상단의 버전 게이트다:

// vacuum_process_log_record -- src/query/vacuum.c
if (worker->drop_files_version != vacuum_Dropped_files_version)
{
VFID_COPY (&vfid, &vacuum_Last_dropped_vfid);
vacuum_cleanup_collected_by_vfid (worker, &vfid); /* <- 확인 전에 먼저 정리 */
worker->drop_files_version = vacuum_Dropped_files_version;
}
error_code = vacuum_is_file_dropped (thread_p, is_file_dropped, &vacuum_info->vfid, *mvccid);

순서가 핵심이다: 워커는 먼저 vacuum_Last_dropped_vfid의 이미 수집된 heap 객체를 폐기하고, 그 후 자신의 drop_files_version을 전진시켜 삭제 수행자의 min-scan이 이를 감지하게 한다; 모든 in-flight 워커가 이 과정을 마친 뒤에야 삭제 수행자의 커밋이 파일 파괴로 진행한다. 카운터는 자유 진행 INT32이므로 vacuum_compare_dropped_files_version이 min-scan을 wraparound에 안전하게 만든다(같은 부호 값은 단순 a - b로 비교; 혼합 부호의 경우 극단적 양수 사분면 >= 0x3FFFFFFF의 값을 이전 wraparound 측으로 간주).

불변식 10-C — 워커가 원장을 앞지르지 않는다. vacuum_notify_all_workers_dropped_file이 반환될 때, 공표 시점에 활성 상태였던 모든 워커는 삭제된 VFID의 수집된 객체를 정리하고 다음 확인 시 해당 엔트리를 볼 수 있다; 이후 워커는 디스크에서 읽는다. mutex(한 번에 하나의 삭제), vacuum_Last_dropped_vfid(정리 대상), min-version 스핀에 의해 보장된다. 이 불변식이 깨지면 8장의 heap 실행자가 해제된 파일의 페이지를 fix하게 된다.

10.6 워커 측 조회 — vacuum_is_file_dropped / vacuum_find_dropped_file

섹션 제목: “10.6 워커 측 조회 — vacuum_is_file_dropped / vacuum_find_dropped_file”

vacuum_is_file_dropped: PRM_ID_DISABLE_VACUUMfalse; 아니면 vacuum_find_dropped_file에 위임하며, 이 함수는 공통 경우를 무비용으로 처리하는 빠른 확인으로 시작한다 — vacuum_Dropped_files_count == 0 → 페이지 fix 없이, latch 없이 false. 아니면 read-latch로 체인을 순회한다. fix 실패는 인터럽트/ 셧다운(assert (error == ER_INTERRUPTED); 워커도 assert (thread_p->shutdown)) 의 경우에만 허용되며; *is_file_droppedfalse(“실제로는 알 수 없지만 중요하지 않음”)로 설정하고 에러가 작업을 중단시킨다. fix된 각 페이지에는 pgbuf_notify_vacuum_follows가 호출된다(dropped-files 페이지는 LRU 부스트 대상이 아니며; 이는 victimization을 늦춘다). 이후 bsearch(불변식 10-A)가 세 가지 결과를 낸다: 찾았고 MVCC_ID_PRECEDES (mvccid, dropped_file->mvccid)true (레코드가 경계값보다 앞이므로 죽은 파일 소속); 찾았지만 레코드 MVCCID가 엔트리 값 >=false(VFID 재사용 — 레코드가 후계자 소속); 찾지 못함 → next_page를 따라가고, 체인 소진 → false. 첫 번째 결과가 원장 전체가 존재하는 이유인 안전 속성이다: 워커는 레코드의 MVCCID가 엔트리의 경계값보다 앞설 때만 레코드를 dropped로 판정하므로, 해당 레코드의 트랜잭션 이후에 삭제된 파일의 페이지를 vacuum하거나 fix하는 일이 절대 발생하지 않는다.

10.7 이미 수집된 작업 정리 — vacuum_cleanup_collected_by_vfid

섹션 제목: “10.7 이미 수집된 작업 정리 — vacuum_cleanup_collected_by_vfid”

버전 게이트에서만 호출된다. 워커의 heap_objectsvacuum_compare_heap_object (VFID 주요, OID 부차)로 qsort하고, 첫 번째 일치 엔트리를 스캔한다 — 없으면 반환; 해당 범위의 끝을 스캔한다; 범위가 배열 끝까지 이어지면 잘라낸다 (n_heap_objects = start); 아니면 tail을 범위 위로 memmove하고 end - start를 차감한다. 재정렬은 무해하다: 8장의 실행자가 배치 처리 전에 다시 정렬한다.

10.8 정리 — vacuum_cleanup_dropped_files와 로깅 쌍

섹션 제목: “10.8 정리 — vacuum_cleanup_dropped_files와 로깅 쌍”

정리는 전체 패스가 vacuum_Data.oldest_unvacuumed_mvccid = log_Gl.hdr.mvcc_next_id를 설정한 뒤 실행된다; 이 리비전에서 유일한 호출 지점은 xvacuum(SA 모드 cubrid vacuumdb, 11장)이며 — 서버 모드 호출자는 남아 있지 않으므로, 운영 서버에서는 다음 오프라인 vacuum까지 원장이 계속 증가한다. 분기: 복구 중 → 건너뜀; vacuum_Dropped_files_count == 0 → 건너뜀; 페이지별(write-latch): 빈 페이지 → unfix, 계속; 아니면 엔트리를 끝에서부터 역방향으로 스캔하여, MVCC_ID_PRECEDES (mvccid, oldest_unvacuumed_mvccid)인 각 항목의 인덱스를 removed_entries[]에 기록하고 즉시 tail을 memmove로 내린다. 제거된 항목이 있으면: ATOMIC_INC_32 (&count, -n), 페이지 카운터 감소, vacuum_log_cleanup_dropped_files로 로깅(redo 전용 RVVAC_DROPPED_FILE_CLEANUP 크럼: n_indexes + 끝부터의 인덱스 배열), track 미러 갱신, dirty 설정 FREE; 없으면 단순 unfix. vacuum_rv_redo_cleanup_dropped_files는 크럼을 언팩(assert (offset == rcv->length))하고 각 제거를 끝부터 재생하므로, 각 memmove가 이후(더 작은) 인덱스에서 건드리지 않은 tail을 압축한다. undo 함수 없음: 충돌 후 재정리는 명백히 불필요한 엔트리만 제거한다.

이 경로를 수정하기 전에 알아야 할 세 가지 코드 문제: (1) 코드 내 todo가 빈 페이지는 절대 해제되지 않는다고 지적한다(“leaked인 것으로 보인다”); (2) trailing cut-off-link 단계는 이중으로 깨져 있다 — vacuum_dropped_files_set_next_page (thread_p, page, &page->next_page)를 호출하여 페이지 자신의 현재 링크를 재할당하는(intended NULL을 쓰지 않는 no-op) 오류가 있으며, last_non_empty_page_vpid는 각 비어 있지 않은 페이지의 후계자를 기록한다(page->next_page로 이동한 뒤 vpid를 복사하므로); 따라서 fix된 페이지조차 틀리다; (3) cleanup redo는 mem_size = (page->n_dropped_files - indexes[i]) * sizeof (VACUUM_DROPPED_FILE) 로 계산 — 런타임 경로의 (page_count - i - 1)보다 엔트리 하나 더 많음 — 따라서 각 재생 제거는 live tail 한 칸 너머까지 복사한다(감소된 카운트를 넘어선 양성 쓰레기, 단 full 페이지에서는 소스 읽기가 페이지 경계를 넘어선다).

불변식 10-D — 정리 안전성. mvccid < vacuum_Data.oldest_unvacuumed_mvccid인 경우에만 엔트리를 제거할 수 있다: 워터마크보다 오래된 모든 로그 레코드는 vacuum 완료 또는 dropped로 건너뜀(9장)이므로, 향후 어떤 원장 조회도 해당 엔트리를 필요로 할 수 없다. 이 불변식이 깨지면 원장이 존재하는 이유인 바로 그 lost-file 문제가 재발한다.

  1. 원장은 VFID 정렬된 페이지 체인 테이블로, notify 시점에 log_Gl.hdr.mvcc_next_id에서 샘플링된 경계 MVCCID를 키로 한다; 이보다 엄격히 오래된 레코드는 죽은 파일 소속이다(불변식 10-A, 10-B).
  2. 등록은 구조적으로 트랜잭셔널하다: vacuum_log_add_dropped_file은 삭제 시 postpone(커밋 시 적용, file-destroy postpone에 앞서)를, 생성 시 undo(abort 시 적용)를 추가하며; 둘 다 vacuum_rv_notify_dropped_file로 수렴한다.
  3. vacuum_add_dropped_file은 네 가지 성공 종료 경로를 갖는다 — replace (VFID 재사용), 정렬된 페이지 내 삽입(zero-length undo), 다음 페이지 재시도, vacuum_dropped_files_set_next_page를 통한 새 페이지 추가. VACUUM_DROPPED_FILE_FLAG_DUPLICATE는 잔재 코드다.
  4. 핸드셰이크는 일반적인 비용 구조를 역전시킨다: 삭제 수행자가 vacuum_Dropped_files_mutex 하에서 모든 활성 워커의 drop_files_version이 따라잡을 때까지 스핀하며(wraparound 안전 비교); 워커는 레코드당 비교 한 번만 지불하고 확인 전에 vacuum_cleanup_collected_by_vfid로 수집된 객체를 정리한다 (불변식 10-C).
  5. vacuum_find_dropped_file은 전역 카운트가 0이면 즉시 단락한다; found-and-older 결과가 안전 테스트이며, found-but-newer는 VFID 재사용을 not-dropped로 올바르게 분류한다.
  6. 정리는 oldest_unvacuumed_mvccid보다 오래된 엔트리를 redo 전용 위치 레코드로 제거한다(불변식 10-D); SA 모드 xvacuum에서만 실행되며, 빈 페이지는 누수되고, 빈 페이지 unlink는 no-op이며, redo가 엔트리 하나씩 초과 복사하는 — 수정자가 조용히 물려받아서는 안 될 네 가지 사항이다.

Chapter 11: 크래시 복구와 독립 실행 경로

섹션 제목: “Chapter 11: 크래시 복구와 독립 실행 경로”

이전 챕터들은 모두 살아 있는 SERVER_MODE 프로세스를 전제했다. Ch 6의 마스터가 잡을 생성하고, Ch 7-8의 워커가 소비하며, append 경로가 vacuum_Block_data_buffer에 공급하는(Ch 3-4) 구조였다. 이 챕터는 블록이 AVAILABLE, IN_PROGRESS, VACUUMED, 또는 아직 등록되지 않은 상태 각각에서 크래시, copydb, 또는 데몬 없이 실행되는 SA 모드 세션이 발생하면 어떻게 되는지를 추적한다. 보조 문서의 “복구 통합” 절(cubrid-vacuum.md)이 전체 맥락을 설명하며, 여기서는 개별 분기를 하나씩 따라간다.

11.1 복구 핸드셰이크: recovery_lsa와 notify 훅

섹션 제목: “11.1 복구 핸드셰이크: recovery_lsa와 notify 훅”

Vacuum은 ARIES의 각 패스(분석·재실행·되돌리기)에 직접 참여하지 않는다. 복구 계약은 필드 하나, vacuum_data::recovery_lsa로 압축된다. 이 필드는 log_recovery가 분석 패스 직전에 설정한다.

// log_recovery -- src/transaction/log_recovery.c
/* Notify vacuum it may need to recover the lost block data.
* ... 1. recovery finds MVCC op log records after last checkpoint ...
* ... 2. no MVCC op log record is found, so vacuum has to start recovery from checkpoint LSA ... */
vacuum_notify_server_crashed (&rcv_lsa); /* <- rcv_lsa = checkpoint LSA (older for media crash) */

vacuum_notify_server_crashed는 한 줄이다: LSA_COPY (&vacuum_Data.recovery_lsa, recovery_lsa). 정상 종료 시 대응 함수인 vacuum_notify_server_shutdownvacuum_stop_workers에서 vacuum_Data.shutdown_sequence.request_shutdown()을 호출해 워커 풀이 파괴되기 전에 마스터(Ch 6)가 잡 생성을 중단하도록 지시한다.

불변 조건 11-A — recovery_lsa는 이번 부팅에서 크래시 복구가 실행된 경우에만 non-null이다. vacuum_notify_server_crashed에서만 설정되고, vacuum_recover_lost_block_data에서 소비되며, vacuum_data_load_and_recoverLSA_SET_NULL로 초기화된다. 따라서 정상 부팅은 lost-block 복구를 건너뛰고(§11.4의 첫 번째 early return), 값이 누수되면 불필요한 로그 끝 재스캔을 강제하게 된다.

11.2 RVVAC_* 카탈로그 — redo 패스가 vacuum을 위해 재실행하는 것들

섹션 제목: “11.2 RVVAC_* 카탈로그 — redo 패스가 vacuum을 위해 재실행하는 것들”

vacuum 전용 부팅 로직이 실행되기 전에, 일반 ARIES redo 패스는 recovery.cRV_fun 테이블로 페이지 수준의 모든 vacuum 변경을 이미 재실행한 상태다. 레코드가 이 함수들에 도달하는 방식 — RV_fun[rcvindex] 조회, 페이지 fix, 페이지-LSA 게이팅, undo 대 redo 선택 — 은 복구 관리자의 디스패치 메커니즘이다; 해당 내용은 cubrid-recovery-manager-detail.md를 참고하고 여기서는 주어진 것으로 전제한다. 각 행의 형태는 {index, name, undofun, redofun, undo_dump, redo_dump}이다:

// RV_fun -- src/transaction/recovery.c
{RVVAC_COMPLETE,
"RVVAC_COMPLETE",
NULL,
vacuum_rv_redo_vacuum_complete,
NULL, NULL},
// ... condensed: RVVAC_START_JOB (105) ... RVVAC_DROPPED_FILE_REPLACE (114) ...
{RVVAC_HEAP_RECORD_VACUUM,
"RVVAC_HEAP_RECORD_VACUUM",
vacuum_rv_undo_vacuum_heap_record,
vacuum_rv_redo_vacuum_heap_record,
NULL,
log_rv_dump_hexa},
// ... condensed: RVVAC_HEAP_PAGE_VACUUM (116), RVVAC_REMOVE_OVF_INSID (117) ...

14개의 인덱스(104-117, recovery.h 기준), 핸들러, 그리고 각 본문의 동작(분기 포함):

인덱스Undo 함수Redo 함수효과 (분기)
RVVAC_COMPLETE (104)vacuum_rv_redo_vacuum_complete로깅된 mvcc_next_idoldest_unvacuumed_mvccid를 복원하고 로그 헤더 vacuum 캐시를 리셋한다. xvacuum(§11.6)만이 기록하며, §11.4의 역방향 탐색 종단점으로도 사용된다.
RVVAC_START_JOB (105)vacuum_rv_redo_start_jobrcv->offset의 항목에 set_job_in_progress()를 수행해 부팅 시 §11.3이 덮어쓸 IN_PROGRESS 상태를 재생성한다. vacuum_job_cursor::start_job_on_current_entry에서 기록되며, 이미 interrupted 상태인 잡을 재로깅하는 것은 아무 효과가 없다.
RVVAC_DATA_APPEND_BLOCKS (106)vacuum_rv_redo_append_dataindex_free에 N개의 항목을 memcpy하고 index_free를 증가시킨다. Ch 4 프로듀서의 영속성이다.
RVVAC_DATA_INIT_NEW_PAGE (107)vacuum_rv_redo_initialize_data_page페이지를 재포맷하고 data->blockid를 로깅된 워터마크로 초기화한다. vacuum_init_data_page_with_last_blockid에서 기록된다 — 페이지 증설(Ch 4), 워터마크 영속성 및 copydb 리셋(§11.7).
RVVAC_DATA_SET_LINK (108)vacuum_rv_undoredo_data_set_link같은 함수두 분기: rcv->data == NULLVPID_SET_NULL (next_page), 아니면 VPID를 복사한다. 새 페이지 연결(Ch 4) 또는 소비된 페이지 연결 해제(Ch 9) 시 undo+redo로 기록된다.
RVVAC_DATA_FINISHED_BLOCKS (109)vacuum_rv_redo_data_finishedCh 9의 mark-and-compact 2단계를 재실행한다; 아래 발췌문 참고.
RVVAC_NOTIFY_DROPPED_FILE (110)vacuum_rv_notify_dropped_file같은 함수논리적 레코드(RCV_IS_LOGICAL_LOG): mvccid = log_Gl.hdr.mvcc_next_id를 drop 경계로 파일을 다시 추가하고, 모든 워커에 알리며, class_oid가 non-null이면 HFID 캐시를 무효화한다. 파일 파괴 시 postpone으로, 파일 생성 시 undo로 추가된다 — 각각 commit 시 drop과 abort 시 drop이다(Ch 10).
RVVAC_DROPPED_FILE_CLEANUP (111)vacuum_rv_redo_cleanup_dropped_filesdropped-files 페이지에서 로깅된 각 위치를 memmove로 삭제하고 n_dropped_files를 감소시킨다.
RVVAC_DROPPED_FILE_NEXT_PAGE (112)vacuum_rv_set_next_page_dropped_files같은 함수before/after 이미지를 이용한 무조건적인 next_page 링크 쓰기.
RVVAC_DROPPED_FILE_ADD (113)vacuum_rv_undo_add_dropped_filevacuum_rv_redo_add_dropped_fileRedo: rcv->offset에 삽입(memmove로 공간 확보, 아니면 끝에 추가; position > n_dropped_filesER_FAILED), 이후 항목의 MVCCID가 앞서 있지 않으면 log_Gl.hdr.mvcc_next_id를 전진. Undo: 해당 위치에서 memmove로 삭제(position >= n_dropped_filesER_FAILED).
RVVAC_DROPPED_FILE_REPLACE (114)vacuum_rv_replace_dropped_file같은 함수rcv->offset의 항목 MVCCID를 덮어쓴다; 범위 초과 또는 VFID 불일치는 ER_FAILED.
RVVAC_HEAP_RECORD_VACUUM (115)vacuum_rv_undo_vacuum_heap_recordvacuum_rv_redo_vacuum_heap_recordRedo: spage_vacuum_slot(slotid와 reusableVACUUM_LOG_VACUUM_HEAP_MASK 아래 rcv->offset에 패킹됨), 필요 시 spage_compact. Undo: 마스크 비트를 제거하고 heap_rv_redo_insert에 위임 — 워커가 제거한 레코드를 재삽입한다(Ch 8).
RVVAC_HEAP_PAGE_VACUUM (116)vacuum_rv_redo_vacuum_heap_page페이지 전체 재실행: n_slots / reusable / all_vacuumedrcv->offset에 패킹됨. n_slots == 0 → 헤더 전용 “once to none” 상태 전이. 그 외 슬롯별: 음수 slotid → 레코드 완전 제거, spage_vacuum_slot; 양수 → insert MVCCID만 vacuum됨, OR_MVCC_FLAG_VALID_INSID | PREV_VERSION 제거 후 레코드 인플레이스 재구성. 필요 시 spage_compact; all_vacuumed → 상태를 none으로(Ch 8).
RVVAC_REMOVE_OVF_INSID (117)vacuum_rv_redo_remove_ovf_insid오버플로 페이지 MVCC 헤더: insid를 MVCCID_ALL_VISIBLE로, prev_version_lsa를 null로 설정. Ch 8의 고정 크기 헤더 비대칭성.

(RVES_NOTIFY_VACUUM, 외부 스토리지 인접 행은 undo와 redo를 모두 vacuum_rv_es_nop — 명시적 no-op — 에 매핑한다.)

불변 조건 11-B — vacuum 데이터는 redo 전용 상태다. vacuum data 페이지를 대상으로 하는 모든 RVVAC 행(104-107, 109)의 undo 함수는 NULL이다; RVVAC_DATA_SET_LINK만 undoable이며, 그 undo는 before-image에 적용되는 동일한 멱등(idempotent) 링크 쓰기다. Vacuum 데이터는 마스터/시스템 스레드가 독점적으로 변경하며, 레코드 단위로 롤백되지 않는다. 찢어진 변경은 앞 방향으로 치유된다 — redo가 재실행하고, §11.3-§11.4가 조정한다. Undoable 행들은 abort 가능한 연산 내부에서 수정되는 다른 구조체(dropped-files 페이지, 힙 페이지)를 대상으로 한다.

구조적으로 가장 복잡한 본문은 RVVAC_DATA_FINISHED_BLOCKS이며, 한 페이지에서 Ch 9의 두 단계 mark-and-compact를 재실행한다:

// vacuum_rv_redo_data_finished -- src/query/vacuum.c
if (rcv_data_ptr != NULL)
{
while (rcv_data_ptr < (char *) rcv->data + rcv->length)
{
blockid_with_flags = *((VACUUM_LOG_BLOCKID *) rcv_data_ptr);
blockid = VACUUM_BLOCKID_WITHOUT_FLAGS (blockid_with_flags);
// ... condensed: data_index = blockid - page_unvacuumed_blockid + index_unvacuumed ...
if (VACUUM_BLOCK_STATUS_IS_VACUUMED (blockid_with_flags))
data_page->data[data_index].set_vacuumed ();
else
data_page->data[data_index].set_interrupted ();
rcv_data_ptr += sizeof (VACUUM_LOG_BLOCKID);
}
}
while (data_page->index_unvacuumed < data_page->index_free
&& data_page->data[data_page->index_unvacuumed].is_vacuumed ())
data_page->index_unvacuumed++;
if (VPID_ISNULL (&data_page->next_page) && data_page->index_unvacuumed > 0)
{
/* Remove all vacuumed blocks. */
// ... condensed: memmove survivors to front; index_free -= index_unvacuumed; index_unvacuumed = 0 ...
}

세 가지 분기가 있다: 플래그 루프(VACUUMED 대 interrupted, Ch 9가 기록한 블록 단위 결과 그대로), index_unvacuumed 전진, 그리고 마지막 페이지 전용 압축(next_page null). 이 루프는 rcv->data == NULL을 허용한다 — 레코드에 블록 목록이 없어도 전진과 압축은 여전히 작동한다.

11.3 vacuum_data_load_and_recover — 재적재, 리셋, 되감기

섹션 제목: “11.3 vacuum_data_load_and_recover — 재적재, 리셋, 되감기”

워커가 존재하기 전에, vacuum_boot에서 호출된다. 두 모드 모두 해당하며, §11.2의 함수들이 redo 패스에서 모든 vacuum data 페이지를 로깅된 상태로 복원한 이후다. 그림 11-1이 모든 분기를 망라한다:

flowchart TD
    A["file_descriptor_get +<br/>fix vpid_first"] -->|오류| Z["goto end:<br/>unfix + unload, 오류 반환"]
    A --> B["페이지 순회 루프:<br/>페이지 내 각 항목 처리"]
    B --> C{"entry->is_job_in_progress?"}
    C -->|예| D["entry->set_interrupted()<br/>페이지 dirty 표시"]
    C -->|아니오| E[다음 항목]
    D --> E
    E --> F{"next_page VPID null?"}
    F -->|아니오, fix 실패| Z
    F -->|아니오| B
    F -->|예| G["last_page = data_page"]
    G --> H{"vacuum_is_empty()?"}
    H -->|예| I{"logpb_last_complete_blockid < 0?"}
    I -->|예: copydb 직후| J["last_blockid 변경 없음"]
    I -->|아니오| K{"recovery_lsa null AND<br/>hdr.mvcc_op_log_lsa null?"}
    K -->|예: 10.1 호환| L["set_last_blockid(log_blockid)"]
    K -->|아니오| M["set_last_blockid(MAX(hdr.vacuum_last_blockid,<br/>last_page->data->blockid))"]
    H -->|아니오| N["set_last_blockid(last_page의<br/>마지막 항목)"]
    J --> O["is_loaded = true;<br/>update_global_oldest_visible"]
    L --> O
    M --> O
    N --> O
    O --> P["vacuum_recover_lost_block_data"]
    P -->|오류| Z
    P --> Q["LSA_SET_NULL(recovery_lsa);<br/>set_oldest_unvacuumed_on_boot;<br/>update_keep_from_log_pageid"]
    Q --> R["vpid_first/vpid_last를<br/>vacuum_Data_load에 저장; 페이지 unfix"]

그림 11-1: vacuum_data_load_and_recover, 전체 분기.

IN_PROGRESS 리셋은 Ch 1의 상태 플래그를 직접 건드린다:

// vacuum_data_load_and_recover -- src/query/vacuum.c
if (entry->is_job_in_progress ())
{
/* Reset in progress flag, mark the job as interrupted and update last_blockid. */
entry->set_interrupted (); /* <- STATUS_SET_AVAILABLE + SET_INTERRUPTED */
is_page_dirty = true;
}

불변 조건 11-C — IN_PROGRESS는 재시작을 결코 살아남지 못한다. 서버와 함께 종료된 워커가 처리 중이던 잡은 — 런타임 상태로 확보됐든 재실행된 RVVAC_START_JOB으로 재생성됐든 — 다시 실행 가능해야 하므로, 페이지 순회가 AVAILABLE + INTERRUPTED로 덮어쓴다. INTERRUPTED는 vacuum_heap_page의 안전 장치를 완화한다(Ch 8): 블록이 부분적으로 실행됐을 수 있으므로 “레코드가 이미 vacuum됨”은 더 이상 이상 징후가 아니다. 단순 AVAILABLE로 리셋하면 반쯤 정리된 페이지를 두 번 처리할 때 assertion이 재활성화된다.

vacuum 데이터가 비어 있을 때 last_blockid는 세 가지 방식으로 복원된다. log_blockid < 0은 로그가 첫 번째 블록을 아직 채우지 않았음을 의미한다(“copydb 직후일 수 있는 케이스” — §11.7과 연결된다). recovery_lsalog_Gl.hdr.mvcc_op_log_lsa 모두 null인 경우는 logpb_last_complete_blockid()를 신뢰하는 10.1 호환 경로다. 기본값은 MAX (log_Gl.hdr.vacuum_last_blockid, vacuum_Data.last_page->data->blockid)를 취하는데, 긴 SA 세션 이후에는 페이지상의 blockid가 “오래됐을 것이기 때문이다. 대신 SA_MODE는 이전 아카이브를 삭제하기 전에 log_Gl.hdr.vacuum_last_blockid를 업데이트한다”(§11.7). 비어 있지 않은 데이터는 추측이 필요 없다: 마지막으로 등록된 항목이 워터마크다.

lost-block 복구 이후, vacuum_data::set_oldest_unvacuumed_on_boot이 Ch 5의 워터마크를 초기화한다. 비어 있는 데이터이면 log_Gl.hdr.oldest_visible_mvccid(vacuum이 필요한 블록이 없으면 mvcc_next_id로 먼저 초기화됨)를; 아니면 첫 번째 항목의 oldest_visible_mvccid를 사용하는데, 이것이 나머지 모든 항목의 하한이다. 마지막으로 — Ch 2의 소유권 모델에 따라 — 페이지들은 부팅 스레드가 fix 상태로 유지할 수 없다. 두 VPID를 vacuum_Data_load에 저장하고 vacuum_data_unload_first_and_last_page로 unfix한다; 마스터(또는 xvacuum)가 vacuum_data_load_first_and_last_page로 다시 fix한다.

11.4 vacuum_recover_lost_block_data — 크래시가 삼킨 블록 재건

섹션 제목: “11.4 vacuum_recover_lost_block_data — 크래시가 삼킨 블록 재건”

Ch 3에서 보았듯, 채워진 블록은 log_Gl.hdrvacuum_Block_data_buffer에만 존재하다가 소비된다(Ch 4). 크래시는 이 둘을 모두 소실시킨다. 이 함수는 WAL로부터 이들을 재건한다. 진입 분기:

  1. recovery_lsa null → 정상 부팅, return NO_ERROR.
  2. log_Gl.hdr.mvcc_op_log_lsa null → 복구된 헤더가 마지막 MVCC op를 잃었음; recovery_lsa부터 역방향 탐색.
  3. vacuum_get_log_blockid (mvcc_op_log_lsa.pageid) <= get_last_blockid () → 이미 등록된 블록 안쪽에 있음; logpb_vacuum_reset_log_header_cache 후 반환.
  4. 그 외 → log_Gl.hdr.mvcc_op_log_lsa에서 직접 시작.

분기 2의 탐색은 back_lsa를 따라 레코드 헤더를 역방향으로 순회하며, stop_at_pageid = VACUUM_LAST_LOG_PAGEID_IN_BLOCK (get_last_blockid ())로 하한이 정해진다. 세 가지 종단 조건이 있다:

// vacuum_recover_lost_block_data -- src/query/vacuum.c
if (log_rec_header.type == LOG_MVCC_UNDO_DATA || log_rec_header.type == LOG_MVCC_UNDOREDO_DATA
|| log_rec_header.type == LOG_MVCC_DIFF_UNDOREDO_DATA)
{
LSA_COPY (&mvcc_op_log_lsa, &log_lsa); /* <- found the chain tail */
break;
}
else if (log_rec_header.type == LOG_SYSOP_END)
{
// ... condensed: hit if sysop_end->type == LOG_SYSOP_END_LOGICAL_MVCC_UNDO ...
}
else if (log_rec_header.type == LOG_REDO_DATA)
{
// ... condensed: break WITHOUT a hit if redo->data.rcvindex == RVVAC_COMPLETE ...
}
LSA_COPY (&log_lsa, &log_rec_header.back_lsa);

RVVAC_COMPLETExvacuum이 기록하는 SA 모드의 “모두 정리” 마커다(§11.6). 이보다 오래된 것은 이미 vacuum됐으므로 탐색은 mvcc_op_log_lsa가 null인 채로 종료된다 → “복구할 것 없음” → NO_ERROR. stop_at_pageid에 도달했을 때도 동일한 null 확인이 적용된다. 여기서(그리고 메인 루프에서) logpb_fetch_page 실패는 logpb_fatal_error다 — 읽을 수 없는 로그 위에서 복구를 진행할 수 없다.

메인 루프는 블록 단위로 VACUUM_DATA_ENTRY를 하나씩 재건하며, 최신 → 오래된 순으로 vacuum_process_log_record로 각 레코드의 prev_mvcc_op_log_lsa 체인을 따라간다(Ch 7에서 워커들이 순회하는 것과 동일한 체인):

// vacuum_recover_lost_block_data -- src/query/vacuum.c
std::stack<VACUUM_DATA_ENTRY> vacuum_block_data_buffer_stack;
/* we don't reset data.oldest_visible_mvccid between blocks. we need to maintain ordered
* oldest_visible_mvccid's ... */
data.oldest_visible_mvccid = MVCCID_NULL;
while (crt_blockid > vacuum_Data.get_last_blockid ())
{
// ... condensed: inner loop folds each record's mvccid into oldest/newest ...
if (data.blockid == vacuum_get_log_blockid (log_Gl.prior_info.prior_lsa.pageid))
{
/* <- the still-open block: restore header cache instead of registering */
log_Gl.hdr.oldest_visible_mvccid = data.oldest_visible_mvccid;
log_Gl.hdr.newest_block_mvccid = data.newest_mvccid;
log_Gl.hdr.does_block_need_vacuum = true;
log_Gl.hdr.mvcc_op_log_lsa = mvcc_op_log_lsa;
}
else
{
vacuum_block_data_buffer_stack.push (data);
}
crt_blockid = vacuum_get_log_blockid (log_lsa.pageid);
}

두 가지 미묘한 점이 있다:

  • 아직 열려 있는 블록은 등록되지 않는다. 가장 최신 블록이 append 지점(log_Gl.prior_info.prior_lsa)을 포함하면, 등록하는 것은 Ch 4의 “완료된 블록만” 규칙을 위반한다. 대신 Ch 3이 관리하는 네 개의 헤더 필드가 복원된다 — 블록이 채워지면 생성되거나, xvacuum의 부분 패스(§11.6)에서 소비된다. 루프 직전에 헤더 캐시가 리셋됐다(“마지막 블록이 소비되지 않았으면 info가 복원될 것이다”).
  • 스택을 통한 blockid 정렬 재실행. 스캔은 최신 → 오래된 순으로 진행하지만, vacuum_consume_buffer_log_blocks(Ch 4)는 오름차순 blockid를 가정한다. std::stack에서 pop하면 오래된 → 최신 순으로 vacuum_Block_data_buffer에 들어간다. 루프 가드 crt_blockid > get_last_blockid()가 이미 등록된 블록과의 중복을 방지한다.

불변 조건 11-D — 복구된 블록은 오름차순 blockid, 빈 틈 없이, last_blockid 보다 엄격하게 위에 vacuum 데이터에 삽입된다. 스택 역전과 루프 가드로 보장된다. 위반하면 Ch 1의 밀집 배열 주소 지정이 깨진다(검증기는 entry->get_blockid () == (entry - 1)->get_blockid () + 1을 assert한다, §11.8).

블록 사이에 data.oldest_visible_mvccid를 리셋하지 않는 것(인용된 주석)은 Ch 5의 단조성을 재수립한다: 더 최신 블록이 로깅되는 동안 활성 상태인 MVCCID는 더 오래된 블록의 하한이기도 해야 한다. 소비에는 마스터 신분이 필요하므로, 이 함수는 vacuum_consume_buffer_log_blocksvacuum_convert_thread_to_master / vacuum_restore_thread로 감싼다 — 데몬과 xvacuum 외부에서 일어나는 유일한 스레드 변환이다.

11.5 vacuum_rv_check_at_undo — 롤백 내부의 유일한 vacuum 훅

섹션 제목: “11.5 vacuum_rv_check_at_undo — 롤백 내부의 유일한 vacuum 훅”

힙 undo 복구(heap_rv_undo_delete, heap_rv_undo_update, heap_rv_undo_ovf_update, heap_file.c 내)는 레코드의 before-image를 복원한다. 여기에는 vacuum이 이미 정리할 자격을 가졌던 MVCC 헤더도 포함된다. vacuum_rv_check_at_undo는 복원된 레코드를 “vacuum 관점에서 유효한” 상태로 재작성한다. 분기 순서: 헤더 읽기(REC_BIGONE의 경우 heap_get_mvcc_rec_header_from_overflow, 아니면 spage_get_record COPY + or_mvcc_get_header; 각 실패는 assert_release + ER_FAILED), 그 다음 판단:

// vacuum_rv_check_at_undo -- src/query/vacuum.c
if (log_is_in_crash_recovery ())
{
/* always clear flags when recovering from crash - all the objects are visible anyway */
if (MVCC_IS_FLAG_SET (&rec_header, OR_MVCC_FLAG_VALID_INSID))
can_vacuum = VACUUM_RECORD_DELETE_INSID_PREV_VER;
else
can_vacuum = VACUUM_RECORD_CANNOT_VACUUM;
}
else
{
/* <- runtime rollback: ask the real oracle, the Ch 5 watermark */
can_vacuum = mvcc_satisfies_vacuum (thread_p, &rec_header, log_Gl.mvcc_table.get_global_oldest_visible ());
}
/* it is impossible to restore a record that should be removed by vacuum */
assert (can_vacuum != VACUUM_RECORD_REMOVE);

크래시 복구 중에는 되돌려지는 모든 트랜잭션이 실패 운명이므로 살아남은 모든 버전이 가시적이고, 유효한 insid는 무조건 제거된다. 런타임에서는 판단을 mvcc_satisfies_vacuum에 위임한다. REMOVE assertion은 안전 명제다: vacuum이 완전히 삭제할 레코드에는 커밋됐으면서 오래된 삭제자가 필요하고, 이것이 동시에 되돌려지는 미완료 트랜잭션일 수는 없다.

VACUUM_RECORD_DELETE_INSID_PREV_VER 케이스: REC_BIGONE이면 insid를 MVCCID_ALL_VISIBLE로, prev_version_lsa를 null로 설정하고 heap_set_mvcc_rec_header_on_overflow로 기록; 아니면 OR_MVCC_FLAG_VALID_INSID | OR_MVCC_FLAG_VALID_PREV_VERSION을 제거하고, or_mvcc_set_headerspage_update를 수행한다. 두 경로 모두 pgbuf_set_dirty로 끝난다. 비대칭성은 Ch 8과 동일하다 — 오버플로 헤더는 고정 크기(값 무력화), 힙 헤더는 축소(플래그 제거).

불변 조건 11-E — undo 이후, 레코드 헤더는 vacuum이 이미 지나간 MVCC 메타데이터를 결코 담지 않는다. 세 가지 힙 undo 경로 모두에서 이 훅이 보장한다. 이것이 빠지면 스냅샷이 이미 회수된 로그 페이지(Ch 9)로 prev_version_lsa를 쫓아가거나, mvcc_satisfies_vacuum이 이미 정리된 레코드를 재분류할 수 있다. (tde.c는 keyinfo 레코드를 의도적으로 이 재작성을 우회하도록 포맷한다 — “HACK” 주석 참고.)

11.6 SA 모드: xvacuum, vacuum_sa_run_job, 그리고 부분 블록

섹션 제목: “11.6 SA 모드: xvacuum, vacuum_sa_run_job, 그리고 부분 블록”

SA 모드에는 데몬이 없다. xvacuum은 전체 생명 주기를 하나의 동기 호출로 압축한다(SERVER_MODE에서는 ER_VACUUM_CS_NOT_AVAILABLE을 반환한다). 그림 11-2:

flowchart TD
    A{"PRM_ID_DISABLE_VACUUM 또는<br/>is_vacuum_complete?"} -->|예| B[NO_ERROR 반환]
    A -->|아니오| C["스레드를 마스터로 변환;<br/>첫/마지막 페이지 적재;<br/>cursor.set_on_vacuum_data_start + load"]
    C --> D{"Block_data_buffer<br/>비어 있지 않음?"}
    D -->|예| E[cursor.force_data_update]
    D -->|아니오| F{cursor.is_valid?}
    E --> F
    F -->|예| G{"logtb_is_interrupted?"}
    G -->|예| H["cursor.unload; vacuum_Data.update;<br/>NO_ERROR 반환"]
    G -->|아니오| I{"entry.is_available?"}
    I -->|예| J["start_job_on_current_entry;<br/>vacuum_sa_run_job(entry, false)"]
    I -->|아니오: vacuumed| K[건너뜀]
    J --> L["increment_blockid"]
    K --> L
    L --> M{"새 블록, finished 큐 가득,<br/>또는 cursor 소진?"}
    M -->|예| N[cursor.force_data_update]
    M -->|아니오| F
    N --> F
    F -->|아니오: 데이터 비어 있음| O{"hdr.does_block_need_vacuum?"}
    O -->|예| P["log_Gl.hdr로 partial_entry 구성;<br/>인터럽트 비활성화;<br/>vacuum_sa_run_job(entry, true)"]
    O -->|아니오| Q["oldest_unvacuumed = mvcc_next_id;<br/>RVVAC_COMPLETE 로깅; 플러시;<br/>dropped files 정리; hdr 캐시 리셋;<br/>vacuum_finalize; is_vacuum_complete = true"]
    P --> Q

그림 11-2: xvacuum, 전체 분기. 인터럽트 종료는 다음 호출을 위해 vacuum 데이터를 일관된 상태로 남긴다.

루프는 동일한 vacuum_job_cursor를 사용하여 인라인으로 재구현된 Ch 6의 마스터 루프다. 다만 잡이 이 스레드에서 즉시 실행된다는 점이 다르다. vacuum_sa_run_job은 이중 변환을 수행한다:

// vacuum_sa_run_job -- src/query/vacuum.c
VACUUM_WORKER *worker_p = vacuum_Worker_entry_manager->claim_worker ();
thread_type save_type = thread_type::TT_NONE;
vacuum_convert_thread_to_worker (thread_p, worker_p, save_type);
assert (save_type == thread_type::TT_VACUUM_MASTER); /* <- caller must be master */
VACUUM_DATA_ENTRY copy_data_entry = data_entry; /* <- worker mutates its copy */
vacuum_process_log_block (thread_p, &copy_data_entry, is_partial);
vacuum_convert_thread_to_master (thread_p, save_type);
// ... condensed: retire_worker, perf tracking ...

is_partial == true 호출은 SA 모드에만 존재한다. cursor가 소진됐지만 log_Gl.hdr.does_block_need_vacuum이 설정된 경우(Ch 3의 아직 열려 있는 블록, §11.4에서 복원됐을 수 있음), xvacuum은 헤더에서 직접 항목을 구성하고(vacuum_data_entry::vacuum_data_entry (const log_header &)) 인터럽트를 끈 채로 실행한다(logtb_set_check_interrupt (thread_p, false) — 헤더 플래그는 이미 제거됐으므로 abort 시 블록을 잃게 된다). vacuum_process_log_block 내부에서 sa_mode_partial_block은 세 가지를 변경한다:

  1. 프리페치 없음vacuum_log_prefetch_vacuum_block 건너뜀: “블록이 완전히 로깅되지 않았으므로 프리페치할 수 없다”.
  2. 인터럽트 의미론 강제was_interrupted = data->was_interrupted () || sa_mode_partial_block;; 인터럽트는 보통 blockid에 플래그로 표시되지만, 부분 블록에는 플래그가 없다.
  3. 완료 부기 없음end:에서 vacuum_finished_block_vacuum(Ch 9)은 if (!sa_mode_partial_block)인 경우에만 실행된다. 부분 블록은 vacuum 데이터에 없었으므로 표시할 것이 없다.

부분 패스 이후 xvacuum은 완전한 종료를 선언한다: vacuum_Data.oldest_unvacuumed_mvccid = log_Gl.hdr.mvcc_next_id를 설정하고, 첫 번째 vacuum data 페이지에 RVVAC_COMPLETE를 로깅한다(mvcc_next_id를 redo 데이터로 포함). 그리고 강제 플러시한다. 이 레코드가 정확히 §11.4의 역방향 탐색 종단점이다 — 이 LSA 이전에 vacuum되지 않은 MVCC op가 없음을 인증한다. vacuum_cleanup_dropped_files(Ch 10), logpb_vacuum_reset_log_header_cache, vacuum_finalize, 그리고 is_vacuum_complete = true(두 번째 xvacuum 호출을 no-op으로 만든다)로 패스를 마친다.

11.7 SA 부기: vacuum_sa_reflect_last_blockidvacuum_reset_data_after_copydb

섹션 제목: “11.7 SA 부기: vacuum_sa_reflect_last_blockid와 vacuum_reset_data_after_copydb”

긴 SA 세션은 블록을 등록하지 않고 로그를 소비한다. 종료 시(xboot_shutdown_server, boot_sr.c의 SA 전용 블록), vacuum_sa_reflect_last_blockid는 워터마크를 영속화하여 다음 부팅의 빈 데이터 분기(§11.3)가 이를 퇴행시키지 않도록 한다. 조기 반환 조건: VPID_ISNULL (&vacuum_Data_load.vpid_first) — 새 부팅이거나 실패한 부팅; vacuum_Data.is_restoredb_session — “restoredb는 vacuum하지 않으므로 여기서 처리할 수 없다”(vacuum_initializeis_restore 매개변수로 설정되는 플래그); logpb_last_complete_blockid () == VACUUM_NULL_LOG_BLOCKID — unload 후 반환. 그 외:

// vacuum_sa_reflect_last_blockid -- src/query/vacuum.c
vacuum_Data.set_last_blockid (last_blockid);
log_Gl.hdr.vacuum_last_blockid = last_blockid; /* <- the MAX() source in section 11.3 */
vacuum_data_empty_update_last_blockid (thread_p); /* <- persists into the empty first page */

vacuum_data_empty_update_last_blockidvacuum_is_empty()를 assert하고(단일 페이지, index_unvacuumed == index_free, 둘 다 0), vacuum_init_data_page_with_last_blockid로 페이지를 재작성한다. 이 함수는 RVVAC_DATA_INIT_NEW_PAGE redo 데이터를 로깅하므로(§11.2) 영속화된 워터마크 자체도 크래시에서 살아남는다.

vacuum_reset_data_after_copydb는 또 다른 신원 불연속성을 처리한다. 복사된 데이터베이스에는 원본 로그를 참조하는 blockid를 가진 vacuum 데이터가 포함된다. copydb 이후 첫 번째 부팅 시(boot_after_copydb, log_Gl.hdr.was_copied를 조건으로)이 함수는 첫 번째 페이지를 fix하고, 비어 있음을 assert하며(VPID_ISNULL (next_page), index_free == 0 — copydb는 완전히 vacuum된 소스를 요구한다), vacuum_init_data_page_with_last_blockid (..., VACUUM_NULL_LOG_BLOCKID)로 재초기화한다. 이 null 워터마크는 이후 vacuum_data_load_and_recover에서 log_blockid < 0인 “copydb 직후” 분기를 트리거한다.

11.8 디버그 검증기 — 이 문서의 불변 조건 목록

섹션 제목: “11.8 디버그 검증기 — 이 문서의 불변 조건 목록”

vacuum_verify_vacuum_data_debug(!NDEBUG 조건, vacuum_data_mark_finished 끝과 vacuum_consume_buffer_log_blocks 끝의 VACUUM_VERIFY_VACUUM_DATA 매크로에서 호출됨)는 모든 페이지를 순회하며, 이 문서가 제시한 모든 구조적 주장을 사실상 assert한다:

Assert (요약)불변 조건 재술챕터
(first_page == last_page) == VPID_ISNULL (first_page->next_page)페이지 체인에 끊어진 꼬리 없음Ch 1
0 <= index_unvacuumed <= index_free <= page_data_max_count페이지 단위 커서 건전성Ch 1
is_vacuumed () ==> i != index_unvacuumedindex_unvacuumed는 항상 살아 있는 항목을 가리킴Ch 9
entry->oldest_visible_mvccid <= get_global_oldest_visible ()어떤 블록도 라이브 워터마크보다 높은 oldest를 주장하지 않음Ch 5
oldest_unvacuumed_mvccid <= entry->oldest_visible_mvccid부팅/업데이트 워터마크가 모든 항목의 하한Ch 5, §11.3
entry->get_blockid () <= get_last_blockid ()m_last_blockid가 등록된 블록의 상한Ch 4
vacuum_get_log_blockid (start_lsa.pageid) == get_blockid ()start_lsa는 자신의 블록 안에 위치Ch 3
비-vacuum 항목 전체에 걸쳐 oldest_visible_mvccid 오름차순단조 oldest 체인 (§11.4가 절대 리셋하지 않는 이유)Ch 5
entry->get_blockid () == (entry - 1)->get_blockid () + 1밀집, 빈 틈 없는 blockid 배열Ch 1, 11-D
in_progress_distance > 500 → 경고만잡 누수 휴리스틱, “vacuum이 뒤처지거나 막힘”Ch 6

마지막 행은 한때 assertion이었으나 vacuum_er_log_warning으로 강등됐다(“assertion이었지만 vacuum이 막히는 케이스를 본 적 없다”) — 한참 뒤처진 IN_PROGRESS 항목은 손상이 아니라 누수된 잡을 나타낸다.

동반 함수 vacuum_verify_vacuum_data_page_fix_count는 다섯 개의 정지점(quiescent point)에서 Ch 2의 fix 규율을 확인한다(vacuum_data_load_and_recover 끝, 마스터 태스크 루프 끝, xvacuum 중간, 위의 각 VACUUM_VERIFY_VACUUM_DATA 지점 직후): 첫 번째와 마지막 페이지가 각각 정확히 fix count 1을 가져야 하고, pgbuf_get_hold_count는 단일 페이지 데이터이면 정확히 1, 아니면 2 — 다른 값이면 Ch 4/6/9의 페이지 핸드오프에서 fix 누수가 있음을 의미한다.

마지막으로 xvacuum_dump(유틸리티 진입점)는 관찰 가능성의 짝이다: vacuum_min_log_pageid_to_keep(Ch 9의 회수 하한선)을 출력하고, 해당 페이지가 활성 로그에 있는지 또는 어느 아카이브에 있는지를 확인한다(logpb_is_page_in_archive / logpb_get_archive_number). 비정상 분기는 실패하지 않는다 — vacuum_Is_booted가 false이거나 NULL_PAGEID 하한선이면 “vacuum이 제대로 부팅되지 않았음”을 출력하고, 음수 아카이브 번호는 빈 줄을 출력한다 — 손상됐을 수 있는 서버를 대상으로 실행되기 때문이다.

  1. Vacuum의 ARIES 크래시 복구 계약은 LSA 하나다: vacuum_notify_server_crashed가 분석 전에 복구 시작점을 기록하고, vacuum_data_load_and_recover가 부팅 시 이를 소비하고 제거한다(불변 조건 11-A).
  2. RV_fun의 14개 RVVAC_* 인덱스(104-117)가 일반 redo 패스 중에 모든 페이지 수준 vacuum 변경을 재실행한다. vacuum data 페이지를 대상으로 하는 행들은 redo 전용이다 — vacuum 데이터는 절대 롤백되지 않고, 부팅 시 앞 방향으로 조정된다(불변 조건 11-B). 디스패치 자체는 복구 관리자의 역할이다(cubrid-recovery-manager-detail.md).
  3. IN_PROGRESS는 휘발성이다: 부팅 페이지 순회가 이를 덮어쓰고(RVVAC_START_JOB redo로 재생성된 상태 포함) AVAILABLE + INTERRUPTED로 바꾼다. 이는 재실행 시 반쯤 vacuum된 페이지를 허용하도록 지시한다(불변 조건 11-C).
  4. vacuum_recover_lost_block_data는 WAL에만 존재하던 블록을 재건한다: 체인 끝을 향한 역방향 탐색(RVVAC_COMPLETE 또는 등록된 데이터로 종료), 블록 단위 체인 순회, 오름차순 blockid를 복원하는 std::stack(불변 조건 11-D), 그리고 아직 열려 있는 블록에 대한 등록이 아닌 헤더 캐시 복원.
  5. vacuum_rv_check_at_undo는 롤백 내부의 유일한 vacuum 로직이다: 복원된 레코드의 insid/prev-version 메타데이터를 제거하며 — 크래시 복구 시 무조건, 런타임 시 워터마크 기반으로 — undo가 vacuum이 이미 지나간 헤더를 부활시키지 않도록 한다(불변 조건 11-E).
  6. SA 모드는 전체 생명 주기를 xvacuum에서 동기적으로 재실행한다(vacuum_sa_run_job에서 잡 단위 워커 변환; 프리페치를 건너뛰고 인터럽트 의미론을 강제하며 완료 부기를 생략하는 인터럽트 불가 부분 블록 패스; RVVAC_COMPLETE가 로그 완전 vacuum을 인증). vacuum_sa_reflect_last_blockidvacuum_reset_data_after_copydb는 종료와 copydb에 걸쳐 워터마크를 보호한다. §11.8의 검증기들은 이 모든 것에 대한 실행 가능한 회귀 체크리스트다.

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

SymbolFileLine
resource_shared_poolsrc/base/resource_shared_pool.hpp29
VACUUM_FIRST_LOG_PAGEID_IN_BLOCKsrc/query/vacuum.c81
VACUUM_LAST_LOG_PAGEID_IN_BLOCKsrc/query/vacuum.c84
vacuum_data_entrysrc/query/vacuum.c104
VACUUM_DATA_ENTRY_FLAG_MASKsrc/query/vacuum.c135
VACUUM_DATA_ENTRY_BLOCKID_MASKsrc/query/vacuum.c137
VACUUM_BLOCK_STATUS_MASKsrc/query/vacuum.c141
VACUUM_BLOCK_FLAG_INTERRUPTEDsrc/query/vacuum.c146
VACUUM_BLOCKID_WITHOUT_FLAGSsrc/query/vacuum.c150
vacuum_data_pagesrc/query/vacuum.c194
VACUUM_DATA_PAGE_HEADER_SIZEsrc/query/vacuum.c212
vacuum_fix_data_pagesrc/query/vacuum.c223
vacuum_unfix_data_pagesrc/query/vacuum.c236
vacuum_unfix_first_and_last_data_pagesrc/query/vacuum.c255
vacuum_job_cursorsrc/query/vacuum.c277
vacuum_shutdown_sequencesrc/query/vacuum.c319
vacuum_datasrc/query/vacuum.c350
oldest_unvacuumed_mvccidsrc/query/vacuum.c356
vacuum_set_dirty_data_pagesrc/query/vacuum.c423
vacuum_data_loadsrc/query/vacuum.c442
vacuum_Data_loadsrc/query/vacuum.c447
vacuum_Mastersrc/query/vacuum.c456
vacuum_Block_data_buffersrc/query/vacuum.c467
VACUUM_BLOCK_DATA_BUFFER_CAPACITYsrc/query/vacuum.c469
vacuum_Finished_job_queuesrc/query/vacuum.c475
VACUUM_PREFETCH_LOG_BLOCK_BUFFER_PAGESsrc/query/vacuum.c479
VACUUM_MAX_TASKS_IN_WORKER_POOLsrc/query/vacuum.c482
VACUUM_FINISHED_JOB_QUEUE_CAPACITYsrc/query/vacuum.c485
VACUUM_WORKER_INDEX_TO_TRANIDsrc/query/vacuum.c490
vacuum_Workerssrc/query/vacuum.c498
vacuum_heap_helpersrc/query/vacuum.c504
VACUUM_DEFAULT_HEAP_OBJECT_BUFFER_SIZEsrc/query/vacuum.c561
vacuum_Dropped_files_loadedsrc/query/vacuum.c567
vacuum_Dropped_files_countsrc/query/vacuum.c576
vacuum_dropped_filesrc/query/vacuum.c580
vacuum_dropped_files_pagesrc/query/vacuum.c588
VACUUM_DROPPED_FILES_PAGE_CAPACITYsrc/query/vacuum.c602
VACUUM_DROPPED_FILE_FLAG_DUPLICATEsrc/query/vacuum.c610
vacuum_track_dropped_filessrc/query/vacuum.c640
vacuum_Track_dropped_filessrc/query/vacuum.c645
vacuum_Dropped_files_versionsrc/query/vacuum.c650
vacuum_Last_dropped_vfidsrc/query/vacuum.c652
vacuum_dropped_files_rcv_datasrc/query/vacuum.c655
vacuum_Is_bootedsrc/query/vacuum.c661
vacuum_init_thread_contextsrc/query/vacuum.c766
vacuum_master_entry_managersrc/query/vacuum.c783
vacuum_master_tasksrc/query/vacuum.c813
m_oldest_visible_mvccidsrc/query/vacuum.c834
vacuum_worker_entry_managersrc/query/vacuum.c843
vacuum_worker_tasksrc/query/vacuum.c916
vacuum_sa_run_jobsrc/query/vacuum.c949
xvacuumsrc/query/vacuum.c979
xvacuum_dumpsrc/query/vacuum.c1121
vacuum_initializesrc/query/vacuum.c1180
vacuum_bootsrc/query/vacuum.c1291
vacuum_stop_workerssrc/query/vacuum.c1363
vacuum_stop_mastersrc/query/vacuum.c1390
vacuum_finalizesrc/query/vacuum.c1416
vacuum_heapsrc/query/vacuum.c1494
vacuum_heap_pagesrc/query/vacuum.c1577
vacuum_heap_prepare_recordsrc/query/vacuum.c1925
vacuum_heap_record_insid_and_prev_versionsrc/query/vacuum.c2195
vacuum_heap_recordsrc/query/vacuum.c2361
vacuum_heap_get_hfid_and_file_typesrc/query/vacuum.c2513
vacuum_heap_page_log_and_resetsrc/query/vacuum.c2587
vacuum_log_vacuum_heap_pagesrc/query/vacuum.c2651
vacuum_rv_redo_vacuum_heap_pagesrc/query/vacuum.c2720
vacuum_log_remove_ovf_insidsrc/query/vacuum.c2856
vacuum_rv_redo_remove_ovf_insidsrc/query/vacuum.c2869
vacuum_produce_log_block_datasrc/query/vacuum.c2905
vacuum_data_load_first_and_last_pagesrc/query/vacuum.c2948
vacuum_data_unload_first_and_last_pagesrc/query/vacuum.c2979
vacuum_master_task::executesrc/query/vacuum.c3002
vacuum_master_task::check_shutdownsrc/query/vacuum.c3077
vacuum_master_task::is_task_queue_fullsrc/query/vacuum.c3089
vacuum_master_task::should_interrupt_iterationsrc/query/vacuum.c3100
vacuum_master_task::is_cursor_entry_ready_to_vacuumsrc/query/vacuum.c3106
vacuum_master_task::is_cursor_entry_availablesrc/query/vacuum.c3136
vacuum_master_task::start_job_on_cursor_entrysrc/query/vacuum.c3155
vacuum_master_task::should_force_data_updatesrc/query/vacuum.c3165
vacuum_master_task::decrease_outstanding_jobsrc/query/vacuum.c3188
vacuum_rv_redo_vacuum_completesrc/query/vacuum.c3221
vacuum_process_log_blocksrc/query/vacuum.c3251
vacuum_worker_allocate_resourcessrc/query/vacuum.c3620
vacuum_finalize_workersrc/query/vacuum.c3689
vacuum_finished_block_vacuumsrc/query/vacuum.c3724
vacuum_read_log_alignedsrc/query/vacuum.c3797
vacuum_read_log_add_alignedsrc/query/vacuum.c3823
vacuum_read_advance_when_doesnt_fitsrc/query/vacuum.c3838
vacuum_copy_data_from_logsrc/query/vacuum.c3859
vacuum_process_log_recordsrc/query/vacuum.c3906
vacuum_get_worker_min_dropped_files_versionsrc/query/vacuum.c4135
vacuum_compare_blockidssrc/query/vacuum.c4166
vacuum_data_load_and_recoversrc/query/vacuum.c4183
vacuum_load_dropped_files_from_disksrc/query/vacuum.c4349
vacuum_create_file_for_vacuum_datasrc/query/vacuum.c4445
vacuum_data_initialize_new_pagesrc/query/vacuum.c4498
vacuum_rv_redo_initialize_data_pagesrc/query/vacuum.c4520
vacuum_create_file_for_dropped_filessrc/query/vacuum.c4544
vacuum_is_work_in_progresssrc/query/vacuum.c4594
vacuum_data_mark_finishedsrc/query/vacuum.c4621
vacuum_data_empty_pagesrc/query/vacuum.c4832
vacuum_rv_redo_data_finishedsrc/query/vacuum.c4986
vacuum_rv_redo_data_finished_dumpsrc/query/vacuum.c5055
vacuum_consume_buffer_log_blockssrc/query/vacuum.c5096
vacuum_rv_undoredo_data_set_linksrc/query/vacuum.c5361
vacuum_rv_redo_append_datasrc/query/vacuum.c5411
vacuum_recover_lost_block_datasrc/query/vacuum.c5465
vacuum_get_log_blockidsrc/query/vacuum.c5702
vacuum_min_log_pageid_to_keepsrc/query/vacuum.c5722
vacuum_is_safe_to_remove_archivessrc/query/vacuum.c5747
vacuum_rv_redo_start_jobsrc/query/vacuum.c5760
vacuum_update_keep_from_log_pageidsrc/query/vacuum.c5782
vacuum_compare_dropped_filessrc/query/vacuum.c5820
vacuum_add_dropped_filesrc/query/vacuum.c5846
vacuum_log_add_dropped_filesrc/query/vacuum.c6121
vacuum_rv_redo_add_dropped_filesrc/query/vacuum.c6167
vacuum_rv_undo_add_dropped_filesrc/query/vacuum.c6235
vacuum_rv_replace_dropped_filesrc/query/vacuum.c6269
vacuum_notify_all_workers_dropped_filesrc/query/vacuum.c6335
vacuum_rv_notify_dropped_filesrc/query/vacuum.c6391
vacuum_cleanup_dropped_filessrc/query/vacuum.c6438
vacuum_is_file_droppedsrc/query/vacuum.c6587
vacuum_find_dropped_filesrc/query/vacuum.c6609
vacuum_log_cleanup_dropped_filessrc/query/vacuum.c6719
vacuum_rv_redo_cleanup_dropped_filessrc/query/vacuum.c6754
vacuum_dropped_files_set_next_pagesrc/query/vacuum.c6809
vacuum_rv_set_next_page_dropped_filessrc/query/vacuum.c6834
vacuum_compare_heap_objectsrc/query/vacuum.c6862
vacuum_collect_heap_objectssrc/query/vacuum.c6912
vacuum_cleanup_collected_by_vfidsrc/query/vacuum.c6955
vacuum_compare_dropped_files_versionsrc/query/vacuum.c6999
vacuum_verify_vacuum_data_debugsrc/query/vacuum.c7060
vacuum_log_prefetch_vacuum_blocksrc/query/vacuum.c7165
vacuum_fetch_log_pagesrc/query/vacuum.c7215
is_not_vacuumed_and_lostsrc/query/vacuum.c7379
vacuum_get_first_page_dropped_filessrc/query/vacuum.c7449
vacuum_is_mvccid_vacuumedsrc/query/vacuum.c7463
vacuum_log_redoundo_vacuum_recordsrc/query/vacuum.c7486
vacuum_rv_undo_vacuum_heap_recordsrc/query/vacuum.c7524
vacuum_rv_redo_vacuum_heap_recordsrc/query/vacuum.c7539
vacuum_notify_server_crashedsrc/query/vacuum.c7570
vacuum_notify_server_shutdownsrc/query/vacuum.c7582
vacuum_verify_vacuum_data_page_fix_countsrc/query/vacuum.c7595
vacuum_rv_check_at_undosrc/query/vacuum.c7627
vacuum_is_emptysrc/query/vacuum.c7731
vacuum_sa_reflect_last_blockidsrc/query/vacuum.c7749
vacuum_data_empty_update_last_blockidsrc/query/vacuum.c7783
vacuum_convert_thread_to_mastersrc/query/vacuum.c7807
vacuum_convert_thread_to_workersrc/query/vacuum.c7830
vacuum_restore_threadsrc/query/vacuum.c7856
vacuum_rv_es_nopsrc/query/vacuum.c7876
vacuum_notify_es_deletedsrc/query/vacuum.c7895
vacuum_check_shutdown_interruptionsrc/query/vacuum.c7936
vacuum_reset_data_after_copydbsrc/query/vacuum.c7951
vacuum_init_data_page_with_last_blockidsrc/query/vacuum.c7989
vacuum_data::get_last_blockidsrc/query/vacuum.c8019
vacuum_data::get_first_blockidsrc/query/vacuum.c8025
vacuum_data::set_last_blockidsrc/query/vacuum.c8042
vacuum_data::updatesrc/query/vacuum.c8058
vacuum_data::set_oldest_unvacuumed_on_bootsrc/query/vacuum.c8089
vacuum_data::upgrade_oldest_unvacuumedsrc/query/vacuum.c8110
vacuum_data_entry::vacuum_data_entrysrc/query/vacuum.c8119
vacuum_data_entry::was_interruptedsrc/query/vacuum.c8162
vacuum_data_entry::set_vacuumedsrc/query/vacuum.c8168
vacuum_data_entry::set_job_in_progresssrc/query/vacuum.c8175
vacuum_data_entry::set_interruptedsrc/query/vacuum.c8181
vacuum_data_page::get_index_of_blockidsrc/query/vacuum.c8203
vacuum_data_page::get_first_blockidsrc/query/vacuum.c8226
vacuum_job_cursor::start_job_on_current_entrysrc/query/vacuum.c8295
vacuum_job_cursor::force_data_updatesrc/query/vacuum.c8314
vacuum_job_cursor::change_blockidsrc/query/vacuum.c8327
vacuum_job_cursor::readjust_to_vacuum_data_changessrc/query/vacuum.c8363
vacuum_job_cursor::searchsrc/query/vacuum.c8424
vacuum_shutdown_sequence::request_shutdownsrc/query/vacuum.c8465
vacuum_shutdown_sequence::check_shutdown_requestsrc/query/vacuum.c8498
VACUUM_LOG_ADD_DROPPED_FILE_POSTPONEsrc/query/vacuum.h78
VACUUM_LOG_BLOCK_PAGES_DEFAULTsrc/query/vacuum.h82
vacuum_worker_statesrc/query/vacuum.h85
vacuum_heap_objectsrc/query/vacuum.h98
vacuum_workersrc/query/vacuum.h106
VACUUM_WORKERsrc/query/vacuum.h106
drop_files_versionsrc/query/vacuum.h109
VACUUM_MAX_WORKER_COUNTsrc/query/vacuum.h132
btree_prepare_btssrc/storage/btree.c15753
btree_rv_read_keybuf_nocopysrc/storage/btree.c18391
btree_rv_read_keybuf_two_objectssrc/storage/btree.c18455
btree_vacuum_insert_mvccidsrc/storage/btree.c30304
btree_vacuum_objectsrc/storage/btree.c30336
HEAP_RV_FLAG_VACUUM_STATUS_CHANGEsrc/storage/heap_file.c514
xheap_reclaim_addressessrc/storage/heap_file.c6227
heap_rv_undo_deletesrc/storage/heap_file.c16946
heap_rv_undo_updatesrc/storage/heap_file.c16981
heap_page_update_chain_after_mvcc_opsrc/storage/heap_file.c24785
heap_rv_remove_flags_from_offsetsrc/storage/heap_file.c25085
heap_rv_undo_ovf_updatesrc/storage/heap_file.c26059
spage_vacuum_slotsrc/storage/slotted_page.c4857
MVCCID_ALL_VISIBLEsrc/storage/storage_common.h329
entry::claim_system_workersrc/thread/thread_entry.cpp425
entry::retire_system_workersrc/thread/thread_entry.cpp433
xboot_shutdown_serversrc/transaction/boot_sr.c3044
xboot_emergency_patchsrc/transaction/boot_sr.c5292
boot_after_copydbsrc/transaction/boot_sr.c6154
xlocator_upgrade_instances_domainsrc/transaction/locator_sr.c12126
redistribute_partition_datasrc/transaction/locator_sr.c12692
prior_update_header_mvcc_infosrc/transaction/log_append.cpp1320
prior_lsa_next_record_internalsrc/transaction/log_append.cpp1357
prior_lsa_next_recordsrc/transaction/log_append.cpp1553
prior_lsa_next_record_with_locksrc/transaction/log_append.cpp1559
prior_lsa_start_appendsrc/transaction/log_append.cpp1593
prior_lsa_end_appendsrc/transaction/log_append.cpp1652
log_prior_nodesrc/transaction/log_append.hpp91
VACUUM_NULL_LOG_BLOCKIDsrc/transaction/log_common_impl.h54
LOG_SYSTEM_WORKER_FIRST_TRANIDsrc/transaction/log_impl.h185
LOG_ISRESTARTEDsrc/transaction/log_impl.h193
block_global_oldest_active_until_commitsrc/transaction/log_impl.h555
LOG_RESTARTEDsrc/transaction/log_impl.h627
log_completesrc/transaction/log_manager.c5653
log_complete_for_2pcsrc/transaction/log_manager.c5758
log_remove_log_archive_daemon_task::executesrc/transaction/log_manager.c10243
logpb_remove_archive_logs_exceed_limitsrc/transaction/log_page_buffer.c5991
logpb_remove_archive_logssrc/transaction/log_page_buffer.c6213
logpb_backupsrc/transaction/log_page_buffer.c7593
log_vacuum_infosrc/transaction/log_record.hpp192
log_rec_mvcc_undoredosrc/transaction/log_record.hpp202
log_rec_mvcc_undosrc/transaction/log_record.hpp211
log_rec_sysop_endsrc/transaction/log_record.hpp305
log_recoverysrc/transaction/log_recovery.c736
VACUUM_LOG_BLOCKIDsrc/transaction/log_storage.hpp91
log_header::vacuum_last_blockidsrc/transaction/log_storage.hpp153
log_header::mvcc_op_log_lsasrc/transaction/log_storage.hpp166
log_header::oldest_visible_mvccidsrc/transaction/log_storage.hpp167
log_header::newest_block_mvccidsrc/transaction/log_storage.hpp168
log_header::does_block_need_vacuumsrc/transaction/log_storage.hpp173
systdes_claim_tdessrc/transaction/log_system_tran.cpp78
log_system_tdes::log_system_tdessrc/transaction/log_system_tran.cpp104
log_tdes::lock_global_oldest_visible_mvccidsrc/transaction/log_tran_table.c6220
MVCC_IS_REC_INSERTED_SINCE_MVCCIDsrc/transaction/mvcc.c58
MVCC_IS_REC_DELETED_SINCE_MVCCIDsrc/transaction/mvcc.c61
mvcc_satisfies_vacuumsrc/transaction/mvcc.c321
MVCC_IS_HEADER_DELID_VALIDsrc/transaction/mvcc.h87
MVCC_IS_HEADER_INSID_NOT_ALL_VISIBLEsrc/transaction/mvcc.h91
mvcc_satisfies_vacuum_resultsrc/transaction/mvcc.h232
LOG_IS_MVCC_HEAP_OPERATIONsrc/transaction/mvcc.h245
LOG_IS_MVCC_BTREE_OPERATIONsrc/transaction/mvcc.h254
Oldest_active_trackersrc/transaction/mvcc_table.cpp77
mvcctable::advance_oldest_activesrc/transaction/mvcc_table.cpp142
mvcctable::build_mvcc_infosrc/transaction/mvcc_table.cpp226
mvcctable::compute_oldest_visible_mvccidsrc/transaction/mvcc_table.cpp355
mvcctable::complete_mvccsrc/transaction/mvcc_table.cpp465
mvcctable::reset_transaction_lowest_activesrc/transaction/mvcc_table.cpp593
mvcctable::get_global_oldest_visiblesrc/transaction/mvcc_table.cpp611
mvcctable::update_global_oldest_visiblesrc/transaction/mvcc_table.cpp617
mvcctable::lock_global_oldest_visiblesrc/transaction/mvcc_table.cpp632
mvcctable::unlock_global_oldest_visiblesrc/transaction/mvcc_table.cpp638
mvcctablesrc/transaction/mvcc_table.hpp64
m_oldest_visiblesrc/transaction/mvcc_table.hpp118
m_ov_lock_countsrc/transaction/mvcc_table.hpp119
RV_fun (RVVAC_* rows)src/transaction/recovery.c687
RVVAC_* enum valuessrc/transaction/recovery.h156
RVVAC_START_JOBsrc/transaction/recovery.h157
  • cubrid-vacuum.md — 상위 수준 동반 문서. 가시성 워터마크 관련 내용은 cubrid-mvcc-detail.md도 참고.
  • 원시 분석 자료: raw/code-analysis/cubrid/storage/vacuum/.
  • 코드: src/query/vacuum.{c,h}; 워터마크 조율 로직은 src/transaction/mvcc_table.cpp.
  • 방법론: knowledge/methodology/code-analysis-detail-doc.md.