Skip to content

CUBRID Prior List — Code-Level Deep Dive

Where this document fits: The high-level analysis cubrid-prior-list.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 a single prior node from construction through LSA assignment to its drain into the log page buffer.

Contents:

ChTitleStatus
1Data Structure Map
2Initialization Memory and the Two Cursors
3Building a Node Outside the Mutex for Non Undoredo Records
4Building an Undoredo Node with Compression and Diff Encoding
5Assigning the LSN and Attaching Under the Mutex
6Detaching the List on the Drain Side
7Copying Nodes into the Authoritative Log Pages
8Flushing to Disk and Waking Commit Waiters
9Backpressure and the Self Help Drain
10Special and Edge Paths

The reader question this chapter answers: what exactly is queued on the prior list, and what state does the queue itself carry? Every later chapter — build (Ch 3–4), stamp and splice (Ch 5), detach (Ch 6), copy into pages (Ch 7) — manipulates the six structs and one enum defined here. We cover every field, mark who writes it and when, and end with a pointer diagram.

We do not repeat the producer/consumer theory or the Postgres/InnoDB/Oracle comparison — those live in the high-level companion cubrid-prior-list.md; this is the field-level expansion of its ### LOG_PRIOR_NODE and ### LOG_PRIOR_LSA_INFO overviews. Structs are declared in log_record.hpp (on-disk header + variant payloads) and log_append.hpp (queue node, state, helper inputs); constructors in log_append.cpp.

1.1 The queued unit — log_prior_node (LOG_PRIOR_NODE)

Section titled “1.1 The queued unit — log_prior_node (LOG_PRIOR_NODE)”

A LOG_PRIOR_NODE is one heap-allocated log record in flight: built by a producer but not yet copied into the shared LOG_PAGE buffer. The queue is a singly-linked list of these; each node owns up to three malloc’d payload buffers and embeds the record header written verbatim into the log.

// log_prior_node -- src/transaction/log_append.hpp
struct log_prior_node
{
LOG_RECORD_HEADER log_header;
LOG_LSA start_lsa; /* for assertion */
bool tde_encrypted; /* whether the log page which'll contain this node has to be encrypted */
/* data header info */
int data_header_length;
char *data_header;
/* data info */
int ulength;
char *udata;
int rlength;
char *rdata;
LOG_PRIOR_NODE *next;
};
FieldRoleWhy it exists
log_headerEmbedded LOG_RECORD_HEADER (1.3): trid, type, three LSA links.The node is the log record. Producer fills trid/type; attach fills the LSA links under the mutex (Ch 5).
start_lsaSnapshot of the assigned prior_lsa at attach. Comment: “for assertion”.The drain asserts the copied byte lands here — drift check; never read by recovery.
tde_encryptedFlag: the log page holding this node must be TDE-encrypted.Set at build by prior_set_tde_encrypted (Ch 3, Ch 10); propagated per-record to the page by the drain.
data_header_lengthByte length of data_header.The variant header is type-dependent in size; the length lets the drain copy it blindly.
data_headermalloc’d buffer for the type-specific header (a log_rec_* struct).Separates the fixed log_header from the variant header (Ch 3–4).
ulengthByte length of udata. May be negative as a zip / no-data sentinel (Ch 4).Tells the drain how many undo bytes to copy; feeds list_size.
udatamalloc’d undo buffer (possibly zlib-compressed).”Before image” for rollback/undo; absent for redo-only records.
rlengthByte length of rdata.Redo byte count; same dual role as ulength.
rdatamalloc’d redo buffer (possibly compressed or diff-encoded).”After image” for crash redo; absent for undo-only records.
nextForward link to the next node, NULL at tail.Makes the list a singly-linked FIFO. Written by attach (Ch 5), torn down by the drain (Ch 6).

Role matrix — live payload segments by record type (log_rectype values):

Record type groupdata_headerudatardata
LOG_UNDOREDO_DATA / LOG_MVCC_UNDOREDO_DATA / LOG_*_DIFF_UNDOREDO_DATAundoredo headerundo imageredo image (diff-encoded in DIFF variants — Ch 4)
LOG_UNDO_DATA / LOG_MVCC_UNDO_DATAundo headerundo imageempty
LOG_REDO_DATA / LOG_MVCC_REDO_DATAredo headeremptyredo image
LOG_COMMIT / LOG_ABORT and other control recordssmall fixed header (often LOG_REC_DONETIME)emptyempty

Invariant 1.A — payload pointers and lengths agree. udata != NULL implies ulength > 0 (and symmetrically for rdata/rlength); NULL implies non-positive length. A non-NULL pointer with length 0 makes the drain emit a zero-length segment, corrupting every following record’s offset.

Invariant 1.B — single-owner until attach. Before prior_lsa_next_record splices the node (Ch 5) only the builder holds the pointer and next is garbage that MUST NOT be read; after attach the list owns it and the producer must not touch it. Use after attach, or a double free in the drain, is a cross-boundary use-after-free.

Figure 1-1 — LOG_PRIOR_NODE: fixed header, three malloc'd payload segments, forward link Figure 1-1 — LOG_PRIOR_NODE: fixed header, three malloc’d segments, forward link.

1.2 The queue state — log_prior_lsa_info (LOG_PRIOR_LSA_INFO)

Section titled “1.2 The queue state — log_prior_lsa_info (LOG_PRIOR_LSA_INFO)”

Exactly one exists in the server: the singleton log_Gl.prior_info (no per-thread or per-volume copy). It holds the list head/tail, the LSN cursor, byte accounting, the detached “flush” sublist, and the attach-vs-detach mutex.

// log_prior_lsa_info -- src/transaction/log_append.hpp
struct log_prior_lsa_info
{
LOG_LSA prior_lsa;
LOG_LSA prev_lsa;
/* list */
LOG_PRIOR_NODE *prior_list_header;
LOG_PRIOR_NODE *prior_list_tail;
INT64 list_size; /* bytes */
/* flush list */
LOG_PRIOR_NODE *prior_flush_list_header;
std::mutex prior_lsa_mutex;
log_prior_lsa_info ();
};

The constructor log_prior_lsa_info::log_prior_lsa_info (log_append.cpp) zero-inits cursors to NULL_LSA, pointers to NULL, list_size to 0.

FieldMutated byRoleWhy it exists
prior_lsaproducer (attach, under mutex)The next LSN to hand out; each attach copies it into the node then advances past the bytes.Monotonic LSN allocator; bumped only under the mutex, so LSNs are gap-free and strictly increasing.
prev_lsaproducer (attach, under mutex)Address of the previous attached record; copied into log_header.back_lsa, then overwritten.Builds the whole-log backward chain. Distinct from per-transaction prev_tranlsa (1.3).
prior_list_headerproducer (first attach) + drain (detach)Head of the FIFO of un-drained nodes.Where the drain starts; NULL means empty.
prior_list_tailproducer (every attach)Tail of the FIFO; new nodes splice here.O(1) append.
list_sizeproducer (attach, +=) + drain (reset at detach)Byte total (sizeof(node) + data_header_length + ulength + rlength).Backpressure vs logpb_get_memsize() (Ch 9).
prior_flush_list_headerdrainHead of the list after detach; the drain re-parents the chain here.Decouples what producers append to from what the drain consumes.
prior_lsa_mutexbothstd::mutex serialising LSN-assign + attach against detach.Makes the multi-producer / single-consumer hand-off safe; held only for the splice and cursor bump (Ch 5).

Invariant 1.C — cursors advance only under prior_lsa_mutex. Both prior_lsa and prev_lsa are plain LOG_LSA (not atomic); their only writers are inside the lock in prior_lsa_next_record_internal (Ch 5). Concurrent bumps would let two records claim overlapping byte ranges and make the log unrecoverable.

Invariant 1.D — header/tail consistency. prior_list_header == NULL iff prior_list_tail == NULL iff the queue is empty, enforced by the attach branch if (tail == NULL) { header = tail = node; } else { tail->next = node; tail = node; } (Ch 5). Nulling one without the other loses records or appends to a stale tail.

Invariant 1.E — list_size tracks exactly the un-drained nodes. Incremented per attach, zeroed when the drain detaches the list. Drift high fires backpressure spuriously; drift low lets the queue grow unbounded and exhaust the log-area memory budget.

Figure 1-2 — log_prior_lsa_info: the singleton queue state with cursors, byte accounting, and three list pointers Figure 1-2 — log_prior_lsa_info: producers grow the header/tail FIFO; the drain re-parents it under prior_flush_list_header.

1.3 The embedded record header — log_rec_header (LOG_RECORD_HEADER)

Section titled “1.3 The embedded record header — log_rec_header (LOG_RECORD_HEADER)”

log_prior_node.log_header is a LOG_RECORD_HEADER — the fixed-size prefix of every log record in memory and on disk, read back byte-for-byte by recovery.

// log_rec_header -- src/transaction/log_record.hpp
struct log_rec_header
{
LOG_LSA prev_tranlsa; /* Address of previous log record for the same transaction */
LOG_LSA back_lsa; /* Backward log address */
LOG_LSA forw_lsa; /* Forward log address */
TRANID trid; /* Transaction identifier of the log record */
LOG_RECTYPE type; /* Log record type (e.g., commit, abort) */
};
FieldSet whenRoleWhy it exists
prev_tranlsaattach (Ch 5), from the transaction descriptorBackward link to the previous record of the same transaction.Lets rollback/undo walk one transaction in reverse. Distinct from back_lsa.
back_lsaattach, from prior_info.prev_lsaBackward link to the previous record in the whole log.Global reverse scan; consumes prev_lsa (1.2).
forw_lsaattach, from the next prior_lsaForward link to the next record.Forward scan during the redo pass.
tridbuild (Ch 3)Owning transaction id.Recovery groups by transaction; vacuum/CDC filter on it.
typebuildA log_rectype value.Dispatches the recovery handler; tells the drain which segments exist (1.1).

Two backward chains: back_lsa (all records) vs prev_tranlsa (this transaction). LOG_LSA is the {pageid, offset} pair from log_lsa.hpp; NULL_LSA marks “no such record.” The variant header in data_header typically begins with a LOG_DATA — what log_data_addr (1.4) feeds.

1.4 Producer helper inputs — log_crumb and log_data_addr

Section titled “1.4 Producer helper inputs — log_crumb and log_data_addr”

Not stored in the node — inputs to the build path; Ch 3–4 dissect their consumers prior_lsa_alloc_and_copy_data / prior_lsa_alloc_and_copy_crumbs.

// log_crumb -- src/transaction/log_append.hpp
struct log_crumb
{
int length;
const void *data;
};
FieldRoleWhy it exists
lengthByte length of this fragment.A “crumb” is one piece of a scatter-gather payload.
dataconst pointer to caller-owned bytes (not yet copied).Lets the caller describe undo/redo as non-contiguous fragments; the build path concatenates them into one buffer. const signals copy-out.
// log_data_addr -- src/transaction/log_append.hpp
struct log_data_addr
{
using offset_type = PGLENGTH;
const VFID *vfid; /* File the page belongs to, or NULL when not file-associated */
PAGE_PTR pgptr;
offset_type offset; /* Offset or slot */ // + ctors
};
FieldRoleWhy it exists
vfidFile id of the modified page, or NULL when not file-backed.Stored into data_header (LOG_DATA.volid); NULL is a legal “no file” branch.
pgptrPointer to the in-memory page being changed.The build path reads page/volume id from it to fill LOG_DATA.
offsetOffset (or slot) within the page.Recovery target offset; high bits double as LOG_RV_RECORD_* flags (LOG_RV_RECORD_SET_MODIFY_MODE) — Ch 10.

LOG_DATA_ADDR_INITIALIZER is { NULL, NULL, 0 } (marked todo: remove me).

1.5 The append-side state — log_append_info (LOG_APPEND_INFO)

Section titled “1.5 The append-side state — log_append_info (LOG_APPEND_INFO)”

LOG_APPEND_INFO is the flush cursor on the far side — the second of the “two cursors” (Ch 2), the durability state Ch 7–8 read and write. Distinguish prior_lsa (queue) from nxio_lsa (durability).

FieldMutated byRoleWhy it exists
vdeslog init / archivingVolume descriptor (fd) of the active log volume (init NULL_VOLDES).The flush path writes pages through it.
nxio_lsaflush (Ch 8)Atomic. Lowest LSN not yet written to disk — the WAL frontier.Read lock-free by commit waiters; atomic as the one append-side field read without the mutex. Accessed via get_nxio_lsa/set_nxio_lsa.
prev_lsaappend/copy (Ch 7)LSA of the last record copied into the pages.Append-side mirror of the queue’s prev_lsa; header note: not really belonging here.
log_pgptrappend/copyThe currently fixed authoritative LOG_PAGE being filled.Destination the drain copies node bytes into (Ch 7).
appending_page_tde_encryptedappend/copyWhether the appended page must be TDE-encrypted.Page-level counterpart of node-level tde_encrypted (1.1).

The WAL safety property is nxio_lsa <= prior_lsa always — a record must be queued (and given an LSN) before it can flush; Ch 8 traces how the atomic load and under-mutex bump preserve this.

1.6 The locking selector — LOG_PRIOR_LSA_LOCK

Section titled “1.6 The locking selector — LOG_PRIOR_LSA_LOCK”
// LOG_PRIOR_LSA_LOCK -- src/transaction/log_append.hpp
enum LOG_PRIOR_LSA_LOCK
{
LOG_PRIOR_LSA_WITHOUT_LOCK = 0,
LOG_PRIOR_LSA_WITH_LOCK = 1
};
ValueMeaningWhy it exists
LOG_PRIOR_LSA_WITHOUT_LOCK (0)Caller has not taken prior_lsa_mutex; the helper must take it.The common path: prior_lsa_next_record enters with the lock unheld.
LOG_PRIOR_LSA_WITH_LOCK (1)Caller already holds prior_lsa_mutex; the helper must not re-lock.For commit/postpone records (Ch 5/Ch 10) that update transaction state and attach under one lock. Passed via prior_lsa_next_record_with_lock.

This is the with_lock parameter threaded into prior_lsa_next_record_internal. Re-locking when held (or skipping when not) is the classic double-lock / lost-update bug; the enum makes lock state explicit.

  1. A LOG_PRIOR_NODE is one in-flight log record: embedded LOG_RECORD_HEADER, three parallel (length, ptr) segments (data_header, udata, rdata), a tde_encrypted flag, assertion-only start_lsa, and a next link. Which segments are live follows log_header.type (1.1).
  2. log_Gl.prior_info is the single LOG_PRIOR_LSA_INFO holding the queue: FIFO head/tail, the prior_lsa/prev_lsa cursors, list_size, the detached prior_flush_list_header, and prior_lsa_mutex. Producers grow the FIFO and bump cursors only under the mutex; the drain detaches and resets list_size.
  3. Two distinct backward chains in log_rec_header: back_lsa links every record, prev_tranlsa links only one transaction’s.
  4. log_crumb and log_data_addr are build-time inputs, not stored state — crumbs are scatter-gather fragments; log_data_addr carries target file/page/offset (offset high bits = LOG_RV_RECORD_* mode).
  5. LOG_APPEND_INFO is the durability-side cursor: atomic nxio_lsa is the WAL frontier read lock-free by commit waiters; the safety property is nxio_lsa <= prior_lsa (Ch 8).
  6. LOG_PRIOR_LSA_LOCK makes the caller’s lock state explicit so the attach helper neither double-locks nor skips the mutex — the linchpin of the race-free multi-producer attach.
  7. The invariants later chapters must preserve: payload/length agreement (1.A), single-owner-until-attach (1.B), cursors only under the mutex (1.C), header/tail consistency (1.D), exact list_size accounting (1.E).

Chapter 2: Initialization Memory and the Two Cursors

Section titled “Chapter 2: Initialization Memory and the Two Cursors”

Chapter 1 mapped the structs at rest. This chapter answers the boot-time question: where do these structures come from, how is a per-node piece of memory born and reclaimed, and how does the producer-side prior_lsa cursor stay welded to the page-side append_lsa cursor? The answer is in three places — the queue state’s C++ constructor, the LSN-reset macros in log_initialize_internal, and the per-thread compression scratch. The why lives in the high-level companion (cubrid-prior-list.md §“CUBRID’s Approach”).

log_Gl.prior_info is a log_prior_lsa_info value member of the global log_Gl — not malloc’d, not memset, but C++ default-constructed with log_Gl, every field given a deliberate empty value:

// log_prior_lsa_info::log_prior_lsa_info -- src/transaction/log_append.cpp
log_prior_lsa_info::log_prior_lsa_info ()
: prior_lsa (NULL_LSA), prev_lsa (NULL_LSA)
, prior_list_header (NULL), prior_list_tail (NULL), list_size (0)
, prior_flush_list_header (NULL), prior_lsa_mutex () { }

Each field’s construction value, role at boot, and reason for existing:

FieldRoleWhy it exists
prior_lsaNULL_LSA; next LSA to hand out, re-seeded before appendCursor the producer advances under the mutex.
prev_lsaNULL_LSA; LSA of the last attached node, re-seeded before appendFeeds the new node’s back_lsa (physical back-link).
prior_list_headerNULL; empty queue headProducer attach point; drain detach point.
prior_list_tailNULL; empty queue tailO(1) append-at-tail target.
list_size0; no bytes queuedBackpressure trigger (Ch. 9); accounting starts at zero.
prior_flush_list_headerNULL; drain not runningSingle-drain liveness flag (Ch. 6); NULL outside the drain CS.
prior_lsa_mutexdefault std::mutex, unlockedThe only mutex on the producer path.

The constructor does not establish run-time cursor values — prior_lsa / prev_lsa are NULL_LSA here, not usable LSNs; they become real only after log_initialize_internal seeds them (§2.3). Construction makes the queue coherent and empty; seeding makes it live.

2.2 The two reset macros — one writer for two cursors each

Section titled “2.2 The two reset macros — one writer for two cursors each”

The coupling between the prior and page-side cursors is not emergent; it is enforced by construction through two helpers (misnamed “macros” — actually functions in log_append.cpp) that each write two locations at once:

// LOG_RESET_APPEND_LSA -- src/transaction/log_append.cpp
LOG_RESET_APPEND_LSA (const LOG_LSA *lsa) {
log_Gl.hdr.append_lsa = *lsa; /* page-side append cursor */
log_Gl.prior_info.prior_lsa = *lsa; } /* producer-side prior cursor -- SAME value */
// LOG_RESET_PREV_LSA -- src/transaction/log_append.cpp
LOG_RESET_PREV_LSA (const LOG_LSA *lsa) {
log_Gl.append.prev_lsa = *lsa; /* page-side prev-record cursor */
log_Gl.prior_info.prev_lsa = *lsa; } /* producer-side prev-record cursor -- SAME value */

The key detail: there is no code path that sets prior_lsa without also setting hdr.append_lsa to the same value, nor prior_info.prev_lsa without append.prev_lsa. The writer is always one of these two functions — the mechanical basis of the cursor-coupling invariant (§2.6). The // todo - concurrency safe-guard comment is honest: these run under LOG_CS during boot/recovery when no producer can race, so the dual write is effectively atomic and never invoked on a live system. The third helper, LOG_APPEND_PTR, returns log_pgptr->area + hdr.append_lsa.offset — the byte address at the page-side cursor, read at boot to recover the EOF record (Branch C below).

2.3 Where the cursors are seeded during log_initialize_internal

Section titled “2.3 Where the cursors are seeded during log_initialize_internal”

log_initialize_internal (log_manager.c) is the boot/restart entry, running inside LOG_CS_ENTER (so the dual-write reset macros are safe). It seeds the two cursors at one of three points by startup branch:

Branch A — media crash, no mountable active log. The header is synthesised, append_lsa forced to {LOGPAGEID_MAX, 0}, then LOG_RESET_APPEND_LSA (&log_Gl.hdr.append_lsa) drags prior_lsa to the same value (inline comment: /* sync append_lsa to prior_lsa */).

Branch B — recovery needed (normal crash). The header is read from the mounted active log; log_recovery leaves append_lsa at the true end of log, resetting the cursors through the same macros as it replays.

Branch C — clean shutdown, nothing to recover. The illustrative path; it shows the coupling-to-the-page explicitly. After logpb_fetch_start_append_page positions the page-side cursor, the EOF record is read off the page and prev_lsa seeded from its back_lsa:

// log_initialize_internal -- src/transaction/log_manager.c (clean-shutdown branch, condensed)
if (log_Gl.hdr.append_lsa.pageid > 0 || log_Gl.hdr.append_lsa.offset > 0)
{
eof = (LOG_RECORD_HEADER *) LOG_APPEND_PTR (); /* read EOF record at append cursor */
LOG_RESET_PREV_LSA (&eof->back_lsa); /* prev_lsa := eof->back_lsa (both copies) */
}

Note what is not in Branch C: no explicit LOG_RESET_APPEND_LSA call. append_lsa was set when the header was fetched (logpb_fetch_header), leaving prior_lsa stale relative to it — the gap §2.4 closes.

2.4 The closing self-check — the invariant made executable

Section titled “2.4 The closing self-check — the invariant made executable”

After the branches converge, log_initialize_internal runs a pair of defensive equality checks — the cursor-coupling invariant written as code:

// log_initialize_internal -- src/transaction/log_manager.c (post-recovery convergence)
if (!LSA_EQ (&log_Gl.append.prev_lsa, &log_Gl.prior_info.prev_lsa))
{ assert (0); LOG_RESET_PREV_LSA (&log_Gl.append.prev_lsa); } /* defense: force equal */
if (!LSA_EQ (&log_Gl.hdr.append_lsa, &log_Gl.prior_info.prior_lsa))
{ assert (0); LOG_RESET_APPEND_LSA (&log_Gl.hdr.append_lsa); } /* defense: force equal */

Trace the branches: each check skips when equal (the expected outcome); on mismatch assert(0) fires in debug, and release builds fall through to the defense reset, which forces the prior copy to the page-side value — reconciling Branch C’s missing LOG_RESET_APPEND_LSA. After both checks pass (or are corrected), the producer begins life with prior_lsa == append_lsa and prior_info.prev_lsa == append.prev_lsa. The create-database path reaches the same state more directly — LSA_SET_NULL then LOG_RESET_PREV_LSA(&log_Gl.append.prev_lsa) starts both prev_lsa copies at NULL_LSA, in lockstep.

