# MariaDB 12.3.2 — InnoDB R-tree crash analysis (SPATIAL index bulk insert)

Root-cause analysis of the `0xc0000005` crash that stops my app's regeneration when
`lngfrpmonitor` carries the `sp_frp_pt` SPATIAL index. Source read directly from the
crashing revision (`9f98f82b14a9b939834281672b6d0cf965db69a3`),
`storage/innobase/gis/gis0rtree.cc`.

**Confidence:** the crash *location* is established by an exact line-number match (§1), and
the minidump now confirms the *nature* of the fault (§1a): a **NULL-pointer read**, which
eliminates the stale/freed-pointer family and pins the cause to an unvalidated null result
from the father-block search. The one remaining unknown is *which* named variable is null —
resolvable by symbolising the exact fault RIP (`server.dll + 0x88238c`) against the release
PDB, which MariaDB's developers hold. The candidate patch guards exactly that null and is a
fix to *propose and test*, not to apply blind to a production server (no custom build in prod).

---

## 1. The crash chain, matched to source

```
rtr_update_mbr_field()      gis0rtree.cc:225   <- SIGSEGV (0xc0000005)
rtr_adjust_upper_level()    gis0rtree.cc:595
rtr_page_split_and_insert() gis0rtree.cc:1160
btr_cur_pessimistic_insert()btr0cur.cc:2652
row_ins_sec_index_entry_low()
...
```

`btr_cur_pessimistic_insert` splits a full page; for a SPATIAL (R-tree) secondary index
that split is `rtr_page_split_and_insert`, which must propagate the split to the parent
level via `rtr_adjust_upper_level`. **The exact-match confirmation:** the crash frame
`rtr_adjust_upper_level [gis0rtree.cc:595]` lands on this line in the fetched source:

```cpp
590    offsets = rtr_page_get_father_block(nullptr, heap, sea_cur, &cursor, thr, mtr);
591
593    page_cursor = btr_cur_get_page_cur(&cursor);
594
595    rtr_update_mbr_field(&cursor, offsets, nullptr, block->page.frame, mbr,
596                         nullptr, mtr);
```

So the parent frame is calling `rtr_update_mbr_field` with the `cursor` and `offsets`
produced by `rtr_page_get_father_block` one statement earlier — **with no check on the
result.** Control enters `rtr_update_mbr_field` and crashes almost immediately:

```cpp
205  {
206      dict_index_t*   index = cursor->index();
...
226      rec = btr_cur_get_rec(cursor);
227      page = btr_cur_get_page(cursor);
228
229      rec_info = rec_get_info_bits(rec, rec_offs_comp(offsets));   // <-- deref
```

(The compiler attributes the fault to line 225, the function's entry/declaration region;
the first real dereference is 226–229.)

---

## 1a. Minidump confirmation (`mysqld_iGISLNG.dmp`)

Parsed directly from the crash dump (exception + thread-context + module streams):

| Field | Value | Reading |
|---|---|---|
| `ExceptionCode` | `0xC0000005` | ACCESS_VIOLATION |
| Access type | **READ** | reading, not writing |
| **Faulting address** | **`0x0000000000000004`** | **null-base field read at offset +4** |
| Fault `RIP` | `0x7fff1934238c` = **`server.dll + 0x88238c`** | symbol-resolvable with the release PDB |
| Zeroed registers | `RCX RDX RBX RSI R8 R14 = 0` | a null/zeroed structure being walked |
| Platform | x64, Windows build 26200, 6 CPU | |

**This is the decisive datum.** A faulting address of `0x4` is far below any mappable page,
so it is unambiguously a **NULL-pointer dereference** — a field read at byte offset +4 of a
pointer whose value is `0`. That **eliminates the stale/freed-page hypothesis** (which would
fault on a large garbage address) and confirms the failure mode is *"a null result was
dereferenced without a check."*

The +4 offset is consistent with reading through a null `rec_offs*` (`rec_offs` is
`uint16_t`, so `rec_offs_base(offsets)[0]` == `offsets[2]` == byte +4) — i.e. **`offsets` is
NULL** — or, less likely, a null record/index field at the same offset. Exactly which is
resolved by symbolising `server.dll + 0x88238c` against the release PDB; either way the base
pointer is null and comes from the unchecked `rtr_page_get_father_block()` result at line 590.
The `!offsets` guard in §3 checks precisely this null.

---

## 2. Root-cause hypothesis

