Skip to content

CUBRID HA Replication — Code-Level Deep Dive

Where this document fits: The high-level analysis cubrid-ha-replication.md covers design intent and theoretical background. This document traces every branch and field at the code level. Each chapter is self-contained, but reading in order follows the full lifecycle of a single replicated change — from master log production, through copylogdb shipping, to applylogdb apply on the replica.

Contents:

ChTitleStatus
1Data Structure Map
2Master Side Initialization and the Staging Array
3Emitting a Replication Record During DML
4Statement Based Emission and Flush Marks
5Commit Time Flush and Atomic Emission
6Shipping Log Pages with copylogdb
7applylogdb Initialization and Log Fetch
8Per Record Dispatch and Per Transaction Buffering
9Applying a Committed Transaction and Reconstructing the Row Image
10Durable Apply Bookmark and Restart Recovery
11Special Paths Long Transactions Role Change Filters and TDE

A replicated change is repackaged five times between a master DML statement and a re-executed row on the slave. This chapter maps every struct and field, and the byte-for-byte correspondence between the master staging entry and the slave apply item. The why of the shape — emission at DML time, flush at commit, per-transaction buffering on the slave — is argued in the high-level companion (cubrid-ha-replication.md); here we only catalog the structures.

The five hops, each owned by one struct: (1) DML thread — LOG_TDES.repl_records[] of LOG_REPL_RECORD; (2) on-disk log — LOG_REC_REPLICATION + packed repl_data; (3) copylogdb — raw log pages, no decode; (4) applylogdb decode — LA_ITEM; (5) applylogdb buffer — LA_APPLY per transaction + the LA_COMMIT queue. Transitions: repl_log_insert (stage) → log_append_repl_info_internal (flush) → ship → la_make_repl_item (decode) → la_set_repl_log (buffer). Figure 1-1 maps the field-level edges.

Figure 1-1 — Struct relationship and field-level edges across the five hops

1.1 Master staging — the LOG_TDES replication group

Section titled “1.1 Master staging — the LOG_TDES replication group”

LOG_TDES (in log_impl.h) carries a realloc-grown array of staged records, two cursors that drive it, a flush-mark index, and two LSA back-patch slots:

// 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 */

The two int fields have near-identical comments but different roles — read them against their users, not the comments. repl_log_insert writes at repl_records[cur_repl_record] then cur_repl_record++; the commit-time log_append_repl_info_internal loop runs while (append_repl_recidx < cur_repl_record). So cur_repl_record is the staging write cursor and append_repl_recidx is the flush read cursor.

FieldRoleWhy it exists
repl_recordsHeap array of LOG_REPL_RECORD, grown by repl_log_info_alloc.Holds the transaction’s staged records, flushed to the log at commit.
num_repl_recordsAllocated capacity (slots). REPL_LOG_IS_FULLnum_repl_records == cur_repl_record + 1.Lets staging grow vs. reuse the array; bounds the realloc trigger.
cur_repl_recordStaging write cursor / count of records staged. Next append lands at repl_records[cur_repl_record], then cur_repl_record++.The high-water mark every staging loop walks 0 .. cur_repl_record-1.
append_repl_recidxFlush read cursor. -1 before any flush; set to 0 on first flush or any commit; advances to cur_repl_record.An incremental flush resumes where it left off; commit rewinds it to 0 to re-scan all records.
fl_mark_repl_recidxIndex where flush-marking opened (= cur_repl_record at repl_start_flush_mark); -1 if no open mark.Lets repl_end_flush_mark rewind the suffix [fl_mark_repl_recidx, cur_repl_record) on undo (Ch 4).
repl_insert_lsaTarget LSA of latest INSERT/MVCC-update heap record.Back-patches the repl record’s lsa once the heap LSA is known.
repl_update_lsaTarget LSA of latest in-place UPDATE heap record.Same back-patch, in-place path (repl_add_update_lsa, Ch 3).
suppress_replicationAdjacent counter; non-zero makes repl_log_insert return early.Catalog/internal ops avoid replicating; also clears the two repl_*_lsa slots.

Invariant — staging vs. flush cursors. During staging, 0 <= fl_mark_repl_recidx <= cur_repl_record < num_repl_records (with fl_mark_repl_recidx == -1 when no mark is open), enforced by the REPL_LOG_IS_FULL realloc check before every append in repl_log_insert. The flush cursor append_repl_recidx is independent: -1 until the first flush, then advanced by log_append_repl_info_internal up to cur_repl_record and never past it (the loop guard is <). If a future edit let cur_repl_record reach num_repl_records without reallocating, the next append would write one slot past the array and the flush loop would ship an out-of-bounds record’s garbage repl_data to the slave.

Each repl_records slot is one LOG_REPL_RECORD (struct log_repl): the in-memory form of one change before it is logged.

// 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 */
};
FieldRoleWhy it exists
repl_typeLOG_REPLICATION_DATA (row) or LOG_REPLICATION_STATEMENT (DDL).Flush picks the log-record header and apply branch from it.
rcvindexOp index: RVREPL_DATA_* / RVREPL_STATEMENT (1.6).Copied to LOG_REC_REPLICATION.rcvindex, then LA_ITEM.item_type (1.7).
inst_oidOID of the affected instance.Correlates with the heap row; used to coalesce the in-place bracket.
lsaLSA the record refers to / target heap LSA.Filled from the repl_*_lsa back-patch slots per rcvindex.
repl_dataPacked payload (len + class + pkey, or DDL fields). Layout in 1.9.Built once in repl_log_insert, shipped verbatim; NULL for STATEMENT.
lengthByte length of repl_data.Drives malloc, on-disk length, slave read-back.
must_flushA LOG_REPL_FLUSH (1.3).COMMIT_NEED_FLUSH from staging; flush loop resets to DONT_NEED_FLUSH.
tde_encryptedSource class is TDE-protected.Routes the prior node through TDE encryption (Ch 11).

Cross-check — stale comment. The repl_type field comment names LOG_REPLICATION_SCHEMA, but no such LOG_RECTYPE exists. The real value set via log_type is LOG_REPLICATION_DATA / LOG_REPLICATION_STATEMENT (1.5). The comment lags a rename; the table above reflects the live enum.

1.3 LOG_REPL_FLUSH — the per-record flush policy

Section titled “1.3 LOG_REPL_FLUSH — the per-record flush policy”
// 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 */
};
ValueRole
LOG_REPL_DONT_NEED_FLUSH (-1)Skip on flush. Set by repl_log_abort_after_lsa (savepoint rollback retires records past a savepoint LSA) and by log_append_repl_info_internal after a record is appended (so a later incremental pass does not re-append it).
LOG_REPL_COMMIT_NEED_FLUSH (0)Default for staged rows: append only when the transaction commits.
LOG_REPL_NEED_FLUSH (1)Append at commit and rollback; set inside a flush-mark for records that must reach the slave regardless (Ch 4).

The flush gate in log_append_repl_info_internal is exactly: (is_commit && must_flush != DONT_NEED_FLUSH) || must_flush == NEED_FLUSH.

1.4 REPL_INFO_TYPE, REPL_INFO, REPL_INFO_SBR — the statement-based carrier

Section titled “1.4 REPL_INFO_TYPE, REPL_INFO, REPL_INFO_SBR — the statement-based carrier”

Statement-based replication (DDL, schema, catalog changes) does not build a LOG_REPL_RECORD from a heap change; it travels in a client-supplied carrier tagged by 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;
ValueRole
REPL_INFO_TYPE_SBRStatement payload in REPL_INFO_SBR; replayed by re-executing the SQL.
REPL_INFO_TYPE_RBR_STARTFirst record of an in-place UPDATE bracket; maps RVREPL_DATA_UPDATERVREPL_DATA_UPDATE_START in repl_log_insert.
REPL_INFO_TYPE_RBR_NORMALStandalone row change (INSERT/DELETE/MVCC-update); leaves rcvindex unchanged.
REPL_INFO_TYPE_RBR_ENDCloses the bracket; maps RVREPL_DATA_UPDATERVREPL_DATA_UPDATE_END.

REPL_INFO is the generic wrapper; its info field downcasts to a payload such as 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 fields:

FieldRoleWhy it exists
infoDowncast pointer to the payload (e.g. REPL_INFO_SBR).One wrapper carries any info kind across the client boundary.
repl_info_typeREPL_INFO_TYPE selecting how to read info.Discriminator for interpreting info.
need_replicationGate checked before staging.Lets the client suppress replication for a statement.

REPL_INFO_SBR fields:

FieldRoleWhy it exists
statement_typeThe CUBRID_STMT_* kind.Packed first, lands in LA_ITEM.item_type for filters (1.7).
nameAffected object name.Identifies the schema object the DDL targets.
stmt_textThe DDL SQL to re-execute.Slave replays the statement verbatim (into LA_ITEM.key).
db_userOwning DB user.Slave re-executes under the master’s identity.
sys_prm_contextSession parameter context.Slave re-executes under the master’s parameters.

1.5 The on-disk header — LOG_REC_REPLICATION

Section titled “1.5 The on-disk header — LOG_REC_REPLICATION”

On flush, the log gets a fixed header followed by the repl_data bytes:

// log_rec_replication -- src/transaction/log_record.hpp
struct log_rec_replication
{
LOG_LSA lsa;
int length;
int rcvindex;
};
FieldRoleWhy it exists
lsaTarget LSA (from LOG_REPL_RECORD.lsa); set NULL for DELETE/STATEMENT.Slave orders and correlates with the data change (becomes LA_ITEM.target_lsa).
lengthByte length of the trailing repl_data.Slave reads exactly this many bytes back (1.9).
rcvindexRVREPL_DATA_* / RVREPL_STATEMENT (1.6).Tells the slave how to decode and which apply branch.

The parent LOG_RECTYPE is LOG_REPLICATION_DATA = 39 or LOG_REPLICATION_STATEMENT = 40 (log_record.hpp), copied from repl_type.

1.6 RVREPL_DATA_* — the recovery-index dispatch keys

Section titled “1.6 RVREPL_DATA_* — the recovery-index dispatch keys”
// 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,
ValueRole
RVREPL_DATA_INSERTRow INSERT; slave inserts the reconstructed image.
RVREPL_DATA_UPDATEMVCC (out-of-place) UPDATE, single record; slave updates by pkey.
RVREPL_DATA_DELETERow DELETE; slave deletes by pkey (payload key-only).
RVREPL_STATEMENTDDL/schema; slave re-executes the SQL.
RVREPL_DATA_UPDATE_STARTOpens the in-place UPDATE bracket (before-image).
RVREPL_DATA_UPDATE_ENDCloses it (after-image); applied atomically (Ch 9).

These six are the join key: rcvindex is set in LOG_REPL_RECORD, written to LOG_REC_REPLICATION.rcvindex, then copied into LA_ITEM.item_type on the slave.

la_make_repl_item unpacks one LOG_REC_REPLICATION + payload into an LA_ITEM — the inverse of master packing.

// 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 */
};
FieldRoleWhy it exists
next / prevList pointers in an LA_APPLY list (1.8).Items accumulate until the commit record arrives.
log_typeLOG_REPLICATION_DATA or _STATEMENT.Selects the unpack branch and apply path.
item_typeDATA: RVREPL_DATA_* (from header rcvindex). STATEMENT: CUBRID_STMT_* (unpacked first).The apply / statement-filter discriminator (Ch 11).
class_nameTarget class name.Names the table; drives class-level filters.
db_userDB user (STATEMENT only).Re-execute DDL as the original user.
ha_sys_prmSession parameters (STATEMENT only).Re-execute DDL under the same parameters.
packed_key_value_lengthPacked pkey byte length (DATA only).Drives malloc/memcpy.
packed_key_valueDisk image of the pkey DB_VALUE (DATA only).Kept packed; unpacked into key lazily.
keyUnpacked pkey DB_VALUE (DATA); statement text via db_make_string (STATEMENT).Slave lookup key, or the SQL to re-execute.
lsaLSA of this replication record.Position bookkeeping / dedup.
target_lsaLSA of the data record (repl_log->lsa), set in la_new_repl_item.Correlates the item with its data change.

Invariant — lazy key unpack. For a DATA item, key is meaningless until packed_key_value is unpacked; apply does this on first use under log_type == LOG_REPLICATION_DATA && DB_IS_NULL (&item->key). Freeing packed_key_value before that point leaves apply unpacking a dangling buffer. (For a STATEMENT item the relationship inverts: key is filled at decode time and packed_key_value stays NULL.)

1.8 Slave buffering — LA_APPLY, LA_COMMIT, LA_INFO

Section titled “1.8 Slave buffering — LA_APPLY, LA_COMMIT, LA_INFO”

LA_ITEMs buffer per transaction in an LA_APPLY; commits queue in LA_COMMIT; the global LA_INFO (la_Info) owns both.

// 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 fields:

FieldRoleWhy it exists
tranidMaster transaction id of this list.Records fan into per-transaction lists keyed by tranid (la_find_apply_list).
num_itemsCount of buffered LA_ITEMs.Triggers the long-transaction cutover at LA_MAX_REPL_ITEMS.
is_long_transList exceeded the cap (see invariant).Switches apply to re-fetch-from-log instead of replaying buffered items.
start_lsaLSA of the transaction’s first record.Re-scan start for a long transaction.
last_lsaLSA of the latest record seen.Updated even while is_long_trans.
head / tailEnds of the LA_ITEM list.Append at tail, replay from head.

LA_COMMIT fields:

FieldRoleWhy it exists
next / prevLink the global commit queue.Commit records apply FIFO across transactions.
typeTermination kind (LOG_COMMIT).Distinguishes commit from other terminations.
tranidSelects the LA_APPLY to flush.Ties the commit to its buffer.
log_lsaLSA of the commit record.The durable apply bookmark advances to it (Ch 10).
log_record_timeMaster commit timestamp (eot_time).Replication-delay reporting.

Invariant — bounded buffering (one-way latch). A list holds at most LA_MAX_REPL_ITEMS (1000) items. On reaching it la_set_repl_log calls la_free_all_repl_items_except_head, sets is_long_trans = true, and thereafter only copies last_lsa — it never appends another item. The latch is cleared only when the transaction is fully applied (is_long_trans = false in the apply path). Without it, one large transaction could exhaust slave memory holding decoded items.

LA_INFO is the singleton (la_Info). Its replication-relevant fields:

FieldRoleWhy it exists
repl_listsArray of LA_APPLY * (capacity repl_cnt, cursor cur_repl).Per-transaction buffers, searched by tranid; grown by la_init_repl_lists.
commit_headHead of the LA_COMMIT queue.Commits dequeue FIFO from the head.
commit_tailTail of the LA_COMMIT queue.New commits enqueue at the tail.

Remaining LA_INFO fields are dissected where used: the LSA bookmark group (final_lsa, committed_lsa, required_lsa) in Ch 10, fetch/cache (act_log, cache_pb, log_data) in Ch 7, filter/role (repl_filter, is_role_changed) in Ch 11.

1.9 Field-level correspondence: repl_dataLA_ITEM

Section titled “1.9 Field-level correspondence: repl_data ↔ LA_ITEM”

The most error-prone hop is the payload layout: the master packs repl_data in repl_log_insert, the slave unpacks it in la_make_repl_item, and they must agree byte for byte. For a LOG_REPLICATION_DATA record:

// 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 region (master)LA_ITEM field (slave)Notes
leading OR_INT_SIZE int = packed_key_lenpacked_key_value_lengthReserved first, back-filled after packing the value.
or_pack_string_with_length(class_name)class_name (via or_unpack_string)Length-prefixed string.
or_pack_mem_value(key_dbvalue) (8-byte aligned)packed_key_value (PTR_ALIGN + memcpy), later → keyPTR_ALIGN to MAX_ALIGNMENT must match the master’s or_pack_mem_value padding or the pkey decodes wrong.
(header) rcvindexitem_typeCopied from repl_log->rcvindex; the apply discriminator.

Invariant — pack/unpack symmetry. The order (int length, class string, aligned pkey value) is hand-coded in two files with no embedded schema. The master sizes the buffer via OR_INT_SIZE + or_packed_string_length + OR_VALUE_ALIGNED_SIZE; the slave re-derives the pkey offset with PTR_ALIGN(ptr, MAX_ALIGNMENT). A pack-order or alignment change in repl_log_insert not mirrored in la_make_repl_item corrupts every replicated row, silently.

A LOG_REPLICATION_STATEMENT record packs instead: item_type (a CUBRID_STMT_* int), class_name, statement text (or_unpack_stringdb_make_string into key), db_user, ha_sys_prm — the unpacked images of REPL_INFO_SBR’s five fields (1.4).

  1. Five structures hold the change across two hosts: LOG_TDES.repl_records[]LOG_REC_REPLICATION + packed repl_data → raw copylogdb pages → LA_ITEMLA_APPLY / LA_COMMIT.
  2. The master array uses two distinct cursors: cur_repl_record is the staging write cursor (every staging loop runs 0 .. cur_repl_record-1), while append_repl_recidx is the flush read cursor (-1 until the first flush, advanced toward cur_repl_record). The repl_*_lsa slots back-patch a record’s lsa once the heap LSA is known.
  3. must_flush (LOG_REPL_FLUSH) decides survival: commit-only for rows (COMMIT_NEED_FLUSH), commit-and-rollback inside a flush mark (NEED_FLUSH), retired after savepoint rollback or after being appended (DONT_NEED_FLUSH).
  4. rcvindex (RVREPL_DATA_* / RVREPL_STATEMENT) is the join key: set in LOG_REPL_RECORD, written to LOG_REC_REPLICATION, copied into LA_ITEM.item_type, switched on by every apply branch.
  5. The slave decodes lazily (LA_ITEM.key unpacked only at apply time), and is_long_trans is a one-way latch: past LA_MAX_REPL_ITEMS (1000) the list is freed to its head and the transaction replays by re-scanning start_lsa..last_lsa from the log.
  6. The repl_data layout (int length, class string, 8-byte-aligned pkey) is hand-coded symmetrically in repl_log_insert / la_make_repl_item; with no self-describing schema, any unmirrored pack-order or alignment change corrupts rows silently.

Chapter 2: Master Side Initialization and the Staging Array

Section titled “Chapter 2: Master Side Initialization and the Staging Array”

Before any DML can ship a row image to a replica, the master must stage the per-transaction stream of replication records somewhere — the tdes->repl_records[] array on every transaction descriptor. This chapter traces that memory’s lifecycle (born lazily on the first write, grown in 100-slot chunks, reset across transaction boundaries), how suppress_replication short-circuits it, and the invariant that the master writes nothing replication-related into the WAL until commit (Chapter 5). Assumes the staging-array overview and master/replica roles from the high-level companion (cubrid-ha-replication.md, “Master-side log generation”); we trace how the array is managed, not why row-based replication stages per transaction.

2.1 The staging fields and where they live

Section titled “2.1 The staging fields and where they live”

The staging array is described by four integer fields and one pointer on LOG_TDES (log_impl.h), introduced in Chapter 1’s data-structure map. The slot payload is a LOG_REPL_RECORD (struct log_repl, replication.h); here we focus on the descriptor-side cursor semantics that govern allocation and reset.

FieldRoleWhy it exists
repl_recordsPointer to the heap LOG_REPL_RECORD array (or NULL)The staging buffer; NULL means no write has happened yet on this (fresh or freshly-freed) descriptor
num_repl_recordsAllocated capacity (slot count)Separates “how big” from “how full”; drives the grow decision
cur_repl_recordSlots in use — also the write cursorrepl_log_insert writes at repl_records[cur_repl_record], then post-increments
append_repl_recidxCursor for the commit-time emit pass into the WALLets emit resume mid-array; -1 = emit never ran this transaction
fl_mark_repl_recidxIndex where statement flush marking began, or -1Drives the must_flush upgrade logic (Chapter 4); -1 = no flush mark

Three of these are index cursorscur_repl_record (fill), append_repl_recidx (emit), fl_mark_repl_recidx (flush mark); repl_records is the buffer pointer and num_repl_records the capacity. The array splits at cur_repl_record: slots [0 .. cur-1] used, [cur .. num-1] free. The gap to num_repl_records is the free space the next insert consumes; when it closes the array is “full” and must grow.

Invariant (fill cursor never exceeds capacity). 0 <= cur_repl_record <= num_repl_records holds at all times, enforced structurally: repl_log_insert calls repl_log_info_alloc before writing whenever the array is full, so a slot is always present at repl_records[cur_repl_record] before the post-increment. Violate it and the write &tdes->repl_records[tdes->cur_repl_record] indexes past the allocation, corrupting the heap. The “full” test (Section 2.3) fires one slot early precisely to keep this safe.

2.2 Birth and reset: the staging fields across transaction boundaries

Section titled “2.2 Birth and reset: the staging fields across transaction boundaries”

The fields have three lifecycle events in three functions. The distinction that matters: which zeroes the pointer versus which merely rewinds the cursors — that is what lets the buffer survive across transactions on a reused descriptor.

(a) Descriptor creation — logtb_initialize_tdes (log_tran_table.c). Run once when a LOG_TDES slot is set up — the only place repl_records is nulled and capacity zeroed:

// 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) Transaction end / reuse — logtb_clear_tdes (log_tran_table.c). Run at every commit or abort to recycle the descriptor. It frees each record’s payload but keeps the array allocation for the next transaction — it does not touch repl_records or num_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 ...

Because pointer and capacity persist, a transaction that grew the array to 300 slots leaves them warm: REPL_LOG_IS_NOT_EXISTS (Section 2.3) reads num_repl_records != 0 and the lazy first-allocation is skipped. The freed slots’ repl_data pointers are re-nulled only for newly grown slots by repl_log_info_alloc (Section 2.4); the slots below cur_repl_record that a prior transaction filled retain dangling repl_data pointers until overwritten — but cur_repl_record was rewound to 0 here, so no read sees them before repl_log_insert overwrites the field.

(c) Descriptor teardown — logtb_free_tran_index (log_tran_table.c). Called when the transaction index is released — the only place the array memory returns to the heap. Note it calls logtb_clear_tdes first (freeing payloads), then frees the array shell:

// 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;

