Skip to content

PS-10256 [trunk] Vector Search MVP - #6286

Merged
inikep merged 30 commits into
percona:trunkfrom
inikep:PS-11299-trunk
Oct 29, 2026
Merged

inikep merged 30 commits into
percona:trunkfrom
inikep:PS-11299-trunk

Conversation

@inikep

@inikep inikep commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

https://perconadev.atlassian.net/browse/PS-10256

Port of the Vector Search MVP (HNSW vector indexes in InnoDB) from 9.7 to trunk. These are the 30 vector commits in cbc733e84cbc..percona/9.7 that were not on trunk yet, cherry-picked in their 9.7 order and with their 9.7 titles plus a [trunk] tag:

  • PS-11266, PS-11267: the basic HNSW class, with persistence and on-demand loading.
  • PS-11264: vector indexes in the data dictionary, and the syntax.
  • PS-11299 [1]–[14]: aux tables, the hidden label column and its counter, the node arena, aux rows, graph persistence, search, corruption handling, in-place ALTER, the DISCARD/IMPORT refusal, the design document (Docs/percona-hnsw.md), and the optimizer and syntax follow-ups.
  • PS-11212: the optimizer changes.
  • Fixes: PS-11750, PS-11591, PS-11756, PS-11618, PS-11615, PS-11612, PS-11748, PS-11751.

Trunk-specific changes

  • Label counter persistence ([2]). Trunk's WL#15550 removed redo-logged dynamic metadata (MLOG_TABLE_DYNAMIC_META, Persister::write_log, dict_persist_update_log_margin). So Vec_label_counter::log() became persist_counter(): it raises the persisted watermark with a CAS-max, then writes the value to DDTableBuffer via dict_table_mark_dirty() + dict_table_persist_to_dd_table_buffer(). The 9.7 log-margin changes were dropped. PM_TABLE_VEC_IDX_ID is also registered in the new redo applier (log0redo.cc). VecIdxIdPersister uses trunk's size_t persister signatures.
  • ut0new.h: vec0dml and vec0hnsw were added to auto_event_names, which trunk enforces at compile time.
  • I_S version: IS_DD_VERSION = 260700, with the i_s_schema_definition_debug checksums for lctn 0 and 1.
  • Tests:
    • percona_vector.aux_node_missing and aux_corrupt_entry_point now run ANALYZE TABLE before the restart. On trunk, the row estimate otherwise stays at or below the LIMIT, so the ANN plan isn't chosen.
    • main.all_persisted_variables: the count went from 499 to 501.
  • Conflicts in handler0alter.cc and ha_innodb.h were resolved to trunk's context.

Open question

  • The per-label DDTableBuffer write takes the global dict_persist->mutex twice per labelled row (in dict_table_mark_dirty() and dict_table_persist_to_dd_table_buffer()). Preallocating labels, as autoinc does, would remove that, at the cost of skipped label values after a restart.
  • Skipped labels are harmless, because labels are hidden and the graph is a hash map keyed by label.
  • This PR keeps the exact per-row write. Preallocation is tracked in PS-11960.

Tests

Debug build, GCC 16, -Werror:

  • Unit tests: 135 + 28 pass.
  • MTR main, innodb, percona, percona_vector, sys_vars, information_schema, funcs_1: all pass.
  • RocksDB suites, Valgrind/ASan: not run.

Implemented by Opus 5.5 high

dlenev and others added 8 commits October 27, 2026 12:45
… read operations support).

https://perconadev.atlassian.net/browse/PS-11266

Added basic implementation of in-memory HNSW index.

It supports insert and approximate k-NN search per the HNSW paper
(https://arxiv.org/abs/1603.09320) and uses arena allocator for nodes.

Also added streaming NN-search support for it.

Added unit tests.

Also added recall / memory / throughput benchmarks for our HNSW implementation
on a synthetic data-set.

Improved by Opus 5.5 high: port to trunk
…1203, PS-11569 - syntax]

Vector indexes have the type (algorithm) SE_SPECIFIC, and we add a column option
in the data dictionary saying `PS_is_vector_index=1;` which gets picked up by
dedicated code in the data dictionary and the handler part of InnoDB.

In the SQL layer, the vector index is very much a thing; there is an
`HA_KEY_ALG_VECTOR`, an `HA_VECTOR` and a `KEYTYPE_VECTOR`.

Extra SQL is added to display the type of a vector index as VECTOR rather than
SE_SPECIFIC.

PS-11203: Vector Index Syntax

Adding the syntax `TYPE <ident> WITH ( <ident> = <ident>...) ` to index creation
syntax. E.g.:

`CREATE INDEX <name> ( <table> ) TYPE hnsw WITH ( M = 6 )`

The syntax in the `WITH` list, must be verified by the storage engine. There
were no hooks for this in InnoDB so one has been added in check_engine().  We
have to do it fairly early so that we can prevent table creation in the DD in
case of errors.  Hence, the index type and parameter list are validated inside
the storage engine using a new handlerton interface
validate_vector_index_params().

The index construction parameters are serialized as a string of key-value pairs
inside the index's `option` field. The serialization and de-serialization happen
entirely inside the Data Dictionary.

