Segmentation Fault in SQLite 3.36.0 Multi-Index OR with LEFT JOIN Optimization
Crash Context: Multi-Index OR Optimization in LEFT JOIN Queries with INDEXED BY Clause
Issue Overview
The crash occurs in SQLite 3.36.0 when executing a SELECT
query that combines a LEFT JOIN
with a WHERE
clause containing an OR
condition, indexed lookups via INDEXED BY
, and a likely()
function. The query leverages the multi-index OR optimization, a feature designed to accelerate queries with OR
predicates by splitting them into separate index scans. However, under specific conditions involving cursor management during LEFT JOIN
processing, this optimization leads to a segmentation fault due to improper initialization of index cursors.
Key Components of the Faulty Query
Schema Setup:
t1(a INT, b)
with indexi1
ona
.t2(e, d, f)
with no explicit indexes.- The
INDEXED BY i1
clause forces the query planner to use indexi1
for thet1
table.
Query Structure:
SELECT e FROM t2 LEFT JOIN t1 INDEXED BY i1 ON likely(a=e) WHERE (b=5 AND e=12) OR (e=11 AND a=4) ORDER BY e;
The
WHERE
clause contains twoOR
branches, each referencing columns fromt1
andt2
. Thelikely(a=e)
hint in theLEFT JOIN
condition biases the query planner toward specific index scans.Crash Trigger:
The segmentation fault occurs during bytecode execution atOP_ReopenIdx
(addresses 10 and 23 in the bytecode dump). This opcode attempts to reopen an index cursor that was never properly initialized. The root cause lies in the interaction between the multi-index OR optimization and cursor lifecycle management forLEFT JOIN
processing.Query Plan Analysis:
The plan usesMULTI-INDEX OR
to split theWHERE
clause into two index scans oni1
, followed by a temporary B-tree for sorting. TheSCAN t2
feeds rows into the OR branches, but the index cursor fori1
is not initialized beforeOP_ReopenIdx
is called, leading to invalid memory access.Bisecting History:
The issue was traced to SQLite check-ince35e39c5c
, which introduced changes to cursor handling during covering index optimizations. Later optimizations (e.g., deferred seeks) exacerbated the problem when combined withINDEXED BY
constraints.
Root Causes: Cursor Initialization Failures in Multi-Index OR with Forced Indexes
Cursor Lifecycle Mismanagement in LEFT JOINs
SQLite uses cursors (B-tree iterators) to traverse tables and indexes. In LEFT JOIN
processing, cursors for the right-hand table (t1
in this case) must be initialized even when no matching rows exist. The OP_NullRow
opcode (bytecode address 52) nullifies t1
’s cursor if no match is found. However, the MULTI-INDEX OR
optimization bypasses standard cursor initialization sequences when forced indexes (INDEXED BY
) are present.
Forced Indexes and Covering Index Assumptions:
TheINDEXED BY i1
clause directs the query planner to treati1
as a covering index. However,i1
only covers columna
, notb
, requiring additional table lookups. The planner incorrectly assumesi1
is fully covering, leading to incomplete cursor setup.Deferred Seek and ReopenIdx Interactions:
TheOP_ReopenIdx
opcode (addresses 10, 23) reuses an existing cursor to avoid reopening costs. However, in this query, the cursor fori1
is never opened prior toReopenIdx
due to:- The
likely()
function altering cost estimates, favoring a suboptimal plan. - The
MULTI-INDEX OR
splitting the index scan into two branches, each attempting to reuse the same cursor without proper initialization.
- The
NullRow Handling in LEFT JOINs:
When no rows match theLEFT JOIN
condition,OP_NullRow
nullifiest1
’s cursor. However, if the cursor was never initialized (due to forced index assumptions), this opcode dereferences an invalid cursor handle, causing the crash.Impact of likely() and Cost Adjustments:
Thelikely(a=e)
hint biases the planner to assume thea=e
condition is true, influencing index selection. Combined withINDEXED BY
, this creates a scenario where the planner skips cursor initialization steps normally required for non-covering indexes.
Resolution Strategy: Cursor Initialization Fixes and Query Plan Adjustments
Step 1: Apply Official SQLite Patches
The SQLite trunk fix (787c76a865dc51db) addresses the cursor initialization race condition. The patch ensures that cursors for forced indexes are properly initialized before OP_ReopenIdx
is executed.
Key Changes in the Fix:
- Explicit Cursor Opening: Forces the cursor associated with
i1
to be opened during query setup, even if the index is assumed to be covering. - NullRow Safeguards: Adds checks to prevent
OP_NullRow
from operating on uninitialized cursors.
Verification:
After applying the patch, re-examine the bytecode for the query. The OP_ReopenIdx
opcodes (addresses 10, 23) should now be preceded by OP_OpenRead
or OP_OpenIdx
instructions for i1
.
Step 2: Query Rewrites to Avoid Risky Optimizations
If upgrading SQLite is not feasible, modify the query to bypass the problematic optimization:
Option 1: Remove INDEXED BY
SELECT e FROM t2 LEFT JOIN t1 ON likely(a=e)
WHERE (b=5 AND e=12) OR (e=11 AND a=4) ORDER BY e;
This allows the query planner to choose indexes dynamically, avoiding forced index assumptions.
Option 2: Disable Multi-Index OR
Add a dummy +
operator to prevent OR flattening:
SELECT e FROM t2 LEFT JOIN t1 INDEXED BY i1 ON likely(a=e)
WHERE (b=5 AND e=12) OR (+(e=11) AND a=4) ORDER BY e;
The +
inhibits the multi-index OR optimization, forcing a single index scan.
Option 3: Materialize Subqueries
Use UNION ALL
to separate OR branches:
SELECT e FROM (
SELECT e FROM t2 LEFT JOIN t1 INDEXED BY i1 ON likely(a=e)
WHERE b=5 AND e=12
UNION ALL
SELECT e FROM t2 LEFT JOIN t1 INDEXED BY i1 ON likely(a=e)
WHERE e=11 AND a=4
) ORDER BY e;
Step 3: Schema Adjustments for Safer Index Usage
- Drop Redundant Indexes: Ensure no unused indexes influence the planner.
- Add Covering Indexes: If
i1
includedb
, it would qualify as covering, preventing the crash:CREATE INDEX i1 ON t1(a, b); -- Now covers both a and b
Step 4: Monitor Query Plans with EXPLAIN
Use EXPLAIN
and EXPLAIN QUERY PLAN
to detect risky optimizations:
EXPLAIN QUERY PLAN
SELECT e FROM t2 LEFT JOIN t1 INDEXED BY i1 ON likely(a=e) ...;
Look for MULTI-INDEX OR
and verify cursor initialization opcodes (OpenRead
, OpenIdx
).
Step 5: Regression Testing with Custom Bytecode Analysis
For advanced users, inspect the bytecode of suspect queries using sqlite3_stmt_status()
or debugging builds. Validate that:
OP_ReopenIdx
is always preceded byOP_OpenRead
for the same cursor.OP_NullRow
operates only on initialized cursors.
This guide provides a comprehensive pathway to diagnose, resolve, and prevent crashes stemming from cursor mismanagement in complex SQLite queries. By addressing both the immediate bug and underlying planner assumptions, users can stabilize queries leveraging multi-index OR optimizations.