Invariant (capacity and pointer move together). repl_records == NULL iff num_repl_records == 0. logtb_initialize_tdes, logtb_free_tran_index, and repl_log_info_alloc (on success) all set both consistently; logtb_clear_tdes touches neither. Null the pointer without zeroing capacity (or vice versa) and REPL_LOG_IS_NOT_EXISTS would misclassify the buffer, leaking the allocation or dereferencing NULL.

A fourth event — append_repl_recidx reset to 0 — happens not here but at commit-time emit in log_append_repl_info_internal (log_manager.c), where if (tdes->append_repl_recidx == -1 || is_commit) rewinds the emit cursor (Chapter 5). Shown only to complete the lifecycle (Figure 2-1).

stateDiagram-v2
  [*] --> Uninit: logtb_initialize_tdes
  Uninit --> Allocated: first repl_log_insert -> malloc
  Allocated --> Allocated: array full -> realloc grow
  Allocated --> Emitted: commit -> log_append_repl_info_internal
  Emitted --> Recycled: logtb_clear_tdes rewinds cursors keeps buffer
  Recycled --> Allocated: next txn reuses warm buffer
  Recycled --> Freed: logtb_free_tran_index frees buffer
  Uninit --> Freed: logtb_free_tran_index
  Freed --> [*]

Figure 2-1. Staging-array lifecycle across descriptor reuse. The Recycled -> Allocated edge is why most transactions skip malloc.

2.3 The two macro guards: NOT_EXISTS and FULL

Section titled “2.3 The two macro guards: NOT_EXISTS and FULL”

Two macros classify the array’s state at every insert, reading the descriptor through the global trantable by tran_index:

// 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 is true when capacity is zero — no buffer ever allocated for this (fresh or freshly-freed) descriptor — and triggers the lazy initial malloc. REPL_LOG_IS_FULL is true when the fill cursor is one short of capacity (num_repl_records == cur_repl_record+1); the +1 is the slot-early test from Section 2.1, guaranteeing a free slot for the insert about to happen. The two are mutually exclusive — a zero-capacity array cannot be “full” — so repl_log_insert tests them with if / else if.

repl_log_info_alloc is the only function that touches repl_records and num_repl_records together. Its need_realloc parameter selects initial malloc vs grow-by-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;
}

Four outcomes cover every branch. A (need_realloc == false): malloc; on success capacity = arr_size, k = 0. A1 (malloc fails): set ER_REPL_ERROR and return — num_repl_records stays 0, pointer stays NULL, so NOT_EXISTS still holds. B (need_realloc == true): grow capacity to old + arr_size, k = old capacity. B1 (realloc fails): set ER_REPL_ERROR and return; the old block leaks (see below). Both success paths run the tail loop for (i = k; ...) repl_records[i].repl_data = NULL;. The k variable is the crux: 0 for a fresh malloc initializes everything; the old capacity for a realloc initializes only the grown tail, leaving staged records untouched. Nulling only repl_data suffices because repl_log_insert fills every other field (repl_type, rcvindex, inst_oid, lsa, length, must_flush, tde_encrypted) before it is read, and repl_data’s NULL-ness is what guards the free in logtb_clear_tdes.

Invariant (every slot’s repl_data is a valid heap pointer or NULL). Nulled here at allocation, set to a malloc’d buffer or NULL in repl_log_insert, and freed in logtb_clear_tdes under if (repl_records[i].repl_data) — an uninitialized grown slot would make that free hit garbage. The tail loop upholds it for grown slots, the per-insert assignment for filled ones. Caveat: branch B1 is the classic p = realloc(p, …) anti-pattern — on failure the original block survives but its only pointer is overwritten with NULL, leaking it. Benign here (OOM precedes abort and process cleanup) but do not preserve it.

2.5 repl_log_insert — the allocation branches and the suppress short-circuit

Section titled “2.5 repl_log_insert — the allocation branches and the suppress short-circuit”

repl_log_insert is dissected for emission in Chapter 3; here, only its entry guards — the suppress short-circuit and the allocation branches preceding any record write.

// 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 traces every branch. The key non-obvious point: the two allocation guards fire at most one repl_log_info_alloc because the macros are mutually exclusive (Section 2.3) and && invokes the allocator only when its guard holds — G3/G4 propagate failure, the non-allocating cases fall through, and a buffer that already has room skips both. The same allocation prologue appears verbatim in repl_log_insert_statement (Chapter 4); only repl_log_insert carries the no_logging early-out at G2.

flowchart TD
  A["repl_log_insert entry"] --> B{"tdes == NULL?"}
  B -- yes --> R0["return ER_FAILED (G0)"]
  B -- no --> C{"suppress_replication != 0?"}
  C -- yes --> S["NULL both repl LSAs<br/>return NO_ERROR (G1)"]
  C -- no --> D{"no_logging AND no flush mark?"}
  D -- yes --> R2["return NO_ERROR (G2)"]
  D -- no --> E{"NOT_EXISTS?"}
  E -- yes --> F["alloc(malloc)"]
  F --> F2{"error?"}
  F2 -- yes --> R3["return error (G3)"]
  F2 -- no --> J["slot = repl_records[cur_repl_record]"]
  E -- no --> G{"IS_FULL?"}
  G -- yes --> H["alloc(realloc grow)"]
  H --> H2{"error?"}
  H2 -- yes --> R4["return error (G4)"]
  H2 -- no --> J
  G -- no --> J
  J --> K["fill record (Chapter 3)"]

Figure 2-2. Entry guards of repl_log_insert; both allocation branches converge on the same safe slot access.

Invariant (suppress nulls the staging LSAs, never the array). When suppress_replication != 0, repl_log_insert clears repl_insert_lsa / repl_update_lsa but leaves repl_records and the cursors alone. Suppression is a per-operation gate a transaction may toggle, so nulling the LSAs stops a later non-suppressed insert in the same transaction from inheriting a stale repl_insert_lsa and stamping a record with the wrong WAL address (Chapter 3: the insert path copies repl_insert_lsa into repl_rec->lsa). The same flag governs repl_log_insert_statement (Chapter 4) and repl_add_update_lsa — the latter returns immediately under suppression without nulling the LSAs, since there is no staged update record to back-patch.

Invariant (no LOG_REPLICATION_DATA in the WAL until commit). Everything here writes to process-private heap memory (tdes->repl_records[]), never to the log buffer or disk. Staged records become real replication WAL records only at commit, when log_append_repl_info_internal walks the array from append_repl_recidx (Chapter 5); enforcement is structural — no call site appends one outside that path. An aborting transaction simply has its staging payloads freed in logtb_clear_tdes, so its row images never reached the WAL and can never reach a replica. Replication is thus atomic with commit by construction; an eager DML-side append would leak phantom changes on abort, silently diverging the replica.

  1. Lazily allocated, warm-reused. repl_records is NULL until the first write triggers repl_log_info_alloc(..., false); logtb_clear_tdes rewinds cursors but keeps the buffer, so later transactions on the descriptor skip the malloc.
  2. Three functions own three lifecycle events. logtb_initialize_tdes nulls the pointer and zeroes capacity; logtb_clear_tdes frees payloads and rewinds the cursors while keeping the allocation; logtb_free_tran_index alone frees the array shell (after calling logtb_clear_tdes).
  3. Capacity and fill are separate. num_repl_records is capacity, cur_repl_record the fill cursor; the “full” macro fires one slot early (num == cur+1) to guarantee a slot for the in-flight insert.
  4. repl_log_info_alloc grows by 100, initializes only new slots. The k variable bounds the repl_data = NULL tail loop so staged records survive a grow; the realloc-on-OOM path leaks the old block.
  5. suppress_replication short-circuits before any allocation, nulling the staging LSAs so a later non-suppressed insert cannot inherit a stale LSA.
  6. Nothing reaches the WAL until commit — staging is private heap memory, converted to WAL records only by the commit-time emit pass (Chapter 5), making replication atomic with commit.

Chapter 3: Emitting a Replication Record During DML

Section titled “Chapter 3: Emitting a Replication Record During DML”

This chapter answers one question: when a row is inserted, updated, or deleted on the master, exactly which call path stages a LOG_REPL_RECORD, and how does the staged record differ per operation kind? The high-level companion (cubrid-ha-replication.md, “Row-based replication” section) explains why CUBRID replicates the primary key rather than the physical row image. Here we trace how the key is captured, where the back-patch happens for UPDATE, and every branch the emission code takes.

The staging array (tdes->repl_records[]) and LOG_REPL_RECORD were mapped in Chapter 1; Chapter 2 covered allocation. We trace the writers into the array, not the array itself.

3.1 The emission topology — one funnel, three call paths

Section titled “3.1 The emission topology — one funnel, three call paths”

All server-side DML arrives at locator_attribute_info_force, which rebuilds the record image from the attribute-info copyarea, switches on the flush operation, and dispatches to locator_insert_force, locator_update_force (carrying the caller-supplied repl_info), or locator_delete_force — the public wrapper that forwards to locator_delete_force_internal; an unknown operation raises ER_LC_BADFORCE_OPERATION.

The key architectural fact: the replication record is staged at the index layer, not the heap layer. All three force routines call locator_add_or_remove_index / locator_update_index, and those call repl_log_insert — the one place the primary-key DB_VALUE is already materialized for the B-tree op, so capturing it costs nothing extra.

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 or DELETE"| RLI["repl_log_insert\n(stages LOG_REPL_RECORD)"]
  LUI -->|"PRIMARY_KEY, UPDATE"| RLI

Figure 3-1 — The emission funnel. INSERT and DELETE stage inside locator_add_or_remove_index; UPDATE stages inside locator_update_index then back-patches the LSA after heap_update_logical via repl_add_update_lsa.

3.2 The three force routines — emission-relevant branches

Section titled “3.2 The three force routines — emission-relevant branches”

The force routines do not call repl_log_insert themselves; they establish the heap log and the need_replication state that the index pass depends on. INSERT and DELETE run the heap pass before the index pass (so the heap LSA is in hand when staging); UPDATE inverts the order — index pass first, heap pass second — which is what forces the back-patch. The branches that matter to replication, across all three:

Routine / armEffect on replication
insert: partition_prune_insert fail to goto error2nothing staged
insert: heap_insert_logical okits RVHF_INSERT/RVHF_MVCC_INSERT heap log makes the log-append path stamp repl_insert_lsa; on error no LSA, no index pass
insert: has_index && add_or_remove_index (... true ...)is_insert = true index pass stages INSERT record
delete: isold_object == falseOID_SET_NULL; instance block skipped — nothing staged
delete: isold_object && OID_IS_ROOTOIDclass delete — catalog path, not the instance index pass
delete: heap_delete_logical okappends heap-delete log, advancing tdes->tail_lsa; the MVCC-disabled skip writes no heap log so tail_lsa is unchanged (3.4 fallback to prior_lsa); on error nothing staged
delete: idx_action_flag == FOR_INSERT_OR_DELETE to add_or_remove_index (... false ...)is_insert = false index pass stages DELETE record
delete: else to ..._index_for_moving (... false ...)partition-move — deletes index entry without staging
update: locator_update_index (earlier in body)stages UPDATE record with NULL lsa; sets/clears repl_info.need_replication
update: heap_update_logical okits RVHF_UPDATE-family heap log makes the log-append path stamp repl_update_lsa; on error the NULL-lsa record is never patched
update: !LOG_CHECK_LOG_APPLIER && log_does_allow_replication () && repl_info.need_replicationguard gating repl_add_update_lsa (thread_p, oid)

3.3 INSERT and DELETE — staging inside locator_add_or_remove_index

Section titled “3.3 INSERT and DELETE — staging inside locator_add_or_remove_index”

Both locator_insert_force and locator_delete_force_internal land in locator_add_or_remove_index_internal, which loops over every B-tree of the class, does the physical index op, and — only for the primary key — stages the replication record:

// 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 */
}

Four guards must all hold: need_replication false means the class is non-replicated (e.g. no PK); a non-BTREE_PRIMARY_KEY index is handled by another loop iteration; error_code != NO_ERROR means the B-tree op failed (do not stage a failed change); and the LOG_CHECK_LOG_APPLIER / log_does_allow_replication () pair short-circuits when we are the applier replaying or replication is globally off. INSERT and DELETE hard-code REPL_INFO_TYPE_RBR_NORMAL (single-row ops are never START/END bracketed — Section 3.5); only rcvindex differs (RVREPL_DATA_INSERT vs RVREPL_DATA_DELETE).

Invariant — a row replication record is staged exactly once per row, against the primary-key index only. Enforced by the index->type == BTREE_PRIMARY_KEY guard in the per-index loop. Drop it and a table with N indexes stages N copies; a PK-less table stages nothing (need_replication/PK short-circuit), so it is silently non-replicated.

3.4 repl_log_insert — the staging core, branch by branch

Section titled “3.4 repl_log_insert — the staging core, branch by branch”

repl_log_insert is the single writer into tdes->repl_records[]. Its signature carries the op kind (rcvindex) and the multi-update refinement (repl_info). It proceeds through five phases; Figure 3-2 traces every branch.

flowchart TB
  A["tdes == NULL?"] -->|yes| RET1["return ER_FAILED"]
  A -->|no| B["suppress_replication?"]
  B -->|yes| C["null both pending LSAs; return"]
  B -->|no| D["no_logging AND no flush-mark?"]
  D -->|yes| E["return NO_ERROR"]
  D -->|no| F["array missing/full -> repl_log_info_alloc"]
  F -->|fail| RET2["return error"]
  F -->|ok| G["P3: rcvindex refine\nP4: payload + tde\nP5: lsa switch; cur_repl_record++"]
  G --> Q["flush-mark open? upgrade must_flush"]

Figure 3-2 — repl_log_insert control flow, every early return shown.

Phase 1–2 — early returns and array growth. Three short-circuits never stage: tdes == NULL; suppress_replication != 0 (Chapter 11 — also nulls both pending LSAs so a later record cannot back-patch onto a suppressed change); and no_logging && fl_mark_repl_recidx == -1. Then REPL_LOG_IS_NOT_EXISTS / REPL_LOG_IS_FULL gate repl_log_info_alloc (Chapter 2); on alloc failure it returns before touching cur_repl_record, preserving cur_repl_record <= capacity.

Phase 3 — rcvindex refinement. Where the op kind is finalized:

// 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 */
}

Because the outer if only fires for RVREPL_DATA_UPDATE, Section 3.3’s REPL_INFO_TYPE_RBR_NORMAL argument is ignored for INSERT/DELETE.

Phase 4 — payload and tde_encrypted. Packed only for LOG_REPLICATION_DATA (the datayn argument); a LOG_REPLICATION_STATEMENT record gets 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 is reset false at the top and only raised here, so STATEMENT and non-TDE records stay false.

Invariant — the 4-byte size header equals the byte length of the packed key that follows it. Written last, after or_pack_mem_value reports packed_key_len. Fill it early with the OR_VALUE_ALIGNED_SIZE upper bound and the slave’s unpack reads past the key, corrupting the row.

Phase 5 — the asymmetric LSA switch. After must_flush = LOG_REPL_COMMIT_NEED_FLUSH, the function picks repl_rec->lsa by op kind:

// 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 copies repl_insert_lsa and nulls both pending LSAs; UPDATE stores NULL because the heap update runs after the index pass and will back-patch via repl_add_update_lsa; DELETE uses its own tail_lsa, falling back to the upcoming prior_lsa when the MVCC-disabled skip (3.2) left tail_lsa unchanged. This index-before-heap inversion for UPDATE — documented in the repl_add_update_lsa header comment — is why UPDATE alone needs a back-patch.

3.5 The flush-mark tail and multi-update bracketing

Section titled “3.5 The flush-mark tail and multi-update bracketing”

After cur_repl_record++, a final block runs only when a flush-mark bracket is open (fl_mark_repl_recidx != -1; opened by 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 */
}

Default must_flush is LOG_REPL_COMMIT_NEED_FLUSH (commit only). Inside an open bracket it upgrades to LOG_REPL_NEED_FLUSH (commit and rollback) — unless an earlier bracket record targets the same inst_oid (dedup to avoid double-flush). CT_SERIAL_NAME (_db_serial) is exempt: serial changes survive rollback, so they always upgrade. repl_log_insert applies the mark; Chapter 4 covers why marks exist.

3.6 UPDATE — staging in locator_update_index, then back-patch

Section titled “3.6 UPDATE — staging in locator_update_index, then back-patch”

locator_force_for_multi_update computes the refinement (REPL_INFO_TYPE_RBR_START for i == first_update_obj, REPL_INFO_TYPE_RBR_END for i == last_update_obj, else NORMAL). locator_update_force wraps it into a REPL_INFO (need_replication = true) and passes it to locator_update_index, which locates the PK (pk_btid_index), materializes the old key, and stages with 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 replicates the old PK (repl_old_key) so the slave can locate the row even when the PK changes. With no PK, need_replication is cleared and the caller skips the back-patch. Back in locator_update_force, heap_update_logical appends the RVHF_UPDATE-family heap log; the replication-aware log-append helpers in log_manager.c stamp tdes->repl_update_lsa from tail_lsa as a side effect. Only then does the three-condition guard (!LOG_CHECK_LOG_APPLIER && log_does_allow_replication () && repl_info.need_replication) fire repl_add_update_lsa (thread_p, oid) to fill the lsa left NULL in Phase 5.

3.7 repl_add_update_lsa — the backward-walk back-patch

Section titled “3.7 repl_add_update_lsa — the backward-walk back-patch”

repl_add_update_lsa walks the staging array backward from the most recent record, filling the nearest UPDATE record matching inst_oid:

// 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");

Branches: suppressed returns immediately; match (OID_EQ, non-null repl_update_lsa) copies the heap-update LSA, nulls both pending LSAs, and stops at the nearest record (a twice-updated instance gets the most recent LSA on its most recent record); no match logs only if PRM_ID_DEBUG_REPLICATION_DATA is set, else returns NO_ERROR silently (a non-replicated class’s record was never staged). The assert documents that INSERT/DELETE never reach here with a null LSA — they resolved eagerly in Phase 5.

Invariant — tdes->repl_update_lsa is consumed exactly once per UPDATE. Set by heap_update_logical, cleared the instant repl_add_update_lsa patches a record; nulling both pending LSAs on success stops a later FK cascade from back-patching the wrong record. The preserved_repl_lsa save/restore around the FK check in locator_update_index protects this when a PK also carries an FK.

  1. All DML funnels through locator_attribute_info_force to the three force routines; the record is staged one layer deeper, at the index pass, where the PK DB_VALUE is already in hand.
  2. The force routines order heap and index passes differently: INSERT/DELETE write the heap log first (the log-append path stamps the pending LSA eagerly), UPDATE runs the index pass first and patches the LSA afterward; every error goto skips staging, and the partition-move and MVCC-disabled branches stage nothing.
  3. repl_log_insert is the single writer into tdes->repl_records[], short-circuiting on tdes == NULL, suppress_replication, and the no-logging/no-flush-mark case before growing the array.
  4. The op kind is the rcvindex argument: INSERT/DELETE pass RVREPL_DATA_INSERT/_DELETE with fixed REPL_INFO_TYPE_RBR_NORMAL; UPDATE passes RVREPL_DATA_UPDATE plus a START/NORMAL/END refinement rewritten into RVREPL_DATA_UPDATE_START/_END.
  5. Only the primary-key index stages a record, once per row; the DATA payload is class name + packed PK behind a back-filled 4-byte size header, with tde_encrypted from heap_get_class_tde_algorithm.
  6. LSA handling is asymmetric: INSERT copies repl_insert_lsa, DELETE uses tail_lsa (or the next prior_lsa), UPDATE stores NULL because the heap update has not happened yet. The pending LSAs themselves are written by the replication-aware log-append helpers in log_manager.c, keyed on the RVHF_INSERT/RVHF_MVCC_INSERT and RVHF_UPDATE heap recovery indexes — not by the heap_*_logical routines directly.
  7. UPDATE alone back-patches via repl_add_update_lsa, which walks the array backward, fills the nearest matching record from repl_update_lsa, and nulls both pending LSAs so FK cascades cannot corrupt an earlier record’s target.

Chapter 4: Statement Based Emission and Flush Marks

Section titled “Chapter 4: Statement Based Emission and Flush Marks”

Chapter 3 staged a row event into repl_records with must_flush = LOG_REPL_COMMIT_NEED_FLUSH. This chapter covers two deviations from that path: (1) statement-based records, carrying SQL text instead of a packed row image, and (2) flush marks, by which a record is forced to survive even a rollback. The reader question: how are DDL and replicated session statements staged differently from row events, and how do flush marks force emission even on rollback? For the design rationale of the SBR path see cubrid-ha-replication.md (“Statement-based vs row-based” and Open Question 8); here we trace the code.

A statement record is produced explicitly by the SQL executor on finishing a DDL statement (and a few replicated session statements), not by the heap/index machinery. The path is do_replicate_statement (in execute_statement.c), which fills a stack-resident REPL_INFO_SBR repl_stmt from the parsed statement -> locator_flush_replication_info -> the server-side dispatcher xrepl_set_info (in locator_sr.c), the gate — only REPL_INFO_TYPE_SBR reaches repl_log_insert_statement; every other repl_info_type is rejected:

// 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;
}
}

The row-based types (REPL_INFO_TYPE_RBR_* in the REPL_INFO_TYPE enum) go through repl_log_insert on the heap/index path (Chapter 3), not here.

REPL_INFO_SBR (alias of struct repl_info_statement, in replication.h) describes one statement in flight — not a staged record, but the input repl_log_insert_statement serializes into repl_rec->repl_data. Every field:

FieldRoleWhy it exists
statement_typeint — a CUBRID_STMT_* code (e.g. CUBRID_STMT_CREATE_CLASS, CUBRID_STMT_GRANT).Tells the applier the statement category; do_replicate_statement switches on the PT node type to set it. PT_DROP_VARIABLE and unhandled types return NO_ERROR early — intentionally not replicated.
nameTarget object name (class/serial/index), or the sentinel unknown_name (the literal string "-") when the parser yields no name.Lets the applier log / route by object; the sentinel keeps it from ever being an unset pointer.
stmt_textThe SQL text to re-execute on the replica.This is the payload — the replica re-parses and re-runs it. For CREATE/ALTER SERVER it is replaced by pt_print_bytes output to scrub passwords; with host variables it is rebuilt with ? placeholders substituted (sbr_text).
db_userExecuting DB user (db_get_user_name ()).The replica must apply the DDL as the same user so authorization matches; assert_release (repl_stmt.db_user != NULL) guards it.
sys_prm_contextHA-relevant system parameters (sysprm_print_parameters_for_ha_repl ()), or NULL for non-DDL.DDL semantics can depend on session parameters; filled only when pt_is_ddl_statement (statement).