2.5 Per-node memory — malloc by producer, free by drain

Section titled “2.5 Per-node memory — malloc by producer, free by drain”

There is no node pool; a LOG_PRIOR_NODE is a free-store object allocated and reclaimed at opposite ends of the pipeline.

  • Producer allocatesprior_lsa_alloc_and_copy_data (and its sister _crumbs) does the single node malloc outside the prior mutex, then zeroes lengths and NULLs pointers:
// prior_lsa_alloc_and_copy_data -- src/transaction/log_append.cpp (condensed)
node = (LOG_PRIOR_NODE *) malloc (sizeof (LOG_PRIOR_NODE));
if (node == NULL) { er_set (...ER_OUT_OF_VIRTUAL_MEMORY...); return NULL; }
node->log_header.type = rec_type; node->tde_encrypted = false;
/* data_header / udata / rdata lengths set 0, all four pointers + next set NULL */

The per-type generators then malloc data_header / udata / rdata separately (Ch. 3-4) — up to four heap allocations per record.

  • Drain frees — the detached node and all three byte buffers are released in the drain walk (logpb_append_prior_lsa_list, Ch. 6) after the bytes reach the log page.

Invariant — node ownership is single and transfers at attach. Before attach the producer owns the node and may free it on an error path; after prior_lsa_next_record links it onto the tail under the mutex, ownership belongs to the drain and the producer must never touch it again. Enforced structurally: the attach functions return only the assigned LOG_LSA, never the node pointer, so a correct caller keeps no handle to misuse — a cached pointer is a use-after-free once the drain frees it.

list_size accounting begins at link time: the attach adds sizeof(LOG_PRIOR_NODE) + data_header_length + ulength + rlength to log_Gl.prior_info.list_size — zero from construction until the first attach; the drain resets it during detach (Ch. 6).

Invariant — the prior and page-side cursors start, and stay, in lockstep. At the end of initialization:

log_Gl.prior_info.prior_lsa == log_Gl.hdr.append_lsa
log_Gl.prior_info.prev_lsa == log_Gl.append.prev_lsa

How it is established. Only LOG_RESET_APPEND_LSA / LOG_RESET_PREV_LSA write these fields at boot, each setting both copies equal (§2.2); the §2.4 self-check corrects toward the page side on any drift.

How it is maintained at run time. The cursors then diverge bounded by design: the producer advances prior_lsa under prior_lsa_mutex (Ch. 5) while append_lsa lags until the drain copies records into pages (Ch. 7), so append_lsa <= prior_lsa, the gap = queued bytes. logpb_append_next_record guards every drained node with if (!LSA_EQ (&node->start_lsa, &log_Gl.hdr.append_lsa)) logpb_fatal_error (...), so the page cursor walks the exact LSAs the prior cursor handed out.

What breaks if violated. Started behind append_lsa, the producer re-issues already-consumed LSAs — duplicate LSNs, a corrupt log. Started ahead, the first drained node’s start_lsa fails the guard above and trips logpb_fatal_error. The boot-time equality is the precondition for the single-writer-of-LSAs scheme.

2.7 The compression scratch — off to the side

Section titled “2.7 The compression scratch — off to the side”

Compression staging memory is not part of the queue and not per-node; it is per-thread scratch letting the producer zlib a payload before allocating the node’s final buffers. Two pieces plus a lifecycle hook.

(1) The zlib contexts (LOG_ZIP), lazy per worker thread — off THREAD_ENTRY in SERVER_MODE, file statics in SA. log_append_get_zip_undo (and the identical _redo) in SERVER_MODE resolves a null thread_p via thread_get_thread_entry_info(), returns NULL if it stays null, else log_zip_alloc (IO_PAGESIZE)s on first use and returns it; the SA #else arm returns the file-static log_zip_undo. This null-thread guard, lazy-alloc-on-first-use, return-NULL/false-on-OOM shape is shared by get_zip_redo, get_data_ptr, and realloc_data_ptr. The THREAD_ENTRY fields are constructed NULL and freed in ~thread_entry via log_zip_free, so each worker owns its compressor — no contention.

(2) The lifecycle hooks. log_append_init_zip (from logpb_initialize_pool under LOG_CS) sets log_Zip_support = false if PRM_ID_LOG_COMPRESS is off; else in SERVER_MODE it only sets it true (contexts come lazily from get_zip_undo/_redo), while the SA #else arm eagerly allocs log_zip_undo/redo and log_data_ptr = malloc (IO_PAGESIZE * 2), freeing what succeeded and clearing log_Zip_support on any failure. log_append_final_zip early-returns at if (!log_Zip_support) return;; its SERVER_MODE arm is then empty (the per-thread destructor cleans up), while the SA #else arm frees both contexts and log_data_ptr.

(3) The staging buffer log_data_ptr — per-thread (global in SA) scratch holding the concatenated, uncompressed crumbs long enough to feed log_zip. Two accessors share the shape from part (1):

// log_append_get_data_ptr -- src/transaction/log_append.cpp (SERVER_MODE, condensed)
if (thread_p == NULL) thread_p = thread_get_thread_entry_info ();
if (thread_p == NULL) return NULL; /* null-thread early-return */
if (thread_p->log_data_ptr == NULL) { /* first use this thread */
thread_p->log_data_length = IO_PAGESIZE * 2;
thread_p->log_data_ptr = malloc (thread_p->log_data_length);
if (thread_p->log_data_ptr == NULL) /* OOM */
{ thread_p->log_data_length = 0; er_set (...ER_OUT_OF_VIRTUAL_MEMORY...); } }
return thread_p->log_data_ptr; /* may be NULL on OOM */

get_data_ptr (above) lazily mallocs IO_PAGESIZE * 2 on first use. log_append_realloc_data_ptr adds a grow branch: it grows only when log_data_length < length, rounding alloc_len up to IO_PAGESIZE via CEIL_PTVDIV and realloc-ing; on realloc-OOM it frees the old buffer, zeroes the length, returns false. Both SA #else arms run identical logic against the file-static log_data_ptr / log_data_length.

The only consumer is the undoredo generator (prior_lsa_gen_undoredo_record_from_crumbs, Ch. 4); none of it touches log_Gl.prior_info or prior_lsa_mutex, so it backs “build outside the mutex” work cheaply.

flowchart LR
  subgraph THR["per THREAD_ENTRY (SERVER_MODE)"]
    ZU["log_zip_undo\nlazy LOG_ZIP"]
    ZR["log_zip_redo\nlazy LOG_ZIP"]
    SB["log_data_ptr\npage-rounded scratch"]
  end
  CR["crumbs in"] --> SB
  SB -->|log_zip| ZU
  SB -->|log_zip| ZR
  ZU --> UD["node->udata\nmalloc'd, compressed"]
  ZR --> RD["node->rdata\nmalloc'd, compressed"]

Figure 2-1 — Compression staging is per-thread and disjoint from the queue: crumbs concatenate into log_data_ptr, compress through the per-thread zlib contexts, and emit into the node’s own udata/rdata. The prior queue and its mutex are not touched.

  1. The queue state is C++-constructed empty, not zeroed — every field NULL_LSA / NULL / 0, coherent but cursors not yet usable.

  2. Two reset helpers each write two cursorsLOG_RESET_APPEND_LSA pairs hdr.append_lsa with prior_info.prior_lsa, LOG_RESET_PREV_LSA pairs the two prev_lsa copies; the only boot-time writers, so they couple the cursors by construction.

  3. log_initialize_internal seeds then self-checks — it seeds from the page-side header across the three startup branches, then a closing LSA_EQ pair (assert(0) + defense reset toward the page side) guarantees both copies match.

  4. The cursor-coupling invariant — cursors start in lockstep and stay bounded (append_lsa <= prior_lsa, gap = queued bytes); behind duplicates LSNs, ahead trips logpb_append_next_record’s start_lsa == append_lsa guard into logpb_fatal_error.

  5. Nodes are malloc’d by the producer, freed by the drain — no pool. Ownership transfers at attach; the API returns only the LSA, so the producer keeps no handle. list_size starts at zero, bumps at attach.

  6. Compression scratch is per-thread and disjoint from the queue — lazy per-THREAD_ENTRY LOG_ZIP contexts plus a page-rounded log_data_ptr let producers compress outside prior_lsa_mutex. init_zip only gates log_Zip_support in server mode (eager alloc is SA); final_zip frees only in SA.

Chapter 3: Building a Node Outside the Mutex for Non Undoredo Records

Section titled “Chapter 3: Building a Node Outside the Mutex for Non Undoredo Records”

The reader question this chapter answers: how does a transaction turn an append-API call into a fully formed LOG_PRIOR_NODE without touching any global state? Chapter 2 established the two cursors and the mutex guarding them; this chapter stays before that mutex. Everything runs on the calling worker thread’s private stack — private memory, private payload — so the emerging node has never been seen by log_Gl.prior_info. Attachment (Ch 5) and draining (Ch 6) come later.

The entry point is prior_lsa_alloc_and_copy_data. Its sibling prior_lsa_alloc_and_copy_crumbs (Ch 4) handles the undoredo family with compression/diff encoding; this is the non-undoredo path — postpone, dbout-redo, 2PC prepare, end-checkpoint, and a catch-all for every other small record type.

Each generator is handed a freshly-malloc’d LOG_PRIOR_NODE and fills three independently-allocated buffers hanging off it — data header, undo payload, redo payload.

// log_prior_node -- src/transaction/log_append.hpp
struct log_prior_node
{
LOG_RECORD_HEADER log_header;
LOG_LSA start_lsa; /* for assertion */
bool tde_encrypted;
int data_header_length;
char *data_header;
int ulength; char *udata;
int rlength; char *rdata;
LOG_PRIOR_NODE *next;
};
FieldRoleWhy it exists
log_headerOn-disk LOG_RECORD_HEADER; only .type set here, LSA links / .trid under the mutex (Ch 5).Copy-out (Ch 7) emits it verbatim.
start_lsaAssertion-only copy of the attach LSA; unused here.Asserts the node landed where promised.
tde_encryptedTDE-encryption flag; false here.Classification deferred for non-undoredo.
data_header_lengthByte size of data_header.Copy-out emits exactly this many bytes.
data_headerHeap buffer for the type-specific header.Keeps the node generic across record types.
ulength / udataLength + heap buffer of the undo payload.2PC global-tran info, checkpoint tran array.
rlength / rdataLength + heap buffer of the redo payload.Postpone recovery data, dbout-redo image.
nextList link once attached.NULL during construction; attach (Ch 5) wires it.

Invariant — a node owns exactly the buffers it allocated, and nothing else. Construction never aliases caller pointers: udata/rdata/data_header are always fresh mallocs with memcpy’d contents (or NULL). That lets the caller free its own scratch the instant the call returns, and lets the error path (3.7) blindly free_and_init all three without double-free or freeing borrowed memory.

3.2 prior_lsa_alloc_and_copy_data: allocate, zero, dispatch

Section titled “3.2 prior_lsa_alloc_and_copy_data: allocate, zero, dispatch”

The function mallocs the node, zeroes every field by hand (it is malloc’d, not calloc’d, so the three buffer pointers start as garbage), then switches on rec_type.

// prior_lsa_alloc_and_copy_data -- src/transaction/log_append.cpp
node = (LOG_PRIOR_NODE *) malloc (sizeof (LOG_PRIOR_NODE));
if (node == NULL) { er_set (... ER_OUT_OF_VIRTUAL_MEMORY ...); return NULL; } /* <- nothing to clean up yet */
node->log_header.type = rec_type; /* <- only header field set pre-mutex */
node->data_header_length = 0; node->data_header = NULL;
node->tde_encrypted = false;
node->ulength = 0; node->udata = NULL;
node->rlength = 0; node->rdata = NULL;
node->next = NULL;

Setting the pointers NULL before the generator runs makes the unified error path (3.7) safe: buffers a failing generator never allocated are guaranteed NULL.

flowchart TD
  A["enter with rec_type"] --> M["malloc node; if NULL return NULL"]
  M --> Z["zero header.type, tde, lengths, pointers, next"]
  Z --> SW{"switch (rec_type)"}
  SW -->|"UNDOREDO / UNDO / REDO / MVCC_* family"| BAD["assert_release(false)<br/>error = ER_FAILED<br/>must use _crumbs path"]
  SW -->|LOG_DBEXTERN_REDO_DATA| G1["prior_lsa_gen_dbout_redo_record"]
  SW -->|LOG_POSTPONE| G2["assert ulength==0, udata==NULL<br/>prior_lsa_gen_postpone_record"]
  SW -->|LOG_2PC_PREPARE| G3["assert addr==NULL<br/>prior_lsa_gen_2pc_prepare_record"]
  SW -->|LOG_END_CHKPT| G4["assert addr==NULL<br/>prior_lsa_gen_end_chkpt_record"]
  SW -->|"~25 small types: COMMIT, ABORT, SAVEPOINT, RUN_POSTPONE, ..."| G5["assert rlength==0, rdata==NULL<br/>prior_lsa_gen_record"]
  SW -->|default| D["fall through, error stays NO_ERROR"]
  BAD --> CK{"error_code == NO_ERROR?"}
  G1 --> CK
  G2 --> CK
  G3 --> CK
  G4 --> CK
  G5 --> CK
  D --> CK
  CK -->|yes| RET["return node"]
  CK -->|no| FREE["free data_header, udata, rdata, node<br/>return NULL"]

Figure 3-1. Branch-complete control flow of prior_lsa_alloc_and_copy_data.

The load-bearing branch fact: the undoredo/MVCC family is a hard error here — those nine rec_types hit assert_release (false) and set error_code = ER_FAILED, because only the crumbs path (Ch 4) carries the compression/diff machinery. The pre-dispatch asserts encode each remaining family’s API contract; the default branch returns a node with all buffers NULL — the path for header-only record types.

3.3 prior_lsa_gen_postpone_record: a redo-only page-addressed record

Section titled “3.3 prior_lsa_gen_postpone_record: a redo-only page-addressed record”

LOG_POSTPONE records the address and recovery data of an operation deferred to commit. Its data header is a LOG_REC_REDO: a LOG_DATA (recovery location) plus its redo length; the redo payload is the recovery data.

// log_rec_redo -- src/transaction/log_record.hpp
struct log_rec_redo {
LOG_DATA data; /* Location of recovery data */
int length; /* Length of redo data */
};
FieldRoleWhy it exists
data (LOG_DATA)Recovery location rcvindex + (volid, pageid, offset), all written by the generator.Recovery dispatches by rcvindex, re-applies at the page slot.
length (int)Byte length of the redo image.Mirrored to node->rlength.
// prior_lsa_gen_postpone_record -- src/transaction/log_append.cpp
node->data_header_length = sizeof (LOG_REC_REDO);
node->data_header = (char *) malloc (node->data_header_length);
if (node->data_header == NULL) { ...; return ER_OUT_OF_VIRTUAL_MEMORY; }
redo = (LOG_REC_REDO *) node->data_header;
redo->data.rcvindex = rcvindex;
if (addr->pgptr != NULL) /* <- page resident: read its VPID */
{ vpid = pgbuf_get_vpid_ptr (addr->pgptr);
redo->data.pageid = vpid->pageid; redo->data.volid = vpid->volid; }
else /* <- no page: null location */
{ redo->data.pageid = NULL_PAGEID; redo->data.volid = NULL_VOLID; }
redo->data.offset = addr->offset;
redo->length = length;
error_code = prior_lsa_copy_redo_data_to_node (node, redo->length, data);
return error_code;

The only branch is addr->pgptr: page-held yields that page’s (volid, pageid), otherwise the location is nulled and only offset survives. Either way the redo data is copied via the helper in 3.6.

3.4 prior_lsa_gen_dbout_redo_record: redo to an external (non-page) destination

Section titled “3.4 prior_lsa_gen_dbout_redo_record: redo to an external (non-page) destination”

LOG_DBEXTERN_REDO_DATA is for redo targeting something outside the page buffer (e.g. a file-level operation). Its header is the compact LOG_REC_DBOUT_REDO — recovery index and length, no page address.

// log_rec_dbout_redo -- src/transaction/log_record.hpp
struct log_rec_dbout_redo {
LOG_RCVINDEX rcvindex; /* Index to recovery function */
int length; /* Length of redo data */
};
FieldRoleWhy it exists
rcvindex (LOG_RCVINDEX)Index into the recovery-function table that replays this external redo.No page address; the index alone identifies the target.
length (int)Byte length of the redo image.Mirrored to node->rlength.
// prior_lsa_gen_dbout_redo_record -- src/transaction/log_append.cpp
node->data_header_length = sizeof (LOG_REC_DBOUT_REDO);
node->data_header = (char *) malloc (node->data_header_length);
if (node->data_header == NULL) { ...; return ER_OUT_OF_VIRTUAL_MEMORY; }
dbout_redo = (LOG_REC_DBOUT_REDO *) node->data_header;
dbout_redo->rcvindex = rcvindex;
dbout_redo->length = length;
error_code = prior_lsa_copy_redo_data_to_node (node, dbout_redo->length, data);
return error_code;

No branch beyond the malloc failure check: copy header, copy redo, done.

3.5 prior_lsa_gen_2pc_prepare_record and prior_lsa_gen_end_chkpt_record: dual-payload headers

Section titled “3.5 prior_lsa_gen_2pc_prepare_record and prior_lsa_gen_end_chkpt_record: dual-payload headers”

These two share one shape: allocate a fixed header struct, then conditionally copy an undo-side and a redo-side buffer. Neither populates the header’s fields — the caller does that later; the generators only do the two copies.

// prior_lsa_gen_2pc_prepare_record -- src/transaction/log_append.cpp
node->data_header_length = sizeof (LOG_REC_2PC_PREPCOMMIT);
node->data_header = (char *) malloc (node->data_header_length); /* if NULL -> return ER_OUT_OF_VIRTUAL_MEMORY */
if (gtran_length > 0) /* <- global-transaction info -> undo side */
error_code = prior_lsa_copy_undo_data_to_node (node, gtran_length, gtran_data);
if (lock_length > 0) /* <- acquired-lock list -> redo side */
error_code = prior_lsa_copy_redo_data_to_node (node, lock_length, lock_data);
return error_code;
// prior_lsa_gen_end_chkpt_record -- src/transaction/log_append.cpp
node->data_header_length = sizeof (LOG_REC_CHKPT);
node->data_header = (char *) malloc (node->data_header_length); /* if NULL -> return ER_OUT_OF_VIRTUAL_MEMORY */
if (tran_length > 0) /* <- active-transaction descriptors -> undo side */
error_code = prior_lsa_copy_undo_data_to_node (node, tran_length, tran_data);
if (topop_length > 0) /* <- system-op descriptors -> redo side */
error_code = prior_lsa_copy_redo_data_to_node (node, topop_length, topop_data);
return error_code;

Branch caution — error_code is overwritten, not accumulated. The redo-side copy assigns error_code unconditionally, masking a failed undo-side copy if the redo-side then succeeds. In practice an OOM at udata makes the next rdata malloc almost certainly fail too, and the helpers no-op on empty length, so the error survives. A modifier adding a third payload must preserve “last copy wins” or switch to short-circuit.

The header structs are populated outside this chapter, but their field roles are part of the record contract:

// log_rec_2pc_prepcommit -- src/transaction/log_record.hpp
struct log_rec_2pc_prepcommit {
char user_name[DB_MAX_USER_LENGTH + 1];
int gtrid; int gtrinfo_length;
unsigned int num_object_locks; unsigned int num_page_locks;
};
FieldRoleWhy it exists
user_nameClient name of the global transaction.Identifies the participant on 2PC reconstruction after restart.
gtridGlobal transaction identifier.Ties this local branch to the distributed transaction.
gtrinfo_lengthByte length of the global-tran info blob (gtran_data copied to udata).How much of the undo payload is global-tran info.
num_object_locksCount of update-type object locks held.Recovery reacquires exactly those object locks.
num_page_locksCount of update-type page locks held.Same for page locks; the list rides in the redo payload.
// log_rec_chkpt -- src/transaction/log_record.hpp
struct log_rec_chkpt {
LOG_LSA redo_lsa; /* Oldest LSA of dirty data page in page buffers */
int ntrans; /* Number of active transactions */
int ntops; /* Total number of system operations */
};
FieldRoleWhy it exists
redo_lsaOldest LSA among dirty data pages at checkpoint time.Restart recovery starts its redo pass here.
ntransNumber of active transactions captured.Count of LOG_INFO_CHKPT_TRANS in the undo payload.
ntopsTotal in-flight system operations captured.Count of LOG_INFO_CHKPT_SYSOP in the redo payload.

LOG_END_CHKPT is reachable two ways — the dedicated generator above (where 3.2’s dispatch routes it) and a case LOG_END_CHKPT arm in prior_lsa_gen_record that just sizes LOG_REC_CHKPT for the parameterless variant.

3.6 prior_lsa_gen_record: the catch-all and the copy helpers

Section titled “3.6 prior_lsa_gen_record: the catch-all and the copy helpers”

prior_lsa_gen_record handles roughly two dozen small record types: a switch that only decides data_header_length, then a shared malloc-and-zero block, then one optional undo copy.

// prior_lsa_gen_record -- src/transaction/log_append.cpp
node->data_header_length = 0;
switch (rec_type)
{
case LOG_DUMMY_HEAD_POSTPONE: ... case LOG_SYSOP_ATOMIC_START:
assert (length == 0 && data == NULL); /* <- pure marker records: no header */
break;
case LOG_RUN_POSTPONE: node->data_header_length = sizeof (LOG_REC_RUN_POSTPONE); break;
case LOG_COMPENSATE: node->data_header_length = sizeof (LOG_REC_COMPENSATE); break;
case LOG_SAVEPOINT: node->data_header_length = sizeof (LOG_REC_SAVEPT); break;
case LOG_COMMIT:
case LOG_ABORT:
assert (length == 0 && data == NULL);
node->data_header_length = sizeof (LOG_REC_DONETIME); break; /* <- termination time record */
case LOG_SYSOP_END: node->data_header_length = sizeof (LOG_REC_SYSOP_END); break;
case LOG_2PC_START: node->data_header_length = sizeof (LOG_REC_2PC_START); break;
case LOG_END_CHKPT: node->data_header_length = sizeof (LOG_REC_CHKPT); break;
/* ... condensed: COMMIT_WITH_POSTPONE[_OBSOLETE], SYSOP_START_POSTPONE,
DUMMY_HA_SERVER_STATE, REPLICATION_{DATA,STATEMENT}, SUPPLEMENTAL_INFO ... */
default: break; /* <- length stays 0: header-less record */
}
if (node->data_header_length > 0)
{
node->data_header = (char *) malloc (node->data_header_length);
if (node->data_header == NULL) { ...; return ER_OUT_OF_VIRTUAL_MEMORY; }
#if !defined (NDEBUG)
memset (node->data_header, 0, node->data_header_length); /* <- silence valgrind */
#endif
}
if (length > 0)
error_code = prior_lsa_copy_undo_data_to_node (node, length, data); /* <- redo-side stays empty */
return error_code;

