vsql::preview::index_type registers the
index structure itself. vsql::preview::index_profile binds that structure to a
custom type and names the functions the index computes with. Both live in
<villagesql/preview/index_builder.h> in the dev ABI headers, so an extension
that uses them builds with -DVSQL_USE_DEV_ABI=ON.
The Three Pieces
An index type is the structure: how entries are stored, inserted, and scanned. It is registered once per extension per structure, under a name likehnsw.
An index function is a deterministic function bound to a profile — a distance
function, for instance. A profile binds two kinds. A function bound with
with_function() is one the optimizer may plan an index scan for. A function
bound with with_helper() is called only by the index implementation.
Binding a function to a profile does not create a SQL function. The optimizer
matches an ORDER BY call by name against the profile’s bindings, so a function
bound with with_function() must also be registered with .func() under the
same name, or no statement can ever name it and the index is never chosen. A
helper needs no .func(), and calling one from SQL gives ERROR 1305 (42000): FUNCTION <database>.<name> does not exist.
An index profile ties a custom type to an index type and gives each bound
function a numeric id the index calls it by. One index type serves many
profiles: vsql_vector registers a single hnsw index type and four profiles
over it — hnsw_l1, hnsw_l2, hnsw_cosine, and hnsw_inner_product — one per
distance metric.
Registering an Index Type
make_index_type<Name, Context>() takes the index name and a per-index context
struct that holds whatever run-time state the extension needs. All twelve hooks
are required, in three groups, in this order. The builder does not compile if one
is missing — the failing assertion names the hook.
The lifecycle hooks create the index storage, load it when the table opens, and
drop it:
insert.
mark_delete and purge must exist with these signatures so the builder
compiles; stub them:
begin, position, fetch, and
end, and passes position only Index::CursorOp::Next. save and restore
must exist and are stubs, like the two DML hooks above:
true on failure after writing the message there.
The cursor belongs to the extension. Allocate it in begin and assign it to
*cursor, already positioned on the first record — the server calls fetch
before it calls position. Free the cursor in end and set *cursor to
nullptr. The SDK does not wrap cursor state.
The bool * argument of begin, position, and restore is an end-of-scan
flag the hook writes. On success the server calls end even when begin set
that flag; on failure begin must leave *cursor null, and end is not
called. The MtrCtx::Ref begin receives is null on the path the server drives
today.
The context does not belong to the extension. The SDK allocates
Index::StorageCtx<T> and your T from the InnoDB arena before it calls
create or load, and destroys the arena — which runs ~T — after drop
returns. Reach your own state through ctx->user() and the arena through
ctx->arena(). Never delete the context, the user object, or the arena.
Declaring What the Index Can Do
.capabilities() declares which access patterns the index supports, and
.storage_props() declares how each index entry refers back to the data it
indexes. Both are required.
The builder accepts five capability values, and the server acts on one:
Index::Support::KNN, a k nearest neighbour scan for
ORDER BY <distance>(<column>, <reference>) LIMIT K. POINT_LOOKUP,
RANGE_SCAN, REVERSE_SCAN, and ORDER_BY compile and register, and the optimizer builds none of those paths. An = condition on the indexed column can still plan an Index lookup on the custom index, for example under FORCE INDEX, whatever the index declares, and that query fails with ERROR 1031 (HY000): Table storage engine for '<table>' doesn't have this option.
The storage declaration that serves a nearest-neighbour scan is
Index::Storage::HAS_COLUMN_REF | Index::Storage::HAS_ROW_REF on a single-column
index over a column the extension stores itself. HAS_COLUMN_REF means each
entry stores a stable column reference the server supplies rather than the full
primary key, and it is what makes get_key_data() and get_key_ref() available.
REF_LOOKUP means the index can re-locate an entry from that reference, and it
carries an obligation: every entry must have a unique reference token.
Index::Storage::HAS_ROW_REF means each entry stores the primary key of the row it indexes: the server passes it to insert() in the pkeys array, fetch() must return it in pkeys, and the server resolves each hit with a primary-key lookup. An index that does not declare it is never chosen for a nearest-neighbour scan. Declare it together with HAS_COLUMN_REF.
Reading and Writing Column References
get_key_data() resolves a KeyPartRef — the kind the DML and scan hooks hand
the index — into the key column’s actual value, sized with
get_max_col_len(key_pos). get_key_ref() goes the other way: it derives the
reference for a key column value already in the column store, such as one of
the keys insert() receives. get_key_data() reads under its own
mini-transaction and must not be called while the extension holds page latches.
Both take key_pos, the zero-based key column the value belongs to, and
return true on failure with the message in index.get_error():
HAS_COLUMN_REF, and the SDK calls them unguarded, so calling either without it
crashes rather than returning an error.
Index Options
WITH (M = 8, ef_construction = 64) in the DDL reaches the extension as index
options. The server lowercases every key and value first, so params[i].key
reads m even though the DDL wrote M. Declare a struct with defaults and a
parse function, and register it with .options<T, &T::parse>():
params is null when the DDL carried no WITH clause, so count is the value
to test first. A parse that returns failure aborts the DDL, and the message it
wrote goes to the server error log — the client sees only
ERROR 1030 (HY000): Got error 168 - 'Unknown (generic) error from engine' from storage engine, so
name the index and the offending key in the message.
index.options<HNSWOptions>() returns a pointer to the parsed struct. It is
reachable from every hook that receives the Index — the lifecycle hooks, the
DML hooks, and begin. The remaining scan hooks receive only a cursor, so an
index type that needs its options mid-scan has to copy them into the cursor in
begin.
Index Functions
An index function must be deterministic and must returnREAL. Build one and
hold it in a static const — the profile stores a reference to it, so its
address has to outlive registration:
VEF_INDEX_PROFILE_FN_MAX_ARGS. The server checks the return type and the arity
at INSTALL EXTENSION rather than at first use.
Index Profiles
A profile names the custom type, the index type, and the functions with their ids.for_type() takes the type name string — the same constant passed to
make_type — not the type descriptor. with_function() and with_helper() take
a built index function, not a name:
with_function(1, ...) and with_helper(1, ...) do not collide.
A repeated id inside one list is rejected at INSTALL EXTENSION.
.default_for_type(true) marks this profile as the one to use when a statement
names no profile. It applies per type and index type pair, and defaults to false.
vsql_vector marks hnsw_l2, which is why an index created without a profile
records hnsw_l2.
.ordering() records the scan directions the profile supports, as NONE, ASC,
DESC, or ASC | DESC. The server stores the value and nothing reads it. Only
an ascending ordering is ever planned, whatever the profile declares: a profile that declares
DESC alone still serves an ascending query, and a descending ORDER BY runs as a table scan.
A hook calls a bound function through index.profile() or a bound helper
through index.helper(). Both take the key column position, the id the profile
gave the function, a pointer to the result, and the column values as
vef_storage_col_data_t arguments.
index.helper_fn_name() reports the registered name of a bound helper instead of
calling it, which lets an inner loop resolve its own native implementation once
rather than dispatching per call:
Putting It Together
A skeleton, with the hook bodies, the options struct, and the context left out:static — the SDK holds pointers into them —
and each must be passed to .with() exactly once. One capability object can
carry several structures: chain .index_type() or .index_profile() per
structure.
The SQL Syntax
A custom index is selected withUSING EXTENDED, and the column may name the
profile to use:
ERROR 3219 (HY000): No default index profile found: column '<name>' is not a custom type. A partitioned table is refused with
ERROR 3219 (HY000): InnoDB: Custom index is not supported on partitioned tables.
The clause works inline in CREATE TABLE, and in CREATE INDEX and ALTER TABLE ADD INDEX. Both the profile and the index type may be qualified with the
extension name, which is how you disambiguate two extensions offering the same
name. When no profile is named, the server uses the profile that declared
default_for_type(true) for that type and index type.
Every statement in this section needs vsql_allow_preview_extensions set to
ON, at startup or with SET PERSIST. Without it the statement is refused:
vsql_vector as the example, build it from the vsql-vector repository, copy
vsql_vector.veb into the veb_dir, turn the setting on, and install the extension first:
DROP INDEX, ALTER TABLE ... DROP INDEX, or
DROP TABLE, which removes the index metadata with the table. A custom index
and the rows it holds survive a server restart.
Reading and Writing Through a Custom Index
Rows go in withINSERT, and the index is maintained as they do. Creating the
index on a table that already holds rows works too, and builds the index over
them. The build blocks writes to the table rather than running as an online DDL; reads
continue. A custom index takes the offline build path with ALGORITHM=INPLACE, ALGORITHM=COPY, or
no clause, because nothing yet replays concurrent writes into one. ALGORITHM=INSTANT and
LOCK=NONE are refused.
One query shape is served by a custom index:
ORDER BY <distance>(<column>, <reference>) LIMIT K as the only ordering term,
ascending, with a constant <reference>. The index must be single-column, its
type must declare Index::Support::KNN and Index::Storage::HAS_ROW_REF, and its bound profile must supply the
distance function. The column and the reference can be given in either order. A
query that misses any of these conditions runs as a table scan and a sort, with
no warning. EXPLAIN names the access:
Changed in 0.0.8-dev. The
<reference> can also be a prepared-statement parameter with a bound value. A NULL or empty <reference> never gets the distance scan.begin should hand back its whole search pool
rather than exactly limit entries, or a concurrent write leaves the query
short of K rows.
An UPDATE that changes an indexed column is refused:
UPDATE that changes the primary key draws the same error, as does a
REPLACE that hits an existing row or an INSERT ... ON DUPLICATE KEY UPDATE that assigns an indexed
column or the primary key. An UPDATE or an INSERT ... ON DUPLICATE KEY UPDATE that assigns only
unindexed columns succeeds.
An ALTER TABLE that changes the primary key of a table with a custom index is
refused regardless of ALGORITHM:
ALGORITHM=COPY or ALGORITHM=INPLACE also leaves the index in place, and a
rebuilt index survives a restart.
What the Server Reports
SHOW CREATE TABLE renders a custom index’s bound profile, index type, and any
WITH parameters:
extension.name
identifiers, never a string. Both the key and the value of a WITH parameter are
lowercased and sorted alphabetically by key — the same normalization described
under Index Options above — so a key written M renders as m. The profile is
always shown, even when the DDL never named one and the server resolved it to the
type’s default: the persisted metadata records only the resolved name. The
rendering round-trips, so replaying a captured SHOW CREATE TABLE string
reproduces an index that renders identically.
INFORMATION_SCHEMA.STATISTICS.INDEX_TYPE and SHOW INDEX report the index-type
name passed to USING EXTENDED(...), uppercased, in place of the storage
engine’s own algorithm name. The value carries no extension qualifier, so two
extensions offering the same index-type name are indistinguishable in this
column. A plain B-tree index on the same table still reports BTREE:
DROP INDEX idx_a ON t2, its row is gone from this query too.
Where the Metadata Lives
The server records each custom index invillagesql.custom_indexes, and its
key columns — with the profile bound to each — in
villagesql.custom_index_columns. Both survive a restart.
Neither is readable from SQL, including as root:
SHOW CREATE TABLE and
INFORMATION_SCHEMA.STATISTICS are the supported way to inspect a custom index
from a client.