Invariant — SBR carries text, not a row image. A statement record’s repl_data packs the {statement_type, name, stmt_text, db_user, sys_prm_context} tuple, and its inst_oid is always NULL (OID_SET_NULL (&repl_rec->inst_oid)); row records carry a packed PK and a real inst_oid. repl_type (LOG_REPLICATION_STATEMENT vs LOG_REPLICATION_DATA) is the discriminator the shipper and applier branch on. A non-NULL inst_oid here would let the conflict-coalescing logic in repl_log_insert (which keys on inst_oid) mistake it for a row update.

4.2 repl_log_insert_statement — branch by branch

Section titled “4.2 repl_log_insert_statement — branch by branch”
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\nno record staged"]
  C -->|no| D{"array state"}
  D -->|NOT_EXISTS| AL1["alloc(false)"]
  D -->|IS_FULL| AL2["alloc(true) realloc"]
  D -->|has room| E
  AL1 --> E["stamp identity\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["or_pack 5 fields\nlsa = tdes->tail_lsa"]
  I --> J{"in flush-mark window?"}
  J -->|yes| K["must_flush = NEED_FLUSH"]
  J -->|no| L["keep COMMIT_NEED_FLUSH"]
  K --> M["cur_repl_record++"]
  L --> M

Figure 4-1 — Control flow of repl_log_insert_statement. The two early returns and the malloc error path all leave cur_repl_record untouched, so no half-built record is published.

The preamble matches repl_log_insert (Chapter 3): resolve the LOG_TDES, honour suppress_replication, allocate or grow repl_records. Two allocation branches matter:

// 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_FULL is num_repl_records == cur_repl_record + 1, so the grow fires one slot before overflow — the write to repl_records[cur_repl_record] always lands in bounds. The record is then stamped with its identity and 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 */

The payload length sums one OR_INT_SIZE (for statement_type) plus four packed-string lengths; malloc produces repl_data, then or_pack_int / or_pack_string_with_length serialize the tuple. A malloc failure raises ER_REPL_ERROR and returns before incrementing cur_repl_record, leaving the array unchanged. The lsa is what repl_log_abort_after_lsa compares against a rollback LSA (§4.4) and what copylogdb/applylogdb order by. Unlike repl_log_insert’s RVREPL_DATA_INSERT/DELETE cases (which use repl_insert_lsa or prior_info.prior_lsa), the statement path has no before/after image, so plain tail_lsa — the last log this txn wrote — is the right anchor.

The flush-mark check at the tail differs from the row check: where the row path scans repl_records[0 .. fl_mark_repl_recidx) for a prior COMMIT_NEED_FLUSH record with the same inst_oid and promotes only if none is found (conflict coalescing), the statement path does no such scan:

// 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++;

The in-source comment is the load-bearing warning: with no inst_oid to deduplicate on, any statement record at or after the flush-mark index is forced to NEED_FLUSH.

4.3 The must_flush state machine and the flush-mark bracket

Section titled “4.3 The must_flush state machine and the flush-mark bracket”

LOG_REPL_FLUSH (in replication.h) is a three-valued enum deciding, per staged record, when it may be shipped:

ValueNumericMeaning
LOG_REPL_DONT_NEED_FLUSH-1Never ship — logically discarded (e.g. by repl_log_abort_after_lsa).
LOG_REPL_COMMIT_NEED_FLUSH0Ship only if the transaction commits (default for every row and statement record); rollback drops it.
LOG_REPL_NEED_FLUSH1Ship on commit and rollback alike — must reach the replica even if the transaction undoes its data. Set only inside a flush-mark bracket.

The bracket is tdes->fl_mark_repl_recidx, an int index into repl_records (a LOG_TDES field, Chapter 1), opened by repl_start_flush_mark and closed by repl_end_flush_mark.

stateDiagram-v2
  [*] --> Closed: fl_mark_repl_recidx == -1
  Closed --> Open: repl_start_flush_mark\n records cur_repl_record
  Open --> Open: insert in window \n new records get NEED_FLUSH
  Open --> Closed: repl_end_flush_mark need_undo=false\n keep records, clear mark
  Open --> RolledBack: repl_end_flush_mark need_undo=true\n free and truncate to mark
  RolledBack --> Closed: mark cleared

Figure 4-2 — The flush-mark state machine on one transaction descriptor. “Open” means the mark holds a valid index, “Closed” means -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 */

Invariant — the flush mark is not re-entrant. A second repl_start_flush_mark while a mark is open is a no-op: the if (== -1) guard fixes the start index at the first call, so the bracket cannot be nested or its start moved. Stacking marks would bind the inner start to the outer window and let the inner end close everything early — hence qexec_execute_selupd_list pairs exactly one start with one 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 */

Two branches:

  1. need_undo == false (success). The window’s records are kept (still LOG_REPL_NEED_FLUSH); only the mark integer resets to -1.
  2. need_undo == true (failed). Each record from fl_mark_repl_recidx up to (not including) cur_repl_record has its repl_data freed and cur_repl_record rewinds to the mark, erasing them; then the mark clears.

Exactly three sites in the tree open and close a bracket, and they all pair one start with one end:

  1. xchksum_insert_repl_log_and_demote_table_lock (in locator_sr.c) — the canonical statement caller and the one that wraps the DDL path of this chapter. It runs log_sysop_start -> repl_start_flush_mark -> xrepl_set_info (which reaches repl_log_insert_statement) -> repl_end_flush_mark (..., false). The bracket promotes the DDL’s statement record to LOG_REPL_NEED_FLUSH, so it reaches the replica even if the wrapping transaction later rolls its data changes back; on error != NO_ERROR it falls to log_sysop_abort instead.
  2. qexec_execute_selupd_list (in query_executor.c) — the canonical row caller, around a batch of increments under lock_is_instant_lock_mode. It calls repl_end_flush_mark (..., false) on success and repl_end_flush_mark (..., true) on exit_on_error, so the batch emits atomically.
  3. serial_update_serial_object (in serial.c) — only when lock_mode != X_LOCK; an X_LOCK (uncommitted create/alter) deliberately skips both the sysop and the flush mark, since marking it would mis-order the replication log.

All three gate the start/end pair behind !LOG_CHECK_LOG_APPLIER and log_does_allow_replication () — the applier never opens a bracket while replaying.

4.4 repl_log_abort_after_lsa — surviving partial rollback

Section titled “4.4 repl_log_abort_after_lsa — surviving partial rollback”

Forcing a record to flush on rollback (§4.3) is only half the story. Partial rollback — a system-operation abort or savepoint rollback — must discard records staged after that point while flush-marked records before it survive. That discard is 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;

It walks every staged record and tombstones any whose lsa is strictly greater than start_lsa with LOG_REPL_DONT_NEED_FLUSH. It does not rewind cur_repl_record or free repl_data — records stay in the array but the shipper skips them. Records at or before start_lsa keep their flush state (including LOG_REPL_NEED_FLUSH), so flush-marked work before the rollback point survives.

The sole caller is log_sysop_abort (in log_manager.c), under three guards that keep it off the applier and non-worker transactions:

// 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));
}

This sits in the else arm of log_sysop_abort’s LSA_ISNULL || LSA_LE guard, so it runs only when the system operation actually logged past its parent LSA; a no-op sysop skips the tombstone walk.

Invariant — LSA_GT is strict, so the rollback boundary record is kept. Records whose lsa equals start_lsa are not tombstoned (>, not >=); the boundary record belongs to the surviving prefix. The two mechanisms compose by orthogonal criteria — the flush mark protects by flush state at ship time, while repl_log_abort_after_lsa tombstones by LSA position regardless of flush state. A flush-marked record before the rollback point keeps NEED_FLUSH and ships; one after it is dropped. A reader touching savepoint or sysop logic must preserve this.

4.5 Statement determinism — pointer to the open question

Section titled “4.5 Statement determinism — pointer to the open question”

The statement path stages SQL text and re-executes it on the replica — compact but not deterministic: a statement with NOW(), RAND(), or a serial-dependent expression can diverge, since it is re-run rather than the row image shipped. CUBRID mitigates this for serials (the CT_SERIAL_NAME branch in repl_log_insert, Chapter 3) and by shipping sys_prm_context, but general re-execution non-determinism is unresolved here — Open Question 8 in cubrid-ha-replication.md. The detail this chapter adds is where it surfaces: the shipped field is stmt_text, not a row image.

  1. Statement records take a separate entry pointdo_replicate_statement -> xrepl_set_info, which dispatches only REPL_INFO_TYPE_SBR to repl_log_insert_statement; everything else is ER_REPL_ERROR, and row events never reach this function.

  2. REPL_INFO_SBR ships text, not a row. Its five fields pack into repl_data; the staged record is LOG_REPLICATION_STATEMENT, rcvindex = RVREPL_STATEMENT, with an always-NULL inst_oid.

  3. must_flush is a three-valued ship gateDONT_NEED_FLUSH (-1) never ships, COMMIT_NEED_FLUSH (0) ships on commit only (default), NEED_FLUSH (1) ships on commit and rollback.

  4. Statement flush-mark promotion is unconditional — unlike repl_log_insert (coalesces by inst_oid), repl_log_insert_statement forces any in-window record to NEED_FLUSH (“use with caution”).

  5. The flush-mark bracket is a single, non-reentrant integer. repl_start_flush_mark sets fl_mark_repl_recidx only if -1; repl_end_flush_mark keeps (need_undo=false) or frees-and-truncates (need_undo=true) the window, then resets to -1. Three callers pair a start with an end — xchksum_insert_repl_log_and_demote_table_lock (statement/DDL), qexec_execute_selupd_list (row batch), and the serial.c update path.

  6. repl_log_abort_after_lsa tombstones by LSA position, not flush mark — on log_sysop_abort it demotes every record with lsa > start_lsa to DONT_NEED_FLUSH (strict, so the boundary survives), overriding a flush mark for work the partial rollback undid.

  7. Statement determinism is the open seamstmt_text is re-executed on the replica, so non-deterministic functions diverge (Open Question 8).

Chapter 5: Commit Time Flush and Atomic Emission

Section titled “Chapter 5: Commit Time Flush and Atomic Emission”

By the end of Chapter 4 the transaction descriptor holds a fully populated staging array tdes->repl_records[0 .. cur_repl_record-1], each a LOG_REPL_RECORD with its repl_type, rcvindex, back-patched lsa, packed repl_data, and a must_flush flag — none yet in the WAL stream. This chapter answers the commit-time question: how are those staged records turned into real LOG_REPLICATION_DATA / LOG_REPLICATION_STATEMENT WAL records, and what guarantees that no peer transaction’s commit record slips between transaction T’s replication records and T’s own commit record?

The companion high-level doc (cubrid-ha-replication.md, section “Commit-time emission — log_append_repl_info_and_commit_log”) states the atomicity contract; this chapter dissects the five functions that implement it, branch by branch, and names the invariant the slave’s restart logic depends on. The prior-list drain that writes these nodes to the log page buffer is not re-derived here — see cubrid-log-manager-detail.md, section “Prior-LSA list and the append pipeline.”

Three end-of-transaction outcomes bound the scope: a committing HA transaction emits its staged records and its commit record under one mutex (§5.2); a plain commit (any guard term false) emits only the commit record and silently drops the staged records (§5.1); and an aborting transaction emits no replication record at all — abort never walks the staging array, so the records are discarded by the descriptor reset at transaction-end cleanup (§5.1, abort note).

5.1 The commit decision in log_commit_local

Section titled “5.1 The commit decision in log_commit_local”

log_commit (the public entry) does 2PC bookkeeping, then for a local or participant transaction calls log_commit_local. The replication fork lives at the tail of log_commit_local, inside the “transaction updated data” arm (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);
}

Every branch of the guard matters:

BranchConditionOutcome
Atomic repl pathreplication allowed and active worker tran and caller is not the log applierlog_append_repl_info_and_commit_log — repl records + commit under one mutex
Plain commitany guard term falselog_append_commit_log — commit record only, staged repl records never emitted
No-update armLSA_ISNULL (&tdes->tail_lsa) (function top)neither is called; state jumps straight to TRAN_UNACTIVE_COMMITTED

LOG_CHECK_LOG_APPLIER excludes the slave’s own apply connection (it must not re-emit replication of its own); is_active_worker_transaction () excludes system worker transactions (vacuum, checkpoint) whose changes are not user-visible DML; log_does_allow_replication () (in log_comm.c) returns false under SA_MODE/WINDOWS, false for log-copier/applier clients, and otherwise consults the HA server state. When any term is false the staged records are discarded with the descriptor.

The same emitter is reached from a second site. log_complete (non-2PC completion) appends only a plain commit; log_complete_for_2pc (the 2PC path) carries the repl+commit emitter under a two-term variant — !LOG_CHECK_LOG_APPLIER (thread_p) && log_does_allow_replication () == true — that drops the is_active_worker_transaction () term (redundant there, since a 2PC transaction is by construction an active worker). The rest of the chapter treats both sites as one.

Ordering note. The repl+commit append happens before lock_unlock_all (the source block comment explains why): if T1 resumed waiter T2, T2 committed, and a crash struck after T2’s commit but before T1’s, an out-of-order unlock log would let recovery abort T1 while T2 is already committed. Writing the unlock/commit log first closes that window.

Abort discards by not emitting. The abort counterpart log_abort_local has no replication fork at all: it calls log_rollback, then lock_unlock_all, and returns TRAN_UNACTIVE_ABORTED — it never calls any log_append_repl_info*. The staged tdes->repl_records are therefore discarded purely by omission: nothing emits them, and at transaction-end cleanup logtb_clear_tdes resets the staging array (cur_repl_record = 0, append_repl_recidx = -1, fl_mark_repl_recidx = -1). There is no abort-time gate walk to skip records; the gate (§5.4.2) is reached only from the commit and system-op paths.

5.2 The atomicity idiom — log_append_repl_info_and_commit_log

Section titled “5.2 The atomicity idiom — 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 ();
}

Invariant 5-A — strict adjacency of T’s repl records and T’s commit. Between transaction T’s last LOG_REPLICATION_* record and T’s LOG_COMMIT record, no other transaction’s commit record may appear in the log. Enforced by holding log_Gl.prior_info.prior_lsa_mutex across both log_append_repl_info_with_lock and log_append_commit_log_with_lock — the same mutex every other committer must take to append its commit donetime, so no peer commit can interleave. If violated: the two appends could be split by a peer commit, producing [T repl...][T' commit][T commit]; a slave (applylogdb) that crashed and restarted with its durable bookmark (Chapter 10) pointing between [T repl...] and [T' commit] would read T’s commit out of position, mis-attribute or mis-order the flush, and could silently drop T’s row changes. The doc comment names this exactly: “Atomic write of replication log and commit log is crucial for replication consistencies.”

The non-HA path differs: log_append_commit_log (§5.5) takes no explicit mutex — log_append_donetime_internal runs with LOG_PRIOR_LSA_WITHOUT_LOCK and acquires the mutex internally for its single commit append. Only the HA path holds one lock across two appends, so only it takes the mutex at the caller.

The has_supplemental_log prefix. When PRM_ID_SUPPLEMENTAL_LOG > 0 and tdes->has_supplemental_log is set, a LOG_SUPPLEMENTAL_INFO record of sub-type LOG_SUPPLEMENT_TRAN_USER carrying the committing DB user name is appended first (outside the mutex) and the flag cleared, so it emits at most once per commit. This feeds CDC / supplemental consumers the transaction’s user identity; it is unrelated to the HA atomicity guarantee and deliberately sits before the lock.

5.3 log_append_repl_info vs log_append_repl_info_with_lock

Section titled “5.3 log_append_repl_info vs log_append_repl_info_with_lock”

These are one-line dispatchers over a shared internal, differing only in the with_lock argument forwarded:

// 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_lock selects prior_lsa_next_record_with_lock (mutex already held by the caller) vs prior_lsa_next_record (acquires the mutex per node). The two callers have distinct purposes:

Calleris_commitwith_lockPurpose
log_append_repl_info_and_commit_log_with_locktrueWITH_LOCKCommit-time flush; emits all not-already-flushed records under the held mutex (the atomic path)
log_sysop_commit_internallog_append_repl_infofalseWITHOUT_LOCKSystem-op commit, under the same three-term guard as §5.1; emits only LOG_REPL_NEED_FLUSH records mid-transaction (DDL flush marks), each taking the mutex itself

The system-op caller is the flush-mark mechanism from Chapter 4: a DDL sub-operation replicated at the system-op boundary (not deferred to user commit) marks its record LOG_REPL_NEED_FLUSH, and log_sysop_commit_internal flushes it early with is_commit = false. The is_commit flag distinguishes “flush only the NEED_FLUSH records now” from “flush everything still pending at commit”; §5.4 shows how the gate uses it.

5.4 log_append_repl_info_internal — branch by branch

Section titled “5.4 log_append_repl_info_internal — branch by branch”

The core: it walks the staging array from a cursor and converts qualifying records into prior-list nodes.

// 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 is the resume cursor, set to 0 at entry in two cases: first time ever (== -1, freshly initialized descriptor), or is_commit true — commit rescans the entire array from 0 because mid-transaction flush-mark passes (§5.3) advanced the cursor past records they inspected, and commit must re-examine every record to catch the COMMIT_NEED_FLUSH ones those passes skipped. When is_commit is false and the cursor is already >= 0, a non-commit pass resumes where it left off, scanning only records appended since the last pass.

5.4.2 The must_flush gate — every combination

Section titled “5.4.2 The must_flush gate — every combination”

The if admits a record when its type is a replication type and the flush condition holds, against the three LOG_REPL_FLUSH values (replication.h):

must_flushvaluecommit pass (is_commit=true)flush-mark pass (is_commit=false)
LOG_REPL_DONT_NEED_FLUSH-1skip (!= DONT_NEED_FLUSH is false)skip (!= NEED_FLUSH)
LOG_REPL_COMMIT_NEED_FLUSH0emit (!= DONT_NEED_FLUSH true)skip (!= NEED_FLUSH)
LOG_REPL_NEED_FLUSH1emitemit (== NEED_FLUSH)

The outer type check (LOG_REPLICATION_DATA || LOG_REPLICATION_STATEMENT) is belt-and-suspenders — the staging array only ever holds those two types.

5.4.3 Building the LOG_REC_REPLICATION node — the lsa-null rules

Section titled “5.4.3 Building the LOG_REC_REPLICATION node — the lsa-null rules”

prior_lsa_alloc_and_copy_data allocates a prior node whose data_header is a LOG_REC_REPLICATION (the 3-field {lsa, length, rcvindex} struct covered in Chapter 1) and whose body copies repl_rec->repl_data (length bytes — the packed pkey_size | class_name | pkey_dbvalue). length and rcvindex are filled straight from the staging record; lsa is set per rcvindex:

rcvindexheader lsaWhy
RVREPL_DATA_DELETELSA_SET_NULLslave needs only the primary key to locate-and-delete; no after-image
RVREPL_STATEMENTLSA_SET_NULLthe statement text is the payload; there is no heap record to point at
RVREPL_DATA_INSERTLSA_COPY from repl_rec->lsapoints at the heap insert log; slave fetches the inserted row image
RVREPL_DATA_UPDATELSA_COPYback-patched by repl_add_update_lsa (Chapter 3); points at the heap update log
RVREPL_DATA_UPDATE_START / UPDATE_ENDLSA_COPYsame as UPDATE; the START/END pair brackets a multi-row update

The values copied here were set on the staging side (Chapter 3).

5.4.4 TDE encryption and the post-emit flush-flag flip

Section titled “5.4.4 TDE encryption and the post-emit flush-flag flip”

Two side conditions complete the per-record handling:

  • tde_encrypted. If the staged record came from a TDE-encrypted class (repl_rec->tde_encrypted set at staging — Chapter 3), prior_set_tde_encrypted (log_append.cpp) checks tde_is_loaded (); if the master key is not loaded it raises ER_TDE_CIPHER_IS_NOT_LOADED and the record skips via the assert(false); continue; arm (which does not advance the cursor — see Invariant 5-B and Figure 5-1), else it sets node->tde_encrypted = true so the carrying page is encrypted at rest. Chapter 11 covers the apply side.
  • must_flush = LOG_REPL_DONT_NEED_FLUSH after a successful prior_lsa_next_record* — the re-emit guard: once a record is in the prior list, flipping it to DONT_NEED_FLUSH makes a later commit pass rescan (§5.4.1) skip it, because the gate excludes DONT_NEED_FLUSH. This matters when a mid-transaction flush-mark pass (§5.3) already emitted a NEED_FLUSH record: the commit rescan that walks from index 0 must not emit it a second time. Abort does not enter this loop at all (§5.1), so the guard is purely a commit-vs-flush-mark de-duplication, not an abort filter.

Invariant 5-B — each staged record is emitted at most once. Enforced by flipping must_flush to DONT_NEED_FLUSH right after the node is handed to prior_lsa_next_record*. If violated: a record emitted by an early flush-mark pass and re-emitted by the commit rescan (which restarts at index 0, §5.4.1) would produce two LOG_REPLICATION_* records for one logical change, and the slave would apply the same INSERT/UPDATE twice. The guard governs only the commit-vs-flush-mark overlap; the abort path emits nothing (§5.1) and needs no such filter. Note the two error arms (node == NULL, prior_set_tde_encrypted != NO_ERROR) continue without advancing append_repl_recidx; both are assert(false) dead-ends for “impossible” states (allocation failure, missing TDE key), not graceful recovery — a modifier must preserve the success-path cursor advance.

Each loop iteration is a 4-decision cascade (the real code above carries the same branches inline):