`rtr_page_get_father_block()` (in `rtr0rtr.cc`) locates the **parent node pointer** for the
child page being split and positions `cursor` on it, returning the record `offsets`. Unlike
a B-tree, an R-tree parent pointer is not found by an ordered key but by an **MBR-guided
descent matching the child page number** — an inherently fragile search when minimum
bounding rectangles overlap.

The crash is the **unguarded consumption of that search result**:

- `rtr_adjust_upper_level:595` passes `offsets` and `&cursor` straight into
  `rtr_update_mbr_field` with no validity check.
- `rtr_update_mbr_field:229` dereferences both — `rec_offs_comp(offsets)` reads through
  `offsets`, and `rec_get_info_bits(rec, …)` reads through `rec = btr_cur_get_rec(cursor)`.

If the father search **failed to position on the matching parent node pointer** — returning
a null/short `offsets`, or leaving `cursor` on the page infimum/supremum or a non-matching
record — line 229 reads through a bad pointer and the server takes an access violation.
In a debug build the mismatch would trip a `ut_ad(...)` assertion first (there are several
downstream, e.g. line 261, 323); in the **release build you run, the asserts compile out**
and the code proceeds to the faulting dereference. That is the classic
"asserts-in-debug / crashes-in-release" shape, and it fits a `0xc0000005` exactly.

**Why it appears only after ~2 hours / on a large tree.** The father search fails only in a
specific geometry: a split that must propagate up through a parent whose node-pointer MBRs
overlap enough that the MBR-guided descent doesn't land on the exact child entry. That
configuration becomes reachable only once the R-tree is deep and dense — i.e. after N and
J1 have loaded millions of rows. The load is deterministic, so the same tree shape recurs
and the same split re-crashes at the same batch. (The *insertion order* matters, which is
why the earlier legacy-order regeneration survived and the matcher-order one does not — a
different order simply reaches a different, non-failing split sequence.)

---

## 3. Candidate patch (defensive — the minimal, maintainer-shaped fix)

Convert the crash into a recoverable error by validating the father-search result before
dereferencing it. `rtr_adjust_upper_level` already returns `DB_CORRUPTION` in its other
not-found paths (lines 630, 681), so this is consistent with the function's own contract
and propagates cleanly up through `btr_cur_pessimistic_insert` to abort the insert instead
of killing the process.

```diff
     offsets = rtr_page_get_father_block(nullptr, heap, sea_cur, &cursor, thr, mtr);

+    /* rtr_page_get_father_block() can fail to position on the child's parent
+       node pointer when the MBR-guided descent does not match (dense/overlapping
+       MBRs on a deep tree). In a release build the downstream ut_ad() asserts are
+       compiled out and rtr_update_mbr_field() would dereference an invalid cursor
+       (gis0rtree.cc:229), taking a SIGSEGV. Abort the split cleanly instead. */
+    if (!offsets
+        || page_rec_is_infimum(btr_cur_get_rec(&cursor))
+        || page_rec_is_supremum(btr_cur_get_rec(&cursor))) {
+        mem_heap_free(heap);
+        return DB_CORRUPTION;
+    }
+
     page_cursor = btr_cur_get_page_cur(&cursor);

     rtr_update_mbr_field(&cursor, offsets, nullptr, block->page.frame, mbr,
                          nullptr, mtr);
```

Belt-and-suspenders, and independently defensible: also harden the callee so it can never
be the one that faults —

```diff
 void
 rtr_update_mbr_field(
     btr_cur_t*  cursor,
     rec_offs*   offsets,
     ...
 {
     dict_index_t*   index = cursor->index();
     ...
+    ut_ad(offsets);
+    if (!offsets) {
+        return;   /* caller must treat as failure; see rtr_adjust_upper_level guard */
+    }
     rec = btr_cur_get_rec(cursor);
```

**What this fixes and what it doesn't.** It stops the **crash** — a corrupt/uncooperative
father search becomes a `DB_CORRUPTION` on that one insert rather than a server-wide
SIGSEGV, and the rest of the instance survives. It does **not** fix the underlying reason
the father search fails; that lives in `rtr_page_get_father_block` / `rtr0rtr.cc` and is a
larger investigation (the R-tree parent-pointer lookup needs to be robust to overlapping
MBRs, or the split path needs to re-seek). The defensive guard is the correct *first* fix
and the one maintainers typically take to close the crash while the deeper search fix is
scoped.

