Repository navigation
PS-10256 [trunk] Vector Search MVP - #6286
Merged
Merged
Conversation
… 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
…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
…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
force-pushed
the
PS-11299-trunk
branch
from
October 29, 2026 08:46
8012066 to
34abfd5
Compare
Collaborator
Author
|
The port will be continued in https://perconadev.atlassian.net/browse/PS-11960 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
https://perconadev.atlassian.net/browse/PS-10256
Port of the Vector Search MVP (HNSW vector indexes in InnoDB) from
9.7totrunk. These are the 30 vector commits incbc733e84cbc..percona/9.7that were not on trunk yet, cherry-picked in their 9.7 order and with their 9.7 titles plus a[trunk]tag:Docs/percona-hnsw.md), and the optimizer and syntax follow-ups.Trunk-specific changes
MLOG_TABLE_DYNAMIC_META,Persister::write_log,dict_persist_update_log_margin). SoVec_label_counter::log()becamepersist_counter(): it raises the persisted watermark with a CAS-max, then writes the value toDDTableBufferviadict_table_mark_dirty()+dict_table_persist_to_dd_table_buffer(). The 9.7 log-margin changes were dropped.PM_TABLE_VEC_IDX_IDis also registered in the new redo applier (log0redo.cc).VecIdxIdPersisteruses trunk'ssize_tpersister signatures.ut0new.h:vec0dmlandvec0hnswwere added toauto_event_names, which trunk enforces at compile time.IS_DD_VERSION = 260700, with thei_s_schema_definition_debugchecksums for lctn 0 and 1.percona_vector.aux_node_missingandaux_corrupt_entry_pointnow runANALYZE TABLEbefore the restart. On trunk, the row estimate otherwise stays at or below theLIMIT, so the ANN plan isn't chosen.main.all_persisted_variables: the count went from 499 to 501.handler0alter.ccandha_innodb.hwere resolved to trunk's context.Open question
DDTableBufferwrite takes the globaldict_persist->mutextwice per labelled row (indict_table_mark_dirty()anddict_table_persist_to_dd_table_buffer()). Preallocating labels, as autoinc does, would remove that, at the cost of skipped label values after a restart.Tests
Debug build, GCC 16,
-Werror:main,innodb,percona,percona_vector,sys_vars,information_schema,funcs_1: all pass.Implemented by Opus 5.5 high