flowchart TD
  C{"recidx < cur_repl_record ?"}
  C -- no --> Z["return"]
  C -- yes --> D{"REPL type and gate passes ?"}
  D -- no --> INC["recidx++"] --> C
  D -- yes --> G{"alloc NULL, or tde set fails ?"}
  G -- yes --> C2["continue, no recidx++"] --> C
  G -- no --> K{"rcvindex DELETE/STATEMENT ?"}
  K -- yes --> L["lsa = NULL"] --> N
  K -- no --> M["lsa = repl_rec->lsa"] --> N
  N["next_record, then must_flush = DONT_NEED_FLUSH"] --> INC

Figure 5-1 — log_append_repl_info_internal loop body: the gate, the non-advancing error continue, and the lsa-null split.

5.5 The commit donetime — log_append_commit_log_with_lock

Section titled “5.5 The commit donetime — log_append_commit_log_with_lock”

The second append under the held mutex is the commit record itself:

// 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_internal allocates a LOG_COMMIT node, stamps donetime->at_time = time (NULL), and — because the HA caller passed WITH_LOCK — calls prior_lsa_next_record_with_lock without re-acquiring the mutex, which is why the emitter holds one lock across both appends. The assigned LSA returns into commit_lsa, flowing back through log_commit_local to log_change_tran_as_completed (drives logpb_flush_pages, flips to TRAN_UNACTIVE_COMMITTED). The prior list now holds, in strict order, [repl record 0 .. n] then [LOG_COMMIT]; its drain into the log page buffer is detailed in cubrid-log-manager.md, section “Prior-LSA list and the append pipeline.”

  1. log_commit_local forks on three conditions (!LOG_CHECK_LOG_APPLIER, is_active_worker_transaction, log_does_allow_replication); only when all hold does it call the atomic emitter, else a plain log_append_commit_log with no repl. The 2PC path (log_complete_for_2pc) uses a two-term variant that drops the redundant is_active_worker_transaction() check.
  2. Atomicity is one mutex held across two appends. log_append_repl_info_and_commit_log holds log_Gl.prior_info.prior_lsa_mutex across both log_append_repl_info_with_lock and log_append_commit_log_with_lock (Invariant 5-A); splitting them would let [T repl...][T' commit][T commit] appear and the slave drop or mis-order T’s changes on restart.
  3. The must_flush gate is is_commit-aware. At commit every record != DONT_NEED_FLUSH emits (both COMMIT_NEED_FLUSH DML rows and pending NEED_FLUSH); a flush-mark pass (is_commit=false) emits only NEED_FLUSH DDL records.
  4. lsa-null is per rcvindex. DELETE and STATEMENT null the header lsa; INSERT and the three UPDATE variants copy the back-patched heap LSA through so the slave fetches the after-image.
  5. Each record emits at most once (Invariant 5-B): after a node is appended must_flush flips to DONT_NEED_FLUSH, so the commit rescan (which restarts at index 0) will not re-emit a record an earlier flush-mark pass already wrote. Abort emits nothinglog_abort_local has no replication fork; staged records are discarded by the logtb_clear_tdes descriptor reset, not by the gate. The two error continue arms are assert(false) dead-ends that do not advance the cursor.
  6. log_append_repl_info (WITHOUT_LOCK, is_commit=false) and log_append_repl_info_with_lock (WITH_LOCK, is_commit=true) are the two faces of one internal: the system-op flush-mark caller and the commit-atomic caller.

Chapter 6: Shipping Log Pages with copylogdb

Section titled “Chapter 6: Shipping Log Pages with copylogdb”

The slave-side copylogdb daemon pulls raw log pages (not parsed records) and lays them into a slave-local active-log volume that near-mirrors the master’s. The companion’s Slave side — copylogdb section established that a puller exists and why it copies pages; this chapter traces how — every struct field, every branch of the fetch loop, the physical-page wraparound math, and the master-side responder that blocks until the page exists.

Two halves live in log_writer.c under opposite compile guards. The client half (#if defined(CS_MODE)) is the copylogdb process on the slave; the server half (SERVER_MODE) is xlogwr_get_log_pages in the master cub_server. They speak the NET_SERVER_LOGWR_GET_LOG_PAGES protocol.

6.1 The two global personalities — LOGWR_GLOBAL vs LOGWR_INFO

Section titled “6.1 The two global personalities — LOGWR_GLOBAL vs LOGWR_INFO”

log_writer.h defines two disjoint structure sets selected by the compile guard.

graph TB
  subgraph CS["CS_MODE  (copylogdb process, the slave puller)"]
    G["LOGWR_GLOBAL logwr_Gl<br/>process-wide singleton"]
    C["LOGWR_CONTEXT ctx<br/>one per copy session"]
    G -.owns active-log fd, buffer.-> C
  end
  subgraph SV["SERVER_MODE  (inside master cub_server)"]
    I["LOGWR_INFO log_Gl.writer_info<br/>master-side coordinator"]
    E["LOGWR_ENTRY<br/>one per connected slave"]
    I -->|writer_list| E
  end
  C -.NET_SERVER_LOGWR_GET_LOG_PAGES.-> E

Figure 6-1. Client structs (LOGWR_GLOBAL, LOGWR_CONTEXT) live in the slave puller; server structs (LOGWR_INFO, LOGWR_ENTRY) live in the master. They never coexist in one binary.

LOGWR_MODE is the only type shared by both halves — it travels on the wire:

EnumeratorValueMeaning
LOGWR_MODE_ASYNC1Master replies as soon as pages are ready; never blocks the master flush.
LOGWR_MODE_SEMISYNC2Master flush waits for the slave to fetch (not apply).
LOGWR_MODE_SYNC3Master flush waits for the slave’s fetch round-trip.

Exact wait semantics are an Open Question in the companion; §6.8 documents the mechanism (thread_suspend_with_other_mutex). The bit LOGWR_COPY_FROM_FIRST_PHY_PAGE_MASK (0x80000000) is OR’d into the wire mode for a fresh copylogdb -f — “copy from the oldest physical page” (§6.8).

LOGWR_GLOBAL — the slave puller’s whole world

Section titled “LOGWR_GLOBAL — the slave puller’s whole world”

logwr_Gl is one static instance. Its fields, grouped:

Field(s)RoleWhy it exists
hdrSlave’s working copy of the master LOG_HEADER.Drives all physical-page math (fpageid, npages, nxarv_*); refreshed from page 0 of every reply.
loghdr_pgptrPointer into logpg_area at the header slot.logwr_flush_header_page writes the header without a copy.
db_name / hostnameDB name with @host split out (hostname points past @).Names volumes; identifies the master. Split in logwr_initialize.
log_path / loginf_path / active_nameSlave log dir, loginfo path, active-log path.Where volumes live; active_name is what logwr_writev_append_pages writes.
append_vdesfd of the slave active log (or NULL_VOLDES).The single mutable output handle.
logpg_area / logpg_area_size / logpg_fill_sizeReceive buffer (NPAGES+1)*LOG_PAGESIZE, its size, bytes received.Lands one reply (page 0 = header, 1..N = data); logwr_set_hdr_and_flush_info walks [0,fill_size).
toflush / max_toflush / num_toflushLOG_PAGE* data pointers, capacity (= NPAGES), valid count.The flush worklist (§6.5); num_toflush is the loop bound.
modeCurrent LOGWR_MODE.Saved/restored per request; downgradable to ASYNC mid-catchup.
actionLOGWR_ACTION_* bitmask.Carries DELAYED_WRITE / ASYNC_WRITE / HDR_WRITE / ARCHIVING across loop turns.
last_recv_pageidHighest logical pageid received.The cursor. Becomes the next request’s first_pageid (§6.3).
last_arv_fpageid / last_arv_lpageid / last_arv_numFirst/last pageid and number of the archive being assembled.Drive logwr_archive_active_log’s copy range and name.
force_flush / last_flush_timeForce a SEMISYNC flush past the timer; last-flush timestamp.SEMISYNC throttles to ~1/sec; force_flush (shutdown/crash) bypasses it.
bg_archive_info / bg_archive_nameBg-archive scratch state and temp path.Dual-write when LOG_BACKGROUND_ARCHIVING is on; temp is renamed into the real archive on roll.
ori_nxarv_pageidnxarv_pageid snapshot before applying this reply.Detects the archive boundary so request mode is not downgraded (§6.3).
start_pageid-2 normal; >= NULL_PAGEID for -f.Distinguishes one-shot full copy from the daemon.
reinit_copylogMaster DB rebuilt under us.Triggers logwr_reinit_copylog.

(last_chkpt_pageid is carried for diagnostics, unread by the core loop.)

Invariant — last_recv_pageid is monotonic and bounds the next request. logwr_set_hdr_and_flush_info sets it to the last received page (more to come) or hdr.eof_lsa.pageid (caught up); the next request derives first_pageid_torecv from it (§6.3). logwr_get_log_pages asserts last_recv_pageid <= hdr.eof_lsa.pageid — a page beyond EOF would make the loop request non-existent pages and hang on the master’s poll-to-push (§6.8).

// struct logwr_context -- src/transaction/log_writer.h
struct logwr_context { int rc; int last_error; bool shutdown; };
FieldRoleWhy it exists
rcNet request handle (> 0 once open).net_client_logwr_send_end_msg(ctx.rc,...) tears down the server thread.
last_errorLast error code, echoed to the master.The master decides whether to keep the writer entry.
shutdownSession must end.Breaks the loop; set on crash, fatal error, or -f completion.

LOGWR_ENTRY and LOGWR_INFO — the master-side mirror

Section titled “LOGWR_ENTRY and LOGWR_INFO — the master-side mirror”

Each connected slave is one LOGWR_ENTRY on writer_info->writer_list:

FieldRoleWhy it exists
thread_pLOGWR thread serving this slave.Suspend/resume target.
fpageidFirst pageid this slave is asking for.Input to logwr_pack_log_pages.
modeSlave’s requested LOGWR_MODE.Decides whether the flush thread waits.
statusLOGWR_STATUS_* (WAIT/FETCH/DONE/DELAY/ERROR).Handshake state with the Log Flush Thread.
eof_lsaMaster EOF snapshot for this entry.Bounds the pack; set under LOG_CS.
last_sent_eof_lsaHighest EOF already shipped.logwr_is_delayed compares it to detect lag.
tmp_last_sent_eof_lsaStaging for last_sent_eof_lsa.Committed only after the send round-trips.
start_copy_timeClock at fetch start.Measures how long the slave delays the flush thread.
copy_from_first_phy_pageThe -f flag.Routes the pack to the oldest-page branch.
nextList linkage.writer_list is singly linked.

LOGWR_INFO (log_Gl.writer_info) is the rendezvous between the single Log Flush Thread (LFT) and the per-slave LOGWR threads (LWTs). Every member:

FieldRoleWhy it exists
writer_listHead of the LOGWR_ENTRY list.The set of slaves the LFT must consider waking.
wr_list_mutexSerializes mutation of writer_list on register/unregister.The list is shared between every LWT and the LFT; distinct from the three cond pairs below.
flush_start_cond / flush_start_mutexAn LWT suspends here awaiting the requested page.The poll-to-push wait point (§6.8); the LFT signals once pages exist.
flush_wait_cond / flush_wait_mutexThe LWT waits here after packing, for the flush to finish.Sequences a packed LWT behind the LFT’s disk flush.
flush_end_cond / flush_end_mutexThe LFT waits here for LWTs to finish their send.Lets the LFT block until interested slaves consumed the pages.
skip_flushLFT skips waiting on writers this cycle.Avoids a stall when there is nothing to coordinate.
flush_completedLFT finished the current flush.The predicate LWTs test when woken on flush_wait_cond.
is_initwriter_info is initialized.Guards the handshake from running before setup.
trace_last_writerEnable last-writer timing instrumentation.Diagnostics for delay attribution.
last_writer_client_infoCLIENTIDS of the last delaying writer.Identifies which slave caused flush latency.
last_writer_elapsed_timeTime that writer made the LFT wait.The measured delay, surfaced for HA monitoring.

6.2 logwr_initialize — opening the slave-local active log

Section titled “6.2 logwr_initialize — opening the slave-local active log”

Runs once at daemon start:

// 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_NPAGES is LOGPB_BUFFER_NPAGES_LOWER (128), the per-reply page ceiling. Then, branch by branch:

  1. Buffer alloc — if logpg_area == NULL, malloc log_nbuffers * LOG_PAGESIZE; on failure reset logpg_area_size = 0, return ER_OUT_OF_VIRTUAL_MEMORY.
  2. toflush alloc — if NULL, calloc max_toflush = log_nbuffers - 1 pointers; same OOM handling.
  3. logwr_read_log_header — if the slave active log exists, mount and read page 0; else defer creation (we need the master’s npages first). Propagate errors.
  4. “Already exists” guardstart_pageid >= NULL_PAGEID (a -f) into a directory that already has replication logs (hdr.nxarv_pageid != NULL_PAGEID) returns ER_HA_GENERIC_ERROR.
  5. Archive cursor seedlast_arv_fpageid = hdr.nxarv_pageid, last_arv_num = hdr.nxarv_num; reset force_flush/last_flush_time/ori_nxarv_pageid.
  6. Bg-archiving branch (only if PRM_ID_LOG_BACKGROUND_ARCHIVING) — mount the temp volume and read its header, recreating on ER_LOG_INCOMPATIBLE_DATABASE; if absent, fileio_format a fresh one and flush its header. Mount/format failure returns ER_IO_MOUNT_FAIL/ER_HA_GENERIC_ERROR.

The active log is not created here in the normal case; that is deferred to the first logwr_write_log_pages (§6.5), which fileio_formats it once it knows hdr.npages.

6.3 logwr_copy_log_file — the daemon main loop

Section titled “6.3 logwr_copy_log_file — the daemon main loop”

The copylogdb lifecycle. After logwr_initialize (returning on init failure), it loops:

flowchart TD
  init["logwr_initialize"] -->|ok| loop{shutdown<br/>or need_shutdown?}
  init -->|err| fin["logwr_finalize; return"]
  loop -->|no| get["logwr_get_log_pages(ctx)<br/>= one request/reply"]
  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 control flow. The network round-trip is inside logwr_get_log_pages; this loop orchestrates write-back, reinit, and shutdown.

Key branches: a logwr_get_log_pages error records ctx.last_error, and ER_HA_LW_FAILED_GET_LOG_PAGE deregisters from the heartbeat (unrecoverable); if reinit_copylog was set, run logwr_reinit_copylog and goto end. On success, action & LOGWR_ACTION_ASYNC_WRITE triggers an eager logwr_write_log_pages (SEMISYNC/SYNC write elsewhere). Every turn, action &= LOGWR_ACTION_DELAYED_WRITE clears all bits except DELAYED_WRITE, which must persist so the client keeps pulling remaining pages. On signal shutdown with SEMISYNC it sets force_flush, does a final write, and returns ER_HA_LW_STOPPED_BY_SIGNAL. At end:, if ctx.rc > 0 it sends the end message; a completed -f copy deliberately sends ER_FAILED to force the server thread down; always logwr_finalize.

logwr_get_log_pages (in network_interface_cl.c) derives the request from the cursor:

// 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;
}

Three packed fields: int64 first pageid (possibly OR’d with the -f mask), int mode, int last_error. While draining, mode is forced to ASYNC so the master never blocks on a catching-up slave; it stays configured only at the archive boundary (first_pageid_torecv == ori_nxarv_pageid). On ER_NET_SERVER_CRASHED / ER_HA_LW_FAILED_GET_LOG_PAGE, a SEMISYNC slave force-flushes and sets ctx->shutdown; the ER_NET_SERVER_CRASHED branch additionally stamps the local header HA_SERVER_STATE_DEAD before flushing it (the ER_HA_LW_FAILED_GET_LOG_PAGE branch does not — it leaves the recorded server state untouched).

6.4 logwr_set_hdr_and_flush_info — reconciling a reply

Section titled “6.4 logwr_set_hdr_and_flush_info — reconciling a reply”

When a reply lands in logpg_area, this rebuilds toflush, copies the master header into logwr_Gl.hdr, decides whether to archive, and advances the cursor. It first walks the data pages (past the first LOG_PAGESIZE header slot):

// 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 */

Branch A — num_toflush > 0 (data arrived): copy wire page 0 into hdr; seed archive info if unset; set LOGWR_ACTION_ARCHIVING in two sub-cases — delayed (last_arv_num+1 < hdr.nxarv_num and ha_file_status == ARCHIVED and last_arv_fpageid <= last_recv_pageidlast_arv_lpageid = last_recv_pageid) or at-boundary (last_arv_num+1 == hdr.nxarv_num and the last page reached hdr.nxarv_pageidlast_arv_lpageid = hdr.nxarv_pageid - 1). Then cursor advance: if the last page is below hdr.eof_lsa.pageid there is more — last_recv_pageid = last page and set DELAYED_WRITE; else caught up — last_recv_pageid = hdr.eof_lsa.pageid and clear DELAYED_WRITE.

Branch B — num_toflush == 0 (header-only reply): the master had nothing new. Rebuild detection — if the server state is non-active, promotion times match, but db_restore_time differs, the master was restored from a different image: set reinit_copylog = true and return ER_LOG_DOESNT_CORRESPOND_TO_DATABASE. A db_creation mismatch returns the same error (a different DB entirely). Cursor rewind to force re-send of the tail: last_recv_pageid becomes hdr.append_lsa.pageid - 1 if local status is not SYNCHRONIZED, else hdr.eof_lsa.pageid - 1.

Tail (both branches): if the local header is not SYNCHRONIZED, the local append_lsa is pinned to last_recv_pageid with NULL_OFFSET, recording where the slave’s writable log ends so a restart resumes there (Chapter 10).

6.5 logwr_writev_append_pages and logwr_flush_all_append_pages

Section titled “6.5 logwr_writev_append_pages and logwr_flush_all_append_pages”

logwr_write_log_pages (caller) lazily fileio_formats the active log sized hdr.npages + 1 if absent, then — if LOGWR_ACTION_ARCHIVING is set — calls logwr_archive_active_log before flushing (archiving the soon-overwritten page first), then logwr_flush_all_append_pages, then logwr_flush_header_page. SEMISYNC also gates on a ~1-second timer unless force_flush or mid-archive.

logwr_flush_all_append_pages coalesces toflush into runs that are both logically and physically contiguous, one logwr_writev_append_pages per run:

// 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;
}

The second clause makes the circular log correct: two logically adjacent pages can straddle the wraparound seam where physical pageid resets to 1, so they split into two writes. After the loop the trailing run flushes, one fileio_synchronize makes everything durable (plus the bg-archive volume if dual-write is on), and toflush is cleared.

logwr_writev_append_pages does the fileio_writes in two phases. The bg-archive write (if LOG_BACKGROUND_ARCHIVING) validates bg_arv_info->vdes, backfills any gap via logwr_copy_necessary_log, and writes each page at phy_pageid = fpageid - start_page_id + 1. The active write computes phy_pageid = logwr_to_physical_pageid(fpageid) and writes each page at phy_pageid + i. Both map ER_IO_WRITE_OUT_OF_SPACE to ER_LOG_WRITE_OUT_OF_SPACE and signal failure by returning to_flush = NULL. The first data page is checksum-validated before any write. The #ifdef UNSTABLE_TDE_FOR_REPLICATION_LOG blocks re-encrypt TDE-marked pages and are compiled out in shipping builds — see the TDE Open Question in Chapter 11.

Invariant — what is written is byte-identical to the master. The wire page area is copied verbatim; the slave never re-serializes records. The only mutation is physical placement (logwr_to_physical_pageid) and optional TDE re-encryption. This is why copylogdb copies pages, not records.

6.6 logwr_to_physical_pageid — circular-log address math

Section titled “6.6 logwr_to_physical_pageid — circular-log address math”

The active log is a fixed ring of hdr.npages pages; a monotonically increasing logical pageid maps to a physical slot:

// 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;
}

Subtract fpageid (the logical pageid mapping to data slot 1), reduce modulo npages (negative case keeps an old pageid mapping correctly), +1 to skip the header slot, reduce once more if +1 pushed past the ring. The -f copy uses LOGWR_COPY_FROM_FIRST_PHY_PAGE_MASK so the master repacks from the oldest physical page rather than trusting a stale fpageid.

6.7 logwr_archive_active_log — rolling the active log to an archive

Section titled “6.7 logwr_archive_active_log — rolling the active log to an archive”

When logwr_set_hdr_and_flush_info decides to seal an archive, this runs (before the flush): build an archive header page, open the destination, copy the page range, update the cursor. Each step falls to goto error on failure.

  1. Header page — malloc one page; fill LOG_ARV_HEADER (db_creation, fpageid = last_arv_fpageid, arv_num = last_arv_num, npages = last_arv_lpageid - fpageid + 1).
  2. Destination — bg-archiving reuses bg_arv_info->vdes; an existing file is fileio_mounted; otherwise fileio_format a new archive sized npages + 1.
  3. Write header at physical page 0 (FILEIO_WRITE_NO_COMPENSATE_WRITE).
  4. Copy range — start at current_page_id (bg) or last_arv_fpageid, loop to last_arv_lpageid in LOGPB_IO_NPAGES chunks clamped so a read never crosses the ring seam; each chunk fileio_read_pages (logical-pageid mismatch → ER_LOG_PAGE_CORRUPTED) then fileio_write_pages to the archive.
  5. Bg-archive roll (if on) — dismount the temp volume, fileio_rename it into the real archive name, format a fresh temp, reset and flush its header.
  6. Cursor + headerlast_arv_num++, last_arv_fpageid = last_arv_lpageid + 1; temporarily pin hdr.append_lsa.pageid = last_arv_lpageid (so a concurrent applier never reads an immature active page), flush the header, restore append_lsa, emit ER_LOG_ARCHIVE_CREATED.

Invariant — archive before overwrite. logwr_write_log_pages runs logwr_archive_active_log before logwr_flush_all_append_pages, so any slot about to be clobbered by ring wraparound is already in a durable archive. Violating the order would lose log on the slave and break point-in-time recovery (Chapter 10).

6.8 xlogwr_get_log_pages — the master-side responder