To do: We will probably still need a hook to handle the case of an
already-existing table with invalid attributes; there are no hooks for this.

PS-11569: Harmonize the vector index syntax with upstream.

We use the VECTOR keyword and drop WITH before parameters, e.g.

  CREATE VECTOR INDEX name ON table ( column ) TYPE hnsw ( k = v... )

Similar for inline and ALTER syntaxes.

We employ a little hack to get by without reserving VECTOR_SYM as a
proper keyword.

Also adding more distance metrics in the parser.

Moves the metric data structures and mapping from Item sources to
vector-common where the innodb handler can reuse them for parsing.

Improved by Opus 5.5 high: port to trunk; bump IS_DD_VERSION to trunk's MYSQL_VERSION_ID 260700 instead of 90702, and publish trunk's I_S checksums for it (the lctn=1 one computed on Linux with lower_case_table_names=1, a method that reproduces trunk's published 90500 lctn=1 checksum)
…e and loading) [persistence, on-demand loading].

https://perconadev.atlassian.net/browse/PS-11267

Added persistence and on-demand loading APIs to HNSW implementation.

Extended unit tests.

Improved by Opus 5.5 high: port to trunk
…e and loading) [thread-safety].

https://perconadev.atlassian.net/browse/PS-11267

Made our HNSW implementation thread-safe.

Extended unit tests.

Improved by Opus 5.5 high: port to trunk
… read operations support) [follow-up].

https://perconadev.atlassian.net/browse/PS-11266

Extended k_nn_search() and streamed search API to return graph node
ids for entries found (in addition to primary keys of corresponding
base table rows).

Improved by Opus 5.5 high: port to trunk
…e and loading) [error and OOM handling].

https://perconadev.atlassian.net/browse/PS-11267

Added handling of errors from persistence callbacks.

Also added handling of OOM to HNSW implementation.

Improved by Opus 5.5 high: port to trunk
…e and loading) [post review changes].

https://perconadev.atlassian.net/browse/PS-11267

- Clarified assumptions about callbacks.
- Clarified invariants for entry point.
- Avoid holding node lock while calling updated_neighbor_cb() callback.
- Explained while failure of the first updated_neighbor_cb() which keeps
  node unlinked is not a big issue.
- Prefer RAII for handling of locks instead of explicit lock/unlock (where
  possible).
- Do not suppress callback failure error code and pass it to user instead.
- Moved HNSW::Result out of HNSW class so we can have strongly typed
  callback return values.
- Split NODE_FAILED (to be used for failed insertion case) from NODE_LOST.
  This solves issue in select_neighbors(), when NODE_INSERTING nodes inserted
  by other threads became NODE_LOST asynchronously, thus failing original
  assert/pre-condition in this function.
- Various improvements/fixes to unit tests.

Improved by Opus 5.5 high: port to trunk
…PS-11673]

Each vector index keeps its graph in its own hidden InnoDB table, named
from the parent's table id and the index id:

  test/t1  --- KEY vk (v) TYPE hnsw --->  test/percona_vec_hnsw_<tid>_<iid>

This adds building and parsing that name, and creating, dropping and
renaming one aux table with its dd::Table and tablespace, through the
InnoDB C API. Aux tables are hidden from the server as FTS aux tables
are. The drop and rename hooks are inert until the next commit sets the
hidden column's flag they key on.

Vector option parsing is split from validation, so a definition read
back from the DD is parsed without being re-validated.

User-visible: RENAME TABLE to an aux-shaped name fails with
ER_WRONG_TABLE_NAME; HNSW M must be between 2 and 200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

PS-11673 : Debug assertion in Dictionary_client::acquire on cross-schema RENAME TABLE after restart for tables with VECTOR index

JIRA: https://perconadev.atlassian.net/browse/PS-11673

Problem:
-------
A cross-schema RENAME of a table with a VECTOR index also renames the
hidden vector aux tables. row_rename_table_for_mysql() calls
vec_aux_rename_tables(), which calls row_rename_table_for_mysql() again
for each aux table. That opens the aux table with
dd_table_open_on_name(thd, nullptr, ...), so no MDL is taken. The server
locked only the parent, because the aux tables are hidden.
dd_rename_aux_table() locks the old and new aux names, but only after
the aux table was opened.