---

## 4. Honest limits

- The minidump confirms it is a **null dereference** (fault address `0x4`), not a stale
  pointer — so the failure *mode* is settled. What remains open is the exact *named* variable
  that is null; that needs `server.dll + 0x88238c` symbolised against the release PDB, which
  the maintainers have and I do not. The evidence (null read at +4, `rec_offs` is `uint16_t`,
  the value flows from `rtr_page_get_father_block` at line 590) points to a null `offsets`,
  and the `!offsets` guard covers it either way.
- I cannot build or test this. Validating it means compiling MariaDB from source with the
  patch and replaying the repro — a substantial exercise on Windows, and **not something to
  do to a production server**. Treat the patch as a proposal for MariaDB's maintainers (or a
  throwaway test build), not a hotfix to deploy.
- `DB_CORRUPTION` is a blunt outcome (the insert fails), but it is vastly better than a
  crash and matches how this function already handles its other failure paths.

---

## 5. Practical recommendation

For my app, the **drop-the-index workaround remains the right operational choice** — it
sidesteps the buggy code path entirely, the index is dormant until Phase 3, and it needs no
custom MariaDB build. Do not run a self-patched server in production to keep a dormant index.

The high value of this analysis is the **bug report** (§6): a maintainer receiving an exact
crash site, a root-cause hypothesis, a candidate patch, and a deterministic repro can turn a
fix around far faster than a bare stack trace. File it, attach the minidump, and let a fixed
release be what lets you re-enable the index at the Phase-3 gate.

---

## 6. Ready-to-file report (paste into jira.mariadb.org)

> **Summary:** SIGSEGV in `rtr_update_mbr_field` during R-tree page split on bulk INSERT
> into a SPATIAL index (release build), MariaDB 12.3.2.
>
> **Version:** 12.3.2-MariaDB-log, source revision 9f98f82b14a9b939834281672b6d0cf965db69a3,
> Windows x64.
>
> **Description:** During a large bulk load into an InnoDB table with a `SPATIAL` index on a
> `POINT NOT NULL` column (SRID 0), the server crashes with exception `0xc0000005` once the
> R-tree is large enough for a page split to propagate to the parent level. Reproducible at
> the same insert on repeated runs of the same data; sensitive to insertion order.
>
> **Stack:**
> ```
> rtr_update_mbr_field()      gis0rtree.cc:225
> rtr_adjust_upper_level()    gis0rtree.cc:595
> rtr_page_split_and_insert() gis0rtree.cc:1160
> btr_cur_pessimistic_insert()btr0cur.cc:2652
> row_ins_sec_index_entry_low()
> ```
>
> **Minidump (attached):** `EXCEPTION_ACCESS_VIOLATION`, **READ at `0x0000000000000004`**
> (null-pointer dereference, field at +4 of a null base — not a stale pointer). Fault
> `RIP = server.dll + 0x88238c`. Registers `RCX=RDX=RBX=RSI=R8=R14=0`. x64, Windows build
> 26200. The `0x4` fault offset is consistent with a null `rec_offs*` (`rec_offs` is
> `uint16_t`, `rec_offs_base(offsets)[0]` == `offsets[2]` == byte +4); please confirm the exact
> variable by symbolising `server.dll + 0x88238c`.
>
> **Analysis:** `rtr_adjust_upper_level` (gis0rtree.cc:590–596) consumes the result of
> `rtr_page_get_father_block()` without validation and passes it to `rtr_update_mbr_field`,
> which dereferences `cursor`/`offsets` at gis0rtree.cc:229 (`rec_get_info_bits(rec,
> rec_offs_comp(offsets))`). When the MBR-guided father-pointer search fails to position on
> the child's parent node pointer (reachable on deep/dense trees with overlapping MBRs),
> release builds — with the downstream `ut_ad()` asserts compiled out — dereference an
> invalid pointer and crash. Suggested minimal fix: validate the father-search result and
> return `DB_CORRUPTION` (consistent with the function's other error paths) rather than
> proceeding to the dereference; longer term, harden `rtr_page_get_father_block` for the
> overlapping-MBR case. Minidump available.
>
> **Repro sketch:** table `(… , pt POINT NOT NULL, SPATIAL INDEX(pt))` ENGINE=InnoDB;
> bulk `INSERT` several hundred thousand points (SRID 0) via batched multi-row inserts; crash
> occurs once the tree is large enough to split at the parent level.