Section titled “6.8 xlogwr_get_log_pages — the master-side responder”

Each slave is served by a thread spinning in xlogwr_get_log_pages (dispatched from slogwr_get_log_pages). It mallocs a LOGWR_COPY_LOG_BUFFER_NPAGES * LOG_PAGESIZE send buffer and loops while (true), one request per iteration.

stateDiagram-v2
  [*] --> Register : strip -f mask\n logwr_register_writer_entry
  Register --> Wait : entry->status == WAIT
  Register --> Delay : entry->status == DELAY
  Wait --> Timeout : ASYNC and 10s elapsed
  Timeout --> Register : unregister, continue
  Wait --> Interrupted : resume due to interrupt
  Interrupted --> Register : if delayed, continue
  Wait --> Pack : THREAD_LOGWR_RESUMED
  Delay --> Pack : LOG_CS_ENTER
  Pack --> Send : logwr_pack_log_pages ok
  Send --> NextReq : xlog_send_log_pages_to_client
  NextReq --> Register : xlog_get_page_request_with_reply
  Pack --> Err : pack failed
  Err --> [*]

Figure 6-3. xlogwr_get_log_pages per-request state machine. Wait is the poll-to-push block: the thread sleeps until the Log Flush Thread wakes it because the requested page now exists.

The poll-to-push suspend on entry->status == WAIT:

// 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);

A SYNC/SEMISYNC writer waits indefinitely until the LFT appends and signals; an ASYNC writer waits at most 10 s, then on ER_CSS_PTHREAD_COND_TIMEDOUT unregisters and continues. Other branches: interrupt/shutdown → ER_INTERRUPTED; THREAD_RESUME_DUE_TO_INTERRUPT + logwr_is_delayed loops to ship the backlog; mutex error → ER_FAILED. A LOGWR_STATUS_DELAY entry skips the wait and packs immediately. After packing it waits on flush_wait_cond, sends via xlog_send_log_pages_to_client, then xlog_get_page_request_with_reply blocks for the next request. ASYNC entries unregister before the send, SYNC after; a -f mask on a subsequent request is illegal (assert_release(false)).

logwr_pack_log_pages chooses the range: is_hdr_page_only packs only page 0; copy_from_first_phy_page (-f) starts at logpb_find_oldest_available_page_id; normally fpageid = entry->fpageid clamped to the unflushed boundary nxio_lsa.pageid. Then the active-vs-archive branch:

// 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 */

The archive lookup inside logpb_fetch_from_archive is where the guess estimator and the archive-page cache live: logpb_get_archive_num_from_info_table checks the in-memory logpb_Arv_page_info_table (a ring of ARV_PAGE_INFO start/end/arv_num triples) for an O(1) hit, falling back to logpb_get_guess_archive_num which interpolates from the loginfo file — locating which archive holds a far-behind slave’s page without scanning all of them. Packing fills page 0, loops fpageid..lpageid via logpb_copy_page_from_log_buffer (or _from_file on an interrupted send), recomputes the checksum of still-mutable pages at/after nxio_lsa.pageid (logpb_set_page_checksum) and validates already-flushed ones. *status is DONE if EOF was included, else DELAY.

logwr_check_page_checksum is a sampled CRC: per 4096-byte block it CRCs the first and last 16 bytes against hdr.checksum (a stored 0 means “unset”) — cheap on the hot path while still catching torn or bit-rotted pages.

  1. copylogdb ships pages, not records. The slave copies the master’s log pages byte-for-byte; record parsing is the applylogdb side (Chapters 7–9).
  2. Two disjoint struct families share one file. LOGWR_GLOBAL/LOGWR_CONTEXT (CS_MODE) never coexist with LOGWR_INFO/LOGWR_ENTRY (SERVER_MODE); only LOGWR_MODE crosses the wire.
  3. last_recv_pageid is the durable cursor. logwr_set_hdr_and_flush_info advances it; the next request derives from it, guarded by the monotonicity assert in logwr_get_log_pages.
  4. The circular log forces two-axis contiguity checks. logwr_flush_all_append_pages splits a run wherever logical or physical adjacency breaks, because logwr_to_physical_pageid wraps at hdr.npages with slot 0 pinned to the header.
  5. Archive before overwrite. logwr_write_log_pages runs logwr_archive_active_log ahead of the flush, sealing any page ring wraparound is about to clobber — the invariant behind slave-side point-in-time recovery.
  6. The master responder is poll-to-push. xlogwr_get_log_pages suspends a SYNC/SEMISYNC writer on flush_start indefinitely (ASYNC: 10 s) until the LFT signals; far-behind slaves are accelerated by logpb_Arv_page_info_table and logpb_get_guess_archive_num.
  7. Checksums are sampled, not full-page. logwr_check_page_checksum CRCs the first/last 16 bytes of each 4 KB block, validating flushed pages while logpb_set_page_checksum recomputes still-mutable tail pages before send.

Chapter 7: applylogdb Initialization and Log Fetch

Section titled “Chapter 7: applylogdb Initialization and Log Fetch”

When an HA node is told to apply the log it copied, the apply daemon must bootstrap one global state object, recover where it left off from the durable _db_ha_apply_info bookmark, and read raw log pages off the slave-local copied volumes. The theory (log shipping, master/slave split, the copylogdb/applylogdb pair) lives in the high-level companion cubrid-ha-replication.md; this chapter traces only the mechanism. Chapters 8-10 consume what is set up here.

Everything hangs off two file-scope globals — la_Info (LA_INFO) and la_recdes_pool (LA_RECDES_POOL). There is exactly one of each (the daemon is single-threaded per database), so the init functions write directly into them rather than threading a context pointer.

flowchart TD
  info["LA_INFO la_Info<br/>(the single global)"]
  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 to buffer"]
  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 -.points into.-> buf
  area -.owns storage of.-> buf

Figure 7-1. LA_INFO and the caches it owns.

LA_INFO — the global apply context. Fields group into log-volume info, the in-flight apply map, slave-side scratch, master-side cache, and the persisted _db_ha_apply_info mirror.

FieldRoleWhy it exists
log_path / loginf_pathCopied-volume dir; *_lginf info-file pathAll volume names derive from these; archive-removal bookkeeping
act_log / arv_logActive / archive volume handlesSee LA_ACT_LOG, LA_ARV_LOG
last_file_stateLast ha_file_statusDetects format/sync transitions
start_vsize / start_timeStartup VSZ / wall-clockmax_mem_size watchdog; archive-removal clock
final_lsaRead cursor — last processed LSASeeded from bookmark; advanced by apply loop
committed_lsa / committed_rep_lsaLSA of last applied LOG_COMMIT / repl recordDurable apply watermark (Ch 10); repl-record progress
last_committed_lsa / last_committed_rep_lsaStartup snapshotsRestart skips already-applied work
repl_lists / repl_cnt / cur_replPer-txn LA_APPLY buckets, count, cursorIn-flight buffering (Ch 8); grown LA_REPL_LIST_COUNT (50) at a time
total_rows / prev_total_rows / log_record_timeRow counters; server time of last commitThroughput and replication-delay reporting
commit_head / commit_tailFIFO of pending LA_COMMITOrders commit application (Ch 9)
last_deleted_archive_num / last_time_archive_deletedHighest archive purged, whenDrive/throttle la_remove_archive_logs
log_data / rec_typeRecord-unpack scratchrec_type = 2-byte slotted type; lazily alloc in la_apply_pre
undo_unzip_ptr / redo_unzip_ptrLOG_ZIP decompress scratchCompressed undo/redo; alloc in la_apply_pre
apply_stateHA_LOG_APPLIER_STATE_*Reported to master; gates dbname-lock release
max_mem_sizeVSZ ceiling (MB)Daemon self-restarts if exceeded
cache_pb / cache_buffer_sizePage cache; buffers per expansionSee LA_CACHE_PB; size = LA_DEFAULT_CACHE_BUFFER_SIZE (100)
last_is_end_of_record / is_end_of_recordEOF-of-log flags”Caught up” vs. “page missing”
last_server_state / is_role_changedMaster HA-state trackingRole-change handling (Ch 11)
append_lsa / eof_lsaActive-log header mirrorBound the cursor; persisted
required_lsa / required_lsa_changedStart LSA of oldest un-applied txn; dirty flagReclaim floor: archives below may be purged; flag avoids needless catalog writes
insert/update/delete/schema/commit/fail_counter / log_commit_timeOp statistics; last-commit timePersisted to _db_ha_apply_info; delay reporting
status / is_apply_info_updatedRow status, mid-update flagIDLE/BUSY; crash-mid-update hint
num_unflushedBuffered-but-unflushed itemsFlush at LA_MAX_UNFLUSHED_REPL_ITEMS (200)
log_path_lockf_vdes / db_lockf_vdesPath-lock / dbname-lock fdsDup-daemon guard (7.4) vs. apply-time lock
repl_filter / reinit_copylogTable filter; “copylogdb must re-init” signalSee LA_REPL_FILTER (Ch 11); set when archive read sees reset volume
tde_sock_for_dks / maxslotted_reclengthTDE data-key socket; heap_Maxslotted_reclengthOnly under UNSTABLE_TDE_FOR_REPLICATION_LOG (Ch 11); row-reconstruction sizing (Ch 9)

Invariant — final_lsa is the single read cursor and never moves backward within a run. la_apply_pre re-seeds it from committed_lsa each outer iteration; the inner loop only advances it. The fetch layer refuses any page whose append_lsa.pageid < pageid, so it can never point past what the master produced — seeding it ahead of eof_lsa would spin la_get_page_buffer forever.

LA_CACHE_PB / LA_CACHE_BUFFER / LA_CACHE_BUFFER_AREA — a hash table over a flat pool of fixed-size buffers, storage carved from one or more slabs.

LA_CACHE_PB fieldRoleWhy it exists
hash_tableMHT_TABLE LOG_PAGEID to LA_CACHE_BUFFER*O(1) lookup of a fetched page
log_bufferBuffer-pointer arrayClock victim scan iterates it
num_buffersLength of log_bufferGrows when no victim found
buffer_areaHead of slab listFreed wholesale at shutdown
LA_CACHE_BUFFER fieldRoleWhy it exists
fix_countPin count> 0 = in use, cannot evict
recently_freeSecond-chance bitSet on release; cleared on first clock pass
in_archiveProvenance flagTrue if filled from an archive volume
pageidLogical page id of contentsHash key; 0 = empty slot
phy_pageidPhysical page in volumeFrom la_log_phypageid
logpageLOG_PAGE payloadThe page image; SIZEOF_LA_CACHE_LOG_BUFFER(io_pagesize)

LA_CACHE_BUFFER_AREA (one slab): the area header is followed contiguously by its buffers, and next links to the previous slab — singly-linked so expansion never moves existing buffers.

Invariant — every live buffer is reachable by log_buffer[] index and (if pageid != 0) by hash_table key, and the two agree. la_cache_buffer_replace calls mht_rem on a victim’s old pageid before reuse; la_get_page_buffer re-inserts under the new pageid. A stale key would let mht_get return wrong contents — silent corruption.

LA_RECDES_POOLRECDES descriptors plus one contiguous arena, allocated once to avoid per-record malloc during apply.

FieldRoleWhy it exists
recdes_arrRECDES arrayHanded out round-robin in row reconstruction
areapage_size * num_recdes arenaBacks all recdes_arr[i].data
next_idxRound-robin cursorReset to 0 each apply pass
db_page_sizePer-recdes capacityDetects page-size change needing re-init
num_recdesPool sizeLA_MAX_UNFLUSHED_REPL_ITEMS (200) at call site
is_initializedGuardMakes la_init_recdes_pool idempotent

LA_ACT_LOG / LA_ARV_LOG — volume handles.

LA_ACT_LOG fieldRoleWhy it exists
path[PATH_MAX]Active-log pathFrom fileio_make_log_active_name
log_vdesOpen fdNULL_VOLDES until opened
hdr_pageHeader-page bufferRe-read each loop to track master
log_hdrLOG_HEADER* into hdr_page->areaAll append_lsa/nxarv_* reads
db_iopagesizeIO page size from headerSizes decompress/recdes scratch
db_logpagesizeLog page size from headerSizes every cache buffer; 4096 until header read
LA_ARV_LOG fieldRoleWhy it exists
path[PATH_MAX]Archive pathBuilt per archive number on demand
log_vdesOpen archive fdReopened when archive number changes
hdr_pageArchive header bufferLazily malloc-ed on first archive read
log_hdrLOG_ARV_HEADER* into hdr_pageGives fpageid/npages
arv_numArchive number openCompared against needed number to reopen

LA_REPL_FILTER (list/list_size/num_filters/type) is the include/exclude table list; its mechanics are Ch 11, so only its initialization to REPL_FILTER_NONE appears here.

7.2 Entry: cubrid hb start to la_apply_log_file

Section titled “7.2 Entry: cubrid hb start to la_apply_log_file”

cubrid heartbeat start spawns applylogdb; its main lands in util_cs.c, which calls la_apply_log_file (database_name, log_path, max_mem_size) in log_applier.c. That call never returns under normal operation — it is the daemon. Its prologue is a fixed sequence: install signal handlers and set slave db-name/peer host; la_init; optionally sl_init if PRM_ID_HA_SQL_LOGGING; la_check_duplicated (path lock — error returns); la_init_cache_pb (NULL = OUT_OF_MEMORY); la_find_log_pagesize (open active vol, read header); only then la_init_cache_log_buffer with the now-real db_logpagesize; la_init_recdes_pool (db_iopagesize, 200); la_get_last_ha_applied_info; committed_lsa := required_lsa; enter the apply loop (Ch 8+).

Two ordering facts break easily in a refactor:

  • Cache buffers cannot be allocated before the header is read. la_init_cache_pb allocates only the container (LA_CACHE_PB, fields NULL/0). Per-buffer size needs db_logpagesize, unknown until la_find_log_pagesize, so la_init_cache_log_buffer runs after it.
  • la_check_duplicated runs before any volume work, so a second daemon is rejected before it can race on the cache.

7.3 la_init — zeroing and defaulting the global

Section titled “7.3 la_init — zeroing and defaulting the global”

la_init is pure state-setting, no failure path except a one-time VSZ probe. Field semantics are in the 7.1 table; only non-obvious intent is annotated:

// 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;

The db_logpagesize = 4096 is a placeholder; anything sized by it before la_find_log_pagesize would silently use 4096 — which is why la_init_cache_pb allocates no buffers. The defaults exist only so the first header read can malloc its provisional hdr_page. The repl_lists array is not touched here; la_init_repl_lists lazily builds it, LA_REPL_LIST_COUNT (50) buckets at a time.

Pre-apply setup is split: la_check_duplicated (one-time, prologue) and la_apply_pre (top of every outer iteration).

la_check_duplicated guards against a duplicate daemon. It builds "<logpath>/<dbname>_lgla__lock" (LA_LOCK_SUFFIX = "_lgla__lock"), opens it O_RDWR|O_CREAT, then write-locks via 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 */

The lock file also stores last_deleted_arv_num durably — fileio_lock_la_log_path reads the integer back out, which is why the fd is kept in log_path_lockf_vdes for later la_update_last_deleted_arv_num writes. The dbname lock (db_lockf_vdes, via la_lock_dbname) is separate — held only while actively applying.

Invariant — at most one applylogdb per (log_path, dbname) holds the path lock. Two daemons would double-apply rows and corrupt committed_lsa. The OS write-lock enforces single-writer; the loser exits via FILEIO_NOT_LOCKF.

la_apply_pre seeds the per-iteration cursor and lazily allocates scratch:

// 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;

The three == NULL guards make allocation idempotent across iterations. The first line — re-pointing final_lsa at committed_lsa — is how the loop resumes from the last durable commit after a transient stall.

la_get_last_ha_applied_info reads the _db_ha_apply_info row (via la_get_ha_apply_info) and copies its persisted LSAs into la_Info.

flowchart TD
  A["la_get_last_ha_applied_info"] --> B["la_get_ha_apply_info -> res"]
  B --> C{"res > 0?<br/>row found"}
  C -->|yes| D["LSA_COPY committed/rep/append/eof/final/required + counters"]
  D --> E{"db_creation matches header?"}
  E -->|no| Rx["return ER_FAILED"]
  E -->|yes| F{"required_lsa NULL in row?"}
  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-fallback fixups"]
  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, all branches. The NULL-fallback fixups (G/G1) are reached from both the row-found and the fresh-row paths.

The row-found (res > 0) path copies six LSAs and six counters off the row, then guards two ways: a db_creation mismatch means the bookmark belongs to a different database incarnation, so it returns ER_FAILED; a NULL required_lsa in an existing row is also fatal (ER_FAILED) — a written row must have a floor. A fresh database (res == 0) skips the copy and only sets insert_apply_info. The NULL-fallback block then runs on both surviving paths, not just the fresh one: required_lsa NULL falls to the header’s eof_lsa, then each of committed_lsa / committed_rep_lsa / final_lsa NULL falls to required_lsa. (On the row-found path required_lsa is already non-NULL — the guard above caught NULL — so the fallback only ever patches the three cursors there.) The row is then inserted or its start-time updated; only on success does db_commit_transaction persist it. Finally last_committed_lsa/last_committed_rep_lsa snapshot the startup watermark so Ch 8 can skip records already applied.

Back in la_apply_log_file, one line completes the seed: LSA_COPY (&la_Info.committed_lsa, &la_Info.required_lsa). Combined with la_apply_pre’s final_lsa := committed_lsa, the first read cursor lands on required_lsa — the oldest transaction still needing apply.

la_init_cache_pb is trivial: malloc the LA_CACHE_PB, NULL every field, return it (NULL on OOM). No buffers yet.

la_init_cache_log_buffer allocates the first slab, then the hash table:

// 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_buffer does one malloc covering the LA_CACHE_BUFFER_AREA header plus slb_cnt contiguous buffers, then reallocs log_buffer[]. The slab is prepended (area->next = cache_pb->buffer_area) and existing buffers never move, so expansion is safe while pages are pinned. log_pageid_hash special-cases LOGPB_HEADER_PAGE_ID to bucket 0.

la_init_recdes_pool is idempotent and self-healing on size change:

// 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 */

Above the cache: la_get_page -> la_get_page_buffer -> (la_cache_buffer_replace -> la_log_fetch). la_get_page loops on la_get_page_buffer until it succeeds and returns &cache_buffer->logpage.

la_get_page_buffer — hash lookup, fetch-on-miss, pin:

// 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;

The “stale hit” branch (found in the hash but contents no longer match) returns NULL so the caller refetches; every successful return pins.

Release vs. invalidate. la_release_page_buffer unpins and keeps the page, arming the second-chance bit:

// 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 evicts — drops the hash key, clears pin and bit, zeroes identity so the slot reads empty:

// 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_replace — clock-style victim finder feeding la_log_fetch:

flowchart TD
  A["la_cache_buffer_replace"] --> B["found = -1"]
  B --> C["scan log_buffer[] from 'last'"]
  C --> D{"fix_count == 0?"}
  D -->|no, pinned| C
  D -->|yes| E{"recently_free?"}
  E -->|yes| F["clear bit, num_recently_free++"]
  F --> C
  E -->|no| G["found = slot; break"]
  C -->|scanned all| H{"found >= 0?"}
  G --> H
  H -->|yes| I["if pageid!=0 mht_rem old key<br/>fix_count=0; la_log_fetch"]
  I --> J{"fetch ok?"}
  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{"expand ok?"}
  O -->|no| P["return NULL"]
  O -->|yes| C

Figure 7-3. la_cache_buffer_replace victim selection.

The static unsigned int last is the clock hand, persisted across calls. A recently_free buffer gets one reprieve (bit cleared, num_recently_free++ — exactly the bit la_release_page_buffer set); only a fix_count == 0, not-recently-free buffer is chosen. A sweep that found no victim but cleared bits loops again; a sweep that found and cleared nothing (all pinned) grows the pool and retries. The victim’s old key is removed before la_log_fetch overwrites it.

la_log_fetch reads the physical page, choosing active vs. archive:

// 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) tests pageid < nxarv_pageid — pages below the next-archive boundary are already in an archive volume. la_log_fetch_from_archive finds the archive number (la_find_archive_num), reopens the fd if it differs from arv_log.arv_num, reads the header, then the page at offset pageid - fpageid + 1; its ER_PB_BAD_PAGEID branch gotos log_reopen to recover a stale fd. The retry loop absorbs the race where the master archives a page between the test and the read.

Invariant — cache_buffer->pageid is stamped only after a verified read. On any failure it stays 0 (or is reset by la_cache_buffer_replace’s error branch), so a half-filled buffer is never published into the hash table.

7.8 Cross-page record copy: la_log_copy_fromlog

Section titled “7.8 Cross-page record copy: la_log_copy_fromlog”

Records straddle page boundaries. la_log_copy_fromlog copies a record’s type word and data out of the cached pages, hopping pages via the LA_LOG_READ_* macros:

// 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_FIT checks whether offset + length >= LA_LOGAREA_SIZE — the usable bytes per page, defined as la_Info.act_log.db_logpagesize - SSIZEOF(LOG_HDRPAGE) — and if so fetches the next page via la_get_page (++pageid), resetting offset to 0. Because la_get_page recurses into la_get_page_buffer, it pins the next page; those pins are released later by la_release_all_page_buffers. Siblings LA_LOG_READ_ALIGN / LA_LOG_READ_ADD_ALIGN step record-to-record in Ch 8.