Before a restart the aux table is in the InnoDB dictionary cache and the
open never reaches the data dictionary. After a restart it is loaded
from the data dictionary without an MDL, and a debug build aborts:

  CREATE TABLE d1.t (id INT PRIMARY KEY, v VECTOR(4) NOT NULL,
                     VECTOR KEY vk (v) TYPE hnsw);
  -- restart
  RENAME TABLE d1.t TO d2.t;

  dictionary_client.cc:734: Dictionary_client::acquire(): Assertion
  `m_thd->is_dd_system_thread() || is_locked(m_thd, ...)' failed.

Fix:
----
innobase_basic_ddl::rename_impl() takes an exclusive MDL on every vector
aux table of a cross-schema rename before row_rename_table_for_mysql()
runs, with vec_aux_lock_all_tables(), as row_drop_table_for_mysql() does
for DROP TABLE. It takes them while the parent is open and no dictionary
latch is held. If a lock cannot be taken, the rename fails and the table
is unchanged.

The FTS aux tables have the same problem, fixed separately in PS-11827.
Until then a table with both a vector and a FULLTEXT index still aborts
a debug server in fts_rename_aux_tables(), and the rename is rolled back
on restart.

Tests: percona_vector.aux_rename_after_restart covers vector-only
tables. percona_vector.aux_rename_fts_after_restart covers a table with
both indexes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Improved by Opus 5.5 high: port to trunk
@inikep
inikep requested review from dlenev and removed request for dlenev October 28, 2026 12:35
satya-bodapati and others added 20 commits October 29, 2026 09:42
…e DD

Every row in a vector index needs a stable label the aux table can key
on, and the primary key can change under an UPDATE. So a table with a
vector index gets a hidden BIGINT UNSIGNED column, percona_vec_aux_id,
filled on INSERT from a per-table counter. The counter must never hand
out a label twice: each advance is redo-logged as autoinc is, and its
value kept in the table's DD entry (Vec_label_counter). CREATE TABLE
with a vector index now creates the aux table too.

A vector index has no B-tree, so insert, search, undo, purge, the row
log, statistics and the DDL log skip it.

User-visible: ADD VECTOR INDEX and rebuilds use COPY for now; INSTANT
ADD/DROP COLUMN is refused while the column exists; a vector index on a
table with a CASCADE foreign key is refused, as are a user column named
percona_vec_aux_id and a CREATE TABLE with an aux-shaped name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Improved by Opus 5.5 high: port to trunk; after WL#15550 dynamic metadata is no longer redo-logged, so Vec_label_counter::log() is replaced by persist_counter(), which writes each counter advance to DDTableBuffer as dict_table_autoinc_persist() does (without preallocation), the redo log margin changes are dropped, and the redo parser also registers PM_TABLE_VEC_IDX_ID
Improved by Opus 5.5 high: apply clang-format
A vector index's nodes, vectors and neighbour lists come from a
per-index arena rather than the general heap, so the memory each index
holds can be measured and capped. Vec_arena takes 64KB slabs from a
mem_heap_t, gives an oversized request a slab of its own, frees
everything at once, and adds what it holds to one server-wide counter.

  Vec_arena --- node, vector, neighbours ---> global byte counter
                                                      |
                                           innodb_hnsw_max_memory

New sysvar innodb_hnsw_max_memory (global, dynamic, default 1GB, 0 = no
limit). Nothing checks it yet; the graph that does comes later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Improved by Opus 5.5 high: port to trunk; count innodb_hnsw_max_memory in all_persisted_variables, as trunk's estimate has no room left for it
The aux table holds one row per graph node, keyed by label: the vector,
the base row's primary key, the node's level and its neighbour list.
Row 0 is not a node; it names the entry point.

vec0dml is that table's row API, on InnoDB's query graphs rather than
the internal SQL parser: vec_aux_insert, vec_aux_update_row and
vec_aux_read_node (a point lookup with no read view). Vec_aux_bulk holds
the state for a bottom-up Btree_load; the build that uses it comes
later.

Nothing calls this from SQL yet. The interpreter gains vec_aux_insert_row,
vec_aux_update_row, vec_aux_dump and vec_aux_verify to drive and check it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Improved by Opus 5.5 high: port to trunk; register vec0dml in auto_event_names, which trunk requires of every file using UT_NEW_THIS_FILE_PSI_KEY
…saction

The HNSW graph, and the DML that keeps it in step with the table.

Each vector index gets a runtime (vec_t), built when the table is
opened. The graph loads lazily: the entry point from aux row 0, every
other node when a traversal first reaches it.

The graph calls back into InnoDB (Vec_persistor) for each node it
creates or rewires. Those aux writes go on a background sub-transaction
that commits after every callback:

  INSERT -> base row -> graph insert -> callback -> aux row, commit ...

The in-memory graph cannot roll back, so neither does the aux: a
rolled-back statement leaves an orphan node that readers filter out.
UPDATE of the vector or the primary key gives the row a fresh label and
a new node; DELETE writes nothing.

User-visible: a vector of the wrong length is refused with
ER_VECTOR_INDEX_WRONG_DIMENSIONS; a graph that cannot allocate fails
the statement, with ER_CAPACITY_EXCEEDED at innodb_hnsw_max_memory.

The search's visited set is an ankerl::unordered_dense::set, reserved
once per search.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Improved by Opus 5.5 high: port to trunk; register vec0hnsw in auto_event_names, which trunk requires of every file using UT_NEW_THIS_FILE_PSI_KEY
Answer SELECT ... ORDER BY DISTANCE(v, <const>) LIMIT k from the graph.

The optimizer gives a single-table block a vector access path (EXPLAIN
type vector_ann) when its only ORDER BY is an ascending EUCLIDEAN
distance over the indexed column, with a constant query vector and a
LIMIT; the sort is dropped. The scan streams, so a WHERE above it can
consume any number of candidates. Each candidate is read by primary key
under the reader's view and returned only if the row's hidden label
matches the node, so stale, deleted and orphan nodes are skipped.

User-visible: new sysvar innodb_hnsw_ef_search (global and session,
default 40); HANDLER ... READ on a vector index is refused; a snapshot
older than the index gets ER_TABLE_DEF_CHANGED; the index is never used
for COUNT(*) or range estimates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

PS-11614 : SELECT ... FOR UPDATE / FOR SHARE on a vector-indexed table locks rows outside the result set (stale HNSW candidate never unlocked)

JIRA: https://perconadev.atlassian.net/browse/PS-11614

Problem:
-------
An UPDATE of the vector leaves the row's old graph node behind. A locking
vector search reads the row for that node, locks it, sees the label does
not match and skips it, but keeps the lock:

  UPDATE t SET v = far WHERE id = 3;        -- old node of 3 stays near
  SELECT id FROM t ORDER BY DISTANCE(v, origin) LIMIT 2 FOR UPDATE;
    returns 1, 2
    locks   1, 2, 3     <- 3 is not in the result

Fix:
----
vec_read_next() calls unlock_row() on a skipped candidate, as for a row
the WHERE rejects: READ COMMITTED releases the lock, REPEATABLE READ keeps
it, as InnoDB does for any row it reads and filters out.

Test: percona_vector.lock_stale_candidate and lock_stale_candidate_debug

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Improved by Opus 5.5 high: port to trunk; aux_node_missing runs ANALYZE TABLE t1 before its restart, so the vector path does not depend on whether background statistics ran first, and all_persisted_variables counts innodb_hnsw_ef_search
When the graph and its aux table disagree, the fault is the index's,
not the table's. An entry point of 0 or a malformed aux row returns
DB_INDEX_CORRUPT, as a missing node already did, rather than
DB_CORRUPTION, which reported the base table as crashed.

It sticks. A search that finds a node missing while faulting in
neighbours now marks the index corrupt too, as the load path does, so
later statements are refused rather than answered from a short graph.
Any other read error passes through unchanged, so a transient one stays
retryable. DB_VEC_OUT_OF_MEMORY now maps to out-of-memory on the DML
and ALTER error paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Improved by Opus 5.5 high: port to trunk; aux_corrupt_entry_point runs ANALYZE TABLE t1 before its restart, so the vector path does not depend on whether background statistics ran first
…s refusals [+ PS-11623]

ADD VECTOR INDEX and rebuilds build the graph through ddl::Builder,
from the same clustered scan as every other index:

  clustered scan --> ddl::Builder --> graph in memory --> aux, in bulk

The scan threads insert into an in-memory graph; when the scan ends it
is walked once and the aux written in the ALTER's transaction. A
rebuild that builds a vector index sorts the clustered rows, so its
scan runs in parallel.

The hidden percona_vec_aux_id column exists only while the table has a
vector index: the first ADD adds it with a rebuild that labels each
row, and dropping the last vector index removes it. Other rebuilds keep
the labels.

User-visible: ALGORITHM=INSTANT and LOCK=NONE are refused while a vector
index exists (dropping the last one may run online); on a FULLTEXT
table the first ADD and last DROP run as COPY and ALGORITHM=INPLACE is
refused; a build stops at innodb_hnsw_max_memory with
ER_CAPACITY_EXCEEDED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

PS-11623 : ALTER TABLE ... ADD FULLTEXT KEY ... , ADD VECTOR KEY ... TYPE hnsw crashes the server with Assertion failure: ddl0loader.cc:273:stage != nullptr when the vector index build aborts due to innodb_hnsw_max_memory capacity exhaustion.

JIRA: https://perconadev.atlassian.net/browse/PS-11623

Problem:
-------
An ALTER that adds several indexes sets up one builder per index, in
index order, before the scan:

  builder:  PRIMARY   vk                    ft        FTS_DOC_ID_INDEX
  setup:    ok        refused               never     never
                      (DB_VEC_MEMORY_LIMIT)

When graph memory already fills innodb_hnsw_max_memory, vec_build_start()
refuses vk, and the scan returns before the builders after it are set up.
They have no stage, and Loader::~Loader() asserted that every builder has
one (ddl0loader.cc:273). FULLTEXT is not needed: any index after the
vector one is enough, e.g.

  SET GLOBAL innodb_hnsw_max_memory = 1;  -- another table's graph is resident
  ALTER TABLE t1 ADD VECTOR KEY vk (v) TYPE hnsw, ADD KEY kc (c), ALGORITHM=INPLACE;

Fix:
----
The assertion is debug-only and allows a builder that never started; the
loop below it already skips a builder with no stage. The ALTER fails with
ER_CAPACITY_EXCEEDED and the table is unchanged.

Test: percona_vector.build_limit_other_index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Improved by Opus 5.5 high: port to trunk
… tables

A table with a vector index cannot move as a transportable tablespace:
the graph is in its own aux .ibd, which does not travel, and the label
counter lives in the DD, not in either file. An imported .ibd would
bring labels the target's counter knows nothing about, and hand them
out again.

So DISCARD and IMPORT are refused on such a table, and an import is
refused when its .cfg describes percona_vec_aux_id. FLUSH TABLES ... FOR
EXPORT only warns: the export can be taken but not imported back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Improved by Opus 5.5 high: port to trunk
The design of persistent HNSW vector indexes in InnoDB: the aux table,
how labels are assigned and persisted, what each DDL and DML path does,
and what is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Improved by Opus 5.5 high: port to trunk
…he index path [+ PS-11580]

The graph search cannot answer a NULL query vector, or one with a NaN
or infinite component; a NaN broke the heap order and asserted in
HNSW::search_layer_core on a debug server. optimize_vector_query() now
leaves such a constant on the exact path, reading it only where
evaluate_during_optimization() allows, so EXPLAIN does not run a stored
function. vec_read_first() keeps its own checks as the fallback: a NULL
searches from the origin, a non-finite component raises
ER_DATA_OUT_OF_RANGE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

PS-11580 : Vector-index (HNSW) access path silently swallows a dimension-mismatch error that DISTANCE() itself raises correctly everywhere else.

https://perconadev.atlassian.net/browse/PS-11580

Problem
-------

SELECT ... ORDER BY DISTANCE(...) LIMIT ... statements which tried to find
closest neighbors of the query vector which had wrong number dimensions
returned error when HNSW index was not used and empty result set when it was.

Analysis
--------
While ha_innobase::vec_read_first() correctly detected the fact that query
vector has wrong number of dimensions, it reported this fact to its
caller as end of result, instead of returning more appropriate error
about dimension-mismatch. Hence SQL executor treated the situation as if
vector search didn't return any rows.

Fix
---
ha_innobase::vec_read_first() now reports problem when query vector has
wrong number of dimensions as HA_ERR_VECTOR_WRONG_DIMENSIONS SE error.
SQL-executor code maps this SE error to the same error as reported by
vector DISTANCE() function in case when its argument vectors have
dimension-mismatch.

Improved by Opus 5.5 high: port to trunk
…f the vector path

A grouped or DISTINCT block's ORDER BY sorts groups, and a window
function must see every row first, so a scan in approximate distance
order answers none of them; GROUP BY g, v ORDER BY DISTANCE(...) LIMIT n
asserted in test_if_skip_sort_order(). A block with MATCH() is kept off
too: the vector scan replaced the full-text access and MATCH() crashed
in innobase_fts_retrieve_ranking. A DISTINCT the optimizer removes
still uses the index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Improved by Opus 5.5 high: port to trunk
ORDER BY d, where d names a select-list DISTANCE() by alias or
position, got a filesort: JOIN::optimize() ran optimize_vector_query()
only when has_vector_funcs() found a DISTANCE() on the block's list.
optimize_vector_query() works everything out from the ORDER BY, so it
now runs without that check. The JT_VECTOR assertion now looks through
real_item(), so ORDER BY a view's DISTANCE() column no longer aborts a
debug server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Improved by Opus 5.5 high: port to trunk
…_vector

vector_index_syntax, vector_index_syntax_innodb and
vector_index_syntax_rocksdb move into percona_vector as index_syntax,
index_syntax_innodb and index_syntax_rocksdb.

Add percona_vector to the jenkins suites

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Improved by Opus 5.5 high: port to trunk
Integrating vector indexes with the optimizer.

A new function add_vector_keys() as added that is now called from
update_ref_and_keys(). It creates Key_use objects for the vecor
indexes which receive special treatment during cost-based optimization
during find_best_ref(), similar to fulltext indexes.

These key uses will in practice never be chosen by the optimizer,
in theory a future cost model might find it cheaper that table
scan, for example if the HNSW index up until the LIMIT index is in RAM.

Instead, the optimizer picks up the vector indexes
during test_if_cheaper_ordering() - called from test_if_skip_sort_order()
and/or make_join_query_block() and activates them for the execution plan.

What we don't do is add a proper cost model. That would require
making a detailed and realistic cost model HNSW access, on par with the
B-tree cost calculation. Instead, we have a crude heuristic checking if
the LIMIT is less than the table size. This is what AliSQL and Google do
in their implementations.

JOIN::optimize_vector_query() is removed as add_vector_keys() takes over
that job. If we want to mirror JOIN::optimize_fts_query(), this would be
the place to do specific vector index optimizations, which we currently
do not do.

PS-11626 : IGNORE INDEX ... FOR ORDER BY (vk)` is ignored for vector (ANN) queries: `ORDER BY DISTANCE() LIMIT n` still uses the HNSW index