Two load-bearing facts. The switch only sizes the header — it never fills any field: every type-specific field (e.g. LOG_REC_DONETIME.at_time, the LOG_REC_SYSOP_END union) is written by the caller after the call returns, by casting node->data_header. And gen_record is undo-only: it calls prior_lsa_copy_undo_data_to_node exclusively, matching the dispatcher’s assert (rlength == 0 && rdata == NULL) guard. The marker asserts reject a payload on a payload-less type; release builds skip the assert but still reach if (length > 0), copying a stray payload harmlessly here yet wrongly for recovery.

The smallest meaningful header in this group is the commit/abort termination record:

// log_rec_donetime -- src/transaction/log_record.hpp
struct log_rec_donetime {
INT64 at_time; /* Database creation time. For safety reasons */
};
FieldRoleWhy it exists
at_timeWall-clock time the transaction terminated (commit or abort).Point-in-time recovery; sanity check vs. db creation time on restart.

The two payload-copy helpers are trivial and identical except for which node fields they touch:

// prior_lsa_copy_undo_data_to_node -- src/transaction/log_append.cpp
static int prior_lsa_copy_undo_data_to_node (LOG_PRIOR_NODE *node, int length, const char *data)
{
if (length <= 0 || data == NULL) return NO_ERROR; /* <- no-op guard: idempotent on empty */
node->udata = (char *) malloc (length);
if (node->udata == NULL) { ...; return ER_OUT_OF_VIRTUAL_MEMORY; }
memcpy (node->udata, data, length);
node->ulength = length; /* <- length set only after successful malloc+memcpy */
return NO_ERROR;
}

prior_lsa_copy_redo_data_to_node is byte-for-byte the same with rdata/rlength substituted. The shared no-op guard is why callers in 3.5 can use if (gtran_length > 0) guards while 3.3/3.4 call the helper unconditionally — both are safe.

Invariant — ulength/rlength is non-zero iff the matching buffer is allocated. The length is set only after a successful malloc+memcpy; the no-op guard leaves both at their zeroed defaults. Nothing here can produce ulength > 0 with udata == NULL (crashes copy-out in Ch 7) or udata != NULL with ulength == 0 (silent leak). Any edit that sets the length before the copy succeeds breaks this.

3.7 The unified error path and the cross-cutting no-mutex invariant

Section titled “3.7 The unified error path and the cross-cutting no-mutex invariant”

Back in prior_lsa_alloc_and_copy_data, every generator returns an int into the shared error_code. The tail is a single decision:

// prior_lsa_alloc_and_copy_data (tail) -- src/transaction/log_append.cpp
if (error_code == NO_ERROR)
return node;
else
{
if (node != NULL)
{
if (node->data_header != NULL) free_and_init (node->data_header);
if (node->udata != NULL) free_and_init (node->udata);
if (node->rdata != NULL) free_and_init (node->rdata);
free_and_init (node);
}
return NULL;
}

This works precisely because of the hand-zeroing in 3.2 and the iff-invariant in 3.6: buffers a generator allocated are non-NULL and get freed; buffers it never reached are NULL and skipped. The NULL/non-NULL of each pointer is the bookkeeping — there is no separate progress flag.

Invariant (the chapter’s headline) — nothing on this path takes prior_lsa_mutex or reads/writes log_Gl.prior_info. Verify by inspection: prior_lsa_alloc_and_copy_data and all five generators reference only the local node, the caller’s arguments, and pgbuf_get_vpid_ptr (a page-buffer read). That makes node construction fully parallel across worker threads; the serialization point (the mutex) is deferred to attach time in Ch 5. An edit peeking at log_Gl.prior_info here would reintroduce hot-path contention — without the mutex, a data race.

The public append-API surface (log_append_undoredo_data, log_append_redo_data, log_append_postpone, the checkpoint and 2PC loggers) all funnel into this function or prior_lsa_alloc_and_copy_crumbs; the full catalogue lives in the high-level companion — see cubrid-prior-list.md, the append-API section.

  1. prior_lsa_alloc_and_copy_data builds a LOG_PRIOR_NODE on the calling thread: one malloc, hand-zeroing of every field (it is malloc’d, not calloc’d), a switch on rec_type to a per-type generator, and a single cleanup decision.
  2. The undoredo/MVCC family is a hard assert_release (false) here; those records must go through prior_lsa_alloc_and_copy_crumbs (Chapter 4), which has the compression/diff encoding this path lacks.
  3. Each generator mallocs a type-specific header (LOG_REC_REDO postpone, LOG_REC_DBOUT_REDO external redo, LOG_REC_2PC_PREPCOMMIT prepare, LOG_REC_CHKPT end-checkpoint, one of ~15 sizes in prior_lsa_gen_record) and copies at most one undo and one redo payload.
  4. The one real intra-generator branch is in postpone: page-resident addr yields a real (volid, pageid), otherwise the location is nulled. The 2PC and end-chkpt generators each do two conditional copies whose error codes overwrite — last copy wins.
  5. The copy helpers are identical malloc+memcpy routines with a length <= 0 || data == NULL no-op guard, enforcing that a length field is non-zero iff its buffer is allocated.
  6. The unified error path frees data_header/udata/rdata/node relying on the NULL-or-allocated invariant, not a progress flag — which is why the up-front zeroing is load-bearing.
  7. The whole path never touches prior_lsa_mutex or log_Gl.prior_info, so node construction is fully concurrent and serialization is deferred to attach time (Chapter 5).

Chapter 4: Building an Undoredo Node with Compression and Diff Encoding

Section titled “Chapter 4: Building an Undoredo Node with Compression and Diff Encoding”

Chapter 3 copies bytes verbatim. UNDOREDO is the MVCC hot path — every heap insert, update, and overflow touch produces one — and the most expensive record to assemble: three transformations stack on the plain copy: diff-encoding of undo against redo, MVCC stamping with vacuum metadata, and LZ4 compression — all before the prior-list mutex. This chapter traces prior_lsa_alloc_and_copy_crumbs and prior_lsa_gen_undoredo_record_from_crumbs branch by branch. For the architecture and why pre-mutex work is central, see cubrid-prior-list.md (“The prior list”, “Producer side”).

4.1 The crumb interface and the entry switch

Section titled “4.1 The crumb interface and the entry switch”

A crumb is a {length, pointer} pair. Callers describe an undo or redo image as an array of crumbs rather than one contiguous buffer, so a record’s bytes can come from scattered structures without the caller gluing them.

// log_crumb -- src/transaction/log_append.hpp
struct log_crumb { int length; const void *data; };

prior_lsa_alloc_and_copy_crumbs is the public entry. It mallocs a LOG_PRIOR_NODE, zeroes the payload pointers (data_header, udata, rdata) so the error path can free them unconditionally, then dispatches on rec_type: every UNDO/REDO/UNDOREDO type funnels into the single delegate, anything else trips assert_release (false). The two LOG_*_DIFF_UNDOREDO_DATA cases appear in the switch but are never passed by callers — diff promotion is internal (4.4), and the delegate even asserts the incoming type is not yet a DIFF type.

// prior_lsa_alloc_and_copy_crumbs -- src/transaction/log_append.cpp
node->data_header = NULL; node->udata = NULL; node->rdata = NULL; /* <- err path frees iff non-NULL */
switch (rec_type) {
case LOG_UNDOREDO_DATA: case LOG_DIFF_UNDOREDO_DATA: case LOG_UNDO_DATA: case LOG_REDO_DATA:
case LOG_MVCC_UNDOREDO_DATA: case LOG_MVCC_DIFF_UNDOREDO_DATA: case LOG_MVCC_UNDO_DATA: case LOG_MVCC_REDO_DATA:
error = prior_lsa_gen_undoredo_record_from_crumbs (thread_p, node, rcvindex, addr, /* + crumb args */);
break;
default: assert_release (false); error = ER_FAILED; break; /* <- not a data record */
}

On success the node is returned. On error the wrapper frees the three payloads and the node; the delegate’s error: label frees the three payloads but not the node, so the cleanups compose without double-free.

Invariant — payload pointers are NULL until owned. Each malloced payload pointer is NULL or live, never stale; the entry zeroes all three before the switch, so both cleanups test != NULL and free safely. Enforcement: the unconditional NULL-init at entry plus the != NULL guard in each free site. What breaks if violated: the wrapper frees a payload the delegate already freed (double-free) or leaks one the delegate allocated. This invariant governs every error path below.

The delegate selects one data header struct, copied later into the log page as the record’s fixed-size head. There are six record families and six matching header structs — LOG_REC_UNDOREDO, LOG_REC_UNDO, LOG_REC_REDO, and their three MVCC counterparts LOG_REC_MVCC_UNDOREDO, LOG_REC_MVCC_UNDO, LOG_REC_MVCC_REDO. All nest the same LOG_DATA locator and are filled by the same function. The non-MVCC trio plus the shared LOG_DATA/LOG_VACUUM_INFO building blocks are tabled below; the MVCC trio composes them: LOG_REC_MVCC_UNDOREDO is { undoredo, mvccid, vacuum_info }, LOG_REC_MVCC_UNDO is { undo, mvccid, vacuum_info }, and LOG_REC_MVCC_REDO is { redo, mvccid } — no vacuum_info, so MVCC redo is off vacuum’s chain (4.5).

LOG_DATA — the recovery locator nested in every variant:

FieldRoleWhy it exists
rcvindexIndex of the recovery (RV) functionSelects which redo/undo routine replays the bytes
pageidPage id of the affected pageNames the target page; NULL_PAGEID for a logical record
offsetOffset within the pageWhere on the page the change lands
volidVolume id of the affected pageNames the volume; NULL_VOLID for a logical record

LOG_REC_UNDOREDO — plain (non-MVCC) undoredo, the common heap/btree record:

FieldRoleWhy it exists
dataLOG_DATA locator: rcvindex + (volid, pageid, offset)Selects the page and RV function to replay against
ulengthUndo image length; top bit = “zipped” via MAKE_ZIP_LENBytes recovery reads for rollback; flag says inflate
rlengthRedo image length; top bit = “zipped”Forward replay; under DIFF redo is XOR-diffed vs undo

LOG_REC_UNDO and LOG_REC_REDO — single-direction records, identical 2-field layout:

FieldRoleWhy it exists
dataLOG_DATA locator, same as aboveOne direction needs one locator
lengthThe image’s length; top bit = “zipped” via MAKE_ZIP_LENBytes recovery reads; flag says inflate

LOG_VACUUM_INFO — appended to MVCC undo-bearing records (UNDO, UNDOREDO) so vacuum can walk the chain:

FieldRoleWhy it exists
prev_mvcc_op_log_lsaLSA of prev MVCC op in this txn; NULL here, patched under the mutexVacuum follows it to find a txn’s versions; the LSA is not assigned pre-mutex (Ch 5)
vfidFile id of the affected heap/btree fileDetects a dropped/reused file; object class (reusable vs referable)

LOG_REC_MVCC_UNDOREDO — the heaviest of the three MVCC headers, field by field (LOG_REC_MVCC_UNDO is the same shape with LOG_REC_UNDO in place of undoredo; LOG_REC_MVCC_REDO drops the vacuum_info field):

FieldRoleWhy it exists
undoredoEmbedded LOG_REC_UNDOREDOReuses the plain layout so recovery reads the head identically
mvccidThe txn’s MVCC id (or innermost sub-txn id)Vacuum and snapshot visibility need the producing MVCCID
vacuum_infoEmbedded LOG_VACUUM_INFOChains the record into vacuum’s per-txn worklist
flowchart LR
  subgraph mvcc["LOG_REC_MVCC_UNDOREDO"]
    ur["undoredo: LOG_REC_UNDOREDO<br/>data + ulength + rlength"]
    mid["mvccid"]
    vi["vacuum_info<br/>prev_mvcc_op_log_lsa + vfid"]
  end
  ur --> ld["data: LOG_DATA<br/>rcvindex,pageid,offset,volid"]

Figure 4-1. The MVCC undoredo header nests the plain header, an MVCCID, and vacuum metadata; the non-MVCC variant is the undoredo box alone, and LOG_REC_MVCC_REDO drops the vacuum_info box.

4.3 The per-thread LOG_ZIP context and deciding can_zip

Section titled “4.3 The per-thread LOG_ZIP context and deciding can_zip”

The delegate asserts a base type, totals both crumb arrays into ulength/rlength, grabs the per-thread zip contexts, and sets has_undo/has_redo/can_zip per record family.

// prior_lsa_gen_undoredo_record_from_crumbs -- src/transaction/log_append.cpp
assert (node->log_header.type != LOG_DIFF_UNDOREDO_DATA
&& node->log_header.type != LOG_MVCC_DIFF_UNDOREDO_DATA); /* <- DIFF arises internally only */
zip_undo = log_append_get_zip_undo (thread_p); zip_redo = log_append_get_zip_redo (thread_p); /* lazy per-thread alloc */
ulength = 0; for (i=0;i<num_ucrumbs;i++) ulength += ucrumbs[i].length; /* re-total both crumb arrays */
rlength = 0; for (i=0;i<num_rcrumbs;i++) rlength += rcrumbs[i].length;
if (LOG_IS_UNDOREDO_RECORD_TYPE (node->log_header.type)) { has_undo = has_redo = true; /* has both */
can_zip = log_Zip_support && (zip_undo != NULL || ulength == 0) && (zip_redo != NULL || rlength == 0); }
else if (LOG_IS_REDO_RECORD_TYPE (node->log_header.type)) { has_redo = true; can_zip = log_Zip_support && zip_redo; }
else /* UNDO type */ { has_undo = true; can_zip = log_Zip_support && zip_undo; }

log_zip compresses into a LOG_ZIP context. Its three fields are the contract between the compressor and the copy step (4.5):

FieldRoleWhy it exists
data_lengthLength of the stored image, including the LOG_ZIP_SIZE_T length prefixThe byte count the copy step hands to prior_lsa_copy_*_data_to_node; the success test data_length < length lives on it
buf_sizeCapacity of log_dataLets log_zip_realloc_if_needed grow lazily and skip realloc when the buffer already fits
log_dataThe compressed bytes, prefixed with the original lengthThe buffer copied into node->udata/node->rdata; the prefix lets the consumer size the unzip