Invariant — *length excludes the rectype after phase 1. Phase 1 decrements *length by sizeof(INT16) so phase 2 copies exactly the data. With rec_type == NULL (overflow pages) phase 1 is skipped, so *length stays the full data length.

  1. One global, two init phases. la_init zeroes la_Info with only provisional page sizes; cache buffers and the recdes pool cannot be sized until la_find_log_pagesize reads the real db_logpagesize. la_init_cache_pb allocates only the empty container in between.
  2. The path lock (_lgla__lock, LA_LOCK_SUFFIX) is the single-writer guard. la_check_duplicated rejects a second daemon via fileio_lock_la_log_path; the same file persists last_deleted_archive_num. The dbname lock is a separate, apply-time-only lock.
  3. final_lsa is the read cursor, re-seeded from committed_lsa each outer iteration by la_apply_pre. Startup chains required_lsa -> committed_lsa -> final_lsa.
  4. The bookmark drives recovery. la_get_last_ha_applied_info copies six LSAs and six counters from the row, guards a mismatched db_creation incarnation (and a NULL required_lsa in an existing row), applies NULL-fallbacks on both the row-found and fresh-row paths so every cursor defaults down through required_lsa to the header eof_lsa, and snapshots the restart watermark.
  5. The page cache is a hash table over a clock-replaced flat pool. la_get_page_buffer pins (fix_count++); la_release_page_buffer unpins and arms recently_free; la_invalidate_page_buffer evicts; la_cache_buffer_replace consumes recently_free as a second chance, growing the pool when all buffers are pinned.
  6. la_log_fetch chooses active vs. archive by the nxarv_pageid boundary (LA_LOG_IS_IN_ARCHIVE), re-reads the header and retries up to 5 times to absorb the master-archiving race, and stamps pageid only after a verified read.
  7. Records straddle pages; the LA_LOG_READ_* macros plus la_log_copy_fromlog hop boundaries transparently, subtracting the 2-byte rectype in phase 1 so phase 2 copies exactly the payload.

Chapter 8: Per Record Dispatch and Per Transaction Buffering

Section titled “Chapter 8: Per Record Dispatch and Per Transaction Buffering”

This chapter dissects the inner loop of applylogdb: for every log record the daemon walks forward over, la_log_record_process decides what kind of record it is and where its effects are stashed until the originating transaction’s commit arrives. The high-level companion (cubrid-ha-replication.md, “applylogdb apply pipeline”) explains why the slave buffers items per transaction and replays them in commit order; this chapter traces how, branch by branch. The caller (Chapter 7) has already fetched the page and located the LOG_RECORD_HEADER at final; our job is the switch (lrec->type). Two side structures absorb the dispatched records:

  • The per-transaction apply lists la_Info.repl_lists[] — one LA_APPLY bucket per live trid, each a doubly-linked list of LA_ITEM items.
  • The commit queue la_Info.commit_head / commit_tail — a FIFO of LA_COMMIT nodes, one per end-of-transaction marker, that fixes the replay order.

Three structs (defined at the top of log_applier.c) carry the buffered state.

LA_APPLY — one bucket per live transaction:

FieldRoleWhy
tranidowning transaction id; 0 marks a recycled slotscan key for la_find_apply_list; 0 lets la_add_apply_list reuse a completed slot before growing
num_itemscount of buffered LA_ITEMsthe long-transaction governor compares it against LA_MAX_REPL_ITEMS
is_long_transsticky flag set once the bucket sheds its tailafter it flips, la_set_repl_log stops allocating and only tracks last_lsa
start_lsaLSA of the transaction’s first recordlowest LSA still owned by an unfinished transaction; Chapter 10’s bookmark cannot pass it
last_lsaLSA of the most recent record for a long transactiontells the Chapter 9 long-transaction replay how far to re-scan
head / tailends of the doubly-linked LA_ITEM chainhead is the anchor preserved across a shed; tail is the append point

LA_ITEM — one buffered replication record:

FieldRoleWhy
next / prevlinks in the per-transaction chaina long transaction’s surviving head has both null — la_unlink_repl_item tolerates that
log_typeLOG_REPLICATION_DATA or _STATEMENTdrives the unpack branch in la_make_repl_item and the lazy-key branch in la_get_item_pk_value
item_typemaster’s rcvindex (DATA) or statement type (STATEMENT)identifies the concrete op to replay
class_nametarget class nameresolves the heap/index on the slave
packed_key_value / _lengthdisk image of the PK, left packedunpacked into key only on demand — no DB_VALUE for a key that may yet abort
keyunpacked PK (DATA, on demand) or statement text (STATEMENT)the value the slave applies; STATEMENT fills it eagerly with need_clear = true
db_user / ha_sys_prmstatement-replication contextreplays under the right user and system-parameter set
lsa / target_lsareplication record LSA and master target LSAorder and provenance of the item

LA_COMMIT — one end-of-transaction marker in the FIFO:

FieldRoleWhy
next / prevFIFO linksthe queue, not repl_lists[], fixes replay order
typeLOG_COMMIT / LOG_SYSOP_END / LOG_ABORTan ABORT node tells la_apply_commit_list to discard the bucket, not replay it
tranidowning transactionmatches the node back to its LA_APPLY bucket
log_lsaLSA of the EOT markerthe commit point copied into committed_lsa after a successful drain
log_record_timewall-clock commit time, 0 for sysopsfeeds the time-bound replica delay

8.2 The two prologue guards before the switch

Section titled “8.2 The two prologue guards before the switch”

Every record passes through two gates before la_log_record_process looks at its type.

// 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;
}
}

Guard 1 — corruption / not-yet-written detection. A record is suspect if its trid is NULL_TRANID, or if either back-pointer (prev_tranlsa, predecessor in the same transaction; back_lsa, predecessor in the global log) points forward past final. A back-pointer can only legitimately point at something already written, so a forward one means the page holds bytes not yet a valid record — typically a page fetched a hair before the master flushed it. LOG_END_OF_LOG is tolerated (the active tail’s sentinel carries null/garbage pointers); anything else is unrecoverable mid-stream.

INVARIANT — back-pointers never point past the record being read. Enforced by Guard 1’s LSA_GT tests on back_lsa and prev_tranlsa. If violated, the daemon refuses to interpret the bytes (which would dereference uninitialized page memory and likely SEGV or apply garbage) and shuts the applier down. The LOG_END_OF_LOG exception is the only legal way past this gate with a forward/null pointer.

Guard 2 — first-sight registration. The second guard fires when LSA_ISNULL (&lrec->prev_tranlsa) and the record is not an exemption (type != LOG_END_OF_LOG && type != LOG_DUMMY_HA_SERVER_STATE && trid != LOG_SYSTEM_TRANID — none own a bucket). prev_tranlsa == NULL marks a transaction’s first log record (no predecessor in its own chain), so the guard calls apply = la_add_apply_list (lrec->trid) and, the first time apply->start_lsa is null, copies final into it so Chapter 10’s bookmark knows the lowest LSA still belonging to an unfinished transaction. A NULL return means OOM: set la_applier_need_shutdown, return the captured er_errid () (or ER_FAILED).

Only after both guards does the function set la_Info.is_end_of_record = false and enter the type switch.

Every arm is traced below. ER_INTERRUPTED is not a failure — the caller (Chapter 7) reads it as “stop this page walk and re-evaluate”, cleanly breaking the inner loop for end-of-log, role change, and crash-recovery markers.

LOG_END_OF_LOG. The tail. Two sub-cases, both returning ER_INTERRUPTED: if the next page exists and the current page is archived (la_does_page_exist(pageid+1) && la_does_page_exist(pageid) == LA_PAGE_EXST_IN_ARCHIVE_LOG), it is a premature EOF in an archive file — warn and skip via final->pageid++; final->offset = 0; otherwise it is the genuine active-log tail — set la_Info.is_end_of_record = true.

LOG_REPLICATION_DATA / LOG_REPLICATION_STATEMENT. The productive arm: both feed la_set_repl_log (pg_ptr, lrec->type, lrec->trid, final) to append the record to the transaction’s bucket (Section 8.4); a non-NO_ERROR return poisons the daemon and propagates.

LOG_SYSOP_END / LOG_COMMIT. The flush trigger — the arm that drains buffered items to the slave.

// 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;

The outer LSA_GT is the idempotency gate (LSA at/behind the durable bookmark → already applied last run, discard the buckets). Each successful la_apply_commit_list pass copies lsa_apply into committed_lsa and increments commit_counter for real commits only. The non-obvious branch is the inner error triage in the do/while: it fires when la_apply_commit_list returns ER_NET_CANT_CONNECT_SERVER, then inspects er_errid () — recoverable server-down aborts (ER_TM_SERVER_DOWN_UNILATERALLY_ABORTED, ER_LK_UNILATERALLY_ABORTED) are swallowed and the loop retries; any other er_errid () re-raises ER_NET_SERVER_COMM_ERROR and returns. Independently, ER_HA_LA_EXCEED_MAX_MEM_SIZE and ER_TDE_CIPHER_IS_NOT_LOADED (Chapter 11’s TDE path) poison and return; flush errors (LA_IS_FLUSH_ERROR) return immediately.

LOG_ABORT. A rolled-back transaction needs a commit-queue slot so replay order stays gap-free, but with no items: the arm calls la_add_node_into_la_commit_list (lrec->trid, final, LOG_ABORT, 0); when la_apply_commit_list later dequeues an ABORT node it discards that trid’s bucket.

LOG_DUMMY_CRASH_RECOVERY. Written by a master that itself recovered from a crash; contiguity can’t be trusted across it — log a notification, jump final to lrec->forw_lsa, return ER_INTERRUPTED to force a re-anchor. LOG_END_CHKPT. Checkpoint-end marker — break (no-op).

LOG_DUMMY_HA_SERVER_STATE. The HA heartbeat and role-change tripwire. After ha_server_state = la_get_ha_server_state (pg_ptr, final), three outcomes: (a) ha_server_state == NULL (unreadable) → log and break; (b) state != HA_SERVER_STATE_ACTIVE && state != HA_SERVER_STATE_TO_BE_STANDBY and we hold the DB lock file (la_Info.db_lockf_vdes != NULL_VOLDES) → set la_Info.is_role_changed = true, return ER_INTERRUPTED — the failover tripwire (Chapter 11); (c) state still active and la_is_repl_lists_empty ()la_update_ha_apply_info_log_record_time (ha_server_state->at_time) then flush the bookmark via la_log_commit (true) (returning on error). The empty-lists check matters: flushing the bookmark on a heartbeat while a transaction is still buffered would skip un-applied work.

default. Any other record type — break (ignored).

flowchart TD
  SW{"lrec->type"} --> INT["EOL / CRASH_RECOVERY / HA role-change\n-> ER_INTERRUPTED, break page walk"]
  SW --> RD["REPL_DATA/STATEMENT -> la_set_repl_log buffers"]
  SW --> CM["SYSOP_END / COMMIT -> enqueue LA_COMMIT, drain loop"]
  SW --> AB["ABORT -> enqueue LA_COMMIT type=ABORT"]
  SW --> NOP["END_CHKPT / HA active / default -> break"]

Figure 8-1: the switch arms grouped by outcome. The three ER_INTERRUPTED arms break the page walk; the rest buffer, flush, abort-placeholder, or no-op.

After the switch, an epilogue is a second corruption net for the last record of an archive page: the predicate lrec->forw_lsa.pageid == -1 || lrec->type <= LOG_SMALLER_LOGREC_TYPE || lrec->type >= LOG_LARGER_LOGREC_TYPE (inclusive <=/>= — the boundary sentinels themselves count as invalid) returns ER_LOG_PAGE_CORRUPTED (after bumping past an archive page if applicable); otherwise NO_ERROR.

8.4 la_set_repl_log — buffering with the long-transaction bypass

Section titled “8.4 la_set_repl_log — buffering with the long-transaction bypass”

la_set_repl_log looks up the bucket with apply = la_find_apply_list (tranid), then four branches:

// 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 */

Non-obvious branches: (1) drops a transaction whose first record we never saw (began before the applier’s start point). Branches (2)/(3) are the memory governor — at the threshold la_free_all_repl_items_except_head releases every item except the first (preserving the list anchor and its start_lsa companion) and flips is_long_trans; thereafter (2) only advances apply->last_lsa so the long-transaction replay (Chapter 9) knows how far to re-scan from the master log directly.

INVARIANT — a long transaction holds at most one buffered item (its head). Enforced by la_free_all_repl_items_except_head at the crossover and by the already-long branch (which never allocates). Memory is bounded; the price is that Chapter 9 re-reads the records straight from the log between start_lsa and last_lsa — exactly the blow-up LA_MAX_REPL_ITEMS exists to prevent.

la_free_all_repl_items_except_head walks from apply->head->next, freeing each via la_free_repl_item; a null head returns immediately. la_free_repl_item first unlinks the item (la_unlink_repl_item, which tolerates a long transaction’s unlinked head), then releases class_name (and, only when class_name was set, clears key via pr_clear_value), db_user, ha_sys_prm, and packed_key_value before freeing the node.

8.5 la_make_repl_item / la_add_repl_item — unpacking and linking

Section titled “8.5 la_make_repl_item / la_add_repl_item — unpacking and linking”

la_make_repl_item builds a populated LA_ITEM: align/advance past the record header (LA_LOG_READ_ALIGN, LA_LOG_READ_ADVANCE_WHEN_DOESNT_FIT — any failure → NULL), copy repl_log->length bytes into a heap area, get a zeroed skeleton from la_new_repl_item (lsa, &repl_log->lsa) (copies lsa and the master’s target_lsa), then 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) keeps the PK packed, unpacked into item->key only on demand by la_get_item_pk_value; item_type carries rcvindex. LOG_REPLICATION_STATEMENT (Chapter 4) puts the statement text in item->key (need_clear = true) plus db_user / ha_sys_prm. The default arm and any alloc failure jump to error_return (frees area and the half-built item, returns NULL); on success item->log_type is stamped, area freed, item returned. la_add_repl_item is a plain tail append, incrementing apply->num_items.

8.6 The per-trid bucket: find, add, and realloc-on-demand

Section titled “8.6 The per-trid bucket: find, add, and realloc-on-demand”

la_Info.repl_lists[] is a flat array of LA_APPLY *; each non-recycled slot holds one transaction’s tranid and the doubly-linked LA_ITEM chain (head to tail), while a recycled slot has tranid == 0.

la_find_apply_list (tranid) linearly scans repl_lists[0 .. cur_repl) for the matching tranid, else NULL. la_add_apply_list (tranid) is find-or-allocate: (1) if la_find_apply_list has it, return it; (2) else reuse the first recycled slot (tranid == 0, left by a completed transaction) by stamping tranid; (3) else if cur_repl == repl_cnt (full), grow via la_init_repl_lists (true) (failure → NULL); (4) claim repl_lists[cur_repl], bump cur_repl, return it.

la_init_repl_lists (need_realloc) owns the array’s storage:

// 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;
}

Starting the loop at j = old_repl_cnt means the realloc initializes only the newly added slots — existing buckets and their item chains survive untouched.

INVARIANT — cur_repl <= repl_cnt, and slots [0, cur_repl) are the only live buckets. la_add_apply_list never claims repl_lists[cur_repl] without first ensuring cur_repl < repl_cnt (the grow branch fires precisely when they are equal), and reuses a recycled tranid == 0 slot ahead of growing, so the array grows only when all live slots are occupied. Breaking this indexes past the array (heap corruption).

8.7 The commit queue: la_add_node_into_la_commit_list and la_retrieve_eot_time

Section titled “8.7 The commit queue: la_add_node_into_la_commit_list and la_retrieve_eot_time”

la_add_node_into_la_commit_list (tranid, lsa, type, eot_time) mallocs an LA_COMMIT, fills type (LOG_COMMIT / LOG_SYSOP_END / LOG_ABORT), log_record_time = eot_time, log_lsa (from the EOT marker) and tranid, then appends to the FIFO at la_Info.commit_tail (or seeds head+tail if empty; OOM → ER_OUT_OF_VIRTUAL_MEMORY). This queue — not repl_lists[] — fixes replay order: Chapter 9’s la_apply_commit_list walks it head-first, so transactions apply in strict master-commit order regardless of how their DML records interleaved on the log.

la_retrieve_eot_time (pgptr, lsa) reads the wall-clock commit time: position past the record header, align/advance to fit a LOG_REC_DONETIME, return 0 on any read failure (the “no time” sentinel the commit arm uses to skip la_delay_replica) or donetime->at_time on success. Only LOG_COMMIT calls it; LOG_SYSOP_END hard-codes 0 (no EOT timestamp).

  1. la_log_record_process gates every record through two guards — a corruption/forward-pointer check that shuts the applier down (except the EOF sentinel), and a first-sight prev_tranlsa == NULL registration that allocates the bucket and stamps start_lsa.
  2. The dispatch switch has eight reachable arms; three (LOG_END_OF_LOG, LOG_DUMMY_CRASH_RECOVERY, the role-change case of LOG_DUMMY_HA_SERVER_STATE) return ER_INTERRUPTED to break the page walk, not to signal failure. Records are buffered until the LOG_COMMIT / LOG_SYSOP_END arm fires la_apply_commit_list; LOG_ABORT enqueues an order-preserving placeholder with no items.
  3. la_set_repl_log enforces the long-transaction cap: at LA_MAX_REPL_ITEMS (1000) a bucket sheds all but its head, sets is_long_trans, and thereafter only tracks last_lsa — bounding memory at the cost of a later log re-scan.
  4. la_make_repl_item unpacks LOG_REPLICATION_DATA (lazy packed-key + rcvindex) and LOG_REPLICATION_STATEMENT (statement + db_user + ha_sys_prm) differently, sharing an error_return that frees the half-built item. repl_lists[] is a recycle-first, grow-by-50 array; la_add_apply_list reuses tranid == 0 slots before la_init_repl_lists (true) reallocs, keeping cur_repl <= repl_cnt.
  5. The LA_COMMIT FIFO from la_add_node_into_la_commit_list — timestamped by la_retrieve_eot_time for real commits, 0 for sysops — imposes master-commit replay order on the slave.

Chapter 9: Applying a Committed Transaction and Reconstructing the Row Image

Section titled “Chapter 9: Applying a Committed Transaction and Reconstructing the Row Image”

Chapter 8 left us with the per-transaction picture: each trid owns an LA_APPLY whose head .. tail chain holds the buffered LA_ITEMs, and la_Info.commit_head .. commit_tail holds the LA_COMMIT records queued per LOG_COMMIT/LOG_ABORT/LOG_SYSOP_END. This chapter closes the apply lifecycle: when the main loop drains a commit record, how are the buffered events replayed in order, how is the after-image rebuilt from the master’s heap log, and how does the row reach the slave server? The “pull, buffer until commit, replay” theory lives in the companion (cubrid-ha-replication.md §“Per-transaction buffering until commit on the slave” and §“Reconstructing the row image — case analysis”); here we trace branches.

9.1 la_apply_commit_list — one commit record per call

Section titled “9.1 la_apply_commit_list — one commit record per call”

The main loop calls this whenever it advances past a commit. It processes exactly one head-of-queue commit (la_Info.commit_head whose type is LOG_COMMIT/LOG_SYSOP_END/LOG_ABORT), calls la_apply_repl_log, LSA_COPYs the drained log_lsa into the caller’s *lsa, then unlinks and free_and_inits the node (see Figure 9-1).

If commit_head == NULL or the head is not a commit-class record, the body is skipped and *lsa stays NULL. Apply success or failure both unlink and free the node — the function does not abort on apply error, propagating it up so the caller decides whether to reconnect. Only LOG_COMMIT copies log_record_time (the master’s commit stamp the delay report subtracts from now()).

Invariant: the commit queue is drained strictly FIFO, one node per call. commit_head advances by exactly one node and only the head is freed. If a future change looped over multiple commits, the per-call *lsa return (the caller’s durable bookmark, Chapter 10) would no longer identify a single safe restart point.

flowchart TB
  A["la_apply_commit_list(lsa)"] --> C{"commit_head != NULL\nand type in COMMIT/SYSOP_END/ABORT?"}
  C -- no --> Z["return NO_ERROR, lsa stays NULL"]
  C -- yes --> D["la_apply_repl_log(tranid, type, log_lsa)"]
  D --> E["LSA_COPY(lsa, commit.log_lsa)"]
  E --> F{"type == LOG_COMMIT?"}
  F -- yes --> G["log_record_time = commit.log_record_time"]
  F -- no --> H["unlink + free commit node"]
  G --> H
  H --> I["return error"]

Figure 9-1 — la_apply_commit_list: one commit node drained per call.

9.2 la_apply_repl_log — draining one transaction’s item chain

Section titled “9.2 la_apply_repl_log — draining one transaction’s item chain”

The per-transaction fan-out looks up the LA_APPLY for the trid, then walks apply->head replaying each item.

Pre-flight branches (before la_lock_dbname and the loop): la_find_apply_list(tranid) == NULL returns NO_ERROR (never buffered); rectype == LOG_ABORT calls la_clear_applied_info and returns. The already-applied guard apply->head == NULL || LSA_LE(commit_lsa, &la_Info.last_committed_lsa) (nothing buffered, or commit_lsa <= the restart bookmark of Chapter 10) means a previous run handled it — here LOG_SYSOP_END calls la_free_all_repl_items (frees only items; the list node is reused for the rest of the long transaction), any other type calls la_clear_applied_info for a full reset. Otherwise it takes the slave DB-name lock and loops.

The replay loop — per-item dispatch:

// 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 chooses data vs. statement; for data, item_type chooses the verb. RVREPL_DATA_UPDATE_START and _END both fall through to la_apply_update_log — the START/END bracket only mattered on the master (Chapter 4). Any other data item_type or unknown log_type trips assert_release(false).

Invariant: committed_rep_lsa only advances on a successful item apply, and only forward. It is LSA_COPY’d to item->lsa strictly after error == NO_ERROR; the skip guard LSA_GT(&item->lsa, &last_committed_rep_lsa) reads the restart baseline, so a re-run never re-applies a covered item. Break this and a crash mid-batch would drop or double-apply rows.

Error sub-branches (the else when error != NO_ERROR):

  • Flush error (LA_IS_FLUSH_ERROR(error)): goto end — the caller reconnects and retries the whole transaction.
  • Server-down (ER_IS_SERVER_DOWN_ERROR(er_errid())): convert to ER_NET_CANT_CONNECT_SERVER, goto end.
  • Retryable, not ignorable (la_ignore_on_error false and la_retry_on_error true): er_set an ER_HA_GENERIC_ERROR notification, LA_SLEEP(10, 0), then continue — jumping back to the while without advancing or freeing item, retrying it after the sleep (the LA_RETRY_ON_ERROR loop).
  • Ignorable (else): falls through, item freed, loop advances — the failure is swallowed.