Restrict vector-index ordering to usable keys in test_if_cheaper_ordering()

The vector-index setup ran before the usable_keys.is_set(nr) check,
so it could pick a vector index excluded from the usable set. Moved
it inside that check, and made add_vector_keys() also respect
keys_is_use_for_order_by.

PS-11616 : Remaining optimizer improvements

Fix the problem of HAVING clauses disqualifying vector search.
The LIMIT value is normally clobbered if there's a HAVING clause,
this commit adds an exception for vector indexes.

Adding test for queries using metrics other than that of the
existing index. They are already properly rejected, this just adds
the test.

Also adding tests for views. They also work already. A view with a
LIMIT gets materialized, but still uses vector search.

Improved by Opus 5.5 high: port to trunk
…ex memory usage

JIRA: https://perconadev.atlassian.net/browse/PS-11750

Problem:
-------
innodb_hnsw_max_memory limits the memory of all HNSW graphs together,
but nothing showed how much is in use. Users could not see how close
they were to the limit, or how much room a vector index rebuild needs.

Fix:
----
New global status variable Innodb_hnsw_memory_used: the bytes held by
every graph's arena on the server, the number the limit is compared
with (Vec_arena::global_bytes()). It is exported through export_vars in
srv_export_innodb_status(), like the other InnoDB status variables.

  SHOW GLOBAL STATUS LIKE 'Innodb_hnsw_memory_used';

