diff --git a/design/replication/684-in-memory-relaylog/benchmark-results.md b/design/replication/684-in-memory-relaylog/benchmark-results.md new file mode 100644 index 000000000000..7469b114030d --- /dev/null +++ b/design/replication/684-in-memory-relaylog/benchmark-results.md @@ -0,0 +1,24 @@ +# Benchmark Results + +To quantify the improvement of the in-memory relaylog, we benchmarked replication throughput for different stages (receiver, applier, and end-to-end) and compared the current CSA applier against CSA with the in-memory relaylog enabled. The test environment and results are below. + +## Environment + +| Setting | Value | +| ---------------- | --------------- | +| Testing platform | AWS | +| Instance type | m7a.16xlarge | +| Disk type | io2 | +| Benchmark tool | Sysbench | +| Workload | oltp_write_only | +| Parallel workers | 128 | + +## Results + +Throughput in MB/s (higher is better). + +| Stage | CSA applier (MB/s) | CSA + In-memory relaylog (MB/s) | +| ---------- | -----------------: | ------------------------------: | +| Receiver | 80 | 230 | +| Applier | 66 | 67 | +| End-to-end | 44 | 65 | diff --git a/design/replication/684-in-memory-relaylog/high-level-description.md b/design/replication/684-in-memory-relaylog/high-level-description.md new file mode 100644 index 000000000000..8cacdd9ae8b4 --- /dev/null +++ b/design/replication/684-in-memory-relaylog/high-level-description.md @@ -0,0 +1,60 @@ +### Executive Summary + +After this feature is implemented, MySQL users running replicas with Change Stream Applier (CSA) can achieve higher throughput and less disk usage, while preserving correctness, recovery, and applier parallelism. In-memory relaylog removes the relaylog disk round trip from the CSA by passing each transaction from the receiver to the applier through an in-memory queue instead. + +In the current CSA code path (referred to as the disk path in this document), a transaction needs to go through disk I/O twice before it can be applied. The receiver (IO thread) writes encoded binlog events received over the network to relaylog files, and the applier reads them back to schedule and apply the transaction. + +In-memory relaylog keeps the transaction in memory instead. As a new transaction arrives, the receiver wraps the transaction metadata and incoming encoded events into a data structure, pushes it to an in-memory queue. The coordinator reads from it and dispatches the transaction to workers, without relaylog file write/read during the process (referred to as the memory path in the document). + +The queue has a hard memory limit per channel. A transaction larger than a configured threshold is re-routed to a temporary file in standard relaylog format (referred to as the spill path in the document). On replication stop, server restart, or crash, the queue is emptied and any uncommitted transactions are re-fetched by GTID auto-positioning. + +### User / Developer Stories + +As a MySQL user, I want lower replication lag on my CSA replica, so that changes on the source propagate faster with high throughput. + +As a MySQL user, I want the replication process to minimize disk I/O due to the relaylog write/read, so that it uses less storage and frees up I/O throughput for my workload. + +As a MySQL user, I want per-channel metrics for queue memory usage, large transaction handling, and receiver-side waiting, so that I can monitor and assess the performance of each channel. + +As a MySQL user, I want recovery to work just like it does on any GTID-based replication today (re-fetch missing transactions by GTID), so that I can rely on the same operational procedures I already know. + +As a MySQL user, I want the feature to be set to `ON` by default for eligible CSA channels, so that I get performance improvement on top of CSA. + +As a MySQL user, I want clear actionable replication errors, so that I can identify the failing transaction by its GTID and still inspect its events through a retained diagnostic file to determine the cause and fix it. + +### Scope + +**In Scope** + +Introduce a per-channel in-memory relaylog queue between the receiver and the CSA applier, that replaces the relaylog write and read path on the disk, for CSA channels only. + +Bound memory per channel with a hard limit. The enqueue decision is determined before streaming the body, and oversized transactions are stored through spill path. + +Recover on replication stop, server restart and crash via GTID auto-positioning, without relying on a durable relay log. + +Provide per-channel `CHANGE REPLICATION SOURCE TO`(CRST) knobs to enable the feature and adjust the memory limit and spill path threshold. + +Expose per-channel observability metrics for memory path usage, spill path usage, and receiver-side block. + +Report apply failures through GTID-based diagnostics `performance_schema.replication_applier_status_by_worker`, and write the failed transaction to a diagnostic file in relaylog format for easy inspection. + +**Out of Scope / Limitations** + +The feature is effective only on CSA channels. It is not supported for `Group Replication` channels, or `Semisynchronous` replication. Enabling this feature on such channels shall be rejected. + +`Semisynchronous` replication acknowledges a transaction to the source only after it is durably written to the relaylog, so a memory-only copy would break the durability requirement. + +`Group Replication` channel has no IO thread. So the streaming and recovery logic is not feasible in GR setup. + +With In-memory relaylog, file based relaylog-related surfaces are reduced. + +`SHOW REPLICA STATUS` relaylog position fields (`Relay_Log_File`, `Relay_Log_Pos`, `Relay_Log_Space`) are not applicable + +`sync_relay_log` is not applicable + +`SHOW RELAYLOG EVENTS` is not applicable + +`FLUSH RELAY LOGS` is a no-op. + +Because in-flight transactions are memory-only, replication restart (`STOP REPLICA` then `START REPLICA`) may need to re-fetch more transactions from the source than a file-based relaylog replica. + diff --git a/design/replication/684-in-memory-relaylog/high-level-design.md b/design/replication/684-in-memory-relaylog/high-level-design.md new file mode 100644 index 000000000000..4cf1f19f1530 --- /dev/null +++ b/design/replication/684-in-memory-relaylog/high-level-design.md @@ -0,0 +1,224 @@ +### Summary of the Approach + +Overview + +The CSA receiver writes every transaction's events to a relaylog file and the coordinator reads them back to build a `Job_applier`. The feature instead has the receiver construct the `Transaction_envelope` in memory and hand it to the coordinator to build `Job_applier` directly, keeping the exact same source stream so the worker apply path is untouched. + +Workflow + +There are four roles in the end-to-end workflow. The Receiver (IO thread) reads events off the network, parses the GTID header, assembles each transaction into a `Transaction_envelope` object, and enqueues it into a queue, replacing the `write_buffer()` write to the relay log files. +The queue (`Trx_envelope_queue`) is a deque implementation with `Transaction_envelope` in source order. The queue retains each transaction until it commits. The coordinator (CSA / SQL thread) reads `Transaction_envelope` in order, builds them into `Job_applier`, schedules and dispatches them to the worker pool without decoding. The workers decode and apply each transaction's events, unchanged from today. + +Envelope structure: `Transaction_envelope` and `Trx_payload` +The envelope is split into two objects with different lifetimes, so that memory can be reclaimed as soon as a transaction commits rather than when its entry is dequeued. + +``` +Transaction_envelope { // queue entry — lightweight, lives until dequeued + mutex + trx_length // header metadata (from GTID event) + path // MEMORY | SPILL + state // open | committed | truncated + unique_ptr payload // reset at commit (memory path) +} + +Trx_payload { // heavy — carries the encoded events, lives until commit + shared_ptr // the CSA trx object consumed by workers +} +``` + +`Transaction_envelope` is the lightweight entry used for ordering and re-dispatch. `Trx_payload` is the heavy object that wraps the existing CSA `Fetchable_transaction`. The encoded bytes are held in the `Event_set_fetchable` batch. The per-channel memory-usage counter depends on the lifecycle of `Trx_payload`. Its constructor reserves `trx_length` against the memory limit and its destructor releases those bytes, and wakes the blocked receiver. +The `Transaction_envelope` slot holds its `payload` reference until commit. After commit, the memory allocation ends, and the payload is reset while the empty `Transaction_envelope` slot lingers in the queue until the commit low-water mark advances. This avoids head-of-line memory blocking when transactions commit with `replica_preserve_commit_order=OFF`. + +Receiver (IO thread) + +At the GTID event, the receiver reads `trx_length`, chooses the path, creates the envelope, and enqueues it immediately. As body events arrive, it appends successive events to the envelope `Trx_payload` and publishes the new end position, waking any waiting worker. At the transaction boundary it stops appending and marks the underlying `Event_set_fetchable` `sealed`. Control events that are not part of a transaction (Format_description, Rotate, heartbeat) are not turned into envelopes. GTID tracking is unchanged. A GTID enters `Retrieved_Gtid_Set` only after the transaction's last event is published. +The change to the IO thread is localized. Instead of writing each event to the relaylog file (`queue_event()` → `write_buffer()`), the receiver now passes transaction events to a different destination. Encoded events are appended into a new in-memory byte source, `Event_set_fetchable_memory`, an implementation similar to `Event_set_fetchable_cache`, that reuses the streaming and synchronization functions (`append_event()` / `seal_stream()`, `wait_next()` / `fetch_next()`) and adds a commit hook in `set_success()` to mark the envelope committed, and release the transaction payload. Because it exposes the same `Event_set_fetchable` interface, the decode and apply path is unchanged. + +Queue + +The queue (`Trx_envelope_queue`) is an in-memory queue of `Transaction_envelope` in source order plus a byte counter for the memory usage. +`stream_seqno` is a monotonically increasing sequence number the queue assigns to each transaction envelope at enqueue time, in the order the receiver inserts them (source order). It is an internal, per-channel logical sequence to address a position within the queue. +Three cursors track the queue progress, similar to how the GAQ (Global Apply Queue) tracks a low-water mark in classic MTA (Multi-threaded Applier). Each cursor is expressed as a `stream_seqno` value: +`commit_seqno`: the head of the queue, and the commit low-water mark. +`insert_seqno`: the tail of the queue, where the receiver inserts new envelopes. +`dispatch_seqno`: the next envelope to dispatch by the coordinator. +Queue admission of `Transaction_envelope` is decided at the GTID event using `trx_length`: + +```cpp +trx_length > disk_path_threshold -> spill path +queue_bytes + trx_length <= limit -> memory path +otherwise -> block until commits free space, then memory path +``` + +Reserving the exact `trx_length` up front guarantees a memory-path transaction fits before streaming begins. A transaction larger than the `IN_MEMORY_RELAYLOG_SPILL_THRESHOLD` always takes the spill path. + +Thread safety +The queue uses a three-tier locking structure so that its shared structure is safe while per-transaction streaming stays parallel. +A single queue-level mutex guards the deque, the three cursors, and envelope properties. Every structural mutation requires this mutex, so the receiver, coordinator, and workers never modify the queue concurrently. These critical sections are small and are taken only at transaction boundaries. +A per-envelope mutex guards each envelope's status flags (committed / truncated) and its payload reference. +The per-transaction event streaming is synchronized separately, by `Fetchable_transaction`'s own mutex and condition variable. This mechanism already exists in the current code, so different transactions stream fully in parallel. +The memory usage counter is an atomic variable. Modifying the variable is a lock-free update. A mutex plus a condition variable is used only when the memory counter is at the `IN_MEMORY_RELAYLOG_LIMIT` and the receiver must block until commits free enough memory. + +Applier (SQL thread) + +The coordinator consumes transactions through the existing `Transaction_provider` interface, so the `Csa_service::run` loop, scheduler, dependency tracking, and worker pool are all reused unchanged. The changes on the coordinator side are limited to the `Reader` interface. +A new reader `Queued_transaction_reader` is introduced as a memory path counterpart of `Relay_log_adaptive_reader`. Its `read()` takes the envelope at `dispatch_seqno` from the `Trx_envelope_queue`, advances the cursor, and wraps the envelope's `Fetchable_transaction` into a fresh `Job_applier` using the same constructor the relaylog reader uses today. +Everything downstream is untouched. The coordinator obtains the next transaction from the provider, computes scheduling dependencies from the logical clock and commit-order inputs, and dispatches it to the worker thread pool exactly as today. +The coordinator also tries to advance `commit_seqno` in the queue over a contiguous run of committed transactions at the head and dequeues each. A transaction that commits behind an uncommitted head is only marked committed and swept later when the head commits. +Workers apply exactly as today. Each worker pulls events from the transaction byte source via `wait_next()` / `fetch_next()`, decodes them, and applies them, blocking only when it catches up to the last published event of a still-receiving transaction. For memory path, the bytes come from the in-memory segment, and for spill path, they read from the disk file. The decode and execution path remain unchanged. On commit, the worker's `set_success()` fires the commit hook that marks the envelope committed and releases its `Trx_payload`. + +Ownership + +Trx_payload +`Trx_payload` carries the byte stream `Fetchable_transaction` of each transaction with `RAII`(Resource Acquisition Is Initialization) memory-usage accounting. The owner of the `Fetchable_transaction` changes as the transaction moves through different phases: +Open (IO thread still receiving): held by the receiver and the `Transaction_envelope` entry in the queue. +Sealed, not yet dispatched (fully received, waiting in the deque): held by the `Transaction_envelope` entry only; the receiver dropped its reference after marking the envelope `sealed`. +Dispatched, applying (in-flight): held by the `Transaction_envelope` entry and the `Job_applier` (a copy taken at dispatch). +Committed: the `Transaction_envelope` entry resets its `Trx_payload` pointer, and the Job is destroyed. With the drop of the last reference, `Fetchable_transaction` is freed while the empty `Transaction_envelope` entry lingers behind an uncommitted head. +Rolled back (stop or retry): the `Job_applier` is dropped by the workers but the `Transaction_envelope` entry keeps its reference, so the uncommitted payload stays alive and re-dispatch can hand it to a fresh `Job_applier`. + +Queue +The in-memory queue `Trx_envelope_queue` is owned by the channel's `Master_info`. `mi` lives in `channel_map`, owned by the server, so it outlives the lifecycle of either IO or SQL thread. When IO or SQL thread starts, it attaches to the queue, and when stopped, detaches from it. +`mi` holds only a pointer to the `Trx_envelope_queue`. A channel that does not use the feature carries only a null pointer. +A queue instance is created when an eligible channel is created either through `CHANGE REPLICATION SOURCE` or when `mi` is reconstructed at server startup. +A `CHANGE REPLICATION SOURCE` that turns off the feature, or that makes the channel ineligible, deletes the queue instance and restores the `mi` queue pointer to `nullptr`. +`RESET REPLICA ALL` / `shutdown` destroys the `mi` along with the `queue` under the `channel_map` write lock. +The queue instance exists for the whole enabled period. The queue interacts with replication threads: +START — the receiver attaches as producer and the coordinator attaches as consumer to the already-present queue instance in the channel. +STOP of one thread only — that replication thread detaches while the queue instance stays. On `STOP REPLICA SQL_THREAD`, the receiver still running, uncommitted envelopes are retained for re-dispatch; on `STOP REPLICA IO_THREAD`, the applier still running, the coordinator drains what remains and idles. +STOP to full idle — when the stop leaves the channel with no active replication thread, the queue is reset. The queue instance itself is not destroyed while the feature is still enabled. + +Commit + +When a worker finishes applying and commits a transaction, it invokes the existing CSA success callback (`Job::set_success` →`Fetchable_transaction::set_success`). In in-memory relaylog, that callback does two operations: mark the transaction's `Transaction_envelope` as committed, and reset its `payload` to free the transaction memory. +With `replica_preserve_commit_order` set to `ON`, commits occur in source order handled by `Commit_order_manager`. + +Recovery + +The queue is purely an in-memory struct that cannot persist through replication stop, server restart, or crash. The recovery process therefore relies only on the durably recorded `gtid_executed`. Any uncommitted transaction can be re-obtained by GTID, either re-dispatched from the queue if it still holds the transaction (in the case of `STOP REPLICA SQL_THREAD`), or re-fetched from the source by auto-positioning. Duplicates are harmless because a worker skips any transaction whose GTID is already in `gtid_executed` (`is_already_logged_transaction`), and an interrupted transaction is always rolled back and re-fetched during recovery. +`STOP REPLICA SQL_THREAD` (applier only; receiver keeps running). The queue and all uncommitted envelopes still stay in memory. In-flight jobs are driven to a terminal state (commit, or roll back), so no transaction is left half-applied. With no advance of `commit_seqno`, all uncommitted transactions remain in the queue. The IO thread keeps enqueuing new envelopes until the memory usage counter reaches the `IN_MEMORY_RELAYLOG_LIMIT`. On next `START REPLICA SQL_THREAD`, the coordinator rewinds `dispatch_seqno` to `commit_seqno` and re-dispatches all the retained uncommitted transactions in order; committed-but-not-yet-swept envelopes are skipped because their payload is freed. +`STOP REPLICA IO_THREAD` (receiver only; applier keeps running). No new transactions are produced, and the queue is retained while the SQL thread runs. The coordinator drains and commits every fully-received transaction in the queue, then idles. A transaction that was only half-received when the receiver stopped is marked as truncated, and the worker applying it simply stops and rolls back. On the next `START REPLICA IO_THREAD`, the receiver simply re-fetches that transaction by GTID into the next slot in the queue and resumes normal operation. +`STOP REPLICA` (both threads). Production and consumption both stop: in-flight jobs commit or roll back, every payload reference drops, the queue is reset. Committed transactions stay in `gtid_executed`, and everything uncommitted is discarded. On the next `START REPLICA`, auto-positioning re-fetches the gap transactions from the source by GTID and continues as normal. +Start single thread after a full stop. The queue was reset at the full stop. +On `START REPLICA IO_THREAD`, the receiver reconnects, auto-positions from `gtid_executed`, and refills the queue from the source. With no worker to apply the changes, the queue fills to the memory limit and the receiver blocks. +On `START REPLICA SQL_THREAD`, the coordinator finds an empty queue and no producer, so the coordinator has nothing to dequeue and simply idles until the receiver starts enqueuing. +Server restart or crash. All in-memory state is gone, including the queue and the retrieved GTID set. Auto-positioning resumes from durable `gtid_executed` and re-fetches everything unapplied from the source — the same outcome as `STOP REPLICA` followed by `START REPLICA`. + +Spill Path + +Transactions stored through spill path are written in standard relaylog format to a dedicated directory, so the entire read/decode path is reused. Each spill path `Transaction_envelope` carries the file payload in `Event_set_fetchable_spill`. Spill path transactions are large by definition and therefore rare. One self-contained file per transaction (Format_description header plus events) keeps the lifecycle trivial. The receiver writes events to the file as they stream and marks it `sealed` at the last event. The worker can begin applying a transaction from the spill file while it is still open. +Dedicated directory. Following the same pattern as the binlog optimization for large transactions (BOLT), spill files live in a dedicated directory named `#in_memory_relaylog_temp_files`, created in the same directory as the channel's relaylog files. Placing it alongside the relaylog keeps the files on the same filesystem and lets them inherit the relaylog's file permissions, ownership, and encryption. The directory is created during server initialization, and is excluded from schema-visible listings (`SHOW DATABASES`, `information_schema`). If the path exists but is not a directory, or cannot be created or secured, the feature logs an error and rejects enabling the feature through CRST. +File lifecycle. Each spill file has a unique name to avoid conflicts. It's retained until its transaction commits, and then deleted. On startup, any leftover uncommitted spill path files in `#in_memory_relaylog_temp_files` are discarded and re-fetched by GTID auto-positioning. Workers read and decode the spill path file exactly as they read the relaylog today. + +Diagnosability + +The memory path is weak on diagnosability, because there is no relaylog on disk to inspect when a transaction failed and the in-memory byte stream is volatile. To improve this, +A failed transaction is identified by its GTID. The existing `performance_schema.replication_applier_status_by_worker` fields (`LAST_ERROR_NUMBER` / `LAST_ERROR_MESSAGE`, `APPLYING_TRANSACTION` and its timestamps) continue displaying the erroneous transaction GTID with error message. +The feature writes the erroneous transaction to a diagnostic file in relaylog format in the dedicated directory `#in_memory_relaylog_temp_files`. The file can be inspected with existing event-dump tooling, restoring the capability to investigate the relaylog as disk path. The retained diagnostic file survives server startup, and is only removed when the replication channel restarts or the channel is reset. + +### User Interface + +### Configuration / Knobs — New configuration clauses or options + +All In-Memory relaylog settings are per channel. They are exposed as `CHANGE REPLICATION SOURCE TO` clauses. Each clause is a per-channel value held in the channel's in-memory `Master_info` and backed by the persisted replication metadata repository. They are effective only on a CSA channel with asynchronous replication. Setting them on any other channel type is rejected with an error. + +To enable In-Memory relaylog on a CSA channel: + +```sql +CHANGE REPLICATION SOURCE TO + IN_MEMORY_RELAYLOG_ENABLED = 1, + IN_MEMORY_RELAYLOG_LIMIT = 134217728, -- bytes (128 MB) + IN_MEMORY_RELAYLOG_SPILL_THRESHOLD = 16777216 -- bytes (16 MB) + FOR CHANNEL 'ch1'; +``` + +**NAME**: `IN_MEMORY_RELAYLOG_ENABLED` + +**VALUES**: 0 \| 1 DEFAULT: 1 (ON) + +**PERSISTED**: YES + +**PRIVILEGES REQUIRED**: REPLICATION_SLAVE_ADMIN + +**DESCRIPTION**: Enables the in-memory relaylog for the channel, replacing the standard relaylog files on the disk path. When set to 0, the feature is off, and the channel uses the standard relaylog disk path with no change in behavior. Effective only on CSA channels; enabling it elsewhere is rejected. + + + +**NAME**: `IN_MEMORY_RELAYLOG_LIMIT` + +**VALUES:** unsigned integer, bytes. Range [33554432 (32 MB), 4294967296 (4 GB)]; values outside the range are rejected. + +**DEFAULT**: 134217728 (128 MB) + +**PERSISTED**: YES + +**PRIVILEGES REQUIRED**: REPLICATION_SLAVE_ADMIN + +**DESCRIPTION**: Hard per-channel limit on the bytes held by memory path transactions. Bounds only memory path transactions. Disk-path transactions live on disk and do not count against it. When the limit is reached, the receiver blocks on enqueue until committing transactions free space. + + + +**NAME**: `IN_MEMORY_RELAYLOG_SPILL_THRESHOLD` + +**VALUES**: unsigned integer, bytes, Range [8388608 (8MB) , `IN_MEMORY_RELAYLOG_LIMIT` ) + +**DEFAULT**: 16777216 (16MB) + +**PERSISTED**: YES + +**PRIVILEGES REQUIRED**: REPLICATION_SLAVE_ADMIN + +**DESCRIPTION**: A transaction whose `trx_length` exceeds this threshold is stored through the spill path instead of the memory path. + +### Configuration / Knobs — New system variables or command-line options + +_TBD_ + +### Configuration / Knobs — New command-line options for utilities + +_TBD_ + +### Configuration / Knobs — New UDFs or similar extension points + +_TBD_ + +### New Statements + +The In-Memory Relaylog feature can be turned on or off using the CHANGE REPLICATION SOURCE TO command. The configuration is set per user-defined channel. The CHANGE REPLICATION SOURCE TO command is extended with new options: * IN_MEMORY_RELAYLOG_ENABLED, accepting either "0" or "1" value, * IN_MEMORY_RELAYLOG_LIMIT, accepting a number within the range <33554432, 4294967296>. * IN_MEMORY_RELAYLOG_SPILL_THRESHOLD, accepting a number within the range <8388608, IN_MEMORY_RELAYLOG_LIMIT>. + +### Observability + +Each channel reports the following runtime per-channel metrics through the `performance_schema` replication tables. All of these metrics are collected through the existing CSA statistics infrastructure (`Statistics_map` / `Statistics_monitor`, per channel). New keys are added and updated inline at their relevant points. No new collection framework is introduced. + +Memory Path: +`memory_bytes_used` — current memory path bytes held (the accountant value). +`memory_bytes_limit` — the configured `IN_MEMORY_RELAYLOG_LIMIT`. +`memory_trx_count` — cumulative transactions admitted to the memory path. +`memory_trx_bytes` — cumulative bytes of transactions admitted to the memory path. + +Spill Path: +`disk_trx_count` — cumulative transactions to the spill path. +`disk_trx_bytes` — cumulative bytes of transactions to the spill path. +`disk_file_count` — current number of temp files to the spill path. +`disk_bytes_used` — current bytes of temp files to the spill path. +`disk_bytes_limit` — the soft limit of total bytes of temp files to the spill path. + +Queue: +`queue_length` — number of transactions currently in the queue. +`block_count` — number of times the receiver blocked waiting for memory. +`block_time_total` — total time the receiver spent blocked waiting for memory. + +### User Procedure + +N/A + +### Security Context + +The `#in_memory_relaylog_temp_files` directory is server-managed storage of uncommitted transaction data. Files within it contain relaylog-equivalent data and use the same file permissions, ownership, and encryption as the channel's relaylog files. Cleanup at startup follows the existing directory-scoped deletion pattern, so only files managed by the feature are removed. Retain-on-error diagnostic files live in the same store under the same protections and are released when the error is cleared or the channel is reset. + +On the memory path, transaction data resides in process memory and is never persisted. It is discarded on replication stop, server restart, or crash, so the feature does not widen on-disk exposure of replicated data. + +No new SQL privilege is introduced. The new `CHANGE REPLICATION SOURCE TO` clauses (`IN_MEMORY_RELAYLOG_ENABLED`, `IN_MEMORY_RELAYLOG_LIMIT`, `IN_MEMORY_RELAYLOG_SPILL_THRESHOLD`) are controlled by the existing `REPLICATION_SLAVE_ADMIN` privilege already required for `CHANGE REPLICATION SOURCE TO`. + +### Compatibility and Behavior Change + diff --git a/design/replication/684-in-memory-relaylog/requirements.md b/design/replication/684-in-memory-relaylog/requirements.md new file mode 100644 index 000000000000..3ba0cd05dd44 --- /dev/null +++ b/design/replication/684-in-memory-relaylog/requirements.md @@ -0,0 +1,81 @@ +### Functional Requirements + +- FR1. The feature must provide a per-channel option, introduced as a new `CHANGE REPLICATION SOURCE TO` option `IN_MEMORY_RELAYLOG_ENABLED`, that controls whether the feature is enabled. It is `ON` by default for eligible channels. When `OFF`, replication must fall back to the standard disk path relaylog. +- FR2. The feature must only apply to CSA channels with asynchronous replication. Enabling the feature on any unsupported channel type must be rejected with a clear error. +- FR3. The feature must store each event in the same encoded bytes as in disk path relaylog, so that the existing decode and apply path can continue working without modification. +- FR4. The receiver must create an object of type `Transaction_envelope` when it encounters a new transaction. The receiver appends the transaction payload to the object as it processes incoming events belonging to the same transaction. +- FR4.1. Upon receiving the GTID event, the receiver must create the envelope object and enqueue it to the in-memory queue before the next transaction payload arrives. The envelope must carry transaction receiving status, transaction payload pointer, and metadata from the GTID event. +- FR4.2. Upon receiving the GTID event, the receiver must select the store path for the payload by comparing its `trx_length` with configured `IN_MEMORY_RELAYLOG_SPILL_THRESHOLD`. A transaction with a `trx_length` that exceeds the threshold takes the spill path. Otherwise, it takes the memory path. +- FR4.3. As the receiver processes each incoming event in a transaction's payload, it must advance the transaction end position to notify the worker. This allows a worker to begin applying already received events while the rest of the transaction is still being received. +- FR5. The feature must not change receiver side GTID tracking. A GTID must be added to `Retrieved_Gtid_Set` only after the transaction has been marked as sealed. +- FR6. The coordinator must build and dispatch a `Job_applier` from the `Transaction_envelope` object, and workers must decode and apply the transaction in the same manner as the existing code path. +- FR6.1. The feature must add a new `Reader` implementation, `Queued_transaction_reader` (the memory-path counterpart of current `Relay_log_adaptive_reader`). The `read()` function reads the next envelope from the in-memory queue, and builds a `Job_applier` for downstream scheduler. +- FR6.2. The feature must implement a new `Event_set_fetchable_memory` (the in-memory transaction byte source, a subclass of `Event_set_fetchable`) carrying a commit hook that, when the worker commits, marks the envelope committed and releases the transaction payload. +- FR6.3. The feature must implement a new `Event_set_fetchable_spill` (the disk file handler, a subclass of `Event_set_fetchable`) carrying a commit hook that, when the worker commits, marks the envelope committed and removes the temporary file. +- FR7. The queue must be a per-channel, in-memory data structure that holds transaction envelopes in receiver insertion order. Envelopes are dequeued in FIFO (First-In, First-Out) order. +- FR8. The queue must maintain a per channel memory usage counter that tracks the total bytes currently held by memory path transaction envelopes. +- FR8.1. The counter is an atomic variable. It increases by the transaction's size when a memory path payload is created, and decreases by the same amount when that payload is released at commit. +- FR9. The queue must be tracked by three cursors: (1) A commit cursor that marks the last contiguously committed transaction, meaning all transactions before this cursor have been committed with no gaps. (2) A dispatch cursor that marks the next available transaction envelope to be dispatched. (3) An insert cursor that marks the position where the next incoming envelope should be appended. +- FR9.1 The cursors must strictly follow the invariant: `commit cursor` <= `dispatch cursor` <= `insert cursor`. +- FR10. The queue must support concurrent access by the receiver, enqueueing at the tail, and by the coordinator and workers, reading from the head. Each transaction envelope is retained from the time its GTID event is received and dequeued after its transaction commits. +- FR10.1. The queue must be protected by a queue-level mutex that guards its structure. All operations that mutate the queue structure and the cursors must hold this mutex. +- FR11. When the queue is empty, the coordinator must wait until the receiver enqueues a new transaction envelope before resuming dispatching. +- FR12. The feature must enforce a hard per channel memory limit, `IN_MEMORY_RELAYLOG_LIMIT`, on the bytes held by memory path transaction envelopes. The value defaults to 134217728 bytes (128 MB) with valid range [33554432 bytes (32 MB), 4294967296 bytes (4 GB)]. +- FR13. The feature must guarantee that a memory path transaction is known to fit within the memory limit at GTID event before any of its body is buffered. If there is not enough memory available, the receiver must block until committing transactions free enough memory. +- FR14. When a transaction commits, its memory path transaction payload must be freed and the per-channel memory-usage counter decreased by the freed bytes, releasing the memory back at commit time. +- FR15. The feature must store a transaction through spill path instead of the memory path when its `trx_length` exceeds a configurable per channel threshold, `IN_MEMORY_RELAYLOG_SPILL_THRESHOLD`. The value defaults to 16777216 bytes (16 MB) with valid range [8388608 bytes (8 MB), `IN_MEMORY_RELAYLOG_LIMIT`). +- FR16. When either `IN_MEMORY_RELAYLOG_SPILL_THRESHOLD` or `IN_MEMORY_RELAYLOG_LIMIT` is set via `CHANGE REPLICATION SOURCE TO`, the server must validate that both variables comply with their required ranges. CRST statement violating the constraint must be rejected with a clear error message. +- FR16.1. When a CRST clause sets `IN_MEMORY_RELAYLOG_LIMIT` alone, the server must validate that the provided value: (1) is greater than the current `IN_MEMORY_RELAYLOG_SPILL_THRESHOLD`, (2) is greater than or equal to 33554432 bytes (32 MB), and (3) is less than or equal to 4294967296 bytes (4 GB). +- FR16.2. When a CRST clause sets `IN_MEMORY_RELAYLOG_SPILL_THRESHOLD` alone, the server must validate that the provided value: (1) is greater than or equal to 8388608 bytes (8 MB), and (2) is less than the current `IN_MEMORY_RELAYLOG_LIMIT`. +- FR16.3. When a CRST clause sets both variables together, the server must validate that: (1) the provided `IN_MEMORY_RELAYLOG_SPILL_THRESHOLD` is greater than or equal to 8388608 bytes (8 MB), (2) the provided `IN_MEMORY_RELAYLOG_LIMIT` is greater than or equal to 33554432 bytes (32 MB) and less than or equal to 4294967296 bytes (4 GB), and (3) the provided `IN_MEMORY_RELAYLOG_SPILL_THRESHOLD` is less than the provided `IN_MEMORY_RELAYLOG_LIMIT`. +- FR17. The feature must store spill files in a dedicated subdirectory named `#in_memory_relaylog_temp_files` under the channel's relay log directory. Files must be written in standard relay log format so that the store is self-contained and on the same filesystem as standard relaylog files. +- FR18. The feature must create the `#in_memory_relaylog_temp_files` directory at server initialization after the relaylog directory is known. +- FR18.1. If the `#in_memory_relaylog_temp_files` directory already exists at startup, the server must clean up its managed temp files according to FR25. This is safe because the channel invalidates and discards all previously fetched relaylog content and recovers solely by GTID auto-positioning based on `gtid_executed`. +- FR18.2. At startup, if the `#in_memory_relaylog_temp_files` path exists but is not a directory (e.g., a symlink or regular file), or if directory creation or permission acquisition fails, the feature must log a clear error message and reject starting replication channels with `IN_MEMORY_RELAYLOG_ENABLED` set to ON. +- FR19. The feature must exclude the `#in_memory_relaylog_temp_files` directory from schema-visible listings such as `SHOW DATABASES` and information_schema, so that it does not appear as a schema. +- FR20. A channel's spill path files count against `relay_log_space_limit` as a soft limit. The receiver finishes writing the current transaction's spill file even if the total disk usage exceeds the limit, then blocks the next spill path transaction until committed files are deleted and the total size drops back under the limit. +- FR21. The spill path file store must use exactly one self-contained file per transaction with a unique filename to avoid conflicts. The file must be deleted automatically once its transaction commits. +- FR22. A spill file must be created with the same file permissions, ownership, and encryption as the channel's relaylog files, since it contains equally sensitive data and resides alongside the relay log. +- FR23. If disk space is exhausted while writing to a temp file during transaction execution, the write error must be handled identically to a disk-full error on the standard binlog cache temp file. +- FR24. A spill path temp file created by the feature must have a name matching the pattern `imr_sp__`, where `` is a lowercase channel identifier, and `` is a lowercase identifier unique within `#in_memory_relaylog_temp_files` (IMR SP stands for "In-Memory Relaylog Spill Path"). +- FR25. Startup cleanup must delete only spill files whose basename matches the temp file naming pattern `imr_sp_*` and must reject all other directory entries. +- FR26. If startup cleanup cannot delete a file, the server must log `ER_BINLOG_CANT_DELETE_FILE` from `MYSQL_BIN_LOG` and reject replication channels start with `IN_MEMORY_RELAYLOG_ENABLED` set to `ON`. +- FR27. When the replication channel enters full stop state, such as `STOP REPLICA`, server restart, and server crash, the in-memory queue's contents must be discarded, and un-applied transactions must be recovered via GTID auto-positioning using the durable `gtid_executed`. +- FR28. When the replication channel enters normal full idle state (both IO, SQL threads stopped), the feature must reset the queue: drop all envelopes, zero the cursors and the memory usage counter, and clear `Retrieved_Gtid_Set` (referred to as queue reset in the document). The next IO thread start triggers auto-position recovery on any unapplied transactions. +- FR29. On `STOP REPLICA SQL_THREAD` only (the receiver keeps running), all uncommitted transactions in the worker session pool are rolled back. The queue must retain every uncommitted transaction envelope with its payload intact and must keep the current `Retrieved_Gtid_Set`. +- FR29.1. On `START REPLICA SQL_THREAD` after `STOP REPLICA SQL_THREAD`, the applier must reset the dispatch cursor to the head of the queue (where the commit cursor is), reconstruct the `Job_applier` objects, and re-dispatch all retained uncommitted transactions in order from the queue head. Committed transactions are skipped based on the envelope status. +- FR30. On `STOP REPLICA IO_THREAD` only (the applier keeps running), the IO thread must truncate the current in-progress open transaction, and stop immediately without affecting the SQL thread. The SQL thread is not interrupted by the IO thread stop and continues applying all fully received transactions in the queue. If a truncated transaction exists, the worker stops executing once it receives the truncate signal and rolls back any applied changes. +- FR30.1. On `START REPLICA IO_THREAD` after `STOP REPLICA IO_THREAD`, the receiver must automatically re-fetch the previously truncated transaction based on GTID auto-positioning, so the applier can re-dispatch and re-apply the transaction. +- FR30.2. The queue must treat a truncated envelope as a terminal state. When a truncated envelope reaches the head, the sweep advances the commit cursor over it, removes it from the queue, and frees its reserved memory on destruction. +- FR31. On `RESET REPLICA`, the feature must perform queue reset, and delete any spill files and the error-diagnostic file. `gtid_executed` is left unchanged, so `START REPLICA` can re-fetch by GTID auto-positioning. +- FR32. The feature must introduce a new Performance Schema table, `replication_in_memory_relaylog`, with one row per channel. Metrics are collected in memory and materialized on read (not persisted; cumulative since replication started; reset on `RESET REPLICA`; zeroed after a server restart). For the full list of fields, refer to the High Level Design: Observability section. +- FR33. The feature must expose its per-channel configuration in `performance_schema.replication_applier_configuration`. The values must reflect what `CHANGE REPLICATION SOURCE TO` set and be persisted with the other in-memory relaylog settings. Runtime metrics remain in the separate `replication_in_memory_relaylog` table (FR32). +- FR34. The feature must add two new columns to `SHOW REPLICA STATUS`, populated per channel: + - `In_Memory_Relay_Log_Space` — the current memory usage (in bytes) of the channel's in-memory relay log queue + - `In_Memory_Queue_Length` — the current number of transaction envelopes held in the queue. + + For a channel that does not use the in-memory relay log, both columns report 0. +- FR35. Receiver-side (IO thread) and queue-side (in-memory queue) errors introduced by the feature must be reported through the standard receiver error channel `SHOW REPLICA STATUS` (`Last_IO_Errno` / `Last_IO_Error`/`Last_IO_Error_Timestamp`) and the server error log, exactly as IO-thread errors are reported today. +- FR36. The memory path must populate `performance_schema.replication_applier_status_by_worker` (`LAST_ERROR_NUMBER`, `LAST_ERROR_MESSAGE`, `APPLYING_TRANSACTION`) in the same manner as the existing CSA design, so that failing transactions are identified by their GTID. +- FR37. On apply error, the failing transaction must be retained so the transaction can be inspected with event-dump tooling. For a memory path transaction, the transaction payload must be written to an error-diagnostic file in relaylog format; a spill path transaction is already on disk and its file is renamed and kept. +- FR37.1. The error-diagnostic file must be created under `#in_memory_relaylog_temp_files` in relaylog format. There is only one error diagnostic file per channel, and it must survive startup cleanup. It's removed only when the replication channel restarts or the channel is reset. +- FR38. An error diagnostic file created by the feature must have a name matching the pattern `imr_err__`, where `` is a lowercase channel identifier and `` is a lowercase identifier unique within `#in_memory_relaylog_temp_files` (imr_err stands for "In-Memory Relaylog Error"). +- FR39. Startup cleanup must not delete temp error diagnostic files, contrary to FR25. +- FR40. If the feature encounters a non-recoverable error, the user must be able to fall back to the standard disk path relaylog and resume replication without data loss by stopping the channel, setting `CHANGE REPLICATION SOURCE TO` with `IN_MEMORY_RELAYLOG_ENABLED = OFF`, and restarting it. + +### Non-Functional Requirements + +- NFR1. The feature must produce the same replication result as the disk path relay log. +- NFR2. The feature must produce the same recovery result regardless of the lifecycle events the server experiences, such as STOP/START REPLICA, server restart, or crash. +- NFR3. In recovery, the feature must use the same mechanism and reach the same outcome as a normal GTID-based replica (re-fetch by GTID), so that existing operational procedures continue to apply unchanged. +- NFR4. Channels that do not enable the feature must see no change in behavior, code path, or performance. +- NFR4.1. The worker decode and execution path must not change. Workers must receive the same event byte stream as the on disk relaylog. +- NFR5. Operators must account for the additional disk space used by the spill files under `#in_memory_relaylog_temp_files`, which is bounded by `relay_log_space_limit` as temp relaylog files created on spill path. +- NFR6. When `relay_log_space_limit` is set while any channel has in-memory relaylog enabled, the server must emit a warning, because its meaning on the spill path differs from the standard disk path. When `IN_MEMORY_RELAYLOG_ENABLED` is set to `ON`, it bounds only the disk usage of temp files on the spill path. +- NFR7. A `transaction envelope` is accessed concurrently by the receiver (appending events), the coordinator (dispatching it), and a worker (applying it); all of this concurrent access must be correctly synchronized, with no data races. +- NFR8. When parallel workers commit transactions out of source order (`replica_preserve_commit_order=OFF`), the queue and the memory-usage counter must stay consistent. There's no entry corruption, no lost or double-swept slots, and no miscounted bytes during commit process. +- NFR9. `STOP REPLICA` with in-flight jobs must be safe. It must not leak a payload's memory, free a payload twice, or leave a transaction applied halfway without rollback. +- NFR10. All of the feature settings are per-channel and persisted, following standard `CHANGE REPLICATION SOURCE TO` semantics. Options can only be configured while replication is stopped, take effect the next time the channel starts, and survive a server restart. +- NFR11. The feature must remain compatible with existing clients, replicas, tooling, and must not weaken data security. The feature does not alter the replication contents, and leaves the source, downstream replicas, and binlog/source tooling unaffected. +- NFR12. The feature must not require any change to the client-server or replication protocol. +- NFR13. The disk-path temp files and the error-diagnostic files must carry the same file permissions, ownership, and encryption as the channel's relaylog files, since they hold equally sensitive data. diff --git a/mysql-test/include/check-testcase.test b/mysql-test/include/check-testcase.test index eda515eacbe6..63e1073c50ab 100644 --- a/mysql-test/include/check-testcase.test +++ b/mysql-test/include/check-testcase.test @@ -107,6 +107,8 @@ if ($tmp) { --echo Source_public_key_path --echo Get_Source_public_key 0 --echo Network_Namespace + --echo In_Memory_Relay_Log_Space 0 + --echo In_Memory_Queue_Length 0 } if (!$tmp) { diff --git a/mysql-test/include/rpl/wait_for_no_imr_spill_files.inc b/mysql-test/include/rpl/wait_for_no_imr_spill_files.inc new file mode 100644 index 000000000000..b8509c380a40 --- /dev/null +++ b/mysql-test/include/rpl/wait_for_no_imr_spill_files.inc @@ -0,0 +1,63 @@ +# ==== Purpose ==== +# +# Wait until the in-memory relay log spill directory holds no spill files +# (basename pattern "imr_sp_*"). A committed spill transaction's file is deleted +# when its byte source is released (commit + applier job teardown), which is +# asynchronous to the transaction's GTID becoming visible on the replica. This +# helper polls the directory so tests can deterministically assert that spill +# files are reclaimed after commit. +# +# The enclosing "in_memory_relaylog_temp_files" directory itself persists and is +# not removed by this check. +# +# ==== Usage ==== +# +# --let $spill_dir_to_check= /path/to/.../in_memory_relaylog_temp_files +# [--let $spill_wait_timeout= 30] # seconds; default 30 +# --source include/rpl/wait_for_no_imr_spill_files.inc + +--let $include_filename= rpl/wait_for_no_imr_spill_files.inc +--source include/begin_include_file.inc + +if ($spill_dir_to_check == '') +{ + --die !!!ERROR IN TEST: you must set $spill_dir_to_check before sourcing wait_for_no_imr_spill_files.inc +} + +--let $_imr_spill_timeout= $spill_wait_timeout +if ($_imr_spill_timeout == '') +{ + --let $_imr_spill_timeout= 30 +} +# Poll at 0.1s granularity. +--let $_imr_spill_tries= `SELECT $_imr_spill_timeout * 10` + +--let $_imr_spill_ls_file= $MYSQL_TMP_DIR/imr_spill_ls.txt +--let $_imr_spill_files= not_empty_yet +while (`SELECT '$_imr_spill_files' <> ''`) +{ + --list_files_write_file $_imr_spill_ls_file $spill_dir_to_check imr_sp_* + --let $read_from_file= $_imr_spill_ls_file + --let $include_silent= 1 + --source include/read_file_to_var.inc + --let $include_silent= + --remove_file $_imr_spill_ls_file + --let $_imr_spill_files= $result + + if (`SELECT '$_imr_spill_files' <> ''`) + { + --dec $_imr_spill_tries + if (!$_imr_spill_tries) + { + --echo Leftover spill files in $spill_dir_to_check: + --echo $_imr_spill_files + --die Timed out waiting for in-memory relay log spill files to be deleted + } + --sleep 0.1 + } +} + +--echo # No spill files remain in the in-memory relay log temp directory. + +--let $include_filename= rpl/wait_for_no_imr_spill_files.inc +--source include/end_include_file.inc diff --git a/mysql-test/suite/rpl/r/rpl_change_master_to_require_row_format_syntax_and_pfs.result b/mysql-test/suite/rpl/r/rpl_change_master_to_require_row_format_syntax_and_pfs.result index a266460dad9d..69df019c5cb7 100644 --- a/mysql-test/suite/rpl/r/rpl_change_master_to_require_row_format_syntax_and_pfs.result +++ b/mysql-test/suite/rpl/r/rpl_change_master_to_require_row_format_syntax_and_pfs.result @@ -13,61 +13,61 @@ Warnings: Note #### Sending passwords in plain text without SSL/TLS is extremely insecure. Note #### Storing MySQL user name or password information in the connection metadata repository is not secure and is therefore not recommended. Please consider using the USER and PASSWORD connection options for START REPLICA; see the 'START REPLICA Syntax' in the MySQL Manual for more information. SELECT * FROM performance_schema.replication_applier_configuration; -CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT - 0 NULL YES STREAM OFF NULL 1 4 1073741824 +CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT IN_MEMORY_RELAYLOG_ENABLED IN_MEMORY_RELAYLOG_LIMIT IN_MEMORY_RELAYLOG_SPILL_THRESHOLD + 0 NULL YES STREAM OFF NULL 1 4 1073741824 NO 134217728 16777216 check_pfs.inc [Require_Row_Format column in performance_schema.replication_applier_configuration is set to 1 for server '2'] SELECT * FROM performance_schema.replication_applier_configuration; -CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT - 0 NULL YES STREAM OFF NULL 1 4 1073741824 +CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT IN_MEMORY_RELAYLOG_ENABLED IN_MEMORY_RELAYLOG_LIMIT IN_MEMORY_RELAYLOG_SPILL_THRESHOLD + 0 NULL YES STREAM OFF NULL 1 4 1073741824 NO 134217728 16777216 check_pfs.inc [Require_Row_Format column in performance_schema.replication_applier_configuration is set to 1 for server '3'] SELECT * FROM performance_schema.replication_applier_configuration; -CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT - 0 NULL YES STREAM OFF NULL 1 4 1073741824 +CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT IN_MEMORY_RELAYLOG_ENABLED IN_MEMORY_RELAYLOG_LIMIT IN_MEMORY_RELAYLOG_SPILL_THRESHOLD + 0 NULL YES STREAM OFF NULL 1 4 1073741824 NO 134217728 16777216 check_pfs.inc [Require_Row_Format column in performance_schema.replication_applier_configuration is set to 1 for server '4'] CHANGE REPLICATION SOURCE TO REQUIRE_ROW_FORMAT = 0; SELECT * FROM performance_schema.replication_applier_configuration; -CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT - 0 NULL NO STREAM OFF NULL 1 4 1073741824 +CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT IN_MEMORY_RELAYLOG_ENABLED IN_MEMORY_RELAYLOG_LIMIT IN_MEMORY_RELAYLOG_SPILL_THRESHOLD + 0 NULL NO STREAM OFF NULL 1 4 1073741824 NO 134217728 16777216 check_pfs.inc [Require_Row_Format column in performance_schema.replication_applier_configuration is set to 0 for server '2'] CHANGE REPLICATION SOURCE TO REQUIRE_ROW_FORMAT = 0; SELECT * FROM performance_schema.replication_applier_configuration; -CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT - 0 NULL NO STREAM OFF NULL 1 4 1073741824 +CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT IN_MEMORY_RELAYLOG_ENABLED IN_MEMORY_RELAYLOG_LIMIT IN_MEMORY_RELAYLOG_SPILL_THRESHOLD + 0 NULL NO STREAM OFF NULL 1 4 1073741824 NO 134217728 16777216 check_pfs.inc [Require_Row_Format column in performance_schema.replication_applier_configuration is set to 0 for server '3'] CHANGE REPLICATION SOURCE TO REQUIRE_ROW_FORMAT = 0; SELECT * FROM performance_schema.replication_applier_configuration; -CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT - 0 NULL NO STREAM OFF NULL 1 4 1073741824 +CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT IN_MEMORY_RELAYLOG_ENABLED IN_MEMORY_RELAYLOG_LIMIT IN_MEMORY_RELAYLOG_SPILL_THRESHOLD + 0 NULL NO STREAM OFF NULL 1 4 1073741824 NO 134217728 16777216 check_pfs.inc [Require_Row_Format column in performance_schema.replication_applier_configuration is set to 0 for server '4'] CHANGE REPLICATION SOURCE TO REQUIRE_ROW_FORMAT = 1; SELECT * FROM performance_schema.replication_applier_configuration; -CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT - 0 NULL YES STREAM OFF NULL 1 4 1073741824 +CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT IN_MEMORY_RELAYLOG_ENABLED IN_MEMORY_RELAYLOG_LIMIT IN_MEMORY_RELAYLOG_SPILL_THRESHOLD + 0 NULL YES STREAM OFF NULL 1 4 1073741824 NO 134217728 16777216 check_pfs.inc [Require_Row_Format column in performance_schema.replication_applier_configuration is set to 1 for server '2'] CHANGE REPLICATION SOURCE TO REQUIRE_ROW_FORMAT = 1; SELECT * FROM performance_schema.replication_applier_configuration; -CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT - 0 NULL YES STREAM OFF NULL 1 4 1073741824 +CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT IN_MEMORY_RELAYLOG_ENABLED IN_MEMORY_RELAYLOG_LIMIT IN_MEMORY_RELAYLOG_SPILL_THRESHOLD + 0 NULL YES STREAM OFF NULL 1 4 1073741824 NO 134217728 16777216 check_pfs.inc [Require_Row_Format column in performance_schema.replication_applier_configuration is set to 1 for server '3'] CHANGE REPLICATION SOURCE TO REQUIRE_ROW_FORMAT = 1; SELECT * FROM performance_schema.replication_applier_configuration; -CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT - 0 NULL YES STREAM OFF NULL 1 4 1073741824 +CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT IN_MEMORY_RELAYLOG_ENABLED IN_MEMORY_RELAYLOG_LIMIT IN_MEMORY_RELAYLOG_SPILL_THRESHOLD + 0 NULL YES STREAM OFF NULL 1 4 1073741824 NO 134217728 16777216 check_pfs.inc [Require_Row_Format column in performance_schema.replication_applier_configuration is set to 1 for server '4'] RESET REPLICA; SELECT * FROM performance_schema.replication_applier_configuration; -CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT - 0 NULL YES STREAM OFF NULL 1 4 1073741824 +CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT IN_MEMORY_RELAYLOG_ENABLED IN_MEMORY_RELAYLOG_LIMIT IN_MEMORY_RELAYLOG_SPILL_THRESHOLD + 0 NULL YES STREAM OFF NULL 1 4 1073741824 NO 134217728 16777216 check_pfs.inc [Require_Row_Format column in performance_schema.replication_applier_configuration is set to 1 for server '2'] RESET REPLICA; SELECT * FROM performance_schema.replication_applier_configuration; -CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT - 0 NULL YES STREAM OFF NULL 1 4 1073741824 +CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT IN_MEMORY_RELAYLOG_ENABLED IN_MEMORY_RELAYLOG_LIMIT IN_MEMORY_RELAYLOG_SPILL_THRESHOLD + 0 NULL YES STREAM OFF NULL 1 4 1073741824 NO 134217728 16777216 check_pfs.inc [Require_Row_Format column in performance_schema.replication_applier_configuration is set to 1 for server '3'] RESET REPLICA; SELECT * FROM performance_schema.replication_applier_configuration; -CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT - 0 NULL YES STREAM OFF NULL 1 4 1073741824 +CHANNEL_NAME DESIRED_DELAY PRIVILEGE_CHECKS_USER REQUIRE_ROW_FORMAT REQUIRE_TABLE_PRIMARY_KEY_CHECK ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_TYPE ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_VALUE APPLIER_VERSION APPLIER_WORKER_COUNT APPLIER_EVENT_MEMORY_LIMIT IN_MEMORY_RELAYLOG_ENABLED IN_MEMORY_RELAYLOG_LIMIT IN_MEMORY_RELAYLOG_SPILL_THRESHOLD + 0 NULL YES STREAM OFF NULL 1 4 1073741824 NO 134217728 16777216 check_pfs.inc [Require_Row_Format column in performance_schema.replication_applier_configuration is set to 1 for server '4'] CHANGE REPLICATION SOURCE TO REQUIRE_ROW_FORMAT = 1; ERROR HY000: This operation cannot be performed with running replication threads; run STOP REPLICA FOR CHANNEL '' first diff --git a/mysql-test/suite/rpl/r/rpl_row_crash_safe.result b/mysql-test/suite/rpl/r/rpl_row_crash_safe.result index 4d6acb3db7a3..ff20e95b0273 100644 --- a/mysql-test/suite/rpl/r/rpl_row_crash_safe.result +++ b/mysql-test/suite/rpl/r/rpl_row_crash_safe.result @@ -32,6 +32,7 @@ slave_relay_log_info CREATE TABLE `slave_relay_log_info` ( `Applier_version` int unsigned NOT NULL DEFAULT '1' COMMENT 'Version of the applier used (either 1 or 2)', `Applier_worker_count` int unsigned NOT NULL DEFAULT '0' COMMENT 'Number of worker threads utilized by the applier', `Applier_event_memory_limit` int unsigned NOT NULL DEFAULT '1073741824' COMMENT 'The maximum amount of memory applier channel may use to cache binlog events', + `In_memory_relaylog` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Indicates whether the channel uses the in-memory relay log instead of writing relay log files to disk.', PRIMARY KEY (`Channel_name`) ) /*!50100 TABLESPACE `mysql` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 STATS_PERSISTENT=0 ROW_FORMAT=DYNAMIC COMMENT='Relay Log Information' SHOW CREATE TABLE mysql.slave_worker_info; @@ -75,6 +76,7 @@ slave_relay_log_info CREATE TABLE `slave_relay_log_info` ( `Applier_version` int unsigned NOT NULL DEFAULT '1' COMMENT 'Version of the applier used (either 1 or 2)', `Applier_worker_count` int unsigned NOT NULL DEFAULT '0' COMMENT 'Number of worker threads utilized by the applier', `Applier_event_memory_limit` int unsigned NOT NULL DEFAULT '1073741824' COMMENT 'The maximum amount of memory applier channel may use to cache binlog events', + `In_memory_relaylog` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Indicates whether the channel uses the in-memory relay log instead of writing relay log files to disk.', PRIMARY KEY (`Channel_name`) ) /*!50100 TABLESPACE `mysql` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 STATS_PERSISTENT=0 ROW_FORMAT=DYNAMIC COMMENT='Relay Log Information' SHOW CREATE TABLE mysql.slave_worker_info; diff --git a/mysql-test/suite/rpl/r/rpl_row_mts_rec_crash_safe.result b/mysql-test/suite/rpl/r/rpl_row_mts_rec_crash_safe.result index 89cadd542ba4..6c18aa7c236e 100644 --- a/mysql-test/suite/rpl/r/rpl_row_mts_rec_crash_safe.result +++ b/mysql-test/suite/rpl/r/rpl_row_mts_rec_crash_safe.result @@ -385,6 +385,7 @@ slave_relay_log_info CREATE TABLE `slave_relay_log_info` ( `Applier_version` int unsigned NOT NULL DEFAULT '1' COMMENT 'Version of the applier used (either 1 or 2)', `Applier_worker_count` int unsigned NOT NULL DEFAULT '0' COMMENT 'Number of worker threads utilized by the applier', `Applier_event_memory_limit` int unsigned NOT NULL DEFAULT '1073741824' COMMENT 'The maximum amount of memory applier channel may use to cache binlog events', + `In_memory_relaylog` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Indicates whether the channel uses the in-memory relay log instead of writing relay log files to disk.', PRIMARY KEY (`Channel_name`) ) /*!50100 TABLESPACE `mysql` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 STATS_PERSISTENT=0 ROW_FORMAT=DYNAMIC COMMENT='Relay Log Information' SHOW CREATE TABLE mysql.slave_worker_info; @@ -467,6 +468,7 @@ slave_relay_log_info CREATE TABLE `slave_relay_log_info` ( `Applier_version` int unsigned NOT NULL DEFAULT '1' COMMENT 'Version of the applier used (either 1 or 2)', `Applier_worker_count` int unsigned NOT NULL DEFAULT '0' COMMENT 'Number of worker threads utilized by the applier', `Applier_event_memory_limit` int unsigned NOT NULL DEFAULT '1073741824' COMMENT 'The maximum amount of memory applier channel may use to cache binlog events', + `In_memory_relaylog` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Indicates whether the channel uses the in-memory relay log instead of writing relay log files to disk.', PRIMARY KEY (`Channel_name`) ) /*!50100 TABLESPACE `mysql` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 STATS_PERSISTENT=0 ROW_FORMAT=DYNAMIC COMMENT='Relay Log Information' SHOW CREATE TABLE mysql.slave_worker_info; diff --git a/mysql-test/suite/rpl/r/rpl_stm_mixed_crash_safe.result b/mysql-test/suite/rpl/r/rpl_stm_mixed_crash_safe.result index 00f43c86c5ac..0a4e8c4b519c 100644 --- a/mysql-test/suite/rpl/r/rpl_stm_mixed_crash_safe.result +++ b/mysql-test/suite/rpl/r/rpl_stm_mixed_crash_safe.result @@ -28,6 +28,7 @@ slave_relay_log_info CREATE TABLE `slave_relay_log_info` ( `Applier_version` int unsigned NOT NULL DEFAULT '1' COMMENT 'Version of the applier used (either 1 or 2)', `Applier_worker_count` int unsigned NOT NULL DEFAULT '0' COMMENT 'Number of worker threads utilized by the applier', `Applier_event_memory_limit` int unsigned NOT NULL DEFAULT '1073741824' COMMENT 'The maximum amount of memory applier channel may use to cache binlog events', + `In_memory_relaylog` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Indicates whether the channel uses the in-memory relay log instead of writing relay log files to disk.', PRIMARY KEY (`Channel_name`) ) /*!50100 TABLESPACE `mysql` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 STATS_PERSISTENT=0 ROW_FORMAT=DYNAMIC COMMENT='Relay Log Information' SHOW CREATE TABLE mysql.slave_worker_info; @@ -71,6 +72,7 @@ slave_relay_log_info CREATE TABLE `slave_relay_log_info` ( `Applier_version` int unsigned NOT NULL DEFAULT '1' COMMENT 'Version of the applier used (either 1 or 2)', `Applier_worker_count` int unsigned NOT NULL DEFAULT '0' COMMENT 'Number of worker threads utilized by the applier', `Applier_event_memory_limit` int unsigned NOT NULL DEFAULT '1073741824' COMMENT 'The maximum amount of memory applier channel may use to cache binlog events', + `In_memory_relaylog` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Indicates whether the channel uses the in-memory relay log instead of writing relay log files to disk.', PRIMARY KEY (`Channel_name`) ) /*!50100 TABLESPACE `mysql` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 STATS_PERSISTENT=0 ROW_FORMAT=DYNAMIC COMMENT='Relay Log Information' SHOW CREATE TABLE mysql.slave_worker_info; diff --git a/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_crst.result b/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_crst.result new file mode 100644 index 000000000000..ec2719d76952 --- /dev/null +++ b/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_crst.result @@ -0,0 +1,93 @@ +include/rpl/init_source_replica.inc +Warnings: +Note #### Sending passwords in plain text without SSL/TLS is extremely insecure. +Note #### Storing MySQL user name or password information in the connection metadata repository is not secure and is therefore not recommended. Please consider using the USER and PASSWORD connection options for START REPLICA; see the 'START REPLICA Syntax' in the MySQL Manual for more information. +[connection master] +[connection slave] +include/rpl/start_replica.inc +include/rpl/stop_replica.inc + +# 1. IMR metadata defaults on a freshly created channel: the feature is +# OFF, and the limit / spill-threshold columns carry their default +# values (inert while the feature is disabled). + +include/assert.inc [IN_MEMORY_RELAYLOG is OFF by default in mysql.slave_relay_log_info] +include/assert.inc [IN_MEMORY_RELAYLOG_LIMIT defaults to 134217728 in mysql.slave_relay_log_info] +include/assert.inc [IN_MEMORY_RELAYLOG_SPILL_THRESHOLD defaults to 16777216 in mysql.slave_relay_log_info] +# PFS view of the IMR defaults: +SELECT IN_MEMORY_RELAYLOG_ENABLED, IN_MEMORY_RELAYLOG_LIMIT, IN_MEMORY_RELAYLOG_SPILL_THRESHOLD +FROM performance_schema.replication_applier_configuration; +IN_MEMORY_RELAYLOG_ENABLED IN_MEMORY_RELAYLOG_LIMIT IN_MEMORY_RELAYLOG_SPILL_THRESHOLD +NO 134217728 16777216 + +# 2. IMR options are rejected on a non-CSA (MTA) channel (R1). + +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = ON; +ERROR HY000: The in-memory relay log is only available on a CSA-enabled channel. +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_LIMIT = 67108864; +ERROR HY000: The in-memory relay log is only available on a CSA-enabled channel. +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_SPILL_THRESHOLD = 12582912; +ERROR HY000: The in-memory relay log is only available on a CSA-enabled channel. +# Disabling IMR on a non-CSA channel is allowed (only enabling is gated). +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = OFF; + +# 3. Switch the channel to CSA, then reject invalid ranges / ordering (R2). + +CHANGE REPLICATION SOURCE TO APPLIER_VERSION = 2, REQUIRE_ROW_FORMAT = 1, GTID_ONLY = 1; +# LIMIT below the 32 MiB floor. +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_LIMIT = 33554431; +ERROR HY000: Invalid in-memory relay log configuration: IN_MEMORY_RELAYLOG_LIMIT (33554431 bytes) must be between 33554432 and 4294967296 bytes +# LIMIT above the 4 GiB ceiling. +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_LIMIT = 4294967297; +ERROR HY000: Invalid in-memory relay log configuration: IN_MEMORY_RELAYLOG_LIMIT (4294967297 bytes) must be between 33554432 and 4294967296 bytes +# SPILL_THRESHOLD below the 8 MiB floor. +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_SPILL_THRESHOLD = 8388607; +ERROR HY000: Invalid in-memory relay log configuration: IN_MEMORY_RELAYLOG_SPILL_THRESHOLD (8388607 bytes) must be at least 8388608 bytes +# SPILL_THRESHOLD alone >= persisted LIMIT (default 134217728). +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_SPILL_THRESHOLD = 134217728; +ERROR HY000: Invalid in-memory relay log configuration: IN_MEMORY_RELAYLOG_LIMIT (134217728 bytes) must be greater than IN_MEMORY_RELAYLOG_SPILL_THRESHOLD (134217728 bytes) +# Both together, valid individual ranges but SPILL_THRESHOLD == LIMIT. +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_LIMIT = 33554432, IN_MEMORY_RELAYLOG_SPILL_THRESHOLD = 33554432; +ERROR HY000: Invalid in-memory relay log configuration: IN_MEMORY_RELAYLOG_LIMIT (33554432 bytes) must be greater than IN_MEMORY_RELAYLOG_SPILL_THRESHOLD (33554432 bytes) +# Both together, SPILL_THRESHOLD > LIMIT. +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_LIMIT = 33554432, IN_MEMORY_RELAYLOG_SPILL_THRESHOLD = 67108864; +ERROR HY000: Invalid in-memory relay log configuration: IN_MEMORY_RELAYLOG_LIMIT (33554432 bytes) must be greater than IN_MEMORY_RELAYLOG_SPILL_THRESHOLD (67108864 bytes) + +# 4. Apply a valid IMR configuration and assert it is persisted (R5). + +CHANGE REPLICATION SOURCE TO +IN_MEMORY_RELAYLOG_ENABLED = ON, +IN_MEMORY_RELAYLOG_LIMIT = 268435456, +IN_MEMORY_RELAYLOG_SPILL_THRESHOLD = 33554432; +include/assert.inc [IN_MEMORY_RELAYLOG is ON in mysql.slave_relay_log_info] +include/assert.inc [IN_MEMORY_RELAYLOG_LIMIT is 268435456 in mysql.slave_relay_log_info] +include/assert.inc [IN_MEMORY_RELAYLOG_SPILL_THRESHOLD is 33554432 in mysql.slave_relay_log_info] +# PFS view of the applied IMR configuration: +SELECT IN_MEMORY_RELAYLOG_ENABLED, IN_MEMORY_RELAYLOG_LIMIT, IN_MEMORY_RELAYLOG_SPILL_THRESHOLD +FROM performance_schema.replication_applier_configuration; +IN_MEMORY_RELAYLOG_ENABLED IN_MEMORY_RELAYLOG_LIMIT IN_MEMORY_RELAYLOG_SPILL_THRESHOLD +YES 268435456 33554432 + +# 5. IMR options cannot be changed while replication is running (R4). + +include/rpl/start_replica.inc +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_LIMIT = 134217728; +ERROR HY000: This operation cannot be performed with running replication threads; run STOP REPLICA FOR CHANNEL '' first +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = OFF; +ERROR HY000: This operation cannot be performed with running replication threads; run STOP REPLICA FOR CHANNEL '' first +include/rpl/stop_replica.inc + +# 6. The valid configuration survives a server restart (R5). + +include/rpl/restart_server.inc [server_number=2] +[connection slave] +include/assert.inc [IN_MEMORY_RELAYLOG survived restart in mysql.slave_relay_log_info] +include/assert.inc [IN_MEMORY_RELAYLOG survived restart in replication_applier_configuration] + +# 7. RESET REPLICA keeps the configuration; RESET REPLICA ALL clears it (R5). + +RESET REPLICA; +include/assert.inc [RESET REPLICA keeps the IN_MEMORY_RELAYLOG configuration] +RESET REPLICA ALL; +include/assert.inc [RESET REPLICA ALL clears the channel row from mysql.slave_relay_log_info] +include/rpl/deinit.inc diff --git a/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_disabled_fallback.result b/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_disabled_fallback.result new file mode 100644 index 000000000000..18fd449115e5 --- /dev/null +++ b/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_disabled_fallback.result @@ -0,0 +1,58 @@ +include/rpl/init_source_replica.inc +Warnings: +Note #### Sending passwords in plain text without SSL/TLS is extremely insecure. +Note #### Storing MySQL user name or password information in the connection metadata repository is not secure and is therefore not recommended. Please consider using the USER and PASSWORD connection options for START REPLICA; see the 'START REPLICA Syntax' in the MySQL Manual for more information. +[connection master] + +# 1. CSA channel with IMR OFF (the default) uses the classic relay-log +# path: relay logs on disk, IMR SHOW REPLICA STATUS columns zero. + +[connection slave] +include/assert.inc [IMR is OFF by default on the CSA channel] +include/assert.inc [In_Memory_Relay_Log_Space is 0 while IMR is OFF] +include/assert.inc [In_Memory_Queue_Length is 0 while IMR is OFF] + +# 2. Apply a workload with IMR OFF and verify data equality. + +[connection master] +CREATE TABLE t1 (id INT PRIMARY KEY, v VARCHAR(50)); +INSERT INTO t1 VALUES (1, 'off-a'), (2, 'off-b'), (3, 'off-c'); +include/rpl/sync_to_replica.inc +include/diff_tables.inc [master:test.t1, slave:test.t1] +# Classic path: relay logs are present on disk while IMR is OFF. +[connection slave] +include/assert.inc [Relay_Log_Space is greater than 0 (classic relay logs in use)] + +# 3. Toggle IMR ON (replication stopped for the change), apply, verify. + +include/rpl/stop_replica.inc +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = ON; +include/rpl/start_replica.inc +include/assert.inc [IMR is ON after the toggle] +[connection master] +INSERT INTO t1 VALUES (4, 'on-a'), (5, 'on-b'), (6, 'on-c'); +include/rpl/sync_to_replica.inc +include/diff_tables.inc [master:test.t1, slave:test.t1] + +# 4. Toggle IMR back OFF, apply, verify equality and the restored classic +# path with zeroed IMR columns (non-destructive fallback). + +[connection slave] +include/rpl/stop_replica.inc +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = OFF; +include/rpl/start_replica.inc +[connection master] +INSERT INTO t1 VALUES (7, 'off2-a'), (8, 'off2-b'), (9, 'off2-c'); +include/rpl/sync_to_replica.inc +include/diff_tables.inc [master:test.t1, slave:test.t1] +[connection slave] +include/assert.inc [In_Memory_Relay_Log_Space is 0 again after falling back to OFF] +include/assert.inc [In_Memory_Queue_Length is 0 again after falling back to OFF] +[connection master] +DROP TABLE t1; +include/rpl/sync_to_replica.inc +[connection slave] +include/rpl/stop_replica.inc +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = OFF; +include/rpl/start_replica.inc +include/rpl/deinit.inc diff --git a/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_memory_limit.result b/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_memory_limit.result new file mode 100644 index 000000000000..a22f5924b5ae --- /dev/null +++ b/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_memory_limit.result @@ -0,0 +1,48 @@ +include/rpl/init_source_replica.inc +Warnings: +Note #### Sending passwords in plain text without SSL/TLS is extremely insecure. +Note #### Storing MySQL user name or password information in the connection metadata repository is not secure and is therefore not recommended. Please consider using the USER and PASSWORD connection options for START REPLICA; see the 'START REPLICA Syntax' in the MySQL Manual for more information. +[connection master] + +# 1. Enable IMR at the 32 MiB limit floor with an 8 MiB spill threshold +# (transactions below 8 MiB stay on the memory path) and start. + +[connection slave] +CHANGE REPLICATION SOURCE TO +IN_MEMORY_RELAYLOG_ENABLED = ON, +IN_MEMORY_RELAYLOG_LIMIT = 33554432, +IN_MEMORY_RELAYLOG_SPILL_THRESHOLD = 8388608; +include/rpl/start_replica.inc +[connection master] +CREATE TABLE t1 (id INT PRIMARY KEY AUTO_INCREMENT, data LONGBLOB); +include/rpl/sync_to_replica.inc + +# 2. Pause the applier and run a workload larger than the memory limit. +# 40 x ~1 MiB memory-path transactions (~40 MiB) exceed the 32 MiB +# limit, so the receiver cannot hold them all at once. + +[connection slave] +include/rpl/stop_applier.inc +[connection master] + +# 3. The receiver fills the queue toward the limit and blocks (applier +# paused). Usage stays within the limit; not all transactions fit. + +[connection slave] +include/assert.inc [In_Memory_Relay_Log_Space stays within IN_MEMORY_RELAYLOG_LIMIT while the queue is saturated] +include/assert.inc [Back-pressure kept fewer than the whole workload queued while the applier was paused] + +# 4. Resume the applier: the queue drains, the receiver unblocks, all +# data applies with no loss, and the memory usage returns to 0. + +include/rpl/start_applier.inc +[connection master] +include/rpl/sync_to_replica.inc +include/rpl/diff.inc +[connection slave] +include/assert.inc [All 40 transactions were applied on the replica] +include/rpl/wait_for_replica_status.inc [In_Memory_Relay_Log_Space] +[connection master] +DROP TABLE t1; +include/rpl/sync_to_replica.inc +include/rpl/deinit.inc diff --git a/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_memory_path.result b/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_memory_path.result new file mode 100644 index 000000000000..84d7aeedaed8 --- /dev/null +++ b/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_memory_path.result @@ -0,0 +1,57 @@ +include/rpl/init_source_replica.inc +Warnings: +Note #### Sending passwords in plain text without SSL/TLS is extremely insecure. +Note #### Storing MySQL user name or password information in the connection metadata repository is not secure and is therefore not recommended. Please consider using the USER and PASSWORD connection options for START REPLICA; see the 'START REPLICA Syntax' in the MySQL Manual for more information. +[connection master] + +# 1. Enable the in-memory relay log on the CSA channel and start. + +[connection slave] +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = ON; +include/assert.inc [The channel reports the in-memory relay log as enabled] +include/rpl/start_replica.inc + +# 2. A mixed DML/DDL workload replicates correctly through the memory path. + +[connection master] +CREATE TABLE t1 (a INT PRIMARY KEY, b VARCHAR(50)); +CREATE TABLE t2 (id INT PRIMARY KEY AUTO_INCREMENT, v INT); +INSERT INTO t1 VALUES (1, 'one'), (2, 'two'), (3, 'three'); +BEGIN; +INSERT INTO t2 (v) VALUES (10), (20), (30); +UPDATE t1 SET b = 'ONE' WHERE a = 1; +COMMIT; +DELETE FROM t1 WHERE a = 3; +ALTER TABLE t1 ADD COLUMN c INT DEFAULT 0; +UPDATE t2 SET v = v + 1; +include/rpl/sync_to_replica.inc +include/rpl/diff.inc +include/rpl/diff.inc + +# 3. The queue memory drains to 0 once everything is applied. +# (Bytes are released at commit; empty envelopes may linger unswept +# behind an idle coordinator, so In_Memory_Relay_Log_Space -- not +# In_Memory_Queue_Length -- is the reliable "all committed" signal.) + +[connection slave] +include/rpl/wait_for_replica_status.inc [In_Memory_Relay_Log_Space] + +# 4. With the applier stopped, received transactions make the metrics +# non-zero; restarting the applier drains them and the data matches. + +[connection slave] +include/rpl/stop_applier.inc +[connection master] +include/rpl/sync_to_replica_received.inc +include/assert.inc [In_Memory_Queue_Length grew by exactly 20 queued transactions while the applier was stopped] +include/assert.inc [In_Memory_Relay_Log_Space is non-zero while transactions are queued] +include/rpl/start_applier.inc +[connection master] +include/rpl/sync_to_replica.inc +include/rpl/diff.inc +[connection slave] +include/rpl/wait_for_replica_status.inc [In_Memory_Relay_Log_Space] +[connection master] +DROP TABLE t1, t2; +include/rpl/sync_to_replica.inc +include/rpl/deinit.inc diff --git a/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_recovery_restart.result b/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_recovery_restart.result new file mode 100644 index 000000000000..adfffe30a1ba --- /dev/null +++ b/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_recovery_restart.result @@ -0,0 +1,68 @@ +include/rpl/init_source_replica.inc +Warnings: +Note #### Sending passwords in plain text without SSL/TLS is extremely insecure. +Note #### Storing MySQL user name or password information in the connection metadata repository is not secure and is therefore not recommended. Please consider using the USER and PASSWORD connection options for START REPLICA; see the 'START REPLICA Syntax' in the MySQL Manual for more information. +[connection master] + +# 1. Enable IMR on the CSA channel, start replication, apply W1. + +[connection slave] +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = ON; +include/rpl/start_replica.inc +include/assert.inc [IMR is ON on the CSA channel] +[connection master] +CREATE TABLE t1 (id INT PRIMARY KEY, v VARCHAR(50)); +INSERT INTO t1 VALUES (1, 'w1-a'), (2, 'w1-b'), (3, 'w1-c'); +include/rpl/sync_to_replica.inc +include/diff_tables.inc [master:test.t1, slave:test.t1] + +# 2. Stop only the applier; the receiver keeps running. + +[connection slave] +include/rpl/stop_applier.inc + +# 3. Produce W2 on the source; the receiver queues it in memory +# (unapplied). The in-memory queue now holds uncommitted envelopes. + +[connection master] +INSERT INTO t1 VALUES (4, 'w2-a'); +INSERT INTO t1 VALUES (5, 'w2-b'); +INSERT INTO t1 VALUES (6, 'w2-c'); +include/rpl/sync_to_replica_received.inc +[connection slave] +include/assert.inc [In_Memory_Queue_Length is greater than 0 with W2 received but unapplied] + +# 4. Abruptly restart (crash) the replica: the volatile queue is lost. + +include/rpl/restart_server.inc [server_number=2] +[connection slave] + +# 5. After the restart the in-memory queue is rebuilt empty (R1). + +include/assert.inc [In_Memory_Queue_Length is 0 after the restart (queue rebuilt empty)] +include/assert.inc [In_Memory_Relay_Log_Space is 0 after the restart] +# IMR is still enabled on the channel after the restart. +include/assert.inc [IMR remains ON after the restart] + +# 6. START REPLICA: auto-positioning re-fetches W2; the replica converges +# (R2). W2 was never committed, so it is re-fetched by GTID. + +include/rpl/start_replica.inc +[connection master] +include/rpl/sync_to_replica.inc +include/diff_tables.inc [master:test.t1, slave:test.t1] + +# 7. Normal IMR replication resumes: apply W3 and verify (R3). + +[connection master] +INSERT INTO t1 VALUES (7, 'w3-a'), (8, 'w3-b'), (9, 'w3-c'); +include/rpl/sync_to_replica.inc +include/diff_tables.inc [master:test.t1, slave:test.t1] +[connection master] +DROP TABLE t1; +include/rpl/sync_to_replica.inc +[connection slave] +include/rpl/stop_replica.inc +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = OFF; +include/rpl/start_replica.inc +include/rpl/deinit.inc diff --git a/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_semisync_incompatible.result b/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_semisync_incompatible.result new file mode 100644 index 000000000000..da22b8bd36ba --- /dev/null +++ b/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_semisync_incompatible.result @@ -0,0 +1,41 @@ +include/rpl/init_source_replica.inc +Warnings: +Note #### Sending passwords in plain text without SSL/TLS is extremely insecure. +Note #### Storing MySQL user name or password information in the connection metadata repository is not secure and is therefore not recommended. Please consider using the USER and PASSWORD connection options for START REPLICA; see the 'START REPLICA Syntax' in the MySQL Manual for more information. +[connection master] +[connection slave] +CALL mtr.add_suppression("Plugin semisync reported.*Source server does not support semi-sync.*"); + +# 1. Install and enable the replica-side semisync plugin (threads stopped). + +INSTALL PLUGIN rpl_semi_sync_replica SONAME 'semisync_replica.so'; +SET GLOBAL rpl_semi_sync_replica_enabled = 1; + +# 2. Enable IMR on the CSA channel. + +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = ON; + +# 3. START REPLICA IO_THREAD is rejected (R1). + +START REPLICA IO_THREAD; +ERROR HY000: The in-memory relay log is incompatible with semi-synchronous replication and cannot be used while it is active. + +# 4. START REPLICA (both threads) is rejected for the same reason (R2). + +START REPLICA; +ERROR HY000: The in-memory relay log is incompatible with semi-synchronous replication and cannot be used while it is active. + +# 5. Disabling IMR clears the incompatibility: the channel starts even +# with the semisync replica plugin still enabled (R3). + +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = OFF; +include/rpl/start_replica.inc +include/rpl/stop_replica.inc + +# 6. Cleanup. + +SET GLOBAL rpl_semi_sync_replica_enabled = 0; +UNINSTALL PLUGIN rpl_semi_sync_replica; +include/rpl/deinit.inc +Warnings: +Note 3084 Replication thread(s) for channel '' are already stopped. diff --git a/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_spill_path.result b/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_spill_path.result new file mode 100644 index 000000000000..60d2866308dd --- /dev/null +++ b/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_spill_path.result @@ -0,0 +1,61 @@ +include/rpl/init_source_replica.inc +Warnings: +Note #### Sending passwords in plain text without SSL/TLS is extremely insecure. +Note #### Storing MySQL user name or password information in the connection metadata repository is not secure and is therefore not recommended. Please consider using the USER and PASSWORD connection options for START REPLICA; see the 'START REPLICA Syntax' in the MySQL Manual for more information. +[connection master] + +# 1. Enable IMR with the minimum 8 MiB spill threshold and start. +# Any transaction larger than 8 MiB takes the spill path; smaller +# ones stay on the memory path (default 128 MiB limit). + +[connection slave] +CHANGE REPLICATION SOURCE TO +IN_MEMORY_RELAYLOG_ENABLED = ON, +IN_MEMORY_RELAYLOG_SPILL_THRESHOLD = 8388608; +include/rpl/start_replica.inc +[connection master] +CREATE TABLE t1 (id INT PRIMARY KEY, data LONGBLOB); +include/rpl/sync_to_replica.inc + +# 2. With the applier stopped, a >threshold transaction is received and +# written to a spill file that persists while uncommitted. + +[connection slave] +include/rpl/stop_applier.inc +[connection master] +BEGIN; +COMMIT; +include/rpl/sync_to_replica_received.inc +[connection slave] +# The dedicated spill subdirectory exists. +# Exactly one imr_sp_ file is present for the uncommitted spill transaction: +imr_sp_ + +# 3. Start the applier: the transaction commits, data matches, and the +# spill file is removed. + +include/rpl/start_applier.inc +[connection master] +include/rpl/sync_to_replica.inc +include/rpl/diff.inc +[connection slave] +include/rpl/wait_for_no_imr_spill_files.inc +# No spill files remain in the in-memory relay log temp directory. + +# 4. A mixed small (memory) + large (spill) workload replicates correctly +# and leaves no spill files behind. + +[connection master] +INSERT INTO t1 VALUES (100, 'small-a'); +BEGIN; +COMMIT; +INSERT INTO t1 VALUES (300, 'small-b'); +include/rpl/sync_to_replica.inc +include/rpl/diff.inc +[connection slave] +include/rpl/wait_for_no_imr_spill_files.inc +# No spill files remain in the in-memory relay log temp directory. +[connection master] +DROP TABLE t1; +include/rpl/sync_to_replica.inc +include/rpl/deinit.inc diff --git a/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_stop_start_lifecycle.result b/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_stop_start_lifecycle.result new file mode 100644 index 000000000000..8bcd11643cc4 --- /dev/null +++ b/mysql-test/suite/rpl_gtid/r/rpl_csa_imr_stop_start_lifecycle.result @@ -0,0 +1,83 @@ + +# 1. Set up a CSA channel with the in-memory relay log enabled. + +include/rpl/init_source_replica.inc +Warnings: +Note #### Sending passwords in plain text without SSL/TLS is extremely insecure. +Note #### Storing MySQL user name or password information in the connection metadata repository is not secure and is therefore not recommended. Please consider using the USER and PASSWORD connection options for START REPLICA; see the 'START REPLICA Syntax' in the MySQL Manual for more information. +[connection master] +[connection slave] +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = ON; +include/rpl/start_replica.inc +[connection master] +CREATE TABLE t1 (id INT PRIMARY KEY); +INSERT INTO t1 VALUES (1), (2), (3); +include/rpl/sync_to_replica.inc +include/diff_tables.inc [master:test.t1, slave:test.t1] + +# 2. STOP REPLICA SQL_THREAD: the receiver keeps running and the queue +# retains the uncommitted transactions; START re-dispatches them. + +[connection slave] +include/rpl/stop_applier.inc +[connection master] +include/rpl/sync_to_replica_received.inc +[connection slave] +include/assert.inc [Uncommitted transactions are retained in the in-memory queue while the applier is stopped] +include/rpl/start_applier.inc +[connection master] +include/rpl/sync_to_replica.inc +include/diff_tables.inc [master:test.t1, slave:test.t1] + +# 3. STOP REPLICA IO_THREAD: the applier keeps running and applies the +# already-received transactions; START IO_THREAD resumes reception. + +[connection master] +include/rpl/sync_to_replica_received.inc +[connection slave] +include/rpl/stop_receiver.inc +include/assert.inc [The applier keeps running after STOP REPLICA IO_THREAD] +[connection master] +include/rpl/sync_to_replica.inc +include/diff_tables.inc [master:test.t1, slave:test.t1] +[connection slave] +include/rpl/start_receiver.inc +[connection master] +INSERT INTO t1 VALUES (44), (45), (46); +include/rpl/sync_to_replica.inc +include/diff_tables.inc [master:test.t1, slave:test.t1] + +# 4. STOP REPLICA (both): the queue is reset; START REPLICA re-fetches by +# GTID auto-positioning and stays consistent. + +[connection slave] +include/rpl/stop_replica.inc +include/assert.inc [A full STOP REPLICA resets the in-memory queue to empty] +[connection master] +INSERT INTO t1 VALUES (47), (48), (49); +[connection slave] +include/rpl/start_replica.inc +[connection master] +include/rpl/sync_to_replica.inc +include/diff_tables.inc [master:test.t1, slave:test.t1] + +# 5. Normal server restart: the queue starts empty and replication +# resumes consistently. + +[connection slave] +include/rpl/stop_replica.inc +include/rpl/restart_server.inc [server_number=2] +[connection slave] +include/assert.inc [The in-memory queue starts empty after a server restart] +include/rpl/start_replica.inc +[connection master] +INSERT INTO t1 VALUES (50), (51), (52); +include/rpl/sync_to_replica.inc +include/diff_tables.inc [master:test.t1, slave:test.t1] + +# 6. Cleanup. + +[connection master] +DROP TABLE t1; +include/rpl/sync_to_replica.inc +include/rpl/deinit.inc diff --git a/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_crst.test b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_crst.test new file mode 100644 index 000000000000..6b92e979ea7a --- /dev/null +++ b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_crst.test @@ -0,0 +1,218 @@ +# ==== Purpose ==== +# +# Test CHANGE REPLICATION SOURCE TO validation and persistence for the +# in-memory relay log (IMR) options on a CSA (Change Stream Applier) channel: +# +# IN_MEMORY_RELAYLOG_ENABLED +# IN_MEMORY_RELAYLOG_LIMIT +# IN_MEMORY_RELAYLOG_SPILL_THRESHOLD +# +# ==== Requirements ==== +# +# R1. The IMR options are CSA-only. Enabling IMR, or setting the limit / +# spill-threshold, on a non-CSA channel is rejected with +# ER_CRST_IN_MEMORY_RELAYLOG_ONLY_FOR_CSA. +# R2. On a CSA channel, invalid ranges / ordering are rejected with +# ER_CRST_IN_MEMORY_RELAYLOG_INVALID_CONFIG: +# - IN_MEMORY_RELAYLOG_LIMIT in [32 MiB, 4 GiB] +# - IN_MEMORY_RELAYLOG_SPILL_THRESHOLD in [8 MiB, IN_MEMORY_RELAYLOG_LIMIT) +# A single option is validated against the persisted value of its peer. +# R3. Defaults: ENABLED = NO, LIMIT = 134217728 (128 MiB), +# SPILL_THRESHOLD = 16777216 (16 MiB). +# R4. The IMR options can only be changed while replication is stopped +# (ER_REPLICA_CHANNEL_MUST_STOP). +# R5. A valid configuration is persisted in mysql.slave_relay_log_info and +# performance_schema.replication_applier_configuration, survives a server +# restart, is kept by RESET REPLICA, and cleared by RESET REPLICA ALL. +# +# ==== Implementation ==== +# +# 1. Assert IMR metadata defaults on the freshly created channel (R3). +# 2. Reject IMR options on the non-CSA (MTA) channel (R1). +# 3. Switch the channel to CSA and reject invalid ranges / ordering (R2). +# 4. Apply a valid IMR configuration and assert it is persisted (R5). +# 5. Assert the options cannot be changed while a thread is running (R4). +# 6. Restart the replica and assert the configuration survived (R5). +# 7. RESET REPLICA keeps the configuration; RESET REPLICA ALL clears it (R5). +# +# ==== References ==== +# +# In-memory relay log for the Change Stream Applier (feature 684). +# See design/replication/684-in-memory-relaylog. +# CSA (Change Stream Applier): WL#10500. + +# CSA requires row-based replication. +--source include/have_binlog_format_row.inc +# This test must start on a non-CSA (MTA) channel so it can check that the IMR +# options are rejected there, then it switches the channel to CSA itself. +# have_mta.inc runs it only under the default (MTA) collection and skips it +# under the CSA collection, where the channel would already be CSA. +--source include/have_mta.inc + +--let $rpl_skip_start_slave= 1 +--source include/rpl/init_source_replica.inc + +--source include/rpl/connection_replica.inc + +--source include/rpl/start_replica.inc +--source include/rpl/stop_replica.inc + +--echo +--echo # 1. IMR metadata defaults on a freshly created channel: the feature is +--echo # OFF, and the limit / spill-threshold columns carry their default +--echo # values (inert while the feature is disabled). +--echo + +--let $count= `SELECT COUNT(*) FROM mysql.slave_relay_log_info WHERE In_memory_relaylog = 0` +--let $assert_text= IN_MEMORY_RELAYLOG is OFF by default in mysql.slave_relay_log_info +--let $assert_cond= $count = 1 +--source include/assert.inc + +--let $count= `SELECT COUNT(*) FROM mysql.slave_relay_log_info WHERE In_memory_relaylog_limit = 134217728` +--let $assert_text= IN_MEMORY_RELAYLOG_LIMIT defaults to 134217728 in mysql.slave_relay_log_info +--let $assert_cond= $count = 1 +--source include/assert.inc + +--let $count= `SELECT COUNT(*) FROM mysql.slave_relay_log_info WHERE In_memory_relaylog_spill_threshold = 16777216` +--let $assert_text= IN_MEMORY_RELAYLOG_SPILL_THRESHOLD defaults to 16777216 in mysql.slave_relay_log_info +--let $assert_cond= $count = 1 +--source include/assert.inc + +--echo # PFS view of the IMR defaults: +SELECT IN_MEMORY_RELAYLOG_ENABLED, IN_MEMORY_RELAYLOG_LIMIT, IN_MEMORY_RELAYLOG_SPILL_THRESHOLD + FROM performance_schema.replication_applier_configuration; + +--echo +--echo # 2. IMR options are rejected on a non-CSA (MTA) channel (R1). +--echo + +--error ER_CRST_IN_MEMORY_RELAYLOG_ONLY_FOR_CSA +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = ON; + +--error ER_CRST_IN_MEMORY_RELAYLOG_ONLY_FOR_CSA +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_LIMIT = 67108864; + +--error ER_CRST_IN_MEMORY_RELAYLOG_ONLY_FOR_CSA +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_SPILL_THRESHOLD = 12582912; + +--echo # Disabling IMR on a non-CSA channel is allowed (only enabling is gated). +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = OFF; + +--echo +--echo # 3. Switch the channel to CSA, then reject invalid ranges / ordering (R2). +--echo + +CHANGE REPLICATION SOURCE TO APPLIER_VERSION = 2, REQUIRE_ROW_FORMAT = 1, GTID_ONLY = 1; + +--echo # LIMIT below the 32 MiB floor. +--error ER_CRST_IN_MEMORY_RELAYLOG_INVALID_CONFIG +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_LIMIT = 33554431; + +--echo # LIMIT above the 4 GiB ceiling. +--error ER_CRST_IN_MEMORY_RELAYLOG_INVALID_CONFIG +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_LIMIT = 4294967297; + +--echo # SPILL_THRESHOLD below the 8 MiB floor. +--error ER_CRST_IN_MEMORY_RELAYLOG_INVALID_CONFIG +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_SPILL_THRESHOLD = 8388607; + +--echo # SPILL_THRESHOLD alone >= persisted LIMIT (default 134217728). +--error ER_CRST_IN_MEMORY_RELAYLOG_INVALID_CONFIG +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_SPILL_THRESHOLD = 134217728; + +--echo # Both together, valid individual ranges but SPILL_THRESHOLD == LIMIT. +--error ER_CRST_IN_MEMORY_RELAYLOG_INVALID_CONFIG +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_LIMIT = 33554432, IN_MEMORY_RELAYLOG_SPILL_THRESHOLD = 33554432; + +--echo # Both together, SPILL_THRESHOLD > LIMIT. +--error ER_CRST_IN_MEMORY_RELAYLOG_INVALID_CONFIG +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_LIMIT = 33554432, IN_MEMORY_RELAYLOG_SPILL_THRESHOLD = 67108864; + +--echo +--echo # 4. Apply a valid IMR configuration and assert it is persisted (R5). +--echo + +CHANGE REPLICATION SOURCE TO + IN_MEMORY_RELAYLOG_ENABLED = ON, + IN_MEMORY_RELAYLOG_LIMIT = 268435456, + IN_MEMORY_RELAYLOG_SPILL_THRESHOLD = 33554432; + +--let $count= `SELECT COUNT(*) FROM mysql.slave_relay_log_info WHERE In_memory_relaylog = 1` +--let $assert_text= IN_MEMORY_RELAYLOG is ON in mysql.slave_relay_log_info +--let $assert_cond= $count = 1 +--source include/assert.inc + +--let $count= `SELECT COUNT(*) FROM mysql.slave_relay_log_info WHERE In_memory_relaylog_limit = 268435456` +--let $assert_text= IN_MEMORY_RELAYLOG_LIMIT is 268435456 in mysql.slave_relay_log_info +--let $assert_cond= $count = 1 +--source include/assert.inc + +--let $count= `SELECT COUNT(*) FROM mysql.slave_relay_log_info WHERE In_memory_relaylog_spill_threshold = 33554432` +--let $assert_text= IN_MEMORY_RELAYLOG_SPILL_THRESHOLD is 33554432 in mysql.slave_relay_log_info +--let $assert_cond= $count = 1 +--source include/assert.inc + +--echo # PFS view of the applied IMR configuration: +SELECT IN_MEMORY_RELAYLOG_ENABLED, IN_MEMORY_RELAYLOG_LIMIT, IN_MEMORY_RELAYLOG_SPILL_THRESHOLD + FROM performance_schema.replication_applier_configuration; + +--echo +--echo # 5. IMR options cannot be changed while replication is running (R4). +--echo + +--source include/rpl/start_replica.inc + +--error ER_REPLICA_CHANNEL_MUST_STOP +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_LIMIT = 134217728; + +--error ER_REPLICA_CHANNEL_MUST_STOP +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = OFF; + +--source include/rpl/stop_replica.inc + +--echo +--echo # 6. The valid configuration survives a server restart (R5). +--echo + +--let $rpl_server_number= 2 +--source include/rpl/restart_server.inc + +--source include/rpl/connection_replica.inc + +--let $count= `SELECT COUNT(*) FROM mysql.slave_relay_log_info WHERE In_memory_relaylog = 1 AND In_memory_relaylog_limit = 268435456 AND In_memory_relaylog_spill_threshold = 33554432` +--let $assert_text= IN_MEMORY_RELAYLOG survived restart in mysql.slave_relay_log_info +--let $assert_cond= $count = 1 +--source include/assert.inc + +--let $count= `SELECT COUNT(*) FROM performance_schema.replication_applier_configuration WHERE IN_MEMORY_RELAYLOG_ENABLED = 'YES' AND IN_MEMORY_RELAYLOG_LIMIT = 268435456 AND IN_MEMORY_RELAYLOG_SPILL_THRESHOLD = 33554432` +--let $assert_text= IN_MEMORY_RELAYLOG survived restart in replication_applier_configuration +--let $assert_cond= $count = 1 +--source include/assert.inc + +--echo +--echo # 7. RESET REPLICA keeps the configuration; RESET REPLICA ALL clears it (R5). +--echo + +RESET REPLICA; + +--let $count= `SELECT COUNT(*) FROM mysql.slave_relay_log_info WHERE In_memory_relaylog = 1 AND In_memory_relaylog_limit = 268435456 AND In_memory_relaylog_spill_threshold = 33554432` +--let $assert_text= RESET REPLICA keeps the IN_MEMORY_RELAYLOG configuration +--let $assert_cond= $count = 1 +--source include/assert.inc + +RESET REPLICA ALL; + +--let $count= `SELECT COUNT(*) FROM mysql.slave_relay_log_info` +--let $assert_text= RESET REPLICA ALL clears the channel row from mysql.slave_relay_log_info +--let $assert_cond= $count = 0 +--source include/assert.inc + +# Cleanup. +--disable_warnings +--disable_query_log +--eval CHANGE REPLICATION SOURCE TO SOURCE_HOST='127.0.0.1', SOURCE_PORT=$MASTER_MYPORT, SOURCE_USER='root' +--enable_query_log +--enable_warnings + +--let $rpl_only_running_threads= 1 +--source include/rpl/deinit.inc diff --git a/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_disabled_fallback.test b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_disabled_fallback.test new file mode 100644 index 000000000000..f22a2b6c5f00 --- /dev/null +++ b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_disabled_fallback.test @@ -0,0 +1,144 @@ +# ==== Purpose ==== +# +# Test that the in-memory relay log (IMR) feature, when disabled, leaves the +# classic (disk) relay-log path untouched on a CSA (Change Stream Applier) +# channel, and that toggling the feature OFF -> ON -> OFF around live workloads +# is non-destructive. +# +# ==== Requirements ==== +# +# R1. On a CSA channel with IN_MEMORY_RELAYLOG_ENABLED = OFF, replication uses +# the classic relay-log path: relay logs are written on disk +# (Relay_Log_Space > 0) and the IMR SHOW REPLICA STATUS columns +# In_Memory_Relay_Log_Space / In_Memory_Queue_Length both report 0. +# R2. Toggling IN_MEMORY_RELAYLOG_ENABLED OFF -> ON -> OFF (with replication +# stopped for each change) keeps the replica data consistent with the +# source (non-destructive fallback), so a channel can safely fall back to +# the disk path without data loss. +# +# ==== Implementation ==== +# +# 1. Set up a CSA replica (IMR defaults to OFF) and assert the classic path: +# relay logs present, IMR columns zero. +# 2. Apply a workload with IMR OFF and verify data equality. +# 3. Toggle IMR ON, apply a workload, verify data equality. +# 4. Toggle IMR back OFF, apply a workload, verify data equality and that the +# classic path / zeroed IMR columns are restored. +# +# ==== References ==== +# +# In-memory relay log for the Change Stream Applier (feature 684). +# See design/replication/684-in-memory-relaylog. +# CSA (Change Stream Applier): WL#10500. + +# CSA requires row-based replication. +--source include/have_binlog_format_row.inc + +--let $rpl_applier_version= 2:2 +--let $rpl_applier_worker_count= 2:4 +--source include/rpl/init_source_replica.inc + +--echo +--echo # 1. CSA channel with IMR OFF (the default) uses the classic relay-log +--echo # path: relay logs on disk, IMR SHOW REPLICA STATUS columns zero. +--echo + +--source include/rpl/connection_replica.inc + +--let $imr_enabled= query_get_value(SELECT IN_MEMORY_RELAYLOG_ENABLED AS v FROM performance_schema.replication_applier_configuration, v, 1) +--let $assert_text= IMR is OFF by default on the CSA channel +--let $assert_cond= "$imr_enabled" = "NO" +--source include/assert.inc + +--let $imr_space= query_get_value(SHOW REPLICA STATUS, In_Memory_Relay_Log_Space, 1) +--let $assert_text= In_Memory_Relay_Log_Space is 0 while IMR is OFF +--let $assert_cond= $imr_space = 0 +--source include/assert.inc + +--let $imr_qlen= query_get_value(SHOW REPLICA STATUS, In_Memory_Queue_Length, 1) +--let $assert_text= In_Memory_Queue_Length is 0 while IMR is OFF +--let $assert_cond= $imr_qlen = 0 +--source include/assert.inc + +--echo +--echo # 2. Apply a workload with IMR OFF and verify data equality. +--echo + +--source include/rpl/connection_source.inc +CREATE TABLE t1 (id INT PRIMARY KEY, v VARCHAR(50)); +INSERT INTO t1 VALUES (1, 'off-a'), (2, 'off-b'), (3, 'off-c'); + +--source include/rpl/sync_to_replica.inc + +--let $diff_tables= master:test.t1, slave:test.t1 +--source include/diff_tables.inc + +--echo # Classic path: relay logs are present on disk while IMR is OFF. +--source include/rpl/connection_replica.inc +--let $relay_space= query_get_value(SHOW REPLICA STATUS, Relay_Log_Space, 1) +--let $assert_text= Relay_Log_Space is greater than 0 (classic relay logs in use) +--let $assert_cond= $relay_space > 0 +--source include/assert.inc + +--echo +--echo # 3. Toggle IMR ON (replication stopped for the change), apply, verify. +--echo + +--source include/rpl/stop_replica.inc +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = ON; +--source include/rpl/start_replica.inc + +--let $imr_enabled= query_get_value(SELECT IN_MEMORY_RELAYLOG_ENABLED AS v FROM performance_schema.replication_applier_configuration, v, 1) +--let $assert_text= IMR is ON after the toggle +--let $assert_cond= "$imr_enabled" = "YES" +--source include/assert.inc + +--source include/rpl/connection_source.inc +INSERT INTO t1 VALUES (4, 'on-a'), (5, 'on-b'), (6, 'on-c'); + +--source include/rpl/sync_to_replica.inc + +--let $diff_tables= master:test.t1, slave:test.t1 +--source include/diff_tables.inc + +--echo +--echo # 4. Toggle IMR back OFF, apply, verify equality and the restored classic +--echo # path with zeroed IMR columns (non-destructive fallback). +--echo + +--source include/rpl/connection_replica.inc +--source include/rpl/stop_replica.inc +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = OFF; +--source include/rpl/start_replica.inc + +--source include/rpl/connection_source.inc +INSERT INTO t1 VALUES (7, 'off2-a'), (8, 'off2-b'), (9, 'off2-c'); + +--source include/rpl/sync_to_replica.inc + +--let $diff_tables= master:test.t1, slave:test.t1 +--source include/diff_tables.inc + +--source include/rpl/connection_replica.inc + +--let $imr_space= query_get_value(SHOW REPLICA STATUS, In_Memory_Relay_Log_Space, 1) +--let $assert_text= In_Memory_Relay_Log_Space is 0 again after falling back to OFF +--let $assert_cond= $imr_space = 0 +--source include/assert.inc + +--let $imr_qlen= query_get_value(SHOW REPLICA STATUS, In_Memory_Queue_Length, 1) +--let $assert_text= In_Memory_Queue_Length is 0 again after falling back to OFF +--let $assert_cond= $imr_qlen = 0 +--source include/assert.inc + +# Cleanup. +--source include/rpl/connection_source.inc +DROP TABLE t1; +--source include/rpl/sync_to_replica.inc + +--source include/rpl/connection_replica.inc +--source include/rpl/stop_replica.inc +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = OFF; +--source include/rpl/start_replica.inc + +--source include/rpl/deinit.inc diff --git a/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_memory_limit.test b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_memory_limit.test new file mode 100644 index 000000000000..9be2a66a3609 --- /dev/null +++ b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_memory_limit.test @@ -0,0 +1,151 @@ +# ==== Purpose ==== +# +# Memory-limit back-pressure on a CSA (Change Stream Applier) channel with the +# in-memory relay log (IMR) enabled: the hard per-channel memory limit +# (IN_MEMORY_RELAYLOG_LIMIT) bounds the bytes held by memory-path transactions. +# When the queue fills, the receiver (IO thread) blocks on enqueue instead of +# exceeding the limit, and no data is lost once the applier drains the queue. +# +# ==== Requirements ==== +# +# R1. The bytes held by memory-path transactions never exceed +# IN_MEMORY_RELAYLOG_LIMIT. (FR12, FR13) +# R2. When the queue is full, the receiver cannot buffer the whole workload: +# back-pressure holds it below the full set of source transactions while +# the applier is paused. (FR13) +# R3. Committing a transaction frees its reserved memory; once the applier +# drains the queue, all data applies with no loss and the memory usage +# returns to 0. (FR14, NFR1) +# +# ==== Implementation ==== +# +# 1. Set up CSA replication (applier stopped), enable IMR at the 32 MiB limit +# floor with an 8 MiB spill threshold (so ~1 MiB transactions stay on the +# memory path), and start. +# 2. Pause the applier and run a workload larger than the memory limit. +# 3. The receiver fills the queue toward the limit and blocks: assert the +# usage stays within the limit and fewer than the whole workload is queued. +# 4. Resume the applier: the queue drains, the receiver unblocks, all data +# applies (no loss), and the memory usage returns to 0. +# +# ==== References ==== +# +# In-memory relay log for the Change Stream Applier (feature 684). +# See design/replication/684-in-memory-relaylog. +# CSA (Change Stream Applier): WL#10500. + +# CSA is row-based only. +--source include/have_binlog_format_row.inc +# Memory accounting is driven by the transaction's on-the-wire byte length. With +# binlog transaction compression on, the highly compressible test payload would +# not fill the limit, so this back-pressure test requires compression off. +--source include/not_binlog_transaction_compression_on.inc + +--let $rpl_skip_start_slave= 1 +--let $rpl_applier_version= 2:2 +--let $rpl_applier_worker_count= 2:4 +--source include/rpl/init_source_replica.inc + +--echo +--echo # 1. Enable IMR at the 32 MiB limit floor with an 8 MiB spill threshold +--echo # (transactions below 8 MiB stay on the memory path) and start. +--echo + +--source include/rpl/connection_replica.inc +CHANGE REPLICATION SOURCE TO + IN_MEMORY_RELAYLOG_ENABLED = ON, + IN_MEMORY_RELAYLOG_LIMIT = 33554432, + IN_MEMORY_RELAYLOG_SPILL_THRESHOLD = 8388608; + +--source include/rpl/start_replica.inc + +--source include/rpl/connection_source.inc +CREATE TABLE t1 (id INT PRIMARY KEY AUTO_INCREMENT, data LONGBLOB); +--source include/rpl/sync_to_replica.inc + +--echo +--echo # 2. Pause the applier and run a workload larger than the memory limit. +--echo # 40 x ~1 MiB memory-path transactions (~40 MiB) exceed the 32 MiB +--echo # limit, so the receiver cannot hold them all at once. +--echo + +--source include/rpl/connection_replica.inc +--source include/rpl/stop_applier.inc + +--source include/rpl/connection_source.inc +--let $trx_count= 40 +--disable_query_log +--let $i= 1 +while ($i <= $trx_count) +{ + --eval INSERT INTO t1 (data) VALUES (REPEAT('x', 1048576)) + --inc $i +} +--enable_query_log + +--echo +--echo # 3. The receiver fills the queue toward the limit and blocks (applier +--echo # paused). Usage stays within the limit; not all transactions fit. +--echo + +--source include/rpl/connection_replica.inc + +# Poll until the queue is saturated near the limit. The receiver blocks in +# admission with the applier paused, so the usage rises and then holds steady; +# the SELECT uses unquoted operands for a numeric (not string) comparison. +--let $limit= 33554432 +--let $watermark= 25165824 +--let $space= 0 +--let $iter= 0 +while (`SELECT $space < $watermark`) +{ + --sleep 0.2 + --let $space= query_get_value(SHOW REPLICA STATUS, In_Memory_Relay_Log_Space, 1) + --inc $iter + if ($iter > 150) + { + --die Timed out waiting for the in-memory queue to saturate near the limit + } +} + +# R1: the memory usage never exceeds the configured hard limit. +--let $assert_text= In_Memory_Relay_Log_Space stays within IN_MEMORY_RELAYLOG_LIMIT while the queue is saturated +--let $assert_cond= $space <= $limit +--source include/assert.inc + +# R2: back-pressure held the receiver below the full workload -- fewer than +# $trx_count transactions are queued while the applier is paused. +--let $qlen= query_get_value(SHOW REPLICA STATUS, In_Memory_Queue_Length, 1) +--let $assert_text= Back-pressure kept fewer than the whole workload queued while the applier was paused +--let $assert_cond= $qlen < $trx_count +--source include/assert.inc + +--echo +--echo # 4. Resume the applier: the queue drains, the receiver unblocks, all +--echo # data applies with no loss, and the memory usage returns to 0. +--echo + +--source include/rpl/start_applier.inc + +--source include/rpl/connection_source.inc +--source include/rpl/sync_to_replica.inc + +--let $rpl_diff_statement= SELECT id, LENGTH(data) AS len FROM t1 ORDER BY id +--source include/rpl/diff.inc + +--source include/rpl/connection_replica.inc +--let $count= `SELECT COUNT(*) FROM t1` +--let $assert_text= All 40 transactions were applied on the replica +--let $assert_cond= $count = 40 +--source include/assert.inc + +--let $slave_param= In_Memory_Relay_Log_Space +--let $slave_param_value= 0 +--source include/rpl/wait_for_replica_status.inc + +# Cleanup. +--source include/rpl/connection_source.inc +DROP TABLE t1; +--source include/rpl/sync_to_replica.inc + +--source include/rpl/deinit.inc diff --git a/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_memory_path.test b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_memory_path.test new file mode 100644 index 000000000000..f1d15fe84fc0 --- /dev/null +++ b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_memory_path.test @@ -0,0 +1,153 @@ +# ==== Purpose ==== +# +# Memory-path end-to-end apply on a CSA (Change Stream Applier) channel with the +# in-memory relay log (IMR) enabled: a normal workload replicates correctly +# through the in-memory queue (no relay-log round trip), and the per-channel +# queue metrics are surfaced in SHOW REPLICA STATUS. +# +# ==== Requirements ==== +# +# R1. With IMR ON on a CSA channel, a mixed DML/DDL workload applies and the +# replica data matches the source. (FR3, FR4, FR4.1, FR4.3, FR5, FR6, +# NFR1, NFR4.1) +# R2. SHOW REPLICA STATUS exposes In_Memory_Relay_Log_Space and +# In_Memory_Queue_Length: non-zero while transactions sit in the queue, +# draining to 0 once everything is applied. (FR34) +# +# ==== Implementation ==== +# +# 1. Set up CSA replication (applier stopped), enable IMR, start the replica. +# 2. Apply a mixed DML/DDL workload; sync; assert the replica matches source. +# 3. Assert the queue metrics drain to 0 once everything is applied. +# 4. With the applier stopped, received-but-unapplied transactions make the +# metrics non-zero; restart the applier, drain, and assert the data matches. +# +# ==== References ==== +# +# In-memory relay log for the Change Stream Applier (feature 684). +# See design/replication/684-in-memory-relaylog. +# CSA (Change Stream Applier): WL#10500. + +# CSA is row-based only. +--source include/have_binlog_format_row.inc + +--let $rpl_skip_start_slave= 1 +--let $rpl_applier_version= 2:2 +--let $rpl_applier_worker_count= 2:4 +--source include/rpl/init_source_replica.inc + +--echo +--echo # 1. Enable the in-memory relay log on the CSA channel and start. +--echo + +--source include/rpl/connection_replica.inc +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = ON; + +--let $count= `SELECT COUNT(*) FROM performance_schema.replication_applier_configuration WHERE IN_MEMORY_RELAYLOG_ENABLED = 'YES'` +--let $assert_text= The channel reports the in-memory relay log as enabled +--let $assert_cond= $count = 1 +--source include/assert.inc + +--source include/rpl/start_replica.inc + +--echo +--echo # 2. A mixed DML/DDL workload replicates correctly through the memory path. +--echo + +--source include/rpl/connection_source.inc +CREATE TABLE t1 (a INT PRIMARY KEY, b VARCHAR(50)); +CREATE TABLE t2 (id INT PRIMARY KEY AUTO_INCREMENT, v INT); + +INSERT INTO t1 VALUES (1, 'one'), (2, 'two'), (3, 'three'); + +BEGIN; +INSERT INTO t2 (v) VALUES (10), (20), (30); +UPDATE t1 SET b = 'ONE' WHERE a = 1; +COMMIT; + +DELETE FROM t1 WHERE a = 3; +ALTER TABLE t1 ADD COLUMN c INT DEFAULT 0; +UPDATE t2 SET v = v + 1; + +--source include/rpl/sync_to_replica.inc + +--let $rpl_diff_statement= SELECT * FROM t1 ORDER BY a +--source include/rpl/diff.inc +--let $rpl_diff_statement= SELECT * FROM t2 ORDER BY id +--source include/rpl/diff.inc + +--echo +--echo # 3. The queue memory drains to 0 once everything is applied. +--echo # (Bytes are released at commit; empty envelopes may linger unswept +--echo # behind an idle coordinator, so In_Memory_Relay_Log_Space -- not +--echo # In_Memory_Queue_Length -- is the reliable "all committed" signal.) +--echo + +--source include/rpl/connection_replica.inc +--let $slave_param= In_Memory_Relay_Log_Space +--let $slave_param_value= 0 +--source include/rpl/wait_for_replica_status.inc + +--echo +--echo # 4. With the applier stopped, received transactions make the metrics +--echo # non-zero; restarting the applier drains them and the data matches. +--echo + +--source include/rpl/connection_replica.inc +--source include/rpl/stop_applier.inc + +# Baseline queue length before the new workload. With the applier stopped the +# queue is retained (the receiver keeps running), and committed-but-unswept +# envelopes from the previous phase can still linger behind the idle +# coordinator, so the baseline is not necessarily 0. Capture it and assert the +# exact DELTA below rather than an absolute value. +--let $qlen_before= query_get_value(SHOW REPLICA STATUS, In_Memory_Queue_Length, 1) + +--source include/rpl/connection_source.inc +--let $trx_count= 20 +--disable_query_log +--let $i= 1 +while ($i <= $trx_count) +{ + --eval INSERT INTO t2 (v) VALUES (100 + $i) + --inc $i +} +--enable_query_log + +# Wait for the receiver (IO thread) to enqueue everything; the applier is +# stopped, so nothing is dispatched, committed, or swept. Leaves us on the +# replica connection. +--source include/rpl/sync_to_replica_received.inc + +# Each autocommit INSERT is one transaction, hence exactly one queue envelope. +# With the applier stopped, every received transaction stays queued, so the +# queue length must grow by exactly $trx_count. +--let $qlen_after= query_get_value(SHOW REPLICA STATUS, In_Memory_Queue_Length, 1) +--let $assert_text= In_Memory_Queue_Length grew by exactly $trx_count queued transactions while the applier was stopped +--let $assert_cond= $qlen_after - $qlen_before = $trx_count +--source include/assert.inc + +--let $space= query_get_value(SHOW REPLICA STATUS, In_Memory_Relay_Log_Space, 1) +--let $assert_text= In_Memory_Relay_Log_Space is non-zero while transactions are queued +--let $assert_cond= $space > 0 +--source include/assert.inc + +--source include/rpl/start_applier.inc + +--source include/rpl/connection_source.inc +--source include/rpl/sync_to_replica.inc + +--let $rpl_diff_statement= SELECT * FROM t2 ORDER BY id +--source include/rpl/diff.inc + +--source include/rpl/connection_replica.inc +--let $slave_param= In_Memory_Relay_Log_Space +--let $slave_param_value= 0 +--source include/rpl/wait_for_replica_status.inc + +# Cleanup. +--source include/rpl/connection_source.inc +DROP TABLE t1, t2; +--source include/rpl/sync_to_replica.inc + +--source include/rpl/deinit.inc diff --git a/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_recovery_restart.test b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_recovery_restart.test new file mode 100644 index 000000000000..28fffa2b26ca --- /dev/null +++ b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_recovery_restart.test @@ -0,0 +1,160 @@ +# ==== Purpose ==== +# +# Test recovery of an in-memory relay log (IMR) CSA channel across an abrupt +# replica restart (crash). The in-memory queue is volatile: on a crash / +# restart its contents are discarded, and any transactions that were received +# into the queue but not yet applied must be recovered purely by GTID +# auto-positioning from the durable gtid_executed. +# +# ==== Requirements ==== +# +# R1. Transactions received into the in-memory queue but not yet applied are +# lost on an abrupt restart (queue is volatile). After the restart the +# queue is rebuilt empty: In_Memory_Queue_Length = 0 and +# In_Memory_Relay_Log_Space = 0. +# R2. On the next START REPLICA the channel auto-positions from gtid_executed +# and re-fetches the lost (unapplied) transactions from the source, so the +# replica converges to the source with no data loss. +# R3. Normal IMR replication resumes after recovery. +# +# ==== Implementation ==== +# +# 1. Set up a CSA replica, enable IMR, and apply a first workload (W1). +# 2. Stop only the applier (SQL thread); the receiver keeps running. +# 3. Produce a second workload (W2) on the source and let the receiver queue +# it in memory (unapplied). The queue now holds uncommitted envelopes. +# 4. Abruptly restart (crash) the replica: the volatile queue is discarded. +# 5. After the restart the queue is rebuilt empty (R1). +# 6. START REPLICA: auto-positioning re-fetches W2; the replica converges (R2). +# 7. Apply a third workload (W3) to confirm normal operation resumed (R3). +# +# ==== References ==== +# +# In-memory relay log for the Change Stream Applier (feature 684). +# See design/replication/684-in-memory-relaylog. +# CSA (Change Stream Applier): WL#10500. + +# CSA requires row-based replication. +--source include/have_binlog_format_row.inc + +--let $rpl_skip_start_slave= 1 +--let $rpl_applier_version= 2:2 +--let $rpl_applier_worker_count= 2:4 +--source include/rpl/init_source_replica.inc + +--echo +--echo # 1. Enable IMR on the CSA channel, start replication, apply W1. +--echo + +--source include/rpl/connection_replica.inc +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = ON; +--source include/rpl/start_replica.inc + +--let $imr_enabled= query_get_value(SELECT IN_MEMORY_RELAYLOG_ENABLED AS v FROM performance_schema.replication_applier_configuration, v, 1) +--let $assert_text= IMR is ON on the CSA channel +--let $assert_cond= "$imr_enabled" = "YES" +--source include/assert.inc + +--source include/rpl/connection_source.inc +CREATE TABLE t1 (id INT PRIMARY KEY, v VARCHAR(50)); +INSERT INTO t1 VALUES (1, 'w1-a'), (2, 'w1-b'), (3, 'w1-c'); + +--source include/rpl/sync_to_replica.inc + +--let $diff_tables= master:test.t1, slave:test.t1 +--source include/diff_tables.inc + +--echo +--echo # 2. Stop only the applier; the receiver keeps running. +--echo + +--source include/rpl/connection_replica.inc +--source include/rpl/stop_applier.inc + +--echo +--echo # 3. Produce W2 on the source; the receiver queues it in memory +--echo # (unapplied). The in-memory queue now holds uncommitted envelopes. +--echo + +--source include/rpl/connection_source.inc +INSERT INTO t1 VALUES (4, 'w2-a'); +INSERT INTO t1 VALUES (5, 'w2-b'); +INSERT INTO t1 VALUES (6, 'w2-c'); + +# Wait for the receiver (IO thread) to fetch W2 into the in-memory queue. The +# applier is stopped, so these envelopes stay queued and unapplied. +--let $use_gtids= 1 +--source include/rpl/sync_to_replica_received.inc + +--source include/rpl/connection_replica.inc +--let $imr_qlen= query_get_value(SHOW REPLICA STATUS, In_Memory_Queue_Length, 1) +--let $assert_text= In_Memory_Queue_Length is greater than 0 with W2 received but unapplied +--let $assert_cond= $imr_qlen > 0 +--source include/assert.inc + +--echo +--echo # 4. Abruptly restart (crash) the replica: the volatile queue is lost. +--echo + +--let $rpl_server_number= 2 +--let $rpl_force_stop= 1 +--source include/rpl/restart_server.inc + +--source include/rpl/connection_replica.inc + +--echo +--echo # 5. After the restart the in-memory queue is rebuilt empty (R1). +--echo + +--let $imr_qlen= query_get_value(SHOW REPLICA STATUS, In_Memory_Queue_Length, 1) +--let $assert_text= In_Memory_Queue_Length is 0 after the restart (queue rebuilt empty) +--let $assert_cond= $imr_qlen = 0 +--source include/assert.inc + +--let $imr_space= query_get_value(SHOW REPLICA STATUS, In_Memory_Relay_Log_Space, 1) +--let $assert_text= In_Memory_Relay_Log_Space is 0 after the restart +--let $assert_cond= $imr_space = 0 +--source include/assert.inc + +--echo # IMR is still enabled on the channel after the restart. +--let $imr_enabled= query_get_value(SELECT IN_MEMORY_RELAYLOG_ENABLED AS v FROM performance_schema.replication_applier_configuration, v, 1) +--let $assert_text= IMR remains ON after the restart +--let $assert_cond= "$imr_enabled" = "YES" +--source include/assert.inc + +--echo +--echo # 6. START REPLICA: auto-positioning re-fetches W2; the replica converges +--echo # (R2). W2 was never committed, so it is re-fetched by GTID. +--echo + +--source include/rpl/start_replica.inc + +--source include/rpl/connection_source.inc +--source include/rpl/sync_to_replica.inc + +--let $diff_tables= master:test.t1, slave:test.t1 +--source include/diff_tables.inc + +--echo +--echo # 7. Normal IMR replication resumes: apply W3 and verify (R3). +--echo + +--source include/rpl/connection_source.inc +INSERT INTO t1 VALUES (7, 'w3-a'), (8, 'w3-b'), (9, 'w3-c'); + +--source include/rpl/sync_to_replica.inc + +--let $diff_tables= master:test.t1, slave:test.t1 +--source include/diff_tables.inc + +# Cleanup. +--source include/rpl/connection_source.inc +DROP TABLE t1; +--source include/rpl/sync_to_replica.inc + +--source include/rpl/connection_replica.inc +--source include/rpl/stop_replica.inc +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = OFF; +--source include/rpl/start_replica.inc + +--source include/rpl/deinit.inc diff --git a/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_semisync_incompatible-master.opt b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_semisync_incompatible-master.opt new file mode 100644 index 000000000000..58029d28acec --- /dev/null +++ b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_semisync_incompatible-master.opt @@ -0,0 +1 @@ +$SEMISYNC_PLUGIN_OPT diff --git a/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_semisync_incompatible-slave.opt b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_semisync_incompatible-slave.opt new file mode 100644 index 000000000000..58029d28acec --- /dev/null +++ b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_semisync_incompatible-slave.opt @@ -0,0 +1 @@ +$SEMISYNC_PLUGIN_OPT diff --git a/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_semisync_incompatible.test b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_semisync_incompatible.test new file mode 100644 index 000000000000..caecfc23a309 --- /dev/null +++ b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_semisync_incompatible.test @@ -0,0 +1,96 @@ +# ==== Purpose ==== +# +# The in-memory relay log (IMR) is incompatible with semi-synchronous +# replication: semisync acknowledges a transaction to the source only after it +# is durably written to the relay log, which the memory path never does. The +# server therefore rejects starting the receiver (IO thread) on an IMR-enabled +# CSA channel while the semisync replica plugin is enabled. +# +# ==== Requirements ==== +# +# R1. With IMR enabled on a CSA channel and the replica-side semisync enabled, +# START REPLICA IO_THREAD is rejected with +# ER_REPLICA_IN_MEMORY_RELAYLOG_INCOMPATIBLE_CONFIGURATION. +# R2. START REPLICA (both threads) is rejected for the same reason (the gate is +# on the receiver and is evaluated before any thread starts). +# R3. Disabling IMR clears the incompatibility: the channel then starts normally +# even with the semisync replica plugin still enabled. +# +# ==== Implementation ==== +# +# 1. Set up a CSA channel with the applier stopped; install and enable the +# replica-side semisync plugin (threads stopped). +# 2. Enable IMR on the channel. +# 3. START REPLICA IO_THREAD is rejected (R1). +# 4. START REPLICA (both) is rejected (R2). +# 5. Disable IMR; the channel starts normally (R3). +# 6. Cleanup. +# +# ==== References ==== +# +# In-memory relay log for the Change Stream Applier (feature 684). +# See design/replication/684-in-memory-relaylog. +# CSA (Change Stream Applier): WL#10500. + +# CSA requires row-based replication. +--source include/have_binlog_format_row.inc +# The semisync replica plugin must be available (needs the plugin dir set by the +# companion -master.opt / -slave.opt files). +--source include/have_semisync_plugin.inc + +--let $rpl_skip_start_slave= 1 +--let $rpl_applier_version= 2:2 +--let $rpl_applier_worker_count= 2:4 +--source include/rpl/init_source_replica.inc + +--source include/rpl/connection_replica.inc + +# Step 5 starts a semisync-enabled replica against a non-semisync source, which +# logs a benign fallback-to-async warning. +CALL mtr.add_suppression("Plugin semisync reported.*Source server does not support semi-sync.*"); + +--echo +--echo # 1. Install and enable the replica-side semisync plugin (threads stopped). +--echo + +--replace_regex /\.dll/.so/ +--eval INSTALL PLUGIN rpl_semi_sync_replica SONAME '$SEMISYNC_REPLICA_PLUGIN' +SET GLOBAL rpl_semi_sync_replica_enabled = 1; + +--echo +--echo # 2. Enable IMR on the CSA channel. +--echo + +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = ON; + +--echo +--echo # 3. START REPLICA IO_THREAD is rejected (R1). +--echo + +--error ER_REPLICA_IN_MEMORY_RELAYLOG_INCOMPATIBLE_CONFIGURATION +START REPLICA IO_THREAD; + +--echo +--echo # 4. START REPLICA (both threads) is rejected for the same reason (R2). +--echo + +--error ER_REPLICA_IN_MEMORY_RELAYLOG_INCOMPATIBLE_CONFIGURATION +START REPLICA; + +--echo +--echo # 5. Disabling IMR clears the incompatibility: the channel starts even +--echo # with the semisync replica plugin still enabled (R3). +--echo + +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = OFF; +--source include/rpl/start_replica.inc +--source include/rpl/stop_replica.inc + +--echo +--echo # 6. Cleanup. +--echo + +SET GLOBAL rpl_semi_sync_replica_enabled = 0; +UNINSTALL PLUGIN rpl_semi_sync_replica; + +--source include/rpl/deinit.inc diff --git a/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_spill_path.test b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_spill_path.test new file mode 100644 index 000000000000..cc01603d38ca --- /dev/null +++ b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_spill_path.test @@ -0,0 +1,152 @@ +# ==== Purpose ==== +# +# Spill-path end-to-end apply on a CSA (Change Stream Applier) channel with the +# in-memory relay log (IMR) enabled: a transaction larger than +# IN_MEMORY_RELAYLOG_SPILL_THRESHOLD is stored through the spill path (a private +# file in a dedicated subdirectory) instead of the memory queue, applies +# correctly, and its spill file is cleaned up once the transaction commits. +# +# ==== Requirements ==== +# +# R1. A transaction whose size exceeds IN_MEMORY_RELAYLOG_SPILL_THRESHOLD takes +# the spill path. (FR15) +# R2. The spill store is a dedicated "in_memory_relaylog_temp_files" +# subdirectory under the channel's relay-log directory, one self-contained +# "imr_sp_" file per transaction. (FR17, FR21, FR24) +# R3. The spill file persists while the transaction is uncommitted and is +# deleted once the transaction commits. (FR21) +# R4. A mixed small (memory-path) + large (spill-path) workload replicates +# correctly; the replica data matches the source. (NFR1) +# +# ==== Implementation ==== +# +# 1. Set up CSA replication (applier stopped), enable IMR with the minimum +# 8 MiB spill threshold, and start. +# 2. With the applier stopped, run one >threshold transaction: it is received +# and written to a spill file that persists while uncommitted (R1, R2, R3). +# 3. Start the applier; the transaction commits, data matches, and the spill +# file is removed (R3, R4). +# 4. A mixed small (memory) + large (spill) workload replicates correctly and +# leaves no spill files behind (R4). +# +# ==== References ==== +# +# In-memory relay log for the Change Stream Applier (feature 684). +# See design/replication/684-in-memory-relaylog. +# CSA (Change Stream Applier): WL#10500. + +# CSA is row-based only. +--source include/have_binlog_format_row.inc + +--let $rpl_skip_start_slave= 1 +--let $rpl_applier_version= 2:2 +--let $rpl_applier_worker_count= 2:4 +--source include/rpl/init_source_replica.inc + +--echo +--echo # 1. Enable IMR with the minimum 8 MiB spill threshold and start. +--echo # Any transaction larger than 8 MiB takes the spill path; smaller +--echo # ones stay on the memory path (default 128 MiB limit). +--echo + +--source include/rpl/connection_replica.inc +CHANGE REPLICATION SOURCE TO + IN_MEMORY_RELAYLOG_ENABLED = ON, + IN_MEMORY_RELAYLOG_SPILL_THRESHOLD = 8388608; + +# Derive the spill directory from the channel's relay-log path: +# /in_memory_relaylog_temp_files +--let $spill_dir= `SELECT CONCAT(LEFT(@@GLOBAL.relay_log_basename, LENGTH(@@GLOBAL.relay_log_basename) - LOCATE('/', REVERSE(@@GLOBAL.relay_log_basename))), '/in_memory_relaylog_temp_files')` + +--source include/rpl/start_replica.inc + +--source include/rpl/connection_source.inc +CREATE TABLE t1 (id INT PRIMARY KEY, data LONGBLOB); +--source include/rpl/sync_to_replica.inc + +--echo +--echo # 2. With the applier stopped, a >threshold transaction is received and +--echo # written to a spill file that persists while uncommitted. +--echo + +--source include/rpl/connection_replica.inc +--source include/rpl/stop_applier.inc + +--source include/rpl/connection_source.inc +# One transaction larger than 8 MiB: 12 rows x ~1 MiB = ~12 MiB > threshold. +BEGIN; +--disable_query_log +--let $i= 1 +while ($i <= 12) +{ + --eval INSERT INTO t1 VALUES ($i, REPEAT('x', 1048576)) + --inc $i +} +--enable_query_log +COMMIT; + +# The receiver (IO thread) enqueues and writes the spill file; the applier is +# stopped, so the transaction stays uncommitted and its file is retained. +--source include/rpl/sync_to_replica_received.inc + +--source include/rpl/connection_replica.inc +--echo # The dedicated spill subdirectory exists. +--file_exists $spill_dir +--echo # Exactly one imr_sp_ file is present for the uncommitted spill transaction: +--replace_regex /imr_sp_[0-9a-f]+/imr_sp_/ +--list_files $spill_dir imr_sp_* + +--echo +--echo # 3. Start the applier: the transaction commits, data matches, and the +--echo # spill file is removed. +--echo + +--source include/rpl/start_applier.inc + +--source include/rpl/connection_source.inc +--source include/rpl/sync_to_replica.inc + +--let $rpl_diff_statement= SELECT id, LENGTH(data) AS len FROM t1 ORDER BY id +--source include/rpl/diff.inc + +# The spill file is deleted when the committed transaction's byte source is +# released (commit + job teardown), which is asynchronous to the GTID becoming +# visible, so poll the spill directory until no imr_sp_ file remains. +--source include/rpl/connection_replica.inc +--let $spill_dir_to_check= $spill_dir +--source include/rpl/wait_for_no_imr_spill_files.inc + +--echo +--echo # 4. A mixed small (memory) + large (spill) workload replicates correctly +--echo # and leaves no spill files behind. +--echo + +--source include/rpl/connection_source.inc +INSERT INTO t1 VALUES (100, 'small-a'); +BEGIN; +--disable_query_log +--let $i= 200 +while ($i <= 210) +{ + --eval INSERT INTO t1 VALUES ($i, REPEAT('y', 1048576)) + --inc $i +} +--enable_query_log +COMMIT; +INSERT INTO t1 VALUES (300, 'small-b'); + +--source include/rpl/sync_to_replica.inc + +--let $rpl_diff_statement= SELECT id, LENGTH(data) AS len FROM t1 ORDER BY id +--source include/rpl/diff.inc + +--source include/rpl/connection_replica.inc +--let $spill_dir_to_check= $spill_dir +--source include/rpl/wait_for_no_imr_spill_files.inc + +# Cleanup. +--source include/rpl/connection_source.inc +DROP TABLE t1; +--source include/rpl/sync_to_replica.inc + +--source include/rpl/deinit.inc diff --git a/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_stop_start_lifecycle.test b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_stop_start_lifecycle.test new file mode 100644 index 000000000000..901427edd31a --- /dev/null +++ b/mysql-test/suite/rpl_gtid/t/rpl_csa_imr_stop_start_lifecycle.test @@ -0,0 +1,211 @@ +# ==== Purpose ==== +# +# Verify the STOP/START thread lifecycle of a CSA channel that uses the +# in-memory relay log (IMR), at the observable level (data equality plus the +# SHOW REPLICA STATUS queue metrics). Covers the three STOP shapes and a normal +# server restart: +# +# (a) STOP REPLICA SQL_THREAD (receiver keeps running) +# (b) STOP REPLICA IO_THREAD (applier keeps running) +# (c) STOP REPLICA (both threads) +# (d) normal server restart +# +# ==== Requirements ==== +# +# R1. On STOP REPLICA SQL_THREAD with the receiver running, the in-memory queue +# retains the uncommitted transactions (In_Memory_Queue_Length > 0); on +# START REPLICA SQL_THREAD they are re-dispatched and applied. +# R2. On STOP REPLICA IO_THREAD with the applier running, the applier keeps +# applying the already-received transactions; START REPLICA IO_THREAD +# resumes reception. +# R3. On a full STOP REPLICA the queue is reset (In_Memory_Queue_Length = 0); +# START REPLICA re-fetches by GTID auto-positioning and stays consistent. +# R4. After a normal server restart the queue starts empty +# (In_Memory_Queue_Length = 0) and replication resumes consistently. +# R5. In every case the replica data matches the source. +# +# ==== Implementation ==== +# +# 1. Set up a CSA channel with IMR enabled and start it; seed a baseline. +# 2. STOP SQL_THREAD: receive while stopped (queue retained), then start and +# re-apply (R1, R5). +# 3. STOP IO_THREAD: applier drains what it has, then resume the receiver +# (R2, R5). +# 4. STOP REPLICA (both): queue is reset, then restart and re-fetch (R3, R5). +# 5. Normal server restart: queue starts empty, then resume (R4, R5). +# 6. Cleanup. +# +# Note: a committed transaction is swept from the queue only on the +# coordinator's next read iteration or on a full stop, so a live drain can +# leave the most recent transaction lingering. In_Memory_Queue_Length is +# therefore asserted to be 0 only where it is deterministic (after a full STOP +# REPLICA and after a restart); elsewhere correctness is asserted with +# diff_tables. +# +# ==== References ==== +# +# In-memory relay log for the Change Stream Applier (feature 684). +# See design/replication/684-in-memory-relaylog. +# CSA (Change Stream Applier): WL#10500. + +--source include/have_binlog_format_row.inc + +--echo +--echo # 1. Set up a CSA channel with the in-memory relay log enabled. +--echo + +--let $rpl_skip_start_slave= 1 +--let $rpl_applier_version= 2:2 +--let $rpl_applier_worker_count= 2:4 +--source include/rpl/init_source_replica.inc + +--source include/rpl/connection_replica.inc +CHANGE REPLICATION SOURCE TO IN_MEMORY_RELAYLOG_ENABLED = ON; +--source include/rpl/start_replica.inc + +--source include/rpl/connection_source.inc +CREATE TABLE t1 (id INT PRIMARY KEY); +INSERT INTO t1 VALUES (1), (2), (3); +--source include/rpl/sync_to_replica.inc + +--let $diff_tables= master:test.t1, slave:test.t1 +--source include/diff_tables.inc + +--echo +--echo # 2. STOP REPLICA SQL_THREAD: the receiver keeps running and the queue +--echo # retains the uncommitted transactions; START re-dispatches them. +--echo + +--source include/rpl/connection_replica.inc +--source include/rpl/stop_applier.inc + +--source include/rpl/connection_source.inc +--disable_query_log +--let $i= 4 +while ($i <= 33) +{ + --eval INSERT INTO t1 VALUES ($i) + --inc $i +} +--enable_query_log + +# Wait for the receiver to fetch everything into the in-memory queue. +--source include/rpl/sync_to_replica_received.inc + +--source include/rpl/connection_replica.inc +--let $queue_len= query_get_value(SHOW REPLICA STATUS, In_Memory_Queue_Length, 1) +--let $assert_text= Uncommitted transactions are retained in the in-memory queue while the applier is stopped +--let $assert_cond= $queue_len > 0 +--source include/assert.inc + +--source include/rpl/start_applier.inc + +--source include/rpl/connection_source.inc +--source include/rpl/sync_to_replica.inc + +--let $diff_tables= master:test.t1, slave:test.t1 +--source include/diff_tables.inc + +--echo +--echo # 3. STOP REPLICA IO_THREAD: the applier keeps running and applies the +--echo # already-received transactions; START IO_THREAD resumes reception. +--echo + +--source include/rpl/connection_source.inc +--disable_query_log +--let $i= 34 +while ($i <= 43) +{ + --eval INSERT INTO t1 VALUES ($i) + --inc $i +} +--enable_query_log +# Ensure the receiver has fetched everything before it is stopped. +--source include/rpl/sync_to_replica_received.inc + +--source include/rpl/connection_replica.inc +--source include/rpl/stop_receiver.inc + +# The applier is still running; assert so and let it drain the queue. +--let $sql_running= query_get_value(SHOW REPLICA STATUS, Replica_SQL_Running, 1) +--let $assert_text= The applier keeps running after STOP REPLICA IO_THREAD +--let $assert_cond= "$sql_running" = "Yes" +--source include/assert.inc + +# Everything already received is applied even though the receiver is stopped. +--source include/rpl/connection_source.inc +--source include/rpl/sync_to_replica.inc + +--let $diff_tables= master:test.t1, slave:test.t1 +--source include/diff_tables.inc + +--source include/rpl/connection_replica.inc +--source include/rpl/start_receiver.inc + +--source include/rpl/connection_source.inc +INSERT INTO t1 VALUES (44), (45), (46); +--source include/rpl/sync_to_replica.inc + +--let $diff_tables= master:test.t1, slave:test.t1 +--source include/diff_tables.inc + +--echo +--echo # 4. STOP REPLICA (both): the queue is reset; START REPLICA re-fetches by +--echo # GTID auto-positioning and stays consistent. +--echo + +--source include/rpl/connection_replica.inc +--source include/rpl/stop_replica.inc + +--let $queue_len= query_get_value(SHOW REPLICA STATUS, In_Memory_Queue_Length, 1) +--let $assert_text= A full STOP REPLICA resets the in-memory queue to empty +--let $assert_cond= $queue_len = 0 +--source include/assert.inc + +--source include/rpl/connection_source.inc +INSERT INTO t1 VALUES (47), (48), (49); + +--source include/rpl/connection_replica.inc +--source include/rpl/start_replica.inc + +--source include/rpl/connection_source.inc +--source include/rpl/sync_to_replica.inc + +--let $diff_tables= master:test.t1, slave:test.t1 +--source include/diff_tables.inc + +--echo +--echo # 5. Normal server restart: the queue starts empty and replication +--echo # resumes consistently. +--echo + +--source include/rpl/connection_replica.inc +--source include/rpl/stop_replica.inc + +--let $rpl_server_number= 2 +--source include/rpl/restart_server.inc + +--source include/rpl/connection_replica.inc +--let $queue_len= query_get_value(SHOW REPLICA STATUS, In_Memory_Queue_Length, 1) +--let $assert_text= The in-memory queue starts empty after a server restart +--let $assert_cond= $queue_len = 0 +--source include/assert.inc + +--source include/rpl/start_replica.inc + +--source include/rpl/connection_source.inc +INSERT INTO t1 VALUES (50), (51), (52); +--source include/rpl/sync_to_replica.inc + +--let $diff_tables= master:test.t1, slave:test.t1 +--source include/diff_tables.inc + +--echo +--echo # 6. Cleanup. +--echo + +--source include/rpl/connection_source.inc +DROP TABLE t1; +--source include/rpl/sync_to_replica.inc + +--source include/rpl/deinit.inc diff --git a/mysql-test/suite/rpl_nogtid/r/rpl_alter_repository.result b/mysql-test/suite/rpl_nogtid/r/rpl_alter_repository.result index 7515be313604..f6ef6baa153e 100644 --- a/mysql-test/suite/rpl_nogtid/r/rpl_alter_repository.result +++ b/mysql-test/suite/rpl_nogtid/r/rpl_alter_repository.result @@ -25,6 +25,9 @@ slave_relay_log_info CREATE TABLE `slave_relay_log_info` ( `Applier_version` int unsigned NOT NULL DEFAULT '1' COMMENT 'Version of the applier used (either 1 or 2)', `Applier_worker_count` int unsigned NOT NULL DEFAULT '0' COMMENT 'Number of worker threads utilized by the applier', `Applier_event_memory_limit` int unsigned NOT NULL DEFAULT '1073741824' COMMENT 'The maximum amount of memory applier channel may use to cache binlog events', + `In_memory_relaylog` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Indicates whether the channel uses the in-memory relay log instead of writing relay log files to disk.', + `In_memory_relaylog_limit` bigint unsigned NOT NULL DEFAULT '134217728' COMMENT 'The hard memory bound (in bytes) of the in-memory relay-log queue for the channel.', + `In_memory_relaylog_spill_threshold` bigint unsigned NOT NULL DEFAULT '16777216' COMMENT 'The transaction size (in bytes) above which the in-memory relay log spills the transaction instead of keeping it in memory.', PRIMARY KEY (`Channel_name`) ) /*!50100 TABLESPACE `mysql` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 STATS_PERSISTENT=0 ROW_FORMAT=DYNAMIC COMMENT='Relay Log Information' ALTER TABLE mysql.slave_relay_log_info ENGINE= Innodb; @@ -49,6 +52,9 @@ slave_relay_log_info CREATE TABLE `slave_relay_log_info` ( `Applier_version` int unsigned NOT NULL DEFAULT '1' COMMENT 'Version of the applier used (either 1 or 2)', `Applier_worker_count` int unsigned NOT NULL DEFAULT '0' COMMENT 'Number of worker threads utilized by the applier', `Applier_event_memory_limit` int unsigned NOT NULL DEFAULT '1073741824' COMMENT 'The maximum amount of memory applier channel may use to cache binlog events', + `In_memory_relaylog` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Indicates whether the channel uses the in-memory relay log instead of writing relay log files to disk.', + `In_memory_relaylog_limit` bigint unsigned NOT NULL DEFAULT '134217728' COMMENT 'The hard memory bound (in bytes) of the in-memory relay-log queue for the channel.', + `In_memory_relaylog_spill_threshold` bigint unsigned NOT NULL DEFAULT '16777216' COMMENT 'The transaction size (in bytes) above which the in-memory relay log spills the transaction instead of keeping it in memory.', PRIMARY KEY (`Channel_name`) ) /*!50100 TABLESPACE `mysql` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 STATS_PERSISTENT=0 ROW_FORMAT=DYNAMIC COMMENT='Relay Log Information' SET @@global.sync_source_info= 1; @@ -91,11 +97,11 @@ include/rpl/wait_for_applier_error.inc [errno=13121] Last_SQL_Error = 'Relay log read failure: Could not parse relay log event entry. The possible reasons are: the source's binary log is corrupted (you can check this by running 'mysqlbinlog' on the binary log), the replica's relay log is corrupted (you can check this by running 'mysqlbinlog' on the relay log), a network problem, the server was unable to fetch a keyring key required to open an encrypted relay log file, or a bug in the source's or replica's MySQL code. If you want to check the source's binary log or replica's relay log, you will be able to know their names by issuing 'SHOW REPLICA STATUS' on this replica.' include/rpl/stop_receiver.inc START REPLICA SQL_THREAD; -ERROR HY000: Column count of mysql.slave_relay_log_info is wrong. Expected 18, found 17. The table is probably corrupted +ERROR HY000: Column count of mysql.slave_relay_log_info is wrong. Expected 21, found 20. The table is probably corrupted RESET REPLICA ALL; -ERROR HY000: Column count of mysql.slave_relay_log_info is wrong. Expected 18, found 17. The table is probably corrupted +ERROR HY000: Column count of mysql.slave_relay_log_info is wrong. Expected 21, found 20. The table is probably corrupted CHANGE REPLICATION SOURCE TO SOURCE_HOST= 'SOURCE_HOST', SOURCE_USER= 'SOURCE_USER', SOURCE_PORT= SOURCE_PORT, SOURCE_LOG_FILE= 'SOURCE_LOG_FILE', SOURCE_LOG_POS= SOURCE_LOG_POS; -ERROR HY000: Column count of mysql.slave_relay_log_info is wrong. Expected 18, found 17. The table is probably corrupted +ERROR HY000: Column count of mysql.slave_relay_log_info is wrong. Expected 21, found 20. The table is probably corrupted ALTER TABLE mysql.slave_relay_log_info ADD COLUMN Number_of_workers INTEGER UNSIGNED AFTER Sql_delay; UPDATE mysql.slave_relay_log_info SET Number_of_workers= 0; RESET REPLICA ALL; @@ -214,6 +220,9 @@ slave_relay_log_info CREATE TABLE `slave_relay_log_info` ( `Applier_version` int unsigned NOT NULL DEFAULT '1' COMMENT 'Version of the applier used (either 1 or 2)', `Applier_worker_count` int unsigned NOT NULL DEFAULT '0' COMMENT 'Number of worker threads utilized by the applier', `Applier_event_memory_limit` int unsigned NOT NULL DEFAULT '1073741824' COMMENT 'The maximum amount of memory applier channel may use to cache binlog events', + `In_memory_relaylog` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Indicates whether the channel uses the in-memory relay log instead of writing relay log files to disk.', + `In_memory_relaylog_limit` bigint unsigned NOT NULL DEFAULT '134217728' COMMENT 'The hard memory bound (in bytes) of the in-memory relay-log queue for the channel.', + `In_memory_relaylog_spill_threshold` bigint unsigned NOT NULL DEFAULT '16777216' COMMENT 'The transaction size (in bytes) above which the in-memory relay log spills the transaction instead of keeping it in memory.', PRIMARY KEY (`Channel_name`) ) /*!50100 TABLESPACE `mysql` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 STATS_PERSISTENT=0 ROW_FORMAT=DYNAMIC COMMENT='Relay Log Information' # Search for occurrences of slave_master_info in the output from mysqldump diff --git a/mysql-test/suite/rpl_nogtid/r/rpl_row_mts_crash_safe.result b/mysql-test/suite/rpl_nogtid/r/rpl_row_mts_crash_safe.result index 89cadd542ba4..6c18aa7c236e 100644 --- a/mysql-test/suite/rpl_nogtid/r/rpl_row_mts_crash_safe.result +++ b/mysql-test/suite/rpl_nogtid/r/rpl_row_mts_crash_safe.result @@ -385,6 +385,7 @@ slave_relay_log_info CREATE TABLE `slave_relay_log_info` ( `Applier_version` int unsigned NOT NULL DEFAULT '1' COMMENT 'Version of the applier used (either 1 or 2)', `Applier_worker_count` int unsigned NOT NULL DEFAULT '0' COMMENT 'Number of worker threads utilized by the applier', `Applier_event_memory_limit` int unsigned NOT NULL DEFAULT '1073741824' COMMENT 'The maximum amount of memory applier channel may use to cache binlog events', + `In_memory_relaylog` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Indicates whether the channel uses the in-memory relay log instead of writing relay log files to disk.', PRIMARY KEY (`Channel_name`) ) /*!50100 TABLESPACE `mysql` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 STATS_PERSISTENT=0 ROW_FORMAT=DYNAMIC COMMENT='Relay Log Information' SHOW CREATE TABLE mysql.slave_worker_info; @@ -467,6 +468,7 @@ slave_relay_log_info CREATE TABLE `slave_relay_log_info` ( `Applier_version` int unsigned NOT NULL DEFAULT '1' COMMENT 'Version of the applier used (either 1 or 2)', `Applier_worker_count` int unsigned NOT NULL DEFAULT '0' COMMENT 'Number of worker threads utilized by the applier', `Applier_event_memory_limit` int unsigned NOT NULL DEFAULT '1073741824' COMMENT 'The maximum amount of memory applier channel may use to cache binlog events', + `In_memory_relaylog` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Indicates whether the channel uses the in-memory relay log instead of writing relay log files to disk.', PRIMARY KEY (`Channel_name`) ) /*!50100 TABLESPACE `mysql` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 STATS_PERSISTENT=0 ROW_FORMAT=DYNAMIC COMMENT='Relay Log Information' SHOW CREATE TABLE mysql.slave_worker_info; diff --git a/scripts/mysql_system_tables.sql b/scripts/mysql_system_tables.sql index 0092cf680498..42532ab17b24 100644 --- a/scripts/mysql_system_tables.sql +++ b/scripts/mysql_system_tables.sql @@ -384,6 +384,9 @@ SET @cmd="CREATE TABLE IF NOT EXISTS slave_relay_log_info ( Applier_version INTEGER UNSIGNED NOT NULL DEFAULT 1 COMMENT 'Version of the applier used (either 1 or 2)', Applier_worker_count INTEGER UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Number of worker threads utilized by the applier', Applier_event_memory_limit INTEGER UNSIGNED NOT NULL DEFAULT 1073741824 COMMENT 'The maximum amount of memory applier channel may use to cache binlog events', + In_memory_relaylog BOOLEAN NOT NULL DEFAULT 0 COMMENT 'Indicates whether the channel uses the in-memory relay log instead of writing relay log files to disk.', + In_memory_relaylog_limit BIGINT UNSIGNED NOT NULL DEFAULT 134217728 COMMENT 'The hard memory bound (in bytes) of the in-memory relay-log queue for the channel.', + In_memory_relaylog_spill_threshold BIGINT UNSIGNED NOT NULL DEFAULT 16777216 COMMENT 'The transaction size (in bytes) above which the in-memory relay log spills the transaction instead of keeping it in memory.', PRIMARY KEY(Channel_name)) DEFAULT CHARSET=utf8mb3 STATS_PERSISTENT=0 COMMENT 'Relay Log Information'"; SET @str=IF(@have_innodb <> 0, CONCAT(@cmd, ' ENGINE= INNODB ROW_FORMAT=DYNAMIC TABLESPACE=mysql ENCRYPTION=\'', @is_mysql_encrypted,'\''), CONCAT(@cmd, ' ENGINE= MYISAM')); diff --git a/scripts/mysql_system_tables_fix.sql b/scripts/mysql_system_tables_fix.sql index 5c627b0d80cc..b042795bbb68 100644 --- a/scripts/mysql_system_tables_fix.sql +++ b/scripts/mysql_system_tables_fix.sql @@ -1608,3 +1608,6 @@ ALTER TABLE procs_priv ALTER TABLE slave_relay_log_info ADD Applier_version INTEGER UNSIGNED NOT NULL DEFAULT 1 COMMENT 'Version of the applier used (either 1 or 2)' AFTER Assign_gtids_to_anonymous_transactions_value; ALTER TABLE slave_relay_log_info ADD Applier_worker_count INTEGER UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Number of worker threads utilized by the applier' AFTER Applier_version; ALTER TABLE slave_relay_log_info ADD Applier_event_memory_limit INTEGER UNSIGNED NOT NULL DEFAULT 1073741824 COMMENT 'The maximum amount of memory applier channel may use to cache binlog events' AFTER Applier_worker_count; +ALTER TABLE slave_relay_log_info ADD In_memory_relaylog BOOLEAN NOT NULL DEFAULT 0 COMMENT 'Indicates whether the channel uses the in-memory relay log instead of writing relay log files to disk.' AFTER Applier_event_memory_limit; +ALTER TABLE slave_relay_log_info ADD In_memory_relaylog_limit BIGINT UNSIGNED NOT NULL DEFAULT 134217728 COMMENT 'The hard memory bound (in bytes) of the in-memory relay-log queue for the channel.' AFTER In_memory_relaylog; +ALTER TABLE slave_relay_log_info ADD In_memory_relaylog_spill_threshold BIGINT UNSIGNED NOT NULL DEFAULT 16777216 COMMENT 'The transaction size (in bytes) above which the in-memory relay log spills the transaction instead of keeping it in memory.' AFTER In_memory_relaylog_limit; diff --git a/share/messages_to_clients.txt b/share/messages_to_clients.txt index 1f014496e5ea..125c6171ecba 100644 --- a/share/messages_to_clients.txt +++ b/share/messages_to_clients.txt @@ -11067,6 +11067,15 @@ ER_JDV_COLUMN_TAG_NOT_SUPPORTED_FOR_SUBQUERY ER_JDV_UPDATE_COLUMN_TAG_NOT_SUPPORTED_FOR_PK eng "The UPDATE column tag is not supported for a primary key projection at JSON path '%s'." +ER_CRST_IN_MEMORY_RELAYLOG_ONLY_FOR_CSA + eng "The in-memory relay log is only available on a CSA-enabled channel." + +ER_REPLICA_IN_MEMORY_RELAYLOG_INCOMPATIBLE_CONFIGURATION + eng "The in-memory relay log is incompatible with %s and cannot be used while it is active." + +ER_CRST_IN_MEMORY_RELAYLOG_INVALID_CONFIG + eng "Invalid in-memory relay log configuration: %s" + # # End of "9.7 cal-ver compatibility lineage (starts from 26.7)" error messages (server-to-client). # diff --git a/sql/CMakeLists.txt b/sql/CMakeLists.txt index 7dd60d4d2209..fb0b07b9518b 100644 --- a/sql/CMakeLists.txt +++ b/sql/CMakeLists.txt @@ -1128,6 +1128,19 @@ SET(RPL_CSA_SOURCES changestreams/apply/storage/relay_log/relay_log_deleter.cpp changestreams/apply/storage/relay_log/relay_log_adaptive_reader.cpp changestreams/apply/storage/relay_log/sync_transaction_provider.cpp + changestreams/apply/storage/in_memory/trx_payload.cc + changestreams/apply/storage/in_memory/transaction_envelope.cc + changestreams/apply/storage/in_memory/trx_envelope_queue.cc + changestreams/apply/storage/in_memory/event_set_fetchable_memory.cc + changestreams/apply/storage/in_memory/cached_event_memory.cc + changestreams/apply/storage/in_memory/spill_file_writer.cc + changestreams/apply/storage/in_memory/event_set_fetchable_spill.cc + changestreams/apply/storage/in_memory/queued_transaction_reader.cc + # TEMPORARY / SPIKE (tasks.md task 12) — do not ship. Standalone, + # minimal-state receiver-helper module prototyped for unit-testability; + # registered here only so imr_queued_transaction_writer-t can link it via + # server_unittest_library. Not wired into queue_event()/rpl_replica.cc. + changestreams/apply/storage/in_memory/queued_transaction_writer.cc changestreams/apply/resource/applier_channel_monitor.cpp changestreams/apply/resource/resource_map.cpp changestreams/apply/resource/resource_monitor.cpp diff --git a/sql/changestreams/apply/service/csa_service.cpp b/sql/changestreams/apply/service/csa_service.cpp index 8581074871fe..b31863ee8c44 100644 --- a/sql/changestreams/apply/service/csa_service.cpp +++ b/sql/changestreams/apply/service/csa_service.cpp @@ -229,10 +229,17 @@ bool Csa_service::run(Relay_log_info *rli) { assert(new_scheduler); Transaction_provider_sptr provider; - // create a relay log reader - provider.reset(new Sync_transaction_provider( - channel_instance_id, rli, tune::provider_max_read_event_bytes, - tune::provider_max_read_payload_bytes)); + if (rli->mi != nullptr && rli->mi->is_in_memory_relaylog()) { + // In-memory relay log: the provider drains transactions from the channel's + // queue via a Queued_transaction_reader instead of the on-disk relay log. + provider.reset(new Sync_transaction_provider(channel_instance_id, rli, + rli->mi->m_trx_queue)); + } else { + // Classic path: read consecutive events from the on-disk relay log. + provider.reset(new Sync_transaction_provider( + channel_instance_id, rli, tune::provider_max_read_event_bytes, + tune::provider_max_read_payload_bytes)); + } if (!provider) { rli->report(ERROR_LEVEL, ER_SERVER_OUT_OF_RESOURCES, "%s", ER_THD(rli->info_thd, ER_SERVER_OUT_OF_RESOURCES)); diff --git a/sql/changestreams/apply/storage/common/streaming_event_sink.h b/sql/changestreams/apply/storage/common/streaming_event_sink.h new file mode 100644 index 000000000000..d36325d8c523 --- /dev/null +++ b/sql/changestreams/apply/storage/common/streaming_event_sink.h @@ -0,0 +1,67 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +#ifndef MYSQL_CSA_STREAMING_EVENT_SINK_H +#define MYSQL_CSA_STREAMING_EVENT_SINK_H + +#include + +namespace mysql::csa { + +/// @brief Abstract destination the receiver streams a transaction's events +/// into, uniformly across storage paths. +/// +/// The receiver appends a transaction's body events one at a time, then seals +/// (or truncates) the stream once the terminal event is seen. This small +/// interface lets the receiver target *any* destination polymorphically. +/// +/// The contract is byte-oriented: the receiver forwards a transaction's raw +/// encoded event bytes (exactly as received) and each sink chooses its own +/// representation. +class Streaming_event_sink { + public: + /// @brief Appends one event's raw encoded bytes to the currently open stream. + /// @param buf Encoded event bytes, as received. The sink copies them + /// synchronously within this call, so @p buf may be a transient or + /// reused buffer. + /// @param len Number of bytes at @p buf. + /// @param seal_after When true, the stream is sealed together with the + /// append (the appended event is the terminal event of the batch). + virtual void append_event(const char *buf, std::size_t len, + bool seal_after = false) = 0; + + /// @brief Seals the open stream: no further events will be appended and the + /// consumer may finish once it drains the buffered events. + virtual void seal_stream() = 0; + + /// @brief Marks the open stream as truncated (incomplete): the consumer stops + /// at the events buffered so far. + virtual void set_stream_truncated() = 0; + + /// @brief Virtual destructor. + virtual ~Streaming_event_sink() = default; +}; + +} // namespace mysql::csa + +#endif // MYSQL_CSA_STREAMING_EVENT_SINK_H diff --git a/sql/changestreams/apply/storage/in_memory/cached_event_memory.cc b/sql/changestreams/apply/storage/in_memory/cached_event_memory.cc new file mode 100644 index 000000000000..fb58675c2b6b --- /dev/null +++ b/sql/changestreams/apply/storage/in_memory/cached_event_memory.cc @@ -0,0 +1,69 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +#include "sql/changestreams/apply/storage/in_memory/cached_event_memory.h" + +#include +#include +#include + +#include "sql/binlog_reader.h" // Default_binlog_event_allocator, binlog_event_deserialize +#include "sql/log_event.h" // Format_description_log_event / Log_event + +namespace mysql::csa { + +Cached_event_memory::Cached_event_memory( + const char *buf, std::size_t len, + std::shared_ptr fde, bool verify_checksum) + : m_bytes(reinterpret_cast(buf), + reinterpret_cast(buf) + len), + m_fde(std::move(fde)), + m_verify_checksum(verify_checksum) { + m_fde_ptr = dynamic_cast(m_fde.get()); + assert(m_fde_ptr != nullptr); +} + +std::shared_ptr Cached_event_memory::decode() { + // Decode from a fresh copy: the Log_event takes ownership + // of this copy (register_temp_buf DELEGATE) and frees it when destroyed. + Default_binlog_event_allocator allocator; + unsigned char *owned = allocator.allocate(m_bytes.size()); + if (owned == nullptr) return {}; + std::memcpy(owned, m_bytes.data(), m_bytes.size()); + + Log_event *event = nullptr; + Binlog_read_error read_status = binlog_event_deserialize( + owned, m_bytes.size(), m_fde_ptr, m_verify_checksum, &event); + if (read_status.has_error()) { + allocator.deallocate(owned); + return {}; + } + event->register_temp_buf( + reinterpret_cast(owned), + Default_binlog_event_allocator::DELEGATE_MEMORY_TO_EVENT_OBJECT); + return std::shared_ptr(event); +} + +void Cached_event_memory::reset(const Format_description_log_event *) {} + +} // namespace mysql::csa diff --git a/sql/changestreams/apply/storage/in_memory/cached_event_memory.h b/sql/changestreams/apply/storage/in_memory/cached_event_memory.h new file mode 100644 index 000000000000..4a69e4692b28 --- /dev/null +++ b/sql/changestreams/apply/storage/in_memory/cached_event_memory.h @@ -0,0 +1,69 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +#ifndef MYSQL_CSA_STORAGE_IN_MEMORY_CACHED_EVENT_MEMORY_H +#define MYSQL_CSA_STORAGE_IN_MEMORY_CACHED_EVENT_MEMORY_H + +#include +#include +#include + +#include "sql/changestreams/apply/storage/relay_log/ireader_event.h" // IReader_event + +class Format_description_log_event; +class Log_event; + +namespace mysql::csa { + +/// @brief An IReader_event that keeps an OWNING copy of the raw event bytes and +/// can be decoded more than once. +class Cached_event_memory : public IReader_event { + public: + /// @brief Take an owning copy of @p len bytes at @p buf. + /// + /// @param buf The (transient) encoded event bytes; copied in. + /// @param len Number of bytes at @p buf. + /// @param fde The active Format_description_log_event (shared + /// ownership) used to deserialize on every decode(). + /// @param verify_checksum Whether decode() re-verifies the event checksum. + Cached_event_memory(const char *buf, std::size_t len, + std::shared_ptr fde, + bool verify_checksum); + + /// @brief Deserialize a FRESH Log_event from the owned byte copy. + /// @return The decoded event, or an empty shared_ptr on error. + std::shared_ptr decode() override; + + /// @brief No-op: the master byte copy is immutable and reused per decode(). + void reset(const Format_description_log_event *) override; + + private: + std::vector m_bytes; ///< Owning master copy of the bytes. + std::shared_ptr m_fde; ///< FDE (owning base pointer). + Format_description_log_event *m_fde_ptr; ///< Non-owning typed FDE. + bool m_verify_checksum{false}; +}; + +} // namespace mysql::csa + +#endif // MYSQL_CSA_STORAGE_IN_MEMORY_CACHED_EVENT_MEMORY_H diff --git a/sql/changestreams/apply/storage/in_memory/event_set_fetchable_memory.cc b/sql/changestreams/apply/storage/in_memory/event_set_fetchable_memory.cc new file mode 100644 index 000000000000..be06e90038c5 --- /dev/null +++ b/sql/changestreams/apply/storage/in_memory/event_set_fetchable_memory.cc @@ -0,0 +1,279 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +#include "sql/changestreams/apply/storage/in_memory/event_set_fetchable_memory.h" + +#include "mysql/binlog/event/compression/payload_event_buffer_istream.h" +#include "sql/changestreams/apply/jobs/fetchable_transaction.h" +#include "sql/changestreams/apply/psi/psi.h" +#include "sql/changestreams/apply/storage/in_memory/cached_event_memory.h" +#include "sql/changestreams/apply/storage/in_memory/transaction_envelope.h" +#include "sql/current_thd.h" // current_thd +#include "sql/psi_memory_resource.h" +#include "sql/sql_class.h" // THD + +using namespace mysql::binlog::event::compression; +using namespace binlog; + +namespace mysql::csa { + +Event_set_fetchable_memory::Event_set_fetchable_memory( + bool is_trx, Event_set_fetchable::Log_event_ptr fde, + Transaction_envelope *owner_envelope, bool streaming_open) + : m_is_trx(is_trx), + m_fde_base(fde), + m_envelope(owner_envelope), + m_stream_open(streaming_open), + m_stream_sealed(!streaming_open), + m_stream_truncated(false) { + m_fde = dynamic_cast(m_fde_base.get()); + assert(m_fde_base); + assert(m_fde != nullptr); + reset(false); +} + +void Event_set_fetchable_memory::set_success() { + // Commit hook. commit() marks the envelope committed and resets its payload + // under only the per-envelope mutex; no queue-level mutex and no sweep. + const THD *thd = current_thd; + if (thd != nullptr && thd->is_killed()) { + return; + } + m_envelope->commit(); +} + +Event_set_fetchable::Fde_ptr Event_set_fetchable_memory::get_fde() { + return m_fde; +} + +bool Event_set_fetchable_memory::is_trx() const { return m_is_trx; } + +void Event_set_fetchable_memory::append_event(const char *buf, std::size_t len, + bool seal_after) { + // The receiver forwards raw encoded bytes. + std::shared_ptr fde(m_fde_base, m_fde); + IReader_event_ptr event = std::make_shared( + buf, len, std::move(fde), /*verify_checksum=*/false); + append_reader_event(std::move(event), seal_after); +} + +void Event_set_fetchable_memory::append_reader_event(IReader_event_ptr event, + bool seal_after) { + { + std::lock_guard lock(m_stream_mutex); + if (!m_stream_open || m_stream_sealed || m_stream_truncated) { + return; + } + m_events.push_back(std::move(event)); + if (seal_after) { + m_stream_sealed = true; + } + } + m_stream_cv.notify_one(); +} + +void Event_set_fetchable_memory::seal_stream() { + // Mark the seal for the transaction's reception. + { + std::lock_guard lock(m_stream_mutex); + m_stream_sealed = true; + } + m_stream_cv.notify_one(); +} + +void Event_set_fetchable_memory::set_stream_truncated() { + // Mark the owning transaction truncated, then this batch. + if (m_fetchable_trx != nullptr) m_fetchable_trx->set_fetching_truncated(); + // Mark the owning envelope truncated. + if (m_envelope != nullptr) m_envelope->set_truncated(); + { + std::lock_guard lock(m_stream_mutex); + m_stream_truncated = true; + m_stream_sealed = true; + } + m_stream_cv.notify_one(); +} + +void Event_set_fetchable_memory::start_decompression() { + assert(m_compressed_event); + m_decompressing = true; + m_compressed_event_ptr = + dynamic_cast(m_compressed_event.get()); + m_decompressing_stream.reset( + new Stream_type(*m_compressed_event_ptr, *m_fde, + psi_memory_resource(key_decompressing_stream))); + if (!m_decompressing_stream) { + m_failure_msg.assign("Fetchable event set: Out of memory"); + m_status = Return_status::error; + } +} + +void Event_set_fetchable_memory::end_decompression() { + m_decompressing = false; + m_decompressing_stream.reset(); + m_compressed_event_ptr = nullptr; + m_compressed_event.reset(); +} + +std::optional +Event_set_fetchable_memory::decompress() { + Log_event_ptr current_event; + *m_decompressing_stream >> current_event; + if (m_decompressing_stream->has_error()) { + using Status_t = Decompressing_event_object_istream::Status_t; + switch (m_decompressing_stream->get_status()) { + case Status_t::out_of_memory: + m_failure_msg.assign( + "Fetchable event set: out of memory while decompressing events"); + m_status = Return_status::error; + break; + case Status_t::exceeds_max_size: + case Status_t::corrupted: + case Status_t::truncated: + m_failure_msg.assign(m_decompressing_stream->get_error_str().c_str()); + m_status = Return_status::error; + break; + case Status_t::success: + case Status_t::end: + end_decompression(); + break; + } + } + + if (current_event && + current_event->get_type_code() == mysql::binlog::event::XID_EVENT) { + end_decompression(); + } + + if (current_event && !is_error()) { + return current_event; + } + return {}; +} + +bool Event_set_fetchable_memory::wait_next() { + while (true) { + if (is_error() || is_done()) { + return false; + } + + if (m_decompressing) { + return true; + } + + { + std::unique_lock lock(m_stream_mutex); + while (m_event_id >= m_events.size()) { + if (m_stream_truncated || m_stream_sealed) { + m_is_done = true; + return false; + } + m_stream_cv.wait(lock); + } + } + return true; + } +} + +std::optional Event_set_fetchable_memory::fetch_next() { + while (true) { + if (is_error() || is_done()) { + return {}; + } + + if (m_decompressing) { + auto decompressed_result = decompress(); + if (!decompressed_result.has_value()) { + if (is_error()) return {}; + continue; + } + { + std::lock_guard lock(m_stream_mutex); + if (!m_decompressing && m_stream_sealed && + m_event_id == m_events.size()) { + m_is_done = true; + } + } + return Managed_event(decompressed_result.value(), false); + } + + IReader_event_ptr reader_event; + bool is_last_in_batch{false}; + { + std::lock_guard lock(m_stream_mutex); + if (m_event_id >= m_events.size()) { + return {}; + } + reader_event = m_events[m_event_id++]; + is_last_in_batch = m_stream_sealed && m_event_id == m_events.size(); + } + + auto current_event = reader_event->decode(); + if (current_event->get_type_code() == + mysql::binlog::event::TRANSACTION_PAYLOAD_EVENT) { + m_compressed_event = current_event; + start_decompression(); + if (is_error()) return {}; + continue; + } + if (is_last_in_batch) { + m_is_done = true; + } + return Managed_event(current_event, false); + } +} + +const std::string &Event_set_fetchable_memory::get_error_str() const { + return m_failure_msg; +} + +bool Event_set_fetchable_memory::is_done() const { + return m_is_done && !is_error(); +} + +bool Event_set_fetchable_memory::is_error() const { + return m_status == Return_status::error; +} + +Event_set_fetchable_memory::~Event_set_fetchable_memory() {} + +void Event_set_fetchable_memory::reset(bool reset_events) { + std::lock_guard lock(m_stream_mutex); + end_decompression(); + m_event_id = 0; + m_is_done = false; + m_status = Return_status::ok; + if (reset_events) { + for (auto &event : m_events) { + assert(m_fde != nullptr); + event->reset(m_fde); + } + } + m_failure_msg.assign(""); + m_stream_truncated = false; + if (!m_stream_open) { + m_stream_sealed = true; + } +} + +} // namespace mysql::csa diff --git a/sql/changestreams/apply/storage/in_memory/event_set_fetchable_memory.h b/sql/changestreams/apply/storage/in_memory/event_set_fetchable_memory.h new file mode 100644 index 000000000000..e44b387ba988 --- /dev/null +++ b/sql/changestreams/apply/storage/in_memory/event_set_fetchable_memory.h @@ -0,0 +1,175 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +#ifndef MYSQL_CSA_STORAGE_IN_MEMORY_EVENT_SET_FETCHABLE_MEMORY_H +#define MYSQL_CSA_STORAGE_IN_MEMORY_EVENT_SET_FETCHABLE_MEMORY_H + +#include +#include +#include +#include +#include +#include +#include + +#include "mysql/utils/return_status.h" +#include "sql/binlog/decompressing_event_object_istream.h" // Decompressing_event_object_istream +#include "sql/changestreams/apply/storage/common/event_set_fetchable.h" +#include "sql/changestreams/apply/storage/common/streaming_event_sink.h" +#include "sql/changestreams/apply/storage/relay_log/ireader_event.h" + +namespace mysql::csa { + +class Transaction_envelope; +class Fetchable_transaction; + +/// @brief In-memory transaction byte source with a commit hook. +/// +/// The memory-path counterpart of Event_set_fetchable_cache: it holds a +/// transaction's encoded events in a RAM buffer and serves them to the worker +/// through the same Event_set_fetchable consumer interface, so it produces +/// the same byte stream as the cache variant. +class Event_set_fetchable_memory : public Event_set_fetchable, + public Streaming_event_sink { + public: + /// @brief Shared pointer to a Log_event. + using Log_event_ptr = std::shared_ptr; + /// @brief Type alias for return status. + using Return_status = mysql::utils::Return_status; + /// @brief Vector of events representing a set of events. + using Event_set_type = std::vector; + /// @brief Type alias for the decompressing event stream. + using Stream_type = ::binlog::Decompressing_event_object_istream; + /// @brief Unique pointer to the decompressing stream. + using Stream_ptr = std::unique_ptr; + + /// @brief Constructs an EMPTY in-memory byte source to be streamed into. + /// + /// @param is_trx Flag indicating if this set represents a transaction. + /// @param fde Shared pointer to the Format_description_event. + /// @param owner_envelope Non-owning pointer to the envelope this source + /// commits on success. May be nullptr for tests that exercise only the + /// byte stream without a commit. + /// @param streaming_open If true (default), events may be appended + /// concurrently until the stream is sealed or truncated. + Event_set_fetchable_memory(bool is_trx, Log_event_ptr fde, + Transaction_envelope *owner_envelope, + bool streaming_open = true); + + bool wait_next() override; + std::optional fetch_next() override; + const std::string &get_error_str() const override; + bool is_done() const override; + bool is_error() const override; + bool is_trx() const override; + void reset(bool reset_events) override; + Fde_ptr get_fde() override; + + /// @brief Virtual destructor. + virtual ~Event_set_fetchable_memory() override; + + /// @brief Callback notifying that the transaction was applied successfully. + /// + /// Commits the owning envelope under only the per-envelope mutex. + void set_success() override; + + /// @brief Byte-oriented sink append. + /// + /// Wraps a COPY of the transient encoded bytes in a Cached_event_memory, + void append_event(const char *buf, std::size_t len, + bool seal_after = false) override; + + /// @brief Internal event-oriented append seam. + /// + /// Pushes a pre-built IReader_event into the batch and does the seal/notify. + /// + /// @param event The pre-built encoded event to publish. + /// @param seal_after When true, seal the stream together with this append. + void append_reader_event(IReader_event_ptr event, bool seal_after = false); + + void seal_stream() override; + void set_stream_truncated() override; + + /// @brief Record the Fetchable_transaction that owns this batch. + /// + /// A memory-path Fetchable_transaction wraps and owns exactly one + /// Event_set_fetchable_memory + /// + /// @param owning_fetchable The owning transaction, or nullptr (tests). + void set_owning_fetchable(Fetchable_transaction *owning_fetchable) { + m_fetchable_trx = owning_fetchable; + } + + private: + /// @brief Cached vector of events (filled by the receiver via append_event). + Event_set_type m_events; + + /// @brief Decompresses and returns the next event from the TPLE stream. + /// @return Optional Log_event_ptr if successful, empty if failed or ended. + std::optional decompress(); + /// @brief Helper to deinitialize the decompression stream and update status. + void end_decompression(); + /// @brief Helper to initialize the decompression stream and update status. + void start_decompression(); + + /// @brief Flag indicating if processing is done (finished or error). + bool m_is_done = false; + /// @brief Index of the next event to fetch. + std::size_t m_event_id{0}; + /// @brief Detailed error message if any. + std::string m_failure_msg{""}; + /// @brief Status of the object. + Return_status m_status; + /// @brief Flag indicating if this is a transaction. + bool m_is_trx{false}; + /// @brief Flag indicating if currently decompressing a TPLE. + bool m_decompressing{false}; + /// @brief Decompressing stream created from TPLE if any. + Stream_ptr m_decompressing_stream{}; + /// @brief Non-owning pointer to compressed event casted to + /// Transaction_payload_log_event. + Transaction_payload_log_event *m_compressed_event_ptr{nullptr}; + /// @brief Compressed event used during decompression. + Log_event_ptr m_compressed_event{}; + /// @brief Owning pointer to Format_description_event. + Log_event_ptr m_fde_base{}; + /// @brief Non-owning pointer to Format_description_event. + Fde_ptr m_fde{}; + /// @brief Non-owning pointer to the owning envelope. + Transaction_envelope *m_envelope{nullptr}; + + /// @brief Non-owning pointer to the Fetchable_transaction that owns this + /// batch (set by Trx_payload::create_memory()). + Fetchable_transaction *m_fetchable_trx{nullptr}; + + /// @brief Stream synchronization state. + mutable std::mutex m_stream_mutex; + std::condition_variable m_stream_cv; + bool m_stream_open{false}; + bool m_stream_sealed{true}; + bool m_stream_truncated{false}; +}; + +} // namespace mysql::csa + +#endif // MYSQL_CSA_STORAGE_IN_MEMORY_EVENT_SET_FETCHABLE_MEMORY_H diff --git a/sql/changestreams/apply/storage/in_memory/event_set_fetchable_spill.cc b/sql/changestreams/apply/storage/in_memory/event_set_fetchable_spill.cc new file mode 100644 index 000000000000..c3cbcd6f08de --- /dev/null +++ b/sql/changestreams/apply/storage/in_memory/event_set_fetchable_spill.cc @@ -0,0 +1,359 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +#include "sql/changestreams/apply/storage/in_memory/event_set_fetchable_spill.h" + +#include +#include +#include + +#include "mysql/binlog/event/compression/payload_event_buffer_istream.h" +#include "sql/changestreams/apply/jobs/fetchable_transaction.h" +#include "sql/changestreams/apply/psi/psi.h" +#include "sql/changestreams/apply/storage/in_memory/spill_file_writer.h" +#include "sql/changestreams/apply/storage/in_memory/transaction_envelope.h" +#include "sql/current_thd.h" // current_thd +#include "sql/log_event.h" // Format_description_log_event +#include "sql/mysqld.h" // opt_replica_sql_verify_checksum +#include "sql/psi_memory_resource.h" +#include "sql/sql_class.h" // THD + +using namespace mysql::binlog::event::compression; +using namespace binlog; + +namespace mysql::csa { + +namespace { +const std::string kEmptyString{}; +} // namespace + +Event_set_fetchable_spill::Event_set_fetchable_spill( + bool is_trx, Event_set_fetchable::Log_event_ptr fde, + std::string relay_log_dir, Transaction_envelope *owner_envelope, + bool streaming_open) + : m_is_trx(is_trx), + m_fde_base(std::move(fde)), + m_envelope(owner_envelope), + m_reader(opt_replica_sql_verify_checksum), + m_stream_open(streaming_open), + m_stream_sealed(!streaming_open), + m_stream_truncated(false) { + m_status = Return_status::ok; + m_fde = dynamic_cast(m_fde_base.get()); + assert(m_fde_base); + assert(m_fde != nullptr); + + // Provision the backing spill file and write the relay-log prefix. + std::shared_ptr typed_fde(m_fde_base, m_fde); + m_writer = std::make_unique(std::move(typed_fde), + std::move(relay_log_dir)); + if (m_writer->open() || m_writer->flush()) { + // Provisioning failed: record the error and leave the stream un-open so + // append_event() short-circuits. + // TODO: Surface the error. + m_failure_msg.assign(m_writer->get_error_str()); + m_status = Return_status::error; + m_stream_open = false; + m_stream_sealed = true; + return; + } + // Where the header (magic + FDE) ends and where next read starts from. + m_start_file_pos = m_writer->end_position(); + m_published_end_file_pos = m_writer->end_position(); +} + +Event_set_fetchable_spill::~Event_set_fetchable_spill() { safe_close_reader(); } + +Event_set_fetchable::Fde_ptr Event_set_fetchable_spill::get_fde() { + return m_fde; +} + +bool Event_set_fetchable_spill::is_trx() const { return m_is_trx; } + +void Event_set_fetchable_spill::append_event(const char *buf, std::size_t len, + bool seal_after) { + // Reject once the stream is closed to appends (sealed/truncated/error). + { + std::lock_guard lock(m_stream_mutex); + if (is_error() || !m_stream_open || m_stream_sealed || m_stream_truncated) { + return; + } + } + + // Write the raw bytes and flush so a concurrent reader can see them. + if (m_writer->append_raw(buf, len) || m_writer->flush()) { + m_failure_msg.assign(m_writer->get_error_str()); + m_status = Return_status::error; + // On a write error, truncate the stream (which also wakes any parked + // reader) and report. + // TODO: Surface the error. + set_stream_truncated(); + return; + } + + // Publish the new readable end position and seal if this was the terminal + // event. + { + std::lock_guard lock(m_stream_mutex); + m_published_end_file_pos = m_writer->end_position(); + if (seal_after) { + m_stream_sealed = true; + } + } + m_stream_cv.notify_one(); +} + +void Event_set_fetchable_spill::seal_stream() { + // The single authoritative seal for the transaction's reception. + { + std::lock_guard lock(m_stream_mutex); + m_stream_sealed = true; + } + m_stream_cv.notify_one(); +} + +void Event_set_fetchable_spill::set_stream_truncated() { + // Mark the owning transaction truncated FIRST, then this batch. Truncation + // must reach both levels: + // - set_fetching_truncated() makes the consumer's wait/fetch observe it so + // the worker rolls back instead of committing a partial transaction; + // - the batch flags + m_stream_cv notify below wake a worker parked in this + // batch's wait_next(). + if (m_fetchable_trx != nullptr) m_fetchable_trx->set_fetching_truncated(); + // Mark the owning envelope truncated so the sweep reclaims this slot. + if (m_envelope != nullptr) m_envelope->set_truncated(); + { + std::lock_guard lock(m_stream_mutex); + m_stream_truncated = true; + m_stream_sealed = true; + } + m_stream_cv.notify_one(); +} + +void Event_set_fetchable_spill::safe_open_reader() { + safe_close_reader(); + // Open at the first-event offset so read_fdle() consumes the FDE prefix and + // installs the file's FDE, leaving the reader positioned at the first event. + if (m_reader.open(m_writer->file_name().c_str(), m_start_file_pos)) { + std::stringstream ss; + ss << "Spill event set: could not open spill file: " + << m_writer->file_name() << " @ " << m_start_file_pos; + m_failure_msg.assign(ss.str()); + m_status = Return_status::error; + return; + } + m_is_initialized = true; +} + +void Event_set_fetchable_spill::safe_close_reader() { + if (m_is_initialized) { + m_reader.close(); + } + m_is_initialized = false; +} + +void Event_set_fetchable_spill::start_reading() { + safe_open_reader(); + if (is_error()) { + return; + } + m_input_stream.reset( + new Stream_type(m_reader, psi_memory_resource(key_decompressing_stream))); + if (!m_input_stream) { + m_failure_msg.assign("Spill event set: out of memory"); + m_status = Return_status::error; + safe_close_reader(); + return; + } +} + +bool Event_set_fetchable_spill::wait_for_event_availability() { + if (decompressing()) { + return true; + } + while (true) { + std::unique_lock lock(m_stream_mutex); + // More bytes have been published than the reader has consumed: readable. + if (m_reader.position() < m_published_end_file_pos) { + return true; + } + if (m_stream_truncated || m_stream_sealed) { + m_is_done = true; + safe_close_reader(); + return false; + } + m_stream_cv.wait(lock); + } +} + +bool Event_set_fetchable_spill::wait_next() { + if (is_done() || is_error()) { + safe_close_reader(); + return false; + } + if (!m_is_initialized) { + start_reading(); + if (is_error()) { + return false; + } + } + return wait_for_event_availability(); +} + +std::optional Event_set_fetchable_spill::fetch_from_stream() { + Log_event_ptr current_event; + *m_input_stream >> current_event; + if (m_input_stream->has_error()) { + using Status_t = Decompressing_event_object_istream::Status_t; + switch (m_input_stream->get_status()) { + case Status_t::out_of_memory: + m_failure_msg.assign( + "Spill event set: out of memory while decompressing events"); + m_status = Return_status::error; + break; + case Status_t::exceeds_max_size: + case Status_t::corrupted: + case Status_t::truncated: + m_failure_msg.assign(m_input_stream->get_error_str().c_str()); + m_status = Return_status::error; + break; + case Status_t::success: + case Status_t::end: + if (!decompressing()) { + m_is_done = true; + } else { + m_decompressing = false; + } + break; + } + } + + if (decompressing() && current_event && + current_event->get_type_code() == mysql::binlog::event::XID_EVENT) { + // The decompressing stream does not always detect end-of-stream; the XID + // event terminates the decompressed transaction. + m_decompressing = false; + m_is_done = true; + safe_close_reader(); + } + + if (current_event && current_event->get_type_code() == + mysql::binlog::event::TRANSACTION_PAYLOAD_EVENT) { + assert(!decompressing()); + m_decompressing = true; + // Skip the wrapper and return the first decompressed event. + return fetch_from_stream(); + } + + if (is_error()) { + safe_close_reader(); + return {}; + } + + if (!current_event) { + // Ran out of readable bytes. If the stream is sealed or + // truncated, hitting the end unexpectedly is an error. + if (!m_stream_sealed && !m_stream_truncated) { + return {}; + } + m_failure_msg.assign("Spill event set: unexpected end of the stream"); + m_status = Return_status::error; + safe_close_reader(); + return {}; + } + + { + std::lock_guard lock(m_stream_mutex); + if (m_stream_sealed && !m_decompressing && + m_reader.position() >= m_published_end_file_pos) { + m_is_done = true; + safe_close_reader(); + } + } + + return Managed_event(current_event, true); +} + +std::optional Event_set_fetchable_spill::fetch_next() { + if (is_done() || is_error()) { + return {}; + } + return fetch_from_stream(); +} + +void Event_set_fetchable_spill::set_success() { + // Commit hook. commit() marks the envelope committed and resets its payload + // under only the per-envelope mutex. Skipped when the worker's THD was killed. + const THD *thd = current_thd; + // Prevent commit hook invoked during commit_order_manager fail through. + if (thd != nullptr && thd->is_killed()) { + return; + } + if (m_envelope != nullptr) m_envelope->commit(); +} + +void Event_set_fetchable_spill::reset(bool) { + // Close the reader and clear consumer/error state so the transaction can be + // re-read from the start (worker retry). The published position and sealed + // flag reflect what was received and are left intact. + safe_close_reader(); + std::lock_guard lock(m_stream_mutex); + m_decompressing = false; + m_is_done = false; + m_status = Return_status::ok; + m_failure_msg.assign(""); + m_stream_truncated = false; +} + +std::size_t Event_set_fetchable_spill::published_end_position() const { + std::lock_guard lock(m_stream_mutex); + return m_published_end_file_pos; +} + +bool Event_set_fetchable_spill::is_sealed() const { + std::lock_guard lock(m_stream_mutex); + return m_stream_sealed; +} + +bool Event_set_fetchable_spill::is_stream_truncated() const { + std::lock_guard lock(m_stream_mutex); + return m_stream_truncated; +} + +const std::string &Event_set_fetchable_spill::spill_file_name() const { + return m_writer ? m_writer->file_name() : kEmptyString; +} + +const std::string &Event_set_fetchable_spill::get_error_str() const { + return m_failure_msg; +} + +bool Event_set_fetchable_spill::is_done() const { + return m_is_done && !is_error(); +} + +bool Event_set_fetchable_spill::is_error() const { + return m_status == Return_status::error; +} + +} // namespace mysql::csa diff --git a/sql/changestreams/apply/storage/in_memory/event_set_fetchable_spill.h b/sql/changestreams/apply/storage/in_memory/event_set_fetchable_spill.h new file mode 100644 index 000000000000..1d62b6a888a8 --- /dev/null +++ b/sql/changestreams/apply/storage/in_memory/event_set_fetchable_spill.h @@ -0,0 +1,197 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +#ifndef MYSQL_CSA_STORAGE_IN_MEMORY_EVENT_SET_FETCHABLE_SPILL_H +#define MYSQL_CSA_STORAGE_IN_MEMORY_EVENT_SET_FETCHABLE_SPILL_H + +#include +#include +#include +#include +#include +#include + +#include "mysql/utils/return_status.h" +#include "sql/binlog/decompressing_event_object_istream.h" // Decompressing_event_object_istream +#include "sql/binlog_reader.h" // Relaylog_file_reader +#include "sql/changestreams/apply/storage/common/event_set_fetchable.h" +#include "sql/changestreams/apply/storage/common/streaming_event_sink.h" + +class Format_description_log_event; + +namespace mysql::csa { + +class Transaction_envelope; +class Fetchable_transaction; +class Spill_file_writer; + +/// @brief Spill-path transaction byte source: streams a large transaction to a +/// private on-disk file and serves it back to a worker. +/// +/// This is the spill-path counterpart of Event_set_fetchable_memory. This streams +/// bytes straight to a private relay-log-format file (owned by Spill_file_writer) +class Event_set_fetchable_spill : public Event_set_fetchable, + public Streaming_event_sink { + public: + /// @brief Shared pointer to a Log_event (owning base pointer for the FDE). + using Log_event_ptr = std::shared_ptr; + /// @brief Type alias for return status. + using Return_status = mysql::utils::Return_status; + /// @brief Type alias for the decompressing event stream. + using Stream_type = ::binlog::Decompressing_event_object_istream; + /// @brief Unique pointer to the decompressing stream. + using Stream_ptr = std::unique_ptr; + + /// @brief Constructs an EMPTY spill byte source and opens its backing file. + /// + /// Provisions the spill file (via Spill_file_writer) under the channel's + /// relay log directory and writes the relay-log prefix (magic + FDE). If the + /// file cannot be provisioned the source is left in an error state + /// (is_error()) and appends are rejected. + /// + /// @param is_trx Whether this set represents a real transaction. + /// @param fde Shared pointer to the Format_description_log_event; also + /// serialized into the spill file prefix. + /// @param relay_log_dir The channel's relay log directory (parent of the + /// spill temp-files subdirectory). + /// @param owner_envelope Non-owning pointer to the envelope this source + /// truncates/commits. May be nullptr for tests that exercise only the + /// byte stream. + /// @param streaming_open If true (default), events may be appended + /// concurrently until the stream is sealed or truncated. + Event_set_fetchable_spill(bool is_trx, Log_event_ptr fde, + std::string relay_log_dir, + Transaction_envelope *owner_envelope, + bool streaming_open = true); + + /// @brief Destructor (defined out-of-line for the unique_ptr member). + ~Event_set_fetchable_spill() override; + + bool wait_next() override; + std::optional fetch_next() override; + const std::string &get_error_str() const override; + bool is_done() const override; + bool is_error() const override; + bool is_trx() const override; + void reset(bool reset_events) override; + Fde_ptr get_fde() override; + + /// @brief Success callback: commit the owning envelope (mirrors the memory + /// sink). + void set_success() override; + + /// @brief Writes one event's raw encoded bytes to the spill file, flushes, + /// and publishes the new readable end position. + /// + /// @param buf Encoded event bytes, as received. Copied synchronously. + /// @param len Number of bytes at @p buf. + /// @param seal_after When true, seal the stream together with this append. + void append_event(const char *buf, std::size_t len, + bool seal_after = false) override; + + void seal_stream() override; + void set_stream_truncated() override; + + /// @brief Record the Fetchable_transaction that owns this batch. + /// + /// @param owning_fetchable The owning transaction, or nullptr (tests). + void set_owning_fetchable(Fetchable_transaction *owning_fetchable) { + m_fetchable_trx = owning_fetchable; + } + + /// @brief The current published readable end position (byte offset the + /// consumer may read up to). Advances monotonically as events are appended. + std::size_t published_end_position() const; + + /// @brief Whether the stream has been sealed (fully received). + bool is_sealed() const; + + /// @brief Whether the stream has been marked truncated (incomplete). + bool is_stream_truncated() const; + + /// @brief Full path of the backing spill file (empty if provisioning failed). + const std::string &spill_file_name() const; + + private: + /// @brief Opens the reader (if needed) and creates the decompressing stream. + void start_reading(); + /// @brief Opens the reader at the first-event offset (past the FDE prefix). + void safe_open_reader(); + /// @brief Closes the reader if it is open. + void safe_close_reader(); + /// @brief Blocks until at least one more event is readable, or the stream is + /// sealed/truncated/errored. + /// @retval true At least one more event can be fetched. + /// @retval false No more events (sealed/truncated/error). + bool wait_for_event_availability(); + /// @brief Reads and decodes the next event from the decompressing stream, + /// handling TPLE decompression and end-of-stream/error. + std::optional fetch_from_stream(); + /// @brief Whether the reader is currently decompressing a TPLE. + bool decompressing() const { return m_decompressing; } + + /// @brief Flag indicating if this is a transaction. + bool m_is_trx{false}; + /// @brief Owning pointer to Format_description_log_event (base type). + Log_event_ptr m_fde_base{}; + /// @brief Non-owning typed pointer to the Format_description_log_event. + Fde_ptr m_fde{nullptr}; + /// @brief Non-owning pointer to the owning envelope (truncate/commit target). + Transaction_envelope *m_envelope{nullptr}; + /// @brief Non-owning pointer to the owning Fetchable_transaction (truncate + /// propagation), set by Trx_payload::create_spill(). + Fetchable_transaction *m_fetchable_trx{nullptr}; + /// @brief Owns the on-disk spill file and its buffered write stream. + std::unique_ptr m_writer; + + /// @brief Flag indicating if the reader is open/initialized. + bool m_is_initialized{false}; + /// @brief Flag indicating if processing is done (finished or error). + bool m_is_done{false}; + /// @brief Flag indicating if currently decompressing a TPLE. + bool m_decompressing{false}; + /// @brief Detailed error message if any. + std::string m_failure_msg{""}; + /// @brief Status of the object. + Return_status m_status; + /// @brief File offset of the transaction's first event (end of the FDE + /// prefix); the reader opens here and reads the FDE at open. + std::size_t m_start_file_pos{0}; + /// @brief Decompressing stream over the spill file's reader. + Stream_ptr m_input_stream; + /// @brief Relay-log file reader over the private spill file. + Relaylog_file_reader m_reader; + + /// @brief Stream synchronization state (mirrors the relay-log sink). + mutable std::mutex m_stream_mutex; + std::condition_variable m_stream_cv; + /// @brief Readable end position published to the consumer (byte offset). + std::size_t m_published_end_file_pos{0}; + bool m_stream_open{false}; + bool m_stream_sealed{true}; + bool m_stream_truncated{false}; +}; + +} // namespace mysql::csa + +#endif // MYSQL_CSA_STORAGE_IN_MEMORY_EVENT_SET_FETCHABLE_SPILL_H diff --git a/sql/changestreams/apply/storage/in_memory/in_memory_types.h b/sql/changestreams/apply/storage/in_memory/in_memory_types.h new file mode 100644 index 000000000000..81b5b2356a3f --- /dev/null +++ b/sql/changestreams/apply/storage/in_memory/in_memory_types.h @@ -0,0 +1,37 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +#ifndef MYSQL_CSA_IN_MEMORY_TYPES_H +#define MYSQL_CSA_IN_MEMORY_TYPES_H + +namespace mysql::csa { + +/// @brief Indicates where a transaction envelope's payload is stored. +enum class Envelope_path { MEMORY, SPILL }; + +/// @brief Result of an admission decision for a transaction of a given size. +enum class Admission { MEMORY, SPILL, WOULD_BLOCK }; + +} // namespace mysql::csa + +#endif // MYSQL_CSA_IN_MEMORY_TYPES_H diff --git a/sql/changestreams/apply/storage/in_memory/queued_transaction_reader.cc b/sql/changestreams/apply/storage/in_memory/queued_transaction_reader.cc new file mode 100644 index 000000000000..093aaa40c329 --- /dev/null +++ b/sql/changestreams/apply/storage/in_memory/queued_transaction_reader.cc @@ -0,0 +1,112 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +#include "sql/changestreams/apply/storage/in_memory/queued_transaction_reader.h" + +#include "mysqld_error.h" // ER_REPLICA_FATAL_ERROR +#include "sql/changestreams/apply/context/channel.h" +#include "sql/changestreams/apply/jobs/fetchable_transaction.h" +#include "sql/changestreams/apply/jobs/job_applier.h" +#include "sql/changestreams/apply/psi/psi.h" // stage_csa_working +#include "sql/changestreams/apply/psi/stage.h" // concurrency::set_thd_stage +#include "sql/changestreams/apply/storage/in_memory/transaction_envelope.h" +#include "sql/changestreams/apply/storage/in_memory/trx_envelope_queue.h" +#include "sql/changestreams/apply/storage/in_memory/trx_payload.h" +#include "sql/current_thd.h" // current_thd +#include "sql/derror.h" // ER_THD +#include "sql/mysqld.h" // slave_trans_retries +#include "sql/rpl_mi.h" // Master_info +#include "sql/rpl_rli.h" // Relay_log_info + +namespace mysql::csa { + +Queued_transaction_reader::Queued_transaction_reader(int instance_id, + Relay_log_info *rli, + Trx_envelope_queue *queue) + : m_instance_id(instance_id), + m_rli(rli), + m_queue(queue), + m_stat_monitor(scheduler::Statistics_monitor::get(instance_id)), + m_resource_monitor(Resource_monitor::get(instance_id)) { + m_channel.reset(new Channel(rli->mi->get_channel(), instance_id, + rli->get_commit_order_manager())); +} + +Queued_transaction_reader::Queued_transaction_reader(Trx_envelope_queue *queue) + : m_instance_id(0), + m_rli(nullptr), + m_queue(queue), + m_stat_monitor(scheduler::Statistics_monitor::get(0)), + m_resource_monitor(Resource_monitor::get(0)) { + // Test-only constructor. +} + +Queued_transaction_reader::~Queued_transaction_reader() = default; + +Job_ptr Queued_transaction_reader::read() { + if (is_stopped()) { + return nullptr; + } + + // Report the coordinator as caught up while it blocks below waiting for the + // next transaction, mirroring the on-disk reader. + if (m_rli != nullptr) + concurrency::set_thd_stage(m_rli->info_thd, + stage_replica_has_read_all_relay_log); + + // Sweeps the committed head envelope,and dispatches the next uncommitted transaction. + Transaction_envelope *envelope = m_queue->sweep_and_dispatch(); + if (envelope == nullptr) { + return nullptr; + } + + // A transaction is available again: restore the working stage. + if (m_rli != nullptr) + concurrency::set_thd_stage(m_rli->info_thd, stage_csa_working); + + // Take a shared ownership copy of the transaction + std::shared_ptr fetchable; + Trx_payload *payload = envelope->payload(); + if (payload != nullptr) { + fetchable = payload->fetchable(); + } + + Job_applier *job = + new Job_applier(m_channel.get(), slave_trans_retries, fetchable, + m_stat_monitor, m_resource_monitor); + return job; +} + +bool Queued_transaction_reader::is_stopped() const { + return m_is_error || m_stopped || m_queue->is_stopped(); +} + +bool Queued_transaction_reader::is_error() const { return m_is_error; } + +void Queued_transaction_reader::stop() { + m_stopped = true; + // Stop the APPLIER role. + m_queue->stop(Trx_envelope_queue::Scope::APPLIER); +} + +} // namespace mysql::csa diff --git a/sql/changestreams/apply/storage/in_memory/queued_transaction_reader.h b/sql/changestreams/apply/storage/in_memory/queued_transaction_reader.h new file mode 100644 index 000000000000..86c19db00498 --- /dev/null +++ b/sql/changestreams/apply/storage/in_memory/queued_transaction_reader.h @@ -0,0 +1,125 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +#ifndef MYSQL_CSA_STORAGE_IN_MEMORY_QUEUED_TRANSACTION_READER_H +#define MYSQL_CSA_STORAGE_IN_MEMORY_QUEUED_TRANSACTION_READER_H + +#include + +#include "mysql/scheduler/statistics_instance_monitor.h" +#include "sql/changestreams/apply/resource/resource_monitor.h" +#include "sql/changestreams/apply/storage/common/reader.h" + +class Relay_log_info; + +namespace mysql::csa { + +class Channel; +class Trx_envelope_queue; + +namespace unittests { +// Unit test fixtures granted access to the queue-only test constructor below. +class Queued_transaction_reader_test; +class Imr_integration_test; +} + +/// @brief Queue-based transaction reader that builds Job_applier work items. +/// +/// This reader is the memory-path counterpart of Relay_log_adaptive_reader. +/// Each read() takes a shared ownership copy of that transaction's +/// Fetchable_transaction, and wraps it in a heap Job_applier for the worker pool. +class Queued_transaction_reader : public Reader { + public: + /// @param instance_id Instance (channel) id. + /// @param rli RLI for the channel; supplies the channel name and commit order + /// manager used to build the per-job Channel. + /// @param queue The per-channel FIFO of transaction envelopes to drain. Must + /// be non-null and outlive this reader (held non-owning). + Queued_transaction_reader(int instance_id, Relay_log_info *rli, + Trx_envelope_queue *queue); + ~Queued_transaction_reader() override; + + Queued_transaction_reader(const Queued_transaction_reader &) = delete; + Queued_transaction_reader &operator=(const Queued_transaction_reader &) = + delete; + Queued_transaction_reader(Queued_transaction_reader &&) = delete; + Queued_transaction_reader &operator=(Queued_transaction_reader &&) = delete; + + /// @brief Reads the next Job (full transaction) from the queue and supplies a + /// fetchable job object. + /// + /// Blocks on the queue's dispatch hand-off until the next envelope is + /// available or a stop is requested. + /// + /// @return Pointer to a Job on success; empty pointer on stop. + Job_ptr read() override; + + /// @brief Checks whether the reader is stopped. + /// @return true if a stop was requested (locally or on the queue) or an error + /// occurred, false otherwise. + bool is_stopped() const override; + + /// @brief Checks whether the reader errored out. + /// @return true if an error occurred, false otherwise. + bool is_error() const override; + + /// @brief Awakes and stops the reader. + /// + /// Sets the local stopped flag and delegates to Trx_envelope_queue::stop(), + /// which wakes a reader blocked in dispatch_next(). + void stop() override; + + private: + // Grants the unit tests access to the queue-only test constructor so the + // read() path can be exercised without a live Relay_log_info/Channel. + friend class unittests::Queued_transaction_reader_test; + friend class unittests::Imr_integration_test; + + /// @brief Test-only constructor that wires ONLY the envelope queue. + /// + /// @param queue The per-channel FIFO to drain. Must be non-null and outlive + /// this reader (held non-owning). + /// TODO: Remove after MTR filling the test gap. + explicit Queued_transaction_reader(Trx_envelope_queue *queue); + + /// Unique instance (channel) id. + int m_instance_id{0}; + /// Relay log context of the applier thread that launches CSA (non-owning). + Relay_log_info *m_rli{nullptr}; + /// The per-channel envelope queue this reader drains (non-owning). + Trx_envelope_queue *m_queue{nullptr}; + /// Owning pointer to the channel object passed to each Job_applier. + std::unique_ptr m_channel; + /// Statistics monitoring object for the current instance. + scheduler::Statistics_instance_monitor_ref m_stat_monitor; + /// Resource monitoring object for the current instance. + Resource_instance_monitor_ref m_resource_monitor; + /// Internal error flag. + bool m_is_error{false}; + /// Stop flag, set by stop(). + bool m_stopped{false}; +}; + +} // namespace mysql::csa + +#endif // MYSQL_CSA_STORAGE_IN_MEMORY_QUEUED_TRANSACTION_READER_H diff --git a/sql/changestreams/apply/storage/in_memory/queued_transaction_writer.cc b/sql/changestreams/apply/storage/in_memory/queued_transaction_writer.cc new file mode 100644 index 000000000000..785d1d88feb4 --- /dev/null +++ b/sql/changestreams/apply/storage/in_memory/queued_transaction_writer.cc @@ -0,0 +1,73 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +// Receiver-side "writer" mechanism for the in-memory relay log — the producer +// counterpart to Queued_transaction_reader. See queued_transaction_writer.h +// for the full description. The thin Master_info adapters live in +// sql/rpl_replica.cc. + +#include "sql/changestreams/apply/storage/in_memory/queued_transaction_writer.h" + +#include + +#include "sql/changestreams/apply/storage/common/streaming_event_sink.h" +#include "sql/changestreams/apply/storage/in_memory/transaction_envelope.h" +#include "sql/changestreams/apply/storage/in_memory/trx_envelope_queue.h" + +namespace mysql::csa { + +bool open_transaction(Trx_envelope_queue &queue, + Streaming_event_sink *¤t_sink, + std::shared_ptr fde, + std::size_t trx_length, bool is_trx) { + // enqueue() creates a reachable sink immediately on return. + // A nullptr envelope means a stop was requested while blocked in admission. + Transaction_envelope *env = queue.enqueue(trx_length, is_trx, std::move(fde)); + if (env == nullptr) { + current_sink = nullptr; + return true; + } + current_sink = env->current_sink(); + return false; +} + +bool append_transaction_event(Streaming_event_sink *¤t_sink, + const char *buf, std::size_t len, + bool is_terminal) { + // Defensive: no group is open, so there is nothing to append into. + if (current_sink == nullptr) return true; + + // Forward the raw encoded bytes straight to the sink. + // On the terminal event, the append also seals the stream. + current_sink->append_event(buf, len, /*seal_after=*/is_terminal); + if (is_terminal) current_sink = nullptr; + return false; +} + +void truncate_transaction(Streaming_event_sink *¤t_sink) { + if (current_sink == nullptr) return; + current_sink->set_stream_truncated(); + current_sink = nullptr; +} + +} // namespace mysql::csa diff --git a/sql/changestreams/apply/storage/in_memory/queued_transaction_writer.h b/sql/changestreams/apply/storage/in_memory/queued_transaction_writer.h new file mode 100644 index 000000000000..9fa2c0a22739 --- /dev/null +++ b/sql/changestreams/apply/storage/in_memory/queued_transaction_writer.h @@ -0,0 +1,98 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +#ifndef MYSQL_CSA_STORAGE_IN_MEMORY_QUEUED_TRANSACTION_WRITER_H +#define MYSQL_CSA_STORAGE_IN_MEMORY_QUEUED_TRANSACTION_WRITER_H + +// ============================================================================= +// Receiver-side "writer" mechanism for the in-memory relay log — the producer +// counterpart to Queued_transaction_reader. +// +// Provides the streaming operations the receiver drives to push a transaction's +// events into the per-channel Trx_envelope_queue: open_transaction() (at the +// GTID event), append_transaction_event() (per body/terminal event), and +// truncate_transaction() (on an incomplete group). +// +// The thin Master_info adapters that map these operations onto the running +// receiver live in sql/rpl_replica.cc. +// ============================================================================= + +#include +#include + +class Format_description_log_event; + +namespace mysql::csa { + +class Trx_envelope_queue; +class Streaming_event_sink; + +/// @brief Open a transaction group at the GTID event (minimal-state variant of +/// the receiver's on-GTID hook). +/// +/// TODO: refactor to avoid passing current_sink arg as output pointer. +/// +/// @param[in,out] queue The per-channel envelope queue to admit into. +/// @param[out] current_sink Set to the opened group's sink on success, or to +/// nullptr when @c enqueue() reports a stop. +/// @param[in] fde The active Format_description_log_event (shared +/// ownership); must be non-null. +/// @param[in] trx_length The transaction's declared byte size, as read +/// from the GTID event. +/// @param[in] is_trx Whether the admitted unit is a real transaction. +/// @retval false success — the group opened; @p current_sink is non-null. +/// @retval true error — a stop was requested while blocked in admission; @p +/// current_sink is left nullptr (queuing-error indication). +bool open_transaction(Trx_envelope_queue &queue, + Streaming_event_sink *¤t_sink, + std::shared_ptr fde, + std::size_t trx_length, bool is_trx = true); + +/// @brief Append one received event's bytes to the open group (minimal-state +/// variant of the receiver's on-body hook). +/// +/// The raw encoded bytes are forwarded straight to @p current_sink. When +/// @p is_terminal, the append also seals the stream. +/// +/// @param[in,out] current_sink The open group's sink; cleared to nullptr after +/// a terminal append. A null sink is a defensive +/// no-op returning true (error). +/// @param[in] buf The transient event bytes (copied in by the +/// sink). +/// @param[in] len Number of bytes at @p buf. +/// @param[in] is_terminal Whether this event terminates (seals) the group. +/// @retval false success — the event was appended. +/// @retval true error — @p current_sink was null (nothing appended). +bool append_transaction_event(Streaming_event_sink *¤t_sink, + const char *buf, std::size_t len, + bool is_terminal); + +/// @brief Truncate the open group (minimal-state variant of the receiver's +/// on-truncate hook). +/// +/// @param[in,out] current_sink The open group's sink, cleared on truncation. +void truncate_transaction(Streaming_event_sink *¤t_sink); + +} // namespace mysql::csa + +#endif // MYSQL_CSA_STORAGE_IN_MEMORY_QUEUED_TRANSACTION_WRITER_H diff --git a/sql/changestreams/apply/storage/in_memory/spill_file_writer.cc b/sql/changestreams/apply/storage/in_memory/spill_file_writer.cc new file mode 100644 index 000000000000..6d5b1deaa3aa --- /dev/null +++ b/sql/changestreams/apply/storage/in_memory/spill_file_writer.cc @@ -0,0 +1,195 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +#include "sql/changestreams/apply/storage/in_memory/spill_file_writer.h" + +#include +#include +#include +#include +#include +#include + +#include "my_io.h" // File, FN_LIBCHAR +#include "my_sys.h" // my_create, my_mkdir, my_close, my_delete +#include "sql/log_event.h" // Format_description_log_event, BINLOG_MAGIC, BIN_LOG_HEADER_SIZE + +namespace mysql::csa { + +namespace { + +std::atomic g_spill_seq{0}; + +/// Joins @p dir and @p leaf with the platform directory separator, avoiding a +/// double separator when @p dir already ends with one. +std::string join_path(const std::string &dir, const std::string &leaf) { + if (dir.empty()) return leaf; + if (dir.back() == FN_LIBCHAR) return dir + leaf; + return dir + FN_LIBCHAR + leaf; +} + +} + +Spill_file_writer::Spill_file_writer(Fde_ptr fde, std::string relay_log_dir) + : m_fde(std::move(fde)), m_relay_log_dir(std::move(relay_log_dir)) {} + +Spill_file_writer::~Spill_file_writer() { + // Close the IO_CACHE, then delete the temp file. + if (m_stream_open) { + m_ostream.close(); + m_stream_open = false; + } + if (!m_file_name.empty()) { + my_delete(m_file_name.c_str(), MYF(0)); + m_file_name.clear(); + } +} + +bool Spill_file_writer::create_unique_file() { + constexpr int kMaxAttempts = 1024; + for (int attempt = 0; attempt < kMaxAttempts; ++attempt) { + const std::uint64_t id = g_spill_seq.fetch_add(1); + char id_buf[32]; + // Lowercase hex id. + std::snprintf(id_buf, sizeof(id_buf), "%llx", + static_cast(id)); + const std::string candidate = + join_path(m_temp_dir, std::string(kFileNamePrefix) + id_buf); + + const File fd = + my_create(candidate.c_str(), 0, O_CREAT | O_EXCL | O_WRONLY, MYF(0)); + if (fd >= 0) { + // Reserved the name exclusively; IO_CACHE_ostream reopens it by name. + my_close(fd, MYF(0)); + m_file_name = candidate; + return false; + } + if (my_errno() != EEXIST) { + m_error.assign("Spill_file_writer: could not create spill file"); + return true; + } + } + m_error.assign("Spill_file_writer: exhausted unique spill file names"); + return true; +} + +bool Spill_file_writer::open() { + assert(!m_is_open); + assert(m_fde != nullptr); + + if (m_relay_log_dir.empty()) { + m_error.assign("Spill_file_writer: empty relay log directory"); + return true; + } + + // Place spill files in the "in_memory_relaylog_temp_files" subdirectory under + // the channel's relay log directory, creating it on demand. + // TODO: Move the folder creation to server start with proper error check. + m_temp_dir = join_path(m_relay_log_dir, kTempSubdirName); + if (my_mkdir(m_temp_dir.c_str(), 0777, MYF(0)) != 0 && my_errno() != EEXIST) { + m_error.assign( + "Spill_file_writer: could not create temp-files subdirectory"); + m_temp_dir.clear(); + return true; + } + + // Create a uniquely named "imr_sp_" file in that directory. + if (create_unique_file()) { + return true; + } + + if (m_ostream.open( +#ifdef HAVE_PSI_INTERFACE + PSI_NOT_INSTRUMENTED, +#endif + m_file_name.c_str(), MYF(MY_WME))) { + m_error.assign("Spill_file_writer: could not open IO_CACHE over spill file"); + my_delete(m_file_name.c_str(), MYF(0)); + m_file_name.clear(); + return true; + } + m_stream_open = true; + + // Relay-log prefix: the 4-byte magic. + if (m_ostream.write(reinterpret_cast(BINLOG_MAGIC), + BIN_LOG_HEADER_SIZE)) { + m_error.assign("Spill_file_writer: failed to write BINLOG_MAGIC"); + return true; + } + m_end_pos = BIN_LOG_HEADER_SIZE; + + // Relay-log prefix: the serialized FDE. + // Serialize a PRIVATE copy. + Format_description_log_event fde_copy; + static_cast(fde_copy) = + static_cast( + *m_fde); + + // Mark it a relay-log FDE and PRESERVE the checksum algorithm the copy + // carries. + fde_copy.set_relay_log_event(); + if (fde_copy.common_footer->checksum_alg == + mysql::binlog::event::BINLOG_CHECKSUM_ALG_UNDEF) { + fde_copy.common_footer->checksum_alg = + mysql::binlog::event::BINLOG_CHECKSUM_ALG_OFF; + } + fde_copy.common_header->log_pos = m_end_pos; + if (fde_copy.write(&m_ostream)) { + m_error.assign("Spill_file_writer: failed to write FDE"); + return true; + } + m_end_pos += fde_copy.common_header->data_written; + + m_is_open = true; + return false; +} + +bool Spill_file_writer::append_raw(const char *buf, std::size_t len) { + assert(m_is_open); + if (!m_is_open) { + m_error.assign("Spill_file_writer: append_raw before open"); + return true; + } + if (len == 0) return false; + if (m_ostream.write(reinterpret_cast(buf), len)) { + m_error.assign("Spill_file_writer: failed to append event bytes"); + return true; + } + m_end_pos += len; + return false; +} + +bool Spill_file_writer::flush() { + assert(m_is_open); + if (!m_is_open) { + m_error.assign("Spill_file_writer: flush before open"); + return true; + } + if (m_ostream.flush()) { + m_error.assign("Spill_file_writer: failed to flush spill file"); + return true; + } + return false; +} + +} // namespace mysql::csa diff --git a/sql/changestreams/apply/storage/in_memory/spill_file_writer.h b/sql/changestreams/apply/storage/in_memory/spill_file_writer.h new file mode 100644 index 000000000000..efe944039d8e --- /dev/null +++ b/sql/changestreams/apply/storage/in_memory/spill_file_writer.h @@ -0,0 +1,140 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +#ifndef MYSQL_CSA_STORAGE_IN_MEMORY_SPILL_FILE_WRITER_H +#define MYSQL_CSA_STORAGE_IN_MEMORY_SPILL_FILE_WRITER_H + +#include +#include +#include + +#include "my_inttypes.h" // my_off_t +#include "sql/basic_ostream.h" // IO_CACHE_ostream + +class Format_description_log_event; + +namespace mysql::csa { + +/// @brief Private on-disk spill file for one large transaction, written in +/// relay-log format. +/// +/// A transaction whose length exceeds the spill threshold is streamed to a +/// private file on disk instead of RAM. This helper owns that file. +class Spill_file_writer { + public: + /// @brief Shared pointer to the Format_description_log_event written as the + /// file's prefix and used by the reader to decode subsequent events. + using Fde_ptr = std::shared_ptr; + + /// @brief Name of the per-channel subdirectory that holds spill files. + static constexpr const char *kTempSubdirName = "in_memory_relaylog_temp_files"; + + /// @brief Prefix of every spill file name: "imr_sp_". + static constexpr const char *kFileNamePrefix = "imr_sp_"; + + /// @brief Constructs a writer. Does not touch the filesystem until open(). + /// + /// @param fde The FDE serialized into the file prefix. Must be non-null when + /// open() runs. + /// @param relay_log_dir The channel's relay log directory. open() creates the + /// @c in_memory_relaylog_temp_files subdirectory under it and places + /// the spill file there. Must be non-empty when open() runs. + Spill_file_writer(Fde_ptr fde, std::string relay_log_dir); + + /// @brief Closes the IO_CACHE and deletes the temp file, if any. + ~Spill_file_writer(); + + Spill_file_writer(const Spill_file_writer &) = delete; + Spill_file_writer &operator=(const Spill_file_writer &) = delete; + Spill_file_writer(Spill_file_writer &&) = delete; + Spill_file_writer &operator=(Spill_file_writer &&) = delete; + + /// @brief Ensures the temp-files subdirectory exists, creates a uniquely + /// named @c imr_sp_ file in it, and writes the relay-log prefix + /// (BINLOG_MAGIC + serialized FDE). + /// + /// @retval false Success. + /// @retval true Error (see get_error_str()). + bool open(); + + /// @brief Appends @p len raw bytes to the open file and advances the end + /// position by @p len. The bytes are copied into the IO_CACHE synchronously, + /// so @p buf may be transient. A zero-length append is a no-op. + /// + /// @param buf Bytes to append. + /// @param len Number of bytes at @p buf. + /// @retval false Success. + /// @retval true Error (see get_error_str()). + bool append_raw(const char *buf, std::size_t len); + + /// @brief Flushes the IO_CACHE buffer to the file so a concurrent reader can + /// read up to end_position(). Does not fsync. + /// + /// @retval false Success. + /// @retval true Error (see get_error_str()). + bool flush(); + + /// @brief Current logical end position: the number of bytes written so far + /// (prefix + all appended bytes). Advances monotonically. + my_off_t end_position() const { return m_end_pos; } + + /// @brief Full path of the backing spill file (empty until open() succeeds). + const std::string &file_name() const { return m_file_name; } + + /// @brief Full path of the temp-files subdirectory (empty until open()). + const std::string &temp_dir() const { return m_temp_dir; } + + /// @brief True once open() has laid down the prefix successfully. + bool is_open() const { return m_is_open; } + + /// @brief Human-readable message for the last error, empty if none. + const std::string &get_error_str() const { return m_error; } + + private: + /// @brief Creates a uniquely named, O_EXCL spill file under m_temp_dir and + /// stores its path in m_file_name. Returns true on error. + bool create_unique_file(); + + /// @brief FDE serialized into the file prefix. + Fde_ptr m_fde; + /// @brief The channel's relay log directory (parent of the temp subdir). + std::string m_relay_log_dir; + /// @brief Buffered write stream over the temp file. + IO_CACHE_ostream m_ostream; + /// @brief Path to the temp-files subdirectory (set in open()). + std::string m_temp_dir; + /// @brief Path to the backing spill file (empty until open() succeeds). + std::string m_file_name; + /// @brief Last error message, empty if none. + std::string m_error; + /// @brief Logical bytes written so far (prefix + appended bytes). + my_off_t m_end_pos{0}; + /// @brief True while the IO_CACHE stream is open (guards close()). + bool m_stream_open{false}; + /// @brief True once the prefix is fully written. + bool m_is_open{false}; +}; + +} // namespace mysql::csa + +#endif // MYSQL_CSA_STORAGE_IN_MEMORY_SPILL_FILE_WRITER_H diff --git a/sql/changestreams/apply/storage/in_memory/transaction_envelope.cc b/sql/changestreams/apply/storage/in_memory/transaction_envelope.cc new file mode 100644 index 000000000000..9b045f132fa4 --- /dev/null +++ b/sql/changestreams/apply/storage/in_memory/transaction_envelope.cc @@ -0,0 +1,104 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +#include "sql/changestreams/apply/storage/in_memory/transaction_envelope.h" + +#include +#include + +namespace mysql::csa { + +Transaction_envelope::Transaction_envelope(std::uint64_t stream_seqno, + std::size_t trx_length, + Envelope_path path) + : m_trx_length(trx_length), m_stream_seqno(stream_seqno), m_path(path) {} + +bool Transaction_envelope::is_committed() const { + std::lock_guard lock(m_mutex); + return m_committed; +} + +bool Transaction_envelope::commit() { + std::lock_guard lock(m_mutex); + if (m_committed) { + // Repeated commit is invalid. + return true; + } + // Committed and truncated are mutually exclusive. + assert(!m_truncated); + m_committed = true; + // Resetting the payload releases bytes and wakes a blocked receiver. + m_payload.reset(); + return false; +} + +bool Transaction_envelope::is_truncated() const { + std::lock_guard lock(m_mutex); + return m_truncated; +} + +void Transaction_envelope::set_truncated() { + std::lock_guard lock(m_mutex); + // The receiver truncates only the still-open, uncommitted transaction. + assert(!m_committed); + m_truncated = true; +} + +void Transaction_envelope::attach_payload( + std::unique_ptr payload) { + std::lock_guard lock(m_mutex); + m_payload = std::move(payload); +} + +void Transaction_envelope::create_memory_destination( + bool is_trx, std::shared_ptr fde, + Trx_envelope_queue *owner_queue) { + // Build the empty MEMORY-path payload. + attach_payload(Trx_payload::create_memory(is_trx, std::move(fde), + m_trx_length, owner_queue, this)); +} + +void Transaction_envelope::create_spill_destination( + bool is_trx, std::shared_ptr fde, + Trx_envelope_queue *owner_queue) { + // Build the empty SPILL-path payloa. + attach_payload( + Trx_payload::create_spill(is_trx, std::move(fde), owner_queue, this)); +} + +Trx_payload *Transaction_envelope::payload() { + std::lock_guard lock(m_mutex); + return m_payload.get(); +} + +void Transaction_envelope::reset_payload() { + std::lock_guard lock(m_mutex); + m_payload.reset(); +} + +Streaming_event_sink *Transaction_envelope::current_sink() const { + std::lock_guard lock(m_mutex); + return m_payload ? m_payload->sink() : nullptr; +} + +} // namespace mysql::csa diff --git a/sql/changestreams/apply/storage/in_memory/transaction_envelope.h b/sql/changestreams/apply/storage/in_memory/transaction_envelope.h new file mode 100644 index 000000000000..3245189f45a6 --- /dev/null +++ b/sql/changestreams/apply/storage/in_memory/transaction_envelope.h @@ -0,0 +1,172 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +#ifndef MYSQL_CSA_STORAGE_IN_MEMORY_TRANSACTION_ENVELOPE_H +#define MYSQL_CSA_STORAGE_IN_MEMORY_TRANSACTION_ENVELOPE_H + +#include +#include +#include +#include + +#include "sql/changestreams/apply/storage/in_memory/in_memory_types.h" +#include "sql/changestreams/apply/storage/in_memory/trx_payload.h" + +// create_memory_destination() threads the active format-description event +// through to the byte source only as a shared_ptr, so an incomplete +// (forward-declared) type is sufficient here. +class Format_description_log_event; + +namespace mysql::csa { + +class Streaming_event_sink; +class Trx_envelope_queue; + +/// @brief Lightweight FIFO-queue entry tracking a single transaction's +/// lifecycle state and its heavy payload. +/// +/// A Transaction_envelope lives from GTID receipt (when it is enqueued in +/// source order) until the coordinator sweeps it past the committed head. It is +/// deliberately lightweight. Keeping the payload separate lets memory be +/// reclaimed at commit (when the payload is reset) rather than at dequeue, +/// avoiding head-of-line memory blocking when workers commit out of source order. +class Transaction_envelope { + public: + /// @brief Construct an uncommitted envelope for a transaction of + /// @p trx_length. + /// + /// @param stream_seqno The monotonic enqueue-order position assigned by the + /// queue. + /// @param trx_length The transaction's declared byte size from the GTID + /// event. + /// @param path Whether the transaction is routed to the memory or spill path. + Transaction_envelope(std::uint64_t stream_seqno, std::size_t trx_length, + Envelope_path path); + + Transaction_envelope(const Transaction_envelope &) = delete; + Transaction_envelope &operator=(const Transaction_envelope &) = delete; + Transaction_envelope(Transaction_envelope &&) = delete; + Transaction_envelope &operator=(Transaction_envelope &&) = delete; + + /// @brief The monotonic enqueue-order position of this envelope. + std::uint64_t stream_seqno() const { return m_stream_seqno; } + + /// @brief The transaction's declared byte size. + std::size_t trx_length() const { return m_trx_length; } + + /// @brief Whether this envelope is on the memory or spill path. + Envelope_path path() const { return m_path; } + + // --- Commit state (serialized under m_mutex) --- + + /// @brief Whether this envelope has been committed, read under @c m_mutex. + bool is_committed() const; + + /// @brief Worker commit path: mark committed and release the payload. + /// + /// Invoked once from the worker's success hook. Under only the per-envelope + /// @c m_mutex, marks the envelope committed and resets the payload. + /// + /// @retval false success: the envelope was marked committed and its payload + /// was released. + /// @retval true failure: the envelope was already committed; commit flag and + /// payload are unchanged. + bool commit(); + + /// @brief Whether this envelope was truncated (its transaction was received + /// incompletely), read under @c m_mutex. + bool is_truncated() const; + + /// @brief Receiver path: mark this envelope truncated. + /// + /// Set when the receiver could not finish this transaction (IO-thread stop + /// mid-transaction, rotate, or a queue write failure). + /// TODO: rotate should be no-op. + void set_truncated(); + + // --- Payload handoff (serialized under m_mutex) --- + + /// @brief Take ownership of the heavy payload for this envelope. + /// + /// Attached at admission for a memory-path envelope, keeping the payload + /// non-null from admission until commit. + /// + /// @param payload The payload to store (ownership is transferred in). + void attach_payload(std::unique_ptr payload); + + /// @brief Create and attach this envelope's empty MEMORY-path destination. + /// + /// Builds the payload via Trx_payload::create_memory() (using this envelope's + /// @c m_trx_length) and attaches it. + /// + /// @param is_trx Whether the admitted unit is a real transaction. + /// @param fde The active Format_description_log_event (shared ownership). + /// @param owner_queue The queue charged for the payload's byte reservation. + void create_memory_destination( + bool is_trx, std::shared_ptr fde, + Trx_envelope_queue *owner_queue); + + /// @brief Create and attach this envelope's empty SPILL-path destination. + /// + /// The spill-path counterpart of create_memory_destination(). Builds the + /// zero-reservation payload via Trx_payload::create_spill() and attaches it. + /// + /// @param is_trx Whether the admitted unit is a real transaction. + /// @param fde The active Format_description_log_event (shared ownership). + /// @param owner_queue The queue hosting the spill file and charged for the + /// (zero) reservation. + void create_spill_destination( + bool is_trx, std::shared_ptr fde, + Trx_envelope_queue *owner_queue); + + /// @brief Non-owning peek at the payload. + /// @return The stored payload pointer, or nullptr once it has been reset. + Trx_payload *payload(); + + /// @brief Drop the payload, releasing its bytes and nulling the reference. + void reset_payload(); + + // --- Sink handle (delegates to the payload) --- + + /// @brief The transaction's memory-path sink, delegating to the payload. + /// + /// Returns the non-owning sink pointer held by the payload. Valid only while + /// the payload is alive (admission→commit). + /// + /// @return The non-owning sink pointer, or nullptr when there is no payload. + Streaming_event_sink *current_sink() const; + + private: + /// TODO: remove m_stream_seqno, and merge state for commit/truncate + mutable std::mutex m_mutex; ///< Guards commit flag + payload reference. + std::size_t m_trx_length; ///< From the GTID event. + std::uint64_t m_stream_seqno; ///< Monotonic enqueue-order position. + Envelope_path m_path; ///< MEMORY | SPILL. + bool m_committed{false}; ///< Commit terminal flag; set once at commit. + bool m_truncated{false}; ///< Truncate terminal flag; set by the receiver. + std::unique_ptr m_payload; ///< Heavy bytes; reset at commit. +}; + +} // namespace mysql::csa + +#endif // MYSQL_CSA_STORAGE_IN_MEMORY_TRANSACTION_ENVELOPE_H diff --git a/sql/changestreams/apply/storage/in_memory/trx_envelope_queue.cc b/sql/changestreams/apply/storage/in_memory/trx_envelope_queue.cc new file mode 100644 index 000000000000..05b74aa65777 --- /dev/null +++ b/sql/changestreams/apply/storage/in_memory/trx_envelope_queue.cc @@ -0,0 +1,299 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +#include "sql/changestreams/apply/storage/in_memory/trx_envelope_queue.h" + +#include +#include + +namespace mysql::csa { + +Trx_envelope_queue::Trx_envelope_queue(std::size_t memory_limit, + std::size_t spill_threshold, + std::string relay_log_dir) + : m_memory_limit(memory_limit), + m_spill_threshold(spill_threshold), + m_relay_log_dir(std::move(relay_log_dir)) {} + +Trx_envelope_queue::~Trx_envelope_queue() { + assert(m_envelopes.empty()); + assert(m_bytes_used.load() == 0); +} + +Admission Trx_envelope_queue::classify(std::size_t trx_length) const { + if (trx_length > m_spill_threshold) { + return Admission::SPILL; + } + const std::size_t used = m_bytes_used.load(); + if (used + trx_length <= m_memory_limit) { + return Admission::MEMORY; + } + return Admission::WOULD_BLOCK; +} + +bool Trx_envelope_queue::acquire_admission(std::size_t trx_length) { + std::unique_lock lock(m_mem_mutex); + // Block while the reservation would exceed the limit and no stop is pending. + m_mem_cv.wait(lock, [this, trx_length] { + return m_receiver_stopped || + m_bytes_used.load() + trx_length <= m_memory_limit; + }); + if (m_receiver_stopped) { + return true; // stop requested: reserve nothing, report failure. + } + return false; +} + +Transaction_envelope *Trx_envelope_queue::enqueue( + std::size_t trx_length, bool is_trx, + std::shared_ptr fde) { + // The MEMORY-path destination created below needs a non-null FDE. + assert(fde != nullptr); + // The queue owns the admission + placement decision. Pick the path first. + const Envelope_path path = (classify(trx_length) == Admission::SPILL) + ? Envelope_path::SPILL + : Envelope_path::MEMORY; + + // Memory path: block until the reservation fits, or bail out on stop. The + // spill path is outside the memory budget, so it never blocks here. + if (path == Envelope_path::MEMORY && acquire_admission(trx_length)) { + return nullptr; // stop requested while blocked: abort the enqueue. + } + + // Read the cursor without m_queue_mutex: as the sole producer. + const std::uint64_t stream_seqno = m_insert_seqno + 1; + + // Build the envelope and its destination outside m_queue_mutex, then publish + // the finished object. + auto owned = + std::make_unique(stream_seqno, trx_length, path); + if (path == Envelope_path::MEMORY) { + // Reserves trx_length bytes (admission was acquired above). + owned->create_memory_destination(is_trx, std::move(fde), this); + } else { + // Spill path: outside the memory budget, so no admission was acquired. + // Provisions the private spill file under the channel's relay log directory + // and reserves zero bytes. + owned->create_spill_destination(is_trx, std::move(fde), this); + } + Transaction_envelope *envelope = owned.get(); + + { + std::lock_guard lock(m_queue_mutex); + assert(m_insert_seqno + 1 == stream_seqno); // single-producer guard + m_envelopes.push_back(std::move(owned)); + ++m_insert_seqno; + assert_cursor_invariant(); + } + // Wake a consumer that may be blocked because dispatch_seqno == insert_seqno. + m_not_empty_cv.notify_one(); + return envelope; +} + +Transaction_envelope *Trx_envelope_queue::dispatch_next() { + // Entry point for callers that do not already hold m_queue_mutex. + std::unique_lock lock(m_queue_mutex); + return dispatch_next_locked(lock); +} + +Transaction_envelope *Trx_envelope_queue::dispatch_next_locked( + std::unique_lock &lock) { + // The caller owns m_queue_mutex and keeps owning it across this call. + assert(lock.owns_lock()); + assert(lock.mutex() == &m_queue_mutex); + + // Block until there is an undispatched envelope or a stop is requested. + m_not_empty_cv.wait(lock, [this] { + return m_applier_stopped || m_dispatch_seqno < m_insert_seqno; + }); + + // On applier stop, advance no cursor and hand back nothing. + if (m_applier_stopped) { + return nullptr; + } + + // The next envelope to dispatch sits at index dispatch_seqno - commit_seqno. + const std::size_t index = + static_cast(m_dispatch_seqno - m_commit_seqno); + Transaction_envelope *envelope = m_envelopes[index].get(); + assert(envelope != nullptr); + ++m_dispatch_seqno; + assert_cursor_invariant(); + + return envelope; +} + +bool Trx_envelope_queue::sweep_committed(bool need_lock) { + if (need_lock) { + std::lock_guard lock(m_queue_mutex); + return sweep_committed(false); + } + // Caller holds m_queue_mutex. Drop the contiguous + // envelopes that are either committed or truncated. + while (!m_envelopes.empty()) { + // Never advance the commit mark past the dispatch cursor. + if (m_commit_seqno >= m_dispatch_seqno) { + break; + } + assert(m_envelopes.front() != nullptr); + // is_committed()/is_truncated() each take the per-envelope mutex (lock + // order queue -> envelope). Reclaim a head that reached either state. + if (!m_envelopes.front()->is_committed() && + !m_envelopes.front()->is_truncated()) { + break; + } + // The head must be the envelope at the commit mark. + if (m_envelopes.front()->stream_seqno() != m_commit_seqno + 1) { + assert(false); // loud in debug builds + return true; + } + // Dropping the entry destroys the envelope (and its Trx_payload, if any). + m_envelopes.pop_front(); + ++m_commit_seqno; + assert_cursor_invariant(); + } + return false; +} + +Transaction_envelope *Trx_envelope_queue::sweep_and_dispatch() { + std::unique_lock lock(m_queue_mutex); + // (1) sweep the finalized head envelope (committed, truncated) + // (2) dispatch the next envelope + // (3) skip any finalized one due to committed out of order in a + // prior session, or truncated by the receiver (wait sweep in next run). + for (;;) { + // Sweep the committed head prefix, reclaiming finished slots. + if (sweep_committed(/*need_lock=*/false)) { + // Structural inconsistency (corrupt cursors). + m_applier_stopped = true; + return nullptr; + } + // (2) Dispatch the next envelope under the lock we hold; + Transaction_envelope *envelope = dispatch_next_locked(lock); + if (envelope == nullptr) { + return nullptr; // stop + } + // (3) Return only a live transaction. + if (!envelope->is_committed() && !envelope->is_truncated()) { + return envelope; + } + } +} + +std::uint64_t Trx_envelope_queue::commit_seqno() const { + std::lock_guard lock(m_queue_mutex); + return m_commit_seqno; +} + +std::uint64_t Trx_envelope_queue::dispatch_seqno() const { + std::lock_guard lock(m_queue_mutex); + return m_dispatch_seqno; +} + +std::uint64_t Trx_envelope_queue::insert_seqno() const { + std::lock_guard lock(m_queue_mutex); + return m_insert_seqno; +} + +std::size_t Trx_envelope_queue::queue_length() const { + std::lock_guard lock(m_queue_mutex); + return m_envelopes.size(); +} + +void Trx_envelope_queue::add_bytes(std::size_t n) { + // Atomic update, no queue-level mutex held. + m_bytes_used.fetch_add(n); +} + +void Trx_envelope_queue::release_bytes(std::size_t n) { + m_bytes_used.fetch_sub(n); + // Wake blocked admitters so they can re-check the limit. + { + std::lock_guard lock(m_mem_mutex); + } + m_mem_cv.notify_all(); +} + +void Trx_envelope_queue::stop(Scope scope) { + if (scope == Scope::RECEIVER || scope == Scope::ALL) { + // Wake an IO thread parked in acquire_admission() so enqueue(). + // The flag and m_mem_cv share m_mem_mutex. + { + std::lock_guard lock(m_mem_mutex); + m_receiver_stopped = true; + } + m_mem_cv.notify_all(); + } + if (scope == Scope::APPLIER || scope == Scope::ALL) { + // Wake a coordinator parked in dispatch_next(). + // The flag and m_not_empty_cv share m_queue_mutex. + { + std::lock_guard lock(m_queue_mutex); + m_applier_stopped = true; + } + m_not_empty_cv.notify_all(); + } +} + +bool Trx_envelope_queue::is_stopped() const { + // Fully stopped == both roles stopped. + bool receiver_stopped; + bool applier_stopped; + { + std::lock_guard lock(m_mem_mutex); + receiver_stopped = m_receiver_stopped; + } + { + std::lock_guard lock(m_queue_mutex); + applier_stopped = m_applier_stopped; + } + return receiver_stopped && applier_stopped; +} + +void Trx_envelope_queue::resume(Scope scope) { + // Re-enable a role stopped at the end of a previous session. + if (scope == Scope::RECEIVER || scope == Scope::ALL) { + std::lock_guard lock(m_mem_mutex); + m_receiver_stopped = false; + } + if (scope == Scope::APPLIER || scope == Scope::ALL) { + std::lock_guard lock(m_queue_mutex); + m_applier_stopped = false; + m_dispatch_seqno = m_commit_seqno; + } +} + +void Trx_envelope_queue::reset() { + // Precondition: no producer or consumer is attached (both replication threads + // stopped and joined) + std::lock_guard lock(m_queue_mutex); + // Drop every entry and zero the cursors. + m_envelopes.clear(); + m_commit_seqno = 0; + m_dispatch_seqno = 0; + m_insert_seqno = 0; + assert_cursor_invariant(); // empty deque with 0/0/0 cursors + assert(m_bytes_used.load() == 0); +} + +} // namespace mysql::csa diff --git a/sql/changestreams/apply/storage/in_memory/trx_envelope_queue.h b/sql/changestreams/apply/storage/in_memory/trx_envelope_queue.h new file mode 100644 index 000000000000..5fdc9b32c5c1 --- /dev/null +++ b/sql/changestreams/apply/storage/in_memory/trx_envelope_queue.h @@ -0,0 +1,332 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +#ifndef MYSQL_CSA_STORAGE_IN_MEMORY_TRX_ENVELOPE_QUEUE_H +#define MYSQL_CSA_STORAGE_IN_MEMORY_TRX_ENVELOPE_QUEUE_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sql/changestreams/apply/storage/in_memory/in_memory_types.h" +#include "sql/changestreams/apply/storage/in_memory/transaction_envelope.h" + +class Format_description_log_event; + +namespace mysql::csa { + +/// @brief Per-channel in-memory FIFO of transaction envelopes with an atomic +/// memory-usage counter and blocking admission control. +/// +/// The queue owns its envelopes in a @c std::deque in enqueue order and tracks +/// progress with three monotonic cursors: @c commit_seqno (the committed head), +/// @c dispatch_seqno (the next envelope to hand to a worker), and +/// @c insert_seqno (the tail). A single mutex, @c m_queue_mutex, guards the +/// deque, the cursors, and all structural changes. +/// +/// Each transaction's commit is done by its worker under the per-envelope mutex, +/// so a committing worker never contends on @c m_queue_mutex. +class Trx_envelope_queue { + public: + /// @brief The replication role a lifecycle operation targets. + /// + /// The queue has two independent sets of waiters: the receiver (IO thread) + /// parked in acquire_admission(), and the applier (coordinator) parked in + /// dispatch_next(). @c Scope lets stop()/resume() target one role without + /// disturbing the other, so a single-thread STOP/START wakes only that role. + /// @c ALL targets both. + enum class Scope { RECEIVER, APPLIER, ALL }; + + /// @brief Construct the queue with its per-channel memory bounds. + /// + /// @param memory_limit The hard per-channel memory bound + /// (IN_MEMORY_RELAYLOG_LIMIT) on bytes held by memory-path payloads. + /// @param spill_threshold The per-channel size + /// (IN_MEMORY_RELAYLOG_SPILL_THRESHOLD) above which a transaction is + /// routed to the spill path instead of the memory path. + /// @param relay_log_dir The channel's relay log directory. Spill files are + /// created in its @c in_memory_relaylog_temp_files subdirectory. + /// Defaults to empty for callers that never spill (e.g. memory-only + /// unit tests); a real directory is required before spilling. + Trx_envelope_queue(std::size_t memory_limit, std::size_t spill_threshold, + std::string relay_log_dir = {}); + + Trx_envelope_queue(const Trx_envelope_queue &) = delete; + Trx_envelope_queue &operator=(const Trx_envelope_queue &) = delete; + Trx_envelope_queue(Trx_envelope_queue &&) = delete; + Trx_envelope_queue &operator=(Trx_envelope_queue &&) = delete; + + /// @brief Destroy the queue, asserting it outlives all its payloads. + /// + /// Every Trx_payload holds a back-pointer to this queue and calls + /// release_bytes() on it when destroyed. + ~Trx_envelope_queue(); + + // --- Producer (receiver) admission API --- + + /// @brief Decide the store path for a transaction of @p trx_length at the + /// GTID event. + /// + /// @param trx_length The transaction's declared byte size from the GTID + /// event. + /// @retval Admission::SPILL when @p trx_length is greater than the spill + /// threshold. + /// @retval Admission::MEMORY when @p trx_length fits the spill threshold and + /// bytes_used() + trx_length is within the memory limit. + /// @retval Admission::WOULD_BLOCK otherwise. + Admission classify(std::size_t trx_length) const; + + /// @brief Block until a memory-path admission of @p trx_length fits under the + /// memory limit. + /// + /// Waits while bytes_used() + trx_length exceeds the memory limit and no stop + /// has been requested, re-checking on each wakeup.. + /// + /// @param trx_length The transaction's declared byte size. + /// @retval false success: admission is acquired (the reservation now fits). + /// @retval true failure: a stop was requested; no bytes were reserved. + bool acquire_admission(std::size_t trx_length); + + /// @brief Admit a transaction at the GTID event and append its queue entry. + /// + /// 1. classifies the store path (SPILL if trx_length > spill_threshold, + /// else MEMORY); + /// 2. for the memory path, blocks the caller (the IO thread) while + /// bytes_used() + trx_length exceeds the memory limit — the intended + /// back-pressure, like a classic relay log stalling the receiver when out + /// of space — returning early only on stop; + /// 3. builds a new Transaction_envelope with a fresh, increasing + /// @c stream_seqno and attaches its empty destination; + /// 4. under @c m_queue_mutex, appends the envelope, bumps @c insert_seqno, + /// and wakes a consumer blocked in dispatch_next(). + /// + /// @param trx_length The transaction's declared byte size from the GTID + /// event. + /// @param is_trx Whether the admitted unit is a real transaction (true) or a + /// standalone/administrative event group (false). + /// @param fde The active Format_description_log_event. Shared ownership keeps + /// it alive for in-flight transactions even after a later FD event + /// replaces the receiver's current one. Must be non-null. + /// @return A non-owning pointer to the appended envelope, or nullptr if a stop + /// was requested while blocked in admission (the receiver then aborts + /// the enqueue). + Transaction_envelope *enqueue(std::size_t trx_length, bool is_trx, + std::shared_ptr fde); + + // --- Consumer (coordinator) API --- + + /// @brief Block until the envelope at @c dispatch_seqno is available, then + /// return it and advance @c dispatch_seqno. + /// + /// Waits while @c dispatch_seqno == @c insert_seqno (nothing to dispatch) and + /// no stop is pending. On success, returns the next envelope and increments + /// @c dispatch_seqno. On stop, returns nullptr without advancing any cursor. + /// + /// Use this from callers that do NOT already hold @c m_queue_mutex。 + /// + /// @retval nullptr a stop was requested while blocked or on entry. + /// @return otherwise, a non-owning pointer to the dispatched envelope, valid + /// until the coordinator sweeps it. + Transaction_envelope *dispatch_next(); + + /// @brief Coordinator sweep: dequeue the contiguous committed head prefix. + /// + /// While the head envelope is committed, pops it and advances @c commit_seqno. + /// Stops at the first uncommitted head or an empty deque. + /// + /// It also stops before advancing @c commit_seqno past @c dispatch_seqno, so + /// the commit mark never overtakes the dispatch cursor. + /// + /// @param need_lock @c true (default) to acquire @c m_queue_mutex here; pass + /// @c false when the caller already holds it (e.g. + /// @c sweep_and_dispatch()). + /// @retval false success: the committed head prefix (possibly empty) was swept + /// and the cursor invariant holds. + /// @retval true failure: a structural inconsistency was found (head + /// stream_seqno != commit_seqno + 1); the sweep stops and leaves the + /// queue unchanged from that point. + bool sweep_committed(bool need_lock = true); + + /// @brief Coordinator step: sweep the committed head prefix, then block for + /// and dispatch the next envelope — all under one @c m_queue_mutex hold. + /// + /// The single entry point the applier's reader uses per iteration. In one lock + /// hold it (1) sweeps the committed head prefix, (2) waits until an + /// undispatched envelope exists or a stop is requested, then (3) dispatches + /// that envelope or returns @c nullptr on stop. Folding sweep and dispatch + /// together makes the reader the sole sweeper and dispatcher. + /// + /// If the sweep finds a structural inconsistency (head @c stream_seqno != + /// @c commit_seqno + 1), it asserts in debug and, in release, stops the + /// applier and returns @c nullptr so the coordinator loop exits rather than + /// running on inconsistent cursors. + /// + /// @retval nullptr a stop was requested (or forced by an inconsistency); only + /// the sweep's @c commit_seqno advance may have changed. + /// @return otherwise, a non-owning pointer to the dispatched envelope, valid + /// until a later sweep. + Transaction_envelope *sweep_and_dispatch(); + + // --- Cursors (read under m_queue_mutex) --- + + /// @brief The commit low-water mark: number of envelopes swept from the head. + std::uint64_t commit_seqno() const; + + /// @brief The number of envelopes dispatched to workers so far. + std::uint64_t dispatch_seqno() const; + + /// @brief The number of envelopes ever enqueued (the last assigned + /// @c stream_seqno). + std::uint64_t insert_seqno() const; + + /// @brief The current number of queue-owned transaction envelopes. + /// + /// Counts undispatched, in-flight, and committed-but-unswept envelopes. Equals + /// @c insert_seqno - commit_seqno. + std::size_t queue_length() const; + + // --- Memory accounting (atomic, lock-free fast path) --- + + /// @brief Atomically add @p n bytes to the memory-usage counter. + /// + /// Called by the Trx_payload constructor; a single atomic update, no queue + /// mutex held. + void add_bytes(std::size_t n); + + /// @brief Atomically subtract @p n bytes from the memory-usage counter and + /// wake all threads blocked in acquire_admission(). + /// + /// Called by the Trx_payload destructor. + void release_bytes(std::size_t n); + + /// @brief The current number of bytes held by admitted memory-path payloads. + std::size_t bytes_used() const { return m_bytes_used.load(); } + + /// @brief The hard per-channel memory bound the queue was constructed with. + /// Immutable for the queue's lifetime. + std::size_t memory_limit() const { return m_memory_limit; } + + /// @brief The per-channel spill threshold the queue was constructed with. + /// Immutable for the queue's lifetime. + std::size_t spill_threshold() const { return m_spill_threshold; } + + /// @brief The channel's relay log directory, under which spill files are + /// created (in the @c in_memory_relaylog_temp_files subdirectory). Immutable; + /// empty when no spill directory was supplied. + const std::string &relay_log_dir() const { return m_relay_log_dir; } + + // --- Lifecycle --- + + /// @brief Request stop for one role (or both) and wake its parked waiters. + /// + /// @c stop(Scope::RECEIVER) sets the receiver stop flag and wakes an IO thread + /// parked in acquire_admission(), so enqueue() aborts with @c nullptr. + /// @c stop(Scope::APPLIER) sets the applier stop flag and wakes a coordinator + /// parked in dispatch_next(), so it returns @c nullptr. @c stop(Scope::ALL) + /// (the default) does both. Each flag shares the lock of the condition + /// variable it gates, so the flag write and the waiter's check stay ordered. + /// + /// Stopping one role leaves the other's waiters undisturbed, which makes a + /// single-thread STOP safe. Re-enable a stopped role with resume(). + void stop(Scope scope = Scope::ALL); + + /// @brief Whether the queue is fully stopped. + /// + /// A single-role stop does NOT make this return true. + bool is_stopped() const; + + /// @brief Re-enable a stopped role (or both) so a new session can enqueue() / + /// dispatch_next() again. + void resume(Scope scope = Scope::ALL); + + /// @brief Return the queue to the empty state (clear @c m_envelopes, zero + /// all three cursors) so the same instance can be reused for a new session. + void reset(); + + private: + /// @brief The body of @c dispatch_next(), run under a lock the caller owns. + /// + /// Blocks until an undispatched envelope exists or the applier is stopped, + /// then hands out the envelope at @c dispatch_seqno and advances that cursor. + /// + /// @param lock A @c std::unique_lock owning @c m_queue_mutex on entry; still + /// owns it on return (asserted in debug builds). + /// @retval nullptr the applier is stopped — no cursor was advanced. + /// @return otherwise, a non-owning pointer to the dispatched envelope. + Transaction_envelope *dispatch_next_locked( + std::unique_lock &lock); + + /// @brief Assert the cursor ordering invariant. Caller holds m_queue_mutex. + /// + /// Checks @c 0 <= commit_seqno <= dispatch_seqno <= insert_seqno and that the + /// deque size equals @c insert_seqno - commit_seqno. + void assert_cursor_invariant() const { + assert(m_commit_seqno <= m_dispatch_seqno); + assert(m_dispatch_seqno <= m_insert_seqno); + assert(m_envelopes.size() == m_insert_seqno - m_commit_seqno); + } + + // --- FIFO structure + cursors (guarded by m_queue_mutex) --- + + mutable std::mutex m_queue_mutex; ///< Guards deque + cursors + structure. + std::condition_variable m_not_empty_cv; ///< Consumer waits when drained. + + /// Envelopes in enqueue order, held through unique_ptr so each envelope's + /// address stays stable from enqueue until it is dropped at sweep. + /// Popping the head destroys the envelope. + std::deque> m_envelopes; + + std::uint64_t m_commit_seqno{0}; ///< Head / commit low-water mark (count). + std::uint64_t m_dispatch_seqno{0}; ///< Number of envelopes dispatched. + std::uint64_t m_insert_seqno{0}; ///< Tail / number ever enqueued. + + /// Applier (coordinator) stop flag, doubling as the applier's attach state. + /// Guarded by m_queue_mutex. + bool m_applier_stopped{true}; + + // --- Memory accounting --- + + std::size_t m_memory_limit; ///< Hard per-channel memory bound. + std::size_t m_spill_threshold; ///< Size above which a trx spills to disk. + std::string m_relay_log_dir; ///< Channel relay log dir (spill file parent). + + std::atomic m_bytes_used{0}; ///< Bytes held by live payloads. + + /// Mutable so the const is_stopped() can read m_receiver_stopped under it. + mutable std::mutex m_mem_mutex; ///< Pairs with m_mem_cv for blocking admission. + std::condition_variable m_mem_cv; ///< Woken on release_bytes()/stop(). + + /// Receiver (IO thread) stop flag, doubling as the receiver's attach state. + /// Guarded by m_mem_mutex. + bool m_receiver_stopped{true}; +}; + +} // namespace mysql::csa + +#endif // MYSQL_CSA_STORAGE_IN_MEMORY_TRX_ENVELOPE_QUEUE_H diff --git a/sql/changestreams/apply/storage/in_memory/trx_payload.cc b/sql/changestreams/apply/storage/in_memory/trx_payload.cc new file mode 100644 index 000000000000..0c7f2b565b3e --- /dev/null +++ b/sql/changestreams/apply/storage/in_memory/trx_payload.cc @@ -0,0 +1,95 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +#include "sql/changestreams/apply/storage/in_memory/trx_payload.h" + +#include + +#include "sql/changestreams/apply/jobs/fetchable_transaction.h" +#include "sql/changestreams/apply/storage/in_memory/event_set_fetchable_memory.h" +#include "sql/changestreams/apply/storage/in_memory/event_set_fetchable_spill.h" +#include "sql/changestreams/apply/storage/in_memory/trx_envelope_queue.h" + +namespace mysql::csa { + +Trx_payload::Trx_payload(std::shared_ptr trx, + std::size_t trx_length, + Trx_envelope_queue *owner_queue) + : m_trx(std::move(trx)), + m_trx_length(trx_length), + m_owner_queue(owner_queue) { + // Reserve trx_length bytes against the channel memory budget. + m_owner_queue->add_bytes(m_trx_length); +} + +Trx_payload::~Trx_payload() { + // Release the reserved bytes. This wakes the receiver waiting on the memory + // limit. + m_owner_queue->release_bytes(m_trx_length); +} + +std::unique_ptr Trx_payload::create_memory( + bool is_trx, std::shared_ptr fde, + std::size_t trx_length, Trx_envelope_queue *owner_queue, + Transaction_envelope *owner_envelope) { + // Create the empty in-memory byte source. + auto src = std::make_unique( + is_trx, std::move(fde), owner_envelope, /*streaming_open=*/true); + // Keep both pointers before the unique_ptr is moved. + Event_set_fetchable_memory *mem_src = src.get(); + Streaming_event_sink *sink = mem_src; + // Wrap the source as the transaction's single batch. + auto trx = std::make_shared(); + trx->append_batch(std::move(src)); + trx->set_fetching_complete(); + // Link the batch back to its transaction so truncating the stream + // also marks the transaction truncated. + mem_src->set_owning_fetchable(trx.get()); + // Construct the payload (reserves trx_length bytes) and record the sink. + auto payload = + std::make_unique(std::move(trx), trx_length, owner_queue); + payload->m_sink = sink; + return payload; +} + +std::unique_ptr Trx_payload::create_spill( + bool is_trx, std::shared_ptr fde, + Trx_envelope_queue *owner_queue, Transaction_envelope *owner_envelope) { + // Create the empty on-disk byte source. + auto src = std::make_unique( + is_trx, std::move(fde), owner_queue->relay_log_dir(), owner_envelope, + /*streaming_open=*/true); + Event_set_fetchable_spill *spill_src = src.get(); + Streaming_event_sink *sink = spill_src; + auto trx = std::make_shared(); + trx->append_batch(std::move(src)); + trx->set_fetching_complete(); + spill_src->set_owning_fetchable(trx.get()); + // Construct the payload reserving ZERO bytes. + auto payload = std::make_unique(std::move(trx), /*trx_length=*/0, + owner_queue); + payload->m_sink = sink; + return payload; +} + +} // namespace mysql::csa diff --git a/sql/changestreams/apply/storage/in_memory/trx_payload.h b/sql/changestreams/apply/storage/in_memory/trx_payload.h new file mode 100644 index 000000000000..c0f97df3e7cc --- /dev/null +++ b/sql/changestreams/apply/storage/in_memory/trx_payload.h @@ -0,0 +1,142 @@ +// Copyright (c) 2026, Oracle and/or its affiliates. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 2.0, +// as published by the Free Software Foundation. +// +// This program is designed to work with certain software (including +// but not limited to OpenSSL) that is licensed under separate terms, +// as designated in a particular file or component or in included license +// documentation. The authors of MySQL hereby grant you an additional +// permission to link the program and your derivative works with the +// separately licensed software that they have either included with +// the program or referenced in the documentation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License, version 2.0, for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + +#ifndef MYSQL_CSA_STORAGE_IN_MEMORY_TRX_PAYLOAD_H +#define MYSQL_CSA_STORAGE_IN_MEMORY_TRX_PAYLOAD_H + +#include +#include + +#include "sql/changestreams/apply/storage/in_memory/in_memory_types.h" + +// The static create_memory() factory takes the active format-description event +// only as a shared_ptr parameter, so an incomplete (forward-declared) type is +// sufficient here. +class Format_description_log_event; + +namespace mysql::csa { + +// Forward declarations +class Fetchable_transaction; +class Trx_envelope_queue; +class Streaming_event_sink; +class Transaction_envelope; + +/// @brief Heavy, RAII memory-accounted holder of one transaction's byte stream. +/// +/// A Trx_payload reserves exactly @c trx_length bytes against the owning +/// Trx_envelope_queue's memory-usage counter on construction and releases them +/// on destruction, waking any receiver blocked at the per-channel memory limit. +class Trx_payload { + public: + /// @brief Construct the payload, reserving @p trx_length bytes against + /// @p owner_queue's memory-usage counter. + /// + /// @param trx The wrapped Fetchable_transaction holding this transaction's + /// event byte stream; non-null. + /// @param trx_length The transaction's declared byte size (bytes reserved). + /// @param owner_queue The queue charged for the reservation; non-null and + /// must outlive this payload (held non-owning). + Trx_payload(std::shared_ptr trx, + std::size_t trx_length, Trx_envelope_queue *owner_queue); + + /// @brief Create a memory-path payload holding one transaction's events + /// in memory. + /// + /// Sets up an empty in-memory byte source for the transaction, reserves + /// @p trx_length bytes against @p owner_queue, and wires up the payload's + /// sink handle so events can be written into it. + /// + /// @param is_trx Whether the admitted unit is a real transaction. + /// @param fde The active Format_description_log_event (shared ownership). + /// @param trx_length The transaction's declared byte size (bytes reserved). + /// @param owner_queue The queue charged for the reservation; non-null. + /// @param owner_envelope The envelope the byte source commits on success. + /// @return The newly created payload with its sink handle populated. + static std::unique_ptr create_memory( + bool is_trx, std::shared_ptr fde, + std::size_t trx_length, Trx_envelope_queue *owner_queue, + Transaction_envelope *owner_envelope); + + /// @brief Create a spill-path payload that stores one large transaction's + /// events on disk. + /// + /// The on-disk counterpart of create_memory(). Sets up an empty spill file + /// under @p owner_queue's relay log directory, wires up the payload's sink + /// handle, and reserves zero bytes against the memory counter, since spilled + /// data lives on disk rather than in the memory budget. The resulting + /// payload's byte_size() is 0. + /// + /// @param is_trx Whether the admitted unit is a real transaction. + /// @param fde The active Format_description_log_event (shared ownership); + /// also written into the spill file prefix. + /// @param owner_queue The queue whose relay log directory hosts the spill + /// file; charged for the (zero) reservation. Non-null. + /// @param owner_envelope The envelope the byte source commits on success. + /// @return The newly created zero-reservation payload with its sink handle + /// populated. + static std::unique_ptr create_spill( + bool is_trx, std::shared_ptr fde, + Trx_envelope_queue *owner_queue, Transaction_envelope *owner_envelope); + + /// @brief Release the reserved bytes and wake any blocked receiver. + /// + /// Returns the reserved bytes to @p owner_queue, which wakes one receiver + /// waiting on the memory limit. + ~Trx_payload(); + + // Pinned in the envelope: neither copyable nor movable. + Trx_payload(const Trx_payload &) = delete; + Trx_payload &operator=(const Trx_payload &) = delete; + Trx_payload(Trx_payload &&) = delete; + Trx_payload &operator=(Trx_payload &&) = delete; + + /// @brief Share ownership of the wrapped Fetchable_transaction. + /// @return A non-null shared_ptr while the payload owns its bytes. + std::shared_ptr fetchable() const { return m_trx; } + + /// @brief The number of bytes reserved at construction. + std::size_t byte_size() const { return m_trx_length; } + + /// @brief The sink that events for this transaction are written into. + /// + /// Returns a non-owning pointer to the transaction's byte source, which + /// accepts streamed events. It is set by create_memory() and stays valid for + /// the payload's lifetime. Returns nullptr until then. + /// + /// @return The non-owning sink pointer, or nullptr if not yet set. + Streaming_event_sink *sink() const { return m_sink; } + + private: + std::shared_ptr m_trx; ///< Consumed by workers. + std::size_t m_trx_length; ///< Bytes reserved. + /// Non-owning pointer to the queue charged for the reservation. Set at construction. + Trx_envelope_queue *m_owner_queue; + /// Non-owning pointer to the transaction's byte source, which accepts + /// streamed events. + Streaming_event_sink *m_sink{nullptr}; +}; + +} // namespace mysql::csa + +#endif // MYSQL_CSA_STORAGE_IN_MEMORY_TRX_PAYLOAD_H diff --git a/sql/changestreams/apply/storage/relay_log/cached_event_payload.cpp b/sql/changestreams/apply/storage/relay_log/cached_event_payload.cpp index e49d53fa8d55..1b10b17c9c4b 100644 --- a/sql/changestreams/apply/storage/relay_log/cached_event_payload.cpp +++ b/sql/changestreams/apply/storage/relay_log/cached_event_payload.cpp @@ -37,6 +37,16 @@ Cached_event_payload::Cached_event_payload(const Event_payload &payload, assert(m_fde_ptr != nullptr); } +Cached_event_payload::~Cached_event_payload() { + // decode() transfers ownership (success) or frees (error) and nulls m_data. + // If decode() was never called, the buffer is still ours: free it here so an + // appended-but-never-decoded event does not leak. + if (m_data != nullptr) { + m_allocator.deallocate(m_data); + m_data = nullptr; + } +} + // nothing to reset, event may be read again void Cached_event_payload::reset(const Format_description_log_event *) {} @@ -50,8 +60,8 @@ std::shared_ptr Cached_event_payload::decode() { m_data, m_length, m_fde_ptr, m_verify_checksum, &event); if (read_status.has_error()) { m_allocator.deallocate(m_data); - return std::shared_ptr(); m_data = nullptr; + return std::shared_ptr(); } // pass m_data ownership to Log_event object event->register_temp_buf( diff --git a/sql/changestreams/apply/storage/relay_log/cached_event_payload.h b/sql/changestreams/apply/storage/relay_log/cached_event_payload.h index ac7b2410ec1d..1b9176189326 100644 --- a/sql/changestreams/apply/storage/relay_log/cached_event_payload.h +++ b/sql/changestreams/apply/storage/relay_log/cached_event_payload.h @@ -41,6 +41,10 @@ class Cached_event_payload : public IReader_event { Cached_event_payload(const Event_payload &payload, std::shared_ptr fde); + /// @brief Frees the owned payload buffer if it was never handed off by + /// decode(). + ~Cached_event_payload() override; + /// @brief Decode function, which decodes payload and returns Log event /// smart pointer /// @return Log event smart pointer diff --git a/sql/changestreams/apply/storage/relay_log/sync_transaction_provider.cpp b/sql/changestreams/apply/storage/relay_log/sync_transaction_provider.cpp index 92385e8cd40a..5340e5ef4811 100644 --- a/sql/changestreams/apply/storage/relay_log/sync_transaction_provider.cpp +++ b/sql/changestreams/apply/storage/relay_log/sync_transaction_provider.cpp @@ -28,6 +28,7 @@ #include "mysql/psi/mysql_file.h" // mysql_file_close #include "sql/changestreams/apply/resource/statistics_map.h" #include "sql/changestreams/apply/service/csa_service.h" +#include "sql/changestreams/apply/storage/in_memory/queued_transaction_reader.h" // Queued_transaction_reader #include "sql/mysqld.h" // slave_trans_retries #include "sql/rpl_mi.h" // Master_info @@ -43,6 +44,18 @@ Sync_transaction_provider::Sync_transaction_provider( instance_id, rli, max_read_event_bytes, max_read_payload_bytes)), m_stat_monitor(scheduler::Statistics_monitor::get(instance_id)) {} +// In-memory relay-log overload: the provider drains transactions from the +// channel's Trx_envelope_queue via a Queued_transaction_reader instead of +// reading the on-disk relay log. Selected by Csa_service::run for in-memory +// channels; the classic (bytes-bounded) overload above is used otherwise. +Sync_transaction_provider::Sync_transaction_provider(int instance_id, + Relay_log_info *rli, + Trx_envelope_queue *queue) + : m_rli(rli), + m_reader( + std::make_shared(instance_id, rli, queue)), + m_stat_monitor(scheduler::Statistics_monitor::get(instance_id)) {} + void Sync_transaction_provider::start() {} bool Sync_transaction_provider::is_error() const { diff --git a/sql/changestreams/apply/storage/relay_log/sync_transaction_provider.h b/sql/changestreams/apply/storage/relay_log/sync_transaction_provider.h index ebd2427f28e4..abff603b2169 100644 --- a/sql/changestreams/apply/storage/relay_log/sync_transaction_provider.h +++ b/sql/changestreams/apply/storage/relay_log/sync_transaction_provider.h @@ -44,6 +44,8 @@ namespace mysql::csa { +class Trx_envelope_queue; + class Sync_transaction_provider; using Sync_transaction_provider_sptr = std::unique_ptr; @@ -70,6 +72,17 @@ class Sync_transaction_provider : public Transaction_provider { std::size_t max_read_event_bytes, std::size_t max_read_payload_bytes); + /// In-memory relay-log overload. Instead of reading the on-disk relay log, + /// the provider drains transactions from the channel's queue via a + /// Queued_transaction_reader. Selected by Csa_service::run when the channel + /// uses the in-memory relay log. + /// @param instance_id Instance (channel) id + /// @param rli Pointer to relay log info structure + /// @param queue The per-channel FIFO of transaction envelopes to drain + /// (non-owning; must outlive this provider) + Sync_transaction_provider(int instance_id, Relay_log_info *rli, + Trx_envelope_queue *queue); + /// Starts asynchronous thread that decodes jobs from the stream void start() override; /// Stops provider and wakes blocked reader calls. diff --git a/sql/lex.h b/sql/lex.h index eb3986b9bad8..4069380c56a5 100644 --- a/sql/lex.h +++ b/sql/lex.h @@ -362,6 +362,10 @@ static const SYMBOL symbols[] = { {SYM("INTERSECT", INTERSECT_SYM)}, {SYM("INTERVAL", INTERVAL_SYM)}, {SYM("INTO", INTO)}, + {SYM("IN_MEMORY_RELAYLOG_ENABLED", IN_MEMORY_RELAYLOG_ENABLED_SYM)}, + {SYM("IN_MEMORY_RELAYLOG_LIMIT", IN_MEMORY_RELAYLOG_LIMIT_SYM)}, + {SYM("IN_MEMORY_RELAYLOG_SPILL_THRESHOLD", + IN_MEMORY_RELAYLOG_SPILL_THRESHOLD_SYM)}, {SYM("IO", IO_SYM)}, {SYM("IO_AFTER_GTIDS", IO_AFTER_GTIDS)}, {SYM("IO_BEFORE_GTIDS", IO_BEFORE_GTIDS)}, diff --git a/sql/rpl_mi.cc b/sql/rpl_mi.cc index eb79df9257ed..ac7fcdc095e8 100644 --- a/sql/rpl_mi.cc +++ b/sql/rpl_mi.cc @@ -27,6 +27,7 @@ #include #include #include +#include #include "include/compression.h" #include "include/mutex_lock.h" @@ -38,11 +39,13 @@ #include "mysql_version.h" #include "mysqld_error.h" #include "prealloced_array.h" +#include "sql/changestreams/apply/storage/in_memory/trx_envelope_queue.h" #include "sql/debug_sync.h" #include "sql/dynamic_ids.h" // Server_ids #include "sql/log.h" #include "sql/mysqld.h" // sync_masterinfo_period #include "sql/rpl_info_handler.h" +#include "my_sys.h" // dirname_length #include "sql/rpl_msr.h" // channel_map #include "sql/rpl_replica.h" // source_retry_count #include "sql/sql_class.h" @@ -259,12 +262,83 @@ Master_info::~Master_info() { mysql_mutex_destroy(&rotate_lock); mysql_cond_destroy(&rotate_cond); + // The in-memory relay-log queue is owned by mi and destroyed with it. A full + // stop reset()s it to empty before this point, so ~Trx_envelope_queue's + // empty-queue invariant holds. + delete m_trx_queue; + m_trx_queue = nullptr; + m_current_sink = nullptr; + delete m_channel_lock; delete ignore_server_ids; - delete mi_description_event; delete gtid_monitoring_info; } +void Master_info::reconcile_in_memory_relaylog_queue() { + DBUG_TRACE; + // Runs only with both replication threads stopped and no thread attached to + // the queue (CHANGE REPLICATION SOURCE apply under the both-threads-stopped + // guard, or mi/rli init before any thread attaches), so nothing races the + // create/destroy of m_trx_queue. + const bool want_queue = + rli != nullptr && rli->is_in_memory_relaylog() && rli->is_csa_enabled(); + + if (!want_queue) { + // Selection OFF or the channel is no longer CSA-eligible: drop the queue. + // It is idle and was reset() to empty at the last full stop, so the + // destructor's empty-queue invariant holds. + if (m_trx_queue != nullptr) { + delete m_trx_queue; + m_trx_queue = nullptr; + m_current_sink = nullptr; + } + return; + } + + // Selection is ON on a CSA channel. The per-channel memory bounds are the + // user-tunable CRST options (IN_MEMORY_RELAYLOG_LIMIT / + // IN_MEMORY_RELAYLOG_SPILL_THRESHOLD), defaulting to 128 MiB / 16 MiB when + // never configured. + const std::size_t memory_limit = rli->get_in_memory_relaylog_limit(); + const std::size_t spill_threshold = + rli->get_in_memory_relaylog_spill_threshold(); + + // Spill files live in an "in_memory_relaylog_temp_files" subdirectory under + // the channel's relay log directory, so the spill store stays on the same + // filesystem as the relay logs and follows the configured relay-log path. + // Derive that directory from the channel's current relay log file name (its + // dirname, including the trailing separator; empty when no relay log name is + // set yet). reconcile() runs at rli init and at the end of every CHANGE + // REPLICATION SOURCE, with both replication threads stopped, so a changed + // relay-log path is observed here and the queue is rebuilt below when the + // directory differs -- keeping the spill path in sync with the relay-log + // path. Reading group_relay_log_name without data_lock is safe because no + // thread is attached to the queue at this point. + const char *relay_log_name = rli->get_group_relay_log_name(); + const std::string spill_dir(relay_log_name, dirname_length(relay_log_name)); + + if (m_trx_queue != nullptr) { + // Already present: reuse it only if the bounds AND the spill directory + // still match, otherwise rebuild it to pick up the change. This runs with + // both threads stopped and the queue reset() to empty at the last full + // stop, so the destructor's empty-queue invariant holds for the rebuild. + if (m_trx_queue->memory_limit() == memory_limit && + m_trx_queue->spill_threshold() == spill_threshold && + m_trx_queue->relay_log_dir() == spill_dir) { + return; + } + delete m_trx_queue; + m_trx_queue = nullptr; + m_current_sink = nullptr; + } + + // Create the empty queue with the configured per-channel bounds and the + // relay-log-derived spill directory. + m_current_sink = nullptr; + m_trx_queue = new mysql::csa::Trx_envelope_queue(memory_limit, + spill_threshold, spill_dir); +} + void Master_info::request_rotate(THD *thd) { DBUG_TRACE; MUTEX_LOCK(lock, &this->rotate_lock); diff --git a/sql/rpl_mi.h b/sql/rpl_mi.h index e5b152117d5e..de0e40863a80 100644 --- a/sql/rpl_mi.h +++ b/sql/rpl_mi.h @@ -27,6 +27,7 @@ #include #include #include +#include #include "compression.h" // COMPRESSION_ALGORITHM_NAME_BUFFER_SIZE #include "my_inttypes.h" @@ -50,6 +51,11 @@ class Server_ids; class THD; struct MYSQL; +namespace mysql::csa { +class Trx_envelope_queue; +class Streaming_event_sink; +} // namespace mysql::csa + #define DEFAULT_CONNECT_RETRY 60 /***************************************************************************** @@ -305,6 +311,49 @@ class Master_info : public Rpl_info { MYSQL *mysql; uint32 file_id; /* for 3.23 load data infile */ Relay_log_info *rli; + + /// Per-channel in-memory relay-log queue, owned by this Master_info for the + /// whole period the in-memory path is selected. nullptr when the in-memory + /// path is not selected for this channel (classic relay-log path). Created + /// with `mi` when the selection is turned on (CHANGE REPLICATION SOURCE TO + /// IN_MEMORY_RELAYLOG = ON, or mi/rli init at server start when the persisted + /// selection is ON); reused across receiver/applier sessions (resume() at + /// start, reset() after a full stop); destroyed when the selection is turned + /// off or with `mi` (RESET REPLICA ALL / channel drop / shutdown). The IO/SQL + /// threads attach/detach but never create or destroy it. See + /// reconcile_in_memory_relaylog_queue(). + mysql::csa::Trx_envelope_queue *m_trx_queue{nullptr}; + + /// Non-owning handle to the Streaming_event_sink of the transaction the IO + /// thread is currently queueing. Resolved once from m_trx_queue->enqueue()'s + /// returned envelope at the GTID event; used directly to append body events + /// and to seal at the terminal event; cleared at seal / truncation / stop. + /// Only ever touched by the IO thread, so it needs no lock. Always nullptr + /// outside an open transaction group. + mysql::csa::Streaming_event_sink *m_current_sink{nullptr}; + + /// @return whether this channel uses the in-memory relay-log path. + bool is_in_memory_relaylog() const { return m_trx_queue != nullptr; } + + /// Create or destroy m_trx_queue so it matches the channel's persisted + /// in-memory relay-log selection (rli->is_in_memory_relaylog() on a + /// CSA-enabled channel). Idempotent. MUST be called only while both + /// replication threads are stopped and no thread is attached to the queue: + /// at CHANGE REPLICATION SOURCE apply (under the both-threads-stopped guard) + /// and during mi/rli init before any thread attaches. The IO/SQL threads + /// never call this. + /// + /// - selection ON + CSA and no queue yet: create an empty queue with the + /// fixed per-channel bounds (IN_MEMORY_RELAYLOG_LIMIT / + /// IN_MEMORY_RELAYLOG_SPILL_THRESHOLD). + /// - selection OFF or channel not CSA and a queue exists: destroy the (idle, + /// empty) queue. + /// + /// The queue's bounds are fixed compile-time constants (not derived from the + /// applier event memory limit and not user-tunable yet), so there is no + /// rebuild-on-bound-change path. + void reconcile_in_memory_relaylog_queue(); + uint port; uint connect_retry; /* @@ -651,17 +700,20 @@ class Master_info : public Rpl_info { Locks: All access is protected by Relay_log::LOCK_log. */ - Format_description_log_event *mi_description_event; + std::shared_ptr mi_description_event; public: Format_description_log_event *get_mi_description_event() { mysql_mutex_assert_owner(rli->relay_log.get_log_lock()); - return mi_description_event; + return mi_description_event.get(); } void set_mi_description_event(Format_description_log_event *fdle) { mysql_mutex_assert_owner(rli->relay_log.get_log_lock()); - delete mi_description_event; - mi_description_event = fdle; + mi_description_event.reset(fdle); + } + std::shared_ptr get_mi_description_event_shared() { + mysql_mutex_assert_owner(rli->relay_log.get_log_lock()); + return mi_description_event; } bool set_info_search_keys(Rpl_info_handler *to) override; diff --git a/sql/rpl_replica.cc b/sql/rpl_replica.cc index ae446171f2c2..03f3845559e8 100644 --- a/sql/rpl_replica.cc +++ b/sql/rpl_replica.cc @@ -110,6 +110,7 @@ #include "sql/auth/sql_security_ctx.h" #include "sql/auto_thd.h" #include "sql/binlog.h" +#include "sql/binlog/global.h" #include "sql/binlog_reader.h" #include "sql/clone_handler.h" // is_provisioning #include "sql/current_thd.h" @@ -149,6 +150,7 @@ #include "sql/rpl_rli_pdb.h" // Slave_worker #include "sql/rpl_trx_boundary_parser.h" #include "sql/rpl_utility.h" +#include "sql/set_var.h" // System_variable_tracker #include "sql/sql_backup_lock.h" // is_instance_backup_locked #include "sql/sql_class.h" // THD #include "sql/sql_const.h" @@ -174,6 +176,8 @@ #include "scope_guard.h" #include "sql/changestreams/apply/service/csa_service.h" +#include "sql/changestreams/apply/storage/in_memory/queued_transaction_writer.h" +#include "sql/changestreams/apply/storage/in_memory/trx_envelope_queue.h" // Trx_envelope_queue::Scope struct mysql_cond_t; struct mysql_mutex_t; @@ -302,6 +306,7 @@ enum enum_slave_apply_event_and_update_pos_retval { }; static int process_io_rotate(Master_info *mi, Rotate_log_event *rev); +static void imr_on_truncate(Master_info *mi); /// @brief Checks whether relay log space will be exceeded after queueing /// additional 'queued_size' bytes. If yes, function will @@ -1436,6 +1441,16 @@ int load_mi_and_rli_from_repositories( if (!init_error && mi->rli->is_relay_log_recovery && mi->rli->mts_recovery_group_cnt) init_error = fill_mts_gaps_and_recover(mi); + + // With mi/rli metadata now loaded and before any replication thread attaches, + // create the per-channel in-memory relay-log queue if this channel selects + // the in-memory path (persisted selection ON on a CSA channel). This runs on + // every init path (server start, START REPLICA, CHANGE REPLICATION SOURCE) + // and is idempotent: a queue that already matches the selection is left + // untouched, so it never disturbs an already-running peer thread. + if (!init_error && mi->rli->inited) + mi->reconcile_in_memory_relaylog_queue(); + return init_error; } @@ -1759,6 +1774,40 @@ static void set_thd_in_use_temporary_tables(Relay_log_info *rli) { } } +/** + Reset the volatile receiver state owned by an in-memory relay-log channel. + + This operation is the replication-level counterpart to the queue's + metadata-independent reset(). The caller must have stopped and joined both + queue roles, so no receiver, coordinator, or worker can observe the state + while it is cleared. The cleanup order mirrors + Relay_log_info::purge_relay_logs(): reset the parser, clear receiver + monitoring under Master_info::data_lock, then clear the retrieved GTID set + and its TSID map under the RLI TSID write lock. The queue and current sink are + reset only after that receiver metadata is clean. + + @param mi The stopped in-memory channel whose volatile state is reset. +*/ +static void reset_in_memory_received_state(Master_info *mi) { + assert(mi != nullptr); + assert(mi->is_in_memory_relaylog()); + assert(mi->m_trx_queue->is_stopped()); + + mi->transaction_parser.reset(); + + mysql_mutex_lock(&mi->data_lock); + mi->clear_gtid_monitoring_info(); + mysql_mutex_unlock(&mi->data_lock); + + Relay_log_info *rli = mi->rli; + rli->get_tsid_lock()->wrlock(); + (const_cast(rli->get_gtid_set()))->clear_set_and_tsid_map(); + rli->get_tsid_lock()->unlock(); + + mi->m_trx_queue->reset(); + mi->m_current_sink = nullptr; +} + int terminate_slave_threads(Master_info *mi, int thread_mask, ulong stop_wait_timeout, bool need_lock_term) { DBUG_TRACE; @@ -1778,6 +1827,10 @@ int terminate_slave_threads(Master_info *mi, int thread_mask, if (thread_mask & (REPLICA_SQL | SLAVE_FORCE_ALL)) { DBUG_PRINT("info", ("Terminating SQL thread")); mi->rli->abort_slave = true; + // Wake a coordinator parked in dispatch_next() so it observes the stop and + // exits; scoped to the applier so a still-running receiver is undisturbed. + if (mi->is_in_memory_relaylog()) + mi->m_trx_queue->stop(mysql::csa::Trx_envelope_queue::Scope::APPLIER); if (mi->rli->is_csa_enabled()) { get_csa_service().stop(mi->get_channel(), force_all); } @@ -1796,6 +1849,9 @@ int terminate_slave_threads(Master_info *mi, int thread_mask, return error; } + if (mi->is_in_memory_relaylog()) + mi->m_trx_queue->sweep_committed(true); + DBUG_PRINT("info", ("Flushing applier metadata.")); if (current_thd) THD_STAGE_INFO(current_thd, stage_flushing_applier_metadata); @@ -1829,6 +1885,10 @@ int terminate_slave_threads(Master_info *mi, int thread_mask, if (thread_mask & (REPLICA_IO | SLAVE_FORCE_ALL)) { DBUG_PRINT("info", ("Terminating IO thread")); mi->abort_slave = true; + // Wake a receiver parked in acquire_admission() so it observes the stop and + // exits; scoped to the receiver so a still-running applier is undisturbed. + if (mi->is_in_memory_relaylog()) + mi->m_trx_queue->stop(mysql::csa::Trx_envelope_queue::Scope::RECEIVER); DBUG_EXECUTE_IF("pause_after_queue_event", { rpl_replica_debug_point(DBUG_RPL_S_PAUSE_QUEUE_EV); }); /* @@ -1905,6 +1965,16 @@ int terminate_slave_threads(Master_info *mi, int thread_mask, mysql_mutex_unlock(log_lock); } + + // In-memory relay log: once both roles have stopped -- a full stop, or the + // last running role stopping -- atomically discard the volatile queue and + // its receiver bookkeeping. is_stopped() is false after a single-thread stop + // (the other role stays armed), so both the queue and Retrieved_Gtid_Set stay + // live for that role. Safe here: the terminated thread(s) have joined, so + // when is_stopped() holds nothing is attached and no worker holds a job. + if (mi->is_in_memory_relaylog() && mi->m_trx_queue->is_stopped()) + reset_in_memory_received_state(mi); + return 0; } @@ -2162,10 +2232,17 @@ bool start_slave_threads(bool need_lock_slave, bool wait_for_start, lock_cond_sql = &mi->rli->run_lock; } - if (thread_mask & REPLICA_IO) + if (thread_mask & REPLICA_IO) { + // Arm the receiver role on the mi-owned queue before creating the IO + // thread, so its first enqueue is admitted (the queue rests stopped). Done + // on the caller (START command) thread under the channel start/stop + // serialization; the IO thread never creates the queue. + if (mi->is_in_memory_relaylog()) + mi->m_trx_queue->resume(mysql::csa::Trx_envelope_queue::Scope::RECEIVER); is_error = start_slave_thread(key_thread_replica_io, handle_slave_io, lock_io, lock_cond_io, cond_io, &mi->slave_running, &mi->slave_run_id, mi); + } if (!is_error && (thread_mask & (REPLICA_IO | SLAVE_MONITOR)) && mi->is_source_connection_auto_failover() && @@ -2189,10 +2266,17 @@ bool start_slave_threads(bool need_lock_slave, bool wait_for_start, my_error(ER_MTA_RECOVERY_FAILURE, MYF(0)); } } - if (!is_error) + if (!is_error) { + // Arm the applier role before creating the SQL (coordinator) thread, so + // dispatch_next() serves it (the queue rests stopped). Caller-thread, + // under the channel start/stop serialization; the SQL thread never + // creates the queue. + if (mi->is_in_memory_relaylog()) + mi->m_trx_queue->resume(mysql::csa::Trx_envelope_queue::Scope::APPLIER); is_error = start_slave_thread( key_thread_replica_sql, handle_slave_sql, lock_sql, lock_cond_sql, cond_sql, &mi->rli->slave_running, &mi->rli->slave_run_id, mi); + } if (is_error) terminate_slave_threads(mi, thread_mask & (REPLICA_IO | SLAVE_MONITOR), rpl_stop_replica_timeout, need_lock_slave); @@ -3501,6 +3585,17 @@ static void show_slave_status_metadata(mem_root_deque *field_list, sizeof(ulong), MYSQL_TYPE_LONG)); field_list->push_back( new Item_empty_string("Network_Namespace", NAME_LEN + 1)); + // Bytes currently held by the channel's in-memory relay-log queue (0 for a + // classic channel). Appended at the END so no existing column's position + // shifts. Its value is stored at the matching trailing position in + // show_slave_status_send_data(). + field_list->push_back( + new Item_return_int("In_Memory_Relay_Log_Space", 10, MYSQL_TYPE_LONGLONG)); + // Number of transactions currently owned by the channel's in-memory relay- + // log queue (0 for a classic channel). Keep this and the matching row value + // at the END so no existing column's position shifts. + field_list->push_back( + new Item_return_int("In_Memory_Queue_Length", 10, MYSQL_TYPE_LONGLONG)); } /** @@ -3780,6 +3875,18 @@ static bool show_slave_status_send_data(THD *thd, Master_info *mi, protocol->store(mi->network_namespace_str(), &my_charset_bin); + // In_Memory_Relay_Log_Space: bytes in use by the in-memory relay-log queue for + // this channel (0 for a classic channel). Kept at the SAME trailing position + // as the In_Memory_Relay_Log_Space field added to the field list. + protocol->store(static_cast( + mi->is_in_memory_relaylog() ? mi->m_trx_queue->bytes_used() : 0)); + + // In_Memory_Queue_Length: queue-owned transactions for this channel, + // including dispatched and committed-but-not-yet-swept entries (0 for a + // classic channel). Keep this at the matching trailing metadata position. + protocol->store(static_cast( + mi->is_in_memory_relaylog() ? mi->m_trx_queue->queue_length() : 0)); + rpl_filter->unlock(); mysql_mutex_unlock(&mi->rli->err_lock); mysql_mutex_unlock(&mi->err_lock); @@ -6023,6 +6130,21 @@ extern "C" void *handle_slave_io(void *arg) { llstr(mi->get_master_log_pos(), llbuff)); /* At this point the I/O thread will not try to reconnect anymore. */ mi->atomic_is_stopping = true; + /* + In-memory relay log: the receiver is stopping for good with a transaction + group possibly still open (mi->m_current_sink != nullptr) -- a large + transaction only partially received when the thread was killed/stopped. + Nothing else will ever seal that sink now, so a worker parked in + Event_set_fetchable_memory::wait_next() would block forever, and a + subsequent STOP REPLICA SQL_THREAD could not join it. Truncate the open + group here so the sink reports end-of-stream: the applier takes its + is_truncated() branch, rolls back the partial transaction, and the whole + transaction is re-fetched from the source on the next START (its GTID is + in neither the Retrieved_Gtid_Set nor gtid_executed). No-op when no group + is open; truncate_transaction() is self-synchronized and nulls the sink. + Classic relay-log channels (m_current_sink always null) are unaffected. + */ + if (mi->is_in_memory_relaylog()) imr_on_truncate(mi); (void)RUN_HOOK(binlog_relay_io, thread_stop, (thd, mi)); /* Pause the IO thread and wait for 'continue_to_stop_io_thread' @@ -7846,6 +7968,119 @@ static bool is_fd_event_saved_in_context_usable_with_event_type( return true; } +/** + In-memory relay log receiver adapter: open a transaction group at the GTID + event. + + Thin Master_info / QUEUE_EVENT_RESULT wrapper over the Master_info-free + mechanism (mysql::csa::open_transaction). Reads the declared transaction byte + length from the GTID event and admits the group into the channel's + Trx_envelope_queue, publishing the opened group's sink through + mi->m_current_sink. + + @param mi The Master_info object for this in-memory channel. + @param gtid_ev The GTID event that opens the transaction group. + + @retval QUEUE_EVENT_OK the group was opened; mi->m_current_sink is + non-null. + @retval QUEUE_EVENT_ERROR_QUEUING a stop was requested while blocked in + admission; mi->m_current_sink left null. +*/ +static QUEUE_EVENT_RESULT imr_on_gtid_event(Master_info *mi, + const Gtid_log_event >id_ev) { + const std::size_t trx_length = + static_cast(gtid_ev.get_trx_length()); + if (mysql::csa::open_transaction(*mi->m_trx_queue, mi->m_current_sink, + mi->get_mi_description_event_shared(), + trx_length, /*is_trx=*/true)) { + return QUEUE_EVENT_ERROR_QUEUING; // stop while blocked; m_current_sink null + } + return QUEUE_EVENT_OK; +} + +/** + In-memory relay log receiver adapter: append one received event's bytes to + the open group (body or terminal event). + + Thin Master_info / QUEUE_EVENT_RESULT wrapper over the Master_info-free + mechanism (mysql::csa::append_transaction_event). The terminal event seals the + group and clears mi->m_current_sink. + + @param mi The Master_info object for this in-memory channel. + @param buf The transient event bytes (copied in by the mechanism). + @param len Number of bytes at @p buf. + @param is_terminal Whether this event terminates (seals) the group. + + @retval QUEUE_EVENT_OK the event was appended. + @retval QUEUE_EVENT_ERROR_QUEUING no group was open (defensive). +*/ +static QUEUE_EVENT_RESULT imr_on_body_event(Master_info *mi, const char *buf, + ulong len, bool is_terminal) { + if (mysql::csa::append_transaction_event(mi->m_current_sink, buf, len, + is_terminal)) { + return QUEUE_EVENT_ERROR_QUEUING; // no open group (defensive) + } + return QUEUE_EVENT_OK; +} + +/** + In-memory relay log receiver adapter: truncate the open group on an + incomplete transaction (rotate / error / stop mid-transaction). + + Thin Master_info wrapper over the Master_info-free mechanism + (mysql::csa::truncate_transaction). Marks the open group's stream truncated + and clears mi->m_current_sink; a no-op when nothing is open. + + @param mi The Master_info object for this in-memory channel. +*/ +static void imr_on_truncate(Master_info *mi) { + mysql::csa::truncate_transaction(mi->m_current_sink); +} + +/** + Complete receiver bookkeeping after a transaction has been successfully + appended to and sealed in the in-memory relay log. + + The caller must be queue_event() at a terminal transaction boundary, while + holding Master_info::data_lock. This mirrors the completion bookkeeping in + MYSQL_BIN_LOG::after_write_to_relay_log() without affecting the classic + relay-log path. + + @param mi The Master_info object for this in-memory channel. +*/ +static void after_write_to_in_memory_relay_log(Master_info *mi) { + assert(mi != nullptr); + assert(mi->is_in_memory_relaylog()); + assert(mi->transaction_parser.is_not_inside_transaction()); + mysql_mutex_assert_owner(&mi->data_lock); + + const Gtid *last_gtid_queued = mi->get_queueing_trx_gtid(); + if (!last_gtid_queued->is_empty()) { + mi->rli->get_tsid_lock()->rdlock(); + DBUG_SIGNAL_WAIT_FOR(current_thd, "updating_received_transaction_set", + "reached_updating_received_transaction_set", + "continue_updating_received_transaction_set"); + mi->rli->add_logged_gtid(last_gtid_queued->sidno, + last_gtid_queued->gno); + mi->rli->get_tsid_lock()->unlock(); + } + + if (mi->is_queueing_trx()) { + mi->finished_queueing(); + + Trx_monitoring_info processing; + Trx_monitoring_info last; + mi->get_gtid_monitoring_info()->copy_info_to(&processing, &last); + + binlog::global_context.monitoring_context() + .transaction_compression() + .update(binlog::monitoring::log_type::RELAY, last.compression_type, + last.gtid, last.end_time, last.compressed_bytes, + last.uncompressed_bytes, + mi->rli->get_gtid_set()->get_tsid_map()); + } +} + /** Store an event received from the master connection into the relay log. @@ -7907,6 +8142,7 @@ QUEUE_EVENT_RESULT queue_event(Master_info *mi, const char *buf, ulonglong compressed_transaction_bytes = 0; ulonglong uncompressed_transaction_bytes = 0; auto compression_type = mysql::binlog::event::compression::type::NONE; + bool in_memory_transaction_completed = false; Log_event_type event_type = (Log_event_type)buf[EVENT_TYPE_OFFSET]; assert(checksum_alg == mysql::binlog::event::BINLOG_CHECKSUM_ALG_OFF || @@ -8175,6 +8411,7 @@ QUEUE_EVENT_RESULT queue_event(Master_info *mi, const char *buf, Now the I/O thread has just changed its mi->get_master_log_name(), so incrementing mi->get_master_log_pos() is nonsense. */ + if (mi->is_in_memory_relaylog()) imr_on_truncate(mi); inc_pos = 0; break; } @@ -8329,6 +8566,9 @@ QUEUE_EVENT_RESULT queue_event(Master_info *mi, const char *buf, inc_pos = event_len; mi->m_queueing_transaction_size = gtid_ev.get_trx_length(); mi->m_queueing_transaction_gtid_event_size = gtid_ev.get_event_length(); + if (mi->is_in_memory_relaylog() && + imr_on_gtid_event(mi, gtid_ev) != QUEUE_EVENT_OK) + goto err; } break; case mysql::binlog::event::ANONYMOUS_GTID_LOG_EVENT: { @@ -8385,6 +8625,9 @@ QUEUE_EVENT_RESULT queue_event(Master_info *mi, const char *buf, mi->m_queueing_transaction_size = anon_gtid_ev.get_trx_length(); mi->m_queueing_transaction_gtid_event_size = anon_gtid_ev.get_event_length(); + if (mi->is_in_memory_relaylog() && + imr_on_gtid_event(mi, anon_gtid_ev) != QUEUE_EVENT_OK) + goto err; } [[fallthrough]]; default: @@ -8481,8 +8724,33 @@ QUEUE_EVENT_RESULT queue_event(Master_info *mi, const char *buf, } else { bool is_error = false; DBUG_EXECUTE_IF("simulate_truncated_relay_log_event", { event_len -= 5; }); - /* write the event to the relay log */ - if (likely(rli->relay_log.write_buffer(buf, event_len, mi) == 0)) { + /* In-memory relay log: stream the event into the per-channel queue's sink + instead of writing it to the relay-log file. A transaction group is open + iff m_current_sink != nullptr (set at the GTID event by imr_on_gtid_event + in the switch above). Inter-group events (Format_description / heartbeat / + rotate) that reach here with no open group are simply not appended (there + is no relay file to write, and the FDE was already installed on + Master_info in its switch case). is_terminal is the transaction-boundary + the parser computed when the event was fed at the top of queue_event. + Classic channels (m_trx_queue == nullptr) take the existing write_buffer + path, byte-for-byte unchanged. */ + bool queued_ok; + if (mi->is_in_memory_relaylog()) { + if (mi->m_current_sink != nullptr) { + const bool is_terminal = + mi->transaction_parser.is_not_inside_transaction(); + queued_ok = + (imr_on_body_event(mi, buf, event_len, is_terminal) == + QUEUE_EVENT_OK); + in_memory_transaction_completed = queued_ok && is_terminal; + } else { + queued_ok = true; // inter-group event: nothing to append in memory + } + } else { + /* write the event to the relay log */ + queued_ok = (rli->relay_log.write_buffer(buf, event_len, mi) == 0); + } + if (likely(queued_ok)) { DBUG_SIGNAL_WAIT_FOR(current_thd, "pause_on_queue_event_after_write_buffer", "receiver_reached_pause_on_queue_event", @@ -8529,6 +8797,15 @@ QUEUE_EVENT_RESULT queue_event(Master_info *mi, const char *buf, mysql::binlog::event::compression::type::NONE, compressed_transaction_bytes, uncompressed_transaction_bytes); } + + /* + The classic relay-log path performs this bookkeeping from + after_write_to_relay_log() once the terminal event has been flushed. + The in-memory path bypasses that callback, so mirror it only after the + terminal event was appended successfully and sealed the stream. + */ + if (mi->is_in_memory_relaylog() && in_memory_transaction_completed) + after_write_to_in_memory_relay_log(mi); } else { /* We failed to write the event and didn't updated slave positions. @@ -8537,6 +8814,7 @@ QUEUE_EVENT_RESULT queue_event(Master_info *mi, const char *buf, restarting the I/O thread without GTID auto positing the parser would assume the failed event as queued. */ + if (mi->is_in_memory_relaylog()) imr_on_truncate(mi); mi->transaction_parser.rollback(); is_error = true; } @@ -9786,7 +10064,12 @@ static bool have_change_replication_source_execute_option( lex_mi->applier_worker_count != LEX_SOURCE_INFO::applier_worker_count_unspecified || lex_mi->applier_event_memory_limit != - LEX_SOURCE_INFO::applier_event_memory_limit_unspecified) + LEX_SOURCE_INFO::applier_event_memory_limit_unspecified || + lex_mi->in_memory_relaylog != LEX_SOURCE_INFO::LEX_MI_IMR_UNCHANGED || + lex_mi->in_memory_relaylog_limit != + LEX_SOURCE_INFO::in_memory_relaylog_limit_unspecified || + lex_mi->in_memory_relaylog_spill_threshold != + LEX_SOURCE_INFO::in_memory_relaylog_spill_threshold_unspecified) have_execute_option = true; if (lex_mi->relay_log_name || lex_mi->relay_log_pos) @@ -9819,7 +10102,12 @@ static bool have_change_replication_source_applier_and_receive_option( lex_mi->auto_position != LEX_SOURCE_INFO::LEX_MI_UNCHANGED || lex_mi->m_source_connection_auto_failover != LEX_SOURCE_INFO::LEX_MI_UNCHANGED || - lex_mi->m_gtid_only != LEX_SOURCE_INFO::LEX_MI_UNCHANGED) + lex_mi->m_gtid_only != LEX_SOURCE_INFO::LEX_MI_UNCHANGED || + lex_mi->in_memory_relaylog != LEX_SOURCE_INFO::LEX_MI_IMR_UNCHANGED || + lex_mi->in_memory_relaylog_limit != + LEX_SOURCE_INFO::in_memory_relaylog_limit_unspecified || + lex_mi->in_memory_relaylog_spill_threshold != + LEX_SOURCE_INFO::in_memory_relaylog_spill_threshold_unspecified) have_applier_receive_option = true; return have_applier_receive_option; @@ -10186,6 +10474,106 @@ static bool change_execute_options(THD *thd, LEX_SOURCE_INFO *lex_mi, } mi->rli->set_applier_event_memory_limit(lex_mi->applier_event_memory_limit); } + if (lex_mi->in_memory_relaylog != LEX_SOURCE_INFO::LEX_MI_IMR_UNCHANGED) { + const bool enable = + (lex_mi->in_memory_relaylog == LEX_SOURCE_INFO::LEX_MI_IMR_ENABLE); + // The in-memory relay log is a CSA-only receiver feature. This CSA gate + // also covers Group Replication channels, which are never CSA-enabled. + if (enable && !mi->rli->is_csa_enabled()) { + my_error(ER_CRST_IN_MEMORY_RELAYLOG_ONLY_FOR_CSA, MYF(0)); + return true; + } + mi->rli->set_in_memory_relaylog(enable); + } + { + const bool limit_specified = + lex_mi->in_memory_relaylog_limit != + LEX_SOURCE_INFO::in_memory_relaylog_limit_unspecified; + const bool spill_threshold_specified = + lex_mi->in_memory_relaylog_spill_threshold != + LEX_SOURCE_INFO::in_memory_relaylog_spill_threshold_unspecified; + + if (limit_specified || spill_threshold_specified) { + // The in-memory relay-log bounds are CSA-only receiver settings, gated + // the same way as the enablement selection (this also covers Group + // Replication channels, which are never CSA-enabled). + if (!mi->rli->is_csa_enabled()) { + my_error(ER_CRST_IN_MEMORY_RELAYLOG_ONLY_FOR_CSA, MYF(0)); + return true; + } + + // Accepted ranges (bytes): + // IN_MEMORY_RELAYLOG_LIMIT : [32 MiB, 4 GiB] + // IN_MEMORY_RELAYLOG_SPILL_THRESHOLD : [8 MiB, IN_MEMORY_RELAYLOG_LIMIT) + // The limit/threshold ordering is validated against the effective + // (post-statement) bounds, so an option given on its own is still checked + // against the currently persisted value of its peer: raising the limit + // must keep it above the current threshold, and vice versa. + constexpr unsigned long long kLimitMin = 32ULL * 1024 * 1024; + constexpr unsigned long long kLimitMax = 4ULL * 1024 * 1024 * 1024; + constexpr unsigned long long kSpillThresholdMin = 8ULL * 1024 * 1024; + + const unsigned long long effective_limit = + limit_specified + ? static_cast( + lex_mi->in_memory_relaylog_limit) + : static_cast( + mi->rli->get_in_memory_relaylog_limit()); + const unsigned long long effective_spill_threshold = + spill_threshold_specified + ? static_cast( + lex_mi->in_memory_relaylog_spill_threshold) + : static_cast( + mi->rli->get_in_memory_relaylog_spill_threshold()); + + char reason[256]; + if (limit_specified && + (effective_limit < kLimitMin || effective_limit > kLimitMax)) { + snprintf(reason, sizeof(reason), + "IN_MEMORY_RELAYLOG_LIMIT (%llu bytes) must be between %llu " + "and %llu bytes", + effective_limit, kLimitMin, kLimitMax); + my_error(ER_CRST_IN_MEMORY_RELAYLOG_INVALID_CONFIG, MYF(0), reason); + return true; + } + if (spill_threshold_specified && + effective_spill_threshold < kSpillThresholdMin) { + snprintf(reason, sizeof(reason), + "IN_MEMORY_RELAYLOG_SPILL_THRESHOLD (%llu bytes) must be at " + "least %llu bytes", + effective_spill_threshold, kSpillThresholdMin); + my_error(ER_CRST_IN_MEMORY_RELAYLOG_INVALID_CONFIG, MYF(0), reason); + return true; + } + // The limit must stay strictly greater than the spill threshold, + // whichever of the two the statement changes. + if (effective_spill_threshold >= effective_limit) { + snprintf(reason, sizeof(reason), + "IN_MEMORY_RELAYLOG_LIMIT (%llu bytes) must be greater than " + "IN_MEMORY_RELAYLOG_SPILL_THRESHOLD (%llu bytes)", + effective_limit, effective_spill_threshold); + my_error(ER_CRST_IN_MEMORY_RELAYLOG_INVALID_CONFIG, MYF(0), reason); + return true; + } + + // All checks passed: apply the specified bound(s). + if (limit_specified) { + mi->rli->set_in_memory_relaylog_limit(lex_mi->in_memory_relaylog_limit); + } + if (spill_threshold_specified) { + mi->rli->set_in_memory_relaylog_spill_threshold( + lex_mi->in_memory_relaylog_spill_threshold); + } + } + } + + // Create/destroy/rebuild the per-channel in-memory relay-log queue to match + // the (possibly just-changed) selection and memory bounds. This CHANGE + // REPLICATION SOURCE runs with both replication threads stopped, so no thread + // is attached to the queue. reconcile() rebuilds the queue when the persisted + // bounds (IN_MEMORY_RELAYLOG_LIMIT / IN_MEMORY_RELAYLOG_SPILL_THRESHOLD) + // differ from the live queue's bounds. + mi->reconcile_in_memory_relaylog_queue(); return false; } @@ -11717,6 +12105,34 @@ static void check_replica_configuration_restrictions() { } } +/** + Whether semi-synchronous replication is enabled on this replica, read from the + plugin-provided global system variable @c rpl_semi_sync_replica_enabled. + + The value is looked up by name through the system-variable infrastructure so + the server does not need to link against the semisync plugin. When the plugin + is not installed the variable is absent; @c Suppress_not_found_error::YES makes + the lookup return an empty optional, which we map to @c false (treat as OFF and + proceed). Reads the global value under @c LOCK_global_system_variables, the + same guard the generic @@sysvar reader uses. + + @param thd the START REPLICA command session (must be non-null). + @return true if semisync is installed and enabled, false otherwise. +*/ +static bool is_semisync_replica_enabled(THD *thd) { + const auto reader = [thd](const System_variable_tracker &, + sys_var *var) -> bool { + mysql_mutex_lock(&LOCK_global_system_variables); + const bool enabled = + *reinterpret_cast(var->value_ptr(thd, OPT_GLOBAL, {})); + mysql_mutex_unlock(&LOCK_global_system_variables); + return enabled; + }; + return System_variable_tracker::make_tracker("rpl_semi_sync_replica_enabled") + .access_system_variable(thd, reader, Suppress_not_found_error::YES) + .value_or(false); +} + /** Checks the current replica configuration when starting a replication thread If some incompatibility is found an error is thrown. @@ -11728,6 +12144,19 @@ static void check_replica_configuration_restrictions() { */ static bool check_replica_configuration_errors(Master_info *mi, int thread_mask) { + // The in-memory relay log is incompatible with semi-synchronous replication. + // Only the receiver (IO thread) connects to the source and acknowledges, so + // the gate applies to an IO-thread start on an in-memory channel. This is a + // start-time (not CHANGE-time) check because semisync is a plugin whose + // enablement is not known when CHANGE REPLICATION SOURCE runs. Leaves the + // queue intact -- it gates running the receiver, not the queue's existence. + if ((thread_mask & REPLICA_IO) && mi->is_in_memory_relaylog() && + current_thd != nullptr && is_semisync_replica_enabled(current_thd)) { + my_error(ER_REPLICA_IN_MEMORY_RELAYLOG_INCOMPATIBLE_CONFIGURATION, MYF(0), + "semi-synchronous replication"); + return true; + } + if (global_gtid_mode.get() != Gtid_mode::ON) { if (mi->is_auto_position() && (thread_mask & REPLICA_IO) && global_gtid_mode.get() == Gtid_mode::OFF) { diff --git a/sql/rpl_rli.cc b/sql/rpl_rli.cc index 6b3b7da6cced..d18b36f70edf 100644 --- a/sql/rpl_rli.cc +++ b/sql/rpl_rli.cc @@ -113,7 +113,10 @@ const char *info_rli_fields[] = {"number_of_lines", "assign_gtids_to_anonymous_transactions_value", "applier_version", "applier_worker_count", - "applier_event_memory_limit"}; + "applier_event_memory_limit", + "in_memory_relaylog", + "in_memory_relaylog_limit", + "in_memory_relaylog_spill_threshold"}; Relay_log_info::Relay_log_info(bool is_slave_recovery, #ifdef HAVE_PSI_INTERFACE @@ -2071,6 +2074,18 @@ bool Relay_log_info::clear_info() { return true; } + if (this->handler->set_info((int)this->m_in_memory_relaylog)) { + return true; + } + + if (this->handler->set_info(this->get_in_memory_relaylog_limit())) { + return true; + } + + if (this->handler->set_info(this->get_in_memory_relaylog_spill_threshold())) { + return true; + } + if (this->handler->flush_info(true)) return true; this->group_relay_log_name[0] = '\0'; @@ -2349,6 +2364,41 @@ bool Relay_log_info::read_info(Rpl_info_handler *from) { set_applier_event_memory_limit(tmp_applier_ev_mem_limit); } + if (lines >= APPLIER_METADATA_LINES_WITH_IN_MEMORY_RELAYLOG) { + int temp_in_memory_relaylog = 0; + if (!!from->get_info(&temp_in_memory_relaylog, 0)) { + return true; + } + set_in_memory_relaylog(temp_in_memory_relaylog != 0); + } else { + // Metadata written by an older server predates the in-memory relay-log + // field; default the selection to OFF without raising an error. + set_in_memory_relaylog(false); + } + + if (lines >= APPLIER_METADATA_LINES_WITH_IN_MEMORY_RELAYLOG_LIMIT) { + long unsigned int tmp_in_memory_relaylog_limit = 0; + if (!!from->get_info(&tmp_in_memory_relaylog_limit, 0UL)) { + return true; + } + set_in_memory_relaylog_limit(tmp_in_memory_relaylog_limit); + } else { + // Older metadata predates the tunable bound; fall back to the default. + set_in_memory_relaylog_limit(0); + } + + if (lines >= APPLIER_METADATA_LINES_WITH_IN_MEMORY_RELAYLOG_SPILL_THRESHOLD) { + long unsigned int tmp_in_memory_relaylog_spill_threshold = 0; + if (!!from->get_info(&tmp_in_memory_relaylog_spill_threshold, 0UL)) { + return true; + } + set_in_memory_relaylog_spill_threshold( + tmp_in_memory_relaylog_spill_threshold); + } else { + // Older metadata predates the tunable threshold; fall back to the default. + set_in_memory_relaylog_spill_threshold(0); + } + group_relay_log_pos = temp_group_relay_log_pos; group_master_log_pos = temp_group_master_log_pos; sql_delay = (int32)temp_sql_delay; @@ -2456,6 +2506,15 @@ bool Relay_log_info::write_info(Rpl_info_handler *to) { if (to->set_info(get_applier_event_memory_limit())) { return true; } + if (to->set_info((int)m_in_memory_relaylog)) { + return true; + } + if (to->set_info(get_in_memory_relaylog_limit())) { + return true; + } + if (to->set_info(get_in_memory_relaylog_spill_threshold())) { + return true; + } return false; } @@ -3710,6 +3769,46 @@ ulong Relay_log_info::get_applier_event_memory_limit() { return applier_event_memory_limit_default; } +void Relay_log_info::set_in_memory_relaylog(bool value) { + DBUG_TRACE; + this->m_in_memory_relaylog = value; +} + +bool Relay_log_info::is_in_memory_relaylog() const { + return this->m_in_memory_relaylog; +} + +void Relay_log_info::set_in_memory_relaylog_limit(ulong number) { + if (number > 0) { + m_in_memory_relaylog_limit = number; + return; + } + m_in_memory_relaylog_limit = in_memory_relaylog_limit_default; +} + +ulong Relay_log_info::get_in_memory_relaylog_limit() const { + if (m_in_memory_relaylog_limit > 0) { + return m_in_memory_relaylog_limit; + } + return in_memory_relaylog_limit_default; +} + +void Relay_log_info::set_in_memory_relaylog_spill_threshold(ulong number) { + if (number > 0) { + m_in_memory_relaylog_spill_threshold = number; + return; + } + m_in_memory_relaylog_spill_threshold = + in_memory_relaylog_spill_threshold_default; +} + +ulong Relay_log_info::get_in_memory_relaylog_spill_threshold() const { + if (m_in_memory_relaylog_spill_threshold > 0) { + return m_in_memory_relaylog_spill_threshold; + } + return in_memory_relaylog_spill_threshold_default; +} + void Relay_log_info::set_channel_instance_id(std::size_t chid) { m_channel_instance_id = chid; } diff --git a/sql/rpl_rli.h b/sql/rpl_rli.h index 4179ee5daae6..a75e3e4aa118 100644 --- a/sql/rpl_rli.h +++ b/sql/rpl_rli.h @@ -1923,6 +1923,13 @@ class Relay_log_info : public Rpl_info { static const int APPLIER_METADATA_LINES_WITH_APPLIER_EVENT_MEMORY_LIMIT = 17; + static const int APPLIER_METADATA_LINES_WITH_IN_MEMORY_RELAYLOG = 18; + + static const int APPLIER_METADATA_LINES_WITH_IN_MEMORY_RELAYLOG_LIMIT = 19; + + static const int + APPLIER_METADATA_LINES_WITH_IN_MEMORY_RELAYLOG_SPILL_THRESHOLD = 20; + /* Total lines in applier metadata. This has to be updated every time a member is added or removed. @@ -1932,7 +1939,7 @@ class Relay_log_info : public Rpl_info { preserved. */ static const int MAXIMUM_APPLIER_METADATA_LINES = - APPLIER_METADATA_LINES_WITH_APPLIER_EVENT_MEMORY_LIMIT; + APPLIER_METADATA_LINES_WITH_IN_MEMORY_RELAYLOG_SPILL_THRESHOLD; bool read_info(Rpl_info_handler *from) override; bool write_info(Rpl_info_handler *to) override; @@ -2165,6 +2172,27 @@ class Relay_log_info : public Rpl_info { /// @return The maximum amount of memory the channel can use to keep binlog /// events ulong get_applier_event_memory_limit(); + /// Sets the persisted in-memory relay-log path selection for the channel + /// @param value true to select the in-memory path, false for the classic + /// relay-log path + void set_in_memory_relaylog(bool value); + /// Accesses the persisted in-memory relay-log path selection for the channel + /// @return true when the in-memory path is selected, false otherwise + bool is_in_memory_relaylog() const; + /// Sets the persisted hard memory bound (bytes) of the in-memory relay-log + /// queue for the channel. A value of 0 forces the default. + /// @param number Requested memory limit in bytes + void set_in_memory_relaylog_limit(ulong number); + /// Accesses the hard memory bound (bytes) of the in-memory relay-log queue + /// @return The configured memory limit in bytes, or the default when unset + ulong get_in_memory_relaylog_limit() const; + /// Sets the persisted spill threshold (bytes) of the in-memory relay-log + /// queue for the channel. A value of 0 forces the default. + /// @param number Requested spill threshold in bytes + void set_in_memory_relaylog_spill_threshold(ulong number); + /// Accesses the spill threshold (bytes) of the in-memory relay-log queue + /// @return The configured spill threshold in bytes, or the default when unset + ulong get_in_memory_relaylog_spill_threshold() const; /// Set CSA worker context used by commit order manager /// @param csa_worker_context CSA worker context void set_csa_worker_context(Parallel_worker_context *csa_worker_context); @@ -2204,6 +2232,22 @@ class Relay_log_info : public Rpl_info { /// default value for the m_applier_event_memory_limit static constexpr ulong applier_event_memory_limit_default = 1024 * 1024 * 1024; + /// Persisted per-channel selection of the in-memory relay-log path, taken + /// into account only when the channel starts. Defaults to OFF (classic + /// relay-log path). + bool m_in_memory_relaylog{false}; + /// Persisted hard memory bound (bytes) of the in-memory relay-log queue, + /// taken into account only when the channel starts. 0 means "use default". + ulong m_in_memory_relaylog_limit{0}; + /// Default value for m_in_memory_relaylog_limit (128 MiB). + static constexpr ulong in_memory_relaylog_limit_default = + 128ULL * 1024 * 1024; + /// Persisted spill threshold (bytes) of the in-memory relay-log queue, taken + /// into account only when the channel starts. 0 means "use default". + ulong m_in_memory_relaylog_spill_threshold{0}; + /// Default value for m_in_memory_relaylog_spill_threshold (16 MiB). + static constexpr ulong in_memory_relaylog_spill_threshold_default = + 16ULL * 1024 * 1024; /// Non-owning, parallel CSA worker execution context, set by CSA Parallel_worker_context *m_csa_worker_context{nullptr}; /// Coordinator RLI. Used in CSA to attach/detach temporary tables diff --git a/sql/sql_lex.cc b/sql/sql_lex.cc index bf37940d2f04..a5cad5c1a7fe 100644 --- a/sql/sql_lex.cc +++ b/sql/sql_lex.cc @@ -5124,9 +5124,13 @@ void LEX_SOURCE_INFO::initialize() { assign_gtids_to_anonymous_transactions_type = LEX_MI_ANONYMOUS_TO_GTID_UNCHANGED; assign_gtids_to_anonymous_transactions_manual_uuid = nullptr; + in_memory_relaylog = LEX_MI_IMR_UNCHANGED; applier_version = Applier_version::unspecified; applier_worker_count = applier_worker_count_unspecified; applier_event_memory_limit = applier_event_memory_limit_unspecified; + in_memory_relaylog_limit = in_memory_relaylog_limit_unspecified; + in_memory_relaylog_spill_threshold = + in_memory_relaylog_spill_threshold_unspecified; } void LEX_SOURCE_INFO::set_unspecified() { diff --git a/sql/sql_lex.h b/sql/sql_lex.h index 33d69bcb9597..ccae13e28dd9 100644 --- a/sql/sql_lex.h +++ b/sql/sql_lex.h @@ -463,6 +463,17 @@ struct LEX_SOURCE_INFO { const char *assign_gtids_to_anonymous_transactions_manual_uuid{nullptr}; + /* + Tri-state option for IN_MEMORY_RELAYLOG, following the LEX_MI_* pattern: + unchanged when the option is absent from the statement (leaving the + persisted selection untouched), disable for OFF, enable for ON. + */ + enum { + LEX_MI_IMR_UNCHANGED = 0, + LEX_MI_IMR_DISABLE, + LEX_MI_IMR_ENABLE + } in_memory_relaylog; + struct Applier_version { static constexpr uint unspecified{0}; ///< use previous or default static constexpr uint mta{1}; ///< use Multi-threaded applier @@ -481,6 +492,19 @@ struct LEX_SOURCE_INFO { /// The maximum amout of memory that can be used by the channel to keep /// binlog events ulong applier_event_memory_limit{applier_event_memory_limit_unspecified}; + /// constant - unspecified IN_MEMORY_RELAYLOG_LIMIT option + static constexpr int in_memory_relaylog_limit_unspecified{0}; + /// Hard per-channel memory bound (bytes) for the in-memory relay-log queue, + /// set via IN_MEMORY_RELAYLOG_LIMIT. Unspecified leaves the persisted value + /// untouched. + ulong in_memory_relaylog_limit{in_memory_relaylog_limit_unspecified}; + /// constant - unspecified IN_MEMORY_RELAYLOG_SPILL_THRESHOLD option + static constexpr int in_memory_relaylog_spill_threshold_unspecified{0}; + /// Per-channel size (bytes) above which a transaction is routed to the spill + /// path, set via IN_MEMORY_RELAYLOG_SPILL_THRESHOLD. Unspecified leaves the + /// persisted value untouched. + ulong in_memory_relaylog_spill_threshold{ + in_memory_relaylog_spill_threshold_unspecified}; /// Initializes everything to zero/NULL/empty. void initialize(); diff --git a/sql/sql_yacc.yy b/sql/sql_yacc.yy index ce76add84d39..f0f18bafd2f5 100644 --- a/sql/sql_yacc.yy +++ b/sql/sql_yacc.yy @@ -1496,6 +1496,9 @@ CHARSET_INFO *warn_on_deprecated_user_defined_collation( %token APPLIER_VERSION_SYM 1243 /* MYSQL */ %token APPLIER_WORKER_COUNT_SYM 1244 /* MYSQL */ %token APPLIER_EVENT_MEMORY_LIMIT_SYM 1245 /* MYSQL */ +%token IN_MEMORY_RELAYLOG_ENABLED_SYM 1246 /* MYSQL */ +%token IN_MEMORY_RELAYLOG_LIMIT_SYM 1247 /* MYSQL */ +%token IN_MEMORY_RELAYLOG_SPILL_THRESHOLD_SYM 1248 /* MYSQL */ /* NOTE! When adding new non-standard keywords, make sure they are added to the @@ -3215,6 +3218,15 @@ source_def: { Lex->mi.applier_event_memory_limit = $3; } + | IN_MEMORY_RELAYLOG_ENABLED_SYM EQ in_memory_relaylog_def + | IN_MEMORY_RELAYLOG_LIMIT_SYM EQ ulong_num + { + Lex->mi.in_memory_relaylog_limit = $3; + } + | IN_MEMORY_RELAYLOG_SPILL_THRESHOLD_SYM EQ ulong_num + { + Lex->mi.in_memory_relaylog_spill_threshold = $3; + } | source_file_def ; @@ -3264,6 +3276,17 @@ table_primary_key_check_def: } ; +in_memory_relaylog_def: + ON_SYM + { + Lex->mi.in_memory_relaylog = LEX_SOURCE_INFO::LEX_MI_IMR_ENABLE; + } + | OFF_SYM + { + Lex->mi.in_memory_relaylog = LEX_SOURCE_INFO::LEX_MI_IMR_DISABLE; + } + ; + assign_gtids_to_anonymous_transactions_def: OFF_SYM { @@ -16271,6 +16294,9 @@ ident_keywords_unambiguous: | INSTANCE_SYM | INVISIBLE_SYM | INVOKER_SYM + | IN_MEMORY_RELAYLOG_ENABLED_SYM + | IN_MEMORY_RELAYLOG_LIMIT_SYM + | IN_MEMORY_RELAYLOG_SPILL_THRESHOLD_SYM | IO_SYM | IPC_SYM | ISOLATION diff --git a/storage/perfschema/table_replication_applier_configuration.cc b/storage/perfschema/table_replication_applier_configuration.cc index f2328eae18a3..2dd94cc1ab17 100644 --- a/storage/perfschema/table_replication_applier_configuration.cc +++ b/storage/perfschema/table_replication_applier_configuration.cc @@ -83,6 +83,15 @@ Plugin_table table_replication_applier_configuration::m_table_def( " 'Number of worker threads utilized by the applier',\n" " APPLIER_EVENT_MEMORY_LIMIT INTEGER UNSIGNED not null COMMENT " " 'Number of worker threads utilized by the applier',\n" + " IN_MEMORY_RELAYLOG_ENABLED ENUM('YES','NO') not null COMMENT " + " 'Indicates whether the channel buffers the relaylog in memory instead" + " of on disk.',\n" + " IN_MEMORY_RELAYLOG_LIMIT BIGINT UNSIGNED not null COMMENT " + " 'Hard per-channel memory bound of the in-memory relaylog" + " queue.',\n" + " IN_MEMORY_RELAYLOG_SPILL_THRESHOLD BIGINT UNSIGNED not null COMMENT " + " 'Per-channel size threshold, in bytes, above which a transaction is" + " spilled to disk instead of held in memory.',\n" " PRIMARY KEY (CHANNEL_NAME) USING HASH\n", /* Options */ " ENGINE=PERFORMANCE_SCHEMA", @@ -270,6 +279,14 @@ int table_replication_applier_configuration::make_row(Master_info *mi) { m_row.applier_event_memory_limit = mi->rli->get_applier_event_memory_limit(); + m_row.in_memory_relaylog_enabled = + mi->rli->is_in_memory_relaylog() ? PS_RPL_YES : PS_RPL_NO; + + m_row.in_memory_relaylog_limit = mi->rli->get_in_memory_relaylog_limit(); + + m_row.in_memory_relaylog_spill_threshold = + mi->rli->get_in_memory_relaylog_spill_threshold(); + mysql_mutex_unlock(&mi->rli->data_lock); mysql_mutex_unlock(&mi->data_lock); @@ -333,6 +350,15 @@ int table_replication_applier_configuration::read_row_values(TABLE *table, set_field_ulong(f, static_cast(m_row.applier_event_memory_limit)); break; + case 10: /** in_memory_relaylog_enabled */ + set_field_enum(f, m_row.in_memory_relaylog_enabled); + break; + case 11: /** in_memory_relaylog_limit */ + set_field_ulonglong(f, m_row.in_memory_relaylog_limit); + break; + case 12: /** in_memory_relaylog_spill_threshold */ + set_field_ulonglong(f, m_row.in_memory_relaylog_spill_threshold); + break; default: assert(false); } diff --git a/storage/perfschema/table_replication_applier_configuration.h b/storage/perfschema/table_replication_applier_configuration.h index f93adce7ffde..cfd1b28c5703 100644 --- a/storage/perfschema/table_replication_applier_configuration.h +++ b/storage/perfschema/table_replication_applier_configuration.h @@ -74,6 +74,9 @@ struct st_row_applier_config { uint applier_version{cs::apply::Applier_version::unspecified}; std::size_t applier_worker_count; ulong applier_event_memory_limit; + enum_rpl_yes_no in_memory_relaylog_enabled{PS_RPL_NO}; + ulonglong in_memory_relaylog_limit{0}; + ulonglong in_memory_relaylog_spill_threshold{0}; }; class PFS_index_rpl_applier_config : public PFS_engine_index { diff --git a/unittest/gunit/changestreams/CMakeLists.txt b/unittest/gunit/changestreams/CMakeLists.txt index 764c3f55fce9..dff664aaf1ee 100644 --- a/unittest/gunit/changestreams/CMakeLists.txt +++ b/unittest/gunit/changestreams/CMakeLists.txt @@ -29,8 +29,21 @@ IF(HAS_WARN_FLAG) ENDIF() INCLUDE_DIRECTORIES(SYSTEM ${GMOCK_INCLUDE_DIRS}) +INCLUDE_DIRECTORIES(SYSTEM + ${CMAKE_SOURCE_DIR}/extra/unordered_dense/unordered_dense-4.4.0/include) # Add tests +# +# NOTE: none of the imr_* tests are in this lightweight list. As of the +# destination-creating enqueue() extension, Trx_envelope_queue::enqueue() itself +# constructs the memory-path byte source (Event_set_fetchable_memory), so the +# queue TU now transitively references the real server internals (Log_event and +# its decode/decompression istream, server globals). Any test that links the +# queue therefore needs those symbols, so ALL imr_* tests are built below +# against gunit_large + server_unittest_library — the conventional MySQL +# mechanism for a gunit test that exercises server internals (as used by the +# sibling binlogevents legacy_gtid_set-t and the main SERVER_TESTS). The tests +# in this list stay on the lightweight standalone-lib link set. SET(TESTS cstreams_reader_state) @@ -56,3 +69,120 @@ FOREACH(test ${TESTS}) COMPILE_DEFINITIONS "${DISABLE_PSI_DEFINITIONS}") ENDFOREACH() +# The in-memory byte source test needs the REAL server internals: Log_event and +# its decode path, binlog::Decompressing_event_object_istream, and the server +# globals the Format_description_log_event vtable pulls in. Rather than compile +# sql/log_event.cc (+ the rpl_*/istream cluster) into an isolated harness and +# satisfy the residue with hand-written link-time stubs, link the conventional +# MySQL server unit-test library (gunit_large + server_unittest_library) — a +# test-only merge of sql_main/binlog/perfschema/... that already provides real +# Log_event/server symbols. This mirrors the sibling binlogevents +# legacy_gtid_set-t and the main SERVER_TESTS. All of the in-memory core sources +# (event_set_fetchable_memory.cpp, trx_envelope_queue.cpp, transaction_envelope.cpp, +# trx_payload.cpp, fetchable_transaction.cpp, managed_event.cpp) are registered +# in sql/CMakeLists.txt and are therefore already inside server_unittest_library, +# so this target needs no EXTRA_SRC — the linker pulls the real objects from the +# library. The standalone binlog libs (mysql_binlog_event_standalone / +# changestreams_standalone_static) are deliberately NOT linked here: they are +# built under -DSTANDALONE_BINLOG and clash (duplicate symbols / ODR) with the +# -DMYSQL_SERVER binlog objects in server_unittest_library. +# +# This target must ALSO be compiled without -DSTANDALONE_BINLOG. That macro is +# added directory-wide (ADD_DEFINITIONS above) and only flips HAVE_MYSYS off in +# the binlog-event wrapper/byteorder headers, selecting the standalone +# (non-mysys) inline helpers. server_unittest_library, however, is built with +# HAVE_MYSYS on (-DMYSQL_SERVER, no STANDALONE_BINLOG). If this TU kept +# STANDALONE_BINLOG, the Log_event objects it allocates/frees would cross that +# HAVE_MYSYS boundary via mismatched inline code (real Format_description_log_event +# built in the library, destroyed through the TU's standalone inlines), which +# crashes on teardown. Dropping STANDALONE_BINLOG here aligns this TU's shared +# inline/header code with the server library it links. The parent +# unittest/gunit/CMakeLists.txt already supplies -DMYSQL_SERVER, so removing +# STANDALONE_BINLOG leaves this TU in the same server build mode as the library. +# All targets defined after this REMOVE_DEFINITIONS are built without +# STANDALONE_BINLOG (server / HAVE_MYSYS mode), matching server_unittest_library. +REMOVE_DEFINITIONS(-DSTANDALONE_BINLOG) + +# The in-memory core tests (admission, trx_payload, transaction_envelope, FIFO +# ordering) now link the real server library too: since enqueue() constructs the +# Event_set_fetchable_memory byte source, the Trx_envelope_queue TU these tests +# link references Log_event/decode/decompression-istream and server globals that +# only exist in server_unittest_library. All the in-memory sources they exercise +# are registered in sql/CMakeLists.txt and are therefore already inside +# server_unittest_library, so these targets need no EXTRA_SRC — the linker pulls +# the real objects from the library. Like the heavy targets below, they must NOT +# be compiled with -DSTANDALONE_BINLOG (removed directory-wide just above). +FOREACH(imr_core_test + imr_queue_admission + imr_trx_payload + imr_transaction_envelope + imr_queue_fifo + imr_queue_lifecycle + imr_sweep_and_dispatch + imr_spill_writer + imr_event_set_fetchable_spill) + MYSQL_ADD_EXECUTABLE(${imr_core_test}-t ${imr_core_test}-t.cc + ADD_TEST ${imr_core_test} + LINK_LIBRARIES gunit_large server_unittest_library + COMPILE_DEFINITIONS "${DISABLE_PSI_DEFINITIONS}") +ENDFOREACH() + +MYSQL_ADD_EXECUTABLE(imr_event_set_fetchable_memory-t + imr_event_set_fetchable_memory-t.cc + ADD_TEST imr_event_set_fetchable_memory + LINK_LIBRARIES gunit_large server_unittest_library + COMPILE_DEFINITIONS "${DISABLE_PSI_DEFINITIONS}") + +# imr_queued_transaction_reader gets the SAME standalone treatment as the byte +# source above. Queued_transaction_reader::read() builds a real Job_applier +# (pulling in the apply/session/statistics stack) and references server globals +# (slave_trans_retries), so it needs the real server symbols from +# server_unittest_library rather than the standalone binlog libs. The reader's +# own source (queued_transaction_reader.cpp) plus the imr core sources are +# already registered in sql/CMakeLists.txt and therefore already inside +# server_unittest_library, so this target needs no EXTRA_SRC. Like the byte +# source, it must NOT be compiled with -DSTANDALONE_BINLOG (removed +# directory-wide above); this target is defined after that REMOVE_DEFINITIONS so +# it inherits the server (HAVE_MYSYS) build mode of the library it links. +MYSQL_ADD_EXECUTABLE(imr_queued_transaction_reader-t + imr_queued_transaction_reader-t.cc + ADD_TEST imr_queued_transaction_reader + LINK_LIBRARIES gunit_large server_unittest_library + COMPILE_DEFINITIONS "${DISABLE_PSI_DEFINITIONS}") + +# imr_integration gets the SAME standalone treatment as the byte source and the +# reader above. The end-to-end harness wires all five in-memory-relaylog classes +# together and exercises the full memory path at once: the byte source's +# Log_event decode path, the Fetchable_transaction consumer surface, and +# Queued_transaction_reader::read() building a real Job_applier (pulling in the +# apply/session/statistics stack and server globals). It therefore needs the +# real server symbols from server_unittest_library rather than the standalone +# binlog libs. All of the in-memory sources it drives are already registered in +# sql/CMakeLists.txt and therefore already inside server_unittest_library, so +# this target needs no EXTRA_SRC. Like the byte source and the reader, it must +# NOT be compiled with -DSTANDALONE_BINLOG (removed directory-wide above); this +# target is defined after that REMOVE_DEFINITIONS so it inherits the server +# (HAVE_MYSYS) build mode of the library it links. +MYSQL_ADD_EXECUTABLE(imr_integration-t + imr_integration-t.cc + ADD_TEST imr_integration + LINK_LIBRARIES gunit_large server_unittest_library + COMPILE_DEFINITIONS "${DISABLE_PSI_DEFINITIONS}") + +# TEMPORARY / SPIKE (tasks.md task 12) — do not ship. +# imr_queued_transaction_writer-t exercises the standalone minimal-state +# receiver-helper module (queued_transaction_writer.{h,cc}) against a REAL +# Trx_envelope_queue plus a local Streaming_event_sink pointer (no Master_info). +# It reuses the real Cached_event_payload decode path and the enqueue-created +# byte source, so it needs the server symbols from server_unittest_library +# exactly like the other imr_* heavy targets above. The module source is +# registered in sql/CMakeLists.txt and therefore already inside +# server_unittest_library, so this target needs no EXTRA_SRC. Defined after the +# REMOVE_DEFINITIONS above so it is built without -DSTANDALONE_BINLOG, matching +# the library. +MYSQL_ADD_EXECUTABLE(imr_queued_transaction_writer-t + imr_queued_transaction_writer-t.cc + ADD_TEST imr_queued_transaction_writer + LINK_LIBRARIES gunit_large server_unittest_library + COMPILE_DEFINITIONS "${DISABLE_PSI_DEFINITIONS}") + diff --git a/unittest/gunit/changestreams/imr_event_set_fetchable_memory-t.cc b/unittest/gunit/changestreams/imr_event_set_fetchable_memory-t.cc new file mode 100644 index 000000000000..27271b78c55b --- /dev/null +++ b/unittest/gunit/changestreams/imr_event_set_fetchable_memory-t.cc @@ -0,0 +1,719 @@ +/* Copyright (c) 2026, Oracle and/or its affiliates. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License, version 2.0, + as published by the Free Software Foundation. + + This program is designed to work with certain software (including + but not limited to OpenSSL) that is licensed under separate terms, + as designated in a particular file or component or in included license + documentation. The authors of MySQL hereby grant you an additional + permission to link the program and your derivative works with the + separately licensed software that they have either included with + the program or referenced in the documentation. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License, version 2.0, for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +/// @file +/// Unit tests for the consumer/producer surface of +/// mysql::csa::Event_set_fetchable_memory. +/// +/// The byte source stores *encoded* events as IReader_event entries and only +/// decodes them lazily inside fetch_next(). Most tests here drive the internal +/// event-oriented seam append_reader_event(IReader_event_ptr): they feed a fake +/// IReader_event (the "encoded" form) whose decode() hands back a known, +/// non-TPLE Log_event, letting the consumer state machine (ordering, seal, +/// truncate, blocking) be exercised with exact object identity. Serving a +/// non-TPLE event keeps the Transaction-payload decompression path out of the +/// tests entirely: a Format_description_log_event constructs cleanly without a +/// THD and its type code (FORMAT_DESCRIPTION_EVENT) is neither +/// TRANSACTION_PAYLOAD_EVENT nor XID_EVENT, so fetch_next() returns it +/// directly. +/// +/// The production Streaming_event_sink contract is the byte-oriented +/// append_event(buf, len, seal_after), which copies the raw bytes, wraps them +/// in an internal Cached_event_memory built from the source's own FDE, and +/// serves them back by real deserialization. That path is covered separately by +/// ByteOrientedAppendDecodesAndSeals (which asserts decoded type + order rather +/// than object identity, since each fetch deserializes a fresh Log_event). + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sql/basic_ostream.h" // StringBuffer_ostream +#include "sql/changestreams/apply/jobs/fetchable_transaction.h" +#include "sql/changestreams/apply/storage/in_memory/event_set_fetchable_memory.h" +#include "sql/changestreams/apply/storage/in_memory/in_memory_types.h" +#include "sql/changestreams/apply/storage/in_memory/transaction_envelope.h" +#include "sql/changestreams/apply/storage/in_memory/trx_envelope_queue.h" +#include "sql/changestreams/apply/storage/in_memory/trx_payload.h" +#include "sql/changestreams/apply/storage/relay_log/ireader_event.h" +#include "sql/log_event.h" + +namespace mysql::csa::unittests { + +namespace { + +/// A fake encoded event: it just hands back a preset, already-decoded +/// Log_event when the byte source asks it to decode(). This lets a test control +/// exactly which Log_event object fetch_next() yields, and assert object +/// identity on the way out. +class Fake_reader_event : public IReader_event { + public: + explicit Fake_reader_event(std::shared_ptr decoded) + : m_decoded(std::move(decoded)) {} + + std::shared_ptr decode() override { return m_decoded; } + + // The byte source calls reset() only when re-reading with reset_events=true; + // these tests never do, so a no-op is sufficient. + void reset(const Format_description_log_event *) override {} + + private: + std::shared_ptr m_decoded; +}; + +/// Build a Format_description_log_event as its precise type. enqueue() takes +/// the exact std::shared_ptr (matching +/// Master_info::get_mi_description_event_shared()), so it is handed directly +/// with no upcast; passing it to the Event_set_fetchable_memory ctor, whose FDE +/// parameter is the base Log_event_ptr, is a well-formed implicit upcast. +std::shared_ptr make_fde() { + return std::make_shared(); +} + +/// A body event served by the stream: another FDE object (distinct instance), +/// so object identity is meaningful. +std::shared_ptr make_event() { + return std::make_shared(); +} + +/// Wrap a decoded Log_event into a fake encoded IReader_event entry. +IReader_event_ptr make_fake(std::shared_ptr decoded) { + return std::make_shared(std::move(decoded)); +} + +/// Serialize a real Format_description_log_event into a byte buffer, mimicking +/// the transient network bytes the receiver hands to the byte-oriented +/// append_event(buf, len, ...). A default server FDE writes with checksum OFF +/// and is self-describing on decode, so the round-trip through the sink's +/// internal Cached_event_memory::decode() succeeds without a THD or checksum +/// plumbing. +std::vector serialize_event() { + Format_description_log_event ev; + StringBuffer_ostream<1024> os; + EXPECT_FALSE(ev.write(&os)) << "serializing the FDE must succeed"; + const auto *p = reinterpret_cast(os.ptr()); + return std::vector(p, p + os.length()); +} + +const char *as_char(const std::vector &v) { + return reinterpret_cast(v.data()); +} + +} // namespace + +// --------------------------------------------------------------------------- +// Task 7.2.1 - Append/seal/fetch ordering and end-of-stream. +// Requirements 7.2, 7.3 +// --------------------------------------------------------------------------- + +// Append several events on an open stream, seal it, then drive +// wait_next()/fetch_next(): the decoded events must come back in append order +// (same objects). After the last event, wait_next() reports end-of-stream, +// fetch_next() yields nothing, is_done() is true and is_error() is false. +TEST(ImrEventSetFetchableMemoryTest, AppendSealFetchOrder) { + auto fde = make_fde(); + Event_set_fetchable_memory source(/*is_trx=*/true, fde, + /*owner_envelope=*/nullptr, + /*streaming_open=*/true); + + // Append a handful of distinct events on the open stream, remembering the + // exact object each fake will decode to so we can assert identity + order. + constexpr int kEventCount = 4; + std::vector expected; + for (int i = 0; i < kEventCount; ++i) { + auto decoded = make_event(); + expected.push_back(decoded.get()); + source.append_reader_event(make_fake(decoded)); + } + + // Seal: the receiver has delivered the whole transaction. + source.seal_stream(); + + // Drive the consumer surface and collect what comes back. + std::vector fetched; + while (source.wait_next()) { + auto managed = source.fetch_next(); + ASSERT_TRUE(managed.has_value()) + << "wait_next() returned true so fetch_next() must yield an event"; + fetched.push_back(managed->get_event().get()); + } + + // Same objects, in append order. + EXPECT_EQ(fetched, expected); + + // End-of-stream: no more events, cleanly done, no error. + EXPECT_FALSE(source.wait_next()); + EXPECT_FALSE(source.fetch_next().has_value()); + EXPECT_TRUE(source.is_done()); + EXPECT_FALSE(source.is_error()); +} + +// --------------------------------------------------------------------------- +// Byte-oriented append: the production Streaming_event_sink path. +// --------------------------------------------------------------------------- + +// The byte-oriented append_event(buf, len, seal_after) is what the receiver +// actually drives: it copies the transient encoded bytes, wraps them in an +// internal Cached_event_memory built from the source's own FDE, and serves them +// back through the consumer surface by real deserialization. Feed serialized +// FDE bytes for a few events, seal on the last, then drain: the consumer yields +// exactly that many events, each decoding to a Format_description_log_event, in +// order, followed by a clean end-of-stream. Object identity is intentionally +// NOT asserted here (each fetch decodes a fresh Log_event from the bytes) — a +// post-seal byte append must still be a silent no-op. +TEST(ImrEventSetFetchableMemoryTest, ByteOrientedAppendDecodesAndSeals) { + auto fde = make_fde(); + Event_set_fetchable_memory source(/*is_trx=*/true, fde, + /*owner_envelope=*/nullptr, + /*streaming_open=*/true); + + constexpr int kEventCount = 3; + for (int i = 0; i < kEventCount; ++i) { + const auto bytes = serialize_event(); + const bool last = (i == kEventCount - 1); + source.append_event(as_char(bytes), bytes.size(), /*seal_after=*/last); + } + + // A post-seal byte append must be a silent no-op. + const auto extra = serialize_event(); + source.append_event(as_char(extra), extra.size()); + + int fetched = 0; + while (source.wait_next()) { + auto managed = source.fetch_next(); + ASSERT_TRUE(managed.has_value()) + << "wait_next() returned true so fetch_next() must yield an event"; + ASSERT_NE(managed->get_event(), nullptr); + EXPECT_EQ(managed->get_event()->get_type_code(), + mysql::binlog::event::FORMAT_DESCRIPTION_EVENT); + ++fetched; + } + + // Exactly the pre-seal events came back; the post-seal append never became + // observable; clean end-of-stream. + EXPECT_EQ(fetched, kEventCount); + EXPECT_FALSE(source.wait_next()); + EXPECT_TRUE(source.is_done()); + EXPECT_FALSE(source.is_error()); +} + +// --------------------------------------------------------------------------- +// Task 7.2.2 - Appends are rejected after the stream is sealed. +// Requirement 7.3 +// --------------------------------------------------------------------------- + +// Append one event on an open stream, seal it, then attempt another append. +// The post-seal append must be dropped (no-op): only the single pre-seal event +// is yielded (by object identity), followed by clean end-of-stream. +TEST(ImrEventSetFetchableMemoryTest, AppendRejectedAfterSeal) { + auto fde = make_fde(); + Event_set_fetchable_memory source(/*is_trx=*/true, fde, + /*owner_envelope=*/nullptr, + /*streaming_open=*/true); + + // One event delivered before the seal. + auto decoded = make_event(); + Log_event *expected = decoded.get(); + source.append_reader_event(make_fake(decoded)); + + // Seal: the receiver has delivered the whole transaction. + source.seal_stream(); + + // Any further append after the seal must be a silent no-op. + source.append_reader_event(make_fake(make_event())); + + // Drive the consumer surface and collect what comes back. + std::vector fetched; + while (source.wait_next()) { + auto managed = source.fetch_next(); + ASSERT_TRUE(managed.has_value()) + << "wait_next() returned true so fetch_next() must yield an event"; + fetched.push_back(managed->get_event().get()); + } + + // Exactly the one pre-seal event, by identity — the post-seal append never + // became observable. + ASSERT_EQ(fetched.size(), 1u); + EXPECT_EQ(fetched[0], expected); + + // End-of-stream: cleanly done, no error. + EXPECT_FALSE(source.wait_next()); + EXPECT_TRUE(source.is_done()); + EXPECT_FALSE(source.is_error()); +} + +// append_event(e, seal_after=true) publishes e and seals atomically; a +// subsequent append is rejected. Exactly the one sealed-with event is yielded +// (by identity), then clean end-of-stream. +TEST(ImrEventSetFetchableMemoryTest, AppendWithSealAfterSealsStream) { + auto fde = make_fde(); + Event_set_fetchable_memory source(/*is_trx=*/true, fde, + /*owner_envelope=*/nullptr, + /*streaming_open=*/true); + + // Publish the event and seal the stream in one atomic step. + auto decoded = make_event(); + Log_event *expected = decoded.get(); + source.append_reader_event(make_fake(decoded), /*seal_after=*/true); + + // The stream is now sealed, so this append must be dropped (no-op). + source.append_reader_event(make_fake(make_event())); + + // Drive the consumer surface and collect what comes back. + std::vector fetched; + while (source.wait_next()) { + auto managed = source.fetch_next(); + ASSERT_TRUE(managed.has_value()) + << "wait_next() returned true so fetch_next() must yield an event"; + fetched.push_back(managed->get_event().get()); + } + + // Exactly the one sealed-with event, by identity — the later append never + // became observable. + ASSERT_EQ(fetched.size(), 1u); + EXPECT_EQ(fetched[0], expected); + + // End-of-stream: cleanly done, no error. + EXPECT_FALSE(source.wait_next()); + EXPECT_TRUE(source.is_done()); + EXPECT_FALSE(source.is_error()); +} + +// --------------------------------------------------------------------------- +// Task 7.2.3 - wait_next() blocking on an empty, unsealed stream and waking on +// append/seal. +// Requirement 7.7 +// --------------------------------------------------------------------------- + +namespace { + +// A short bounded probe: how long we wait to *prove* a blocked wait_next() is +// still blocked. Kept small so the tests stay quick. +constexpr auto kBlockProbe = std::chrono::milliseconds(50); +// A generous ceiling for the "became ready" wait, so a slow-but-correct wakeup +// is not mistaken for a hang. +constexpr auto kWakeTimeout = std::chrono::seconds(3); + +} // namespace + +// A wait_next() on an empty, unsealed stream must block; it becomes ready and +// returns true once an event is appended. A background thread runs wait_next() +// into a promise: while the stream is empty the future must stay unfulfilled +// (timeout), and it must complete with true after the append. +TEST(ImrEventSetFetchableMemoryTest, WaitNextBlocksThenWakesOnAppend) { + auto fde = make_fde(); + Event_set_fetchable_memory source(/*is_trx=*/true, fde, + /*owner_envelope=*/nullptr, + /*streaming_open=*/true); + + std::promise result; + std::future future = result.get_future(); + std::thread waiter( + [&source, &result]() { result.set_value(source.wait_next()); }); + + // Still blocked: nothing has been appended and the stream is not sealed. + EXPECT_EQ(future.wait_for(kBlockProbe), std::future_status::timeout) + << "wait_next() must block on an empty, unsealed stream"; + + // Publish one event: the blocked waiter must wake and report an event. + source.append_reader_event(make_fake(make_event())); + + ASSERT_EQ(future.wait_for(kWakeTimeout), std::future_status::ready) + << "wait_next() must wake once an event is appended"; + EXPECT_TRUE(future.get()); + + waiter.join(); +} + +// A wait_next() blocked on an empty stream returns false (clean end-of-stream) +// when the stream is sealed with no events buffered. +TEST(ImrEventSetFetchableMemoryTest, WaitNextBlockedWakesOnSealEmpty) { + auto fde = make_fde(); + Event_set_fetchable_memory source(/*is_trx=*/true, fde, + /*owner_envelope=*/nullptr, + /*streaming_open=*/true); + + std::promise result; + std::future future = result.get_future(); + std::thread waiter( + [&source, &result]() { result.set_value(source.wait_next()); }); + + // Still blocked: empty and unsealed. + EXPECT_EQ(future.wait_for(kBlockProbe), std::future_status::timeout) + << "wait_next() must block on an empty, unsealed stream"; + + // Seal with no events: the blocked waiter wakes to end-of-stream. + source.seal_stream(); + + ASSERT_EQ(future.wait_for(kWakeTimeout), std::future_status::ready) + << "wait_next() must wake when the empty stream is sealed"; + EXPECT_FALSE(future.get()); + + waiter.join(); + + // Cleanly done, no error. + EXPECT_TRUE(source.is_done()); + EXPECT_FALSE(source.is_error()); +} + +// --------------------------------------------------------------------------- +// Task 7.2.4 - Truncation surfaces an incomplete transaction. +// Requirement 7.6 +// --------------------------------------------------------------------------- + +// set_stream_truncated() drains any already-buffered events, then reports +// end-of-stream WITHOUT blocking for the missing remainder. Append two events, +// truncate, then drive wait_next()/fetch_next(): both buffered events come back +// in append order (by identity), followed by clean end-of-stream. +TEST(ImrEventSetFetchableMemoryTest, TruncationDrainsBufferedThenReportsDone) { + auto fde = make_fde(); + Event_set_fetchable_memory source(/*is_trx=*/true, fde, + /*owner_envelope=*/nullptr, + /*streaming_open=*/true); + + // Two events delivered before truncation. + auto first = make_event(); + auto second = make_event(); + std::vector expected{first.get(), second.get()}; + source.append_reader_event(make_fake(first)); + source.append_reader_event(make_fake(second)); + + // The transaction is incomplete: truncate the stream. The already-buffered + // events must still drain; the missing remainder must not be waited for. + source.set_stream_truncated(); + + std::vector fetched; + while (source.wait_next()) { + auto managed = source.fetch_next(); + ASSERT_TRUE(managed.has_value()) + << "wait_next() returned true so fetch_next() must yield an event"; + fetched.push_back(managed->get_event().get()); + } + + // Both buffered events, by identity, in append order. + EXPECT_EQ(fetched, expected); + + // End-of-stream reached without blocking: done, no error. + EXPECT_FALSE(source.wait_next()); + EXPECT_FALSE(source.fetch_next().has_value()); + EXPECT_TRUE(source.is_done()); + EXPECT_FALSE(source.is_error()); +} + +// A wait_next() already blocked on an empty stream wakes and returns false +// (end-of-stream) when set_stream_truncated() is called. +TEST(ImrEventSetFetchableMemoryTest, WaitNextBlockedWakesOnTruncate) { + auto fde = make_fde(); + Event_set_fetchable_memory source(/*is_trx=*/true, fde, + /*owner_envelope=*/nullptr, + /*streaming_open=*/true); + + std::promise result; + std::future future = result.get_future(); + std::thread waiter( + [&source, &result]() { result.set_value(source.wait_next()); }); + + // Still blocked: empty and unsealed. + EXPECT_EQ(future.wait_for(kBlockProbe), std::future_status::timeout) + << "wait_next() must block on an empty, unsealed stream"; + + // Truncate: the blocked waiter wakes to end-of-stream. + source.set_stream_truncated(); + + ASSERT_EQ(future.wait_for(kWakeTimeout), std::future_status::ready) + << "wait_next() must wake when the empty stream is truncated"; + EXPECT_FALSE(future.get()); + + waiter.join(); + + // Truncation surfaces as end-of-stream (incomplete transaction). + EXPECT_TRUE(source.is_done()); +} + +// The stranded-worker contract that the receiver-stop truncate fix relies on: +// a consumer that has already drained the buffered events and is RE-BLOCKED in +// wait_next() waiting for more of a still-unsealed stream (a worker mid-apply of +// a partially-received transaction) is released by set_stream_truncated(), +// waking to end-of-stream after yielding exactly the events buffered before the +// truncation. Without a truncate on receiver stop this consumer would block +// forever, which is the hang this fix removes. +TEST(ImrEventSetFetchableMemoryTest, + WaitNextBlockedAfterPartialConsumeWakesOnTruncate) { + auto fde = make_fde(); + Event_set_fetchable_memory source(/*is_trx=*/true, fde, + /*owner_envelope=*/nullptr, + /*streaming_open=*/true); + + // One event delivered so far; the transaction is still open (unsealed). + auto decoded = make_event(); + Log_event *expected = decoded.get(); + source.append_reader_event(make_fake(decoded)); + + // Background consumer: drain everything wait_next() offers until it reports + // end-of-stream, recording the events seen by identity. + std::promise> result; + std::future> future = result.get_future(); + std::thread consumer([&source, &result]() { + std::vector seen; + while (source.wait_next()) { + auto managed = source.fetch_next(); + if (!managed.has_value()) break; + seen.push_back(managed->get_event().get()); + } + result.set_value(std::move(seen)); + }); + + // The consumer takes the one buffered event, then re-blocks in wait_next() + // waiting for more of the unsealed stream: the future must stay pending. + EXPECT_EQ(future.wait_for(kBlockProbe), std::future_status::timeout) + << "consumer must re-block in wait_next() on the unsealed stream after " + "draining the buffered event"; + + // The receiver stopped mid-transaction: truncate. The blocked consumer must + // wake to end-of-stream. + source.set_stream_truncated(); + + ASSERT_EQ(future.wait_for(kWakeTimeout), std::future_status::ready) + << "wait_next() must wake when the stream is truncated"; + std::vector seen = future.get(); + consumer.join(); + + // Exactly the one pre-truncation event, by identity; then clean end-of-stream. + ASSERT_EQ(seen.size(), 1u); + EXPECT_EQ(seen[0], expected); + EXPECT_TRUE(source.is_done()); + EXPECT_FALSE(source.is_error()); +} + +// --------------------------------------------------------------------------- +// Task 7.2.5 - set_success() commits the envelope and releases bytes once. +// Requirement 7.4 +// --------------------------------------------------------------------------- + +namespace { + +// Per-channel memory bounds for the commit-hook tests. kTrxLength must be at +// or below the spill threshold so classify() picks the MEMORY path and +// enqueue() returns a non-null envelope. +constexpr std::size_t kMemoryLimit = 1u << 20; // 1 MiB +constexpr std::size_t kSpillThreshold = 1u << 16; // 64 KiB +constexpr std::size_t kTrxLength = 4096; + +// Enqueue a memory-path envelope. enqueue() itself creates the empty +// single-batch destination and attaches a Trx_payload reserving kTrxLength +// bytes, so the returned envelope is already ready for the commit hook. The +// commit-hook tests drive commit through a SEPARATE Event_set_fetchable_memory +// (also owning this envelope); its set_success() commits the envelope and +// releases the enqueue-created payload's reserved bytes. Returns the +// (non-owning) envelope pointer; asserts enqueue succeeded. +Transaction_envelope *enqueue_envelope_with_payload(Trx_envelope_queue &queue) { + Transaction_envelope *env = queue.enqueue(kTrxLength, true, make_fde()); + EXPECT_NE(env, nullptr); + return env; +} + +} // namespace + +// With an envelope enqueued and a payload attached, set_success() drives +// commit(): the envelope becomes committed, its payload is nulled, and the +// reserved bytes are released back to the queue counter (bytes_used() returns +// to 0). "Fully received" is observed via the consumer surface, not any +// envelope state. +TEST(ImrEventSetFetchableMemoryTest, SetSuccessCommitsEnvelopeAndReleasesBytes) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + Transaction_envelope *env = enqueue_envelope_with_payload(queue); + ASSERT_NE(env, nullptr); + + // The byte source holds a non-owning back-reference to its owning envelope. + Event_set_fetchable_memory source(/*is_trx=*/true, make_fde(), env, + /*streaming_open=*/true); + + // Precondition: the payload's bytes are reserved and the envelope is + // uncommitted. + ASSERT_EQ(queue.bytes_used(), kTrxLength); + ASSERT_FALSE(env->is_committed()); + + // Fire the commit hook. + source.set_success(); + + // The envelope committed exactly once: committed, payload nulled, bytes back. + EXPECT_TRUE(env->is_committed()); + EXPECT_EQ(env->payload(), nullptr); + EXPECT_EQ(queue.bytes_used(), 0u); + + // Drain the committed head so the queue destructor invariant (empty deque, + // bytes_used() == 0) holds. After the sweep, env dangles - do not touch it. + EXPECT_FALSE(queue.sweep_committed()); +} + +// --------------------------------------------------------------------------- +// Task 7.2.6 - repeated set_success() is a no-op. +// Requirement 7.5 +// --------------------------------------------------------------------------- + +// A second set_success() after commit is rejected internally (the envelope is +// already committed) and discarded: is_committed() stays true and bytes_used() +// stays unchanged (no double release). +TEST(ImrEventSetFetchableMemoryTest, RepeatedSetSuccessLeavesStateAndCounter) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + Transaction_envelope *env = enqueue_envelope_with_payload(queue); + ASSERT_NE(env, nullptr); + + Event_set_fetchable_memory source(/*is_trx=*/true, make_fde(), env, + /*streaming_open=*/true); + + // First commit: succeeds, releasing the reserved bytes. + source.set_success(); + ASSERT_TRUE(env->is_committed()); + ASSERT_EQ(queue.bytes_used(), 0u); + + // Second commit: rejected internally and discarded - no state change, no + // double release. + source.set_success(); + EXPECT_TRUE(env->is_committed()); + EXPECT_EQ(queue.bytes_used(), 0u); + + // Drain the committed head so the queue destructor invariant holds. After the + // sweep, env dangles - do not touch it. + EXPECT_FALSE(queue.sweep_committed()); +} + +// --------------------------------------------------------------------------- +// Task 7.3 - Property 7: Interface parity. +// Validates: Requirements 7.1 +// --------------------------------------------------------------------------- +// +// Property 7 states that the in-memory source serves the SAME event byte stream +// via wait_next()/fetch_next() as the on-disk Event_set_fetchable_cache path. +// +// We deliberately do NOT construct a live Event_set_fetchable_cache fixture in +// this STANDALONE_BINLOG gunit harness. A real cache requires a +// Relay_log_deleter_handle and the relay-log deleter infrastructure, which is +// far heavier than a unit test should pull in (it would drag in additional +// server link dependencies that this lightweight target is specifically wired +// to avoid). Standing one up here would trade a focused, fast unit test for a +// brittle integration harness. +// +// Interface parity is nevertheless meaningful to express as an equivalent +// randomized-sequence ("property") test. The memory source and the cache source +// share the identical consumer state machine (wait_next / fetch_next / +// decompress); the only thing that differs is where the encoded bytes come +// from. Given that shared consumer, the observable, testable contract for the +// memory variant is that it serves back EXACTLY the appended event sequence, in +// order — i.e. append-order fidelity. If the memory source reproduces its input +// sequence faithfully for arbitrary randomized batches, then, driven through +// the shared state machine, it yields the same stream the cache path would for +// the same encoded input. That append-order fidelity is the equivalent +// expression of Property 7 here. +// +// The MySQL tree does not integrate rapidcheck, so — as elsewhere in this spec +// — the property is checked with a deterministically seeded std::mt19937 +// generator running many trials inside a single gtest TEST. The seed is echoed +// once and every assertion carries seed / trial / index so any failure +// reproduces exactly. +TEST(ImrEventSetFetchableMemoryTest, PropertyInterfaceParity) { + constexpr std::uint32_t kSeed = 0x0E5E7A11; + constexpr int kTrials = 500; + constexpr int kMaxBatch = 16; + + std::cout << "PropertyInterfaceParity seed=0x" << std::hex << kSeed + << std::dec << " trials=" << kTrials << std::endl; + + std::mt19937 rng(kSeed); + std::uniform_int_distribution batch_dist(0, kMaxBatch); + + for (int trial = 0; trial < kTrials; ++trial) { + Event_set_fetchable_memory src(/*is_trx=*/true, make_fde(), + /*owner_envelope=*/nullptr, + /*streaming_open=*/true); + + const int n = batch_dist(rng); + + // Build N fake events, each decoding to a distinct known Log_event. Keep the + // fakes alive for the whole trial: they own the Log_event that decode() + // returns and that fetch_next() yields by identity. Record the expected raw + // Log_event* pointers in append order. + std::vector fakes; + std::vector expected; + fakes.reserve(n); + expected.reserve(n); + for (int i = 0; i < n; ++i) { + auto decoded = make_event(); + expected.push_back(decoded.get()); + fakes.push_back(make_fake(decoded)); + } + + // Append all N events, then seal the stream. + for (int i = 0; i < n; ++i) { + src.append_reader_event(fakes[i]); + } + src.seal_stream(); + + // Drive wait_next()/fetch_next() to completion, collecting returned events + // by identity. + std::vector fetched; + fetched.reserve(n); + while (src.wait_next()) { + auto managed = src.fetch_next(); + ASSERT_TRUE(managed.has_value()) + << "PropertyInterfaceParity seed=0x" << std::hex << kSeed << std::dec + << " trial=" << trial << " index=" << fetched.size() + << ": wait_next() returned true so fetch_next() must yield an event"; + fetched.push_back(managed->get_event().get()); + } + + // The served sequence must EXACTLY equal the appended sequence: same size + // and element-wise identity, in order. + ASSERT_EQ(fetched.size(), expected.size()) + << "PropertyInterfaceParity seed=0x" << std::hex << kSeed << std::dec + << " trial=" << trial << ": served count must equal appended count"; + for (std::size_t i = 0; i < expected.size(); ++i) { + ASSERT_EQ(fetched[i], expected[i]) + << "PropertyInterfaceParity seed=0x" << std::hex << kSeed << std::dec + << " trial=" << trial << " index=" << i + << ": served event must match appended event by identity/order"; + } + + // Clean end-of-stream: done, no error. + EXPECT_TRUE(src.is_done()) + << "PropertyInterfaceParity seed=0x" << std::hex << kSeed << std::dec + << " trial=" << trial << ": stream must be done after the last event"; + EXPECT_FALSE(src.is_error()) + << "PropertyInterfaceParity seed=0x" << std::hex << kSeed << std::dec + << " trial=" << trial << ": stream must not be in error"; + } +} + +} // namespace mysql::csa::unittests diff --git a/unittest/gunit/changestreams/imr_event_set_fetchable_spill-t.cc b/unittest/gunit/changestreams/imr_event_set_fetchable_spill-t.cc new file mode 100644 index 000000000000..cd363ab307f8 --- /dev/null +++ b/unittest/gunit/changestreams/imr_event_set_fetchable_spill-t.cc @@ -0,0 +1,500 @@ +/* Copyright (c) 2026, Oracle and/or its affiliates. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License, version 2.0, + as published by the Free Software Foundation. + + This program is designed to work with certain software (including + but not limited to OpenSSL) that is licensed under separate terms, + as designated in a particular file or component or in included license + documentation. The authors of MySQL hereby grant you an additional + permission to link the program and your derivative works with the + separately licensed software that they have either included with + the program or referenced in the documentation. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License, version 2.0, for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +/// @file +/// Unit tests for the WRITE (sink) side of mysql::csa::Event_set_fetchable_spill +/// (tasks.md task 3). The tests drive the Streaming_event_sink surface +/// (append_event / seal_stream / set_stream_truncated) and assert that the +/// published readable end position advances, that sealing is one-way and +/// rejects further appends, and that truncation propagates to the owning +/// Fetchable_transaction and Transaction_envelope. The consumer read-back is a +/// later task and is not exercised here. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sql/basic_ostream.h" // StringBuffer_ostream +#include "sql/changestreams/apply/jobs/fetchable_transaction.h" +#include "sql/changestreams/apply/storage/in_memory/event_set_fetchable_spill.h" +#include "sql/changestreams/apply/storage/in_memory/in_memory_types.h" +#include "sql/changestreams/apply/storage/in_memory/transaction_envelope.h" +#include "sql/log_event.h" // Format_description_log_event + +namespace mysql::csa::unittests { + +namespace fs = std::filesystem; + +namespace { + +/// A fresh relay-log FDE, upcast to the Log_event_ptr the sink constructor +/// takes (matching how the payload factory hands the sink its FDE). +std::shared_ptr make_fde() { + return std::make_shared(); +} + +/// Serialize a real Format_description_log_event into bytes, standing in for +/// one event's raw wire bytes. A default server FDE serializes with checksum +/// OFF and needs no THD. +std::vector serialize_event() { + Format_description_log_event ev; + StringBuffer_ostream<1024> os; + EXPECT_FALSE(ev.write(&os)) << "serializing a stand-in event must succeed"; + const auto *p = reinterpret_cast(os.ptr()); + return std::vector(p, p + os.length()); +} + +const char *as_char(const std::vector &v) { + return reinterpret_cast(v.data()); +} + +/// Serialize a Rotate_log_event carrying a distinct @p pos, standing in for one +/// transaction event whose identity survives the serialize/decode round-trip +/// (so the consumer can assert events come back in order). Checksum OFF matches +/// the spill file's FDE. +std::vector serialize_rotate(unsigned long long pos) { + Rotate_log_event ev("binlog.000001", std::strlen("binlog.000001"), pos, + /*flags=*/0); + ev.common_footer->checksum_alg = + mysql::binlog::event::BINLOG_CHECKSUM_ALG_OFF; + StringBuffer_ostream<1024> os; + EXPECT_FALSE(ev.write(&os)) << "serializing a Rotate event must succeed"; + const auto *p = reinterpret_cast(os.ptr()); + return std::vector(p, p + os.length()); +} + +/// Append one Rotate stand-in event carrying @p pos to the sink. +void append_rotate(Event_set_fetchable_spill &sink, unsigned long long pos, + bool seal_after = false) { + const auto bytes = serialize_rotate(pos); + sink.append_event(as_char(bytes), bytes.size(), seal_after); +} + +/// Drain the consumer surface, returning the pos values of the events fetched +/// in order. Stops when wait_next() reports end-of-stream. +std::vector drain_positions( + Event_set_fetchable_spill &sink) { + std::vector out; + while (sink.wait_next()) { + auto managed = sink.fetch_next(); + if (!managed.has_value()) break; + auto *rot = dynamic_cast(managed->get_event().get()); + EXPECT_NE(rot, nullptr) << "streamed-back event must decode to a Rotate"; + if (rot != nullptr) out.push_back(rot->pos); + } + return out; +} + +/// Short bounded wait used to observe that a consumer is still parked. +constexpr std::chrono::milliseconds kShortWait{50}; +/// Generous upper bound for a "must make progress / unblock" observation. +constexpr std::chrono::seconds kLongWait{5}; + +/// The relay-log identity a CRC32 Rotate stand-in event carries; its exact +/// length is what proves the reader stripped the 4-byte CRC32 trailer (a +/// checksum-OFF FDE would leave those 4 bytes glued onto new_log_ident). +const char *const kRotateIdent = "binlog.000123"; + +/// A fresh FDE advertising CRC32, matching a source running +/// binlog_checksum=CRC32. The spill writer preserves this algorithm in the +/// file's FDE so the reader strips the per-event checksum trailer. +std::shared_ptr make_fde_crc32() { + auto fde = std::make_shared(); + fde->common_footer->checksum_alg = + mysql::binlog::event::BINLOG_CHECKSUM_ALG_CRC32; + return fde; +} + +/// Serialize a Rotate_log_event WITH a CRC32 checksum trailer (as a CRC32 +/// source would send it), carrying kRotateIdent and a distinct @p pos. +std::vector serialize_rotate_crc32(unsigned long long pos) { + Rotate_log_event ev(kRotateIdent, std::strlen(kRotateIdent), pos, + /*flags=*/0); + ev.common_footer->checksum_alg = + mysql::binlog::event::BINLOG_CHECKSUM_ALG_CRC32; + StringBuffer_ostream<1024> os; + EXPECT_FALSE(ev.write(&os)) << "serializing a CRC32 Rotate event must succeed"; + const auto *p = reinterpret_cast(os.ptr()); + return std::vector(p, p + os.length()); +} + +} // namespace + +/// Fixture that gives each test a private, empty "relay log directory" and +/// tears the whole tree down afterwards (spill files + temp-files subdir). +class ImrSpillSinkTest : public ::testing::Test { + protected: + void SetUp() override { + static std::atomic counter{0}; + m_relay_log_dir = + (fs::temp_directory_path() / + ("imr_spillsink_" + std::to_string(::getpid()) + "_" + + std::to_string(counter.fetch_add(1)))) + .string(); + fs::create_directories(m_relay_log_dir); + } + + void TearDown() override { + std::error_code ec; + fs::remove_all(m_relay_log_dir, ec); + } + + std::string m_relay_log_dir; +}; + +// --------------------------------------------------------------------------- +// Construction: opens the file and publishes the prefix as the initial +// readable watermark. +// --------------------------------------------------------------------------- + +TEST_F(ImrSpillSinkTest, ConstructionOpensFileAndPublishesPrefix) { + Event_set_fetchable_spill sink(/*is_trx=*/true, make_fde(), m_relay_log_dir, + /*owner_envelope=*/nullptr, + /*streaming_open=*/true); + + EXPECT_FALSE(sink.is_error()) << sink.get_error_str(); + EXPECT_FALSE(sink.spill_file_name().empty()); + EXPECT_TRUE(fs::exists(sink.spill_file_name())); + EXPECT_TRUE(sink.is_trx()); + EXPECT_NE(sink.get_fde(), nullptr); + + // Streaming-open: not sealed, not truncated, and the published watermark is + // the (non-empty) relay-log prefix. + EXPECT_FALSE(sink.is_sealed()); + EXPECT_FALSE(sink.is_stream_truncated()); + EXPECT_GT(sink.published_end_position(), static_cast(4)); +} + +// --------------------------------------------------------------------------- +// append_event advances the published position by the appended length, and +// sealing on the terminal event is one-way (further appends are rejected). +// --------------------------------------------------------------------------- + +TEST_F(ImrSpillSinkTest, AppendAdvancesPublishedPositionAndSealsOnce) { + Event_set_fetchable_spill sink(/*is_trx=*/true, make_fde(), m_relay_log_dir, + /*owner_envelope=*/nullptr, + /*streaming_open=*/true); + ASSERT_FALSE(sink.is_error()) << sink.get_error_str(); + + constexpr int kEventCount = 4; + std::size_t prev = sink.published_end_position(); + for (int i = 0; i < kEventCount; ++i) { + const auto ev = serialize_event(); + const bool last = (i == kEventCount - 1); + sink.append_event(as_char(ev), ev.size(), /*seal_after=*/last); + + ASSERT_FALSE(sink.is_error()) << sink.get_error_str(); + EXPECT_EQ(sink.published_end_position(), prev + ev.size()); + EXPECT_GT(sink.published_end_position(), prev); + prev = sink.published_end_position(); + } + + // The last append sealed the stream. + EXPECT_TRUE(sink.is_sealed()); + EXPECT_FALSE(sink.is_stream_truncated()); + + // A post-seal append is a silent no-op: the position does not move. + const auto extra = serialize_event(); + sink.append_event(as_char(extra), extra.size()); + EXPECT_EQ(sink.published_end_position(), prev); +} + +// --------------------------------------------------------------------------- +// seal_stream() seals independently of an append, and appends after it are +// rejected. +// --------------------------------------------------------------------------- + +TEST_F(ImrSpillSinkTest, SealStreamRejectsFurtherAppends) { + Event_set_fetchable_spill sink(/*is_trx=*/true, make_fde(), m_relay_log_dir, + /*owner_envelope=*/nullptr, + /*streaming_open=*/true); + ASSERT_FALSE(sink.is_error()) << sink.get_error_str(); + + const auto ev = serialize_event(); + sink.append_event(as_char(ev), ev.size()); + const std::size_t after_one = sink.published_end_position(); + + sink.seal_stream(); + EXPECT_TRUE(sink.is_sealed()); + + const auto extra = serialize_event(); + sink.append_event(as_char(extra), extra.size()); + EXPECT_EQ(sink.published_end_position(), after_one) + << "append after seal must not advance the position"; +} + +// --------------------------------------------------------------------------- +// set_stream_truncated() marks the batch truncated AND propagates to the owning +// Fetchable_transaction and Transaction_envelope. +// --------------------------------------------------------------------------- + +TEST_F(ImrSpillSinkTest, TruncatePropagatesToOwners) { + Transaction_envelope env(/*stream_seqno=*/1, /*trx_length=*/128, + Envelope_path::SPILL); + Fetchable_transaction ft; + + Event_set_fetchable_spill sink(/*is_trx=*/true, make_fde(), m_relay_log_dir, + /*owner_envelope=*/&env, + /*streaming_open=*/true); + ASSERT_FALSE(sink.is_error()) << sink.get_error_str(); + sink.set_owning_fetchable(&ft); + + // Append one event, then truncate mid-stream. + const auto ev = serialize_event(); + sink.append_event(as_char(ev), ev.size()); + ASSERT_FALSE(ft.is_truncated()); + ASSERT_FALSE(env.is_truncated()); + + sink.set_stream_truncated(); + + EXPECT_TRUE(sink.is_stream_truncated()); + EXPECT_TRUE(sink.is_sealed()); // truncate also seals + EXPECT_TRUE(ft.is_truncated()) << "truncation must reach the transaction"; + EXPECT_TRUE(env.is_truncated()) << "truncation must reach the envelope"; + + // Appends after truncation are rejected. + const std::size_t pos = sink.published_end_position(); + const auto extra = serialize_event(); + sink.append_event(as_char(extra), extra.size()); + EXPECT_EQ(sink.published_end_position(), pos); +} + +// A truncate with no wired owners is safe (nullptr owners are simply skipped). +TEST_F(ImrSpillSinkTest, TruncateWithoutOwnersIsSafe) { + Event_set_fetchable_spill sink(/*is_trx=*/false, make_fde(), m_relay_log_dir, + /*owner_envelope=*/nullptr, + /*streaming_open=*/true); + ASSERT_FALSE(sink.is_error()) << sink.get_error_str(); + + sink.set_stream_truncated(); + EXPECT_TRUE(sink.is_stream_truncated()); + EXPECT_TRUE(sink.is_sealed()); + EXPECT_FALSE(sink.is_trx()); +} + +// --------------------------------------------------------------------------- +// READ (consumer) side: stream events back through the Event_set_fetchable +// surface (wait_next / fetch_next) over the spill file. +// --------------------------------------------------------------------------- + +// A sequence written by the sink and sealed on the last event is streamed back +// through the consumer interface, decoding to the same events in order, then +// the consumer terminates cleanly (is_done, not error). +TEST_F(ImrSpillSinkTest, StreamsBackEventsInOrder) { + Event_set_fetchable_spill sink(/*is_trx=*/true, make_fde(), m_relay_log_dir, + /*owner_envelope=*/nullptr, + /*streaming_open=*/true); + ASSERT_FALSE(sink.is_error()) << sink.get_error_str(); + + constexpr int kEventCount = 6; + std::vector expected; + for (int i = 0; i < kEventCount; ++i) { + const unsigned long long pos = 100 + i; + expected.push_back(pos); + append_rotate(sink, pos, /*seal_after=*/(i == kEventCount - 1)); + ASSERT_FALSE(sink.is_error()) << sink.get_error_str(); + } + + const auto got = drain_positions(sink); + EXPECT_EQ(got, expected); + EXPECT_TRUE(sink.is_done()); + EXPECT_FALSE(sink.is_error()) << sink.get_error_str(); + + // Fully drained: further consumer calls are terminal no-ops. + EXPECT_FALSE(sink.wait_next()); + EXPECT_FALSE(sink.fetch_next().has_value()); +} + +// Regression test for the checksum-algorithm mismatch: a CRC32 source sends +// events with a trailing 4-byte CRC32, and the spill file's FDE (preserved from +// the receiver's FDE) advertises CRC32, so the consumer must strip that trailer +// before decoding. Proven by the decoded Rotate's new_log_ident being EXACTLY +// kRotateIdent: under the previous "force checksum OFF" writer the reader would +// not strip the 4 CRC bytes, leaving them glued onto the identity (4 bytes +// longer) and corrupting every event. +TEST_F(ImrSpillSinkTest, Crc32EventsRoundTripWithMatchingChecksumFde) { + Event_set_fetchable_spill sink(/*is_trx=*/true, make_fde_crc32(), + m_relay_log_dir, /*owner_envelope=*/nullptr, + /*streaming_open=*/true); + ASSERT_FALSE(sink.is_error()) << sink.get_error_str(); + + constexpr int kEventCount = 4; + std::vector expected_pos; + for (int i = 0; i < kEventCount; ++i) { + const unsigned long long pos = 500 + i; + expected_pos.push_back(pos); + const auto bytes = serialize_rotate_crc32(pos); + sink.append_event(as_char(bytes), bytes.size(), + /*seal_after=*/(i == kEventCount - 1)); + ASSERT_FALSE(sink.is_error()) << sink.get_error_str(); + } + + const std::size_t expected_ident_len = std::strlen(kRotateIdent); + std::vector got_pos; + while (sink.wait_next()) { + auto managed = sink.fetch_next(); + ASSERT_TRUE(managed.has_value()); + auto *rot = dynamic_cast(managed->get_event().get()); + ASSERT_NE(rot, nullptr); + got_pos.push_back(rot->pos); + // The decisive assertion: the CRC32 trailer was stripped, so the decoded + // identity is exactly kRotateIdent (not 4 bytes longer). + EXPECT_EQ(rot->ident_len, expected_ident_len); + EXPECT_EQ(std::string(rot->new_log_ident, rot->ident_len), + std::string(kRotateIdent)); + } + EXPECT_EQ(got_pos, expected_pos); + EXPECT_TRUE(sink.is_done()); + EXPECT_FALSE(sink.is_error()) << sink.get_error_str(); +} + +// A mid-stream wait_next() blocks until the writer publishes more bytes, then +// unblocks and delivers them (streaming read-back, not seal-gated). +TEST_F(ImrSpillSinkTest, WaitBlocksUntilPublishedThenUnblocks) { + Event_set_fetchable_spill sink(/*is_trx=*/true, make_fde(), m_relay_log_dir, + /*owner_envelope=*/nullptr, + /*streaming_open=*/true); + ASSERT_FALSE(sink.is_error()) << sink.get_error_str(); + + // Publish only the first event (do not seal): the consumer can read it, then + // must block waiting for more. + append_rotate(sink, 1); + + std::atomic fetched{0}; + std::promise> done; + auto future = done.get_future(); + std::thread consumer([&] { + std::vector out; + while (sink.wait_next()) { + auto managed = sink.fetch_next(); + if (!managed.has_value()) break; + auto *rot = dynamic_cast(managed->get_event().get()); + if (rot != nullptr) out.push_back(rot->pos); + fetched.fetch_add(1); + } + done.set_value(std::move(out)); + }); + + // The consumer reads event 1 then parks in wait_next (stream not sealed). + const auto deadline = std::chrono::steady_clock::now() + kLongWait; + while (fetched.load() < 1 && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + ASSERT_EQ(fetched.load(), 1); + // Still blocked: it has not finished draining (no seal yet). + EXPECT_EQ(future.wait_for(kShortWait), std::future_status::timeout) + << "consumer must block until more bytes are published"; + + // Publish the terminal event: the parked consumer must wake and finish. + append_rotate(sink, 2, /*seal_after=*/true); + ASSERT_EQ(future.wait_for(kLongWait), std::future_status::ready) + << "consumer must unblock once more bytes are published"; + const auto out = future.get(); + consumer.join(); + + EXPECT_EQ(out, (std::vector{1, 2})); + EXPECT_TRUE(sink.is_done()); + EXPECT_FALSE(sink.is_error()) << sink.get_error_str(); +} + +// Truncation mid-stream: the consumer drains the events published so far, then +// stops (done, not error), and the truncation reaches the owning transaction +// and envelope so the worker can roll back. +TEST_F(ImrSpillSinkTest, TruncateMidStreamStopsAfterAvailable) { + Transaction_envelope env(/*stream_seqno=*/1, /*trx_length=*/128, + Envelope_path::SPILL); + Fetchable_transaction ft; + + Event_set_fetchable_spill sink(/*is_trx=*/true, make_fde(), m_relay_log_dir, + /*owner_envelope=*/&env, + /*streaming_open=*/true); + ASSERT_FALSE(sink.is_error()) << sink.get_error_str(); + sink.set_owning_fetchable(&ft); + + // Two events published but the stream is never sealed; instead it truncates. + append_rotate(sink, 11); + append_rotate(sink, 22); + sink.set_stream_truncated(); + + const auto got = drain_positions(sink); + EXPECT_EQ(got, (std::vector{11, 22})) + << "consumer must surface the events received before truncation"; + EXPECT_TRUE(sink.is_done()); + EXPECT_FALSE(sink.is_error()) << sink.get_error_str(); + EXPECT_TRUE(ft.is_truncated()); + EXPECT_TRUE(env.is_truncated()); +} + +// A consumer parked in wait_next() with nothing published is unblocked by a +// truncation and reports end-of-stream having read nothing. +TEST_F(ImrSpillSinkTest, TruncateUnblocksParkedConsumer) { + Transaction_envelope env(/*stream_seqno=*/1, /*trx_length=*/128, + Envelope_path::SPILL); + Fetchable_transaction ft; + + Event_set_fetchable_spill sink(/*is_trx=*/true, make_fde(), m_relay_log_dir, + /*owner_envelope=*/&env, + /*streaming_open=*/true); + ASSERT_FALSE(sink.is_error()) << sink.get_error_str(); + sink.set_owning_fetchable(&ft); + + std::promise done; + auto future = done.get_future(); + std::thread consumer([&] { + std::size_t n = 0; + while (sink.wait_next()) { + auto managed = sink.fetch_next(); + if (!managed.has_value()) break; + ++n; + } + done.set_value(n); + }); + + // Nothing published beyond the prefix: the consumer parks in wait_next. + EXPECT_EQ(future.wait_for(kShortWait), std::future_status::timeout) + << "consumer must park until sealed/truncated"; + + sink.set_stream_truncated(); + ASSERT_EQ(future.wait_for(kLongWait), std::future_status::ready) + << "truncation must wake a parked consumer"; + const std::size_t n = future.get(); + consumer.join(); + + EXPECT_EQ(n, static_cast(0)); + EXPECT_TRUE(sink.is_stream_truncated()); + EXPECT_TRUE(ft.is_truncated()); + EXPECT_TRUE(env.is_truncated()); + EXPECT_FALSE(sink.is_error()) << sink.get_error_str(); +} + +} // namespace mysql::csa::unittests diff --git a/unittest/gunit/changestreams/imr_integration-t.cc b/unittest/gunit/changestreams/imr_integration-t.cc new file mode 100644 index 000000000000..8e7d7e75ea49 --- /dev/null +++ b/unittest/gunit/changestreams/imr_integration-t.cc @@ -0,0 +1,1092 @@ +/* Copyright (c) 2026, Oracle and/or its affiliates. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License, version 2.0, + as published by the Free Software Foundation. + + This program is designed to work with certain software (including + but not limited to OpenSSL) that is licensed under separate terms, + as designated in a particular file or component or in included license + documentation. The authors of MySQL hereby grant you an additional + permission to link the program and your derivative works with the + separately licensed software that they have either included with + the program or referenced in the documentation. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License, version 2.0, for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +/// @file +/// End-to-end in-memory harness test wiring the five core in-memory-relaylog +/// classes together: Trx_envelope_queue, Transaction_envelope, Trx_payload, +/// Event_set_fetchable_memory, and Queued_transaction_reader. +/// +/// The flow mirrors the runtime memory path: admit + enqueue an envelope, build +/// a single-batch Event_set_fetchable_memory byte source owned by a +/// Fetchable_transaction, wrap that in a Trx_payload attached to the envelope, +/// stream a few events into the byte source and seal the byte stream (the +/// single authoritative "fully received" signal), dispatch the fully-received +/// transaction through the real Queued_transaction_reader::read() path (which +/// builds a Job_applier), drive the consumer surface to completion, then fire +/// the commit hook and sweep the committed head — asserting the reserved bytes +/// are fully released (bytes_used() == 0) and the queue drains. +/// +/// The transaction is fully received (streamed + sealed) BEFORE it is +/// dispatched: building the Job_applier constructs a Job_binlog, whose ctor +/// (via restart_internal) peeks the transaction's first event with wait_next(). +/// In this single-threaded harness, that peek would block forever if the stream +/// were still empty and unsealed, so reception must complete before read(). The +/// concurrent "dispatch while still streaming" case is a worker-thread scenario +/// exercised by the 7.2.x byte-source blocking tests. +/// +/// As in imr_event_set_fetchable_memory-t.cc, the byte source stores *encoded* +/// events as IReader_event entries and decodes them lazily inside fetch_next(). +/// The tests therefore feed a fake IReader_event whose decode() hands back a +/// known, non-TPLE Log_event (a Format_description_log_event), keeping the +/// transaction-payload decompression path out of the harness. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "mysql/scheduler/statistics_map.h" +#include "sql/basic_ostream.h" // StringBuffer_ostream +#include "sql/changestreams/apply/jobs/fetchable_transaction.h" +#include "sql/changestreams/apply/jobs/job.h" +#include "sql/changestreams/apply/resource/statistics_map.h" +#include "sql/changestreams/apply/storage/common/event_set_fetchable.h" +#include "sql/changestreams/apply/storage/in_memory/event_set_fetchable_memory.h" +#include "sql/changestreams/apply/storage/in_memory/event_set_fetchable_spill.h" +#include "sql/changestreams/apply/storage/in_memory/in_memory_types.h" +#include "sql/changestreams/apply/storage/in_memory/queued_transaction_reader.h" +#include "sql/changestreams/apply/storage/in_memory/queued_transaction_writer.h" +#include "sql/changestreams/apply/storage/in_memory/transaction_envelope.h" +#include "sql/changestreams/apply/storage/in_memory/trx_envelope_queue.h" +#include "sql/changestreams/apply/storage/in_memory/trx_payload.h" +#include "sql/changestreams/apply/storage/relay_log/ireader_event.h" +#include "sql/log_event.h" + +namespace mysql::csa::unittests { + +namespace { + +// Generous per-channel bounds so enqueue() always takes the MEMORY path and +// never blocks in acquire_admission(). kTrxLength must be at or below the spill +// threshold so classify() picks MEMORY. +constexpr std::size_t kMemoryLimit = 1u << 20; // 1 MiB +constexpr std::size_t kSpillThreshold = 1u << 16; // 64 KiB +constexpr std::size_t kTrxLength = 4096; + +/// A fake encoded event: it hands back a preset, already-decoded Log_event when +/// the byte source asks it to decode(). This lets the test control exactly +/// which Log_event object fetch_next() yields and assert identity/order. +class Fake_reader_event : public IReader_event { + public: + explicit Fake_reader_event(std::shared_ptr decoded) + : m_decoded(std::move(decoded)) {} + + std::shared_ptr decode() override { return m_decoded; } + + // The byte source calls reset() only when re-reading with reset_events=true; + // this harness never does, so a no-op is sufficient. + void reset(const Format_description_log_event *) override {} + + private: + std::shared_ptr m_decoded; +}; + +/// Build a Format_description_log_event as its precise type. enqueue() takes +/// the exact std::shared_ptr (matching +/// Master_info::get_mi_description_event_shared()), so it is handed directly +/// with no upcast; passing it to the Event_set_fetchable_memory ctor, whose FDE +/// parameter is the base Log_event_ptr, is a well-formed implicit upcast. +std::shared_ptr make_fde() { + return std::make_shared(); +} + +/// A body event served by the stream: another FDE object (distinct instance), +/// so object identity is meaningful. +std::shared_ptr make_event() { + return std::make_shared(); +} + +/// Wrap a decoded Log_event into a fake encoded IReader_event entry. +IReader_event_ptr make_fake(std::shared_ptr decoded) { + return std::make_shared(std::move(decoded)); +} + +/// Serialize a real Format_description_log_event into a byte buffer, mimicking +/// the transient network bytes the receiver hands to +/// append_transaction_event(). A default server FDE serializes with checksum +/// OFF and is self-describing on decode, so the round-trip through the sink's +/// copy + Cached_event_memory:: decode() succeeds without a THD or checksum +/// plumbing (mirrors the helper in imr_queued_transaction_writer-t.cc). +std::vector serialize_event() { + Format_description_log_event ev; + StringBuffer_ostream<1024> os; + EXPECT_FALSE(ev.write(&os)) << "serializing the FDE must succeed"; + const auto *p = reinterpret_cast(os.ptr()); + return std::vector(p, p + os.length()); +} + +const char *as_char(const std::vector &v) { + return reinterpret_cast(v.data()); +} + +/// A transaction length strictly above the spill threshold, so classify() +/// routes it to the SPILL path. +constexpr std::size_t kSpillTrxLength = kSpillThreshold + 1; + +/// RAII private "relay log directory" for the spill-path integration tests; +/// removed on scope exit (with the spill files and temp-files subdir under it). +struct Scoped_temp_dir { + std::string path; + Scoped_temp_dir() { + static std::atomic counter{0}; + path = (std::filesystem::temp_directory_path() / + ("imr_integ_" + std::to_string(::getpid()) + "_" + + std::to_string(counter.fetch_add(1)))) + .string(); + std::filesystem::create_directories(path); + } + ~Scoped_temp_dir() { + std::error_code ec; + std::filesystem::remove_all(path, ec); + } +}; + +} // namespace + +namespace fs = std::filesystem; + +/// @brief End-to-end fixture wiring the five in-memory-relaylog classes. +/// +/// Declared a friend of Queued_transaction_reader so make_reader() may build a +/// reader through the private queue-only test constructor (m_rli/m_channel stay +/// null; read() still runs its real code path). SetUp() initializes the +/// instance-0 statistics maps because constructing the reader binds its monitor +/// references via get(0). +class Imr_integration_test : public ::testing::Test { + protected: + void SetUp() override { + // Statistics_monitor::get(0) / Resource_monitor::get(0) — bound by the + // reader's monitor reference members — need the instance-0 statistics maps + // initialized first (mirrors imr_queued_transaction_reader-t.cc). + std::ignore = scheduler::Statistics_map::init_statistics(0); + std::ignore = csa::Statistics_map::init_statistics(0, 1, false); + } + + // Builds a reader via the private queue-only constructor. Legal here because + // this fixture is a friend of Queued_transaction_reader. + std::unique_ptr make_reader( + Trx_envelope_queue *queue) { + return std::unique_ptr( + new Queued_transaction_reader(queue)); + } +}; + +// 9.1 — Wire all five classes into one end-to-end memory-path flow and assert +// the transaction is dispatched, committed via the byte-source commit hook, and +// swept, with the reserved bytes fully released. +// Requirements 1.1, 1.2, 2.2, 2.3, 6.1, 6.2, 6.3, 7.4, 8.1, 8.2, 9.2, 9.5 +TEST_F(Imr_integration_test, EndToEndMemoryPathDispatchCommitSweep) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + // 1) Admit + enqueue an uncommitted memory-path envelope. enqueue() now + // creates the EMPTY single-batch byte source, wraps it in a + // Fetchable_transaction (append_batch + set_fetching_complete), and + // attaches the payload — reserving exactly kTrxLength bytes — all before + // the envelope becomes observable. So the destination and its sink already + // exist on return. + Transaction_envelope *env = queue.enqueue(kTrxLength, true, make_fde()); + ASSERT_NE(env, nullptr); + EXPECT_EQ(env->path(), Envelope_path::MEMORY); + EXPECT_FALSE(env->is_committed()); + // The payload was attached at enqueue, so kTrxLength bytes are reserved. + EXPECT_EQ(queue.bytes_used(), kTrxLength); + + // 2) The enqueue-created destination's sink is reachable via the envelope. + Streaming_event_sink *sink = env->current_sink(); + ASSERT_NE(sink, nullptr); + + // 3) Stream a few events into the already-attached destination via the sink, + // then seal the byte stream. Reception is sealed on the SINK (byte source) + // — the single source of truth for "fully received". No + // set_fetching_complete() here: create_memory() already sealed the + // (single-batch) metadata stream. + // + // This MUST happen before dispatch (step 4). Building the Job_applier in + // read() constructs a Job_binlog whose ctor (Job_binlog::restart_internal) + // peeks the transaction's first event via m_fetch_metadata->wait_next() + + // fetch_next() when is_trx() is true. In this single-threaded harness, if + // the stream were still empty and unsealed at that point, wait_next() would + // block forever on the stream CV and the Job ctor would deadlock. Streaming + // the events and sealing the stream first guarantees wait_next() has data + // to return. The concurrent "dispatch while still streaming" case is a + // worker-thread scenario covered by the 7.2.x byte-source blocking tests. + constexpr int kEventCount = 3; + std::vector expected; + expected.reserve(kEventCount); + for (int i = 0; i < kEventCount; ++i) { + auto decoded = make_event(); + expected.push_back(decoded.get()); + // Inject via the event-oriented seam so fetch_next() yields these exact + // objects (the byte-oriented append_event decodes fresh events). + static_cast(sink)->append_reader_event( + make_fake(decoded)); + } + sink->seal_stream(); + + // Grab a shared_ptr copy of the enqueue-created Fetchable_transaction to + // drive the consumer surface and fire the commit hook. It is created and + // sealed by create_memory(), so it is non-null and already trx-typed. + ASSERT_NE(env->payload(), nullptr); + auto fetchable = env->payload()->fetchable(); + ASSERT_NE(fetchable, nullptr); + ASSERT_TRUE(fetchable->is_trx()); + + // 4) Dispatch the fully-received transaction through the REAL reader path. + // read() copies the dispatched Fetchable_transaction and builds a + // Job_applier (whose Job_binlog ctor peeks the first event — safe now that + // the stream is sealed). read() performs no further decoding. + auto reader = make_reader(&queue); + EXPECT_EQ(queue.dispatch_seqno(), 0u); + Job_ptr job = reader->read(); + ASSERT_NE(job, nullptr); + EXPECT_EQ(queue.dispatch_seqno(), 1u); + + // 5) Drive the consumer surface to completion through the dispatched + // Fetchable_transaction: the events flow back in append order, by object + // identity, followed by a clean end-of-stream. + std::vector fetched; + fetched.reserve(kEventCount); + while (fetchable->wait_next()) { + auto managed = fetchable->fetch_next(); + ASSERT_TRUE(managed.has_value()) + << "wait_next() returned true so fetch_next() must yield an event"; + fetched.push_back(managed->get_event().get()); + } + EXPECT_EQ(fetched, expected); + EXPECT_TRUE(fetchable->is_fetching_done()); + EXPECT_FALSE(fetchable->is_fetching_error()); + + // 6) Fire the commit hook via the Fetchable_transaction, which fans out to + // the single byte source's set_success() and drives env->commit(): the + // envelope becomes committed, its payload is nulled, and the reserved + // bytes are released back to the queue counter. + fetchable->set_success(); + EXPECT_TRUE(env->is_committed()); + EXPECT_EQ(env->payload(), nullptr); + EXPECT_EQ(queue.bytes_used(), 0u); + + // The Job_applier still holds its own shared_ptr copy of the + // Fetchable_transaction, independent of the now-dropped envelope payload. + EXPECT_GE(fetchable.use_count(), 2); // ours + the job's copy + + // 7) Coordinator sweep: the contiguous committed head is dequeued and + // commit_seqno advances. After the sweep, env dangles — do not touch it. + EXPECT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.commit_seqno(), 1u); + + // The byte counter has returned to zero and the queue is drained, so the + // queue destructor's "empty deque / bytes_used() == 0" invariant holds. + EXPECT_EQ(queue.bytes_used(), 0u); + + // Releasing the job drops its reference; only our local copy remains. + delete job; + EXPECT_EQ(fetchable.use_count(), 1); +} + +// 9.2 — Concurrency property test for out-of-order commit (Property 1 + +// Property 4, concurrent). This mirrors the real single-producer / +// single-consumer architecture: one producer thread streams envelopes into a +// shared Trx_envelope_queue, ONE coordinator thread is the sole consumer of the +// queue (it alone calls dispatch_next(), in FIFO order), and it hands each +// dispatched envelope to a pool of worker threads that COMMIT them out of source +// order. A concurrent sweeper thread advances the committed head prefix. The +// queue is never dispatched from more than one thread; out-of-order commit +// arises from the workers, exactly as in production (the coordinator dispatches +// in order, workers apply/commit in parallel). +// +// This deliberately operates at the queue/envelope/payload layer only: NO +// Queued_transaction_reader, NO Job_applier, and NO Event_set_fetchable_memory +// byte source is attached. Building a Job peeks the transaction's first event +// (which would block an empty stream), and the byte source is irrelevant to the +// memory-accounting / cursor-ordering properties under test. Plain Trx_payloads +// therefore stand in for the heavy byte source: each reserves its bytes at +// attach and releases them on commit() with no seal. +// +// The test is written to be ThreadSanitizer-clean: there is NO fixed sleep used +// for correctness. Threads coordinate exclusively through the queue's own +// blocking calls (dispatch_next() / enqueue()), the worker hand-off CV, atomics, +// and joins; std::this_thread::yield() is used only to avoid a busy spin and is +// never relied upon for ordering. All final assertions hold for ANY interleaving. +// +// Validates: Requirements 10.2 (dispatch/commit ordering), 10.5 (cursor +// invariant under concurrency), 6.5 (bytes released at commit, counter returns +// to 0 when drained). +TEST_F(Imr_integration_test, ConcurrentOutOfOrderCommitSweep) { + constexpr int kEnvelopes = 200; + constexpr int kWorkers = 4; + // Huge memory limit and a large spill threshold so classify() always returns + // MEMORY and enqueue() never blocks in admission — the property under test is + // the dispatch/commit/sweep ordering, not admission back-pressure. + constexpr std::size_t kHugeMemoryLimit = 1u << 30; // 1 GiB + constexpr std::size_t kLargeSpillThreshold = 1u << 20; // 1 MiB + + // Fixed seed so any failure reproduces exactly; echoed once. + constexpr std::uint32_t kSeed = 0xC0FFEE11u; + std::cout << "[ConcurrentOutOfOrderCommitSweep] seed=" << kSeed + << " kEnvelopes=" << kEnvelopes << " kWorkers=" << kWorkers + << std::endl; + + // Precompute the per-envelope byte lengths from the fixed seed so the + // producer thread does no shared RNG work (keeps the test race-free). + std::mt19937 rng(kSeed); + std::uniform_int_distribution len_dist(1, 4096); + std::vector lengths(kEnvelopes); + for (int i = 0; i < kEnvelopes; ++i) lengths[i] = len_dist(rng); + + Trx_envelope_queue queue(kHugeMemoryLimit, kLargeSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + std::atomic committed_count{0}; + + // Producer: enqueue every envelope in source order. enqueue() attaches the + // MEMORY-path payload (reserving its bytes) itself, so no manual attach is + // needed. This test never consumes events off the enqueue-created source; the + // source is simply destroyed at commit. No seal is required; commit() releases + // the bytes. Each enqueue wakes the coordinator blocked in dispatch_next(). + std::thread producer([&]() { + for (int i = 0; i < kEnvelopes; ++i) { + const std::size_t len = lengths[i]; + Transaction_envelope *env = queue.enqueue(len, true, make_fde()); + ASSERT_NE(env, nullptr); + } + }); + + // Worker hand-off: the sole consumer (coordinator) pushes FIFO-dispatched + // envelopes here; the worker pool pops and commits them. A plain + // mutex+CV+deque hand-off keeps the test race-free and TSan-clean. + std::mutex work_mutex; + std::condition_variable work_cv; + std::deque work; + bool dispatch_done = false; + + // Coordinator: the ONLY consumer of the queue. It dispatches all envelopes in + // FIFO order (single-consumer: no other thread calls dispatch_next()) and + // enqueues each onto the worker hand-off. + std::thread coordinator([&]() { + for (int i = 0; i < kEnvelopes; ++i) { + Transaction_envelope *env = queue.dispatch_next(); + ASSERT_NE(env, nullptr); + { + std::lock_guard lock(work_mutex); + work.push_back(env); + } + work_cv.notify_one(); + } + { + std::lock_guard lock(work_mutex); + dispatch_done = true; + } + work_cv.notify_all(); + }); + + // Workers: pop dispatched envelopes and commit them. Because scheduling + // decides which worker commits when, commit order is NOT the dispatch order — + // out-of-order commit arises naturally. Each envelope is handed to exactly one + // worker, so commit() must succeed (returns false). + std::vector workers; + workers.reserve(kWorkers); + for (int w = 0; w < kWorkers; ++w) { + workers.emplace_back([&]() { + for (;;) { + Transaction_envelope *env = nullptr; + { + std::unique_lock lock(work_mutex); + work_cv.wait(lock, [&] { return dispatch_done || !work.empty(); }); + if (work.empty()) break; // dispatch_done and drained: exit. + env = work.front(); + work.pop_front(); + } + EXPECT_FALSE(env->commit()); + committed_count.fetch_add(1, std::memory_order_relaxed); + } + }); + } + + // Sweeper: repeatedly sweep the contiguous committed head prefix until every + // envelope has been swept (commit_seqno == kEnvelopes). The cursor reads are + // atomic. The only cross-cursor checks are the <= orderings, which hold + // regardless of the (non-atomic) skew between the three separate reads. + // yield() avoids a busy spin; it is not relied on for correctness. + std::thread sweeper([&]() { + for (;;) { + EXPECT_FALSE(queue.sweep_committed()); + + const std::uint64_t commit = queue.commit_seqno(); + const std::uint64_t dispatch = queue.dispatch_seqno(); + const std::uint64_t insert = queue.insert_seqno(); + const std::size_t queue_length = queue.queue_length(); + // These orderings hold for ANY interleaving: a swept envelope was + // dispatched, and a dispatched envelope was inserted. The independently + // synchronized length snapshot must remain bounded and can never wrap. + ASSERT_LE(commit, dispatch); + ASSERT_LE(dispatch, insert); + ASSERT_LE(queue_length, static_cast(kEnvelopes)); + + if (commit == static_cast(kEnvelopes)) break; + std::this_thread::yield(); + } + }); + + // Join in dependency order: producer feeds the coordinator, the coordinator + // feeds the workers (and sets dispatch_done), the workers commit, and the + // sweeper drains once every commit lands. No queue.stop() is needed: the + // coordinator dispatches an exact count and returns on its own. + producer.join(); + coordinator.join(); + for (auto &t : workers) t.join(); + sweeper.join(); + + // Final assertions — hold for ANY interleaving now that all threads joined: + // - every envelope was inserted, dispatched, and swept exactly once, so all + // three cursors equal the total (commit_seqno advanced by exactly N means + // each envelope was swept exactly once); + // - the byte counter returned to 0: every payload's bytes were released at + // commit and the queue is fully drained (its destructor's empty-ring / + // bytes_used()==0 invariant therefore holds). + EXPECT_EQ(queue.insert_seqno(), static_cast(kEnvelopes)); + EXPECT_EQ(queue.dispatch_seqno(), static_cast(kEnvelopes)); + EXPECT_EQ(queue.commit_seqno(), static_cast(kEnvelopes)); + EXPECT_EQ(queue.queue_length(), 0u); + EXPECT_EQ(queue.bytes_used(), 0u); + EXPECT_EQ(committed_count.load(), kEnvelopes); +} + +// 9.3 — Concurrency property test for streaming into an OPEN envelope. One +// producer thread and one consumer thread share a single Trx_envelope_queue. +// The producer enqueues an envelope, attaches a Trx_payload wrapping an +// Event_set_fetchable_memory-backed Fetchable_transaction, then streams events +// over time (append_transaction_event) into the still-OPEN (unsealed) stream +// and finally seal_stream(). The consumer dispatch_next()s the envelope and +// drives wait_next()/fetch_next() DIRECTLY on the Fetchable_transaction/stream +// — so it blocks in wait_next() on the OPEN envelope until the producer appends +// events. +// +// This is the streaming case the single-threaded 9.1 harness cannot cover: 9.1 +// must stream + seal BEFORE dispatch because building a Job_applier peeks the +// first event in Job_binlog::restart_internal() and would deadlock on an empty, +// unsealed stream. Here we deliberately stay at the queue + byte-source level +// (like 9.2): NO Queued_transaction_reader and NO Job_applier are built, so the +// consumer can begin waiting on the open stream while the producer is still +// streaming. +// +// ThreadSanitizer-clean: there is NO fixed sleep used for correctness. Ordering +// is enforced purely by the stream's own blocking wait_next() (which blocks +// until an append/seal/truncation), one acquire/release atomic that publishes +// the attached payload to the consumer, and the thread joins. +// std::this_thread::yield() appears only to avoid a busy spin while waiting for +// that publish and is never relied upon for ordering. The seed is fixed and +// echoed in every assertion so any failure reproduces exactly. +// +// Property — every appended event is consumed exactly once and in append order +// regardless of producer/consumer interleaving; after commit + sweep +// bytes_used() returns to 0. +// +// Validates: Requirements 6.1, 6.2, 6.3, 6.5, 10.2 +TEST_F(Imr_integration_test, ConcurrentStreamingIntoOpenEnvelope) { + // Generous bounds so enqueue() takes the MEMORY path and never blocks in + // admission — the property under test is streaming/consumption ordering, not + // back-pressure. + constexpr std::size_t kHugeMemoryLimit = 1u << 30; // 1 GiB + constexpr std::size_t kLargeSpillThreshold = 1u << 20; // 1 MiB + + // Fixed seed so any failure reproduces exactly; echoed once and in every + // assertion message. + constexpr std::uint32_t kSeed = 0x57EA311Du; + std::mt19937 rng(kSeed); + std::uniform_int_distribution count_dist(1, 128); + const int kEventCount = count_dist(rng); + std::cout << "[ConcurrentStreamingIntoOpenEnvelope] seed=" << kSeed + << " kEventCount=" << kEventCount << std::endl; + + Trx_envelope_queue queue(kHugeMemoryLimit, kLargeSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + // Pre-generate the events on the main thread and keep the shared_ptrs alive + // for the whole test so the raw pointers stay valid. The expected consumption + // order is exactly this generation order. + std::vector> owned_events; + std::vector expected; + owned_events.reserve(kEventCount); + expected.reserve(kEventCount); + for (int i = 0; i < kEventCount; ++i) { + auto e = make_event(); + expected.push_back(e.get()); + owned_events.push_back(std::move(e)); + } + + // Consumer results, read by the main thread only after join() (which + // establishes happens-before). + std::vector fetched; + std::atomic consumer_saw_committed{false}; + std::atomic bytes_after_commit{~std::size_t{0}}; + std::atomic consumer_fetch_done{false}; + std::atomic consumer_fetch_error{true}; + + // Producer: enqueue an OPEN envelope — enqueue() creates and attaches the + // EMPTY single-batch Event_set_fetchable_memory destination (already + // set_fetching_complete-sealed on the metadata side) and reserves kTrxLength + // bytes, all under the queue mutex before the envelope is observable. Then + // stream events one at a time into the still-open byte stream via the sink and + // finally seal it. No sleeps between appends — the consumer wakes on each + // append via the stream's own CV. + std::thread producer([&]() { + Transaction_envelope *env = queue.enqueue(kTrxLength, true, make_fde()); + ASSERT_NE(env, nullptr); + + // The enqueue-created destination's sink is reachable via the envelope. + Streaming_event_sink *sink = env->current_sink(); + ASSERT_NE(sink, nullptr); + + // Stream events into the OPEN (unsealed) byte stream over time. The + // consumer, blocked in wait_next() on this open stream, wakes on each + // append. + for (int i = 0; i < kEventCount; ++i) { + // Inject via the event-oriented seam to preserve object identity through + // fetch_next() (the byte-oriented append_event decodes fresh events). + static_cast(sink)->append_reader_event( + make_fake(owned_events[i])); + } + // Seal the byte stream — the single authoritative "fully received" signal; + // wait_next() reports end-of-stream after the last event. The (single-batch) + // metadata stream was already sealed by create_memory(), so no + // set_fetching_complete() is needed here for the consumer's wait_next() loop + // to finish. + sink->seal_stream(); + }); + + // Consumer: dispatch the envelope, then drive wait_next()/fetch_next() + // DIRECTLY on the Fetchable_transaction. It blocks in wait_next() on the OPEN + // stream until the producer appends, drains events in order, and terminates + // when wait_next() returns false after seal_stream()+set_fetching_complete(). + std::thread consumer([&]() { + Transaction_envelope *env = queue.dispatch_next(); + ASSERT_NE(env, nullptr); + + // enqueue() attaches the payload under the queue mutex BEFORE the envelope + // becomes observable, so by the time dispatch_next() returns it the payload + // is guaranteed attached (happens-before via the queue mutex). No publish + // gate is needed. + Trx_payload *payload = env->payload(); + ASSERT_NE(payload, nullptr); + // Hold an independent shared_ptr copy so the Fetchable_transaction (and its + // byte source) survives the commit that nulls the envelope's payload. + std::shared_ptr fetchable = payload->fetchable(); + ASSERT_NE(fetchable, nullptr); + + // Drain: this is where the consumer blocks on the OPEN stream and wakes on + // each producer append. Events flow back by object identity in append + // order. + while (fetchable->wait_next()) { + auto managed = fetchable->fetch_next(); + ASSERT_TRUE(managed.has_value()) + << "wait_next() returned true so fetch_next() must yield an event"; + fetched.push_back(managed->get_event().get()); + } + consumer_fetch_done.store(fetchable->is_fetching_done(), + std::memory_order_relaxed); + consumer_fetch_error.store(fetchable->is_fetching_error(), + std::memory_order_relaxed); + + // Commit through the byte-source commit hook: set_success() fans out to the + // single batch, driving env->commit() (mark committed + reset payload, + // releasing the reserved bytes) under only the per-envelope mutex. + fetchable->set_success(); + consumer_saw_committed.store(env->is_committed(), + std::memory_order_relaxed); + bytes_after_commit.store(queue.bytes_used(), std::memory_order_relaxed); + }); + + producer.join(); + consumer.join(); + + // Property: every appended event was consumed exactly once and in append + // order, for this interleaving. + EXPECT_EQ(fetched, expected) + << "seed=" << kSeed << " kEventCount=" << kEventCount; + EXPECT_EQ(fetched.size(), static_cast(kEventCount)) + << "seed=" << kSeed; + EXPECT_TRUE(consumer_fetch_done.load()) + << "seed=" << kSeed << " (stream must reach clean end-of-stream)"; + EXPECT_FALSE(consumer_fetch_error.load()) << "seed=" << kSeed; + + // The commit hook committed the envelope and released the reserved bytes. + EXPECT_TRUE(consumer_saw_committed.load()) << "seed=" << kSeed; + EXPECT_EQ(bytes_after_commit.load(), 0u) << "seed=" << kSeed; + + // Coordinator sweep drains the committed head; bytes stay at 0 and the queue + // empties (all three cursors reach 1). After the sweep env dangles — do not + // touch it. + EXPECT_FALSE(queue.sweep_committed()) << "seed=" << kSeed; + EXPECT_EQ(queue.commit_seqno(), 1u) << "seed=" << kSeed; + EXPECT_EQ(queue.dispatch_seqno(), 1u) << "seed=" << kSeed; + EXPECT_EQ(queue.insert_seqno(), 1u) << "seed=" << kSeed; + EXPECT_EQ(queue.bytes_used(), 0u) << "seed=" << kSeed; +} + +// 10.1 — Wire the receiver -> queue -> coordinator path end-to-end through the +// actual receiver-hook mechanism the Master_info adapters wrap. +// +// FIDELITY / WHY A MIRROR: at runtime the receiver drives these steps through +// the file-static imr_on_gtid_event / imr_on_body_event / imr_on_truncate +// adapters in rpl_replica.cc, which are one-line wrappers that call exactly the +// open_transaction / append_transaction_event / truncate_transaction free +// functions below +// on mi->m_trx_queue and mi->m_current_sink. A real Master_info cannot be built +// in a gunit process (its constructor is private to Rpl_info_factory, and its +// destructor asserts the global channel_map write lock), and the provider's +// in-memory reader ctor dereferences rli->mi->get_channel() at construction. So +// this test mirrors the two Master_info fields with a local queue and a local +// Streaming_event_sink *current_sink and drives the SAME writer functions the +// adapters wrap. The accessor is_in_memory_relaylog() is exactly +// (m_trx_queue != nullptr); that trivial getter and the full Master_info + +// Sync_transaction_provider path are exercised by MTR integration. The +// current_sink lifecycle asserted here (null outside a group; the resolved sink +// between the GTID event and the terminal/truncate) is the m_current_sink +// contract from former task 2.2. +// Requirements 2.1, 2.2, 2.4, 3.1, 4.1, 4.5, 6.1, 6.3, 7.1, 7.5 +TEST_F(Imr_integration_test, ReceiverHooksToCoordinatorCommitSweep) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + // Mirror of mi->m_current_sink: null outside an open transaction group. + Streaming_event_sink *current_sink = nullptr; + ASSERT_EQ(current_sink, nullptr) << "m_current_sink is null outside a group"; + + // --- GTID event -> open_transaction (imr_on_gtid_event) --- + // Admits the transaction into the queue (reserving kTrxLength bytes) and + // publishes the enqueue-created destination's sink through current_sink. + ASSERT_FALSE(open_transaction(queue, current_sink, make_fde(), kTrxLength, + /*is_trx=*/true)); + ASSERT_NE(current_sink, nullptr) + << "open publishes the resolved sink (mi->m_current_sink)"; + EXPECT_EQ(queue.bytes_used(), kTrxLength); + // The byte source lives in the envelope's payload and outlives the terminal + // event that clears current_sink; keep a handle to fire the commit hook after + // the coordinator dispatches (see below). + Streaming_event_sink *byte_source = current_sink; + + // --- body + terminal events -> append_transaction_event (imr_on_body_event) + // --- Real serialized FDE bytes so the sink's copy + lazy-decode path runs + // for real. The terminal append seals the stream (the single authoritative + // "fully received" signal) and clears current_sink. + const std::vector bytes = serialize_event(); + ASSERT_FALSE(append_transaction_event(current_sink, as_char(bytes), + bytes.size(), /*is_terminal=*/false)); + ASSERT_NE(current_sink, nullptr) << "a non-terminal append keeps the group open"; + ASSERT_FALSE(append_transaction_event(current_sink, as_char(bytes), + bytes.size(), /*is_terminal=*/true)); + EXPECT_EQ(current_sink, nullptr) + << "the terminal event seals the group and clears mi->m_current_sink"; + + // --- coordinator iteration -> reader.read() (internally sweep_and_dispatch) --- + // read() sweeps the (empty) committed head, dispatches the fully-received + // transaction, and builds a Job_applier (whose Job_binlog ctor peeks the first + // event -- safe now that the stream is sealed). + auto reader = make_reader(&queue); + EXPECT_EQ(queue.dispatch_seqno(), 0u); + Job_ptr job = reader->read(); + ASSERT_NE(job, nullptr) << "read() dispatches the fully-received transaction"; + EXPECT_EQ(queue.dispatch_seqno(), 1u); + EXPECT_EQ(queue.commit_seqno(), 0u) << "not committed until the worker succeeds"; + + // --- worker set_success commit hook --- + // In production a worker drives Job::set_success() once the job reaches the + // 'done' phase, which (single memory batch) fans out to + // Event_set_fetchable_memory::set_success() -> Transaction_envelope::commit(). + // Running a job to the 'done' phase needs a live worker/session, so the test + // fires the terminal link of that exact chain -- the byte source's commit hook + // -- directly. The Job built above holds a shared reference to the same + // Fetchable_transaction, so the byte source is alive here. + static_cast(byte_source)->set_success(); + EXPECT_EQ(queue.bytes_used(), 0u) + << "commit resets the payload, releasing the reserved bytes"; + + // --- coordinator sweep --- + // The contiguous committed head is dequeued and commit_seqno advances. After + // the sweep the envelope dangles -- do not touch it. + EXPECT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.commit_seqno(), 1u) << "the committed head is swept"; + EXPECT_EQ(queue.bytes_used(), 0u); + + // Queue is empty with bytes_used()==0, so the destructor invariant holds. + delete job; +} + +// 10.1 (truncation) — a transaction cut short mid-stream is truncated (never +// sealed cleanly), so a worker rolls it back and never fires the success hook; +// the envelope is therefore never committed. Truncated is a terminal state, so +// the committed-head sweep reclaims it exactly like a committed one — advancing +// the commit low-water mark and releasing its reserved bytes at sweep — letting +// the applier keep progressing without a full-stop reset() (the same behavior +// asserted directly in imr_queue_lifecycle-t's +// SweepReclaimsDispatchedTruncatedHead). Drives the real truncate_transaction +// receiver hook. Requirements 2.4, 4.1, 6.3 +TEST_F(Imr_integration_test, ReceiverHookTruncatedTransactionNeverCommits) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + Streaming_event_sink *current_sink = nullptr; + + // GTID event -> open_transaction. + ASSERT_FALSE(open_transaction(queue, current_sink, make_fde(), kTrxLength, + /*is_trx=*/true)); + ASSERT_NE(current_sink, nullptr); + EXPECT_EQ(queue.bytes_used(), kTrxLength); + + // A partial body event arrives, then the group is cut short (rotate / error / + // stop mid-transaction) -> truncate_transaction (imr_on_truncate): mark the + // stream truncated and clear mi->m_current_sink. No commit is performed. + const std::vector bytes = serialize_event(); + ASSERT_FALSE(append_transaction_event(current_sink, as_char(bytes), + bytes.size(), /*is_terminal=*/false)); + truncate_transaction(current_sink); + EXPECT_EQ(current_sink, nullptr) << "truncation clears mi->m_current_sink"; + + // The coordinator still dispatches the envelope (Req 4.4). A worker applying + // a truncated stream rolls back and never calls set_success(), so the + // envelope is never committed. + Transaction_envelope *env = queue.dispatch_next(); + ASSERT_NE(env, nullptr); + EXPECT_EQ(queue.dispatch_seqno(), 1u); + EXPECT_TRUE(env->is_truncated()) + << "the receiver marked the open transaction truncated"; + EXPECT_FALSE(env->is_committed()) + << "a truncated transaction is rolled back, never committed"; + // Truncation alone does not release the payload; the bytes stay charged until + // the sweep drops the envelope. + EXPECT_EQ(queue.bytes_used(), kTrxLength); + + // The committed-head sweep reclaims the truncated head exactly like a + // committed one (it is a terminal state): commit_seqno advances past it and + // its reserved bytes are released at sweep, so the applier keeps progressing + // without a full-stop reset(). After the sweep env dangles — do not touch it. + EXPECT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.commit_seqno(), 1u) + << "the truncated head is reclaimed by the sweep"; + EXPECT_EQ(queue.bytes_used(), 0u) + << "the reserved bytes are released when the truncated envelope is swept"; +} + +// 10.2 — Back-pressure property: the memory budget blocks ONLY the IO thread +// (the producer in acquire_admission), never the coordinator/workers. A budget +// far smaller than the workload forces the producer to block, and it can only +// advance as the consumer commits (releasing bytes) and sweeps. If the +// commit -> release_bytes -> wake path were broken, the producer would block +// forever and this test would hang, so completion itself proves the property. +// +// ThreadSanitizer-clean: threads coordinate exclusively through the queue's own +// blocking calls (enqueue()/dispatch_next()), env->commit(), sweep_committed(), +// and joins; the only shared reads are of the atomic byte counter. No fixed +// sleep is used for correctness. +// Validates: Requirements 5.1, 5.2, 5.4 +TEST_F(Imr_integration_test, BackPressureBlocksProducerNotConsumer) { + constexpr std::size_t kLen = 4096; + constexpr std::size_t kCapacity = 4; // live payloads that fit + constexpr std::size_t kSmallLimit = kLen * kCapacity; // tight memory budget + constexpr std::size_t kSpill = kSmallLimit; // kLen <= threshold => MEMORY path + constexpr int kTotal = 40; // >> capacity: forces blocking + + Trx_envelope_queue queue(kSmallLimit, kSpill); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + std::atomic committed{0}; + + // Coordinator + worker: dispatch FIFO, commit (releasing kLen bytes and waking + // the blocked IO thread), then sweep the committed head. Never blocks in + // admission -- only the producer can. + std::thread consumer([&] { + for (int i = 0; i < kTotal; ++i) { + Transaction_envelope *env = queue.dispatch_next(); + if (env == nullptr) { + ADD_FAILURE() << "dispatch_next returned nullptr before draining"; + break; + } + // Back-pressure invariant: the counter is never above the limit at any + // instant (admission enforces it before the producer reserves). + EXPECT_LE(queue.bytes_used(), kSmallLimit); + EXPECT_FALSE(env->commit()); // releases kLen bytes, wakes the producer + EXPECT_FALSE(queue.sweep_committed()); + committed.fetch_add(1, std::memory_order_relaxed); + } + }); + + // IO thread (receiver): enqueue far more than the budget holds at once, so it + // MUST block in admission and advance only as the consumer frees space. + std::thread producer([&] { + for (int i = 0; i < kTotal; ++i) { + if (queue.enqueue(kLen, /*is_trx=*/true, make_fde()) == nullptr) { + ADD_FAILURE() << "enqueue returned nullptr (unexpected stop)"; + break; + } + } + }); + + producer.join(); + consumer.join(); + + EXPECT_EQ(committed.load(), kTotal); + EXPECT_EQ(queue.bytes_used(), 0u); + EXPECT_EQ(queue.commit_seqno(), static_cast(kTotal)); + EXPECT_EQ(queue.insert_seqno(), static_cast(kTotal)); + // Queue drained + empty: destructor invariant holds. +} + +// 10.2 — A stop wakes an IO thread parked in admission: the blocked enqueue +// aborts and returns nullptr rather than reserving bytes. +// Validates: Requirement 5.3 +TEST_F(Imr_integration_test, StopUnblocksProducerBlockedInAdmission) { + constexpr std::chrono::milliseconds kShortWait{50}; + constexpr std::chrono::seconds kJoinWait{3}; + constexpr std::size_t kLen = 4096; + constexpr std::size_t kCapacity = 2; + constexpr std::size_t kSmallLimit = kLen * kCapacity; + constexpr std::size_t kSpill = kSmallLimit; + + Trx_envelope_queue queue(kSmallLimit, kSpill); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + // Fill the budget so the next admission must block. + for (std::size_t i = 0; i < kCapacity; ++i) { + ASSERT_NE(queue.enqueue(kLen, /*is_trx=*/true, make_fde()), nullptr); + } + ASSERT_EQ(queue.bytes_used(), kSmallLimit); + + // Park the IO thread in acquire_admission(): the budget is full, so it blocks. + std::promise parked; + std::future fut = parked.get_future(); + std::thread producer( + [&] { parked.set_value(queue.enqueue(kLen, /*is_trx=*/true, make_fde())); }); + + ASSERT_EQ(fut.wait_for(kShortWait), std::future_status::timeout) + << "the IO thread must block in admission while the budget is full"; + + // stop() wakes the blocked admission; the enqueue aborts and returns nullptr. + queue.stop(); + ASSERT_EQ(fut.wait_for(kJoinWait), std::future_status::ready) + << "stop() must wake the blocked IO thread"; + EXPECT_EQ(fut.get(), nullptr) << "a stopped admission fails the enqueue"; + producer.join(); + + // The admitted envelopes are uncommitted; reset() drops them so the queue + // dtor invariant holds. + queue.reset(); + EXPECT_EQ(queue.bytes_used(), 0u); +} + +// 10.2 — A transaction larger than the whole budget is routed to SPILL, never +// WOULD_BLOCK, so an over-limit transaction never back-pressures the IO thread +// (the memory path's WOULD_BLOCK is the only blocking signal). +// Validates: Requirement 5.5 +TEST_F(Imr_integration_test, OverLimitTransactionClassifiesSpillNotBlock) { + constexpr std::size_t kLen = 4096; + constexpr std::size_t kCapacity = 2; + constexpr std::size_t kSmallLimit = kLen * kCapacity; + constexpr std::size_t kSpill = kSmallLimit; // spill_threshold == memory_limit + + Trx_envelope_queue queue(kSmallLimit, kSpill); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + // Larger than the whole budget -> SPILL (never WOULD_BLOCK). + EXPECT_EQ(queue.classify(kSmallLimit + 1), Admission::SPILL); + // Within the threshold and room available -> MEMORY. + EXPECT_EQ(queue.classify(kLen), Admission::MEMORY); + + // Saturate the budget (without enqueuing): a memory-path trx now WOULD_BLOCK + // (the back-pressure signal), but an over-threshold trx still classifies SPILL + // regardless of current usage. + queue.add_bytes(kSmallLimit); + EXPECT_EQ(queue.classify(kLen), Admission::WOULD_BLOCK); + EXPECT_EQ(queue.classify(kSmallLimit + 1), Admission::SPILL); + + queue.release_bytes(kSmallLimit); // undo; no envelopes were created. + EXPECT_EQ(queue.bytes_used(), 0u); +} + +// --------------------------------------------------------------------------- +// Task 7 - End-to-end SPILL path through the real queue + reader. +// --------------------------------------------------------------------------- + +// A transaction larger than the spill threshold flows end-to-end through the +// SPILL path: enqueue creates the on-disk destination, the receiver streams the +// body + terminal (sealing) into the spill file, the reader dispatches and +// builds a Job, the consumer drains the events back from the file, the commit +// hook commits the envelope (releasing ZERO bytes), the sweep reclaims it, and +// the spill file is deleted once the last holder of the byte source is dropped. +// Requirements 3.7, 5.5, 6.1, 6.2, 6.3, 8.1, 8.2, 9.2, 9.5 +TEST_F(Imr_integration_test, EndToEndSpillPathDispatchCommitSweepAndCleanup) { + Scoped_temp_dir relay_dir; + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold, relay_dir.path); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + // 1) Admit + enqueue a SPILL-path envelope. enqueue() provisions the on-disk + // destination and reserves ZERO bytes, before the envelope is observable. + Transaction_envelope *env = + queue.enqueue(kSpillTrxLength, /*is_trx=*/true, make_fde()); + ASSERT_NE(env, nullptr); + EXPECT_EQ(env->path(), Envelope_path::SPILL); + EXPECT_FALSE(env->is_committed()); + EXPECT_EQ(queue.bytes_used(), 0u) << "spill is outside the memory budget"; + + // 2) The enqueue-created spill destination is reachable, healthy, over a real + // file on the channel's relay log directory. + Streaming_event_sink *sink = env->current_sink(); + ASSERT_NE(sink, nullptr); + auto *spill = dynamic_cast(sink); + ASSERT_NE(spill, nullptr); + ASSERT_FALSE(spill->is_error()) << spill->get_error_str(); + const std::string spill_path = spill->spill_file_name(); + ASSERT_FALSE(spill_path.empty()); + EXPECT_TRUE(fs::exists(spill_path)); + + // 3) Stream a few real serialized events into the spill file via the byte- + // oriented sink, sealing on the terminal event. Reception must complete + // before dispatch: read() builds a Job whose ctor peeks the first event. + constexpr int kEventCount = 4; + for (int i = 0; i < kEventCount; ++i) { + const std::vector bytes = serialize_event(); + const bool last = (i == kEventCount - 1); + sink->append_event(as_char(bytes), bytes.size(), /*seal_after=*/last); + ASSERT_FALSE(spill->is_error()) << spill->get_error_str(); + } + + // A shared handle to the enqueue-created Fetchable_transaction to drive the + // consumer and fire the commit hook. + ASSERT_NE(env->payload(), nullptr); + auto fetchable = env->payload()->fetchable(); + ASSERT_NE(fetchable, nullptr); + ASSERT_TRUE(fetchable->is_trx()); + + // 4) Dispatch through the REAL reader path (sweep_and_dispatch + Job build). + auto reader = make_reader(&queue); + EXPECT_EQ(queue.dispatch_seqno(), 0u); + Job_ptr job = reader->read(); + ASSERT_NE(job, nullptr); + EXPECT_EQ(queue.dispatch_seqno(), 1u); + + // 5) Drive the consumer surface: the events decode back from the spill file + // in order, followed by a clean end-of-stream. + int fetched = 0; + while (fetchable->wait_next()) { + auto managed = fetchable->fetch_next(); + ASSERT_TRUE(managed.has_value()) + << "wait_next() returned true so fetch_next() must yield an event"; + ++fetched; + } + EXPECT_EQ(fetched, kEventCount); + EXPECT_TRUE(fetchable->is_fetching_done()); + EXPECT_FALSE(fetchable->is_fetching_error()); + + // 6) Commit hook: the envelope commits, its payload is nulled, and ZERO bytes + // are released (spill never charged the counter). + fetchable->set_success(); + EXPECT_TRUE(env->is_committed()); + EXPECT_EQ(env->payload(), nullptr); + EXPECT_EQ(queue.bytes_used(), 0u); + + // The spill file is still present: the Job and our local handle keep the byte + // source (hence the Spill_file_writer) alive past the envelope's payload. + EXPECT_TRUE(fs::exists(spill_path)); + + // 7) Coordinator sweep: the committed head is dequeued. After the sweep env + // dangles — do not touch it. + EXPECT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.commit_seqno(), 1u); + EXPECT_EQ(queue.bytes_used(), 0u); + + // 8) Drop the remaining holders of the byte source. When the last shared_ptr + // to the Fetchable_transaction goes away, the Event_set_fetchable_spill + // (and its Spill_file_writer) is destroyed and the spill file is deleted. + delete job; + fetchable.reset(); + EXPECT_FALSE(fs::exists(spill_path)) + << "the spill file must be deleted once the byte source is released"; +} + +// A spill transaction cut short mid-stream is truncated (never sealed), so the +// coordinator dispatches it, a worker would roll it back (never committing), +// and the committed-head sweep reclaims the truncated envelope — releasing its +// (zero) bytes and deleting its spill file. Drives the real receiver hooks. +// Requirements 2.4, 4.1, 6.3, 3.7 +TEST_F(Imr_integration_test, EndToEndSpillPathTruncatedRollbackSweepAndCleanup) { + Scoped_temp_dir relay_dir; + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold, relay_dir.path); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + Streaming_event_sink *current_sink = nullptr; + + // GTID event -> open_transaction: admits the SPILL transaction and publishes + // its spill sink. No memory is charged. + ASSERT_FALSE(open_transaction(queue, current_sink, make_fde(), + kSpillTrxLength, /*is_trx=*/true)); + ASSERT_NE(current_sink, nullptr); + EXPECT_EQ(queue.bytes_used(), 0u); + + auto *spill = dynamic_cast(current_sink); + ASSERT_NE(spill, nullptr); + const std::string spill_path = spill->spill_file_name(); + ASSERT_FALSE(spill_path.empty()); + EXPECT_TRUE(fs::exists(spill_path)); + + // A partial body event arrives, then the group is cut short -> truncate. + const std::vector bytes = serialize_event(); + ASSERT_FALSE(append_transaction_event(current_sink, as_char(bytes), + bytes.size(), /*is_terminal=*/false)); + truncate_transaction(current_sink); + EXPECT_EQ(current_sink, nullptr) << "truncation clears the current sink"; + + // The coordinator still dispatches the envelope; a worker rolls back a + // truncated stream and never commits. + Transaction_envelope *env = queue.dispatch_next(); + ASSERT_NE(env, nullptr); + EXPECT_EQ(queue.dispatch_seqno(), 1u); + EXPECT_TRUE(env->is_truncated()); + EXPECT_FALSE(env->is_committed()); + EXPECT_EQ(queue.bytes_used(), 0u); + // The spill file is still present until the envelope is swept. + EXPECT_TRUE(fs::exists(spill_path)); + + // The committed-head sweep reclaims the truncated head exactly like a + // committed one: it destroys the envelope (and its payload/byte source), so + // the spill file is deleted. After the sweep env dangles — do not touch it. + EXPECT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.commit_seqno(), 1u); + EXPECT_EQ(queue.bytes_used(), 0u); + EXPECT_FALSE(fs::exists(spill_path)) + << "a truncated spill transaction's file must be removed on sweep"; +} + +} // namespace mysql::csa::unittests diff --git a/unittest/gunit/changestreams/imr_queue_admission-t.cc b/unittest/gunit/changestreams/imr_queue_admission-t.cc new file mode 100644 index 000000000000..a5067c2d942e --- /dev/null +++ b/unittest/gunit/changestreams/imr_queue_admission-t.cc @@ -0,0 +1,350 @@ +/* Copyright (c) 2026, Oracle and/or its affiliates. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License, version 2.0, + as published by the Free Software Foundation. + + This program is designed to work with certain software (including + but not limited to OpenSSL) that is licensed under separate terms, + as designated in a particular file or component or in included license + documentation. The authors of MySQL hereby grant you an additional + permission to link the program and your derivative works with the + separately licensed software that they have either included with + the program or referenced in the documentation. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License, version 2.0, for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +/// @file +/// Unit and randomized-sequence ("property") tests for the memory-accounting +/// and admission core of mysql::csa::Trx_envelope_queue. +/// +/// The MySQL tree does not integrate a property-testing library (e.g. +/// rapidcheck), so the property test below is expressed as a deterministically +/// seeded randomized-sequence generator that runs many trials inside a plain +/// gtest TEST. The seed is fixed and echoed in every assertion message so any +/// failure reproduces exactly. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sql/changestreams/apply/storage/in_memory/in_memory_types.h" +#include "sql/changestreams/apply/storage/in_memory/trx_envelope_queue.h" + +namespace mysql::csa::unittests { + +namespace { +/// A short bounded wait used to observe that a background admission call is (or +/// is not) still blocked, without relying on a fixed sleep for correctness. +constexpr std::chrono::milliseconds kShortWait{50}; +} // namespace + +// --------------------------------------------------------------------------- +// Task 2.2 - Unit tests: classification and blocking admission. +// Requirements 4.1, 4.2, 4.3, 4.5, 4.6, 5.1, 5.2, 5.3, 5.4, 5.5 +// --------------------------------------------------------------------------- + +// Req 4.1: trx_length == spill_threshold is NOT spill (it fits the threshold), +// and classifies MEMORY when it fits the limit; trx_length == spill_threshold+1 +// is SPILL regardless of usage. +TEST(ImrQueueAdmissionTest, ClassifySpillThresholdBoundary) { + const std::size_t memory_limit = 1000; + const std::size_t spill_threshold = 100; + Trx_envelope_queue queue(memory_limit, spill_threshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + // Exactly at the threshold: not spill. With no usage it fits the limit. + EXPECT_EQ(queue.classify(spill_threshold), Admission::MEMORY); + // One byte over the threshold: spill, independent of usage. + EXPECT_EQ(queue.classify(spill_threshold + 1), Admission::SPILL); +} + +// Req 4.1: SPILL is returned independently of bytes_used - even when the queue +// is completely full a too-large transaction still classifies SPILL. +TEST(ImrQueueAdmissionTest, ClassifySpillIndependentOfUsage) { + const std::size_t memory_limit = 1000; + const std::size_t spill_threshold = 100; + Trx_envelope_queue queue(memory_limit, spill_threshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + queue.add_bytes(memory_limit); // Fill the budget entirely. + EXPECT_EQ(queue.bytes_used(), memory_limit); + EXPECT_EQ(queue.classify(spill_threshold + 1), Admission::SPILL); + + // This test reserved bytes with add_bytes() without creating the payloads + // whose destructors would release them, so release them here: the queue + // destructor asserts bytes_used() == 0. + queue.release_bytes(queue.bytes_used()); +} + +// Req 4.2 / 4.3: at the memory_limit boundary the decision flips from MEMORY to +// WOULD_BLOCK. spill_threshold is set high so these lengths never route SPILL. +TEST(ImrQueueAdmissionTest, ClassifyMemoryLimitBoundary) { + const std::size_t memory_limit = 1000; + const std::size_t spill_threshold = 500; + Trx_envelope_queue queue(memory_limit, spill_threshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + queue.add_bytes(900); // 900 bytes already reserved. + ASSERT_EQ(queue.bytes_used(), 900u); + + // bytes_used + trx_length == memory_limit -> MEMORY. + EXPECT_EQ(queue.classify(100), Admission::MEMORY); + // bytes_used + trx_length == memory_limit + 1 -> WOULD_BLOCK. + EXPECT_EQ(queue.classify(101), Admission::WOULD_BLOCK); + + // Release what add_bytes() reserved: the queue destructor asserts + // bytes_used() == 0. + queue.release_bytes(queue.bytes_used()); +} + +// Req 4.5 / 4.6: classify is pure - it never mutates bytes_used, and repeated +// calls with identical inputs yield identical results. +TEST(ImrQueueAdmissionTest, ClassifyIsPure) { + const std::size_t memory_limit = 1000; + const std::size_t spill_threshold = 500; + Trx_envelope_queue queue(memory_limit, spill_threshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + queue.add_bytes(200); + const std::size_t before = queue.bytes_used(); + + const Admission first = queue.classify(300); + const Admission second = queue.classify(300); + + // Deterministic result for identical inputs (Req 4.5). + EXPECT_EQ(first, second); + // No observable state mutation (Req 4.6). + EXPECT_EQ(queue.bytes_used(), before); + + // Repeated calls at different sizes still leave the counter untouched. + (void)queue.classify(700); + (void)queue.classify(1500); + EXPECT_EQ(queue.bytes_used(), before); + + // Release what add_bytes() reserved: the queue destructor asserts + // bytes_used() == 0. + queue.release_bytes(queue.bytes_used()); +} + +// Req 5.2 / 5.3: acquire_admission succeeds immediately (returns false) when the +// reservation fits, and the call itself reserves nothing (the Trx_payload ctor +// reserves later). +TEST(ImrQueueAdmissionTest, AcquireAdmissionSucceedsWhenItFits) { + const std::size_t memory_limit = 1000; + const std::size_t spill_threshold = 500; + Trx_envelope_queue queue(memory_limit, spill_threshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + // Run under a timeout so a regression that blocks here fails fast. + std::future failed = std::async( + std::launch::async, [&queue] { return queue.acquire_admission(100); }); + ASSERT_EQ(failed.wait_for(std::chrono::seconds(3)), std::future_status::ready) + << "acquire_admission must return immediately when the reservation fits"; + // false == success. + EXPECT_FALSE(failed.get()); + + // acquire_admission does not itself reserve any bytes. + EXPECT_EQ(queue.bytes_used(), 0u); +} + +// Req 5.1 / 5.4: acquire_admission blocks while over the limit, then is +// unblocked and succeeds (returns false) once release_bytes frees enough room. +TEST(ImrQueueAdmissionTest, AcquireAdmissionBlocksThenUnblocksOnRelease) { + const std::size_t memory_limit = 1000; + const std::size_t spill_threshold = 500; + Trx_envelope_queue queue(memory_limit, spill_threshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + queue.add_bytes(memory_limit); // Fully occupied: a 100-byte request blocks. + + std::atomic returned{false}; + std::promise result_promise; + std::future result_future = result_promise.get_future(); + + std::thread waiter([&] { + const bool failed = queue.acquire_admission(100); + returned.store(true); + result_promise.set_value(failed); + }); + + // The call must still be blocked: nothing has been released yet. + EXPECT_EQ(result_future.wait_for(kShortWait), std::future_status::timeout); + EXPECT_FALSE(returned.load()); + + // Partial release: freeing only 50 bytes leaves bytes_used at 950, so the + // 100-byte request still does not fit (950 + 100 > 1000). The waiter must + // wake, re-evaluate the predicate, and keep blocking. + queue.release_bytes(50); + EXPECT_EQ(result_future.wait_for(kShortWait), std::future_status::timeout); + EXPECT_FALSE(returned.load()); + + // Second release brings bytes_used to 900, so 900 + 100 == memory_limit fits + // and the waiter must now acquire admission. + queue.release_bytes(50); + ASSERT_EQ(result_future.wait_for(std::chrono::seconds(3)), + std::future_status::ready) + << "acquire_admission must return once release_bytes frees enough room"; + // false == success. + EXPECT_FALSE(result_future.get()); + + waiter.join(); + // The waiter reserved nothing itself; only the two releases changed the + // counter (1000 - 50 - 50). + EXPECT_EQ(queue.bytes_used(), memory_limit - 100); + + // Release the rest of what add_bytes() reserved: the queue destructor asserts + // bytes_used() == 0. + queue.release_bytes(queue.bytes_used()); +} + +// Req 5.5: a stop while blocked fails (returns true) and leaves bytes_used +// unchanged (no bytes reserved). +TEST(ImrQueueAdmissionTest, AcquireAdmissionFailsOnStop) { + const std::size_t memory_limit = 1000; + const std::size_t spill_threshold = 500; + Trx_envelope_queue queue(memory_limit, spill_threshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + queue.add_bytes(memory_limit); // Fully occupied: the request will block. + const std::size_t used_before = queue.bytes_used(); + + std::atomic returned{false}; + std::promise result_promise; + std::future result_future = result_promise.get_future(); + + std::thread waiter([&] { + const bool failed = queue.acquire_admission(100); + returned.store(true); + result_promise.set_value(failed); + }); + + // Confirm it is blocked before requesting stop. + EXPECT_EQ(result_future.wait_for(kShortWait), std::future_status::timeout); + EXPECT_FALSE(returned.load()); + + queue.stop(); + ASSERT_EQ(result_future.wait_for(std::chrono::seconds(3)), + std::future_status::ready) + << "acquire_admission must return after stop()"; + // true == failure (stopped, not acquired). + EXPECT_TRUE(result_future.get()); + + waiter.join(); + EXPECT_TRUE(queue.is_stopped()); + // bytes_used unchanged by the stopped, failed admission attempt. + EXPECT_EQ(queue.bytes_used(), used_before); + + // Release what add_bytes() reserved: the queue destructor asserts + // bytes_used() == 0. + queue.release_bytes(queue.bytes_used()); +} + +// --------------------------------------------------------------------------- +// Task 2.3 - Property test: the memory bound. +// Validates: Requirements 4.1, 4.2, 4.3, 5.2, 5.6 +// --------------------------------------------------------------------------- + +// Property 2 (Memory bound): over a randomized single-producer admit/release +// sequence, an admitted memory-path reservation never lets bytes_used exceed +// memory_limit; every trx_length > spill_threshold classifies SPILL regardless +// of the current usage; and bytes_used always equals the sum of live +// reservations, returning to 0 once everything is released. +TEST(ImrQueueAdmissionTest, PropertyMemoryBound) { + // Fixed, deterministic seed so any failure reproduces exactly. It is echoed + // in every assertion message below. + constexpr std::uint32_t kSeed = 0xC0FFEEu; + constexpr int kTrials = 4000; + + const std::size_t memory_limit = 10000; + const std::size_t spill_threshold = 500; + + std::mt19937 rng(kSeed); + // Range spans well above spill_threshold so SPILL is exercised often, and + // includes 0 and both boundary values. + std::uniform_int_distribution len_dist(0, 1000); + std::uniform_int_distribution action_dist(0, 3); + + Trx_envelope_queue queue(memory_limit, spill_threshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + // Multiset of live reservations currently counted in bytes_used. + std::vector live; + + auto sum_live = [&live]() { + return std::accumulate(live.begin(), live.end(), std::size_t{0}); + }; + + for (int trial = 0; trial < kTrials; ++trial) { + const std::size_t trx_length = len_dist(rng); + const Admission decision = queue.classify(trx_length); + + // Every over-threshold length must classify SPILL, independent of usage + // (Req 4.1). + if (trx_length > spill_threshold) { + ASSERT_EQ(decision, Admission::SPILL) + << "seed=" << kSeed << " trial=" << trial + << " trx_length=" << trx_length; + } + + if (decision == Admission::SPILL) { + // Spill path does not touch the memory counter: leave `live` and the + // counter untouched (verified by the invariant check at the loop end). + } else { + // Non-blocking admission: admit only when it actually fits (single + // threaded, so we drive admission without blocking). classify already + // encodes this, cross-check with the raw bound (Req 4.2/4.3). + const bool fits = queue.bytes_used() + trx_length <= memory_limit; + ASSERT_EQ(decision == Admission::MEMORY, fits) + << "seed=" << kSeed << " trial=" << trial + << " trx_length=" << trx_length + << " bytes_used=" << queue.bytes_used(); + if (decision == Admission::MEMORY) { + queue.add_bytes(trx_length); + live.push_back(trx_length); + // Memory bound holds at every admission point (Req 5.2/5.6). + ASSERT_LE(queue.bytes_used(), memory_limit) + << "seed=" << kSeed << " trial=" << trial + << " trx_length=" << trx_length; + } + } + + // Randomly release a previously-admitted reservation. + if (!live.empty() && action_dist(rng) == 0) { + std::uniform_int_distribution idx_dist(0, live.size() - 1); + const std::size_t idx = idx_dist(rng); + const std::size_t amount = live[idx]; + live[idx] = live.back(); + live.pop_back(); + queue.release_bytes(amount); + } + + // Counter always equals the sum of live reservations (Req 5.6). + ASSERT_EQ(queue.bytes_used(), sum_live()) + << "seed=" << kSeed << " trial=" << trial; + } + + // Release everything that remains; the counter must return to zero. + for (const std::size_t amount : live) { + queue.release_bytes(amount); + } + live.clear(); + ASSERT_EQ(queue.bytes_used(), 0u) << "seed=" << kSeed; +} + +} // namespace mysql::csa::unittests diff --git a/unittest/gunit/changestreams/imr_queue_fifo-t.cc b/unittest/gunit/changestreams/imr_queue_fifo-t.cc new file mode 100644 index 000000000000..177bb2907263 --- /dev/null +++ b/unittest/gunit/changestreams/imr_queue_fifo-t.cc @@ -0,0 +1,1170 @@ +/* Copyright (c) 2026, Oracle and/or its affiliates. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License, version 2.0, + as published by the Free Software Foundation. + + This program is designed to work with certain software (including + but not limited to OpenSSL) that is licensed under separate terms, + as designated in a particular file or component or in included license + documentation. The authors of MySQL hereby grant you an additional + permission to link the program and your derivative works with the + separately licensed software that they have either included with + the program or referenced in the documentation. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License, version 2.0, for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +/// @file +/// Unit and randomized-sequence ("property") tests for the FIFO ordering, +/// cursors, and coordinator sweep of mysql::csa::Trx_envelope_queue. +/// +/// These tests exercise the ordering side of the queue (enqueue / +/// dispatch_next / sweep_committed and the three monotonic cursors), not the +/// admission side (covered by imr_queue_admission-t.cc). To keep enqueue from +/// blocking in admission the queue is always built with a very large memory +/// limit and every transaction length stays well under both the limit and the +/// spill threshold, so enqueue always takes the (non-blocking, once it fits) +/// MEMORY path. +/// +/// In the commit-only model there is no queue/envelope seal step: an envelope +/// is committed directly via Transaction_envelope::commit(), and the +/// coordinator sweep detects the committed head via is_committed(). +/// +/// The MySQL tree does not integrate a property-testing library (e.g. +/// rapidcheck), so the property tests are expressed as deterministically seeded +/// randomized-sequence generators run over many trials inside plain gtest +/// TESTs. Each seed is fixed and echoed in every assertion message so any +/// failure reproduces exactly. +/// +/// Because @c ~Trx_envelope_queue asserts that the deque is empty and +/// @c bytes_used() == 0, every test fully drains the queue (commit every +/// envelope that owns a payload, then sweep the whole committed prefix) before +/// the queue goes out of scope. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sql/changestreams/apply/jobs/fetchable_transaction.h" +#include "sql/changestreams/apply/storage/common/event_set_fetchable.h" +#include "sql/changestreams/apply/storage/common/streaming_event_sink.h" +#include "sql/changestreams/apply/storage/in_memory/event_set_fetchable_memory.h" +#include "sql/changestreams/apply/storage/in_memory/event_set_fetchable_spill.h" +#include "sql/changestreams/apply/storage/in_memory/in_memory_types.h" +#include "sql/changestreams/apply/storage/in_memory/transaction_envelope.h" +#include "sql/changestreams/apply/storage/in_memory/trx_envelope_queue.h" +#include "sql/changestreams/apply/storage/in_memory/trx_payload.h" +#include "sql/changestreams/apply/storage/relay_log/ireader_event.h" +#include "sql/log_event.h" + +namespace mysql::csa::unittests { + +namespace { +/// A short bounded wait used to observe that a background dispatch call is (or +/// is not) still blocked, without relying on a fixed sleep for correctness. +constexpr std::chrono::milliseconds kShortWait{50}; + +/// Build the active FDE that every enqueue() must be handed. enqueue() takes +/// the precise std::shared_ptr (the exact type +/// Master_info::get_mi_description_event_shared() returns), so no upcast is +/// needed at the call site. These ordering tests never create a byte source, so +/// the FDE is only asserted non-null. +std::shared_ptr make_fde() { + return std::make_shared(); +} + +/// Memory bounds large enough that enqueue never blocks in admission: the limit +/// dwarfs any total the tests reserve, and the spill threshold dwarfs any +/// single transaction length, so every enqueue takes the MEMORY path. enqueue() +/// itself creates and attaches the empty MEMORY-path destination, reserving +/// each envelope's @c trx_length bytes at enqueue time, so the tests no longer +/// attach payloads by hand. +constexpr std::size_t kMemoryLimit = std::size_t{1} << 30; // 1 GiB +constexpr std::size_t kSpillThreshold = std::size_t{1} << 20; // 1 MiB + +/// RAII private "relay log directory" for spill-path enqueue tests; removed on +/// scope exit (with the spill files and temp-files subdir under it). +struct Scoped_temp_dir { + std::string path; + Scoped_temp_dir() { + static std::atomic counter{0}; + path = (std::filesystem::temp_directory_path() / + ("imr_fifo_" + std::to_string(::getpid()) + "_" + + std::to_string(counter.fetch_add(1)))) + .string(); + std::filesystem::create_directories(path); + } + ~Scoped_temp_dir() { + std::error_code ec; + std::filesystem::remove_all(path, ec); + } +}; + +/// Commit and sweep everything still live in @p envs so the queue is empty and +/// @c bytes_used() == 0 before it is destroyed. @p envs must contain only +/// envelopes that have not yet been swept (their pointers are still valid). +void drain_queue(Trx_envelope_queue &queue, + const std::vector &envs) { + // An envelope must be dispatched before it can be committed/swept + // (commit_seqno <= dispatch_seqno). Dispatch anything still undispatched + // first. The caller must not have stopped the queue (dispatch_next() would + // return nullptr without advancing); stopped-queue tests use reset() instead. + while (queue.dispatch_seqno() < queue.insert_seqno()) { + queue.dispatch_next(); + } + for (auto *env : envs) { + if (!env->is_committed()) env->commit(); + } + while (queue.commit_seqno() < queue.insert_seqno()) { + if (queue.sweep_committed()) break; // structural error: avoid a spin. + } +} + +/// A fake encoded event used by the destination-creating enqueue tests below: +/// it hands back a preset, already-decoded Log_event when the byte source asks +/// it to decode(). This lets the test control exactly which Log_event object +/// fetch_next() yields and assert identity/order, keeping the +/// transaction-payload decompression path out of the harness (mirrors +/// imr_integration-t.cc / imr_queued_transaction_reader-t.cc). +class Fake_reader_event : public IReader_event { + public: + explicit Fake_reader_event(std::shared_ptr decoded) + : m_decoded(std::move(decoded)) {} + + std::shared_ptr decode() override { return m_decoded; } + + // These tests never re-read with reset_events=true, so a no-op suffices. + void reset(const Format_description_log_event *) override {} + + private: + std::shared_ptr m_decoded; +}; + +/// A body event served by the stream: a distinct FDE instance, so object +/// identity is meaningful when asserting fetch order. +std::shared_ptr make_event() { + return std::make_shared(); +} + +/// Wrap a decoded Log_event into a fake encoded IReader_event entry. +IReader_event_ptr make_fake(std::shared_ptr decoded) { + return std::make_shared(std::move(decoded)); +} +} // namespace + +// --------------------------------------------------------------------------- +// Task 5.2 - Unit tests: FIFO enqueue / dispatch / sweep. +// Requirements 3.1, 3.2, 3.5, 3.6, 3.7, 3.8, 6.2, 6.3, 6.4, 6.5, 6.7 +// --------------------------------------------------------------------------- + +// Req 3.1: a freshly constructed queue has all three cursors at 0. +TEST(ImrQueueFifoTest, CursorsInitializeToZero) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + EXPECT_EQ(queue.commit_seqno(), 0u); + EXPECT_EQ(queue.dispatch_seqno(), 0u); + EXPECT_EQ(queue.insert_seqno(), 0u); + EXPECT_EQ(queue.queue_length(), 0u); + EXPECT_EQ(queue.bytes_used(), 0u); + // Empty queue: nothing to drain. +} + +// Queue length is the number of entries still owned by the queue. Dispatch and +// worker commit leave ownership unchanged; only sweeping a contiguous committed +// head prefix removes entries. +TEST(ImrQueueFifoTest, QueueLengthTracksOwnedEntriesUntilSweep) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + Transaction_envelope *e1 = queue.enqueue(11, true, make_fde()); + ASSERT_NE(e1, nullptr); + EXPECT_EQ(queue.queue_length(), 1u); + + Transaction_envelope *e2 = queue.enqueue(22, true, make_fde()); + ASSERT_NE(e2, nullptr); + Transaction_envelope *e3 = queue.enqueue(33, true, make_fde()); + ASSERT_NE(e3, nullptr); + EXPECT_EQ(queue.queue_length(), 3u); + + ASSERT_EQ(queue.dispatch_next(), e1); + ASSERT_EQ(queue.dispatch_next(), e2); + ASSERT_EQ(queue.dispatch_next(), e3); + EXPECT_EQ(queue.queue_length(), 3u); + + // A committed entry behind an uncommitted head remains queue-owned. + ASSERT_FALSE(e2->commit()); + EXPECT_EQ(queue.queue_length(), 3u); + ASSERT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.queue_length(), 3u); + + // Committing does not remove entries. Sweeping removes the now-contiguous + // committed prefix (#1 and #2), leaving only #3. + ASSERT_FALSE(e1->commit()); + EXPECT_EQ(queue.queue_length(), 3u); + ASSERT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.queue_length(), 1u); + + ASSERT_FALSE(e3->commit()); + EXPECT_EQ(queue.queue_length(), 1u); + ASSERT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.queue_length(), 0u); +} + +// Req 3.2: stream_seqno starts at 1 and strictly increases with each enqueue; +// insert_seqno tracks the last assigned stream_seqno. +TEST(ImrQueueFifoTest, StreamSeqnoStartsAtOneAndStrictlyIncreases) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + Transaction_envelope *e1 = queue.enqueue(11, true, make_fde()); + Transaction_envelope *e2 = queue.enqueue(22, true, make_fde()); + Transaction_envelope *e3 = queue.enqueue(33, true, make_fde()); + + EXPECT_EQ(e1->stream_seqno(), 1u); + EXPECT_EQ(e2->stream_seqno(), 2u); + EXPECT_EQ(e3->stream_seqno(), 3u); + EXPECT_EQ(queue.insert_seqno(), 3u); + // dispatch/commit untouched by enqueue. + EXPECT_EQ(queue.dispatch_seqno(), 0u); + EXPECT_EQ(queue.commit_seqno(), 0u); + + drain_queue(queue, {e1, e2, e3}); +} + +// Req 3.5: dispatch_next hands back envelopes in stream_seqno order 1..N and +// advances dispatch_seqno by one each time. +TEST(ImrQueueFifoTest, DispatchAdvancesDispatchSeqnoInOrder) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + constexpr int kN = 4; + std::vector envs; + for (int i = 0; i < kN; ++i) + envs.push_back(queue.enqueue(100 + i, true, make_fde())); + + for (int i = 0; i < kN; ++i) { + // Guard: only dispatch while an undispatched envelope exists so the call + // never blocks in this single-threaded test. + ASSERT_LT(queue.dispatch_seqno(), queue.insert_seqno()); + Transaction_envelope *env = queue.dispatch_next(); + ASSERT_NE(env, nullptr); + EXPECT_EQ(env->stream_seqno(), static_cast(i + 1)); + EXPECT_EQ(queue.dispatch_seqno(), static_cast(i + 1)); + } + EXPECT_EQ(queue.dispatch_seqno(), queue.insert_seqno()); + + drain_queue(queue, envs); +} + +// Req 3.5: dispatch_next blocks while the queue is fully dispatched (here, +// empty) and returns nullptr once stop() is requested, without advancing any +// cursor. A blocking API forces this to be a thread-based test. +TEST(ImrQueueFifoTest, DispatchBlocksWhenEmptyReturnsNullptrOnStop) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + std::promise result_promise; + std::future result_future = + result_promise.get_future(); + + std::thread waiter( + [&] { result_promise.set_value(queue.dispatch_next()); }); + + // Nothing enqueued, so the call must still be blocked. + EXPECT_EQ(result_future.wait_for(kShortWait), std::future_status::timeout); + + queue.stop(); + ASSERT_EQ(result_future.wait_for(std::chrono::seconds(3)), + std::future_status::ready) + << "dispatch_next must return after stop()"; + EXPECT_EQ(result_future.get(), nullptr); + + waiter.join(); + // Stop did not advance any cursor and nothing was ever enqueued. + EXPECT_EQ(queue.commit_seqno(), 0u); + EXPECT_EQ(queue.dispatch_seqno(), 0u); + EXPECT_EQ(queue.insert_seqno(), 0u); + // Empty queue: nothing to drain. +} + +// Req 3.6, 3.7, 3.8, 6.3, 6.4, 6.5: committing out of source order releases each +// envelope's bytes immediately (independent of sweep) and leaves a +// committed-behind-uncommitted-head envelope in the deque; sweep_committed only +// advances commit_seqno over the contiguous committed head prefix. +TEST(ImrQueueFifoTest, OutOfOrderCommitRetainsCommittedBehindHead) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + // Distinct lengths so each byte-accounting assertion is unambiguous. + const std::size_t len1 = 111, len2 = 222, len3 = 333; + // enqueue() attaches the MEMORY-path payload, reserving trx_length bytes. + Transaction_envelope *e1 = queue.enqueue(len1, true, make_fde()); + Transaction_envelope *e2 = queue.enqueue(len2, true, make_fde()); + Transaction_envelope *e3 = queue.enqueue(len3, true, make_fde()); + + ASSERT_EQ(queue.insert_seqno(), 3u); + ASSERT_EQ(queue.bytes_used(), len1 + len2 + len3); + + // Dispatch all three before committing (commit_seqno <= dispatch_seqno). + ASSERT_EQ(queue.dispatch_next(), e1); + ASSERT_EQ(queue.dispatch_next(), e2); + ASSERT_EQ(queue.dispatch_next(), e3); + + // Commit the MIDDLE envelope first. Its bytes are released immediately. + EXPECT_FALSE(e2->commit()); + EXPECT_EQ(queue.bytes_used(), len1 + len3); + + // Sweep must NOT advance: the head (#1) is not yet committed. #2 stays in the + // deque behind the uncommitted head. + EXPECT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.commit_seqno(), 0u); + EXPECT_EQ(queue.insert_seqno(), 3u); + + // Commit the head (#1); its bytes release immediately. + EXPECT_FALSE(e1->commit()); + EXPECT_EQ(queue.bytes_used(), len3); + + // Now #1 and #2 form a contiguous committed prefix: sweep pops both. + EXPECT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.commit_seqno(), 2u); + // #3 is still uncommitted and remains. + EXPECT_EQ(queue.insert_seqno(), 3u); + + // Commit #3, then sweep the rest. + EXPECT_FALSE(e3->commit()); + EXPECT_EQ(queue.bytes_used(), 0u); + EXPECT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.commit_seqno(), 3u); + EXPECT_EQ(queue.commit_seqno(), queue.insert_seqno()); + EXPECT_EQ(queue.bytes_used(), 0u); + // Fully drained: destructor invariants hold. +} + +// Req 3.6, 6.5: sweep_committed advances commit_seqno only over the contiguous +// committed head and never changes bytes_used() (the worker already released +// the bytes at commit); it returns false on success. +TEST(ImrQueueFifoTest, SweepAdvancesContiguousPrefixWithoutChangingBytes) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + const std::size_t len1 = 500, len2 = 700; + // enqueue() attaches the MEMORY-path payload, reserving trx_length bytes. + Transaction_envelope *e1 = queue.enqueue(len1, true, make_fde()); + Transaction_envelope *e2 = queue.enqueue(len2, true, make_fde()); + + ASSERT_EQ(queue.bytes_used(), len1 + len2); + + // Dispatch both before committing (commit_seqno <= dispatch_seqno). + ASSERT_EQ(queue.dispatch_next(), e1); + ASSERT_EQ(queue.dispatch_next(), e2); + + // Commit only the head; its bytes are released at commit time. + ASSERT_FALSE(e1->commit()); + const std::size_t bytes_after_commit = queue.bytes_used(); + EXPECT_EQ(bytes_after_commit, len2); + + // Sweep pops the single committed head and does not touch the byte counter. + EXPECT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.commit_seqno(), 1u); + EXPECT_EQ(queue.bytes_used(), bytes_after_commit); + + // A second sweep with a non-committed head is a no-op success. + EXPECT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.commit_seqno(), 1u); + EXPECT_EQ(queue.bytes_used(), bytes_after_commit); + + // Finish: commit #2 and sweep it away. + ASSERT_FALSE(e2->commit()); + EXPECT_EQ(queue.bytes_used(), 0u); + EXPECT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.commit_seqno(), 2u); + EXPECT_EQ(queue.commit_seqno(), queue.insert_seqno()); +} + +// Req 6.2, 6.7: once every envelope has committed, bytes_used() is 0 whether or +// not the coordinator has swept them (bytes are reclaimed at commit, not at +// dequeue). +TEST(ImrQueueFifoTest, BytesUsedZeroWhenAllCommittedBeforeSweep) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + const std::size_t len1 = 128, len2 = 256, len3 = 512; + // enqueue() attaches the MEMORY-path payload, reserving trx_length bytes. + Transaction_envelope *e1 = queue.enqueue(len1, true, make_fde()); + Transaction_envelope *e2 = queue.enqueue(len2, true, make_fde()); + Transaction_envelope *e3 = queue.enqueue(len3, true, make_fde()); + + ASSERT_EQ(queue.bytes_used(), len1 + len2 + len3); + + // Dispatch all three before committing (commit_seqno <= dispatch_seqno). + ASSERT_EQ(queue.dispatch_next(), e1); + ASSERT_EQ(queue.dispatch_next(), e2); + ASSERT_EQ(queue.dispatch_next(), e3); + + // Commit all three (in an out-of-order sequence), WITHOUT sweeping. + ASSERT_FALSE(e2->commit()); + ASSERT_FALSE(e3->commit()); + ASSERT_FALSE(e1->commit()); + + // All committed: bytes are back to 0 even though nothing has been swept and + // the deque is still full (commit_seqno has not moved yet). + EXPECT_EQ(queue.bytes_used(), 0u); + EXPECT_EQ(queue.commit_seqno(), 0u); + EXPECT_EQ(queue.insert_seqno(), 3u); + + // Now sweep the whole committed prefix in one call. + EXPECT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.commit_seqno(), 3u); + EXPECT_EQ(queue.bytes_used(), 0u); +} + +// --------------------------------------------------------------------------- +// Task 3.4 - Unit tests: destination-creating enqueue (post-3.3 behavior). +// Requirements 3.2, 3.3, 3.4, 3.5, 3.6, 3.8 +// --------------------------------------------------------------------------- + +// Req 3.2, 3.3, 3.4: enqueue() on the MEMORY path creates and attaches the +// empty single-batch destination itself. The returned envelope is on the MEMORY +// path, owns a payload, has reserved exactly trx_length bytes AT ENQUEUE TIME +// (the key change from the pre-3.3 behavior where bytes were 0 right after +// enqueue), and exposes the single memory batch as its current sink — the sink +// IS an Event_set_fetchable_memory, and the payload's Fetchable_transaction is +// trx-typed. +TEST(ImrQueueFifoTest, EnqueueCreatesMemoryDestination) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + constexpr std::size_t kLen = 4096; + + Transaction_envelope *env = queue.enqueue(kLen, /*is_trx=*/true, make_fde()); + ASSERT_NE(env, nullptr); + EXPECT_EQ(env->path(), Envelope_path::MEMORY); + + // enqueue() attached the payload, so it is non-null on return. + ASSERT_NE(env->payload(), nullptr); + + // Exactly trx_length bytes are reserved at enqueue time (post-3.3 behavior). + EXPECT_EQ(queue.bytes_used(), kLen); + + // The current sink exists and IS the single Event_set_fetchable_memory batch. + Streaming_event_sink *sink = env->current_sink(); + ASSERT_NE(sink, nullptr); + EXPECT_NE(dynamic_cast(sink), nullptr) + << "the single memory batch must be the enqueue-created sink"; + + // The wrapped Fetchable_transaction is present and carries is_trx=true. + auto fetchable = env->payload()->fetchable(); + ASSERT_NE(fetchable, nullptr); + EXPECT_TRUE(fetchable->is_trx()); + + // Drain so the queue destructor's empty / bytes_used()==0 invariant holds. + drain_queue(queue, {env}); +} + +// Task 6: a transaction larger than the spill threshold is routed to the SPILL +// path — enqueue() returns a non-null envelope with a live spill destination +// (an Event_set_fetchable_spill over a real file), advances stream_seqno, and +// reserves ZERO bytes against the memory counter (Req 3.7, 5.5). +TEST(ImrQueueFifoTest, EnqueueSpillCreatesSpillDestinationNoReservation) { + Scoped_temp_dir relay_dir; + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold, relay_dir.path); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + // Strictly greater than the spill threshold -> SPILL path. + const std::size_t kBig = kSpillThreshold + 1; + Transaction_envelope *env = queue.enqueue(kBig, /*is_trx=*/true, make_fde()); + ASSERT_NE(env, nullptr); + EXPECT_EQ(env->path(), Envelope_path::SPILL); + EXPECT_EQ(env->stream_seqno(), 1u); + + // A payload and a reachable spill sink now exist. + ASSERT_NE(env->payload(), nullptr); + Streaming_event_sink *sink = env->current_sink(); + ASSERT_NE(sink, nullptr); + auto *spill = dynamic_cast(sink); + ASSERT_NE(spill, nullptr) << "spill enqueue must expose a spill sink"; + EXPECT_FALSE(spill->is_error()) << spill->get_error_str(); + EXPECT_FALSE(spill->spill_file_name().empty()); + + // Req 3.7 / 5.5: the spill path never charges the memory budget. + EXPECT_EQ(queue.bytes_used(), 0u); + EXPECT_EQ(env->payload()->byte_size(), static_cast(0)); + + drain_queue(queue, {env}); + EXPECT_EQ(queue.bytes_used(), 0u); +} + +// Task 6 demo: a mix of a small (memory) and a >threshold (spill) transaction +// both produce dispatchable envelopes with live sinks, source order is +// preserved, and only the memory transaction charges the byte counter. +TEST(ImrQueueFifoTest, EnqueueMixMemoryAndSpill) { + Scoped_temp_dir relay_dir; + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold, relay_dir.path); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + const std::size_t kSmall = 4096; // memory path + const std::size_t kBig = kSpillThreshold + 1; // spill path + + Transaction_envelope *mem = queue.enqueue(kSmall, /*is_trx=*/true, make_fde()); + Transaction_envelope *spl = queue.enqueue(kBig, /*is_trx=*/true, make_fde()); + ASSERT_NE(mem, nullptr); + ASSERT_NE(spl, nullptr); + + // Path classification and source ordering. + EXPECT_EQ(mem->path(), Envelope_path::MEMORY); + EXPECT_EQ(spl->path(), Envelope_path::SPILL); + EXPECT_EQ(mem->stream_seqno(), 1u); + EXPECT_EQ(spl->stream_seqno(), 2u); + + // Both have live, path-appropriate sinks. + ASSERT_NE(mem->current_sink(), nullptr); + ASSERT_NE(spl->current_sink(), nullptr); + EXPECT_NE(dynamic_cast(mem->current_sink()), + nullptr); + auto *spill = dynamic_cast(spl->current_sink()); + ASSERT_NE(spill, nullptr); + EXPECT_FALSE(spill->is_error()) << spill->get_error_str(); + + // Only the memory transaction charges the byte counter (Req 3.7 / 5.5). + EXPECT_EQ(queue.bytes_used(), kSmall); + + drain_queue(queue, {mem, spl}); + EXPECT_EQ(queue.bytes_used(), 0u); +} + +// Req 3.2: the is_trx flag handed to enqueue() propagates through to the +// created destination's Fetchable_transaction (false variant of the test +// above). +TEST(ImrQueueFifoTest, EnqueueIsTrxFalsePropagates) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + constexpr std::size_t kLen = 4096; + + Transaction_envelope *env = queue.enqueue(kLen, /*is_trx=*/false, make_fde()); + ASSERT_NE(env, nullptr); + EXPECT_EQ(env->path(), Envelope_path::MEMORY); + ASSERT_NE(env->payload(), nullptr); + EXPECT_EQ(queue.bytes_used(), kLen); + + Streaming_event_sink *sink = env->current_sink(); + ASSERT_NE(sink, nullptr); + EXPECT_NE(dynamic_cast(sink), nullptr); + + auto fetchable = env->payload()->fetchable(); + ASSERT_NE(fetchable, nullptr); + EXPECT_FALSE(fetchable->is_trx()); + + drain_queue(queue, {env}); +} + +// Req 3.4 (single-batch invariant): the enqueue-created destination is exactly +// ONE completed batch. Stream a few events into the sink, seal the byte stream +// on the terminal event, then drive the fetchable consumer to completion: the +// events come back in append order and the stream reaches a CLEAN end +// (is_fetching_done() true, is_fetching_error() false) — proving there is +// exactly one batch that terminates. create_memory() already sealed the +// (single-batch) metadata stream via set_fetching_complete(), so it is NOT +// called again here. +TEST(ImrQueueFifoTest, EnqueueSingleBatchDrainsCleanly) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + constexpr std::size_t kLen = 4096; + + Transaction_envelope *env = queue.enqueue(kLen, /*is_trx=*/true, make_fde()); + ASSERT_NE(env, nullptr); + Streaming_event_sink *sink = env->current_sink(); + ASSERT_NE(sink, nullptr); + // The sink is the memory batch; reach the event-oriented injection seam so + // the test can assert exact object identity on the way out (the byte-oriented + // append_event decodes fresh events and would lose identity). + auto *mem_sink = dynamic_cast(sink); + ASSERT_NE(mem_sink, nullptr); + + // Stream a few events into the still-open byte stream; seal on the last one. + constexpr int kEventCount = 3; + std::vector expected; + expected.reserve(kEventCount); + for (int i = 0; i < kEventCount; ++i) { + auto decoded = make_event(); + expected.push_back(decoded.get()); + const bool last = (i == kEventCount - 1); + mem_sink->append_reader_event(make_fake(decoded), /*seal_after=*/last); + } + + // Drive the consumer surface via the enqueue-created Fetchable_transaction. + ASSERT_NE(env->payload(), nullptr); + auto fetchable = env->payload()->fetchable(); + ASSERT_NE(fetchable, nullptr); + + std::vector fetched; + fetched.reserve(kEventCount); + while (fetchable->wait_next()) { + auto managed = fetchable->fetch_next(); + ASSERT_TRUE(managed.has_value()) + << "wait_next() returned true so fetch_next() must yield an event"; + fetched.push_back(managed->get_event().get()); + } + + // Events flow back in append order and the single batch ends cleanly. + EXPECT_EQ(fetched, expected); + EXPECT_TRUE(fetchable->is_fetching_done()); + EXPECT_FALSE(fetchable->is_fetching_error()); + + drain_queue(queue, {env}); +} + +// Req 3.6, 3.8: a stop() while a second enqueue is blocked in admission makes +// that enqueue return nullptr WITHOUT side effects — it emplaced no envelope +// (insert_seqno unchanged), created no destination, and reserved no bytes +// (bytes_used() unchanged). A small memory_limit forces the second enqueue to +// block; the spill threshold equals the limit so neither length routes SPILL. +TEST(ImrQueueFifoTest, EnqueueStopWhileBlockedReturnsNullptr) { + const std::size_t kSmallLimit = 4096; + // spill_threshold == limit, so a length up to the limit still classifies + // MEMORY (it is not > threshold) and admission — not spill — is what blocks. + Trx_envelope_queue queue(kSmallLimit, kSmallLimit); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + // First envelope fills the whole budget. + const std::size_t len1 = kSmallLimit; + Transaction_envelope *e1 = queue.enqueue(len1, /*is_trx=*/true, make_fde()); + ASSERT_NE(e1, nullptr); + ASSERT_EQ(queue.insert_seqno(), 1u); + ASSERT_EQ(queue.bytes_used(), len1); + + // Second envelope has no room: MEMORY-classified (<= threshold) but its + // reservation would exceed the limit, so it must block in acquire_admission. + const std::size_t len2 = 100; + std::promise result_promise; + std::future result_future = + result_promise.get_future(); + std::thread waiter([&] { + result_promise.set_value(queue.enqueue(len2, /*is_trx=*/true, make_fde())); + }); + + // Nothing has been released, so the second enqueue must still be blocked. + EXPECT_EQ(result_future.wait_for(kShortWait), std::future_status::timeout); + + // Stop unblocks the parked enqueue; it must abort and return nullptr. + queue.stop(); + ASSERT_EQ(result_future.wait_for(std::chrono::seconds(3)), + std::future_status::ready) + << "enqueue must return after stop()"; + EXPECT_EQ(result_future.get(), nullptr); + waiter.join(); + + // The blocked enqueue emplaced nothing, created no destination, reserved no + // bytes: both cursors and the byte counter are exactly as after the first. + EXPECT_EQ(queue.insert_seqno(), 1u); + EXPECT_EQ(queue.bytes_used(), len1); + + // Stopped queue: reset() drops e1 and restores the empty invariant. + queue.reset(); +} + +// --------------------------------------------------------------------------- +// Task 5.3 - Property test: cursor monotonicity and ordering. +// Validates: Requirements 3.3, 3.4 +// --------------------------------------------------------------------------- + +namespace { +/// Independent shadow model of one still-live (enqueued, not-yet-swept) +/// envelope, kept in FIFO order in a deque whose front mirrors the queue head. +struct EnvModel { + Transaction_envelope *ptr; ///< Pointer returned by enqueue (valid until swept). + std::uint64_t stream_seqno; ///< Expected stream_seqno (1-based). + std::size_t len; ///< Reserved bytes (for the final drain check). + bool committed; ///< Shadow commit flag. + bool dispatched; ///< Whether we have dispatched this envelope. +}; +} // namespace + +// Property 3 (Cursor monotonicity and ordering): over a randomized sequence of +// enqueue / dispatch / commit / sweep operations, after EVERY operation the +// invariant commit_seqno <= dispatch_seqno <= insert_seqno holds and each +// cursor is non-decreasing versus its previously observed value. As a stronger +// cross-check, commit_seqno always equals insert_seqno minus the number of +// envelopes still present in the deque (mirrored by the shadow model). +// +// dispatch_next() blocks when the queue is fully dispatched, so DISPATCH is +// guarded to fire only while dispatch_seqno < insert_seqno; the whole sequence +// therefore runs single-threaded without ever blocking. +TEST(ImrQueueFifoTest, PropertyCursorMonotonicity) { + // Fixed, deterministic seed so any failure reproduces exactly. + constexpr std::uint32_t kSeed = 0xF1F0C0DEu; + constexpr int kTrials = 300; + constexpr int kOpsPerTrial = 48; + + std::mt19937 rng(kSeed); + std::uniform_int_distribution op_dist(0, 3); // 4 operation kinds. + std::uniform_int_distribution len_dist(1, 4096); + + enum Op { kEnqueue = 0, kDispatch = 1, kCommit = 2, kSweep = 3 }; + + for (int trial = 0; trial < kTrials; ++trial) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + std::deque model; // front mirrors the queue head. + std::uint64_t next_stream = 0; + + std::uint64_t prev_commit = 0, prev_dispatch = 0, prev_insert = 0; + + for (int step = 0; step < kOpsPerTrial; ++step) { + switch (op_dist(rng)) { + case kEnqueue: { + const std::size_t len = len_dist(rng); + Transaction_envelope *env = queue.enqueue(len, true, make_fde()); + ASSERT_NE(env, nullptr) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + // enqueue() attaches the MEMORY-path payload, reserving len bytes. + ++next_stream; + ASSERT_EQ(env->stream_seqno(), next_stream) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + model.push_back({env, next_stream, len, false, false}); + break; + } + case kDispatch: { + // Guard: dispatch only when an undispatched envelope exists, so the + // call cannot block. + if (queue.dispatch_seqno() < queue.insert_seqno()) { + Transaction_envelope *env = queue.dispatch_next(); + ASSERT_NE(env, nullptr) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + // The next envelope to dispatch is the first not-yet-dispatched one + // in FIFO order. + auto it = std::find_if(model.begin(), model.end(), + [](const EnvModel &m) { return !m.dispatched; }); + ASSERT_NE(it, model.end()) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + EXPECT_EQ(env, it->ptr) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + EXPECT_EQ(env->stream_seqno(), it->stream_seqno) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + it->dispatched = true; + } + break; + } + case kCommit: { + // Commit the first dispatched-but-uncommitted envelope directly (no + // seal step in the commit-only model). + auto it = std::find_if(model.begin(), model.end(), [](const EnvModel &m) { + return m.dispatched && !m.committed; + }); + if (it != model.end()) { + ASSERT_FALSE(it->ptr->commit()) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + it->committed = true; + } + break; + } + case kSweep: { + ASSERT_FALSE(queue.sweep_committed()) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + // Mirror the sweep on the model: pop the contiguous committed prefix. + while (!model.empty() && model.front().committed) { + model.pop_front(); + } + break; + } + default: + FAIL() << "unreachable op"; + } + + // Invariant + monotonicity checks after EVERY operation. + const std::uint64_t c = queue.commit_seqno(); + const std::uint64_t d = queue.dispatch_seqno(); + const std::uint64_t i = queue.insert_seqno(); + + ASSERT_LE(c, d) << "seed=" << kSeed << " trial=" << trial + << " step=" << step << " (commit>dispatch)"; + ASSERT_LE(d, i) << "seed=" << kSeed << " trial=" << trial + << " step=" << step << " (dispatch>insert)"; + ASSERT_GE(c, prev_commit) << "seed=" << kSeed << " trial=" << trial + << " step=" << step << " (commit regressed)"; + ASSERT_GE(d, prev_dispatch) << "seed=" << kSeed << " trial=" << trial + << " step=" << step << " (dispatch regressed)"; + ASSERT_GE(i, prev_insert) << "seed=" << kSeed << " trial=" << trial + << " step=" << step << " (insert regressed)"; + // Strong cross-check: swept count == enqueued - still-present, and the + // encapsulated queue length matches the independently maintained model. + ASSERT_EQ(c, i - static_cast(model.size())) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + ASSERT_EQ(queue.queue_length(), model.size()) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + + prev_commit = c; + prev_dispatch = d; + prev_insert = i; + } + + // Dispatch anything still undispatched before committing (commit <= dispatch). + while (queue.dispatch_seqno() < queue.insert_seqno()) { + ASSERT_NE(queue.dispatch_next(), nullptr) + << "seed=" << kSeed << " trial=" << trial << " (drain dispatch)"; + } + + // Drain fully: commit every remaining envelope, then sweep it all. + for (EnvModel &m : model) { + if (!m.committed) { + ASSERT_FALSE(m.ptr->commit()) + << "seed=" << kSeed << " trial=" << trial << " (drain commit)"; + m.committed = true; + } + } + while (queue.commit_seqno() < queue.insert_seqno()) { + ASSERT_FALSE(queue.sweep_committed()) + << "seed=" << kSeed << " trial=" << trial << " (drain sweep)"; + } + ASSERT_EQ(queue.bytes_used(), 0u) + << "seed=" << kSeed << " trial=" << trial << " (drained)"; + } +} + +// --------------------------------------------------------------------------- +// Task 5.4 - Property test: FIFO dispatch and sweep. +// Validates: Requirements 3.5, 3.6, 6.3, 6.4 +// --------------------------------------------------------------------------- + +// Property 4 (FIFO dispatch and sweep): a batch is enqueued, then dispatched +// (which must return the envelopes in non-decreasing stream_seqno order +// 1..N == dispatch order), then committed in a RANDOM order +// (replica_preserve_commit_order=OFF semantics). After each commit + sweep, +// commit_seqno only advances over the contiguous committed prefix: a committed +// envelope behind an uncommitted head is retained until the head commits. An +// independent model of which stream_seqnos are committed predicts exactly how +// far commit_seqno advances and what bytes_used() should be. After all commits +// and the final sweep, commit_seqno == insert_seqno and bytes_used() == 0. +TEST(ImrQueueFifoTest, PropertyFifoDispatchAndSweep) { + // Fixed, deterministic seed so any failure reproduces exactly. + constexpr std::uint32_t kSeed = 0xD15A7C4Fu; + constexpr int kTrials = 400; + + std::mt19937 rng(kSeed); + std::uniform_int_distribution batch_dist(1, 8); + std::uniform_int_distribution len_dist(1, 4096); + + for (int trial = 0; trial < kTrials; ++trial) { + const int n = batch_dist(rng); + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + // Per-stream_seqno bookkeeping (indexed 1..n). Index 0 is unused. + std::vector envs(n + 1, nullptr); + std::vector lens(n + 1, 0); + std::vector committed(n + 1, false); + + // Enqueue the batch and attach a payload to each envelope. + std::size_t total_bytes = 0; + for (int s = 1; s <= n; ++s) { + const std::size_t len = len_dist(rng); + Transaction_envelope *env = queue.enqueue(len, true, make_fde()); + ASSERT_NE(env, nullptr) + << "seed=" << kSeed << " trial=" << trial << " s=" << s; + // enqueue() attaches the MEMORY-path payload, reserving len bytes. + envs[s] = env; + lens[s] = len; + total_bytes += len; + } + ASSERT_EQ(queue.insert_seqno(), static_cast(n)) + << "seed=" << kSeed << " trial=" << trial; + ASSERT_EQ(queue.bytes_used(), total_bytes) + << "seed=" << kSeed << " trial=" << trial; + + // Dispatch the whole batch: it must come back in stream_seqno order 1..n. + for (int s = 1; s <= n; ++s) { + ASSERT_LT(queue.dispatch_seqno(), queue.insert_seqno()) + << "seed=" << kSeed << " trial=" << trial << " s=" << s; + Transaction_envelope *env = queue.dispatch_next(); + ASSERT_NE(env, nullptr) + << "seed=" << kSeed << " trial=" << trial << " s=" << s; + EXPECT_EQ(env->stream_seqno(), static_cast(s)) + << "seed=" << kSeed << " trial=" << trial << " s=" << s + << " (dispatch out of FIFO order)"; + EXPECT_EQ(env, envs[s]) + << "seed=" << kSeed << " trial=" << trial << " s=" << s; + EXPECT_EQ(queue.dispatch_seqno(), static_cast(s)) + << "seed=" << kSeed << " trial=" << trial << " s=" << s; + } + ASSERT_EQ(queue.dispatch_seqno(), queue.insert_seqno()) + << "seed=" << kSeed << " trial=" << trial; + + // Commit the batch in a random order. + std::vector order(n); + for (int k = 0; k < n; ++k) order[k] = k + 1; + std::shuffle(order.begin(), order.end(), rng); + + std::size_t expected_bytes = total_bytes; + for (int k = 0; k < n; ++k) { + const int s = order[k]; + // Commit releases this envelope's bytes immediately (before any sweep). + ASSERT_FALSE(envs[s]->commit()) + << "seed=" << kSeed << " trial=" << trial << " s=" << s; + committed[s] = true; + expected_bytes -= lens[s]; + ASSERT_EQ(queue.bytes_used(), expected_bytes) + << "seed=" << kSeed << " trial=" << trial << " s=" << s; + + // Sweep advances commit_seqno only over the contiguous committed prefix. + ASSERT_FALSE(queue.sweep_committed()) + << "seed=" << kSeed << " trial=" << trial << " s=" << s; + std::uint64_t expected_commit = 0; + while (expected_commit < static_cast(n) && + committed[expected_commit + 1]) { + ++expected_commit; + } + ASSERT_EQ(queue.commit_seqno(), expected_commit) + << "seed=" << kSeed << " trial=" << trial << " s=" << s + << " (commit_seqno past the contiguous committed prefix)"; + } + + // Everything committed and swept: cursors meet and no bytes remain. + ASSERT_EQ(queue.commit_seqno(), queue.insert_seqno()) + << "seed=" << kSeed << " trial=" << trial; + ASSERT_EQ(queue.commit_seqno(), static_cast(n)) + << "seed=" << kSeed << " trial=" << trial; + ASSERT_EQ(queue.bytes_used(), 0u) + << "seed=" << kSeed << " trial=" << trial; + } +} + +// --------------------------------------------------------------------------- +// Task 3.5 - Property test: set-before-observable and single-batch. +// Property 1: R1 — current-sink set-before-observable +// Property 7: R7 — single-batch preserved by the receiver +// Validates: Requirements 3.1, 3.2, 3.4 +// --------------------------------------------------------------------------- + +// Over a randomized sequence of enqueue / commit / sweep operations, every +// envelope the test can observe from the pointer enqueue() returns already +// carries its destination and sink (R1: set-before-observable) — this holds +// structurally single-threaded because enqueue() creates and attaches the +// MEMORY-path destination (payload + Event_set_fetchable_memory sink) under +// m_queue_mutex before it returns/notifies, so there is no window in which a +// returned envelope lacks a payload/sink. Each observed envelope wraps exactly +// ONE memory batch (R7): the current sink IS the Event_set_fetchable_memory, +// the wrapped Fetchable_transaction round-trips the is_trx flag, and a subset +// of envelopes is driven through a single-batch drain that reaches a CLEAN end +// (is_fetching_done() && !is_fetching_error()) — a second batch would keep the +// stream from terminating. bytes_used() never exceeds memory_limit at any +// admission point (checked after every enqueue, commit, and sweep). +// +// memory_limit comfortably holds every envelope that can be live at once +// (kOpsPerTrial * kMaxLen, doubled for headroom), so a single-threaded enqueue +// never blocks in admission (no deadlock); commit/sweep are still exercised to +// drive byte release. Every trx_length stays <= the spill threshold so +// classify() always picks MEMORY. NO fixed sleeps. +TEST(ImrQueueFifoTest, PropertySetBeforeObservableAndSingleBatch) { + // Fixed, deterministic seed so any failure reproduces exactly. + constexpr std::uint32_t kSeed = 0x5E7B4C0Eu; + constexpr int kTrials = 200; + constexpr int kOpsPerTrial = 40; + + constexpr std::size_t kMinLen = 1; + constexpr std::size_t kMaxLen = 4096; + // Comfortably holds every envelope that can be live simultaneously, so a + // single-threaded enqueue never parks in admission (would deadlock). + constexpr std::size_t kMemLimit = + static_cast(kOpsPerTrial) * kMaxLen * 2; + // Every trx_length (<= kMaxLen) is <= the spill threshold, so classify() + // always routes MEMORY and never SPILL. + constexpr std::size_t kSpill = kMaxLen; + + std::mt19937 rng(kSeed); + std::uniform_int_distribution op_dist(0, 9); // enqueue-weighted below. + std::uniform_int_distribution len_dist(kMinLen, kMaxLen); + std::uniform_int_distribution bool_dist(0, 1); + std::uniform_int_distribution drive_dist(0, 3); // ~1 in 4 gets drained. + + for (int trial = 0; trial < kTrials; ++trial) { + Trx_envelope_queue queue(kMemLimit, kSpill); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + std::deque model; // front mirrors the queue head; live only. + std::uint64_t next_stream = 0; + + for (int step = 0; step < kOpsPerTrial; ++step) { + // Enqueue on 0..5 (60%), commit on 6..7, sweep on 8..9: mixing occasional + // commit/sweep in keeps memory moving without ever blocking admission. + const int op = op_dist(rng); + if (op <= 5) { + const std::size_t len = len_dist(rng); + const bool is_trx = (bool_dist(rng) == 1); + Transaction_envelope *env = queue.enqueue(len, is_trx, make_fde()); + ASSERT_NE(env, nullptr) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + ++next_stream; + ASSERT_EQ(env->stream_seqno(), next_stream) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + + // R1 (set-before-observable): the destination + sink are already + // present the moment the envelope is observable from the returned + // pointer — no observable window without a payload/sink. + ASSERT_NE(env->payload(), nullptr) + << "seed=" << kSeed << " trial=" << trial << " step=" << step + << " (payload missing on observable envelope)"; + Streaming_event_sink *sink = env->current_sink(); + ASSERT_NE(sink, nullptr) + << "seed=" << kSeed << " trial=" << trial << " step=" << step + << " (sink missing on observable envelope)"; + + // R7 (single memory batch): the current sink IS the memory batch. + ASSERT_NE(dynamic_cast(sink), nullptr) + << "seed=" << kSeed << " trial=" << trial << " step=" << step + << " (current sink is not the memory batch)"; + + // The wrapped Fetchable_transaction exists and round-trips is_trx. + auto fetchable = env->payload()->fetchable(); + ASSERT_NE(fetchable, nullptr) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + ASSERT_EQ(fetchable->is_trx(), is_trx) + << "seed=" << kSeed << " trial=" << trial << " step=" << step + << " (is_trx did not round-trip)"; + + // Drive a single-batch drain on a subset: stream one sealed event via + // the current sink, then consume to completion. A CLEAN end proves + // there is exactly ONE batch (a second batch would keep the stream from + // terminating). Kept on ~1/4 of envelopes to stay fast. + if (drive_dist(rng) == 0) { + dynamic_cast(sink)->append_reader_event( + make_fake(make_event()), /*seal_after=*/true); + while (fetchable->wait_next()) { + auto managed = fetchable->fetch_next(); + ASSERT_TRUE(managed.has_value()) + << "seed=" << kSeed << " trial=" << trial << " step=" << step + << " (wait_next true but fetch_next empty)"; + } + EXPECT_TRUE(fetchable->is_fetching_done()) + << "seed=" << kSeed << " trial=" << trial << " step=" << step + << " (single batch did not reach a clean end)"; + EXPECT_FALSE(fetchable->is_fetching_error()) + << "seed=" << kSeed << " trial=" << trial << " step=" << step + << " (single batch ended in error)"; + } + + // This suite exercises set-before-observable and single-batch, not + // dispatch ordering, but an envelope must be dispatched before commit + // (commit_seqno <= dispatch_seqno). Dispatch it immediately. + ASSERT_EQ(queue.dispatch_next(), env) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + model.push_back({env, next_stream, len, false, false}); + } else if (op <= 7) { + // Commit a random still-live, uncommitted envelope (out-of-order commit + // is allowed); its bytes release immediately. + std::vector uncommitted; + for (std::size_t k = 0; k < model.size(); ++k) { + if (!model[k].committed) uncommitted.push_back(k); + } + if (!uncommitted.empty()) { + std::uniform_int_distribution pick( + 0, uncommitted.size() - 1); + EnvModel &m = model[uncommitted[pick(rng)]]; + ASSERT_FALSE(m.ptr->commit()) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + m.committed = true; + } + } else { + // Sweep: pop the contiguous committed head prefix from both the queue + // and the shadow model. + ASSERT_FALSE(queue.sweep_committed()) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + while (!model.empty() && model.front().committed) { + model.pop_front(); + } + } + + // Memory bound: bytes_used() never exceeds the limit at any point. + ASSERT_LE(queue.bytes_used(), kMemLimit) + << "seed=" << kSeed << " trial=" << trial << " step=" << step + << " (bytes_used exceeded memory_limit)"; + } + + // Fully drain: commit every still-live envelope, then sweep the whole + // committed prefix. Only the not-yet-swept envelopes remain in the model, + // so their pointers are valid; commit-before-sweep is honored by + // drain_queue. + std::vector live; + live.reserve(model.size()); + for (const EnvModel &m : model) live.push_back(m.ptr); + drain_queue(queue, live); + ASSERT_EQ(queue.bytes_used(), 0u) + << "seed=" << kSeed << " trial=" << trial << " (drained)"; + ASSERT_EQ(queue.commit_seqno(), queue.insert_seqno()) + << "seed=" << kSeed << " trial=" << trial << " (drained)"; + } +} + +// --------------------------------------------------------------------------- +// Restart re-dispatch: sweep_and_dispatch() re-serves uncommitted envelopes in +// source order and SKIPS any that committed out of source order in the prior +// session (reset payload). +// --------------------------------------------------------------------------- + +// sweep_and_dispatch() (the coordinator entry point) skips an envelope that +// committed out of source order (reset payload) and returns the next +// uncommitted one, advancing the dispatch cursor past the skipped one. The skip +// is localized in the queue here (not the reader), so a committed envelope is +// never handed to the coordinator payload-less. +TEST(ImrQueueFifoTest, SweepAndDispatchSkipsCommittedOutOfOrder) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + Transaction_envelope *e1 = queue.enqueue(11, true, make_fde()); + Transaction_envelope *e2 = queue.enqueue(22, true, make_fde()); + Transaction_envelope *e3 = queue.enqueue(33, true, make_fde()); + ASSERT_NE(e1, nullptr); + ASSERT_NE(e2, nullptr); + ASSERT_NE(e3, nullptr); + + // e1 dispatched (head, uncommitted); e2 committed out of order behind it, so + // its payload is reset to null. The dispatch cursor now points at e2. + ASSERT_EQ(queue.dispatch_next(), e1); + ASSERT_FALSE(e2->commit()); + ASSERT_EQ(e2->payload(), nullptr); + ASSERT_EQ(queue.dispatch_seqno(), 1u); + + // sweep_and_dispatch() skips the committed e2 and returns e3, advancing the + // dispatch cursor past BOTH. + Transaction_envelope *got = queue.sweep_and_dispatch(); + ASSERT_EQ(got, e3); + EXPECT_NE(got->payload(), nullptr) << "a dispatched envelope must have a payload"; + EXPECT_EQ(queue.dispatch_seqno(), 3u); + + // Drain: commit the still-uncommitted e1 and e3 (e2 already committed), then + // sweep the whole prefix so the queue dtor's empty/bytes==0 invariant holds. + ASSERT_FALSE(e1->commit()); + ASSERT_FALSE(e3->commit()); + ASSERT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.bytes_used(), 0u); +} + +// NOTE: dispatch_next() itself does NOT skip already-committed envelopes -- it +// returns whatever sits at dispatch_seqno and advances the cursor. The skip of +// out-of-order-committed envelopes on restart is done by sweep_and_dispatch() +// (the coordinator entry point, tested just above), so the low-level +// dispatch_next() primitive stays lock-simple. The end-to-end skip through the +// reader is covered in imr_queued_transaction_reader-t.cc. + +// NOTE: sweep_committed()'s guard never advancing commit_seqno past +// dispatch_seqno is covered in imr_queue_lifecycle-t.cc +// (SweepDoesNotAdvanceCommitPastDispatch). + +} // namespace mysql::csa::unittests diff --git a/unittest/gunit/changestreams/imr_queue_lifecycle-t.cc b/unittest/gunit/changestreams/imr_queue_lifecycle-t.cc new file mode 100644 index 000000000000..f6c6def31ee2 --- /dev/null +++ b/unittest/gunit/changestreams/imr_queue_lifecycle-t.cc @@ -0,0 +1,531 @@ +/* Copyright (c) 2026, Oracle and/or its affiliates. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License, version 2.0, + as published by the Free Software Foundation. + + This program is designed to work with certain software (including + but not limited to OpenSSL) that is licensed under separate terms, + as designated in a particular file or component or in included license + documentation. The authors of MySQL hereby grant you an additional + permission to link the program and your derivative works with the + separately licensed software that they have either included with + the program or referenced in the documentation. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License, version 2.0, for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +/// @file +/// Unit tests for the queue-reuse lifecycle primitives of +/// mysql::csa::Trx_envelope_queue: reset(), the scoped stop()/resume() wakes, +/// is_stopped(), and the role stop-flags used as attach state. These are the +/// additive core extensions that let one queue instance live with Master_info +/// and be reused across receiver/applier sessions (tasks 7.1, 7.3, 7.4); the +/// Master_info-owned lifecycle wiring itself (CREATE at CRST, resume() at start, +/// reset() after a full stop, the semisync gate) is driven from rpl_replica.cc +/// / rpl_mi.cc and is covered by MTR integration tests, not this unit harness. +/// +/// The queue rests STOPPED for both roles at construction (the mi-owned model: +/// the queue exists before any thread attaches), so every test that enqueues or +/// dispatches arms it first with resume(). + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sql/changestreams/apply/jobs/fetchable_transaction.h" +#include "sql/changestreams/apply/storage/in_memory/in_memory_types.h" +#include "sql/changestreams/apply/storage/in_memory/transaction_envelope.h" +#include "sql/changestreams/apply/storage/in_memory/trx_envelope_queue.h" +#include "sql/changestreams/apply/storage/in_memory/trx_payload.h" +#include "sql/log_event.h" + +namespace mysql::csa::unittests { + +namespace { +using Scope = Trx_envelope_queue::Scope; + +/// A short bounded wait used to observe that a background call is (or is not) +/// still blocked, without relying on a fixed sleep for correctness. +constexpr std::chrono::milliseconds kShortWait{50}; + +/// A generous wait used only on the success path, where the call is expected to +/// have already returned; it just bounds a hang so a regression fails fast. +constexpr std::chrono::seconds kJoinWait{3}; + +/// Memory bounds large enough that enqueue never blocks in admission (unless a +/// test deliberately saturates the budget with add_bytes): the limit dwarfs any +/// total the tests reserve, and the spill threshold dwarfs any single +/// transaction length, so every enqueue takes the (non-blocking) MEMORY path. +constexpr std::size_t kMemoryLimit = std::size_t{1} << 30; // 1 GiB +constexpr std::size_t kSpillThreshold = std::size_t{1} << 20; // 1 MiB + +/// Build the active FDE that every enqueue() must be handed; enqueue() only +/// asserts it non-null (these tests never create a real byte source). +std::shared_ptr make_fde() { + return std::make_shared(); +} +} // namespace + +// --------------------------------------------------------------------------- +// Role stop-flags as attach state (task 7.4). +// Requirements 7.5, 7.6, 7.12 +// --------------------------------------------------------------------------- + +// A freshly constructed queue rests stopped for BOTH roles: it exists before +// any thread attaches, so is_stopped() (both stopped) is true. +TEST(ImrQueueLifecycleTest, FreshQueueIsStopped) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + EXPECT_TRUE(queue.is_stopped()); + // Empty and unused: nothing to drain before destruction. +} + +// resume() (ALL) arms both roles so is_stopped() is false; stop() (ALL) stops +// both so is_stopped() is true again. resume() is idempotent. +TEST(ImrQueueLifecycleTest, ResumeAllArmsStopAllStops) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + + queue.resume(); + EXPECT_FALSE(queue.is_stopped()); + queue.resume(); // idempotent: still armed. + EXPECT_FALSE(queue.is_stopped()); + + queue.stop(); + EXPECT_TRUE(queue.is_stopped()); +} + +// Arming a SINGLE role leaves is_stopped() false (that role is attached), and +// stopping just that role returns to is_stopped() true. This is the +// "only one role ever started" case: the never-armed role stays stopped, so +// stopping the one armed role makes is_stopped() true (the reset trigger). +TEST(ImrQueueLifecycleTest, SingleRoleAttachDetachTogglesIsStopped) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + ASSERT_TRUE(queue.is_stopped()); + + queue.resume(Scope::RECEIVER); // receiver attached, applier never armed. + EXPECT_FALSE(queue.is_stopped()); + + queue.stop(Scope::RECEIVER); // last (only) armed role detaches. + EXPECT_TRUE(queue.is_stopped()); +} + +// With both roles armed, stopping ONE role is a single-thread stop: +// is_stopped() stays false (the other role is still attached, queue kept live). +// Stopping the last armed role makes is_stopped() true (full stop / reset). +TEST(ImrQueueLifecycleTest, LastRoleStopMakesIsStopped) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // both roles armed. + ASSERT_FALSE(queue.is_stopped()); + + queue.stop(Scope::APPLIER); // single-thread stop: applier only. + EXPECT_FALSE(queue.is_stopped()) << "receiver still armed -> not fully stopped"; + + queue.stop(Scope::RECEIVER); // now the last armed role stops. + EXPECT_TRUE(queue.is_stopped()); +} + +// --------------------------------------------------------------------------- +// resume() clears a prior scoped stop() (tasks 7.1, 7.3). +// Requirements 7.1, 7.2, 7.3 +// --------------------------------------------------------------------------- + +// A receiver-scoped stop makes the MEMORY-path enqueue fail (its admission +// bails); resume(RECEIVER) clears that so a later enqueue is admitted again. +TEST(ImrQueueLifecycleTest, ResumeReceiverReenablesEnqueue) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + queue.stop(Scope::RECEIVER); + // Admission bails on the receiver stop, so enqueue reports failure (nullptr) + // and reserves nothing. + EXPECT_EQ(queue.enqueue(11, true, make_fde()), nullptr); + EXPECT_EQ(queue.bytes_used(), 0u); + + queue.resume(Scope::RECEIVER); + Transaction_envelope *env = queue.enqueue(11, true, make_fde()); + ASSERT_NE(env, nullptr); + EXPECT_EQ(env->stream_seqno(), 1u); + + queue.reset(); // drop the live envelope; back to the pristine empty state. +} + +// An applier-scoped stop makes dispatch_next() return nullptr even with an +// undispatched envelope present; resume(APPLIER) clears that so the same +// envelope dispatches. +TEST(ImrQueueLifecycleTest, ResumeApplierReenablesDispatch) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + Transaction_envelope *env = queue.enqueue(11, true, make_fde()); + ASSERT_NE(env, nullptr); + + queue.stop(Scope::APPLIER); + EXPECT_EQ(queue.dispatch_next(), nullptr); + EXPECT_EQ(queue.dispatch_seqno(), 0u) << "a stopped dispatch advances nothing"; + + queue.resume(Scope::APPLIER); + Transaction_envelope *dispatched = queue.dispatch_next(); + ASSERT_EQ(dispatched, env); + EXPECT_EQ(queue.dispatch_seqno(), 1u); + + queue.reset(); +} + +// --------------------------------------------------------------------------- +// reset() drains and zeroes, and the queue is reusable afterwards (task 7.1). +// Requirements 7.1, 7.7 +// --------------------------------------------------------------------------- + +// reset() drops every leftover envelope (even dispatched / committed ones), +// zeroes the three cursors and returns bytes_used() to 0. It does not touch the +// role stop-flags. +TEST(ImrQueueLifecycleTest, ResetDrainsAndZeroesCursors) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + Transaction_envelope *e1 = queue.enqueue(11, true, make_fde()); + ASSERT_NE(e1, nullptr); + ASSERT_NE(queue.enqueue(22, true, make_fde()), nullptr); + ASSERT_NE(queue.enqueue(33, true, make_fde()), nullptr); + ASSERT_EQ(queue.insert_seqno(), 3u); + ASSERT_EQ(queue.queue_length(), 3u); + ASSERT_EQ(queue.bytes_used(), 66u); + + // Advance the dispatch and commit cursors so reset() has non-zero cursors to + // clear: dispatch and commit+sweep the head. + ASSERT_EQ(queue.dispatch_next(), e1); + ASSERT_FALSE(e1->commit()); + ASSERT_FALSE(queue.sweep_committed()); + ASSERT_EQ(queue.commit_seqno(), 1u); + ASSERT_EQ(queue.dispatch_seqno(), 1u); + + queue.reset(); + + EXPECT_EQ(queue.commit_seqno(), 0u); + EXPECT_EQ(queue.dispatch_seqno(), 0u); + EXPECT_EQ(queue.insert_seqno(), 0u); + EXPECT_EQ(queue.queue_length(), 0u); + EXPECT_EQ(queue.bytes_used(), 0u); + // reset() leaves the attach state alone: both roles were armed, still armed. + EXPECT_FALSE(queue.is_stopped()); +} + +// After reset() the queue is reusable: enqueue restarts stream_seqno at 1 and +// the cursors track a fresh session. +TEST(ImrQueueLifecycleTest, ResetLeavesQueueReusable) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + ASSERT_NE(queue.enqueue(11, true, make_fde()), nullptr); + ASSERT_NE(queue.enqueue(22, true, make_fde()), nullptr); + ASSERT_EQ(queue.insert_seqno(), 2u); + + queue.reset(); + ASSERT_EQ(queue.insert_seqno(), 0u); + ASSERT_EQ(queue.bytes_used(), 0u); + + // Reuse: a new enqueue starts a fresh numbering from 1. + Transaction_envelope *again = queue.enqueue(44, true, make_fde()); + ASSERT_NE(again, nullptr); + EXPECT_EQ(again->stream_seqno(), 1u); + EXPECT_EQ(queue.insert_seqno(), 1u); + + queue.reset(); +} + +// --------------------------------------------------------------------------- +// Scoped wake: one role stops without poisoning the other (task 7.3). +// Requirements 7.3, 7.5 +// --------------------------------------------------------------------------- + +// stop(APPLIER) wakes a coordinator parked in dispatch_next() (it returns +// nullptr) while the receiver keeps admitting: is_stopped() stays false and a +// concurrent-role enqueue still succeeds. +TEST(ImrQueueLifecycleTest, StopApplierWakesDispatchReceiverKeepsAdmitting) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + std::promise got; + std::future fut = got.get_future(); + // Park a consumer in dispatch_next() on the empty queue. + std::thread consumer([&] { got.set_value(queue.dispatch_next()); }); + + // It must still be blocked: the queue is empty and the applier is armed. + ASSERT_EQ(fut.wait_for(kShortWait), std::future_status::timeout); + + queue.stop(Scope::APPLIER); + ASSERT_EQ(fut.wait_for(kJoinWait), std::future_status::ready) + << "stop(APPLIER) must wake the parked dispatch_next()"; + EXPECT_EQ(fut.get(), nullptr) << "a stopped dispatch returns nullptr"; + consumer.join(); + + // The applier is stopped but the receiver is not: not fully stopped, and the + // receiver can still admit and enqueue. + EXPECT_FALSE(queue.is_stopped()); + Transaction_envelope *env = queue.enqueue(11, true, make_fde()); + EXPECT_NE(env, nullptr) << "stop(APPLIER) must not poison the receiver"; + + queue.reset(); +} + +// stop(RECEIVER) wakes an IO thread parked in acquire_admission() (it returns +// failure) while the applier keeps dispatching: is_stopped() stays false and a +// concurrent-role dispatch of an already-enqueued envelope still succeeds. +TEST(ImrQueueLifecycleTest, StopReceiverWakesAdmissionApplierKeepsDispatching) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + // One dispatchable envelope enqueued before the budget is saturated. + Transaction_envelope *env = queue.enqueue(11, true, make_fde()); + ASSERT_NE(env, nullptr); + + // Saturate the budget so the next admission blocks. This reservation is not + // tied to any payload, so it must be released by hand before reset(). + queue.add_bytes(kMemoryLimit); + + std::promise failed; + std::future fut = failed.get_future(); + // Park a producer in acquire_admission(): the budget is full, so it blocks. + std::thread producer([&] { failed.set_value(queue.acquire_admission(100)); }); + + ASSERT_EQ(fut.wait_for(kShortWait), std::future_status::timeout); + + queue.stop(Scope::RECEIVER); + ASSERT_EQ(fut.wait_for(kJoinWait), std::future_status::ready) + << "stop(RECEIVER) must wake the parked acquire_admission()"; + EXPECT_TRUE(fut.get()) << "a stopped admission reports failure"; + producer.join(); + + // The receiver is stopped but the applier is not: not fully stopped, and the + // applier can still dispatch the envelope enqueued earlier. + EXPECT_FALSE(queue.is_stopped()); + EXPECT_EQ(queue.dispatch_next(), env) + << "stop(RECEIVER) must not poison the applier"; + + queue.release_bytes(kMemoryLimit); // undo the manual saturation. + queue.reset(); // drop env and its reserved bytes. +} + +// stop(ALL) wakes BOTH a parked dispatch_next() and a parked acquire_admission() +// at once. +TEST(ImrQueueLifecycleTest, StopAllWakesBothWaiters) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + // Saturate so the producer blocks in admission; queue empty so the consumer + // blocks in dispatch. + queue.add_bytes(kMemoryLimit); + + std::promise admission_failed; + std::future admission_fut = admission_failed.get_future(); + std::thread producer( + [&] { admission_failed.set_value(queue.acquire_admission(100)); }); + + std::promise dispatched; + std::future dispatch_fut = dispatched.get_future(); + std::thread consumer([&] { dispatched.set_value(queue.dispatch_next()); }); + + // Both must still be parked. + ASSERT_EQ(admission_fut.wait_for(kShortWait), std::future_status::timeout); + ASSERT_EQ(dispatch_fut.wait_for(kShortWait), std::future_status::timeout); + + queue.stop(); // stop(ALL): wake both roles. + + ASSERT_EQ(admission_fut.wait_for(kJoinWait), std::future_status::ready) + << "stop(ALL) must wake the parked acquire_admission()"; + ASSERT_EQ(dispatch_fut.wait_for(kJoinWait), std::future_status::ready) + << "stop(ALL) must wake the parked dispatch_next()"; + EXPECT_TRUE(admission_fut.get()); + EXPECT_EQ(dispatch_fut.get(), nullptr); + producer.join(); + consumer.join(); + + EXPECT_TRUE(queue.is_stopped()); + + queue.release_bytes(kMemoryLimit); // undo the manual saturation. + queue.reset(); // queue is already empty; bytes -> 0. +} + +// sweep_committed()'s guard must never advance commit_seqno past dispatch_seqno, +// even when contiguous committed envelopes sit AHEAD of the dispatch cursor -- +// the situation that arises when an out-of-order commit lands right after a +// dispatched head. The sweep stops at the dispatch cursor and reclaims the rest +// only as dispatch advances. +TEST(ImrQueueLifecycleTest, SweepDoesNotAdvanceCommitPastDispatch) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + Transaction_envelope *e1 = queue.enqueue(11, true, make_fde()); + Transaction_envelope *e2 = queue.enqueue(22, true, make_fde()); + ASSERT_NE(e1, nullptr); + ASSERT_NE(e2, nullptr); + + // Dispatch only e1, but commit BOTH e1 and e2 -- e2 is committed "ahead" of + // the dispatch cursor, mimicking an out-of-order commit sitting past the + // rewound dispatch_seqno. + ASSERT_EQ(queue.dispatch_next(), e1); + ASSERT_EQ(queue.dispatch_seqno(), 1u); + ASSERT_FALSE(e1->commit()); + ASSERT_FALSE(e2->commit()); + + // Sweep reclaims e1 (commit 0 -> 1) but must STOP there: sweeping e2 would + // push commit_seqno (2) past dispatch_seqno (1). + EXPECT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.commit_seqno(), 1u); + EXPECT_EQ(queue.dispatch_seqno(), 1u); + EXPECT_LE(queue.commit_seqno(), queue.dispatch_seqno()); + EXPECT_EQ(queue.queue_length(), 1u); // e2 still queued + + // Once e2 is dispatched (dispatch == 2), the next sweep may reclaim it. + ASSERT_EQ(queue.dispatch_next(), e2); + ASSERT_EQ(queue.dispatch_seqno(), 2u); + EXPECT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.commit_seqno(), 2u); + EXPECT_LE(queue.commit_seqno(), queue.dispatch_seqno()); + EXPECT_EQ(queue.bytes_used(), 0u); +} + +// --------------------------------------------------------------------------- +// Truncated envelopes are reclaimed by the sweep and skip-dispatched (task 4). +// --------------------------------------------------------------------------- + +// A dispatched envelope marked truncated is a terminal state the sweep +// reclaims exactly like a committed one: sweep_committed() drops it, advances +// the commit low-water mark, and releases its reserved bytes (at sweep, since +// truncation -- unlike commit -- does not reset the payload). +TEST(ImrQueueLifecycleTest, SweepReclaimsDispatchedTruncatedHead) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + Transaction_envelope *e1 = queue.enqueue(11, true, make_fde()); + ASSERT_NE(e1, nullptr); + ASSERT_EQ(queue.bytes_used(), 11u); + + // Dispatch it, then truncate it (models a worker that was streaming-applying + // when the receiver truncated the transaction). + ASSERT_EQ(queue.dispatch_next(), e1); + ASSERT_EQ(queue.dispatch_seqno(), 1u); + e1->set_truncated(); + ASSERT_TRUE(e1->is_truncated()); + // Truncation alone does not release the payload; the bytes are still charged. + ASSERT_EQ(queue.bytes_used(), 11u); + + // The sweep reclaims the truncated head just like a committed one. + EXPECT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.commit_seqno(), 1u); + EXPECT_EQ(queue.queue_length(), 0u); + EXPECT_EQ(queue.bytes_used(), 0u); // released at sweep (envelope dropped) +} + +// A truncated envelope that was NEVER dispatched is reclaimed via skip-dispatch: +// sweep_and_dispatch() advances the dispatch cursor past it (creating no worker +// for it), the follow-up sweep drops it, and the next live transaction is +// returned. This is the path that keeps the applier progressing after an +// IO-only stop truncates the open transaction. +TEST(ImrQueueLifecycleTest, SweepAndDispatchSkipsUndispatchedTruncatedAndReturnsNext) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + Transaction_envelope *e1 = queue.enqueue(11, true, make_fde()); + Transaction_envelope *e2 = queue.enqueue(22, true, make_fde()); + ASSERT_NE(e1, nullptr); + ASSERT_NE(e2, nullptr); + ASSERT_EQ(queue.bytes_used(), 33u); + + // Truncate e1 before it is ever dispatched. + e1->set_truncated(); + + // One coordinator step: e1 is skip-dispatched + reclaimed, e2 is returned. + Transaction_envelope *dispatched = queue.sweep_and_dispatch(); + EXPECT_EQ(dispatched, e2) << "the truncated head must be skipped, next returned"; + + // e1 reclaimed (commit advanced past it), e2 dispatched, cursors consistent. + EXPECT_EQ(queue.commit_seqno(), 1u); + EXPECT_EQ(queue.dispatch_seqno(), 2u); + EXPECT_LE(queue.commit_seqno(), queue.dispatch_seqno()); + EXPECT_EQ(queue.queue_length(), 1u); // only e2 remains + EXPECT_EQ(queue.bytes_used(), 22u); // e1's 11 released, e2's 22 still held + + queue.reset(); // drop e2 and its reserved bytes. +} + +// The sweep reclaims a contiguous head prefix that mixes both terminal states: +// committed and truncated envelopes are dropped together, in order, and every +// reserved byte is released. +TEST(ImrQueueLifecycleTest, SweepReclaimsContiguousCommittedAndTruncated) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + Transaction_envelope *e1 = queue.enqueue(11, true, make_fde()); + Transaction_envelope *e2 = queue.enqueue(22, true, make_fde()); + Transaction_envelope *e3 = queue.enqueue(33, true, make_fde()); + ASSERT_NE(e1, nullptr); + ASSERT_NE(e2, nullptr); + ASSERT_NE(e3, nullptr); + + // Dispatch all three, then finalize with a mix: commit, truncate, commit. + ASSERT_EQ(queue.dispatch_next(), e1); + ASSERT_EQ(queue.dispatch_next(), e2); + ASSERT_EQ(queue.dispatch_next(), e3); + ASSERT_FALSE(e1->commit()); + e2->set_truncated(); + ASSERT_FALSE(e3->commit()); + + // One sweep drops the whole finalized prefix e1..e3. + EXPECT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.commit_seqno(), 3u); + EXPECT_EQ(queue.queue_length(), 0u); + EXPECT_EQ(queue.bytes_used(), 0u); +} + +// Lifetime: the sweep may destroy a truncated envelope while an in-flight +// worker still references the transaction via its shared_ptr. +// Dropping the envelope releases the payload bytes but must not invalidate the +// still-held byte source. The truncated path never calls the sink's +// set_success() (the only m_envelope deref), so the dangling back-pointer is +// never followed -- exercised here by keeping the fetchable alive across the +// sweep and using it afterwards. +TEST(ImrQueueLifecycleTest, SweepTruncatedWhileWorkerHoldsFetchableNoUseAfterFree) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + Transaction_envelope *e1 = queue.enqueue(11, true, make_fde()); + ASSERT_NE(e1, nullptr); + ASSERT_NE(e1->payload(), nullptr); + + // A worker would hold a shared-ownership copy of the byte source; take one + // here so it outlives the envelope the sweep is about to destroy. + std::shared_ptr worker_ref = e1->payload()->fetchable(); + ASSERT_NE(worker_ref, nullptr); + + ASSERT_EQ(queue.dispatch_next(), e1); + e1->set_truncated(); + + // Sweep destroys the envelope (and its payload) -- e1 dangles now. + EXPECT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.commit_seqno(), 1u); + EXPECT_EQ(queue.queue_length(), 0u); + EXPECT_EQ(queue.bytes_used(), 0u); // payload bytes released exactly once + + // The byte source is still alive through worker_ref and safely usable; this + // touches the sink WITHOUT going through set_success()/m_envelope. + EXPECT_TRUE(worker_ref->is_trx()); + + worker_ref.reset(); // last reference: byte source destroyed cleanly. +} + +} // namespace mysql::csa::unittests diff --git a/unittest/gunit/changestreams/imr_queued_transaction_reader-t.cc b/unittest/gunit/changestreams/imr_queued_transaction_reader-t.cc new file mode 100644 index 000000000000..a1fde690edaa --- /dev/null +++ b/unittest/gunit/changestreams/imr_queued_transaction_reader-t.cc @@ -0,0 +1,298 @@ +/* Copyright (c) 2026, Oracle and/or its affiliates. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License, version 2.0, + as published by the Free Software Foundation. + + This program is designed to work with certain software (including + but not limited to OpenSSL) that is licensed under separate terms, + as designated in a particular file or component or in included license + documentation. The authors of MySQL hereby grant you an additional + permission to link the program and your derivative works with the + separately licensed software that they have either included with + the program or referenced in the documentation. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License, version 2.0, for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ +#include + +#include +#include + +#include "mysql/scheduler/statistics_map.h" +#include "sql/changestreams/apply/jobs/fetchable_transaction.h" +#include "sql/changestreams/apply/jobs/job.h" +#include "sql/changestreams/apply/resource/statistics_map.h" +#include "sql/changestreams/apply/storage/common/streaming_event_sink.h" +#include "sql/changestreams/apply/storage/in_memory/event_set_fetchable_memory.h" +#include "sql/changestreams/apply/storage/in_memory/queued_transaction_reader.h" +#include "sql/changestreams/apply/storage/in_memory/transaction_envelope.h" +#include "sql/changestreams/apply/storage/in_memory/trx_envelope_queue.h" +#include "sql/changestreams/apply/storage/in_memory/trx_payload.h" +#include "sql/changestreams/apply/storage/relay_log/ireader_event.h" +#include "sql/log_event.h" + +namespace mysql::csa::unittests { + +namespace { + +/// A fake encoded event: it hands back a preset, already-decoded Log_event when +/// the byte source asks it to decode(). This lets a single event be streamed +/// into the enqueue-created sink so the Job_binlog ctor's first-event peek +/// (wait_next()) returns immediately instead of blocking on an empty stream. +class Fake_reader_event : public IReader_event { + public: + explicit Fake_reader_event(std::shared_ptr decoded) + : m_decoded(std::move(decoded)) {} + + std::shared_ptr decode() override { return m_decoded; } + + // Never re-read with reset_events=true in these tests, so a no-op suffices. + void reset(const Format_description_log_event *) override {} + + private: + std::shared_ptr m_decoded; +}; + +/// A body event served by the stream. A non-GTID event is fine: +/// Job_binlog::restart_internal dynamic_casts to Gtid_log_event*, gets nullptr, +/// and simply skips setting the gtid — no crash. +std::shared_ptr make_event() { + return std::make_shared(); +} + +/// Wrap a decoded Log_event into a fake encoded IReader_event entry. +IReader_event_ptr make_fake(std::shared_ptr decoded) { + return std::make_shared(std::move(decoded)); +} + +} // namespace + +// Generous per-channel bounds so enqueue() always takes the MEMORY path and +// never blocks in acquire_admission(). +constexpr std::size_t kMemoryLimit = 1u << 20; // 1 MiB +constexpr std::size_t kSpillThreshold = 1u << 20; // 1 MiB +constexpr std::size_t kTrxLength = 128; // fits the memory path + +/// Build the active FDE that every enqueue() must be handed. enqueue() takes +/// the precise std::shared_ptr (the exact type +/// Master_info::get_mi_description_event_shared() returns), so no upcast is +/// needed at the call site. +std::shared_ptr make_fde() { + return std::make_shared(); +} + +/// @brief Fixture that exercises the REAL Queued_transaction_reader::read() +/// path through the queue-only test constructor. +/// +/// This class is declared a friend of Queued_transaction_reader, so its member +/// functions (and, through make_reader(), the TEST_F bodies) may construct a +/// reader wired to nothing but a Trx_envelope_queue. m_channel/m_rli stay null; +/// read() still drains the queue, copies the dispatched Fetchable_transaction, +/// and builds a real Job_applier with a null Channel*. +class Queued_transaction_reader_test : public ::testing::Test { + protected: + void SetUp() override { + // Statistics_monitor::get(0) / Resource_monitor::get(0) — used by the + // reader's monitor reference members — need the instance-0 statistics maps + // initialized first (mirrors rpl_applier_monitor-t.cc). + std::ignore = scheduler::Statistics_map::init_statistics(0); + std::ignore = csa::Statistics_map::init_statistics(0, 1, false); + } + + // Builds a reader via the private queue-only constructor. Legal here because + // this fixture is a friend of Queued_transaction_reader. + std::unique_ptr make_reader( + Trx_envelope_queue *queue) { + return std::unique_ptr( + new Queued_transaction_reader(queue)); + } + + // Enqueue one OPEN memory-path envelope. enqueue() itself creates the empty + // single-batch destination and attaches the payload (reserving kTrxLength + // bytes), so the returned envelope already owns a Fetchable_transaction + // reachable via env->payload()->fetchable(). Returns the envelope + // (non-owning). + Transaction_envelope *enqueue_memory(Trx_envelope_queue *queue) { + Transaction_envelope *env = queue->enqueue(kTrxLength, true, make_fde()); + EXPECT_NE(env, nullptr); + return env; + } + + // Commits the envelope and sweeps it off the head so the queue destructor's + // "no live payloads / bytes_used()==0" invariant holds when the local queue + // goes out of scope. + void drain(Trx_envelope_queue *queue, Transaction_envelope *env) { + EXPECT_FALSE(env->commit()); + EXPECT_FALSE(queue->sweep_committed()); + } +}; + +// 8.2 — read() builds a Job wired to the dispatched transaction, advances +// dispatch_seqno, and performs no decode. +TEST_F(Queued_transaction_reader_test, ReadBuildsJobAndAdvancesCursor) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + Transaction_envelope *env = enqueue_memory(&queue); + ASSERT_NE(env, nullptr); + // Use the enqueue-created Fetchable_transaction (a shared_ptr copy). + ASSERT_NE(env->payload(), nullptr); + auto fetchable = env->payload()->fetchable(); + ASSERT_NE(fetchable, nullptr); + + // Stream ONE event into the enqueue-created sink before read(). The + // Job_binlog ctor peeks the first event via wait_next() (is_trx() is true); + // without a buffered event that peek would block forever on the empty, open + // stream. Do NOT seal: leaving the stream open keeps is_fetching_done() false + // after the peek's reset_fetching(false). + ASSERT_NE(env->current_sink(), nullptr); + static_cast(env->current_sink()) + ->append_reader_event(make_fake(make_event())); + + auto reader = make_reader(&queue); + EXPECT_EQ(queue.dispatch_seqno(), 0u); + + Job_ptr job = reader->read(); + ASSERT_NE(job, nullptr); + // The dispatch cursor advanced past the single envelope. + EXPECT_EQ(queue.dispatch_seqno(), 1u); + // read() must not decode: the transaction was never driven, so it is neither + // done nor errored (no events were consumed). + EXPECT_FALSE(fetchable->is_fetching_done()); + EXPECT_FALSE(fetchable->is_fetching_error()); + + delete job; + drain(&queue, env); +} + +// 8.2 — after stop(), read() returns no job and leaves dispatch_seqno +// unchanged (it never reaches the queue hand-off). +TEST_F(Queued_transaction_reader_test, ReadAfterStopReturnsNullAndKeepsCursor) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + Transaction_envelope *env = enqueue_memory(&queue); + ASSERT_NE(env, nullptr); + + auto reader = make_reader(&queue); + reader->stop(); + EXPECT_TRUE(reader->is_stopped()); + + Job_ptr job = reader->read(); + EXPECT_EQ(job, nullptr); + EXPECT_EQ(queue.dispatch_seqno(), 0u); + + drain(&queue, env); +} + +// 8.3 — payload lifetime under dispatch: the shared_ptr the reader hands to the +// Job_applier keeps the Fetchable_transaction alive while the job holds it, +// independent of the envelope's own payload reference. +TEST_F(Queued_transaction_reader_test, ReadKeepsFetchableAliveViaJob) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + Transaction_envelope *env = enqueue_memory(&queue); + ASSERT_NE(env, nullptr); + // Take an independent shared_ptr copy of the enqueue-created + // Fetchable_transaction (payload()->fetchable() returns by value). + ASSERT_NE(env->payload(), nullptr); + auto fetchable = env->payload()->fetchable(); + ASSERT_NE(fetchable, nullptr); + + // Stream ONE event into the enqueue-created sink before read() so the + // Job_binlog ctor's first-event peek (wait_next()) returns immediately + // instead of blocking on the empty, open stream. Appending an event does not + // change the Fetchable_transaction use_count, so the reference-count + // assertions below still hold. Left unsealed on purpose. + ASSERT_NE(env->current_sink(), nullptr); + static_cast(env->current_sink()) + ->append_reader_event(make_fake(make_event())); + + auto reader = make_reader(&queue); + + // Our own copy (1) + the envelope payload's copy (1). + const long before = fetchable.use_count(); + EXPECT_EQ(before, 2); + + Job_ptr job = reader->read(); + ASSERT_NE(job, nullptr); + // The job now holds an extra reference to the same Fetchable_transaction. + EXPECT_EQ(fetchable.use_count(), before + 1); + + // Commit drops the envelope's payload reference; the job's copy must keep the + // Fetchable_transaction alive on its own. + EXPECT_FALSE(env->commit()); + EXPECT_EQ(env->payload(), nullptr); + EXPECT_EQ(fetchable.use_count(), before); // ours + job's copy + + // Deleting the job releases its reference; only our copy remains. + delete job; + EXPECT_EQ(fetchable.use_count(), 1); + + EXPECT_FALSE(queue.sweep_committed()); +} + +// Restart re-dispatch skip: read() skips an envelope that committed out of +// source order in a prior session (payload reset) and dispatches the next +// uncommitted one instead. The skip lives here on the reader side, off the +// queue lock, so the queue's dispatch primitive never takes a per-envelope lock +// while holding the queue lock. +TEST_F(Queued_transaction_reader_test, ReadSkipsCommittedOutOfOrderEnvelope) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + Transaction_envelope *e1 = enqueue_memory(&queue); + Transaction_envelope *e2 = enqueue_memory(&queue); + Transaction_envelope *e3 = enqueue_memory(&queue); + ASSERT_NE(e1, nullptr); + ASSERT_NE(e2, nullptr); + ASSERT_NE(e3, nullptr); + + // e3 is the one that will be handed to a job, so give it a first event so the + // Job_binlog ctor peek (wait_next()) returns immediately. e2 is skipped + // before any job is built, so it needs no event. + ASSERT_NE(e3->current_sink(), nullptr); + static_cast(e3->current_sink()) + ->append_reader_event(make_fake(make_event())); + + // Prior session: e1 dispatched (head, uncommitted); e2 committed out of order + // behind the uncommitted head, its payload reset to null. dispatch_seqno now + // points at e2. + ASSERT_EQ(queue.dispatch_next(), e1); + ASSERT_FALSE(e2->commit()); + ASSERT_EQ(e2->payload(), nullptr); + ASSERT_EQ(queue.dispatch_seqno(), 1u); + + // Track e3's transaction so we can prove the job wraps e3, not the skipped e2. + ASSERT_NE(e3->payload(), nullptr); + auto f3 = e3->payload()->fetchable(); + ASSERT_NE(f3, nullptr); + const long before = f3.use_count(); // ours + e3's payload == 2 + + auto reader = make_reader(&queue); + Job_ptr job = reader->read(); + ASSERT_NE(job, nullptr); + + // read() skipped the committed e2 and dispatched e3: the cursor advanced past + // BOTH, and the job holds a reference to e3's transaction. + EXPECT_EQ(queue.dispatch_seqno(), 3u); + EXPECT_EQ(f3.use_count(), before + 1) << "the job must wrap e3, not e2"; + + delete job; + EXPECT_EQ(f3.use_count(), before); + + // Drain: commit the still-uncommitted e1 and e3 (e2 already committed), then + // sweep the whole prefix so the queue dtor's empty/bytes==0 invariant holds. + EXPECT_FALSE(e1->commit()); + EXPECT_FALSE(e3->commit()); + EXPECT_FALSE(queue.sweep_committed()); + EXPECT_EQ(queue.bytes_used(), 0u); +} + +} // namespace mysql::csa::unittests diff --git a/unittest/gunit/changestreams/imr_queued_transaction_writer-t.cc b/unittest/gunit/changestreams/imr_queued_transaction_writer-t.cc new file mode 100644 index 000000000000..296293f38b9f --- /dev/null +++ b/unittest/gunit/changestreams/imr_queued_transaction_writer-t.cc @@ -0,0 +1,538 @@ +/* Copyright (c) 2026, Oracle and/or its affiliates. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License, version 2.0, + as published by the Free Software Foundation. + + This program is designed to work with certain software (including + but not limited to OpenSSL) that is licensed under separate terms, + as designated in a particular file or component or in included license + documentation. The authors of MySQL hereby grant you an additional + permission to link the program and your derivative works with the + separately licensed software that they have either included with + the program or referenced in the documentation. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License, version 2.0, for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +/// @file +/// Unit tests (tasks.md task 4.2) for the minimal-state receiver-side +/// mysql::csa::queued_transaction_writer mechanism. The three streaming +/// operations the receiver drives — open_transaction (at the GTID event), +/// append_transaction_event (per body / terminal event), and +/// truncate_transaction (on an incomplete group) — are exercised against a REAL +/// Trx_envelope_queue plus a LOCAL `Streaming_event_sink *current_sink` — +/// deliberately NO Master_info — which is exactly how the mechanism is +/// parameterized so it can be tested without any receiver coupling. +/// +/// Unlike the sibling byte-source tests (which feed fake IReader_events whose +/// decode() returns a preset Log_event), these tests drive the REAL decode +/// path: append_transaction_event forwards the transient bytes to the sink, +/// which copies them into an internal Cached_event_memory whose decode() runs +/// binlog_event_deserialize when the consumer drains the sink. To +/// keep decode() valid and self-contained, each "event" is a genuinely +/// serialized Format_description_log_event: FDE bytes are self-describing for +/// the checksum algorithm (binlog_event_deserialize reads the alg from the FDE +/// body itself, not from the caller's fde), and a default server FDE serializes +/// with checksum OFF, so the round-trip needs no THD and no checksum plumbing. +/// Draining every appended event also decodes it, transferring buffer ownership +/// to the produced Log_event (freed via my_free) — the exact ownership handoff +/// the byte-copy is designed to match, with no leak / double-free. + +#include + +#include +#include +#include +#include +#include + +#include "sql/basic_ostream.h" // StringBuffer_ostream +#include "sql/changestreams/apply/jobs/fetchable_transaction.h" +#include "sql/changestreams/apply/storage/common/streaming_event_sink.h" +#include "sql/changestreams/apply/storage/in_memory/queued_transaction_writer.h" +#include "sql/changestreams/apply/storage/in_memory/in_memory_types.h" +#include "sql/changestreams/apply/storage/in_memory/transaction_envelope.h" +#include "sql/changestreams/apply/storage/in_memory/trx_envelope_queue.h" +#include "sql/changestreams/apply/storage/in_memory/trx_payload.h" +#include "sql/log_event.h" + +namespace mysql::csa::unittests { + +namespace { + +// Generous per-channel bounds so enqueue() always takes the MEMORY path and +// never blocks in acquire_admission(). kTrxLength must be at or below the spill +// threshold so classify() picks MEMORY. +constexpr std::size_t kMemoryLimit = 1u << 20; // 1 MiB +constexpr std::size_t kSpillThreshold = 1u << 16; // 64 KiB +constexpr std::size_t kTrxLength = 4096; + +/// Build the active FDE handed to the ops. The exact type matches what +/// Master_info::get_mi_description_event_shared() returns in the real receiver. +std::shared_ptr make_fde() { + return std::make_shared(); +} + +/// Serialize a real Format_description_log_event into a byte buffer, mimicking +/// the transient network bytes the receiver would hand to append_event. A +/// default server FDE writes with checksum OFF and is self-describing on +/// decode, so the round-trip through Cached_event_payload::decode() succeeds +/// without a THD or checksum plumbing. +std::vector serialize_event() { + Format_description_log_event ev; + StringBuffer_ostream<1024> os; + EXPECT_FALSE(ev.write(&os)) << "serializing the FDE must succeed"; + const auto *p = reinterpret_cast(os.ptr()); + return std::vector(p, p + os.length()); +} + +const char *as_char(const std::vector &v) { + return reinterpret_cast(v.data()); +} + +/// Drain a Fetchable_transaction's consumer surface to completion, returning +/// the number of events served. Every drained event is decoded (its owning +/// buffer handed to the Log_event and freed), so this also proves the +/// byte-copy ownership handoff is clean. +int drain(const std::shared_ptr &fetchable) { + int count = 0; + while (fetchable->wait_next()) { + auto managed = fetchable->fetch_next(); + EXPECT_TRUE(managed.has_value()) + << "wait_next() returned true so fetch_next() must yield an event"; + if (!managed.has_value()) break; + ++count; + } + return count; +} + +} // namespace + +// open: open_transaction admits a memory-path transaction and publishes the +// enqueue-created destination's sink through current_sink. +TEST(QueuedTransactionWriterTest, OpenGroupPublishesSink) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + Streaming_event_sink *current_sink = nullptr; + ASSERT_FALSE(open_transaction(queue, current_sink, make_fde(), kTrxLength)); + ASSERT_NE(current_sink, nullptr); + // enqueue() reserved exactly kTrxLength bytes for the memory-path payload. + EXPECT_EQ(queue.bytes_used(), kTrxLength); + + // Seal the (empty) stream so the consumer terminates cleanly, then commit and + // sweep so the queue destructor invariant (empty deque, bytes_used()==0) + // holds. + current_sink->seal_stream(); + Transaction_envelope *env = queue.dispatch_next(); + ASSERT_NE(env, nullptr); + ASSERT_NE(env->payload(), nullptr); + auto fetchable = env->payload()->fetchable(); + ASSERT_NE(fetchable, nullptr); + EXPECT_EQ(drain(fetchable), 0); + fetchable->set_success(); + EXPECT_EQ(queue.bytes_used(), 0u); + EXPECT_FALSE(queue.sweep_committed()); +} + +// open-after-stop: once the queue is stopped, open_transaction reports the +// queuing-error indication (true) and leaves current_sink null. No envelope is +// created, so the queue destructor invariant holds untouched. +TEST(QueuedTransactionWriterTest, OpenGroupAfterStopReportsErrorAndLeavesSinkNull) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + queue.stop(); + + // Start from a deliberately non-null sentinel to prove the op clears it. + auto *sentinel = reinterpret_cast(0x1); + Streaming_event_sink *current_sink = sentinel; + + EXPECT_TRUE(open_transaction(queue, current_sink, make_fde(), kTrxLength)); + EXPECT_EQ(current_sink, nullptr); + EXPECT_EQ(queue.bytes_used(), 0u); +} + +// append order + terminal seal: after opening, appending a couple of +// non-terminal events then a terminal one seals the stream exactly once, clears +// current_sink to null, and the consumer observes the events in order followed +// by a clean end-of-stream. +TEST(QueuedTransactionWriterTest, AppendOrderAndTerminalSeal) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + Streaming_event_sink *current_sink = nullptr; + ASSERT_FALSE(open_transaction(queue, current_sink, make_fde(), kTrxLength)); + ASSERT_NE(current_sink, nullptr); + + const auto e1 = serialize_event(); + const auto e2 = serialize_event(); + const auto e3 = serialize_event(); + + EXPECT_FALSE(append_transaction_event(current_sink, as_char(e1), e1.size(), + /*is_terminal=*/false)); + EXPECT_NE(current_sink, nullptr); + EXPECT_FALSE(append_transaction_event(current_sink, as_char(e2), e2.size(), + /*is_terminal=*/false)); + EXPECT_NE(current_sink, nullptr); + // Terminal append seals the stream and clears the sink. + EXPECT_FALSE(append_transaction_event(current_sink, as_char(e3), e3.size(), + /*is_terminal=*/true)); + EXPECT_EQ(current_sink, nullptr); + + // Drive the consumer: all three events come back, then a clean end. + Transaction_envelope *env = queue.dispatch_next(); + ASSERT_NE(env, nullptr); + ASSERT_NE(env->payload(), nullptr); + auto fetchable = env->payload()->fetchable(); + ASSERT_NE(fetchable, nullptr); + + EXPECT_EQ(drain(fetchable), 3); + EXPECT_TRUE(fetchable->is_fetching_done()); + EXPECT_FALSE(fetchable->is_fetching_error()); + + // Commit + sweep so the queue destructor invariant holds. + fetchable->set_success(); + EXPECT_TRUE(env->is_committed()); + EXPECT_EQ(queue.bytes_used(), 0u); + EXPECT_FALSE(queue.sweep_committed()); +} + +// atomic-DDL single event: a group opened by open_transaction and terminated by +// a SINGLE terminal append (the sole body-and-terminal event, mirroring an +// atomic-DDL Query_log_event) seals the stream exactly once and clears +// current_sink. The consumer drains exactly one event, then reaches a clean +// end-of-stream — one event both opened and terminated the group. +TEST(QueuedTransactionWriterTest, AtomicDdlSingleEventOpensAndSealsOnce) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + Streaming_event_sink *current_sink = nullptr; + ASSERT_FALSE(open_transaction(queue, current_sink, make_fde(), kTrxLength)); + ASSERT_NE(current_sink, nullptr); + + // The sole event is both the body and the terminal event: appending it seals + // the stream and clears the sink in one step. + const auto e = serialize_event(); + EXPECT_FALSE(append_transaction_event(current_sink, as_char(e), e.size(), + /*is_terminal=*/true)); + EXPECT_EQ(current_sink, nullptr); + + // Drive the consumer: exactly one event comes back, then a clean end. + Transaction_envelope *env = queue.dispatch_next(); + ASSERT_NE(env, nullptr); + ASSERT_NE(env->payload(), nullptr); + auto fetchable = env->payload()->fetchable(); + ASSERT_NE(fetchable, nullptr); + + EXPECT_EQ(drain(fetchable), 1); + EXPECT_TRUE(fetchable->is_fetching_done()); + EXPECT_FALSE(fetchable->is_fetching_error()); + + // Commit + sweep so the queue destructor invariant holds. + fetchable->set_success(); + EXPECT_TRUE(env->is_committed()); + EXPECT_EQ(queue.bytes_used(), 0u); + EXPECT_FALSE(queue.sweep_committed()); +} + +// append defensive: append_event with a null current_sink is a no-op that +// returns true (error). +TEST(QueuedTransactionWriterTest, AppendWithNullSinkReturnsError) { + Streaming_event_sink *current_sink = nullptr; + const auto e = serialize_event(); + EXPECT_TRUE(append_transaction_event(current_sink, as_char(e), e.size(), + /*is_terminal=*/false)); + EXPECT_EQ(current_sink, nullptr); +} + +// truncate: after opening and appending one event, truncate_transaction marks +// the transaction truncated at BOTH levels -- the batch stream AND the owning +// Fetchable_transaction -- and clears current_sink. The consumer therefore +// stops immediately: it delivers no buffered partial events and surfaces +// truncation (is_truncated), NOT a clean done and NOT an error. That is what +// drives the worker's is_truncated() rollback-and-replay branch and prevents a +// buffered event from being mis-treated as the transaction's terminal event. A +// second truncate_transaction on a null current_sink is a no-op. +TEST(QueuedTransactionWriterTest, TruncateClearsSinkAndConsumerObservesTruncation) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + Streaming_event_sink *current_sink = nullptr; + ASSERT_FALSE(open_transaction(queue, current_sink, make_fde(), kTrxLength)); + ASSERT_NE(current_sink, nullptr); + + const auto e1 = serialize_event(); + EXPECT_FALSE(append_transaction_event(current_sink, as_char(e1), e1.size(), + /*is_terminal=*/false)); + + // Truncate the still-open group: batch + transaction marked truncated, sink + // cleared. + truncate_transaction(current_sink); + EXPECT_EQ(current_sink, nullptr); + + // truncate_transaction on a null sink is a no-op (no crash, stays null). + truncate_transaction(current_sink); + EXPECT_EQ(current_sink, nullptr); + + // The consumer stops immediately on truncation: no buffered event is + // delivered, and it surfaces truncation rather than a clean done. + Transaction_envelope *env = queue.dispatch_next(); + ASSERT_NE(env, nullptr); + ASSERT_NE(env->payload(), nullptr); + auto fetchable = env->payload()->fetchable(); + ASSERT_NE(fetchable, nullptr); + + EXPECT_EQ(drain(fetchable), 0); + EXPECT_TRUE(fetchable->is_truncated()); + EXPECT_FALSE(fetchable->is_fetching_done()); + EXPECT_FALSE(fetchable->is_fetching_error()); + + // Commit + sweep so the queue destructor invariant holds (the worker's + // success hook commits the rolled-back transaction as an empty unit). + fetchable->set_success(); + EXPECT_EQ(queue.bytes_used(), 0u); + EXPECT_FALSE(queue.sweep_committed()); +} + +// --------------------------------------------------------------------------- +// Task 4.3 - Property test: seal-exactly-once. +// Property 2: R2 — seal-exactly-once. +// Validates: Requirements 2.4 +// --------------------------------------------------------------------------- + +// Property 2 (seal-exactly-once): over many deterministically-seeded trials, +// each trial opens a group, appends a RANDOM number n of non-terminal events, +// then a SINGLE terminal append — the one and only seal. That terminal append +// clears current_sink to null (the observable "sealed" signal). To prove no +// append succeeds after the seal, the pre-seal sink pointer is captured and a +// couple of further appends are driven into it via a local handle (bypassing +// the mechanism's null guard): because the sink is sealed, its byte source +// drops each event, so none is delivered. Draining the consumer must then yield +// exactly the n body events + 1 terminal (the post-seal appends delivered +// nothing) and reach a clean end-of-stream — demonstrating the seal happened +// exactly once and nothing was appended after it. +TEST(QueuedTransactionWriterTest, PropertySealExactlyOnce) { + // Fixed, deterministic seed so any failure reproduces exactly. + constexpr std::uint32_t kSeed = 0x5EA10CE5u; + constexpr int kTrials = 200; + constexpr int kMaxBodyEvents = 6; // K: random body-event count is 0..K. + + std::mt19937 rng(kSeed); + std::uniform_int_distribution n_dist(0, kMaxBodyEvents); + + for (int trial = 0; trial < kTrials; ++trial) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + // Open the group: success (false) and a published, non-null sink. + Streaming_event_sink *current_sink = nullptr; + ASSERT_FALSE(open_transaction(queue, current_sink, make_fde(), kTrxLength)) + << "seed=" << kSeed << " trial=" << trial; + ASSERT_NE(current_sink, nullptr) << "seed=" << kSeed << " trial=" << trial; + + // Append a random number of non-terminal (body) events. + const int n = n_dist(rng); + for (int step = 0; step < n; ++step) { + const auto body = serialize_event(); + ASSERT_FALSE(append_transaction_event(current_sink, as_char(body), + body.size(), + /*is_terminal=*/false)) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + ASSERT_NE(current_sink, nullptr) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + } + + // Capture the sink pointer BEFORE the seal so post-seal appends can target + // it directly (the mechanism nulls current_sink on the terminal event). + Streaming_event_sink *sealed_sink = current_sink; + + // The single terminal append is the one and only seal. + const auto terminal = serialize_event(); + ASSERT_FALSE(append_transaction_event(current_sink, as_char(terminal), + terminal.size(), + /*is_terminal=*/true)) + << "seed=" << kSeed << " trial=" << trial; + // Sealing cleared the sink handle (observable "sealed" signal). + ASSERT_EQ(current_sink, nullptr) << "seed=" << kSeed << " trial=" << trial; + + // Prove no append succeeds after the seal: drive a couple more appends into + // the sealed sink via a local handle that bypasses the null guard. The + // mechanism reports success (its handle is non-null) but the sealed byte + // source no-ops each append, so nothing is delivered; is_terminal=false + // keeps the local handle set. + for (int post = 0; post < 2; ++post) { + Streaming_event_sink *p = sealed_sink; + const auto extra = serialize_event(); + ASSERT_FALSE(append_transaction_event(p, as_char(extra), extra.size(), + /*is_terminal=*/false)) + << "seed=" << kSeed << " trial=" << trial << " post-seal=" << post; + } + + // Drive the consumer: exactly the n body events + 1 terminal come back, the + // post-seal appends delivered nothing, and the stream ends cleanly. + Transaction_envelope *env = queue.dispatch_next(); + ASSERT_NE(env, nullptr) << "seed=" << kSeed << " trial=" << trial; + ASSERT_NE(env->payload(), nullptr) + << "seed=" << kSeed << " trial=" << trial; + auto fetchable = env->payload()->fetchable(); + ASSERT_NE(fetchable, nullptr) << "seed=" << kSeed << " trial=" << trial; + + EXPECT_EQ(drain(fetchable), n + 1) + << "seed=" << kSeed << " trial=" << trial; + EXPECT_TRUE(fetchable->is_fetching_done()) + << "seed=" << kSeed << " trial=" << trial; + EXPECT_FALSE(fetchable->is_fetching_error()) + << "seed=" << kSeed << " trial=" << trial; + + // Commit + sweep so the queue destructor invariant (empty deque, + // bytes_used()==0) holds this trial. + fetchable->set_success(); + EXPECT_EQ(queue.bytes_used(), 0u) + << "seed=" << kSeed << " trial=" << trial; + ASSERT_FALSE(queue.sweep_committed()) + << "seed=" << kSeed << " trial=" << trial; + } +} + +// --------------------------------------------------------------------------- +// Task 4.4 - Property test: truncate-on-incomplete and current-sink clearing. +// Property 3: R3 — truncate-on-incomplete. +// Property 4: R4 — current-sink cleared at group end. +// Validates: Requirements 4.1, 2.4, 7.4 +// --------------------------------------------------------------------------- + +// Property 3 + 4: over many deterministically-seeded trials, each trial opens a +// group, appends a RANDOM number n of non-terminal events, then randomly ends +// the group one of two ways: +// mode A: a terminal append (normal completion), or +// mode B: truncate_transaction() (models rotate/incomplete/error/stop mid- +// transaction). +// In BOTH modes current_sink must be null afterward (R4: cleared at group end), +// and it is asserted null at the start of the trial (outside any open group) — +// i.e. it is non-null ONLY between open and terminate/truncate. For mode B, a +// repeat truncate on the now-null sink is a no-op (stays null) and a post- +// truncate append into the truncated sink (via a captured local handle) +// delivers nothing — proving exactly one truncation took effect (R3). Draining +// the consumer then differs by mode: mode A (terminal) delivers all n+1 events +// and reaches a clean done; mode B (truncate) marks the transaction truncated, +// so the consumer stops immediately (delivers nothing) and surfaces truncation +// (is_truncated, not done and not error) — the signal that drives the worker's +// rollback-and-replay branch. +TEST(QueuedTransactionWriterTest, PropertyTruncateOnIncompleteAndSinkCleared) { + // Fixed, deterministic seed so any failure reproduces exactly. + constexpr std::uint32_t kSeed = 0x7C0FFEE1u; + constexpr int kTrials = 200; + constexpr int kMaxBodyEvents = 6; // K: random body-event count is 0..K. + + std::mt19937 rng(kSeed); + std::uniform_int_distribution n_dist(0, kMaxBodyEvents); + std::uniform_int_distribution mode_dist(0, 1); // 0=terminal, 1=truncate. + + for (int trial = 0; trial < kTrials; ++trial) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + // R4: current_sink is null outside any open group (start of trial). + Streaming_event_sink *current_sink = nullptr; + ASSERT_EQ(current_sink, nullptr) << "seed=" << kSeed << " trial=" << trial; + + ASSERT_FALSE(open_transaction(queue, current_sink, make_fde(), kTrxLength)) + << "seed=" << kSeed << " trial=" << trial; + ASSERT_NE(current_sink, nullptr) << "seed=" << kSeed << " trial=" << trial; + + // Append a random number of non-terminal (body) events. + const int n = n_dist(rng); + for (int step = 0; step < n; ++step) { + const auto body = serialize_event(); + ASSERT_FALSE(append_transaction_event(current_sink, as_char(body), + body.size(), + /*is_terminal=*/false)) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + } + + const bool mode_truncate = (mode_dist(rng) == 1); + int expected_count = 0; + if (!mode_truncate) { + // Mode A: normal termination via the terminal event. + const auto terminal = serialize_event(); + ASSERT_FALSE(append_transaction_event(current_sink, as_char(terminal), + terminal.size(), + /*is_terminal=*/true)) + << "seed=" << kSeed << " trial=" << trial << " (mode A)"; + // R4: cleared at group end. + ASSERT_EQ(current_sink, nullptr) + << "seed=" << kSeed << " trial=" << trial << " (mode A)"; + expected_count = n + 1; // n body events + 1 terminal. + } else { + // Mode B: rotate/incomplete/error/stop mid-transaction → truncate. + // Capture the sink pointer BEFORE truncation for the post-truncate probe. + Streaming_event_sink *truncated_sink = current_sink; + truncate_transaction(current_sink); + // R4: cleared at group end. + ASSERT_EQ(current_sink, nullptr) + << "seed=" << kSeed << " trial=" << trial << " (mode B)"; + // A repeat truncate on the now-null sink is a no-op (stays null). + truncate_transaction(current_sink); + ASSERT_EQ(current_sink, nullptr) + << "seed=" << kSeed << " trial=" << trial << " (mode B repeat)"; + // A post-truncate append into the truncated sink delivers nothing — + // exactly one truncation took effect (R3). + Streaming_event_sink *p = truncated_sink; + const auto extra = serialize_event(); + ASSERT_FALSE(append_transaction_event(p, as_char(extra), extra.size(), + /*is_terminal=*/false)) + << "seed=" << kSeed << " trial=" << trial << " (post-truncate)"; + // Truncation marks the transaction truncated, so the consumer stops + // immediately: no buffered partial event is delivered. + expected_count = 0; + } + + // R4: current_sink is null after the group ends, in BOTH modes. + ASSERT_EQ(current_sink, nullptr) << "seed=" << kSeed << " trial=" << trial; + + // Drive the consumer. Mode A (terminal) delivers all n+1 events and reaches + // a clean done. Mode B (truncate) delivers nothing and surfaces truncation + // (not done, not error) so the worker takes its rollback-and-replay branch. + Transaction_envelope *env = queue.dispatch_next(); + ASSERT_NE(env, nullptr) << "seed=" << kSeed << " trial=" << trial; + ASSERT_NE(env->payload(), nullptr) + << "seed=" << kSeed << " trial=" << trial; + auto fetchable = env->payload()->fetchable(); + ASSERT_NE(fetchable, nullptr) << "seed=" << kSeed << " trial=" << trial; + + EXPECT_EQ(drain(fetchable), expected_count) + << "seed=" << kSeed << " trial=" << trial + << " mode=" << (mode_truncate ? "truncate" : "terminal"); + if (mode_truncate) { + EXPECT_TRUE(fetchable->is_truncated()) + << "seed=" << kSeed << " trial=" << trial << " (mode B)"; + EXPECT_FALSE(fetchable->is_fetching_done()) + << "seed=" << kSeed << " trial=" << trial << " (mode B)"; + } else { + EXPECT_TRUE(fetchable->is_fetching_done()) + << "seed=" << kSeed << " trial=" << trial << " (mode A)"; + EXPECT_FALSE(fetchable->is_truncated()) + << "seed=" << kSeed << " trial=" << trial << " (mode A)"; + } + EXPECT_FALSE(fetchable->is_fetching_error()) + << "seed=" << kSeed << " trial=" << trial; + + // Commit + sweep so the queue destructor invariant (empty deque, + // bytes_used()==0) holds this trial. + fetchable->set_success(); + EXPECT_EQ(queue.bytes_used(), 0u) + << "seed=" << kSeed << " trial=" << trial; + ASSERT_FALSE(queue.sweep_committed()) + << "seed=" << kSeed << " trial=" << trial; + } +} + +} // namespace mysql::csa::unittests diff --git a/unittest/gunit/changestreams/imr_spill_writer-t.cc b/unittest/gunit/changestreams/imr_spill_writer-t.cc new file mode 100644 index 000000000000..d164951fd74e --- /dev/null +++ b/unittest/gunit/changestreams/imr_spill_writer-t.cc @@ -0,0 +1,260 @@ +/* Copyright (c) 2026, Oracle and/or its affiliates. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License, version 2.0, + as published by the Free Software Foundation. + + This program is designed to work with certain software (including + but not limited to OpenSSL) that is licensed under separate terms, + as designated in a particular file or component or in included license + documentation. The authors of MySQL hereby grant you an additional + permission to link the program and your derivative works with the + separately licensed software that they have either included with + the program or referenced in the documentation. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License, version 2.0, for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +/// @file +/// Unit tests for mysql::csa::Spill_file_writer (tasks.md task 2): the private +/// on-disk spill-file writer. The writer provisions a spill file under an +/// "in_memory_relaylog_temp_files" subdirectory of the channel's relay log +/// directory (FR17), names it "imr_sp_" (FR24), lays down +/// the relay-log prefix (BINLOG_MAGIC + serialized FDE), appends raw event +/// bytes, flushes without fsync, tracks a monotonically advancing end position, +/// and deletes the file on destruction. These tests exercise the write side +/// only by reading the file's raw bytes back (the Relaylog_file_reader +/// round-trip is covered by the read-side task). + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "my_inttypes.h" +#include "sql/changestreams/apply/storage/in_memory/spill_file_writer.h" +#include "sql/log_event.h" // Format_description_log_event, BINLOG_MAGIC + +namespace mysql::csa::unittests { + +namespace fs = std::filesystem; + +namespace { + +/// Reads a whole file into a byte vector. Returns empty on open failure. +std::vector read_whole_file(const std::string &name) { + std::ifstream in(name, std::ios::binary); + if (!in.good()) return {}; + return std::vector(std::istreambuf_iterator(in), + std::istreambuf_iterator()); +} + +/// A fresh relay-log FDE shared_ptr, matching how the sink holds its FDE. +std::shared_ptr make_fde() { + return std::make_shared(); +} + +/// Serialize a real Format_description_log_event into bytes, standing in for +/// the raw wire bytes of an event the receiver would append. A default server +/// FDE serializes with checksum OFF and needs no THD. +std::vector serialize_event() { + Format_description_log_event ev; + StringBuffer_ostream<1024> os; + EXPECT_FALSE(ev.write(&os)) << "serializing a stand-in event must succeed"; + const auto *p = reinterpret_cast(os.ptr()); + return std::vector(p, p + os.length()); +} + +const char *as_char(const std::vector &v) { + return reinterpret_cast(v.data()); +} + +bool is_all_lowercase(const std::string &s) { + for (unsigned char c : s) { + if (std::isupper(c)) return false; + } + return true; +} + +} // namespace + +/// Fixture that gives each test a private, empty "relay log directory" and +/// tears the whole tree down afterwards (including the spill files and the +/// temp-files subdirectory the writer creates inside it). +class ImrSpillWriterTest : public ::testing::Test { + protected: + void SetUp() override { + static std::atomic counter{0}; + m_relay_log_dir = + (fs::temp_directory_path() / + ("imr_relaylog_" + std::to_string(::getpid()) + "_" + + std::to_string(counter.fetch_add(1)))) + .string(); + fs::create_directories(m_relay_log_dir); + } + + void TearDown() override { + std::error_code ec; + fs::remove_all(m_relay_log_dir, ec); + } + + std::string m_relay_log_dir; +}; + +// --------------------------------------------------------------------------- +// FR17 / FR24: location and naming. +// --------------------------------------------------------------------------- + +// The spill file lives in "/in_memory_relaylog_temp_files/" and +// is named "imr_sp_". +TEST_F(ImrSpillWriterTest, PlacesFileInTempSubdirWithExpectedName) { + Spill_file_writer writer(make_fde(), m_relay_log_dir); + ASSERT_FALSE(writer.open()) << writer.get_error_str(); + + const fs::path temp_dir = writer.temp_dir(); + EXPECT_EQ(temp_dir.filename().string(), "in_memory_relaylog_temp_files"); + EXPECT_EQ(temp_dir.parent_path(), fs::path(m_relay_log_dir)); + EXPECT_TRUE(fs::is_directory(temp_dir)); + + const fs::path file = writer.file_name(); + EXPECT_EQ(file.parent_path(), temp_dir); + const std::string base = file.filename().string(); + EXPECT_EQ(base.rfind("imr_sp_", 0), 0u) << "name must start with imr_sp_"; + EXPECT_TRUE(is_all_lowercase(base)) << "name must be lowercase: " << base; + EXPECT_GT(base.size(), std::strlen("imr_sp_")) + << "name must carry a non-empty unique id"; +} + +// Each writer gets its own distinct file within the same channel's temp dir. +TEST_F(ImrSpillWriterTest, DistinctFilesPerWriter) { + Spill_file_writer a(make_fde(), m_relay_log_dir); + Spill_file_writer b(make_fde(), m_relay_log_dir); + ASSERT_FALSE(a.open()) << a.get_error_str(); + ASSERT_FALSE(b.open()) << b.get_error_str(); + EXPECT_EQ(a.temp_dir(), b.temp_dir()); // same subdirectory + EXPECT_NE(a.file_name(), b.file_name()); +} + +// An empty relay log directory is rejected. +TEST_F(ImrSpillWriterTest, EmptyRelayLogDirIsError) { + Spill_file_writer writer(make_fde(), ""); + EXPECT_TRUE(writer.open()); + EXPECT_FALSE(writer.is_open()); + EXPECT_FALSE(writer.get_error_str().empty()); +} + +// --------------------------------------------------------------------------- +// open(): lays down the BINLOG_MAGIC + FDE prefix. +// --------------------------------------------------------------------------- + +// After open(): a backing file exists, the stream is open, the file starts with +// the 4-byte magic, and the end position equals the on-disk prefix length. +TEST_F(ImrSpillWriterTest, OpenWritesRelayLogPrefix) { + Spill_file_writer writer(make_fde(), m_relay_log_dir); + ASSERT_FALSE(writer.open()) << writer.get_error_str(); + + EXPECT_TRUE(writer.is_open()); + ASSERT_FALSE(writer.file_name().empty()); + // Prefix is magic (4 bytes) plus a non-empty serialized FDE. + EXPECT_GT(writer.end_position(), static_cast(4)); + + ASSERT_FALSE(writer.flush()) << writer.get_error_str(); + + const auto bytes = read_whole_file(writer.file_name()); + ASSERT_GE(bytes.size(), static_cast(4)); + EXPECT_EQ(0, std::memcmp(bytes.data(), BINLOG_MAGIC, 4)) + << "file must begin with BINLOG_MAGIC"; + // Everything written so far is on disk after flush(). + EXPECT_EQ(bytes.size(), writer.end_position()); +} + +// --------------------------------------------------------------------------- +// append_raw(): the end position advances by exactly the appended length, and +// only ever forward (monotonic). +// --------------------------------------------------------------------------- + +TEST_F(ImrSpillWriterTest, AppendAdvancesEndPositionMonotonically) { + Spill_file_writer writer(make_fde(), m_relay_log_dir); + ASSERT_FALSE(writer.open()) << writer.get_error_str(); + + const std::vector chunks = {"a", "bcbcbc", "1234567890", + std::string(4096, 'x')}; + my_off_t prev = writer.end_position(); + for (const auto &c : chunks) { + ASSERT_FALSE(writer.append_raw(c.data(), c.size())) << writer.get_error_str(); + EXPECT_EQ(writer.end_position(), prev + c.size()); + EXPECT_GT(writer.end_position(), prev) << "end position must advance"; + prev = writer.end_position(); + } + + // A zero-length append is a no-op and does not move the position. + ASSERT_FALSE(writer.append_raw("", 0)); + EXPECT_EQ(writer.end_position(), prev); +} + +// --------------------------------------------------------------------------- +// Raw round-trip: prefix + several appended "events" read back byte-for-byte. +// --------------------------------------------------------------------------- + +TEST_F(ImrSpillWriterTest, RawRoundTripBytes) { + Spill_file_writer writer(make_fde(), m_relay_log_dir); + ASSERT_FALSE(writer.open()) << writer.get_error_str(); + + // Capture the prefix length (magic + FDE) so we can slice the body out. + const my_off_t prefix_len = writer.end_position(); + + // Append a handful of stand-in events and accumulate what we expect the body + // region of the file to contain. + std::vector expected_body; + for (int i = 0; i < 5; ++i) { + const auto ev = serialize_event(); + ASSERT_FALSE(writer.append_raw(as_char(ev), ev.size())) + << writer.get_error_str(); + expected_body.insert(expected_body.end(), ev.begin(), ev.end()); + } + ASSERT_FALSE(writer.flush()) << writer.get_error_str(); + + const auto bytes = read_whole_file(writer.file_name()); + ASSERT_EQ(bytes.size(), writer.end_position()); + ASSERT_GE(bytes.size(), static_cast(prefix_len)); + EXPECT_EQ(0, std::memcmp(bytes.data(), BINLOG_MAGIC, 4)); + + // The bytes after the prefix are exactly the concatenation of what we fed to + // append_raw(), in order. + const std::vector body(bytes.begin() + prefix_len, + bytes.end()); + EXPECT_EQ(body, expected_body); +} + +// --------------------------------------------------------------------------- +// Destruction removes the spill file (the temp subdirectory is left in place). +// --------------------------------------------------------------------------- + +TEST_F(ImrSpillWriterTest, DestructionRemovesFile) { + std::string name; + { + Spill_file_writer writer(make_fde(), m_relay_log_dir); + ASSERT_FALSE(writer.open()) << writer.get_error_str(); + name = writer.file_name(); + ASSERT_FALSE(name.empty()); + ASSERT_FALSE(writer.append_raw("payload-bytes", 13)); + ASSERT_FALSE(writer.flush()); + EXPECT_TRUE(fs::exists(name)) << "file should exist while writer is alive"; + } + EXPECT_FALSE(fs::exists(name)) + << "spill file must be deleted when the writer is destroyed"; +} + +} // namespace mysql::csa::unittests diff --git a/unittest/gunit/changestreams/imr_sweep_and_dispatch-t.cc b/unittest/gunit/changestreams/imr_sweep_and_dispatch-t.cc new file mode 100644 index 000000000000..27151f0b158d --- /dev/null +++ b/unittest/gunit/changestreams/imr_sweep_and_dispatch-t.cc @@ -0,0 +1,253 @@ +/* Copyright (c) 2026, Oracle and/or its affiliates. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License, version 2.0, + as published by the Free Software Foundation. + + This program is designed to work with certain software (including + but not limited to OpenSSL) that is licensed under separate terms, + as designated in a particular file or component or in included license + documentation. The authors of MySQL hereby grant you an additional + permission to link the program and your derivative works with the + separately licensed software that they have either included with + the program or referenced in the documentation. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License, version 2.0, for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +/// @file +/// Unit tests for mysql::csa::Trx_envelope_queue::sweep_and_dispatch() -- the +/// folded coordinator step the applier's Queued_transaction_reader drives once +/// per iteration. In a single m_queue_mutex hold it (1) sweeps the contiguous +/// committed head prefix (reclaiming slots workers finished), then (2) blocks +/// for the next undispatched envelope (or a stop), then (3) dispatches it. These +/// tests exercise that fold, its shared dispatch_next_locked() helper, and the +/// behavior-preserving sweep_committed(need_lock) refactor. +/// +/// The queue rests STOPPED for both roles at construction (the mi-owned model), +/// so every test arms it with resume() right after construction. No seal-gated +/// dispatch: an envelope is handed out while still uncommitted/open; the sweep +/// step only reclaims already-committed slots and never blocks the dispatch. + +#include + +#include +#include +#include +#include +#include + +#include "sql/changestreams/apply/storage/in_memory/in_memory_types.h" +#include "sql/changestreams/apply/storage/in_memory/transaction_envelope.h" +#include "sql/changestreams/apply/storage/in_memory/trx_envelope_queue.h" +#include "sql/changestreams/apply/storage/in_memory/trx_payload.h" +#include "sql/log_event.h" + +namespace mysql::csa::unittests { + +namespace { +using Scope = Trx_envelope_queue::Scope; + +/// A short bounded wait to observe that a background call is still blocked. +constexpr std::chrono::milliseconds kShortWait{50}; +/// A generous wait bounding the success path so a regression fails fast. +constexpr std::chrono::seconds kJoinWait{3}; + +/// Bounds large enough that enqueue never blocks in admission: every enqueue +/// takes the (non-blocking) MEMORY path. +constexpr std::size_t kMemoryLimit = std::size_t{1} << 30; // 1 GiB +constexpr std::size_t kSpillThreshold = std::size_t{1} << 20; // 1 MiB + +/// Build the active FDE every enqueue() must be handed; enqueue() only asserts +/// it non-null (these tests never create a real byte source). +std::shared_ptr make_fde() { + return std::make_shared(); +} +} // namespace + +// A single sweep_and_dispatch() call BOTH reclaims the committed head prefix +// (dequeues it, advances commit_seqno, and bytes_used() reflects the payload the +// committing worker already released) AND dispatches the next undispatched +// envelope (advances dispatch_seqno) -- the committed head is reclaimed before +// the same call hands back the next envelope. +// Requirements 6.3, 6.4, 6.6 +TEST(ImrSweepAndDispatchTest, SweepsCommittedHeadAndDispatchesNextInOneCall) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + Transaction_envelope *e1 = queue.enqueue(11, true, make_fde()); + Transaction_envelope *e2 = queue.enqueue(22, true, make_fde()); + ASSERT_NE(queue.enqueue(33, true, make_fde()), nullptr); // e3 + ASSERT_NE(e1, nullptr); + ASSERT_NE(e2, nullptr); + ASSERT_EQ(queue.insert_seqno(), 3u); + ASSERT_EQ(queue.bytes_used(), 66u); + + // First step: nothing committed yet, so the sweep is a no-op and e1 is + // dispatched. + ASSERT_EQ(queue.sweep_and_dispatch(), e1); + ASSERT_EQ(queue.commit_seqno(), 0u); + ASSERT_EQ(queue.dispatch_seqno(), 1u); + + // The worker commits e1: commit() releases e1's payload bytes (66 - 11 = 55). + ASSERT_FALSE(e1->commit()); + ASSERT_EQ(queue.bytes_used(), 55u); + + // Second step: one call both sweeps the committed head (e1) AND dispatches the + // next envelope (e2). + Transaction_envelope *dispatched = queue.sweep_and_dispatch(); + EXPECT_EQ(dispatched, e2) << "the same call must hand back the next envelope"; + EXPECT_EQ(queue.commit_seqno(), 1u) << "committed head e1 was swept"; + EXPECT_EQ(queue.dispatch_seqno(), 2u) << "e2 was dispatched"; + EXPECT_EQ(queue.insert_seqno(), 3u); + // Sweeping does not touch the byte counter; e1's bytes were released at commit. + EXPECT_EQ(queue.bytes_used(), 55u); + + queue.reset(); // drop e2, e3; back to the pristine empty state. +} + +// With no undispatched envelope, sweep_and_dispatch() blocks (matching +// dispatch_next()); a subsequent enqueue wakes it and it returns that envelope. +// Requirements 6.3, 6.5 +TEST(ImrSweepAndDispatchTest, BlocksWhenEmptyThenReturnsOnEnqueue) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + std::promise got; + std::future fut = got.get_future(); + std::thread consumer([&] { got.set_value(queue.sweep_and_dispatch()); }); + + // Empty queue, applier armed: it must be parked. + ASSERT_EQ(fut.wait_for(kShortWait), std::future_status::timeout); + + Transaction_envelope *env = queue.enqueue(11, true, make_fde()); + ASSERT_NE(env, nullptr); + + ASSERT_EQ(fut.wait_for(kJoinWait), std::future_status::ready) + << "enqueue must wake the parked sweep_and_dispatch()"; + EXPECT_EQ(fut.get(), env); + consumer.join(); + + EXPECT_EQ(queue.dispatch_seqno(), 1u); + queue.reset(); +} + +// stop(APPLIER) wakes a parked sweep_and_dispatch(): it returns nullptr and +// advances no cursor, and every later call also returns nullptr. +// Requirements 6.3, 7.5 +TEST(ImrSweepAndDispatchTest, StopApplierWakesReturnsNullptrNoCursorAdvance) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + std::promise got; + std::future fut = got.get_future(); + std::thread consumer([&] { got.set_value(queue.sweep_and_dispatch()); }); + + ASSERT_EQ(fut.wait_for(kShortWait), std::future_status::timeout); + + queue.stop(Scope::APPLIER); + ASSERT_EQ(fut.wait_for(kJoinWait), std::future_status::ready) + << "stop(APPLIER) must wake the parked sweep_and_dispatch()"; + EXPECT_EQ(fut.get(), nullptr); + consumer.join(); + + EXPECT_EQ(queue.dispatch_seqno(), 0u) << "a stopped step advances nothing"; + EXPECT_EQ(queue.commit_seqno(), 0u); + // Once stopped, every later call returns nullptr too. + EXPECT_EQ(queue.sweep_and_dispatch(), nullptr); + EXPECT_EQ(queue.dispatch_seqno(), 0u); + + queue.reset(); +} + +// stop(ALL) also wakes a parked sweep_and_dispatch(). +// Requirements 6.3, 7.5 +TEST(ImrSweepAndDispatchTest, StopAllWakesReturnsNullptr) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + std::promise got; + std::future fut = got.get_future(); + std::thread consumer([&] { got.set_value(queue.sweep_and_dispatch()); }); + + ASSERT_EQ(fut.wait_for(kShortWait), std::future_status::timeout); + + queue.stop(); // Scope::ALL + ASSERT_EQ(fut.wait_for(kJoinWait), std::future_status::ready) + << "stop(ALL) must wake the parked sweep_and_dispatch()"; + EXPECT_EQ(fut.get(), nullptr); + consumer.join(); + + EXPECT_TRUE(queue.is_stopped()); + queue.reset(); +} + +// sweep_and_dispatch() hands out an envelope while it is still uncommitted and +// open -- there is no seal gate. The sweep step reclaims only already-committed +// slots and never blocks the dispatch of the live envelope. +// Requirements 6.3, 6.4, 6.5, 6.6, 6.7 +TEST(ImrSweepAndDispatchTest, DispatchesUncommittedEnvelopeNoSealGate) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + Transaction_envelope *env = queue.enqueue(11, true, make_fde()); + ASSERT_NE(env, nullptr); + ASSERT_FALSE(env->is_committed()); + + Transaction_envelope *dispatched = queue.sweep_and_dispatch(); + EXPECT_EQ(dispatched, env); + EXPECT_FALSE(dispatched->is_committed()) + << "dispatch must not wait for the commit/seal"; + EXPECT_EQ(queue.dispatch_seqno(), 1u); + EXPECT_EQ(queue.commit_seqno(), 0u); + + queue.reset(); +} + +// The extracted building block sweep_committed(need_lock=true) still sweeps +// correctly as a standalone external call (behavior-preserving refactor): it +// dequeues the contiguous committed head prefix, advances commit_seqno, returns +// false on success, and leaves the byte counter reflecting the released +// payloads. +// Requirements 6.4 +TEST(ImrSweepAndDispatchTest, SweepCommittedStandaloneStillSweeps) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); + + Transaction_envelope *e1 = queue.enqueue(11, true, make_fde()); + Transaction_envelope *e2 = queue.enqueue(22, true, make_fde()); + ASSERT_NE(e1, nullptr); + ASSERT_NE(e2, nullptr); + + // Dispatch both (commit_seqno <= dispatch_seqno must hold), then commit both; + // commit() releases each payload's bytes, so the counter reaches 0. + ASSERT_EQ(queue.dispatch_next(), e1); + ASSERT_EQ(queue.dispatch_next(), e2); + ASSERT_FALSE(e1->commit()); + ASSERT_FALSE(e2->commit()); + ASSERT_EQ(queue.bytes_used(), 0u); + + EXPECT_FALSE(queue.sweep_committed()) << "standalone sweep succeeds"; + EXPECT_EQ(queue.commit_seqno(), 2u) << "both committed heads were swept"; + EXPECT_EQ(queue.dispatch_seqno(), 2u); + EXPECT_EQ(queue.insert_seqno(), 2u); + EXPECT_EQ(queue.bytes_used(), 0u); + // The queue is fully drained and empty; the destructor's empty-queue + // invariant holds without a reset(). +} + +// NOTE: the structural-inconsistency guard in sweep_and_dispatch() (head +// stream_seqno != commit_seqno + 1 -> mark the applier stopped, return nullptr) +// is an assert-in-debug / abort-in-release path that the public commit-order +// invariant makes unreachable through the normal API. It is not covered here +// because there is no test seam to construct the corrupt-cursor state without +// friend access to the queue internals. + +} // namespace mysql::csa::unittests diff --git a/unittest/gunit/changestreams/imr_transaction_envelope-t.cc b/unittest/gunit/changestreams/imr_transaction_envelope-t.cc new file mode 100644 index 000000000000..4bcf80a27924 --- /dev/null +++ b/unittest/gunit/changestreams/imr_transaction_envelope-t.cc @@ -0,0 +1,473 @@ +/* Copyright (c) 2026, Oracle and/or its affiliates. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License, version 2.0, + as published by the Free Software Foundation. + + This program is designed to work with certain software (including + but not limited to OpenSSL) that is licensed under separate terms, + as designated in a particular file or component or in included license + documentation. The authors of MySQL hereby grant you an additional + permission to link the program and your derivative works with the + separately licensed software that they have either included with + the program or referenced in the documentation. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License, version 2.0, for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +/// @file +/// Unit tests for mysql::csa::Transaction_envelope, the lightweight FIFO-queue +/// entry that tracks a single transaction's COMMIT flag (uncommitted -> +/// committed) and owns its heavy Trx_payload. The envelope tracks only the +/// commit axis; reception ("fully received") is owned by the byte source, not +/// the envelope. The tests use a real Trx_envelope_queue as the byte-accounting +/// backend and a default-constructed Fetchable_transaction as the wrapped byte +/// source so payload release is observed through the queue's memory-usage +/// counter. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sql/changestreams/apply/jobs/fetchable_transaction.h" +#include "sql/changestreams/apply/storage/in_memory/event_set_fetchable_spill.h" +#include "sql/changestreams/apply/storage/in_memory/in_memory_types.h" +#include "sql/changestreams/apply/storage/in_memory/transaction_envelope.h" +#include "sql/changestreams/apply/storage/in_memory/trx_envelope_queue.h" +#include "sql/changestreams/apply/storage/in_memory/trx_payload.h" +#include "sql/log_event.h" // Format_description_log_event + +namespace mysql::csa::unittests { + +namespace { +/// Memory bounds large enough that attaching a payload never blocks; payloads +/// are attached directly, so the spill threshold is not exercised here. +constexpr std::size_t kMemoryLimit = 1u << 20; // 1 MiB +constexpr std::size_t kSpillThreshold = 1u << 16; // 64 KiB + +/// A representative payload size for the byte-accounting tests. +constexpr std::size_t kTrxLength = 4096; + +/// Build a payload wrapping a fresh Fetchable_transaction against @p queue. +std::unique_ptr make_payload(Trx_envelope_queue *queue, + std::size_t len) { + return std::make_unique( + std::make_shared(), len, queue); +} + +/// RAII private "relay log directory" for spill-path tests; removed on scope +/// exit (along with the spill files and temp-files subdir under it). +struct Scoped_temp_dir { + std::string path; + Scoped_temp_dir() { + static std::atomic counter{0}; + path = (std::filesystem::temp_directory_path() / + ("imr_env_" + std::to_string(::getpid()) + "_" + + std::to_string(counter.fetch_add(1)))) + .string(); + std::filesystem::create_directories(path); + } + ~Scoped_temp_dir() { + std::error_code ec; + std::filesystem::remove_all(path, ec); + } +}; + +/// A fresh relay-log FDE, as the queue/receiver would supply. +std::shared_ptr make_fde() { + return std::make_shared(); +} +} // namespace + +// The envelope owns a std::mutex member and lives by value in the queue's +// deque, so it must be neither copyable nor movable. +static_assert(!std::is_copy_constructible_v, + "Transaction_envelope must not be copy constructible"); +static_assert(!std::is_move_constructible_v, + "Transaction_envelope must not be move constructible"); + +// --------------------------------------------------------------------------- +// Task 4.2 - Unit tests: envelope commit lifecycle. +// Requirements 2.1, 2.2, 2.3, 2.5, 2.6 +// --------------------------------------------------------------------------- + +// Req 2.1: a freshly constructed envelope is uncommitted and its immutable +// accessors echo the values it was constructed with. +TEST(ImrTransactionEnvelopeTest, InitialStateUncommittedAndAccessors) { + const std::uint64_t stream_seqno = 42; + const std::size_t trx_length = 8192; + + Transaction_envelope env(stream_seqno, trx_length, Envelope_path::MEMORY); + + EXPECT_FALSE(env.is_committed()); + EXPECT_FALSE(env.is_truncated()); + EXPECT_EQ(env.stream_seqno(), stream_seqno); + EXPECT_EQ(env.trx_length(), trx_length); + EXPECT_EQ(env.path(), Envelope_path::MEMORY); + + // The SPILL path is recorded verbatim as well. + Transaction_envelope spill_env(7, 128, Envelope_path::SPILL); + EXPECT_EQ(spill_env.path(), Envelope_path::SPILL); +} + +// Req 2.6, 9.2: commit() releases the payload's reserved bytes and nulls the +// payload reference. Committing a freshly-attached (uncommitted) envelope +// SUCCEEDS (returns false) — there is no seal precondition. Byte release is +// observed through the queue counter. +TEST(ImrTransactionEnvelopeTest, CommitReleasesPayloadBytesAndNullsPayload) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + Transaction_envelope env(1, kTrxLength, Envelope_path::MEMORY); + + env.attach_payload(make_payload(&queue, kTrxLength)); + ASSERT_EQ(queue.bytes_used(), kTrxLength); + ASSERT_NE(env.payload(), nullptr); + + // Commit from uncommitted succeeds: false == success. + EXPECT_FALSE(env.commit()); + + EXPECT_TRUE(env.is_committed()); + EXPECT_EQ(env.payload(), nullptr); + EXPECT_EQ(queue.bytes_used(), 0u); +} + +// Req 2.5: a second commit() on an already-committed envelope is a contract +// violation (the envelope is committed by its single worker exactly once). It +// is rejected (returns true) and leaves the commit flag, payload reference, and +// byte counter unchanged - no double release of the payload's bytes. +TEST(ImrTransactionEnvelopeTest, ReCommitRejectedNoDoubleRelease) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + Transaction_envelope env(1, kTrxLength, Envelope_path::MEMORY); + + env.attach_payload(make_payload(&queue, kTrxLength)); + + // First commit: succeeds (false), releasing the reserved bytes. + ASSERT_FALSE(env.commit()); + ASSERT_TRUE(env.is_committed()); + ASSERT_EQ(env.payload(), nullptr); + ASSERT_EQ(queue.bytes_used(), 0u); + + // Re-commit: rejected (true == failure), no state change, no payload change, + // no further byte release. + EXPECT_TRUE(env.commit()); + EXPECT_TRUE(env.is_committed()); + EXPECT_EQ(env.payload(), nullptr); + EXPECT_EQ(queue.bytes_used(), 0u); +} + +// Req 2.6: reset_payload() drops the payload, releasing its bytes and nulling +// the reference, without committing the envelope. +TEST(ImrTransactionEnvelopeTest, ResetPayloadReleasesBytesAndNullsPayload) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + Transaction_envelope env(1, kTrxLength, Envelope_path::MEMORY); + + env.attach_payload(make_payload(&queue, kTrxLength)); + ASSERT_EQ(queue.bytes_used(), kTrxLength); + ASSERT_NE(env.payload(), nullptr); + + env.reset_payload(); + + EXPECT_EQ(env.payload(), nullptr); + EXPECT_EQ(queue.bytes_used(), 0u); + // reset_payload() does not itself commit the envelope. + EXPECT_FALSE(env.is_committed()); +} + +// --------------------------------------------------------------------------- +// Truncated end-state (Task 3). +// --------------------------------------------------------------------------- + +// set_truncated() marks the envelope truncated (a second terminal state) and +// leaves it uncommitted. Unlike commit(), it does NOT release the payload: the +// reserved bytes stay charged until the envelope is swept/destroyed (Task 4's +// sweep-time release), which the queue counter confirms is unchanged here. +TEST(ImrTransactionEnvelopeTest, SetTruncatedMarksTruncatedAndKeepsPayload) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + Transaction_envelope env(1, kTrxLength, Envelope_path::MEMORY); + + env.attach_payload(make_payload(&queue, kTrxLength)); + ASSERT_EQ(queue.bytes_used(), kTrxLength); + ASSERT_FALSE(env.is_truncated()); + ASSERT_FALSE(env.is_committed()); + + env.set_truncated(); + + EXPECT_TRUE(env.is_truncated()); + // Truncated is mutually exclusive with committed. + EXPECT_FALSE(env.is_committed()); + // Payload (and its reserved bytes) is retained; not released by truncation. + EXPECT_NE(env.payload(), nullptr); + EXPECT_EQ(queue.bytes_used(), kTrxLength); + + // set_truncated() is idempotent: a second call is a no-op. + env.set_truncated(); + EXPECT_TRUE(env.is_truncated()); + EXPECT_EQ(queue.bytes_used(), kTrxLength); + // (env destroyed at scope end -> payload dtor releases the bytes; see below.) +} + +// A truncated envelope releases its reserved bytes when it is destroyed — this +// is how the coordinator's sweep (Task 4) reclaims a truncated head: dropping +// the envelope runs the Trx_payload destructor, which returns the bytes to the +// queue counter. (For a committed envelope the release happens earlier, at +// commit(); for a truncated one it happens here, at sweep/destruction.) +TEST(ImrTransactionEnvelopeTest, TruncatedEnvelopeReleasesBytesOnDestruction) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + { + Transaction_envelope env(1, kTrxLength, Envelope_path::MEMORY); + env.attach_payload(make_payload(&queue, kTrxLength)); + env.set_truncated(); + // Not released by truncation itself. + ASSERT_EQ(queue.bytes_used(), kTrxLength); + } + // Envelope destroyed exactly as the sweep would drop it: bytes released once. + EXPECT_EQ(queue.bytes_used(), 0u); +} + +// --------------------------------------------------------------------------- +// Task 4.3 - Property test: commit-once monotonicity. +// Validates: Requirements 2.2, 2.3, 2.5 (Property 5: Commit-once) +// --------------------------------------------------------------------------- + +// Property 5 (Commit-once): over a randomized sequence of commit() calls, +// is_committed() starts false, flips false->true on the FIRST call (which +// returns false == success), stays true forever after, every later commit() +// returns true (rejected), and is_committed() never regresses. +// +// The MySQL tree does not integrate a property-testing library, so the property +// is expressed as a deterministically seeded randomized-sequence generator run +// over many trials. The seed is fixed and echoed in every assertion message so +// any failure reproduces exactly. A payload is attached right after +// construction so commit()'s payload-reset path is genuinely exercised; a real +// Trx_envelope_queue declared before the envelope outlives it. +TEST(ImrTransactionEnvelopeTest, PropertyCommitOnceMonotonicity) { + // Fixed, deterministic seed so any failure reproduces exactly. + constexpr std::uint32_t kSeed = 0x5A1E5EEDu; + constexpr int kTrials = 3000; + constexpr int kStepsPerTrial = 8; + + std::mt19937 rng(kSeed); + // 0 -> skip this step; 1 -> issue a commit() call. Randomizing WHEN the + // commits happen (and how many) exercises the "at most one success" contract. + std::uniform_int_distribution op_dist(0, 1); + + for (int trial = 0; trial < kTrials; ++trial) { + // Queue declared before the envelope so it outlives it; the payload's + // bytes are released through it when commit() resets the payload. + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + Transaction_envelope env(1, kTrxLength, Envelope_path::MEMORY); + env.attach_payload(make_payload(&queue, kTrxLength)); + + // Independent shadow model of the expected commit flag. + bool shadow_committed = false; + + // Initial: uncommitted. + ASSERT_FALSE(env.is_committed()) + << "seed=" << kSeed << " trial=" << trial << " (initial state)"; + + for (int step = 0; step < kStepsPerTrial; ++step) { + const bool prev_committed = shadow_committed; + + if (op_dist(rng) == 1) { + // commit() succeeds (false) iff currently uncommitted; every later call + // is rejected (true). + const bool expected_failure = shadow_committed; + const bool actual_failure = env.commit(); + ASSERT_EQ(actual_failure, expected_failure) + << "seed=" << kSeed << " trial=" << trial << " step=" << step + << " (commit return mismatch)"; + shadow_committed = true; // Committed after the first successful call. + // The very first successful commit released the bytes; refine the + // shadow: only flip to committed when the call actually succeeded. + if (!prev_committed && !actual_failure) { + // First successful commit: bytes released. + ASSERT_EQ(queue.bytes_used(), 0u) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + } + } + + // Observed flag matches the model. + const bool observed = env.is_committed(); + ASSERT_EQ(observed, shadow_committed) + << "seed=" << kSeed << " trial=" << trial << " step=" << step; + + // Core Property 5: the commit flag never regresses true->false. + ASSERT_TRUE(observed || !prev_committed) + << "seed=" << kSeed << " trial=" << trial << " step=" << step + << " (commit flag regressed)"; + } + + // Ensure the envelope is committed by the end so the queue drains cleanly + // (bytes_used() == 0), whether or not the random sequence issued a commit. + if (!env.is_committed()) { + ASSERT_FALSE(env.commit()) + << "seed=" << kSeed << " trial=" << trial << " (final commit)"; + } + ASSERT_EQ(queue.bytes_used(), 0u) + << "seed=" << kSeed << " trial=" << trial << " (drained)"; + } +} + +// --------------------------------------------------------------------------- +// Task 4.4 - Property test: payload lifetime. +// Validates: Requirements 9.1, 9.2, 9.5 +// --------------------------------------------------------------------------- + +// Property 6 (Payload lifetime): a memory-path envelope holds a non-null +// payload from admission (attach) until commit, and after a successful commit +// the payload is null and its reserved bytes are released back to the queue. +// The property is checked across a batch of concurrently-live envelopes sharing +// one queue: bytes_used() always equals the sum of the not-yet-committed +// envelopes' trx_length, and returns to 0 once every envelope has committed. +// +// The MySQL tree does not integrate a property-testing library, so the property +// is expressed as a deterministically seeded randomized-sequence generator run +// over many trials. The seed is fixed and echoed in every assertion message so +// any failure reproduces exactly. Because Transaction_envelope is non-movable, +// the live envelopes are pinned on the heap and held via movable unique_ptr +// handles in a vector; the single queue is declared first so it outlives them +// all. Envelopes are committed directly (no seal step). +TEST(ImrTransactionEnvelopeTest, PropertyPayloadLifetime) { + // Fixed, deterministic seed so any failure reproduces exactly. + constexpr std::uint32_t kSeed = 0x9A710AD5u; + constexpr int kTrials = 400; + constexpr int kEnvelopesPerTrial = 12; + + // A large limit so attaching a payload never blocks; the spill threshold is + // not exercised (payloads are attached directly). + const std::size_t memory_limit = 1u << 24; // 16 MiB + const std::size_t spill_threshold = 1u << 16; // 64 KiB + + std::mt19937 rng(kSeed); + // Payload sizes stay well under spill_threshold and include 0. + std::uniform_int_distribution len_dist(0, (1u << 16) - 1); + + for (int trial = 0; trial < kTrials; ++trial) { + // One real queue, declared first so it outlives every envelope below. + Trx_envelope_queue queue(memory_limit, spill_threshold); + + // Pinned, still-live envelopes and their reserved byte sizes, tracked as an + // independent model of bytes_used() (sum of not-yet-committed lengths). + std::vector> live; + std::size_t expected = 0; + + ASSERT_EQ(queue.bytes_used(), expected) + << "seed=" << kSeed << " trial=" << trial << " (initial)"; + + for (int i = 0; i < kEnvelopesPerTrial; ++i) { + const std::size_t len = len_dist(rng); + const std::size_t before = queue.bytes_used(); + + auto env = std::make_unique( + static_cast(i + 1), len, Envelope_path::MEMORY); + env->attach_payload(make_payload(&queue, len)); + + // Req 9.1: non-null payload from admission, and bytes_used rose by exactly + // this envelope's declared length. + ASSERT_NE(env->payload(), nullptr) + << "seed=" << kSeed << " trial=" << trial << " i=" << i; + ASSERT_EQ(queue.bytes_used(), before + len) + << "seed=" << kSeed << " trial=" << trial << " i=" << i + << " len=" << len; + // Uncommitted while its bytes are still reserved. + ASSERT_FALSE(env->is_committed()) + << "seed=" << kSeed << " trial=" << trial << " i=" << i; + + live.push_back(std::move(env)); + expected += len; + + // bytes_used() equals the model (sum of live, not-yet-committed lengths). + ASSERT_EQ(queue.bytes_used(), expected) + << "seed=" << kSeed << " trial=" << trial << " i=" << i; + } + + // Commit every live envelope in a randomized order. Each commit must null + // the payload and release exactly that envelope's reserved bytes. + std::shuffle(live.begin(), live.end(), rng); + for (std::size_t k = 0; k < live.size(); ++k) { + Transaction_envelope *env = live[k].get(); + const std::size_t len = env->trx_length(); + const std::size_t before = queue.bytes_used(); + + // Non-null right up to the commit call. + ASSERT_NE(env->payload(), nullptr) + << "seed=" << kSeed << " trial=" << trial << " k=" << k; + ASSERT_FALSE(env->is_committed()) + << "seed=" << kSeed << " trial=" << trial << " k=" << k; + + // commit() succeeds (false) from uncommitted (no seal precondition). + ASSERT_FALSE(env->commit()) + << "seed=" << kSeed << " trial=" << trial << " k=" << k; + + // Req 9.2 / 9.5: after commit the payload is null and the reserved bytes + // were released (bytes_used dropped by exactly this envelope's length). + ASSERT_TRUE(env->is_committed()) + << "seed=" << kSeed << " trial=" << trial << " k=" << k; + ASSERT_EQ(env->payload(), nullptr) + << "seed=" << kSeed << " trial=" << trial << " k=" << k; + ASSERT_EQ(queue.bytes_used(), before - len) + << "seed=" << kSeed << " trial=" << trial << " k=" << k + << " len=" << len; + + expected -= len; + ASSERT_EQ(queue.bytes_used(), expected) + << "seed=" << kSeed << " trial=" << trial << " k=" << k; + } + + // Every envelope committed: all reserved bytes released. + ASSERT_EQ(queue.bytes_used(), 0u) + << "seed=" << kSeed << " trial=" << trial << " (all committed)"; + } +} + +// --------------------------------------------------------------------------- +// Task 5 - Spill-path destination: zero-reservation, live sink. +// --------------------------------------------------------------------------- + +// create_spill_destination() attaches a live spill payload whose sink is +// reachable, reserves NO bytes against the queue counter (Req 3.7), and — when +// the payload is dropped — leaves the counter at zero. +TEST(ImrTransactionEnvelopeTest, CreateSpillDestinationLiveSinkNoReservation) { + Scoped_temp_dir relay_dir; + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold, relay_dir.path); + const std::size_t before = queue.bytes_used(); + + Transaction_envelope env(1, kTrxLength, Envelope_path::SPILL); + env.create_spill_destination(/*is_trx=*/true, make_fde(), &queue); + + // A payload and a reachable sink now exist. + ASSERT_NE(env.payload(), nullptr); + Streaming_event_sink *sink = env.current_sink(); + ASSERT_NE(sink, nullptr); + auto *spill = dynamic_cast(sink); + ASSERT_NE(spill, nullptr) << "spill destination must expose a spill sink"; + EXPECT_FALSE(spill->is_error()) << spill->get_error_str(); + EXPECT_FALSE(spill->spill_file_name().empty()); + + // Req 3.7: the spill path reserves zero bytes — the counter is unchanged and + // the payload's accounted size is zero. + EXPECT_EQ(queue.bytes_used(), before); + EXPECT_EQ(env.payload()->byte_size(), static_cast(0)); + + // Dropping the payload releases zero, leaving the counter at zero. + env.reset_payload(); + EXPECT_EQ(env.payload(), nullptr); + EXPECT_EQ(queue.bytes_used(), before); +} + +} // namespace mysql::csa::unittests diff --git a/unittest/gunit/changestreams/imr_trx_payload-t.cc b/unittest/gunit/changestreams/imr_trx_payload-t.cc new file mode 100644 index 000000000000..2d219e112539 --- /dev/null +++ b/unittest/gunit/changestreams/imr_trx_payload-t.cc @@ -0,0 +1,384 @@ +/* Copyright (c) 2026, Oracle and/or its affiliates. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License, version 2.0, + as published by the Free Software Foundation. + + This program is designed to work with certain software (including + but not limited to OpenSSL) that is licensed under separate terms, + as designated in a particular file or component or in included license + documentation. The authors of MySQL hereby grant you an additional + permission to link the program and your derivative works with the + separately licensed software that they have either included with + the program or referenced in the documentation. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License, version 2.0, for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +/// @file +/// Unit tests for mysql::csa::Trx_payload, the RAII memory-accounted holder of +/// one transaction's byte stream. The tests use a real Trx_envelope_queue as +/// the accounting backend and a default-constructed Fetchable_transaction as +/// the wrapped byte source. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sql/changestreams/apply/jobs/fetchable_transaction.h" +#include "sql/changestreams/apply/storage/in_memory/event_set_fetchable_spill.h" +#include "sql/changestreams/apply/storage/in_memory/transaction_envelope.h" +#include "sql/changestreams/apply/storage/in_memory/trx_envelope_queue.h" +#include "sql/changestreams/apply/storage/in_memory/trx_payload.h" +#include "sql/log_event.h" // Format_description_log_event + +namespace mysql::csa::unittests { + +namespace { +/// A short bounded wait used to observe that a background admission call is +/// still blocked, without relying on a fixed sleep for correctness. +constexpr std::chrono::milliseconds kShortWait{50}; + +/// Generous upper bound for a positive "must unblock" observation. +constexpr std::chrono::seconds kLongWait{3}; + +/// Memory bounds large enough that construction never itself blocks. +constexpr std::size_t kMemoryLimit = 1u << 20; // 1 MiB +constexpr std::size_t kSpillThreshold = 1u << 16; // 64 KiB + +/// RAII private "relay log directory" for spill-path tests; removed on scope +/// exit (with the spill files and temp-files subdir under it). +struct Scoped_temp_dir { + std::string path; + Scoped_temp_dir() { + static std::atomic counter{0}; + path = (std::filesystem::temp_directory_path() / + ("imr_payload_" + std::to_string(::getpid()) + "_" + + std::to_string(counter.fetch_add(1)))) + .string(); + std::filesystem::create_directories(path); + } + ~Scoped_temp_dir() { + std::error_code ec; + std::filesystem::remove_all(path, ec); + } +}; + +/// A fresh relay-log FDE, as the queue/receiver would supply. +std::shared_ptr make_fde() { + return std::make_shared(); +} +} // namespace + +// --------------------------------------------------------------------------- +// Task 3.2 - Unit tests: Trx_payload accounting. +// Requirements 1.1, 1.2, 1.3, 1.4, 1.5, 1.6 +// --------------------------------------------------------------------------- + +// Req 1.1: constructing a payload reserves exactly trx_length bytes against the +// owning queue's memory-usage counter. +TEST(ImrTrxPayloadTest, ConstructionAddsTrxLength) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + const std::size_t trx_length = 4096; + const std::size_t before = queue.bytes_used(); + + { + auto ft = std::make_shared(); + Trx_payload payload(ft, trx_length, &queue); + // Counter increased by exactly trx_length while the payload is alive. + EXPECT_EQ(queue.bytes_used(), before + trx_length); + } +} + +// Req 1.2: destroying a payload subtracts exactly the reserved amount, so the +// counter returns to its prior value. +TEST(ImrTrxPayloadTest, DestructionSubtractsTrxLength) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + const std::size_t trx_length = 4096; + const std::size_t before = queue.bytes_used(); + + { + auto ft = std::make_shared(); + Trx_payload payload(ft, trx_length, &queue); + ASSERT_EQ(queue.bytes_used(), before + trx_length); + } + + // Back to baseline after destruction. + EXPECT_EQ(queue.bytes_used(), before); +} + +// Req 1.3: destruction notifies the memory-availability condition variable, so +// a thread blocked in acquire_admission wakes and succeeds once the payload's +// destructor frees enough room. Verified behaviorally. +TEST(ImrTrxPayloadTest, DestructionNotifiesBlockedWaiter) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + // Fill the budget to exactly the limit using a payload, leaving no room. + // A request for trx_length bytes then blocks until this payload is destroyed. + const std::size_t trx_length = kMemoryLimit / 2; + auto ft = std::make_shared(); + auto payload = std::make_unique(ft, kMemoryLimit, &queue); + ASSERT_EQ(queue.bytes_used(), kMemoryLimit); + + std::atomic returned{false}; + std::promise result_promise; + std::future result_future = result_promise.get_future(); + + std::thread waiter([&] { + const bool failed = queue.acquire_admission(trx_length); + returned.store(true); + result_promise.set_value(failed); + }); + + // Still blocked: the budget is full and nothing has been released. + EXPECT_EQ(result_future.wait_for(kShortWait), std::future_status::timeout); + EXPECT_FALSE(returned.load()); + + // Destroying the payload releases kMemoryLimit bytes and must wake the + // waiter, which then re-evaluates the predicate and acquires admission. + payload.reset(); + + ASSERT_EQ(result_future.wait_for(kLongWait), std::future_status::ready) + << "payload destruction must notify the blocked admission waiter"; + // false == success (admission acquired). + EXPECT_FALSE(result_future.get()); + + waiter.join(); + // The waiter reserved nothing itself; the queue is empty again. + EXPECT_EQ(queue.bytes_used(), 0u); +} + +// Req 1.5: byte_size() returns the trx_length the payload was constructed with. +TEST(ImrTrxPayloadTest, ByteSizeReturnsConstructedLength) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + const std::size_t trx_length = 12345; + auto ft = std::make_shared(); + Trx_payload payload(ft, trx_length, &queue); + + EXPECT_EQ(payload.byte_size(), trx_length); +} + +// Req 1.4: fetchable() returns a non-null shared_ptr that shares ownership of +// the same Fetchable_transaction the payload was constructed with. +TEST(ImrTrxPayloadTest, FetchableSharesOwnership) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + auto ft = std::make_shared(); + const long use_count_before = ft.use_count(); + + { + Trx_payload payload(ft, 1024, &queue); + // Holding the payload adds a shared owner. + EXPECT_GT(ft.use_count(), use_count_before); + + std::shared_ptr got = payload.fetchable(); + ASSERT_NE(got, nullptr); + // Same underlying object. + EXPECT_EQ(got.get(), ft.get()); + } + + // The payload dropped its reference; only the test's `ft` remains. + EXPECT_EQ(ft.use_count(), use_count_before); +} + +// Req 1.4/1.5 (compile-time): copy and move are deleted. The payload is pinned +// inside its owning envelope, so none of these operations may be available. +static_assert(!std::is_copy_constructible_v, + "Trx_payload must not be copy constructible"); +static_assert(!std::is_copy_assignable_v, + "Trx_payload must not be copy assignable"); +static_assert(!std::is_move_constructible_v, + "Trx_payload must not be move constructible"); +static_assert(!std::is_move_assignable_v, + "Trx_payload must not be move assignable"); + +// A runtime test carrying the same static assertions so the check is visible in +// the test report as well. +TEST(ImrTrxPayloadTest, CopyAndMoveAreDeleted) { + EXPECT_FALSE(std::is_copy_constructible_v); + EXPECT_FALSE(std::is_copy_assignable_v); + EXPECT_FALSE(std::is_move_constructible_v); + EXPECT_FALSE(std::is_move_assignable_v); +} + +// Req 1.6: a payload releases its bytes exactly once, only on destruction. +// Since the payload is neither copyable nor movable and owns its reservation +// for its whole lifetime, a single construct/destruct cycle returns the counter +// to its baseline with no residual and no double release. (Repeated across a +// batch of payloads to guard against any drift.) +TEST(ImrTrxPayloadTest, ReleaseHappensExactlyOnceOnDestruction) { + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + const std::size_t baseline = queue.bytes_used(); + + for (int i = 0; i < 8; ++i) { + const std::size_t trx_length = 1024 * (i + 1); + { + auto ft = std::make_shared(); + Trx_payload payload(ft, trx_length, &queue); + ASSERT_EQ(queue.bytes_used(), baseline + trx_length); + } + // Destruction released exactly trx_length: back to baseline every cycle. + EXPECT_EQ(queue.bytes_used(), baseline); + } +} + +// --------------------------------------------------------------------------- +// Task 3.3 - Property test: counter conservation. +// Validates: Requirements 1.1, 1.2, 6.5, 9.5 +// --------------------------------------------------------------------------- + +// Property 1 (Counter conservation): over a randomized multiset of Trx_payload +// lifetimes constructed and destroyed in RANDOM order, each construction +// reserves exactly its trx_length and each destruction releases exactly the +// amount its payload had reserved - independent of the order in which payloads +// are created versus destroyed. The queue's bytes_used() therefore always +// equals the sum of the byte_size() of the currently-live payloads, and once +// every payload has been destroyed bytes_used() returns to 0 (Req 6.5, 9.5). +// +// The MySQL tree does not integrate a property-testing library, so the property +// is expressed as a deterministically seeded randomized-sequence generator run +// over many trials. The seed is fixed and echoed in every assertion message so +// any failure reproduces exactly. Because Trx_payload is neither copyable nor +// movable, live payloads are pinned on the heap and held via a movable +// unique_ptr handle so they can be stored in a vector and erased in random +// order. +TEST(ImrTrxPayloadTest, PropertyCounterConservation) { + // Fixed, deterministic seed so any failure reproduces exactly. + constexpr std::uint32_t kSeed = 0x5EED1A9Cu; + constexpr int kTrials = 5000; + + // A large limit that this direct-construction test never approaches; the + // spill_threshold is irrelevant here (payloads are constructed directly, not + // routed through classify/acquire_admission). + const std::size_t memory_limit = 1u << 30; + const std::size_t spill_threshold = 1u << 16; + + std::mt19937 rng(kSeed); + // trx_length range includes 0 and spans a wide interval well within the + // limit, so many payloads can be live simultaneously. + std::uniform_int_distribution len_dist(0, 1u << 20); + // Bias toward construction so the live set grows and shrinks repeatedly. + std::uniform_int_distribution action_dist(0, 2); + + Trx_envelope_queue queue(memory_limit, spill_threshold); + queue.resume(); // mi-owned queue defaults to stopped; arm it for the test. + + // Live payloads, pinned on the heap. The independent model `expected` mirrors + // the sum of the live payloads' byte_size() and must equal bytes_used() after + // every single operation. + std::vector> live; + std::size_t expected = 0; + + ASSERT_EQ(queue.bytes_used(), expected) << "seed=" << kSeed; + + for (int trial = 0; trial < kTrials; ++trial) { + const bool do_construct = live.empty() || action_dist(rng) != 0; + + if (do_construct) { + // CONSTRUCT: reserving exactly `len` must raise bytes_used() by exactly + // `len` (Req 1.1). + const std::size_t len = len_dist(rng); + const std::size_t before = queue.bytes_used(); + + auto ft = std::make_shared(); + live.push_back(std::make_unique(ft, len, &queue)); + expected += len; + + ASSERT_EQ(queue.bytes_used(), before + len) + << "seed=" << kSeed << " trial=" << trial << " len=" << len; + ASSERT_EQ(queue.bytes_used(), expected) + << "seed=" << kSeed << " trial=" << trial << " len=" << len; + } else { + // DESTROY a random live payload: destruction must lower bytes_used() by + // exactly the amount that payload reserved at construction (Req 1.2), + // regardless of construction order. + std::uniform_int_distribution idx_dist(0, live.size() - 1); + const std::size_t idx = idx_dist(rng); + const std::size_t amount = live[idx]->byte_size(); + const std::size_t before = queue.bytes_used(); + + // Swap-and-pop: moving the unique_ptr handle does not move the pinned + // payload; popping it destroys the payload and releases its bytes. + live[idx] = std::move(live.back()); + live.pop_back(); + expected -= amount; + + ASSERT_EQ(queue.bytes_used(), before - amount) + << "seed=" << kSeed << " trial=" << trial << " amount=" << amount; + ASSERT_EQ(queue.bytes_used(), expected) + << "seed=" << kSeed << " trial=" << trial << " amount=" << amount; + } + } + + // Destroy everything that remains: with no live payloads the counter must be + // fully reclaimed to zero (Req 6.5, 9.5). + live.clear(); + expected = 0; + ASSERT_EQ(queue.bytes_used(), expected) << "seed=" << kSeed; +} + +// --------------------------------------------------------------------------- +// Task 5 - Zero-reservation spill payload. +// --------------------------------------------------------------------------- + +// create_spill() builds a live spill payload whose sink is reachable and whose +// backing file exists, reserves ZERO bytes against the queue counter (Req 3.7), +// reports byte_size() == 0, and — on destruction — releases zero (the counter +// never moves off its prior value). +TEST(ImrTrxPayloadTest, CreateSpillReservesZeroAndExposesSink) { + Scoped_temp_dir relay_dir; + Trx_envelope_queue queue(kMemoryLimit, kSpillThreshold, relay_dir.path); + const std::size_t before = queue.bytes_used(); + + // A standalone owning envelope for the spill source's commit/truncate target. + Transaction_envelope env(1, /*trx_length=*/0, Envelope_path::SPILL); + + { + auto payload = Trx_payload::create_spill(/*is_trx=*/true, make_fde(), &queue, + &env); + ASSERT_NE(payload, nullptr); + + // Zero reservation (Req 3.7). + EXPECT_EQ(payload->byte_size(), static_cast(0)); + EXPECT_EQ(queue.bytes_used(), before); + + // Reachable, healthy spill sink over a real file. + Streaming_event_sink *sink = payload->sink(); + ASSERT_NE(sink, nullptr); + auto *spill = dynamic_cast(sink); + ASSERT_NE(spill, nullptr); + EXPECT_FALSE(spill->is_error()) << spill->get_error_str(); + EXPECT_FALSE(spill->spill_file_name().empty()); + + // A live wrapped Fetchable_transaction is shared out. + EXPECT_NE(payload->fetchable(), nullptr); + } + + // Payload destroyed: released zero, so the counter is unchanged. + EXPECT_EQ(queue.bytes_used(), before); +} + +} // namespace mysql::csa::unittests