Loop advance / long-transaction break. la_get_next_repl_item dispatches on apply->is_long_trans: a normal transaction returns item->next (la_get_next_repl_item_from_list); only when is_long_trans does it re-read the log via apply->last_lsa (la_get_next_repl_item_from_log). If the next item’s LSA exceeds commit_lsa it must be a LOG_SYSOP_END partial commit (asserted), so the loop breaks with has_more_commit_items = true. At end: for LOG_SYSOP_END, survivors past commit_lsa are re-attached via la_free_and_add_next_repl_item (else la_free_all_repl_items empties the list but keeps the node); a normal LOG_COMMIT calls la_clear_applied_info for a full reset.

9.3 The three data-DML appliers — common shape

Section titled “9.3 The three data-DML appliers — common shape”

la_apply_insert_log, la_apply_update_log, la_apply_delete_log all open with la_flush_repl_items (false) and close on the same two-arm tail: error → la_log_apply_error (bumps fail_counter), success → bump the per-verb counter and num_unflushed. Between those bookends they diverge in two real ways (Figure 9-2): insert and update fix a log page and rebuild the after-image while delete does neither, and the two image-rebuilders resolve the class at different points.

flowchart TB
  B["la_flush_repl_items(false)"] --> P["la_get_page(item->target_lsa.pageid)"]
  P -- NULL --> XR["return er_errid()"]
  P -- ok --> CIN{"insert: db_find_class first\nupdate: defer class to after guards"}
  CIN --> E["la_get_recdes(&target_lsa, pgptr, recdes, &rcvindex, is_mvcc_class)"]
  E --> F{"recdes.type ASSIGN_ADDRESS\nor RELOCATION?"}
  F -- yes --> X["ER_FAILED -> end"]
  F -- no --> G{"rcvindex in allowed set?"}
  G -- no --> X
  G -- yes --> J["la_repl_add_object(class_obj, item, recdes)"]
  J --> K["counter++ ; num_unflushed++ ; la_release_page_buffer"]

Figure 9-2 — Common insert/update apply flow. Delete skips P-G and the page release.

Class-resolution order differs by verb. la_apply_insert_log calls db_find_class before la_get_recdes, because it must derive is_mvcc_class = la_is_mvcc_class (ws_oid (class_obj)) and pass it in (the flag drives the §9.5 loaddb up-conversion). la_apply_update_log reverses this: it calls la_get_recdes with a hard-coded is_mvcc_class = false, runs its guards, and only then db_find_class — sound because the update redo log already carries the fully-formed image. A missing class thus exits early in insert but post-guard in update.

The type guards differ. Both first reject recdes->type == REC_ASSIGN_ADDRESS || REC_RELOCATION (an indirection that escaped la_get_recdes) with ER_FAILED. Then insert accepts only RVHF_INSERT/RVHF_MVCC_INSERT; update accepts RVHF_UPDATE, RVOVF_CHANGE_LINK, RVHF_MVCC_INSERT, RVHF_UPDATE_NOTIFY_VACUUM, RVHF_INSERT_NEWHOME.

Page-fix lifecycle is insert/update only. Both pin the heap-redo log page with la_get_page (item->target_lsa.pageid) — a NULL return is an immediate return er_errid() before the end label (no counter, no release) — and both la_release_page_buffer (old_pageid) on exit.

Delete is the pkey-only path. la_apply_delete_log never touches a log page or la_get_recdes — it needs only the key in item->packed_key_value, calling la_repl_add_object (class_obj, item, NULL); on error la_log_apply_error(..., ER_HA_LA_FAILED_TO_APPLY_DELETE), on success it bumps 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 — the five-case after-image reconstructor

Section titled “9.4 la_get_recdes — the five-case after-image reconstructor”

Given the target LSA of a heap redo record, la_get_recdes produces the on-disk RECDES. Its is_mvcc_class input (passed in from §9.3, false for update) only gates the §9.5 MVCC tweaks at the tail. The base call is always la_get_log_data (§9.6), which fills recdes->data/length and sets recdes->type = *(INT16 *) rec_type; after that an if/else if ladder runs exactly one of the four indirection cases below (a fifth implicit case is “none of them”).

CaseConditionMeaningHelper
1RVOVF_CHANGE_LINKOverflow record updated; redo only changed the link. Re-stitch, force REC_BIGONE.la_get_overflow_recdes(RVOVF_PAGE_UPDATE)
2type == REC_BIGONEFresh overflow insert: home slot points to an overflow-page chain.la_get_overflow_recdes(RVOVF_NEWPAGE_INSERT)
3RVHF_INSERT + REC_ASSIGN_ADDRESSDeferred-insert: slot reserved first, real image in a later UPDATE of the trid.la_get_next_update_log (then case 2 if BIGONE)
4RVHF_UPDATE/_NOTIFY_VACUUM + REC_RELOCATIONHome slot forwards; real image at the relocated RVHF_INSERT_NEWHOME.la_get_relocation_recdes

Each arm re-reads recdes->type from the helper’s rec_type; case 3 re-enters case 2 if the chased real row is itself REC_BIGONE. If none match (the common normal REC_HOME path), the base image is already full — the implicit fifth case. Then the §9.5 MVCC tweaks run.

A master heap record may lack the slot-resident MVCC insert id; the slave re-materializes the layout its own server expects. For RVHF_MVCC_INSERT it just calls la_make_room_for_mvcc_insid (recdes). The second arm (is_mvcc_class && *rcvindex == RVHF_INSERT && type != REC_BIGONE) exists for the loaddb bulk insert on a CS-mode master, whose rows are logged as plain RVHF_INSERT without MVCC flags: it sets VALID_INSID, calls la_make_room_for_mvcc_insid, and if recdes->length > la_Info.maxslotted_reclength (the record will overflow into REC_BIGONE) also sets VALID_DELID|VALID_PREV_VERSION and calls la_make_room_for_mvcc_delid_and_prev_ver. Both helpers assert their flag preconditions, then LA_MOVE_INSIDE_RECORD-shift the body right; LA_MOVE_INSIDE_RECORD asserts area_size >= length + delta, so an undersized buffer trips an assert rather than corrupting memory.

9.6 la_get_log_data and the decompress path

Section titled “9.6 la_get_log_data and the decompress path”

la_get_log_data reads one heap log record’s data area, switching on lrec->type:

  • LOG_UNDOREDO_DATA / LOG_DIFF_UNDOREDO_DATA / MVCC variants — pick the MVCC_UNDOREDO vs UNDOREDO layout. If match_rcvindex == 0 or the record’s rcvindex matches, publish *rcvindex/*logs; else *logs = NULL (“not the record you wanted”). DIFF types call la_get_undoredo_diff for the undo image; others align past it.
  • LOG_UNDO_DATA/LOG_MVCC_UNDO_DATA and LOG_REDO_DATA/LOG_MVCC_REDO_DATA — same match/publish over LOG_REC_UNDO/LOG_REC_REDO.
  • default*logs = NULL, no data.

If ZIP_CHECK(temp_length), the redo is log_unzip’d and la_get_zipped_data produces the final image — for DIFF by log_diff (XOR) of the undo against the decompressed redo, otherwise a straight copy — peeling the leading INT16 rec_type when requested and allocating when is_overflow. Otherwise the raw payload is copied directly.

la_get_overflow_recdes walks prev_tranlsa backward, prepending every LOG_REDO-type record of the trid (head ends first), stopping at a trid change or LOG_DUMMY_OVF_RECORD, then concatenates the parts, stripping LA_OVF_FIRST_PART/LA_OVF_REST_PARTS headers. Malloc failure frees the partial list and returns ER_OUT_OF_VIRTUAL_MEMORY.

la_get_next_update_log (deferred-insert chaser) scans forward from prev_lrec->forw_lsa for the UPDATE whose data.{pageid,offset,volid} match the reserved slot and whose rcvindex is RVHF_UPDATE/_NOTIFY_VACUUM. Failure mode: hitting LOG_COMMIT/LOG_ABORT for the trid first returns ER_GENERIC_ERROR — the expected update never arrived.

la_get_relocation_recdes follows prev_tranlsa one hop back to the RVHF_INSERT_NEWHOME record (calling la_get_log_data with match_rcvindex == RVHF_INSERT_NEWHOME); a null prev LSA or trid mismatch raises ER_LOG_PAGE_CORRUPTED.

9.8 la_disk_to_obj and la_repl_add_object — to the slave server

Section titled “9.8 la_disk_to_obj and la_repl_add_object — to the slave server”

la_disk_to_obj (SQL-logging path only, not the main flush) turns a RECDES into a DB_OTMPL, parsing the MVCC header for the skip size: mvcc_flags == 0 advances past chn only; otherwise past INSID/DELID (OR_MVCCID_SIZE), chn, then a PREV_VERSION LOG_LSA if flagged, before la_get_current reads columns. A buf->ptr > buf->endptr overrun returns ER_TF_BUFFER_OVERFLOW.

la_repl_add_object is the bridge to the slave server. It returns early if au_fetch_class or sm_flush_objects fails, then (skipping sm_partitioned_class_type for delete) maps item_type to an operation in a switch — UPDATE_*LC_UPDATE_OPERATION_TYPE, INSERTLC_INSERT_OPERATION_TYPE, DELETELC_FLUSH_DELETE, default: assert(false) on an unknown verb — and finally queues the object via __gv_loc_repl.ws_add_to_repl_obj_list (class_oid, item->packed_key_value, ..., recdes, operation, has_index). The network flush happens later in la_flush_repl_items.

9.9 la_need_filter_out — per-item table filtering

Section titled “9.9 la_need_filter_out — per-item table filtering”

Before any data item, la_apply_repl_log calls this. It strips [...] quoting from item->class_name, then returns false (never filter) when filter->type == REPL_FILTER_NONE, for most statement replication (log_type == LOG_REPLICATION_STATEMENT && item_type != CUBRID_STMT_TRUNCATE — only truncate, a bulk delete on one table, is filterable), or for the serial system class (CT_SERIAL_NAME, which must always replicate to keep sequence state consistent). Otherwise it scans filter->list for the class and returns true when REPL_FILTER_INCLUDE_TBL && !filter_found or REPL_FILTER_EXCLUDE_TBL && filter_found.

9.10 la_apply_statement_log — statement replay and determinism

Section titled “9.10 la_apply_statement_log — statement replay and determinism”

This path replays SQL text, not a row image: it flushes pending row objects hard (la_flush_repl_items(true)), optionally sets item->db_user (skipped for stored-procedure and user DDL via need_set_user = false) and item->ha_sys_prm around the call, runs res = la_update_query_execute(stmt_text, false), and buckets the affected-row count (DDL → schema_counter; INSERT/DELETE/UPDATE → matching counter). TRUNCATE short-circuits to NO_ERROR if filtered; CUBRID_STMT_DROP_LABEL/default returns NO_ERROR (unsupported, ignored).

Non-happy-path branches:

  • User not found — when need_set_user and a db_user is set, if au_find_user(item->db_user) == NULL, a server-down er_errid() is converted to ER_NET_CANT_CONNECT_SERVER, otherwise error = er_errid() (asserted non-zero); either way it breaks out of the switch.
  • Query-execute failure — if res < 0, error = er_errid(), and an ER_IS_SERVER_DOWN_ERROR(error) is again converted to ER_NET_CANT_CONNECT_SERVER.
  • Final failure arm — after the switch, any error != NO_ERROR er_sets ER_HA_LA_FAILED_TO_APPLY_STATEMENT (class, stmt, error, message) and bumps la_Info.fail_counter++ before returning.

Because this executes SQL on the slave, statement-based DML inherits any non-determinism (NOW(), RAND(), …) — CUBRID does not rewrite or block such functions at emit time. This is the divergence risk in the companion’s Open Questions (cubrid-ha-replication.md §“Open Questions” item 8). Row-based replication (§9.3–9.8) avoids it by shipping the materialized after-image, not the recipe.

  1. la_apply_commit_list drains one commit per call and returns its LSA as the durable-bookmark candidate; only LOG_COMMIT updates the delay clock log_record_time.
  2. la_apply_repl_log replays the item chain in LSA order, dispatching on log_type then item_type (UPDATE_START/_END both route to update); la_get_next_repl_item returns item->next normally and only re-reads the log via last_lsa when is_long_trans.
  3. committed_rep_lsa advances only on a successful apply, only forward — the skip guard makes re-runs idempotent. Retryable errors LA_SLEEP(10,0)+continue without advancing; flush/server-down goto end to reconnect; ignorable errors are swallowed.
  4. Delete is the pkey-only path (no log page, la_repl_add_object(..., NULL)); insert and update pin a heap-redo log page (la_get_page / la_release_page_buffer) and rebuild the after-image, differing in their accepted rcvindex sets and in class-resolution order — insert resolves the class (and la_is_mvcc_class) before la_get_recdes, update after its guards.
  5. la_get_recdes resolves five record shapes — normal REC_HOME, RVOVF_CHANGE_LINK overflow-update, REC_BIGONE overflow-insert, RVHF_INSERT+REC_ASSIGN_ADDRESS deferred-insert (la_get_next_update_log), and RVHF_UPDATE*+REC_RELOCATION relocation (la_get_relocation_recdes).
  6. The slave re-materializes MVCC headers via la_make_room_for_mvcc_insid / ..._delid_and_prev_ver (notably to up-convert loaddb rows), after the la_get_log_data / la_get_undoredo_diff / la_get_zipped_data unzip-and-XOR-diff decode.
  7. la_repl_add_object and la_apply_statement_log both have hard error exits — the former returns early on au_fetch_class / sm_flush_objects failure and asserts on an unknown verb; the latter bumps fail_counter and er_sets ER_HA_LA_FAILED_TO_APPLY_STATEMENT on any non-NO_ERROR exit, converting server-down to ER_NET_CANT_CONNECT_SERVER.

Chapter 10: Durable Apply Bookmark and Restart Recovery

Section titled “Chapter 10: Durable Apply Bookmark and Restart Recovery”

On boot, applylogdb (Ch 7–9) must answer: where did I stop applying? CUBRID keeps that bookmark in the slave catalog table _db_ha_apply_info, updated inside the same slave transaction that applies each batch of replicated rows — so bookmark advance and row apply commit or roll back together. This chapter covers the struct mirror (LA_HA_APPLY_INFO), the writer (la_log_commit), the boot read-back, the SQL helpers, the floor (la_find_required_lsa), and archive trimming (la_remove_archive_logs). For why a durable cursor is needed see the companion’s “Forward log walking with a position cursor and durable bookmark” and “Atomic emission of replication and commit records” — not re-derived here.

10.1 LA_HA_APPLY_INFO — the in-memory mirror of the catalog row

Section titled “10.1 LA_HA_APPLY_INFO — the in-memory mirror of the catalog row”

LA_HA_APPLY_INFO is a one-to-one image of a single _db_ha_apply_info row keyed by (db_name, copied_log_path). It is a transfer struct: la_get_ha_apply_info fills it from a SELECT, the values are copied into the long-lived global la_Info, and the fields then live scattered across la_Info and are written back via 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;
};
FieldRoleWhy it exists
db_nameRow key half 1; master DB prefix.Disambiguates rows per master.
creation_timeMaster log_hdr->db_creation snapshot.Boot guard against log re-creation (10.3).
copied_log_pathRow key half 2; slave log_path.Distinguishes two copylog streams.
committed_lsaHighest committed commit LSA.”How far replicated”; re-apply floor.
committed_rep_lsaHighest committed REPL LSA.Separate stream; REPL-filter floor.
append_lsaMaster header append_lsa at write.Lag diagnostics.
eof_lsaMaster header eof_lsa.Lag diagnostics; readable bound.
final_lsaLast LSA processed (read+dispatched).Restart resume cursor; seeds la_Info.final_lsa.
required_lsaStart LSA of oldest open txn.Archive-trim floor (10.6–10.7).
log_record_timeLast applied commit record time (master).Replication-delay metric.
log_commit_timeSlave commit wall-clock.Delay / operator visibility.
last_access_timeSYS_DATETIME of last write.Liveness heartbeat.
statusLA_STATUS_IDLE / _BUSY.Forced IDLE on read-back (10.2).
insert_counterfail_counterCumulative row/failure tallies.Survive restarts; resume from stored value.
start_timeWhen this instance (re)started.Separates “since boot” from cumulative.

la_init_ha_apply_info zeroes the struct, then NULL-marks the six LSA fields:

// 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 zeroes the bytes, but LSA_SET_NULL writes the sentinel (pageid = -1) that LSA_ISNULL tests for — a zero LSA is a valid page-0 position, not “absent”. This lets la_get_last_ha_applied_info distinguish “column was SQL NULL” from “column was 0”.

Invariant — the struct is a strict mirror of one catalog row. Every field maps to one _db_ha_apply_info column (LSAs split into _pageid/_offset pairs). la_get_ha_apply_info asserts both col_cnt == LA_OUT_VALUE_COUNT (the SELECT returned exactly 23 columns) and out_value_idx == LA_OUT_VALUE_COUNT after the decode; the column guard is what actually catches a schema that grew a column. The INSERT mirror, la_insert_ha_apply_info, asserts in_value_idx == LA_IN_VALUE_COUNT (15 bound params; the other 11 columns are SQL literals). Add a column without bumping these counts and the assert_release fires — schema and struct cannot silently drift.

flowchart LR
  cat["_db_ha_apply_info row\n(LSA cols split pageid/offset)"]
  mem["LA_HA_APPLY_INFO\n(transient)"]
  glob["la_Info\n(resident)"]
  cat -->|"la_get_ha_apply_info SELECT"| mem
  mem -->|"la_get_last_ha_applied_info copy"| glob
  glob -->|"la_update_ha_last_applied_info UPDATE"| cat

Figure 10-1. The row, its transient struct image, and the resident globals — and the three functions that move data between them.

10.2 Reading the row — la_get_ha_apply_info

Section titled “10.2 Reading the row — la_get_ha_apply_info”

A parameterized SELECT against CT_HA_APPLY_INFO_NAME keyed by (prefix_name, log_path), returning the affected-tuple count as res (>0 found, 0 not found, <0 error). Branch map:

  1. db_find_class (CT_HA_APPLY_INFO_NAME) == NULL → catalog class missing; assert (er_errid () != NO_ERROR), return the error id (the “DB not yet HA-prepared” path).
  2. db_execute_with_values (...)res. Only res > 0 decodes a tuple; else fall through to cleanup and return res (0 = no row, the caller’s insert path).
  3. db_query_first_tuple switch — DB_CURSOR_SUCCESS decodes all 23 out-values (the six LSA columns test DB_IS_NULL on both halves and LSA_SET_NULL + out_value_idx += 2 on NULL; append_lsa/eof_lsa read unconditionally; status forced to LA_STATUS_IDLE). END / ERROR / defaultres = ER_FAILED.
  4. Cleanup: db_query_end + db_value_clear on every in-value, always reached; out-values cleared only in the SUCCESS arm.

The NULL-tolerant decode mirrors la_init_ha_apply_info: a legitimately NULL column (e.g. committed_lsa before the first commit) decodes back to LSA_ISNULL, not a bogus page-0 position.

10.3 Seeding the cursor at boot — la_get_last_ha_applied_info

Section titled “10.3 Seeding the cursor at boot — la_get_last_ha_applied_info”

The restart-recovery entry point. It runs once during applylogdb startup (Ch 7) and decides the resume 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; /* <- stored row is a different DB incarnation */
if (LSA_ISNULL (&la_Info.required_lsa))
{ er_set (...ER_HA_GENERIC_ERROR...); return ER_FAILED; } /* <- malformed row */
}
else if (res == 0)
insert_apply_info = true; /* <- first ever boot: no row yet */
else
return res; /* <- SELECT error, propagate */

Three top-level branches — row found, no row (flag insert), error (return) — plus two hard failures inside the found branch: a creation-time mismatch (the copied log was replaced by a different DB incarnation) and a NULL required_lsa (an existing row must carry one). Then four LSA_ISNULL fixups establish the fresh-row floor in dependency order:

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 */

On a brand-new applier all four collapse onto the master’s current eof_lsa (replication starts from “now”); on a resume the stored final_lsa is non-NULL and untouched. Finally the row is persisted and the seed locked in:

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 == failure */
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);

Invariant — last_committed_lsa is the high-water floor that blocks regression. Seeded here from the stored committed_lsa, it is what la_update_ha_last_applied_info compares against with LSA_GE before writing (10.4). Without it a restart could persist a lower committed LSA than was previously durable, silently rewinding the mark.

10.3a First-boot insert — la_insert_ha_apply_info branch by branch

Section titled “10.3a First-boot insert — la_insert_ha_apply_info branch by branch”

Called only when la_get_last_ha_applied_info found no row (res == 0), this INSERTs the 26-column row: 15 bound ? params (db_name, creation time, copied path, and the six LSA pageid/offset pairs) plus 11 SQL literals (SYS_DATETIME for the three times and start_time; 0 for status and the six counters). After binding the params:

// la_insert_ha_apply_info -- src/transaction/log_applier.c
assert_release (in_value_idx == LA_IN_VALUE_COUNT); /* <- exactly 15 binds, else 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 failed: no log-info file written */
}
/* 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 miss: hard-coded fallback */
}
fp = fopen (la_Info.loginf_path, "w");
if (fp == NULL)
{
er_set (...ER_LOG_MOUNT_FAIL..., la_Info.loginf_path); /* <- warning only, not fatal */
}
else
{
(void) fprintf (fp, msg, CUBRID_MAGIC_LOG_INFO, la_Info.loginf_path);
fflush (fp);
fclose (fp); /* <- .log_info stamped with magic comment */
}
return res;

Four branches after binding: the assert_release guard (same drift check as 10.1’s invariant); the res <= 0 early return propagating an INSERT failure without touching the .log_info file; the msg == NULL msgcat-miss path substituting a literal format string; and the fopen outcome — fp == NULL raises a non-fatal ER_LOG_MOUNT_FAIL warning, else write the CUBRID_MAGIC_LOG_INFO comment + fflush/fclose. The .log_info file is best-effort: its failure never aborts the insert because res (the affected-row count) is already > 0 and is returned either way.

10.4 Writing the row — la_update_ha_last_applied_info

Section titled “10.4 Writing the row — la_update_ha_last_applied_info”