It grows as graphs are loaded and drops when one is freed (DROP, a
rebuild, eviction of the table). It does not count the node map
(HNSW::m_nodes) or search scratch space. Per-index memory is PS-11749.

Test: percona_vector.hnsw_memory_used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Improved by Opus 5.5 high: port to trunk
…) — server crash on INSERT into a vector-indexed table after an unclean restart

JIRA: https://perconadev.atlassian.net/browse/PS-11591

Problem:
-------
A vector table persists AUTO_INCREMENT and the vector next id. Each
change writes a redo record holding only that counter, with the table
version. A checkpoint writes both to the DD table buffer. At load,
dict_table_apply_dynamic_metadata() used them only if the buffered
version equals the table's; otherwise they came from the definition.
The version is the AUTO_INCREMENT epoch: only dd_set_autoinc() bumps it.

1. Crash with no checkpoint after a DDL:

  CREATE ... AUTO_INCREMENT, vector index    version 1
  INSERT x7                                   buffer {v1, autoinc 7, id 7}
  ALTER ... ADD INDEX                         version 2, definition: id 7
  DELETE id 1, 2; INSERT id 1, 2 again        redo [v2 id 8] [v2 id 9]
  kill -9

  The recovered id 9 did not carry version 2, so the load ignored it and
  took 7 from the definition. The next INSERT got id 8 again: assertion
  in HNSW::insert().

