Contents
sift for MariaDB
BM25 full-text search for MariaDB tables: ranked top-k, exact counts, phrases, prefix / wildcard / fuzzy / regular
expression terms, AND / OR / NOT, boosts, fields, filters and facets, as a user defined function library
(libsift_udf.so) and two SQL files. It is the portable core of pg_sift (the files of src/portable.list, compiled
unmodified, the same ones the PostgreSQL and SQLite extensions use) over a small host in mariadb/host/, so the page format, the
analyzer, the ranking and the query language are those of pg_sift; for the same text and the same options the counts, the top-10
order and the scores are bit identical.
It adds no storage engine and patches nothing: a trigger-fed queue table keeps the index in step with an InnoDB table, the
index lives in plain files next to the data directory, and a query is a function call. Use it when MATCH ... AGAINST
is too slow or too limited (InnoDB FULLTEXT takes 0.09 to 1.0 s for a ranked top-10 on 218,058 articles, sift 0.4 to 1.3 ms), you
want BM25 and phrases that behave the same on every engine of the product, or you want filters and facets inside the ranked
scan. Do not use it if you need MATCH ... AGAINST syntax (it cannot be added to a UDF, see “MATCH … AGAINST”) or a
MySQL server (MariaDB only).
Contents: install - quick start - queries - keeping the index current - visibility - filters and facets - options - keys - replication and backup - operations - limits - MATCH AGAINST - versions - numbers - tests - layout
Install
From a release. sift-mariadb-<version>-linux-x86_64.tar.gz holds one library per MariaDB series (lib/<series>/libsift_udf.so,
SELECT VERSION() tells yours; BUILD-INFO lists the server version and glibc each was built for), sql/functions.sql,
sql/sift.sql and this document. A library loads only into the series it was built for. Copy the one of your series into the
server’s plugin directory and run the two SQL files as below.
From source. Needs a MariaDB server 10.5 to 13.x (built and tested on the series listed under “Versions”), its development
headers (libmariadbd-dev, /usr/include/mysql/server), a C compiler and make (or CMake). Nothing else: the build needs no
PostgreSQL. libmariadb is only needed for the test tools.
nice make -C mariadb -j4 OUT=/tmp/sift-build # or: cmake -S mariadb -B /tmp/sift-build && nice cmake --build /tmp/sift-build -j4
mariadb -e "SHOW VARIABLES LIKE 'plugin_dir'" # where the server loads UDF libraries from
cp /tmp/sift-build/libsift_udf.so /usr/lib/mysql/plugin/ # that directory (needs write access to it); a release: lib/<series>/libsift_udf.so
mariadb mydb < mariadb/sql/functions.sql # registers the functions in the server (mysql.func), once per server
mariadb mydb < mariadb/sql/sift.sql # the procedures and the index table, once per database that has indexed tables
make finds the MariaDB headers with mariadb_config (or MDB_INC=<dir with mysql_version.h>). The library exports only the
sift_* functions. Indexes are directories under <datadir>/.sift/<index name> (SIFT_DIR in the server’s environment
moves them); index names are server wide, 1 to 63 letters, digits or underscores. Uninstall: CALL sift_detach(...) for each
index, DROP FUNCTION sift_... (the list is sql/functions.sql), remove the library.
Quick start
Every statement below was run against a private MariaDB 13.0.2; the output is what it printed.
-- setup (once): the functions of the server, the procedures of the database
SOURCE mariadb/sql/functions.sql; -- once per server (see Install)
SOURCE mariadb/sql/sift.sql; -- once per database
CREATE TABLE articles (id BIGINT PRIMARY KEY, title VARCHAR(200), body MEDIUMTEXT, category INT, likes INT);
INSERT INTO articles VALUES
(1, 'Solar power', 'Solar power plants turn sunlight into electricity.', 1, 10),
(2, 'Wind farms', 'A wind farm is a power plant made of many turbines.', 1, 5),
(3, 'Coal', 'A coal power plant burns coal to boil water.', 2, 1),
(4, 'Pasta', 'How to cook pasta in salted water.', 3, 7);
-- index title and body of articles; the key is the primary key id; 32 documents per page
CALL sift_attach('news', 'articles', 'id', "CONCAT_WS(' ', t.title, t.body)", 32);
-- ranked top 10: the UDF in a derived table, JSON_TABLE over it, a join with the table
SELECT a.id, a.title, ROUND(j.s, 3) AS score
FROM (SELECT sift_topk('news', 'solar OR "power plant"', 10) AS r) x,
JSON_TABLE(x.r, '$[*]' COLUMNS (n FOR ORDINALITY, id BIGINT PATH '$.id', s DOUBLE PATH '$.s')) j
JOIN articles a ON a.id = j.id ORDER BY j.n;
+----+-------------+-------+
| id | title | score |
+----+-------------+-------+
| 1 | Solar power | 1.703 |
| 3 | Coal | 1.050 |
| 2 | Wind farms | 0.935 |
+----+-------------+-------+
SELECT sift_count('news', 'power AND plant') AS plants, sift_matches('news', 'water') AS water;
+--------+-------+
| plants | water |
+--------+-------+
| 2 | [3,4] |
+--------+-------+
-- changes are visible at once, before any drain
INSERT INTO articles VALUES (5, 'Hydro power', 'Hydroelectric power plants use water.', 1, 3);
UPDATE articles SET body = 'Pasta needs no power plant.' WHERE id = 4;
DELETE FROM articles WHERE id = 3;
SELECT sift_matches('news', '"power plant"') AS power_plant, sift_matches('news', 'water') AS water;
+-------------+-------+
| power_plant | water |
+-------------+-------+
| [2,4] | [5] |
+-------------+-------+
SELECT sift_stats('news') AS stats;
+------------------------------------------------------------------------------------------------------------------------+
| stats |
+------------------------------------------------------------------------------------------------------------------------+
| {"segments":0,"docs":0,"dead":0,"live":0,"drained":0,"pending":7,"bytes":0,"dpp":32,"docbase":0,"merges":0,"format":2} |
+------------------------------------------------------------------------------------------------------------------------+
-- write the queue into a segment (an EVENT can do this every second: CALL sift_schedule('news', 1))
CALL sift_sync('news');
SELECT JSON_VALUE(sift_stats('news'), '$.pending') AS pending, sift_count('news', '"power plant"') AS power_plant;
+--------------+
| rows_applied |
+--------------+
| 7 |
+--------------+
+---------+-------------+
| pending | power_plant |
+---------+-------------+
| 0 | 2 |
+---------+-------------+
-- a filter inside the ranked scan, and facets
CALL sift_search('news', 'power', 10, 't.category = 1');
+----+-------------+-----------------------------------------------------+----------+-------+--------------+
| id | title | body | category | likes | score |
+----+-------------+-----------------------------------------------------+----------+-------+--------------+
| 5 | Hydro power | Hydroelectric power plants use water. | 1 | 3 | 0.124776244 |
| 1 | Solar power | Solar power plants turn sunlight into electricity. | 1 | 10 | 0.116445482 |
| 2 | Wind farms | A wind farm is a power plant made of many turbines. | 1 | 5 | 0.0702005848 |
+----+-------------+-----------------------------------------------------+----------+-------+--------------+
CALL sift_facets('news', 'power', 't.category', NULL);
+-------+---+
| value | n |
+-------+---+
| 1 | 3 |
| 3 | 1 |
+-------+---+
SELECT sift_topk('news', 'power', 10, (SELECT sift_idset(id) FROM articles WHERE likes > 4)) AS likes_over_4;
+--------------------------------------------------------------------------------+
| likes_over_4 |
+--------------------------------------------------------------------------------+
| [{"id":1,"s":0.116445482},{"id":4,"s":0.0977388024},{"id":2,"s":0.0702005848}] |
+--------------------------------------------------------------------------------+
SELECT sift_tokens('news', 'Solar-powered PLANTS') AS tokens;
+----------------------+
| tokens |
+----------------------+
| solar powered plants |
+----------------------+
SELECT JSON_VALUE(sift_merge('news', 'full'), '$.segments') AS segments;
+----------+
| segments |
+----------+
| 1 |
+----------+
Queries
The ranked top-k has one documented shape: the UDF in a derived table (so MariaDB evaluates it once), JSON_TABLE over its
result, a join with the table. Written directly in the FROM clause of JSON_TABLE the argument is evaluated twice.
SELECT a.*, j.s AS score
FROM (SELECT sift_topk('news', 'solar AND "power plant"', 10) AS r) x,
JSON_TABLE(x.r, '$[*]' COLUMNS (n FOR ORDINALITY, id BIGINT PATH '$.id', s DOUBLE PATH '$.s')) j
JOIN articles a ON a.id = j.id ORDER BY j.n;
| function | returns |
|---|---|
sift_topk(index, query, k [, filter]) |
JSON [{"id": key, "s": score}], best first (BM25, ties in document order) |
sift_count(index, query [, filter]) |
the number of matching rows |
sift_matches(index, query [, filter]) |
JSON array of every matching key |
sift_tokens(index, text) |
the terms the analyzer makes of a text (what the index stores) |
sift_stats(index) |
JSON: segments docs dead live drained pending bytes dpp docbase merges format |
sift_idset(key) |
aggregate: a compact set of keys, the filter argument of the functions above |
sift_merge(index [, 'full']) |
merges segments now ('full': all of them, dropping the deleted documents) |
sift_watermark(index), sift_exists(index), sift_version(), sift_create, sift_drop |
housekeeping used by the procedures |
sift_drain, sift_load |
aggregates that feed the index (used by sift_sync, sift_rebuild) |
sift_ping(k) |
k fake hits, to measure the cost of a UDF call and its JSON |
The query language is SiftQL, that of pg_sift: terms (analyzed like the indexed text), "phrases", AND / OR / NOT
and parentheses, {a b c} (any of), term^2.5 boosts, pre*, w?ldcard, fuzzy~1, /regular expressions/ (an ARE
subset, matched against terms), and field:term for indexes with fields. A syntax error is an SQL error with the position.
Keeping the index current
sift_attach creates the index, a queue table sift_q_<index> and three triggers (insert, update, delete) on the table.
A trigger writes (op, key) into the queue inside the writer’s transaction: a rolled back write leaves nothing. The queue is a
log; a drain (CALL sift_sync('news')) reads the rows above the watermark of this node, fetches the current text of each
row, and writes one new immutable segment atomically (temporary file, fsync, rename of the manifest). An interrupted drain
is repeated harmlessly. Small drains make small segments; more than merge_fanin (6) of a size merge in the background.
CALL sift_schedule('news', 1) creates an EVENT that drains every second (SET GLOBAL event_scheduler = ON) and prunes
drained queue rows older than an hour (CALL sift_prune('news', seconds)).
You do not have to wait for the drain to see a change: see the next section.
What a query sees
By default (fresh) every query reads the queue rows above the watermark through a local connection of the server, builds a
small in-memory live segment from them (cached while the queue does not change), and merges it with the segments on disk.
The consequences, all covered by tests/fresh.sh:
- An inserted or updated row is found by the next statement; a deleted row is gone; an updated row matches its new text only.
Counts equal
SELECT COUNT(*) ... LIKEover the table. - A
LIMITis never short: documents that were deleted or replaced since the last drain are excluded inside the ranked scan, and the best candidates are checked against the table (recheck) so rows removed without a trigger (TRUNCATE, foreign key cascades) cannot fill the page. - Isolation. The index reads the queue and the rows on its own local connection of the server, not in the caller’s
transaction. It never returns uncommitted data, the caller’s own uncommitted writes included (they are found after the
commit). With autocommit and in
READ COMMITTEDthe next statement sees every row committed before it started. InREPEATABLE READthe local connection may or may not share the transaction’s snapshot: a transaction that openedSTART TRANSACTION WITH CONSISTENT SNAPSHOTbefore its first call read the queue as of that snapshot in our tests, a plainSTART TRANSACTIONread the latest committed state, and a drain by another session makes rows committed after the snapshot part of the segments. Joining with the table, as in the query above, applies the snapshot of the caller to the rows, so a row the transaction cannot see is dropped from the page; the index never invents a row. That is what an index outside the storage engine can promise: committed rows, never uncommitted ones, a snapshot-consistent final result when you join. - A long writing transaction does not stall readers: the drain asks for a barrier row with a one second lock timeout and, if a writer is in the way, uses the barrier of the previous round and tries again on the next call.
fresh = falseindexes the committed state of the last drain only (the older, cheaper mode);recheck = falseskips the existence check. Both are index options.
Filters and facets
A filter restricts the ranked scan itself, so a selective filter still fills the page. It is a set of keys: the aggregate
sift_idset(key) over any SQL subquery (a bitmap), or a JSON array of keys.
SELECT sift_topk('news', 'power', 10, (SELECT sift_idset(id) FROM articles WHERE category = 1));
SELECT sift_matches('news', 'power', '[1, 2, 7]');
CALL sift_search('news', 'power', 10, 't.category = 1 AND t.likes > 3'); -- rows with their score, predicate over alias t
CALL sift_facets('news', 'power', 't.category', NULL); -- value, n: the same as GROUP BY over the matches
CALL sift_facets('news', 'power', 't.likes DIV 10', 't.category = 1');
tests/filters.sh compares both with the SQL baseline. sift_filter(index, predicate) leaves the bitmap in @sift_f.
Index options
CALL sift_attach_opts(name, table, key_columns, text_expression, options_json). The text expression is written over the alias t
of the table. All options are those of the pg_sift index of the same name, and give the same results (tests/options.sh checks
each against pg_sift):
| option | default | meaning |
|---|---|---|
dpp |
32 | documents per page of the index (a build parameter; 8 to 32 for small tables) |
k1, b |
1.2, 0.75 | BM25 saturation and length normalization |
stemmer |
none | "en" (English) or none |
case_folding, accent_folding |
true | fold case / strip accents when analyzing |
positions |
true | store positions (phrases are decided by the index); false: phrases are rechecked on the text |
prefix_index |
0 | index the first 1 to n characters of every word, so pre* is a lookup |
fields |
none | [{"name":"title","expr":"t.title"},{"name":"body","expr":"t.body"}]: queries can say title:term, body:(a b)^2 |
analyzer |
"text" |
"keyword": the whole value is one term |
merge_fanin |
6 | segments of a size merged together |
fresh, recheck |
true | see “What a query sees” |
Keys
The key is the primary key of the table: an integer column (the fast path, the key is the document id) or any other
primary key, text, UUID or several columns. For those the triggers keep a dense mapping table sift_k_<index> (key to integer);
the functions return the integers, and sift_search / sift_facets join them back to the rows. tests/keys.sh covers text,
UUID and composite keys through insert, update, key change, delete, drain, merge, restart and rebuild.
Replication and backup
The index is derived data, local to a node. Only the table and the queue (a table, with its barrier rows) travel through
the binlog (binlog_format=ROW); the triggers fire on the source only, and the queue rows replicate like any other.
- Replica. After the replica has the table and the queue,
CALL sift_rebuild('news')builds its index (it creates the engine index if the node has none, and reads the table in one consistent snapshot together with the newest barrier). From then on a replica answers fresh queries from the replicated queue rows andCALL sift_sync('news')drains them with the watermark of its own node; a replica (read_only) never writes a barrier. Runsift_syncon each node: anEVENTcreated on the primary replicates as disabled on the replica, which should run a drain-only event (CREATE EVENT ... DO CALL sift_sync('news')), notsift_schedule, whosesift_prunedeletes queue rows. - Pruning.
CALL sift_prune('news', seconds)deletes drained queue rows older thanseconds; a node that is further behind than that gets “the queue was pruned past the watermark of this node” and runssift_rebuild. - Backup.
mariadb-dump --single-transaction --routines --triggers dbcontains the table, the queue, the triggers and the index catalogue, not the index files (they are not InnoDB files: treat them as derived data, whatever physical backup you use). After a restore on a fresh server: register the functions (functions.sql), thenCALL sift_rebuild('news')for each index. tests/repl.shruns a private primary and replica with binlog replication and a third server for the restore, and checks equal counts and top-10 after catch-up, after rebuilds, after pruning, and after a dump restore.
Operations
SELECT sift_stats('news'):segments, live anddeaddocuments,pending(queue rows not yet in a segment), bytes.- Crash safety. A segment, the event log and the manifest are written to temporary files,
fsynced and renamed; the manifest rename is the only commit point; files the manifest does not name are removed at open.tests/functional.shkills the server after a drain,tests/live.shkills it at three points of a merge: the answers after the restart are those of the last published state. - Merge. The background merge keeps the number of segments within
merge_fanin;sift_merge('news', 'full')merges all of them and drops deleted documents. The postings are streamed through temporary runs whose sort buffers are bounded bymaintenance_work_mem(64 MB, set inshim.c); what else stays in memory is the document map (about 70 bytes per document at the peak: 36 MB for 540,000 documents). - Concurrency. Each index has a reader / writer lock; queries of different connections run in parallel, a drain or a merge
publishes under a short write lock.
tests/stress.shruns 8 reader threads, 4 writers, a draining and a merging thread at once and then checks the index against the table. - Memory and files. Segments are mapped from the page cache; they cost no heap.
<datadir>/.siftis outside InnoDB.
Limits
- MariaDB only. One text expression per index (a
CONCAT_WSorfields), over one table; no partition awareness; noALTER TABLEstory for the indexed table (detach, alter, attach). - Triggers are the change feed:
TRUNCATE, bulk loads withSET unique_checks… that skip triggers, and foreign key cascades do not reach the queue. A cascaded or truncated row is never returned (the existence check), but aTRUNCATEleaves the documents in the index untilCALL sift_rebuild; rows added by a trigger-less load need the same. - A query reads the pending queue rows: about 0.3 ms per 1,000 of them. Drain on an
EVENT(see “Numbers”). The live segment holds the text of the pending rows in memory while a query uses it. sift_idsetis limited bymax_allowed_packetand the server’s UDF result size (256 MB, about 32 million keys).- The UDF library is loaded into
mariadbd(GPLv2) and built against its headers; pg_sift is under the PostgreSQL license, which is GPL compatible. Whether linking a UDF is a derivative work is a question for a lawyer. - Not done:
MATCH ... AGAINST, a storage engine, visibility inside the caller’s own uncommitted transaction, highlighting.
MATCH … AGAINST
Assessment: not feasible as a UDF, and no proof is shipped. MATCH(col) AGAINST(...) is parsed by the server into a full-text
function that is bound to a FULLTEXT key of the table’s storage engine: the server calls the handler’s ft_init_ext,
ft_read and ft_end. A UDF library cannot register a handler method, and a full-text parser plugin (MYSQL_FTPARSER_PLUGIN)
only changes how text is split into words for an index that the engine (InnoDB, MyISAM, Aria) still builds, stores and ranks
itself, so it cannot route queries to sift. The only ways to the syntax are a storage engine that owns the FULLTEXT key (the
model of Mroonga’s wrapper mode: it wraps InnoDB for the rows and answers ft_init_ext from its own index) or a patch of the
server. Both are C++ code against the server’s private handler interface, rebuilt for every MariaDB series, with the full handler
surface (about 100 virtual methods) to wrap. The smallest next step is a read-only storage engine in the style of SphinxSE:
CREATE TABLE hits (id BIGINT, score FLOAT, query VARCHAR(1024) NOT NULL, KEY (query)) ENGINE=SIFT CONNECTION='news' so that
SELECT ... FROM hits WHERE query = 'solar AND wind' LIMIT 10 plans as an index lookup and joins with the table; it needs the
engine skeleton (rnd_init, index_read_map, ha_* registration) and nothing of sift changes. Wrapper mode with real
MATCH AGAINST comes after that. Until then use the derived-table query above.
Versions, ABI and release artifacts
- Tested on MariaDB 13.0.2 (the whole suite below, on the host’s server) and, with
tests/smoke.sh(12 checks: version string, ranked top-10, count, matches, DML visible at once, prefix / wildcard / fuzzy / regular expression, sync and merge, restart, detach), on the stock Docker imagesmariadb:10.11(10.11.19),11.4(11.4.13),11.8(11.8.9),12.1(12.1.2) and13.0(13.0.2), each loading the library built against the headers of that very image. The sources compile against the headers of any MariaDB from 10.5 to 13.x and refuse other versions at compile time (a#erroronMYSQL_VERSION_ID, checked bytests/version.sh); the UDF structures arestatic_asserted. 10.5 to 10.10, 11.0 to 11.3, 11.5 to 11.7 and 12.0 have no release library and were not run. MySQL 8.4 / 9.x are not supported (the UDF interface and the server-side SQL service differ). - ABI. The library calls symbols of
mariadbditself, so it works only with the series whose headers it was built against; at load time it compares its series with the running server’s (server_version) and every function fails with “rebuild it against the headers of this server” on a mismatch. It needs libc and libm only (no libstdc++, no libmariadb), x86-64 baseline (no POPCNT, no vector instruction outside the runtime-dispatched kernels:make isa-check), glibc 2.34 (Ubuntu 22.04 image, series 10.11) or 2.38 (the 24.04 and 26.04 images, series 11.4 to 13.0). sift_version()reports the release, the format of the engine, the page format and the server it was built for. An index written by another format is refused with “rebuild it with sift_rebuild”, andsift_rebuildreplaces it (tests/version.shdamages the manifest in four ways). The index files do not depend on the server version: they depend on the page format, which the library checks when it opens an index.- Release artifact:
sift-mariadb-<version>-linux-x86_64.tar.gz, built byscripts/package-mariadb.sh(and themariadbjob of.github/workflows/release.yml):lib/<series>/libsift_udf.sofor each series above,sql/functions.sql,sql/sift.sql, LICENSE, NOTICE, this document andBUILD-INFO(server version and glibc floor of each library). Every library is built in the stockmariadb:<series>image againstlibmariadbd-devfrom MariaDB’s own repository (which pins the headers to that server’s exact version) and loaded into a server of the same image by the smoke test before the tarball is accepted; the tarball is reproducible (sorted names, commit time) and goes into the singleSHA256SUMSof the release. The version is the tag (v1.4.0is 1.4.0) and must equalmariadb/VERSION; CHANGELOG.md lists the changes. A distribution with an older glibc, or another architecture, builds from source (make).
Numbers against InnoDB FULLTEXT
Measured on Simple English Wikipedia (218,058 articles), top 10 by BM25, warm cache, the same queries for both systems. The numbers are those of a 3.0 development build; the port code did not change in 3.1 and later, and the shared core did, so sizes and speeds may differ.
| class | sift, joined | sift, UDF only | sift, plain index | InnoDB FULLTEXT | InnoDB / sift | | — | — | — | — | — | — | | AND top-10 | 476 | 245 | 298 | 87,191 | 183x | | OR top-10 | 1,301 | 1,196 | 1,228 | 943,532 natural, 1,032,812 boolean | 725x / 794x | | phrase top-10 | 424 | 250 | 338 | 213,772 | 504x | | count AND | 110 | | 83 | 112,650 | 1,025x | | count OR | 269 | | 247 | 1,506,334 | 5,608x | | count phrase | 172 | | 144 | 233,629 | 1,357x | | statement | before | after | after / before | | — | — | — | — | | AND top-10, joined | 466 +- 56 | 476 +- 81 | 1.05 +- 0.15 | | AND top-10, UDF only | 286 +- 36 | 245 +- 36 | 0.89 +- 0.12 | | AND top-10, plain index | 373 +- 41 | 298 +- 34 | 0.82 +- 0.09 | | OR top-10, joined | 1,488 +- 120 | 1,301 +- 152 | 0.90 +- 0.13 | | OR top-10, plain index | 1,361 +- 149 | 1,228 +- 168 | 0.94 +- 0.15 | | phrase top-10, joined | 513 +- 113 | 424 +- 52 | 0.94 +- 0.18 | | phrase top-10, plain index | 293 +- 41 | 338 +- 51 | 1.20 +- 0.18 | | count AND | 139 +- 10 | 110 +- 11 | 0.82 +- 0.12 | | count OR | 307 +- 35 | 269 +- 20 | 0.93 +- 0.14 | | count phrase | 179 +- 21 | 172 +- 21 | 0.98 +- 0.11 | | build (s) | 7.3 +- 0.8 | 6.6 +- 0.9 | 0.93 +- 0.09 | | index size (MB) | 135.9 | 135.9 | 1.00 | Honest losses. sift’s OR is its slowest class (1.2 to 1.3 ms: OR queries over common terms match most of the corpus); the joined shape
Tests
mariadb/test.sh builds, starts a private PostgreSQL (the pg_sift reference) and a private MariaDB under /tmp, loads the
218,058-article Simple English Wikipedia corpus, runs everything below and the benchmark, and deletes both servers (STEPS,
KEEP, WORK, SIFT_BENCH_DATA, BENCH_QUERIES: see the header of the script). mariadb/server.sh is the private server on
its own (start|stop|sql|destroy).
| suite | what it checks |
|---|---|
test.sh pgref |
5,098 queries (and / or / phrase traces, prefix, wildcard, fuzzy, NOT, boosts, groups, nesting, phrases under OR/NOT including CJK, regular expressions): count, top-10 order and float32 scores equal to pg_sift |
test.sh inno |
match sets against InnoDB FULLTEXT (boolean mode), with the analyzer differences listed below |
tests/functional.sh |
67 checks: queries, DML visible at once, rollback, update / delete, drain, merge, restart, kill -9, concurrent writers, EVENT |
tests/fresh.sh |
freshness without sync, LIMIT never short, rollback / savepoints, REPEATABLE READ / READ COMMITTED, TRUNCATE, fresh=false |
tests/keys.sh |
text, UUID, composite keys, the integer fast path |
tests/filters.sh |
bitmap, id list and predicate filters, facets against GROUP BY |
tests/options.sh |
every index option against pg_sift with the same options (needs the private PostgreSQL) |
tests/version.sh |
version strings, refused manifests, the compile-time window, the CMake build |
tests/live.sh |
small segments, background merge, bounded merge memory, kill -9 during a merge, log compaction |
tests/stress.sh |
reader, writer, drain and merge threads at once, then index equals table |
tests/regex_check.sh |
the regular expression engine against PostgreSQL’s, 227,040 pattern / word pairs |
tests/repl.sh |
primary, replica, rebuild, prune, dump restore |
tests/smoke.sh |
a release library in a stock MariaDB image (scripts/package-mariadb.sh): version, queries, DML, merge, restart, detach |
Against InnoDB FULLTEXT the match sets differ only where the analyzers differ: InnoDB keeps _ and an apostrophe between letters
inside a word and splits at U+00AD; sift (like the PostgreSQL parser the trace comes from) splits at _ and ' and keeps U+00AD
inside the token. On a copy of the text with those characters turned into spaces, 97 to 100 of 100 queries per class are equal; the rest
are off by 1 or 2 documents of tens of thousands.
Layout and shared core
| path | what |
|---|---|
udf.c, sql/ |
MariaDB-specific: the UDFs (arguments, JSON, the local SQL connection) and the procedures / triggers |
engine.c, engine.h, ix.h, query.c, feed.c, merge.c |
the index: manifest, event log, live segment, queries, drains, merge driver, locks |
host/host.h |
the host interface of the portable core (SIFT_HOST_MARIADB in src/core/host.h): the plain-C vocabulary of sqlite/host/host.h, whose host.c, container.c, mb.c and spool.c (memory contexts, ereport as longjmp, hash tables, strings, UTF-8, temporary files, heaps) are compiled as they are, with host_stubs.c (no write buffers, no kept docmap directories) |
host/store.c, host/store.h |
src/core/store.h over segment files mapped as the buffer pool: the stream reader and writer, write targets |
host/stubs.c |
the engine’s own visibility, and the filter of a list of documents as the TID bitmap the shared filter builder reads |
../sqlite/host/regex.c, regex_parse.c, regex_exec.c |
pg_regcomp / pg_regexec / pg_regprefix: a matcher for PostgreSQL’s advanced regular expressions |
cli.c |
sift_cli: the engine without a server |
Makefile, CMakeLists.txt |
the build (it reads src/portable.list) |
tests/, bench/, tools/, test.sh, server.sh |
tests, the benchmark, the comparison tools, the private server |
Compiled from src/ unmodified: every .c file of src/portable.list, except that bm25/bitmap.c is compiled from a copy whose seek limit is
per thread (sed, checked by the build). No PostgreSQL header, library or code is involved: scripts/check-portable.sh guarantees that the
files of the list reach their host only through core/host.h.
What is shared, and what is not. Taken from the core and not copied: the merge policy (sift_merge_pick, tiers from 1 MiB), the merge of
segments through sorted runs (sift_run_from_segment, sift_runs_write), the inverted buffer of a drain (SiftInvBuf), the deletion of a
document from a segment (sift_seg_delete_key: the merge deletes the engine’s dead documents from its private copy of the pages, then
rewrites them), the filter builder (sift_filter_build), the extent bookkeeping and padding of byte streams (core/stream.c), the CPU
dispatch, the analyzer options. MariaDB’s own: the manifest, the event log and the queue protocol (engine.c, feed.c, sql/), the
local-connection host callbacks and the UDF glue (udf.c), the mapped store with its MADV_POPULATE_READ, the background merge thread, the
live segment of a query. The regular expression matcher (sqlite/host/regex*.c, PostgreSQL’s advanced syntax in the C collation) is shared with the SQLite host, and tests/regex_check.sh compares it with PostgreSQL’s on 227,040 pattern and word pairs.