Called from la_log_commit, this UPDATEs all twelve LSA halves, two optional times, six counters, and last_access_time = SYS_DATETIME, keyed by (db_name, copied_log_path). Two pieces of logic matter:

// 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 */ } /* <- never regress the mark */
/* same LSA_GE guard for committed_rep_lsa; times use IFNULL(?, col) */
res = la_update_query_execute_with_values (query_buf, in_value_idx, &in_value[0], true);
if (res == 0) /* <- 0 rows matched: row was deleted out from under us */
{
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);
}

The committed columns are written as MAX(current, last_committed) so the durable mark cannot regress even if the in-memory value momentarily lags; log_record_time / log_commit_time use IFNULL(?, col) so a batch with no commit record leaves the stored timestamp intact. If the row was deleted (UPDATE matched zero rows) the function re-INSERTs via la_insert_ha_apply_info and retries the UPDATE so la_Info state is not lost (same pattern in la_update_ha_apply_info_log_record_time). It does not commit — leaving the transaction open so la_log_commit can commit the bookmark together with the applied rows.

la_log_commit (bool update_commit_time) is the single chokepoint where a slave apply batch becomes durable, invoked from the dispatch path (Ch 8) at commit records, at server-state records when the repl list is empty, and from the main apply loop. It recomputes required_lsa, snapshots the master header LSAs, flushes buffered rows, stages the bookmark UPDATE, and commits — each step gating the next:

// 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; /* <- not flushed: nothing committed */
res = la_update_ha_last_applied_info (); /* stage bookmark UPDATE */
if (res > 0)
error = la_commit_transaction (); /* <- atomic: rows + bookmark */
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; } /* <- swallow, retry same final_lsa */
}
return error;
flowchart TD
  A["la_log_commit(update_commit_time)"] --> B["la_find_required_lsa"]
  B --> C["copy append_lsa, eof_lsa from header"]
  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{flush error?}
  G -->|"!= NO_ERROR"| H["return error -- nothing committed"]
  G -->|NO_ERROR| I["la_update_ha_last_applied_info -> res"]
  I --> J{res > 0?}
  J -->|yes| K["la_commit_transaction -- atomic apply plus bookmark"]
  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 -- swallow"]
  K --> P["return error"]
  N --> P
  O --> P

Figure 10-2. la_log_commit branch map. Every exit either commits both the rows and the bookmark, or commits neither.

Invariant — apply and bookmark are one transaction. la_flush_repl_items issues the row mutations and la_update_ha_last_applied_info the bookmark UPDATE, both on the same connection with no intervening commit; the single la_commit_transaction (calling db_commit_transaction) makes them durable together. A crash after staging but before commit rolls both back — no window where the bookmark says “applied through X” but the rows for X are missing.

On bookmark-UPDATE failure (res <= 0) a server-down error becomes ER_NET_CANT_CONNECT_SERVER so the loop reconnects and re-applies; any other failure is swallowed to NO_ERROR after fail_counter++. The rows never committed, so the next iteration retries from the same final_lsa — cost is one idempotent re-apply (10.8).

10.6 Computing the floor — la_find_required_lsa

Section titled “10.6 Computing the floor — la_find_required_lsa”

required_lsa is the lowest LSA the daemon might still re-read: the start_lsa of the oldest open transaction in the per-transaction lists (Ch 8). la_log_commit recomputes it before every 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; /* <- empty/closed slot */
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); /* <- no open txn: floor = cursor */
else
LSA_COPY (required_lsa, &lowest_lsa);

Three branches: skip closed slots (tranid <= 0), track the running minimum, and — if no transaction is open — fall back to final_lsa so the floor advances during idle periods and archives can still be trimmed.

Invariant — required_lsa <= final_lsa and never above any open transaction’s start. Everything strictly below required_lsa is guaranteed never re-read — the precondition la_remove_archive_logs relies on to delete an archive safely.

10.7 Trimming slave-local archives — la_remove_archive_logs

Section titled “10.7 Trimming slave-local archives — la_remove_archive_logs”

copylogdb continually appends archive logs to the slave’s log_path (Ch 6). la_remove_archive_logs reclaims that space, gated by both a max-count cap and the required_lsa floor, returning the new last_deleted_arv_num (or the old one if nothing was deleted).

// la_remove_archive_logs -- src/transaction/log_applier.c
if (LA_LOG_IS_IN_ARCHIVE (la_Info.required_lsa.pageid)) /* floor still in an archive? */
{
error = la_find_archive_num (&required_arv_num, la_Info.required_lsa.pageid);
if (error != NO_ERROR) return last_deleted_arv_num; /* <- can't locate: keep all */
max_arv_count = MAX (log_max_archives, nxarv_num - required_arv_num); /* protect floor */
}
else
max_arv_count = log_max_archives; /* floor in active log: cap only */
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; /* <- nothing eligible */
/* 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); /* <- physical delete */
return last_arv_num_to_delete;
}
return last_deleted_arv_num; /* under the cap: keep all */

Branch summary:

  • Floor in archive (pageid < nxarv_pageid): raise max_arv_count so archives from required_arv_num up are retained; if la_find_archive_num fails, bail keeping all.
  • Floor in active log: only HA_COPY_LOG_MAX_ARCHIVES caps retention.
  • Under the cap, or eligible range empty/negative: unchanged.
  • max_arv_count_to_delete throttle: caller passes 1 for interval-paced deletion or INT_MAX when a new archive just appeared.

The caller feeds the return into la_Info.last_deleted_archive_num, persists it via la_update_last_deleted_arv_num, then stamps la_Info.last_time_archive_deleted.

Invariant — an archive below required_lsa is deleted only after la_log_commit has persisted that floor. required_lsa is written inside the apply-batch transaction (10.5) before the loop reaches the trim step, so the persisted floor is never higher than what was applied. A crash after deleting but before the next commit is safe: the durable required_lsa already excludes the deleted range.

10.8 The crash window and re-apply idempotency

Section titled “10.8 The crash window and re-apply idempotency”

Order matters only between la_repl_add_object staging a row into the bulk-flush list (Ch 9) and la_log_commit committing it. A crash there rolls the transaction back — staged rows and the bookmark UPDATE vanish together (10.5 invariant); on restart la_get_last_ha_applied_info reads the previous committed final_lsa and the daemon re-applies that range.

Re-apply is idempotent, and both guards live in la_apply_repl_log — not in its la_apply_commit_list caller, which only dequeues each pending commit and hands it down. la_apply_repl_log skips an entire commit whose LSA is LSA_LE (commit_lsa, &la_Info.last_committed_lsa) (Ch 9), and for a commit that does proceed it filters each REPL item, applying only those with LSA_GT (&item->lsa, &la_Info.last_committed_rep_lsa). A record committed before the crash is dropped as already-applied; one only staged is re-applied cleanly. The companion’s “Open Questions” covers the remaining edge — a non-idempotent user trigger observing a re-apply, which this path does not guard — so cross-link there.

  1. LA_HA_APPLY_INFO is a transient one-to-one image of one _db_ha_apply_info row keyed by (db_name, copied_log_path); resident state lives field-by-field in la_Info, and the LA_*_VALUE_COUNT asserts keep struct and schema from drifting.
  2. final_lsa is the restart resume cursor, required_lsa the minimal-needed floor; on a fresh applier both collapse onto the master’s eof_lsa, while committed_lsa / committed_rep_lsa are the monotonic already-applied marks.
  3. la_insert_ha_apply_info writes the 15-bind row once on first boot (and as the self-heal step inside the UPDATE path); after the asserted bind count it propagates an INSERT failure verbatim and treats the .log_info file as best-effort, never aborting on ER_LOG_MOUNT_FAIL.
  4. la_log_commit is the single atomic chokepoint: flush buffered rows and stage the bookmark UPDATE on one connection, then commit both with one la_commit_transaction — durable together or not at all.
  5. la_update_ha_last_applied_info never lets the committed marks regress (LSA_GE guard) and self-heals a deleted row by re-INSERTing and retrying the UPDATE.
  6. la_get_last_ha_applied_info rejects a stale bookmark on a creation-time mismatch or NULL required_lsa, and seeds last_committed_lsa / last_committed_rep_lsa as the anti-regression floor.
  7. la_find_required_lsa and la_remove_archive_logs together reclaim slave-local archive space only above the persisted required_lsa, making the la_repl_add_objectla_log_commit crash window safe via idempotent re-apply.

Chapter 11: Special Paths Long Transactions Role Change Filters and TDE

Section titled “Chapter 11: Special Paths Long Transactions Role Change Filters and TDE”

Chapters 8 to 10 trace the steady-state apply lifecycle. This chapter collects the four paths that break that shape: a transaction too large to stage; a planned role change demoting the slave; a table-level filter that drops events; and TDE-encrypted pages needing a key shared over a socket. For the steady-state mechanics and the master/slave topology these perturb, assume the companion cubrid-ha-replication.md (sections “Apply pipeline” and “Failover”).

11.1 Long transactions: trading memory for an O(records) re-walk

Section titled “11.1 Long transactions: trading memory for an O(records) re-walk”

Chapter 8 showed la_set_repl_log appending each REPL record to the apply->head .. apply->tail list, capped at LA_MAX_REPL_ITEMS. A transaction that exceeds it flips the LA_APPLY entry into long-transaction mode and discards the staged items:

// 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;
}

The entry now tracks only start_lsa (set by la_log_record_process at the first record) and last_lsa (bumped on every later REPL record); the item list is gone except the head.

INVARIANT — a long transaction is fully described by start_lsa .. last_lsa in the log, not by its staged item list. la_set_repl_log enforces it by refusing to append once is_long_trans is set, and by keeping last_lsa current on every record.

At commit, la_apply_repl_log walks the items via la_get_next_repl_item, which dispatches on the flag: is_long_trans true → la_get_next_repl_item_from_log (item, last_lsa) (re-read log); false → la_get_next_repl_item_from_list (item), which is return item->next. The from-log path is the expensive one:

// 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 */
}

Every branch of the loop is exercised:

  1. First iteration seeds prev_repl_log_record with the start-record trid (the prev_repl_log_record == NULL clause, condensed above).
  2. Same LSA as start (LSA_EQ) — skip; this is why the loop never returns the item it was handed.
  3. Different trid — interleaved transaction; release page, follow forw_lsa.
  4. Same trid, past last_lsa / COMMIT / ABORT / EOF — exhausted, break with next_item == NULL.
  5. Same trid, a REPL recordla_make_repl_item, break.

Each retrieval follows forw_lsa forward, so applying the whole transaction is O(total-log-records-spanned) — the trade-off in takeaway 2.

11.2 Role change: demoting the slave on a planned failover

Section titled “11.2 Role change: demoting the slave on a planned failover”

The master writes a LOG_DUMMY_HA_SERVER_STATE marker on every HA server-state change (cub_master drives these on failover — cross-link cubrid-heartbeat.md). The applier handles it in 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) */

Branches:

  • Leaves ACTIVE/TO_BE_STANDBY and db lock held — peer is no longer a master to follow; set is_role_changed, return ER_INTERRUPTED (the agreed clean-shutdown signal, not an error).
  • Same demotion, lock not held — nothing to release; fall through.
  • ACTIVE/TO_BE_STANDBY, lists empty — benign; record the timestamp, force a commit so the bookmark stays current.
  • ACTIVE/TO_BE_STANDBY, lists non-empty — nothing here; pending transactions apply normally.

ER_INTERRUPTED propagates to the la_apply_log_file main loop, which on is_role_changed == true calls la_unlock_dbname (..., clear_owner = true), releasing ownership of the db lock so a new master can claim it. (LOG_DUMMY_CRASH_RECOVERY, the case just above, also returns ER_INTERRUPTED but without is_role_changed — there the interrupt forces a refetch and keeps the lock.)

la_change_state is the complementary half: once per main-loop iteration it reports the applier’s own HA_LOG_APPLIER_STATE, driven by the log header’s ha_server_state / ha_file_status. Its first branch is an early-out — when last_server_state, last_file_state, and last_is_end_of_record all still equal the current values, return NO_ERROR. Otherwise it chooses a target state through a two-level switch:

  • Caught up (is_end_of_record && ha_file_status == LOG_HA_FILESTAT_SYNCHRONIZED): ACTIVE / TO_BE_STANDBY / TO_BE_ACTIVEWORKING; DEAD / STANDBY / MAINTENANCEDONE (and, unless DEAD, la_clear_all_repl_and_commit_list flushes pending lists, since a standby master produces no commits to drain them).
  • Still catching up (else): every live state → RECOVERING (two switch cases with different already-in-state guards, but both target RECOVERING).
  • Unknown ha_server_state (either switch): er_log_debug ("BUG ..."), return ER_FAILED.

On a real transition — new_state != HA_LOG_APPLIER_STATE_NA and, after a second apply_state == new_state early-out, the new state actually differs — la_change_state forces a commit (la_log_commit (true)) before boot_notify_ha_log_applier_state, so the reported bookmark is durable.

INVARIANT — the applier never advertises a new state without first making its last-applied bookmark durable. Enforced by the unconditional la_log_commit (true) ahead of boot_notify_ha_log_applier_state.

11.3 Table-level filtering: REPL_FILTER_TYPE and per-item evaluation

Section titled “11.3 Table-level filtering: REPL_FILTER_TYPE and per-item evaluation”

The slave can replicate only some tables (INCLUDE) or all-but-some (EXCLUDE). The policy is one enum plus a parallel-array filter struct.

// REPL_FILTER_TYPE -- src/transaction/log_applier.h
typedef enum { REPL_FILTER_NONE, REPL_FILTER_INCLUDE_TBL, REPL_FILTER_EXCLUDE_TBL } REPL_FILTER_TYPE;
EnumeratorRoleWhy it exists
REPL_FILTER_NONEla_need_filter_out returns false at once.Default; zero overhead when replicating everything.
REPL_FILTER_INCLUDE_TBLAllow-list: drop unless the class is listed.”Replicate only these tables.”
REPL_FILTER_EXCLUDE_TBLDeny-list: drop if the class is listed.”Replicate all except these tables.”

The runtime state LA_REPL_FILTER (la_Info.repl_filter) is a parallel array:

// 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; };
FieldRoleWhy it exists
liststrdup’d class names from the filter file.The allow/deny set; grown in LA_NUM_REPL_FILTER chunks by la_add_repl_filter.
list_sizeAllocated capacity of list.Tells la_add_repl_filter when to realloc; distinct from entries used.
num_filtersPopulated entries (<= list_size).Iteration bound in la_need_filter_out; a count, not a capacity.
typeThe REPL_FILTER_TYPE policy.Selects allow vs deny, or short-circuits to “no filter”.

la_create_repl_filter loads it once at startup. Branch map:

  • type = PRM_ID_HA_REPL_FILTER_TYPE; REPL_FILTER_NONE → return now (no read).
  • Filter file = PRM_ID_HA_REPL_FILTER_FILE; empty or fopen failure → ER_HA_LA_REPL_FILTER_GENERIC; a relative path resolves via envvar_confdir_file.
  • Per line: trim, strip newline, skip blanks; validate user/identifier length (over-long → goto error_return); normalise via sm_user_specified_name, resolve via locator_find_class. A missing class is non-fatal — the error is raised inside an er_stack_push / er_stack_pop pair (so it is emitted then immediately popped off the error stack) and the entry is still added via la_add_repl_filter, since the table may be created later.
  • error_return closes the file, calls la_destroy_repl_filter.

The filter is evaluated per item at apply time, not at staging — in the la_apply_repl_log loop, the apply dispatch runs only when LSA_GT (&item->lsa, &la_Info.last_committed_rep_lsa) && la_need_filter_out (item) == false. la_need_filter_out itself:

// 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;

So statement replication (except TRUNCATE) and the serial catalog (CT_SERIAL_NAME) are never filtered; otherwise INCLUDE drops an absent class and EXCLUDE drops a present one.

Cost note for modifiers: the class name comes from a la_get_recdes walk in la_apply_* before la_need_filter_out runs, so filtered events still pay the record-description decode — the filter saves the slave apply, not the log read.

11.4 TDE: sharing data keys over a unix socket

Section titled “11.4 TDE: sharing data keys over a unix socket”

When the master encrypts log pages (TDE), copylogdb ships ciphertext and applylogdb decrypts each fetched page — both behind the UNSTABLE_TDE_FOR_REPLICATION_LOG guard (off by default).

Note on la_load_tde. No symbol named la_load_tde exists; the conceptual “applier loads-and-serves the keys” role is la_start_dk_sharing (listener setup) plus its server thread la_process_dk_request (the real counterpart to copylogdb’s logwr_load_tde). Grep for those two.

The decrypt point is la_log_fetch (and the archive path): under the guard, if LOG_IS_PAGE_TDE_ENCRYPTED (&cache_buffer->logpage) it calls tde_decrypt_log_page (..., logwr_get_tde_algorithm (...), ...) in place.

The keys come from the applier, which neither daemon owns intrinsically. In la_apply_log_file it calls tde_get_data_keys (), sets tde_Cipher.is_loaded, and calls la_start_dk_sharing — which builds the path via fileio_make_ha_sock_name (..., TDE_HA_SOCK_NAME), unlinks any stale node, socket/bind/listen, then pthread_create (la_process_dk_request) (each failure → a distinct ER_TDE_DK_SHARING_*). The client logwr_load_tde connects, writes its log_path as a handshake, reads a status word, and on success reads a 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;

The server thread la_process_dk_request validates each client before handing over keys:

// 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 ...

The status word is always sent first (so logwr_load_tde learns why it was refused), and only the all-clear path writes the TDE_DATA_KEY_SET, after which la_log_fetch can decrypt pages. Full handshake: applier tde_get_data_keys + listen → client connects, writes log_path → applier returns the status word → on NO_ERROR, sends the TDE_DATA_KEY_SET.

LIMITATION — TDE-for-replication is compile-gated and off by default. Every code path in this section sits behind #ifdef UNSTABLE_TDE_FOR_REPLICATION_LOG; a stock build replicates plaintext log pages and never opens the key socket.

On the master, tde_encrypted rides on each staged LOG_REPL_RECORD (bool tde_encrypted in struct log_repl, replication.h). At emission in log_manager.c, if (repl_rec->tde_encrypted) triggers prior_set_tde_encrypted (node, repl_rec->rcvindex), so the redo node inherits the flag. Chapter 2 covers where tde_encrypted is first set; here the point is only that it survives onto the prior-list node.

An abort is not freed where it is read. In la_log_record_process a LOG_ABORT only queues itself — la_add_node_into_la_commit_list (lrec->trid, final, LOG_ABORT, 0) — exactly like a commit, so it is processed in LSA order when la_apply_commit_list reaches it. The staged items are released later, in la_apply_repl_log, whose very first branch short-circuits the 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;
}

So la_clear_applied_info (the per-LA_APPLY reset: la_free_all_repl_items then null start_lsa / last_lsa / tranid) is reached on abort and on an empty / already-committed apply list, while the LOG_SYSOP_END arm of that same guard keeps the slot LSAs via la_free_all_repl_items. Because la_free_all_repl_items also clears is_long_trans, a reused slot never inherits the long-transaction flag from a prior occupant.

la_free_repl_items_by_tranid is the other cleanup entry point, distinct from the abort path. It is called (a) from la_clear_all_repl_and_commit_list when la_change_state drops to a non-DEAD standby (11.2), and (b) from the LOG_COMMIT / LOG_SYSOP_END else-branch in la_log_record_process when the record’s LSA is <= committed_lsa (a stale, already-applied EOT). It both clears the staged items and unlinks any commit-list node for that 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 */

INVARIANT — the commit list’s head/tail are always mutually consistent. la_free_repl_items_by_tranid enforces it by repairing commit_tail when it unlinks the last node (the commit->next == NULL arm sets commit_tail = commit->prev) and by resetting commit_tail = NULL whenever commit_head becomes NULL.

The full LOG_SYSOP_END vs commit dispatch through la_apply_commit_list is Chapter 9’s concern; here the point is only which leaf frees the slot and which preserves it.

  1. A transaction over LA_MAX_REPL_ITEMS flips is_long_trans, discards its item list, and is thereafter described only by start_lsa .. last_lsa; la_set_repl_log refuses further appends once the flag is set.
  2. Applying a long transaction re-reads REPL records via la_get_next_repl_item_from_log, following forw_lsa and skipping foreign-trid / non-REPL records — O(records-spanned) for a bounded footprint.
  3. A demotion arrives as LOG_DUMMY_HA_SERVER_STATE; on a non-ACTIVE/TO_BE state with the db lock held, the applier sets is_role_changed, returns ER_INTERRUPTED, and the main loop releases the lock (clear_owner = true).
  4. la_change_state reports the applier’s own state from the log header and always forces a durable commit before boot_notify_ha_log_applier_state.
  5. Filtering is a per-item decision (la_need_filter_out) over a REPL_FILTER_TYPE allow/deny list; statement replication (except TRUNCATE) and the serial catalog are never filtered, and filtered events still pay the la_get_recdes decode.
  6. TDE is compile-gated (UNSTABLE_TDE_FOR_REPLICATION_LOG): applylogdb serves keys (la_start_dk_sharing / la_process_dk_request), copylogdb fetches them (logwr_load_tde), pages decrypt in la_log_fetch, and tde_encrypted reaches the redo node via prior_set_tde_encrypted.
  7. An abort is queued via la_add_node_into_la_commit_list like a commit and freed later in la_apply_repl_log (rectype == LOG_ABORTla_clear_applied_info); la_free_repl_items_by_tranid is the separate path used on standby teardown and on stale already-applied EOTs, and it is what keeps the commit list’s head/tail consistent.

The following are line numbers as observed on 2026-06-22; symbols are the canonical anchor and line numbers are hints that decay.

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 — the high-level companion. See also cubrid-log-manager-detail.md (the log it ships) and cubrid-heartbeat.md (failover control).
  • Raw analyses under raw/code-analysis/cubrid/distributed/.
  • Code: src/transaction/log_applier.{c,h}, log_writer.{c,h}, replication.{c,h}; master log in log_manager.c.
  • Methodology: knowledge/methodology/code-analysis-detail-doc.md.