log_append_get_zip_undo/_redo return thread_p->log_zip_undo/_redo, allocating an IO_PAGESIZE LOG_ZIP on first use (the #if !defined(SERVER_MODE) build instead uses file-scope statics). This is the load-bearing line: the zip buffers are per-thread, so N producers compress in parallel outside the prior-list mutex; under the drain mutex it would serialise the costliest step. log_Zip_support is set by log_append_init_zip only when PRM_ID_LOG_COMPRESS is on; the size floor log_Zip_min_size_to_compress defaults to 255.

flowchart TD
  S["total ulength, rlength"] --> Z{"can_zip and some len >= 255?"}
  Z -- no --> RAW["raw crumb copy"]
  Z -- yes --> R{"realloc data_ptr ok?"}
  R -- no --> RAW
  R -- yes --> B{"both >= 255?"}
  B -- yes --> D["log_diff then zip both; is_diff if redo zipped"]
  B -- no --> O["zip the large side only"]

Figure 4-2. The prior_lsa_gen_undoredo_record_from_crumbs compression decision; only when both directions clear 255 does diff-encoding run.

4.4 Staging, diff-encoding, and compression

Section titled “4.4 Staging, diff-encoding, and compression”

When can_zip holds and one length reaches 255, the crumbs are staged into the per-thread data_ptr (grown by log_append_realloc_data_ptr). If that realloc fails, data_ptr stays NULL and the zip block is skipped — logging the record raw.

if (can_zip && (ulength >= MIN || rlength >= MIN)) { /* MIN = log_Zip_min_size_to_compress (255) */
total_length = (ulength>0?ulength:0) + (rlength>0?rlength:0);
if (log_append_realloc_data_ptr (thread_p, total_length)) data_ptr = log_append_get_data_ptr (thread_p);
if (data_ptr != NULL) {
tmp_ptr = data_ptr;
if (ulength >= MIN) { undo_data = data_ptr; /* flatten undo crumbs into data_ptr */
for(i=0;i<num_ucrumbs;i++){ memcpy(tmp_ptr,ucrumbs[i].data,ucrumbs[i].length); tmp_ptr+=ucrumbs[i].length; } }
if (rlength >= MIN) { redo_data = tmp_ptr; /* same loop over rcrumbs after undo */ }
if (ulength >= MIN && rlength >= MIN) { /* BOTH: diff then zip both */
(void) log_diff (ulength, undo_data, rlength, redo_data); /* redo ^= undo, in place */
is_undo_zip = log_zip (zip_undo, ulength, undo_data);
is_redo_zip = log_zip (zip_redo, rlength, redo_data);
if (is_redo_zip) is_diff = true; /* diff counts only if redo compressed */
} else { /* ONE side >= MIN: zip it only */
if (ulength >= MIN) is_undo_zip = log_zip (zip_undo, ulength, undo_data);
if (rlength >= MIN) is_redo_zip = log_zip (zip_redo, rlength, redo_data); }
}
}

log_diff XORs redo against undo in place over MIN(ulength, rlength) bytes; for an UPDATE the near-identical images yield mostly zeros that compress far better. log_zip LZ4-compresses into the thread’s LOG_ZIP; the resulting data_length includes a LOG_ZIP_SIZE_T length prefix, and the success test data_length < length (compressed output plus prefix smaller than the raw image) means a non-shrinking record is logged raw.

Invariant — is_diff is set only when redo compressed. log_diff mutates redo_data in place, but is_diff turns true only in the both-sides branch and only after is_redo_zip. Enforcement: the lone if (is_redo_zip) is_diff = true; inside the both-sides arm, plus the later assert (has_redo && has_undo) on the promotion. What breaks if violated: when undo compressed but redo did not, is_diff stays false and the un-zipped redo is recopied from the original crumbs (4.5), not the mutated buffer; setting is_diff with redo carried raw would replay corrupt redo. When set, the type is promoted to LOG_[MVCC_]DIFF_UNDOREDO_DATA.

4.5 Allocating the header, the fallthrough cascade, and the final copy

Section titled “4.5 Allocating the header, the fallthrough cascade, and the final copy”

A switch sizes data_header_length to the chosen struct (LOG_REC_MVCC_UNDOREDO, LOG_REC_MVCC_UNDO, LOG_REC_MVCC_REDO, LOG_REC_UNDOREDO, or the narrower LOG_REC_UNDO/LOG_REC_REDO); the header is malloced, OOM jumps to error:. A second switch using [[fallthrough]] wires the bookkeeping pointers so one block fills each MVCC/non-MVCC pair:

case LOG_MVCC_UNDOREDO_DATA: case LOG_MVCC_DIFF_UNDOREDO_DATA:
mvcc_undoredo_p = (LOG_REC_MVCC_UNDOREDO *) node->data_header;
vacuum_info_p = &mvcc_undoredo_p->vacuum_info; mvccid_p = &mvcc_undoredo_p->mvccid; /* MVCC-only -> 4.6 */
[[fallthrough]];
case LOG_UNDOREDO_DATA: case LOG_DIFF_UNDOREDO_DATA:
undoredo_p = (type==LOG_UNDOREDO_DATA||type==LOG_DIFF_UNDOREDO_DATA
? (LOG_REC_UNDOREDO *) node->data_header : &mvcc_undoredo_p->undoredo);
data_header_ulength_p = &undoredo_p->ulength; data_header_rlength_p = &undoredo_p->rlength;
log_data_p = &undoredo_p->data; break;

The MVCC case sets vacuum_info_p/mvccid_p then falls through, resolving undoredo_p to the top-level header (non-MVCC) or the nested undoredo (MVCC). The UNDO/REDO arms are asymmetric: LOG_MVCC_UNDO_DATA sets both vacuum_info_p and mvccid_p (then falls through to resolve undo_p), while LOG_MVCC_REDO_DATA sets mvccid_p only and never vacuum_info_p — because LOG_REC_MVCC_REDO has no such field. So MVCC undo/undoredo are on vacuum’s worklist; MVCC redo is not.

The locator is filled from addr (pgptr set ⇒ pgbuf_get_vpid_ptr gives the real (volid, pageid), else NULL_PAGEID/NULL_VOLID for a logical record). The final copy uses the four 4.4 flags:

if (is_undo_zip) { *data_header_ulength_p = MAKE_ZIP_LEN (zip_undo->data_length); /* zipped: top bit set */
error_code = prior_lsa_copy_undo_data_to_node (node, zip_undo->data_length, zip_undo->log_data); }
else if (has_undo) { *data_header_ulength_p = ulength; /* raw */
error_code = prior_lsa_copy_undo_crumbs_to_node (node, num_ucrumbs, ucrumbs); }
/* redo mirrors: is_redo_zip -> MAKE_ZIP_LEN + copy_redo_data; else has_redo -> copy_redo_crumbs */

The raw helpers prior_lsa_copy_undo_crumbs_to_node/prior_lsa_copy_redo_crumbs_to_node re-total crumb lengths, malloc node->udata/node->rdata once, concatenate all crumbs (zero total allocates nothing), and assert the target is NULL first — so zip and raw are mutually exclusive per direction. The zipped helpers prior_lsa_copy_undo_data_to_node/prior_lsa_copy_redo_data_to_node no-op when length <= 0.

4.6 MVCC stamping and the deferred vacuum LSA

Section titled “4.6 MVCC stamping and the deferred vacuum LSA”

If mvccid_p is non-NULL (MVCC types only), the MVCCID is stamped from the TDES, innermost sub-txn first; a NULL TDES or invalid id is assert_release (false):

tdes = LOG_FIND_CURRENT_TDES (thread_p);
if (tdes == NULL || !MVCCID_IS_VALID (tdes->mvccinfo.id)) { assert_release(false); error_code = ER_FAILED; goto error; }
*mvccid_p = tdes->mvccinfo.sub_ids.empty () ? tdes->mvccinfo.id : tdes->mvccinfo.sub_ids.back (); /* innermost */

If vacuum_info_p is non-NULL (MVCC undo/undoredo/diff — not redo), vfid is copied from addr->vfid. RVES_NOTIFY_VACUUM legitimately carries a NULL vfid (VFID_SET_NULL); any other MVCC record with no vfid is a bug (assert_release (false)). The back-pointer is then LSA_SET_NULL (&vacuum_info_p->prev_mvcc_op_log_lsa).

Invariant — the previous-MVCC-op LSA cannot be filled pre-mutex. prev_mvcc_op_log_lsa needs this record’s own LSA, which is assigned only on attach under the prior-list mutex; the function writes NULL and defers the patch (cubrid-prior-list.md Chapter 5). Enforcement: the unconditional LSA_SET_NULL here, with the real value written during the mutexed attach. What breaks if violated: a stale or guessed LSA would chain vacuum to the wrong record, so vacuum either skips live versions or follows a dangling link. Any error_code from the copy helpers falls to the shared error: label, NULL-guarded per 4.1.

  1. Crumbs decouple caller from layout. Bytes arrive as a {length, data} array (LOG_CRUMB); the builder flattens them, so callers never pre-concatenate.
  2. Three transformations distinguish UNDOREDO: XOR diff-encoding (log_diffLOG_*_DIFF_UNDOREDO_DATA), MVCC stamping (mvccid + LOG_VACUUM_INFO), and LZ4 compression (log_zip) — gated by log_Zip_support and the 255-byte floor.
  3. Compression runs on a per-thread LOG_ZIP before the mutex — the load-bearing choice: N producers compress in parallel; under the drain mutex it would serialise the costliest step. LOG_ZIP carries data_length (output plus length-prefix), buf_size, and the log_data buffer.
  4. Six families, six header structs, chosen by final type: LOG_REC_MVCC_UNDOREDO for MVCC undoredo/diff, LOG_REC_MVCC_UNDO/LOG_REC_MVCC_REDO for single-direction MVCC, plain LOG_REC_UNDOREDO/LOG_REC_UNDO/LOG_REC_REDO otherwise. Only LOG_REC_MVCC_REDO lacks vacuum_info, so MVCC redo is off vacuum’s chain.
  5. MAKE_ZIP_LEN overloads the length’s top bit (| 0x80000000) to flag a compressed image; is_diff is set only when redo compresses, and only then is redo carried as redo XOR undo for the consumer to reverse.
  6. DIFF types are internal-only; cleanup splits two ways (delegate frees payloads, wrapper frees node — safe via “payload pointers NULL until owned”), and prev_mvcc_op_log_lsa stays NULL for the mutexed attach step (Chapter 5).

Chapter 5: Assigning the LSN and Attaching Under the Mutex

Section titled “Chapter 5: Assigning the LSN and Attaching Under the Mutex”

A producer arrives holding a fully-built LOG_PRIOR_NODE (Chapters 3–4) with a null start_lsa and no links. This chapter answers: once built, how is a node’s LSN assigned, how is it spliced onto the tail, and what does prior_lsa_mutex protect? It all happens inside prior_lsa_next_record_internal: the producer pays O(1) — advance a cursor, stamp a few LSAs, splice one pointer — and leaves; copy-and-flush is the drain side’s job (Chapters 6–8). See the high-level companion’s “prior LSA assignment” and “single global serialization point” sections for rationale; backpressure is Chapter 9.

Two shims differing only in the with_lock constant they forward:

// prior_lsa_next_record / _with_lock -- src/transaction/log_append.cpp
LOG_LSA prior_lsa_next_record (THREAD_ENTRY *thread_p, LOG_PRIOR_NODE *node, log_tdes *tdes)
{ return prior_lsa_next_record_internal (thread_p, node, tdes, LOG_PRIOR_LSA_WITHOUT_LOCK); } /* <- internal takes the mutex */
LOG_LSA prior_lsa_next_record_with_lock (THREAD_ENTRY *thread_p, LOG_PRIOR_NODE *node, log_tdes *tdes)
{ return prior_lsa_next_record_internal (thread_p, node, tdes, LOG_PRIOR_LSA_WITH_LOCK); } /* <- caller already holds it */

WITHOUT_LOCK takes prior_lsa_mutex on entry, releases it on exit, and runs the backpressure block (Section 5.9). WITH_LOCK does none of these — it is for callers that hold the mutex once and append several records atomically (_with_lock repeatedly), then release it; backpressure must run with the mutex not held.

flowchart TB
  A["with_lock == WITHOUT_LOCK?"] -->|yes| B["prior_lsa_mutex.lock()"]
  A -->|no, caller holds mutex| D
  B --> D["prior_lsa_start_append: start_lsa, tran chain, back_lsa"]
  D --> E["copy start_lsa out, vacuum block-boundary check"]
  E --> G["type-dispatch stamping: MVCC / sysop / commit / abort"]
  G --> H["advance data_header, append udata then rdata"]
  H --> J["prior_lsa_end_append, splice tail, list_size += bytes"]
  J --> M["with_lock == WITHOUT_LOCK?"]
  M -->|yes| N["unlock + backpressure, Chapter 9"]
  M -->|no| P
  N --> P["num_log_records_written++, return start_lsa"]

Figure 5-1 — Control flow of prior_lsa_next_record_internal. Everything between lock and unlock is the critical section. Note num_log_records_written++ sits after the unlock guard — per-transaction state, bumped outside the critical section.

Invariant — prior_lsa_mutex totally orders LSN assignment and list attachment. Every byte of logical-log address space is handed out by advancing prior_lsa inside this mutex, and every node spliced onto prior_list_tail inside it, giving (1) non-overlapping LSAs and (2) a list ordered by ascending start_lsa = flush order. Advancing or splicing outside the mutex would let two transactions claim the same byte range.

5.3 prior_lsa_start_appendstart_lsa, the transaction chain, back_lsa

Section titled “5.3 prior_lsa_start_append — start_lsa, the transaction chain, back_lsa”

The LSN is born here — start_lsa is the current prior_lsa:

// prior_lsa_start_append -- src/transaction/log_append.cpp
log_prior_lsa_append_advance_when_doesnot_fit (sizeof (LOG_RECORD_HEADER)); /* <- header must not straddle a page */
node->log_header.trid = tdes->trid;
LSA_COPY (&node->start_lsa, &log_Gl.prior_info.prior_lsa); /* <- THE LSN */
if (tdes->is_system_worker_transaction () && !tdes->is_under_sysop ())
{ LSA_SET_NULL (&node->log_header.prev_tranlsa); /* <- no per-tran undo chain */
LSA_SET_NULL (&tdes->head_lsa); LSA_SET_NULL (&tdes->tail_lsa); }
else
{ LSA_COPY (&node->log_header.prev_tranlsa, &tdes->tail_lsa); /* <- chain back to tran's last */
LSA_COPY (&tdes->tail_lsa, &log_Gl.prior_info.prior_lsa); /* <- record is now tran's tail */
if (LSA_ISNULL (&tdes->head_lsa)) { LSA_COPY (&tdes->head_lsa, &tdes->tail_lsa); } /* <- first record */
LSA_COPY (&tdes->undo_nxlsa, &log_Gl.prior_info.prior_lsa); } /* <- rollback resumes here */
LSA_COPY (&node->log_header.back_lsa, &log_Gl.prior_info.prev_lsa); /* <- physical back link, every record */
LSA_SET_NULL (&node->log_header.forw_lsa); /* <- filled by end_append */
LSA_COPY (&log_Gl.prior_info.prev_lsa, &log_Gl.prior_info.prior_lsa);
log_prior_lsa_append_add_align (sizeof (LOG_RECORD_HEADER)); /* <- reserve header, advance cursor */

The physical chain (back_lsa/forw_lsa) is set in both branches so recovery’s backward scan always works; the if/else diverges only on the transaction chain, which the if branch (system worker outside a sysop, no rollback) nulls. The leading advance_when_doesnot_fit runs before the stamp, so start_lsa points at a page-contained header.

After start_lsa is read:

// prior_lsa_next_record_internal -- src/transaction/log_append.cpp
if (LOG_ISRESTARTED () && log_Gl.hdr.does_block_need_vacuum)
{
assert (!LSA_ISNULL (&log_Gl.hdr.mvcc_op_log_lsa));
if (vacuum_get_log_blockid (log_Gl.hdr.mvcc_op_log_lsa.pageid) != vacuum_get_log_blockid (start_lsa.pageid))
{ // ... assert block-id advances monotonically ...
vacuum_produce_log_block_data (thread_p); } /* <- different block: hand the closed one to vacuum */
}

Fires only when fully restarted and a prior MVCC record dirtied the block.

An if/else-if chain on node->log_header.type — exactly one arm runs, most records stamp nothing. These types record recovery state atomically with the LSN assignment, which is why the stamps live inside prior_lsa_mutex. The table enumerates every arm; MVCC and LOG_SYSOP_END get excerpts for their non-obvious shape.

MVCC arm. The guard fires for the three MVCC data types or a LOG_SYSOP_END of sub-type LOG_SYSOP_END_LOGICAL_MVCC_UNDO. A three-way internal dispatch then picks the embedded struct:

// prior_lsa_next_record_internal -- src/transaction/log_append.cpp
if (node->log_header.type == LOG_MVCC_UNDO_DATA)
{ mvcc_undo = (LOG_REC_MVCC_UNDO *) node->data_header; } /* <- top-level undo cast */
else if (node->log_header.type == LOG_SYSOP_END)
{ mvcc_undo = & ((LOG_REC_SYSOP_END *) node->data_header)->mvcc_undo; } /* <- NESTED member, not a cast */
else /* UNDOREDO / DIFF_UNDOREDO */
{ mvcc_undoredo = (LOG_REC_MVCC_UNDOREDO *) node->data_header; } /* <- undoredo cast */
// vacuum_info and mvccid read from the selected struct, then:
LSA_COPY (&vacuum_info->prev_mvcc_op_log_lsa, &log_Gl.hdr.mvcc_op_log_lsa); /* <- chain to prior MVCC op */
prior_update_header_mvcc_info (start_lsa, mvccid); /* <- advance global MVCC head */

Undo and undoredo/diff cast data_header directly, but LOG_SYSOP_END reads a mvcc_undo embedded inside the record — not a top-level cast. All three then link prev_mvcc_op_log_lsa and call the head update.

Sysop / commit / abort arms stamp recovery state into tdes under the mutex so a concurrent checkpoint sees it consistent:

typeActionWhy under the mutex
LOG_SYSOP_START_POSTPONEset sysop_start_postpone_lsa = start_lsa; clear atomic_sysop_start_lsa if finished; set state = TRAN_UNACTIVE_TOPOPE_COMMITTED_WITH_POSTPONELSA + state seen together
LOG_SYSOP_ENDtwo independent guarded clears (code below): null atomic_sysop_start_lsa, then sysop_start_postpone_lsa, each only if its slot is set and lastparent_lsa < itUnwind nesting atomically
LOG_COMMIT_WITH_POSTPONE / _OBSOLETEtran_start_postpone_lsa = start_lsaRecovery resumes here
LOG_SYSOP_ATOMIC_STARTatomic_sysop_start_lsa = start_lsaAtomic region boundary
LOG_COMMIT / LOG_ABORTcommit_abort_lsa = start_lsaThe decisive WAL point

The LOG_SYSOP_END arm is two separate ifs (not one combined test):

// prior_lsa_next_record_internal -- src/transaction/log_append.cpp
if (!LSA_ISNULL (&tdes->rcv.atomic_sysop_start_lsa)
&& LSA_LT (&sysop_end->lastparent_lsa, &tdes->rcv.atomic_sysop_start_lsa))
{ LSA_SET_NULL (&tdes->rcv.atomic_sysop_start_lsa); } /* <- clear #1, guarded by its own slot */
if (!LSA_ISNULL (&tdes->rcv.sysop_start_postpone_lsa)
&& LSA_LT (&sysop_end->lastparent_lsa, &tdes->rcv.sysop_start_postpone_lsa))
{ LSA_SET_NULL (&tdes->rcv.sysop_start_postpone_lsa); } /* <- clear #2, independent guard */

The asserts in the other arms (e.g. commit_abort_lsa.is_null ()) state each slot is written once per phase; a double-write is a malformed transaction.

5.6 prior_update_header_mvcc_info — the global MVCC head

Section titled “5.6 prior_update_header_mvcc_info — the global MVCC head”
// prior_update_header_mvcc_info -- src/transaction/log_append.cpp
assert (MVCCID_IS_VALID (mvccid));
if (!log_Gl.hdr.does_block_need_vacuum) /* <- first MVCC record of this block */
{ log_Gl.hdr.oldest_visible_mvccid = log_Gl.mvcc_table.get_global_oldest_visible ();
log_Gl.hdr.newest_block_mvccid = mvccid; }
else /* <- same block: track max */
{ /* ... sanity asserts ... */
if (log_Gl.hdr.newest_block_mvccid < mvccid) { log_Gl.hdr.newest_block_mvccid = mvccid; } }
log_Gl.hdr.mvcc_op_log_lsa = record_lsa; /* <- this record is now latest MVCC op */
log_Gl.hdr.does_block_need_vacuum = true; /* <- re-arms the flag Section 5.4 reads next */

5.7 Advancing past data_header, then udata, then rdata

Section titled “5.7 Advancing past data_header, then udata, then rdata”

The cursor advances over the three regions in on-disk order — the header via the fit/align pair, then the variable regions via prior_lsa_append_data:

// prior_lsa_next_record_internal -- src/transaction/log_append.cpp
log_prior_lsa_append_advance_when_doesnot_fit (node->data_header_length);
log_prior_lsa_append_add_align (node->data_header_length);
if (node->ulength > 0) { prior_lsa_append_data (node->ulength); }
if (node->rlength > 0) { prior_lsa_append_data (node->rlength); }

The three primitives (prior_lsa = log_Gl.prior_info.prior_lsa):

// log_prior_lsa_append_align -- src/transaction/log_append.cpp
prior_lsa.offset = DB_ALIGN (prior_lsa.offset, DOUBLE_ALIGNMENT);
if ((size_t) prior_lsa.offset >= (size_t) LOGAREA_SIZE) { prior_lsa.pageid++; prior_lsa.offset = 0; } /* <- align rolled page */
// log_prior_lsa_append_advance_when_doesnot_fit -- src/transaction/log_append.cpp
if ((size_t) prior_lsa.offset + length >= (size_t) LOGAREA_SIZE) { prior_lsa.pageid++; prior_lsa.offset = 0; } /* <- push chunk forward */
// log_prior_lsa_append_add_align -- src/transaction/log_append.cpp
prior_lsa.offset += (add); log_prior_lsa_append_align (); /* <- advance then re-align */

LOG_PRIOR_LSA_LAST_APPEND_OFFSET returns LOGAREA_SIZE. Unlike the header, variable payloads may span pages, so prior_lsa_append_data carries a page-spanning loop:

// prior_lsa_append_data -- src/transaction/log_append.cpp
if (length == 0) { return; } /* <- nothing to advance */
log_prior_lsa_append_align ();
current_offset = prior_lsa.offset; last_offset = LOG_PRIOR_LSA_LAST_APPEND_OFFSET ();
if ((current_offset + length) >= last_offset) /* <- crosses >=1 page boundary */
while (length > 0)
{
if (current_offset >= last_offset) /* <- at page end: roll, reset offsets */
{ prior_lsa.pageid++; current_offset = 0; /* ... offset=0 ... */ }
copy_length = (current_offset + length >= last_offset)
? CAST_BUFLEN (last_offset - current_offset) /* <- rest of page */
: length; /* <- final partial chunk */
current_offset += copy_length; length -= copy_length; prior_lsa.offset += copy_length;
}
else { prior_lsa.offset += length; } /* <- fits in page, single bump */
log_prior_lsa_append_align ();

Despite the name it copies no bytes — it only moves the cursor so prior_lsa ends one past the last byte of rdata (the copy is Chapter 7).

5.8 prior_lsa_end_append, splicing the tail, and list_size

Section titled “5.8 prior_lsa_end_append, splicing the tail, and list_size”
// prior_lsa_end_append -- src/transaction/log_append.cpp
log_prior_lsa_append_align ();
log_prior_lsa_append_advance_when_doesnot_fit (sizeof (LOG_RECORD_HEADER)); /* <- next record's header must fit */
LSA_COPY (&node->log_header.forw_lsa, &log_Gl.prior_info.prior_lsa); /* <- forward link = next record's start */

forw_lsa is the address the next record will get; advance_when_doesnot_fit first means it accounts for any page roll the next header needs. Then the splice and size bump:

// prior_lsa_next_record_internal -- src/transaction/log_append.cpp
if (log_Gl.prior_info.prior_list_tail == NULL) /* <- empty list */
{ log_Gl.prior_info.prior_list_header = node; log_Gl.prior_info.prior_list_tail = node; }
else /* <- append to tail */
{ log_Gl.prior_info.prior_list_tail->next = node; log_Gl.prior_info.prior_list_tail = node; }
log_Gl.prior_info.list_size += (sizeof (LOG_PRIOR_NODE) + node->data_header_length + node->ulength + node->rlength);

node->next was already null from the build phase, so the node is terminal in either branch. list_size accrues the in-memory footprint (node struct + three buffers) — the figure the drain daemon and Section 5.9 compare against the memory ceiling.

5.9 Backpressure tail-block and the two consequences

Section titled “5.9 Backpressure tail-block and the two consequences”

A guarded backpressure block runs at the end of the WITHOUT_LOCK path after the mutex is released (Chapter 9); _with_lock callers skip it.

On return, two facts hold: (1) the returned start_lsa is the record’s final on-disk LSN — assigned from the monotonic cursor under the mutex, never changing, so callers store it as the durable address; and (2) the producer has no further WAL work — copy and flush belong to the drain side (Chapters 6–8).

  1. prior_lsa_next_record_internal is the single serialization point; prior_lsa_mutex totally orders LSN assignment and list attachment, giving non-overlapping LSAs and a start_lsa-ascending list = flush order.
  2. with_lock separates the two wrappersprior_lsa_next_record manages the mutex and runs backpressure; _with_lock assumes a caller-held mutex and skips both.
  3. prior_lsa_start_append assigns the LSN and maintains two chains — the always-on physical chain (back_lsa/forw_lsa) and the transaction chain, nulled for non-sysop system workers, chained else.
  4. The type dispatch stamps recovery state atomically with the LSN — note the MVCC arm’s three-way dispatch (LOG_SYSOP_END reads a nested mvcc_undo) and that arm’s two independent guarded clears.
  5. Cursor advance copies no bytes — fit/align/add-align and prior_lsa_append_data’s page-spanning loop only move prior_lsa; the copy is Chapter 7.
  6. prior_lsa_end_append and the splice finalize the nodeforw_lsa completes the physical chain, the empty-vs-append branch adds it to the tail, list_size accrues its footprint.
  7. On return the LSN is final and the producer is done — flushing is Chapters 6–8, backpressure is Chapter 9.

Chapter 6: Detaching the List on the Drain Side

Section titled “Chapter 6: Detaching the List on the Drain Side”

Chapters 3-5 followed a producer thread: it built a node outside the mutex, then under prior_lsa_mutex stamped the node’s LSN and linked it onto the tail of the shared singly-linked list rooted at log_Gl.prior_info. This chapter crosses to the consumer side and answers one question: when the flush runs, how does it take the producer-built list away from the producers without copying a single byte and without racing an in-flight append?

The answer is a two-function pair that is deliberately tiny. The outer function logpb_prior_lsa_append_all_list holds the coarse LOG_CS critical section, briefly grabs prior_lsa_mutex, snapshots the byte count, and calls the inner prior_lsa_remove_prior_list, which is nothing more than three pointer stores and one integer store. After the swap the detached chain is a private, single-owner list — no other thread can see it — so everything downstream (Chapter 7’s copy into the authoritative log pages) runs lock-free. See cubrid-prior-list §“Producer/consumer split” for why the design splits work this way; this chapter traces the handover at the pointer level.

We introduced log_prior_lsa_info fully in Chapter 1 and do not re-derive it. Only four of its fields participate in the drain. They are reproduced here verbatim for local reference; the role matrix is the authoritative copy in Chapter 1.

// log_prior_lsa_info -- src/transaction/log_append.hpp
struct log_prior_lsa_info
{
LOG_LSA prior_lsa;
LOG_LSA prev_lsa;
/* list */
LOG_PRIOR_NODE *prior_list_header;
LOG_PRIOR_NODE *prior_list_tail;
INT64 list_size; /* bytes */
/* flush list */
LOG_PRIOR_NODE *prior_flush_list_header;
std::mutex prior_lsa_mutex;
log_prior_lsa_info ();
};
FieldRole in the drainWhy it exists
prior_list_headerThe value returned to the caller; reset to NULL so the next producer starts a fresh list.The drain’s entire payload is this one pointer — the whole chain travels by reading it once.
prior_list_tailReset to NULL so the next append (Ch.5) re-initializes both ends.Without resetting it, a later prior_lsa_next_record would splice onto a node now owned by the flusher (use-after-handover).
list_sizeSnapshotted into current_size before the swap, then zeroed.Drives the PSTAT_PRIOR_LSA_LIST_SIZE perfmon counter in kbytes; zeroing restarts the backpressure accounting (Ch.9).
prior_lsa_mutexHeld only across the snapshot read plus the swap, not across the copy.Keeps the producer/consumer hand-off atomic while keeping the lock-hold window O(1).

Invariant (single-owner after detach). After prior_lsa_remove_prior_list returns, the chain reachable from the returned prior_list pointer is reachable from nowhere else: prior_list_header and prior_list_tail are both NULL, so no producer can splice onto it and no second drain can observe it. The code enforces this by doing all three resets under prior_lsa_mutex in the same locked region that reads the head. If violated — e.g. if prior_list_tail were left pointing into the detached chain — a concurrent prior_lsa_next_record would link a new node onto a list the flusher is already walking, corrupting the log stream.

6.2 logpb_prior_lsa_append_all_list — the drain entry point

Section titled “6.2 logpb_prior_lsa_append_all_list — the drain entry point”

This is the only public symbol on the drain side (extern in log_impl.h). It is short enough to quote whole:

// logpb_prior_lsa_append_all_list -- src/transaction/log_page_buffer.c
int
logpb_prior_lsa_append_all_list (THREAD_ENTRY * thread_p)
{
LOG_PRIOR_NODE *prior_list;
INT64 current_size;
assert (LOG_CS_OWN_WRITE_MODE (thread_p));
log_Gl.prior_info.prior_lsa_mutex.lock ();
current_size = log_Gl.prior_info.list_size;
prior_list = prior_lsa_remove_prior_list (thread_p);
log_Gl.prior_info.prior_lsa_mutex.unlock ();
if (prior_list != NULL)
{
perfmon_add_stat (thread_p, PSTAT_PRIOR_LSA_LIST_SIZE, (unsigned int) current_size / ONE_K); /* kbytes */
perfmon_inc_stat (thread_p, PSTAT_PRIOR_LSA_LIST_REMOVED);
logpb_append_prior_lsa_list (thread_p, prior_list);
}
return NO_ERROR;
}

Branch-complete walkthrough — there are only two branches, but both matter:

  1. Precondition assert. LOG_CS_OWN_WRITE_MODE (thread_p) (declared in log_manager.h) asserts the caller already holds the coarse LOG_CS critical section in write mode. This is a debug-build guard, not a lock acquisition: the function never takes LOG_CS itself, it trusts the caller. Every one of the callers in §6.4 wraps the call in LOG_CS_ENTER … LOG_CS_EXIT.

  2. Inner lock + snapshot + detach. prior_lsa_mutex.lock() is taken, list_size is copied into the local current_size while the lock is held (so the snapshot is consistent with the chain being detached), then prior_lsa_remove_prior_list performs the swap and returns the old head. The mutex is released immediately after — the lock-hold window is exactly the snapshot read plus four stores, never the byte copy.

  3. Empty-list branch (prior_list == NULL). If no producer appended anything since the last drain, the detached head is NULL. The function skips both perfmon updates and the copy entirely and returns NO_ERROR. This is the common case for an idle server and keeps the flush daemon’s wakeups cheap.

  4. Non-empty branch (prior_list != NULL). Two perfmon statistics fire, then the copy. PSTAT_PRIOR_LSA_LIST_SIZE accumulates current_size / ONE_K — the snapshot converted to kilobytes (integer division, so a sub-1 KB drain records 0). PSTAT_PRIOR_LSA_LIST_REMOVED is a simple counter of how many non-empty drains have occurred. Only after the counters are updated does control pass to logpb_append_prior_lsa_list (Chapter 7), which walks the now-private chain and copies each node into the authoritative log pages. The return value of that call is ignored here — logpb_prior_lsa_append_all_list always returns NO_ERROR; copy-side failures escalate through logpb_fatal_error inside the copy path, not via this return code.

flowchart TD
  A["logpb_prior_lsa_append_all_list"] --> B["assert LOG_CS_OWN_WRITE_MODE"]
  B --> C["prior_lsa_mutex.lock"]
  C --> D["current_size = list_size  (snapshot)"]
  D --> E["prior_list = prior_lsa_remove_prior_list"]
  E --> F["prior_lsa_mutex.unlock"]
  F --> G{"prior_list != NULL ?"}
  G -- "no, idle" --> H["return NO_ERROR"]
  G -- "yes" --> I["perfmon_add PRIOR_LSA_LIST_SIZE = current_size/ONE_K kB"]
  I --> J["perfmon_inc PRIOR_LSA_LIST_REMOVED"]
  J --> K["logpb_append_prior_lsa_list  -> Ch.7 copy"]
  K --> H

Figure 6-1. Control flow of the drain entry point. The inner lock spans only C-F; the copy at K runs unlocked.

6.3 prior_lsa_remove_prior_list — the minimal swap

Section titled “6.3 prior_lsa_remove_prior_list — the minimal swap”

The inner function does the actual handover. It is static and assumes both locks are already held — it asserts the outer one and silently relies on the caller holding the inner one:

// prior_lsa_remove_prior_list -- src/transaction/log_page_buffer.c
static LOG_PRIOR_NODE *
prior_lsa_remove_prior_list (THREAD_ENTRY * thread_p)
{
LOG_PRIOR_NODE *prior_list;
assert (LOG_CS_OWN_WRITE_MODE (thread_p));
prior_list = log_Gl.prior_info.prior_list_header;
log_Gl.prior_info.prior_list_header = NULL;
log_Gl.prior_info.prior_list_tail = NULL;
log_Gl.prior_info.list_size = 0;
return prior_list;
}

There is no loop and no branch — the function has a single path. It reads prior_list_header into a local, then performs exactly four stores:

  • prior_list_header = NULL — the next producer’s append will see a NULL head and re-initialize the list (Chapter 5’s if (prior_list_tail == NULL) branch fires).
  • prior_list_tail = NULL — re-armed for the same re-init.
  • list_size = 0 — restarts byte accounting from zero.

Counting the stores precisely matters for the design claim: three pointer stores and one INT64 store, zero bytes of payload moved. The entire log record chain — which may be megabytes — changes ownership by reassigning a single head pointer. The thread_p argument exists only for the LOG_CS_OWN_WRITE_MODE assert; it is otherwise unused. The prev_lsa, prior_lsa, and prior_flush_list_header fields of log_prior_lsa_info are deliberately not touched here — prior_lsa/prev_lsa track the running LSN cursors owned by the producers (Chapter 5) and must survive across drains; prior_flush_list_header belongs to a different list and is irrelevant to this swap.

stateDiagram-v2
  [*] --> Shared
  Shared --> Detached: drain takes prior_lsa_mutex \n reads header, then 4 stores
  note right of Shared
    header -> n1 -> n2 -> ... -> tail
    list_size = sum(bytes)
    visible to all producers
  end note
  note right of Detached
    header = NULL, tail = NULL, size = 0
    returned prior_list -> n1 -> ... -> tail
    visible to flusher ONLY
  end note
  Detached --> Shared: next producer append re-inits header and tail

Figure 6-2. The list’s ownership state before and after the swap. The chain’s internal links are never rewritten — only the roots in log_prior_lsa_info move.

6.4 The two-lock ordering and the three drain callers

Section titled “6.4 The two-lock ordering and the three drain callers”

Invariant (lock ordering: outer LOG_CS first, inner prior_lsa_mutex second, briefly). Every path that reaches the drain holds LOG_CS in write mode before entering, and the drain takes prior_lsa_mutex inside that region and releases it before the copy. The nesting is always LOG_CS then prior_lsa_mutex, never the reverse. If violated — if some path took prior_lsa_mutex and then tried to enter LOG_CS — it would invert the order against the producer-then-flusher discipline and risk deadlock with a thread holding LOG_CS waiting on prior_lsa_mutex. The producer side (Chapter 5) only ever holds prior_lsa_mutex and never reaches up for LOG_CS while holding it, so the partial order is total.

This split is also why detach is kept distinct from copy: prior_lsa_mutex is contended by every committing transaction on the producer side (Ch.5 takes it to stamp each record’s LSN). Holding it across the multi-page byte copy would stall every producer; detaching first releases it in O(1), so producers immediately resume building a fresh list while the flusher copies the old, now-private one with no lock at all.

Three drain paths matter for this chapter, and each is wrapped in its own LOG_CS_ENTER/LOG_CS_EXIT (or asserts ownership). Pointer-level enumeration:

  1. Log-flush daemon. log_flush_execute (log_manager.c) calls LOG_CS_ENTER, then logpb_flush_pages_direct (log_page_buffer.c), which in turn calls logpb_prior_lsa_append_all_list. This is the steady-state path: a producer raises log_Flush_has_been_requested, wakes log_Flush_daemon, and the daemon drains then flushes. The daemon body itself — the wait loop, the group-commit broadcast after the flush — is Chapter 8; here we only note that it is the entry that calls the drain.

    // log_flush_execute -- src/transaction/log_manager.c
    LOG_CS_ENTER (&thread_ref);
    logpb_flush_pages_direct (&thread_ref);
    LOG_CS_EXIT (&thread_ref);
  2. Direct flush. logpb_flush_pages_direct (log_page_buffer.c) is also called synchronously by transactions that cannot wait for the daemon — single-process mode, server not yet restarted, daemon unavailable, or an explicit logpb_flush_pages fallback. Its first statement after the LOG_CS_OWN_WRITE_MODE assert is the drain:

    // logpb_flush_pages_direct -- src/transaction/log_page_buffer.c
    assert (LOG_CS_OWN_WRITE_MODE (thread_p));
    logpb_prior_lsa_append_all_list (thread_p);
    (void) logpb_flush_all_append_pages (thread_p);

    Both caller 1 and caller 2 funnel through this same function — the daemon just reaches it via log_flush_execute. Direct callers of logpb_flush_pages_direct already hold LOG_CS.

  3. Backpressure / partial-record self-help flush. Two producer-side paths reach the drain directly rather than through logpb_flush_pages_direct:

    • prior_lsa_next_record_internal (log_append.cpp): when the list grows past logpb_get_memsize() and the daemon path is unavailable (crash recovery, or SA_MODE), the producer self-drains — LOG_CS_ENTER, then logpb_prior_lsa_append_all_list, then LOG_CS_EXIT. This is the backpressure valve detailed in Chapter 9.

      // prior_lsa_next_record_internal -- src/transaction/log_append.cpp
      LOG_CS_ENTER (thread_p);
      logpb_prior_lsa_append_all_list (thread_p);
      LOG_CS_EXIT (thread_p);
    • heap_get_visible_version_from_log (heap_file.c): when reading a prior MVCC version whose prev_version_lsa has not yet been flushed out of the prior list, the reader forces a drain so the version becomes fetchable — LOG_CS_ENTER, then logpb_prior_lsa_append_all_list, then LOG_CS_EXIT, then re-reads the append LSA and asserts the version is now durable.

All three honor the same nesting, and all three reach the identical swap in prior_lsa_remove_prior_list. The drain is intentionally caller-agnostic: it neither knows nor cares why it was invoked.

Footnote (out of scope). A fourth direct call site exists beyond the three above: logpb_fetch_page (log_page_buffer.c) self-drains inside its own LOG_CS_ENTER/LOG_CS_EXIT region before fetching a log page, ensuring the requested page reflects any not-yet-copied prior records. It reaches the identical swap but is deferred as out of this chapter’s scope.

  1. The drain is two functions. logpb_prior_lsa_append_all_list is the public entry (asserts LOG_CS, takes prior_lsa_mutex, snapshots list_size, calls the swap, releases the mutex, does perfmon + copy); prior_lsa_remove_prior_list is the swap itself.
  2. The swap moves no bytes. It is exactly three pointer stores (prior_list_header, prior_list_tail set to NULL) and one INT64 store (list_size = 0), returning the old head. A megabyte chain changes owner by reassigning one pointer.
  3. Two branches only in the entry function. Empty list skips perfmon and copy and returns NO_ERROR; non-empty records PRIOR_LSA_LIST_SIZE (kB) and PRIOR_LSA_LIST_REMOVED, then hands to logpb_append_prior_lsa_list.
  4. Single-owner invariant. After the swap the detached chain is reachable from nowhere in log_prior_lsa_info, so the downstream copy (Chapter 7) runs lock-free and no producer can splice onto it.
  5. Lock ordering is LOG_CS then prior_lsa_mutex, always. The drain trusts the caller to hold LOG_CS (debug-asserted) and takes the inner mutex only for the O(1) swap, never across the copy — keeping producer stalls minimal.
  6. Three callers, one swap. The log-flush daemon (via log_flush_execute then logpb_flush_pages_direct), the direct/synchronous flush (logpb_flush_pages_direct), and the self-help backpressure / partial-record paths (prior_lsa_next_record_internal, heap_get_visible_version_from_log) all converge on the same detach. The daemon’s body and the copy/flush they trigger are Chapters 8 and 7.

Chapter 7: Copying Nodes into the Authoritative Log Pages

Section titled “Chapter 7: Copying Nodes into the Authoritative Log Pages”

The drain side has detached the producer queue under prior_lsa_mutex (Chapter 6) and holds a private singly-linked list of LOG_PRIOR_NODEs no other thread can see. What remains is the step producers were forbidden from doing: moving record bytes out of the malloc’d nodes into the authoritative LOG_PAGE ring buffer, in LSN order, under LOG_CS_OWN_WRITE_MODE. This is the queue-to-page half of the two-stage split in the companion (cubrid-prior-list.md §“Flush — making it durable”); page-to-disk is Chapter 8.

The reader question: how do the bytes in the detached nodes finally land in the real LOG_PAGE buffer, and when is each node freed? We trace logpb_append_prior_lsa_list (walk-and-free) and logpb_append_next_record (per-node copy), then its primitives logpb_start_append / logpb_append_data / logpb_end_append, and the page-boundary crossing logpb_next_append_page.

7.1 The walk-and-free loop — logpb_append_prior_lsa_list

Section titled “7.1 The walk-and-free loop — logpb_append_prior_lsa_list”

The detached list is handed to logpb_append_prior_lsa_list, which stages it at a second global, prior_flush_list_header, and consumes it node by node:

// logpb_append_prior_lsa_list -- src/transaction/log_page_buffer.c
static int
logpb_append_prior_lsa_list (THREAD_ENTRY * thread_p, LOG_PRIOR_NODE * list)
{
LOG_PRIOR_NODE *node;
assert (LOG_CS_OWN_WRITE_MODE (thread_p)); /* <- outer log CS held */
/* append prior_flush_list */
assert (log_Gl.prior_info.prior_flush_list_header == NULL); /* <- single-drain guard */
log_Gl.prior_info.prior_flush_list_header = list; /* <- stage under a named global */
while (log_Gl.prior_info.prior_flush_list_header != NULL)
{
node = log_Gl.prior_info.prior_flush_list_header;
log_Gl.prior_info.prior_flush_list_header = node->next; /* <- advance BEFORE copy */
logpb_append_next_record (thread_p, node); /* <- copy bytes into LOG_PAGE */
if (node->data_header != NULL) { free_and_init (node->data_header); }
if (node->udata != NULL) { free_and_init (node->udata); }
if (node->rdata != NULL) { free_and_init (node->rdata); }
free_and_init (node); /* <- node and its three buffers reclaimed here */
}
return NO_ERROR;
}

prior_flush_list_header is a contract/debug marker, not merely a local walk cursor. A full-tree grep finds no concurrent reader (only the declaration, the initializer, and the five uses inside this function). The global instead buys (a) the entry assert(prior_flush_list_header == NULL), which checks the single-drain contract, and (b) visibility — the mid-drain list is named in log_Gl, so a core dump shows which records were being copied at the time. The header advances to node->next before the copy.

Invariant — at most one drain runs at a time. The entry assert (prior_flush_list_header == NULL) is the in-process enforcement point; the invariant is upheld structurally by LOG_CS_OWN_WRITE_MODE — only the write-mode holder of the log critical section reaches this function (daemon, self-help drain, or forced flush; see cubrid-prior-list.md §“The drain side”). If violated, the second assignment would overwrite the header, orphan the first list, and double-drive the page cursor, tripping the §7.2 start_lsa == append_lsa assertion.

Each node is freed immediately after its bytes are copied. No node pool; the three payload buffers and the node go to the C allocator via free_and_init, each guarded by a non-NULL check mirroring the conditional copy in §7.2. The cross-thread malloc/free asymmetry (producer mallocs, drain frees) is Open Question 3 in the companion.

flowchart TD
  A["enter: list = detached prior list\nassert prior_flush_list_header == NULL"] --> B["prior_flush_list_header = list"]
  B --> C{"prior_flush_list_header != NULL ?"}
  C -- no --> Z["return NO_ERROR"]
  C -- yes --> D["node = prior_flush_list_header\nprior_flush_list_header = node->next"]
  D --> E["logpb_append_next_record(node)\ncopy bytes into LOG_PAGE"]
  E --> F["free data_header / udata / rdata\nif non-NULL"]
  F --> G["free_and_init(node)"]
  G --> C

Figure 7-1 — The walk-and-free loop. The staged-list header is a named global for the single-drain assert and debugger visibility; each node is freed the instant its bytes are in the page buffer.

7.2 Copying one node — logpb_append_next_record

Section titled “7.2 Copying one node — logpb_append_next_record”

Where node bytes meet the authoritative pages — short, but every branch matters here:

// logpb_append_next_record -- src/transaction/log_page_buffer.c
static int
logpb_append_next_record (THREAD_ENTRY * thread_p, LOG_PRIOR_NODE * node)
{
if (!LSA_EQ (&node->start_lsa, &log_Gl.hdr.append_lsa)) /* <- order sanity, fatal */
{ logpb_fatal_error (thread_p, true, ARG_FILE_LINE, "logpb_append_next_record"); }
/* forcing flush in the middle of log record append is a complicated business. */
if (log_Gl.flush_info.num_toflush + 1 >= log_Gl.flush_info.max_toflush) /* <- pre-flush guard */
{ logpb_flush_all_append_pages (thread_p); } /* <- empty the toflush array first */
log_Gl.append.appending_page_tde_encrypted = prior_is_tde_encrypted (node); /* <- stamp TDE intent */
logpb_start_append (thread_p, &node->log_header); /* <- write the LOG_RECORD_HEADER */
if (node->data_header != NULL)
{
LOG_APPEND_ADVANCE_WHEN_DOESNOT_FIT (thread_p, node->data_header_length); /* <- keep header whole */
logpb_append_data (thread_p, node->data_header_length, node->data_header);
}
if (node->udata != NULL) { logpb_append_data (thread_p, node->ulength, node->udata); }
if (node->rdata != NULL) { logpb_append_data (thread_p, node->rlength, node->rdata); }
logpb_end_append (thread_p, &node->log_header); /* <- finalize, validate forw_lsa */
log_Gl.append.appending_page_tde_encrypted = false; /* <- reset TDE intent */
return NO_ERROR;
}

The start_lsa == append_lsa assertion is the load-bearing check of the whole drain. node->start_lsa was stamped by the producer under prior_lsa_mutex (Chapter 5); log_Gl.hdr.append_lsa is the page-side cursor where the next byte lands. A mismatch — queue and page buffer desynchronised — fires logpb_fatal_error; there is no recovery branch.

Invariant — page cursor tracks prior-list LSNs exactly. For every node, node->start_lsa == log_Gl.hdr.append_lsa on entry and header->forw_lsa == log_Gl.hdr.append_lsa on exit (§7.3); the two asserts bracket the copy in lock-step. Maintained because the producer computed start_lsa/forw_lsa with the same {pageid, offset} arithmetic the page cursor uses (log_prior_lsa_append_* in log_append.cpp) and the drain processes nodes in attach order. If violated, an on-disk record’s self-described LSA would not match its physical position, breaking recovery’s forw_lsa/back_lsa chains.

The pre-flush guard (num_toflush + 1 >= max_toflush) flushes toflush[] now, before starting this record (logpb_flush_all_append_pages, Chapter 8). Forcing a flush mid-record is “a complicated business” — it triggers the LOGPB_APPENDREC_STATUS partial-record dance; draining between records keeps the common case free of it.

The remaining branches: appending_page_tde_encrypted is stamped so a page created mid-record (§7.5) is encrypted, reset at the end. The three logpb_append_data calls fire only for non-NULL buffers in on-disk order data_header → udata → rdata, and only the data header gets the LOG_APPEND_ADVANCE_WHEN_DOESNOT_FIT pre-advance keeping it whole on one page (logpb_append_data itself spans udata/rdata, §7.4). The function always returns NO_ERROR; data-path failures escalate to logpb_fatal_error.

7.3 Writing the header and closing the record

Section titled “7.3 Writing the header and closing the record”

logpb_start_append writes the fixed LOG_RECORD_HEADER into the page and opens the record:

// logpb_start_append -- src/transaction/log_page_buffer.c
static void
logpb_start_append (THREAD_ENTRY * thread_p, LOG_RECORD_HEADER * header)
{
LOG_RECORD_HEADER *log_rec;
/* ... condensed: assert LOG_CS write mode, perfmon_inc_stat ... */
LOG_APPEND_ADVANCE_WHEN_DOESNOT_FIT (thread_p, sizeof (LOG_RECORD_HEADER)); /* <- header stays whole */
if (!LSA_EQ (&header->back_lsa, &log_Gl.append.prev_lsa)) /* <- back-link sanity, fatal */
{ logpb_fatal_error (thread_p, true, ARG_FILE_LINE, "logpb_start_append"); }
/* ... condensed: assert log_pgptr != NULL, set TDE algorithm on page if appending_page_tde_encrypted ... */
log_rec = (LOG_RECORD_HEADER *) LOG_APPEND_PTR (); /* <- page ptr at append_lsa.offset */
*log_rec = *header; /* <- struct copy header into page */
if (log_Gl.append.log_pgptr->hdr.offset == NULL_OFFSET) /* <- first record on page? */
{ log_Gl.append.log_pgptr->hdr.offset = (PGLENGTH) log_Gl.hdr.append_lsa.offset; }
if (log_rec->type == LOG_END_OF_LOG) /* <- EOF marker, flush only; no IN_PROGRESS */
{
/* this comes from logpb_flush_all_append_pages */
assert (log_Pb.partial_append.status == LOGPB_APPENDREC_SUCCESS
|| log_Pb.partial_append.status == LOGPB_APPENDREC_PARTIAL_ENDED); /* <- two legal prestates */
LSA_COPY (&log_Gl.hdr.eof_lsa, &log_Gl.hdr.append_lsa);
logpb_set_dirty (thread_p, log_Gl.append.log_pgptr);
}
else
{
assert (log_Pb.partial_append.status == LOGPB_APPENDREC_SUCCESS); /* <- no record open */
LSA_COPY (&log_Gl.append.prev_lsa, &log_Gl.hdr.append_lsa); /* <- this LSA -> prev */
LOG_APPEND_SETDIRTY_ADD_ALIGN (thread_p, sizeof (LOG_RECORD_HEADER)); /* <- past header */
log_Pb.partial_append.status = LOGPB_APPENDREC_IN_PROGRESS; /* <- open record */
}
}

Branches: the advance macro crosses to a fresh page (§7.5) so the header never splits; the fatal back_lsa == prev_lsa check ties header->back_lsa to the physically preceding record’s LSA; first-record-on-page records hdr.offset so recovery can locate where records begin. The LOG_END_OF_LOG branch — the EOF marker written only by the flusher (Chapter 8) — legitimately follows either LOGPB_APPENDREC_SUCCESS (a completed record) or LOGPB_APPENDREC_PARTIAL_ENDED (the post-mid-record-flush path), then copies append_lsa into eof_lsa and dirties the page without opening a record. The normal branch records this LSA as prev_lsa, advances past the header, and opens IN_PROGRESS.

logpb_end_append closes the record:

// logpb_end_append -- src/transaction/log_page_buffer.c
static void
logpb_end_append (THREAD_ENTRY * thread_p, LOG_RECORD_HEADER * header)
{
LOG_APPEND_ALIGN (thread_p, LOG_DONT_SET_DIRTY);
LOG_APPEND_ADVANCE_WHEN_DOESNOT_FIT (thread_p, sizeof (LOG_RECORD_HEADER)); /* <- next hdr fits */
assert (LSA_EQ (&header->forw_lsa, &log_Gl.hdr.append_lsa)); /* <- forw_lsa points to next record */
if (!LSA_EQ (&log_Gl.append.prev_lsa, &log_Gl.hdr.append_lsa))
{ logpb_set_dirty (thread_p, log_Gl.append.log_pgptr); }
if (log_Pb.partial_append.status == LOGPB_APPENDREC_IN_PROGRESS) { /* normal: fall through */ }
else if (log_Pb.partial_append.status == LOGPB_APPENDREC_PARTIAL_FLUSHED_END_OF_LOG)
{
log_Pb.partial_append.status = LOGPB_APPENDREC_PARTIAL_ENDED;
logpb_flush_all_append_pages (thread_p); /* <- re-flush the now-complete record */
assert (log_Pb.partial_append.status == LOGPB_APPENDREC_PARTIAL_FLUSHED_ORIGINAL); /* <- re-flush left it here */
}
else { assert_release (false); } /* <- invalid state */
log_Pb.partial_append.status = LOGPB_APPENDREC_SUCCESS;
}

It ensures the next header would fit, so append_lsa points where the next record begins. The forw_lsa == append_lsa assertion closes the lock-step bracket opened by the §7.2 start_lsa check. The status switch is normally IN_PROGRESS; PARTIAL_FLUSHED_END_OF_LOG is reached only after a mid-record forced flush (Chapter 8) and re-flushes the completed record; anything else is assert_release(false). Status returns to SUCCESS.

7.4 Copying payload bytes — logpb_append_data

Section titled “7.4 Copying payload bytes — logpb_append_data”

logpb_append_data is the byte mover — the only place in the drain that spans a payload across page boundaries:

// logpb_append_data -- src/transaction/log_page_buffer.c
static void
logpb_append_data (THREAD_ENTRY * thread_p, int length, const char *data)
{
int copy_length;
char *ptr, *last_ptr;
if (length == 0 || data == NULL) { return; } /* <- nothing to copy */
LOG_APPEND_ALIGN (thread_p, LOG_DONT_SET_DIRTY);
ptr = LOG_APPEND_PTR (); /* <- write cursor in page */
last_ptr = LOG_LAST_APPEND_PTR (); /* <- end of usable area */
if ((ptr + length) >= last_ptr) /* <- payload spans page boundary */
{
while (length > 0)
{
if (ptr >= last_ptr) /* <- page exhausted: cross, dirty old page */
{
logpb_next_append_page (thread_p, LOG_SET_DIRTY);
ptr = LOG_APPEND_PTR (); last_ptr = LOG_LAST_APPEND_PTR ();
}
copy_length = (ptr + length >= last_ptr) ? CAST_BUFLEN (last_ptr - ptr) : length;
memcpy (ptr, data, copy_length);
ptr += copy_length; data += copy_length; length -= copy_length;
log_Gl.hdr.append_lsa.offset += copy_length;
}
}
else /* <- fits in one page */
{
memcpy (ptr, data, length);
log_Gl.hdr.append_lsa.offset += length;
}
LOG_APPEND_ALIGN (thread_p, LOG_SET_DIRTY); /* <- align next, dirty page */
}

Three branches: the empty guard returns at once; the else branch is the common case — one memcpy, one offset bump; the while branch fires when the payload reaches last_ptr, copying what fits, crossing via logpb_next_append_page (§7.5), and repeating until exhausted. Because LOG_APPEND_PTR() resolves to log_pgptr->area + append_lsa.offset, these memcpys land directly in the live LOG_PAGE frames with no staging buffer — the work producers were forbidden from doing under prior_lsa_mutex, run only on the single drain under LOG_CS.

7.5 Crossing a page boundary — logpb_next_append_page

Section titled “7.5 Crossing a page boundary — logpb_next_append_page”

Every page-boundary cross — from the advance/align macros or the spanning loop in logpb_append_data — funnels through logpb_next_append_page: it dirties the page just finished, allocates the next, and registers it in flush_info.toflush[]:

// logpb_next_append_page -- src/transaction/log_page_buffer.c
static void
logpb_next_append_page (THREAD_ENTRY * thread_p, LOG_SETDIRTY current_setdirty)
{
LOG_FLUSH_INFO *flush_info = &log_Gl.flush_info;
bool need_flush;
if (current_setdirty == LOG_SET_DIRTY)
{ logpb_set_dirty (thread_p, log_Gl.append.log_pgptr); } /* <- dirty the page we are leaving */
log_Gl.append.log_pgptr = NULL;
log_Gl.hdr.append_lsa.pageid++; /* <- next logical page */
log_Gl.hdr.append_lsa.offset = 0;
if (LOGPB_AT_NEXT_ARCHIVE_PAGE_ID (log_Gl.hdr.append_lsa.pageid)) { logpb_archive_active_log (thread_p); }
if (LOGPB_IS_FIRST_PHYSICAL_PAGE (log_Gl.hdr.append_lsa.pageid)) { /* ring wrap: bump fpageid, flush header */ }
log_Gl.append.log_pgptr = logpb_create_page (thread_p, log_Gl.hdr.append_lsa.pageid);
if (log_Gl.append.log_pgptr == NULL)
{ logpb_fatal_error (thread_p, true, ARG_FILE_LINE, "log_next_append_page"); return; }
/* ... condensed: if appending_page_tde_encrypted, set TDE algorithm on new page ... */
rv = pthread_mutex_lock (&flush_info->flush_mutex);
flush_info->toflush[flush_info->num_toflush] = log_Gl.append.log_pgptr; /* <- register page to flush */
flush_info->num_toflush++;
need_flush = (flush_info->num_toflush >= flush_info->max_toflush);
pthread_mutex_unlock (&flush_info->flush_mutex);
if (need_flush) { logpb_flush_all_append_pages (thread_p); } /* <- toflush array full */
}

Branch by branch: dirty-on-leaveLOG_SET_DIRTY (the spanning loop) dirties the page being left, LOG_DONT_SET_DIRTY means the caller already did. Archive / ring-wrap — colliding with the next archive page archives first; the first physical page bumps fpageid and flushes the header (log-ring housekeeping, log-manager companion). logpb_create_page failure is fatal. TDE — a page created while appending_page_tde_encrypted is set gets a TDE algorithm. Register and maybe flush — the frame is appended to toflush[] under flush_mutex; reaching max_toflush forces a flush here, the threshold the §7.2 guard stays ahead of.

  1. logpb_append_prior_lsa_list walks the detached list and frees each node the instant its bytes are copied. It stages the list at the global prior_flush_list_header as a single-drain contract marker (entry assert(... == NULL) plus debugger visibility); the serialization allowing only one drain is LOG_CS in write mode.

  2. logpb_append_next_record opens with a fatal start_lsa == append_lsa check — the invariant proving the prior list’s LSNs and the page cursor are in lock-step. A mismatch aborts the server; no recovery branch.

  3. The pre-flush guard (num_toflush + 1 >= max_toflush) drains toflush[] between records to dodge the costly mid-record forced flush, whose partial-record handling is the LOGPB_APPENDREC_* state machine in logpb_start_append / logpb_end_append.

  4. The node’s three buffers are appended only if non-NULL, in on-disk order data_header → udata → rdata; the data header gets a pre-advance so it never splits, while logpb_append_data spans udata/rdata across pages.

  5. logpb_start_append struct-copies the header and opens the record (IN_PROGRESS); logpb_end_append validates forw_lsa == append_lsa and closes it (SUCCESS). The EOF branch legitimately follows either a succeeded record or a PARTIAL_ENDED state.

  6. logpb_next_append_page is the single funnel for every page cross — it dirties the page left, allocates the next, propagates TDE intent, and registers the new frame in flush_info.toflush[] (forcing a flush if it fills). The drain copies into the live LOG_PAGE frames direct via LOG_APPEND_PTR() with no staging buffer, so toflush[] plus the per-page dirty flag is the only handoff to the Chapter 8 disk writer.

Chapter 8: Flushing to Disk and Waking Commit Waiters

Section titled “Chapter 8: Flushing to Disk and Waking Commit Waiters”

This chapter answers: once nodes are in the page buffer, how are pages made durable, how does nxio_lsa advance, and how does a parked committer learn its record is on disk? Ch.6/7 left each node’s payload in a LOG_PAGE dirty in memory but not on disk; nxio_lsa (the durable watermark) still points where the last write left off. The high-level companion cubrid-prior-list.md carries the policy in its §“Commit waiters” four-quadrant table; we assume it and trace the branches here. For where this flush sits in the wider WAL pipeline (checkpoint, archive, recovery scan), cross-link cubrid-log-manager-detail.md.

8.1 The drain-then-write sequencer: logpb_flush_pages_direct

Section titled “8.1 The drain-then-write sequencer: logpb_flush_pages_direct”

Every durable-flush path funnels through one sequencer:

// logpb_flush_pages_direct -- src/transaction/log_page_buffer.c
assert (LOG_CS_OWN_WRITE_MODE (thread_p)); /* <- caller already holds the log CS */
logpb_prior_lsa_append_all_list (thread_p); /* <- Ch.6/7: drain queue into LOG_PAGE buffer */
(void) logpb_flush_all_append_pages (thread_p); /* <- this chapter: write pages, advance nxio_lsa */

Invariant: drain precedes write, and (non-HA path) the caller owns LOG_CS write mode for the whole sequence. Drain must run first, else nxio_lsa advances past a record left in the prior list and a committer is woken on it — a lost commit. The “CS held throughout” half is non-HA only: the HA hand-off (§8.2.6) demotes the CS around the write loop. logpb_force_flush_pages wraps the CS for callers lacking it (LOG_CS_ENTER → direct → LOG_CS_EXIT).

8.2 logpb_flush_all_append_pages — the write-to-disk half

Section titled “8.2 logpb_flush_all_append_pages — the write-to-disk half”

The largest function: write the dirty flush_info->toflush[] pages to the active log, fsync, advance nxio_lsa. The complexity is crash-safety bookkeeping; Figure 8-1 is the authoritative branch map.

flowchart TB
  B{"num_toflush<1 or\nlone clean page?"} -->|yes| R0["return 0"]
  B -->|need_flush| E{"partial_append.status?"}
  E -->|IN_PROGRESS| E1["EOF into page copy, write copy,\n-> PARTIAL_FLUSHED_END_OF_LOG"]
  E -->|PARTIAL_FLUSHED_EOL| E2["no new marker"]
  E -->|PARTIAL_ENDED or SUCCESS| E3["logpb_start_append EOF in buffer"]
  E -->|other| ERR["assert_release, error"]
  E1 --> HA["HA on? LOG_CS_DEMOTE, wake LOGWR"]
  E2 --> HA
  E3 --> HA
  HA --> F["loop: skip clean+nxio, collect\ndirty run, writev_append_pages"]
  F --> G{"flush nxio page?\nSUCCESS or nxio != prev_lsa"}
  G -->|yes| G1["write nxio page, need_sync=true"]
  G -->|no| G2["skip: nxio is incomplete header"]
  G1 --> H{"need_sync?"}
  G2 --> H
  H -->|yes| H1["fileio_synchronize\nper SUPPRESS_FSYNC"]
  H -->|no| BA["bg archiving? to_archive"]
  H1 --> BA
  BA --> I{"status?"}
  I -->|PARTIAL_ENDED| J1["restore header, rewrite+fsync,\nnxio=append_lsa, -> FLUSHED_ORIGINAL"]
  I -->|PARTIAL_FLUSHED_EOL| J2["nxio = append.prev_lsa"]
  I -->|SUCCESS| J3["nxio = hdr.append_lsa"]
  I -->|other| ERR
  J1 --> K["num_toflush=0, re-add log_pgptr,\nHA on? notify writers, LOG_CS_PROMOTE"]
  J2 --> K
  J3 --> K
  K --> R1["return 1"]

Figure 8-1 — Branch map of logpb_flush_all_append_pages. The two partial_append.status switches bracket the write loop and the optional HA hand-off.

8.2.1 Early exits. Under flush_info->flush_mutex: if num_toflush < 1, or it equals 1 and that lone page is not dirty (logpb_is_dirty), return 0 — avoiding a needless marker rewrite on an idle tick. Return convention: 1 = flushed, 0 = no flush needed, < 0 = error; callers (void)-cast it because durability lives in nxio_lsa, not the return code.

8.2.2 The end-of-log marker — top half of the partial dance. The log must end with a LOG_END_OF_LOG marker so recovery’s forward scan knows where the durable log stops. The marker depends on log_Pb.partial_append.status:

statusMeaningMarker action
..._IN_PROGRESSrecord mid-append when flush forcedcopy header page, overwrite the record with LOG_END_OF_LOG (forw_lsa nulled), write the copy, set hdr.eof_lsa = append.prev_lsa, → PARTIAL_FLUSHED_END_OF_LOG
..._PARTIAL_FLUSHED_END_OF_LOGtemp EOF already on disknothing
..._PARTIAL_ENDED / ..._SUCCESSrecord fully appendedlogpb_start_append a fresh LOG_END_OF_LOG into the buffer (no log-address advance)
elseunexpectedassert_release (false); goto error

The IN_PROGRESS branch is the heart of crash safety for oversized records:

// logpb_flush_all_append_pages -- src/transaction/log_page_buffer.c (IN_PROGRESS, condensed)
memcpy (log_Pb.partial_append.log_page_record_header, bufptr->logpage, LOG_PAGESIZE);
bufptr->dirty = false; /* <- keep fake EOF off the real page */
LSA_SET_NULL (&log_Pb.partial_append.record_header_p->forw_lsa); /* <- no forward pointer past EOF */
log_Pb.partial_append.record_header_p->type = LOG_END_OF_LOG; /* (original header saved for §8.2.5) */

Only the disk copy carries the fake EOF; the buffer page stays the real record, marked clean so the write loop skips it.

8.2.3 The two-step write loop — nxio page last.

Invariant: the nxio_lsa page is written last. Recovery validates the new end-of-log only when its page lands, so a crash mid-flush leaves the old end-of-log (nxio page never written) or the new one (written after everything it points to) — never a page whose forw_lsa points into a record body that did not reach disk.

The loop (Figure 8-1 node F) skips clean pages and the nxio page, then extends a run while pages stay dirty and consecutive in both logical and physical pageid. Four conditions break a run (clean, nxio, logical gap, physical gap); the gap checks let one logpb_writev_append_pages do a single vectored write per run. The nxio page is then written alone, unless it is the incomplete record’s header page (status != SUCCESS and nxio page == log_Gl.append.prev_lsa.pageid) — already carrying the fake EOF, so left for §8.2.5. On SUCCESS it holds the fresh end-of-log and is written last to validate the new tail.

8.2.4 The fsync and background archiving. If need_sync and (PRM_ID_SUPPRESS_FSYNC == 0 or total_sync_count % PRM_ID_SUPPRESS_FSYNC == 0), call fileio_synchronize (thread_p, log_Gl.append.vdes, log_Name_active, false)SUPPRESS_FSYNC = 0 syncs every flush, non-zero every Nth; this is where durability becomes physical. Then, if PRM_ID_LOG_BACKGROUND_ARCHIVING, logpb_write_toflush_pages_to_archive mirrors the flushed pages into the pre-allocated archive volume (so archive-log creation is a rename) — a side write off the nxio_lsa path.

8.2.5 Advancing nxio_lsa — bottom half of the partial dance. The value set here is the writer-to-waiter contract: what a committer (§8.4) observes.

// logpb_flush_all_append_pages -- src/transaction/log_page_buffer.c (nxio advance, condensed)
if (log_Pb.partial_append.status == LOGPB_APPENDREC_PARTIAL_ENDED) { /* oversized record now complete */
*log_Pb.partial_append.record_header_p = log_Pb.partial_append.original_record_header; /* restore real header */
logpb_write_page_to_disk (...); fileio_synchronize (...); /* rewrite + sync again */
log_Gl.append.set_nxio_lsa (log_Gl.hdr.append_lsa); /* <- now safe: real record durable */
log_Pb.partial_append.status = LOGPB_APPENDREC_PARTIAL_FLUSHED_ORIGINAL;
} else if (log_Pb.partial_append.status == LOGPB_APPENDREC_PARTIAL_FLUSHED_END_OF_LOG)
log_Gl.append.set_nxio_lsa (log_Gl.append.prev_lsa); /* <- cannot pass incomplete record yet */
else if (log_Pb.partial_append.status == LOGPB_APPENDREC_SUCCESS)
log_Gl.append.set_nxio_lsa (log_Gl.hdr.append_lsa); /* <- normal: watermark = append cursor */
else { assert_release (false); goto error; }

set_nxio_lsa is an atomic store (std::atomic<LOG_LSA> in log_append_info), so committers reading via get_nxio_lsa without the CS see a consistent value. The PARTIAL_ENDED double write — fake EOF in §8.2.2, real header restored and re-synced here — makes a dangling forw_lsa impossible: the watermark reaches append_lsa only after the real record is durable.

8.2.6 HA writer hand-off. In server mode with HA enabled and writer flush not suppressed (!HA_DISABLED () && !writer_info->skip_flush), the function demotes the log CS to read mode around the write loop, flips each waiting LWT entry to LOGWR_STATUS_FETCH, and wakes the thread:

// logpb_flush_all_append_pages -- src/transaction/log_page_buffer.c (HA hand-off, condensed)
LOG_CS_DEMOTE (thread_p); /* <- write -> read: LOGWR threads read the log concurrently */
entry->status = LOGWR_STATUS_FETCH; /* <- tell each waiting LWT: go fetch */
thread_wakeup_already_had_mutex (wait_thread_p, THREAD_LOGWR_RESUMED);

The demote happens after the marker is chosen (§8.2.2) but before the write loop. The paired completion block (Figure 8-1 node K) broadcasts writer_info->flush_wait_cond, waits until every LOGWR_STATUS_FETCH entry drains, then LOG_CS_PROMOTEs back — the exception to §8.1’s “CS held throughout”. LOG_CS_DEMOTE/LOG_CS_PROMOTE are functions in log_manager.c, not macros; with HA disabled the pair is skipped.

In server mode the flush is normally done by the log-flush daemon; its body is small and every line is a gate:

// log_flush_execute -- src/transaction/log_manager.c
if (!BO_IS_SERVER_RESTARTED () || !log_Flush_has_been_requested)
return; /* <- short-circuit: empty tick does no work */
LOG_CS_ENTER (&thread_ref);
logpb_flush_pages_direct (&thread_ref); /* <- drain + write + advance nxio_lsa */
LOG_CS_EXIT (&thread_ref);
pthread_mutex_lock (&group_commit_info.gc_mutex);
pthread_cond_broadcast (&group_commit_info.gc_cond); /* <- wake ALL committers */
log_Flush_has_been_requested = false; /* <- clear the request flag */
pthread_mutex_unlock (&group_commit_info.gc_mutex);

Branches: (1) !BO_IS_SERVER_RESTARTED () — boot/recovery not yet open, return (recovery self-flushes). (2) !log_Flush_has_been_requested — the looper ticks on a timed period (§8.5) even unprompted; with no request it returns without taking LOG_CS (so the high-level doc’s “INF, woken by commits” is imprecise — it is a timed looper short-circuiting empty ticks). (3) flush under LOG_CS. (4) after CS release, broadcast and clear the flag under gc_mutex, paired with the §8.4 set so no interleaved request is missed; the atomic nxio_lsa store inside the CS makes the broadcast the release fence.

Invariant: the broadcast covers every committer waiting when the flush completed. pthread_cond_broadcast (not signal) wakes all waiters to re-evaluate nxio_lsa < flush_lsa: one fsync advances nxio_lsa once, one broadcast releases the whole covered cohort.

8.4 Waking the daemon: log_wakeup_log_flush_daemon and friends

Section titled “8.4 Waking the daemon: log_wakeup_log_flush_daemon and friends”

A committer (or a backpressure producer, Ch.9) requests a flush via log_wakeup_log_flush_daemon: if log_is_log_flush_daemon_available () (log_Flush_daemon != NULL — false in SA mode and before init), it sets log_Flush_has_been_requested = true then calls log_Flush_daemon->wakeup (). Setting the flag (std::atomic_bool) before wakeup() closes a race: the period function (§8.5) also reads it and collapses its sleep to zero when pending, so a lost wakeup() cannot delay the flush.

8.4.1 The committer wait loop: logpb_flush_pages. logpb_flush_pages(flush_lsa) is the committer’s entry. Branch-complete walkthrough: (0) the SA build (#if !defined(SERVER_MODE)) and the two server-mode runtime bypasses — !BO_IS_SERVER_RESTARTED () || flush_lsa == NULL || LSA_ISNULL, and !log_is_log_flush_daemon_available () — all route into the §8.4.2 direct flush. (1) else set need_wait = (PRM_ID_LOG_ASYNC_COMMIT == false) and need_wakeup_LFT = (LOG_IS_GROUP_COMMIT_ACTIVE () == false) — the four (async, group) quadrants of the high-level doc’s matrix; only the two need_wait (synchronous) quadrants enter the CV loop, the async pair returns immediately (after a daemon wakeup when !group). (2) the CV loop spins while (LSA_LT (&nxio_lsa, flush_lsa)): lock gc_mutex, re-read get_nxio_lsa (), conditionally log_wakeup_log_flush_daemon, pthread_cond_timedwait (&gc_cond, &gc_mutex, ...) 1 s, unlock, wakeup_LFT=true. The recheck under gc_mutex before sleeping closes the lost-wakeup race against the §8.3 broadcast. need_wakeup_LFT is the group-commit lever: in sync+group it starts false, so the first iteration parks without waking the daemon, letting the timed tick (§8.5) batch arrivals. Permanent data-page latches (pgbuf_has_perm_pages_fixed) or any post-timeout iteration force a wakeup.

Invariant: a synchronous committer returns only when nxio_lsa >= flush_lsa. With §8.2.5’s rule that nxio_lsa advances only after fileio_synchronize, this guarantees no transaction sees a commit ack before its LOG_COMMIT is on stable storage. The async quadrants break it (need_wait = false) — why PRM_ID_LOG_ASYNC_COMMIT is unsafe across crashes.

8.4.2 The non-server / not-restarted bypasses. The SA build compiles to a bare LOG_CS_ENTERlogpb_flush_pages_directLOG_CS_EXIT. In server mode, two runtime guards route around the CV loop into a direct flush: !BO_IS_SERVER_RESTARTED () || flush_lsa == NULL || LSA_ISNULL (boot/recovery — §8.3 short-circuits anyway), and !log_is_log_flush_daemon_available (). The load-bearing assert (!LOG_CS_OWN_WRITE_MODE (thread_p)) sits between the two — after the restart bypass returns and before the daemon-availability bypass — guarding the path that actually parks: a committer must not hold the log CS when it waits, else it deadlocks the daemon.

8.5 Daemon registration: log_flush_daemon_init

Section titled “8.5 Daemon registration: log_flush_daemon_init”

log_flush_daemon_init builds a cubthread::looper over the period function log_get_log_group_commit_interval (not a fixed interval) and create_daemons it with log_flush_execute as the body, named "log-flush":

// log_get_log_group_commit_interval -- src/transaction/log_manager.c (condensed)
if (log_Flush_has_been_requested) { period = milliseconds (0); return; } /* <- pending: tick now */
period = milliseconds (msec == 0 ? 1000 : msec); /* <- 0 means default 1s cap */

Because the period is a function pointer, changing PRM_ID_LOG_GROUP_COMMIT_INTERVAL_MSECS propagates on the next cycle without a restart, and a pending request collapses it to 0 ms (defeating a lost wakeup()). At the 0 default the idle cap is 1 s, bounding the group-commit batch window.

  1. logpb_flush_pages_direct is the one sequencer: assert write-mode CS, drain the prior list, then write — drain-before-write is hard, or nxio_lsa could pass a record still in volatile memory.
  2. nxio_lsa is the durable watermark, advanced only inside logpb_flush_all_append_pages after fileio_synchronize by atomic set_nxio_lsa: SUCCESS jumps it to append_lsa; the two partial states pin/restore it so it never crosses an incomplete record.
  3. The two-step page write is a crash-safety dance: the nxio_lsa page (the fresh end-of-log on SUCCESS) is written last, so a crash leaves only the old or new end-of-log, never a dangling forw_lsa. Oversized records add the fake-EOF / real-header double-write tracked by partial_append.status.
  4. HA replication brackets the write loop with LOG_CS_DEMOTE / LOG_CS_PROMOTE (flip LOGWR entries to LOGWR_STATUS_FETCH, drain, promote); background archiving mirrors flushed pages after fsync.
  5. log_flush_execute is the daemon body: short-circuit unless restarted and log_Flush_has_been_requested; flush under LOG_CS; broadcast gc_cond and clear the flag under gc_mutex — the broadcast after the atomic nxio_lsa store is the release fence.
  6. A committer parks in logpb_flush_pages under the four-quadrant (async, group) policy; only the synchronous quadrants enter the gc_cond CV loop, returning only when nxio_lsa >= flush_lsa — a commit ack thus implies a durable LOG_COMMIT.
  7. The daemon’s period is a function (log_get_log_group_commit_interval): 0 ms when a request is pending (defeating lost wakeups), else the configured interval capped at 1 s.

Chapter 9: Backpressure and the Self Help Drain

Section titled “Chapter 9: Backpressure and the Self Help Drain”

The reader question this chapter answers: what happens when producers fill the prior list faster than the flush daemon can drain it?

Every call to prior_lsa_next_record_internal that takes the lock-free producer path (Chapters 3–5) ends with the same tail block. After it has attached its node and bumped list_size, it checks whether the in-memory prior list has grown past the size of the log page buffer. If it has, the producer applies soft backpressure: it either nudges the flush daemon and yields its timeslice, or — when there is no daemon — drains the list itself. This is the only place in the producer path that reacts to the aggregate depth of the queue rather than to its own single record.

This chapter dissects that tail block branch by branch. It assumes the high-level companion’s treatment of why the prior list exists and how flush works — see cubrid-prior-list.md §“The producer/consumer split” and §“Write-ahead logging”. It introduces no new structs.

The threshold is computed fresh on every check by logpb_get_memsize:

// logpb_get_memsize -- src/transaction/log_page_buffer.c
size_t
logpb_get_memsize ()
{
return (size_t) log_Pb.num_buffers * (size_t) LOG_PAGESIZE;
}

log_Pb.num_buffers is set once at boot from the log_max_buffers system parameter (PRM_ID_LOG_NBUFFERS) in logpb_initialize_pool:

// logpb_initialize_pool -- src/transaction/log_page_buffer.c
log_Pb.num_buffers = prm_get_integer_value (PRM_ID_LOG_NBUFFERS);

So logpb_get_memsize() is the total byte capacity of the authoritative in-memory log page buffer — the same pool that Chapter 7 copies nodes into. The design ties the prior-list soft cap to that pool deliberately: there is no point letting the pending prior list grow much larger than the buffer it will be copied into, since the copy + flush pipeline downstream is what ultimately bounds memory. The cap is expressed in the same currency as list_size: bytes. Because list_size (Chapter 5) also counts per-node LOG_PRIOR_NODE overhead the page buffer does not, it crosses the threshold somewhat before the data would actually fill the buffer — intentional conservatism so the producer reacts early.

Invariant — the cap is soft, never a hard producer block. Crossing logpb_get_memsize() never blocks a producer from attaching its node. The node was already attached and list_size already bumped (Chapter 5) before this check runs. The check only decides whether the producer helps drain or yields. A producer that ignored the signal entirely would still be correct — it would just let the list grow. The real, hard flow-control bound on the system is the WAL invariant on data pages (a dirty data page cannot be flushed past the log; see cubrid-prior-list.md §“Write-ahead logging”): that is what eventually stalls transactions, not this soft cap. This block is a pressure-relief valve, not a gate.

flowchart LR
  subgraph prod["producer (no mutex held here)"]
    A["list_size += node bytes<br/>(under mutex, Ch.5)"] --> B["unlock prior_lsa_mutex"]
    B --> C{"list_size >=<br/>logpb_get_memsize() ?"}
  end
  C -->|no| Z["return start_lsa"]
  C -->|yes| D["PSTAT_PRIOR_LSA_LIST_MAXED++"]
  D --> E["dispatch by build/runtime mode<br/>(Figure 9-2)"]
  E --> Z

Figure 9-1. Where the backpressure check sits relative to the mutex. The check runs only on the WITHOUT_LOCK producer path, and only after the mutex is released.

9.2 The operator-visible signal: PSTAT_PRIOR_LSA_LIST_MAXED

Section titled “9.2 The operator-visible signal: PSTAT_PRIOR_LSA_LIST_MAXED”

The first thing the tail block does, unconditionally once the threshold is crossed, is bump a performance counter:

// prior_lsa_next_record_internal -- src/transaction/log_append.cpp
if (log_Gl.prior_info.list_size >= (INT64) logpb_get_memsize ())
{
perfmon_inc_stat (thread_p, PSTAT_PRIOR_LSA_LIST_MAXED); /* <- counts every "maxed" event */
// ... mode dispatch below ...
}

PSTAT_PRIOR_LSA_LIST_MAXED is the operator-visible symptom of producer/consumer imbalance. It is incremented once per maxed event, before any of the three mode branches run, so it counts both the daemon-nudge path and the self-help-drain path. A rising Num_prior_lsa_list_maxed in cubrid statdump means producers are outrunning the flush daemon often enough to trip the soft cap — a signal to investigate log device throughput, log_max_buffers sizing, or flush daemon scheduling. Contrast it with PSTAT_PRIOR_LSA_LIST_REMOVED / PSTAT_PRIOR_LSA_LIST_SIZE, which the drain side emits in logpb_prior_lsa_append_all_list (§9.4) to report how much was actually drained.

The (INT64) cast on logpb_get_memsize() matters: list_size is a signed INT64 and the comparison must be signed-vs-signed, so the size_t return is narrowed deliberately. Past the increment, the block splits three ways across two compile-time #if arms and one runtime branch inside the SERVER_MODE arm:

// prior_lsa_next_record_internal -- src/transaction/log_append.cpp
#if defined(SERVER_MODE)
if (!log_is_in_crash_recovery ()) /* <- runtime split inside SERVER_MODE */
{
log_wakeup_log_flush_daemon (); /* (a) nudge the daemon ... */
thread_sleep (1); /* ... and yield 1 msec */
}
else
{
LOG_CS_ENTER (thread_p); /* (b) crash recovery: no daemon yet */
logpb_prior_lsa_append_all_list (thread_p); /* so drain it ourselves */
LOG_CS_EXIT (thread_p);
}
#else
LOG_CS_ENTER (thread_p); /* (c) standalone: never a daemon */
logpb_prior_lsa_append_all_list (thread_p);
LOG_CS_EXIT (thread_p);
#endif
stateDiagram-v2
  [*] --> Maxed: list_size >= memsize
  Maxed --> ServerMode: compiled SERVER_MODE
  Maxed --> Standalone: not SERVER_MODE
  ServerMode --> Normal: not in crash recovery
  ServerMode --> Recovery: in crash recovery
  Normal --> Nudge: wakeup daemon then sleep 1ms
  Recovery --> SelfHelp: LOG_CS then append_all_list then LOG_CS_EXIT
  Standalone --> SelfHelp: LOG_CS then append_all_list then LOG_CS_EXIT
  Nudge --> [*]
  SelfHelp --> [*]

Figure 9-2. The three backpressure outcomes. Two are compile-time selected; the SERVER_MODE arm splits once more at runtime on crash-recovery state.

(a) SERVER_MODE, normal operation — nudge and yield. This is the steady-state production path. A dedicated flush daemon exists (log_Flush_daemon), so the producer does not touch the log critical section at all. It calls log_wakeup_log_flush_daemon, which sets the request flag and wakes the daemon:

// log_wakeup_log_flush_daemon -- src/transaction/log_manager.c
void
log_wakeup_log_flush_daemon ()
{
if (log_is_log_flush_daemon_available ()) /* <- false if log_Flush_daemon == NULL */
{
#if defined (SERVER_MODE)
log_Flush_has_been_requested = true; /* <- daemon checks this to avoid spurious wakeups */
log_Flush_daemon->wakeup ();
#endif /* SERVER_MODE */
}
}

Then the producer calls thread_sleep (1) — yields for one millisecond. The sleep is the actual backpressure: the producing thread voluntarily stalls, giving the daemon CPU time and the disk a chance to catch up before this thread can enqueue more. It is cooperative, not enforced — the producer chose to sleep; nothing prevented it from continuing. Note log_wakeup_log_flush_daemon is internally guarded by log_is_log_flush_daemon_available(), so even on this arm a missing daemon makes the wakeup a no-op (the producer would then sleep 1 ms and re-check on its next record) — but in normal SERVER_MODE the daemon is always present.

(b) SERVER_MODE, during crash recovery — self-help drain. log_is_in_crash_recovery() returns true during restart recovery, before the flush daemon has been started. With no daemon to nudge, the nudge-and-sleep strategy would stall forever — the list would never drain. So the producer drains it inline: it enters the log critical section (LOG_CS_ENTER), calls logpb_prior_lsa_append_all_list to copy-and-detach the whole list (§9.4), and exits. This is the “self-help” path: the producer becomes the consumer for one drain cycle.

(c) Standalone (non-SERVER_MODE) — always self-help. In standalone/SA builds there is never a flush daemon (log_is_log_flush_daemon_available() is hard-coded false). The #else arm is therefore identical to (b) and unconditional: every maxed event self-drains under LOG_CS. There is no log_is_in_crash_recovery() test here because the answer does not matter — with no daemon, self-help is the only option in or out of recovery.

Invariant — prior_lsa_mutex is released before LOG_CS is taken, so self-help cannot deadlock or recurse. Look at Figure 9-1: the threshold check, and therefore all of (a)/(b)/(c), runs after log_Gl.prior_info.prior_lsa_mutex.unlock(). This matters for two reasons. (1) Lock ordering: logpb_prior_lsa_append_all_list re-acquires prior_lsa_mutex internally (§9.4) while holding LOG_CS. Releasing first fixes the producer’s order to LOG_CS then prior_lsa_mutex, matching the drain side; holding prior_lsa_mutex across the LOG_CS_ENTER would self-deadlock on that re-acquire. (2) No recursion: logpb_prior_lsa_append_all_list never calls back into prior_lsa_next_record_internal, and the producer is not holding prior_lsa_mutex, so the self-help drain is a flat call, not a re-entrant one.

9.4 The drain primitive reused by self-help: logpb_prior_lsa_append_all_list

Section titled “9.4 The drain primitive reused by self-help: logpb_prior_lsa_append_all_list”

Both self-help arms call the same function the flush daemon uses to drain. It is the natural seam between this chapter and Chapters 6–7 (detach + copy):

// logpb_prior_lsa_append_all_list -- src/transaction/log_page_buffer.c
int
logpb_prior_lsa_append_all_list (THREAD_ENTRY * thread_p)
{
LOG_PRIOR_NODE *prior_list;
INT64 current_size;
assert (LOG_CS_OWN_WRITE_MODE (thread_p)); /* <- caller MUST hold LOG_CS in write mode */
log_Gl.prior_info.prior_lsa_mutex.lock (); /* <- the re-acquire the §9.3 invariant guards */
current_size = log_Gl.prior_info.list_size;
prior_list = prior_lsa_remove_prior_list (thread_p); /* detach whole list, reset size to 0 (Ch.6) */
log_Gl.prior_info.prior_lsa_mutex.unlock ();
if (prior_list != NULL) /* <- empty-list guard: nothing to copy */
{
perfmon_add_stat (thread_p, PSTAT_PRIOR_LSA_LIST_SIZE, (unsigned int) current_size / ONE_K); /* kbytes drained */
perfmon_inc_stat (thread_p, PSTAT_PRIOR_LSA_LIST_REMOVED);
logpb_append_prior_lsa_list (thread_p, prior_list); /* copy nodes into log pages (Ch.7) */
}
return NO_ERROR;
}

Branch-complete reading of this function from the self-help caller’s perspective:

  1. assert (LOG_CS_OWN_WRITE_MODE) — both self-help arms satisfy this because they wrapped the call in LOG_CS_ENTER/LOG_CS_EXIT. This is why the producer must take LOG_CS itself before calling; the function does not take it.
  2. Snapshot + detach under prior_lsa_mutex. current_size is read, then prior_lsa_remove_prior_list unhooks the entire list (head/tail → NULL, list_size → 0; see Chapter 6). The mutex protects the detach against concurrent producers and is released immediately.
  3. prior_list != NULL branch (non-empty): report PSTAT_PRIOR_LSA_LIST_SIZE in kilobytes and bump PSTAT_PRIOR_LSA_LIST_REMOVED, then hand the detached chain to logpb_append_prior_lsa_list (Chapter 7) which copies every node into the authoritative log pages. After this returns, list_size is back at 0 and the producer’s next-record check (§9.1) will pass cleanly.
  4. prior_list == NULL branch (empty): another drainer (the daemon, or a racing self-help producer) detached the list between this producer’s threshold check and its LOG_CS_ENTER. The function silently returns NO_ERROR with no copy and no perf stats. This is the benign race the empty-list guard exists for — the producer that lost the race did redundant LOG_CS work but caused no harm.

Invariant — after a successful self-help drain, list_size == 0 and the detached chain is fully owned by the draining thread. prior_lsa_remove_prior_list resets list_size to 0 and nulls both head and tail pointers under prior_lsa_mutex atomically, so a concurrent producer can never observe a half-detached list (head moved but size not reset, or vice versa). The returned prior_list is then private to the draining thread — no other thread can reach those nodes — which is what makes the subsequent logpb_append_prior_lsa_list copy safe to run outside prior_lsa_mutex (it still holds LOG_CS). Violating the atomicity — say, resetting size before nulling head — would let a producer append onto a chain that is about to be copied and freed.

9.5 Why nudge-and-sleep in production but self-help in recovery/standalone

Section titled “9.5 Why nudge-and-sleep in production but self-help in recovery/standalone”

The asymmetry follows from who else can drain. In normal SERVER_MODE the flush daemon is the designated drainer, so producers stay out of LOG_CS and apply pressure purely by yielding — having them also self-drain would contend with the daemon under exactly the load that trips the cap. In crash recovery the daemon does not exist yet (recovery replays log before background services start), and in standalone it never exists; with no specialist, the producer must drain or the list grows unbounded. The single shared primitive logpb_prior_lsa_append_all_list funnels recovery, standalone, and the daemon through identical copy-and-flush logic; only who calls it and whether it is wrapped in a yield differs.

  1. The check is list_size >= logpb_get_memsize(), where logpb_get_memsize() is num_buffers * LOG_PAGESIZE — the byte capacity of the in-memory log page buffer. Both sides are bytes; list_size includes per-node overhead, so it trips slightly early by design.
  2. The cap is soft. The node is already attached and counted before the check; crossing the threshold never blocks the producer from enqueuing. Hard flow control comes from the WAL invariant on data pages, not from this block.
  3. PSTAT_PRIOR_LSA_LIST_MAXED is bumped once per maxed event, before any mode branch — it is the operator-visible signal that producers are outrunning the flush daemon.
  4. Three outcomes, two compile-time plus one runtime: (a) SERVER_MODE normal → log_wakeup_log_flush_daemon + thread_sleep(1) (nudge and yield, never touches LOG_CS); (b) SERVER_MODE during crash recovery → self-help drain under LOG_CS; (c) standalone → always self-help drain under LOG_CS.
  5. Self-help exists because there is no flush daemon during recovery or in standalone builds. The producer temporarily becomes the consumer, reusing the daemon’s own drain primitive logpb_prior_lsa_append_all_list.
  6. prior_lsa_mutex is released before LOG_CS is taken, fixing the lock order to LOG_CS then prior_lsa_mutex and preventing both self-deadlock and re-entrancy when the self-help path re-acquires prior_lsa_mutex inside the drain.
  7. The drain is race-tolerant: logpb_prior_lsa_append_all_list detaches and zeroes list_size atomically under prior_lsa_mutex, then guards on prior_list != NULL so a producer that lost the drain race to the daemon simply does no work and returns cleanly.

Chapters 3-9 traced one record through the steady-state lifecycle (build outside the mutex, assign LSN and link, detach, copy into the LOG_PAGE ring, flush, backpressure). This chapter collects the paths that do not fit that shape: checkpoint’s across-generation mutex hold, TDE flag propagation, the node-less LSA stamp, the shutdown drain assertion, crash with a non-empty list, and the non-commit force-flush entry points — all downstream of one rule:

Master invariant (commit durability). A transaction never sees a commit ack unless its LOG_COMMIT record is durable. Every edge path below either upholds this directly or is a no-op with respect to it.

10.1 The checkpoint path — holding the mutex across record generation

Section titled “10.1 The checkpoint path — holding the mutex across record generation”

The normal attach (prior_lsa_next_record, Ch 5) takes prior_lsa_mutex internally for one node. Checkpoint needs more: its LOG_END_CHKPT payload (tran table + system top-op array) must reflect a state no other record mutates while it is stamped — a worker LSN assigned between snapshot and attach would leave the recorded redo LSA stale. So logpb_checkpoint locks once, generates the record while holding it, attaches via the with-lock variant, and unlocks:

// logpb_checkpoint (END_CHKPT span) -- src/transaction/log_page_buffer.c (condensed)
log_Gl.prior_info.prior_lsa_mutex.lock (); /* <- held across the whole span */
// ... snapshot tran table + system topops, build node ...
node = prior_lsa_alloc_and_copy_data (thread_p, LOG_END_CHKPT, RV_NOT_DEFINED, NULL, ...);
if (node == NULL) {
log_Gl.prior_info.prior_lsa_mutex.unlock (); /* <- error path must release once */
goto error_cannot_chkpt;
}
// ... *chkpt = tmp_chkpt ...
prior_lsa_next_record_with_lock (thread_p, node, tdes); /* <- attach WITHOUT re-locking */
log_Gl.prior_info.prior_lsa_mutex.unlock (); /* <- single matching release */

prior_lsa_next_record_with_lock wraps prior_lsa_next_record_internal (..., LOG_PRIOR_LSA_WITH_LOCK). Per Ch 5 the internal function locks only on the WITHOUT_LOCK arm; the WITH_LOCK arm assumes the caller holds it and also skips the backpressure tail (the list_size >= logpb_get_memsize() self-help of Ch 9), so checkpoint relies on its own logpb_flush_pages_direct after the unlock.

Invariant (single acquisition — std::mutex is non-recursive). Every _with_lock caller must hold prior_lsa_mutex exactly once and pair one lock() with one unlock() on every path including the error goto. Enforced only by audit: the internal function never locks on the with-lock arm. Breaks if violated — a caller that both pre-locks and calls the plain prior_lsa_next_record (which locks again) self-deadlocks.

The other with-lock site is the replication-and-commit path: the orchestrator log_append_repl_info_and_commit_log locks once and sequences log_append_repl_info_with_lock then log_append_commit_log_with_lock (which also covers the log_append_donetime_internal done-time record), so a foreign commit cannot land between a tx’s replication records and its LOG_COMMIT — its header comment warns that would “break consistencies of slaves/replicas”. The inner log_append_repl_info_internal branches on with_lock: the LOG_PRIOR_LSA_WITH_LOCK arm calls prior_lsa_next_record_with_lock inside the held span, else plain prior_lsa_next_record.

flowchart TD
  A["caller needs N records\nwith no interleave"] --> B["prior_lsa_mutex.lock() ONCE"]
  B --> C["build record\nprior_lsa_alloc_and_copy_*"]
  C --> D{"alloc ok?"}
  D -->|no| E["unlock() ONCE\ngoto error"]
  D -->|yes| F["prior_lsa_next_record_with_lock\nno re-lock, no backpressure"]
  F --> G{"more records?"}
  G -->|yes| C
  G -->|no| H["prior_lsa_mutex.unlock() ONCE"]
  H --> I["logpb_flush_pages_direct\nown durability"]

Figure 10-1 — The with-lock atomic-group write. The mutex spans every record’s generation and attach; the error path at D releases the single lock, and durability (I) is the caller’s own responsibility.

10.2 TDE — propagating tde_encrypted from node to page

Section titled “10.2 TDE — propagating tde_encrypted from node to page”

The prior node carries one bool, node->tde_encrypted, set by prior_set_tde_encrypted (producer) and read by prior_is_tde_encrypted (drain):

// prior_set_tde_encrypted -- src/transaction/log_append.cpp
int
prior_set_tde_encrypted (log_prior_node *node, LOG_RCVINDEX recvindex)
{
if (!tde_is_loaded()) /* <- master key must be loaded */
{
er_set (ER_ERROR_SEVERITY, ARG_FILE_LINE, ER_TDE_CIPHER_IS_NOT_LOADED, 0);
return ER_TDE_CIPHER_IS_NOT_LOADED; /* <- caller asserts(false) on this */
}
node->tde_encrypted = true; return NO_ERROR; /* <- success write */
}
// prior_is_tde_encrypted -- src/transaction/log_append.cpp
bool prior_is_tde_encrypted (const log_prior_node *node) { return node->tde_encrypted; } /* <- pure read */

The setter has two branches: the cipher-not-loaded error (every caller treats it as assert(false) — a should-be-encrypted record that cannot be is a logic error) and the success write. Public append APIs call it only when the recvindex targets a TDE-class object. On the drain side logpb_append_next_record reads the flag into a page-side global before copying any bytes, then clears it:

// logpb_append_next_record -- src/transaction/log_page_buffer.c
log_Gl.append.appending_page_tde_encrypted = prior_is_tde_encrypted (node); /* <- node flag -> page flag */
/* ... logpb_start_append, logpb_append_data x3, logpb_end_append ... */
log_Gl.append.appending_page_tde_encrypted = false; /* <- cleared after the record */

appending_page_tde_encrypted is a member of log_append_info (the page-side cursor, Ch 1), not the node or queue. While true, two consumers brand the destination page:

// logpb_next_append_page -- src/transaction/log_page_buffer.c (page allocation)
if (log_Gl.append.appending_page_tde_encrypted) /* <- on a brand-new page: stamp unconditionally */
{
TDE_ALGORITHM tde_algo = (TDE_ALGORITHM) prm_get_integer_value (PRM_ID_TDE_DEFAULT_ALGORITHM);
logpb_set_tde_algorithm (thread_p, log_Gl.append.log_pgptr, tde_algo); /* <- stamp the new page */
logpb_set_dirty (thread_p, log_Gl.append.log_pgptr);
}

logpb_start_append runs the identical stamp on an existing page but idempotently — guarded on !LOG_IS_PAGE_TDE_ENCRYPTED (...) so it only brands a page logpb_next_append_page did not already brand. Either way the brand is honoured at write time: the flush path encrypts pages carrying the TDE algorithm flag. The bool thus rides the node to the drain, transiently brands the page, and is cleared per record.

Hazard (master-key rotation between build and drain). A node is flagged under the master-key generation loaded on the producer thread, but encryption happens later on the drain/write path — possibly after the operator rotated the key. The node carries no IV or key-generation bookkeeping, only the bool, so a rotation in that window leaves the page branded under the current generation while the intent was formed under the old one. tde_is_loaded() gating and the administrative nature of rotation are the only mitigations. This is Open Question 6 in the companion.

10.3 The direct LSA-stamp-under-mutex path

Section titled “10.3 The direct LSA-stamp-under-mutex path”

log_skip_logging_set_lsa is the one prior_lsa-mutex acquisition that attaches no node and does not advance prior_lsa — it only reads prior_lsa to stamp a coherent value onto the data page via pgbuf_set_lsa. It serves the case where a data page is modified but intentionally not logged (e.g. log_no_logging mode), yet the page still needs an LSA so the page buffer’s WAL check has something monotonic to compare against:

// log_skip_logging_set_lsa -- src/transaction/log_manager.c
log_Gl.prior_info.prior_lsa_mutex.lock ();
(void) pgbuf_set_lsa (thread_p, addr->pgptr, &log_Gl.prior_info.prior_lsa); /* <- stamp current prior_lsa, do NOT advance it */
log_Gl.prior_info.prior_lsa_mutex.unlock ();

Reading without the lock could observe a torn value while a concurrent attach advances prior_lsa; the mutex gives the page a coherent LSA <= any record subsequently appended — exactly what the WAL invariant needs (the page’s LSA must never lead the durable log). No record means no commit ack, so it is a no-op w.r.t. the master invariant.

log_prior_has_worker_log_records asks “does the prior list still hold any non-system (user) records not yet drained to pages?” Its sole caller is the server stop sequence in server_support.c: after workers and vacuum are stopped and logpb_force_flush_pages has run, it is wrapped in a debug-only assert (!log_prior_has_worker_log_records (thread_p)) confirming no worker work is left un-drained before the log writers stop — a shutdown-time sanity check (relevant to HA, since the log writer must ship all user log first). The probe walks the live (undrained) list under the lock:

// log_prior_has_worker_log_records -- src/transaction/log_append.cpp (condensed)
LOG_CS_ENTER (thread_p); /* <- outer lock first */
std::unique_lock<std::mutex> ulock (log_Gl.prior_info.prior_lsa_mutex); /* <- inner lock second */
LOG_LSA nxio_lsa = log_Gl.append.get_nxio_lsa ();
if (!LSA_EQ (&nxio_lsa, &log_Gl.prior_info.prior_lsa)) /* <- durable cursor behind producer cursor? */
{
assert (LSA_LT (&nxio_lsa, &log_Gl.prior_info.prior_lsa)); /* <- nxio can only lag, never lead */
for (node = log_Gl.prior_info.prior_list_header; node != NULL; node = node->next)
if (node->log_header.trid != LOG_SYSTEM_TRANID) /* <- a USER record is pending */
{ ulock.unlock (); LOG_CS_EXIT (thread_p); return true; } /* <- early exit on first user node */
}
ulock.unlock (); LOG_CS_EXIT (thread_p);
return false; /* <- no user records pending */

Branch-complete reading: if nxio_lsa == prior_lsa the queue is fully drained and flushed — skip the loop, return false. If nxio_lsa < prior_lsa (undrained/unflushed work, assert confirms the cursor can only lag), walk the list: a trid != LOG_SYSTEM_TRANID node (real worker tx) returns true immediately — the only early-return; LOG_SYSTEM_TRANID nodes (vacuum, checkpoint housekeeping) are skipped; reaching the end with only system nodes falls through to return false. Lock ordering matches the global discipline — LOG_CS (outer) before prior_lsa_mutex (inner) — and this is the only reader that walks the live prior_list_header chain under the mutex; the drain detaches the chain (Ch 6) before walking it, so the two never race the same list.

Invariant (cursor ordering — nxio_lsa <= prior_lsa). The durable I/O cursor can equal or lag the producer cursor, never exceed it. Enforced by construction: prior_lsa advances only at attach (under the mutex); nxio_lsa advances only at flush (after the named bytes are on disk). Breaks if violated: durability would be claimed for LSNs no record produced.

10.5 Force-flush wrappers and the WAL-invariant consumer

Section titled “10.5 Force-flush wrappers and the WAL-invariant consumer”

Two thin wrappers serve callers needing durability now, off the group-commit path. Both wrap logpb_flush_pages_direct (drain + write, Ch 7-8) inside LOG_CS_ENTER/LOG_CS_EXIT. logpb_force_flush_pages is the bare “make everything durable” call, used by server shutdown (server_support.c), heap / disk / btree bulk operations, the page buffer, and vacuum. Checkpoint does not use this wrapper — it calls logpb_flush_pages_direct directly after the END_CHKPT attach (§10.1). logpb_force_flush_header_and_pages adds one logpb_flush_header call after the pages: the header records the last checkpoint and end-of-log location, so writing it before the pages it references would point past durable data. Pages first, then header.

The WAL-invariant consumer is the page buffer’s contract enforcer:

// logpb_flush_log_for_wal -- src/transaction/log_page_buffer.c (condensed)
if (logpb_need_wal (lsa_ptr)) /* <- 1st check: data page ahead of durable log? */
{
LOG_CS_ENTER (thread_p);
if (logpb_need_wal (lsa_ptr)) /* <- 2nd check (double-checked under CS) */
logpb_flush_pages_direct (thread_p); /* <- force the log forward */
LOG_CS_EXIT (thread_p);
assert (LSA_ISNULL (lsa_ptr) || !logpb_need_wal (lsa_ptr)); /* <- WAL rule now holds */
}

The page-buffer manager calls this before writing any dirty data page. It is double-checked, three branches: (1) outer !logpb_need_wal — the durable log already covers lsa_ptr, return with no CS (hot path); (2) outer true, inner false — a concurrent flush advanced nxio_lsa past the page’s LSA in the window, do nothing; (3) both true — flush forward, then the post-condition assert confirms the page is safe to write. This is where the prior-list drain is forced on behalf of a page flush, making it a participant in page-durability, not just the commit path.

The most important edge has the least code: there is no special handling, and that is correct. At crash time the prior list may hold any number of malloc’d LOG_PRIOR_NODEs whose bytes were never copied into a log page, let alone fsync’d. These nodes are memory-only and were never acked — any committing owner would still be parked on gc_cond in logpb_flush_pages awaiting nxio_lsa >= commit_lsa, so no client believes it committed. Recovery sees no LOG_COMMIT, marks the transaction active-at-crash, and undoes its durable records cleanly (data pages obey the WAL invariant, §10.5). The un-drained nodes are simply leaked; the OS reclaims the heap at process exit, with no pool to corrupt and no on-disk structure to repair.

flowchart TD
  A["crash, non-empty prior list"] --> B["nodes malloc'd,\nnever on disk, never acked"]
  B --> F["recovery: no LOG_COMMIT\n-> mark active -> undo"]
  F --> G["WAL-safe pages -> undo rolls back;\nleaked nodes -> OS reclaims at exit"]

Figure 10-3 — Crash with a non-empty prior list. The durability gate already made the only dangerous case — an acked-but-not-durable commit — impossible, so no crash-time cleanup is needed.

  1. Checkpoint holds prior_lsa_mutex across record generation, not just attach, via prior_lsa_next_record_with_lock. std::mutex is non-recursive: every with-lock caller locks once, releases on every path (including error gotos), and gets no backpressure self-help.
  2. TDE is one bool on the node. prior_set_tde_encrypted (gated on tde_is_loaded()) sets it; the drain reads it via prior_is_tde_encrypted into appending_page_tde_encrypted, which brands the page idempotently across logpb_next_append_page and logpb_start_append, cleared per record. Master-key rotation in that window is unguarded — no key-generation/IV bookkeeping (Open Q 6).
  3. log_skip_logging_set_lsa is the only prior_lsa-mutex acquisition with no record — it reads a coherent prior_lsa and stamps it via pgbuf_set_lsa, without advancing or linking.
  4. log_prior_has_worker_log_records is a shutdown-time drain assertion (sole caller: the server stop sequence), returning true on the first non-LOG_SYSTEM_TRANID node when nxio_lsa < prior_lsa.
  5. The force-flush wrappers and logpb_flush_log_for_wal are the non-commit drain entry points. _force_flush_pages is bare drain+write (not checkpoint); _header_and_pages flushes the header after the pages; logpb_flush_log_for_wal double-checks then forces the log forward before a dirty data page is home-written.
  6. Crash with a non-empty list needs no handling. Un-drained nodes are memory-only and leaked at exit; the owner was never acked (master invariant), so recovery correctly undoes the uncommitted work.

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

SymbolFileLine
heap_get_visible_version_from_logsrc/storage/heap_file.c25329
pgbuf_has_perm_pages_fixedsrc/storage/page_buffer.c11424
log_zip_undosrc/thread/thread_entry.hpp259
log_data_ptrsrc/thread/thread_entry.hpp261
log_Zip_supportsrc/transaction/log_append.cpp40
log_Zip_min_size_to_compresssrc/transaction/log_append.cpp41
LOG_PRIOR_LSA_LAST_APPEND_OFFSETsrc/transaction/log_append.cpp44
log_append_info::get_nxio_lsasrc/transaction/log_append.cpp106
log_append_info::set_nxio_lsasrc/transaction/log_append.cpp112
log_prior_lsa_info::log_prior_lsa_infosrc/transaction/log_append.cpp117
LOG_RESET_APPEND_LSAsrc/transaction/log_append.cpp129
LOG_RESET_PREV_LSAsrc/transaction/log_append.cpp137
LOG_APPEND_PTRsrc/transaction/log_append.cpp145
log_prior_has_worker_log_recordssrc/transaction/log_append.cpp152
log_append_init_zipsrc/transaction/log_append.cpp185
log_append_final_zipsrc/transaction/log_append.cpp232
prior_lsa_alloc_and_copy_datasrc/transaction/log_append.cpp273
prior_lsa_alloc_and_copy_crumbssrc/transaction/log_append.cpp409
prior_lsa_copy_undo_data_to_nodesrc/transaction/log_append.cpp492
prior_lsa_copy_undo_data_to_nodesrc/transaction/log_append.cpp493
prior_lsa_copy_redo_data_to_nodesrc/transaction/log_append.cpp523
prior_lsa_copy_redo_data_to_nodesrc/transaction/log_append.cpp524
prior_lsa_copy_undo_crumbs_to_nodesrc/transaction/log_append.cpp554
prior_lsa_copy_redo_crumbs_to_nodesrc/transaction/log_append.cpp599
prior_lsa_gen_undoredo_record_from_crumbssrc/transaction/log_append.cpp650
prior_lsa_gen_undoredo_record_from_crumbssrc/transaction/log_append.cpp651
prior_lsa_gen_postpone_recordsrc/transaction/log_append.cpp1062
prior_lsa_gen_dbout_redo_recordsrc/transaction/log_append.cpp1109
prior_lsa_gen_2pc_prepare_recordsrc/transaction/log_append.cpp1144
prior_lsa_gen_end_chkpt_recordsrc/transaction/log_append.cpp1181
prior_lsa_gen_recordsrc/transaction/log_append.cpp1217
prior_update_header_mvcc_infosrc/transaction/log_append.cpp1320
prior_lsa_next_record_internalsrc/transaction/log_append.cpp1357
PSTAT_PRIOR_LSA_LIST_MAXEDsrc/transaction/log_append.cpp1524
thread_sleepsrc/transaction/log_append.cpp1531
prior_lsa_next_recordsrc/transaction/log_append.cpp1553
prior_lsa_next_record_with_locksrc/transaction/log_append.cpp1559
prior_set_tde_encryptedsrc/transaction/log_append.cpp1565
prior_is_tde_encryptedsrc/transaction/log_append.cpp1581
prior_lsa_start_appendsrc/transaction/log_append.cpp1593
prior_lsa_end_appendsrc/transaction/log_append.cpp1652
prior_lsa_append_datasrc/transaction/log_append.cpp1661
log_append_get_zip_undosrc/transaction/log_append.cpp1725
log_append_get_zip_redosrc/transaction/log_append.cpp1751
log_append_realloc_data_ptrsrc/transaction/log_append.cpp1787
log_append_get_data_ptrsrc/transaction/log_append.cpp1858
log_prior_lsa_append_alignsrc/transaction/log_append.cpp1892
log_prior_lsa_append_advance_when_doesnot_fitsrc/transaction/log_append.cpp1905
log_prior_lsa_append_add_alignsrc/transaction/log_append.cpp1917
log_crumbsrc/transaction/log_append.hpp46
log_data_addrsrc/transaction/log_append.hpp53
LOG_DATA_ADDR_INITIALIZERsrc/transaction/log_append.hpp64
LOG_PRIOR_LSA_LOCKsrc/transaction/log_append.hpp66
LOG_PRIOR_LSA_WITHOUT_LOCKsrc/transaction/log_append.hpp68
LOG_PRIOR_LSA_WITH_LOCKsrc/transaction/log_append.hpp69
log_append_infosrc/transaction/log_append.hpp73
appending_page_tde_encryptedsrc/transaction/log_append.hpp81
log_prior_nodesrc/transaction/log_append.hpp91
log_prior_lsa_infosrc/transaction/log_append.hpp112
prior_flush_list_headersrc/transaction/log_append.hpp124
LOG_RV_RECORD_SET_MODIFY_MODEsrc/transaction/log_append.hpp204
log_zipsrc/transaction/log_compress.c45
log_diffsrc/transaction/log_compress.c176
MAKE_ZIP_LENsrc/transaction/log_compress.h33
log_zipsrc/transaction/log_compress.h53
LOG_IS_GROUP_COMMIT_ACTIVEsrc/transaction/log_impl.h124
log_Flush_has_been_requestedsrc/transaction/log_manager.c364
log_is_in_crash_recoverysrc/transaction/log_manager.c518
LOG_RESET_PREV_LSAsrc/transaction/log_manager.c887
log_initialize_internalsrc/transaction/log_manager.c1100
log_skip_logging_set_lsasrc/transaction/log_manager.c3225
log_append_repl_info_internalsrc/transaction/log_manager.c4555
log_append_repl_info_and_commit_logsrc/transaction/log_manager.c4647
log_append_donetime_internalsrc/transaction/log_manager.c4679
log_get_log_group_commit_intervalsrc/transaction/log_manager.c10044
log_wakeup_log_flush_daemonsrc/transaction/log_manager.c10126
log_is_log_flush_daemon_availablesrc/transaction/log_manager.c10141
log_flush_executesrc/transaction/log_manager.c10377
log_flush_daemon_initsrc/transaction/log_manager.c10493
LOG_CS_DEMOTEsrc/transaction/log_manager.c15246
LOG_CS_PROMOTEsrc/transaction/log_manager.c15257
LOG_CS_OWN_WRITE_MODEsrc/transaction/log_manager.h281
LOG_LAST_APPEND_PTRsrc/transaction/log_page_buffer.c162
LOG_APPEND_ALIGNsrc/transaction/log_page_buffer.c164
LOG_APPEND_ADVANCE_WHEN_DOESNOT_FITsrc/transaction/log_page_buffer.c177
LOG_APPEND_SETDIRTY_ADD_ALIGNsrc/transaction/log_page_buffer.c185
LOGPB_APPENDREC_STATUSsrc/transaction/log_page_buffer.c217
logpb_initialize_poolsrc/transaction/log_page_buffer.c553
logpb_next_append_pagesrc/transaction/log_page_buffer.c2630
logpb_writev_append_pagessrc/transaction/log_page_buffer.c2780
logpb_write_toflush_pages_to_archivesrc/transaction/log_page_buffer.c2868
logpb_append_next_recordsrc/transaction/log_page_buffer.c2981
logpb_append_prior_lsa_listsrc/transaction/log_page_buffer.c3040
prior_lsa_remove_prior_listsrc/transaction/log_page_buffer.c3084
logpb_prior_lsa_append_all_listsrc/transaction/log_page_buffer.c3106
logpb_flush_all_append_pagessrc/transaction/log_page_buffer.c3232
logpb_flush_pages_directsrc/transaction/log_page_buffer.c3952
logpb_flush_pagessrc/transaction/log_page_buffer.c3980
logpb_force_flush_pagessrc/transaction/log_page_buffer.c4096
logpb_force_flush_header_and_pagessrc/transaction/log_page_buffer.c4104
logpb_flush_log_for_walsrc/transaction/log_page_buffer.c4162
logpb_start_appendsrc/transaction/log_page_buffer.c4207
logpb_append_datasrc/transaction/log_page_buffer.c4290
logpb_end_appendsrc/transaction/log_page_buffer.c4455
logpb_checkpointsrc/transaction/log_page_buffer.c6877
logpb_get_memsizesrc/transaction/log_page_buffer.c11553
log_rectypesrc/transaction/log_record.hpp35
LOG_DIFF_UNDOREDO_DATAsrc/transaction/log_record.hpp122
log_rec_headersrc/transaction/log_record.hpp146
log_datasrc/transaction/log_record.hpp157
log_rec_undoredosrc/transaction/log_record.hpp167
log_rec_undosrc/transaction/log_record.hpp176
log_rec_redosrc/transaction/log_record.hpp184
log_vacuum_infosrc/transaction/log_record.hpp192
log_rec_mvcc_undoredosrc/transaction/log_record.hpp202
log_rec_mvcc_undosrc/transaction/log_record.hpp211
log_rec_mvcc_redosrc/transaction/log_record.hpp220
log_rec_donetimesrc/transaction/log_record.hpp237
log_rec_dbout_redosrc/transaction/log_record.hpp254
log_rec_chkptsrc/transaction/log_record.hpp345
log_rec_2pc_prepcommitsrc/transaction/log_record.hpp388
LOG_IS_UNDOREDO_RECORD_TYPEsrc/transaction/log_record.hpp455
  • cubrid-prior-list.md — the high-level companion. See also cubrid-log-manager-detail.md (the surrounding append pipeline; this doc zooms into its producer side).
  • Code: src/transaction/log_append.{cpp,hpp}, drain side in src/transaction/log_manager.c, record shapes in src/transaction/log_record.hpp.
  • Methodology: knowledge/methodology/code-analysis-detail-doc.md.