이 문서의 위치: 상위 분석 문서인
cubrid-double-write-buffer.md는 설계 의도와 이론적 배경을 다룬다.
이 문서는 모든 분기와 필드를 코드 수준에서 추적한다. 각 챕터는
독립적으로 읽을 수 있으나, 순서대로 읽으면 단일 페이지가 DWB를 통과하는
전체 생명주기 — 슬롯 예약, 블록 채움, 이중 쓰기, 그리고 크래시 복구 —
를 순서대로 따라갈 수 있다.
이 챕터는 DWB를 구성하는 인메모리·온디스크 구조가 무엇이며, 각 필드와 포인터가 어떻게 연결되는지를 답한다. 이후 챕터들은 모두 여기서 정의하는 일곱 개의 구조체와 하나의 싱글턴을 전제로 한다. Torn-page 이론은 고수준 동반 문서 cubrid-double-write-buffer.md의 “Theoretical Background”, “Torn-page protection” 절에 있다. 모든 정의는 src/storage/double_write_buffer.cpp에 있으며, double_write_slot만 예외로 .hpp에 존재한다(caller가 직접 이름을 쓰는 유일한 타입). 인스턴스는 파일 스코프의 static이다.
DWB는 고정 크기의 블록 배열이다. 각 블록은 하나의 연속된 write_buffer(유일한 실제 페이지 메모리)를 소유하며, 두 개의 병렬 뷰를 가진다: slots(페이지 하나당 DWB_SLOT 하나)와 flush_volumes_info(서로 다른 목적지 볼륨 하나당 하나). 싱글턴 dwb_Global은 블록 배열, 락-프리 VPID 해시맵, 그리고 채우기 커서와 블록별 상태를 인코딩하는 64비트 position_with_flags 워드를 소유한다(Chapter 2).
FILEIO_PAGE *io_page; /* <- NOT owned; points into block->write_buffer */
VPID vpid;
LOG_LSA lsa;
bool ensure_metadata;
unsignedint position_in_block;
unsignedint block_no;
};
필드
역할
존재 이유
io_page
블록의 write_buffer에 있는 스테이징된 페이지 이미지. 빌려온 것으로, 절대 해제하지 않는다.
플러시가 이중 복사 없이 페이지 바이트를 직접 쓴다.
vpid
현재 보유 중인 (volid, pageid) 식별자.
해시 키 + 홈 주소; io_page->prv에서 읽어 채울 때마다 덮어쓴다.
lsa
스테이징된 페이지의 Log Sequence Address.
같은 VPID의 두 복사본이 있을 때 “최신 우선” 판단 기준.
ensure_metadata
true이면 볼륨 sync 시 메타데이터도 함께 플러시.
볼륨 헤더는 목적지 메타데이터가 내구적이어야 한다.
position_in_block
슬롯 인덱스 (0 .. num_block_pages-1). 고정값.
write_buffer 내 오프셋 고정 (position_in_block * IO_PAGESIZE).
block_no
dwb_Global.blocks에서 소유 블록의 인덱스. 고정값.
맨 슬롯 포인터만으로 자기 블록을 찾을 수 있게 한다.
INVARIANT (slot↔buffer aliasing). 모든 블록과 슬롯 인덱스 i에서 block->slots[i].io_page == (FILEIO_PAGE *)(block->write_buffer + i * IO_PAGESIZE)가 성립한다. dwb_create_blocks에서 확립되며, 위반 시 write_buffer의 단일 블록 쓰기에서 해당 페이지가 누락되어 DWB 볼륨에서 조용히 드롭된다:
같은 슬롯의 다른 생애(incarnation)를 구별; DWB_GET_BLOCK_VERSION으로 접근.
all_pages_written
모든 페이지가 홈 볼륨에 도달하면 true. volatile.
드라이버에게 블록이 소진(drain)되어 재사용 가능함을 알린다.
INVARIANT (버전 내 단조 증가). 하나의 version 안에서 count_wb_pages는 0에서 num_block_pages로만 올라간다 (재활용 시 version 증가와 함께 리셋). 생산자가 커서를 원자적으로 전진시키므로(Ch 4), 증가 없이 역방향 이동이 발생하면 두 생산자가 같은 슬롯을 받게 된다.
flowchart LR
BLK["double_write_block"] --> WB["write_buffer<br/>num_block_pages * IO_PAGESIZE"]
BLK --> SLOTS["slots[] — write_buffer의 VIEW"]
BLK --> FVI["flush_volumes_info[] — 홈 볼륨별 하나"]
Figure 1-2. 블록의 세 병렬 배열; slots만 write_buffer를 앨리어싱한다.
INVARIANT (볼륨별 블록당 fsync 정확히 한 번). 플러시 스레드와 파일 sync 헬퍼가 ATOMIC_CAS_32 (&...flushed_status, VOLUME_NOT_FLUSHED, ...)로 경쟁한다. 승자가 fsync하고, 패자는 건너뛴다. CAS 자체가 내구성·핸드오프 신호 역할을 겸한다(Ch 6–7).
using dwb_hashmap_type = cubthread::lockfree_hashmap<VPID, dwb_slots_hash_entry>;
INVARIANT (해시 항목은 가장 최신 스테이징 복사본을 추적). VPID당 최대 하나의 살아있는 항목만 존재하며, 그 slot은 가장 최신 스테이징 이미지(가장 높은 lsa)를 가리킨다. 재추가 시 항목을 재지향하므로 dwb_read_page는 절대 오래된 이미지를 반환하지 않는다.
64비트: 하위 = 채우기 커서, 상위 = 블록별 플래그 + 생성/변경 상태. volatile.
생산자들이 CAS로 슬롯을 확보하고 생명주기 플래그를 설정 (Ch 2).
slots_hashmap
VPID -> dwb_slots_hash_entry 맵.
dwb_read_page에 응답하고 재스테이징 페이지를 중복 제거 (1.5).
vdes
DWB 볼륨의 파일 디스크립터.
블록 쓰기의 목적지; 없으면 NULL_VOLDES.
file_sync_helper_block
헬퍼에게 fsync를 요청받은 블록. volatile.
헬퍼로의 핸드오프; 소비되면 ATOMIC_TAS_ADDR로 NULL로 교체.
INVARIANT (싱글턴 일관성). 생성자가 모든 것을 zero/NULL로 초기화하므로(vdes (NULL_VOLDES), position_with_flags (0)), DWB를 사용하지 않는 프로세스도 유효한 dwb_Global을 가진다. 생성 여부는 blocks가 아니라 position_with_flags로 판단한다(dwb_is_created). 생성된 버퍼라면 num_pages == num_blocks * num_block_pages이고 num_block_pages == 1 << log2_num_block_pages이며, 모두 2의 거듭제곱이다 — 생성 시 단 한 번 설정되는 이 관계가 성립해야만 Chapter 2의 시프트/마스크 디코딩이 올바르게 작동한다.
실제 버퍼는 하나, 뷰는 둘. 블록은 write_buffer 하나만 소유하며, slots[]와 flush_volumes_info[]는 페이지를 복사하지 않고 그 위에 걸친 병렬 배열이다.
앨리어싱 불변식은 핵심 구조를 떠받친다.slots[i].io_page == write_buffer + i*IO_PAGESIZE는 dwb_create_blocks/dwb_initialize_slot에서 position_in_block을 키로 하여 고정된다. 이 불변식이 깨지면 설계 전체가 무너진다.
DWB_SLOT은 빌려온 핸들이다.io_page는 슬롯 경로로 절대 해제되지 않으며, vpid/lsa/ensure_metadata는 재사용 시 덮어쓰고, position_in_block/block_no는 초기화 후 고정된다.
볼륨별 내구성은 3-상태 경쟁이다.flushed_status에 대한 CAS로 {플러시 스레드, 파일 sync 헬퍼} 중 정확히 하나만 각 홈 볼륨을 fsync한다.
해시맵은 최신성 인덱스이지 복사본이 아니다.slot은 가장 최신 스테이징 이미지를 가리키며, stack/next/del_id/mutex는 오프셋 디스크립터와 회수만을 위해 존재한다.
position_with_flags가 락-프리 프로토콜을 한 워드에 담는다. 생산자들과 플러시 드라이버가 이 단일 원자 값으로 조율한다; Chapter 2에서 디코딩한다.
dwb_Global은 항상 존재한다. 생성자가 일관된 상태로 남겨두며, 존재 여부는 position_with_flags로 읽고, 2의 거듭제곱 관계는 생성된 버퍼라면 항상 성립한다.
Double write buffer의 동시성 계약 전체가 단 하나의 64비트 필드인
dwb_Global.position_with_flags에 집약되어 있다. 슬롯 할당, 블록별 “쓰기
시작” 추적, 생성·구조 변경 라이프사이클이 모두 이 워드를 공유하며, 변경은
오직 ATOMIC_CAS_64로만, 읽기는 ATOMIC_INC_64 (&..., 0ULL)(원자적
0 덧셈을 torn-read-free 로드로 활용)로만 이루어진다. 이 챕터는 비트
레이아웃, 모든 접근자·변경자 매크로, 그리고 이 워드를 전진시키는 두 CAS
재시도 루프를 상세히 살펴본다. 왜 별도의 카운터와 락 대신 원자적 단일 워드를
사용하는지는 상위 개요 문서의 “Position-with-flags — 중앙 조정자” 절
(cubrid-double-write-buffer.md)을 참고한다.
평탄화된 슬롯 배열을 순환하는 단조 증가 ring 커서; DWB_GET_NEXT_POSITION_WITH_FLAGS가 전진시키며, num_pages에서 wrap-around.
30
DWB_CREATE (0x40000000)
DWB 존재 / 사용 가능
생성 시 DWB_STARTS_CREATION, 해제 시 DWB_ENDS_CREATION이 설정/해제.
31
DWB_MODIFY_STRUCTURE (0x80000000)
리사이즈 진행 중
리사이즈 시작 시 DWB_STARTS_MODIFYING_STRUCTURE, 완료 시 DWB_ENDS_MODIFYING_STRUCTURE가 설정/해제; 설정된 동안 슬롯 할당 차단.
32..63
블록 상태 비트마스크
DWB_BLOCKS_STATUS_MASK (0xffffffff00000000)
블록당 1비트(블록 b는 비트 63 - b); 설정 = “쓰기 시작됨”. DWB_STARTS_BLOCK_WRITING이 설정, DWB_ENDS_BLOCK_WRITING이 해제.
DWB_FLAG_MASK는 30비트 position을 제외한 모든 비트, 즉 position 증가 시에도
살아남아야 하는 비트들이다(2.5절). 블록 b의 상태 비트는 (63 - b) 위치로,
블록 0이 비트 63, 블록 31이 비트 32에 위치한다. 상태 비트가 32개뿐이므로 설계상
블록 수는 최대 32개(DWB_MAX_BLOCKS, 최솟값은 DWB_MIN_BLOCKS1)로 제한된다.
(제목에 등장하는 “BLOCKS_FLUSH”는 비공식 표현이다. 이 워드에는 BLOCKS_FLUSH
플래그가 존재하지 않는다. 32비트 영역은 “쓰기 시작” 비트마스크이며, 별도의
blocks_flush_counter 정수는 position_with_flags 바깥에 위치한다.)
불변 조건 — 세 영역은 결코 겹치지 않는다. 마스크 상수의 구성상 비트 비중복이
보장된다. position은 최대 0x3fffffff(10억을 훨씬 넘음)로, 어떤 합법적인
num_pages보다도 크다(DWB는 32MB로 상한이 설정됨, 챕터 3). 따라서 position
산술 연산이 비트 30으로 올림수를 전달하는 일은 없다. 마스크가 겹친다면 position
증가 하나가 라이프사이클 비트나 상태 비트를 조용히 지워버려, 아직 플러시 중인
블록을 생산자가 덮어쓰게 된다. 이 보장은 상수만으로 이루어지며, 런타임에 재확인하지
않는다.
flowchart LR
subgraph W["position_with_flags (UINT64)"]
direction LR
B["비트 63..32\n블록 상태 비트마스크\n블록 b -> 비트 63-b"]
M["비트 31\nDWB_MODIFY_STRUCTURE"]
C["비트 30\nDWB_CREATE"]
P["비트 29..0\n슬롯 위치\nDWB_POSITION_MASK"]
end
// position projections -- src/storage/double_write_buffer.cpp
#defineDWB_GET_POSITION(position_with_flags)\
((position_with_flags) & DWB_POSITION_MASK) /* <- bits 0..29 only */
#defineDWB_RESET_POSITION(position_with_flags)\
((position_with_flags) & DWB_FLAG_MASK) /* <- keep flags, zero position */
#defineDWB_GET_BLOCK_STATUS(position_with_flags)\
((position_with_flags) & DWB_BLOCKS_STATUS_MASK) /* <- bits 32..63 only */
DWB_GET_POSITION은 순수한 ring 커서 값을 반환하고, DWB_RESET_POSITION은 그
보수로서 생성 시(2.6절) 들어오는 플래그는 유지하면서 position을 0으로 시작하는
새 구조를 만들 때 사용한다. DWB_GET_BLOCK_STATUS는 블록 상태 워드(상위 32비트)를
그대로 반환하며, 호출 측에서는 절대 비트별로 해석하지 않는다. 두 가지 용도는 모두
전체 영역 제로 확인이다: dwb_starts_structure_modification 말미의 사후-플러시
assert (DWB_GET_BLOCK_STATUS (...) == 0)(리사이즈 전 모든 블록이 비워져야 함)과,
dwb_flush_force의 “플러시할 대상이 있는가?” 조기 탈출
if (DWB_GET_BLOCK_STATUS (...) == 0). 비트별 검사는 DWB_IS_BLOCK_WRITE_STARTED(2.4절)로 수행한다.
그 로그값만큼 오른쪽으로 shift하면 블록 인덱스가 나오고, DWB_BLOCK_NUM_PAGES - 1
(하위 비트 전체가 1인 마스크, num_block_pages가 2의 거듭제곱임을 이용 — 챕터 3)과
AND하면 블록 내 오프셋이 나온다. unsigned int 캐스트는 손실이 없으며(마스크 적용 후
position은 30비트 이내), shift 연산을 32비트 산술 내에서 처리한다. 할당 루프는 두
값을 읽은 뒤 current_block_no < DWB_NUM_TOTAL_BLOCKS && position_in_current_block < DWB_BLOCK_NUM_PAGES를
assert한다.
DWB_IS_BLOCK_WRITE_STARTED(w,b)는 비트 63-b를 검사하고,
DWB_IS_ANY_BLOCK_WRITE_STARTED(w)는 전체 영역을 검사한다.
DWB_STARTS_BLOCK_WRITING / DWB_ENDS_BLOCK_WRITING은 해당 비트를
설정/해제한 워드를 반환한다. 세 매크로는 쉼표 연산자로 assert를
내장한다(먼저 assert를 평가하고, 그 다음 비트 연산 결과를 값으로 반환):
STARTS/IS 쌍은 block_no < DWB_MAX_BLOCKS를 보호하고(범위 초과 시 position/flag
영역으로 shift가 침범), ENDS는 비트가 이미 설정되어 있음을 assert하여 이중
해제를 잡아낸다. 네 매크로 모두 순수(pure) 함수로, CAS는 호출 측에서 처리한다.
블록의 첫 번째 슬롯을 차지하는 생산자는 STARTS 비트 설정과 커서 전진을 동일하게
공개된 하나의 워드에 담아야 한다. 할당자는 CAS 이전에 이 복합 값을 구성한다 —
먼저 DWB_STARTS_BLOCK_WRITING, 그 위에 DWB_GET_NEXT_POSITION_WITH_FLAGS(2.5절)를
적용하여, CAS 한 번으로 두 변경을 동시에 공개한다:
DWB_STARTS_BLOCK_WRITING (current_position_with_flags, current_block_no); /* <- set status bit */
new_position_with_flags =
DWB_GET_NEXT_POSITION_WITH_FLAGS (current_position_with_block_write_started); /* <- then advance cursor */
불변 조건 — 블록의 상태 비트는 플러시 사이클 하나 동안 설정 상태를 유지한다.
블록의 첫 슬롯이 할당될 때(DWB_STARTS_BLOCK_WRITING) 설정되고, 블록이 플러시된
후에만(DWB_ENDS_BLOCK_WRITING, 챕터 6) 해제된다. 설정 상태에서 할당자가 첫 슬롯
위치로 wrap-around하면(position_in_current_block == 0), DWB_IS_BLOCK_WRITE_STARTED가
true를 반환하고 할당자는 대기한다:
두 분기(Figure 2-2): num_pages - 1에서의 wrap 분기는
position_with_flags & DWB_FLAG_MASK(position을 0으로, 플래그 유지)를 반환하고,
그 외의 increment 분기는 position_with_flags + 1을 반환한다. position이 비트
30보다 훨씬 아래에 있으므로 하위 비트만 변경된다.
Figure 2-2. DWB_GET_NEXT_POSITION_WITH_FLAGS의 두 분기.
불변 조건 — 플래그 비트는 increment와 wrap 양쪽 모두에서 살아남는다.DWB_FLAG_MASK는 DWB_POSITION_MASK의 정확한 보수이므로, create 비트·구조
변경 비트·상태 비트는 어느 경로에서도 변경되지 않은 채 나온다. 이 덕분에 CAS
하나로 커서 전진과 위에서 설정한 상태 비트를 동시에 공개할 수 있다. wrap 분기가
순수한 0을 반환한다면 DWB_CREATE와 모든 상태 비트가 지워져 버퍼가 사라진 것으로
생산자가 오인한다.
이 매크로는 dwb_acquire_next_slot의 할당 CAS 루프, 즉 이 워드에 대한 첫
번째 재시도 루프에 사용된다. 워드는 ATOMIC_INC_64 (&dwb_Global.position_with_flags, 0ULL)로
읽히고, 조기 탈출 및 첫 슬롯/비첫 슬롯 분기(2.4절)가 new_position_with_flags를
구성하며, 단일 CAS로 커밋한다. CAS에 실패하면 goto start로 돌아가 재로드한다:
생성 트리오는 비트 30에, 변경 트리오는 비트 31에 작용한다. set 매크로는 단순
OR, clear 매크로는 비트가 설정되어 있음을 assert(begin/end 비대칭 쌍을 잡아냄)한
뒤 보수와 AND한다. DWB_NOT_CREATED_OR_MODIFYING은 두 라이프사이클 비트를 함께
마스킹하여 결과가 정확히DWB_CREATE인지 검사한다. “생성됨, 변경 중이 아님”일
때만 false이고, DWB가 없거나(비트 30 해제) 리사이즈 중이면(비트 31 설정) true이다.
이것이 할당 및 플러시 경로 최상단의 조기 탈출 guard이다
(if (DWB_NOT_CREATED_OR_MODIFYING (...)) { ... skip ... }). dwb_acquire_next_slot에서
이 guard가 발동하면 세 가지로 분기한다: DWB_IS_MODIFYING_STRUCTURE가 true이면
dwb_wait_for_strucure_modification으로 대기 후 goto start 재시도; 그렇지 않으면
DWB가 삭제된 경우로, 쓰기가 시작된 블록이 남아 있으면(DWB_IS_ANY_BLOCK_WRITE_STARTED
— 데이터 유실) ER_DWB_DISABLED, 없으면 조용한 NO_ERROR를 반환한다. 마지막
else는 assert (false)이다(마스크 비교가 이 경우를 배제하기 때문).
생성은 DWB_RESET_POSITION → DWB_STARTS_CREATION → CAS 순으로 DWB_CREATE를
공개하여 position 0에서 create 비트가 켜진 새 구조를 시작한다(실패 시
assert(false) — 생성은 단일 스레드):
if (!ATOMIC_CAS_64 (&dwb_Global.position_with_flags, *current_position_with_flags, new_position_with_flags))
{
assert (false); /* <- single-threaded; a race here is a bug */
}
dwb_starts_structure_modification의 구조 변경 CAS 루프는 이 워드에 대한
두 번째이자 마지막 실질적인 재시도 루프다. 워드를 원자적으로 읽고, 이미 리사이즈가
진행 중이면 반환하며(리사이저는 동시에 하나만 허용 — 대기 없이 ER_FAILED 반환),
DWB_MODIFY_STRUCTURE를 OR한 뒤 CAS에 성공할 때까지 스핀한다:
while (!ATOMIC_CAS_64 (&dwb_Global.position_with_flags, local_current_position_with_flags, new_position_with_flags));
// ... condensed: drain in-flight flushers, flush all started blocks, then assert status==0 ...
불변 조건 — 마스크 비교 하나로 “영업 중”과 그 외 모든 상태를 구분한다.DWB_NOT_CREATED_OR_MODIFYING은 비트 31을 비트 30과 함께 검사한다. 위 루프가
DWB_CREATE를 해제하지 않고 DWB_MODIFY_STRUCTURE를 설정하면, 마스킹 결과는
DWB_CREATE | DWB_MODIFY_STRUCTURE가 되어 != DWB_CREATE로 배제된다. 별도의
워드였다면 생산자가 낡은 “생성됨” 상태를 보고 절반만 재구성된 버퍼에 할당할 수
있다. 단일 리사이저 보장은 무조건적인 DWB_STARTS_MODIFYING_STRUCTURE OR를 안전하게
만드는 요소이기도 하다. 루프 본문의 DWB_IS_MODIFYING_STRUCTURE 검사가 CAS로
재검증되므로, 두 스레드가 동시에 set에 성공하는 일은 없다.
dwb_Global.position_with_flags는 UINT64 하나를 세 개의 비중첩 영역으로
분할한다: 30비트 ring position(DWB_POSITION_MASK), 두 개의 라이프사이클 플래그
(DWB_CREATE 비트 30, DWB_MODIFY_STRUCTURE 비트 31), 상위 워드의 32비트 블록별
“쓰기 시작” 비트마스크(DWB_BLOCKS_STATUS_MASK). 읽기는
ATOMIC_INC_64 (&..., 0ULL), 쓰기는 오직 ATOMIC_CAS_64로만 이루어진다.
블록 b의 상태 비트는 (63 - b) 위치에 있으며, 블록 수를 DWB_MAX_BLOCKS = 32로
하드 캡한다. raw position은 log2_num_block_pages 기준으로 shift right / 마스크
(num_block_pages - 1)하여 (block_no, position_in_block)으로 디코딩되며, 2의
거듭제곱 크기에 의존한다.
DWB_GET_NEXT_POSITION_WITH_FLAGS는 num_pages - 1에서 w & DWB_FLAG_MASK로
wrap-around하여 position을 0으로, 플래그를 유지하며, 그 외에는 w + 1을 반환한다.
어느 경우든 플래그/상태 비트는 살아남으므로, CAS 하나로 커서 전진과 상태 비트 설정을
원자적으로 공개할 수 있다. 할당자는 커밋 전에 DWB_STARTS_BLOCK_WRITING 후
DWB_GET_NEXT_POSITION_WITH_FLAGS를 순서대로 구성한다.
이 워드를 변경하는 CAS 재시도 루프는 두 개다: dwb_acquire_next_slot의 할당
루프(CAS 실패 시 goto start, 첫 슬롯 대기는 dwb_wait_for_block_completion,
변경 중/미생성 조기 탈출 포함)와, DWB_MODIFY_STRUCTURE를 설정하는
dwb_starts_structure_modification의 do/while (!ATOMIC_CAS_64) 루프(이미 리사이즈
중이면 ER_FAILED 반환). 생성은 재시도 없는 단일 CAS(assert(false) on loss).
블록 상태 비트는 블록의 첫 슬롯이 확보될 때 설정되고 블록 플러시 후에만 해제된다.
clear/end 매크로는 먼저 “설정됨” predicate를 assert하며, 모든 변경자 매크로는
순수 함수로 CAS를 호출 측에 위임한다.
DWB_NOT_CREATED_OR_MODIFYING은 두 라이프사이클 비트를 DWB_CREATE에 대한
단일 마스크 비교로 통합하여, DWB가 없거나 리사이즈 중일 때 fast path를 차단한다.
DWB_GET_BLOCK_STATUS는 전체 영역 제로 확인(리사이즈 경로의 사후 배수 assert,
dwb_flush_force의 “플러시 대상 있음?” 판단)에만 사용되며, 비트별로는 절대
해석하지 않는다.
독자 질문: Chapter 1의 구조체와 Chapter 2의 position word를 전제로 할 때, DWB는 어떻게 크기가 결정되고, 할당되고, 디스크에 포맷되고, 부트 과정에 연결되는가? 두 튜너블(PRM_ID_DWB_SIZE, PRM_ID_DWB_BLOCKS)에서 시작해 malloc된 쓰기 버퍼, slot/io_page 앨리어싱, 온디스크 포맷, 해제까지 모든 분기를 추적한다. 복구 시점의 블록 재구성은 범위 밖이다(Chapter 8). 왜 double-write buffer가 존재하는지는 cubrid-double-write-buffer.md § “Why a double write buffer”를 참조한다.
두 튜너블은 모두 2의 거듭제곱으로 클램프되어, num_pages = size / IO_PAGESIZE와 num_block_pages = num_pages / num_blocks가 정확히 2의 거듭제곱이 되도록 보장한다. 이로써 런타임이 log2_num_block_pages(§3.7)를 캐싱해 나머지 연산(modulo) 대신 비트 마스크 시프트를 쓸 수 있다. 세 함수가 협력한다: dwb_power2_ceil, dwb_load_buffer_size, dwb_load_block_count.
if (*p_value < min) { *p_value = min; } /* branch A: below floor -> snap to min */
elseif (*p_value > max) { *p_value = max; } /* branch B: above ceiling -> snap to max */
elseif (!IS_POWER_OF_2 (*p_value)) { /* branch C: in range, not power of 2 */
limit = min <<1;
while (limit <*p_value) limit = limit <<1; /* shift up until >= value -> round UP */
*p_value = limit;
}
/* branch D (implicit): in range AND already power of 2 -> untouched */
assert (IS_POWER_OF_2 (*p_value));
assert (*p_value >= min &&*p_value <= max);
A/B는 경계 상수(둘 다 2의 거듭제곱)로 클램프하고, C는 min << 1에서 시작해 왼쪽으로만 시프트하므로 올림(round-up) 방식이며 max를 초과하지 않는다. D는 이미 2의 거듭제곱인 값을 건드리지 않는다.
Invariant 3-A (클램프 범위 내 2의 거듭제곱). 반환 후 *p_value는 [min, max] 범위의 2의 거듭제곱임이 assert로 보장된다. 이 불변식이 깨지면 num_block_pages가 2의 거듭제곱이 아니게 되어 log2_num_block_pages가 유효한 시프트 폭으로 기능하지 못한다.
flowchart TD
S["dwb_power2_ceil(min,max,*v)"] --> A{"*v < min?"}
A -->|yes| AA["*v = min"] --> Z["assert: 범위 내 2의 거듭제곱"]
A -->|no| B{"*v > max?"}
B -->|yes| BB["*v = max"] --> Z
B -->|no| C{"IS_POWER_OF_2?"}
C -->|yes| Z
C -->|no| CC["limit=min<<1; while limit<*v: limit<<=1; *v=limit"] --> Z
Figure 3-2-1 — dwb_power2_ceil의 네 가지 분기
3.3 dwb_load_buffer_size와 dwb_load_block_count — 0은 비활성화 게이트
파라미터를 읽어 0이면 DWB 비활성화(return false), 그렇지 않으면 dwb_power2_ceil로 클램프하고 return true를 반환한다. dwb_load_block_count는 PRM_ID_DWB_BLOCKS / DWB_MIN_BLOCKS / DWB_MAX_BLOCKS를 대상으로 구조적으로 동일하다.
Invariant 3-B (0은 원자적 비활성화). 두 파라미터 중 하나라도 0이면 버퍼 전체가 비활성화된다. 호출 측의 단락 평가(short-circuit) ||(!dwb_load_buffer_size(...) || !dwb_load_block_count(...))로 인해, size가 0이면 block-count 파라미터는 읽히지 않으며 함수는 아무것도 할당하지 않고 NO_ERROR를 반환한다 — 절반만 초기화된 상태가 남지 않는다.
Invariant 3-C (flush-info 배열은 과잉 프로비저닝됨).max_to_flush_vdes == num_block_pages이므로 모든 페이지가 서로 다른 볼륨을 대상으로 하더라도 count_flush_volumes_info가 경계를 초과하지 않는다. 런타임은 경계 검사 없이 flush_volumes_info[count_flush_volumes_info++]에 추가하며, 이 크기 결정에 의존한다.
dwb_initialize_block (&blocks[i], i, 0, blocks_write_buffer[i], slots[i], flush_volumes_info[i], 0, num_block_pages);
}
*p_blocks = blocks;
return NO_ERROR;
exit_on_error:
for (i =0; i < DWB_MAX_BLOCKS; i++) { /* free slots[i], blocks_write_buffer[i], flush_volumes_info[i] if non-NULL */ }
if (blocks !=NULL) free_and_init (blocks);
return error_code;
}
핵심 동작은 pass 4의 앨리어싱이다: 각 slots[i][j].io_page는 연속 버퍼 내 write_buffer + j * IO_PAGESIZE를 가리키는 포인터로, 블록이 단일 벌크 write로 플러시되면서도 페이지 단위로 주소 지정이 가능하다. fileio_initialize_res는 각 FILEIO_PAGE 헤더를 깨끗한 초기 상태로 만든다(pageid/volid = -1, NULL LSA, zero ptype/pflag/reserves/tde_nonce). 이 값은 플러시 전에 덮어써진다. Figure 3-6-1은 분기를 추적한다. DWB_MAX_BLOCKS 크기로 NULL 초기화된 스크래치 배열 덕분에 에러 분기에서 일관되게 해제할 수 있으며, *p_blocks는 완전 성공 시에만 할당된다.
Invariant 3-D (블록 하나 = 연속 버퍼 하나). 모든 slots[i][j].io_page는 blocks_write_buffer[i]의 오프셋 j*IO_PAGESIZE 지점을 정확히 가리키므로, block->write_buffer는 유효한 단일 I/O 벡터가 된다. 페이지를 별도로 malloc하면 Chapter 6의 DWB 볼륨 벌크 쓰기가 깨진다.
flowchart TD
A["malloc blocks[]"] -->|NULL| ERR["exit_on_error"]
A -->|ok| B["pass1: 블록마다 write_buffer malloc"]
B -->|NULL 발생| ERR
B -->|ok| C["pass2: 블록마다 slots malloc"]
C -->|NULL 발생| ERR
C -->|ok| D["pass3: 블록마다 flush_volumes_info malloc"]
D -->|NULL 발생| ERR
D -->|ok| E["pass4: io_page 앨리어싱, slot 초기화, block 초기화"]
E --> F["*p_blocks = blocks; return NO_ERROR"]
ERR --> G["NULL 아닌 scratch[i] 전체 해제, blocks 해제, error_code 반환"]
슬롯의 io_page가 write_buffer 내부를 앨리어싱하므로, 페이지를 별도로 해제할 필요가 없다 — write_buffer를 해제하면 num_block_pages 전체가 한 번에 회수된다(Inv 3-D의 해제 측 대칭). wait queue 비우기와 mutex 해제는 마지막에 수행된다.
세 가지 핵심 포인트가 있다. 첫째, 비활성화 게이트: 두 로더 중 하나라도 false를 반환하면 아무것도 건드리지 않고 NO_ERROR를 반환한다. 둘째, 볼륨 우선: LOG_DBDWB_VOLID 볼륨이 포맷되고 이전의 더티 페이지가 sync된 후에야 블록이 생성된다. 셋째, 공개 CAS: 호출자가 modification 플래그를 보유하고 있으므로 assert(false)는 로직 버그가 있을 때만 발동한다. 네 번째 assert는 §3.6 스크래치의 경계를 보장하며, slots_entry_Descriptor는 lock-free 해시의 vtable로 slots hash 활용 지점에서 상세히 다룬다.
Invariant 3-E (CREATE 플래그와 함께 원자적으로 공개되는 지오메트리). 모든 dwb_Global 지오메트리는 CREATE 플래그를 설정하는 단일 ATOMIC_CAS_64 이전에 기록된다. 리더는 DWB_IS_CREATED(position_with_flags)를 확인하며, CAS가 완료되기 전까지는 “미생성” 상태를 보므로 절반만 채워진 dwb_Global을 절대 관찰하지 않는다. CAS가 공개 배리어다.
에러 경로.exit_on_error는 vdes가 열려 있으면 볼륨을 dismount하고 unformat하며, blocks가 할당된 경우 0 .. num_blocks-1을 순회하며 dwb_finalize_block을 호출하고 배열을 해제한다. position word는 건드리지 않으므로 전역 상태는 “미생성”으로 남는다.
3.8 dwb_create와 fileio_make_dwb_name — 공개 래퍼와 이름 생성
§3.7의 역순이다: 전역 wait queue와 mutex, 이어서 DWB_NUM_TOTAL_BLOCKS를 순회하며 dwb_finalize_block으로 모든 블록 해제, hashmap 파괴, 볼륨 dismount 및 unformat으로 파일 삭제, 마지막으로 modification 플래그 하에 CREATE 플래그를 해제하는 CAS(Inv 3-E의 역방향). 반환 후 전역 상태는 “미생성”이 되고 _dwb 파일도 사라진다.
이 챕터는 프로듀서 측의 핵심 질문 하나에 답한다: 스레드는 어떻게 락 없이 다음 빈 DWB 슬롯을 선점하고, 페이지 바이트를 복사해 넣으며, 경쟁과 라이프사이클의 각 분기에서 무엇을 하는가? 락-프리 슬롯 선점은 DWB 쓰기 경로의 핵심이다 — 왜 이 설계인지는 동반 문서의 “Lock-free position cursor” 절을 참고하고, 이 챕터에서는 분기별로 어떻게 동작하는지를 추적한다.
호출 체인은 dwb_set_data_on_next_slot(래퍼) → dwb_acquire_next_slot(락-프리 CAS 선점, dwb_wait_for_strucure_modification 또는 dwb_wait_for_block_completion에서 대기 가능) → dwb_set_slot_data(페이지를 memcpy로 복사)이다. position-with-flags 워드와 매크로는 챕터 2에서 다룬다.
error_code != NO_ERROR는 그대로 전파된다. 성공 후 *p_dwb_slot == NULL은 획득 불가, 하지만 오류는 아닌 결과이며, can_wait == false인 비-대기자에게만 허용된다.
Invariant 4-A — 대기자는 반드시 슬롯을 들고 돌아온다.can_wait == true일 때 이 함수는 non-NULL *p_dwb_slot을 반환하거나 하드 에러를 반환한다. dwb_acquire_next_slot은 모든 대기 후 goto start로 루프를 돌아 이를 보장한다. NULL-with-NO_ERROR 출구는 can_wait == false 분기에만 존재한다. assert는 대기자가 NULL을 반환하는 경우를 잡아낸다 — release 빌드에서 그 경우는 보장된 페이지 쓰기를 조용히 누락시킨다.
start: 레이블의 read-modify-CAS 재시도 루프로, 라이프사이클/경쟁 프리앰블을 감싼다. 함수는 먼저 출력 파라미터를 NULL로 초기화(*p_dwb_slot = NULL)하므로, 조기 return NO_ERROR 분기는 모두 호출자에게 NULL을 남긴다. Figure 4-1은 전체 분기 지도이며, 이하 산문은 각 분기를 순서대로 설명한다.
flowchart TD
A["start: 커서 원자적 읽기"] --> B{NOT_CREATED_OR_MODIFYING?}
B -- "no, 정상" --> P{position_in_block == 0?}
B -- "yes, 드문 경우" --> C{MODIFYING_STRUCTURE?}
C -- "yes" --> C1{can_wait?}
C1 -- "false" --> CR["return NO_ERROR, slot NULL"]
C1 -- "true" --> C2["wait_for_strucure_modification"]
C2 --> C3{rc?}
C3 -- "TIMEDOUT 또는 NO_ERROR" --> A
C3 -- "기타 에러" --> CE["return error_code"]
C -- "no" --> D{!IS_CREATED?}
D -- "yes" --> D1{ANY_BLOCK_WRITE_STARTED?}
D1 -- "yes" --> DE["ER_DWB_DISABLED"]
D1 -- "no" --> DR["return NO_ERROR, slot NULL"]
D -- "no, 불가능" --> DX["assert false"]
P -- "yes, 첫 번째 writer" --> E{BLOCK_WRITE_STARTED?}
E -- "yes" --> E1{can_wait?}
E1 -- "false" --> ER["return NO_ERROR, slot NULL"]
E1 -- "true" --> E2["wait_for_block_completion"]
E2 --> E3{rc?}
E3 -- "TIMEDOUT 또는 NO_ERROR" --> A
E3 -- "기타 에러" --> EE["return error_code"]
E -- "no" --> F["new = NEXT(STARTS_BLOCK_WRITING)"]
P -- "no, 블록 중간" --> G["new = NEXT(pos)"]
F --> H{ATOMIC_CAS_64 성공?}
G --> H
H -- "no, 경쟁 패배" --> A
H -- "yes" --> I["슬롯 선점, vpid NULL, return"]
Figure 4-1. dwb_acquire_next_slot의 전체 분기.
매 루프 패스마다 ATOMIC_INC_64 (&dwb_Global.position_with_flags, 0ULL)로 start: 레이블에서 커서를 다시 읽는다 — 0을 더하는 것은 원자적 로드의 관용구다. 매번 재읽기하기 때문에 루프는 항상 정확하다: CAS 경쟁에 진 스레드, 대기에서 깨어난 스레드, 또는 과도적 플래그를 본 스레드 모두 goto start 후에는 최신 커서 상태를 반영한다.
4.3.1 드문 라이프사이클 프리앰블: DWB_NOT_CREATED_OR_MODIFYING
(a) 구조 수정 중.ER_CSS_PTHREAD_COND_TIMEDOUT은 전파되지 않는다 — goto start로 재시도된다. 그 외 에러(할당 실패, 셧다운 시 ER_INTERRUPTED)는 반환한다. 정상 wake-up도 goto start하는데, 수정이 끝났다는 의미일 뿐이므로 커서를 다시 읽어야 하기 때문이다.
(b) 생성되지 않은 상태. 아직 쓰기 시작 비트가 설정된 블록이 있다면, 이는 비정상적인 부분 해제 상태이므로 ER_DWB_DISABLED를 반환한다. 그렇지 않으면 DWB가 단순히 꺼진 것이며, 호출자는 페이지를 직접 쓴다.
(c) 도달 불가 분기.DWB_NOT_CREATED_OR_MODIFYING은 (pos & DWB_CREATE_OR_MODIFY_MASK) != DWB_CREATE로 정의된다. 두 if 분기가 이 조건의 모든 경우를 소진하므로 else는 도달 불가다. assert 뒤에 return이 없다는 점에 주의 — release 빌드에서는 비정상 커서 상태로 정상 경로로 fall-through하므로, 이것은 순수한 디버그 가드다.
프리앰블을 통과하면, 커서를 블록 번호(DWB_GET_BLOCK_NO_FROM_POSITION)와 블록 내 오프셋(DWB_GET_POSITION_IN_BLOCK)으로 분리하고, 각각 DWB_NUM_TOTAL_BLOCKS / DWB_BLOCK_NUM_PAGES를 기준으로 범위 assert를 수행한다. 판단의 핵심은 현재 스레드가 블록에 첫 번째 프로듀서인지 여부다.
첫 번째 프로듀서는 해당 비트가 클리어임을 확인한 후에만 쓰기 시작 비트를 설정한다. 비트가 여전히 set이라면 이전 채우기 작업이 아직 플러시되지 않은 것이고, 그 슬롯을 재사용하면 플러시가 아직 필요로 하는 데이터를 덮어쓰게 된다(래핑 해저드 — 단일 블록 버퍼에서는 드물지 않다). can_wait == false이면 바로 bail-out하고, 대기자는 dwb_wait_for_block_completion(§4.4)을 호출하고 정상 wake-up 시 goto start로 돌아간다. 비트가 클리어이면, DWB_STARTS_BLOCK_WRITING이 상태 비트를 OR로 설정하고 DWB_GET_NEXT_POSITION_WITH_FLAGS가 위치를 전진시킨다 — 두 작업이 하나의 64비트 워드에서 이루어지므로, CAS가 둘을 원자적으로 공표한다.
position_in_current_block != 0 — 블록 중간 writer.else 분기는 두 가지 디버그 assert(DWB_IS_CREATED(pos)와 !DWB_IS_MODIFYING_STRUCTURE(pos)) 아래에서 위치만 전진시킨다(new = DWB_GET_NEXT_POSITION_WITH_FLAGS(pos)).
Invariant 4-B — 첫 번째가 아닌 writer는 해제 경쟁을 피할 수 있다. 어떤 스레드가 슬롯 0을 선점한 순간, 해당 블록의 쓰기 시작 비트는 그 블록이 플러시될 때까지 DWB를 “생성됨, 수정 중 아님” 상태로 고정한다. position_in_current_block != 0을 보는 스레드는 두 플래그가 모두 유지됨을 전제할 수 있으며, assert가 디버그 빌드에서 이를 강제한다. 위반 시 프로듀서는 해제 중인 블록에 스테이징할 수 있다.
CAS는 선형화 지점이다: 읽기 이후 커서가 변경되지 않은 경우에만 성공한다. 실패 시 스레드는 새로운 읽기부터 goto start로 재시도한다(CAS 경쟁에 진 첫 번째 writer는 시작 비트를 다시 설정하지 않는다). 성공 시 슬롯을 독점적으로 소유하고(동일 커서 값에서 CAS 성공자는 둘이 될 수 없다) vpid를 NULL로 초기화한다.
Invariant 4-D — 슬롯 인덱스는 DWB 볼륨 오프셋에 1:1 대응한다. 선점된 슬롯은 blocks[block_no].slots + position_in_block이며, slots[i].position_in_block == i는 블록 빌드 시 고정된다(챕터 3). 마지막 assert가 이를 커서 파생 오프셋과 대조 확인한다. 이 인덱스는 DWB 볼륨에서 슬롯의 고정 오프셋이기도 하므로, 커서 위치 p에서 획득한 슬롯은 항상 동일한 온-디스크 위치에 쓴다 — 목적지를 프로듀서가 고르는 것이 아니라 커서가 결정한다.
r =thread_suspend_timeout_wakeup_and_unlock_entry (thread_p, &to, THREAD_DWB_QUEUE_SUSPENDED); /* <- 10 ms */
mutex 하에서의 재검사는 이미 클리어된 조건에서 sleep하는 것을 막는다. 재개 분기는 셋: ER_CSS_PTHREAD_COND_TIMEDOUT → 엔트리 제거 후 타임아웃 반환(호출자 재시도); resume_status != THREAD_DWB_QUEUE_RESUMED → 셧다운 wake, ER_INTERRUPTED; 그 외 → dwb_signal_structure_modificated로 깨어남, NO_ERROR.
**dwb_wait_for_block_completion**은 구조는 동일하나, 블록별dwb_block->wait_queue에서 dwb_block->mutex 하에 대기하고, DWB_IS_BLOCK_WRITE_STARTED로 블록의 시작 비트를 재검사하며, 20 ms 타임아웃을 사용한다. 두 변형이 다른 점은 per-thread lock 획득 순서와 재검사 시점이다: block-completion은 dwb_block->mutex를 잠근 뒤 thread_lock_entry를 하고 그 다음 재검사한다(두 lock을 모두 쥔 채 재검사); structure-mod는 dwb_Global.mutex를 먼저 잠그고 DWB_IS_MODIFYING_STRUCTURE를 재검사한 다음 큐 등록을 결정할 때만 thread_lock_entry를 호출한다. 어느 쪽이든 mutex 하 재검사가 이미 클리어된 조건에서의 sleep을 방지한다. TIMEDOUT 분기도 다르다: block-completion은 dwb_remove_wait_queue_entry에 dwb_set_status_resumed를 전달하고, structure-mod 변형은 모든 분기에서 NULL을 전달한다. 큐 메커니즘과 dwb_signal_* 웨이커는 챕터 10에서 다룬다. 계약: 모든 대기는 NO_ERROR(재시도), ER_CSS_PTHREAD_COND_TIMEDOUT(재시도), 또는 하드 에러(전파) 중 하나를 반환한다. goto start는 모든 재시도에서 커서를 다시 읽는다.
VPID_SET (&dwb_slot->vpid, io_page_p->prv.volid, io_page_p->prv.pageid);/* <- set LAST: now identifiable */
dwb_slot->ensure_metadata= ensure_metadata;
분기는 prv.pageid에 따른다: 실제 페이지(!= NULL_PAGEID)는 슬롯의 io_page로 memcpy 전체 복사되고, hole(== NULL_PAGEID)은 fileio_initialize_res로 정형화된 이미지를 만든다. 그 다음 lsa, vpid(반쯤 스테이징된 슬롯이 아직 NULL-VPID로 읽히도록 마지막에 설정), ensure_metadata 순으로 채운다. assert는 무결성을 검사한다: 헤더 예약 필드 prv.p_reserve_2 == 0, 소스 페이지(복사본이 아님)에 대한 fileio_is_page_sane.
Invariant 4-C — VPID는 슬롯의 가시성 플래그다. 슬롯은 vpid가 non-NULL일 때만 읽기 측에 유효하다. dwb_acquire_next_slot이 선점 시 NULL로 초기화하고, dwb_set_slot_data는 바이트와 LSA가 모두 설정된 후에야 vpid를 재설정한다. 따라서 non-NULL VPID를 발견한 읽기 측(챕터 8)은 완전히 스테이징된 이미지와 LSA를 보장받는다. 쓰기 순서만으로 이를 보장하며, 슬롯별 락은 없다.
선점은 DWB_SLOT 하나를 반환하며, 획득 시점에는 vpid만 건드린다.io_page, position_in_block, block_no는 챕터 3에서 고정된 불변 값이다. 스테이징이 이후에 io_page의 내용, lsa, vpid, ensure_metadata를 채운다.
dwb_acquire_next_slot은 start: 레이블의 read-modify-CAS 루프다 — ATOMIC_INC_64(&pos, 0)로 읽고, ATOMIC_CAS_64로 커밋하며, 경쟁 패배/대기/플래그 변경은 모두 goto start로 귀결된다.
라이프사이클 프리앰블은 네 분기이며 하나만 정상 진행한다: 구조 수정 중(대기 또는 bail-out), !DWB_IS_CREATED → ER_DWB_DISABLED(부분 해제) vs. NULL-NO_ERROR(정상 비활성), 도달 불가 assert(false).
첫 번째 writer는 시작 비트 설정과 위치 전진을 하나의 CAS 워드로 처리한다. 비트가 이미 설정돼 있으면 이전 채우기가 미플러시된 것이므로 래핑을 피하기 위해 dwb_wait_for_block_completion으로 대기한다.
can_wait이 실패 모드를 결정한다:false이면 NULL-NO_ERROR 반환(Invariant 4-A), true이면 블록하고 ER_CSS_PTHREAD_COND_TIMEDOUT은 항상 재시도다.
선점된 슬롯 인덱스는 DWB 볼륨 오프셋에 고정 대응한다(Invariant 4-D) — blocks[block_no].slots[position_in_block], 선점 시 assert로 검증하며, 프로듀서가 목적지를 선택하지 않는다.
dwb_set_slot_data는 원자 연산이 불필요하다 (CAS가 소유권을 부여했다). prv.pageid == NULL_PAGEID(reserved) vs. 실제 페이지(memcpy)로 분기하고, LSA, VPID, ensure_metadata를 순서대로 설정한다.
VPID는 슬롯의 가시성 플래그다(Invariant 4-C) — 선점 시 NULL로 초기화하고 마지막에 재설정한다. 쓰기 순서만으로 반쯤 스테이징된 슬롯을 감추며, 슬롯별 락은 없다. 스테이징이 끝나면 슬롯은 독자가 발견할 수 있지만, 해시 인덱싱이나 플러시 카운팅은 되지 않는다 — 그 승격은 챕터 5에서 다룬다.
Chapter 4에서 우리는 staged 상태의 슬롯을 얻었다. (block_no, position_in_block) 예약 위치에 페이지 이미지와 vpid/lsa를 담고 있지만, 동시 실행 중인 다른 스레드에게는 아직 보이지 않는 상태다. 이 챕터는 두 가지 질문에 답한다. staged 슬롯은 어떻게 발견 가능한 상태가 되는가, 그리고 블록의 마지막 슬롯이 채워졌을 때 flush는 어떻게 시작되는가? 두 가지 모두 dwb_add_page 안에서 일어난다. (1) dwb_slots_hash_insert로 슬롯을 VPID 해시에 공개하고, (2) 블록별 채움 카운터를 증가시켜 블록이 가득 찼는지 여부에 따라 분기한다.
CUBRID가 진행 중인 페이지(in-flight page)의 VPID 인덱스를 유지하는 이유는 상위 문서 cubrid-double-write-buffer.md의 “Why a hash over the staged pages” / “Decache after write” 절을 참고하라. 여기서는 재유도하지 않는다.
슬롯의 상태 전이는 FREE -> STAGED (Chapter 4) -> {HASHED | INVALIDATED} -> FLUSHED (Chapter 6) 순서를 따른다. 이 챕터는 STAGED 이후의 화살표를 다룬다. dwb_slots_hash_insert가 inserted == true를 반환하면 STAGED -> HASHED 전환이 일어나고, 경쟁에서 지면 STAGED -> INVALIDATED가 되며 해당 슬롯은 나중에 0으로 채워진 무효 페이지로 기록된다.
표준 무효화 원시 연산(canonical invalidation primitive). 이후 절에서 “슬롯을 무효화한다”고 할 때는 항상 두 가지 쓰기를 의미한다. VPID_SET_NULL (&slot->vpid)로 VPID를 null로 만들어 dwb_slots_hash_delete가 이 슬롯을 건너뛰게 하고(null VPID에서 즉시 반환, §5.5), fileio_initialize_res (thread_p, slot->io_page, IO_PAGESIZE)로 이미지를 0으로 초기화한다. 슬롯은 여전히 자신의 위치를 유지하며 DWB 볼륨에 기록되지만, 유효한 데이터를 담지 않으며 해시 엔트리도 소유하지 않는다. 둘 중 하나라도 생략하면 유령 해시 삭제나 오래된 데이터의 복구 재실행(recovery replay) 위험이 생긴다(Chapter 9).
불변 조건 (STAGED-before-HASHED 가시성). 읽기 측이 슬롯을 발견할 수 있는 것은 오직 dwb_slots_hash_insert가 slots_hash_entry->slot = slot을 저장한 이후다. 코드는 이 포인터를 엔트리 mutex 아래에서, 모든 LSA/블록 결정이 끝난 뒤 마지막 쓰기로 공개한다. 순서가 바뀐다면 dwb_read_page의 읽기 측이 절반만 기록된 슬롯을 역참조해 잘린 페이지를 돌려줄 수 있다. slots_hash_entry mutex는 같은 VPID에 대한 삽입/삭제 간의 교환을 원자적으로 만든다.
네 가지 경우가 있다. 이미 staged된 슬롯은 그대로 사용하고, dwb_set_data_on_next_slot 오류는 상위로 전파한다. can_wait = true임에도 재획득 결과가 NULL이면 (DWB 없음 또는 슬롯 없음) NO_ERROR를 반환해 호출자가 페이지를 직접 기록하도록 한다. 정상적으로 슬롯을 재획득한 경우에는 이후 처리로 넘어간다. assert (VPID_EQ ...) 는 호출자의 vpid와 staging 시 dwb_set_slot_data가 기록한 dwb_slot->vpid가 일치하는지 검증한다. 불일치하면 호출자가 한 페이지를 staging하고 다른 페이지를 공개하는 것이다. io_page->prv와의 삼각 검증은 dwb_slots_hash_insert 안에서 수행된다(§5.4).
분기별 설명: null VPID(예: 메타데이터 전용 flush 마커)는 삽입을 건너뛰지만 count_wb_pages에는 계속 반영된다. 삽입 오류는 전파된다. inserted == true이면 이 슬롯이 해당 VPID의 소유자가 된다. inserted == false(!inserted 경로)이면 더 나은 또는 동등한 슬롯이 이미 이 VPID를 소유하고 있다는 의미이므로(§5.4), §5.1에 따라 이 슬롯을 무효화한다.
assert_release (count_wb_pages <= DWB_BLOCK_NUM_PAGES); /* never overfill */
if (count_wb_pages < DWB_BLOCK_NUM_PAGES)
needs_flush =false;
else
needs_flush =true;
if (needs_flush ==false)
return NO_ERROR;
ATOMIC_INC_32는 “마지막 슬롯을 쓴 스레드” 결정을 직렬화하므로, 정확히 하나의 호출자만 count_wb_pages == DWB_BLOCK_NUM_PAGES를 관찰하고 flush 분기를 탄다. assert_release(프로덕션에서도 제거되지 않음)는 상한을 보호한다. 이것이 발동하면 Chapter 4에서 위치를 과하게 배분했다는 의미다. 무효화된 !inserted 슬롯도 실제 위치를 차지하므로 카운터를 증가시킨다. needs_flush == false인 일반적인 경우는 즉시 반환한다.
SERVER_MODE에서 daemon이 활성화된 경우 daemon을 깨우고 즉시 반환한다. flush 지연이 없고 이전 블록을 기다리지도 않는다(순서 보장은 daemon의 역할, Chapter 7). Standalone이거나 daemon이 없는 경우 호출자가 dwb_flush_block (thread_p, block, false, NULL)로 인라인 flush를 수행한다. 첫 번째 false는 file_sync_helper_can_flush 파라미터이고, NULL은 current_position_with_flags다(프로토타입 확인 완료). dwb_flush_block 자체가 dwb_write_block에 전달할 remove_from_hash 값을 결정하며, 이 함수가 해시 삭제 루프를 실행한다(§5.5). Chapter 6에서 자세히 다룬다.
find_or_insert는 새 엔트리를 생성했으면 true(VPID 없음), 기존 엔트리가 있었으면 false를 반환한다. 두 경우 모두 mutex를 잡은 채로slots_hash_entry를 반환하므로 이 함수는 모든 종료 경로에서 반드시 unlock해야 한다. 두 개의 assert는 VPID 삼각 검증을 수행한다. 해시 키, 슬롯의 vpid, io_page->prv가 모두 일치해야 한다.
flowchart TD
A[find_or_insert, mutex 획득] --> B{inserted == true?}
B -- 예, 새 엔트리 --> Z[이 슬롯이 소유자]
B -- 아니오, 기존 엔트리 존재 --> C{LSA_LT?}
C -- 예, 이 슬롯이 더 오래됨 --> D[unlock, 반환, inserted 유지 false]
C -- 아니오 --> E{LSA_EQ?}
E -- 예 --> F{같은 block_no?}
F -- 예 --> G[기존 슬롯 무효화 per 5.1]
F -- 아니오 --> H[디버그 빌드 전용: 이전 블록 순서 assert]
E -- 아니오, 이 슬롯이 더 최신 --> I[무효화 없음]
G --> J[ensure_metadata OR 병합]
H --> J
I --> J
J --> Z
Z --> K[슬롯 포인터 교체, unlock, inserted = true]
Figure 5-1: dwb_slots_hash_insert 결정 트리.
신규 삽입 경로(*inserted == true)는 곧바로 포인터 교체로 간다. 충돌 경로(*inserted == false)는 기존 소유자가 있으며, 세 가지 LSA 하위 분기를 거친다(Fig. 5-1).
LSA가 더 작다는 것은 더 오래된 버전임을 의미한다. 따라서 LSA_LT(이 슬롯이 더 오래됨)는 기존 소유자를 유지하고 inserted == false로 조기 반환한다. §5.2에서 이 슬롯을 무효화한다. **LSA_EQ**는 “로깅 없이 수정됨” 케이스로, 두 슬롯이 정당하게 같은 LSA를 공유한다. 같은 블록의 패배 슬롯은 §5.1에 따라 제자리에서 무효화되며, 기존 소유자를 대상으로 한다. 블록 flush 시 중복 VPID가 없도록 보장하기 위해서다. 다른 블록의 패배 슬롯은 무효화할 필요 없이 독립적으로 flush된다. #if !defined (NDEBUG) 블록은 디버그 빌드에서만 컴파일되며, 이전 소유자의 block_no를 ATOMIC_INC_32 (..., 0)으로 읽고(동시 flush가 0으로 만들었을 수 있음), 그 값이 > 0인 경우에만 이전 페이지의 블록이 새 블록보다 flush 순서가 앞서는지 assert한다: assert ((old_block->version < new_block->version) || (old_block->version == new_block->version && old_block->block_no < new_block->block_no)). 이는 블록 간 크래시 복구 순서를 유지하기 위함이다(Chapter 9). 암묵적 else인 newer-LSA 경로는 무효화가 필요 없다. 기존 소유자는 포인터 교체로 분리되고 dwb_slots_hash_delete가 더 이상 매칭하지 않는다(§5.5). ensure_metadata OR 병합은 모든 충돌 하위 분기에서 실행되어, 교체된 버전에 대한 메타데이터 동기화 요청이 유실되지 않도록 한다.
포인터 교체는 신규 삽입과 LSA_EQ/newer-LSA 경로에서 도달하지만, LSA_LT 조기 반환 경로에서는 실행되지 않는다.
이 저장 연산이 STAGED -> HASHED 전환의 실체다(§5.1). mutex 해제 직전의 마지막 쓰기로, 가시성 불변 조건을 만족한다. 마지막 *inserted = true는 “새로 생성”과 “기존 소유자 교체” 두 경우를 모두 정규화한다. 두 경우 모두 이 슬롯이 VPID를 소유하므로 무효화되어서는 안 된다. LSA_LT만 false를 반환한다.
반대 방향 연산으로, 블록의 페이지들이 홈 볼륨에 도달한 뒤 dwb_write_block의 if (remove_from_hash) 루프에서 null이 아닌 슬롯마다 호출된다. 이 함수는 슬롯을 제거해 이후 읽기가 페이지 버퍼/홈 볼륨으로 향하게 하고, DWB 복사본을 참조하지 않게 한다.
분기별 설명: VPID_ISNULL 슬롯은 §5.1에서 무효화되어 해시에 등록된 적이 없다. VPID를 null로 만드는 것이 정확히 이 경우를 처리하기 위해서다. find가 NULL을 반환하면 엔트리가 이미 사라진 것이다(no-op; find는 엔트리를 찾을 때 mutex를 잡은 채로 반환한다). slot == slot이면 erase_locked로 삭제한다(mutex를 소비함). mutex를 잡은 상태에서 실패하는 것은 있어서는 안 되는 상황이며, assert_release (false)와 dwb_log_error로 표시한다. 이때 return error_code는 지역 변수가 재할당된 적 없으므로 여전히 NO_ERROR를 담고 있어, 프로덕션에서도 오류 전파 없이 조용한 no-op으로 처리된다. else 경로는 이 슬롯이 hashing된 이후 더 최신 슬롯이 교체된 경우다. 이 오래된 슬롯이 엔트리를 삭제하면 현재 소유자를 파괴하므로 삭제해서는 안 된다.
불변 조건 (현재 소유자만 decache). 슬롯은 slots_hash_entry->slot == slot인 경우에만 엔트리를 삭제한다. §5.4의 포인터 교체 덕분에 VPID 엔트리는 매 순간 하나의 명확한 소유자를 갖는다.
*block_no = DWB_NUM_TOTAL_BLOCKS; /* <- sentinel: "nothing to flush" */
if (dwb_Global.blocks[dwb_Global.next_block_to_flush].count_wb_pages!= DWB_BLOCK_NUM_PAGES)
return; /* <- next block not full yet */
*block_no =dwb_Global.next_block_to_flush;
두 분기가 있다. next_block_to_flush가 아직 가득 차지 않았으면 센티넬 DWB_NUM_TOTAL_BLOCKS를 반환한다. 그보다 뒤에 있는 블록이 가득 찼더라도 앞서 flush할 수 없다. 순서를 어기면 크래시 복구 일관성이 깨지기 때문이다(Chapter 9). 가득 찬 경우에는 해당 블록의 인덱스를 반환하고, daemon(Chapter 7)이 flush 후 next_block_to_flush를 전진시킨다. 이 구조 덕분에 §5.3의 wakeup에서 이전 블록을 기다릴 필요가 없다. daemon이 여기서 다시 확인해 순서상 다음인 가득 찬 블록만 가져가기 때문이다. DWB_BLOCK_NUM_PAGES 비교는 dwb_add_page의 것과 동일하므로 “가득 찼다”는 두 관점이 항상 일치한다.
dwb_add_page는 공개와 계산을 담당한다.*p_dwb_slot == NULL이면 슬롯을 재획득하고(DWB를 사용할 수 없으면 null 슬롯으로 NO_ERROR 반환), dwb_slots_hash_insert로 공개한 뒤, count_wb_pages를 원자적으로 증가시켜 블록 가득 참 여부에 따라 분기한다.
무효화는 하나의 원시 연산이다: VPID_SET_NULL 더하기 fileio_initialize_res (§5.1). VPID를 null로 만들면 decache가 슬롯을 건너뛰고, io_page를 재초기화하면 재실행(replay) 시 무해하다. !inserted 경로와 LSA_EQ 같은 블록 경로 모두 이를 적용한다.
dwb_slots_hash_insert는 LSA로 충돌을 해결한다.LSA_LT는 더 최신인 기존 소유자를 유지하고 inserted == false로 조기 반환한다. LSA_EQ(로깅 없이 수정됨)는 교체하며, 같은 블록이면 기존 슬롯을 무효화한다. 다른 블록 충돌은 디버그 빌드(#if !defined (NDEBUG))에서만 순서 assert를 실행한다. 더 최신 LSA는 교체한다. 승자는 항상 inserted == true를 보고한다.
포인터 교체가 STAGED -> HASHED 가시성 전환점이다.slots_hash_entry->slot = slot은 엔트리 mutex 아래 마지막 쓰기로, 읽기 측이 절반만 공개된 슬롯을 볼 수 없다. ensure_metadata는 교체 전반에 걸쳐 OR 병합된다.
dwb_slots_hash_delete는 slots_hash_entry->slot == slot일 때만 삭제한다. null VPID 슬롯, 없는 엔트리, 더 최신 슬롯이 소유한 엔트리는 모두 no-op이다.
flush 분기는 실행 모드에 따라 다르다. SERVER_MODE에서 daemon이 있으면 flush 지연 없이 daemon을 깨운다. 그 외에는 dwb_flush_block (..., file_sync_helper_can_flush = false, NULL)로 인라인 flush한다.
dwb_get_next_block_for_flush는 채움 순서를 강제한다.next_block_to_flush가 정확히 가득 찼을 때만 해당 블록을 내어주고, 그렇지 않으면 DWB_NUM_TOTAL_BLOCKS 센티넬을 반환한다.
독자 질문: 블록 전체를 플러시할 때, 홈 페이지에 쓰기 전에 DWB에 페이지를 내구적으로 기록하는 정확한 쓰기-fsync 시퀀스는 무엇이며, 찢긴 홈 페이지를 항상 복구 가능하게 만드는 방법은 무엇인가?
동반 문서(cubrid-double-write-buffer.md)는 왜 페이지가 홈보다 먼저 DWB에 내구적으로 도달해야 하는지를 설명한다. 이 챕터는 어떻게 — dwb_flush_block, dwb_write_block, 그리고 다섯 가지 헬퍼 함수를 — 분기별로 추적한다. 블록 전체를 플러시 데몬에 넘기는 프로듀서(page-buffer flush path)는 이 챕터에서 재도출하지 않는다 — cubrid-page-buffer-manager-detail.md를 참고한다. 두 가지 쓰기 프리미티브 fileio_write / fileio_write_pages와 fileio_synchronize fsync 래퍼는 디스크 매니저에 속한다 — 재시도, 부분 쓰기 처리, O_DIRECT vs 버퍼드 모드의 내부 동작은 cubrid-disk-manager-detail.md를 참고한다. 이 챕터에서는 이들을 “이 페이지들을 쓰라”와 “이 파일 디스크립터를 fsync하라”는 원자 연산으로 간주한다. 새로운 구조체는 없다: Ch 1의 DWB_BLOCK, Ch 1·4의 DWB_SLOT을 재사용하고, FLUSH_VOLUME_INFO를 새로 소개한다.
블록의 페이지들은 여러 데이터 볼륨에 걸쳐 있을 수 있다. 플러셔는 홈에 페이지를 쓴 뒤 각 볼륨을 정확히 한 번 fsync해야 하며, 두 스레드가 동일한 볼륨을 중복 sync하지 않도록 그 fsync를 헬퍼에 위임할 수 있어야 한다. FLUSH_VOLUME_INFO가 이 조율을 경쟁 없이 가능하게 한다.
fsync 대상. dwb_add_volume_to_block_flush_area에서 vol_fd 인수로 설정되며, 상위에서 fileio_get_volume_descriptor로 획득한 값이다.
num_pages
홈에 써졌지만 아직 fsync되지 않은 페이지 수
헬퍼가 이미 0으로 만든 볼륨을 플러셔가 건너뛸 수 있게 하고, max_pages_to_sync로 큰 볼륨을 헬퍼에 위임할 수 있게 한다. volatile: 플러셔가 쓰고, 헬퍼가 읽는다.
all_pages_written
이 볼륨의 모든 페이지가 쓰여진 뒤 true가 됨
프로듀서-컨슈머 게이트: 플러셔가 더 이상 페이지를 추가하지 않겠다는 약속이 내려지기 전까지 헬퍼는 fsync해선 안 된다.
metadata
fileio_synchronize에 전달되는 ensure_metadata 인수
볼륨이 확장된 경우 inode/size 메타데이터도 함께 sync해야 한다. 해당 볼륨을 건드리는 모든 슬롯에 걸쳐 OR 연산된다.
flushed_status
이 볼륨의 fsync를 어느 스레드가 선점했는지를 나타냄
3-상태 열거형(VOLUME_NOT_FLUSHED / VOLUME_FLUSHED_BY_DWB_FILE_SYNC_HELPER_THREAD / VOLUME_FLUSHED_BY_DWB_FLUSH_THREAD). CAS 중재자로서 정확히 한 번만 fsync되도록 보장한다. 플러셔와 헬퍼(Ch 7) 모두 NOT_FLUSHED에서 자신의 값으로 CAS를 시도하며, 이긴 쪽이 fsync하고 진 쪽은 건너뛴다.
flowchart LR
blk["DWB_BLOCK"] -->|"flush_volumes_info[0..count]"| fvi["볼륨별 FLUSH_VOLUME_INFO"]
fvi -->|"flushed_status CAS"| arb["플러셔 vs 헬퍼 중재"]
fvi -->|"num_pages / all_pages_written"| handoff["프로듀서-컨슈머 게이트"]
Figure 6-1. 블록은 볼륨별 플러시 레코드 배열을 소유하며, 각 레코드가 해당 볼륨의 fsync를 중재한다.
불변 조건 (볼륨당 정확히 한 번 fsync). {플러셔, 헬퍼} 중 최대 하나만 각 레코드의 fileio_synchronize를 실행한다. 이는 flushed_status에 대한 ATOMIC_CAS_32로 VOLUME_NOT_FLUSHED에서부터 강제된다. 이 조건이 깨지면 두 스레드가 동일한 디스크립터를 fsync하게 되고, 승자가 ATOMIC_TAS_32로 0으로 만든 num_pages가 이중 계산된다. CAS 덕분에 num_pages == 0 -> 건너뜀 단축 경로가 안전하다. (이 동일한 CAS의 헬퍼 측은 Ch 7에서 상세히 다룬다.)
6.2 정렬된 스냅샷 구축: dwb_block_create_ordered_slots 및 관련 함수
dwb_flush_block은 먼저 슬롯 배열의 VPID 정렬된 프라이빗 사본을 구축한다. 이렇게 하면 (a) 동일 볼륨의 페이지들이 연속으로 배치되어 디스크립터 조회와 fsync가 각각 한 번씩만 필요하고, (b) 중복 VPID가 인접하게 위치하여 오래된 사본을 버릴 수 있다.
*p_ordered_slots_length = block->count_wb_pages +1; /* length INCLUDES the sentinel */
두 가지 분기가 있다: malloc 실패 시 OOM을 반환하고(호출자 dwb_flush_block이 ER_FAILED로 변환한 뒤 goto end), 그 외에는 사본을 정렬하여 반환한다. qsort는 처음 count_wb_pages 항목만 정렬하므로 센티넬은 높은 위치에 고정된다. dwb_init_slot은 해당 슬롯을 센티넬로 만든다 — NULL VPID, LSA, io_page(slot->io_page = NULL; VPID_SET_NULL (&slot->vpid); LSA_SET_NULL (&slot->lsa);) — 이로써 dwb_write_block의 [i + 1] 프로브가 별도의 경계 검사 없이 안전하게 실행된다.
dwb_compare_slots는 (volid, pageid, lsa.pageid, lsa.offset) 순서로 사전식 정렬을 수행한다. LSA 타이브레이크는 중요하다: 같은 VPID를 가진 두 슬롯은 오래된 사본(더 작은 LSA)이 낮은 인덱스에 위치하며, dedup 루프는 이를 기반으로 더 새로운 사본을 보존한다.
불변 조건 (센티넬이 있는 전체 정렬 순서). 인덱스 0..count_wb_pages-1은 dwb_compare_slots 기준으로 비감소 순서다; 인덱스 count_wb_pages는 NULL 센티넬(qsort + dwb_init_slot으로 강제됨). 비-NULL 센티넬이면 dwb_write_block의 assert (VPID_ISNULL (&p[i+1].vpid) || VPID_LT (...)) 검증이 마지막 실제 페이지에서 실패한다.
분기 완전 번호 순 설명 (S0–S8). 네 곳의 goto end 지점이 있다 — (1) ordered snapshot 구축 OOM (S1), (2) DWB 볼륨 fileio_write_pages가 NULL 반환 (S5), (3) DWB 볼륨 fileio_synchronize 불일치 (S5), (4) dwb_write_block이 non-NO_ERROR 반환 (S6) — 이 네 곳과 정상 fall-through 모두 end:에 도달한다. 내구성 배리어는 S5다.
S0은 단일 플러셔를 assert한다(ATOMIC_INC_32 (&dwb_Global.blocks_flush_counter, 1); assert (dwb_Global.blocks_flush_counter <= 1);). 대응하는 -1은 end:에서 이루어지며, 모든 goto end 분기를 포함한 모든 종료 경로에서 실행된다 — 이것이 에러 경로들이 return 대신 jump를 사용하는 이유다. S1은 정렬된 스냅샷을 구축한다(6.2 참고). 실패 시 error_code = ER_FAILED; goto end. S2는 아래의 dedup 루프로, i = 0 .. count_wb_pages - 2 범위(for (i = 0; i < count_wb_pages - 1; i++))를 순회한다. 따라서 s2 = &p[i+1]은 최대 마지막 실제 슬롯에 도달하며, 루프는 센티넬을 건드리지 않는다. S3 (SERVER_MODE)은 file_sync_helper_block != NULL인 동안 스핀하여, 이전 블록이 내구적으로 완료된 뒤에야 이번 블록의 홈 쓰기가 시작되도록 보장한다: 헬퍼가 살아있으면 thread_sleep(1) 후 재시도; 헬퍼가 완료되면 플러셔가 직접 이전 블록의 볼륨을 fsync하고(if (ATOMIC_INC_32(&num_pages, 0) >= 0) 가드는 사실상 항상 참이어서 모든 볼륨이 sync됨) ATOMIC_TAS_ADDR로 file_sync_helper_block을 NULL로 만들어 루프를 종료한다. S4는 ATOMIC_TAS_32 (&count_flush_volumes_info, 0)과 all_pages_written = false로 초기화한다.
dedup 분기는 인접한 두 슬롯의 VPID가 같을 때만 실행된다. 세 가지 작업을 수행한다: 오래된 사본을 스냅샷과 라이브 블록 양쪽에서 null 처리하고(홈 쓰기 시 dwb_write_block이 건너뛰고, 해시 삭제 패스도 생존 슬롯을 그대로 둔다), 버려진 슬롯의 io_page를 fileio_initialize_res로 재초기화하며(S5가 write_buffer 전체를 DWB 볼륨에 쓰기 때문에 — 버려진 페이지는 오래된 바이트가 아닌 깨끗이 초기화된 페이지여야 한다), ensure_metadata를 생존 슬롯으로 전달한다. #if !defined(NDEBUG) WAL 검사(logpb_need_wal을 페이지 LSA에 적용)가 같은 루프 본문에 있지만, 디버그 전용 프로브이며 런타임 분기에는 영향을 주지 않는다.
write_buffer 전체(방금 재초기화된 dedup 페이지를 포함해 무해한 깨끗한 페이지로 기록됨)가 단일 DWB 볼륨에 하나의fileio_write_pages 호출로 기록된다(cubrid-disk-manager-detail.md에서 재시도/부분 쓰기 처리 방법 참고). NULL 반환과 dwb_Global.vdes 이외의 값 반환이 두 goto end 분기다. DWB 볼륨 fsync는 ensure_metadata = false를 전달하는데, DWB 볼륨은 고정 크기로 사전에 생성되기 때문이다 — 홈 볼륨만 확장될 수 있다. S6에서 dwb_write_block (thread_p, block, p_dwb_ordered_slots, ordered_slots_length, file_sync_helper_can_flush, /*remove_from_hash=*/true)를 호출한다(6.4 참고). 에러 시 assert (false); goto end.
불변 조건 (내구성 배리어 — DWB 존재의 이유).dwb_Global.vdes의 fileio_synchronize는 첫 번째 홈 쓰기가 시작되기 전에 반드시 완료되어야 한다 — 직선적인 시퀀싱으로 강제된다(dwb_write_block이 바로 다음 문장이며, sync의 에러 분기 goto end가 이를 건너뛴다). 이 조건이 깨지면 홈 쓰기 중간의 크래시로 홈 페이지가 찢기는데, DWB 사본도 아직 내구적이지 않아 복구(Ch 9)가 손상된 홈 페이지와 유효한 DWB 사본 없이 직면하게 된다. 배리어가 유지되는 한, 크래시 시점에 쓰이고 있거나 곧 쓰일 모든 홈 페이지는 DWB 볼륨에 완전하고 fsync된 쌍둥이 사본이 존재한다. Ch 9의 재시작 스캔이 그 쌍둥이를 찢긴 홈에 복사해 torn-page 복구를 항상 가능하게 한다.
S7 — 홈 볼륨별 fsync (fvi는 아래에서 block->flush_volumes_info의 약자):
세 개의 continue — (a)/(b)/(c) — 는 인라인으로 주석 처리되어 있다. (a)와 (c)는 모두 헬퍼가 먼저 도달했을 때 실행된다 — (a)는 소진된 카운터를 보고, (c)는 status CAS에서 진다. (b’)의 else 분기(SA_MODE 또는 데몬 없음처럼 file_sync_helper_can_flush가 false일 때)는 헬퍼가 없음을 assert하므로, 남아있는 모든 볼륨이 인라인으로 fsync된다. 플러셔가 볼륨을 선점하면 fsync 전에 ATOMIC_TAS_32로 num_pages를 0으로 만들어 헬퍼의 num_pages == 0 체크가 건너뛰도록 한다.
S8 — 고정 순서로 완료 게시: all_pages_written = true (S7 종료 시, 헬퍼가 마무리할 수 있도록) -> ATOMIC_TAS_32 (&count_wb_pages, 0) -> ATOMIC_INC_64 (&version, 1ULL) -> WRITE-STARTED 비트 클리어 -> next_block_to_flush 진행 -> dwb_signal_block_completion -> *current_position_with_flags 저장. 두 CAS 지점은 재시도 정책이 다르다: 공유 position word에서 WRITE-STARTED 비트를 클리어하는 것(Ch 2, DWB_ENDS_BLOCK_WRITING 경유)은 동시 프로듀서가 해당 word를 건드릴 수 있어 reset_bit_position: 재시도 루프를 사용한다; next_block_to_flush 진행은 CAS로, 실패 분기에서 assert_release (false)를 실행한다 — 단일 플러셔만이 이를 진행시키므로 반드시 이겨야 한다. dwb_signal_block_completion은 대기 큐를 해제하고 블록된 스레드를 깨운다(Ch 8); version++는 오래된 리더(Ch 8)에게 블록이 재활용되었음을 알린다.
S end — 정리. fall-through와 모든 goto end에서 도달: ATOMIC_INC_32 (&blocks_flush_counter, -1), 스냅샷이 할당된 경우 free_and_init, PSTAT_DWB_FLUSH_BLOCK_TIME_COUNTERS 충전, return error_code.
flowchart TD
s1["S1 정렬된 스냅샷 구축"] --> s2["S2 인접한 동일-VPID dedup"]
s2 --> s3["S3 이전 블록 내구성 대기"]
s3 --> s5["S5 DWB 볼륨 쓰기+fsync -- 배리어"]
s5 --> s6["S6 dwb_write_block: 홈으로 페이지 분산 쓰기"]
s6 --> s7["S7 홈 볼륨별 fsync"]
s7 --> s8["S8 완료 게시, version++, signal"]
s5 -.->|"sync 실패"| e["end: 카운터--, free, return"]
s8 --> e
Figure 6-2. 플러시 시퀀스. S5 이후의 모든 작업(홈 쓰기 + 홈 fsync)은 S5에서 fsync된 DWB 사본으로 복구 가능하다.
dwb_write_block은 스냅샷을 순회하며 살아있는 각 페이지를 홈에 쓰고, 볼륨 경계에서 flush_volumes_info[]를 구축하며, 기회가 되면 볼륨을 헬퍼에 위임한다. 페이지별 루프(모든 분기 포함):
NULL VPID (dedup된 슬롯 또는 후행 센티넬): continue — 쓰지 않고 카운트하지 않는다.
새 volid: 이전 레코드를 닫고(all_pages_written = true, can_flush_volume 올림, current_flush_volume_info = NULL 초기화), fileio_get_volume_descriptor를 호출한다. 드롭된 볼륨(NULL_VOLDES)은 레코드를 열지 않고 continue — 페이지는 조용히 건너뛴다. 그 외에는 dwb_add_volume_to_block_flush_area로 새 레코드를 연다.
같은 volid: current_flush_volume_info->metadata = metadata || ensure_metadata.
fileio_write HOME (NO_COMPENSATE_WRITE; 디스크 매니저 프리미티브; cubrid-disk-manager-detail.md 참고). 유일한 NULL 반환 시 return ER_FAILED를 직접 수행한다(not goto end) — dwb_flush_block이 이를 자신의 goto end로 변환한다.
ATOMIC_INC_32 (¤t_flush_volume_info->num_pages, 1); /* count this home write; count_writes++ too */
if (file_sync_helper_can_flush && (count_writes >= num_pages_to_sync || can_flush_volume ==true)
&&dwb_is_file_sync_helper_daemon_available ())
{
if (ATOMIC_CAS_ADDR (&dwb_Global.file_sync_helper_block, (DWB_BLOCK *) NULL, block)) /* claim only if NULL */
dwb_file_sync_helper_daemon->wakeup ();
/* then reset count_writes = 0, can_flush_volume = false */
}
루프 뒤에 세 가지 후속 동작이 있다: 마지막으로 열린 볼륨을 닫고(all_pages_written = true); 작은 블록으로 count_writes가 임계값에 도달하지 않은 경우를 위한 두 번째 헬퍼 깨우기(file_sync_helper_block == NULL && count_flush_volumes_info > 0 조건); 그리고 remove_from_hash가 설정된 경우 해시 삭제 패스(6.5).
불변 조건 (볼륨 레코드는 페이지 카운트보다 먼저 게시됨).dwb_add_volume_to_block_flush_area는 레코드를 게시(count_flush_volumes_info에 ATOMIC_INC_32)한 뒤에야 첫 번째 fileio_write/num_pages++에 사용할 레코드 포인터를 반환하므로, 헬퍼가 배열 범위 밖의 레코드에서 num_pages를 읽어 페이지를 놓치는 일이 발생하지 않는다.
remove_from_hash가 true일 때(dwb_flush_block의 호출에서는 항상 그러하다), dwb_write_block의 후미에서 살아남은 각 슬롯을 전역 VPID→slot 해시에서 삭제한다. 이로써 미래의 프로듀서가 플러시된 페이지를 더 이상 찾지 못하게 된다. 루프는 스냅샷 사본이 아닌 라이브block->slots[p_dwb_ordered_slots[i].position_in_block]를 대상으로 dwb_slots_hash_delete를 호출한다. 아래의 slot == slot 동일성 검사가 해시에 저장된 정확한 주소를 비교할 수 있도록 하기 위해서다:
pthread_mutex_unlock (&slots_hash_entry->mutex); /* a newer slot owns this VPID now: just unlock */
세 가지 분기(인라인 주석): NULL VPID 건너뜀; 항목 없음 건너뜀; 항목 발견 — slots_hash_entry->slot == slot인 경우(아직 이 정확한 슬롯)에만 erase_locked, 그렇지 않으면 해시가 VPID를 다른 슬롯(더 최신 프로듀서가 새로운 사본으로 재바인딩한 것)에 매핑하므로 삭제하지 않고 잠금만 해제한다. 이 else 분기는 플러셔가 동시에 쓰인 다른 슬롯의 항목을 삭제하지 못하도록 막는다. 소스 측 guard는 발견된 항목의 VPID가 NULL이거나 우리 것과 같은지(VPID_ISNULL (&entry->slot->vpid) || VPID_EQ (...)) 동일성 검사 전에 assert한다. find는 항목 mutex를 보유한 채 반환한다; erase_locked 경로와 else 경로 모두 이를 해제한다(erase_locked는 내부적으로; else는 명시적 pthread_mutex_unlock으로).
ATOMIC_INC_32 (&block->count_flush_volumes_info, 1); /* publish AFTER all fields are set */
return flush_new_volume_info;
모든 필드를 설정한 뒤 카운트를 원자적으로 증가시킨다 — 소스 주석은 “코드 재정렬을 방지하기 위해” atomic을 사용한다고 명시한다(쓰는 쪽은 하나, 읽는 쪽은 둘: 플러셔와 헬퍼). 데몬이 절반만 구성된 원소를 보는 일이 없도록 한다. 후미의 assert는 카운트가 정확히 1 증가했고 max_to_flush_vdes 이하(블록 생성 시 배열 크기, Ch 3)임을 확인한다.
dwb_compare_vol_fd(return ((*vol_fd1) - (*vol_fd2));)는 단순한 int 디스크립터 qsort 비교자다. 이는 플러시 경로에서 호출되지 않는다(이 개정 시점에서 double_write_buffer.cpp 내에 호출자가 없다). 디스크립터 배열 비교자로 보존되어 있으며, 동일한 flush-area 북키핑 패밀리에 속하기 때문에 여기서 언급할 뿐이다. dwb_flush_block 또는 dwb_write_block에서 이를 호출한다고 추론하지 말 것.
내구성 배리어는 단 한 줄이며, 그것이 DWB가 존재하는 이유다.write_buffer 전체가 하나의 fileio_write_pages(NO_COMPENSATE_WRITE)로 단일 DWB 볼륨에 기록되고, 바로 이어지는 fileio_synchronize (dwb_Global.vdes, ...)가 dwb_write_block이 홈으로 페이지를 분산 쓰기 시작하기 전에 엄격히 순서가 보장된다. sync의 에러 분기 goto end는 홈 쓰기를 건너뛴다. DWB 사본이 먼저 fsync-내구적이 되므로, 크래시 이후 찢긴 홈 페이지가 발견되면 그 깨끗한 쌍둥이를 복원할 수 있다(Ch 9).
정렬된 스냅샷은 두 가지 역할을 동시에 수행한다.(volid, pageid, lsa) 기준 정렬로 볼륨이 연속으로 배치되어(각각 한 번의 fsync) 같은 VPID 중복본이 인접하게 위치한다. dedup 루프는 더 높은 LSA 사본을 보존하고, 오래된 것을 스냅샷과 라이브 블록 양쪽에서 null 처리하며, fileio_initialize_res로 버려진 페이지를 재초기화해 DWB 쓰기가 깨끗한 페이지를 내보내도록 한다. count_wb_pages 위치의 NULL 센티넬은 [i+1] 프로브를 경계 안전하게 만든다.
fsync는 3-상태 CAS로 볼륨당 정확히 한 번 실행된다.flushed_status + ATOMIC_CAS_32가 플러셔와 헬퍼를 중재한다. num_pages == 0과 max_pages_to_sync 임계값이 큰 볼륨을 헬퍼에 위임하며, CAS에서 진 쪽은 건너뛴다. 레코드는 모든 필드가 설정된 뒤에만 게시되며, 해시 삭제 패스(dwb_write_block 내부, dwb_flush_block이 아님)는 해당 항목이 아직 이 라이브 슬롯을 가리킬 때만 삭제한다.
완료는 고정 순서로 게시된다.all_pages_written -> count_wb_pages = 0 -> version++ -> WRITE 비트 클리어(CAS 재시도 루프) -> next_block_to_flush 진행(실패 분기가 assert_release (false)인 CAS) -> dwb_signal_block_completion. version 증가는 오래된 리더(Ch 8)에게 블록이 재활용되었음을 알린다.
두 가지 쓰기 프리미티브와 플러시 프로듀서는 빌려온 것이며 재도출하지 않는다.fileio_write / fileio_write_pages / fileio_synchronize는 디스크 매니저(cubrid-disk-manager-detail.md)에 속한다. 블록을 채우고 위임하는 page-buffer flush path는 cubrid-page-buffer-manager-detail.md에 있다. 볼륨별 fsync CAS를 공유하는 헬퍼 스레드는 Ch 7에서 다룬다.
DWB는 두 개의 백그라운드 스레드로 스스로를 비워낸다. "dwb-flush-block" 으로 등록된 플러시 드라이버(1 ms 루퍼)는 가득 찬 블록을 하나 골라 쓰기-동기화 파이프라인 전체를 수행하고, "dwb-file-sync" 로 등록된 헬퍼(10 ms 루퍼)는 가장 느린 부분 — 홈 볼륨별 fileio_synchronize 호출 — 을 분리해 처리한다. 이 덕분에 플러셔는 디스크 I/O에 묶이지 않고 후속 라이터들이 DWB 볼륨을 계속 채울 수 있다. 인계(handoff)는 단일 포인터 (dwb_Global.file_sync_helper_block)와 볼륨별 원자적 상태 필드 하나로 이루어진다. 이 장에서는 그 인계 구조, 데몬 본체, 그리고 가용성 판별 조건을 해부한다.
교차 참조: double-buffering과 torn-page 이론은 상위 개요 문서 cubrid-double-write-buffer.md에, 블록 채우기와 dwb_flush_block 본체는 5장과 6장에 있다.
두 데몬은 모두 모듈 전역 cubthread::daemon * 포인터(dwb_flush_block_daemon, dwb_file_sync_helper_daemon)이며, #if defined (SERVER_MODE) 아래에서만 정의되고 생성 전까지는 NULL이다. 이들에 접근하는 모든 경로는 PRM_ID_ENABLE_DWB_FLUSH_THREAD로 게이트되며, 이 파라미터가 꺼져 있으면 DWB는 라이터 스레드에서 인라인 플러시로 동작하는 축소 모드로 전환된다. 각 데몬 본체 내부에는 두 번째 게이트인 BO_IS_FLUSH_DAEMON_AVAILABLE()이 있어 boot_Enabled_flush_daemons(부트·재시작 중에 클리어됨)를 읽는다. 두 조건이 모두 충족되어야 한다: 서버가 부트를 마쳤고, 그리고 운영자가 명시적으로 활성화했어야 한다.
Figure 7-1은 인계 슬롯 주변의 관계를 보여준다.
flowchart LR
FB["dwb-flush-block 데몬<br/>루퍼 1 ms"]
FH["dwb-file-sync 데몬<br/>루퍼 10 ms"]
HP["file_sync_helper_block<br/>단일 DWB_BLOCK* 슬롯"]
HOME["홈 볼륨<br/>fileio_synchronize"]
FB -->|"CAS NULL to block + wakeup"| HP
HP -->|"dwb_file_sync_helper가 읽음"| FH
FH --> HOME
FB -->|"대체: 헬퍼 미가용 시 인라인 fsync"| HOME
Figure 7-1. 인계 슬롯. 등록된 데몬 이름은 "dwb-flush-block"과 "dwb-file-sync"이다 (후자는 함수명 접두사 dwb_file_sync_helper_*와 달리 "dwb-file-sync-helper"가 아니다).
dwb_daemons_init는 DWB 생성 시 한 번 실행되며, dwb_flush_block_daemon_init() 다음에 dwb_file_sync_helper_daemon_init()을 순서대로 호출해 두 데몬을 생성한다. 각각 cubthread::looper(깨어나는 주기)와 태스크 객체를 만든 뒤 cubthread::get_manager()->create_daemon(looper, task, name)을 호출한다. 두 데몬은 주기와 래퍼 방식에서 차이가 있다:
dwb_flush_block_daemon_init: looper(milliseconds(1)) + 전용 dwb_flush_block_daemon_task, 이름 "dwb-flush-block".
dwb_file_sync_helper_daemon_init: looper(milliseconds(10)) + 범용 entry_callable_task(dwb_file_sync_helper_execute), 이름 "dwb-file-sync".
각 init 함수 앞에는 파일 스코프에서 REGISTER_DAEMON(...) 매크로가 정적으로 데몬 이름을 스레드 매니저에 등록한다(REGISTER_DAEMON (dwb_flush_block), REGISTER_DAEMON (dwb_file_sync_helper)).
이 비대칭은 의도적이다. 플러셔의 전용 태스크 클래스는 cond-wait 시간을 측정하는 PERF_UTIME_TRACKER를 담고 있는 반면(7.3절), 상태가 없는 헬퍼는 범용 래퍼만으로 충분하다. 주기 차이도 중요하다 — 플러셔는 블록이 꽉 찼을 때 즉각 반응해야 하고(그러지 않으면 라이터들이 빈 슬롯을 기다리며 멈춘다), 헬퍼는 자신에게 넘겨진 볼륨만 처리하면 된다.
dwb_daemons_destroy는 플러셔를 먼저 destroy_daemon(...)으로 종료한다. destroy_daemon은 스레드를 join하므로 반환 시점에 두 데몬 본체가 실행 중인 상태는 없다. 포인터는 NULL이 되지 않고 유지되며, 종료 처리(10장)에서 생산자가 이미 소멸된 데몬을 역참조하지 않도록 보장한다.
dwb_flush_block_daemon_task는 cubthread::entry_task를 상속하고, 생성자에서 시작되는 멤버 PERF_UTIME_TRACKER m_perf_track 하나를 갖는다. execute 오버라이드가 매 틱의 진입점이며, 두 게이트를 준수하는 얇은 래퍼다:
PERF_UTIME_TRACKER 쌍은 작업 구간을 감싸 PSTAT_DWB_FLUSH_BLOCK_COND_WAIT 카운터가 유휴 시간만 누적하도록 한다: _TIME은 활성 틱 시작 시점에 클록을 멈추고, _START는 본체가 슬립으로 돌아가기 직전에 재시작한다. assert_release(false)는 실질적 의미를 갖는다 — 플러시 데몬에는 복구 경로가 없으므로, 플러시 오류는 블록을 반쯤 플러시한 채 두는 대신 서버를 종료한다.
dwb_flush_next_block이 실제 드라이버다 — goto start로 구동되는 루프가 준비된 블록을 모두 비운다. 매 이터레이션은 position_with_flags = ATOMIC_INC_64 (&dwb_Global.position_with_flags, 0ULL)를 다시 읽고 분기한다: (1)!DWB_IS_CREATED || DWB_IS_MODIFYING_STRUCTURE이면 return NO_ERROR; (2)dwb_get_next_block_for_flush가 block_no < DWB_NUM_TOTAL_BLOCKS를 반환하면 dwb_flush_block (..., true /* file_sync_helper_can_flush */, NULL) 호출; (3) 오류 시 return error_code (호출자가 assert_release(false)로 전환); (4) 성공 시 또 다른 블록이 가득 찰 수 있으므로 goto start; (5) 가득 찬 블록이 없으면 이번 틱은 return NO_ERROR로 종료.
position_with_flags 재독(2장)은 루프를 종료 안전하게 만든다 — 구조 변경은 MODIFYING을 세트하므로 다음 이터레이션이 branch 1에서 탈출한다. branch 2 대 5는 dwb_get_next_block_for_flush가 결정하는데, 이 함수는 먼저 sentinel *block_no = DWB_NUM_TOTAL_BLOCKS를 세트한 뒤 dwb_Global.blocks[next_block_to_flush].count_wb_pages == DWB_BLOCK_NUM_PAGES일 때만 dwb_Global.next_block_to_flush로 덮어쓴다 — 즉 next_block_to_flush만 제공하며, 완전히 가득 찬 경우에만 제공한다.
불변 조건 — 블록은 채워진 순서대로 플러시된다. 데몬은 임의의 가득 찬 블록을 고르지 않고, 오직 next_block_to_flush만 선택한다; 커서는 dwb_flush_block 내부에서(dwb_Global.next_block_to_flush에 대한 ATOMIC_CAS_32 근처, 본체 말미에서) 라운드 로빈으로 전진하므로, 디스크 상의 쓰기 순서는 버퍼링 순서와 일치한다. 크래시 복구 재플레이(9장)는 이 순서에 의존한다 — 순서가 어긋나면 더 최신 페이지 버전이 더 오래된 것보다 먼저 기록되어 복구 시 낡은 데이터가 부활할 수 있다. count_wb_pages 가드는 블록의 모든 슬롯이 기록되었을 때만 인계가 이루어지도록 보장한다. 호출은 file_sync_helper_can_flush = true를 전달해 홈 볼륨 fsync를 헬퍼에게 위임할 수 있는 신호를 준다(본체는 6장, 수신측은 7.4절).
틱 단위 래퍼 dwb_file_sync_helper_execute는 execute와 동일하게 두 게이트를 적용한다 — !BO_IS_FLUSH_DAEMON_AVAILABLE()이면 return, 이후 PRM_ID_ENABLE_DWB_FLUSH_THREAD일 때만 dwb_file_sync_helper(&thread_ref) 호출 — 그러나 오류 에스컬레이션이 없다: 반환값을 무시한다(최선 노력 방식; 놓친 볼륨은 플러셔가 인라인으로 동기화, 7.5절). dwb_file_sync_helper는 인계 포인터를 한 번 읽은 뒤 해당 블록의 flush_volumes_info 배열을 순회한다(Figure 7-2).
flowchart TD
B{"생성됨이고 구조 변경 중 아님?"}
B -- no --> Z1["return NO_ERROR"]
B -- yes --> D{"file_sync_helper_block이 NULL?"}
D -- yes --> Z1
D -- no --> E["count_flush_volumes_info와 all_pages_written 스냅샷"]
E --> F["i = start_flush_volume .. count 반복"]
F --> G{"이미 FILE_SYNC_HELPER?"}
G -- no --> H{"CAS NOT_FLUSHED → FILE_SYNC_HELPER"}
H -- fail --> F
H -- ok --> J["볼륨 소유"]
G -- yes --> J
J --> K{"num_pages가 num_pages_to_sync 미만?"}
K -- "부분, 더 올 예정" --> L["need_wait, break"]
K -- "충분 OR 소량이지만 완료" --> M["TAS num_pages=0, fileio_synchronize"]
M --> F
L --> N["can_flush_volume 재계산"]
F -->|루프 종료| N
N --> O{"can_flush_volume?"}
O -- yes --> E
O -- "no, need_wait" --> P["thread_sleep 1, can_flush_volume=true"]
P --> E
O -- "no, 완료" --> Q["TAS_ADDR file_sync_helper_block=NULL, return"]
Figure 7-2. 헬퍼의 볼륨에 대한 외부 do-while.
두 조기 반환(branch 1 !DWB_IS_CREATED || DWB_IS_MODIFYING_STRUCTURE, branch 2 block == NULL) 이후, 본체는 내부 볼륨 for 루프를 감싸는 외부 do { ... } while (can_flush_volume == true)다. 재시도 세부 사항: need_wait가 세트되고 어떤 볼륨도 플러시하지 못했을 때 헬퍼는 thread_sleep(1)을 수행한 뒤 can_flush_volume = true를 세트해, 슬립 동안 도착한 데이터를 처리하기 위해 do-while을 재진입한다(Figure 7-2 엣지 P --> E).
불변 조건 — 각 볼륨은 정확히 한 스레드가 fsync한다.flushed_status는 세 가지 상태의 열거형 FLUSH_VOLUME_STATUS (VOLUME_NOT_FLUSHED, VOLUME_FLUSHED_BY_DWB_FILE_SYNC_HELPER_THREAD, VOLUME_FLUSHED_BY_DWB_FLUSH_THREAD)이며, flush_volume_info의 멤버로 vdes, 원자적 num_pages, all_pages_written, metadata와 함께 있다. 헬퍼는 CAS NOT_FLUSHED → FILE_SYNC_HELPER로 클레임하고, 플러셔의 인라인 경로(7.5절, 6장)는 대칭적으로 FLUSH_THREAD로 CAS한다. 먼저 성공한 쪽이 fileio_synchronize를 수행하고, 실패한 쪽은 continue한다. 이를 깨면 볼륨이 두 번 fsync되거나, 원자성 없이는 둘 다 건너뛰어 복구 구멍이 생긴다. 실패 후의 assert_release는 패배한 쪽의 유일한 합법적 상태를 FLUSH_THREAD로 고정한다. TAS 이후의 assert_release(num_pages2 >= num_pages)는 동반 불변 조건을 고정한다: 두 읽기 사이에 동시 라이터는 페이지를 추가할 수만 있고 제거할 수 없다.
클레임 아래의 게이팅(J → K → ... 노드, Figure 7-2)은 두 목표의 균형을 잡는다: 라이터가 아직 스트리밍 중인 볼륨에 일부 더티 페이지만 있을 때 fsync를 하지 않고(break + need_wait), all_pages_written이 세트되면 무한정 대기하지 않는다(num_pages_to_sync = PRM_ID_PB_SYNC_ON_NFLUSH). ATOMIC_TAS_32(..., 0)은 카운트를 조율한다: 0으로 리셋하는 것은 “처리됨”을 의미하고, 동시 라이터가 다시 올리면 first_partial_flushed_volume으로 볼륨이 재방문된다(== -1 가드로 가장 이른 미완성 볼륨을 고정). 루프 이후 start_flush_volume이 전진하고(first_partial_flushed_volume이 있으면 그쪽으로, 없으면 마지막 볼륨을 지나), can_flush_volume/need_wait가 증가했을 수 있는 count_flush_volumes_info 기준으로 재계산된다; 최종 패스에서 ATOMIC_TAS_ADDR로 슬롯을 NULL로 해제한다(block == dwb_Global.file_sync_helper_block assert로 보호).
불변 조건 — 인계 슬롯에는 블록이 최대 하나.file_sync_helper_block은 단일 포인터이지 큐가 아니다. 생산자는 오직 ATOMIC_CAS_ADDR(NULL, block)으로만 설치하며 — NULL이 아닌 슬롯을 절대 덮어쓰지 않는다 — 헬퍼(또는 인라인 폴백, 또는 teardown)만이 클리어한다. 슬롯에 블록이 있는 동안 다른 블록은 넘겨질 수 없으므로, 플러셔의 “이전 블록 동기화 완료 대기” 루프(7.5절)가 블록을 직렬화한다 — 이것이 바로 블록이 채워진 순서대로 플러시되는 이유다(7.3절).
CAS 가드는 생산자 측에서 단일 슬롯 불변 조건을 적용한다: 이전 블록이 여전히 파킹되어 있으면 CAS가 실패하고 플러셔는 작업을 스스로 처리한다. dwb_write_block은 두 곳에서 설치를 시도한다 — 이 중간 스트림 체크와 마지막 볼륨 이후의 꼬리 체크 — 둘 다 동일한 CAS를 사용한다. 꼬리 체크는 file_sync_helper_can_flush && dwb_Global.file_sync_helper_block == NULL && block->count_flush_volumes_info > 0이고 헬퍼가 가용한 경우 발화하며, 위 루프가 한 번도 인계를 트리거하지 않은 케이스를 처리한다.
헬퍼가 가용하지 않을 때는 플러셔가 직접 볼륨을 동기화한다. dwb_flush_block 내부에는 두 개의 별개 인라인 동기화 지점이 있다: (a) 새 블록을 쓰기 전, while (file_sync_helper_block != NULL) 드레인 루프 — 헬퍼가 미가용이면 이전에 파킹된 블록의 볼륨을 fsync하고 ATOMIC_TAS_ADDR로 슬롯을 NULL로 클리어; (b) dwb_write_block 반환 후, NOT_FLUSHED → FLUSH_THREAD를 CAS하고 헬퍼가 클레임하지 않은 각 볼륨을 fsync하는 볼륨별 루프. 생산자의 동작은 전적으로 dwb_is_file_sync_helper_daemon_available() 결과에 달려 있다: 가용 → 게시하고 깨우기; 미가용 → 인라인 fsync. 정합성은 동일하며 지연 시간만 다르다. 6장은 해당 루프들과 세 번째 위임 지점을 해부한다: dwb_write_block 이후의 볼륨별 루프에서 num_pages > PRM_ID_PB_SYNC_ON_NFLUSH / 2인 볼륨은 헬퍼가 가용할 때 헬퍼에게 남겨지며(슬롯이 이미 NULL이 아님을 assert), 6장에서 다룬다.
라이터-투-플러셔 인계 역시 호출이 아닌 깨우기임을 주목하라: 블록이 꽉 차면 라이터 스레드는(dwb_acquire_next_slot의 블록-풀 분기로 이어지는 경로에서) dwb_is_flush_block_daemon_available()이면 dwb_flush_block_daemon을 깨우고 반환한다; 해당 주석은 “이전 블록 대기” 명시적 코드가 제거된 것을 기록하는데, 플러시 데몬이 이미 채워진 순서를 강제하기 때문이다. 플러시 데몬이 미가용일 때만 라이터가 자신의 스레드에서 dwb_flush_block(..., false, NULL)을 인라인으로 호출한다.
#if defined (SERVER_MODE) 아래에서 동일한 형태로 정의된 네 개의 boolean 헬퍼가 있으며, 그 외에서는 false를 반환한다(독립 실행형이 항상 인라인 플러시를 하는 이유). available 쌍(“이 작업을 위임해야 하는가?”)은 PRM_ID_ENABLE_DWB_FLUSH_THREAD == true && dwb_flush_block_daemon != NULL을 반환하고, running 쌍은 여기에 && dwb_flush_block_daemon->is_running()을 추가한다 — “데몬이 현재 본체 내부에 있는가?”. 파일-동기화-헬퍼 변형은 dwb_file_sync_helper_daemon에 같은 구조로 동작한다.
이 구분은 구조 변경(10장)에서 핵심적이다: 변경자는 MODIFYING을 세트한 뒤 ATOMIC_INC_32 (&dwb_Global.blocks_flush_counter, 0) > 0 || dwb_flush_block_daemon_is_running() || dwb_file_sync_helper_daemon_is_running()이 클리어될 때까지 thread_sleep(20)으로 스핀한다. *_available만으로는 부족하다 — 데몬이 가용한 상태이면서 플러시 중일 수 있기 때문이다; 변경자는 running이 클리어되어야만 단독 접근자가 될 수 있다. 데몬들의 틱별 position_with_flags 재독이 MODIFYING에서 탈출하게 하므로, 루프는 종료된다.
두 데몬이 하나의 파이프라인을 분담한다."dwb-flush-block"(1 ms)은 블록 하나의 전체 쓰기-동기화를 수행하고, "dwb-file-sync"(10 ms)는 홈 볼륨별 fileio_synchronize 호출을 분리한다. 헬퍼의 함수명 접두사는 dwb_file_sync_helper_*이지만 등록된 데몬 이름은 "dwb-file-sync"이다.
인계는 단일 포인터다.dwb_Global.file_sync_helper_block은 블록을 최대 하나만 보유하며, 생산자는 ATOMIC_CAS_ADDR(NULL → block)으로 설치하고 헬퍼는 ATOMIC_TAS_ADDR(→ NULL)로 클리어한다. 단일 슬롯이 블록을 직렬화한다.
볼륨별 소유권은 3-상태 열거형에 대한 CAS다.flushed_status(FLUSH_VOLUME_STATUS, flush_volume_info의 필드)는 NOT_FLUSHED → FILE_SYNC_HELPER 또는 → FLUSH_THREAD로 전이되며, CAS가 각 홈 볼륨을 정확히 한 스레드가 fsync하도록 보장하고, assert_release가 패배 상태와 num_pages2 >= num_pages 단조성을 고정한다.
블록은 채워진 순서대로 엄격하게 플러시된다.dwb_get_next_block_for_flush는 next_block_to_flush만 제공하며, 완전히 가득 찼을 때만 제공한다; 커서는 dwb_flush_block 내부의 CAS로 전진한다 — 복구 정합성의 불변 조건이다.
데몬 경로는 최선 노력이며 인라인 폴백이 있다.dwb_is_*_daemon_available()이 위임 여부를 결정하며, 미가용 시 dwb_flush_block이 파킹된 볼륨을 인라인으로 fsync한다(두 지점: 쓰기 전 드레인 루프와 쓰기 후 볼륨별 루프). 정합성은 동일하며 지연 시간만 다르다.
두 게이트와 부트 플래그가 모든 경로를 보호한다.PRM_ID_ENABLE_DWB_FLUSH_THREAD(선택적 활성화)와 BO_IS_FLUSH_DAEMON_AVAILABLE()이 모두 충족되어야 하며, 독립 실행형 빌드는 *_available을 false로 단락한다.
available ≠ running. 구조 변경은 dwb_*_daemon_is_running() — 포인터가 세트되어 있고 is_running() — 을 기다리므로, 변경자는 단독 접근자가 된 뒤에야 작업을 시작한다.
3~7장은 DWB 내부를 추적했다. 이 장은 외부 접점 — 엔진이 살아 있는 DWB를 건드리는 세 가지 경로를 다룬다: 읽기 측(pgbuf_fix 미스 시 슬롯에서 더 새로운 사본을 찾는 경우), 비-pgbuf 프로듀서(file_io.c의 직접 쓰기가 DWB를 경유해야 하는 경우), 그리고 강제 드레인. 슬롯 해시를 라이트-백 캐시로 활용하는 개요는 cubrid-double-write-buffer.md의 “Slot hash — turning the DWB into a read-side cache” 절을 참고하라. 여기서는 dwb_read_page, fileio_write_or_add_to_dwb, dwb_flush_force, dwb_synchronize, 그리고 pgbuf_bcb_safe_flush_internal → pgbuf_bcb_flush_with_wal 체인의 모든 분기를 추적한다.
pgbuf_fix의 미스 경로에서 fileio_read 호출 이전에, page_buffer.c의 콜 사이트는 세 갈래 if/else if/else if 안에서 dwb_read_page를 호출한다. 첫 번째 갈래(NO_ERROR가 아닌 경우)는 assert(false)를 실행한 뒤 아무것도 해제하지 않고 return NULL한다 — 실제로 dwb_read_page는 오류를 반환하지 않으며 항상 NO_ERROR를 돌려준다. 두 번째 갈래(success == true)는 디스크 읽기를 건너뛴다 — 슬롯에서 바로 복사한 것이다. 세 번째 갈래가 fileio_read를 실행하며, 읽기 오류가 발생할 경우 이 갈래에서만 BCB가 롤백된다(pgbuf_put_bcb_into_invalid_list + pgbuf_unlock_page). 이것이 읽기 계약의 전부다. dwb_read_page 자체는 짧지만 각 분기 하나하나가 중요하다:
/* assert prv.pageid/prv.volid still match vpid -- second invariant layer */
*success =true; } /* <- genuine hit */
pthread_mutex_unlock (&slots_hash_entry->mutex); } /* (D) find() left this locked */
return NO_ERROR;
(A)와 (B)는 직관적이다(Figure 8-1). find는 per-entry mutex를 보유한 채 엔트리를 반환하므로, (D)는 (C)의 두 하위 분기 모두에서 잠금을 해제한다. (C)의 VPID_EQ 재확인이 미묘한 부분이다: find 일치 후 mutex를 획득하기 전 사이에 엔트리의 slot 포인터가 다른 VPID를 가진 슬롯으로 교체될 수 있다(dwb_slots_hash_insert는 5장에서 설명한 대로 엔트리를 재사용한다). 따라서 memcpy 이전에 다시 검증한다. memcpy 직후의 assert는 복사된 바이트의 prv.pageid/prv.volid를 요청된 VPID와 비교하는 디버그 빌드 전용 두 번째 불변식 검사 계층이다.
불변식 — DWB 읽기 히트는 자기 일관성(self-consistent) 있는 페이지 이미지를 반환한다.memcpy는 entry mutex를 보유한 상태에서, 그리고 VPID_EQ 통과 후에만 실행된다. 프로듀서도 dwb_slots_hash_insert에서 동일한 mutex를 취득해야 한다. 재확인을 생략하면 읽기 측이 바이트가 이미 무관한 다른 페이지의 것으로 바뀐 슬롯을 복사하게 된다.
flowchart TB
A["dwb_read_page(vpid)"]
B{"dwb_is_created?"}
C["반환: *success=false\n(호출자가 fileio_read 실행)"]
D["slots_hashmap.find(vpid)\nentry 반환 + entry mutex 보유"]
E{"entry != NULL?"}
F{"VPID_EQ(slot->vpid, vpid)?"}
G["memcpy slot->io_page -> 호출자 buf\n*success=true"]
H["*success=false 유지\n(슬롯이 덮어써짐)"]
I["pthread_mutex_unlock(entry->mutex)"]
A --> B
B -- "no" --> C
B -- "yes" --> D --> E
E -- "no (미스)" --> C
E -- "yes" --> F
F -- "yes (히트)" --> G --> I
F -- "no (경쟁 발생)" --> H --> I
Figure 8-1 — dwb_read_page 분기 맵. VPID_EQ가 확인된 경로만 *success를 설정하며, 나머지 모든 경로는 홈 볼륨 읽기로 폴백한다.
dwb_read_page가 슬롯을 찾을 수 있는 것은 dwb_add_page가 해시에 등록한 이후뿐이다. 프로듀서는 먼저 스테이징(dwb_set_data_on_next_slot — 바이트는 복사됐으나 아직 해시 미등록 = STAGED)하고, WAL을 강제 플러시한 뒤, 공개(dwb_add_page = HASHED, §8.3)한다. STAGED와 HASHED 사이 구간에서는 slots_hashmap.find가 NULL을 반환하므로 동시 독자는 DWB를 미스하고 이전 홈 이미지를 읽게 된다.
불변식 — STAGED 구간에서 오래된 홈 이미지를 읽는 것은 안전하다. 새 이미지는 아직 내구적이지 않다(구간 시작 시점에는 WAL조차 강제되지 않은 상태다). 따라서 오래된 읽기는 직전 커밋된 이미지를 반환하며 — 이는 올바른 값이다. 해시는 가속기이지 정합성의 관문이 아니다. STAGED-but-unhashed 슬롯을 들여다보면 WAL이 아직 디스크에 없는 페이지가 노출된다.
8.3 페이지 버퍼 프로듀서 — pgbuf_bcb_safe_flush_internal에서 pgbuf_bcb_flush_with_wal까지
직관적이지 않은 분기가 두 개 있다. (b) WAL FORCE는 oldest_unflush_lsa가 non-NULL일 때만 실행된다 — NULL이면 건너뛴다(로그 없이 더티해진 페이지이며, 이는 허용된다). (d)에서 dwb_add_page(혹은 (a))가 dwb_slot == NULL을 반환하면 DWB가 플러시 도중 해체된 것이다 → BCB를 재잠금하고 비-DWB 경로로 복사를 재시도한다.
불변식 — WAL은 STAGE와 PUBLISH 사이에서 강제되며, 그 이후에는 실행되지 않는다.dwb_set_data_on_next_slot과 dwb_add_page 사이에 logpb_flush_log_for_wal이 삽입되어 있어, 슬롯이 독자에게 공개(§8.1)되는 시점(PUBLISH)에는 해당 WAL이 이미 디스크에 있음이 보장된다. 순서가 뒤집히면 크래시 시 DWB의 온-디스크 사본이 로그보다 앞설 수 있다.
두 프로듀서 모두 dwb_is_created()로 fileio_write 모드를 결정한다. 이는 “DWB가 현재 torn-page 방어 메커니즘으로 활성화되어 있는가”를 인코딩한다: DWB가 살아 있으면 FILEIO_WRITE_NO_COMPENSATE_WRITE(DWB가 torn-page 복구를 담당하므로 fileio_write는 자체적인 partial-write 보상을 건너뜀), 그렇지 않으면 FILEIO_WRITE_DEFAULT_WRITE(fileio_write가 유일한 torn-page 방어 수단이므로 partial write를 재발행함). §8.5의 (7)과 요약 항목 5에서 참조된다.
페이지 버퍼 프로듀서와 달리 STAGE 단계가 없고(호출자가 io_page_p를 소유하며 dwb_add_page에 직접 전달), WAL 강제도 없다(비로그 구조적 쓰기이기 때문). 인라인 태그가 분기를 설명한다: (2)는 §8.3의 is_temp 게이트를 반영하고, (5)/(6) — DWB 비활성 또는 비-permanent 볼륨 — 은 (7)의 직접 쓰기 폴백을 공유한다. CS_MODE에서는 이 함수가 단순한 fileio_write로 동작한다.
체크포인트, sync, 또는 dismount는 스테이징된 모든 내용이 먼저 내구적인 홈 스토리지에 도달했음을 보장해야 한다. “모든 블록을 플러시”하는 방식으로는 충분하지 않다(프로듀서가 블록을 채우는 도중일 수 있다). 대신 아직 플러시되지 않은 블록 중 가장 최신 것을 선택하고, 프로듀서가 멈춰 있으면 null 페이지로 패딩하면서 완료까지 밀어붙인다 — position_with_flags를 상태로 하는 goto 상태 머신이다:
// dwb_flush_force -- src/storage/double_write_buffer.cpp (goto graph; tags keyed to prose)
while (dwb_Global.file_sync_helper_block ==&dwb_Global.blocks[initial_block_no])
thread_sleep (1); // (L) wait out helper fsync
end:
*all_sync =true; return NO_ERROR; // (M) caller may skip fsync
인라인 태그 이외에 주목할 점: (E) 버전이 가장 높은 started 블록이 가장 최신의 미플러시 블록이다(이전 블록은 플러시 순서에 따라 이미 제거됐다). 따라서 이 블록을 드레인하는 것으로 충분하다. **(D)/(H)/(I)**는 tail 전용이다 — 해당 블록이 이미 플러시됐거나 새 버전으로 재사용됐으며, helper의 홈 fsync만 남은 상태다. (J)/(K) 패딩이 전진 보장 엔진이다:
불변식 — dwb_flush_force는 활성 프로듀서 없이도 전진한다. 반쯤 찬 블록이 채워지기를 기다리면, 강제자가 유일한 DWB 스레드일 경우 교착 상태에 빠진다. (J) 패딩은 DWB_BLOCK_NUM_PAGES에 도달할 페이지를 직접 만들어내어 플러시를 스스로 촉발한다.
flowchart LR
S["스냅샷"] -- "DWB 꺼짐" --> D["완료\nall_sync=true"]
S -- "helper 사용 중" --> T["HelperTail\nhelper 대기"]
S -- "블록 시작됨" --> P["블록 선택"] --> C{"플러시 확인"}
C -- "유휴, 공간 있음" --> J["null 페이지 패딩"] --> C
C -- "사라짐 / 버전 증가" --> T --> D
Figure 8-2 — dwb_flush_force 흐름. Pad 자기 루프가 전진 보장 엔진이다. (F)/(G)의 재시작/대기 경로는 생략.
직관적이지 않은 분기는 **(1)**이다: PRM_ID_SUPPRESS_FSYNC가 켜져 있으면 fileio_fsync_pending은 N번마다 한 번을 제외하고 true를 반환하므로, dwb_synchronize는 아무것도 건드리지 않고 반환한다(fsync 일괄처리 밸브). **(2)**는 permanent 볼륨에 한해서만 드레인한다(complete는 §8.6의 all_sync에 해당한다). (3) 직접 fsync는 complete == false일 때만 실행되어 이중 fsync를 방지한다.
불변식 — dwb_synchronize는 DWB 커버 볼륨을 이중으로 fsync하지 않는다. (3)의 complete == false 가드는 드레인이 홈 볼륨을 이미 sync했을 때는 명시적 fsync(vol_fd)를 실행하지 않는다. (3)이 무조건 실행된다면 DWB 커버 볼륨마다 요청당 두 번씩 fsync하는 셈이 된다.
두 기본 연산은 file_io.c 내에서 서로 다른 단위로 연결된다. 주의:fileio_synchronize 자체는 DWB를 드레인하지 않으며 직접 fsync한다. 드레인은 아래의 볼륨 연산 호출자에 위치한다.
호출자
단위
기본 연산
목적
fileio_synchronize_all
전체 데이터베이스
dwb_flush_force
DWB 드레인 후 !all_sync일 때만 볼륨 sweep
fileio_reset_volume
볼륨 하나
dwb_synchronize
페이지 LSA 초기화: 드레인 후 fsync
fileio_copy_volume
볼륨 하나
dwb_synchronize
fileio_write_or_add_to_dwb로 복사 후 내구성 선언 전 드레인
fileio_dismount
볼륨 하나
dwb_synchronize
디스크립터 닫기 전 드레인
fileio_dismount_volume
볼륨 하나
dwb_synchronize
dismount-all 중 볼륨별 sweep
fileio_synchronize_all은 dwb_flush_force(thread_p, &all_sync)를 호출한 후, all_sync == false일 때만 볼륨별 sweep를 수행한다. true이면 DWB 자체의 홈 볼륨 fsync(6장)가 모든 permanent 볼륨을 이미 커버한 것이다 — §8.7의 complete 단락을 전체 데이터베이스 단위로 구현한 방식이다. 참고 자료에는 fileio_synchronize_volume_and_dwb가 드레인 래퍼로 언급되지만, 해당 함수는 존재하지 않는다 — 드레인은 위 네 호출자가 각자 인라인으로 처리한다(이 드리프트는 9장에서 기록한다).
file_io.c 외부에도 두 개의 드레인 콜 사이트가 존재한다. disk_manager.c에 dwb_flush_force 하나와 dwb_synchronize 하나가 있으며, 둘 다 볼륨 포맷/확장 경로에 있다. 위 표와 동일한 계약을 따른다. 표의 범위를 file_io.c로 한정한 이유는 볼륨 내구성이 주로 그곳에서 구동되기 때문이다.
이 챕터는 하나의 질문에 답한다: 재시작 시, 디스크 상의 DWB 볼륨을 어떻게
읽어 들이고, torn home page를 복구하며, 새 볼륨을 다시 만드는가 — 그것도
로그 redo가 실행되기 전에? 상위 레벨 문서
(cubrid-double-write-buffer.md)는 torn-page 구간이 왜 존재하는지를 설명한다;
전체 재시작 파이프라인에서 이 단계의 위치는 cubrid-recovery-manager-detail.md
(pre-redo 단계)를 참고한다. 진입점은 dwb_load_and_recover_pages이며, 부트
시퀀스에서 정확히 한 번 호출되고, 복구 완료 후 다음 보장을 남긴다:
불변 조건 9-A (복구 인계).dwb_load_and_recover_pages가 NO_ERROR를
반환할 때, 디스크 상의 구 DWB 볼륨은 더 이상 존재하지 않고, 설정된 크기의
새 빈 DWB가 생성되어 있으며, 크래시 시점에 torn(절반만 쓰인) 상태였던 모든
home page는 마지막으로 완전히 플러시된 — 체크섬이 유효한 — DWB 이미지로
덮어써진 상태다. 따라서 로그 redo는 일관된(torn 되지 않은) 페이지 위에서
재생된다.
이 순서는 세 가지 측면에서 필수적이다: dwb_daemons_init이전 (복구가
DWB를 단독으로 점유 — 동시성 없음, 구조 변경 댄스 없음, Chapter 10 참고);
log_initialize / log_recovery이전 (redo가 torn 되지 않은 home을
받음); vacuum_initialize이후 (복구가 쓰기를 통과시키는 데이터 볼륨
디스크립터가 이미 마운트된 상태). 빈 DWB + 일관된 페이지 상태(불변 조건 9-A)
가 redo에 인계되는 계약이다.
외부 if와 내부 2의 거듭제곱 검사가 모든 재시작 형태를 다룬다(분류는 §9.10
항목 2 참고). DWB 볼륨 부재 경우(Branch A false) — 정상 종료 시 삭제됐거나
최초 부트인 경우 — 는 곧바로 dwb_create로 진행된다: 복구할 것이 없다.
Branch B-false가 더 중요하다: 소스 주석에 따르면 유효한 DWB 크기는 반드시
2의 거듭제곱이어야 하므로, 0이거나 2의 거듭제곱이 아닌 카운트는 볼륨이 생성
됐지만 일관되게 플러시된 적이 없음을 의미한다(생성 도중 크래시) — 복구를
건너뛰고, home에는 아무것도 쓰지 않으며, 제어 흐름은 여전히
dismount/unformat/재생성으로 진행된다.
원래 블록 수에 상관없이 단일 복구 블록. 복구는
dwb_create_blocks (thread_p, 1, num_dwb_pages, &rcv_block)을 호출한다:
Chapter 3의 설정된 블록당 크기가 아니라, 전체 디스크 상의 DWB 크기에 맞춘
하나의 DWB_BLOCK — 복구는 단일 fileio_read_pages로 채울 수 있도록 모든
페이지가 연속적으로 배치되기만 하면 된다.
공유 end: 레이블(모든 에러 goto와 성공 경로)은 p_dwb_ordered_slots를
해제하고, rcv_block을 정리 및 해제한 뒤 error_code를 반환한다. 에러 시
구 DWB 볼륨은 의도적으로 디스크에 남겨진다(소스 주석: “Do not remove the
old file if an error occurs”) — 반쯤 파괴된 DWB는 유일한 좋은 복사본을 잃게
되므로, 운영자가 재시도할 수 있도록 한다. fileio_unformat은 Branch A 성공
경로에서만 실행된다.
*p_ordered_slots_length = block->count_wb_pages +1; /* includes sentinel */
dwb_compare_slots는 vpid.volid, vpid.pageid, lsa.pageid,
lsa.offset 순으로 사전식 정렬을 수행한다(LSA 필드는 오버플로 방지를 위해
64비트 INT64 차이, VPID 필드는 int 차이 사용). 따라서 동일한 home VPID를
가진 두 슬롯은 인접하게 놓이며, LSA가 작은(오래된) 것이 먼저 온다.
불변 조건 9-B (정렬된 슬롯 인접성).dwb_block_create_ordered_slots
실행 후, 중복 home page들은 연속되어 있고 LSA 오름차순이다. dedup 루프와
디버그 체커가 이에 의존하며, 항상 이웃 i-1/i만 비교한다.
센티넬은 정렬 영역에 포함되지 않는다.qsort는 첫 count_wb_pages 원소만
정렬하며, 센티넬은 인덱스 count_wb_pages — 정렬 범위 밖 — 에 위치하고,
비교자에 절대 전달되지 않는다(null-VPID 슬롯은 비교된다면 맨 앞으로 정렬되겠
지만, 실제로는 그렇지 않다). 센티넬은 오직 dwb_write_block의
ordered_slots_length = count_wb_pages + 1 루프가 null 슬롯에서 종료될 수
있도록 하기 위해 존재한다; dedup 및 sanity 루프는 count_wb_pages에서 경계가
지어지며 센티넬에 닿지 않는다.
인접한 동일-VPID 쌍에서: s1->lsa < s2->lsa 이면 s1을 null로 만든다.
LSA가 같은 경우(LSA를 전진시키지 않은 상태에서 dirty) position_in_block
으로 판단한다 — 프로듀서는 오름차순 위치를 채우므로, 더 낮은 위치가 가장 최근
플러시다: 낮은 것을 유지하고 높은 것을 null로 만든다. VPID를 null로 만드는 것
이 이 챕터에서의 “이 슬롯 무시”: 이후 모든 하위 루프는 VPID_ISNULL을
건너뛴다.
if (is_page_corrupted) continue; /* one corrupt -> not a real dup */
// ... same error_code-checked check on slot i; two memcmp vs a zeroed page skip uninit slots ...
assert (false); /* genuine duplicate -> bug */
}
}
두 fileio_page_check_corruption 호출 모두 error_code를 캡처하고, 실패 시
return error_code로 단락(short-circuit)한다(두 번째는 위에서 축약). 그렇지
않은 경우, 같은 VPID의 이웃은 한쪽이 corrupted이거나, 어느 한쪽이 새로 zeroed
된 iopage와 memcmp로 동일한(초기화되지 않은 슬롯) 경우에만 허용된다; 정상적이고 구별 가능한 동일-VPID 페이지 쌍이 나타나면 assert (false)를 발동한다
— §9.5가 이미 한쪽을 null로 만들었어야 하기 때문이다. 릴리즈 빌드에서는 이
함수가 제거된다 — 불변 조건 9-B에 대한 개발 단계 수비다.
복구의 핵심: 살아남은 각 슬롯의 현재 home page를 읽고, 페이지 체크섬을
기준으로 DWB 복사본으로 덮어써야 하는지 판단하며, 복구 가능한 페이지 수를
집계하여 §9.8이 쓸 것이 없을 때 쓰기를 건너뛸 수 있게 한다. 여기서 “torn”은
페이지-체크섬 검사(fileio_page_check_corruption) 실패를 뜻한다 — 크래시
시점에 절반만 쓰인 페이지는 체크섬이 일치하지 않는다.
flowchart TB
S["슬롯 i"] --> N{"VPID null /\nfd null /\npageid >= vol_pages?"}
N -- 예 --> SKIP["건너뜀"]
N -- 아니오 --> R["fileio_read home"]
R -- "읽기 또는 검사 오류" --> RERR["error_code 반환"]
R -- ok --> C{"home 체크섬 정상?"}
C -- "예, 정상" --> CLEAN["슬롯 VPID null +\nfileio_initialize_res"]
C -- "아니오, 검사 오류" --> RERR
C -- "아니오, torn" --> D{"DWB 복사본 체크섬 정상?"}
D -- "아니오, 손상" --> FATAL["assert_release false\nER_FAILED 반환"]
D -- "예, 정상" --> REC["num_recoverable_pages++"]
Figure 9-1 — dwb_check_data_page_is_sane의 슬롯별 판단 흐름. 모든 분기는
건너뜀, clean-건너뜀, 상위 error_code 반환(fileio_read 실패 또는 두
fileio_page_check_corruption 중 하나 실패), 치명적 ER_FAILED, 복구 중
하나로 끝난다.
num_recoverable_pages++; /* DWB sane, home torn -> repair */
}
*p_num_recoverable_pages = num_recoverable_pages;
분기 비고: (VPID, LSA) 정렬이 슬롯을 볼륨별로 묶으므로, 디스크립터 / 페이지
수 조회는 볼륨당 한 번만 실행된다; pageid >= vol_pages 건너뜀은 DWB에
있었지만 데이터 볼륨에는 할당된 적 없는 페이지(볼륨이 확장되기 전에 크래시)를
처리한다. 세 개의 상위 에러 분기 중 어느 것이든 호출자의 end: 정리는 여전히
실행된다.
불변 조건 9-C (체크섬 torn 확인된 페이지만 덮어쓴다). home page는 해당
home 페이지가 체크섬 검사에 실패(fileio_page_check_corruption가 손상
보고)하고 동시에 DWB 복사본이 체크섬을 통과할 때만 DWB로부터 덮어
써진다. 정상인 home은 쓰기 세트에서 제거된다 — 복구는 torn 쓰기를 복원할
뿐, 정상 페이지를 오래된 이미지로 되돌리지 않는다. home과 DWB 복사본
양쪽 모두 체크섬에 실패하면, 복구는 알려진 불량 페이지를 쓰는 대신
ER_FAILED로 중단된다.
for (i =0; i <rcv_block->count_flush_volumes_info; i++)
{
if (fileio_synchronize (thread_p, rcv_block->flush_volumes_info[i].vdes, NULL, true)
== NULL_VOLDES)
{ error_code = ER_FAILED; goto end; }
}
rcv_block->count_flush_volumes_info=0;
}
assert (rcv_block->count_flush_volumes_info ==0);
dwb_write_block (Chapter 6)은 null이 아닌 각 정렬된 슬롯을 해당 home
(volid, pageid)에 쓴다. 복구 호출은 뒤쪽 플래그
file_sync_helper_can_flush / remove_from_hash에 false, false를 전달한다:
복구는 아래에서 직접 fsync하고, 퇴출할 슬롯 해시가 없다(vdes == NULL_VOLDES),
반면 런타임 플러시는 true, true를 전달한다. 복구된 각 데이터 볼륨
(flush_volumes_info 내)은 DWB 볼륨이 삭제되기 전에 복구가 지속되도록
fileio_synchronize된다; assert는 목록이 소진됐음을 확인한다.
그런 다음 Branch A의 나머지(§9.2 스켈레톤)가 실행된다: fileio_dismount +
fileio_unformat이 DWB 볼륨을 닫고 삭제하며, dwb_create가 복구된 크기가
아닌 설정된 파라미터 크기로 새 빈 DWB를 생성한다 — 여기서 운영자가 변경한
double_write_* 크기가 적용되며, §9.2가 복구된 카운트와의 차이를 허용하는
이유다. 이로써 불변 조건 9-A가 실현된다: torn home이 덮어써지고 fsync됐으며,
구 DWB가 사라지고, 새 빈 DWB가 데몬을 위해 준비된 상태. 오직 이후에야
log_recovery가 신뢰할 수 있는 home 위에서 redo를 수행한다.
§9.1 발췌는 축약본이지, 원문 그대로가 아니다. 실제 재시작 경로에는
vacuum_initialize와 복구 호출 사이에 oid_set_root가 있으며, SERVER_MODE
블록은 dwb_daemons_init 이후에
parallel_query::worker_manager_global::get_manager ().init ()도 호출한다.
발췌는 핵심 문장만 유지하며, 생략된 것들은 DWB 순서에 영향을 주지 않는다.
dedup dwb_log의 잠재적 포맷 문자열 불일치. 소스의 “Found duplicates”
로그 라인은 세 개의 %d 지정자("...positions = (%d,%d) %d\n")를 가진
포맷 문자열을 사용하지만 인수는 두 개(s1->position_in_block, s2->position_in_block)만 전달한다. 세 번째 지정자는 미정의 vararg를 읽는다.
실제로는 무해하지만(dwb_check_logging() 하에서만 발동), 이 챕터의 로직
요약이 아닌 소스의 실제 결함이다. §9.5의 발췌는 간결함을 위해 dwb_log
호출을 생략했으므로 버그를 재현하지 않는다.
dwb_write_block은 소스 파일에서 재시작-복구 함수들보다 훨씬 위에 정의되어
있다, 인접하게 위치하지 않는다. position-hint 테이블에 검증된 정의 라인이
기록되어 있다.
부트 내 위치가 전부다. 복구는 vacuum_initialize 이후,
dwb_daemons_init / log_initialize 이전에 실행되므로, DWB를 단독으로
점유하고 log_recovery에 일관된 home을 인계한다(불변 조건 9-A). 이 보장을
소비하는 redo 단계는 cubrid-recovery-manager-detail.md를 참고한다.
두 개의 외부 분기가 모든 경우를 다룬다. DWB 볼륨 없음 -> dwb_create만
실행; 존재하지만 0이거나 2의 거듭제곱이 아님 -> 복구 건너뜀, 재생성; 존재
하고 2의 거듭제곱 -> 실제 복구 경로.
볼륨은 자기 기술적(self-describing)이다.num_dwb_pages 크기의
DWB_BLOCK 하나, fileio_read_pages 한 번, io_page->prv에서 슬롯
VPID/LSA 재구성 — 외부 온-디스크 인덱스 없음.
정렬 후 dedup.dwb_block_create_ordered_slots는 count_wb_pages개의
실제 슬롯만 (VPID, LSA) 기준으로 정렬하며(불변 조건 9-B), 센티넬은 루프
종료자로서 정렬 범위 밖에 유지된다. dedup 루프는 오래된 복사본을 null로
만들되, 동일 LSA 시 position_in_block으로 판단한다. dwb_debug_check_dwb
는 디버그 빌드에서 결과를 재단언한다.
torn은 체크섬 판정이며, torn 페이지만 덮어쓴다(불변 조건 9-C).dwb_check_data_page_is_sane은 home page에 fileio_page_check_corruption을
실행한다: 정상 home -> 슬롯 null(절대 덮어쓰지 않음); home torn + DWB 복사본
체크섬 유효 -> 복구 대상으로 집계; home torn + DWB 복사본도 손상 -> 치명적
ER_FAILED; 데이터 볼륨 범위 밖 페이지 -> 건너뜀. 읽기 또는 체크 실패는
error_code를 end: 정리로 전파한다.
DWB 삭제 전에 복구 내용이 지속된다.dwb_write_block(두 후행 플래그
모두 false)이 체크섬 유효한 DWB 이미지로 torn home을 덮어쓰고, 이후
fileio_synchronize가 fileio_unformat으로 DWB를 삭제하기 전에 모든 수정된
볼륨을 fsync한다. 에러 발생 시 구 DWB 볼륨은 재시도를 위해 디스크에 남겨진다.
재생성은 복구된 크기가 아닌 설정된 크기로 이루어진다.dwb_create를
호출해 재생성되며 — 운영자가 재시작으로 DWB 크기를 조정할 수 있는 이유다.
4장부터 9장까지는 페이지의 주 경로(main path) 를 추적했다. 이 장은 그 경로에서 갈라지는 분기들을 다룬다 — 런타임에 DWB를 크기 변경(resize), 재생성(recreate), 또는 파괴(destroy)하는 경로, 그리고 모든 대기 경로가 공유하는 대기 큐(wait queue) 기본 연산들이다. 새로운 구조체는 없다(1장에 DWB_WAIT_QUEUE / DWB_WAIT_QUEUE_ENTRY, 2장에 플래그 비트가 내장된 position 워드가 있다). 이 장이 해부하는 것은 연산이다: CUBRID가 버퍼를 정지(quiesce)시키고, 진행 중인 모든 블록을 소진(drain)하고, 모든 대기자를 깨우고, 볼륨을 재구성하거나 해제하는 방법 — 페이지 손실이나 데드락 없이.
create, recreate, destroy는 모두 dwb_starts_structure_modification과 dwb_ends_structure_modification 사이에 작업을 배치하여, 다수의 프로듀서와 두 개의 데몬으로 이루어진 시스템을 거친 락(coarse lock) 없이 단일 스레드 임계 구역(critical section)으로 만든다. 게이트는 position_with_flags(2장)의 MODIFY_STRUCTURE 플래그 비트다: 플러시를 시도하는 프로듀서는 이 비트를 확인하고 dwb_wait_for_strucure_modification으로 우회한다. 분기 전체 흐름:
스캔(Scan) — position_with_flags를 다시 읽은 후, 가장 오래된(min_version) write-started 블록을 찾아 start_block_no를 결정.
스윕(Sweep) — start_block_no 모듈로 DWB_NUM_TOTAL_BLOCKS부터 순회: write-started 상태이면 dwb_flush_block (..., /*file_sync_helper=*/false, ...)로 인라인 fsync; 오류 시 return error_code(플래그는 유지). blocks_count == 0이 될 때까지 반복.
사후 확인(Post) — assert (DWB_GET_BLOCK_STATUS (...) == 0): write-started 블록이 남아 있지 않음; 오름차순 처리는 복구(9장)와 일치.
불변식 — 동시에 최대 하나의 구조 수정자 (I2). DWB_IS_MODIFYING_STRUCTURE 검사는 CAS 루프 내부에 있다: 두 스레드가 경쟁하면 하나가 이기고, 진 쪽은 다시 읽어 비트를 확인하고 ER_FAILED를 반환한다. 이를 위반하면 두 스레드가 모두 dwb_destroy_internal에 진입해 dwb_Global.blocks를 이중 해제(double-free)한다. 비대칭 설계는 의도적이다 — 두 번째 수정자는 빠르게 실패하고, 두 번째 프로듀서는 대기한다.
불변식 — 소진 중 오류가 발생하면 플래그가 유지됨 (I9). dwb_flush_block이 실패하면 플래그를 지우지 않고 반환한다(dwb_ends_structure_modification만이 플래그를 지우는데, 실패한 호출자는 거기에 도달하지 못한다) — 의도적인 fail-stop 설계. 이를 위반하면 DWB가 오래된 블록을 가진 채로 다시 열린다; 대신 DWB는 동결(frozen) 상태를 유지하고 다음 재시작에서 볼륨을 재실행(replay)한다(9장).
ATOMIC_TAS_64 (&dwb_Global.position_with_flags, new_position_with_flags); /* plain TAS, no contender */
dwb_signal_structure_modificated (thread_p); /* wake global-queue waiters */
DWB_ENDS_MODIFYING_STRUCTURE는 플래그 비트를 지운다. assert는 단일 수정자 불변식을 재확인한다(플래그가 설정된 상태에서는 다른 스레드가 해당 워드를 쓸 수 없으므로 라이브 값은 여전히 수정자의 스냅샷과 같다). ATOMIC_TAS_64는 이를 공개(publish)한다 — 경쟁자가 없으므로 plain TAS로 충분하다. 마지막으로 dwb_signal_structure_modificated는 전역 큐 대기자들을 깨운다(10.5절).
두 진입점 모두 dwb_destroy_internal / dwb_create_internal 주위에 브래킷을 배치한다. dwb_recreate는 런타임에 PRM_ID_DWB_SIZE 또는 PRM_ID_DWB_BLOCKS가 변경될 때 시스템 파라미터 기계(system-parameter machinery)에 의해 호출된다. 세 가지 분기: dwb_starts_structure_modification 실패 → return error_code; 그렇지 않고 DWB_IS_CREATED이면 dwb_destroy_internal로 이전 크기를 해제; 그 다음 dwb_create_internal (thread_p, dwb_Volume_name, ...), 오류 시 goto end — end:에서 dwb_ends_structure_modification을 실행. “하나의 플래그 아래 destroy 후 create”는 크기 변경을 원자적(atomic)으로 만든다(이전 DWB 또는 새 DWB, 중간 상태는 없다). 기존 dwb_Volume_name을 재사용하므로 인메모리 기하 구조(geometry)만 변경된다.
dwb_destroy는 세 가지 분기를 가지며, 아래에 인라인으로 표기된다. 분기 3(dwb_daemons_destroy)은 dwb_recreate가 생략하는 부분으로, 재생성된 버퍼는 여전히 데몬들이 필요하기 때문이다:
dwb_daemons_destroy (); /* <- branch 3: full shutdown only, not on recreate */
#endif
dwb_destroy_internal(3장)이 핵심 작업을 수행한다: dwb_destroy_wait_queue (&dwb_Global.wait_queue, ...)로 전역 큐를 소진하고 mutex를 해제, 모든 블록에 dwb_finalize_block을 적용, 슬롯(slot) 해시맵을 해제, 볼륨을 언마운트/언포맷한 뒤 position 워드를 reset+ended-creation 상태로 CAS 복귀. SA 모드와 서버 모드의 차이는 #if defined(SERVER_MODE) 가드에서 비롯된다 — SA_MODE에는 데몬이 없으므로 dwb_daemons_destroy와 10.1절의 소진 루프가 no-op이다.
대기 큐는 자체 제작된 단일 연결 리스트(singly-linked list)에 내장 free list를 결합한 구조로, 블록별 대기자(DWB_BLOCK::wait_queue)와 전역 구조 변경 대기자(dwb_Global.wait_queue) 양쪽에 재사용된다. 모든 기본 연산은 재진입 불가(non-reentrant)이며, 호출자가 mutex로 직렬화한다. dwb_init_wait_queue는 두 리스트를 모두 초기화(zero)한다. dwb_make_wait_queue_entry는 free list 빠른 경로(fast path)로 할당한다(비어 있지 않으면 free_list에서 pop, 그렇지 않으면 malloc — malloc 분기만 실패할 수 있으며 NULL을 반환). dwb_block_add_wait_queue_entry는 이를 감싸며, 빈 큐 분기(head == NULL이면 head와 tail 모두 설정)와 함께 꼬리(tail)에 추가한다.
dwb_block_disconnect_wait_queue_entry는 분기가 가장 많은 기본 연산이다 — 데이터 포인터로 노드를 찾아 연결을 끊는다(data == NULL이면 head를 제거). 아래에 인라인으로 표기된 네 가지 종료 분기:
불변식 — head/tail/count가 일치함 (I6). 모든 기본 연산 이후 count는 head에서 도달 가능한 노드 수와 같아야 하고, tail은 마지막 노드(빈 경우 NULL)이며, tail->next == NULL이다. disconnect는 꼬리 제거 시 tail을 수정하고, add는 추가 시 갱신한다. 이를 위반하면dwb_signal_waiting_threads의 소진 루프(head == NULL에서 종료)가 무한 루프에 빠지거나 해제된 엔트리를 깨울 수 있다.
dwb_block_free_wait_queue_entry는 실제로 메모리를 해제하지 않는다 — NULL 엔트리에서는 즉시 반환하고(이것이 disconnect가 NULL을 반환할 때 dwb_remove_wait_queue_entry를 안전하게 만드는 이유), 선택적 콜백 이후 엔트리를 free_list에 push한다. data는 디버깅을 위해 그대로 유지된다. dwb_remove_wait_queue_entry는 선택적 mutex 아래에서 disconnect와 free를 조합한다 — 타임아웃 및 인터럽트 경로가 호출하는 빌딩 블록이다.
두 가드(인라인)는 지워진 엔트리를 건너뛰고, 이미 타임아웃으로 자체 깨어난 스레드를 이중 깨움(double-wake)하는 것을 방지한다. dwb_set_status_resumed는 다른 콜백(타임아웃 경로)이다: 깨우지 않고 resume_status를 THREAD_DWB_QUEUE_RESUMED로 설정한다 — 스레드가 스스로 깨어난 것이다. 두 콜백 모두 SERVER_MODE 외부에서는 no-op이다. dwb_signal_block_completion과 dwb_signal_structure_modificated는 발생 시점에 이름을 부여하는 얇은 래퍼들이다(블록별 큐는 플러시 완료 시, 6장; 전역 큐는 수정 종료 시, 10.2절). 둘 다 dwb_signal_waiting_threads에 위임한다. dwb_destroy_wait_queue는 해체(teardown) 연산이다: 모든 대기자에게 신호를 보낸 후 free_list를 순회하며 재활용된 모든 엔트리를 free()한다 — 힙(heap)으로 실제 메모리를 해제하는 유일한 기본 연산.
write-started 블록의 플러시를 기다려야 하는 프로듀서가 여기에 파킹된다. mutex 아래에서 재확인하고(블록이 읽기와 락 사이에 이미 완료되었을 수 있음 — 빠른 return NO_ERROR), 자신을 큐에 추가하고(dwb_block_add_wait_queue_entry; NULL → 할당 오류), 20 ms 타임아웃으로 정지한 후, 세 가지 재개 결과를 처리한다:
다섯 가지 종료 분기: 이미 완료, 할당 오류, 타임아웃, 셧다운 인터럽트, 정상 깨움. 전역 큐 쌍둥이 dwb_wait_for_strucure_modification(10.1절)은 두 가지 차이점과 함께 이를 거울처럼 반영한다: 10 ms 타임아웃, 그리고 타임아웃 분기에서 dwb_set_status_resumed 대신 NULL을 콜백으로 전달한다 — 전역 대기자는 수정이 끝나면 단순히 플래그를 재확인하고 진행하므로 자신의 resume_status를 다시 스탬프할 필요가 없다.
불변식 — 대기자는 한 번 큐에 들어가고 정확히 한 번 빠져나옴 (I5). 블록 mutex가 큐 추가를 직렬화하고, 재개 분기가 누가 큐에서 제거하는지를 결정한다(타임아웃/인터럽트는 자체 제거, 정상 깨움은 신호자가 제거). 이를 위반하면 이중 제거(double-dequeue)로 리스트가 손상된다; dwb_block_disconnect_wait_queue_entry가 이미 제거된 엔트리에 NULL을 반환하고 dwb_block_free_wait_queue_entry가 아무것도 해제하지 않아 방지된다.
dwb_is_created는 모든 쓰기가 참조하는 저렴한 공개 술어(predicate)다 — 원자적 읽기 하나와 DWB_IS_CREATED 매크로. dwb_get_volume_name은 이 술어에 따라 이름을 제공하며, DWB가 없으면 NULL을 반환해 호출자가 오래된 경로를 참조하지 않도록 한다.
임시 볼륨 단락은 호출자 측에 있다: file_io.c에서 dwb_is_created ()가 fileio_traverse_permanent_volume으로 대상이 영구적(permanent) 볼륨임을 확인한 후에만 쓰기를 dwb_add_page로 라우팅한다. 임시 볼륨은 직접 쓰기(direct write)로 떨어진다 — 재시작 시 폐기되므로 DWB 쓰기 증폭(write-amplification)(상위 수준 설명 참조)은 낭비가 된다. DWB 자체 볼륨은 예약 ID LOG_DBDWB_VOLID(log_volids.hpp의 = LOG_DBFIRST_VOLID - 22)로 격리되어 있으며, 프로듀서가 스캔하는 데이터 볼륨 ID 공간 밖의 음수 시스템 볼륨 범위에 속한다 — 동일한 술어만으로 DWB 볼륨이 자기 자신에 버퍼링되는 것을 막는다.