2. Crash with a checkpoint after the DDL:

  ALTER ... ADD INDEX                         version 2, definition: autoinc 3
  INSERT x4, checkpoint                       buffer {v2, autoinc 7, id 7}
  DELETE id 1, 2; INSERT id 1, 2 again        redo [v2 id 8] [v2 id 9]
  kill -9

  MetadataRecover::store() wrote the recovered {id 9} over the buffered
  row, dropping autoinc 7. AUTO_INCREMENT came back as 3 and the next
  INSERT failed with a duplicate key. The same overwrite loses a buffered
  corrupted-index flag or AUTO_INCREMENT on tables without vectors.

Fix:
----
Each field keeps one rule at every stage:

  AUTO_INCREMENT     newer version replaces, older ignored (unchanged)
  corrupted ids      union, no duplicates
  vector next id     the larger value, version ignored

- dict_table_apply_dynamic_metadata() applies the vector next id before
  the version check: it only grows, so the larger value is safe.
- store() merges the recovered entry into the buffered row with the new
  Persisters::aggregate() instead of overwriting it.
- CorruptedIndexPersister::aggregate() no longer adds an id twice.

Test: percona_vector.counter_crash_no_checkpoint, counter_crash_autoinc,
counter_crash_label, counter_crash_autoinc_lowered,
counter_crash_corrupt_index, counter_crash_buffer_newer,
partition_refused; innodb.dyn_meta_corrupt_lost,
innodb.dyn_meta_autoinc_lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Improved by Opus 5.5 high: port to trunk
…init()

https://perconadev.atlassian.net/browse/PS-11756

Problem:
VectorSearchIterator::DoInit() started the scan by calling the engine
hook handler::vec_init() directly, and ha_innobase::vec_init() in turn
called rnd_init() directly. Neither went through a handler::ha_*()
wrapper, so handler::inited stayed NONE for the whole vector scan:

- ~VectorSearchIterator() calls ha_index_or_rnd_end(), which does
  nothing when inited is NONE, so it never reached index_end() to close
  the scan. The scan was closed later by end_stmt() or close().
- The server's "handler already initialised" debug assertions (in
  ha_rnd_init(), ha_index_init(), ha_reset()) read inited, so they
  could not catch another access interleaved on the same handler while
  the scan was open. Such an access would change m_prebuilt state under
  the open scan, giving wrong or truncated results.

Fix:
VectorSearchIterator::DoInit() now calls ha_index_init() on the vector
key, unless the handler is already initialised from an earlier run of
the iterator, and then vec_init() on every run: the same two steps
FullTextSearchIterator::DoInit() takes with ft_init(). ha_index_init()
sets inited to INDEX, so the destructor's ha_index_or_rnd_end() reaches
ha_index_end() -> index_end(), which closes the scan, and the debug
assertions on inited see the open scan.

InnoDB's index_init() -> change_active_index() on the vector key now
makes the checks every index read gets (a vector index newer than the
snapshot, or a corrupt one, is refused), so ha_innobase::vec_init()
no longer looks the vector key up itself. It calls rnd_init(), which
sets up the clustered index for the per-candidate PK fetches, and then
puts active_index back on the vector key so it matches inited. Only
active_index changes: m_prebuilt->index and the template stay on the
clustered index.

