콘텐츠로 이동

CUBRID HA Replication — 코드 수준 심층 분석

이 문서의 위치: 상위 분석 문서인 cubrid-ha-replication.md는 설계 의도와 이론적 배경을 다룬다. 이 문서는 모든 분기와 필드를 코드 수준에서 추적한다. 각 챕터는 독립적으로 읽을 수 있지만, 순서대로 읽으면 하나의 변경이 복제되는 전체 생명주기 — 마스터 로그 생성, copylogdb 전송, 레플리카의 applylogdb 적용 — 를 순서대로 따라갈 수 있다.

목차:

Ch제목상태
1자료구조 지도
2마스터 측 초기화와 스테이징 배열
3DML 실행 중 복제 레코드 기록
4구문 기반 방출(Statement-Based Emission)과 플러시 마크(Flush Mark)
5커밋 시점 플러시와 원자적 방출
6copylogdb로 로그 페이지 전송하기
7applylogdb 초기화와 로그 페치
8레코드별 디스패치와 트랜잭션별 버퍼링
9커밋된 트랜잭션 적용과 행 이미지 재구성
10내구성 있는 적용 북마크와 재시작 복구
11특수 경로 — 장기 트랜잭션, 역할 전환, 필터, TDE

복제되는 변경 사항은 마스터의 DML 문에서 슬레이브의 행 재실행에 이르기까지 다섯 번 재포장된다. 이 챕터는 모든 구조체와 필드, 그리고 마스터 스테이징 엔트리와 슬레이브 적용 아이템 사이의 바이트 수준 대응 관계를 목록으로 정리한다. 구조의 이유 — DML 시점의 발행, 커밋 시점의 플러시, 슬레이브에서의 트랜잭션 단위 버퍼링 — 는 상위 수준 문서(cubrid-ha-replication.md)에서 논증한다. 여기서는 구조체 목록 자체만을 다룬다.

다섯 개의 홉은 각각 하나의 구조체가 담당한다: (1) DML 스레드 — LOG_TDES.repl_records[], 원소 타입 LOG_REPL_RECORD; (2) 온디스크 로그 — LOG_REC_REPLICATION + 패킹된 repl_data; (3) copylogdb — 로그 페이지 원본, 디코드 없음; (4) applylogdb 디코드 — LA_ITEM; (5) applylogdb 버퍼 — 트랜잭션별 LA_APPLY + LA_COMMIT 큐. 상태 전이: repl_log_insert (스테이지) → log_append_repl_info_internal (플러시) → 전송 → la_make_repl_item (디코드) → la_set_repl_log (버퍼). Figure 1-1은 다섯 홉에 걸친 필드 수준 연결을 나타낸다.

Figure 1-1 — 다섯 홉을 가로지르는 구조체 관계와 필드 수준 엣지

1.1 마스터 스테이징 — LOG_TDES 복제 그룹

섹션 제목: “1.1 마스터 스테이징 — LOG_TDES 복제 그룹”

LOG_TDES (log_impl.h)는 realloc으로 증가하는 스테이징 레코드 배열, 이를 구동하는 두 개의 커서, 플러시 마크 인덱스, 그리고 두 개의 LSA 역추적 슬롯을 포함한다:

// log_tdes -- src/transaction/log_impl.h
int num_repl_records; /* # of replication records */
int cur_repl_record; /* # of replication records */
int append_repl_recidx; /* index of append replication records */
int fl_mark_repl_recidx; /* index of flush marked replication record at first */
struct log_repl *repl_records; /* replication records */
LOG_LSA repl_insert_lsa; /* insert or mvcc update target lsa */
LOG_LSA repl_update_lsa; /* in-place update target lsa */
// ... condensed ...
int suppress_replication; /* suppress writing replication logs when flag is set */

int 필드는 주석이 거의 동일하지만 역할이 다르다 — 주석이 아니라 실제 사용처를 기준으로 읽어야 한다. repl_log_insertrepl_records[cur_repl_record]에 쓴 후 cur_repl_record++를 실행하고, 커밋 시점의 log_append_repl_info_internal 루프는 while (append_repl_recidx < cur_repl_record) 조건으로 반복한다. 따라서 cur_repl_record스테이징 쓰기 커서이고, append_repl_recidx플러시 읽기 커서다.

필드역할존재 이유
repl_recordsLOG_REPL_RECORD의 힙 배열. repl_log_info_alloc으로 증가.트랜잭션의 스테이징 레코드를 보관하며 커밋 시 로그에 플러시된다.
num_repl_records할당된 용량(슬롯 수). REPL_LOG_IS_FULLnum_repl_records == cur_repl_record + 1.스테이징 확장 vs. 배열 재사용을 제어하고 realloc 트리거의 기준이 된다.
cur_repl_record스테이징 쓰기 커서 / 스테이징된 레코드 수. 다음 추가는 repl_records[cur_repl_record]에 이뤄진 후 cur_repl_record++.모든 스테이징 루프가 0 .. cur_repl_record-1을 순회하는 상한선.
append_repl_recidx플러시 읽기 커서. 첫 플러시 이전에는 -1; 첫 플러시 또는 커밋 시 0으로 설정되고 cur_repl_record까지 전진.점진적 플러시는 마지막 위치에서 재개하며, 커밋 시 0으로 되감아 전체를 재스캔한다.
fl_mark_repl_recidx플러시 마킹 시작 인덱스 (= repl_start_flush_mark 호출 시의 cur_repl_record); 열린 마크가 없으면 -1.repl_end_flush_mark가 undo 시 [fl_mark_repl_recidx, cur_repl_record) 범위를 되돌릴 수 있도록 한다 (Ch 4).
repl_insert_lsa최신 INSERT/MVCC-update 힙 레코드의 대상 LSA.힙 LSA가 확정되면 repl 레코드의 lsa를 역추적 패치한다.
repl_update_lsa최신 in-place UPDATE 힙 레코드의 대상 LSA.동일한 역추적 패치, in-place 경로 (repl_add_update_lsa, Ch 3).
suppress_replication인접 카운터. 0이 아니면 repl_log_insert가 조기 반환.카탈로그/내부 작업이 복제를 건너뛸 수 있으며, 두 repl_*_lsa 슬롯도 함께 초기화한다.

불변 조건 — 스테이징 vs. 플러시 커서. 스테이징 중에는 0 <= fl_mark_repl_recidx <= cur_repl_record < num_repl_records (마크가 열려 있지 않으면 fl_mark_repl_recidx == -1)가 유지되며, repl_log_insert의 모든 추가 직전에 수행되는 REPL_LOG_IS_FULL realloc 검사가 이를 보장한다. 플러시 커서 append_repl_recidx는 독립적으로 동작하며, 첫 플러시 이전에는 -1을 유지하다가 log_append_repl_info_internal에 의해 cur_repl_record까지 전진하고 그 값을 절대 넘지 않는다(루프 조건이 <). 만약 미래의 수정으로 cur_repl_record가 realloc 없이 num_repl_records에 도달한다면, 다음 추가는 배열 범위를 벗어난 슬롯에 쓰이고, 플러시 루프는 해당 슬롯의 repl_data 쓰레기 값을 슬레이브에 전송하게 된다.

1.2 스테이징 엔트리 — LOG_REPL_RECORD

섹션 제목: “1.2 스테이징 엔트리 — LOG_REPL_RECORD”

repl_records 슬롯은 하나의 LOG_REPL_RECORD (struct log_repl)이며, 로그에 기록되기 전 변경 사항의 인메모리 형태다.

// log_repl -- src/transaction/replication.h
struct log_repl
{
LOG_RECTYPE repl_type; /* LOG_REPLICATION_DATA or LOG_REPLICATION_SCHEMA */
LOG_RCVINDEX rcvindex;
OID inst_oid;
LOG_LSA lsa;
char *repl_data; /* the content of the replication log record */
int length;
LOG_REPL_FLUSH must_flush;
bool tde_encrypted; /* if it contains user data of tde-class */
};
필드역할존재 이유
repl_typeLOG_REPLICATION_DATA (행) 또는 LOG_REPLICATION_STATEMENT (DDL).플러시 시 로그 레코드 헤더와 적용 분기를 결정한다.
rcvindex연산 인덱스: RVREPL_DATA_* / RVREPL_STATEMENT (1.6).LOG_REC_REPLICATION.rcvindex로 복사되고, 이후 LA_ITEM.item_type이 된다 (1.7).
inst_oid영향받은 인스턴스의 OID.힙 행과 연관되며, in-place 브래킷 병합에 사용된다.
lsa레코드가 참조하는 LSA / 대상 힙 LSA.rcvindex에 따라 repl_*_lsa 역추적 슬롯에서 채워진다.
repl_data패킹된 페이로드 (길이 + 클래스 + pkey, 또는 DDL 필드). 레이아웃은 1.9 참조.repl_log_insert에서 한 번 생성되어 그대로 전송되며, STATEMENT이면 NULL.
lengthrepl_data의 바이트 길이.malloc, 온디스크 length, 슬레이브 읽기의 기준.
must_flushLOG_REPL_FLUSH 값 (1.3).스테이징 시 COMMIT_NEED_FLUSH로 설정되며, 플러시 루프가 DONT_NEED_FLUSH로 리셋한다.
tde_encrypted소스 클래스가 TDE 보호 대상.이전 노드를 TDE 암호화 경로로 라우팅한다 (Ch 11).

교차 검증 — 오래된 주석. repl_type 필드 주석은 LOG_REPLICATION_SCHEMA를 언급하지만, 그런 LOG_RECTYPE 값은 존재하지 않는다. log_type으로 실제 설정되는 값은 LOG_REPLICATION_DATA / LOG_REPLICATION_STATEMENT이다 (1.5). 주석은 이름 변경 이전의 잔재이며, 위 표는 현재 살아있는 enum을 기준으로 작성했다.

1.3 LOG_REPL_FLUSH — 레코드별 플러시 정책

섹션 제목: “1.3 LOG_REPL_FLUSH — 레코드별 플러시 정책”
// log_repl_flush -- src/transaction/replication.h
enum log_repl_flush
{
LOG_REPL_DONT_NEED_FLUSH = -1, /* no flush */
LOG_REPL_COMMIT_NEED_FLUSH = 0, /* log must be flushed at commit */
LOG_REPL_NEED_FLUSH = 1 /* log must be flushed at commit and rollback */
};
역할
LOG_REPL_DONT_NEED_FLUSH (-1)플러시 건너뜀. repl_log_abort_after_lsa (세이브포인트 롤백 시 해당 세이브포인트 LSA 이후의 레코드를 퇴역시킴)와 log_append_repl_info_internal이 레코드를 추가한 후 설정하여, 이후의 점진적 패스가 같은 레코드를 재추가하지 않도록 한다.
LOG_REPL_COMMIT_NEED_FLUSH (0)스테이징된 행의 기본값: 트랜잭션이 커밋될 때만 추가.
LOG_REPL_NEED_FLUSH (1)커밋과 롤백 모두에서 추가. 슬레이브에 반드시 전달되어야 하는 레코드용으로 플러시 마크 내부에서 설정된다 (Ch 4).

log_append_repl_info_internal의 플러시 게이트 조건은 정확히 다음과 같다: (is_commit && must_flush != DONT_NEED_FLUSH) || must_flush == NEED_FLUSH.

1.4 REPL_INFO_TYPE, REPL_INFO, REPL_INFO_SBR — 문장 기반 복제 캐리어

섹션 제목: “1.4 REPL_INFO_TYPE, REPL_INFO, REPL_INFO_SBR — 문장 기반 복제 캐리어”

문장 기반 복제(DDL, 스키마, 카탈로그 변경)는 힙 변경으로부터 LOG_REPL_RECORD를 구성하지 않는다. 대신 REPL_INFO_TYPE으로 태그된 클라이언트 제공 캐리어로 전달된다:

// REPL_INFO_TYPE -- src/transaction/replication.h
typedef enum
{ REPL_INFO_TYPE_SBR, REPL_INFO_TYPE_RBR_START, REPL_INFO_TYPE_RBR_NORMAL, REPL_INFO_TYPE_RBR_END } REPL_INFO_TYPE;
역할
REPL_INFO_TYPE_SBRREPL_INFO_SBR에 담긴 문장 페이로드. SQL을 재실행하여 복제.
REPL_INFO_TYPE_RBR_STARTin-place UPDATE 브래킷의 첫 번째 레코드. repl_log_insert에서 RVREPL_DATA_UPDATERVREPL_DATA_UPDATE_START로 매핑.
REPL_INFO_TYPE_RBR_NORMAL독립적인 행 변경 (INSERT/DELETE/MVCC-update). rcvindex를 변경하지 않음.
REPL_INFO_TYPE_RBR_END브래킷을 닫음. RVREPL_DATA_UPDATERVREPL_DATA_UPDATE_END로 매핑.

REPL_INFO는 일반 래퍼이며, info 필드를 REPL_INFO_SBR 등의 페이로드로 다운캐스트한다:

// repl_info -- src/transaction/replication.h
struct repl_info { char *info; int repl_info_type; bool need_replication; };
// repl_info_statement -- src/transaction/replication.h
struct repl_info_statement
{ int statement_type; char *name; char *stmt_text; char *db_user; char *sys_prm_context; };

REPL_INFO 필드:

필드역할존재 이유
info페이로드로의 다운캐스트 포인터 (예: REPL_INFO_SBR).하나의 래퍼가 클라이언트 경계를 넘어 어떤 종류의 정보든 전달한다.
repl_info_typeinfo를 해석하는 방법을 선택하는 REPL_INFO_TYPE.info를 해석하기 위한 판별자.
need_replication스테이징 전에 검사되는 게이트.클라이언트가 특정 문장에 대한 복제를 억제할 수 있게 한다.

REPL_INFO_SBR 필드:

필드역할존재 이유
statement_typeCUBRID_STMT_* 종류.먼저 패킹되어 LA_ITEM.item_type에 도달하며 필터링에 사용된다 (1.7).
name영향받는 객체 이름.DDL이 대상으로 하는 스키마 객체를 식별한다.
stmt_text재실행할 DDL SQL.슬레이브가 문장을 그대로 재실행 (LA_ITEM.key로 전달).
db_user소유 DB 사용자.슬레이브가 마스터의 신원으로 재실행한다.
sys_prm_context세션 파라미터 컨텍스트.슬레이브가 마스터의 파라미터 환경에서 재실행한다.

1.5 온디스크 헤더 — LOG_REC_REPLICATION

섹션 제목: “1.5 온디스크 헤더 — LOG_REC_REPLICATION”

플러시 시 로그는 고정 헤더 다음에 repl_data 바이트를 기록한다:

// log_rec_replication -- src/transaction/log_record.hpp
struct log_rec_replication
{
LOG_LSA lsa;
int length;
int rcvindex;
};
필드역할존재 이유
lsa대상 LSA (LOG_REPL_RECORD.lsa에서). DELETE/STATEMENT이면 NULL 설정.슬레이브가 데이터 변경 사항과 순서 및 연관관계를 맺는다 (LA_ITEM.target_lsa가 됨).
length뒤따르는 repl_data의 바이트 길이.슬레이브가 정확히 이 바이트 수를 읽는다 (1.9).
rcvindexRVREPL_DATA_* / RVREPL_STATEMENT (1.6).슬레이브에게 디코드 방법과 적용 분기를 알린다.

부모 LOG_RECTYPELOG_REPLICATION_DATA = 39 또는 LOG_REPLICATION_STATEMENT = 40 (log_record.hpp)이며, repl_type에서 복사된다.

1.6 RVREPL_DATA_* — 복구 인덱스 디스패치 키

섹션 제목: “1.6 RVREPL_DATA_* — 복구 인덱스 디스패치 키”
// LOG_RCVINDEX (replication subset) -- src/transaction/recovery.h
RVREPL_DATA_INSERT = 98, RVREPL_DATA_UPDATE = 99, RVREPL_DATA_DELETE = 100,
RVREPL_STATEMENT = 101, RVREPL_DATA_UPDATE_START = 102, RVREPL_DATA_UPDATE_END = 103,
역할
RVREPL_DATA_INSERT행 INSERT. 슬레이브가 재구성된 이미지를 삽입한다.
RVREPL_DATA_UPDATEMVCC (out-of-place) UPDATE, 단일 레코드. 슬레이브가 pkey로 업데이트.
RVREPL_DATA_DELETE행 DELETE. 슬레이브가 pkey로 삭제 (페이로드는 키만 포함).
RVREPL_STATEMENTDDL/스키마. 슬레이브가 SQL을 재실행.
RVREPL_DATA_UPDATE_STARTin-place UPDATE 브래킷 개시 (before-image).
RVREPL_DATA_UPDATE_END브래킷 종료 (after-image). 원자적으로 적용됨 (Ch 9).

이 여섯 값은 조인 키다: rcvindexLOG_REPL_RECORD에서 설정되고, LOG_REC_REPLICATION.rcvindex에 기록되며, 슬레이브에서 LA_ITEM.item_type으로 복사된다.

la_make_repl_itemLOG_REC_REPLICATION 하나와 페이로드를 LA_ITEM으로 언패킹한다 — 마스터 패킹의 역연산이다.

// la_item -- src/transaction/log_applier.c
struct la_item
{
LA_ITEM *next; LA_ITEM *prev;
int log_type; /* LOG_REPLICATION_DATA or _STATEMENT */
int item_type; /* rcvindex (DATA) or CUBRID_STMT_* (STATEMENT) */
char *class_name;
char *db_user;
char *ha_sys_prm;
int packed_key_value_length;
char *packed_key_value; /* disk image of pkey value */
DB_VALUE key; /* it will be unpacked from packed_key_value on demand */
LOG_LSA lsa; /* the LSA of the replication log record */
LOG_LSA target_lsa; /* the LSA of the target log record */
};
필드역할존재 이유
next / prevLA_APPLY 목록 내의 링크 포인터 (1.8).커밋 레코드가 도착할 때까지 아이템이 누적된다.
log_typeLOG_REPLICATION_DATA 또는 _STATEMENT.언패킹 분기와 적용 경로를 선택한다.
item_typeDATA: 헤더 rcvindex에서 온 RVREPL_DATA_*. STATEMENT: 먼저 언패킹된 CUBRID_STMT_*.적용 / 문장 필터 판별자 (Ch 11).
class_name대상 클래스 이름.테이블을 식별하며 클래스 수준 필터를 구동한다.
db_userDB 사용자 (STATEMENT 전용).DDL을 원래 사용자로 재실행.
ha_sys_prm세션 파라미터 (STATEMENT 전용).동일한 파라미터 환경에서 DDL을 재실행.
packed_key_value_length패킹된 pkey 바이트 길이 (DATA 전용).malloc/memcpy의 기준.
packed_key_valuepkey DB_VALUE의 디스크 이미지 (DATA 전용).패킹 상태로 유지되며, key로 지연 언패킹.
key언패킹된 pkey DB_VALUE (DATA). 또는 db_make_string을 통한 문장 텍스트 (STATEMENT).슬레이브 조회 키 또는 재실행할 SQL.
lsa이 복제 레코드의 LSA.위치 추적 / 중복 제거.
target_lsa데이터 레코드의 LSA (repl_log->lsa), la_new_repl_item에서 설정.아이템을 해당 데이터 변경과 연관짓는다.

불변 조건 — 지연 키 언패킹. DATA 아이템의 경우 keypacked_key_value가 언패킹되기 전에는 유효하지 않다. 적용 코드는 log_type == LOG_REPLICATION_DATA && DB_IS_NULL (&item->key) 조건으로 첫 사용 시점에 언패킹을 수행한다. 그 전에 packed_key_value를 해제하면 적용 코드가 댕글링 버퍼를 언패킹하게 된다. (STATEMENT 아이템은 관계가 역전된다: key는 디코드 시점에 채워지고 packed_key_valueNULL로 유지된다.)

1.8 슬레이브 버퍼링 — LA_APPLY, LA_COMMIT, LA_INFO

섹션 제목: “1.8 슬레이브 버퍼링 — LA_APPLY, LA_COMMIT, LA_INFO”

LA_ITEM은 트랜잭션별로 LA_APPLY에 버퍼링되며, 커밋은 LA_COMMIT 큐에 쌓이고, 전역 싱글톤 LA_INFO (la_Info)가 두 구조를 모두 소유한다.

// la_apply -- src/transaction/log_applier.c
struct la_apply
{ int tranid; int num_items; bool is_long_trans;
LOG_LSA start_lsa; LOG_LSA last_lsa; LA_ITEM *head; LA_ITEM *tail; };
// la_commit -- src/transaction/log_applier.c
struct la_commit
{ LA_COMMIT *next; LA_COMMIT *prev; int type; int tranid;
LOG_LSA log_lsa; time_t log_record_time; };

LA_APPLY 필드:

필드역할존재 이유
tranid이 목록의 마스터 트랜잭션 ID.레코드들은 tranid를 키로 트랜잭션별 목록에 분배된다 (la_find_apply_list).
num_items버퍼링된 LA_ITEM 수.LA_MAX_REPL_ITEMS에서 long-transaction 전환을 트리거한다.
is_long_trans목록이 상한선을 초과했음 (불변 조건 참조).버퍼링된 아이템 재실행 대신 로그 재페치 방식으로 전환한다.
start_lsa트랜잭션의 첫 번째 레코드 LSA.long transaction의 재스캔 시작점.
last_lsa마지막으로 확인된 레코드의 LSA.is_long_trans 상태에서도 계속 업데이트된다.
head / tailLA_ITEM 목록의 양 끝.tail에 추가하고 head부터 재실행.

LA_COMMIT 필드:

필드역할존재 이유
next / prev전역 커밋 큐를 연결.커밋 레코드는 트랜잭션에 걸쳐 FIFO 순으로 적용된다.
type종료 종류 (LOG_COMMIT).커밋을 다른 종료 방식과 구분한다.
tranid플러시할 LA_APPLY를 선택.커밋을 해당 버퍼와 연결한다.
log_lsa커밋 레코드의 LSA.내구성 있는 적용 북마크가 이 값으로 전진한다 (Ch 10).
log_record_time마스터 커밋 타임스탬프 (eot_time).복제 지연 보고에 사용.

불변 조건 — 제한된 버퍼링 (단방향 래치). 목록은 최대 LA_MAX_REPL_ITEMS (1000)개의 아이템을 보관한다. 이 한도에 도달하면 la_set_repl_logla_free_all_repl_items_except_head를 호출하고 is_long_trans = true로 설정한 뒤, 이후로는 last_lsa만 갱신할 뿐 새 아이템을 추가하지 않는다. 이 래치는 트랜잭션이 완전히 적용된 시점에만 해제된다 (is_long_trans = false). 이 메커니즘이 없다면 하나의 거대한 트랜잭션이 디코딩된 아이템을 보관하면서 슬레이브 메모리를 고갈시킬 수 있다.

LA_INFO는 싱글톤 (la_Info)이다. 복제와 관련된 필드들:

필드역할존재 이유
repl_listsLA_APPLY * 배열 (용량 repl_cnt, 커서 cur_repl).tranid를 키로 검색되는 트랜잭션별 버퍼. la_init_repl_lists로 확장.
commit_headLA_COMMIT 큐의 헤드.커밋이 헤드에서부터 FIFO 순으로 디큐된다.
commit_tailLA_COMMIT 큐의 테일.새로운 커밋이 테일에 인큐된다.

나머지 LA_INFO 필드는 사용처에서 각각 분석한다: LSA 북마크 그룹 (final_lsa, committed_lsa, required_lsa)은 Ch 10에서, 페치/캐시 (act_log, cache_pb, log_data)는 Ch 7에서, 필터/역할 (repl_filter, is_role_changed)은 Ch 11에서 다룬다.

1.9 필드 수준 대응: repl_dataLA_ITEM

섹션 제목: “1.9 필드 수준 대응: repl_data ↔ LA_ITEM”

가장 오류가 발생하기 쉬운 홉은 페이로드 레이아웃이다. 마스터는 repl_log_insert에서 repl_data를 패킹하고, 슬레이브는 la_make_repl_item에서 이를 언패킹하는데, 두 쪽이 바이트 단위로 일치해야 한다. LOG_REPLICATION_DATA 레코드의 경우:

// repl_log_insert (pack) -- src/transaction/replication.c
ptr_to_packed_key_value_size = ptr; ptr += OR_INT_SIZE; /* reserve len */
ptr = or_pack_string_with_length (ptr, class_name, strlen);
ptr = or_pack_mem_value (ptr, key_dbvalue, &packed_key_len);
or_pack_int (ptr_to_packed_key_value_size, packed_key_len); /* back-fill len */
// la_make_repl_item (unpack) -- src/transaction/log_applier.c
ptr = or_unpack_int (area, &item->packed_key_value_length);
ptr = or_unpack_string (ptr, &item->class_name);
item->packed_key_value = (char *) malloc (item->packed_key_value_length);
ptr = PTR_ALIGN (ptr, MAX_ALIGNMENT); /* 8 bytes alignment. see or_pack_mem_value */
memcpy (item->packed_key_value, ptr, item->packed_key_value_length);
item->item_type = repl_log->rcvindex;
repl_data 영역 (마스터)LA_ITEM 필드 (슬레이브)비고
선행 OR_INT_SIZE int = packed_key_lenpacked_key_value_length먼저 자리를 예약하고, 값 패킹 후 역추적 채움.
or_pack_string_with_length(class_name)class_name (or_unpack_string으로 언팩)길이 접두사가 붙은 문자열.
or_pack_mem_value(key_dbvalue) (8바이트 정렬)packed_key_value (PTR_ALIGN + memcpy), 이후 → keyPTR_ALIGNMAX_ALIGNMENT가 마스터 or_pack_mem_value의 패딩과 반드시 일치해야 하며, 그렇지 않으면 pkey가 잘못 디코드된다.
(헤더) rcvindexitem_typerepl_log->rcvindex에서 복사됨. 적용 판별자.

불변 조건 — pack/unpack 대칭. (int 길이, 클래스 문자열, 8바이트 정렬 pkey 값) 순서는 내장 스키마 없이 두 파일에 수동으로 동일하게 코딩되어 있다. 마스터는 OR_INT_SIZE + or_packed_string_length + OR_VALUE_ALIGNED_SIZE로 버퍼 크기를 계산하고, 슬레이브는 PTR_ALIGN(ptr, MAX_ALIGNMENT)으로 pkey 오프셋을 재유도한다. repl_log_insert의 패킹 순서나 정렬 방식이 변경되고 이것이 la_make_repl_item에 반영되지 않으면, 복제되는 모든 행이 조용히 손상된다.

LOG_REPLICATION_STATEMENT 레코드는 대신 다음 순서로 패킹한다: item_type (CUBRID_STMT_* int), class_name, 문장 텍스트 (or_unpack_stringdb_make_string으로 key에 저장), db_user, ha_sys_prmREPL_INFO_SBR의 다섯 필드를 언패킹한 이미지들이다 (1.4).

  1. 두 호스트에 걸쳐 변경 사항을 보관하는 다섯 개의 구조체: LOG_TDES.repl_records[]LOG_REC_REPLICATION + 패킹된 repl_data → copylogdb 원본 페이지 → LA_ITEMLA_APPLY / LA_COMMIT.
  2. 마스터 배열은 두 개의 독립된 커서를 사용한다: cur_repl_record는 스테이징 쓰기 커서(모든 스테이징 루프는 0 .. cur_repl_record-1을 순회)이고, append_repl_recidx는 플러시 읽기 커서(첫 플러시 이전 -1, cur_repl_record를 향해 전진). repl_*_lsa 슬롯은 힙 LSA가 확정된 후 레코드의 lsa를 역추적 패치한다.
  3. must_flush (LOG_REPL_FLUSH)가 생존을 결정한다: 행에 대해서는 커밋 전용 (COMMIT_NEED_FLUSH), 플러시 마크 내부에서는 커밋과 롤백 모두 (NEED_FLUSH), 세이브포인트 롤백 후 또는 추가 완료 후에는 퇴역 (DONT_NEED_FLUSH).
  4. rcvindex (RVREPL_DATA_* / RVREPL_STATEMENT)는 조인 키다: LOG_REPL_RECORD에서 설정되고, LOG_REC_REPLICATION에 기록되며, LA_ITEM.item_type으로 복사되어 모든 적용 분기에서 스위치된다.
  5. 슬레이브는 지연 디코드한다 (LA_ITEM.key는 적용 시점에만 언패킹). is_long_trans는 단방향 래치다: LA_MAX_REPL_ITEMS (1000)를 초과하면 목록이 헤드만 남기고 해제되며, 해당 트랜잭션은 로그에서 start_lsa..last_lsa를 재스캔하여 재실행한다.
  6. repl_data 레이아웃 (int 길이, 클래스 문자열, 8바이트 정렬 pkey)repl_log_insert / la_make_repl_item 두 파일에 대칭적으로 수동 코딩되어 있다. 자기 서술 스키마가 없으므로, 패킹 순서나 정렬 방식이 한쪽에만 변경되면 복제되는 행이 조용히 손상된다.

Chapter 2: 마스터 측 초기화와 스테이징 배열

섹션 제목: “Chapter 2: 마스터 측 초기화와 스테이징 배열”

어떤 DML이든 행 이미지를 레플리카에 전달하기 전에, 마스터는 트랜잭션별 복제 레코드 스트림을 어딘가에 스테이징해야 한다 — 모든 트랜잭션 디스크립터의 tdes->repl_records[] 배열이 바로 그 공간이다. 이 챕터는 그 메모리의 생애주기를 추적한다: 첫 번째 쓰기 시점에 지연 생성(lazy allocation)되고, 100-슬롯 단위로 증가하며, 트랜잭션 경계마다 초기화된다. 또한 suppress_replication이 이 과정을 어떻게 단락(short-circuit)시키는지, 그리고 마스터가 커밋 전까지 WAL에 복제 관련 내용을 전혀 기록하지 않는다는 불변 조건(Chapter 5)을 다룬다. 스테이징 배열 개요와 마스터/레플리카 역할은 상위 개요 문서(cubrid-ha-replication.md, “마스터 측 로그 생성” 절)에서 이미 다뤘다고 가정하며, 여기서는 배열이 사용되는지가 아니라 어떻게 관리되는지를 추적한다.

스테이징 배열은 LOG_TDES(log_impl.h) 위의 네 개 정수 필드와 포인터 하나로 기술된다. 이들은 Chapter 1의 자료구조 맵에서 소개됐다. 슬롯 페이로드는 LOG_REPL_RECORD(struct log_repl, replication.h)이며, 여기서는 할당과 초기화를 제어하는 디스크립터 측 커서 의미론(cursor semantics)에 집중한다.

필드역할존재 이유
repl_recordsLOG_REPL_RECORD 배열을 가리키는 포인터 (또는 NULL)스테이징 버퍼; NULL이면 해당 디스크립터에서 아직 쓰기가 발생하지 않았거나, 막 초기화/해제된 상태임
num_repl_records할당된 용량 (슬롯 수)“크기”와 “채워진 정도”를 분리; 증가 여부 결정에 사용
cur_repl_record사용 중인 슬롯 수 — 동시에 쓰기 커서 역할repl_log_insertrepl_records[cur_repl_record]에 쓴 후 후위 증가
append_repl_recidx커밋 시 WAL 방출 패스의 커서방출을 배열 중간에서 재개할 수 있게 함; -1 = 이 트랜잭션에서 아직 방출 미실행
fl_mark_repl_recidx구문 flush 마킹이 시작된 인덱스, 또는 -1must_flush 업그레이드 로직(Chapter 4)을 구동; -1 = flush 마크 없음

이 중 세 개는 인덱스 커서다 — cur_repl_record(채우기), append_repl_recidx(방출), fl_mark_repl_recidx(flush 마크); repl_records는 버퍼 포인터이고 num_repl_records는 용량이다. 배열은 cur_repl_record를 기준으로 나뉜다: 슬롯 [0 .. cur-1]은 사용 중, [cur .. num-1]은 여유 공간이다. num_repl_records까지의 여유분이 다음 삽입이 소비할 공간이며, 이 여유가 없어지면 배열이 “가득 찬” 상태이므로 증가가 필요하다.

불변 조건 (fill 커서는 용량을 절대 초과하지 않는다). 항상 0 <= cur_repl_record <= num_repl_records가 성립하며, 이는 구조적으로 강제된다: repl_log_insert는 배열이 가득 찼을 때 쓰기 전에 반드시 repl_log_info_alloc을 먼저 호출하므로, 후위 증가 전에 repl_records[cur_repl_record]에 슬롯이 항상 존재한다. 이 조건을 위반하면 &tdes->repl_records[tdes->cur_repl_record] 쓰기가 할당 범위를 벗어나 힙을 오염시킨다. “가득 찬” 판정(Section 2.3)이 한 슬롯 일찍 발생하는 것은 정확히 이 안전성을 보장하기 위해서다.

2.2 생성과 초기화: 트랜잭션 경계를 가로지르는 스테이징 필드

섹션 제목: “2.2 생성과 초기화: 트랜잭션 경계를 가로지르는 스테이징 필드”

세 개의 함수에서 세 가지 생애주기 이벤트가 발생한다. 핵심 구분은 포인터를 null로 만드는 것과 커서만 되감는 것의 차이 — 이것이 재사용 디스크립터에서 버퍼가 트랜잭션을 가로질러 살아남는 이유다.

(a) 디스크립터 생성 — logtb_initialize_tdes (log_tran_table.c). LOG_TDES 슬롯 설정 시 딱 한 번 실행된다 — repl_records를 null로 만들고 동시에 용량을 0으로 만드는 유일한 위치:

// logtb_initialize_tdes -- src/transaction/log_tran_table.c
tdes->num_repl_records = 0;
tdes->cur_repl_record = 0;
tdes->append_repl_recidx = -1;
tdes->fl_mark_repl_recidx = -1;
tdes->repl_records = NULL;
LSA_SET_NULL (&tdes->repl_insert_lsa);
LSA_SET_NULL (&tdes->repl_update_lsa);
// ... first_save_entry / suppress_replication = 0 ...

(b) 트랜잭션 종료/재사용 — logtb_clear_tdes (log_tran_table.c). 커밋 또는 롤백 시마다 디스크립터를 재활용하기 위해 실행된다. 각 레코드의 페이로드를 해제하지만, 배열 할당 자체는 유지해 다음 트랜잭션이 재사용할 수 있게 한다 — repl_recordsnum_repl_records를 건드리지 않는다:

// logtb_clear_tdes -- src/transaction/log_tran_table.c
for (i = 0; i < tdes->cur_repl_record; i++)
{
if (tdes->repl_records[i].repl_data)
{
free_and_init (tdes->repl_records[i].repl_data);
}
}
// ... bind_history cleanup condensed ...
tdes->cur_repl_record = 0;
tdes->append_repl_recidx = -1;
tdes->fl_mark_repl_recidx = -1;
LSA_SET_NULL (&tdes->repl_insert_lsa);
LSA_SET_NULL (&tdes->repl_update_lsa);
// ... later: tdes->suppress_replication = 0 ...

포인터와 용량이 유지되므로, 배열을 300 슬롯까지 키운 트랜잭션은 그 상태를 다음 트랜잭션에 물려준다: REPL_LOG_IS_NOT_EXISTS(Section 2.3)는 num_repl_records != 0을 읽어 지연 초기 할당을 건너뛴다. 해제된 슬롯의 repl_data 포인터는 repl_log_info_alloc(Section 2.4)이 새로 증가된 슬롯에 대해서만 null로 초기화한다; cur_repl_record 이하의 슬롯에는 이전 트랜잭션이 채운 dangling repl_data 포인터가 남아 있지만, 여기서 cur_repl_record0으로 되감겼으므로 repl_log_insert가 해당 필드를 덮어쓰기 전에는 어떤 읽기도 이를 참조하지 않는다.

(c) 디스크립터 해제 — logtb_free_tran_index (log_tran_table.c). 트랜잭션 인덱스가 반환될 때 호출된다 — 배열 메모리가 힙으로 돌아가는 유일한 위치. logtb_clear_tdes를 먼저 호출해 페이로드를 해제한 다음 배열 껍데기를 해제한다는 점에 주목하라:

// logtb_free_tran_index -- src/transaction/log_tran_table.c
logtb_clear_tdes (thread_p, tdes);
if (tdes->repl_records)
{
free_and_init (tdes->repl_records);
}
tdes->num_repl_records = 0;

불변 조건 (용량과 포인터는 함께 움직인다). repl_records == NULL인 것과 num_repl_records == 0인 것은 동치다. logtb_initialize_tdes, logtb_free_tran_index, 그리고 repl_log_info_alloc(성공 시)은 항상 둘을 일관되게 설정한다; logtb_clear_tdes는 둘 다 건드리지 않는다. 포인터만 null로 만들고 용량을 0으로 만들지 않거나, 반대로 하면 REPL_LOG_IS_NOT_EXISTS가 버퍼를 잘못 분류해 메모리 누수나 NULL 역참조를 유발한다.

네 번째 이벤트 — append_repl_recidx0으로 초기화되는 것 — 는 여기가 아니라 커밋 시 방출 단계인 log_append_repl_info_internal(log_manager.c)에서 발생한다. if (tdes->append_repl_recidx == -1 || is_commit) 조건이 방출 커서를 되감는다(Chapter 5). 생애주기를 완성하기 위해 언급만 한다(Figure 2-1).

stateDiagram-v2
  [*] --> 미초기화: logtb_initialize_tdes
  미초기화 --> 할당됨: 첫 repl_log_insert -> malloc
  할당됨 --> 할당됨: 배열 가득 참 -> realloc 증가
  할당됨 --> 방출됨: 커밋 -> log_append_repl_info_internal
  방출됨 --> 재활용됨: logtb_clear_tdes 커서 되감기 버퍼 유지
  재활용됨 --> 할당됨: 다음 트랜잭션이 warm 버퍼 재사용
  재활용됨 --> 해제됨: logtb_free_tran_index 버퍼 해제
  미초기화 --> 해제됨: logtb_free_tran_index
  해제됨 --> [*]

Figure 2-1. 디스크립터 재사용을 가로지르는 스테이징 배열 생애주기. 재활용됨 -> 할당됨 엣지가 대부분의 트랜잭션이 malloc을 건너뛰는 이유다.

2.3 두 매크로 가드: NOT_EXISTS와 FULL

섹션 제목: “2.3 두 매크로 가드: NOT_EXISTS와 FULL”

두 매크로가 모든 삽입 시점에 배열 상태를 분류한다. tran_index로 전역 trantable에서 디스크립터를 읽는다:

// REPL_LOG_IS_NOT_EXISTS / REPL_LOG_IS_FULL -- src/transaction/replication.c
#define REPL_LOG_IS_NOT_EXISTS(tran_index) \
(log_Gl.trantable.all_tdes[(tran_index)]->num_repl_records == 0)
#define REPL_LOG_IS_FULL(tran_index) \
(log_Gl.trantable.all_tdes[(tran_index)]->num_repl_records \
== log_Gl.trantable.all_tdes[(tran_index)]->cur_repl_record+1)
static const int REPL_LOG_INFO_ALLOC_SIZE = 100;

REPL_LOG_IS_NOT_EXISTS는 용량이 0일 때 참이다 — 이 디스크립터에 버퍼가 한 번도 할당되지 않았거나(신규) 방금 해제된 상태 — 그리고 지연 초기 malloc을 트리거한다. REPL_LOG_IS_FULL은 fill 커서가 용량보다 하나 부족할 때(num_repl_records == cur_repl_record+1) 참이다; +1은 Section 2.1의 “한 슬롯 일찍” 판정으로, 곧 발생할 삽입을 위한 여유 슬롯을 보장한다. 두 매크로는 상호 배타적이다 — 용량이 0인 배열은 “가득 찰” 수 없다 — 따라서 repl_log_insertif / else if로 순서대로 검사한다.

repl_log_info_allocrepl_recordsnum_repl_records를 함께 다루는 유일한 함수다. need_realloc 파라미터가 초기 malloc인지 realloc을 통한 증가인지를 선택한다.

// repl_log_info_alloc -- src/transaction/replication.c
static int
repl_log_info_alloc (LOG_TDES * tdes, int arr_size, bool need_realloc)
{
int i = 0, k;
int error = NO_ERROR;
if (need_realloc == false)
{
i = arr_size * DB_SIZEOF (LOG_REPL_RECORD);
tdes->repl_records = (LOG_REPL_RECORD *) malloc (i);
if (tdes->repl_records == NULL)
{
error = ER_REPL_ERROR;
er_set (ER_WARNING_SEVERITY, ARG_FILE_LINE, ER_REPL_ERROR, 1, "can't allocate memory");
return error;
}
tdes->num_repl_records = arr_size;
k = 0;
}
else
{
i = tdes->num_repl_records + arr_size;
tdes->repl_records = (LOG_REPL_RECORD *) realloc (tdes->repl_records, i * DB_SIZEOF (LOG_REPL_RECORD));
// ... same NULL check / ER_REPL_ERROR / er_set / return as the malloc path ...
k = tdes->num_repl_records;
tdes->num_repl_records = i;
}
for (i = k; i < tdes->num_repl_records; i++)
{
tdes->repl_records[i].repl_data = NULL;
}
return error;
}

네 가지 결과가 모든 분기를 커버한다. A (need_realloc == false): malloc; 성공하면 용량 = arr_size, k = 0. A1 (malloc 실패): ER_REPL_ERROR를 설정하고 반환 — num_repl_records0으로, 포인터는 NULL로 남으므로 NOT_EXISTS가 여전히 성립한다. B (need_realloc == true): 용량을 old + arr_size로 증가, k = 이전 용량. B1 (realloc 실패): ER_REPL_ERROR를 설정하고 반환; 이전 블록이 누수된다(아래 참조). 성공 경로 둘 다 꼬리 루프 for (i = k; ...) repl_records[i].repl_data = NULL;을 실행한다. k 변수가 핵심이다: 신규 malloc에서는 0이므로 모든 슬롯을 초기화하고, realloc에서는 이전 용량이므로 증가된 꼬리 부분만 초기화해 이미 스테이징된 레코드를 건드리지 않는다. repl_data만 null로 초기화해도 충분한 이유는, repl_log_insert가 다른 모든 필드(repl_type, rcvindex, inst_oid, lsa, length, must_flush, tde_encrypted)를 읽기 전에 채우며, repl_dataNULL 여부가 logtb_clear_tdes의 해제를 제어하는 가드이기 때문이다.

불변 조건 (모든 슬롯의 repl_data는 유효한 힙 포인터이거나 NULL이다). 여기서 할당 시 null로 초기화되고, repl_log_insert에서 malloc된 버퍼 또는 NULL로 설정되며, logtb_clear_tdes에서 if (repl_records[i].repl_data) 조건 하에 해제된다 — 초기화되지 않은 증가 슬롯이 있으면 그 해제가 garbage를 건드리게 된다. 꼬리 루프가 증가된 슬롯의 이 조건을 보장하고, 삽입별 대입이 채워진 슬롯을 보장한다. 주의: 분기 B1은 전형적인 p = realloc(p, …) 안티패턴이다 — 실패 시 원본 블록은 살아 있지만 그 유일한 포인터가 NULL로 덮어써져 누수가 발생한다. OOM 이후에는 어차피 abort와 프로세스 정리가 뒤따르므로 실제 해악은 없지만, 이 패턴을 보존하지 않도록 한다.

2.5 repl_log_insert — 할당 분기와 suppress 단락

섹션 제목: “2.5 repl_log_insert — 할당 분기와 suppress 단락”

repl_log_insert의 방출 부분은 Chapter 3에서 해부한다; 여기서는 진입 가드 — suppress 단락과 레코드 쓰기 이전의 할당 분기만 다룬다.

// repl_log_insert -- src/transaction/replication.c
tran_index = LOG_FIND_THREAD_TRAN_INDEX (thread_p);
tdes = LOG_FIND_TDES (tran_index);
if (tdes == NULL)
{
return ER_FAILED;
}
/* If suppress_replication flag is set, do not write replication log. */
if (tdes->suppress_replication != 0)
{
/* clear repl lsa in tdes since no replication log will be written */
LSA_SET_NULL (&tdes->repl_insert_lsa);
LSA_SET_NULL (&tdes->repl_update_lsa);
return NO_ERROR;
}
if (thread_p->no_logging && tdes->fl_mark_repl_recidx == -1)
{
return NO_ERROR;
}
/* check the replication log array status, if we need to alloc? */
if (REPL_LOG_IS_NOT_EXISTS (tran_index)
&& ((error = repl_log_info_alloc (tdes, REPL_LOG_INFO_ALLOC_SIZE, false)) != NO_ERROR))
{
return error;
}
/* the replication log array is full? re-alloc? */
else if (REPL_LOG_IS_FULL (tran_index)
&& (error = repl_log_info_alloc (tdes, REPL_LOG_INFO_ALLOC_SIZE, true)) != NO_ERROR)
{
return error;
}
repl_rec = (LOG_REPL_RECORD *) (&tdes->repl_records[tdes->cur_repl_record]);
// ... record fill (Chapter 3) ...

Figure 2-2가 모든 분기를 추적한다. 핵심적인 비자명한 점: 두 할당 가드는 repl_log_info_alloc을 최대 한 번만 호출하는데, 매크로가 상호 배타적이기 때문이다(Section 2.3). &&는 해당 가드가 성립할 때만 할당자를 호출하며, G3/G4는 실패를 전파하고, 할당이 불필요한 경우는 fall-through하며, 여유 슬롯이 이미 있는 버퍼는 둘 다 건너뛴다. 동일한 할당 프롤로그가 repl_log_insert_statement(Chapter 4)에도 그대로 등장한다; no_logging early-out(G2)은 repl_log_insert에만 있다.

flowchart TD
  A["repl_log_insert 진입"] --> B{"tdes == NULL?"}
  B -- 예 --> R0["return ER_FAILED (G0)"]
  B -- 아니오 --> C{"suppress_replication != 0?"}
  C -- 예 --> S["repl LSA 둘 다 NULL 설정<br/>return NO_ERROR (G1)"]
  C -- 아니오 --> D{"no_logging AND flush 마크 없음?"}
  D -- 예 --> R2["return NO_ERROR (G2)"]
  D -- 아니오 --> E{"NOT_EXISTS?"}
  E -- 예 --> F["alloc(malloc)"]
  F --> F2{"오류?"}
  F2 -- 예 --> R3["return error (G3)"]
  F2 -- 아니오 --> J["slot = repl_records[cur_repl_record]"]
  E -- 아니오 --> G{"IS_FULL?"}
  G -- 예 --> H["alloc(realloc 증가)"]
  H --> H2{"오류?"}
  H2 -- 예 --> R4["return error (G4)"]
  H2 -- 아니오 --> J
  G -- 아니오 --> J
  J --> K["레코드 채우기 (Chapter 3)"]

Figure 2-2. repl_log_insert의 진입 가드; 두 할당 분기 모두 동일한 안전한 슬롯 접근으로 수렴한다.

불변 조건 (suppress는 스테이징 LSA를 null로 만들 뿐, 배열을 건드리지 않는다). suppress_replication != 0일 때 repl_log_insertrepl_insert_lsa / repl_update_lsa를 초기화하지만 repl_records와 커서는 그대로 둔다. suppress는 트랜잭션 내에서 토글 가능한 per-operation 게이트이므로, LSA를 null로 만드는 것은 같은 트랜잭션의 이후 비-suppress 삽입이 낡은 repl_insert_lsa를 물려받아 잘못된 WAL 주소로 레코드를 스탬핑하는 것을 막는다(Chapter 3: 삽입 경로가 repl_insert_lsarepl_rec->lsa에 복사한다). 동일한 플래그가 repl_log_insert_statement(Chapter 4)와 repl_add_update_lsa를 제어한다 — 후자는 suppress 하에서 LSA를 null로 만들지 않고 즉시 반환하는데, 백패치할 스테이징된 update 레코드가 없기 때문이다.

2.6 커밋 전까지 WAL에 기록하지 않는다는 불변 조건

섹션 제목: “2.6 커밋 전까지 WAL에 기록하지 않는다는 불변 조건”

불변 조건 (커밋 전까지 WAL에 LOG_REPLICATION_DATA가 없다). 이 챕터의 모든 작업은 로그 버퍼나 디스크가 아닌 프로세스 사유 힙 메모리(tdes->repl_records[])에만 쓴다. 스테이징된 레코드는 커밋 시 log_append_repl_info_internalappend_repl_recidx부터 배열을 순회할 때 비로소 실제 복제 WAL 레코드가 된다(Chapter 5); 이 경로 밖에서 레코드를 append하는 호출 지점은 없으며, 이는 구조적 강제다. abort하는 트랜잭션은 logtb_clear_tdes에서 스테이징 페이로드가 해제되므로, 그 행 이미지는 WAL에 도달하지 않았고 따라서 레플리카에도 절대 도달할 수 없다. 복제는 이로써 커밋과 원자적으로 구성된다; DML 측에서 즉시 append를 수행했다면 abort 시 phantom 변경이 누출되어 레플리카가 조용히 diverge했을 것이다.

  1. 지연 할당, warm 재사용. repl_records는 첫 번째 쓰기가 repl_log_info_alloc(..., false)를 트리거할 때까지 NULL이다; logtb_clear_tdes는 커서를 되감지만 버퍼를 유지하므로, 이후 트랜잭션은 malloc을 건너뛴다.
  2. 세 함수가 세 가지 생애주기 이벤트를 소유한다. logtb_initialize_tdes는 포인터를 null로 만들고 용량을 0으로 만든다; logtb_clear_tdes는 페이로드를 해제하고 커서를 되감되 할당을 유지한다; logtb_free_tran_index만이 (logtb_clear_tdes 호출 후) 배열 껍데기를 해제한다.
  3. 용량과 채우기는 분리된다. num_repl_records가 용량, cur_repl_record가 fill 커서; “가득 참” 매크로는 한 슬롯 일찍(num == cur+1) 발생해 진행 중인 삽입을 위한 슬롯을 보장한다.
  4. repl_log_info_alloc은 100씩 증가하며, 새 슬롯만 초기화한다. k 변수가 repl_data = NULL 꼬리 루프의 범위를 한정해 증가 시 스테이징된 레코드가 살아남게 한다; OOM 시 realloc 경로는 이전 블록을 누수시킨다.
  5. suppress_replication은 어떤 할당보다 먼저 단락된다. 스테이징 LSA를 null로 만들어 이후 비-suppress 삽입이 낡은 LSA를 물려받지 않도록 한다.
  6. 커밋 전까지 WAL에 아무것도 도달하지 않는다 — 스테이징은 사유 힙 메모리이며, 커밋 시 방출 패스(Chapter 5)에 의해서만 WAL 레코드로 변환된다. 이로써 복제는 커밋과 원자적이 된다.

Chapter 3: DML 실행 중 복제 레코드 기록

섹션 제목: “Chapter 3: DML 실행 중 복제 레코드 기록”

이 챕터는 하나의 질문에 답한다. 마스터에서 행이 삽입, 수정, 삭제될 때, 정확히 어느 호출 경로가 LOG_REPL_RECORD를 스테이징하며, 작업 종류에 따라 스테이징된 레코드는 어떻게 달라지는가? 상위 개요 문서(cubrid-ha-replication.md, “Row-based replication” 절)는 CUBRID가 물리적 행 이미지 대신 기본 키(primary key)를 복제하는 이유를 설명한다. 여기서는 키가 어떻게 캡처되는지, UPDATE에서 역방향 패치(back-patch)가 어디서 발생하는지, 그리고 기록 코드가 취하는 모든 분기를 추적한다.

스테이징 배열(tdes->repl_records[])과 LOG_REPL_RECORD 구조는 Chapter 1에서 다루었고, Chapter 2에서는 할당을 설명했다. 이 챕터는 배열 자체가 아니라 배열에 쓰는 경로를 추적한다.

3.1 기록 토폴로지 — 하나의 입구, 세 가지 호출 경로

섹션 제목: “3.1 기록 토폴로지 — 하나의 입구, 세 가지 호출 경로”

서버 측 DML은 모두 locator_attribute_info_force에 도달한다. 이 함수는 attribute-info copyarea로부터 레코드 이미지를 재구성하고 플러시 작업 종류에 따라 분기하여 locator_insert_force, locator_update_force(호출자가 넘긴 repl_info 포함), 또는 locator_delete_force로 디스패치한다. locator_delete_forcelocator_delete_force_internal로 전달하는 공개 래퍼이며, 알 수 없는 작업은 ER_LC_BADFORCE_OPERATION을 발생시킨다.

핵심 아키텍처 사실: 복제 레코드는 힙(heap) 레이어가 아니라 인덱스(index) 레이어에서 스테이징된다. 세 force 루틴 모두 locator_add_or_remove_index / locator_update_index를 호출하고, 이 함수들repl_log_insert를 호출한다. B-트리 작업을 위해 기본 키 DB_VALUE가 이미 구체화되어 있는 지점이므로, 이를 캡처하는 추가 비용이 없다.

flowchart TB
  AIF["locator_attribute_info_force"]
  AIF --> INS["locator_insert_force"] --> HIL["heap_insert_logical"]
  AIF --> UPD["locator_update_force"]
  AIF --> DEL["locator_delete_force"] --> DELI["locator_delete_force_internal"] --> HDL["heap_delete_logical"]
  INS --> ARI["locator_add_or_remove_index\n(is_insert true/false)"]
  DELI --> ARI
  UPD --> LUI["locator_update_index"]
  UPD --> HUL["heap_update_logical"]
  UPD --> RAU["repl_add_update_lsa\n(back-patch lsa)"]
  ARI -->|"PRIMARY_KEY, INSERT 또는 DELETE"| RLI["repl_log_insert\n(LOG_REPL_RECORD 스테이징)"]
  LUI -->|"PRIMARY_KEY, UPDATE"| RLI

Figure 3-1 — 기록 깔때기(emission funnel). INSERT와 DELETE는 locator_add_or_remove_index 내부에서 스테이징되고, UPDATE는 locator_update_index 내부에서 스테이징한 뒤 heap_update_logical 이후 repl_add_update_lsa로 LSA를 역방향 패치한다.

3.2 세 가지 force 루틴 — 복제 관련 분기

섹션 제목: “3.2 세 가지 force 루틴 — 복제 관련 분기”

force 루틴들은 repl_log_insert를 직접 호출하지 않는다. 대신 힙 로그와 need_replication 상태를 확립하여, 이후 인덱스 패스가 의존하도록 한다. INSERT와 DELETE는 힙 패스를 인덱스 패스 이전에 실행하여(힙 LSA가 스테이징 시점에 이미 확보된 상태로), UPDATE는 순서를 반전하여 인덱스 패스 먼저 힙 패스 나중에 실행한다. 이 역전이 역방향 패치를 필요하게 만드는 원인이다. 세 루틴 전반에서 복제와 관련된 분기들을 정리하면 다음과 같다.

루틴 / 분기복제에 대한 영향
insert: partition_prune_insert 실패 후 goto error2아무것도 스테이징되지 않음
insert: heap_insert_logical 성공힙 로그(RVHF_INSERT/RVHF_MVCC_INSERT)가 로그 추가 경로에서 repl_insert_lsa를 기록; 오류 시 LSA 없음, 인덱스 패스 없음
insert: has_index && add_or_remove_index (... true ...)is_insert = true 인덱스 패스에서 INSERT 레코드 스테이징
delete: isold_object == falseOID_SET_NULL; 인스턴스 블록 건너뜀 — 아무것도 스테이징되지 않음
delete: isold_object && OID_IS_ROOTOID클래스 삭제 — 카탈로그 경로, 인스턴스 인덱스 패스 아님
delete: heap_delete_logical 성공힙 삭제 로그 추가, tdes->tail_lsa 전진; MVCC 비활성화 건너뜀은 힙 로그를 기록하지 않아 tail_lsa 변화 없음(3.4의 prior_lsa 폴백); 오류 시 아무것도 스테이징되지 않음
delete: idx_action_flag == FOR_INSERT_OR_DELETE에서 add_or_remove_index (... false ...)is_insert = false 인덱스 패스에서 DELETE 레코드 스테이징
delete: 그 외 ..._index_for_moving (... false ...)파티션 이동 — 인덱스 항목은 삭제하지만 스테이징하지 않음
update: locator_update_index(본문 앞부분)NULL lsa로 UPDATE 레코드 스테이징; repl_info.need_replication 설정/해제
update: heap_update_logical 성공RVHF_UPDATE 계열 힙 로그가 로그 추가 경로에서 repl_update_lsa를 기록; 오류 시 NULL-lsa 레코드는 패치되지 않음
update: !LOG_CHECK_LOG_APPLIER && log_does_allow_replication () && repl_info.need_replicationrepl_add_update_lsa (thread_p, oid) 호출을 제어하는 가드

3.3 INSERT와 DELETE — locator_add_or_remove_index 내부에서의 스테이징

섹션 제목: “3.3 INSERT와 DELETE — locator_add_or_remove_index 내부에서의 스테이징”

locator_insert_forcelocator_delete_force_internal 모두 locator_add_or_remove_index_internal에 도달한다. 이 함수는 클래스의 모든 B-트리를 순회하면서 물리적 인덱스 작업을 수행하고, 기본 키에 한해서만 복제 레코드를 스테이징한다.

// locator_add_or_remove_index_internal -- src/transaction/locator_sr.c
if (need_replication && index->type == BTREE_PRIMARY_KEY && error_code == NO_ERROR
&& !LOG_CHECK_LOG_APPLIER (thread_p) && log_does_allow_replication () == true)
{
error_code =
repl_log_insert (thread_p, class_oid, inst_oid,
datayn ? LOG_REPLICATION_DATA : LOG_REPLICATION_STATEMENT,
is_insert ? RVREPL_DATA_INSERT : RVREPL_DATA_DELETE, /* <- op kind */
key_dbvalue,
REPL_INFO_TYPE_RBR_NORMAL); /* <- always NORMAL here */
}

네 가지 조건이 모두 충족되어야 한다. need_replication이 false이면 클래스가 복제 대상이 아니다(예: PK 없음). BTREE_PRIMARY_KEY가 아닌 인덱스는 다음 반복에서 처리된다. error_code != NO_ERROR는 B-트리 작업이 실패했음을 뜻하며 실패한 변경을 스테이징해서는 안 된다. LOG_CHECK_LOG_APPLIER / log_does_allow_replication () 쌍은 현재 프로세스가 applier로서 재현 중이거나 복제가 전역적으로 비활성화된 경우 단락(short-circuit)된다. INSERT와 DELETE는 REPL_INFO_TYPE_RBR_NORMAL을 하드코딩한다(단일 행 작업은 START/END 괄호로 묶이지 않는다 — 3.5절). 차이는 rcvindex 하나뿐이다(RVREPL_DATA_INSERTRVREPL_DATA_DELETE).

불변 조건 — 행 복제 레코드는 행당 정확히 한 번, 기본 키 인덱스에 대해서만 스테이징된다. per-index 루프 내의 index->type == BTREE_PRIMARY_KEY 가드가 이를 강제한다. 이 가드를 제거하면 N개 인덱스를 가진 테이블은 N개의 복사본을 스테이징하고, PK 없는 테이블은 아무것도 스테이징하지 않아(need_replication/PK 단락) 조용히 복제 대상에서 제외된다.

3.4 repl_log_insert — 스테이징 핵심 함수, 분기별 분석

섹션 제목: “3.4 repl_log_insert — 스테이징 핵심 함수, 분기별 분석”

repl_log_inserttdes->repl_records[]에 쓰는 유일한 함수다. 시그니처에는 작업 종류(rcvindex)와 다중 업데이트 정제 인수(repl_info)가 담긴다. 다섯 단계를 거치며, Figure 3-2는 모든 분기를 추적한다.

flowchart TB
  A["tdes == NULL?"] -->|yes| RET1["return ER_FAILED"]
  A -->|no| B["suppress_replication?"]
  B -->|yes| C["대기 중인 LSA 두 개를 null로; return"]
  B -->|no| D["no_logging이고 flush-mark 없음?"]
  D -->|yes| E["return NO_ERROR"]
  D -->|no| F["배열 없음/가득 참 -> repl_log_info_alloc"]
  F -->|실패| RET2["return error"]
  F -->|성공| G["P3: rcvindex 정제\nP4: 페이로드 + tde\nP5: lsa 설정; cur_repl_record++"]
  G --> Q["flush-mark 열림? must_flush 업그레이드"]

Figure 3-2 — repl_log_insert 제어 흐름, 모든 조기 반환 표시.

Phase 1–2 — 조기 반환과 배열 확장. 세 가지 단락 조건은 절대 스테이징하지 않는다. tdes == NULL; suppress_replication != 0(Chapter 11 — 대기 중인 LSA 두 개를 null로 설정하여 이후 레코드가 억제된 변경에 역방향 패치하지 못하도록 막는다); no_logging && fl_mark_repl_recidx == -1. 이후 REPL_LOG_IS_NOT_EXISTS / REPL_LOG_IS_FULL 조건이 repl_log_info_alloc을 호출한다(Chapter 2). 할당 실패 시 cur_repl_record를 건드리지 않고 반환하여 cur_repl_record <= capacity 불변 조건을 유지한다.

Phase 3 — rcvindex 정제. 작업 종류가 최종 결정되는 지점이다.

// repl_log_insert -- src/transaction/replication.c
repl_rec->rcvindex = rcvindex;
if (rcvindex == RVREPL_DATA_UPDATE) /* INSERT/DELETE skip this block */
switch (repl_info) {
case REPL_INFO_TYPE_RBR_START: repl_rec->rcvindex = RVREPL_DATA_UPDATE_START; break;
case REPL_INFO_TYPE_RBR_END: repl_rec->rcvindex = RVREPL_DATA_UPDATE_END; break;
default: break; /* NORMAL -> stays RVREPL_DATA_UPDATE */
}

외부 ifRVREPL_DATA_UPDATE일 때만 실행되므로, 3.3절에서 INSERT/DELETE에 전달하는 REPL_INFO_TYPE_RBR_NORMAL 인수는 무시된다.

Phase 4 — 페이로드와 tde_encrypted. LOG_REPLICATION_DATA일 때만 패킹된다(datayn 인수). LOG_REPLICATION_STATEMENT 레코드는 repl_data = NULL, length = 0이 된다.

// repl_log_insert -- src/transaction/replication.c
if (log_type == LOG_REPLICATION_DATA) {
// ... condensed: heap_get_class_name / heap_get_class_tde_algorithm -> ER_REPL_ERROR on failure ...
repl_rec->tde_encrypted = tde_algo != TDE_ALGORITHM_NONE; /* <- per-class TDE flag */
repl_rec->length = OR_INT_SIZE + or_packed_string_length (class_name, &strlen)
+ OR_VALUE_ALIGNED_SIZE (key_dbvalue);
ptr = (char *) malloc (repl_rec->length); repl_rec->repl_data = ptr; /* NULL -> ER_REPL_ERROR */
ptr_to_packed_key_value_size = ptr; ptr += OR_INT_SIZE; /* reserve 4 bytes, fill last */
ptr = or_pack_string_with_length (ptr, class_name, strlen); /* class name */
ptr = or_pack_mem_value (ptr, key_dbvalue, &packed_key_len); /* primary key DB_VALUE */
or_pack_int (ptr_to_packed_key_value_size, packed_key_len); /* back-fill size header */
} else { repl_rec->repl_data = NULL; repl_rec->length = 0; }

tde_encrypted는 상단에서 false로 초기화되고 이 Phase에서만 올라가므로, STATEMENT 레코드와 TDE 미사용 레코드는 false를 유지한다.

불변 조건 — 4바이트 크기 헤더는 그 뒤에 이어지는 패킹된 키의 바이트 길이와 같아야 한다. or_pack_mem_valuepacked_key_len을 보고한 뒤 마지막에 기록된다. OR_VALUE_ALIGNED_SIZE 상한값으로 미리 채우고 슬레이브가 언팩하면 키를 넘어 읽어 행이 손상된다.

Phase 5 — 비대칭 LSA 설정. must_flush = LOG_REPL_COMMIT_NEED_FLUSH 이후, 함수는 작업 종류에 따라 repl_rec->lsa를 선택한다.

// repl_log_insert -- src/transaction/replication.c
switch (rcvindex) {
case RVREPL_DATA_INSERT: /* heap_insert already ran */
if (!LSA_ISNULL (&tdes->repl_insert_lsa)) {
LSA_COPY (&repl_rec->lsa, &tdes->repl_insert_lsa);
LSA_SET_NULL (&tdes->repl_insert_lsa); LSA_SET_NULL (&tdes->repl_update_lsa);
} break;
case RVREPL_DATA_UPDATE:
LSA_SET_NULL (&repl_rec->lsa); break; /* <- heap update not done yet; back-patch later */
case RVREPL_DATA_DELETE: /* tail_lsa if set, else next prior_lsa */
LSA_COPY (&repl_rec->lsa, LSA_ISNULL (&tdes->tail_lsa) ? &log_Gl.prior_info.prior_lsa : &tdes->tail_lsa);
break;
default: break;
}
tdes->cur_repl_record++;

INSERT는 repl_insert_lsa를 복사하고 대기 중인 LSA 두 개를 null로 설정한다. UPDATE는 힙 업데이트가 인덱스 패스 이후에 실행되어 repl_add_update_lsa로 역방향 패치할 것이므로 NULL을 저장한다. DELETE는 자체 tail_lsa를 사용하되, MVCC 비활성화 건너뜀(3.2)으로 tail_lsa가 변하지 않은 경우 다음 prior_lsa로 폴백한다. UPDATE에서만 역방향 패치가 필요한 이유는 인덱스 패스를 힙 패스 이전에 실행하는 이 역전 때문이며, repl_add_update_lsa 헤더 주석에도 기록되어 있다.

3.5 flush-mark 꼬리와 다중 업데이트 괄호

섹션 제목: “3.5 flush-mark 꼬리와 다중 업데이트 괄호”

cur_repl_record++ 이후, flush-mark 괄호가 열려 있을 때만(fl_mark_repl_recidx != -1; repl_start_flush_mark로 열림, Chapter 4) 마지막 블록이 실행된다.

// repl_log_insert -- src/transaction/replication.c
if (tdes->fl_mark_repl_recidx != -1) {
if (strcmp (class_name, CT_SERIAL_NAME) != 0) {
for (i = 0; i < tdes->fl_mark_repl_recidx; i++) /* same instance already staged? */
if (recsp[i].must_flush == LOG_REPL_COMMIT_NEED_FLUSH && OID_EQ (&recsp[i].inst_oid, &repl_rec->inst_oid))
break; /* -> do NOT upgrade */
if (i >= tdes->fl_mark_repl_recidx)
repl_rec->must_flush = LOG_REPL_NEED_FLUSH; /* <- flush at commit AND rollback */
}
else repl_rec->must_flush = LOG_REPL_NEED_FLUSH; /* serial changes always upgrade */
}

기본 must_flushLOG_REPL_COMMIT_NEED_FLUSH(커밋 시에만 플러시)다. 열린 괄호 안에서는 LOG_REPL_NEED_FLUSH(커밋 롤백 시 플러시)로 업그레이드된다. 단, 같은 inst_oid를 대상으로 하는 이전 괄호 레코드가 있으면 이중 플러시를 방지하기 위해 업그레이드하지 않는다. CT_SERIAL_NAME(_db_serial)은 예외다. 시리얼 변경은 롤백 후에도 유지되므로 항상 업그레이드된다. repl_log_insert는 마크를 적용하고, 마크가 존재하는 이유는 Chapter 4에서 다룬다.

3.6 UPDATE — locator_update_index에서의 스테이징 후 역방향 패치

섹션 제목: “3.6 UPDATE — locator_update_index에서의 스테이징 후 역방향 패치”

locator_force_for_multi_update가 정제 값을 계산한다(i == first_update_obj이면 REPL_INFO_TYPE_RBR_START, i == last_update_obj이면 REPL_INFO_TYPE_RBR_END, 그 외에는 NORMAL). locator_update_force는 이를 REPL_INFO(need_replication = true)로 감싸 locator_update_index에 전달하고, locator_update_index는 PK를 찾아(pk_btid_index), 이전 키를 구체화하고, RVREPL_DATA_UPDATE로 스테이징한다.

// locator_update_index -- src/transaction/locator_sr.c
if (pk_btid_index != -1) {
// ... condensed: heap_attrvalue_get_key materializes repl_old_key, MIDXKEY domain fixup ...
error_code = repl_log_insert (thread_p, class_oid, oid, LOG_REPLICATION_DATA, RVREPL_DATA_UPDATE,
repl_old_key, (REPL_INFO_TYPE) repl_info->repl_info_type); /* START/NORMAL/END */
} else {
LSA_SET_NULL (&tdes->repl_insert_lsa); /* no PK -> FK action can't steal it */
if (repl_info != NULL) repl_info->need_replication = false; /* <- mark class non-replicated */
}

UPDATE는 이전 PK(repl_old_key)를 복제하여, PK가 변경된 경우에도 슬레이브가 행을 찾을 수 있게 한다. PK가 없으면 need_replication이 해제되고 호출자는 역방향 패치를 건너뛴다. locator_update_force로 돌아오면, heap_update_logicalRVHF_UPDATE 계열 힙 로그를 추가하고, log_manager.c의 복제 인식(replication-aware) 로그 추가 헬퍼가 부수 효과로 tail_lsa에서 tdes->repl_update_lsa를 기록한다. 그 후에야 세 조건 가드(!LOG_CHECK_LOG_APPLIER && log_does_allow_replication () && repl_info.need_replication)가 repl_add_update_lsa (thread_p, oid)를 호출하여 Phase 5에서 NULL로 남겨둔 lsa를 채운다.

3.7 repl_add_update_lsa — 역방향 탐색을 통한 역방향 패치

섹션 제목: “3.7 repl_add_update_lsa — 역방향 탐색을 통한 역방향 패치”

repl_add_update_lsa는 스테이징 배열을 역방향으로 순회하며 inst_oid가 일치하는 가장 가까운 UPDATE 레코드를 채운다.

// repl_add_update_lsa -- src/transaction/replication.c
if (tdes->suppress_replication != 0) return NO_ERROR; /* <- suppressed: nothing to patch */
for (i = tdes->cur_repl_record - 1; i >= 0; i--) { /* newest first */
repl_rec = &tdes->repl_records[i];
if (OID_EQ (&repl_rec->inst_oid, inst_oid) && !LSA_ISNULL (&tdes->repl_update_lsa)) {
assert (repl_rec->rcvindex == RVREPL_DATA_UPDATE || ... _UPDATE_START || ... _UPDATE_END);
LSA_COPY (&repl_rec->lsa, &tdes->repl_update_lsa); /* patch the NULL lsa */
LSA_SET_NULL (&tdes->repl_update_lsa); LSA_SET_NULL (&tdes->repl_insert_lsa);
find = true; break; /* patch only the nearest match */
}
}
if (find == false && prm_get_bool_value (PRM_ID_DEBUG_REPLICATION_DATA))
_er_log_debug (ARG_FILE_LINE, "can't find out the UPDATE LSA");

분기별로: **억제됨(suppressed)**은 즉시 반환; 일치(OID_EQ, non-null repl_update_lsa)는 힙 업데이트 LSA를 복사하고 대기 중인 LSA 두 개를 null로 설정한 뒤 가장 가까운 레코드에서 멈춘다(두 번 업데이트된 인스턴스는 가장 최근 레코드에 가장 최근 LSA를 얻는다); 일치 없음PRM_ID_DEBUG_REPLICATION_DATA가 설정된 경우에만 로그를 남기고 그렇지 않으면 NO_ERROR를 조용히 반환한다(복제 대상이 아닌 클래스의 레코드는 스테이징된 적이 없다). assert는 INSERT/DELETE가 여기에 null LSA를 가지고 도달하지 않음을 문서화한다 — 이들은 Phase 5에서 이미 LSA를 확정했다.

불변 조건 — tdes->repl_update_lsa는 UPDATE 당 정확히 한 번 소비된다. heap_update_logical이 설정하고, repl_add_update_lsa가 레코드를 패치하는 즉시 해제된다. 성공 시 대기 중인 LSA 두 개를 모두 null로 설정하면 이후 FK 캐스케이드가 잘못된 레코드에 역방향 패치하는 것을 막는다. PK에 FK가 함께 있을 때 FK 검사 전후의 preserved_repl_lsa 저장/복원(locator_update_index 내)이 이를 보호한다.

  1. 모든 DML은 locator_attribute_info_force를 거쳐 세 force 루틴으로 도달한다. 레코드는 한 단계 더 깊은 인덱스 패스에서 스테이징되는데, 이 시점에 PK DB_VALUE가 이미 준비되어 있다.
  2. force 루틴들은 힙 패스와 인덱스 패스의 순서를 서로 다르게 정한다. INSERT/DELETE는 힙 로그를 먼저 기록하고(로그 추가 경로가 대기 중인 LSA를 즉시 기록), UPDATE는 인덱스 패스를 먼저 실행하고 LSA를 나중에 패치한다. 모든 오류 goto는 스테이징을 건너뛰며, 파티션 이동과 MVCC 비활성화 분기는 아무것도 스테이징하지 않는다.
  3. repl_log_inserttdes->repl_records[]에 쓰는 유일한 함수다. 배열을 확장하기 전에 tdes == NULL, suppress_replication, no-logging/no-flush-mark 경우를 단락한다.
  4. 작업 종류는 rcvindex 인수다. INSERT/DELETE는 고정 REPL_INFO_TYPE_RBR_NORMAL과 함께 RVREPL_DATA_INSERT/_DELETE를 전달한다. UPDATE는 RVREPL_DATA_UPDATE와 함께 START/NORMAL/END 정제 값을 전달하며, 이는 RVREPL_DATA_UPDATE_START/_END로 재작성된다.
  5. 기본 키 인덱스만 레코드를 스테이징하며, 행당 한 번이다. DATA 페이로드는 클래스 이름과 역방향 채움 4바이트 크기 헤더 뒤에 패킹된 PK로 구성되며, tde_encryptedheap_get_class_tde_algorithm에서 가져온다.
  6. LSA 처리는 비대칭이다. INSERT는 repl_insert_lsa를 복사하고, DELETE는 tail_lsa(또는 다음 prior_lsa)를 사용하며, UPDATE는 힙 업데이트가 아직 발생하지 않았으므로 NULL을 저장한다. 대기 중인 LSA 자체는 log_manager.c의 복제 인식 로그 추가 헬퍼가 RVHF_INSERT/RVHF_MVCC_INSERTRVHF_UPDATE 힙 복구 인덱스를 키로 삼아 기록한다. heap_*_logical 루틴이 직접 기록하는 것이 아니다.
  7. UPDATE만 역방향 패치를 수행한다. repl_add_update_lsa는 배열을 역방향으로 탐색하여 가장 가까운 일치 레코드를 repl_update_lsa로 채우고, 대기 중인 LSA 두 개를 null로 설정하여 FK 캐스케이드가 이전 레코드의 대상을 손상시키지 못하도록 한다.

Chapter 4: 구문 기반 방출(Statement-Based Emission)과 플러시 마크(Flush Mark)

섹션 제목: “Chapter 4: 구문 기반 방출(Statement-Based Emission)과 플러시 마크(Flush Mark)”

3장에서는 must_flush = LOG_REPL_COMMIT_NEED_FLUSHrepl_records에 row 이벤트를 스테이징하는 경로를 다뤘다. 이 장은 그 경로에서 벗어나는 두 가지 경우를 다룬다: (1) row 이미지 대신 SQL 텍스트를 담는 구문 기반(statement-based) 레코드, (2) 트랜잭션이 롤백되더라도 레코드가 살아남도록 강제하는 플러시 마크(flush mark). 독자가 가져야 할 질문: DDL과 복제 대상 세션 구문은 row 이벤트와 어떻게 다르게 스테이징되며, 플러시 마크는 어떻게 롤백 시에도 방출을 강제하는가? SBR 경로의 설계 근거는 cubrid-ha-replication.md의 “Statement-based vs row-based” 및 Open Question 8을 참고하라. 이 장은 코드를 따라간다.

구문 레코드는 heap/index 기계가 아닌, SQL 실행기가 DDL 구문(및 일부 복제 대상 세션 구문)을 완료할 때 명시적으로 생성한다. 경로는 do_replicate_statement(execute_statement.c)로, 파싱된 구문으로부터 스택 상주 REPL_INFO_SBR repl_stmt를 채운다. 이어서 locator_flush_replication_info → 서버 측 디스패처 xrepl_set_info(locator_sr.c) 순서로 진행된다. 이 게이트에는 오직 REPL_INFO_TYPE_SBRrepl_log_insert_statement에 도달하고, 다른 repl_info_type은 모두 거부된다:

// xrepl_set_info -- src/transaction/locator_sr.c
if (!LOG_CHECK_LOG_APPLIER (thread_p) && log_does_allow_replication () == true)
{
switch (repl_info->repl_info_type)
{
case REPL_INFO_TYPE_SBR:
error_code = repl_log_insert_statement (thread_p, (REPL_INFO_SBR *) repl_info->info);
break;
default: /* <- RBR types never arrive here */
error_code = ER_REPL_ERROR;
er_set (..., "can't make repl sbr info");
break;
}
}

row 기반 타입(REPL_INFO_TYPE 열거형의 REPL_INFO_TYPE_RBR_*)은 heap/index 경로(3장)에서 repl_log_insert가 처리하며, 이 함수에는 도달하지 않는다.

REPL_INFO_SBR(replication.hstruct repl_info_statement alias)은 진행 중인 하나의 구문을 기술한다. 이것은 스테이징된 레코드가 아니라 repl_log_insert_statementrepl_rec->repl_data에 직렬화하는 입력이다. 각 필드:

필드역할존재 이유
statement_typeintCUBRID_STMT_* 코드(예: CUBRID_STMT_CREATE_CLASS, CUBRID_STMT_GRANT).applier에게 구문 범주를 알린다. do_replicate_statement가 PT 노드 타입으로 분기해 설정한다. PT_DROP_VARIABLE 및 미처리 타입은 조기에 return NO_ERROR — 의도적으로 복제하지 않는다.
name대상 오브젝트 이름(클래스/시리얼/인덱스), 또는 파서가 이름을 산출하지 못할 때의 센티널 unknown_name(리터럴 문자열 "-").applier가 오브젝트 단위로 로깅·라우팅할 수 있게 한다. 센티널 덕분에 미설정 포인터가 생기지 않는다.
stmt_text레플리카에서 재실행할 SQL 텍스트.이것이 핵심 페이로드다 — 레플리카는 이를 다시 파싱해 실행한다. CREATE/ALTER SERVER의 경우 비밀번호를 제거하기 위해 pt_print_bytes 출력으로 대체된다. 호스트 변수가 있으면 ? 플레이스홀더로 치환하여 재구성된다(sbr_text).
db_user실행 DB 사용자(db_get_user_name ()).레플리카가 동일 사용자로 DDL을 적용해야 권한이 일치한다. assert_release (repl_stmt.db_user != NULL)이 이를 보장한다.
sys_prm_contextHA 관련 시스템 파라미터(sysprm_print_parameters_for_ha_repl ()), DDL이 아닌 경우 NULL.DDL 의미론은 세션 파라미터에 따라 달라질 수 있다. pt_is_ddl_statement (statement) 시에만 채워진다.

불변 조건 — SBR은 row 이미지가 아닌 텍스트를 담는다. 구문 레코드의 repl_data{statement_type, name, stmt_text, db_user, sys_prm_context} 튜플을 패킹하며, inst_oid항상 NULL(OID_SET_NULL (&repl_rec->inst_oid))이다. row 레코드는 패킹된 PK와 실제 inst_oid를 가진다. repl_type(LOG_REPLICATION_STATEMENTLOG_REPLICATION_DATA)이 shipper와 applier가 분기하는 판별자다. 여기서 inst_oid가 NULL이 아니면, repl_log_insert의 충돌 병합 로직(inst_oid를 키로 사용)이 이를 row 업데이트로 오인할 수 있다.

4.2 repl_log_insert_statement — 분기별 분석

섹션 제목: “4.2 repl_log_insert_statement — 분기별 분석”
flowchart TB
  A["LOG_FIND_TDES"] --> B{"tdes == NULL?"}
  B -->|yes| RF["return ER_FAILED"]
  B -->|no| C{"suppress_replication?"}
  C -->|yes| OK0["return NO_ERROR\n레코드 스테이징 안 함"]
  C -->|no| D{"배열 상태"}
  D -->|NOT_EXISTS| AL1["alloc(false)"]
  D -->|IS_FULL| AL2["alloc(true) realloc"]
  D -->|여유 있음| E
  AL1 --> E["식별자 스탬핑\ntype/rcvindex/must_flush/inst_oid"]
  AL2 --> E
  E --> G["malloc repl_data"]
  G --> H{"malloc OK?"}
  H -->|no| ME["ER_REPL_ERROR, return"]
  H -->|yes| I["5개 필드 or_pack\nlsa = tdes->tail_lsa"]
  I --> J{"플러시 마크 윈도우 안?"}
  J -->|yes| K["must_flush = NEED_FLUSH"]
  J -->|no| L["COMMIT_NEED_FLUSH 유지"]
  K --> M["cur_repl_record++"]
  L --> M

Figure 4-1 — repl_log_insert_statement의 제어 흐름. 두 개의 조기 반환과 malloc 오류 경로 모두 cur_repl_record를 건드리지 않으므로, 반쯤 완성된 레코드가 게시되지 않는다.

전처리 부분은 repl_log_insert(3장)와 동일하다: LOG_TDES 확인, suppress_replication 처리, repl_records 할당 또는 확장. 두 가지 할당 분기가 중요하다:

// repl_log_insert_statement -- src/transaction/replication.c
if (REPL_LOG_IS_NOT_EXISTS (tran_index) /* <- never allocated */
&& ((error = repl_log_info_alloc (tdes, REPL_LOG_INFO_ALLOC_SIZE, false)) != NO_ERROR))
return error;
else if (REPL_LOG_IS_FULL (tran_index) /* <- last free slot used */
&& (error = repl_log_info_alloc (tdes, REPL_LOG_INFO_ALLOC_SIZE, true)) != NO_ERROR)
return error;

REPL_LOG_IS_FULLnum_repl_records == cur_repl_record + 1이므로, 오버플로우 직전 슬롯에서 확장이 발동된다 — repl_records[cur_repl_record]에 대한 쓰기는 항상 범위 안에 있다. 레코드에는 이후 식별자와 LSA가 스탬핑된다:

// repl_log_insert_statement -- src/transaction/replication.c
repl_rec->repl_type = LOG_REPLICATION_STATEMENT;
repl_rec->tde_encrypted = false; /* <- SBR text is never TDE data */
repl_rec->rcvindex = RVREPL_STATEMENT;
repl_rec->must_flush = LOG_REPL_COMMIT_NEED_FLUSH; /* <- default: flush at commit only */
OID_SET_NULL (&repl_rec->inst_oid); /* <- statements have no instance */
// ... condensed: length, malloc repl_data, or_pack the 5 fields ...
LSA_COPY (&repl_rec->lsa, &tdes->tail_lsa); /* <- last log this txn wrote */

페이로드 길이는 OR_INT_SIZE 하나(statement_type용)와 네 개의 패킹된 문자열 길이를 합산한다. malloc으로 repl_data를 생성한 뒤 or_pack_int / or_pack_string_with_length로 튜플을 직렬화한다. malloc 실패 시 ER_REPL_ERROR를 발생시키고, cur_repl_record를 증가시키기 전에 반환하므로 배열은 변경되지 않는다. lsarepl_log_abort_after_lsa가 롤백 LSA와 비교할 때(§4.4)와 copylogdb/applylogdb가 순서를 정할 때 사용하는 앵커다. repl_log_insertRVREPL_DATA_INSERT/DELETE 케이스(repl_insert_lsa 또는 prior_info.prior_lsa 사용)와 달리, 구문 경로에는 before/after 이미지가 없으므로 단순한 tail_lsa — 해당 트랜잭션이 마지막으로 기록한 로그 — 가 올바른 앵커다.

끝부분의 플러시 마크 확인은 row 확인과 다르다. row 경로는 repl_records[0 .. fl_mark_repl_recidx)를 스캔하여 동일한 inst_oid를 가진 기존 COMMIT_NEED_FLUSH 레코드가 없을 때만 승격하는 반면(충돌 병합), 구문 경로는 그러한 스캔을 수행하지 않는다:

// repl_log_insert_statement -- src/transaction/replication.c
if (tdes->fl_mark_repl_recidx != -1 && tdes->cur_repl_record >= tdes->fl_mark_repl_recidx)
{
/* statement replication does not check log conflicts, so
* use repl_start_flush_mark with caution. */
repl_rec->must_flush = LOG_REPL_NEED_FLUSH; /* <- unconditional promotion */
}
tdes->cur_repl_record++;

소스 내 주석이 핵심 경고다: inst_oid로 중복 제거를 할 수 없으므로, 플러시 마크 인덱스 이후의 모든 구문 레코드는 무조건 NEED_FLUSH로 강제된다.

4.3 must_flush 상태 기계와 플러시 마크 브래킷

섹션 제목: “4.3 must_flush 상태 기계와 플러시 마크 브래킷”

LOG_REPL_FLUSH(replication.h)는 스테이징된 레코드별로 언제 발송될 수 있는지를 결정하는 세 가지 값의 열거형이다:

숫자의미
LOG_REPL_DONT_NEED_FLUSH-1절대 발송하지 않음 — 논리적으로 폐기됨(예: repl_log_abort_after_lsa에 의해).
LOG_REPL_COMMIT_NEED_FLUSH0트랜잭션이 커밋될 때만 발송(모든 row·구문 레코드의 기본값). 롤백 시 폐기.
LOG_REPL_NEED_FLUSH1커밋과 롤백 모두에서 발송 — 트랜잭션이 데이터를 되돌리더라도 레플리카에 도달해야 한다. 플러시 마크 브래킷 안에서만 설정.

브래킷은 tdes->fl_mark_repl_recidx로, repl_recordsint 인덱스다(LOG_TDES 필드, 1장). repl_start_flush_mark로 열리고 repl_end_flush_mark로 닫힌다.

stateDiagram-v2
  [*] --> Closed: fl_mark_repl_recidx == -1
  Closed --> Open: repl_start_flush_mark\n cur_repl_record 기록
  Open --> Open: 윈도우 내 삽입 \n 새 레코드는 NEED_FLUSH
  Open --> Closed: repl_end_flush_mark need_undo=false\n 레코드 유지, 마크 해제
  Open --> RolledBack: repl_end_flush_mark need_undo=true\n 마크까지 해제 및 잘라내기
  RolledBack --> Closed: 마크 해제

Figure 4-2 — 하나의 트랜잭션 디스크립터 위의 플러시 마크 상태 기계. “Open”은 마크가 유효한 인덱스를 보유한 상태, “Closed”는 -1인 상태를 의미한다.

// repl_start_flush_mark -- src/transaction/replication.c
tdes = LOG_FIND_CURRENT_TDES (thread_p);
if (tdes == NULL)
{
er_set (ER_FATAL_ERROR_SEVERITY, ..., ER_LOG_UNKNOWN_TRANINDEX, ...);
return; /* <- fatal: no current txn */
}
if (tdes->fl_mark_repl_recidx == -1)
tdes->fl_mark_repl_recidx = tdes->cur_repl_record; /* <- remember where window opened */
/* else already started, return */ /* <- idempotent, not nested */

불변 조건 — 플러시 마크는 재진입(re-entrant)이 불가능하다. 마크가 열려 있는 동안 두 번째 repl_start_flush_mark를 호출하면 no-op이 된다. if (== -1) 가드가 첫 번째 호출 시 시작 인덱스를 고정하므로, 브래킷은 중첩되거나 시작 위치가 이동될 수 없다. 마크를 쌓으면 내부 start가 외부 윈도우에 묶이고 내부 end가 모든 것을 조기에 닫아버릴 수 있다. 따라서 qexec_execute_selupd_list는 start 하나와 end 하나를 정확히 쌍으로 맞춘다.

// repl_end_flush_mark -- src/transaction/replication.c
if (need_undo)
{
LOG_REPL_RECORD *recsp = tdes->repl_records;
for (i = tdes->fl_mark_repl_recidx; i < tdes->cur_repl_record; i++)
free_and_init (recsp[i].repl_data); /* <- free payloads staged in window */
tdes->cur_repl_record = tdes->fl_mark_repl_recidx; /* <- truncate array back to mark */
}
tdes->fl_mark_repl_recidx = -1; /* <- always close the window */

두 가지 분기가 있다:

  1. need_undo == false (성공). 윈도우의 레코드는 보존된다(여전히 LOG_REPL_NEED_FLUSH). 마크 정수만 -1로 리셋된다.
  2. need_undo == true (실패). fl_mark_repl_recidx부터 cur_repl_record 직전까지 각 레코드의 repl_data가 해제되고, cur_repl_record가 마크 위치로 되감겨 해당 레코드들을 지운다. 이후 마크가 해제된다.

코드 트리 전체에서 정확히 세 곳이 브래킷을 열고 닫으며, 모두 start 하나와 end 하나를 쌍으로 맞춘다:

  1. xchksum_insert_repl_log_and_demote_table_lock (locator_sr.c) — 이 장의 DDL 경로를 감싸는 정식 구문 호출자. log_sysop_startrepl_start_flush_markxrepl_set_info(→ repl_log_insert_statement) → repl_end_flush_mark (..., false) 순서로 실행된다. 브래킷이 DDL 구문 레코드를 LOG_REPL_NEED_FLUSH로 승격시켜, 감싸는 트랜잭션이 나중에 데이터 변경을 롤백하더라도 레플리카에 도달한다. error != NO_ERROR이면 log_sysop_abort로 떨어진다.
  2. qexec_execute_selupd_list (query_executor.c)lock_is_instant_lock_mode 하에서 일괄 증분(batch of increments)을 처리하는 정식 row 호출자. 성공 시 repl_end_flush_mark (..., false), exit_on_errorrepl_end_flush_mark (..., true)를 호출하여 배치가 원자적으로 방출된다.
  3. serial_update_serial_object (serial.c)lock_mode != X_LOCK인 경우에만 해당. X_LOCK(커밋되지 않은 create/alter)은 sysop와 플러시 마크를 의도적으로 건너뛴다. 마킹하면 복제 로그 순서가 어긋나기 때문이다.

세 곳 모두 !LOG_CHECK_LOG_APPLIERlog_does_allow_replication () 뒤에 start/end 쌍을 배치한다 — applier는 재실행 중에 브래킷을 열지 않는다.

4.4 repl_log_abort_after_lsa — 부분 롤백 생존

섹션 제목: “4.4 repl_log_abort_after_lsa — 부분 롤백 생존”

롤백 시에도 레코드를 플러시하도록 강제하는 것(§4.3)은 이야기의 절반에 불과하다. 부분적 롤백 — 시스템 오퍼레이션(sysop) 중단이나 세이브포인트 롤백 — 은 해당 시점 이후에 스테이징된 레코드를 폐기하되, 그 이전의 플러시 마크된 레코드는 살아남아야 한다. 그 폐기를 담당하는 것이 repl_log_abort_after_lsa다:

// repl_log_abort_after_lsa -- src/transaction/replication.c
repl_rec_arr = tdes->repl_records;
for (i = 0; i < tdes->cur_repl_record; i++)
{
if (LSA_GT (&repl_rec_arr[i].lsa, start_lsa)) /* <- staged strictly after the rollback point */
repl_rec_arr[i].must_flush = LOG_REPL_DONT_NEED_FLUSH;
}
return NO_ERROR;

스테이징된 모든 레코드를 순회하며 lsastart_lsa보다 엄격히 큰 것에 LOG_REPL_DONT_NEED_FLUSH 툼스톤을 찍는다. cur_repl_record를 되감거나 repl_data를 해제하지는 않는다 — 레코드는 배열에 남지만 shipper가 건너뛴다. start_lsa 이하의 레코드는 플러시 상태(LOG_REPL_NEED_FLUSH 포함)를 유지하므로, 롤백 지점 이전의 플러시 마크된 작업은 살아남는다.

유일한 호출자는 log_sysop_abort(log_manager.c)로, applier와 비워커 트랜잭션에서는 실행되지 않도록 세 가지 가드를 배치한다:

// log_sysop_abort -- src/transaction/log_manager.c
if (!LOG_CHECK_LOG_APPLIER (thread_p) /* <- not the applier replaying */
&& tdes->is_active_worker_transaction () /* <- a real worker txn, not sysop-main */
&& log_does_allow_replication () == true) /* <- HA replication enabled */
{
repl_log_abort_after_lsa (tdes, LOG_TDES_LAST_SYSOP_PARENT_LSA (tdes));
}

이 코드는 log_sysop_abortLSA_ISNULL || LSA_LE 가드의 else 분기에 위치하므로, sysop가 실제로 부모 LSA를 넘어 로그를 기록했을 때만 실행된다. no-op sysop는 툼스톤 순회를 건너뛴다.

불변 조건 — LSA_GT는 엄격한 비교이므로 롤백 경계 레코드는 보존된다. lsastart_lsa와 같은 레코드는 툼스톤 처리되지 않는다(>, >= 아님). 경계 레코드는 살아남는 접두 구간에 속한다. 두 메커니즘은 직교하는 기준으로 조합된다 — 플러시 마크는 발송 시점의 플러시 상태로 보호하고, repl_log_abort_after_lsa는 플러시 상태와 무관하게 LSA 위치로 툼스톤을 찍는다. 롤백 지점 이전의 플러시 마크된 레코드는 NEED_FLUSH를 유지하고 발송된다. 롤백 지점 이후의 것은 폐기된다. 세이브포인트나 sysop 로직을 건드리는 독자는 이 조합을 보존해야 한다.

4.5 구문 결정론 — 미해결 문제에 대한 포인터

섹션 제목: “4.5 구문 결정론 — 미해결 문제에 대한 포인터”

구문 경로는 SQL 텍스트를 스테이징하고 레플리카에서 재실행한다 — 간결하지만 비결정론적이다. NOW(), RAND(), 또는 시리얼에 의존하는 표현식이 포함된 구문은, 행 이미지가 아닌 구문이 재실행되므로 결과가 달라질 수 있다. CUBRID는 시리얼 비결정론을 완화하고(repl_log_insert 3장의 CT_SERIAL_NAME 분기) sys_prm_context를 전송하지만, 일반적인 재실행 비결정론은 아직 해결되지 않았다 — cubrid-ha-replication.mdOpen Question 8. 이 장이 추가하는 세부 사항은 어디서 드러나는가이다: 전송되는 필드는 stmt_text이며 row 이미지가 아니다.

  1. 구문 레코드는 별도의 진입점을 사용한다do_replicate_statementxrepl_set_info. 여기서는 오직 REPL_INFO_TYPE_SBRrepl_log_insert_statement로 디스패치되며, 그 외는 모두 ER_REPL_ERROR다. row 이벤트는 이 함수에 도달하지 않는다.

  2. REPL_INFO_SBR은 row가 아닌 텍스트를 발송한다. 다섯 개 필드가 repl_data에 패킹되며, 스테이징된 레코드는 LOG_REPLICATION_STATEMENT, rcvindex = RVREPL_STATEMENT이고 inst_oid는 항상 NULL이다.

  3. must_flush는 세 값짜리 발송 게이트다DONT_NEED_FLUSH (-1)는 절대 발송하지 않고, COMMIT_NEED_FLUSH (0)는 커밋 시에만 발송하며(기본값), NEED_FLUSH (1)는 커밋 롤백 모두에서 발송한다.

  4. 구문 플러시 마크 승격은 무조건적이다repl_log_insert(inst_oid로 병합)와 달리, repl_log_insert_statement는 윈도우 내의 모든 레코드를 NEED_FLUSH로 강제한다(“use with caution”).

  5. 플러시 마크 브래킷은 단일, 재진입 불가 정수다. repl_start_flush_mark-1일 때만 fl_mark_repl_recidx를 설정하며, repl_end_flush_mark는 윈도우를 유지(need_undo=false)하거나 해제 후 잘라낸다(need_undo=true). 세 호출자가 start와 end를 쌍으로 맞춘다 — xchksum_insert_repl_log_and_demote_table_lock(구문/DDL), qexec_execute_selupd_list(row 배치), serial.c 업데이트 경로.

  6. repl_log_abort_after_lsa는 플러시 마크가 아닌 LSA 위치로 툼스톤을 찍는다log_sysop_abort에서 lsa > start_lsa인 모든 레코드를 DONT_NEED_FLUSH로 강등한다(엄격 비교이므로 경계는 살아남음). 부분 롤백이 되돌린 작업에 대해서는 플러시 마크를 덮어쓴다.

  7. 구문 결정론이 미해결 접합부다stmt_text가 레플리카에서 재실행되므로, 비결정론적 함수는 결과가 달라진다(Open Question 8).

Chapter 5: 커밋 시점 플러시와 원자적 방출

섹션 제목: “Chapter 5: 커밋 시점 플러시와 원자적 방출”

Chapter 4가 끝날 시점에 트랜잭션 디스크립터는 tdes->repl_records[0 .. cur_repl_record-1] 스테이징 배열을 완전히 채운 상태다. 각 원소는 LOG_REPL_RECORD로, repl_type, rcvindex, 역산출된 lsa, 패킹된 repl_data, must_flush 플래그를 담고 있으나 아직 WAL 스트림에는 기록되지 않았다. 이 챕터는 커밋 시점의 핵심 질문에 답한다: 스테이징된 레코드들이 어떻게 실제 LOG_REPLICATION_DATA / LOG_REPLICATION_STATEMENT WAL 레코드로 전환되는가, 그리고 피어 트랜잭션 T’의 커밋 레코드가 트랜잭션 T의 복제 레코드와 T의 커밋 레코드 사이에 끼어들지 않도록 보장하는 것은 무엇인가?

동반 고수준 문서(cubrid-ha-replication.md, “Commit-time emission — log_append_repl_info_and_commit_log” 절)는 원자성 계약을 명시한다. 이 챕터는 그것을 구현하는 다섯 함수를 분기별로 해부하고, 슬레이브의 재시작 로직이 의존하는 불변 조건을 명명한다. 이 노드들을 로그 페이지 버퍼에 쓰는 prior-list 드레인은 여기서 재도출하지 않는다 — cubrid-log-manager-detail.md, “Prior-LSA list and the append pipeline” 절을 참조하라.

트랜잭션 종료 결과는 세 가지로 범위가 한정된다: 커밋하는 HA 트랜잭션은 스테이징된 레코드와 커밋 레코드를 하나의 mutex 아래 방출한다(§5.2); 일반 커밋(가드 조건 중 하나라도 거짓)은 커밋 레코드만 방출하고 스테이징 레코드를 조용히 버린다(§5.1); 중단(abort)하는 트랜잭션은 복제 레코드를 전혀 방출하지 않는다 — abort는 스테이징 배열을 전혀 순회하지 않으므로, 레코드들은 트랜잭션 종료 정리 시 디스크립터 리셋에 의해 폐기된다(§5.1, abort 주석).

5.1 log_commit_local에서의 커밋 결정

섹션 제목: “5.1 log_commit_local에서의 커밋 결정”

log_commit(공개 진입점)은 2PC 부기 작업을 수행한 뒤, 로컬 또는 참여자 트랜잭션에 log_commit_local을 호출한다. 복제 분기는 log_commit_local의 말미, “트랜잭션이 데이터를 갱신한” 분기 (is_local_tran) 안에 존재한다:

// log_commit_local -- src/transaction/log_manager.c
if (is_local_tran)
{
LOG_LSA commit_lsa;
// ... condensed ...
if (!LOG_CHECK_LOG_APPLIER (thread_p) && tdes->is_active_worker_transaction ()
&& log_does_allow_replication () == true)
log_append_repl_info_and_commit_log (thread_p, tdes, &commit_lsa); /* <- atomic path */
else
log_append_commit_log (thread_p, tdes, &commit_lsa); /* <- plain commit, no repl */
if (retain_lock != true)
lock_unlock_all (thread_p); /* <- AFTER the commit log is appended; see ordering note */
log_change_tran_as_completed (thread_p, tdes, LOG_COMMIT, &commit_lsa);
}

가드의 각 분기는 모두 의미를 가진다:

분기조건결과
원자적 복제 경로복제 허용 and 활성 워커 트랜잭션 and 호출자가 log applier가 아님log_append_repl_info_and_commit_log — 하나의 mutex 아래 복제 레코드 + 커밋
일반 커밋가드 조건 중 하나라도 거짓log_append_commit_log — 커밋 레코드만, 스테이징된 복제 레코드는 방출되지 않음
갱신 없음 분기LSA_ISNULL (&tdes->tail_lsa) (함수 상단)둘 다 호출되지 않음; 상태가 바로 TRAN_UNACTIVE_COMMITTED로 전환

LOG_CHECK_LOG_APPLIER는 슬레이브 자체의 apply 연결을 배제한다(자신의 복제를 재방출해서는 안 된다); is_active_worker_transaction()은 사용자 가시적 DML이 아닌 시스템 워커 트랜잭션(vacuum, checkpoint)을 배제한다; log_does_allow_replication()(log_comm.c)은 SA_MODE/WINDOWS 환경, log-copier/applier 클라이언트일 때 거짓을 반환하며, 그 외에는 HA 서버 상태를 조회한다. 조건 중 하나라도 거짓이면 스테이징된 레코드는 디스크립터와 함께 폐기된다.

동일한 방출자에 도달하는 두 번째 경로도 존재한다. log_complete(비-2PC 완료)는 일반 커밋만 추가한다; log_complete_for_2pc(2PC 경로)는 !LOG_CHECK_LOG_APPLIER (thread_p) && log_does_allow_replication () == true 라는 두 조건 변형으로 복제+커밋 방출자를 호출한다 — 2PC 트랜잭션은 구조적으로 활성 워커이므로 is_active_worker_transaction() 조건은 여기서 중복이다. 이 챕터의 나머지 부분은 두 경로를 하나로 취급한다.

순서 주석. 복제+커밋 추가는 lock_unlock_all보다 앞서 발생한다 (소스 블록 주석이 이유를 설명한다): T1이 대기 중인 T2를 재개시켰고, T2가 커밋했으며, T2의 커밋 이후 T1의 커밋 이전에 크래시가 발생한다면, 순서가 뒤바뀐 unlock 로그로 인해 복구는 T1을 중단시키면서 T2는 이미 커밋된 상태가 된다. 커밋 로그를 먼저 기록함으로써 그 취약 구간을 닫는다.

Abort는 방출하지 않음으로써 폐기한다. 대응하는 abort 함수 log_abort_local에는 복제 분기가 전혀 없다: log_rollback을 호출하고, lock_unlock_all을 호출하며, TRAN_UNACTIVE_ABORTED를 반환한다 — log_append_repl_info*를 전혀 호출하지 않는다. 스테이징된 tdes->repl_records는 순전히 방출되지 않음으로써 폐기된다: 아무것도 방출하지 않고, 트랜잭션 종료 정리 시 logtb_clear_tdes가 스테이징 배열을 리셋한다(cur_repl_record = 0, append_repl_recidx = -1, fl_mark_repl_recidx = -1). abort 시점에 게이트를 순회하며 레코드를 건너뛰는 처리는 없다; 게이트(§5.4.2)는 커밋 경로와 system-op 경로에서만 진입된다.

5.2 원자성 관용구 — log_append_repl_info_and_commit_log

섹션 제목: “5.2 원자성 관용구 — log_append_repl_info_and_commit_log”
// log_append_repl_info_and_commit_log -- src/transaction/log_manager.c
static void
log_append_repl_info_and_commit_log (THREAD_ENTRY * thread_p, LOG_TDES * tdes, LOG_LSA * commit_lsa)
{
if (tdes->has_supplemental_log) /* <- SUPPLEMENTAL_LOG > 0 set a user-name marker */
{
log_append_supplemental_info (thread_p, LOG_SUPPLEMENT_TRAN_USER,
strlen (tdes->client.get_db_user ()),
tdes->client.get_db_user ());
tdes->has_supplemental_log = false;
}
log_Gl.prior_info.prior_lsa_mutex.lock (); /* <- single hold spans BOTH appends */
log_append_repl_info_with_lock (thread_p, tdes, true); /* is_commit = true */
log_append_commit_log_with_lock (thread_p, tdes, commit_lsa); /* LOG_COMMIT donetime */
log_Gl.prior_info.prior_lsa_mutex.unlock ();
}

불변 조건 5-A — T의 복제 레코드와 T의 커밋 사이의 엄격한 인접성. 트랜잭션 T의 마지막 LOG_REPLICATION_* 레코드와 T의 LOG_COMMIT 레코드 사이에는 다른 어떤 트랜잭션의 커밋 레코드도 나타날 수 없다. 보장 방법: log_append_repl_info_with_locklog_append_commit_log_with_lock 양쪽에 걸쳐 log_Gl.prior_info.prior_lsa_mutex를 유지한다 — 다른 모든 커밋 주체도 자신의 커밋 donetime을 추가하려면 동일한 mutex를 취득해야 하므로, 어떤 피어 커밋도 사이에 끼어들 수 없다. 위반 시: 두 추가 사이에 피어 커밋이 삽입되어 [T repl...][T' commit][T commit] 형태가 생긴다; 내구적 북마크(Chapter 10)가 [T repl...][T' commit] 사이를 가리키고 있다가 크래시 후 재시작한 슬레이브(applylogdb)는 T의 커밋을 순서 밖에서 읽고, 플러시를 잘못 귀속하거나 순서를 잘못 처리하여 T의 행 변경을 조용히 누락할 수 있다. 문서 주석이 이를 명확히 명시한다: “Atomic write of replication log and commit log is crucial for replication consistencies.”

비-HA 경로는 다르다: log_append_commit_log(§5.5)는 명시적 mutex를 전혀 취득하지 않는다 — log_append_donetime_internalLOG_PRIOR_LSA_WITHOUT_LOCK으로 실행되며 단일 커밋 추가를 위해 내부적으로 mutex를 취득한다. HA 경로만이 번의 추가에 걸쳐 하나의 lock을 유지하므로, HA 경로만이 호출자 측에서 mutex를 취득한다.

has_supplemental_log 접두 처리. PRM_ID_SUPPLEMENTAL_LOG > 0이고 tdes->has_supplemental_log가 설정된 경우, 커밋 중인 DB 사용자 이름을 담은 LOG_SUPPLEMENT_TRAN_USER 하위 타입의 LOG_SUPPLEMENTAL_INFO 레코드가 mutex 밖에서 먼저 추가되고 플래그가 초기화된다. 이는 커밋당 최대 한 번만 방출된다. CDC / supplemental 소비자에게 트랜잭션의 사용자 신원을 제공하기 위한 것으로, HA 원자성 보장과는 무관하며 의도적으로 lock 취득 이전에 위치한다.

5.3 log_append_repl_infolog_append_repl_info_with_lock

섹션 제목: “5.3 log_append_repl_info와 log_append_repl_info_with_lock”

두 함수는 공유 내부 함수의 one-line 디스패처로, with_lock 인수의 전달 방식만 다르다:

// log_append_repl_info / log_append_repl_info_with_lock -- src/transaction/log_manager.c
void log_append_repl_info (THREAD_ENTRY * thread_p, LOG_TDES * tdes, bool is_commit)
{ log_append_repl_info_internal (thread_p, tdes, is_commit, LOG_PRIOR_LSA_WITHOUT_LOCK); }
static void log_append_repl_info_with_lock (THREAD_ENTRY * thread_p, LOG_TDES * tdes, bool is_commit)
{ log_append_repl_info_internal (thread_p, tdes, is_commit, LOG_PRIOR_LSA_WITH_LOCK); }

with_lockprior_lsa_next_record_with_lock(호출자가 이미 mutex를 보유) 또는 prior_lsa_next_record(노드당 mutex를 취득)를 선택한다. 두 호출자의 목적은 서로 다르다:

호출자is_commitwith_lock목적
log_append_repl_info_and_commit_log_with_locktrueWITH_LOCK커밋 시점 플러시; 보유 중인 mutex 아래 아직 플러시되지 않은 모든 레코드를 방출 (원자적 경로)
log_sysop_commit_internallog_append_repl_infofalseWITHOUT_LOCKSystem-op 커밋, §5.1과 동일한 세 조건 가드 아래; 트랜잭션 중간에 LOG_REPL_NEED_FLUSH 레코드만 방출 (DDL flush 마킹), 각각 자체적으로 mutex를 취득

system-op 호출자는 Chapter 4의 flush-mark 메커니즘이다: DDL 서브-오퍼레이션은 사용자 커밋까지 지연하지 않고 system-op 경계에서 복제되며, 해당 레코드에 LOG_REPL_NEED_FLUSH를 표시하고 log_sysop_commit_internalis_commit = false로 조기 플러시한다. is_commit 플래그는 “지금 NEED_FLUSH 레코드만 플러시”와 “커밋 시점에 아직 남은 모든 것을 플러시”를 구분한다; §5.4에서 게이트가 이를 어떻게 사용하는지 보여준다.

5.4 log_append_repl_info_internal — 분기별 해부

섹션 제목: “5.4 log_append_repl_info_internal — 분기별 해부”

핵심 함수다: 커서부터 스테이징 배열을 순회하며 조건을 충족하는 레코드를 prior-list 노드로 변환한다.

// log_append_repl_info_internal -- src/transaction/log_manager.c
static void
log_append_repl_info_internal (THREAD_ENTRY * thread_p, LOG_TDES * tdes, bool is_commit, int with_lock)
{
LOG_REPL_RECORD *repl_rec; LOG_REC_REPLICATION *log; LOG_PRIOR_NODE *node;
if (tdes->append_repl_recidx == -1 || is_commit) /* first time, OR commit rescans from start */
tdes->append_repl_recidx = 0;
while (tdes->append_repl_recidx < tdes->cur_repl_record)
{
repl_rec = (LOG_REPL_RECORD *) (&(tdes->repl_records[tdes->append_repl_recidx]));
if ((repl_rec->repl_type == LOG_REPLICATION_DATA || repl_rec->repl_type == LOG_REPLICATION_STATEMENT)
&& ((is_commit && repl_rec->must_flush != LOG_REPL_DONT_NEED_FLUSH)
|| repl_rec->must_flush == LOG_REPL_NEED_FLUSH)) /* <- the gate, §5.4.2 */
{
node = prior_lsa_alloc_and_copy_data (thread_p, repl_rec->repl_type, RV_NOT_DEFINED, NULL,
repl_rec->length, repl_rec->repl_data, 0, NULL);
if (node == NULL)
{ assert (false); continue; } /* <- does NOT advance recidx */
if (repl_rec->tde_encrypted)
if (prior_set_tde_encrypted (node, repl_rec->rcvindex) != NO_ERROR)
{ assert (false); continue; } /* <- same non-advancing continue */
log = (LOG_REC_REPLICATION *) node->data_header;
if (repl_rec->rcvindex == RVREPL_DATA_DELETE || repl_rec->rcvindex == RVREPL_STATEMENT)
LSA_SET_NULL (&log->lsa); /* <- DELETE/STATEMENT carry no target LSA */
else
LSA_COPY (&log->lsa, &repl_rec->lsa); /* <- INSERT/UPDATE copy the heap LSA through */
log->length = repl_rec->length;
log->rcvindex = repl_rec->rcvindex;
// ... with_lock ? prior_lsa_next_record_with_lock : prior_lsa_next_record (both arms in §5.3) ...
repl_rec->must_flush = LOG_REPL_DONT_NEED_FLUSH; /* <- §5.4.4 re-emit guard */
}
tdes->append_repl_recidx++;
}
}

append_repl_recidx는 재개 커서로, 두 경우에 진입 시 0으로 설정된다: 최초 진입 (== -1, 새로 초기화된 디스크립터)이거나 is_commit이 true일 때 — 커밋은 배열 전체를 처음부터 재스캔한다. 트랜잭션 중간 flush-mark 패스(§5.3)가 커서를 이미 검사한 레코드들 너머로 전진시켰기 때문에, 커밋은 해당 패스가 건너뛴 COMMIT_NEED_FLUSH 레코드들을 잡아내기 위해 모든 레코드를 재검사해야 한다. is_commit이 false이고 커서가 이미 >= 0이면, 비-커밋 패스는 중단된 지점부터 재개하여 마지막 패스 이후 추가된 레코드만 스캔한다.

5.4.2 must_flush 게이트 — 모든 조합

섹션 제목: “5.4.2 must_flush 게이트 — 모든 조합”

if 조건은 타입이 복제 타입이고 그리고 플러시 조건이 성립할 때 레코드를 통과시킨다. LOG_REPL_FLUSH 세 값(replication.h)에 대한 조합은 다음과 같다:

must_flush커밋 패스 (is_commit=true)flush-mark 패스 (is_commit=false)
LOG_REPL_DONT_NEED_FLUSH-1건너뜀 (!= DONT_NEED_FLUSH 거짓)건너뜀 (!= NEED_FLUSH)
LOG_REPL_COMMIT_NEED_FLUSH0방출 (!= DONT_NEED_FLUSH 참)건너뜀 (!= NEED_FLUSH)
LOG_REPL_NEED_FLUSH1방출방출 (== NEED_FLUSH)

외부 타입 검사(LOG_REPLICATION_DATA || LOG_REPLICATION_STATEMENT)는 이중 안전장치다 — 스테이징 배열은 오직 이 두 타입만을 담는다.

5.4.3 LOG_REC_REPLICATION 노드 구성 — lsa null 규칙

섹션 제목: “5.4.3 LOG_REC_REPLICATION 노드 구성 — lsa null 규칙”

prior_lsa_alloc_and_copy_datadata_headerLOG_REC_REPLICATION (Chapter 1에서 다룬 {lsa, length, rcvindex} 3-필드 구조체)이고 본문이 repl_rec->repl_data(length 바이트 — 패킹된 pkey_size | class_name | pkey_dbvalue)를 복사한 prior 노드를 할당한다. lengthrcvindex는 스테이징 레코드에서 직접 채워지고, lsarcvindex에 따라 설정된다:

rcvindex헤더 lsa이유
RVREPL_DATA_DELETELSA_SET_NULL슬레이브는 위치 지정 및 삭제에 primary key만 필요; after-image 없음
RVREPL_STATEMENTLSA_SET_NULL문장 텍스트 자체가 페이로드; 가리킬 힙 레코드가 없음
RVREPL_DATA_INSERTrepl_rec->lsa에서 LSA_COPY힙 insert 로그를 가리킴; 슬레이브가 삽입된 행 이미지를 fetch
RVREPL_DATA_UPDATELSA_COPYrepl_add_update_lsa(Chapter 3)에서 역산출; 힙 update 로그를 가리킴
RVREPL_DATA_UPDATE_START / UPDATE_ENDLSA_COPYUPDATE와 동일; START/END 쌍이 다중 행 update를 괄호로 묶음

여기서 복사되는 값들은 스테이징 측(Chapter 3)에서 설정된 것이다.

5.4.4 TDE 암호화와 방출 후 flush-flag 뒤집기

섹션 제목: “5.4.4 TDE 암호화와 방출 후 flush-flag 뒤집기”

레코드별 처리를 완성하는 두 가지 부대 조건이 있다:

  • tde_encrypted. 스테이징된 레코드가 TDE 암호화 클래스에서 온 경우 (스테이징 시 repl_rec->tde_encrypted 설정 — Chapter 3), prior_set_tde_encrypted(log_append.cpp)가 tde_is_loaded()를 확인한다; 마스터 키가 로드되지 않은 경우 ER_TDE_CIPHER_IS_NOT_LOADED를 발생시키고 레코드는 assert(false); continue; 분기로 건너뛴다 (커서를 전진시키지 않음 — 불변 조건 5-B 및 Figure 5-1 참조), 마스터 키가 로드된 경우 node->tde_encrypted = true로 설정하여 포함 페이지가 rest 상태에서 암호화되도록 한다. Chapter 11이 apply 측을 다룬다.
  • 방출 성공 후 must_flush = LOG_REPL_DONT_NEED_FLUSH재방출 방지 가드: 레코드가 prior 리스트에 들어가면 DONT_NEED_FLUSH로 뒤집어 이후 커밋 패스 재스캔(§5.4.1)이 건너뛰게 만든다. 게이트가 DONT_NEED_FLUSH를 배제하기 때문이다. 트랜잭션 중간 flush-mark 패스 (§5.3)가 이미 NEED_FLUSH 레코드를 방출한 경우, 인덱스 0부터 재스캔하는 커밋 재스캔이 같은 레코드를 두 번 방출하지 않도록 하는 것이 핵심이다. Abort는 이 루프에 전혀 진입하지 않으므로(§5.1), 이 가드는 순전히 커밋 대 flush-mark 간의 중복 제거 장치이지 abort 필터가 아니다.

불변 조건 5-B — 스테이징된 레코드는 최대 한 번만 방출된다. 보장 방법: 노드가 prior_lsa_next_record*에 전달된 직후 must_flushDONT_NEED_FLUSH로 뒤집는다. 위반 시: 조기 flush-mark 패스가 방출한 레코드가 (인덱스 0에서 재시작하는, §5.4.1) 커밋 재스캔에서 다시 방출되어, 하나의 논리적 변경에 LOG_REPLICATION_* 레코드가 두 개 생성되고 슬레이브가 같은 INSERT/UPDATE를 두 번 적용하게 된다. 이 가드는 커밋 대 flush-mark 중첩만을 관장한다; abort 경로는 아무것도 방출하지 않으므로(§5.1) 이런 필터가 필요 없다. 두 에러 분기(node == NULL, prior_set_tde_encrypted != NO_ERROR)는 append_repl_recidx를 전진시키지 않고 continue한다; 양쪽 모두 “불가능한” 상태(할당 실패, TDE 키 누락)에 대한 assert(false) 막다른 길로, 정상적인 복구 경로가 아니다 — 수정자는 성공 경로의 커서 전진을 반드시 보존해야 한다.

5.4.5 커밋 시점 패스 한 번의 흐름

섹션 제목: “5.4.5 커밋 시점 패스 한 번의 흐름”

루프의 각 반복은 4단계 결정 연쇄다(위의 실제 코드가 동일한 분기를 인라인으로 담는다):

flowchart TD
  C{"recidx < cur_repl_record ?"}
  C -- 아니오 --> Z["반환"]
  C -- 예 --> D{"REPL 타입이고 게이트 통과 ?"}
  D -- 아니오 --> INC["recidx++"] --> C
  D -- 예 --> G{"alloc NULL, 또는 tde 설정 실패 ?"}
  G -- 예 --> C2["continue, recidx++ 없음"] --> C
  G -- 아니오 --> K{"rcvindex DELETE/STATEMENT ?"}
  K -- 예 --> L["lsa = NULL"] --> N
  K -- 아니오 --> M["lsa = repl_rec->lsa"] --> N
  N["next_record, 이후 must_flush = DONT_NEED_FLUSH"] --> INC

Figure 5-1 — log_append_repl_info_internal 루프 본문: 게이트, 커서를 전진시키지 않는 에러 continue, 그리고 lsa-null 분기.

5.5 커밋 donetime — log_append_commit_log_with_lock

섹션 제목: “5.5 커밋 donetime — log_append_commit_log_with_lock”

보유 중인 mutex 아래의 두 번째 추가는 커밋 레코드 자체다:

// log_append_commit_log_with_lock / log_append_commit_log -- src/transaction/log_manager.c
static void log_append_commit_log_with_lock (THREAD_ENTRY * thread_p, LOG_TDES * tdes, LOG_LSA * commit_lsa)
{ log_append_donetime_internal (thread_p, tdes, commit_lsa, LOG_COMMIT, LOG_PRIOR_LSA_WITH_LOCK); }
static void log_append_commit_log (THREAD_ENTRY * thread_p, LOG_TDES * tdes, LOG_LSA * commit_lsa)
{
if (tdes->has_supplemental_log) /* <- non-HA path emits the user-name marker here (cf. §5.2) */
{ /* ... condensed: same log_append_supplemental_info + clear flag as §5.2 ... */ }
log_append_donetime_internal (thread_p, tdes, commit_lsa, LOG_COMMIT, LOG_PRIOR_LSA_WITHOUT_LOCK);
}

log_append_donetime_internalLOG_COMMIT 노드를 할당하고 donetime->at_time = time (NULL)을 스탬핑한다. HA 호출자가 WITH_LOCK을 전달했기 때문에 mutex를 재취득하지 않고 prior_lsa_next_record_with_lock을 호출한다 — 방출자가 두 추가에 걸쳐 하나의 lock을 유지하는 이유가 바로 이것이다. 할당된 LSA는 commit_lsa로 반환되어 log_commit_local에서 log_change_tran_as_completed로 흘러간다(logpb_flush_pages를 구동하고 TRAN_UNACTIVE_COMMITTED로 전환). prior 리스트는 이제 엄격한 순서로 [복제 레코드 0 .. n] 다음에 [LOG_COMMIT]을 담고 있다; 이것이 로그 페이지 버퍼로 드레인되는 과정은 cubrid-log-manager.md, “Prior-LSA list and the append pipeline” 절에 상세히 설명된다.

  1. log_commit_local은 세 조건으로 분기한다 (!LOG_CHECK_LOG_APPLIER, is_active_worker_transaction, log_does_allow_replication); 셋 모두 성립할 때만 원자적 방출자를 호출하고, 그렇지 않으면 복제 없이 log_append_commit_log만 호출한다. 2PC 경로(log_complete_for_2pc)는 중복인 is_active_worker_transaction() 검사를 생략한 두 조건 변형을 사용한다.
  2. 원자성은 두 추가에 걸친 하나의 mutex다. log_append_repl_info_and_commit_loglog_append_repl_info_with_locklog_append_commit_log_with_lock 양쪽에 걸쳐 log_Gl.prior_info.prior_lsa_mutex를 유지한다(불변 조건 5-A); 이를 분리하면 [T repl...][T' commit][T commit]이 출현하여 슬레이브가 재시작 시 T의 변경을 누락하거나 순서를 잘못 처리할 수 있다.
  3. must_flush 게이트는 is_commit을 인식한다. 커밋 시에는 != DONT_NEED_FLUSH인 모든 레코드가 방출된다(COMMIT_NEED_FLUSH DML 행과 미처리 NEED_FLUSH 모두); flush-mark 패스(is_commit=false)는 NEED_FLUSH DDL 레코드만 방출한다.
  4. lsa-null은 rcvindex별로 다르다. DELETE와 STATEMENT는 헤더 lsa를 null로 설정하고; INSERT와 세 UPDATE 변형은 역산출된 힙 LSA를 복사하여 슬레이브가 after-image를 fetch할 수 있게 한다.
  5. 각 레코드는 최대 한 번만 방출된다 (불변 조건 5-B): 노드가 추가된 후 must_flushDONT_NEED_FLUSH로 뒤집혀, (인덱스 0에서 재시작하는) 커밋 재스캔이 조기 flush-mark 패스가 이미 기록한 레코드를 재방출하지 않는다. Abort는 아무것도 방출하지 않는다log_abort_local에는 복제 분기가 없으며; 스테이징된 레코드는 게이트에 의해서가 아니라 logtb_clear_tdes 디스크립터 리셋에 의해 폐기된다. 두 에러 continue 분기는 커서를 전진시키지 않는 assert(false) 막다른 길이다.
  6. log_append_repl_info (WITHOUT_LOCK, is_commit=false)와 log_append_repl_info_with_lock (WITH_LOCK, is_commit=true) 는 하나의 내부 함수의 두 얼굴이다: system-op flush-mark 호출자와 커밋-원자적 호출자가 각각 사용한다.

Chapter 6: copylogdb로 로그 페이지 전송하기

섹션 제목: “Chapter 6: copylogdb로 로그 페이지 전송하기”

슬레이브 측 copylogdb 데몬은 파싱된 레코드가 아니라 원시 로그 페이지를 가져와서 슬레이브 로컬 active-log 볼륨에 기록한다. 결과적으로 마스터의 active log에 거의 근접한 복사본이 유지된다. 동반 문서 Slave side — copylogdb에서는 puller가 존재하는 이유와 페이지를 복사하는 이유를 다뤘다. 이 챕터는 어떻게 동작하는지를 추적한다 — 모든 구조체 필드, fetch 루프의 모든 분기, 물리 페이지 wraparound 수식, 그리고 페이지가 존재할 때까지 block하는 마스터 측 응답자까지.

두 절반은 log_writer.c 안에서 서로 반대되는 컴파일 가드로 구분된다. 클라이언트 절반 (#if defined(CS_MODE))은 슬레이브의 copylogdb 프로세스이고, 서버 절반 (SERVER_MODE)은 마스터 cub_serverxlogwr_get_log_pages다. 두 절반은 NET_SERVER_LOGWR_GET_LOG_PAGES 프로토콜로 통신한다.

6.1 두 가지 전역 역할 — LOGWR_GLOBAL vs LOGWR_INFO

섹션 제목: “6.1 두 가지 전역 역할 — LOGWR_GLOBAL vs LOGWR_INFO”

log_writer.h는 컴파일 가드에 따라 선택되는 두 가지 서로소(disjoint) 구조체 집합을 정의한다.

graph TB
  subgraph CS["CS_MODE  (copylogdb process, the slave puller)"]
    G["LOGWR_GLOBAL logwr_Gl<br/>프로세스 전역 싱글턴"]
    C["LOGWR_CONTEXT ctx<br/>복사 세션 하나당 하나"]
    G -.active-log fd, 버퍼 소유.-> C
  end
  subgraph SV["SERVER_MODE  (inside master cub_server)"]
    I["LOGWR_INFO log_Gl.writer_info<br/>마스터 측 코디네이터"]
    E["LOGWR_ENTRY<br/>연결된 슬레이브 하나당 하나"]
    I -->|writer_list| E
  end
  C -.NET_SERVER_LOGWR_GET_LOG_PAGES.-> E

Figure 6-1. 클라이언트 구조체(LOGWR_GLOBAL, LOGWR_CONTEXT)는 슬레이브 puller에, 서버 구조체(LOGWR_INFO, LOGWR_ENTRY)는 마스터에 존재한다. 두 구조체 집합은 같은 바이너리에 절대 공존하지 않는다.

LOGWR_MODE는 양쪽이 공유하는 유일한 타입이며 wire로 전달된다:

열거자의미
LOGWR_MODE_ASYNC1페이지가 준비되는 즉시 마스터가 응답한다. 마스터 flush를 차단하지 않는다.
LOGWR_MODE_SEMISYNC2마스터 flush는 슬레이브가 fetch할 때까지 대기한다(apply 아님).
LOGWR_MODE_SYNC3마스터 flush는 슬레이브 fetch 왕복이 완료될 때까지 대기한다.

정확한 대기 의미론은 동반 문서의 Open Question에서 다루며, §6.8에서 메커니즘(thread_suspend_with_other_mutex)을 설명한다. 비트 LOGWR_COPY_FROM_FIRST_PHY_PAGE_MASK(0x80000000)는 새로운 copylogdb -f 실행 시 wire mode에 OR되어 “가장 오래된 물리 페이지부터 복사”를 지시한다(§6.8).

LOGWR_GLOBAL — 슬레이브 puller의 전체 상태

섹션 제목: “LOGWR_GLOBAL — 슬레이브 puller의 전체 상태”

logwr_Gl은 하나의 정적 인스턴스다. 필드를 그룹별로 정리하면 다음과 같다:

필드역할존재 이유
hdr슬레이브가 갖고 있는 마스터 LOG_HEADER의 작업 복사본.물리 페이지 수식 전체(fpageid, npages, nxarv_*)를 구동한다. 응답 page 0에서 매번 갱신된다.
loghdr_pgptr헤더 슬롯의 logpg_area 내 포인터.logwr_flush_header_page가 복사 없이 헤더를 쓴다.
db_name / hostname@host를 분리한 DB 이름(hostname@ 이후를 가리킨다).볼륨 이름 생성 및 마스터 식별에 사용된다. logwr_initialize에서 분리된다.
log_path / loginf_path / active_name슬레이브 로그 디렉토리, loginfo 경로, active-log 경로.볼륨이 저장되는 위치. active_namelogwr_writev_append_pages가 쓰는 대상이다.
append_vdes슬레이브 active log의 fd(또는 NULL_VOLDES).유일하게 변경 가능한 출력 핸들.
logpg_area / logpg_area_size / logpg_fill_size(NPAGES+1)*LOG_PAGESIZE 수신 버퍼, 크기, 수신된 바이트 수.응답 하나를 담는다(page 0 = 헤더, 1..N = 데이터). logwr_set_hdr_and_flush_info[0,fill_size) 구간을 순회한다.
toflush / max_toflush / num_toflushLOG_PAGE* 데이터 포인터들, 용량(= NPAGES), 유효한 수.flush 작업 목록(§6.5). num_toflush가 루프 상한이다.
mode현재 LOGWR_MODE.요청마다 저장·복원되며, catchup 중에는 ASYNC로 다운그레이드된다.
actionLOGWR_ACTION_* 비트마스크.DELAYED_WRITE / ASYNC_WRITE / HDR_WRITE / ARCHIVING을 루프 턴 사이에 전달한다.
last_recv_pageid수신된 가장 높은 논리 pageid.커서. 다음 요청의 first_pageid_torecv가 여기서 파생된다(§6.3).
last_arv_fpageid / last_arv_lpageid / last_arv_num조립 중인 아카이브의 첫/마지막 pageid와 번호.logwr_archive_active_log의 복사 범위와 파일 이름을 결정한다.
force_flush / last_flush_timeSEMISYNC flush를 타이머와 무관하게 강제; 마지막 flush 타임스탬프.SEMISYNC는 약 1초에 한 번씩 throttle한다. force_flush(종료/크래시)는 이를 우회한다.
bg_archive_info / bg_archive_namebg-archive 스크래치 상태와 임시 경로.LOG_BACKGROUND_ARCHIVING 활성 시 이중 쓰기. 임시 파일은 롤 시점에 실제 아카이브 이름으로 rename된다.
ori_nxarv_pageid이번 응답 적용 전의 nxarv_pageid 스냅샷.아카이브 경계를 감지하여 요청 모드가 다운그레이드되지 않도록 한다(§6.3).
start_pageid일반 시 -2; -f일 때 >= NULL_PAGEID.원샷 전체 복사와 데몬을 구별한다.
reinit_copylog마스터 DB가 우리 모르게 재빌드됨.logwr_reinit_copylog 호출을 트리거한다.

(last_chkpt_pageid는 진단용으로만 보존되며, 코어 루프에서는 읽지 않는다.)

불변식 — last_recv_pageid는 단조 증가하며 다음 요청의 상한을 결정한다. logwr_set_hdr_and_flush_info는 아직 수신할 페이지가 있을 때 마지막 수신 페이지로, 따라잡은 경우 hdr.eof_lsa.pageid로 설정한다. 다음 요청은 여기서 first_pageid_torecv를 파생한다(§6.3). logwr_get_log_pageslast_recv_pageid <= hdr.eof_lsa.pageid를 assert한다 — EOF를 넘는 페이지를 요청하면 루프가 마스터의 poll-to-push에서 영구적으로 대기하게 된다(§6.8).

// struct logwr_context -- src/transaction/log_writer.h
struct logwr_context { int rc; int last_error; bool shutdown; };
필드역할존재 이유
rc네트워크 요청 핸들(> 0이면 열려 있음).net_client_logwr_send_end_msg(ctx.rc,...) 호출로 서버 스레드를 정리한다.
last_error마지막 에러 코드로, 마스터에게 echo된다.마스터가 writer 엔트리를 유지할지 결정한다.
shutdown세션을 종료해야 함.루프를 탈출한다. 크래시, 치명적 에러, 또는 -f 완료 시 설정된다.

LOGWR_ENTRYLOGWR_INFO — 마스터 측 대응 구조체

섹션 제목: “LOGWR_ENTRY와 LOGWR_INFO — 마스터 측 대응 구조체”

연결된 슬레이브마다 writer_info->writer_listLOGWR_ENTRY 하나가 존재한다:

필드역할존재 이유
thread_p이 슬레이브를 담당하는 LOGWR 스레드.suspend/resume의 대상이다.
fpageid이 슬레이브가 요청하는 첫 번째 pageid.logwr_pack_log_pages의 입력이다.
mode슬레이브가 요청한 LOGWR_MODE.flush 스레드의 대기 여부를 결정한다.
statusLOGWR_STATUS_* (WAIT/FETCH/DONE/DELAY/ERROR).Log Flush Thread와의 핸드셰이크 상태.
eof_lsa이 엔트리를 위한 마스터 EOF 스냅샷.팩 범위를 제한한다. LOG_CS 하에서 설정된다.
last_sent_eof_lsa이미 전송한 최고 EOF.logwr_is_delayed가 지연 감지 시 비교한다.
tmp_last_sent_eof_lsalast_sent_eof_lsa의 스테이징 값.전송 왕복이 완료된 이후에만 커밋된다.
start_copy_timefetch 시작 시각.슬레이브가 flush 스레드를 얼마나 지연시켰는지 측정한다.
copy_from_first_phy_page-f 플래그.팩을 가장 오래된 페이지 분기로 라우팅한다.
next리스트 연결.writer_list는 단방향 연결 리스트다.

LOGWR_INFO(log_Gl.writer_info)는 단일 Log Flush Thread(LFT)와 슬레이브별 LOGWR 스레드(LWT) 사이의 랑데부 지점이다. 모든 멤버를 정리하면:

필드역할존재 이유
writer_listLOGWR_ENTRY 리스트의 head.LFT가 깨워야 할 슬레이브 집합이다.
wr_list_mutexregister/unregister 시 writer_list 변경을 직렬화.모든 LWT와 LFT가 공유하는 리스트다. 아래 세 condvar 쌍과는 별개다.
flush_start_cond / flush_start_mutexLWT가 요청한 페이지를 기다리며 여기서 suspend.poll-to-push 대기 지점(§6.8). LFT가 페이지 생성 후 신호를 보낸다.
flush_wait_cond / flush_wait_mutexLWT가 팩 이후 flush 완료를 기다리며 여기서 대기.팩된 LWT가 LFT의 디스크 flush 완료 후 진행하도록 순서를 맞춘다.
flush_end_cond / flush_end_mutexLFT가 LWT들의 전송 완료를 기다리며 여기서 대기.LFT가 관심 있는 슬레이브들이 페이지를 소비할 때까지 블록할 수 있게 한다.
skip_flushLFT가 이번 사이클에서 writer 대기를 건너뜀.조율할 내용이 없을 때 불필요한 stall을 방지한다.
flush_completedLFT가 현재 flush를 완료함.flush_wait_cond에서 깨어난 LWT가 검사하는 조건이다.
is_initwriter_info가 초기화되었음.설정 전에 핸드셰이크가 실행되는 것을 막는다.
trace_last_writer마지막 writer 타이밍 계측 활성화.지연 원인 분석을 위한 진단용.
last_writer_client_info마지막으로 지연을 일으킨 writer의 CLIENTIDS.어느 슬레이브가 flush 지연을 유발했는지 식별한다.
last_writer_elapsed_time해당 writer가 LFT를 대기시킨 시간.HA 모니터링에 노출되는 측정된 지연 값.

6.2 logwr_initialize — 슬레이브 로컬 active log 열기

섹션 제목: “6.2 logwr_initialize — 슬레이브 로컬 active log 열기”

데몬 시작 시 한 번 실행된다:

// logwr_initialize -- src/transaction/log_writer.c
strncpy (logwr_Gl.db_name, db_name, PATH_MAX - 1);
if ((at_char = strchr (logwr_Gl.db_name, '@')) != NULL) {
*at_char = '\0'; logwr_Gl.hostname = at_char + 1; /* <- split db@host */
}
log_nbuffers = LOGWR_COPY_LOG_BUFFER_NPAGES + 1; /* <- 128 data + 1 header */

LOGWR_COPY_LOG_BUFFER_NPAGESLOGPB_BUFFER_NPAGES_LOWER(128)로, 응답당 페이지 상한이다. 이후 분기별로 진행한다:

  1. 버퍼 할당logpg_area == NULL이면 log_nbuffers * LOG_PAGESIZE를 malloc한다. 실패 시 logpg_area_size = 0으로 리셋하고 ER_OUT_OF_VIRTUAL_MEMORY를 반환한다.
  2. toflush 할당 — NULL이면 max_toflush = log_nbuffers - 1개의 포인터를 calloc한다. 동일한 OOM 처리를 한다.
  3. logwr_read_log_header — 슬레이브 active log가 존재하면 마운트하고 page 0을 읽는다. 없으면 생성을 지연한다(마스터의 npages가 먼저 필요하다). 에러는 전파한다.
  4. “이미 존재” 가드start_pageid >= NULL_PAGEID(즉 -f)인데 해당 디렉토리에 이미 복제 로그가 있으면(hdr.nxarv_pageid != NULL_PAGEID) ER_HA_GENERIC_ERROR를 반환한다.
  5. 아카이브 커서 초기화last_arv_fpageid = hdr.nxarv_pageid, last_arv_num = hdr.nxarv_num; force_flush / last_flush_time / ori_nxarv_pageid를 리셋한다.
  6. bg-archiving 분기 (PRM_ID_LOG_BACKGROUND_ARCHIVING이 활성화된 경우에만) — 임시 볼륨을 마운트하고 헤더를 읽는다. ER_LOG_INCOMPATIBLE_DATABASE 시 재생성하고, 없으면 fileio_format으로 새로 만들고 헤더를 flush한다. 마운트/포맷 실패 시 ER_IO_MOUNT_FAIL / ER_HA_GENERIC_ERROR를 반환한다.

일반적인 경우 active log는 여기서 생성되지 않는다. 생성은 첫 번째 logwr_write_log_pages(§6.5)로 미뤄지며, 그때 hdr.npages를 알게 된 후 fileio_format된다.

6.3 logwr_copy_log_file — 데몬 메인 루프

섹션 제목: “6.3 logwr_copy_log_file — 데몬 메인 루프”

copylogdb의 생명주기. logwr_initialize 이후(초기화 실패 시 반환), 다음을 반복한다:

flowchart TD
  init["logwr_initialize"] -->|ok| loop{shutdown<br/>또는 need_shutdown?}
  init -->|err| fin["logwr_finalize; return"]
  loop -->|no| get["logwr_get_log_pages(ctx)<br/>= 요청/응답 한 번"]
  get -->|error| errb{reinit_copylog?}
  errb -->|yes| reinit["logwr_reinit_copylog<br/>goto end"]
  errb -->|no| clr["action &= DELAYED_WRITE"]
  get -->|NO_ERROR| asy{action & ASYNC_WRITE?}
  asy -->|yes| wlp["logwr_write_log_pages"]
  asy -->|no| clr
  wlp --> clr
  clr --> loop
  loop -->|yes| sig{need_shutdown<br/>and SEMISYNC?}
  sig -->|yes| flush["force_flush=true<br/>logwr_write_log_pages"]
  sig -->|no| endlbl["end:"]
  flush --> endlbl
  reinit --> endlbl
  endlbl --> endmsg["if rc>0: net_client_logwr_send_end_msg"]
  endmsg --> fin

Figure 6-2. logwr_copy_log_file 제어 흐름. 네트워크 왕복은 logwr_get_log_pages 안에 있다. 이 루프는 write-back, reinit, shutdown을 조율한다.

주요 분기: logwr_get_log_pages 에러ctx.last_error를 기록하고, ER_HA_LW_FAILED_GET_LOG_PAGE는 heartbeat에서 deregister한다(복구 불가). reinit_copylog가 설정되어 있으면 logwr_reinit_copylog를 실행하고 goto end. 성공action & LOGWR_ACTION_ASYNC_WRITE가 있으면 즉시 logwr_write_log_pages를 호출한다 (SEMISYNC/SYNC 쓰기는 다른 경로). 매 턴, action &= LOGWR_ACTION_DELAYED_WRITE로 DELAYED_WRITE를 제외한 모든 비트를 제거한다. DELAYED_WRITE는 클라이언트가 남은 페이지를 계속 가져오게 하므로 반드시 유지되어야 한다. SEMISYNC로 신호 종료force_flush를 설정하고 마지막 쓰기 후 ER_HA_LW_STOPPED_BY_SIGNAL을 반환한다. end: 에서 ctx.rc > 0이면 end 메시지를 전송한다. 완료된 -f 복사는 의도적으로 ER_FAILED를 보내 서버 스레드를 종료시키며, 항상 logwr_finalize한다.

logwr_get_log_pages(network_interface_cl.c)는 커서에서 요청을 파생한다:

// logwr_get_log_pages -- src/communication/network_interface_cl.c
if (logwr_Gl.last_recv_pageid == logwr_Gl.hdr.eof_lsa.pageid) { /* caught up */
first_pageid_torecv = logwr_Gl.last_recv_pageid;
mode = (logwr_Gl.last_recv_pageid == NULL_PAGEID) ? LOGWR_MODE_ASYNC : logwr_Gl.mode;
} else { /* still draining */
if (logwr_Gl.last_recv_pageid == NULL_PAGEID)
first_pageid_torecv = LOGPB_HEADER_PAGE_ID; /* <- prove DB identity */
else if (logwr_Gl.hdr.ha_file_status == LOG_HA_FILESTAT_SYNCHRONIZED)
first_pageid_torecv = logwr_Gl.last_recv_pageid; /* re-fetch tail page */
else
first_pageid_torecv = logwr_Gl.last_recv_pageid + 1;
mode = (first_pageid_torecv == logwr_Gl.ori_nxarv_pageid) ? logwr_Gl.mode : LOGWR_MODE_ASYNC;
}

세 가지 pack된 필드: int64 첫 pageid(-f 마스크가 OR될 수 있음), int mode, int last_error. 드레이닝 중에는 마스터가 catch-up 중인 슬레이브 때문에 block하지 않도록 mode를 강제로 ASYNC로 설정한다. 아카이브 경계(first_pageid_torecv == ori_nxarv_pageid)에서만 설정된 mode를 유지한다. ER_NET_SERVER_CRASHED / ER_HA_LW_FAILED_GET_LOG_PAGE 시 SEMISYNC 슬레이브는 force-flush 후 ctx->shutdown을 설정한다. ER_NET_SERVER_CRASHED 분기는 추가로 로컬 헤더에 HA_SERVER_STATE_DEAD를 기록한 후 flush한다 (ER_HA_LW_FAILED_GET_LOG_PAGE 분기는 기록된 서버 상태를 그대로 둔다).

6.4 logwr_set_hdr_and_flush_info — 응답 데이터 정합

섹션 제목: “6.4 logwr_set_hdr_and_flush_info — 응답 데이터 정합”

응답이 logpg_area에 도착하면, 이 함수는 toflush를 재구성하고 마스터 헤더를 logwr_Gl.hdr에 복사하고 아카이브 여부를 결정한 후 커서를 전진시킨다. 먼저 데이터 페이지를 순회한다(첫 LOG_PAGESIZE 헤더 슬롯 이후):

// logwr_set_hdr_and_flush_info -- src/transaction/log_writer.c
p = logwr_Gl.logpg_area + LOG_PAGESIZE; /* <- skip header page */
while (p < (logwr_Gl.logpg_area + logwr_Gl.logpg_fill_size)) {
logwr_Gl.toflush[num_toflush++] = (LOG_PAGE *) p;
p += LOG_PAGESIZE;
}
logwr_Gl.ori_nxarv_pageid = logwr_Gl.hdr.nxarv_pageid; /* <- snapshot BEFORE overwrite */

분기 A — num_toflush > 0 (데이터 도착): wire page 0을 hdr에 복사한다. 아카이브 정보가 미설정 상태이면 초기값을 채운다. 두 가지 하위 케이스에서 LOGWR_ACTION_ARCHIVING을 설정한다 — 지연(last_arv_num+1 < hdr.nxarv_num이고 ha_file_status == ARCHIVED이고 last_arv_fpageid <= last_recv_pageidlast_arv_lpageid = last_recv_pageid) 또는 경계 도달(last_arv_num+1 == hdr.nxarv_num이고 마지막 페이지가 hdr.nxarv_pageid에 도달 → last_arv_lpageid = hdr.nxarv_pageid - 1). 이후 커서 전진: 마지막 페이지가 hdr.eof_lsa.pageid보다 낮으면 더 받을 것이 있다 — last_recv_pageid = 마지막 페이지이고 DELAYED_WRITE를 설정한다. 다 받았으면 last_recv_pageid = hdr.eof_lsa.pageid이고 DELAYED_WRITE를 제거한다.

분기 B — num_toflush == 0 (헤더 전용 응답): 마스터에 새 내용이 없다. 재빌드 감지 — 서버 상태가 non-active이고 프로모션 시각이 일치하지만 db_restore_time이 다르면 마스터가 다른 이미지에서 복원된 것이다: reinit_copylog = true를 설정하고 ER_LOG_DOESNT_CORRESPOND_TO_DATABASE를 반환한다. db_creation 불일치도 동일한 에러를 반환한다(완전히 다른 DB). 커서 되감기로 tail을 재전송 강제: 로컬 상태가 SYNCHRONIZED가 아니면 last_recv_pageidhdr.append_lsa.pageid - 1로, SYNCHRONIZED이면 hdr.eof_lsa.pageid - 1로 설정한다.

꼬리 처리 (두 분기 공통): 로컬 헤더가 SYNCHRONIZED가 아니면, 로컬 append_lsalast_recv_pageidNULL_OFFSET으로 고정한다. 이는 슬레이브의 쓰기 가능 로그 끝 위치를 기록하여 재시작 시 그 지점부터 재개할 수 있게 한다(Chapter 10).

6.5 logwr_writev_append_pageslogwr_flush_all_append_pages

섹션 제목: “6.5 logwr_writev_append_pages와 logwr_flush_all_append_pages”

logwr_write_log_pages(호출자)는 active log가 없으면 hdr.npages + 1 크기로 fileio_format한 뒤, LOGWR_ACTION_ARCHIVING이 설정되어 있으면 flush 전에 logwr_archive_active_log를 호출한다(곧 덮일 페이지를 먼저 아카이브한다). 그다음 logwr_flush_all_append_pages, 그다음 logwr_flush_header_page를 실행한다. SEMISYNC는 force_flush이거나 아카이브 중간이 아닌 한 약 1초 타이머로 throttle한다.

logwr_flush_all_append_pagestoflush논리적으로도 물리적으로도 연속한 run으로 합쳐서 run마다 logwr_writev_append_pages를 한 번씩 호출한다:

// logwr_flush_all_append_pages -- src/transaction/log_writer.c
if ((pageid != prv_pageid + 1)
|| (logwr_to_physical_pageid (pageid) != logwr_to_physical_pageid (prv_pageid) + 1)) {
if (logwr_writev_append_pages (&logwr_Gl.toflush[idxflush], i - idxflush) == NULL)
return er_errid (); /* not contiguous: flush the run, restart */
need_sync = true; flush_page_count += i - idxflush; idxflush = -1;
}

두 번째 조건이 원형 로그를 올바르게 처리하게 한다: 논리적으로 인접한 두 페이지가 물리 pageid가 1로 리셋되는 wraparound 경계에 걸쳐 있으면 두 번의 쓰기로 분리된다. 루프 이후 마지막 run을 flush하고, fileio_synchronize 한 번으로 모든 데이터를 영속화한다 (dual-write가 활성화되어 있으면 bg-archive 볼륨도 포함). 그 후 toflush를 초기화한다.

logwr_writev_append_pagesfileio_write를 두 단계로 수행한다. bg-archive 쓰기 (if LOG_BACKGROUND_ARCHIVING) — bg_arv_info->vdes를 검증하고 logwr_copy_necessary_log로 빈 구간을 채운 뒤 각 페이지를 phy_pageid = fpageid - start_page_id + 1 위치에 쓴다. active 쓰기phy_pageid = logwr_to_physical_pageid(fpageid)를 계산하고 각 페이지를 phy_pageid + i 위치에 쓴다. 두 경우 모두 ER_IO_WRITE_OUT_OF_SPACEER_LOG_WRITE_OUT_OF_SPACE로 매핑하고 to_flush = NULL을 반환해 실패를 알린다. 첫 번째 데이터 페이지는 쓰기 전에 체크섬이 검증된다. #ifdef UNSTABLE_TDE_FOR_REPLICATION_LOG 블록은 TDE 마킹된 페이지를 재암호화하며 출하 빌드에서는 컴파일 제외된다 — Chapter 11의 TDE Open Question 참조.

불변식 — 기록되는 내용은 마스터와 바이트 단위로 동일하다. wire 페이지 영역은 그대로 복사된다. 슬레이브는 레코드를 재직렬화하지 않는다. 유일한 변경은 물리적 배치 (logwr_to_physical_pageid)와 선택적 TDE 재암호화뿐이다. 이것이 copylogdb가 레코드가 아닌 페이지를 복사하는 이유다.

6.6 logwr_to_physical_pageid — 원형 로그 주소 수식

섹션 제목: “6.6 logwr_to_physical_pageid — 원형 로그 주소 수식”

active log는 hdr.npages 페이지의 고정 링이다. 단조 증가하는 논리 pageid는 물리 슬롯으로 매핑된다:

// logwr_to_physical_pageid -- src/transaction/log_writer.c
if (logical_pageid == LOGPB_HEADER_PAGE_ID) /* header page is always slot 0 */
phy_pageid = 0;
else {
tmp_pageid = logical_pageid - logwr_Gl.hdr.fpageid;
if (tmp_pageid >= logwr_Gl.hdr.npages) tmp_pageid %= logwr_Gl.hdr.npages;
else if (tmp_pageid < 0)
tmp_pageid = (logwr_Gl.hdr.npages - ((-tmp_pageid) % logwr_Gl.hdr.npages)); /* negative-safe */
tmp_pageid++; /* slot 0 reserved -> data starts at 1 */
if (tmp_pageid > logwr_Gl.hdr.npages) tmp_pageid %= logwr_Gl.hdr.npages;
phy_pageid = (LOG_PHY_PAGEID) tmp_pageid;
}

fpageid(데이터 슬롯 1에 매핑되는 논리 pageid)를 빼고, npages로 모듈로 연산한다 (음수 케이스는 오래된 pageid를 올바르게 매핑한다). +1로 헤더 슬롯을 건너뛰고, +1이 링을 넘어섰으면 다시 모듈로 연산한다. -f 복사는 LOGWR_COPY_FROM_FIRST_PHY_PAGE_MASK를 사용하여 마스터가 오래된 fpageid를 신뢰하는 대신 가장 오래된 물리 페이지부터 다시 팩하게 한다.

6.7 logwr_archive_active_log — active log를 아카이브로 롤

섹션 제목: “6.7 logwr_archive_active_log — active log를 아카이브로 롤”

logwr_set_hdr_and_flush_info가 아카이브를 봉인하기로 결정하면, flush 전에 이 함수가 실행된다: 아카이브 헤더 페이지 생성, 목적지 열기, 페이지 범위 복사, 커서 갱신. 각 단계는 실패 시 goto error로 떨어진다.

  1. 헤더 페이지 — 페이지 하나를 malloc한다. LOG_ARV_HEADER를 채운다(db_creation, fpageid = last_arv_fpageid, arv_num = last_arv_num, npages = last_arv_lpageid - fpageid + 1).
  2. 목적지 — bg-archiving이면 bg_arv_info->vdes를 재사용한다. 기존 파일이 있으면 fileio_mount한다. 없으면 npages + 1 크기의 새 아카이브를 fileio_format한다.
  3. 헤더 쓰기 — 물리 페이지 0에 쓴다(FILEIO_WRITE_NO_COMPENSATE_WRITE).
  4. 범위 복사 — bg이면 current_page_id에서, 아니면 last_arv_fpageid에서 시작하여 last_arv_lpageid까지 LOGPB_IO_NPAGES 청크 단위로 반복한다. 청크가 링 경계를 넘지 않도록 크기를 제한한다. 각 청크는 fileio_read_pages(논리 pageid 불일치 → ER_LOG_PAGE_CORRUPTED) 후 fileio_write_pages로 아카이브에 쓴다.
  5. Bg-archive 롤 (활성화된 경우) — 임시 볼륨을 dismount하고 fileio_rename으로 실제 아카이브 이름으로 변경한 뒤, 새 임시 파일을 format하고 헤더를 reset·flush한다.
  6. 커서 + 헤더last_arv_num++, last_arv_fpageid = last_arv_lpageid + 1. 동시 applier가 미완성 active 페이지를 읽지 못하도록 일시적으로 hdr.append_lsa.pageid = last_arv_lpageid로 고정하고 헤더를 flush한 후 append_lsa를 복원하고 ER_LOG_ARCHIVE_CREATED를 발행한다.

불변식 — 덮어쓰기 전에 아카이브한다. logwr_write_log_pageslogwr_flush_all_append_pages 전에 logwr_archive_active_log를 실행하므로, 링 wraparound로 곧 덮일 슬롯은 이미 내구성 있는 아카이브에 들어가 있다. 순서를 위반하면 슬레이브 로그를 잃고 point-in-time 복구가 깨진다(Chapter 10).

6.8 xlogwr_get_log_pages — 마스터 측 응답자

섹션 제목: “6.8 xlogwr_get_log_pages — 마스터 측 응답자”

각 슬레이브는 xlogwr_get_log_pages에서 spin하는 스레드 하나로 서비스된다 (slogwr_get_log_pages에서 dispatch됨). LOGWR_COPY_LOG_BUFFER_NPAGES * LOG_PAGESIZE 크기의 send 버퍼를 malloc하고 while (true) 루프를 돌며 반복마다 요청 하나를 처리한다.

stateDiagram-v2
  [*] --> Register : -f 마스크 제거\n logwr_register_writer_entry
  Register --> Wait : entry->status == WAIT
  Register --> Delay : entry->status == DELAY
  Wait --> Timeout : ASYNC이고 10초 경과
  Timeout --> Register : unregister, continue
  Wait --> Interrupted : 인터럽트로 resume
  Interrupted --> Register : 지연 상태이면 continue
  Wait --> Pack : THREAD_LOGWR_RESUMED
  Delay --> Pack : LOG_CS_ENTER
  Pack --> Send : logwr_pack_log_pages 성공
  Send --> NextReq : xlog_send_log_pages_to_client
  NextReq --> Register : xlog_get_page_request_with_reply
  Pack --> Err : pack 실패
  Err --> [*]

Figure 6-3. xlogwr_get_log_pages 요청별 상태 머신. Wait는 poll-to-push 블록 지점으로, 스레드가 요청한 페이지가 존재할 때까지 Log Flush Thread가 깨워줄 때까지 잠든다.

entry->status == WAIT 시 poll-to-push suspend:

// xlogwr_get_log_pages -- src/transaction/log_writer.c
if (mode == LOGWR_MODE_ASYNC) { timeout = LOGWR_THREAD_SUSPEND_TIMEOUT; /* 10s */
to.tv_sec = time (NULL) + timeout; to.tv_nsec = 0; }
else { timeout = INF_WAIT; to.tv_sec = to.tv_nsec = 0; } /* SYNC/SEMISYNC block forever */
rv = thread_suspend_with_other_mutex (thread_p, &writer_info->flush_start_mutex,
timeout, &to, THREAD_LOGWR_SUSPENDED);

SYNC/SEMISYNC writer는 LFT가 append 후 신호를 보낼 때까지 무한정 대기한다. ASYNC writer는 최대 10초 대기 후 ER_CSS_PTHREAD_COND_TIMEDOUT 시 unregister하고 continue한다. 다른 분기: 인터럽트/종료 → ER_INTERRUPTED. THREAD_RESUME_DUE_TO_INTERRUPT + logwr_is_delayed이면 백로그를 전송하기 위해 루프를 반복한다. mutex 에러 → ER_FAILED. LOGWR_STATUS_DELAY 엔트리는 대기를 건너뛰고 즉시 팩한다. 팩 후 flush_wait_cond에서 대기하고, xlog_send_log_pages_to_client로 전송한 뒤, xlog_get_page_request_with_reply가 다음 요청을 block하며 기다린다. ASYNC 엔트리는 전송 전에 unregister하고, SYNC는 전송 후에 한다. 이후 요청에 -f 마스크가 있으면 불법이다(assert_release(false)).

logwr_pack_log_pages는 범위를 선택한다: is_hdr_page_only면 page 0만 팩한다. copy_from_first_phy_page(-f)이면 logpb_find_oldest_available_page_id에서 시작한다. 일반적으로 fpageid = entry->fpageid를 unflushed 경계인 nxio_lsa.pageid로 clamp한다. 그다음 active-vs-archive 분기:

// logwr_pack_log_pages -- src/transaction/log_writer.c
if (!logpb_is_page_in_archive (fpageid)) /* still in active log */
lpageid = eof_lsa.pageid;
else { /* already archived */
logpb_fetch_from_archive (thread_p, fpageid, NULL, NULL, &arvhdr, false);
lpageid = arvhdr.fpageid + arvhdr.npages - 1; /* bounded by that archive */
if (fpageid == arvhdr.fpageid) ha_file_status = LOG_HA_FILESTAT_ARCHIVED;
}
if (((size_t)(lpageid - fpageid + 1)) > (LOGWR_COPY_LOG_BUFFER_NPAGES - 1))
lpageid = fpageid + (LOGWR_COPY_LOG_BUFFER_NPAGES - 1) - 1; /* cap at 127 pages */

logpb_fetch_from_archive 내부의 아카이브 조회는 guess estimator와 아카이브 페이지 캐시가 있는 곳이다: logpb_get_archive_num_from_info_table이 인메모리 logpb_Arv_page_info_table(start/end/arv_num 트리플의 링)에서 O(1) hit를 시도하고, 실패 시 logpb_get_guess_archive_num이 loginfo 파일로부터 보간하여 — 모든 아카이브를 스캔하지 않고도 한참 뒤처진 슬레이브의 페이지가 어느 아카이브에 있는지 찾는다. 팩은 page 0을 채운 뒤 fpageid..lpageidlogpb_copy_page_from_log_buffer (인터럽트된 전송이면 _from_file)로 순회하며, nxio_lsa.pageid 이후의 아직 변경 가능한 페이지는 체크섬을 재계산(logpb_set_page_checksum)하고, 이미 flush된 페이지는 검증한다. *status는 EOF가 포함되면 DONE, 아니면 DELAY다.

logwr_check_page_checksum샘플링 CRC다: 4096바이트 블록마다 처음과 마지막 16바이트를 hdr.checksum과 CRC를 비교한다(저장된 값이 0이면 “미설정”). hot path에서 저렴하면서도 찢긴 페이지나 bit-rot를 잡아낸다.

  1. copylogdb는 레코드가 아닌 페이지를 전송한다. 슬레이브는 마스터의 로그 페이지를 바이트 단위로 복사한다. 레코드 파싱은 applylogdb 쪽의 역할이다(Chapter 7–9).
  2. 두 개의 독립적인 구조체 패밀리가 하나의 파일을 공유한다. LOGWR_GLOBAL / LOGWR_CONTEXT(CS_MODE)와 LOGWR_INFO / LOGWR_ENTRY(SERVER_MODE)는 절대 공존하지 않는다. wire를 넘는 것은 LOGWR_MODE뿐이다.
  3. last_recv_pageid가 내구성 있는 커서다. logwr_set_hdr_and_flush_info가 전진시키고, 다음 요청은 여기서 파생된다. logwr_get_log_pages의 단조성 assert가 이를 보호한다.
  4. 원형 로그는 두 축의 연속성 검사를 강제한다. logwr_flush_all_append_pages는 논리적 또는 물리적 인접성이 끊기는 곳에서 run을 분리한다. logwr_to_physical_pageidhdr.npages에서 wrap하면서 슬롯 0을 헤더에 고정하기 때문이다.
  5. 덮어쓰기 전에 아카이브한다. logwr_write_log_pages는 flush 전에 logwr_archive_active_log를 실행하여 링 wraparound로 곧 덮일 페이지를 봉인한다 — 슬레이브 측 point-in-time 복구를 뒷받침하는 불변식이다.
  6. 마스터 응답자는 poll-to-push 방식이다. xlogwr_get_log_pages는 SYNC/SEMISYNC writer를 flush_start에서 무한정(ASYNC: 10초) suspend하며, LFT가 신호를 보낼 때까지 기다린다. 한참 뒤처진 슬레이브는 logpb_Arv_page_info_tablelogpb_get_guess_archive_num으로 가속된다.
  7. 체크섬은 전체 페이지가 아닌 샘플링 방식이다. logwr_check_page_checksum은 4KB 블록마다 처음/마지막 16바이트의 CRC로 flush된 페이지를 검증하고, logpb_set_page_checksum은 전송 전에 아직 변경 가능한 tail 페이지의 체크섬을 재계산한다.

Chapter 7: applylogdb 초기화와 로그 페치

섹션 제목: “Chapter 7: applylogdb 초기화와 로그 페치”

HA 노드가 복사된 로그를 *적용(apply)*하도록 지시받으면, apply 데몬은 하나의 전역 상태 객체를 부트스트랩하고, 내구성 있는 _db_ha_apply_info 북마크에서 중단 지점을 복구한 뒤, 슬레이브 로컬에 복사된 볼륨에서 원시 로그 페이지를 읽어야 한다. 이론적 배경(로그 쉬핑, 마스터/슬레이브 분리, copylogdb/applylogdb 쌍)은 상위 레벨 문서인 cubrid-ha-replication.md에서 다루며, 이 챕터는 메커니즘만 추적한다. 챕터 8~10은 여기서 설정된 내용을 토대로 진행된다.

모든 것은 두 개의 파일 스코프 전역 변수인 la_Info(LA_INFO)와 la_recdes_pool(LA_RECDES_POOL)에 의존한다. 각각 정확히 하나씩 존재하며(데몬은 데이터베이스당 싱글 스레드이므로), init 함수는 컨텍스트 포인터를 스레딩하지 않고 직접 이 변수에 쓴다.

flowchart TD
  info["LA_INFO la_Info<br/>(단일 전역 변수)"]
  act["LA_ACT_LOG act_log"]
  arv["LA_ARV_LOG arv_log"]
  filt["LA_REPL_FILTER repl_filter"]
  pb["LA_CACHE_PB *cache_pb"]
  ht["MHT_TABLE *hash_table<br/>pageid에서 버퍼로"]
  arr["LA_CACHE_BUFFER **log_buffer"]
  area["LA_CACHE_BUFFER_AREA *buffer_area"]
  buf["LA_CACHE_BUFFER<br/>fix_count + LOG_PAGE"]
  info --> act
  info --> arv
  info --> filt
  info --> pb
  pb --> ht
  pb --> arr
  pb --> area
  arr -.포인팅.-> buf
  area -.스토리지 소유.-> buf

Figure 7-1. LA_INFO와 그것이 소유하는 캐시들.

LA_INFO — 전역 apply 컨텍스트. 필드는 로그 볼륨 정보, 진행 중인 apply 맵, 슬레이브 측 임시 공간, 마스터 측 캐시, 그리고 영속화된 _db_ha_apply_info 미러로 구분된다.

필드역할존재 이유
log_path / loginf_path복사된 볼륨 디렉터리; *_lginf info-file 경로모든 볼륨 이름이 여기서 파생됨; 아카이브 삭제 북키핑
act_log / arv_log활성 / 아카이브 볼륨 핸들LA_ACT_LOG, LA_ARV_LOG 참조
last_file_state마지막 ha_file_status포맷/동기화 전환 감지
start_vsize / start_time시작 시 VSZ / 벽시계 시각max_mem_size watchdog; 아카이브 삭제 클럭
final_lsa읽기 커서 — 마지막으로 처리된 LSA북마크에서 시드; apply 루프가 전진시킴
committed_lsa / committed_rep_lsa마지막으로 적용된 LOG_COMMIT / repl 레코드의 LSA내구성 있는 apply 워터마크(챕터 10); repl-record 진행 상황
last_committed_lsa / last_committed_rep_lsa시작 시 스냅샷재시작 시 이미 적용된 작업 건너뜀
repl_lists / repl_cnt / cur_repl트랜잭션별 LA_APPLY 버킷, 카운트, 커서인플라이트 버퍼링(챕터 8); LA_REPL_LIST_COUNT(50) 단위로 증가
total_rows / prev_total_rows / log_record_time행 카운터; 마지막 커밋의 서버 시각처리량 및 복제 지연 보고
commit_head / commit_tail보류 중인 LA_COMMIT의 FIFO커밋 적용 순서 지정(챕터 9)
last_deleted_archive_num / last_time_archive_deleted마지막으로 제거된 아카이브 번호, 시각la_remove_archive_logs 구동/스로틀
log_data / rec_type레코드 언팩 임시 공간rec_type = 2바이트 슬롯형 타입; la_apply_pre에서 지연 할당
undo_unzip_ptr / redo_unzip_ptrLOG_ZIP 압축 해제 임시 공간압축된 undo/redo; la_apply_pre에서 할당
apply_stateHA_LOG_APPLIER_STATE_*마스터에 보고됨; dbname-lock 해제 게이팅
max_mem_sizeVSZ 상한(MB)초과 시 데몬 자체 재시작
cache_pb / cache_buffer_size페이지 캐시; 확장당 버퍼 수LA_CACHE_PB 참조; 크기 = LA_DEFAULT_CACHE_BUFFER_SIZE(100)
last_is_end_of_record / is_end_of_record로그 EOF 플래그”따라잡음” vs. “페이지 없음”
last_server_state / is_role_changed마스터 HA 상태 추적역할 변경 처리(챕터 11)
append_lsa / eof_lsa활성 로그 헤더 미러커서 범위 지정; 영속화됨
required_lsa / required_lsa_changed적용되지 않은 가장 오래된 트랜잭션의 시작 LSA; dirty 플래그재확보 하한선: 이 아래 아카이브는 제거 가능; 플래그는 불필요한 카탈로그 쓰기 방지
insert/update/delete/schema/commit/fail_counter / log_commit_time연산 통계; 마지막 커밋 시각_db_ha_apply_info에 영속화; 지연 보고
status / is_apply_info_updatedstatus, 업데이트 중 플래그IDLE/BUSY; 업데이트 중간 크래시 힌트
num_unflushed버퍼링됐지만 flush되지 않은 항목 수LA_MAX_UNFLUSHED_REPL_ITEMS(200)에서 flush
log_path_lockf_vdes / db_lockf_vdespath-lock / dbname-lock fd중복 데몬 방지(7.4) vs. apply 시간 락
repl_filter / reinit_copylog테이블 필터; “copylogdb가 재초기화해야 함” 신호LA_REPL_FILTER 참조(챕터 11); 아카이브 읽기에서 리셋 볼륨 감지 시 설정됨
tde_sock_for_dks / maxslotted_reclengthTDE data-key 소켓; heap_Maxslotted_reclengthUNSTABLE_TDE_FOR_REPLICATION_LOG 하에서만(챕터 11); 행 재구성 크기 지정(챕터 9)

불변 조건 — final_lsa는 단일 읽기 커서이며 한 실행 내에서 절대 뒤로 이동하지 않는다. la_apply_pre는 바깥쪽 이터레이션마다 committed_lsa에서 이를 다시 시드하고, 안쪽 루프는 오직 전진만 한다. fetch 레이어는 append_lsa.pageid < pageid인 페이지를 거부하므로 마스터가 생성한 것을 초과하여 커서가 이동할 수 없다 — eof_lsa 앞으로 시드되면 la_get_page_buffer가 무한 스핀한다.

LA_CACHE_PB / LA_CACHE_BUFFER / LA_CACHE_BUFFER_AREA — 고정 크기 버퍼의 플랫 풀 위에 구축된 해시 테이블로, 스토리지는 하나 이상의 슬랩(slab)에서 할당된다.

LA_CACHE_PB 필드역할존재 이유
hash_tableMHT_TABLE LOG_PAGEIDLA_CACHE_BUFFER*페치된 페이지의 O(1) 조회
log_buffer버퍼 포인터 배열클럭 피해자 스캔이 순회
num_bufferslog_buffer의 길이피해자를 찾지 못하면 증가
buffer_area슬랩 리스트의 헤드종료 시 일괄 해제
LA_CACHE_BUFFER 필드역할존재 이유
fix_count핀 카운트> 0이면 사용 중, 축출 불가
recently_free세컨드 찬스 비트해제 시 설정; 클럭 첫 번째 패스에서 지워짐
in_archive출처 플래그아카이브 볼륨에서 채워졌으면 true
pageid내용의 논리적 페이지 id해시 키; 0 = 빈 슬롯
phy_pageid볼륨 내 물리적 페이지la_log_phypageid에서 가져옴
logpageLOG_PAGE 페이로드페이지 이미지; SIZEOF_LA_CACHE_LOG_BUFFER(io_pagesize)

LA_CACHE_BUFFER_AREA(슬랩 하나): area 헤더 뒤에 버퍼들이 연속으로 배치되며, next는 이전 슬랩을 가리키는 단방향 연결이다 — 기존 버퍼를 이동시키지 않으므로 핀된 페이지가 있을 때도 안전하게 확장할 수 있다.

불변 조건 — 살아 있는 모든 버퍼는 log_buffer[] 인덱스로 도달 가능하고, pageid != 0이면 hash_table 키로도 도달 가능하며, 두 경로가 일치해야 한다. la_cache_buffer_replace는 재사용 전에 피해자의 이전 pageidmht_rem을 호출하고, la_get_page_buffer pageid로 다시 삽입한다. 오래된 키가 남아 있으면 mht_get이 잘못된 내용을 반환 — 무성한 데이터 손상이 발생한다.

LA_RECDES_POOL — apply 중 레코드당 malloc을 피하기 위해 한 번 할당하는 RECDES 디스크립터와 하나의 연속 아레나.

필드역할존재 이유
recdes_arrRECDES 배열행 재구성 시 라운드 로빈으로 배분
areapage_size * num_recdes 아레나모든 recdes_arr[i].data의 백킹
next_idx라운드 로빈 커서각 apply 패스마다 0으로 초기화
db_page_sizerecdes당 용량재초기화가 필요한 페이지 크기 변경 감지
num_recdes풀 크기호출 지점에서 LA_MAX_UNFLUSHED_REPL_ITEMS(200)
is_initialized가드la_init_recdes_pool을 멱등성 있게 만듦

LA_ACT_LOG / LA_ARV_LOG — 볼륨 핸들.

LA_ACT_LOG 필드역할존재 이유
path[PATH_MAX]활성 로그 경로fileio_make_log_active_name에서 가져옴
log_vdes열린 fd열리기 전까지 NULL_VOLDES
hdr_page헤더 페이지 버퍼마스터 추적을 위해 루프마다 재읽기
log_hdrhdr_page->area 안의 LOG_HEADER*모든 append_lsa/nxarv_* 읽기
db_iopagesize헤더에서 가져온 IO 페이지 크기압축 해제/recdes 임시 공간 크기 지정
db_logpagesize헤더에서 가져온 로그 페이지 크기모든 캐시 버퍼 크기 지정; 헤더 읽기 전까지 4096
LA_ARV_LOG 필드역할존재 이유
path[PATH_MAX]아카이브 경로아카이브 번호에 따라 온디맨드로 구성
log_vdes열린 아카이브 fd아카이브 번호가 바뀌면 재오픈
hdr_page아카이브 헤더 버퍼첫 아카이브 읽기 시 지연 malloc
log_hdrhdr_page 안의 LOG_ARV_HEADER*fpageid/npages 제공
arv_num현재 열린 아카이브 번호필요한 번호와 비교하여 재오픈 여부 결정

LA_REPL_FILTER(list/list_size/num_filters/type)는 include/exclude 테이블 목록으로, 그 동작 원리는 챕터 11에서 다루며, 여기서는 REPL_FILTER_NONE으로의 초기화만 나타난다.

7.2 진입점: cubrid hb start에서 la_apply_log_file까지

섹션 제목: “7.2 진입점: cubrid hb start에서 la_apply_log_file까지”

cubrid heartbeat startapplylogdb를 생성하면, mainutil_cs.c에 진입하고, 이는 log_applier.cla_apply_log_file (database_name, log_path, max_mem_size)를 호출한다. 이 호출은 정상 동작 하에서는 절대 반환하지 않는다 — 이 함수가 곧 데몬 그 자체이다. 프롤로그는 고정된 순서로 진행된다: 시그널 핸들러 설치 및 슬레이브 db-name/peer host 설정; la_init; PRM_ID_HA_SQL_LOGGING이 설정된 경우 선택적으로 sl_init; la_check_duplicated(path lock — 오류 시 반환); la_init_cache_pb(NULL이면 OUT_OF_MEMORY); la_find_log_pagesize(활성 볼륨 열기, 헤더 읽기); 그제야 실제 db_logpagesizela_init_cache_log_buffer; la_init_recdes_pool (db_iopagesize, 200); la_get_last_ha_applied_info; committed_lsa := required_lsa; apply 루프 진입(챕터 8+).

리팩터링 시 쉽게 깨지는 두 가지 순서 제약:

  • 헤더를 읽기 전에 캐시 버퍼를 할당할 수 없다. la_init_cache_pb컨테이너(LA_CACHE_PB, 필드 NULL/0)만 할당한다. 버퍼당 크기는 la_find_log_pagesize 이후에야 알게 되는 db_logpagesize를 필요로 하므로, la_init_cache_log_buffer는 그 이후에 실행된다.
  • la_check_duplicated는 볼륨 작업 이전에 실행된다, 따라서 두 번째 데몬은 캐시를 두고 경합하기 전에 거부된다.

7.3 la_init — 전역 변수 초기화와 기본값 설정

섹션 제목: “7.3 la_init — 전역 변수 초기화와 기본값 설정”

la_init은 순수한 상태 설정이며, VSZ 프로브 단 한 번을 제외하면 실패 경로가 없다. 필드 의미는 7.1 표에 있으며, 비직관적인 의도만 주석으로 표시한다:

// la_init -- src/transaction/log_applier.c
memset (&la_Info, 0, sizeof (la_Info)); /* <- everything zeroed */
strncpy (la_Info.log_path, log_path, PATH_MAX - 1);
la_Info.cache_buffer_size = LA_DEFAULT_CACHE_BUFFER_SIZE; /* 100 */
la_Info.act_log.db_iopagesize = LA_DEFAULT_LOG_PAGE_SIZE; /* 4096, provisional */
la_Info.act_log.db_logpagesize = LA_DEFAULT_LOG_PAGE_SIZE;
la_Info.act_log.log_vdes = la_Info.arv_log.log_vdes = NULL_VOLDES; /* no volume open */
LSA_SET_NULL (&la_Info.committed_lsa); /* all cursors start NULL */
// ... committed_rep_lsa, append_lsa, eof_lsa, required_lsa, final_lsa,
// last_committed_lsa, last_committed_rep_lsa all SET_NULL ...
la_Info.last_deleted_archive_num = -1; /* nothing purged yet */
la_Info.max_mem_size = max_mem_size;
if (!start_vsize) start_vsize = la_get_mem_size (); /* probe VSZ once per process */
la_Info.start_vsize = start_vsize;
la_recdes_pool.is_initialized = false; /* force pool (re)alloc */
la_Info.repl_filter.type = REPL_FILTER_NONE;
la_Info.reinit_copylog = false;

db_logpagesize = 4096플레이스홀더이다; la_find_log_pagesize 이전에 이 값으로 크기를 결정하면 조용히 4096을 사용하게 된다 — la_init_cache_pb가 버퍼를 할당하지 않는 이유가 바로 이것이다. 기본값은 첫 헤더 읽기 시 malloc으로 임시 hdr_page를 할당할 수 있도록만 존재한다. repl_lists 배열은 여기서 건드리지 않는다; la_init_repl_listsLA_REPL_LIST_COUNT(50) 버킷 단위로 지연 구축한다.

적용 전 설정은 두 단계로 나뉜다: la_check_duplicated(프롤로그에서 한 번)와 la_apply_pre(바깥쪽 이터레이션마다 최상단에서).

**la_check_duplicated**는 중복 데몬으로부터 보호한다. "<logpath>/<dbname>_lgla__lock"(LA_LOCK_SUFFIX = "_lgla__lock")을 구성하여 O_RDWR|O_CREAT로 열고, fileio_lock_la_log_path로 쓰기 락을 건다:

// la_check_duplicated -- src/transaction/log_applier.c
*lockf_vdes = fileio_open (lock_path, O_RDWR | O_CREAT, 0644);
if (*lockf_vdes == NULL_VOLDES) return ER_IO_MOUNT_FAIL; /* branch: cannot open */
lockf_type = fileio_lock_la_log_path (dbname, lock_path, *lockf_vdes, last_deleted_arv_num);
if (lockf_type == FILEIO_NOT_LOCKF) { /* branch: another daemon holds it */
fileio_close (*lockf_vdes); *lockf_vdes = NULL_VOLDES;
return ER_FAILED;
}
return NO_ERROR; /* branch: lock acquired */

락 파일에는 last_deleted_arv_num도 내구성 있게 저장된다 — fileio_lock_la_log_path가 정수를 다시 읽어 오므로, 이후 la_update_last_deleted_arv_num 쓰기를 위해 fd를 log_path_lockf_vdes에 보관한다. dbname 락(db_lockf_vdes, la_lock_dbname 경유)은 별개로, apply 중에만 보유한다.

불변 조건 — (log_path, dbname)당 최대 하나의 applylogdb만 path lock을 보유한다. 두 데몬이 동시에 실행되면 행을 이중 적용하여 committed_lsa가 손상된다. OS 쓰기 락이 단일 쓰기를 강제하며, 패배한 쪽은 FILEIO_NOT_LOCKF를 받아 종료한다.

**la_apply_pre**는 이터레이션별 커서를 시드하고 임시 공간을 지연 할당한다:

// la_apply_pre -- src/transaction/log_applier.c
LSA_COPY (&la_Info.final_lsa, &la_Info.committed_lsa); /* <- restart cursor at the watermark */
if (la_Info.rec_type == NULL) { /* 2-byte slotted rectype scratch, once */
la_Info.rec_type = (char *) malloc (DB_SIZEOF (INT16));
if (la_Info.rec_type == NULL) return false; /* branch: OOM */
}
if (la_Info.undo_unzip_ptr == NULL) { /* LOG_ZIP undo scratch */
la_Info.undo_unzip_ptr = log_zip_alloc (la_Info.act_log.db_iopagesize);
if (la_Info.undo_unzip_ptr == NULL) return false; /* branch: OOM */
}
if (la_Info.redo_unzip_ptr == NULL) { /* LOG_ZIP redo scratch */
la_Info.redo_unzip_ptr = log_zip_alloc (la_Info.act_log.db_iopagesize);
if (la_Info.redo_unzip_ptr == NULL) return false; /* branch: OOM */
}
return true;

세 개의 == NULL 가드는 이터레이션 간 할당을 멱등성 있게 만든다. 첫 번째 줄 — final_lsacommitted_lsa로 재설정 — 은 일시적 정지 후 루프가 마지막 내구성 있는 커밋에서 재개하는 방식이다.

7.5 북마크에서 final_lsa 시드하기

섹션 제목: “7.5 북마크에서 final_lsa 시드하기”

la_get_last_ha_applied_info_db_ha_apply_info 행을 읽고(la_get_ha_apply_info 경유), 영속화된 LSA들을 la_Info에 복사한다.

flowchart TD
  A["la_get_last_ha_applied_info"] --> B["la_get_ha_apply_info -> res"]
  B --> C{"res > 0?<br/>행 발견"}
  C -->|yes| D["LSA_COPY committed/rep/append/eof/final/required + 카운터"]
  D --> E{"db_creation이 헤더와 일치?"}
  E -->|no| Rx["return ER_FAILED"]
  E -->|yes| F{"행의 required_lsa가 NULL?"}
  F -->|yes| Rx2["return ER_FAILED"]
  F -->|no| G
  C -->|res == 0| H["insert_apply_info := true"]
  C -->|res < 0| Rz["return res"]
  H --> G["NULL 폴백 픽스업"]
  G --> G1["required NULL -> eof; committed/rep/final NULL -> required"]
  G1 --> I{"insert_apply_info?"}
  I -->|yes| J["la_insert_ha_apply_info"]
  I -->|no| K["la_update_ha_apply_info_start_time"]
  J --> L
  K --> L{"res == 0?"}
  L -->|yes| Rw["return ER_FAILED"]
  L -->|res < 0| Rv["return res"]
  L -->|ok| M["db_commit_transaction"]
  M --> N["last_committed_lsa/rep_lsa := committed_lsa/rep_lsa"]
  N --> O["return NO_ERROR"]

Figure 7-2. la_get_last_ha_applied_info의 모든 분기. NULL 폴백 픽스업(G/G1)은 행 발견 경로와 새 행 경로 양쪽에서 모두 도달한다.

행 발견(res > 0) 경로는 행에서 여섯 개의 LSA와 여섯 개의 카운터를 복사한 뒤, 두 가지를 검증한다: db_creation 불일치는 북마크가 다른 데이터베이스 인카네이션에 속한다는 의미이므로 ER_FAILED를 반환하고; 기존 행에서 required_lsa가 NULL이면 이 역시 치명적(ER_FAILED) — 기록된 행에는 반드시 하한선이 있어야 한다. 새 데이터베이스(res == 0)는 복사를 건너뛰고 insert_apply_info만 설정한다. NULL 폴백 블록은 살아남은 두 경로 모두에서 실행된다: required_lsa가 NULL이면 헤더의 eof_lsa로 폴백하고, 이후 committed_lsa / committed_rep_lsa / final_lsa 각각이 NULL이면 required_lsa로 폴백한다. (행 발견 경로에서는 위의 가드가 NULL을 이미 잡았으므로 required_lsa는 이미 비NULL — 따라서 폴백은 세 커서만 패치한다.) 행을 삽입하거나 시작 시각을 업데이트하고, 성공 시에만 db_commit_transaction으로 영속화한다. 마지막으로 last_committed_lsa/last_committed_rep_lsa가 시작 워터마크를 스냅샷하여 챕터 8이 이미 적용된 레코드를 건너뛸 수 있게 한다.

la_apply_log_file로 돌아오면, 한 줄로 시드가 완성된다: LSA_COPY (&la_Info.committed_lsa, &la_Info.required_lsa). la_apply_prefinal_lsa := committed_lsa와 합산하면, 첫 번째 읽기 커서는 required_lsa — 적용이 필요한 가장 오래된 트랜잭션 — 에 놓인다.

la_init_cache_pb는 단순하다: LA_CACHE_PBmalloc하고, 모든 필드를 NULL로 설정하며, 반환한다(OOM이면 NULL). 버퍼는 아직 없다.

la_init_cache_log_buffer는 첫 번째 슬랩을 할당한 뒤 해시 테이블을 만든다:

// la_init_cache_log_buffer -- src/transaction/log_applier.c
error = la_expand_cache_log_buffer (cache_pb, slb_cnt, slb_size); /* slb_cnt=100 buffers */
if (error != NO_ERROR) return error; /* branch: OOM */
cache_pb->hash_table = mht_create ("...", cache_pb->num_buffers * 8, /* <- 8x headroom */
log_pageid_hash, mht_compare_logpageids_are_equal);
if (cache_pb->hash_table == NULL) error = ER_OUT_OF_VIRTUAL_MEMORY;
return error;

la_expand_cache_log_bufferLA_CACHE_BUFFER_AREA 헤더와 slb_cnt개의 연속 버퍼를 포함하는 하나의 malloc을 수행하고, log_buffer[]realloc한다. 슬랩은 앞에 추가되며(area->next = cache_pb->buffer_area), 기존 버퍼는 절대 이동하지 않으므로 페이지가 핀된 상태에서도 확장이 안전하다. log_pageid_hashLOGPB_HEADER_PAGE_ID를 버킷 0에 특수 처리한다.

la_init_recdes_pool은 멱등성이 있으며 크기 변경 시 자체 복구한다:

// la_init_recdes_pool -- src/transaction/log_applier.c
if (la_recdes_pool.is_initialized == false) {
la_recdes_pool.area = malloc (page_size * num_recdes); /* one arena + OOM branches */
la_recdes_pool.recdes_arr = malloc (sizeof (RECDES) * num_recdes);
p = la_recdes_pool.area;
for (i = 0; i < num_recdes; i++) {
recdes = &la_recdes_pool.recdes_arr[i];
recdes->data = p; recdes->area_size = page_size; recdes->length = 0; /* carve from arena */
p += page_size;
}
la_recdes_pool.is_initialized = true;
}
else if (la_recdes_pool.db_page_size != page_size /* branch: size changed */
|| la_recdes_pool.num_recdes != num_recdes) {
la_clear_recdes_pool ();
return la_init_recdes_pool (page_size, num_recdes); /* recurse once with new size */
}
la_recdes_pool.next_idx = 0; /* always reset cursor */

캐시 위의 호출 스택: la_get_page -> la_get_page_buffer -> (la_cache_buffer_replace -> la_log_fetch). la_get_page는 성공할 때까지 la_get_page_buffer를 반복 호출하고 &cache_buffer->logpage를 반환한다.

la_get_page_buffer — 해시 조회, 미스 시 페치, 핀:

// la_get_page_buffer -- src/transaction/log_applier.c
cache_buffer = (LA_CACHE_BUFFER *) mht_get (cache_pb->hash_table, &pageid);
if (cache_buffer == NULL) { /* MISS */
cache_buffer = la_cache_buffer_replace (cache_pb, pageid,
la_Info.act_log.db_logpagesize, la_Info.cache_buffer_size);
if (cache_buffer == NULL
|| cache_buffer->logpage.hdr.logical_pageid != pageid) /* fetch failed / wrong page */
return NULL;
(void) mht_rem (cache_pb->hash_table, &cache_buffer->pageid, NULL, NULL); /* drop stale key */
if (mht_put (cache_pb->hash_table, &cache_buffer->pageid, cache_buffer) == NULL)
return NULL; /* hash insert failed */
}
else if (cache_buffer->logpage.hdr.logical_pageid != pageid) { /* stale HIT -> evict, retry */
(void) mht_rem (cache_pb->hash_table, &cache_buffer->pageid, NULL, NULL);
return NULL;
}
cache_buffer->fix_count++; /* <- pin before returning */
return cache_buffer;

“오래된 히트” 분기(해시에서는 찾았지만 내용이 더 이상 일치하지 않음)는 호출자가 재페치하도록 NULL을 반환한다; 성공적인 모든 반환은 핀을 건다.

해제 vs. 무효화. la_release_page_buffer언핀하되 페이지를 유지하며, 세컨드 찬스 비트를 세운다:

// la_release_page_buffer -- src/transaction/log_applier.c
cache_buffer = (LA_CACHE_BUFFER *) mht_get (cache_pb->hash_table, (void *) &pageid);
if (cache_buffer != NULL) {
if ((--cache_buffer->fix_count) <= 0) cache_buffer->fix_count = 0; /* <- clamp, never negative */
cache_buffer->recently_free = true; /* <- the bit Figure 7-3's clock reads */
}

la_invalidate_page_buffer축출한다 — 해시 키를 제거하고, 핀과 비트를 초기화하며, 슬롯이 비어 있음을 나타내도록 식별자를 0으로 초기화한다:

// la_invalidate_page_buffer -- src/transaction/log_applier.c
if (cache_buffer->pageid != 0)
(void) mht_rem (cache_pb->hash_table, &cache_buffer->pageid, NULL, NULL); /* drop old key */
cache_buffer->fix_count = 0;
cache_buffer->recently_free = false; /* <- it is gone, not a candidate */
cache_buffer->pageid = 0;

la_cache_buffer_replacela_log_fetch에 공급하는 클럭 방식 피해자 선택:

flowchart TD
  A["la_cache_buffer_replace"] --> B["found = -1"]
  B --> C["log_buffer[]를 'last'부터 스캔"]
  C --> D{"fix_count == 0?"}
  D -->|no, 핀됨| C
  D -->|yes| E{"recently_free?"}
  E -->|yes| F["비트 지움, num_recently_free++"]
  F --> C
  E -->|no| G["found = 슬롯; break"]
  C -->|전체 스캔 완료| H{"found >= 0?"}
  G --> H
  H -->|yes| I["pageid!=0이면 mht_rem 이전 키<br/>fix_count=0; la_log_fetch"]
  I --> J{"페치 성공?"}
  J -->|no| K["victim->pageid=0; return NULL"]
  J -->|yes| L["return victim"]
  H -->|no| M{"num_recently_free > 0?"}
  M -->|yes| C
  M -->|no| N["la_expand_cache_log_buffer"]
  N --> O{"확장 성공?"}
  O -->|no| P["return NULL"]
  O -->|yes| C

Figure 7-3. la_cache_buffer_replace 피해자 선택.

static unsigned int last는 클럭 핸드로, 호출 간에 유지된다. recently_free 버퍼는 한 번의 유예를 받는다(비트 지워짐, num_recently_free++ — 정확히 la_release_page_buffer가 세운 비트); fix_count == 0이고 recently_free가 아닌 버퍼만 선택된다. 피해자를 찾지 못했지만 비트를 지운 스윕은 다시 루프하고; 비트를 찾지도 지우지도 못한 스윕(모두 핀됨)은 풀을 확장하고 재시도한다. 피해자의 이전 키는 la_log_fetch가 덮어쓰기 전에 제거된다.

**la_log_fetch**는 활성 vs. 아카이브를 선택하여 물리적 페이지를 읽는다:

// la_log_fetch -- src/transaction/log_applier.c
phy_pageid = la_log_phypageid (pageid);
if (la_Info.act_log.log_hdr->append_lsa.pageid < pageid) { /* page beyond what master wrote? */
error = la_fetch_log_hdr (&la_Info.act_log); /* re-read header, recheck */
if (error != NO_ERROR) return error;
if (la_Info.act_log.log_hdr->append_lsa.pageid < pageid)
return ER_LOG_NOTIN_ARCHIVE; /* genuinely not there yet */
}
do {
if (LA_LOG_IS_IN_ARCHIVE (pageid)) { /* pageid < nxarv_pageid */
error = la_log_fetch_from_archive (pageid, (char *) &cache_buffer->logpage);
if (error != NO_ERROR) { la_applier_need_shutdown = true; return error; }
cache_buffer->in_archive = true;
} else {
error = la_log_io_read (la_Info.act_log.path, la_Info.act_log.log_vdes,
&cache_buffer->logpage, phy_pageid, la_Info.act_log.db_logpagesize);
if (error != NO_ERROR) return ER_LOG_READ;
cache_buffer->in_archive = false;
}
if (cache_buffer->logpage.hdr.logical_pageid == pageid) break; /* got the right page */
usleep (100 * 1000); /* master may be archiving; retry */
error = la_fetch_log_hdr (&la_Info.act_log);
if (error != NO_ERROR) return error;
} while (error == NO_ERROR && --retry > 0); /* up to 5 tries */
if (retry <= 0 || la_Info.act_log.log_hdr->append_lsa.pageid < pageid)
return ER_LOG_NOTIN_ARCHIVE;
cache_buffer->pageid = pageid; /* <- stamp identity only on success */
cache_buffer->phy_pageid = phy_pageid;
return error;

LA_LOG_IS_IN_ARCHIVE(pageid)pageid < nxarv_pageid를 검사한다 — 다음 아카이브 경계 아래의 페이지들은 이미 아카이브 볼륨에 있다. la_log_fetch_from_archive는 아카이브 번호를 찾고(la_find_archive_num), arv_log.arv_num과 다르면 fd를 재오픈하고, 헤더를 읽은 뒤 pageid - fpageid + 1 오프셋의 페이지를 읽는다; ER_PB_BAD_PAGEID 분기는 오래된 fd를 복구하기 위해 log_reopen으로 goto한다. 재시도 루프는 마스터가 테스트와 읽기 사이에 페이지를 아카이브하는 경쟁 조건을 흡수한다.

불변 조건 — cache_buffer->pageid는 검증된 읽기 후에만 스탬프된다. 어떠한 실패에서도 0을 유지하거나 la_cache_buffer_replace의 오류 분기에서 초기화되므로, 반쯤 채워진 버퍼는 절대 해시 테이블에 게시되지 않는다.

7.8 페이지 경계를 넘는 레코드 복사: la_log_copy_fromlog

섹션 제목: “7.8 페이지 경계를 넘는 레코드 복사: la_log_copy_fromlog”

레코드는 페이지 경계에 걸쳐 있다. la_log_copy_fromlog는 캐시된 페이지들에서 레코드의 타입 워드와 데이터를 복사하며, LA_LOG_READ_* 매크로로 페이지를 넘나든다:

// la_log_copy_fromlog -- src/transaction/log_applier.c
int rec_length = (int) sizeof (INT16); /* the 2-byte slotted rectype */
pg = log_pgptr;
while (rec_type != NULL && rec_length > 0) { /* phase 1: copy the rectype */
LA_LOG_READ_ADVANCE_WHEN_DOESNT_FIT (error, 0, log_offset, log_pageid, pg); /* hop if at edge */
if (pg == NULL) break; /* defensive: page vanished */
copy_length = (log_offset + rec_length <= LA_LOGAREA_SIZE)
? rec_length : LA_LOGAREA_SIZE - log_offset; /* clip to page end */
memcpy (rec_type + area_offset, (char *) pg->area + log_offset, copy_length);
rec_length -= copy_length; area_offset += copy_length; log_offset += copy_length;
*length = *length - DB_SIZEOF (INT16); /* rectype is not part of the data */
}
area_offset = 0; t_length = *length;
while (t_length > 0) { /* phase 2: copy the record data */
LA_LOG_READ_ADVANCE_WHEN_DOESNT_FIT (error, 0, log_offset, log_pageid, pg);
if (pg == NULL) break;
copy_length = (log_offset + t_length <= LA_LOGAREA_SIZE)
? t_length : LA_LOGAREA_SIZE - log_offset;
memcpy (area + area_offset, (char *) pg->area + log_offset, copy_length);
t_length -= copy_length; area_offset += copy_length; log_offset += copy_length;
}

LA_LOG_READ_ADVANCE_WHEN_DOESNT_FIToffset + length >= LA_LOGAREA_SIZE — 페이지당 사용 가능한 바이트 수, la_Info.act_log.db_logpagesize - SSIZEOF(LOG_HDRPAGE)로 정의됨 — 를 검사하고, 그 경우 la_get_page (++pageid)로 다음 페이지를 페치하며 offset을 0으로 초기화한다. la_get_pagela_get_page_buffer로 재귀하므로, 다음 페이지에 을 건다; 이 핀들은 나중에 la_release_all_page_buffers가 해제한다. 형제 매크로 LA_LOG_READ_ALIGN / LA_LOG_READ_ADD_ALIGN은 챕터 8에서 레코드 간 이동에 사용된다.

불변 조건 — *length는 페이즈 1 이후 rectype을 제외한 값이다. 페이즈 1이 *lengthsizeof(INT16) 만큼 감소시키므로 페이즈 2는 정확히 데이터만 복사한다. rec_type == NULL(오버플로우 페이지)인 경우 페이즈 1은 건너뛰어지므로 *length는 전체 데이터 길이를 유지한다.

  1. 전역 변수 하나, 두 단계 초기화. la_init임시 페이지 크기로 la_Info를 초기화한다; 캐시 버퍼와 recdes 풀은 la_find_log_pagesize가 실제 db_logpagesize를 읽기 전까지 크기를 결정할 수 없다. la_init_cache_pb는 그 사이에 빈 컨테이너만 할당한다.
  2. Path lock(_lgla__lock, LA_LOCK_SUFFIX)이 단일 쓰기 보장. la_check_duplicatedfileio_lock_la_log_path로 두 번째 데몬을 거부하며, 같은 파일이 last_deleted_archive_num을 영속화한다. dbname 락은 별개이며 apply 시간에만 보유된다.
  3. final_lsa는 읽기 커서이며, la_apply_pre가 바깥쪽 이터레이션마다 committed_lsa에서 재시드한다. 시작 시 체인: required_lsa -> committed_lsa -> final_lsa.
  4. 북마크가 복구를 주도한다. la_get_last_ha_applied_info는 행에서 여섯 개의 LSA와 여섯 개의 카운터를 복사하고, 불일치하는 db_creation 인카네이션(및 기존 행의 NULL required_lsa)을 검증하며, 행 발견과 새 행 경로 양쪽에서 NULL 폴백을 적용하여 모든 커서가 required_lsa를 거쳐 헤더 eof_lsa까지 기본값이 되도록 하고, 재시작 워터마크를 스냅샷한다.
  5. 페이지 캐시는 클럭 교체 플랫 풀 위의 해시 테이블이다. la_get_page_buffer가 핀(fix_count++); la_release_page_buffer가 언핀하고 recently_free를 세움; la_invalidate_page_buffer가 축출; la_cache_buffer_replacerecently_free를 세컨드 찬스로 소비하며, 모든 버퍼가 핀됐을 때 풀을 확장한다.
  6. la_log_fetchnxarv_pageid 경계(LA_LOG_IS_IN_ARCHIVE)로 활성 vs. 아카이브를 선택하고, 마스터 아카이빙 경쟁 조건을 흡수하기 위해 헤더를 재읽고 최대 5회 재시도하며, 검증된 읽기 후에만 pageid를 스탬프한다.
  7. 레코드는 페이지에 걸쳐 있으며, LA_LOG_READ_* 매크로와 la_log_copy_fromlog가 경계를 투명하게 넘나들고, 페이즈 1에서 2바이트 rectype을 빼서 페이즈 2가 정확히 페이로드만 복사하도록 한다.

Chapter 8: 레코드별 디스패치와 트랜잭션별 버퍼링

섹션 제목: “Chapter 8: 레코드별 디스패치와 트랜잭션별 버퍼링”

이 챕터는 applylogdb 내부 루프의 핵심을 해부한다. 데몬이 로그 레코드를 하나씩 앞으로 순회할 때마다, la_log_record_process는 해당 레코드의 종류와 원본 트랜잭션의 커밋이 도착할 때까지 그 효과를 어디에 쌓아둘지 결정한다. 상위 문서(cubrid-ha-replication.md, “applylogdb apply pipeline”)는 슬레이브가 트랜잭션 단위로 아이템을 버퍼링하고 커밋 순서대로 재생하는 이유를 설명한다. 이 챕터는 branch별로 어떻게 이루어지는지를 추적한다. 호출자(Chapter 7)는 이미 페이지를 가져와 final 위치의 LOG_RECORD_HEADER를 찾아 놓은 상태다. 여기서 우리가 다룰 것은 switch (lrec->type)이다. 디스패치된 레코드는 두 가지 보조 구조에 흡수된다.

  • 트랜잭션별 apply 리스트 la_Info.repl_lists[] — 살아있는 각 trid마다 하나의 LA_APPLY 버킷이 존재하며, 각 버킷은 LA_ITEM의 이중 연결 리스트다.
  • 커밋 큐 la_Info.commit_head / commit_tail — 트랜잭션 종료 마커마다 하나씩 생성되는 LA_COMMIT 노드의 FIFO로, 재생 순서를 확정한다.

세 가지 구조체(log_applier.c 상단에 정의됨)가 버퍼링된 상태를 담는다.

LA_APPLY — 살아있는 트랜잭션마다 하나의 버킷:

필드역할이유
tranid소유 트랜잭션 ID; 0은 재활용된 슬롯을 표시la_find_apply_list의 검색 키; 0이면 la_add_apply_list가 배열을 늘리기 전에 완료된 슬롯을 재사용한다
num_items버퍼링된 LA_ITEM 개수롱 트랜잭션 제어기가 이 값을 LA_MAX_REPL_ITEMS와 비교한다
is_long_trans버킷이 tail을 버린 이후에 고정되는 플래그플립되면, la_set_repl_log는 더 이상 할당하지 않고 last_lsa만 추적한다
start_lsa트랜잭션의 첫 번째 레코드 LSA아직 끝나지 않은 트랜잭션이 소유한 가장 낮은 LSA; Chapter 10의 북마크는 이 지점을 넘어갈 수 없다
last_lsa롱 트랜잭션의 가장 최근 레코드 LSAChapter 9의 롱 트랜잭션 재생이 어디까지 재스캔해야 하는지 알려준다
head / tail이중 연결 LA_ITEM 체인의 양 끝head는 shed 이후에도 보존되는 anchor이며, tail은 추가 지점이다

LA_ITEM — 버퍼링된 복제 레코드 하나:

필드역할이유
next / prev트랜잭션 내 체인 링크롱 트랜잭션에서 살아남은 head는 둘 다 null이며, la_unlink_repl_item은 이를 허용한다
log_typeLOG_REPLICATION_DATA 또는 _STATEMENTla_make_repl_item의 unpack branch와 la_get_item_pk_value의 lazy-key branch를 결정한다
item_type마스터의 rcvindex(DATA) 또는 statement 유형(STATEMENT)재생할 구체적인 연산을 식별한다
class_name대상 클래스 이름슬레이브에서 heap/index를 조회한다
packed_key_value / _lengthPK의 디스크 이미지, packed 상태 유지아직 abort될 수 있는 키를 위해 DB_VALUE 없이 on-demand로만 key로 unpack된다
keyunpack된 PK(DATA, on-demand) 또는 statement 텍스트(STATEMENT)슬레이브가 apply하는 값; STATEMENT는 need_clear = true로 즉시 채워진다
db_user / ha_sys_prmstatement 복제 컨텍스트올바른 사용자와 시스템 파라미터 세트로 재생한다
lsa / target_lsa복제 레코드 LSA와 마스터 target LSA아이템의 순서와 출처를 나타낸다

LA_COMMIT — FIFO 내의 트랜잭션 종료 마커 하나:

필드역할이유
next / prevFIFO 링크repl_lists[]가 아닌 이 큐가 재생 순서를 확정한다
typeLOG_COMMIT / LOG_SYSOP_END / LOG_ABORTABORT 노드는 la_apply_commit_list에게 해당 버킷을 재생하지 말고 버리라고 지시한다
tranid소유 트랜잭션노드를 해당 LA_APPLY 버킷과 연결한다
log_lsaEOT 마커의 LSA성공적인 drain 이후 committed_lsa로 복사되는 커밋 지점
log_record_time벽시계 커밋 시각, sysop은 0시간 제한 복제 지연(replica delay)에 사용된다

8.2 switch 이전의 두 가지 프롤로그 가드

섹션 제목: “8.2 switch 이전의 두 가지 프롤로그 가드”

모든 레코드는 la_log_record_process가 타입을 살피기 전에 두 개의 게이트를 통과한다.

// la_log_record_process -- src/transaction/log_applier.c
if (lrec->trid == NULL_TRANID || LSA_GT (&lrec->prev_tranlsa, final) || LSA_GT (&lrec->back_lsa, final)) {
if (lrec->type != LOG_END_OF_LOG) { /* EOF sentinel is the only tolerated exception */
la_applier_need_shutdown = true; /* poison pill: caller exits the apply loop */
er_set (..., ER_HA_LA_INVALID_REPL_LOG_RECORD, 10, ...); return ER_LOG_PAGE_CORRUPTED;
}
}

가드 1 — 손상 / 미기록 감지. 레코드는 다음 중 하나에 해당하면 의심 레코드로 판단한다: tridNULL_TRANID이거나, 역방향 포인터(prev_tranlsa — 동일 트랜잭션 내 직전 레코드; back_lsa — 전역 로그 내 직전 레코드) 중 하나가 final보다 을 가리키는 경우. 역방향 포인터는 이미 기록된 것만 합법적으로 가리킬 수 있다. 따라서 앞을 가리킨다는 것은 마스터가 flush하기 직전에 페이지를 가져온, 아직 유효하지 않은 레코드임을 의미한다. LOG_END_OF_LOG는 허용된다(활성 로그 tail의 sentinel은 null/쓰레기 포인터를 가진다). 그 외에는 복구 불가능하므로 데몬을 종료한다.

INVARIANT — 역방향 포인터는 읽히는 레코드 위치를 절대 초과할 수 없다. 가드 1의 LSA_GT 검사(back_lsa, prev_tranlsa)로 강제된다. 위반 시 데몬은 해당 바이트 해석을 거부한다(초기화되지 않은 페이지 메모리를 역참조하면 SEGV가 발생하거나 쓰레기 데이터가 apply될 수 있다). LOG_END_OF_LOG 예외만이 forward/null 포인터로 이 게이트를 통과할 수 있는 유일한 합법적 경로다.

가드 2 — 최초 인식 등록. LSA_ISNULL (&lrec->prev_tranlsa)이고 레코드가 예외 대상이 아닌 경우(type != LOG_END_OF_LOG && type != LOG_DUMMY_HA_SERVER_STATE && trid != LOG_SYSTEM_TRANID — 이 타입들은 버킷을 소유하지 않는다) 이 가드가 동작한다. prev_tranlsa == NULL은 트랜잭션의 첫 번째 로그 레코드(트랜잭션 내 직전 레코드 없음)를 표시하므로, 가드는 apply = la_add_apply_list (lrec->trid)를 호출하고, apply->start_lsa가 처음으로 null인 경우 final을 복사하여 Chapter 10의 북마크가 미완료 트랜잭션이 소유한 가장 낮은 LSA를 알 수 있게 한다. NULL 반환은 OOM을 의미한다: la_applier_need_shutdown을 설정하고 캡처된 er_errid ()(또는 ER_FAILED)를 반환한다.

두 가드를 통과한 후에야 함수는 la_Info.is_end_of_record = false를 설정하고 타입 switch에 진입한다.

모든 분기를 아래에 정리한다. ER_INTERRUPTED는 실패가 아니다 — 호출자(Chapter 7)는 이를 “이 페이지 워크를 멈추고 재평가하라”로 읽어, end-of-log, 역할 변경, 충돌 복구 마커에서 내부 루프를 깔끔하게 중단한다.

LOG_END_OF_LOG. 로그 tail이다. 두 가지 하위 경우 모두 ER_INTERRUPTED를 반환한다: 다음 페이지가 존재하고 현재 페이지가 아카이브된 상태(la_does_page_exist(pageid+1) && la_does_page_exist(pageid) == LA_PAGE_EXST_IN_ARCHIVE_LOG)라면 아카이브 파일의 조기 EOF다 — 경고 후 final->pageid++; final->offset = 0으로 건너뛴다. 그 외에는 진짜 active 로그 tail이다 — la_Info.is_end_of_record = true로 설정한다.

LOG_REPLICATION_DATA / LOG_REPLICATION_STATEMENT. 실제 작업이 이루어지는 분기다. 둘 다 la_set_repl_log (pg_ptr, lrec->type, lrec->trid, final)에 공급되어 레코드를 트랜잭션 버킷에 추가한다(섹션 8.4). NO_ERROR가 아닌 반환은 데몬을 종료하고 전파된다.

LOG_SYSOP_END / LOG_COMMIT. flush 트리거 — 버퍼링된 아이템을 슬레이브로 drain하는 분기다.

// la_log_record_process -- src/transaction/log_applier.c
case LOG_SYSOP_END:
case LOG_COMMIT:
if (LSA_GT (final, &la_Info.committed_lsa)) { /* idempotency gate vs durable bookmark */
eot_time = (lrec->type == LOG_SYSOP_END) ? 0 : la_retrieve_eot_time (pg_ptr, final);
error = la_add_node_into_la_commit_list (lrec->trid, final, lrec->type, eot_time);
if (eot_time != 0) { error = la_delay_replica (eot_time); ... } /* time-bound replica */
if (la_Info.status == LA_STATUS_IDLE) la_Info.status = LA_STATUS_BUSY;
do {
error = la_apply_commit_list (&lsa_apply, final_pageid); /* drains buckets in commit order */
... /* error triage below; on success advance committed_lsa, bump commit_counter for LOG_COMMIT */
} while (!LSA_ISNULL (&lsa_apply));
} else { la_free_repl_items_by_tranid (lrec->trid); } /* already applied across a restart: discard */
break;

외부의 LSA_GT는 멱등성 게이트다(LSA가 내구적 북마크 위치 이하이면 이미 지난 실행에서 적용되었음을 의미하므로 버킷을 버린다). 각 성공적인 la_apply_commit_list 패스는 lsa_applycommitted_lsa로 복사하고 실제 커밋에 한해 commit_counter를 증가시킨다. 비직관적인 분기는 do/while 내부의 오류 분류 처리다: la_apply_commit_listER_NET_CANT_CONNECT_SERVER를 반환할 때 동작하며, er_errid ()를 검사한다 — 복구 가능한 서버 다운 abort(ER_TM_SERVER_DOWN_UNILATERALLY_ABORTED, ER_LK_UNILATERALLY_ABORTED)는 삼켜지고 루프가 재시도된다. 그 외 er_errid ()ER_NET_SERVER_COMM_ERROR를 다시 발생시키고 반환한다. 독립적으로, ER_HA_LA_EXCEED_MAX_MEM_SIZEER_TDE_CIPHER_IS_NOT_LOADED(Chapter 11의 TDE 경로)는 데몬을 종료하고 반환하며, flush 오류(LA_IS_FLUSH_ERROR)는 즉시 반환된다.

LOG_ABORT. 롤백된 트랜잭션도 재생 순서에 빈 자리가 생기지 않도록 커밋 큐 슬롯이 필요하지만, 아이템은 없다. 이 분기는 la_add_node_into_la_commit_list (lrec->trid, final, LOG_ABORT, 0)을 호출한다. 나중에 la_apply_commit_listABORT 노드를 디큐할 때 해당 trid의 버킷을 버린다.

LOG_DUMMY_CRASH_RECOVERY. 스스로 충돌에서 복구한 마스터가 기록한다. 이 지점을 가로질러 연속성을 신뢰할 수 없다 — 알림을 로그에 남기고 finallrec->forw_lsa로 점프시킨 뒤 재앵커링을 강제하기 위해 ER_INTERRUPTED를 반환한다. LOG_END_CHKPT. 체크포인트 종료 마커 — break(아무것도 하지 않음).

LOG_DUMMY_HA_SERVER_STATE. HA 하트비트이자 역할 변경 트립와이어다. ha_server_state = la_get_ha_server_state (pg_ptr, final) 이후 세 가지 결과: (a) ha_server_state == NULL(읽기 불가) → 로그 후 break; (b) state != HA_SERVER_STATE_ACTIVE && state != HA_SERVER_STATE_TO_BE_STANDBY이면서 DB lock 파일을 보유 중(la_Info.db_lockf_vdes != NULL_VOLDES) → la_Info.is_role_changed = true 설정 후 ER_INTERRUPTED 반환 — 페일오버 트립와이어(Chapter 11); (c) 상태가 여전히 active이고 la_is_repl_lists_empty ()la_update_ha_apply_info_log_record_time (ha_server_state->at_time)la_log_commit (true)로 북마크를 flush(오류 시 반환). 리스트가 비어있는지 확인하는 것이 중요하다: 트랜잭션이 아직 버퍼링된 상태에서 하트비트에 북마크를 flush하면 적용되지 않은 작업을 건너뛰게 된다.

default. 그 외 모든 레코드 타입 — break(무시됨).

flowchart TD
  SW{"lrec->type"} --> INT["EOL / CRASH_RECOVERY / HA 역할 변경\n-> ER_INTERRUPTED, 페이지 워크 중단"]
  SW --> RD["REPL_DATA/STATEMENT -> la_set_repl_log 버퍼링"]
  SW --> CM["SYSOP_END / COMMIT -> LA_COMMIT 큐 추가, drain 루프"]
  SW --> AB["ABORT -> LA_COMMIT 큐 추가, type=ABORT"]
  SW --> NOP["END_CHKPT / HA active / default -> break"]

Figure 8-1: switch 분기를 결과별로 묶은 그림. 세 개의 ER_INTERRUPTED 분기는 페이지 워크를 중단하고, 나머지는 버퍼링, flush, abort 플레이스홀더, 또는 아무것도 하지 않는다.

switch 이후 에필로그는 아카이브 페이지 마지막 레코드에 대한 두 번째 손상 검사다: lrec->forw_lsa.pageid == -1 || lrec->type <= LOG_SMALLER_LOGREC_TYPE || lrec->type >= LOG_LARGER_LOGREC_TYPE(경계 sentinel 자체도 유효하지 않음으로 포함하는 <=/>=) 조건 성립 시 ER_LOG_PAGE_CORRUPTED를 반환한다(해당하는 경우 아카이브 페이지를 건너뜀). 그 외에는 NO_ERROR.

8.4 la_set_repl_log — 롱 트랜잭션 우회를 포함한 버퍼링

섹션 제목: “8.4 la_set_repl_log — 롱 트랜잭션 우회를 포함한 버퍼링”

la_set_repl_logapply = la_find_apply_list (tranid)로 버킷을 조회한 뒤 네 가지 분기를 따른다.

// la_set_repl_log -- src/transaction/log_applier.c (condensed branch heads)
if (apply == NULL) { er_log_debug (...); return NO_ERROR; } /* (1) never registered: silently drop */
if (apply->is_long_trans) { LSA_COPY (&apply->last_lsa, lsa); return NO_ERROR; } /* (2) already long: track last_lsa */
if (apply->num_items >= LA_MAX_REPL_ITEMS) { /* (3) crossing the 1000-item threshold */
la_free_all_repl_items_except_head (apply); apply->is_long_trans = true; LSA_COPY (&apply->last_lsa, lsa); return NO_ERROR;
}
item = la_make_repl_item (log_pgptr, log_type, tranid, lsa); /* (4) normal: build (NULL -> ER_OUT_OF_VIRTUAL_MEMORY) + la_add_repl_item */

비직관적인 분기들: (1)은 applier의 시작 지점 이전에 시작된, 첫 번째 레코드를 본 적 없는 트랜잭션을 조용히 버린다. (2)/(3)은 메모리 제어기다 — 임계값에서 la_free_all_repl_items_except_head는 첫 번째 아이템을 제외한 모든 아이템을 해제하고(리스트 anchor와 start_lsa를 보존), is_long_trans를 플립한다. 이후 (2)는 apply->last_lsa만 전진시켜 롱 트랜잭션 재생(Chapter 9)이 마스터 로그에서 어디까지 직접 재스캔해야 하는지 알 수 있게 한다.

INVARIANT — 롱 트랜잭션은 최대 하나의 버퍼링된 아이템(head)만 보유한다. 크로스오버 시 la_free_all_repl_items_except_head와 already-long 분기(할당을 전혀 하지 않음)로 강제된다. 메모리는 제한되지만, 그 대가로 Chapter 9는 start_lsalast_lsa 사이를 마스터 로그에서 직접 다시 읽어야 한다 — 정확히 LA_MAX_REPL_ITEMS가 방지하고자 했던 팽창이다.

la_free_all_repl_items_except_headapply->head->next부터 순회하며 각 아이템을 la_free_repl_item으로 해제한다. head가 null이면 즉시 반환된다. la_free_repl_item은 먼저 아이템을 unlink하고(la_unlink_repl_item — 롱 트랜잭션의 unlink된 head를 허용함), class_name을 해제하고(설정된 경우에만 pr_clear_valuekey를 정리), db_user, ha_sys_prm, packed_key_value를 해제한 후 노드를 free한다.

8.5 la_make_repl_item / la_add_repl_item — unpack과 연결

섹션 제목: “8.5 la_make_repl_item / la_add_repl_item — unpack과 연결”

la_make_repl_item은 채워진 LA_ITEM을 생성한다: 레코드 헤더를 지나 정렬/전진하고(LA_LOG_READ_ALIGN, LA_LOG_READ_ADVANCE_WHEN_DOESNT_FIT — 실패 시 NULL), repl_log->length 바이트를 힙 area에 복사하고, la_new_repl_item (lsa, &repl_log->lsa)에서 초기화된 skeleton을 가져온 뒤(lsa와 마스터의 target_lsa를 복사), switch (log_type)을 실행한다.

// la_make_repl_item -- src/transaction/log_applier.c
case LOG_REPLICATION_DATA: /* layout: [int key_len][string class_name][aligned key] */
ptr = or_unpack_int (area, &item->packed_key_value_length);
ptr = or_unpack_string (ptr, &item->class_name);
item->packed_key_value = malloc (item->packed_key_value_length);
if (item->packed_key_value == NULL) goto error_return;
ptr = PTR_ALIGN (ptr, MAX_ALIGNMENT); /* 8-byte align, see or_pack_mem_value */
memcpy (item->packed_key_value, ptr, item->packed_key_value_length);
item->item_type = repl_log->rcvindex; break; /* rcvindex identifies the DML op */
case LOG_REPLICATION_STATEMENT: /* layout: [int type][string class][string stmt][string user][string prm] */
ptr = or_unpack_int (area, &item->item_type);
ptr = or_unpack_string (ptr, &item->class_name);
ptr = or_unpack_string (ptr, &str_value); db_make_string (&item->key, str_value); item->key.need_clear = true;
ptr = or_unpack_string (ptr, &item->db_user); ptr = or_unpack_string (ptr, &item->ha_sys_prm); break;
default:
er_set (..., ER_GENERIC_ERROR, 0); goto error_return;

LOG_REPLICATION_DATA(Chapter 3)는 PK를 packed 상태로 유지하며, la_get_item_pk_value의 on-demand 요청 시에만 item->key로 unpack된다. item_type에는 rcvindex가 담긴다. LOG_REPLICATION_STATEMENT(Chapter 4)는 statement 텍스트를 item->key에 넣고(need_clear = true), db_user / ha_sys_prm도 채운다. default 분기와 모든 할당 실패는 error_return으로 점프한다(area와 반쯤 만들어진 아이템을 해제하고 NULL 반환). 성공 시 item->log_type을 stamping하고, area를 해제한 뒤 아이템을 반환한다. la_add_repl_item은 단순한 tail append이며 apply->num_items를 증가시킨다.

8.6 트랜잭션별 버킷: 조회, 추가, on-demand 재할당

섹션 제목: “8.6 트랜잭션별 버킷: 조회, 추가, on-demand 재할당”

la_Info.repl_lists[]LA_APPLY *의 평탄한 배열이다. 재활용되지 않은 슬롯은 트랜잭션의 tranid와 이중 연결 LA_ITEM 체인(head에서 tail까지)을 보유하며, 재활용된 슬롯은 tranid == 0이다.

la_find_apply_list (tranid)는 일치하는 tranid를 찾기 위해 repl_lists[0 .. cur_repl)을 선형 스캔하며, 없으면 NULL을 반환한다. la_add_apply_list (tranid)는 찾기 또는 할당: (1) la_find_apply_list에 이미 있으면 반환; (2) 없으면 첫 번째 재활용 슬롯(tranid == 0, 완료된 트랜잭션이 남긴 것)을 재사용하여 tranid를 stamping; (3) cur_repl == repl_cnt(배열 가득 참)이면 la_init_repl_lists (true)로 확장(실패 시 NULL); (4) repl_lists[cur_repl]을 할당하고 cur_repl을 증가시킨 뒤 반환한다.

la_init_repl_lists (need_realloc)은 배열 스토리지를 소유한다.

// la_init_repl_lists -- src/transaction/log_applier.c (condensed)
if (need_realloc == false) { /* first call: malloc LA_REPL_LIST_COUNT (50) pointers; repl_cnt = 50, cur_repl = 0, j = 0 */
...
} else { /* grow: realloc to repl_cnt + 50; j = old repl_cnt marks first NEW slot, then repl_cnt += 50 */
...
}
for (i = j; i < la_Info.repl_cnt; i++) { /* init ONLY [j, repl_cnt); each malloc OOM -> free + ER_OUT_OF_VIRTUAL_MEMORY */
la_Info.repl_lists[i] = malloc (DB_SIZEOF (LA_APPLY));
...->tranid = 0; num_items = 0; is_long_trans = false; start_lsa = last_lsa = NULL; head = tail = NULL;
}

루프를 j = old_repl_cnt에서 시작하는 것은 realloc이 새로 추가된 슬롯만 초기화한다는 의미다 — 기존 버킷과 아이템 체인은 그대로 유지된다.

INVARIANT — cur_repl <= repl_cnt이며, [0, cur_repl) 슬롯만이 유효한 버킷이다. la_add_apply_listcur_repl < repl_cnt를 먼저 보장하지 않고서는 repl_lists[cur_repl]을 절대 할당하지 않으며(확장 분기는 정확히 둘이 같을 때 동작한다), 배열을 늘리기 전에 재활용 tranid == 0 슬롯을 우선 재사용한다. 이를 위반하면 배열 밖을 인덱싱하여 힙 손상이 발생한다.

8.7 커밋 큐: la_add_node_into_la_commit_listla_retrieve_eot_time

섹션 제목: “8.7 커밋 큐: la_add_node_into_la_commit_list와 la_retrieve_eot_time”

la_add_node_into_la_commit_list (tranid, lsa, type, eot_time)LA_COMMIT을 malloc하고, type(LOG_COMMIT / LOG_SYSOP_END / LOG_ABORT), log_record_time = eot_time, log_lsa(EOT 마커에서), tranid를 채운 뒤 la_Info.commit_tail의 FIFO에 추가한다(비어있으면 head+tail을 초기화; OOM → ER_OUT_OF_VIRTUAL_MEMORY). repl_lists[]가 아닌 이 큐가 재생 순서를 확정한다. Chapter 9의 la_apply_commit_list는 head부터 순서대로 걷기 때문에, DML 레코드들이 로그에서 아무리 뒤섞여 있어도 트랜잭션들은 마스터 커밋 순서 그대로 슬레이브에 적용된다.

la_retrieve_eot_time (pgptr, lsa)는 벽시계 커밋 시각을 읽는다: 레코드 헤더를 지나 위치를 잡고, LOG_REC_DONETIME이 맞게 들어오도록 정렬/전진한 뒤, 읽기 실패 시 0(“시각 없음” sentinel — 커밋 분기가 la_delay_replica를 건너뛰는 데 사용)을 반환하거나 성공 시 donetime->at_time을 반환한다. la_retrieve_eot_timeLOG_COMMIT에서만 호출된다. LOG_SYSOP_END는 EOT 타임스탬프가 없으므로 0을 하드코딩한다.

  1. la_log_record_process는 모든 레코드를 두 개의 가드로 검사한다 — EOF sentinel을 제외하고 applier를 종료시키는 손상/forward 포인터 검사, 그리고 첫 번째 레코드(prev_tranlsa == NULL)를 감지해 버킷을 할당하고 start_lsa를 stamp하는 최초 인식 등록.
  2. 디스패치 switch에는 도달 가능한 분기가 여덟 개 있다. 그 중 세 개(LOG_END_OF_LOG, LOG_DUMMY_CRASH_RECOVERY, LOG_DUMMY_HA_SERVER_STATE의 역할 변경 케이스)는 실패가 아닌 페이지 워크 중단을 위해 ER_INTERRUPTED를 반환한다. 레코드는 LOG_COMMIT / LOG_SYSOP_END 분기가 la_apply_commit_list를 동작시킬 때까지 버퍼링된다. LOG_ABORT는 아이템 없이 순서 보존용 플레이스홀더를 큐에 추가한다.
  3. la_set_repl_log는 롱 트랜잭션 상한을 강제한다: LA_MAX_REPL_ITEMS(1000)에 도달하면 버킷은 head를 제외한 모든 것을 버리고, is_long_trans를 설정한 뒤 이후에는 last_lsa만 추적한다 — 나중에 로그를 재스캔하는 비용으로 메모리를 제한한다.
  4. la_make_repl_itemLOG_REPLICATION_DATA(lazy packed-key + rcvindex)와 LOG_REPLICATION_STATEMENT(statement + db_user + ha_sys_prm)를 다르게 unpack하며, 반쯤 만들어진 아이템을 해제하는 공유 error_return을 사용한다. repl_lists[]는 재활용 우선, 50개씩 확장하는 배열이다. la_add_apply_listla_init_repl_lists (true)로 realloc하기 전에 tranid == 0 슬롯을 재사용하여 cur_repl <= repl_cnt를 유지한다.
  5. la_add_node_into_la_commit_list가 생성하는 LA_COMMIT FIFO — 실제 커밋은 la_retrieve_eot_time으로 타임스탬핑되고, sysop은 0 — 은 슬레이브에서의 재생 순서를 마스터 커밋 순서에 고정한다.

Chapter 9: 커밋된 트랜잭션 적용과 행 이미지 재구성

섹션 제목: “Chapter 9: 커밋된 트랜잭션 적용과 행 이미지 재구성”

8장에서는 트랜잭션 단위 그림을 살펴봤다. 각 trid는 하나의 LA_APPLY를 소유하며, head .. tail 체인에 버퍼링된 LA_ITEM이 연결되어 있고, la_Info.commit_head .. commit_tail에는 LOG_COMMIT/LOG_ABORT/LOG_SYSOP_END마다 큐에 쌓인 LA_COMMIT 레코드가 대기 중이다. 이 장은 적용(apply) 생애주기의 마지막 단계를 다룬다. 메인 루프가 커밋 레코드를 꺼낼 때, 버퍼링된 이벤트가 어떤 순서로 재실행되는지, 마스터의 힙 로그에서 after-image가 어떻게 재구성되는지, 그리고 행이 어떻게 슬레이브 서버에 도달하는지를 추적한다. “pull → 커밋 전까지 버퍼 → 재실행” 이론은 동반 문서(cubrid-ha-replication.md §“슬레이브의 커밋 전 트랜잭션 단위 버퍼링”과 §“행 이미지 재구성 — 경우 분석”)에 있으며, 이 장에서는 각 분기를 추적한다.

9.1 la_apply_commit_list — 호출당 커밋 레코드 하나

섹션 제목: “9.1 la_apply_commit_list — 호출당 커밋 레코드 하나”

메인 루프는 커밋을 하나 지나칠 때마다 이 함수를 호출한다. 큐의 맨 앞에 있는 커밋(la_Info.commit_headtypeLOG_COMMIT/LOG_SYSOP_END/LOG_ABORT인 것) 딱 하나를 처리하고, la_apply_repl_log를 호출한 뒤 꺼낸 log_lsa를 호출자의 *lsaLSA_COPY하며, 노드를 연결 해제하고 free_and_init한다(Figure 9-1 참고).

commit_head == NULL이거나 head가 커밋 계열 레코드가 아니면 본문은 건너뛰고 *lsaNULL 상태로 남는다. 적용 성공·실패 모두 노드를 해제한다 — 함수가 적용 오류 시 중단하지 않고 오류를 상위로 전파하여 호출자가 재연결 여부를 판단하게 한다. log_record_time(딜레이 리포트가 now()에서 빼는 마스터의 커밋 타임스탬프)은 LOG_COMMIT일 때만 복사된다.

불변 조건: 커밋 큐는 엄격한 FIFO 순서로, 호출당 정확히 하나의 노드만 꺼낸다. commit_head는 정확히 한 노드 앞으로 이동하고 head만 해제된다. 미래에 누군가 여러 커밋을 한 번에 루프 처리하도록 바꾸면, 호출당 *lsa 반환값(10장의 호출자 내구성 북마크)이 더 이상 단일한 안전 재시작 지점을 가리키지 않게 된다.

flowchart TB
  A["la_apply_commit_list(lsa)"] --> C{"commit_head != NULL\n그리고 type이 COMMIT/SYSOP_END/ABORT?"}
  C -- 아니오 --> Z["NO_ERROR 반환, lsa는 NULL 유지"]
  C -- 예 --> D["la_apply_repl_log(tranid, type, log_lsa)"]
  D --> E["LSA_COPY(lsa, commit.log_lsa)"]
  E --> F{"type == LOG_COMMIT?"}
  F -- 예 --> G["log_record_time = commit.log_record_time"]
  F -- 아니오 --> H["커밋 노드 연결 해제 + 해제"]
  G --> H
  H --> I["오류 반환"]

Figure 9-1 — la_apply_commit_list: 호출당 커밋 노드 하나 소비.

9.2 la_apply_repl_log — 하나의 트랜잭션 아이템 체인 소비

섹션 제목: “9.2 la_apply_repl_log — 하나의 트랜잭션 아이템 체인 소비”

트랜잭션 단위 팬아웃은 trid에 해당하는 LA_APPLY를 조회한 뒤 apply->head를 순회하며 각 아이템을 재실행한다.

루프 진입 전 분기 (la_lock_dbname과 루프 이전): la_find_apply_list(tranid) == NULL이면 NO_ERROR 반환(버퍼링된 적 없음); rectype == LOG_ABORTla_clear_applied_info를 호출하고 반환한다. 이미 적용 완료된 경우 가드 apply->head == NULL || LSA_LE(commit_lsa, &la_Info.last_committed_lsa) — 버퍼가 비어 있거나 commit_lsa <= 10장의 재시작 북마크인 경우 — 는 이전 실행이 처리했음을 의미한다. 이때 LOG_SYSOP_ENDla_free_all_repl_items(아이템만 해제; 긴 트랜잭션의 나머지를 위해 리스트 노드는 재사용)를, 다른 타입은 전체 리셋을 위해 la_clear_applied_info를 호출한다. 그 외에는 슬레이브 DB 이름 락을 잡고 루프에 진입한다.

재실행 루프 — 아이템별 디스패치:

// la_apply_repl_log -- src/transaction/log_applier.c (loop, condensed)
while (item) {
/* ... periodic la_release_all_page_buffers every LA_MAX_REPL_ITEM_WITHOUT_RELEASE_PB ... */
if (LSA_GT (&item->lsa, &la_Info.last_committed_rep_lsa)
&& la_need_filter_out (item) == false) { /* <- skip already-done + filtered */
if (item->log_type == LOG_REPLICATION_DATA)
switch (item->item_type) {
case RVREPL_DATA_UPDATE_START: case RVREPL_DATA_UPDATE_END:
case RVREPL_DATA_UPDATE: error = la_apply_update_log (item); break;
case RVREPL_DATA_INSERT: error = la_apply_insert_log (item); break;
case RVREPL_DATA_DELETE: error = la_apply_delete_log (item); break;
default: assert_release (false); } /* <- corruption */
else if (item->log_type == LOG_REPLICATION_STATEMENT) error = la_apply_statement_log (item);
else assert_release (false);
if (error == NO_ERROR) LSA_COPY (&la_Info.committed_rep_lsa, &item->lsa); /* advance bookmark */
else { /* error sub-branches -- below */ }
}
next_item = la_get_next_repl_item (item, apply->is_long_trans, &apply->last_lsa);
la_free_repl_item (apply, item); item = next_item;
if (item != NULL && LSA_GT (&item->lsa, commit_lsa))
{ assert (rectype == LOG_SYSOP_END); has_more_commit_items = true; break; }
}

log_type데이터 vs. 구문 경로를 선택하고, 데이터의 경우 item_type이 동사를 선택한다. RVREPL_DATA_UPDATE_START_END 모두 la_apply_update_log로 흘러간다 — START/END 브라켓은 마스터에서만 의미가 있었다(4장). 그 외 데이터 item_type이나 알 수 없는 log_typeassert_release(false)를 발생시킨다.

불변 조건: committed_rep_lsa성공적인 아이템 적용에서만, 오직 앞 방향으로만 전진한다. error == NO_ERROR 이후에만 item->lsaLSA_COPY하며, 건너뛰기 가드 LSA_GT(&item->lsa, &last_committed_rep_lsa)는 재시작 기준선을 읽어 재실행 시 이미 처리된 아이템을 중복 적용하지 않도록 한다. 이를 어기면 배치 도중 크래시 발생 시 행이 누락되거나 이중 적용된다.

오류 하위 분기 (error != NO_ERROR일 때의 else):

  • 플러시 오류 (LA_IS_FLUSH_ERROR(error)): goto end — 호출자가 재연결 후 해당 트랜잭션 전체를 다시 시도한다.
  • 서버 다운 (ER_IS_SERVER_DOWN_ERROR(er_errid())): ER_NET_CANT_CONNECT_SERVER로 변환 후 goto end.
  • 재시도 가능, 무시 불가 (la_ignore_on_error가 false이고 la_retry_on_error가 true): er_set으로 ER_HA_GENERIC_ERROR 알림을 설정하고 LA_SLEEP(10, 0)continue — 아이템을 전진하거나 해제하지 않고 while로 돌아가 슬립 후 동일 아이템을 재시도한다(LA_RETRY_ON_ERROR 루프).
  • 무시 가능 (else): 진행되며 아이템 해제, 루프 전진 — 실패가 삼켜진다.

루프 전진 / 긴 트랜잭션 break. la_get_next_repl_itemapply->is_long_trans 여부에 따라 다르게 동작한다: 일반 트랜잭션은 item->next를 반환(la_get_next_repl_item_from_list); is_long_trans일 때만 apply->last_lsa로 로그를 다시 읽는다(la_get_next_repl_item_from_log). 다음 아이템의 LSA가 commit_lsa를 초과하면 LOG_SYSOP_END의 부분 커밋임을 의미하므로(assert 검증), 루프를 has_more_commit_items = true로 설정한 뒤 break한다. end 이후: LOG_SYSOP_END의 경우 commit_lsa 이후에 남은 아이템은 la_free_and_add_next_repl_item으로 재연결되며(그 외 타입은 la_free_all_repl_items로 리스트를 비우되 노드는 유지); 일반 LOG_COMMITla_clear_applied_info로 전체 리셋한다.

9.3 세 가지 데이터 DML 적용자 — 공통 구조

섹션 제목: “9.3 세 가지 데이터 DML 적용자 — 공통 구조”

la_apply_insert_log, la_apply_update_log, la_apply_delete_log는 모두 la_flush_repl_items(false)로 시작하고 동일한 두 갈래 꼬리로 끝난다: 오류 → la_log_apply_error(fail_counter 증가), 성공 → 동사별 카운터와 num_unflushed 증가. 그 사이에서 두 가지 실질적인 차이가 있다(Figure 9-2): insert와 update는 로그 페이지를 고정하고 after-image를 재구성하지만 delete는 그렇지 않으며, 두 이미지 재구성자는 서로 다른 시점에 클래스를 결정한다.

flowchart TB
  B["la_flush_repl_items(false)"] --> P["la_get_page(item->target_lsa.pageid)"]
  P -- NULL --> XR["er_errid() 반환"]
  P -- ok --> CIN{"insert: db_find_class 먼저\nupdate: 가드 이후로 클래스 지연"}
  CIN --> E["la_get_recdes(&target_lsa, pgptr, recdes, &rcvindex, is_mvcc_class)"]
  E --> F{"recdes.type이 ASSIGN_ADDRESS\n또는 RELOCATION?"}
  F -- 예 --> X["ER_FAILED -> end"]
  F -- 아니오 --> G{"rcvindex가 허용 집합에 있는가?"}
  G -- 아니오 --> X
  G -- 예 --> J["la_repl_add_object(class_obj, item, recdes)"]
  J --> K["카운터++ ; num_unflushed++ ; la_release_page_buffer"]

Figure 9-2 — insert/update 공통 적용 흐름. delete는 P-G 단계와 page release를 건너뛴다.

동사별 클래스 결정 순서가 다르다. la_apply_insert_logla_get_recdes 이전에 db_find_class를 호출한다. is_mvcc_class = la_is_mvcc_class(ws_oid(class_obj))를 구해서 전달해야 하기 때문이다(이 플래그가 §9.5의 loaddb 업컨버전을 제어한다). la_apply_update_log는 반대: is_mvcc_class = false를 하드코딩하여 la_get_recdes를 먼저 호출하고, 가드를 거친 뒤 db_find_class를 호출한다 — update redo 로그가 이미 완성된 이미지를 담고 있기 때문에 이 순서가 맞다. 따라서 클래스 없음 오류는 insert에서는 일찍 종료되지만 update에서는 가드 이후에 종료된다.

타입 가드도 다르다. 둘 다 먼저 recdes->type == REC_ASSIGN_ADDRESS || REC_RELOCATION(la_get_recdes를 빠져나온 간접 주소)을 ER_FAILED로 거부한다. 이후 insert는 RVHF_INSERT/RVHF_MVCC_INSERT만 허용하고, update는 RVHF_UPDATE, RVOVF_CHANGE_LINK, RVHF_MVCC_INSERT, RVHF_UPDATE_NOTIFY_VACUUM, RVHF_INSERT_NEWHOME을 허용한다.

페이지 고정 생애주기는 insert/update에만 있다. 두 함수 모두 la_get_page(item->target_lsa.pageid)로 힙 redo 로그 페이지를 pin한다 — NULL 반환은 end 레이블 이전에 즉시 return er_errid()로 나간다(카운터 없음, release 없음) — 그리고 종료 시 la_release_page_buffer(old_pageid)를 호출한다.

delete는 기본 키(pkey)만 사용하는 경로다. la_apply_delete_log는 로그 페이지나 la_get_recdes를 건드리지 않는다 — item->packed_key_value에 있는 키만 필요하여 la_repl_add_object(class_obj, item, NULL)을 호출한다; 오류 시 la_log_apply_error(..., ER_HA_LA_FAILED_TO_APPLY_DELETE), 성공 시 delete_counter/num_unflushed를 증가시킨다.

// la_apply_insert_log -- src/transaction/log_applier.c (condensed)
pgptr = la_get_page (item->target_lsa.pageid);
if (pgptr == NULL) return er_errid (); /* page-fix failure: before end label */
class_obj = db_find_class (item->class_name); /* insert: class resolved FIRST */
recdes = la_assign_recdes_from_pool ();
is_mvcc_class = la_is_mvcc_class (ws_oid (class_obj));
error = la_get_recdes (&item->target_lsa, pgptr, recdes, &rcvindex, la_Info.rec_type, is_mvcc_class);
// ... guards on recdes->type and rcvindex, then ...
error = la_repl_add_object (class_obj, item, recdes);

9.4 la_get_recdes — 다섯 가지 경우의 after-image 재구성자

섹션 제목: “9.4 la_get_recdes — 다섯 가지 경우의 after-image 재구성자”

힙 redo 레코드의 target LSA를 받아 la_get_recdes는 온디스크 RECDES를 생성한다. is_mvcc_class 입력(§9.3에서 전달, update는 false)은 §9.5 MVCC 조정을 끝 부분에서만 제어한다. 기본 호출은 항상 la_get_log_data(§9.6)이며, recdes->data/length를 채우고 recdes->type = *(INT16 *) rec_type으로 설정한다. 이후 if/else if 분기가 아래 네 가지 간접 케이스 중 정확히 하나를 실행한다(다섯 번째는 암묵적인 “해당 없음” 케이스).

케이스조건의미헬퍼
1RVOVF_CHANGE_LINK오버플로 레코드 업데이트; redo는 링크만 변경함. 재이어붙임, REC_BIGONE 강제.la_get_overflow_recdes(RVOVF_PAGE_UPDATE)
2type == REC_BIGONE신규 오버플로 insert: home 슬롯이 오버플로 페이지 체인을 가리킴.la_get_overflow_recdes(RVOVF_NEWPAGE_INSERT)
3RVHF_INSERT + REC_ASSIGN_ADDRESS지연 insert: 먼저 슬롯 예약, 실제 이미지는 같은 trid의 이후 UPDATE에 있음.la_get_next_update_log (BIGONE이면 이후 케이스 2)
4RVHF_UPDATE/_NOTIFY_VACUUM + REC_RELOCATIONhome 슬롯이 전달; 실제 이미지는 재배치된 RVHF_INSERT_NEWHOME에 있음.la_get_relocation_recdes

각 분기는 헬퍼의 rec_type에서 recdes->type을 다시 읽는다; 케이스 3은 추적한 실제 행이 REC_BIGONE이면 케이스 2로 다시 진입한다. 해당하는 것이 없으면(일반 REC_HOME 경로) 기본 이미지가 이미 완성된 상태 — 암묵적인 다섯 번째 케이스. 이후 §9.5 MVCC 조정이 실행된다.

9.5 슬레이브에서의 MVCC 헤더 주입

섹션 제목: “9.5 슬레이브에서의 MVCC 헤더 주입”

마스터 힙 레코드에는 슬롯 상주 MVCC insert id가 없을 수 있다; 슬레이브는 자신의 서버가 기대하는 레이아웃으로 재구성한다. RVHF_MVCC_INSERT에서는 la_make_room_for_mvcc_insid(recdes)를 바로 호출한다. 두 번째 분기(is_mvcc_class && *rcvindex == RVHF_INSERT && type != REC_BIGONE)는 CS 모드 마스터에서의 loaddb 벌크 insert를 위한 것으로, 이 경우 행은 MVCC 플래그 없이 일반 RVHF_INSERT로 로깅된다. 이 분기는 VALID_INSID를 설정하고 la_make_room_for_mvcc_insid를 호출하며, recdes->length > la_Info.maxslotted_reclength(레코드가 REC_BIGONE으로 오버플로될 것)이면 VALID_DELID|VALID_PREV_VERSION도 설정하고 la_make_room_for_mvcc_delid_and_prev_ver를 호출한다. 두 헬퍼 모두 플래그 전제조건을 assert하고, LA_MOVE_INSIDE_RECORD로 본문을 오른쪽으로 시프트한다; LA_MOVE_INSIDE_RECORDarea_size >= length + delta를 assert하므로 버퍼가 작으면 메모리를 오염시키는 대신 assert가 발생한다.

9.6 la_get_log_data와 압축 해제 경로

섹션 제목: “9.6 la_get_log_data와 압축 해제 경로”

la_get_log_datalrec->type을 분기하여 힙 로그 레코드 하나의 데이터 영역을 읽는다:

  • LOG_UNDOREDO_DATA / LOG_DIFF_UNDOREDO_DATA / MVCC 변형MVCC_UNDOREDO vs UNDOREDO 레이아웃을 선택한다. match_rcvindex == 0 또는 레코드의 rcvindex가 일치하면 *rcvindex/*logs를 공개; 그렇지 않으면 *logs = NULL(“원하는 레코드가 아님”). DIFF 타입은 undo 이미지를 위해 la_get_undoredo_diff를 호출하고, 나머지는 그 앞을 정렬 이동한다.
  • LOG_UNDO_DATA/LOG_MVCC_UNDO_DATALOG_REDO_DATA/LOG_MVCC_REDO_DATALOG_REC_UNDO/LOG_REC_REDO 위에서 동일한 일치/공개 처리.
  • default*logs = NULL, 데이터 없음.

ZIP_CHECK(temp_length)이면 redo는 log_unzip되고 la_get_zipped_data가 최종 이미지를 생성한다 — DIFF의 경우 undo와 압축 해제된 redo를 log_diff(XOR)하고, 그 외에는 단순 복사 — 요청 시 앞의 INT16 rec_type을 제거하고 is_overflow이면 할당한다. 그 외에는 원시 페이로드를 직접 복사한다.

**la_get_overflow_recdes**는 prev_tranlsa를 역방향으로 순회하며 해당 trid의 모든 LOG_REDO 타입 레코드를 앞에 붙이고(head 끝이 먼저), trid 변경 또는 LOG_DUMMY_OVF_RECORD에서 멈춘 뒤, LA_OVF_FIRST_PART/LA_OVF_REST_PARTS 헤더를 제거하며 부분들을 연결한다. malloc 실패 시 부분 리스트를 해제하고 ER_OUT_OF_VIRTUAL_MEMORY를 반환한다.

la_get_next_update_log(지연 insert 추적자)는 prev_lrec->forw_lsa에서 순방향으로 스캔하며, 예약된 슬롯의 data.{pageid,offset,volid}와 일치하고 rcvindexRVHF_UPDATE/_NOTIFY_VACUUM인 UPDATE를 찾는다. 실패 모드: 해당 trid의 LOG_COMMIT/LOG_ABORT를 먼저 만나면 ER_GENERIC_ERROR를 반환한다 — 예상된 update가 끝내 오지 않은 것이다.

**la_get_relocation_recdes**는 prev_tranlsa를 한 홉 역방향으로 따라가 RVHF_INSERT_NEWHOME 레코드를 찾는다(match_rcvindex == RVHF_INSERT_NEWHOME으로 la_get_log_data를 호출); prev LSA가 null이거나 trid가 불일치하면 ER_LOG_PAGE_CORRUPTED를 발생시킨다.

9.8 la_disk_to_objla_repl_add_object — 슬레이브 서버로

섹션 제목: “9.8 la_disk_to_obj와 la_repl_add_object — 슬레이브 서버로”

la_disk_to_obj(SQL 로깅 경로 전용, 메인 플러시에서는 아님)는 RECDESDB_OTMPL로 변환하며, MVCC 헤더를 파싱하여 건너뛸 크기를 결정한다: mvcc_flags == 0이면 chn만 지나치고; 그 외에는 INSID/DELID(OR_MVCCID_SIZE), chn, 플래그가 있으면 PREV_VERSION LOG_LSA를 지나친 뒤 la_get_current로 컬럼을 읽는다. buf->ptr > buf->endptr 오버런은 ER_TF_BUFFER_OVERFLOW를 반환한다.

la_repl_add_object는 슬레이브 서버로 향하는 브릿지다. au_fetch_class 또는 sm_flush_objects가 실패하면 일찍 반환하고, delete의 경우 sm_partitioned_class_type 건너뜀 이후, item_type을 switch에서 연산으로 매핑한다 — UPDATE_*LC_UPDATE_OPERATION_TYPE, INSERTLC_INSERT_OPERATION_TYPE, DELETELC_FLUSH_DELETE, default: assert(false) — 최종적으로 __gv_loc_repl.ws_add_to_repl_obj_list(class_oid, item->packed_key_value, ..., recdes, operation, has_index)로 객체를 큐에 넣는다. 네트워크 플러시는 이후 la_flush_repl_items에서 발생한다.

9.9 la_need_filter_out — 아이템별 테이블 필터링

섹션 제목: “9.9 la_need_filter_out — 아이템별 테이블 필터링”

데이터 아이템 처리 전에 la_apply_repl_log가 이 함수를 호출한다. item->class_name에서 [...] 인용 부호를 제거한 뒤, 다음 경우에는 false(필터링 안 함)를 반환한다: filter->type == REPL_FILTER_NONE이거나, 대부분의 구문 복제(log_type == LOG_REPLICATION_STATEMENT && item_type != CUBRID_STMT_TRUNCATE — truncate, 즉 테이블 전체 삭제만 필터링 가능), 또는 시리얼 시스템 클래스(CT_SERIAL_NAME, 시퀀스 상태 일관성을 위해 항상 복제해야 함). 그 외에는 filter->list에서 클래스를 스캔하여 REPL_FILTER_INCLUDE_TBL && !filter_found 또는 REPL_FILTER_EXCLUDE_TBL && filter_found이면 true를 반환한다.

9.10 la_apply_statement_log — 구문 재실행과 결정성

섹션 제목: “9.10 la_apply_statement_log — 구문 재실행과 결정성”

이 경로는 행 이미지가 아닌 SQL 텍스트를 재실행한다: 대기 중인 행 객체를 강제로 플러시하고(la_flush_repl_items(true)), 필요하면 item->db_user(저장 프로시저와 사용자 DDL은 need_set_user = false로 건너뜀)와 item->ha_sys_prm을 호출 전후로 설정하며, res = la_update_query_execute(stmt_text, false)를 실행하고, 영향받은 행 수를 분류한다(DDL → schema_counter; INSERT/DELETE/UPDATE → 해당 카운터). TRUNCATE는 필터링된 경우 NO_ERROR로 단락 처리; CUBRID_STMT_DROP_LABEL/defaultNO_ERROR 반환(미지원, 무시).

비정상 경로 분기:

  • 사용자 없음need_set_user이고 db_user가 설정된 경우, au_find_user(item->db_user) == NULL이면 서버 다운 er_errid()ER_NET_CANT_CONNECT_SERVER로 변환되고, 그 외에는 error = er_errid()(0이 아님을 assert); 어느 경우든 switch에서 break한다.
  • 쿼리 실행 실패res < 0이면 error = er_errid()이며, ER_IS_SERVER_DOWN_ERROR(error)는 다시 ER_NET_CANT_CONNECT_SERVER로 변환된다.
  • 최종 실패 처리 — switch 이후 error != NO_ERROR이면 er_set으로 ER_HA_LA_FAILED_TO_APPLY_STATEMENT(클래스, 구문, 오류, 메시지)를 설정하고 반환 전에 la_Info.fail_counter++를 증가시킨다.

슬레이브에서 SQL을 실행하기 때문에, 구문 기반 DML은 비결정적 함수의 영향을 그대로 받는다(NOW(), RAND(), …) — CUBRID는 생성 시점에 이런 함수를 재작성하거나 차단하지 않는다. 이것이 동반 문서의 미결 질문에서 언급한 다이버전스 위험이다(cubrid-ha-replication.md §“Open Questions” 항목 8). 행 기반 복제(§9.3–9.8)는 레시피가 아닌 구체화된 after-image를 전송함으로써 이 문제를 피한다.

  1. la_apply_commit_list는 호출당 커밋 하나를 소비하고 그 LSA를 내구성 북마크 후보로 반환한다; log_record_time(딜레이 클록)은 LOG_COMMIT에서만 갱신된다.
  2. la_apply_repl_log는 아이템 체인을 LSA 순서대로 재실행하며, log_typeitem_type에 따라 디스패치한다(UPDATE_START/_END 모두 update로 라우팅); la_get_next_repl_item은 보통 item->next를 반환하고 is_long_trans일 때만 last_lsa로 로그를 다시 읽는다.
  3. committed_rep_lsa는 성공적인 적용 후에만, 앞 방향으로만 전진한다 — 건너뛰기 가드가 재실행을 멱등으로 만든다. 재시도 가능 오류는 LA_SLEEP(10,0)+continue로 전진 없이 처리되고; 플러시/서버 다운은 재연결을 위해 goto end; 무시 가능 오류는 삼켜진다.
  4. delete는 기본 키 전용 경로다(로그 페이지 없음, la_repl_add_object(..., NULL)); insert와 update는 힙 redo 로그 페이지를 pin하고(la_get_page / la_release_page_buffer) after-image를 재구성하며, 허용되는 rcvindex 집합과 클래스 결정 순서에서 차이가 있다 — insert는 la_get_recdes 이전에 클래스(와 la_is_mvcc_class)를 결정하고, update는 가드 이후에 결정한다.
  5. la_get_recdes는 다섯 가지 레코드 형태를 처리한다 — 일반 REC_HOME, RVOVF_CHANGE_LINK 오버플로 업데이트, REC_BIGONE 오버플로 insert, RVHF_INSERT+REC_ASSIGN_ADDRESS 지연 insert(la_get_next_update_log), RVHF_UPDATE*+REC_RELOCATION 재배치(la_get_relocation_recdes).
  6. 슬레이브는 MVCC 헤더를 재구성한다la_make_room_for_mvcc_insid / ..._delid_and_prev_ver로(특히 loaddb 행 업컨버전을 위해), la_get_log_data / la_get_undoredo_diff / la_get_zipped_data의 압축 해제 및 XOR diff 디코드 이후에 실행된다.
  7. la_repl_add_objectla_apply_statement_log 모두 하드 오류 종료 지점이 있다 — 전자는 au_fetch_class / sm_flush_objects 실패 시 일찍 반환하고 알 수 없는 동사에 assert를 발생시키며; 후자는 NO_ERROR가 아닌 모든 종료 시 fail_counter를 증가시키고 ER_HA_LA_FAILED_TO_APPLY_STATEMENTer_set하며, 서버 다운을 ER_NET_CANT_CONNECT_SERVER로 변환한다.

Chapter 10: 내구성 있는 적용 북마크와 재시작 복구

섹션 제목: “Chapter 10: 내구성 있는 적용 북마크와 재시작 복구”

applylogdb(7~9장)는 부팅 시 어디까지 적용을 마쳤는가를 반드시 알아야 한다. CUBRID는 이 북마크를 슬레이브 카탈로그 테이블 _db_ha_apply_info에 저장하고, 해당 배치의 행 적용과 동일한 슬레이브 트랜잭션 안에서 갱신한다 — 북마크 전진과 행 적용 커밋이 원자적으로 묶이는 것이다. 이 장에서는 구조체 미러(LA_HA_APPLY_INFO), 기록 함수(la_log_commit), 부팅 시 읽기(la_get_last_ha_applied_info), SQL 헬퍼, 하한선(la_find_required_lsa), 아카이브 정리(la_remove_archive_logs)를 다룬다. 내구성 있는 커서가 왜 필요한지는 companion 문서의 “위치 커서와 내구성 북마크를 이용한 전방향 로그 탐색”과 “복제 레코드와 커밋 레코드의 원자적 방출” 절에서 설명하므로 여기서는 다시 유도하지 않는다.

10.1 LA_HA_APPLY_INFO — 카탈로그 행의 인메모리 미러

섹션 제목: “10.1 LA_HA_APPLY_INFO — 카탈로그 행의 인메모리 미러”

LA_HA_APPLY_INFO(db_name, copied_log_path)를 키로 하는 _db_ha_apply_info 행 하나와 일대일로 대응하는 구조체다. 전달용 구조체로서: la_get_ha_apply_info가 SELECT로 채우고, 그 값들을 수명이 긴 전역 la_Info에 복사한 뒤, la_update_ha_last_applied_info로 카탈로그에 다시 기록된다.

// struct la_ha_apply_info -- src/transaction/log_applier.c
struct la_ha_apply_info
{
char db_name[256];
DB_DATETIME creation_time;
char copied_log_path[4096];
LOG_LSA committed_lsa; /* last committed commit log lsa */
LOG_LSA committed_rep_lsa; /* last committed replication log lsa */
LOG_LSA append_lsa; /* append lsa of active log header */
LOG_LSA eof_lsa; /* eof lsa of active log header */
LOG_LSA final_lsa; /* last processed log lsa */
LOG_LSA required_lsa; /* start lsa of first txn to be applied */
DB_DATETIME log_record_time;
DB_DATETIME log_commit_time;
DB_DATETIME last_access_time;
int status;
INT64 insert_counter;
INT64 update_counter;
INT64 delete_counter;
INT64 schema_counter;
INT64 commit_counter;
INT64 fail_counter;
DB_DATETIME start_time;
};
필드역할존재 이유
db_name행 키 1번; 마스터 DB 접두어.마스터별 행 구분.
creation_time마스터 log_hdr->db_creation 스냅샷.로그 재생성 방어용 부팅 가드(10.3).
copied_log_path행 키 2번; 슬레이브 log_path.두 copylog 스트림 구분.
committed_lsa가장 높이 커밋된 commit LSA.”얼마나 복제됐는가”; 재적용 하한.
committed_rep_lsa가장 높이 커밋된 REPL LSA.별도 스트림; REPL 필터 하한.
append_lsa기록 시점 마스터 헤더 append_lsa.지연 진단.
eof_lsa마스터 헤더 eof_lsa.지연 진단; 읽기 가능 경계.
final_lsa마지막으로 처리(읽기+디스패치)한 LSA.재시작 재개 커서; la_Info.final_lsa 시드.
required_lsa가장 오래된 열린 트랜잭션의 시작 LSA.아카이브 정리 하한(10.6~10.7).
log_record_time마지막으로 적용된 커밋 레코드 시각(마스터).복제 지연 지표.
log_commit_time슬레이브 커밋 벽시계 시각.지연 / 운영자 가시성.
last_access_time마지막 기록 시점의 SYS_DATETIME.활성 상태 heartbeat.
statusLA_STATUS_IDLE / _BUSY.읽기 시 강제 IDLE(10.2).
insert_counterfail_counter누적 행/실패 카운터.재시작 후에도 유지; 저장 값에서 재개.
start_time이 인스턴스가 (재)시작한 시각.”이번 부팅 이후”와 누적값 구분.

la_init_ha_apply_info는 구조체를 0으로 초기화한 뒤 LSA 필드 여섯 개에 NULL 마크를 찍는다:

// la_init_ha_apply_info -- src/transaction/log_applier.c
memset ((void *) ha_apply_info, 0, sizeof (LA_HA_APPLY_INFO));
LSA_SET_NULL (&ha_apply_info->committed_lsa);
/* ... committed_rep, append, eof, final ... */
LSA_SET_NULL (&ha_apply_info->required_lsa);

memset은 바이트를 0으로 채우지만, LSA_SET_NULLLSA_ISNULL이 검사하는 센티넬 (pageid = -1)을 기록한다 — 0인 LSA는 유효한 page-0 위치이지 “없음”이 아니다. 덕분에 la_get_last_ha_applied_info는 “컬럼이 SQL NULL이었다”와 “컬럼 값이 0이었다”를 구별할 수 있다.

불변 조건 — 이 구조체는 카탈로그 행 하나의 엄격한 미러다. 모든 필드는 _db_ha_apply_info 컬럼 하나에 대응하며, LSA는 _pageid/_offset 쌍으로 분할된다. la_get_ha_apply_infocol_cnt == LA_OUT_VALUE_COUNT (SELECT가 정확히 23개 컬럼을 반환했는지)와 디코딩 후 out_value_idx == LA_OUT_VALUE_COUNT를 모두 assert한다. 컬럼이 늘어난 스키마는 이 가드가 잡아낸다. INSERT 미러인 la_insert_ha_apply_infoin_value_idx == LA_IN_VALUE_COUNT(15개 바인드 파라미터; 나머지 11개 컬럼은 SQL 리터럴)를 assert한다. 이 카운트를 올리지 않고 컬럼을 추가하면 assert_release가 발화한다 — 스키마와 구조체는 조용히 벌어질 수 없다.

flowchart LR
  cat["_db_ha_apply_info 행\n(LSA 컬럼은 pageid/offset 분리)"]
  mem["LA_HA_APPLY_INFO\n(임시)"]
  glob["la_Info\n(상주)"]
  cat -->|"la_get_ha_apply_info SELECT"| mem
  mem -->|"la_get_last_ha_applied_info 복사"| glob
  glob -->|"la_update_ha_last_applied_info UPDATE"| cat

Figure 10-1. 카탈로그 행, 임시 구조체 이미지, 상주 전역 — 그리고 세 함수가 데이터를 이동하는 방향.

(prefix_name, log_path)를 키로 CT_HA_APPLY_INFO_NAME을 파라미터화된 SELECT로 조회하고, 영향받은 튜플 수res로 반환한다(>0 발견, 0 없음, <0 오류). 분기 흐름:

  1. db_find_class (CT_HA_APPLY_INFO_NAME) == NULL → 카탈로그 클래스 없음; assert (er_errid () != NO_ERROR), 오류 id 반환(“DB가 아직 HA 준비 안 됨” 경로).
  2. db_execute_with_values (...)res. res > 0일 때만 튜플을 디코딩; 그 외엔 정리 후 res 반환(0 = 행 없음, 호출자의 INSERT 경로).
  3. db_query_first_tuple switch — DB_CURSOR_SUCCESS이면 out-value 23개 전체 디코딩 (LSA 6개 컬럼은 양쪽 half 모두 DB_IS_NULL 검사, NULL이면 LSA_SET_NULL + out_value_idx += 2; append_lsa/eof_lsa는 무조건 읽음; status는 강제로 LA_STATUS_IDLE). END / ERROR / defaultres = ER_FAILED.
  4. 정리: db_query_end + 모든 in-value를 db_value_clear, 항상 도달; out-value는 SUCCESS 분기에서만 정리.

NULL-허용 디코딩은 la_init_ha_apply_info를 반영한다: 합법적으로 NULL인 컬럼 (예: 첫 커밋 전 committed_lsa)은 엉뚱한 page-0 위치가 아닌 LSA_ISNULL로 디코딩된다.

10.3 부팅 시 커서 초기화 — la_get_last_ha_applied_info

섹션 제목: “10.3 부팅 시 커서 초기화 — la_get_last_ha_applied_info”

재시작 복구 진입점으로, applylogdb 시작(7장) 시 한 번 실행되며 재개 LSA를 결정한다:

// la_get_last_ha_applied_info -- src/transaction/log_applier.c
log_db_creation = act_log->log_hdr->db_creation;
db_localdatetime (&log_db_creation, &log_db_creation_time);
res = la_get_ha_apply_info (la_Info.log_path, act_log->log_hdr->prefix_name, &apply_info);
if (res > 0)
{
LSA_COPY (&la_Info.committed_lsa, &apply_info.committed_lsa);
/* ... committed_rep, append, eof, final, required, counters ... */
if ((log_db_creation_time.date != apply_info.creation_time.date)
|| (log_db_creation_time.time != apply_info.creation_time.time))
return ER_FAILED; /* <- 저장된 행이 다른 DB 인카네이션 */
if (LSA_ISNULL (&la_Info.required_lsa))
{ er_set (...ER_HA_GENERIC_ERROR...); return ER_FAILED; } /* <- 비정상 행 */
}
else if (res == 0)
insert_apply_info = true; /* <- 최초 부팅: 아직 행 없음 */
else
return res; /* <- SELECT 오류, 전파 */

최상위 분기는 세 가지 — 행 발견, 행 없음(INSERT 플래그), 오류(반환) — 그리고 발견 분기 내부의 두 가지 하드 실패: creation-time 불일치 (복사된 로그가 다른 DB 인카네이션으로 교체됨)와 NULL required_lsa (기존 행은 반드시 이 값을 가져야 함). 이어서 네 개의 LSA_ISNULL 픽스업이 의존 순서에 따라 fresh-row 하한을 확립한다:

if (LSA_ISNULL (&la_Info.required_lsa))
LSA_COPY (&la_Info.required_lsa, &act_log->log_hdr->eof_lsa); /* anchor at master EOF */
if (LSA_ISNULL (&la_Info.committed_lsa))
LSA_COPY (&la_Info.committed_lsa, &la_Info.required_lsa);
/* same for committed_rep_lsa */
if (LSA_ISNULL (&la_Info.final_lsa))
LSA_COPY (&la_Info.final_lsa, &la_Info.required_lsa); /* <- cursor seed */

완전히 새 applier라면 네 값 모두 마스터의 현재 eof_lsa로 수렴한다(복제가 “지금”부터 시작됨). 재개 시에는 저장된 final_lsa가 non-NULL이므로 그대로 유지된다. 마지막으로 행을 영속화하고 시드를 고정한다:

if (insert_apply_info == true)
res = la_insert_ha_apply_info (&log_db_creation_time);
else
res = la_update_ha_apply_info_start_time ();
if (res == 0) return ER_FAILED; /* <- 0 affected rows == 실패 */
else if (res < 0) return res;
(void) db_commit_transaction ();
LSA_COPY (&la_Info.last_committed_lsa, &la_Info.committed_lsa);
LSA_COPY (&la_Info.last_committed_rep_lsa, &la_Info.committed_rep_lsa);

불변 조건 — last_committed_lsa는 역진을 막는 고수위 하한이다. 여기서 저장된 committed_lsa로 시드되며, la_update_ha_last_applied_info가 기록 전에 LSA_GE로 비교하는 값이다(10.4). 이것이 없으면 재시작 시 이전에 내구성을 확보한 것보다 낮은 committed LSA를 영속화해 마크를 조용히 되감을 수 있다.

10.3a 최초 부팅 INSERT — la_insert_ha_apply_info 분기별 분석

섹션 제목: “10.3a 최초 부팅 INSERT — la_insert_ha_apply_info 분기별 분석”

la_get_last_ha_applied_info가 행을 찾지 못했을 때(res == 0) 호출된다. 26컬럼 행을 INSERT한다: 15개 바인드 ? 파라미터(db_name, creation time, copied path, 6개 LSA pageid/offset 쌍)와 11개 SQL 리터럴(3개 시각 및 start_time에 SYS_DATETIME; status와 6개 카운터에 0). 파라미터 바인딩 후:

// la_insert_ha_apply_info -- src/transaction/log_applier.c
assert_release (in_value_idx == LA_IN_VALUE_COUNT); /* <- 정확히 15개 바인드, 아니면 abort */
res = la_update_query_execute_with_values (query_buf, in_value_idx, &in_value[0], true);
for (i = 0; i < in_value_idx; i++)
db_value_clear (&in_value[i]);
if (res <= 0)
{
return res; /* <- INSERT 실패: log-info 파일 미기록 */
}
/* create log info */
msg = msgcat_message (MSGCAT_CATALOG_CUBRID, MSGCAT_SET_LOG, MSGCAT_LOG_LOGINFO_COMMENT);
if (msg == NULL)
{
msg = "COMMENT: %s for database %s\n"; /* <- msgcat 미스: 하드코딩 폴백 */
}
fp = fopen (la_Info.loginf_path, "w");
if (fp == NULL)
{
er_set (...ER_LOG_MOUNT_FAIL..., la_Info.loginf_path); /* <- 경고만, 치명적 오류 아님 */
}
else
{
(void) fprintf (fp, msg, CUBRID_MAGIC_LOG_INFO, la_Info.loginf_path);
fflush (fp);
fclose (fp); /* <- .log_info에 magic comment 기록 */
}
return res;

바인딩 이후 네 개의 분기: assert_release 가드(10.1 불변 조건의 드리프트 검사); INSERT 실패 시 .log_info 파일 미기록 상태로 res <= 0 조기 반환; msgcat 미스 시 리터럴 포맷 문자열 대체; 그리고 fopen 결과 — fp == NULL이면 비치명적 ER_LOG_MOUNT_FAIL 경고, 아니면 CUBRID_MAGIC_LOG_INFO 코멘트 기록 후 fflush/fclose. .log_info 파일은 최선 노력(best-effort)이다: res(영향받은 행 수)는 이미 > 0이며 어느 경로든 반환되므로, 이 파일 실패가 INSERT를 중단시키지 않는다.

10.4 행 기록 — la_update_ha_last_applied_info

섹션 제목: “10.4 행 기록 — la_update_ha_last_applied_info”

la_log_commit에서 호출되며, 12개 LSA half, 2개 선택적 시각, 6개 카운터, last_access_time = SYS_DATETIME(db_name, copied_log_path) 키로 UPDATE한다. 두 가지 핵심 로직:

// la_update_ha_last_applied_info -- src/transaction/log_applier.c
if (LSA_GE (&la_Info.committed_lsa, &la_Info.last_committed_lsa))
{ /* bind committed_lsa */ }
else
{ /* bind last_committed_lsa instead */ } /* <- 마크 역진 방지 */
/* committed_rep_lsa도 동일한 LSA_GE 가드; 시각은 IFNULL(?, col) 사용 */
res = la_update_query_execute_with_values (query_buf, in_value_idx, &in_value[0], true);
if (res == 0) /* <- 0 rows matched: 행이 우리 모르게 삭제됨 */
{
db_localdatetime (&la_Info.act_log.log_hdr->db_creation, &log_db_creation_time);
res = la_insert_ha_apply_info (&log_db_creation_time);
if (res > 0)
res = la_update_query_execute_with_values (query_buf, in_value_idx, &in_value[0], true);
}

커밋된 컬럼은 MAX(현재값, last_committed) 방식으로 기록되므로 인메모리 값이 일시적으로 낮더라도 내구성 있는 마크가 역진되지 않는다. log_record_time / log_commit_timeIFNULL(?, col)을 사용해 커밋 레코드가 없는 배치는 저장된 타임스탬프를 그대로 둔다. 행이 삭제된 경우(UPDATE 0 rows) 함수는 la_insert_ha_apply_info로 재INSERT 후 UPDATE를 재시도해 la_Info 상태를 잃지 않는다(la_update_ha_apply_info_log_record_time에도 동일한 패턴). 커밋은 하지 않는다la_log_commit이 적용된 행과 북마크를 함께 커밋할 수 있도록 트랜잭션을 열어둔다.

la_log_commit (bool update_commit_time)은 슬레이브 적용 배치가 내구성을 얻는 단일 병목 지점이다. 디스패치 경로(8장)의 커밋 레코드, repl 목록이 비었을 때의 server-state 레코드, 그리고 메인 적용 루프에서 호출된다. required_lsa 재산정, 마스터 헤더 LSA 스냅샷, 버퍼링된 행 플러시, 북마크 UPDATE 스테이징, 커밋을 순서대로 수행하며 각 단계가 다음 단계를 조건화한다:

// la_log_commit -- src/transaction/log_applier.c
(void) la_find_required_lsa (&la_Info.required_lsa); /* recompute floor */
LSA_COPY (&la_Info.append_lsa, &la_Info.act_log.log_hdr->append_lsa);
LSA_COPY (&la_Info.eof_lsa, &la_Info.act_log.log_hdr->eof_lsa);
if (update_commit_time) la_Info.log_commit_time = time (0);
error = la_flush_repl_items (true); /* push buffered rows */
if (error != NO_ERROR) return error; /* <- 미플러시: 아무것도 커밋 안 됨 */
res = la_update_ha_last_applied_info (); /* stage bookmark UPDATE */
if (res > 0)
error = la_commit_transaction (); /* <- 원자적: 행 + 북마크 */
else
{
la_Info.fail_counter++;
if (ER_IS_SERVER_DOWN_ERROR (res)) error = ER_NET_CANT_CONNECT_SERVER; /* loop reconnects */
else { er_set (...); error = NO_ERROR; } /* <- 삼킴, 동일 final_lsa로 재시도 */
}
return error;
flowchart TD
  A["la_log_commit(update_commit_time)"] --> B["la_find_required_lsa"]
  B --> C["헤더에서 append_lsa, eof_lsa 복사"]
  C --> D{update_commit_time?}
  D -->|yes| E["log_commit_time = time(0)"]
  D -->|no| F["la_flush_repl_items(true)"]
  E --> F
  F --> G{플러시 오류?}
  G -->|"!= NO_ERROR"| H["return error -- 아무것도 커밋 안 됨"]
  G -->|NO_ERROR| I["la_update_ha_last_applied_info -> res"]
  I --> J{res > 0?}
  J -->|yes| K["la_commit_transaction -- 적용 + 북마크 원자적 커밋"]
  J -->|no| L["fail_counter++"]
  L --> M{ER_IS_SERVER_DOWN_ERROR?}
  M -->|yes| N["error = ER_NET_CANT_CONNECT_SERVER"]
  M -->|no| O["er_set HA_GENERIC; error = NO_ERROR -- 삼킴"]
  K --> P["return error"]
  N --> P
  O --> P

Figure 10-2. la_log_commit 분기 지도. 모든 종료 경로는 행과 북마크를 함께 커밋하거나 아무것도 커밋하지 않는다.

불변 조건 — 적용과 북마크는 하나의 트랜잭션이다. la_flush_repl_items는 행 변경을 수행하고 la_update_ha_last_applied_info는 북마크 UPDATE를 스테이징한다. 둘 다 동일한 커넥션에서 중간 커밋 없이 실행되며, 단 하나의 la_commit_transaction(db_commit_transaction 호출)으로 함께 내구성을 얻는다. 스테이징 후 커밋 전에 크래시가 나면 둘 다 롤백된다 — 북마크가 “X까지 적용됨”이라고 기록했지만 X에 해당하는 행이 없는 상황이 생기지 않는다.

북마크 UPDATE 실패(res <= 0) 시 서버 다운 오류는 루프 재접속을 위해 ER_NET_CANT_CONNECT_SERVER로 변환되고, 그 외 실패는 fail_counter++NO_ERROR삼켜진다. 행은 커밋되지 않았으므로 다음 반복에서 동일한 final_lsa부터 재시도한다 — 비용은 멱등적 재적용 한 번이다(10.8).

10.6 하한선 계산 — la_find_required_lsa

섹션 제목: “10.6 하한선 계산 — la_find_required_lsa”

required_lsa는 데몬이 여전히 다시 읽을 수 있는 가장 낮은 LSA다: 트랜잭션별 목록(8장)에서 가장 오래된 열린 트랜잭션의 start_lsa. la_log_commit은 매번 커밋 전에 이를 재산정한다.

// la_find_required_lsa -- src/transaction/log_applier.c
LSA_SET_NULL (&lowest_lsa);
for (i = 0; i < la_Info.cur_repl; i++)
{
if (la_Info.repl_lists[i]->tranid <= 0)
continue; /* <- 빈/닫힌 슬롯 */
if (LSA_ISNULL (&lowest_lsa) || LSA_GT (&lowest_lsa, &la_Info.repl_lists[i]->start_lsa))
LSA_COPY (&lowest_lsa, &la_Info.repl_lists[i]->start_lsa);
}
if (LSA_ISNULL (&lowest_lsa))
LSA_COPY (required_lsa, &la_Info.final_lsa); /* <- 열린 트랜잭션 없음: 하한 = 커서 */
else
LSA_COPY (required_lsa, &lowest_lsa);

세 가지 분기: 닫힌 슬롯 건너뛰기(tranid <= 0), 최솟값 추적, 그리고 열린 트랜잭션이 없으면 final_lsa로 폴백해 유휴 기간에도 하한이 전진하고 아카이브가 정리될 수 있도록 한다.

불변 조건 — required_lsa <= final_lsa이며, 열린 트랜잭션의 start보다 높을 수 없다. required_lsa 엄밀히 하의 모든 것은 절대 다시 읽히지 않는다 — 이것이 la_remove_archive_logs가 아카이브를 안전하게 삭제하기 위해 전제하는 조건이다.

10.7 슬레이브 로컬 아카이브 정리 — la_remove_archive_logs

섹션 제목: “10.7 슬레이브 로컬 아카이브 정리 — la_remove_archive_logs”

copylogdb는 슬레이브 log_path에 아카이브 로그를 계속 추가한다(6장). la_remove_archive_logs는 최대 개수 상한과 required_lsa 하한 모두에 의해 제어되며, 새 last_deleted_arv_num(삭제된 것이 없으면 기존 값)을 반환한다.

// la_remove_archive_logs -- src/transaction/log_applier.c
if (LA_LOG_IS_IN_ARCHIVE (la_Info.required_lsa.pageid)) /* 하한이 아카이브 안에 있는가? */
{
error = la_find_archive_num (&required_arv_num, la_Info.required_lsa.pageid);
if (error != NO_ERROR) return last_deleted_arv_num; /* <- 위치 불명: 전부 보존 */
max_arv_count = MAX (log_max_archives, nxarv_num - required_arv_num); /* 하한 보호 */
}
else
max_arv_count = log_max_archives; /* 하한이 활성 로그에 있음: 상한만 적용 */
if (current_arv_count > max_arv_count) /* nxarv_num - last_deleted - 1 */
{
/* first = last_deleted+1; last = nxarv_num - max_arv_count - 1 */
if (last_arv_num_to_delete < 0 || last_arv_num_to_delete < first_arv_num_to_delete)
return last_deleted_arv_num; /* <- 삭제 대상 없음 */
/* throttle: cap (last-first+1) at max_arv_count_to_delete */
for (i = first ...; i <= last_arv_num_to_delete; i++)
fileio_unformat (NULL, archive_name); /* <- 물리 삭제 */
return last_arv_num_to_delete;
}
return last_deleted_arv_num; /* 상한 이하: 전부 보존 */

분기 요약:

  • 하한이 아카이브 안에 있을 때 (pageid < nxarv_pageid): required_arv_num부터 위쪽 아카이브를 보존하도록 max_arv_count를 올린다; la_find_archive_num 실패 시 전부 보존하고 반환.
  • 하한이 활성 로그에 있을 때: HA_COPY_LOG_MAX_ARCHIVES만 보존 개수를 제한.
  • 상한 이하이거나 삭제 대상 범위가 비었거나 음수: 변경 없음.
  • max_arv_count_to_delete 스로틀: 호출자가 구간별 페이스 조절 시 1을, 새 아카이브가 방금 나타났을 때는 INT_MAX를 전달.

호출자는 반환값을 la_Info.last_deleted_archive_num에 반영하고, la_update_last_deleted_arv_num으로 영속화한 뒤 la_Info.last_time_archive_deleted를 기록한다.

불변 조건 — required_lsa 아래의 아카이브는 la_log_commit이 그 하한을 영속화한 이후에만 삭제된다. required_lsa는 적용 배치 트랜잭션 내부에서 (10.5) 루프가 정리 단계에 도달하기 전에 기록되므로, 영속화된 하한은 적용된 것보다 높을 수 없다. 삭제 후 다음 커밋 전에 크래시가 나도 안전하다: 내구성 있는 required_lsa가 이미 삭제된 범위를 제외하고 있기 때문이다.

순서가 중요한 구간은 la_repl_add_object가 행을 bulk-flush 목록에 스테이징하는 시점(9장)과 la_log_commit이 이를 커밋하는 시점 사이뿐이다. 이 구간에서 크래시가 나면 트랜잭션이 롤백된다 — 스테이징된 행과 북마크 UPDATE 모두 사라진다(10.5 불변 조건). 재시작 시 la_get_last_ha_applied_info이전에 커밋된 final_lsa를 읽고 데몬은 그 범위를 재적용한다.

재적용은 멱등적이며, 두 가드 모두 la_apply_repl_log 안에 있다 — 단순히 대기 중인 커밋들을 꺼내 아래로 넘기는 la_apply_commit_list 호출자가 아니다. la_apply_repl_logLSA_LE (commit_lsa, &la_Info.last_committed_lsa)인 커밋 전체를 건너뛰고(9장), 진행되는 커밋에 대해서는 각 REPL 항목을 LSA_GT (&item->lsa, &la_Info.last_committed_rep_lsa)인 것만 적용한다. 크래시 전에 커밋된 레코드는 이미 적용됨으로 버려지고, 스테이징만 됐던 레코드는 깨끗하게 재적용된다. 남은 엣지 케이스 — 재적용을 관찰하는 비멱등적 사용자 트리거 — 는 companion의 “Open Questions”에서 다루므로 그쪽을 참고하라.

  1. LA_HA_APPLY_INFO(db_name, copied_log_path) 키를 갖는 _db_ha_apply_info 행 하나의 임시 일대일 이미지다. 상주 상태는 la_Info에 필드별로 흩어져 있으며, LA_*_VALUE_COUNT assert가 구조체와 스키마의 드리프트를 차단한다.
  2. final_lsa는 재시작 재개 커서, required_lsa는 최소 필요 하한이다. 새 applier에서 둘 다 마스터의 eof_lsa로 수렴하고, committed_lsa / committed_rep_lsa는 단조 증가하는 이미 적용된 마크다.
  3. la_insert_ha_apply_info는 최초 부팅 시(그리고 UPDATE 경로 내 자가 치유 단계로) 15-바인드 행을 한 번 기록한다. assert된 바인드 카운트 이후 INSERT 실패를 그대로 전파하며, .log_info 파일은 최선 노력으로 취급해 ER_LOG_MOUNT_FAIL에서 중단하지 않는다.
  4. la_log_commit은 단일 원자적 병목 지점이다: 버퍼링된 행을 플러시하고 북마크 UPDATE를 하나의 커넥션에서 스테이징한 뒤, 단 하나의 la_commit_transaction으로 함께 커밋한다 — 함께 내구성을 얻거나 아무것도 얻지 않는다.
  5. la_update_ha_last_applied_infoLSA_GE 가드로 커밋 마크의 역진을 차단하고, 삭제된 행을 재INSERT 후 UPDATE 재시도로 자가 치유한다.
  6. la_get_last_ha_applied_info는 creation-time 불일치나 NULL required_lsa 시 낡은 북마크를 거부하며, 역진 방지 하한으로 last_committed_lsa / last_committed_rep_lsa를 시드한다.
  7. la_find_required_lsala_remove_archive_logs가 함께 영속화된 required_lsa 위쪽의 슬레이브 로컬 아카이브 공간만 회수하므로, la_repl_add_objectla_log_commit 크래시 창은 멱등적 재적용으로 안전하게 처리된다.

Chapter 11: 특수 경로 — 장기 트랜잭션, 역할 전환, 필터, TDE

섹션 제목: “Chapter 11: 특수 경로 — 장기 트랜잭션, 역할 전환, 필터, TDE”

8장부터 10장까지는 정상 상태(steady-state)의 apply 생명주기를 다뤘다. 이 장은 그 흐름을 깨는 네 가지 경로를 모은다: 스테이징 한도를 초과하는 트랜잭션, 슬레이브를 강등하는 계획된 역할 전환, 이벤트를 버리는 테이블 수준 필터, 그리고 소켓으로 키를 공유해야 하는 TDE 암호화 페이지. 정상 상태 메커니즘과 master/slave 토폴로지에 대해서는 함께 참조하는 cubrid-ha-replication.md의 “Apply pipeline” 및 “Failover” 절을 전제한다.

11.1 장기 트랜잭션: 메모리를 아끼되 O(records) 재탐색을 감수하다

섹션 제목: “11.1 장기 트랜잭션: 메모리를 아끼되 O(records) 재탐색을 감수하다”

8장은 la_set_repl_log가 각 REPL 레코드를 apply->head .. apply->tail 리스트에 추가하되, LA_MAX_REPL_ITEMS 한도 안에서만 유지함을 보였다. 이 한도를 초과하는 트랜잭션은 LA_APPLY 항목을 장기 트랜잭션 모드로 전환하고 스테이징된 아이템을 버린다:

// la_set_repl_log -- src/transaction/log_applier.c
if (apply->is_long_trans) /* already long: track last_lsa only */
{ LSA_COPY (&apply->last_lsa, lsa); return NO_ERROR; }
if (apply->num_items >= LA_MAX_REPL_ITEMS) /* cap hit: switch to long mode */
{
la_free_all_repl_items_except_head (apply); /* <- discard the staged list */
apply->is_long_trans = true;
LSA_COPY (&apply->last_lsa, lsa); return NO_ERROR;
}

이제 항목은 start_lsa(la_log_record_process가 첫 레코드에서 설정)와 last_lsa(이후 모든 REPL 레코드에서 갱신)만 추적한다. 아이템 리스트는 head 하나만 남기고 사라진다.

불변 조건 — 장기 트랜잭션은 스테이징된 아이템 리스트가 아니라 로그 내의 start_lsa .. last_lsa로 완전히 기술된다. la_set_repl_logis_long_trans가 설정된 이후 추가를 거부하고, 매 레코드마다 last_lsa를 최신으로 유지함으로써 이를 강제한다.

커밋 시점에 la_apply_repl_logla_get_next_repl_item으로 아이템을 순회하는데, 이 함수는 플래그에 따라 분기한다: is_long_trans 가 true이면 → la_get_next_repl_item_from_log (item, last_lsa) (로그 재독); false이면 → la_get_next_repl_item_from_list (item), 즉 return item->next. 로그 재독 경로가 비용이 크다:

// la_get_next_repl_item_from_log -- src/transaction/log_applier.c
LSA_COPY (&curr_lsa, &item->lsa);
while (!LSA_ISNULL (&curr_lsa))
{
curr_log_record = LOG_GET_LOG_RECORD_HEADER (la_get_page (curr_lsa.pageid), &curr_lsa);
// ... condensed: snapshot start-record trid into prev_repl_log_record ...
if (!LSA_EQ (&curr_lsa, &prev_repl_lsa) && prev_repl_log_record->trid == curr_log_record->trid)
{
if (LSA_GT (&curr_lsa, last_lsa) || curr_log_record->type == LOG_COMMIT
|| curr_log_record->type == LOG_ABORT
|| LSA_GE (&curr_lsa, &la_Info.act_log.log_hdr->eof_lsa))
break;
if (curr_log_record->type == LOG_REPLICATION_DATA
|| curr_log_record->type == LOG_REPLICATION_STATEMENT)
{ next_item = la_make_repl_item (...); break; }
}
la_release_page_buffer (curr_lsa.pageid);
LSA_COPY (&curr_lsa, &curr_log_record->forw_lsa); /* follow forward chain */
}

루프의 모든 분기가 실제로 실행된다:

  1. 첫 번째 반복prev_repl_log_record == NULL 절(위에서 생략)에서 시작 레코드의 trid를 prev_repl_log_record에 저장한다.
  2. 시작 LSA와 동일 (LSA_EQ) — 건너뜀; 루프가 넘겨받은 아이템을 자신이 반환하지 않는 이유다.
  3. 다른 trid — 인터리브된 트랜잭션; 페이지를 해제하고 forw_lsa를 따른다.
  4. 동일 trid, last_lsa 초과 / COMMIT / ABORT / EOF — 소진; next_item == NULLbreak.
  5. 동일 trid, REPL 레코드la_make_repl_item, break.

각 조회는 forw_lsa를 앞 방향으로 따르므로, 전체 트랜잭션 적용 비용은 O(스팬된 로그 레코드 수)다 — 핵심 정리 2의 트레이드오프.

11.2 역할 전환: 계획된 페일오버에서 슬레이브 강등하기

섹션 제목: “11.2 역할 전환: 계획된 페일오버에서 슬레이브 강등하기”

마스터는 모든 HA 서버 상태 변경(cub_master가 페일오버 시 구동 — cubrid-heartbeat.md 교차 참조)마다 LOG_DUMMY_HA_SERVER_STATE 마커를 기록한다. 어플라이어는 la_log_record_process에서 이를 처리한다:

// la_log_record_process, case LOG_DUMMY_HA_SERVER_STATE -- src/transaction/log_applier.c
ha_server_state = la_get_ha_server_state (pg_ptr, final); /* NULL -> log + break */
if (ha_server_state->state != HA_SERVER_STATE_ACTIVE
&& ha_server_state->state != HA_SERVER_STATE_TO_BE_STANDBY) /* demotion */
{
if (la_Info.db_lockf_vdes != NULL_VOLDES) /* <- still hold the db lock */
{ la_Info.is_role_changed = true; return ER_INTERRUPTED; } /* <- clean stop */
}
else if (la_is_repl_lists_empty ()) /* benign: la_log_commit (true) */

분기 설명:

  • ACTIVE/TO_BE_STANDBY를 벗어났고 db 락을 보유 중 — 더 이상 따를 마스터가 없다; is_role_changed를 설정하고 ER_INTERRUPTED(에러가 아닌 합의된 정상 종료 신호)를 반환한다.
  • 같은 강등 상태, 락 미보유 — 해제할 것이 없다; 그냥 통과.
  • ACTIVE/TO_BE_STANDBY, 리스트 비어 있음 — 무해; 타임스탬프를 기록하고 북마크를 최신으로 유지하기 위해 강제 커밋.
  • ACTIVE/TO_BE_STANDBY, 리스트 비어 있지 않음 — 여기서는 아무것도 하지 않는다; 대기 중인 트랜잭션이 정상적으로 적용된다.

ER_INTERRUPTEDla_apply_log_file 메인 루프로 전파되고, is_role_changed == true이면 la_unlock_dbname (..., clear_owner = true)를 호출해 db 락의 소유권을 해제함으로써 새 마스터가 이를 획득할 수 있게 한다. (LOG_DUMMY_CRASH_RECOVERY는 바로 위의 케이스인데 ER_INTERRUPTED를 반환하지만 is_role_changed는 설정하지 않는다 — 이 경우 인터럽트는 재fetch를 강제하면서 락은 유지한다.)

la_change_state는 보완적인 나머지 절반이다: 메인 루프 이터레이션마다 로그 헤더ha_server_state / ha_file_status를 기반으로 어플라이어 자신의 HA_LOG_APPLIER_STATE를 보고한다. 첫 번째 분기는 조기 반환이다 — last_server_state, last_file_state, last_is_end_of_record가 모두 현재 값과 같으면 return NO_ERROR. 그렇지 않으면 이중 switch로 목표 상태를 결정한다:

  • 따라잡음 (is_end_of_record && ha_file_status == LOG_HA_FILESTAT_SYNCHRONIZED): ACTIVE / TO_BE_STANDBY / TO_BE_ACTIVEWORKING; DEAD / STANDBY / MAINTENANCEDONE (그리고 DEAD가 아닌 한, 대기 마스터는 드레인할 커밋을 생성하지 않으므로 la_clear_all_repl_and_commit_list로 대기 리스트를 플러시한다).
  • 아직 따라잡는 중 (else): 모든 활성 상태 → RECOVERING (두 switch 케이스가 이미-상태 가드만 다르고 둘 다 RECOVERING을 목표로 한다).
  • 알 수 없는 ha_server_state (어느 switch에서든): er_log_debug ("BUG ..."), return ER_FAILED.

실제 전환 — new_state != HA_LOG_APPLIER_STATE_NA이고, 두 번째 apply_state == new_state 조기 반환 이후에도 새 상태가 실제로 다를 때 — la_change_stateboot_notify_ha_log_applier_state 전에 강제 커밋(la_log_commit (true))을 수행해 보고되는 북마크가 내구적임을 보장한다.

불변 조건 — 어플라이어는 마지막으로 적용한 북마크를 내구적으로 만들기 전에 새 상태를 외부에 알리지 않는다. boot_notify_ha_log_applier_state 앞의 무조건적인 la_log_commit (true)가 이를 강제한다.

11.3 테이블 수준 필터링: REPL_FILTER_TYPE과 아이템별 평가

섹션 제목: “11.3 테이블 수준 필터링: REPL_FILTER_TYPE과 아이템별 평가”

슬레이브는 일부 테이블만(INCLUDE) 복제하거나 일부를 제외하고(EXCLUDE) 복제할 수 있다. 정책은 하나의 열거형과 병렬 배열 필터 구조체로 표현된다.

// REPL_FILTER_TYPE -- src/transaction/log_applier.h
typedef enum { REPL_FILTER_NONE, REPL_FILTER_INCLUDE_TBL, REPL_FILTER_EXCLUDE_TBL } REPL_FILTER_TYPE;
열거값역할존재 이유
REPL_FILTER_NONEla_need_filter_out이 즉시 false를 반환한다.기본값; 모든 것을 복제할 때 오버헤드 없음.
REPL_FILTER_INCLUDE_TBL허용 목록: 클래스가 목록에 없으면 버린다.”이 테이블만 복제한다.”
REPL_FILTER_EXCLUDE_TBL거부 목록: 클래스가 목록에 있으면 버린다.”이 테이블을 제외하고 모두 복제한다.”

런타임 상태 LA_REPL_FILTER(la_Info.repl_filter)는 병렬 배열이다:

// struct la_repl_filter -- src/transaction/log_applier.c
struct la_repl_filter { char **list; int list_size; int num_filters; REPL_FILTER_TYPE type; };
필드역할존재 이유
list필터 파일에서 strdup된 클래스 이름들.허용/거부 집합; la_add_repl_filterLA_NUM_REPL_FILTER 단위로 늘린다.
list_sizelist의 할당된 용량.la_add_repl_filterrealloc 시점을 판단하는 기준; 사용 중인 항목 수와 별개.
num_filters채워진 항목 수 (<= list_size).la_need_filter_out의 반복 상한; 용량이 아닌 카운트.
typeREPL_FILTER_TYPE 정책.허용/거부를 선택하거나 “필터 없음”으로 단락한다.

la_create_repl_filter가 시작 시 한 번 로드한다. 분기 맵:

  • type = PRM_ID_HA_REPL_FILTER_TYPE; REPL_FILTER_NONE → 즉시 반환(파일 읽지 않음).
  • 필터 파일 = PRM_ID_HA_REPL_FILTER_FILE; 비어 있거나 fopen 실패 → ER_HA_LA_REPL_FILTER_GENERIC; 상대 경로는 envvar_confdir_file로 해석.
  • 줄별 처리: trim, 개행 제거, 빈 줄 건너뜀; user/식별자 길이 검증 (초과 → goto error_return); sm_user_specified_name으로 정규화 후 locator_find_class로 해석. 클래스 미존재는 비치명적 — 에러는 er_stack_push / er_stack_pop 쌍 안에서 발생(발생 후 즉시 스택에서 제거)하고, 테이블이 나중에 생성될 수 있으므로 la_add_repl_filter로 항목을 추가한다.
  • error_return: 파일을 닫고 la_destroy_repl_filter를 호출한다.

필터는 스테이징 시점이 아니라 apply 시점에 아이템별로 평가된다 — la_apply_repl_log 루프에서 apply 디스패치는 LSA_GT (&item->lsa, &la_Info.last_committed_rep_lsa) && la_need_filter_out (item) == false일 때만 실행된다. la_need_filter_out 함수 자체는:

// la_need_filter_out -- src/transaction/log_applier.c
if (filter->type == REPL_FILTER_NONE
|| (item->log_type == LOG_REPLICATION_STATEMENT && item->item_type != CUBRID_STMT_TRUNCATE)
|| strcasecmp (class_name, CT_SERIAL_NAME) == 0)
return false; /* never filter these */
for (i = 0; i < filter->num_filters; i++)
if (strcasecmp (filter->list[i], class_name) == 0) { filter_found = true; break; }
if ((filter->type == REPL_FILTER_INCLUDE_TBL && filter_found == false)
|| (filter->type == REPL_FILTER_EXCLUDE_TBL && filter_found == true))
return true;
return false;

즉, 구문 복제(TRUNCATE 제외)와 시리얼 카탈로그(CT_SERIAL_NAME)는 절대 필터링되지 않는다. 그 외에는 INCLUDE가 목록에 없는 클래스를 버리고, EXCLUDE가 목록에 있는 클래스를 버린다.

수정자를 위한 비용 참고사항: 클래스 이름은 la_need_filter_out이 실행되기 전에 la_apply_* 내의 la_get_recdes 탐색에서 가져오므로, 필터링된 이벤트도 레코드 디스크립터 디코딩 비용은 치른다 — 필터가 아끼는 것은 슬레이브 apply이지 로그 읽기가 아니다.

11.4 TDE: 유닉스 소켓으로 데이터 키 공유하기

섹션 제목: “11.4 TDE: 유닉스 소켓으로 데이터 키 공유하기”

마스터가 로그 페이지를 TDE로 암호화하면, copylogdb는 암호문을 전달하고 applylogdb는 fetch된 각 페이지를 복호화한다 — 둘 다 UNSTABLE_TDE_FOR_REPLICATION_LOG 가드 뒤에 위치한다(기본값: 비활성).

la_load_tde에 대한 주석. la_load_tde라는 이름의 심벌은 존재하지 않는다; “어플라이어가 키를 로드하고 서빙한다”는 개념적 역할은 la_start_dk_sharing(리스너 설정)과 서버 스레드 la_process_dk_request(copylogdblogwr_load_tde의 실제 대응체)가 담당한다. 이 두 심벌로 grep하라.

복호화 지점은 la_log_fetch(및 아카이브 경로)다: 가드 하에서 LOG_IS_PAGE_TDE_ENCRYPTED (&cache_buffer->logpage)가 참이면 tde_decrypt_log_page (..., logwr_get_tde_algorithm (...), ...)를 그 자리에서 호출한다.

키는 어플라이어에서 온다 — 어느 데몬도 본질적으로 키를 소유하지 않는다. la_apply_log_file에서 tde_get_data_keys ()를 호출하고, tde_Cipher.is_loaded를 설정하고, la_start_dk_sharing을 호출한다. la_start_dk_sharingfileio_make_ha_sock_name (..., TDE_HA_SOCK_NAME)으로 경로를 만들고, 오래된 노드를 unlink하고, socket/bind/listen한 뒤 pthread_create (la_process_dk_request)를 수행한다(각 실패 → 고유한 ER_TDE_DK_SHARING_*). 클라이언트 logwr_load_tde는 연결하고, 핸드셰이크로 log_path를 기록하고, 상태 워드를 읽고, 성공 시 TDE_DATA_KEY_SET을 읽는다:

// logwr_load_tde -- src/transaction/log_writer.c
fileio_make_ha_sock_name (sock_path, logwr_Gl.log_path, TDE_HA_SOCK_NAME);
// ... socket / connect / write(log_path) / read(err_msg) ...
if (err_msg != NO_ERROR) { close (client_sockfd); return err_msg; } /* refused */
// ... read TDE_DATA_KEY_SET dks; memcpy perm_key / temp_key / log_key ...
tde_Cipher.is_loaded = true;

서버 스레드 la_process_dk_request는 키를 넘기기 전에 각 클라이언트를 검증한다:

// la_process_dk_request -- src/transaction/log_applier.c
if (error == NO_ERROR) /* prior read may set ER_TDE_DK_SHARING_SOCK_READ */
{
if (!tde_is_loaded ())
error = ER_TDE_CIPHER_IS_NOT_LOADED; /* <- keys not ready yet */
else if (memcmp (buf, la_Info.log_path, PATH_MAX) != 0)
error = ER_TDE_WRONG_DK_REQUEST; /* <- client's log_path mismatch */
}
// ... write `error` to client first; only if NO_ERROR send TDE_DATA_KEY_SET ...

상태 워드는 항상 먼저 전송되고(따라서 logwr_load_tde가 거부 이유를 알 수 있고), TDE_DATA_KEY_SET은 오직 정상 경로에서만 기록된다. 이후 la_log_fetch가 페이지를 복호화할 수 있다. 전체 핸드셰이크: 어플라이어 tde_get_data_keys + listen → 클라이언트 연결, log_path 기록 → 어플라이어 상태 워드 반환 → NO_ERROR이면 TDE_DATA_KEY_SET 전송.

제한 사항 — 복제용 TDE는 컴파일 시점에 가드되며 기본적으로 비활성이다. 이 절의 모든 코드 경로는 #ifdef UNSTABLE_TDE_FOR_REPLICATION_LOG 뒤에 있다; 일반 빌드는 평문 로그 페이지를 복제하며 키 소켓을 열지 않는다.

마스터 측에서는 tde_encrypted가 스테이징된 각 LOG_REPL_RECORD의 멤버로 탑승한다(struct log_replbool tde_encrypted, replication.h). log_manager.c의 emit 지점에서 if (repl_rec->tde_encrypted)이면 prior_set_tde_encrypted (node, repl_rec->rcvindex)가 실행되어 redo 노드가 플래그를 상속한다. tde_encrypted가 처음 설정되는 위치는 2장에서 다루며, 여기서는 그것이 prior-list 노드까지 살아남는다는 점만 확인한다.

abort는 읽히는 곳에서 해제되지 않는다. la_log_record_process에서 LOG_ABORT는 — commit처럼 — la_add_node_into_la_commit_list (lrec->trid, final, LOG_ABORT, 0)으로 큐에 삽입될 뿐이며, la_apply_commit_list가 LSA 순서대로 도달할 때 처리된다. 스테이징된 아이템은 나중에 la_apply_repl_log에서 해제된다 — 첫 번째 분기가 abort를 단락시킨다:

// la_apply_repl_log -- src/transaction/log_applier.c
if (rectype == LOG_ABORT)
{
la_clear_applied_info (apply); /* free items + reset LSAs, then return */
return NO_ERROR;
}
if (apply->head == NULL || LSA_LE (commit_lsa, &la_Info.last_committed_lsa))
{
if (rectype == LOG_SYSOP_END) la_free_all_repl_items (apply); /* preserve slot LSAs */
else la_clear_applied_info (apply); /* full reset */
return NO_ERROR;
}

la_clear_applied_info(LA_APPLY별 리셋: la_free_all_repl_itemsstart_lsa / last_lsa / tranid null 처리)는 abort 시, 그리고 비어 있거나 이미 커밋된 apply 리스트일 때 도달한다. 반면 같은 가드의 LOG_SYSOP_END 분기는 la_free_all_repl_items를 호출해 슬롯 LSA를 보존한다. la_free_all_repl_itemsis_long_trans도 초기화하므로, 재사용된 슬롯은 이전 점유자의 장기 트랜잭션 플래그를 물려받지 않는다.

la_free_repl_items_by_tranid는 abort 경로와 별개인 또 다른 정리 진입점이다. 이 함수는 (a) la_change_state가 non-DEAD standby로 전환될 때 la_clear_all_repl_and_commit_list에서, (b) la_log_record_processLOG_COMMIT / LOG_SYSOP_END else 분기에서 레코드 LSA가 <= committed_lsa인 경우(오래된, 이미 적용된 EOT)에 호출된다. 이 함수는 스테이징된 아이템을 초기화하고 해당 trid의 커밋 리스트 노드도 언링크한다:

// la_free_repl_items_by_tranid -- src/transaction/log_applier.c
apply = la_find_apply_list (tranid);
if (apply) la_clear_applied_info (apply); /* free items + reset LSAs */
// ... walk commit_head; for the node with commit->tranid == tranid, unlink + free_and_init ...
if (la_Info.commit_head == NULL) la_Info.commit_tail = NULL; /* keep head/tail consistent */

불변 조건 — 커밋 리스트의 head/tail은 항상 상호 일관성을 유지한다. la_free_repl_items_by_tranid는 마지막 노드를 언링크할 때 commit_tail을 수정하고(commit->next == NULL 분기: commit_tail = commit->prev), commit_head가 NULL이 될 때마다 commit_tail = NULL로 리셋함으로써 이를 강제한다.

LOG_SYSOP_END vs commit 디스패치의 전체 내용은 9장의 몫이다; 여기서는 어느 리프가 슬롯을 해제하고 어느 쪽이 보존하는지만 확인한다.

  1. LA_MAX_REPL_ITEMS를 초과하는 트랜잭션은 is_long_trans를 설정하고 아이템 리스트를 버린다. 이후 start_lsa .. last_lsa만으로 기술되며, la_set_repl_log는 플래그가 설정된 후 추가를 거부한다.
  2. 장기 트랜잭션 적용은 la_get_next_repl_item_from_log로 REPL 레코드를 재독하고, forw_lsa를 따르며 외래 trid / 비-REPL 레코드를 건너뛴다 — 메모리 풋프린트를 최소화하는 대신 O(스팬된 레코드 수)의 비용을 치른다.
  3. 강등은 LOG_DUMMY_HA_SERVER_STATE로 도달한다; db 락을 보유한 non-ACTIVE/TO_BE 상태에서 어플라이어는 is_role_changed를 설정하고 ER_INTERRUPTED를 반환하며, 메인 루프가 clear_owner = true로 락을 해제한다.
  4. la_change_state는 로그 헤더로부터 어플라이어 자체 상태를 보고하며, boot_notify_ha_log_applier_state 전에 반드시 내구적 커밋을 강제한다.
  5. 필터링은 아이템별 결정(la_need_filter_out)으로, REPL_FILTER_TYPE 허용/거부 목록을 기준으로 한다. 구문 복제(TRUNCATE 제외)와 시리얼 카탈로그는 절대 필터링되지 않으며, 필터링된 이벤트도 la_get_recdes 디코딩 비용을 치른다.
  6. TDE는 컴파일 가드(UNSTABLE_TDE_FOR_REPLICATION_LOG)로 비활성: applylogdb가 키를 서빙하고(la_start_dk_sharing / la_process_dk_request), copylogdb가 가져가며(logwr_load_tde), 페이지 복호화는 la_log_fetch에서 이뤄지고, tde_encryptedprior_set_tde_encrypted를 거쳐 redo 노드까지 전달된다.
  7. abort는 커밋처럼 la_add_node_into_la_commit_list로 큐에 삽입되고 나중에 la_apply_repl_log에서 해제된다(rectype == LOG_ABORTla_clear_applied_info). la_free_repl_items_by_tranid는 standby 종료 및 이미 적용된 EOT 처리에 쓰이는 별개의 경로이며, 커밋 리스트의 head/tail 일관성을 유지한다.

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

SymbolFileLine
logwr_get_log_pagessrc/communication/network_interface_cl.c9153
slogwr_get_log_pagessrc/communication/network_interface_sr.cpp8500
la_apply_log_file (call site)src/executables/util_cs.c3170
CT_SERIAL_NAMEsrc/object/schema_system_catalog_constants.h45
ws_add_to_repl_obj_listsrc/object/work_space.c5360
do_replicate_statementsrc/query/execute_statement.c16136
qexec_execute_selupd_listsrc/query/query_executor.c13832
serial_update_serial_objectsrc/query/serial.c918
btree_updatesrc/storage/btree.c14696
btree_insertsrc/storage/btree.c26876
heap_get_class_namesrc/storage/heap_file.c9718
heap_get_class_tde_algorithmsrc/storage/heap_file.c11083
heap_insert_logicalsrc/storage/heap_file.c23460
heap_delete_logicalsrc/storage/heap_file.c23676
heap_update_logicalsrc/storage/heap_file.c23867
locator_insert_forcesrc/transaction/locator_sr.c4938
locator_insert_force heap_insert_logical callsrc/transaction/locator_sr.c5065
locator_insert_force add_or_remove_index callsrc/transaction/locator_sr.c5184
locator_update_forcesrc/transaction/locator_sr.c5396
locator_update_force heap_update_logical callsrc/transaction/locator_sr.c6028
locator_update_force repl_add_update_lsa guardsrc/transaction/locator_sr.c6052
locator_update_force repl_add_update_lsa callsrc/transaction/locator_sr.c6055
locator_delete_forcesrc/transaction/locator_sr.c6116
locator_delete_force_internalsrc/transaction/locator_sr.c6172
locator_delete_force_internal isold_object skipsrc/transaction/locator_sr.c6251
locator_delete_force_internal heap_delete_logical callsrc/transaction/locator_sr.c6327
locator_delete_force_internal add_or_remove_index callsrc/transaction/locator_sr.c6349
locator_delete_force_internal add_or_remove_index_for_moving callsrc/transaction/locator_sr.c6355
locator_force_for_multi_update repl_info flag setsrc/transaction/locator_sr.c6634
locator_force_for_multi_update REPL_INFO_TYPE_RBR_START assignmentsrc/transaction/locator_sr.c6636
locator_attribute_info_forcesrc/transaction/locator_sr.c7461
locator_attribute_info_force dispatch switchsrc/transaction/locator_sr.c7569
locator_add_or_remove_indexsrc/transaction/locator_sr.c7695
locator_add_or_remove_index_internalsrc/transaction/locator_sr.c7760
locator_add_or_remove_index_internal repl_log_insert guardsrc/transaction/locator_sr.c8038
locator_add_or_remove_index_internal repl_log_insert callsrc/transaction/locator_sr.c8042
locator_update_indexsrc/transaction/locator_sr.c8260
locator_update_index pk_btid_index discoverysrc/transaction/locator_sr.c8416
locator_update_index repl_log_insert callsrc/transaction/locator_sr.c8804
xrepl_set_infosrc/transaction/locator_sr.c11707
xchksum_insert_repl_log_and_demote_table_locksrc/transaction/locator_sr.c12620
prior_set_tde_encryptedsrc/transaction/log_append.cpp1565
enum LOG_PRIOR_LSA_LOCKsrc/transaction/log_append.hpp66
LA_DEFAULT_CACHE_BUFFER_SIZEsrc/transaction/log_applier.c80
LA_REPL_LIST_COUNTsrc/transaction/log_applier.c85
LA_PAGE_EXST_IN_ARCHIVE_LOGsrc/transaction/log_applier.c89
LA_LOCK_SUFFIXsrc/transaction/log_applier.c94
LA_MAX_REPL_ITEMSsrc/transaction/log_applier.c98
LA_LOG_IS_IN_ARCHIVEsrc/transaction/log_applier.c112
LA_LOGAREA_SIZEsrc/transaction/log_applier.c118
LA_LOG_READ_ADVANCE_WHEN_DOESNT_FITsrc/transaction/log_applier.c119
LA_MOVE_INSIDE_RECORDsrc/transaction/log_applier.c156
LA_IS_FLUSH_ERRORsrc/transaction/log_applier.c174
struct la_cache_buffersrc/transaction/log_applier.c178
struct la_cache_buffer_areasrc/transaction/log_applier.c191
struct la_cache_pbsrc/transaction/log_applier.c198
struct la_repl_filtersrc/transaction/log_applier.c207
la_repl_filtersrc/transaction/log_applier.c207
struct la_act_logsrc/transaction/log_applier.c216
struct la_arv_logsrc/transaction/log_applier.c227
la_itemsrc/transaction/log_applier.c237
la_item (struct)src/transaction/log_applier.c237
la_applysrc/transaction/log_applier.c255
la_apply (struct)src/transaction/log_applier.c255
la_commitsrc/transaction/log_applier.c267
la_commit (struct)src/transaction/log_applier.c267
la_infosrc/transaction/log_applier.c280
struct la_infosrc/transaction/log_applier.c280
repl_listssrc/transaction/log_applier.c298
commit_headsrc/transaction/log_applier.c304
commit_tailsrc/transaction/log_applier.c305
struct la_recdes_poolsrc/transaction/log_applier.c383
LA_HA_APPLY_INFOsrc/transaction/log_applier.c393
la_init_ha_apply_infosrc/transaction/log_applier.c606
la_log_phypageidsrc/transaction/log_applier.c630
la_log_fetch_from_archivesrc/transaction/log_applier.c912
la_log_fetchsrc/transaction/log_applier.c1063
la_expand_cache_log_buffersrc/transaction/log_applier.c1177
la_cache_buffer_replacesrc/transaction/log_applier.c1232
la_get_page_buffersrc/transaction/log_applier.c1297
la_get_pagesrc/transaction/log_applier.c1336
la_release_page_buffersrc/transaction/log_applier.c1364
la_invalidate_page_buffersrc/transaction/log_applier.c1418
la_find_required_lsasrc/transaction/log_applier.c1471
la_get_ha_apply_infosrc/transaction/log_applier.c1514
la_insert_ha_apply_infosrc/transaction/log_applier.c1716
la_update_ha_apply_info_start_timesrc/transaction/log_applier.c1860
la_update_ha_apply_info_log_record_timesrc/transaction/log_applier.c1897
la_get_last_ha_applied_infosrc/transaction/log_applier.c1965
la_update_ha_last_applied_infosrc/transaction/log_applier.c2074
la_assign_recdes_from_poolsrc/transaction/log_applier.c2381
la_init_recdes_poolsrc/transaction/log_applier.c2416
la_init_cache_pbsrc/transaction/log_applier.c2474
log_pageid_hashsrc/transaction/log_applier.c2500
la_init_cache_log_buffersrc/transaction/log_applier.c2528
la_apply_presrc/transaction/log_applier.c2693
la_init_repl_listssrc/transaction/log_applier.c2773
la_is_repl_lists_emptysrc/transaction/log_applier.c2837
la_find_apply_listsrc/transaction/log_applier.c2860
la_add_apply_listsrc/transaction/log_applier.c2889
la_log_copy_fromlogsrc/transaction/log_applier.c2960
la_new_repl_itemsrc/transaction/log_applier.c3012
la_add_repl_itemsrc/transaction/log_applier.c3050
la_get_item_pk_valuesrc/transaction/log_applier.c3073
la_make_repl_itemsrc/transaction/log_applier.c3092
la_unlink_repl_itemsrc/transaction/log_applier.c3228
la_free_repl_itemsrc/transaction/log_applier.c3266
la_free_all_repl_items_except_headsrc/transaction/log_applier.c3300
la_free_and_add_next_repl_itemsrc/transaction/log_applier.c3327
la_free_all_repl_itemssrc/transaction/log_applier.c3358
la_clear_applied_infosrc/transaction/log_applier.c3378
la_clear_all_repl_and_commit_listsrc/transaction/log_applier.c3392
la_set_repl_logsrc/transaction/log_applier.c3419
la_add_node_into_la_commit_listsrc/transaction/log_applier.c3473
la_retrieve_eot_timesrc/transaction/log_applier.c3515
la_make_room_for_mvcc_insidsrc/transaction/log_applier.c3669
la_make_room_for_mvcc_delid_and_prev_versrc/transaction/log_applier.c3700
la_disk_to_objsrc/transaction/log_applier.c3732
la_get_zipped_datasrc/transaction/log_applier.c3803
la_get_undoredo_diffsrc/transaction/log_applier.c3880
la_get_log_datasrc/transaction/log_applier.c3949
la_get_overflow_recdessrc/transaction/log_applier.c4249
la_get_next_update_logsrc/transaction/log_applier.c4393
la_get_relocation_recdessrc/transaction/log_applier.c4552
la_get_recdessrc/transaction/log_applier.c4604
la_repl_add_objectsrc/transaction/log_applier.c4882
la_apply_delete_logsrc/transaction/log_applier.c5000
la_apply_update_logsrc/transaction/log_applier.c5110
la_is_mvcc_classsrc/transaction/log_applier.c5218
la_apply_insert_logsrc/transaction/log_applier.c5311
la_apply_statement_logsrc/transaction/log_applier.c5496
la_apply_repl_logsrc/transaction/log_applier.c5739
la_apply_commit_listsrc/transaction/log_applier.c5920
la_free_repl_items_by_tranidsrc/transaction/log_applier.c5973
la_get_next_repl_itemsrc/transaction/log_applier.c6024
la_get_next_repl_item_from_listsrc/transaction/log_applier.c6036
la_get_next_repl_item_from_logsrc/transaction/log_applier.c6043
la_log_record_processsrc/transaction/log_applier.c6101
la_change_statesrc/transaction/log_applier.c6397
la_log_commitsrc/transaction/log_applier.c6531
la_commit_transactionsrc/transaction/log_applier.c6647
la_check_duplicatedsrc/transaction/log_applier.c6796
la_initsrc/transaction/log_applier.c6917
la_remove_archive_logssrc/transaction/log_applier.c7490
la_need_filter_outsrc/transaction/log_applier.c7723
la_add_repl_filtersrc/transaction/log_applier.c7784
la_create_repl_filtersrc/transaction/log_applier.c7831
la_apply_log_filesrc/transaction/log_applier.c8074
la_start_dk_sharingsrc/transaction/log_applier.c8743
la_process_dk_requestsrc/transaction/log_applier.c8796
REPL_FILTER_TYPEsrc/transaction/log_applier.h48
log_does_allow_replicationsrc/transaction/log_comm.c272
log_tdessrc/transaction/log_impl.h475
num_repl_recordssrc/transaction/log_impl.h522
cur_repl_recordsrc/transaction/log_impl.h523
append_repl_recidxsrc/transaction/log_impl.h524
fl_mark_repl_recidxsrc/transaction/log_impl.h525
repl_recordssrc/transaction/log_impl.h526
repl_insert_lsasrc/transaction/log_impl.h527
repl_update_lsasrc/transaction/log_impl.h528
suppress_replicationsrc/transaction/log_impl.h531
log_append_undoredo_crumbs repl_insert/update_lsa stampsrc/transaction/log_manager.c2198
log_append_redo_crumbs repl_insert/update_lsa stampsrc/transaction/log_manager.c2463
log_sysop_commit_internalsrc/transaction/log_manager.c3825
log_sysop_abortsrc/transaction/log_manager.c4038
log_append_repl_info_internalsrc/transaction/log_manager.c4555
prior_set_tde_encryptedsrc/transaction/log_manager.c4587
log_append_repl_infosrc/transaction/log_manager.c4623
log_append_repl_info_with_locksrc/transaction/log_manager.c4629
log_append_repl_info_and_commit_logsrc/transaction/log_manager.c4647
log_append_donetime_internalsrc/transaction/log_manager.c4679
log_append_commit_logsrc/transaction/log_manager.c4779
log_append_commit_log_with_locksrc/transaction/log_manager.c4802
log_append_supplemental_infosrc/transaction/log_manager.c4837
log_commit_localsrc/transaction/log_manager.c5159
log_abort_localsrc/transaction/log_manager.c5277
log_commitsrc/transaction/log_manager.c5352
log_completesrc/transaction/log_manager.c5653
log_complete_for_2pcsrc/transaction/log_manager.c5758
logpb_Arv_page_info_tablesrc/transaction/log_page_buffer.c282
logpb_get_guess_archive_numsrc/transaction/log_page_buffer.c4558
logpb_get_archive_num_from_info_tablesrc/transaction/log_page_buffer.c6386
LOG_REPLICATION_DATAsrc/transaction/log_record.hpp116
LOG_REPLICATION_STATEMENTsrc/transaction/log_record.hpp117
log_rec_replicationsrc/transaction/log_record.hpp228
struct log_rec_replicationsrc/transaction/log_record.hpp228
logtb_free_tran_indexsrc/transaction/log_tran_table.c1202
logtb_clear_tdessrc/transaction/log_tran_table.c1493
logtb_initialize_tdessrc/transaction/log_tran_table.c1614
LOGWR_COPY_LOG_BUFFER_NPAGESsrc/transaction/log_writer.c70
logwr_to_physical_pageidsrc/transaction/log_writer.c195
logwr_initializesrc/transaction/log_writer.c428
logwr_set_hdr_and_flush_infosrc/transaction/log_writer.c639
logwr_writev_append_pagessrc/transaction/log_writer.c838
logwr_flush_all_append_pagessrc/transaction/log_writer.c1016
logwr_archive_active_logsrc/transaction/log_writer.c1275
logwr_write_log_pagessrc/transaction/log_writer.c1512
logwr_copy_log_filesrc/transaction/log_writer.c1659
logwr_load_tdesrc/transaction/log_writer.c1834
logwr_check_page_checksumsrc/transaction/log_writer.c1978
logwr_pack_log_pagessrc/transaction/log_writer.c2263
xlogwr_get_log_pagessrc/transaction/log_writer.c2571
LOGWR_CONTEXTsrc/transaction/log_writer.h41
LOGWR_MODEsrc/transaction/log_writer.h48
LOGWR_COPY_FROM_FIRST_PHY_PAGE_MASKsrc/transaction/log_writer.h55
LOGWR_GLOBALsrc/transaction/log_writer.h69
LOGWR_ENTRYsrc/transaction/log_writer.h142
LOGWR_INFOsrc/transaction/log_writer.h157
wr_list_mutexsrc/transaction/log_writer.h160
RVREPL_DATA_INSERTsrc/transaction/recovery.h149
RVREPL_DATA_UPDATEsrc/transaction/recovery.h150
RVREPL_DATA_DELETEsrc/transaction/recovery.h151
RVREPL_STATEMENTsrc/transaction/recovery.h152
RVREPL_DATA_UPDATE_STARTsrc/transaction/recovery.h153
RVREPL_DATA_UPDATE_ENDsrc/transaction/recovery.h154
REPL_LOG_IS_NOT_EXISTSsrc/transaction/replication.c43
REPL_LOG_IS_FULLsrc/transaction/replication.c45
REPL_LOG_INFO_ALLOC_SIZEsrc/transaction/replication.c49
repl_log_info_allocsrc/transaction/replication.c165
repl_add_update_lsasrc/transaction/replication.c229
repl_log_insertsrc/transaction/replication.c293
repl_log_insert rcvindex refinementsrc/transaction/replication.c344
repl_log_insert payload packingsrc/transaction/replication.c363
repl_log_insert lsa switchsrc/transaction/replication.c428
repl_log_insert flush-mark tailsrc/transaction/replication.c466
repl_log_insert_statementsrc/transaction/replication.c512
repl_start_flush_marksrc/transaction/replication.c606
repl_end_flush_marksrc/transaction/replication.c635
repl_log_abort_after_lsasrc/transaction/replication.c673
REPL_INFO_TYPEsrc/transaction/replication.h43
REPL_INFO_TYPEsrc/transaction/replication.h49
repl_infosrc/transaction/replication.h52
repl_info_statementsrc/transaction/replication.h60
log_repl_flushsrc/transaction/replication.h70
enum log_repl_flushsrc/transaction/replication.h70
log_replsrc/transaction/replication.h79
log_repl (LOG_REPL_RECORD)src/transaction/replication.h79
repl_datasrc/transaction/replication.h85
tde_encryptedsrc/transaction/replication.h88
  • cubrid-ha-replication.md — 상위 수준 동반 문서. 관련 문서로 cubrid-log-manager-detail.md(전송 대상 로그)와 cubrid-heartbeat.md(failover 제어)도 함께 참조한다.
  • 원본 분석 자료: raw/code-analysis/cubrid/distributed/ 하위 파일들.
  • 코드: src/transaction/log_applier.{c,h}, log_writer.{c,h}, replication.{c,h}; 마스터 로그는 log_manager.c.
  • 방법론: knowledge/methodology/code-analysis-detail-doc.md.