ha_innobase::vec_read_first() and vec_read_next() assert in debug
builds that inited is INDEX and that active_index is a valid key
number that names a vector key.

ha_innobase::index_end() now has a debug sync point,
ib_vec_scan_closed, reached only when it closes an open vector scan.
New test percona_vector.scan_close holds a vector SELECT at that
point and checks in performance_schema.processlist that the SELECT is
still running, for a scan cut short by LIMIT and for a scan that reads
the whole graph. Without the fix the sync point is never reached: the
scan is closed only at statement cleanup by ha_reset() -> end_stmt(),
so the wait times out and the statement has already finished.

Docs/percona-hnsw.md: the handler API section now describes what
vec_init() does and how the iterator starts and ends the scan.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Improved by Opus 5.5 high: port to trunk
https://perconadev.atlassian.net/browse/PS-11618

Enabled COSINE metric usage for Vector Indexes.

In order to correctly detect situation when existing vector index can't be
used to process given ORDER BY DISTANCE clause since it uses different
metric, exposed Vector Index metric as KEY::vector_index_metric.
This in its turn required introduction of new handlerton method to get
the metric from the map of vector index parameters.

Since COSINE distance is undefined for zero vectors, blocked insertion of
such vectors into tables with vector indexes with COSINE metric.
This lead to introduction of new error codes and messages on InnoDB and
SQL layer.

Note that since DISTANCE(..., COSINE) returns NULL for zero vectors in
our implemetation, and since MySQL orders NULL values before non-NULLs,
queries with ORDER BY DISTANCE(..., COSINE) are supposed to return rows
corresponding to zero-vectors before any other rows. This means that
refusing such vectors on index insertion is a better option that simply
omitting them (pgvector does this, but Postgres sorts NULLs after non-NULLs)
or inserting them with some artificial distance (MariaDB style).

Added test coverage.

Improved by Opus 5.5 high: port to trunk
…ebug crash) when a vector search runs during an in-place vector index replace

JIRA: https://perconadev.atlassian.net/browse/PS-11615

Problem:
-------
An in-place ALTER that replaces a vector index puts the new index in the
table's index list before it commits:

  ALTER TABLE t DROP KEY vk, ADD VECTOR KEY vk2 (v) ..., ALGORITHM=INPLACE;

  dict_table_t::indexes:  PRIMARY -> vk (committed) -> vk2 (uncommitted)

The vector code walked that list itself. A search running during the
ALTER found two vector indexes, and vec_index_of() hit its assertion
!other->is_vector(). open() also tried to open a runtime for vk2, and
INSERT and UPDATE could reach vk2's graph.

Fix:
----
- vec_read_first() resolves the scan's key (active_index, set by
  ha_index_init()) with innobase_get_index(), as every index read does.
  No KEY of the TABLE names an uncommitted index, so it gets vk. It
  asserts the index is a committed vector index.
- open() opens runtimes for the TABLE's vector KEYs the same way.
- vec_index_of() skips an uncommitted index, and vec_insert_row() and
  vec_update_row() take their index from it. A vector index is never
  built online (LOCK=NONE is refused for it), and vec_index_of() asserts
  that in debug builds.

Test: percona_vector.ann_during_index_replace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Improved by Opus 5.5 high: port to trunk
…ary [+ PS-11588, PS-11617]

An HNSW index created without M or metric stored no parameters in the
DD, so every open of the table filled them in from the compiled-in
HnswParam defaults (M=25, metric=euclidean, ef_construction=200). A
later release that changed a default would silently reopen existing
indexes with a different configuration than the one their graph was
built with, and SHOW CREATE TABLE did not describe the index fully.

The parameters are now resolved once, at create time, and the full set
is always stored:

- The handlerton hook validate_vector_index_params, called from
  check_engine() over every key in the Alter_info, is replaced by
  prepare_vector_index_params, called from prepare_key() for each
  vector KEY. It gets the Key_spec and the per-execution KEY that goes
  to the DD. The Key_spec is left untouched, since it is shared across
  prepared statement re-executions.
- InnoDB implements it with vec::prepare_options(): it checks the shape
  of the index on the Key_spec (no prefix, an SE-specific algorithm),
  then vec::resolve_options() parses the WITH(...) list and replaces it
  with M, metric and ef_construction in canonical form (names
  normalised, values reformatted). It is idempotent, so ALTER TABLE
  rebuilding from the DD is unaffected.
- As the check is now per key, a statement with two vector keys reports
  the error of the first one in statement order; a bad option on the
  second key is not reached, since the server refuses a second vector
  key first.
- ef_construction becomes a user-settable option, validated to
  1..4096. Unknown options such as ef_runtime are still refused.
- The HNSW defaults and limits are gathered in
storage::innobase::vec::hnsw
  (vec0vec.h), next to HnswParam. They now apply only at create time.
  metric_name(), the inverse of metric_from_name(), is added to
  vector-common/vector_constants.h.
- HnswParam::max_elements - it was unused and now it's removed.
- vec_index_config() asserts that an opened index has its parameters
set.

Tests: new percona_vector.dd_params_resolved (defaults stored, restart,
canonicalisation, SHOW CREATE round-trip, ef_construction bounds, ALTER
ADD / CREATE INDEX / ALTER COPY, prepared statement). runtime_open and
the Vec0Vec gunit tests cover ef_construction and the ef_runtime
refusal. index_syntax follows the per-key error order. Results showing
the VECTOR KEY definition are re-recorded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

PS-11617 : Same-name DROP + ADD of a vector index with a different M is a silent no-op

JIRA: https://perconadev.atlassian.net/browse/PS-11617

Problem:
-------
  ALTER TABLE t1 DROP KEY vk, ADD VECTOR KEY vk (v) TYPE hnsw (M = 12);

succeeded in place without actually touching the index: it kept its old
M. The in-place ALTER matches old and new keys by name, and
has_index_def_changed() compared neither the vector index type nor its
parameters, so it saw an unchanged index. The same applied to
ef_construction and metric. Without a rebuild, a switch to
metric = cosine also skipped the zero vector check, which the same
ALTER with ALGORITHM = COPY, or under a new name, enforces.

Fix:
----
A new handlerton hook, compare_vector_index_params, lets the SE that
owns the parameters compare two definitions of a vector index.
has_index_def_changed() calls it for a vector key, and a difference
makes the index a modified key, so it is rebuilt.

InnoDB implements it with vec::params_differ(): it parses both KEYs
with parse_options() and compares the type and the HnswParam values
(M, ef_construction, metric) in their native format. Both KEYs carry
the full resolved set - the old one from the DD, the new one from
handlerton::prepare_vector_index_params - so the same parameters
written in another order, case or spelling still keep the index.

mysql_compare_tables(), which compares key definitions for EXCHANGE
PARTITION, now asserts that it never compares two vector keys: a
partitioned table cannot have one. If that changes, it must compare
their parameters too.

Tests: percona_vector.index_replace_params, and an EXCHANGE PARTITION
case in percona_vector.partition_refused.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

PS-11588 : HNSW index options silently accept duplicate keys, e.g. (M = 8, M = 16), and the value used depends on key case and order

Removing some unused #includes.

Renaming index_syntax* files to the briefer syntax*

Co-Authored-By: Martin Hansson <martin.hansson@percona.com>
Improved by Opus 5.5 high: port to trunk
dlenev and others added 2 commits October 29, 2026 09:43
…SERT/REPLACE after unclean restart: node layer lower than searched layer (release and debug)

https://perconadev.atlassian.net/browse/PS-11748

Problem
-------

An attempt to execute a query or insert operation on a HNSW index
with broken graph sometimes led to crash. The problem occurred when
a neighbor list for a node for some layer, contained a reference
to some other node which was not present on this layer at all.
Attempts to access neighbor list for this referenced node on the layer
it was not present at all led to out-of-bound array access/crash during
search.

Note that such references should not normally occur in properly
constructed HNSW graph, but might be caused by bugs or data corruption.
In this particular case, https://perconadev.atlassian.net/browse/PS-11591
was the most likely reason for corrupted HNSW graph. This issue has been
fixed separately.

Fix
---
This commit makes HNSW class more robust against such misplaced nodes.
We now detect situations when node which is not present on the layer
is referenced by another node on this layer and report index corruption.
HNSW also detects cases when persistence layer provides neighbor list
of incorrect length for the node during its load and reports it as index
corruption as well.

Added unit and MTR tests for these situations.

Also manually checked that server no longer crashes when it tries to do
search in corrupted saved HNSW index provided by QA.

Improved by Opus 5.5 high: port to trunk
…l value for every row when read through an HNSW vector index (ANN query)

https://perconadev.atlassian.net/browse/PS-11751

Problem
-------

A query which used vector index for ORDER BY DISTANCE() ... LIMIT n
returned incorrect, repeating values for VIRTUAL generated columns in
the select list, and WHERE conditions on such columns filtered rows
incorrectly. STORED generated columns were not affected.

VIRTUAL columns are not stored by InnoDB and have to be computed by
the SQL layer after a row is read. All handler::ha_* read wrappers
(ha_index_next(), ha_rnd_next(), ha_ft_read(), ...) do this by calling
update_generated_read_fields(). However, VectorSearchIterator called
handler::vec_read_first()/vec_read_next() directly, so virtual columns
were never computed and kept whatever value was left in the record
buffer (e.g. from the last row inserted). Table row status was not
updated by these calls either.

Fix
---

Added handler::ha_vec_read_first() and ha_vec_read_next() wrappers
which, similarly to ha_ft_read(), compute virtual generated columns
for the row read and update table row status. VectorSearchIterator
now uses these wrappers.

Added MTR test percona_vector.index_virtual.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Improved by Opus 5.5 high: port to trunk
@inikep
inikep merged commit c11e595 into percona:trunk Oct 29, 2026
9 of 17 checks passed
@inikep
inikep deleted the PS-11299-trunk branch October 29, 2026 08:47
@inikep

inikep commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator Author

The port will be continued in https://perconadev.atlassian.net/browse/PS-11960

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants