diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 194bf357..60c96adc 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -17,6 +17,11 @@ concurrency:
env:
CARGO_TERM_COLOR: always
+ # The size of the shipped code, rounded up to 500 lines, and the number of tests, rounded up
+ # to 10. A change that crosses either number edits it here, in the same change, where its
+ # reader sees that the code grew or shrank. Neither ever passes 150,000 lines or 1,000 tests.
+ SHIPPED_LINES: 11000
+ TESTS: 180
jobs:
lint:
@@ -26,8 +31,21 @@ jobs:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- run: cargo fmt --manifest-path v2/Cargo.toml --all --check
- run: cargo clippy --manifest-path v2/Cargo.toml --workspace --all-targets --locked -- -D warnings
- - name: Shipped lines stay under 150,000
- run: test "$(find v2/contextdb/src v2/contextdb-cli/src \( -name '*.rs' -o -name '*.pest' \) ! -name tests.rs -exec cat {} + | wc -l)" -le 150000
+ - name: Shipped lines are at most the stated number and within 500 of it
+ run: |
+ lines=$(find v2/contextdb/src v2/contextdb-cli/src \( -name '*.rs' -o -name '*.pest' \) ! -name tests.rs -exec cat {} + | wc -l)
+ echo "$lines shipped lines; the stated number is $SHIPPED_LINES"
+ test "$SHIPPED_LINES" -le 150000
+ test "$lines" -le "$SHIPPED_LINES"
+ test "$lines" -gt "$((SHIPPED_LINES - 500))"
+ - name: No shipped file is longer than 1,500 lines
+ run: test -z "$(find v2/contextdb/src v2/contextdb-cli/src -name '*.rs' ! -name tests.rs -exec wc -l {} + | awk '$2 != "total" && $1 > 1500')"
+ - name: No test-only name in shipped code
+ run: test -z "$(grep -rnE --include='*.rs' --exclude=tests.rs '_for_test|test[-_]seam|feature *= *"test' v2/contextdb/src v2/contextdb-cli/src)"
+ - name: No ignored test
+ run: test -z "$(grep -rn --include='*.rs' '#\[ignore' v2/contextdb v2/contextdb-cli)"
+ - name: Only the four allow attributes that v2/AGENTS.md names, and no expect attribute
+ run: test "$(grep -rnE --include='*.rs' '#!?\[ *(allow|expect) *\(' v2/contextdb v2/contextdb-cli | wc -l)" -eq 4
test:
strategy:
@@ -45,9 +63,15 @@ jobs:
- run: cargo test --manifest-path v2/Cargo.toml --workspace --locked --doc
- name: The storage library's own tests
run: cargo test --manifest-path v2/redb/Cargo.toml --locked --lib --tests
- # A test is one the runner lists, or one script file.
- - name: Tests stay under 1,000
- run: test "$(( $(cargo nextest list --manifest-path v2/Cargo.toml --workspace --locked -T oneline | wc -l) + $(find v2/contextdb/tests/sql -name '*.sql' | wc -l) ))" -le 1000
+ # A test is one the runner lists, or one script file. Counted on Linux, where every test runs.
+ - name: Tests are at most the stated number and within 10 of it
+ if: matrix.os == 'ubuntu-latest'
+ run: |
+ tests=$(( $(cargo nextest list --manifest-path v2/Cargo.toml --workspace --locked -T oneline | wc -l) + $(find v2/contextdb/tests/sql -name '*.sql' | wc -l) ))
+ echo "$tests tests; the stated number is $TESTS"
+ test "$TESTS" -le 1000
+ test "$tests" -le "$TESTS"
+ test "$tests" -gt "$((TESTS - 10))"
minimum-rust:
runs-on: ubuntu-latest
diff --git a/v2/AGENTS.md b/v2/AGENTS.md
index b76f75fd..ed6ac801 100644
--- a/v2/AGENTS.md
+++ b/v2/AGENTS.md
@@ -13,7 +13,7 @@ repeated here; every command is in [`README.md`](README.md).
| `contextdb-cli/` | The `contextdb` program, a SQL shell. Every statement and command goes through the library's public calls; it never opens a store's files itself. |
| `redb/` | redb 4.1.0 with the changes listed at the top of `redb/README.md`. It keeps its own manifest and lock file and is not a workspace member. |
-The library is ten parts, one folder each under `contextdb/src/`:
+The library is twelve parts, one folder each under `contextdb/src/`:
| Part | Does | Never |
|---|---|---|
@@ -21,17 +21,29 @@ The library is ten parts, one folder each under `contextdb/src/`:
| `system` | the time of day, through `Clock`; the one place in the library that reads a clock | decides anything |
| `memory` | the budget and the reservations taken from it | allocates for another part: it counts what they are about to take and says no first |
| `sql` | grammar, parser, planner: text to a statement, a statement to a plan | touches storage or knows what a table holds |
-| `store` | the folder and data file, how each record is keyed, views of one moment, the reader files (one in `readers/` per reader that is inside a statement, naming the commit it reads) and the locks | interprets SQL or a table's rules |
+| `store` | the folder and data file, how each record is keyed, a row's large text, JSON and vectors kept beside the row in slices (text and JSON compressed there when that saves an eighth, `store/record.rs`), views of one moment, the reader files (one in `readers/` per reader that is inside a statement, naming the commit it reads) and the locks | interprets SQL or a table's rules |
| `schema` | table declarations: checking, storing, printing them back | changes a row |
| `query` | answers a SELECT from one table; holds the one expression evaluator; counts the rows it reads | changes anything |
| `write` | applies INSERT, UPDATE and DELETE to a change set; checks types, NOT NULL, the primary key and UNIQUE rules; keeps index entries and row counts | returns a query's result to a caller |
| `database` | the handle a store is opened for writing with, its transactions, the one commit function | has a second way to commit |
| `read` | a session that reads a store without holding it for writing, from any process | changes anything a writer stored |
+| `vector` | the blocks of a VECTOR column (a one-byte copy of every vector), the lists of vectors not yet in a block, the column's partitions and their count, exact search over them, and sealing | decides which rows a reader may see: `query` tells it, row by row, and a row is in an answer only with the distance `query` worked out from the row's stored vector |
+| `upkeep` | one cycle of background work when it is called: sealing at most 4,096 waiting vectors into blocks, in one commit; the 4,096 is `SEAL_ROWS` in `upkeep/mod.rs` | starts a thread, reads a clock, or runs unless called |
+
+The files of `vector/`, each as its opening comment says:
+
+- `slot.rs`: one vector as a block keeps it (a scale, a rounding error and a signed byte for each number), the range a search gets from it, and the distance every answer is ordered by.
+- `block.rs`: the slots of as many vectors of one partition as fit in the block's bytes, each with the key of its row.
+- `directory.rs`: the partitions of a VECTOR column, one record for each key tuple under which a row has a vector.
+- `search.rs`: the one search: scores the rows in no block exactly, screens the slots of the selected partitions, asks for the true distance of the rows that can reach the answer, and proves the answer.
+- `seal.rs`: moving the vectors on the waiting lists into blocks.
+- `stage.rs`: what a commit's row changes mean for the vector part, worked out once inside the commit function.
+- `tally.rs`: the partitions a transaction's statements have made and emptied, so that the statement that would give a column one partition too many is refused when it runs.
## Where the parts meet
A part reaches another only through what that part makes visible: the `pub(crate)` items of its
-`mod.rs`, and for `sql` its `ast` and `plan` modules. The types handed across:
+`mod.rs`, and for `sql` its `ast` and `plan` modules. A statement form this version does not have is refused in `sql` (`not_supported` in `sql/mod.rs`, called by the parser and the planner), before any table is looked up. The types handed across:
| Type | Defined in | Who takes it |
|---|---|---|
@@ -43,6 +55,14 @@ A part reaches another only through what that part makes visible: the `pub(crate
| `Catalog`, `Table` | `schema` | `query`, `write`, `database` and `read` look tables up in it |
| `eval`, `matching` | `query` | `write` finds the rows of an UPDATE or DELETE with `query::matching` and works out values with `query::eval` |
| `QueryResult` | `query` | `database` and `read` build it and hand it to the caller |
+| `PhysicalPlan::VectorSearch`; `Expr::Distance`, `Expr::Row`, `Expr::InList` | `sql` | `query` runs the search, puts its distance wherever the select list writes `<=>`, and evaluates `IN` lists and row values |
+| `VectorColumn` (a VECTOR column's number, partition key, limit and mode), from `Table::vectors`, `Table::vector` and `Catalog::vector` | `schema` | `query`, `write` and `vector` |
+| `staged`, `vectors` and `vector_number` on `Read`; `put_vector`, `delete_vector` and `put_vector_number` on `ChangeSet`; `VectorRecord`, `VectorNumber`, `VectorId` | `store` | `vector` reads and stages its blocks, waiting entries, partition entries and counts through them; `store` keeps them and never interprets one |
+| `search`, `Nearest`, `distance` | `vector` | `query` calls `search` with the partitions the filter selects and answers it, row by row, with `distance` of the row's stored vector, or with nothing for a row that is gone, filtered out or has no vector |
+| `Tally`, the partitions a transaction has started and emptied | `vector` | `database` holds one beside each change set and forgets a refused statement's part of it; `write` notes every row it stages and settles the tally as a statement's last act, which is where a statement over `MAX_PARTITIONS` is refused |
+| `stage`, `seal` | `vector` | `database` calls `stage` inside the one commit function, after the commit's other rules have passed; `upkeep` calls `seal` |
+| `cycle`, `MaintenanceReport` | `upkeep` | `database` runs one cycle for `run_maintenance_cycle` and commits it through the one commit function |
+| `Store::count_cache_under` | `store` | `database` has the store check its page cache against a new limit, on the one path that sets the memory limit |
## Invariants and the tests that guard them
@@ -59,17 +79,27 @@ Every path in this guide is written from `v2/`.
| The memory limit holds | `contextdb/tests/memory_ceiling.rs`: `a_million_rows_load_and_scan_under_64_mib` |
| A refused statement changes nothing | `contextdb/tests/memory.rs`: `a_statement_that_would_cross_the_limit_is_refused_by_name_and_committed_rows_stay`; `contextdb/tests/transactions.rs`: `a_refused_statement_changes_nothing_and_leaves_the_transaction_usable` |
| A damaged page is reported as damage | `contextdb/tests/crash/main.rs`: `a_damaged_page_is_refused_as_damage_by_name`; `redb/tests/contextdb_changes.rs`: `a_page_with_a_wrong_checksum_is_refused_when_read` |
+| The stored bytes of a large value, a vector record or a table's vector declaration that disagree with themselves are reported as damage, never a panic or another value | `contextdb/src/store/tests.rs`: `a_plain_text_whose_slices_hold_fewer_bytes_than_its_row_records_is_damage`, `a_compressed_text_that_decodes_to_bytes_that_are_not_text_is_damage`; `contextdb/src/vector/tests.rs`: `a_pending_record_of_a_row_with_no_vector_is_reported_as_damage`, `a_write_to_a_vector_column_that_is_no_column_of_its_table_is_reported_as_damage` |
| Counted work: a count reads no rows, a key lookup reads one | `contextdb/tests/counted_work.rs`: `count_of_a_whole_table_reads_no_rows`, `a_lookup_by_primary_key_reads_one_row` |
+| Every row with a vector is found by a search, whether its vector waits on a list or is in a block, and after a kill inside a commit or a seal | `contextdb/tests/vectors.rs`: `exact_search_returns_the_true_nearest_vectors`; `contextdb/tests/vectors_upkeep.rs`: `sealing_advances_only_when_asked_and_in_bounded_cycles`; `contextdb/tests/crash/main.rs`: `a_kill_inside_a_commit_of_vectors_keeps_rows_and_vectors_together`, `a_kill_at_any_storage_call_of_a_seal_leaves_every_vector_found` |
+| A row whose vector is NULL, and a deleted row, is never in an answer | `contextdb/tests/vectors.rs`: `exact_search_returns_the_true_nearest_vectors`, `exact_search_reads_its_own_snapshot` |
+| A partition exists exactly while a row with a vector is in it, and `MAX_PARTITIONS` counts those and nothing else | `contextdb/tests/vectors_partitions.rs`: `vector_partition_limit_counts_and_refuses`, `the_partition_cap_counts_live_partitions_while_a_reader_holds_an_earlier_state` |
+| No answer holds a row twice, whatever the row's vector has been | `contextdb/tests/vectors.rs`: `a_row_is_in_an_answer_once_after_its_vector_changes` |
+| A store's data file is no longer than a recorded number of bytes for a fixed content | `contextdb/tests/vectors_upkeep.rs`: `a_small_fixed_store_stays_within_its_recorded_size` |
+| A text or JSON value reads back byte for byte whether or not it is stored compressed; one that does not shrink by an eighth takes the room it takes uncompressed, and text that repeats takes less than its own bytes | `contextdb/tests/large_values.rs`: `large_text_and_json_come_back_byte_for_byte`, `a_value_that_does_not_shrink_is_kept_as_it_is`, `a_store_of_repeating_text_is_smaller_than_the_text` |
+| No value is produced past the memory limit: a compressed value whose real size does not fit is refused before its bytes exist, and compression never refuses a commit | `contextdb/tests/large_values.rs`: `a_compressed_value_too_large_for_the_limit_is_refused`, `a_commit_with_no_room_to_compress_is_written_plain`; `contextdb/tests/memory_ceiling.rs`: `a_refused_compressed_value_is_never_produced` |
+| A compressed value whose stored bytes do not give the length it records is reported as store damage | `contextdb/tests/large_values.rs`: `a_damaged_length_of_a_compressed_value_is_reported_as_damage` |
## Where a change lives
| Change | Folder | Test it needs |
|---|---|---|
| A new SQL statement or clause | `contextdb/src/sql/` (`grammar.pest`, `parser/`, the `Statement` type in `ast.rs`, `plan.rs`, `planner.rs`; take its first words out of the grammar's list of what a later version reads), then the part that runs it: `schema`, `write` or `query` | a script under `contextdb/tests/sql/` |
+| A new vector clause | `contextdb/src/sql/` reads it, as any clause; `contextdb/src/schema/` checks, stores and prints back one that is declared with the column; `contextdb/src/query/search.rs` settles what a search is asked (mode, vector, partitions, filter); `contextdb/src/vector/` only when blocks, partitions or the search itself change | `contextdb/tests/vectors_statements.rs` for what is read and refused, `contextdb/tests/vectors.rs` for the rows a search returns |
| A new refusal (error kind) | `Error` in `contextdb/src/types/mod.rs`; how the program prints it in `contextdb-cli/src/output.rs` | `contextdb/tests/refusals.rs` for its fields and that nothing changed; `contextdb/tests/sql/refusals.sql` for its name |
| What a statement returns | `contextdb/src/query/` for a SELECT, `contextdb/src/write/` for a row count | the expected output of the scripts under `contextdb/tests/sql/`, rewritten and the diff read |
| How a store is laid out on disk | `contextdb/src/store/` (keys, records, the data file, the folder's files) | `contextdb/tests/values.rs` and `contextdb/tests/store.rs` for reopen, `contextdb/tests/crash/` for a kill |
-| The storage library | `redb/src/`; `redb/README.md` lists every change against upstream, so add yours there. The storage library decides which pages may be reused; the `store` part only tells it the oldest commit a reader still holds, and keeps the reader files and the lock | `redb/tests/contextdb_changes.rs` |
+| The storage library | `redb/src/`; `redb/README.md` lists every change against upstream, so add yours there. The storage library decides which pages may be reused; the `store` part only tells it the oldest commit a reader still holds, and keeps the reader files and the lock. Two of its rules keep a store close to the size of what it holds, and a change there keeps both: a leaf written in key order is left full, and past 1 MiB the file grows by a sixteenth of its usable bytes (`a_table_written_in_key_order_has_full_leaves`, `a_file_is_at_most_a_sixteenth_longer_than_its_pages`) | `redb/tests/contextdb_changes.rs` |
| A new shell option or dot command | `contextdb-cli/src/main.rs` for an option, `contextdb-cli/src/shell.rs` for a command | `contextdb-cli/tests/shell.rs` |
| A new public call | the part that owns it, re-exported in `contextdb/src/lib.rs`, described in `contextdb/README.md` | a Rust test under `contextdb/tests/` |
@@ -87,12 +117,47 @@ Two lint settings hold this up:
- `clippy.toml` refuses `std::thread::sleep`, starting a thread, `Instant::now`, `SystemTime::now` and `uuid::Uuid::new_v4`: the engine does not sleep, start a thread or draw a random number, and reads the time only through `Clock`.
- `Cargo.toml` denies `clippy::iter_over_hash_type` for both members: a hash map is walked in a different order in every process, so nothing is built by walking one.
+## Making a change
+
+A defect is fixed by changing or removing code where its promise is owned, not by adding
+around it. Before writing a line:
+
+1. **Name what broke.** Find the row of "Invariants and the tests that guard them" that the
+ defect breaks, or write the one sentence that should be a row. A defect that cannot be
+ said in one sentence is not understood yet.
+2. **Find the part that owns it** in the parts table, and fix it there, once. A check added
+ at each caller, a flag, an option, a retry, a second path or a special case for one input
+ means the fix is in the wrong part: stop and look again.
+3. **Fix the kind, not the one.** Look for the same mistake beside it in that part; a defect
+ is usually one of several of its kind.
+4. **Remove what the fix makes unnecessary** in the same change: a guard an earlier fix
+ added, a helper nothing calls, a comment that is no longer true. Prefer making a wrong
+ state impossible where it enters (a type, a limit, a refusal when it is written) to
+ handling it wherever it could arrive.
+5. **Extend a test that guards the invariant** rather than add one beside it. A new test is
+ for an invariant, or a case of one, that no test covers, and the table's row names it. A
+ test changes only when the promise it states has changed, never to let a fix pass.
+6. **Keep it small.** A fix that adds more than about fifty shipped lines, or changes more
+ than two parts, is a redesign: stop, and write in the change's description what the
+ invariant is and why its owner cannot hold the fix.
+
+The check holds the line where no judgement is needed (`.github/workflows/check.yml`):
+
+- It states the size of the shipped code, rounded up to 500 lines, and the number of tests,
+ rounded up to 10. A change that crosses either edits the number in the same change, and
+ says why in its description; a change that removes enough lowers it. Neither number ever
+ passes 150,000 lines or 1,000 tests.
+- It refuses a shipped file longer than 1,500 lines, a test-only name in shipped code, an
+ ignored test, and an `allow` attribute beyond the four named below.
+- The lint settings above refuse a sleep, a thread, a clock read, a random id and a walk
+ over a hash map.
+
## Do not add
- A second way to do one thing: there is one commit function, one expression evaluator, one `Error`.
- A test-only item in shipped code: no test-only name, feature or `pub` door; tests use what a user has.
- A check another part already makes: `write` checks a table's rules, `memory` says no, `store` only keeps.
-- A lint switched off. Outside `redb/` there are four `allow` attributes and they stay the only ones: the machine clock in `contextdb/src/system/mod.rs` (the one place the time of day is read); the timer in `contextdb/examples/measure.rs` (measuring elapsed time is what that program is for); and two in `contextdb/tests/support/mod.rs`, one on its one thread (it moves a child's output lines to a channel so that a wait can give up) and `dead_code` for the whole file (each test program uses only some of its helpers).
+- A lint switched off. Outside `redb/` there are four `allow` attributes and they stay the only ones: the machine clock in `contextdb/src/system/mod.rs` (the one place the time of day is read); the timer in `contextdb/examples/support/mod.rs` (measuring elapsed time is what the programs under `contextdb/examples/` are for); and two in `contextdb/tests/support/mod.rs`, one on its one thread (it moves a child's output lines to a channel so that a wait can give up) and `dead_code` for the whole file (each test program uses only some of its helpers).
## Fast commands
diff --git a/v2/Cargo.lock b/v2/Cargo.lock
index 94b65371..e6bb3b9d 100644
--- a/v2/Cargo.lock
+++ b/v2/Cargo.lock
@@ -155,6 +155,7 @@ name = "contextdb"
version = "2.0.0"
dependencies = [
"contextdb-redb",
+ "lz4_flex",
"pest",
"pest_derive",
"serde_json",
@@ -266,6 +267,12 @@ version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
+[[package]]
+name = "lz4_flex"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226"
+
[[package]]
name = "memchr"
version = "2.8.3"
diff --git a/v2/README.md b/v2/README.md
index d0029a4f..d4022b40 100644
--- a/v2/README.md
+++ b/v2/README.md
@@ -27,8 +27,12 @@ cargo +1.89 check --manifest-path v2/Cargo.toml --workspace --locked
```
The last line checks the oldest Rust the packages declare (`rustup toolchain install 1.89`
-first). The workflow also fails when the library and the program together pass 150,000 lines or
-the tests pass 1,000; the two lines that count them are in the workflow file.
+first). The workflow also states the size of the library and the program, rounded up to 500
+lines, and the number of tests, rounded up to 10, and fails when either is passed or is no
+longer within that distance: a change that grows or shrinks the code past a boundary edits
+the number in the workflow file, in the same change. It also refuses a shipped file longer
+than 1,500 lines, a test-only name in shipped code, an ignored test and an `allow` attribute
+beyond the four `AGENTS.md` names. `AGENTS.md` says how a change is made so that these hold.
## One test
diff --git a/v2/contextdb-cli/src/main.rs b/v2/contextdb-cli/src/main.rs
index 82603005..40a0c493 100644
--- a/v2/contextdb-cli/src/main.rs
+++ b/v2/contextdb-cli/src/main.rs
@@ -51,16 +51,26 @@ EXIT STATUS:
2 the command line was refused; nothing was opened
MEMORY:
- --memory-limit is this session's ceiling, in bytes or with K, M or G. Without it a
- --write session has the limit the store remembers and a read session has none.
- The store keeps 8M for its page cache and 1M is set aside before that, so the
- smallest limit that opens a store is 9M.
+ --memory-limit is this session's ceiling, in bytes or with K, M or G; the store does
+ not remember it. Without it a --write session has the limit the store remembers and
+ a read session has none.
+ A session sets aside 1M and its page cache, sized when it starts: a quarter of its
+ limit and never less than 8M, which is also its size with no limit. So the smallest
+ limit that opens a store is 9M.
`SET MEMORY_LIMIT 512M;` (or NONE) declares the limit the store remembers; it needs
--write, and inside a transaction it still takes effect at once and ROLLBACK does not
- undo it. `SHOW MEMORY_LIMIT;` reports this session's limit and what is in use.
+ undo it. The smallest limit a session takes is 1M plus its page cache plus what it
+ holds, which is 9M in a session started with no limit; a limit below it is refused.
+ To go below the page cache of a session started under a larger limit, start with the
+ smaller one, `--memory-limit 64M`, and then run `SET MEMORY_LIMIT 64M;`.
+ `SHOW MEMORY_LIMIT;` reports this session's limit and what is in use: with no limit,
+ `none` and 9437184 bytes used, the 1M and the 8M page cache.
JSON (--json), one document a line:
a SELECT or SHOW MEMORY_LIMIT {"result":{"columns":[...],"rows":[{...}]}}
+ a vector is an array of numbers, each at the full
+ precision of the stored 32-bit value, so a number
+ may print with more digits than were typed
any other statement {"rows_affected":N}
.tables {"tables":{"items":[...]}}
.schema
{"schema":{"table":...,"ddl":...}}
diff --git a/v2/contextdb-cli/tests/shell.rs b/v2/contextdb-cli/tests/shell.rs
index 6e9b49d1..69001312 100644
--- a/v2/contextdb-cli/tests/shell.rs
+++ b/v2/contextdb-cli/tests/shell.rs
@@ -94,6 +94,49 @@ fn listing(folder: &Path) -> Vec {
names
}
+/// The tables a run printed for a person, in order: each is its header and then its rows, cell
+/// by cell. A result with no rows prints no table and is an empty list here.
+fn tables(stdout: &str) -> Vec>> {
+ let mut tables = Vec::new();
+ let mut table = Vec::new();
+ for line in stdout.lines() {
+ let cells = line
+ .strip_prefix('|')
+ .and_then(|rest| rest.strip_suffix('|'));
+ if let Some(cells) = cells {
+ let cells = cells.split('|').map(|cell| cell.trim().to_owned());
+ table.push(cells.collect());
+ } else if line.starts_with('(') {
+ tables.push(std::mem::take(&mut table));
+ }
+ }
+ tables
+}
+
+/// The rows of a printed search whose columns are `id` and `distance`: each row's key and its
+/// distance as a number, however the program writes a whole REAL.
+fn nearest(table: &[Vec]) -> Vec<(i64, f64)> {
+ assert_eq!(table[0], ["id", "distance"], "{table:?}");
+ table[1..]
+ .iter()
+ .map(|row| (row[0].parse().unwrap(), row[1].parse().unwrap()))
+ .collect()
+}
+
+/// A vector in a JSON document: an array of numbers.
+fn numbers(vector: &Json) -> Vec {
+ vector
+ .as_array()
+ .unwrap_or_else(|| panic!("not a JSON array: {vector}"))
+ .iter()
+ .map(|number| {
+ number
+ .as_f64()
+ .unwrap_or_else(|| panic!("not a number: {number}")) as f32
+ })
+ .collect()
+}
+
const CREATE_NOTES: &str = "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);\n";
const SELECT_NOTES: &str = "SELECT id, body FROM notes ORDER BY id;\n";
@@ -636,6 +679,20 @@ fn the_memory_limit_option_is_the_sessions_ceiling() {
documents(&run.stdout)[0]["result"]["rows"][0]["limit"].clone()
};
assert_eq!(limit(&[arg(&store), "--write", "--json"]), json!("none"));
+ // With no limit a session counts 9 MiB as used before any statement: 1 MiB and a page
+ // cache of 8 MiB. A read shell with no limit counts the same.
+ for args in [
+ &[arg(&store), "--write", "--json"][..],
+ &[arg(&store), "--json"],
+ ] {
+ let run = shell(&dir, args, "SHOW MEMORY_LIMIT;\n");
+ assert_eq!(run.code, Some(0), "{}", run.stderr);
+ assert_eq!(
+ documents(&run.stdout)[0]["result"]["rows"][0]["used"],
+ json!(9_437_184_u64),
+ "used with no limit"
+ );
+ }
for (size, bytes) in [
("65536K", 67_108_864_u64),
("64M", 67_108_864),
@@ -730,3 +787,227 @@ fn the_memory_limit_option_is_the_sessions_ceiling() {
);
}
}
+
+#[test]
+fn a_similarity_search_runs_through_the_program() {
+ let dir = folder("a-similarity-search-runs");
+ let store = dir.join("store");
+ // From [1, 0, 0] row 1 is at distance 0, row 5 between 0 and 1, row 2 at 1 and row 3 at 2.
+ // Row 4 has no vector.
+ let declare = "\
+CREATE TABLE t (
+ id INTEGER PRIMARY KEY,
+ kind TEXT NOT NULL,
+ v VECTOR(3) PARTITION_KEY (kind) SEARCH_MODE EXACT
+);
+INSERT INTO t (id, kind, v)
+VALUES (1, 'a', [1.0, 0.0, 0.0]), (2, 'a', [0.0, 1.0, 0.0]), (3, 'b', [-1.0, 0.0, 0.0]),
+ (4, 'b', NULL), (5, 'b', [0.1, 0.2, 0.3]);
+";
+ let search =
+ "SELECT id, v <=> [1.0, 0.0, 0.0] AS distance FROM t ORDER BY distance LIMIT 10;\n";
+ let rows = "SELECT id, v FROM t ORDER BY id;\n";
+
+ // Declared, written and searched in one piped write session.
+ let run = shell(
+ &dir,
+ &[arg(&store), "--write"],
+ &format!("{declare}{search}{rows}"),
+ );
+ assert_eq!(run.code, Some(0), "{}", run.stderr);
+ assert_eq!(run.errors(), Vec::<&str>::new());
+ let printed = tables(&run.stdout);
+ assert_eq!(printed.len(), 2, "{}", run.stdout);
+
+ // The rows that have a vector, nearest first, each with its distance under the name the
+ // statement gave it.
+ let found = nearest(&printed[0]);
+ let ids: Vec = found.iter().map(|(id, _)| *id).collect();
+ assert_eq!(ids, [1, 5, 2, 3], "{}", run.stdout);
+ assert_eq!(
+ (found[0].1, found[2].1, found[3].1),
+ (0.0, 1.0, 2.0),
+ "{}",
+ run.stdout
+ );
+ assert!(0.0 < found[1].1 && found[1].1 < 1.0, "{}", run.stdout);
+
+ // A vector prints as its numbers in brackets, and no vector as NULL.
+ assert_eq!(printed[1][0], ["id", "v"], "{}", run.stdout);
+ assert_eq!(printed[1][1], ["1", "[1.0, 0.0, 0.0]"], "{}", run.stdout);
+ assert_eq!(printed[1][4], ["4", "NULL"], "{}", run.stdout);
+ assert_eq!(printed[1][5], ["5", "[0.1, 0.2, 0.3]"], "{}", run.stdout);
+
+ // For a program the distance is a number, a vector an array of numbers and no vector null.
+ let run = shell(&dir, &[arg(&store), "--json"], &format!("{search}{rows}"));
+ assert_eq!(run.code, Some(0), "{}", run.stderr);
+ let results = documents(&run.stdout);
+ assert_eq!(results.len(), 2, "{}", run.stdout);
+ assert_eq!(results[0]["result"]["columns"], json!(["id", "distance"]));
+ let found = results[0]["result"]["rows"].as_array().unwrap();
+ let ids: Vec = found
+ .iter()
+ .map(|row| row["id"].as_i64().unwrap())
+ .collect();
+ assert_eq!(ids, [1, 5, 2, 3], "{}", run.stdout);
+ for row in found {
+ assert!(row["distance"].is_number(), "{row}");
+ }
+ let distance = |row: usize| found[row]["distance"].as_f64().unwrap();
+ assert_eq!(
+ (distance(0), distance(2), distance(3)),
+ (0.0, 1.0, 2.0),
+ "{}",
+ run.stdout
+ );
+ assert!(0.0 < distance(1) && distance(1) < 1.0, "{}", run.stdout);
+ assert_eq!(results[1]["result"]["columns"], json!(["id", "v"]));
+ let stored = results[1]["result"]["rows"].as_array().unwrap();
+ assert_eq!(stored.len(), 5, "{}", run.stdout);
+ assert_eq!(numbers(&stored[0]["v"]), [1.0, 0.0, 0.0]);
+ assert!(stored[3]["v"].is_null(), "{}", stored[3]);
+ assert_eq!(numbers(&stored[4]["v"]), [0.1, 0.2, 0.3]);
+
+ // `.schema` prints the VECTOR column with its clauses in the order they are declared in,
+ // and with the limit of 256 partitions that a key declared without one has.
+ let schema = shell(&dir, &[arg(&store)], ".schema t\n");
+ assert_eq!(schema.code, Some(0), "{}", schema.stderr);
+ let words = schema
+ .stdout
+ .split_whitespace()
+ .collect::>()
+ .join(" ");
+ assert!(
+ words.contains("v VECTOR(3) PARTITION_KEY (kind) MAX_PARTITIONS 256 SEARCH_MODE EXACT"),
+ "{}",
+ schema.stdout
+ );
+
+ // What it printed declares the same table again in another store.
+ let copy = dir.join("copy");
+ let made = shell(&dir, &[arg(©), "--write"], &schema.stdout);
+ assert_eq!(made.code, Some(0), "{}", made.stderr);
+ assert_eq!(
+ shell(&dir, &[arg(©)], ".schema t\n").stdout,
+ schema.stdout
+ );
+}
+
+#[test]
+fn indexed_search_is_refused_by_name_through_the_program() {
+ let dir = folder("indexed-search-is-refused");
+ let store = dir.join("store");
+ let made = shell(
+ &dir,
+ &[arg(&store), "--write"],
+ "CREATE TABLE passages (id INTEGER PRIMARY KEY, embedding VECTOR(3));\n\
+ INSERT INTO passages (id, embedding) VALUES (1, [0.0, 1.0, 0.0]), (2, [1.0, 0.0, 0.0]);\n",
+ );
+ assert_eq!(made.code, Some(0), "{}", made.stderr);
+
+ // The same search asked for INDEXED and then for EXACT.
+ let search = |mode: &str| {
+ format!(
+ "SELECT id FROM passages ORDER BY embedding <=> [1.0, 0.0, 0.0] USE VECTOR {mode} \
+ LIMIT 2;\n"
+ )
+ };
+ let input = format!("{}{}", search("INDEXED"), search("EXACT"));
+ let exact = "+----+\n| id |\n+----+\n| 2 |\n| 1 |\n+----+\n(2 rows)\n";
+
+ // Reading or writing, this version keeps no index and says so.
+ for mode in [&[][..], &["--write"][..]] {
+ let mut args = vec![arg(&store)];
+ args.extend_from_slice(mode);
+
+ // For a person: one line on the error stream, the refusal's sentence naming the column,
+ // and a piped run that fails.
+ let run = shell(&dir, &args, &input);
+ assert_eq!(run.code, Some(1), "{mode:?}: {}", run.stderr);
+ let lines: Vec<&str> = run.stderr.lines().collect();
+ assert_eq!(lines.len(), 1, "{mode:?}: {}", run.stderr);
+ assert!(
+ lines[0].starts_with("Error: ")
+ && lines[0].contains("passages.embedding")
+ && lines[0].contains("INDEXED"),
+ "{mode:?}: {}",
+ run.stderr
+ );
+ // INDEXED gave no rows under any name, and the statement after it still ran.
+ assert_eq!(run.stdout, exact, "{mode:?}");
+
+ // For a program: the refusal's kind.
+ args.push("--json");
+ let run = shell(&dir, &args, &input);
+ assert_eq!(run.code, Some(1), "{mode:?}: {}", run.stderr);
+ let refusals = documents(&run.stderr);
+ assert_eq!(refusals.len(), 1, "{mode:?}: {}", run.stderr);
+ let error = &refusals[0]["error"];
+ assert_eq!(error["class"], "sql", "{mode:?}: {error}");
+ assert_eq!(
+ error["detail"]["kind"], "vector_indexed_route_unavailable",
+ "{mode:?}: {error}"
+ );
+ assert!(
+ error["message"]
+ .as_str()
+ .is_some_and(|message| message.contains("passages.embedding")),
+ "{mode:?}: {error}"
+ );
+ assert_eq!(
+ documents(&run.stdout),
+ [json!({"result": {"columns": ["id"], "rows": [{"id": 2}, {"id": 1}]}})],
+ "{mode:?}"
+ );
+ }
+}
+
+#[test]
+fn a_read_shell_searches_while_a_writer_holds_the_store() {
+ let dir = folder("a-read-shell-searches-while-a-writer-holds");
+ let store = dir.join("store");
+
+ // This test is the writer, and holds the store until the end. From [1, 0, 0] row 1 is at
+ // distance 1 and row 2 at 2.
+ let writer = Database::open(&store).unwrap();
+ for sql in [
+ "CREATE TABLE passages (id INTEGER PRIMARY KEY, embedding VECTOR(3))",
+ "INSERT INTO passages (id, embedding) VALUES (1, [0.0, 1.0, 0.0]), (2, [-1.0, 0.0, 0.0])",
+ ] {
+ writer.execute(sql, ¶ms! {}).unwrap();
+ }
+ let search = "SELECT id, embedding <=> [1.0, 0.0, 0.0] AS distance FROM passages \
+ ORDER BY distance LIMIT 2;\n";
+
+ // The store really is held: a second writer is refused, naming this process.
+ let second = shell(&dir, &[arg(&store), "--write"], search);
+ assert_eq!(second.code, Some(1), "{}", second.stderr);
+ assert!(
+ second.stderr.contains(&std::process::id().to_string()),
+ "{}",
+ second.stderr
+ );
+
+ // A read shell needs nothing from the writer: it searches and gets the rows.
+ let run = shell(&dir, &[arg(&store)], search);
+ assert_eq!(run.code, Some(0), "{}", run.stderr);
+ let printed = tables(&run.stdout);
+ assert_eq!(printed.len(), 1, "{}", run.stdout);
+ assert_eq!(nearest(&printed[0]), [(1, 1.0), (2, 2.0)], "{}", run.stdout);
+
+ // The writer was not held up by it, and the next read shell searches its next commit, in
+ // which row 3 is the nearest.
+ writer
+ .execute(
+ "INSERT INTO passages (id, embedding) VALUES (3, [1.0, 0.0, 0.0])",
+ ¶ms! {},
+ )
+ .unwrap();
+ let run = shell(&dir, &[arg(&store)], search);
+ assert_eq!(run.code, Some(0), "{}", run.stderr);
+ let printed = tables(&run.stdout);
+ assert_eq!(printed.len(), 1, "{}", run.stdout);
+ assert_eq!(nearest(&printed[0]), [(3, 0.0), (1, 1.0)], "{}", run.stdout);
+
+ writer.close().unwrap();
+}
diff --git a/v2/contextdb/Cargo.toml b/v2/contextdb/Cargo.toml
index 1002a1d0..111a7b5b 100644
--- a/v2/contextdb/Cargo.toml
+++ b/v2/contextdb/Cargo.toml
@@ -9,6 +9,7 @@ repository = "https://github.com/context-graph-ai/contextdb"
readme = "README.md"
[dependencies]
+lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode"] }
pest = "2.8.6"
pest_derive = "2.8.6"
redb = { package = "contextdb-redb", version = "4.1.0", path = "../redb" }
diff --git a/v2/contextdb/README.md b/v2/contextdb/README.md
index 0d75e9cb..42032c29 100644
--- a/v2/contextdb/README.md
+++ b/v2/contextdb/README.md
@@ -2,16 +2,23 @@
An embedded SQL database: tables in one folder, inside your own process.
+An application that keeps its records in one store and its embeddings in a separate vector
+library has to keep the two consistent by hand; here a vector is a column, a row and its vector
+commit together, and one `SELECT` filters rows and orders them by distance.
+
This is the first version of contextdb 2. What it has:
- tables with typed columns, a primary key, `NOT NULL`, `UNIQUE` and `DEFAULT`, and indexes;
- `INSERT`, `UPDATE`, `DELETE` and `SELECT` over one table;
+- vector search: the rows nearest a vector by cosine distance, each with its distance, exact,
+ over vectors that a column may keep in partitions;
- transactions;
- one process writing a store while any number of other processes read it;
- a memory limit that holds.
-What it does not have yet: vector search, relationships between rows, joins, grouping, and sync
-between machines. A statement that uses one of them is refused and changes nothing.
+What it does not have yet: an index for vector search (every search is exact), relationships
+between rows, joins, grouping, and sync between machines. A statement that uses one of them is
+refused and changes nothing.
It is not on crates.io yet. Clone and use it from that checkout:
@@ -26,30 +33,42 @@ use contextdb::{params, Database, Uuid, Value};
fn main() -> contextdb::Result<()> {
let db = Database::open("./notes")?; // a folder, created if it is missing
db.execute(
- "CREATE TABLE notes (id UUID PRIMARY KEY, body TEXT NOT NULL, made TIMESTAMP DEFAULT NOW())",
+ "CREATE TABLE notes (id UUID PRIMARY KEY, body TEXT NOT NULL, embedding VECTOR(3),
+ made TIMESTAMP DEFAULT NOW())",
¶ms! {},
)?;
- db.execute(
- "INSERT INTO notes (id, body) VALUES ($id, $body)",
- ¶ms! { "id" => Uuid::new_v4(), "body" => "buy milk" },
- )?;
+ // A real embedding has hundreds of numbers and comes from a model; three show the shape.
+ let notes = [("buy milk", vec![0.9, 0.1, 0.0]), ("call the plumber", vec![0.0, 0.2, 0.9])];
+ for (body, embedding) in notes {
+ db.execute(
+ "INSERT INTO notes (id, body, embedding) VALUES ($id, $body, $embedding)",
+ ¶ms! { "id" => Uuid::new_v4(), "body" => body, "embedding" => embedding },
+ )?;
+ }
let tx = db.transaction()?; // rolls back if dropped
tx.execute("UPDATE notes SET body = 'buy oat milk' WHERE body = 'buy milk'", ¶ms! {})?;
tx.commit()?;
- let found = db.execute("SELECT body, made FROM notes ORDER BY made LIMIT 10", ¶ms! {})?;
+ // The notes nearest a vector, nearest first, each with its distance.
+ let found = db.execute(
+ "SELECT body, embedding <=> $q AS distance FROM notes ORDER BY embedding <=> $q LIMIT 10",
+ ¶ms! { "q" => vec![1.0, 0.0, 0.0] },
+ )?;
for row in found.rows() {
- if let Value::Text(body) = &row[0] {
- println!("{body}");
+ if let (Value::Text(body), Value::Float64(distance)) = (&row[0], &row[1]) {
+ println!("{body}: {distance:.3}");
}
}
Ok(())
}
```
-It prints `buy oat milk`. This page is also the crate's documentation, and the documentation
-tests compile this program as it is written here. Rust 1.89 or newer; Linux and macOS. Licence:
+It prints `buy oat milk: 0.006` and then `call the plumber: 1.000`. After writing vectors, a host
+calls `db.run_maintenance_cycle()?` until its report's `vector.maintenance_vectors_remaining` is
+0, because this version does not do it by itself; a search before that returns the same rows,
+slower ([Sealing](#vector-search) below). This page is also the crate's
+documentation, and the documentation tests compile this program as it is written here. Rust 1.89 or newer; Linux and macOS. Licence:
Apache-2.0. It is developed in the open at by the context-graph-ai organisation.
## Statements
@@ -57,6 +76,8 @@ Apache-2.0. It is developed in the open at vector [USE VECTOR AUTO | EXACT | INDEXED] LIMIT k;
SET MEMORY_LIMIT 512M; SET MEMORY_LIMIT NONE; SHOW MEMORY_LIMIT;
```
@@ -72,15 +95,19 @@ SET MEMORY_LIMIT 512M; SET MEMORY_LIMIT NONE; SHOW MEMORY_LIMIT;
- A condition or a value is built from column names, literals, `$name` values,
`= != <> < <= > >=`, `AND`, `OR`, `NOT`, `IS NULL`, `IS NOT NULL`, `+ - * /`,
parentheses and `NOW()`. A comparison with NULL is not true, as in standard SQL.
+- `x IN (a, b, ...)` and `x NOT IN (a, b, ...)` test a value against a list, and
+ `(x, y) IN ((a, b), (c, d), ...)` tests several columns at once, both as standard SQL has
+ them. A list is written out: `IN (SELECT ...)` is refused.
- `SELECT` also takes `COUNT(*)`, `COUNT(column)`, `SUM`, `MIN` and `MAX` over the rows that
match, and `AS` to name a column of the result.
- `ORDER BY` puts NULL last ascending and first descending.
+- The second `SELECT` is a vector search; [Vector search](#vector-search) below says what it
+ returns.
- `--` and `/* */` comments are read past.
-Not in this version, and refused: `JOIN`, `GROUP BY`, `DISTINCT`, `IN`, `LIKE`, `BETWEEN`,
-`OFFSET`, `UNION`, subqueries, table aliases, `ALTER`, `DROP`, a primary key over several
-columns, and similarity search (`<=>`). Most are refused by name; this is the sentence the
-refusal prints:
+Not in this version, and refused: `JOIN`, `GROUP BY`, `DISTINCT`, `LIKE`, `BETWEEN`,
+`OFFSET`, `UNION`, subqueries, table aliases, `ALTER`, `DROP`, and a primary key over several
+columns. Most are refused by name; this is the sentence the refusal prints:
```text
plan error: LIKE is not supported in this version of the library
@@ -97,12 +124,77 @@ plan error: LIKE is not supported in this version of the library
| `UUID` | a UUID, written as quoted text | `Value::Uuid` |
| `TIMESTAMP` | milliseconds since the first moment of 1970, UTC; written as ISO 8601 text (`'2026-01-01T00:00:01Z'`) or taken from `NOW()` | `Value::Timestamp` |
| `JSON` | a JSON value, written as quoted text; the value is kept, not the text's spacing | `Value::Json` |
-| `VECTOR(n)` | a vector of exactly `n` numbers, written `[0.1, 0.2, 0.3]`; stored and returned, not searchable in this version | `Value::Vector` |
+| `VECTOR(n)` | a vector of exactly `n` finite numbers, each kept as an `f32`, written `[0.1, 0.2, 0.3]`; searched with `<=>` | `Value::Vector` |
A value must be of its column's type: text into an `INTEGER` column is refused, and so is NULL
into a `NOT NULL` column, on `INSERT` and on `UPDATE` alike. A whole number is accepted into a
`REAL` column.
+## Vector search
+
+A `VECTOR(n)` column is searched by ordering its rows by their distance from a vector and
+taking the first `k`:
+
+```sql
+SELECT id, embedding <=> $q AS distance FROM notes ORDER BY distance LIMIT 10;
+```
+
+- `embedding <=> $q` is the cosine distance between the row's vector and `$q`, a `REAL`. Lower
+ is closer: 0 for vectors that point the same way, 1 for vectors at a right angle, 2 for
+ opposite ones, as Postgres with pgvector reads the operator. Rows come nearest first, and
+ rows at the same distance in primary-key order.
+- A search returns the columns the statement wrote and no other. To get a row's distance, write
+ the expression in the select list, alone or inside another expression, and name it as you
+ like: `1 - (embedding <=> $q) AS similarity` is a similarity. `ORDER BY` takes the expression
+ itself or the name the select list gives it.
+- The vector on the right is a `$name` bound to a `Vec`, a literal `[1.0, 0.0, 0.0]`, or
+ the same as quoted text, `'[1, 0, 0]'`. It has the column's dimension and finite numbers.
+- `WHERE` is applied first: the answer is the `k` nearest of the rows that pass it. A row whose
+ vector is NULL is in no answer. The distance to a vector of all zeros is 1.
+- Every search is exact: it returns the rows that comparing `$q` with every stored vector
+ returns.
+
+Only `<=>`, cosine, is here: pgvector's `<->` and `<#>` are a parse error. `<=>` has a value
+only for the rows of a search, so it is refused everywhere else: in `WHERE`,
+in the select list of a statement that does not order by it, in an `INSERT`, `UPDATE` or
+`DELETE`, with a vector other than the one the statement orders by, and in an `ORDER BY` that
+is `DESC`, has a second item or has no `LIMIT`; a search takes no `OFFSET` either.
+
+**Sealing.** Beside every stored vector the engine keeps a one-byte copy of each of its numbers
+and packs the copies of many vectors into a block, which a search screens quickly to rule most
+rows out without reading them. A vector that was just written is on the waiting list, the
+vectors not yet in a block, which a search compares one by one by reading their rows.
+`Database::run_maintenance_cycle` seals them, which is to move them from the list into blocks:
+one call seals at most 4,096 in one commit and returns how many it sealed and how many still
+wait; nothing in this version calls it on its own. A search returns the same rows with the same distances whether or not vectors are sealed; sealed,
+it reads far fewer rows, and the result's `QueryResult::rows_examined` says how many it read.
+
+**Partitions.** `PARTITION_KEY (column, ...)` on a `VECTOR` column lays its vectors out by the
+values of the named columns, one partition for each combination that has a row with a vector,
+so that a search which filters on the key reads only that part. A partition is not a wall: a
+search with no filter on the key reads every partition, and which rows a caller may see is the
+`WHERE`'s business. A search whose `WHERE` gives the key columns values with `=` or `IN` reads
+only the partitions named, up to 4,096 of them (a filter that names more reads every
+partition); the whole condition is still applied to every row, so partitions change the work
+and never the answer. A key column is `NOT NULL` and a `UUID`, `TEXT`,
+`INTEGER`, `BOOLEAN` or `TIMESTAMP`.
+
+`MAX_PARTITIONS m`, written after the key when the table is declared, is the most partitions
+the column may have at one time, 256 when it is not written. An `INSERT` or `UPDATE` that would
+take the column past it is refused with `Error::VectorPartitionLimitExceeded`, which names the
+column and its limit; the statement changes nothing, and inside a transaction the transaction
+goes on. A column with no `PARTITION_KEY` is one partition and has no limit.
+
+**Modes.** `SEARCH_MODE` on the column says how its searches run when a statement does not, and
+`USE VECTOR`, written after the `ORDER BY` item, says it for one statement. `AUTO`, which is
+the default, and `EXACT` both run the exact search. `INDEXED` asks for an index, and this
+version has none: a column may be declared with it, and a search under it is refused before
+anything is read, unless the statement itself says `USE VECTOR EXACT` or `AUTO`.
+
+```text
+no vector index ships in this version, so notes.embedding cannot be searched INDEXED; use EXACT or AUTO
+```
+
## Binding values
`$name` in a statement is the entry `"name"` of the map passed with it. `params!` writes the
@@ -114,7 +206,9 @@ any other value is written as a `Value`, for example `Value::Timestamp(1_767_225
## Refusals
-Every call that can fail returns `contextdb::Result` with one `contextdb::Error`. A program
+Every call that can fail returns `contextdb::Result` with one `contextdb::Error`, except
+`Database::memory_limit`, `memory_used`, `table_names` and `committed_watermark`, which return
+a plain value and panic on a handle that was closed. A program
matches on the kind (`Error::UniqueViolation { table, column }`, `Error::ColumnNotNullable`,
`Error::TableNotFound`, `Error::DatabaseLocked`, `Error::MemoryBudgetExceeded`, and the rest
listed on the `Error` type); a person reads the sentence it prints:
@@ -155,23 +249,61 @@ answering when the writer stops, is killed or starts again.
## The memory limit
`db.set_memory_limit(Some(bytes))` or `SET MEMORY_LIMIT 512M` declares how much memory the
-engine may use for data in the writing process, and the store remembers it until it is changed
-or set to `NONE`. A statement that would pass the limit is refused and committed rows stay as
-they were:
+engine may use for data in the writing process, and the store remembers it: every later open
+for writing that names no limit of its own is under it, until it is changed or set to `NONE`.
+A statement that would pass the limit is refused and committed rows stay as they were:
```text
memory limit reached during transaction: 30000210 bytes were needed and 7340032 bytes of the 16777216-byte limit were free. select or write less at once, or raise the limit with SET MEMORY_LIMIT or the memory_limit open option
```
-With nothing declared there is no ceiling. A reader has a limit of its own, given when it opens,
-and none unless one is given. Every open sets 9 MiB of the limit aside, 8 MiB for the page cache
-and 1 MiB for small fixed things, so 9 MiB is the smallest limit that opens a store and a
-statement needs room above it.
+With nothing declared there is no ceiling. A limit given when a store is opened, as
+`DatabaseOpenOptions::memory_limit` or the shell's `--memory-limit`, is that session's limit and
+no more: it holds until the session closes, and the store does not remember it. The options
+type is `#[non_exhaustive]`, so start from its default and assign:
+
+```rust,no_run
+use contextdb::{Database, DatabaseOpenOptions};
+
+fn main() -> contextdb::Result<()> {
+ let mut options = DatabaseOpenOptions::default();
+ options.memory_limit = Some(64 << 20); // 64 MiB, for this session only
+ let db = Database::open_with_options("./notes", options)?;
+ db.close()
+}
+```
+
+A reader has a
+limit of its own, given when it opens, and none unless one is given: it never takes the limit
+the store remembers.
+
+Every open sets aside 1 MiB for small fixed things and its page cache, and counts both as used
+from the start. The cache is sized once, when the store is opened, and keeps that size until
+the session closes: a quarter of the limit in force and never less than 8 MiB, which is also
+its size when there is no limit. So 9 MiB is the smallest limit that opens a store, and a
+statement needs room above what is set aside.
+
+A limit declared in an open session has to hold with the cache that session already has: the
+smallest one it takes is 1 MiB, plus its cache, plus what its open transactions and results
+hold. In a store opened with no limit in force that is 9 MiB plus what is held, so
+`SET MEMORY_LIMIT 64M` is accepted at once. A smaller limit is refused with
+`Error::MemoryBudgetExceeded`, changes nothing and is not remembered; the bytes the refusal
+says were needed are the smallest limit that session takes. Any other limit holds at once for
+what statements take, and the next open sizes its cache from it.
+
+Only a limit below the cache of a session opened under a larger limit needs two steps: open the
+store with the smaller limit, which sizes the cache from it, and declare it in that session,
+which makes the store remember it:
+
+```text
+echo "SET MEMORY_LIMIT 64M;" | contextdb ./notes --write --memory-limit 64M
+```
`SHOW MEMORY_LIMIT` returns one row about the session that asks, a writer's or a reader's:
-`limit` (its limit in bytes, or `none`), `used` (bytes in use now, the 9 MiB included),
-`available` (what is left under the limit), `startup_ceiling` (the limit the session was opened
-under) and `ceiling_refresh_failures` (always 0 in this version).
+`limit` (its limit in bytes, or `none`), `used` (bytes in use now, the 1 MiB and the page cache
+included), `available` (what is left under the limit), `startup_ceiling` (the limit the session
+was opened under) and `ceiling_refresh_failures` (always 0 in this version). In a session with
+no limit it reports `none` as the limit and 9437184 bytes, which is 9 MiB, as used.
## The store on disk
@@ -182,7 +314,9 @@ notes/
readers/ one small file per reader, while a statement of its runs
```
-Nothing is written anywhere else. While no process has the folder open for writing it is a plain
+Nothing is written anywhere else. Text and JSON values from about 1 KiB up to 1 MiB are stored
+compressed when that saves an eighth or more, and read back byte for byte, with nothing to
+switch on or off, so a store of long text is usually smaller than the text. While no process has the folder open for writing it is a plain
folder: copy it, move it or delete it with ordinary tools. Do not copy it while a writer is
running; this version has no command that copies a store beside a live writer.
@@ -225,7 +359,9 @@ piped run exits 1 if any statement or command was refused, an unknown command an
a table that is not there included, and 0 otherwise. However the input arrives, the exit status is 1 when the
store could not be opened or closed, and 2 when the command line itself is refused. `--json`
prints one JSON document per statement or command and each refusal as a JSON document on the
-error stream. `contextdb --help` has the
+error stream. A vector in a result is a JSON array of numbers, each written at the full
+precision of the 32-bit value that is stored, so a number may print with more digits than
+were typed, where the table a person reads shows `[0.1, ...]`. `contextdb --help` has the
options, the exit statuses and the JSON forms.
## Numbers
@@ -236,16 +372,18 @@ nothing else.
| What | Measured |
|---|---|
-| 20,000 rows of 1.6 KB, one commit per row: time to insert | 17.2 s |
-| That store on disk | 60.3 MiB |
-| Open that store | 0.631 ms |
-| `SELECT COUNT(*)` over it | 0.026 ms, 0 rows read |
-| A million rows under a 64 MiB limit: time to load | 11.9 s |
-| Scan the million rows | 246 ms |
-| Most memory the engine had allocated during that load and scan | 13.4 MiB |
-| Most memory the process held, as the operating system reports it | 18.0 MiB |
-| First build of a program that uses the library | 5 s, 46 packages, a 2.7 MiB program |
-| From a fresh clone to a passing `cargo test` | 2.1 min |
-| Lines of Rust and grammar in the library and the program | 7,369 |
-| Tests | 98 |
-| Lines that differ from redb 4.1.0 in the storage library under the engine, which is redb 4.1.0 with the changes listed in `v2/redb/README.md` | 577 |
+| 20,000 rows of 1.6 KB, one commit per row: time to insert | 11.0 s |
+| That store on disk (its rows repeat their text, which compresses far better than real text) | 2.4 MiB |
+| Open that store | 0.606 ms |
+| `SELECT COUNT(*)` over it | 0.025 ms, 0 rows read |
+| A million rows under a 64 MiB limit: time to load | 9.7 s |
+| Scan the million rows | 239.167 ms |
+| Most memory the engine had allocated during that load and scan | 16.4 MiB |
+| Most memory the process held, as the operating system reports it | 21.3 MiB |
+| First build of a program that uses the library | 6 s, 47 packages, a 3.0 MiB program |
+| From a fresh clone to a passing `cargo test` | 2.7 min |
+| Ten nearest of 20,000 vectors, sealed, through SQL: 95th percentile | 1.500 ms |
+| That vector store on disk, and how many times the bytes it holds | 44.3 MiB, 1.50 times |
+| Lines of Rust and grammar in the library and the program | 10,809 |
+| Tests | 171 |
+| Lines that differ from redb 4.1.0 in the storage library under the engine, which is redb 4.1.0 with the changes listed in `v2/redb/README.md` | 626 |
diff --git a/v2/contextdb/examples/measure.rs b/v2/contextdb/examples/measure.rs
index c19a110b..f59e3875 100644
--- a/v2/contextdb/examples/measure.rs
+++ b/v2/contextdb/examples/measure.rs
@@ -1,83 +1,37 @@
//! Prints the numbers this library is held to, one per line as `name value unit`.
//!
-//! `measure readme ` runs the README's first program at 20,000 rows of 1.6 KB, one
-//! commit per row, and prints the store's size on disk, how long opening it takes and how long
-//! `COUNT(*)` takes. `measure million ` loads a million small rows under a 64 MiB limit
-//! in commits of 10,000, scans them, and prints the most memory the process had allocated beside
-//! the most the operating system saw it hold. The folder must be missing or empty.
+//! - `measure readme ` inserts 20,000 notes of 1.6 KB, one
+//! commit per row, and prints the store's size on disk, how long opening it takes and how
+//! long `COUNT(*)` takes.
+//! - `measure million ` loads a million small rows under a 64 MiB limit in commits of
+//! 10,000, scans them, and prints the most memory the process had allocated beside the most
+//! the operating system saw it hold.
+//! - `measure vectors ` loads 20,000 rows with a vector of 384 numbers made from the
+//! row number, 500 to a commit, seals them, searches for the 10 nearest of 100 further
+//! vectors after 5 searches that are not timed, and prints the search time and the store's
+//! size beside the bytes held.
+//! - `measure sessions ` loads every line that is
+//! not empty of the first `count` files the list names, 500 rows to a commit, and prints the
+//! files' bytes, the bytes put in (each line's and 8 for its number) and the store's size.
+//! It prints nothing of a file but those counts.
+//!
+//! The store's folder must be missing or empty. A store's size is the bytes of every file in
+//! its folder after it was closed.
//!
//! This program measures. It never passes or fails, no test reads what it prints, and
//! `numbers.sh` is what runs it.
-use std::alloc::{GlobalAlloc, Layout, System};
-use std::fmt::Display;
+mod support;
+
+use std::fs::File;
+use std::io::{BufRead, BufReader};
use std::path::Path;
-use std::sync::atomic::{AtomicUsize, Ordering};
-use std::time::{Duration, Instant};
use contextdb::{Database, DatabaseOpenOptions, Result, Uuid, params};
-
-/// The system allocator, counting the bytes handed out and not yet given back.
-struct Counting;
-
-static IN_USE: AtomicUsize = AtomicUsize::new(0);
-static PEAK: AtomicUsize = AtomicUsize::new(0);
-
-// SAFETY: every request goes to the system allocator unchanged; this only counts its bytes.
-unsafe impl GlobalAlloc for Counting {
- unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
- let in_use = IN_USE.fetch_add(layout.size(), Ordering::Relaxed) + layout.size();
- PEAK.fetch_max(in_use, Ordering::Relaxed);
- unsafe { System.alloc(layout) }
- }
-
- unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
- IN_USE.fetch_sub(layout.size(), Ordering::Relaxed);
- unsafe { System.dealloc(pointer, layout) }
- }
-}
+use support::{bytes_on_disk, line, mebibytes, milliseconds, percentile, ratio, seconds, timed};
#[global_allocator]
-static ALLOCATOR: Counting = Counting;
-
-/// Runs `work` and says how long it took. Apart from the library's `system` part, this is the
-/// only place in the workspace that reads a clock.
-#[allow(
- clippy::disallowed_methods,
- reason = "measuring elapsed time is what this program is for"
-)]
-fn timed(work: impl FnOnce() -> T) -> (T, Duration) {
- let start = Instant::now();
- let result = work();
- (result, start.elapsed())
-}
-
-fn line(name: &str, value: impl Display, unit: &str) {
- println!("{name} {value} {unit}");
-}
-
-fn milliseconds(duration: Duration) -> String {
- format!("{:.3}", duration.as_secs_f64() * 1000.0)
-}
-
-fn mebibytes(bytes: u64) -> String {
- format!("{:.1}", bytes as f64 / 1_048_576.0)
-}
-
-/// The bytes of every file under a folder.
-fn bytes_on_disk(folder: &Path) -> std::io::Result {
- let mut bytes = 0;
- for entry in std::fs::read_dir(folder)? {
- let entry = entry?;
- let meta = entry.metadata()?;
- bytes += if meta.is_dir() {
- bytes_on_disk(&entry.path())?
- } else {
- meta.len()
- };
- }
- Ok(bytes)
-}
+static ALLOCATOR: support::Counting = support::Counting;
/// The most memory the operating system has seen this process hold, where it says so.
fn peak_resident_bytes() -> Option {
@@ -87,7 +41,7 @@ fn peak_resident_bytes() -> Option {
Some(kibibytes * 1024)
}
-/// The README's first program, at 20,000 rows of 1.6 KB.
+/// 20,000 notes of 1.6 KB, one commit per row.
fn readme(folder: &Path) -> Result<()> {
const ROWS: u128 = 20_000;
let db = Database::open(folder)?;
@@ -108,7 +62,7 @@ fn readme(folder: &Path) -> Result<()> {
inserted?;
db.close()?;
line("readme_rows", ROWS, "rows");
- line("readme_insert", format!("{:.1}", insert.as_secs_f64()), "s");
+ line("readme_insert", seconds(insert), "s");
let on_disk = bytes_on_disk(folder).expect("the store's folder can be listed");
line("readme_store_size", mebibytes(on_disk), "MiB");
@@ -153,11 +107,7 @@ fn million(folder: &Path) -> Result<()> {
loaded?;
let (sum, scan) = timed(|| db.execute("SELECT SUM(n) FROM small", ¶ms! {}));
let sum = sum?;
- line(
- "million_rows_load",
- format!("{:.1}", load.as_secs_f64()),
- "s",
- );
+ line("million_rows_load", seconds(load), "s");
line("million_rows_scan", milliseconds(scan), "ms");
line(
"million_rows_scan_rows_examined",
@@ -166,7 +116,7 @@ fn million(folder: &Path) -> Result<()> {
);
line(
"million_rows_peak_allocated",
- mebibytes(PEAK.load(Ordering::Relaxed) as u64),
+ mebibytes(support::take_peak_allocated()),
"MiB",
);
if let Some(bytes) = peak_resident_bytes() {
@@ -175,29 +125,170 @@ fn million(folder: &Path) -> Result<()> {
db.close()
}
-fn main() -> Result<()> {
- let arguments: Vec = std::env::args().skip(1).collect();
- let [what, folder] = arguments.as_slice() else {
- eprintln!("usage: measure readme|million ");
- std::process::exit(2);
+/// The numbers a vector is made of: the same for the same `seed` on every machine, each from
+/// -1 up to 1.
+fn vector_of(seed: u64, dimensions: usize) -> Vec {
+ let mut state = seed;
+ let mut next = || {
+ // SplitMix64.
+ state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
+ let mut z = state;
+ z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
+ z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
+ z ^ (z >> 31)
};
- let folder = Path::new(folder);
- if folder
- .read_dir()
- .is_ok_and(|mut entries| entries.next().is_some())
- {
- eprintln!(
- "{} is not empty; give a folder that is missing or empty",
- folder.display()
- );
- std::process::exit(2);
+ (0..dimensions)
+ .map(|_| (next() >> 40) as f32 / (1u64 << 23) as f32 - 1.0)
+ .collect()
+}
+
+/// 20,000 vectors of 384 numbers, sealed, and the 10 nearest of 100 others.
+fn vectors(folder: &Path) -> Result<()> {
+ const ROWS: i64 = 20_000;
+ const PER_COMMIT: i64 = 500;
+ const DIMENSIONS: usize = 384;
+ let db = Database::open(folder)?;
+ db.execute(
+ "CREATE TABLE items (id INTEGER PRIMARY KEY, embedding VECTOR(384))",
+ ¶ms! {},
+ )?;
+ for first in (1..=ROWS).step_by(PER_COMMIT as usize) {
+ let tx = db.transaction()?;
+ for id in first..first + PER_COMMIT {
+ tx.execute(
+ "INSERT INTO items (id, embedding) VALUES ($id, $embedding)",
+ ¶ms! { "id" => id, "embedding" => vector_of(id as u64, DIMENSIONS) },
+ )?;
+ }
+ tx.commit()?;
}
- match what.as_str() {
- "readme" => readme(folder),
- "million" => million(folder),
- _ => {
- eprintln!("usage: measure readme|million ");
- std::process::exit(2);
+ while db
+ .run_maintenance_cycle()?
+ .vector
+ .maintenance_vectors_remaining
+ > 0
+ {}
+ let mut times = Vec::new();
+ for n in -5..100 {
+ let q = vector_of((ROWS + 6 + n) as u64, DIMENSIONS);
+ let (found, time) = timed(|| {
+ db.execute(
+ "SELECT id, embedding <=> $q AS distance FROM items ORDER BY distance LIMIT 10",
+ ¶ms! { "q" => q },
+ )
+ });
+ found?;
+ if n >= 0 {
+ times.push(time);
}
}
+ db.close()?;
+ line(
+ "vectors_20000_find_p95",
+ milliseconds(percentile(×, 0.95)),
+ "ms",
+ );
+ let on_disk = bytes_on_disk(folder).expect("the store's folder can be listed");
+ line("vectors_20000_store_size", mebibytes(on_disk), "MiB");
+ let held = ROWS as u64 * (8 + 4 * DIMENSIONS as u64);
+ line("vectors_20000_size_over_held", ratio(on_disk, held), "x");
+ Ok(())
+}
+
+/// Stops the program with a sentence that holds nothing of a session file.
+fn stop(sentence: &str, position: usize) -> ! {
+ eprintln!("file {position} of the list {sentence}");
+ std::process::exit(1);
+}
+
+/// The lines of the first `count` files the list names, one row each.
+fn sessions(corpus: &Path, list: &Path, count: usize, folder: &Path) -> Result<()> {
+ const PER_COMMIT: u32 = 500;
+ let names = std::fs::read_to_string(list).expect("the list file can be read");
+ let names = names.lines().filter(|name| !name.is_empty()).take(count);
+ let db = Database::open(folder)?;
+ db.execute(
+ "CREATE TABLE session_lines (id INTEGER PRIMARY KEY, body TEXT NOT NULL)",
+ ¶ms! {},
+ )?;
+ let (mut files, mut source, mut put_in, mut id) = (0usize, 0u64, 0u64, 0i64);
+ let (loaded, load) = timed(|| -> Result<()> {
+ let mut tx = db.transaction()?;
+ let mut in_commit = 0;
+ for name in names {
+ files += 1;
+ let Ok(file) = File::open(corpus.join(name)) else {
+ stop("cannot be opened", files);
+ };
+ let mut file = BufReader::new(file);
+ loop {
+ let mut bytes = Vec::new();
+ match file.read_until(b'\n', &mut bytes) {
+ Ok(0) => break,
+ Ok(read) => source += read as u64,
+ Err(_) => stop("cannot be read", files),
+ }
+ if bytes.last() == Some(&b'\n') {
+ bytes.pop();
+ }
+ if bytes.is_empty() {
+ continue;
+ }
+ let Ok(body) = String::from_utf8(bytes) else {
+ stop("has a line that is not UTF-8", files);
+ };
+ id += 1;
+ put_in += body.len() as u64 + 8;
+ tx.execute(
+ "INSERT INTO session_lines (id, body) VALUES ($id, $body)",
+ ¶ms! { "id" => id, "body" => body },
+ )?;
+ in_commit += 1;
+ if in_commit == PER_COMMIT {
+ std::mem::replace(&mut tx, db.transaction()?).commit()?;
+ in_commit = 0;
+ }
+ }
+ }
+ tx.commit()
+ });
+ loaded?;
+ db.close()?;
+ let on_disk = bytes_on_disk(folder).expect("the store's folder can be listed");
+ let name = |what: &str| format!("sessions_{files}_{what}");
+ line(&name("source_size"), mebibytes(source), "MiB");
+ line(&name("rows"), id, "rows");
+ line(&name("load"), seconds(load), "s");
+ line(&name("put_in_size"), mebibytes(put_in), "MiB");
+ line(&name("store_size"), mebibytes(on_disk), "MiB");
+ line(&name("size_over_source"), ratio(on_disk, source), "x");
+ line(&name("size_over_put_in"), ratio(on_disk, put_in), "x");
+ Ok(())
+}
+
+fn usage() -> ! {
+ eprintln!(
+ "usage: measure readme|million|vectors \n \
+ measure sessions "
+ );
+ std::process::exit(2);
+}
+
+fn main() -> Result<()> {
+ let arguments: Vec = std::env::args().skip(1).collect();
+ let arguments: Vec<&str> = arguments.iter().map(String::as_str).collect();
+ let Some(folder) = arguments.last().map(Path::new) else {
+ usage();
+ };
+ support::must_be_empty(folder);
+ match arguments.as_slice() {
+ ["readme", _] => readme(folder),
+ ["million", _] => million(folder),
+ ["vectors", _] => vectors(folder),
+ ["sessions", corpus, list, count, _] => match count.parse() {
+ Ok(count) => sessions(Path::new(corpus), Path::new(list), count, folder),
+ Err(_) => usage(),
+ },
+ _ => usage(),
+ }
}
diff --git a/v2/contextdb/examples/passages.rs b/v2/contextdb/examples/passages.rs
new file mode 100644
index 00000000..2b27bf7b
--- /dev/null
+++ b/v2/contextdb/examples/passages.rs
@@ -0,0 +1,367 @@
+//! Loads a dump of search passages into a store and measures searches on them, printing one
+//! number per line as `name value unit`.
+//!
+//! `passages `
+//!
+//! The dump folder holds `rows.bin` (36 bytes for each passage, numbers low byte first: a row
+//! number `u64`, the passage's id, the index `u32` of its Context, the index `u32` of its
+//! (Context, generation) pair, and flags `u32`: 1 current, 2 searchable, 4 has a vector),
+//! `vectors.f32` (768 `f32` for each passage, in the same order), `contexts.txt` (`index uuid
+//! name` on each line), `pairs.txt` (`index context-index generation-uuid active`) and
+//! `queries.f32` (768 `f32` for each question). The answers folder holds `engine-one.jsonl`,
+//! `engine-several.jsonl` and `engine-all.jsonl`: on each line the ten answers recorded for one
+//! question, `{"q": n, "ids": [...], "scores": [...]}`, a score being one minus the distance,
+//! rounded to 32 bits. The store folder must be missing or empty.
+//!
+//! What it does, in order:
+//!
+//! 1. opens the store with no memory limit and inserts the passages, 500 to a commit;
+//! 2. searches all Contexts for every question once through a read session, with every vector
+//! still waiting to be sealed;
+//! 3. calls `run_maintenance_cycle` until nothing waits, closes the store and takes its size;
+//! 4. through a read session with no memory limit, then one under 1 GiB, then one under
+//! 64 MiB, searches one Context (the first), several (the first three) and all of them: 5
+//! searches that are not timed, then every question, each `execute` call timed whole;
+//! 5. compares the answers of the session with no limit with the recorded ones.
+//!
+//! A time named `p50` or `p95` is the one at position `round((count - 1) * share)` of the
+//! times in order. "Held" is the bytes of the values put in: 50 for a passage's five other
+//! columns and 3,072 for its vector.
+//!
+//! This program measures. It never passes or fails, no test reads what it prints, and
+//! `numbers.sh` is what runs it. It prints nothing of a passage but counts, sizes and times.
+
+mod support;
+
+use std::collections::{BTreeSet, HashMap};
+use std::fs::File;
+use std::io::{BufReader, Read};
+use std::path::Path;
+use std::time::Duration;
+
+use contextdb::serde_json::Value as Json;
+use contextdb::{Database, ReadSession, ReadSessionOptions, Uuid, Value, params};
+use support::{bytes_on_disk, line, mebibytes, milliseconds, percentile, ratio, seconds, timed};
+
+#[global_allocator]
+static ALLOCATOR: support::Counting = support::Counting;
+
+type Fallible = Result>;
+
+const DIMENSIONS: usize = 768;
+const PER_COMMIT: usize = 500;
+const UNTIMED: usize = 5;
+const SCOPES: [&str; 3] = ["one", "several", "all"];
+
+/// What the dump says beside its passages.
+struct Dump {
+ /// Each Context's id, by index.
+ contexts: Vec,
+ /// Each pair's Context index, generation, and whether it is the Context's active one.
+ pairs: Vec<(usize, Uuid, bool)>,
+ /// The question vectors.
+ questions: Vec>,
+}
+
+/// What the searches of one scope gave: for each question the time, the rows read, and the
+/// ids and distances returned.
+#[derive(Default)]
+struct Finds {
+ first: Duration,
+ times: Vec,
+ examined: Vec,
+ answers: Vec>,
+}
+
+fn floats(bytes: &[u8]) -> Vec {
+ let four = bytes.chunks_exact(4);
+ four.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
+ .collect()
+}
+
+fn read_dump(folder: &Path) -> Fallible {
+ let mut contexts = Vec::new();
+ for text in std::fs::read_to_string(folder.join("contexts.txt"))?.lines() {
+ contexts.push(text.split(' ').nth(1).ok_or("contexts.txt")?.parse()?);
+ }
+ let mut pairs = Vec::new();
+ for text in std::fs::read_to_string(folder.join("pairs.txt"))?.lines() {
+ let fields: Vec<&str> = text.split(' ').collect();
+ let [_, context, generation, active] = fields.as_slice() else {
+ return Err("pairs.txt".into());
+ };
+ pairs.push((context.parse()?, generation.parse()?, *active == "1"));
+ }
+ let questions = floats(&std::fs::read(folder.join("queries.f32"))?);
+ let questions = questions.chunks_exact(DIMENSIONS).map(<[f32]>::to_vec);
+ Ok(Dump {
+ contexts,
+ pairs,
+ questions: questions.collect(),
+ })
+}
+
+/// Inserts every passage of the dump. Returns how many there were and the bytes held.
+fn load(db: &Database, folder: &Path, dump: &Dump) -> Fallible<(u64, u64)> {
+ let rows = File::open(folder.join("rows.bin"))?;
+ let passages = rows.metadata()?.len() / 36;
+ let mut rows = BufReader::new(rows);
+ let mut vectors = BufReader::new(File::open(folder.join("vectors.f32"))?);
+ let (mut record, mut vector) = ([0u8; 36], vec![0u8; 4 * DIMENSIONS]);
+ let mut held = 0;
+ let mut tx = db.transaction()?;
+ for passage in 1..=passages as usize {
+ rows.read_exact(&mut record)?;
+ vectors.read_exact(&mut vector)?;
+ let number = |at: usize| {
+ u32::from_le_bytes([record[at], record[at + 1], record[at + 2], record[at + 3]])
+ };
+ let (context, generation, _) = dump.pairs[number(28) as usize];
+ let flags = number(32);
+ let embedding = if flags & 4 == 0 {
+ Value::Null
+ } else {
+ Value::Vector(floats(&vector))
+ };
+ held += 3 * 16
+ + 2
+ + if flags & 4 == 0 {
+ 0
+ } else {
+ 4 * DIMENSIONS as u64
+ };
+ tx.execute(
+ "INSERT INTO event_search_passages \
+ (id, context_id, generation_id, vector_text, is_current, is_searchable) \
+ VALUES ($id, $context, $generation, $vector, $current, $searchable)",
+ ¶ms! {
+ "id" => Uuid::from_slice(&record[8..24])?,
+ "context" => dump.contexts[context],
+ "generation" => generation,
+ "vector" => embedding,
+ "current" => flags & 1 != 0,
+ "searchable" => flags & 2 != 0,
+ },
+ )?;
+ if passage % PER_COMMIT == 0 {
+ std::mem::replace(&mut tx, db.transaction()?).commit()?;
+ }
+ }
+ tx.commit()?;
+ Ok((passages, held))
+}
+
+/// The search statement for the active pairs of the scope's Contexts, with its values.
+fn statement(dump: &Dump, scope: &[usize], question: &[f32]) -> (String, HashMap) {
+ let mut values = params! {
+ "query" => question.to_vec(), "is_current" => true, "is_searchable" => true,
+ };
+ let active = dump.pairs.iter().filter(|pair| pair.2);
+ let wanted = active.filter(|pair| scope.contains(&pair.0));
+ let pairs: BTreeSet<(Uuid, Uuid)> =
+ wanted.map(|pair| (dump.contexts[pair.0], pair.1)).collect();
+ let mut names = Vec::new();
+ for (i, (context, generation)) in pairs.into_iter().enumerate() {
+ values.insert(format!("search_context_{i}"), Value::Uuid(context));
+ values.insert(format!("search_generation_{i}"), Value::Uuid(generation));
+ names.push(format!("($search_context_{i}, $search_generation_{i})"));
+ }
+ let filter = if names.len() == 1 {
+ "context_id = $search_context_0 AND generation_id = $search_generation_0".to_owned()
+ } else {
+ format!("(context_id, generation_id) IN ({})", names.join(", "))
+ };
+ let sql = format!(
+ "SELECT id, context_id, generation_id, vector_text <=> $query AS distance \
+ FROM event_search_passages WHERE {filter} \
+ AND is_current = $is_current AND is_searchable = $is_searchable \
+ ORDER BY distance LIMIT 10"
+ );
+ (sql, values)
+}
+
+/// The Contexts of a scope, by index.
+fn contexts_of(scope: &str, dump: &Dump) -> Vec {
+ match scope {
+ "one" => vec![0],
+ "several" => vec![0, 1, 2],
+ _ => (0..dump.contexts.len()).collect(),
+ }
+}
+
+/// Searches a scope for every question, after `untimed` searches that are not recorded.
+fn find(session: &ReadSession, dump: &Dump, scope: &str, untimed: usize) -> Fallible {
+ let scope = contexts_of(scope, dump);
+ let mut finds = Finds::default();
+ for run in 0..untimed + dump.questions.len() {
+ let question = run.checked_sub(untimed);
+ let asked = question.unwrap_or(run % dump.questions.len());
+ let (sql, values) = statement(dump, &scope, &dump.questions[asked]);
+ let (found, time) = timed(|| session.execute(&sql, &values));
+ let found = found?;
+ if run == 0 {
+ finds.first = time;
+ }
+ if question.is_none() {
+ continue;
+ }
+ let answer = found.rows().iter().map(|row| match (&row[0], &row[3]) {
+ (Value::Uuid(id), Value::Float64(distance)) => Ok((*id, *distance)),
+ _ => Err("a search row is not an id and a distance"),
+ });
+ finds.answers.push(answer.collect::>()?);
+ finds.times.push(time);
+ finds.examined.push(found.rows_examined());
+ }
+ Ok(finds)
+}
+
+/// The three scopes through a read session opened under `limit`.
+fn find_under(store: &Path, limit: Option, dump: &Dump) -> Fallible> {
+ let mut options = ReadSessionOptions::default();
+ options.memory_limit = limit;
+ let session = ReadSession::open_with_options(store, options)?;
+ let each = SCOPES.iter();
+ each.map(|scope| find(&session, dump, scope, UNTIMED))
+ .collect()
+}
+
+/// Compares a scope's answers with the recorded ones: how many questions have the same ten
+/// ids, how many have them in the same order, and the largest difference between a recorded
+/// score and one minus the distance, rounded to 32 bits, of the same id.
+fn compare(answers: &[Vec<(Uuid, f64)>], recorded: &Path) -> Fallible<(usize, usize, f64)> {
+ let (mut same_ten, mut same_order, mut gap) = (0, 0, 0f64);
+ for text in std::fs::read_to_string(recorded)?.lines() {
+ let recorded: Json = contextdb::serde_json::from_str(text)?;
+ let (Some(question), Some(ids), Some(scores)) = (
+ recorded["q"].as_u64(),
+ recorded["ids"].as_array(),
+ recorded["scores"].as_array(),
+ ) else {
+ continue; // the summary line
+ };
+ let ids = ids.iter().map(|id| id.as_str().unwrap_or_default().parse());
+ let ids: Vec = ids.collect::>()?;
+ let answer = answers
+ .get(question as usize)
+ .ok_or("a question out of range")?;
+ let ours: Vec = answer.iter().map(|(id, _)| *id).collect();
+ same_order += usize::from(ours == ids);
+ let as_set = |ids: &[Uuid]| ids.iter().copied().collect::>();
+ same_ten += usize::from(as_set(&ours) == as_set(&ids));
+ for (id, distance) in answer {
+ if let Some(at) = ids.iter().position(|recorded| recorded == id) {
+ let score = scores[at].as_f64().ok_or("a score that is not a number")?;
+ gap = gap.max((f64::from((1.0 - distance) as f32) - score).abs());
+ }
+ }
+ }
+ Ok((same_ten, same_order, gap))
+}
+
+fn main() -> Fallible<()> {
+ let arguments: Vec = std::env::args().skip(1).collect();
+ let [dump_folder, answers, store] = arguments.as_slice() else {
+ eprintln!("usage: passages ");
+ std::process::exit(2);
+ };
+ let (dump_folder, answers, store) =
+ (Path::new(dump_folder), Path::new(answers), Path::new(store));
+ support::must_be_empty(store);
+ let dump = read_dump(dump_folder)?;
+
+ let db = Database::open(store)?;
+ db.execute(
+ "CREATE TABLE event_search_passages (
+ id UUID PRIMARY KEY,
+ context_id UUID NOT NULL,
+ generation_id UUID NOT NULL,
+ vector_text VECTOR(768) PARTITION_KEY (context_id, generation_id)
+ MAX_PARTITIONS 4096 SEARCH_MODE AUTO,
+ is_current BOOLEAN NOT NULL,
+ is_searchable BOOLEAN NOT NULL DEFAULT true
+ )",
+ ¶ms! {},
+ )?;
+ support::take_peak_allocated();
+ let (loaded, load_time) = timed(|| load(&db, dump_folder, &dump));
+ let (passages, held) = loaded?;
+ let load_peak = support::take_peak_allocated();
+ let name = |what: &str| format!("passages_{passages}_{what}");
+ line(&name("load"), seconds(load_time), "s");
+
+ let unsealed = find(&ReadSession::open(store)?, &dump, "all", 0)?;
+
+ let mut cycles = 0;
+ let (sealed, seal_time) = timed(|| -> contextdb::Result<()> {
+ loop {
+ cycles += 1;
+ let report = db.run_maintenance_cycle()?;
+ if report.vector.maintenance_vectors_remaining == 0 {
+ return Ok(());
+ }
+ }
+ });
+ sealed?;
+ db.close()?;
+ let on_disk = bytes_on_disk(store)?;
+ line(&name("seal"), seconds(seal_time), "s");
+ line(&name("seal_cycles"), cycles, "cycles");
+ line(&name("store_size"), mebibytes(on_disk), "MiB");
+ line(
+ &name("bytes_per_passage"),
+ on_disk / passages.max(1),
+ "bytes",
+ );
+ line(&name("size_over_held"), ratio(on_disk, held), "x");
+ let p95 = |times: &[Duration]| milliseconds(percentile(times, 0.95));
+ line(&name("find_unsealed_all_p95"), p95(&unsealed.times), "ms");
+ let rows = percentile(&unsealed.examined, 0.5);
+ line(&name("find_unsealed_all_rows_examined_p50"), rows, "rows");
+
+ support::take_peak_allocated();
+ let unlimited = find_under(store, None, &dump)?;
+ for (scope, finds) in SCOPES.iter().zip(&unlimited) {
+ let p50 = milliseconds(percentile(&finds.times, 0.5));
+ line(&name(&format!("find_{scope}_p50")), p50, "ms");
+ line(&name(&format!("find_{scope}_p95")), p95(&finds.times), "ms");
+ }
+ let all = &unlimited[2];
+ line(&name("find_all_first"), milliseconds(all.first), "ms");
+ line(
+ &name("find_all_max"),
+ milliseconds(percentile(&all.times, 1.0)),
+ "ms",
+ );
+ for (limit, bytes) in [("1g", 1 << 30), ("64m", 64 << 20)] {
+ let limited = find_under(store, Some(bytes), &dump)?;
+ let name = name(&format!("find_all_p95_limit_{limit}"));
+ line(&name, p95(&limited[2].times), "ms");
+ }
+ for (what, share) in [("p50", 0.5), ("p95", 0.95), ("max", 1.0)] {
+ let rows = percentile(&all.examined, share);
+ line(
+ &name(&format!("find_all_rows_examined_{what}")),
+ rows,
+ "rows",
+ );
+ }
+
+ let mut compared = Vec::new();
+ for (scope, finds) in SCOPES.iter().zip(&unlimited) {
+ let recorded = answers.join(format!("engine-{scope}.jsonl"));
+ compared.push(compare(&finds.answers, &recorded)?);
+ }
+ for (scope, (same_ten, _, _)) in SCOPES.iter().zip(&compared) {
+ line(&name(&format!("same_ten_{scope}")), same_ten, "of_100");
+ }
+ for (scope, (_, same_order, _)) in SCOPES.iter().zip(&compared) {
+ line(&name(&format!("same_order_{scope}")), same_order, "of_100");
+ }
+ let gap = compared.iter().map(|(_, _, gap)| *gap).fold(0.0, f64::max);
+ line(&name("distance_gap_max"), gap, "-");
+ line(&name("load_peak_allocated"), mebibytes(load_peak), "MiB");
+ let find_peak = support::take_peak_allocated();
+ line(&name("find_peak_allocated"), mebibytes(find_peak), "MiB");
+ Ok(())
+}
diff --git a/v2/contextdb/examples/support/mod.rs b/v2/contextdb/examples/support/mod.rs
new file mode 100644
index 00000000..19e57b98
--- /dev/null
+++ b/v2/contextdb/examples/support/mod.rs
@@ -0,0 +1,105 @@
+//! What the measuring programs of this folder share: an allocator that counts, the one timer,
+//! the size of a folder, and the `name value unit` lines they print.
+
+use std::alloc::{GlobalAlloc, Layout, System};
+use std::fmt::Display;
+use std::path::Path;
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::time::{Duration, Instant};
+
+/// The system allocator, counting the bytes handed out and not yet given back. A program makes
+/// it its global allocator.
+pub struct Counting;
+
+static IN_USE: AtomicUsize = AtomicUsize::new(0);
+static PEAK: AtomicUsize = AtomicUsize::new(0);
+
+// SAFETY: every request goes to the system allocator unchanged; this only counts its bytes.
+unsafe impl GlobalAlloc for Counting {
+ unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
+ let in_use = IN_USE.fetch_add(layout.size(), Ordering::Relaxed) + layout.size();
+ PEAK.fetch_max(in_use, Ordering::Relaxed);
+ unsafe { System.alloc(layout) }
+ }
+
+ unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
+ IN_USE.fetch_sub(layout.size(), Ordering::Relaxed);
+ unsafe { System.dealloc(pointer, layout) }
+ }
+}
+
+/// The most bytes the process had allocated at one moment since the last call, or since it
+/// started. The next count starts from what is allocated now.
+pub fn take_peak_allocated() -> u64 {
+ PEAK.swap(IN_USE.load(Ordering::Relaxed), Ordering::Relaxed) as u64
+}
+
+/// Runs `work` and says how long it took. Apart from the library's `system` part, this is the
+/// only place in the workspace that reads a clock.
+#[allow(
+ clippy::disallowed_methods,
+ reason = "measuring elapsed time is what these programs are for"
+)]
+pub fn timed(work: impl FnOnce() -> T) -> (T, Duration) {
+ let start = Instant::now();
+ let result = work();
+ (result, start.elapsed())
+}
+
+pub fn line(name: &str, value: impl Display, unit: &str) {
+ println!("{name} {value} {unit}");
+}
+
+pub fn milliseconds(duration: Duration) -> String {
+ format!("{:.3}", duration.as_secs_f64() * 1000.0)
+}
+
+pub fn seconds(duration: Duration) -> String {
+ format!("{:.1}", duration.as_secs_f64())
+}
+
+pub fn mebibytes(bytes: u64) -> String {
+ format!("{:.1}", bytes as f64 / 1_048_576.0)
+}
+
+/// One number over another, to two places.
+pub fn ratio(over: u64, under: u64) -> String {
+ format!("{:.2}", over as f64 / under as f64)
+}
+
+/// The value a `share` of the values lie at or under: the one at position
+/// `round((count - 1) * share)` when they are put in order. There must be at least one.
+pub fn percentile(values: &[T], share: f64) -> T {
+ let mut sorted = values.to_vec();
+ sorted.sort_unstable();
+ sorted[((sorted.len() - 1) as f64 * share).round() as usize]
+}
+
+/// The bytes of every file under a folder, by length.
+pub fn bytes_on_disk(folder: &Path) -> std::io::Result {
+ let mut bytes = 0;
+ for entry in std::fs::read_dir(folder)? {
+ let entry = entry?;
+ let meta = entry.metadata()?;
+ bytes += if meta.is_dir() {
+ bytes_on_disk(&entry.path())?
+ } else {
+ meta.len()
+ };
+ }
+ Ok(bytes)
+}
+
+/// Stops the program with a sentence unless the folder is missing or empty.
+pub fn must_be_empty(folder: &Path) {
+ if folder
+ .read_dir()
+ .is_ok_and(|mut entries| entries.next().is_some())
+ {
+ eprintln!(
+ "{} is not empty; give a folder that is missing or empty",
+ folder.display()
+ );
+ std::process::exit(2);
+ }
+}
diff --git a/v2/contextdb/src/database/mod.rs b/v2/contextdb/src/database/mod.rs
index f9d1e0e1..939ed370 100644
--- a/v2/contextdb/src/database/mod.rs
+++ b/v2/contextdb/src/database/mod.rs
@@ -19,6 +19,8 @@ use crate::sql::{self, Effect, Plan};
use crate::store::{ChangeSet, Read, Store, View, WrapStorage};
use crate::system::{self, Clock};
use crate::types::{Error, Result, TxId, Value};
+use crate::upkeep::{self, MaintenanceReport};
+use crate::vector::{self, Tally};
use crate::write;
/// What a host may say when it opens a store for writing.
@@ -42,8 +44,11 @@ pub struct DatabaseOpenOptions {
/// ever declared. A limit given here holds until the store is closed and leaves what the
/// store remembers as it is.
///
- /// Every open sets 9 MiB aside out of the limit, 8 MiB of it for the store's page cache. A
- /// smaller limit is refused with [`crate::Error::MemoryBudgetExceeded`] and opens no store.
+ /// Every open sets aside, out of the limit, 1 MiB and the store's page cache. The cache is
+ /// a quarter of the limit in force and never less than 8 MiB, which is also its size when
+ /// no limit is in force; it is chosen when the store is opened and keeps its size until the
+ /// store is closed. A limit below 9 MiB is refused with
+ /// [`crate::Error::MemoryBudgetExceeded`] and opens no store.
///
/// A [`crate::ReadSession`] never takes the limit the store remembers: `None` in its own
/// options means no limit at all, so a reader that needs a ceiling is given one there.
@@ -108,6 +113,9 @@ struct Staged {
catalog: Arc,
/// The declarations it ran, in order, for a commit that has to run them again.
declared: Vec,
+ /// The vector partitions its statements have made and emptied, for the limit a statement
+ /// is refused by. The commit function does not read it: it counts again.
+ partitions: Tally,
}
fn planned(sql: &str) -> Result {
@@ -146,6 +154,7 @@ impl Open {
changes: ChangeSet::new(view, &self.budget)?,
catalog,
declared: Vec::new(),
+ partitions: Tally::new(&self.budget)?,
})
}
@@ -174,8 +183,17 @@ impl Open {
return Err(Error::MixedSchemaAndRowWrite);
}
let (catalog, changes) = (&staged.catalog, &mut staged.changes);
- write::apply(plan, params, catalog, changes, &self.budget, now)
- .map(QueryResult::affected)
+ let partitions = &mut staged.partitions;
+ write::apply(
+ plan,
+ params,
+ catalog,
+ changes,
+ partitions,
+ &self.budget,
+ now,
+ )
+ .map(QueryResult::affected)
}
Effect::Reads => {
if let Plan::ShowMemoryLimit = plan {
@@ -211,12 +229,14 @@ impl Open {
/// its declarations again over the newest moment, because a table's and an index's number
/// are counted on from the catalog and a new index is filled from the rows that are there;
/// a refusal of that second run is the commit's refusal. One that wrote rows is laid over
- /// the newest moment and `write` checks its rows against it.
+ /// the newest moment and `write` checks its rows against it. Then the vector part stages
+ /// what the rows mean for it, so a row and its place in a search commit together.
fn commit(&self, _held: &MutexGuard<'_, ()>, staged: Staged) -> Result<()> {
let Staged {
mut changes,
mut catalog,
declared,
+ partitions: _,
} = staged;
let (view, tables) = self.newest()?;
if changes.commit() != view.commit() {
@@ -243,6 +263,7 @@ impl Open {
if changes.is_empty() {
return Ok(());
}
+ vector::stage(&mut changes, &catalog, &self.budget)?;
let commit = self.store.commit(changes)?;
// The commit is on disk whatever the read after it says. When that read fails no view
// is kept and the next call reads again, so none answers from before this commit.
@@ -259,6 +280,9 @@ impl Open {
/// hold in this open and is committed for the store to remember, or neither happens.
fn set_memory_limit(&self, limit: Option) -> Result<()> {
let held = locked(&self.committing);
+ // Refused here, before anything changes, when the page cache of this open and what is
+ // in use do not fit under the limit.
+ self.store.count_cache_under(limit)?;
let mut staged = self.begin()?;
staged.changes.put_memory_limit(limit)?;
let before = self.budget.limit();
@@ -389,13 +413,18 @@ impl Database {
/// Declares the memory limit in bytes, or no limit for `None`.
///
/// The limit takes hold at once and is committed, so the store remembers it for every
- /// later open whose options name no limit, exactly as `SET MEMORY_LIMIT` does.
+ /// later open whose options name no limit, exactly as `SET MEMORY_LIMIT` does. The page
+ /// cache keeps the size this open gave it; the next open sizes it from the limit.
///
/// # Errors
///
- /// [`crate::Error::MemoryBudgetExceeded`] when the limit is below what is in use now, which
- /// is never less than the 9 MiB every open sets aside. [`crate::Error::StoreIo`] when the
- /// commit cannot be written. In both cases the limit stays what it was.
+ /// [`crate::Error::MemoryBudgetExceeded`] when the limit is below the smallest this open
+ /// can take: 1 MiB, its page cache, and what open transactions and results not yet dropped
+ /// hold. That is never less than 9 MiB, and is 9 MiB plus what is held for a store opened
+ /// with no limit in force. The refusal names it; a limit below the cache of a store opened
+ /// under a larger limit is given as [`DatabaseOpenOptions::memory_limit`] when the store is
+ /// opened. [`crate::Error::StoreIo`] when the commit cannot be written. In both cases the
+ /// limit stays what it was.
/// [`crate::Error::DatabaseClosed`] on a handle that was closed.
pub fn set_memory_limit(&self, limit: Option) -> Result<()> {
self.opened()?
@@ -467,6 +496,41 @@ impl Database {
locked(&self.must_be_open().newest).commit
}
+ /// Runs one cycle of upkeep and returns what it did.
+ ///
+ /// A cycle moves at most 4,096 vectors from the pending lists of VECTOR columns into blocks,
+ /// in one commit, and reports how many it moved and how many still wait. Searches give the
+ /// same rows before and after a cycle; they read fewer rows after it, because a vector in a
+ /// block is ruled in or out from its one-byte copy and a pending vector is read from its
+ /// row. A store with nothing pending is left as it is, and no commit is made.
+ ///
+ /// Under a memory limit with no room for all of that, a cycle seals the partitions it has
+ /// room for, at least one, and the next cycle goes on from there.
+ ///
+ /// Nothing in this version calls a cycle on its own: a host that searches a VECTOR column
+ /// calls this after it has written, and again while
+ /// `vector.maintenance_vectors_remaining` is not zero. Other calls on the handle wait for
+ /// the cycle's commit as they wait for any other commit; readers do not wait.
+ ///
+ /// # Errors
+ ///
+ /// [`crate::Error::MemoryBudgetExceeded`] when the memory limit has no room to seal the
+ /// pending vectors of one partition: a call holds one block's bytes (256 KiB less 64) for its
+ /// whole run, the keys of the pending vectors it collected (at most 4,096, each key's bytes
+ /// plus 24), the partition's record, and one row with its vector at a time;
+ /// [`crate::Error::StoreIo`] or
+ /// [`crate::Error::StoreCorrupted`] when the store cannot be read or written;
+ /// [`crate::Error::DatabaseClosed`] on a handle that was closed. In each case nothing was
+ /// sealed and every vector is still found by a search.
+ pub fn run_maintenance_cycle(&self) -> Result {
+ let open = self.opened()?;
+ let held = locked(&open.committing);
+ let mut staged = open.begin()?;
+ let report = upkeep::cycle(&mut staged.changes, &staged.catalog, &open.budget)?;
+ open.commit(&held, staged)?;
+ Ok(report)
+ }
+
/// Lets go of the store, so that the next writer can open it. Every commit that returned is
/// already on disk, and closing writes nothing. While a call is still running on another
/// thread, the store is let go when that call returns. Closing a handle that was closed
@@ -514,6 +578,7 @@ impl Transaction<'_> {
let result = open.run(&plan, params, staged);
if result.is_err() {
staged.changes.rewind(mark);
+ staged.partitions.forget();
}
result
}
diff --git a/v2/contextdb/src/lib.rs b/v2/contextdb/src/lib.rs
index 77df6f5a..0492c21a 100644
--- a/v2/contextdb/src/lib.rs
+++ b/v2/contextdb/src/lib.rs
@@ -9,6 +9,8 @@ mod sql;
mod store;
mod system;
mod types;
+mod upkeep;
+mod vector;
mod write;
pub use database::{Database, DatabaseOpenOptions, Transaction};
@@ -16,7 +18,11 @@ pub use query::QueryResult;
pub use read::{ReadSession, ReadSessionOptions};
pub use store::{StorageBackend, WrapStorage};
pub use system::Clock;
-pub use types::{ColumnType, Error, Result, TxId, Uuid, Value};
+pub use types::{
+ ColumnType, Error, Result, TxId, Uuid, Value, VectorIndexRef, VectorPartitionDeclarationIssue,
+ VectorPolicyDeclarationIssue,
+};
+pub use upkeep::{MaintenanceReport, VectorMaintenanceReport};
/// The JSON package whose `Value` a [`Value::Json`] holds, so a host needs no copy of its own.
pub use serde_json;
diff --git a/v2/contextdb/src/memory/mod.rs b/v2/contextdb/src/memory/mod.rs
index 719d1c54..d8e87225 100644
--- a/v2/contextdb/src/memory/mod.rs
+++ b/v2/contextdb/src/memory/mod.rs
@@ -28,9 +28,10 @@ const NO_CEILING: u64 = u64::MAX;
/// names one. With no limit there is no ceiling, and what is reserved is still counted.
///
/// Three kinds of memory are counted against it: small fixed things, covered by an allowance
-/// of 1 MiB set aside in [`Budget::new`]; the page cache of 8 MiB, reserved once when the store
-/// is opened; and everything that grows with data, which must hold a [`Reservation`]. A limit
-/// below those 9 MiB therefore cannot open any store: the open is refused with
+/// of 1 MiB set aside in [`Budget::new`]; the page cache, reserved once when the store is
+/// opened; and everything that grows with data, which must hold a [`Reservation`]. The page
+/// cache is a quarter of the limit and never less than 8 MiB, which is also its size with no
+/// limit. A limit below 9 MiB therefore cannot open any store: the open is refused with
/// `MemoryBudgetExceeded`.
#[derive(Clone)]
pub(crate) struct Budget {
diff --git a/v2/contextdb/src/query/access.rs b/v2/contextdb/src/query/access.rs
index bfe6428c..9410e380 100644
--- a/v2/contextdb/src/query/access.rs
+++ b/v2/contextdb/src/query/access.rs
@@ -1,15 +1,16 @@
-//! Index choice: which rows of a table a statement has to read, and reading them.
+//! Index choice: which rows of a table a statement has to read, reading them, and working out
+//! the select list for a row that was read.
use std::collections::HashMap;
use std::ops::Bound;
-use super::expression::{column_index, eval};
+use super::expression::{column_index, eval, eval_found, most_bytes, reads};
use super::{heap_bytes, row_overhead};
use crate::memory::Reservation;
use crate::schema::Table;
use crate::sql::Expr;
use crate::sql::ast::{BinOp, SortDirection as Asked};
-use crate::sql::plan::SortKey;
+use crate::sql::plan::{ProjectColumn, SortKey};
use crate::store::{Cell, IndexId, Key, Read, Row};
use crate::types::{ColumnType, Result, SortDirection, Value};
@@ -65,7 +66,7 @@ fn starting_after(key: Key) -> Bound {
/// The value as a key of a column of this type holds it, when there is one such value: only
/// then is looking the value up the same as comparing every row with it.
-fn as_key(column: ColumnType, value: Value) -> Option {
+pub(super) fn as_key(column: ColumnType, value: Value) -> Option {
match (column, value) {
(ColumnType::Integer, value @ Value::Int64(_))
| (ColumnType::Real, value @ Value::Float64(_))
@@ -300,16 +301,9 @@ impl<'a> Source<'a> {
let mut examined = 0;
let mut offer = |key: Key, row: Row| {
examined += 1;
- let before = reservation.bytes();
match self.values(row, reservation)? {
- Some(values) => {
- let bytes = reservation.bytes() - before;
- each(key, values, bytes, reservation)
- }
- None => {
- reservation.shrink(reservation.bytes() - before);
- Ok(true)
- }
+ Some((values, bytes)) => each(key, values, bytes, reservation),
+ None => Ok(true),
}
};
let table = self.table.id;
@@ -341,9 +335,83 @@ impl<'a> Source<'a> {
Ok(examined)
}
- /// The values of a stored row if it passes the filter, charged to `reservation`. A column
- /// the statement does not read is left as NULL.
- fn values(&self, row: Row, reservation: &mut Reservation) -> Result>> {
+ /// The row with this key if it is there and passes the filter, as [`Source::walk`] hands a
+ /// row over: its values, and the bytes of `reservation` it holds.
+ pub(super) fn row(
+ &self,
+ key: &Key,
+ reservation: &mut Reservation,
+ ) -> Result , u64)>> {
+ match self.read.row(self.table.id, key)? {
+ Some(row) => self.values(row, reservation),
+ None => Ok(None),
+ }
+ }
+
+ /// For each item of a select list, the column whose value it moves out of a row: the column
+ /// it names, when no other item of the list reads that column. Any other item copies what
+ /// it reads. Empty for `SELECT *`, which hands the row over as it is.
+ pub(super) fn moved(&self, columns: Option<&[ProjectColumn]>) -> Result>> {
+ let columns = columns.unwrap_or_default();
+ let mut readers = vec![0; self.table.meta.columns.len()];
+ for column in columns {
+ let mut read = vec![false; readers.len()];
+ reads(&column.expr, self.table, self.params, &mut read)?;
+ for (readers, read) in readers.iter_mut().zip(read) {
+ *readers += usize::from(read);
+ }
+ }
+ let alone = |column: &ProjectColumn| match &column.expr {
+ Expr::Column(named) => column_index(self.table, named).ok(),
+ _ => None,
+ };
+ let moved = columns.iter().map(alone);
+ Ok(moved.map(|at| at.filter(|at| readers[*at] == 1)).collect())
+ }
+
+ /// The select list worked out for one row this source handed over, or the row itself for
+ /// `SELECT *`. `moved` is [`Source::moved`] of the list, and `distance` is the row's distance
+ /// when a similarity search found it. The memory of the values is asked for before they
+ /// are made, and the row's bytes are given back once they are. A value that is moved is
+ /// held once: what it takes passes from the row to the result and is not asked for again.
+ pub(super) fn output(
+ &self,
+ columns: Option<&[ProjectColumn]>,
+ moved: &[Option],
+ mut row: Vec,
+ bytes: u64,
+ distance: Option,
+ reservation: &mut Reservation,
+ ) -> Result> {
+ let Some(columns) = columns else {
+ return Ok(row);
+ };
+ let most = columns
+ .iter()
+ .map(|column| most_bytes(&column.expr, self.table, &row, self.params));
+ let passed = moved.iter().flatten().map(|at| heap_bytes(&row[*at]));
+ let passed = passed.sum::().min(bytes);
+ reservation.grow(row_overhead(columns.len()) + most.sum::() - passed)?;
+ let values = columns
+ .iter()
+ .zip(moved)
+ .map(|(column, moved)| match moved {
+ Some(at) => Ok(std::mem::replace(&mut row[*at], Value::Null)),
+ None => {
+ let (params, now) = (self.params, self.now);
+ eval_found(&column.expr, self.table, &row, distance, params, now)
+ }
+ })
+ .collect::>>()?;
+ reservation.shrink(bytes - passed);
+ Ok(values)
+ }
+
+ /// The values of a stored row if it passes the filter, and the bytes of `reservation` they
+ /// hold; a row that does not pass holds none. A column the statement does not read is left
+ /// as NULL.
+ fn values(&self, row: Row, reservation: &mut Reservation) -> Result, u64)>> {
+ let before = reservation.bytes();
let mut cells = row.cells;
reservation.grow(row_overhead(cells.len()))?;
let mut values = vec![Value::Null; cells.len()];
@@ -351,10 +419,11 @@ impl<'a> Source<'a> {
if let Some(filter) = self.filter
&& eval(filter, self.table, &values, self.params, self.now)? != Value::Bool(true)
{
+ reservation.shrink(reservation.bytes() - before);
return Ok(None);
}
self.take(&mut cells, &mut values, &self.rest, reservation)?;
- Ok(Some(values))
+ Ok(Some((values, reservation.bytes() - before)))
}
/// Moves the marked cells into `values`, loading a value kept beside its row. The memory
diff --git a/v2/contextdb/src/query/expression.rs b/v2/contextdb/src/query/expression.rs
index c0a95719..a22d7dff 100644
--- a/v2/contextdb/src/query/expression.rs
+++ b/v2/contextdb/src/query/expression.rs
@@ -15,6 +15,9 @@ use crate::types::{Error, Result, Uuid, Value};
/// one time in milliseconds, which `NOW()` answers with. A comparison with NULL, and one between
/// values of types that do not compare, is unknown: it gives NULL, and a filter keeps a row
/// only for TRUE.
+///
+/// A distance, `column <=> vector`, has no value here: only a similarity search gives a row one
+/// ([`eval_found`]), so it is refused by name.
pub(crate) fn eval(
expr: &Expr,
table: &Table,
@@ -22,7 +25,20 @@ pub(crate) fn eval(
params: &HashMap,
now: i64,
) -> Result {
- let value = |expr: &Expr| eval(expr, table, row, params, now);
+ eval_found(expr, table, row, None, params, now)
+}
+
+/// [`eval`] for a row a similarity search found. `distance` is the distance the search worked
+/// out for the row, and every `<=>` in the expression is that number as it is.
+pub(super) fn eval_found(
+ expr: &Expr,
+ table: &Table,
+ row: &[Value],
+ distance: Option,
+ params: &HashMap,
+ now: i64,
+) -> Result {
+ let value = |expr: &Expr| eval_found(expr, table, row, distance, params, now);
Ok(match expr {
Expr::Column(column) => {
let at = column_index(table, column)?;
@@ -92,9 +108,70 @@ pub(crate) fn eval(
}
_ => return Err(refused(name)),
},
+ Expr::InList {
+ expr,
+ list,
+ negated,
+ } => {
+ // TRUE when an item equals the left side, else unknown when any equality was.
+ let left = side(expr, value)?;
+ let mut found = Some(false);
+ for item in list {
+ let item = side(item, value)?;
+ if item.len() != left.len() {
+ return Err(Error::PlanError(
+ "a row value beside IN needs as many values as its partner".to_owned(),
+ ));
+ }
+ match rows_equal(&left, &item) {
+ Some(true) => {
+ found = Some(true);
+ break;
+ }
+ Some(false) => {}
+ None => found = None,
+ }
+ }
+ found.map_or(Value::Null, |found| Value::Bool(found != *negated))
+ }
+ Expr::Row(_) => {
+ return Err(Error::PlanError(
+ "a row value, (a, b), stands only on either side of IN".to_owned(),
+ ));
+ }
+ Expr::Distance { .. } => Value::Float64(distance.ok_or_else(|| {
+ Error::PlanError(
+ "<=> has a value only for the rows of a similarity search, SELECT ... ORDER BY \
+ column <=> vector LIMIT k, and in that statement's select list"
+ .to_owned(),
+ )
+ })?),
})
}
+/// The values of one side of IN: those of a row value, or the one value of anything else.
+fn side(expr: &Expr, value: impl Fn(&Expr) -> Result) -> Result> {
+ match expr {
+ Expr::Row(items) => items.iter().map(value).collect(),
+ one => Ok(vec![value(one)?]),
+ }
+}
+
+/// Whether two row values of one length are equal, as standard SQL has it: FALSE as soon as one
+/// pair of columns is definitely different, whatever a NULL beside it holds; TRUE when every
+/// pair is equal; otherwise unknown. One value is a row of one column.
+fn rows_equal(left: &[Value], right: &[Value]) -> Option {
+ let mut equal = Some(true);
+ for (left, right) in left.iter().zip(right) {
+ match compare(left, right) {
+ Some(Ordering::Equal) => {}
+ Some(_) => return Some(false),
+ None => equal = None,
+ }
+ }
+ equal
+}
+
/// The most memory the value of an expression can take outside its own cell. It is known
/// before the expression is worked out, so the memory is asked for before the value is made.
pub(super) fn most_bytes(
@@ -123,8 +200,10 @@ pub(super) fn most_bytes(
/// Marks in `read` each column the expression reads.
///
/// Refuses, before any row is read, what [`eval`] would refuse at the first row: a column the
-/// table does not have, a `$name` nothing is bound to, and a form this version does not work
-/// out. A statement is therefore refused or not whatever rows its table holds.
+/// table does not have, a `$name` nothing is bound to, and a function this version does not
+/// work out. A statement is therefore refused or not whatever rows its table holds. A distance
+/// and a row value are walked and not judged: the planner refuses the ones a SELECT cannot
+/// hold, and [`eval`] any other when it meets it.
pub(super) fn reads(
expr: &Expr,
table: &Table,
@@ -154,6 +233,22 @@ pub(super) fn reads(
reads(arg, table, params, read)?;
}
}
+ // The search gives a row's distance: the column on the left is looked up, not read.
+ Expr::Distance { left, right } => {
+ reads(left, table, params, &mut read.to_vec())?;
+ reads(right, table, params, read)?;
+ }
+ Expr::Row(items) => {
+ for item in items {
+ reads(item, table, params, read)?;
+ }
+ }
+ Expr::InList { expr, list, .. } => {
+ reads(expr, table, params, read)?;
+ for item in list {
+ reads(item, table, params, read)?;
+ }
+ }
}
Ok(())
}
diff --git a/v2/contextdb/src/query/mod.rs b/v2/contextdb/src/query/mod.rs
index 414a7587..ad87e195 100644
--- a/v2/contextdb/src/query/mod.rs
+++ b/v2/contextdb/src/query/mod.rs
@@ -1,11 +1,13 @@
//! query: answers a SELECT from one table: which rows to read and through which index, the
-//! filter, the select list, COUNT, SUM, MIN and MAX, ORDER BY and LIMIT. It holds the one
-//! expression evaluator, and counts exactly the rows it reads.
+//! filter, the select list, COUNT, SUM, MIN and MAX, ORDER BY and LIMIT, and a similarity
+//! search, whose rows the vector part finds. It holds the one expression evaluator, and counts
+//! exactly the rows it reads.
//!
//! It never changes anything.
mod access;
mod expression;
+mod search;
#[cfg(test)]
mod tests;
@@ -14,6 +16,7 @@ use std::collections::HashMap;
use std::fmt;
pub(crate) use expression::eval;
+pub(crate) use search::vector_from_text;
use crate::memory::{Budget, Reservation};
use crate::schema::{Catalog, Table};
@@ -129,6 +132,9 @@ pub(crate) fn memory_limit_shown(budget: &Budget, opened_under: Option) ->
/// table is answered from the kept count. Loads a value kept beside its row only when a selected
/// or filtered column needs it. The result, and any sort or grouping on the way to it, grows
/// through reservations on `budget` and refuses with `MemoryBudgetExceeded` before it grows.
+///
+/// A similarity search, `ORDER BY column <=> vector LIMIT k`, is answered by the vector part
+/// from the rows this part lets it see, and returns the nearest rows first.
pub(crate) fn run(
plan: &Plan,
params: &HashMap,
@@ -137,6 +143,9 @@ pub(crate) fn run(
budget: &Budget,
now: i64,
) -> Result {
+ if let Some(found) = search::run(plan, params, catalog, read, budget, now)? {
+ return Ok(found);
+ }
let select = Select::of(plan)?;
let table = catalog
.table(select.table)
@@ -152,12 +161,8 @@ pub(crate) fn run(
reads(&key.expr, table, params, &mut wanted)?;
}
let mut aggregates = Vec::new();
- let columns = match select.columns {
- None => {
- wanted.fill(true);
- let declared = table.meta.columns.iter();
- declared.map(|column| column.name.clone()).collect()
- }
+ match select.columns {
+ None => wanted.fill(true),
Some(columns) => {
for column in columns {
let Some(aggregate) = Aggregate::of(&column.expr)? else {
@@ -173,19 +178,9 @@ pub(crate) fn run(
if !aggregates.is_empty() && aggregates.len() < columns.len() {
return Err(unsupported("a column beside an aggregate (GROUP BY)"));
}
- columns
- .iter()
- .map(|column| match (&column.alias, &column.expr) {
- (Some(alias), _) => alias.clone(),
- (None, Expr::Column(column)) => column.column.clone(),
- (None, Expr::FunctionCall { name, .. }) if !aggregates.is_empty() => {
- name.clone()
- }
- (None, _) => "expr".to_owned(),
- })
- .collect()
}
- };
+ }
+ let columns = names(table, select.columns, !aggregates.is_empty());
let source = Source::new(table, select.filter, params, now, read, filtered, &wanted);
let limit = select.limit.map_or(usize::MAX, |limit| {
@@ -217,24 +212,11 @@ pub(crate) fn run(
rows.push(aggregates.into_iter().map(Aggregate::answer).collect());
}
} else if limit > 0 {
- // The select list worked out for one row. The memory of its values is asked for before
- // they are made, and the row's bytes are given back once they are.
- let output =
- |row: Vec, bytes: u64, reservation: &mut Reservation| -> Result> {
- let Some(columns) = select.columns else {
- return Ok(row);
- };
- let most = columns
- .iter()
- .map(|column| most_bytes(&column.expr, table, &row, params));
- reservation.grow(row_overhead(columns.len()) + most.sum::())?;
- let values = columns
- .iter()
- .map(|column| eval(&column.expr, table, &row, params, now))
- .collect::>>()?;
- reservation.shrink(bytes);
- Ok(values)
- };
+ // The select list worked out for one row.
+ let moved = source.moved(select.columns)?;
+ let output = |row: Vec, bytes: u64, reservation: &mut Reservation| {
+ source.output(select.columns, &moved, row, bytes, None, reservation)
+ };
let (path, in_order) = source.choose(select.order)?;
if in_order {
examined = source.walk(&path, &mut reservation, |_, row, bytes, reservation| {
@@ -330,6 +312,25 @@ pub(crate) fn matching(
Ok((found, reservation))
}
+/// What a result calls its columns: the table's own names for `SELECT *` (`None`), otherwise
+/// each item's `AS` name, a bare column's name, an aggregate's name, and `expr` for any other
+/// expression.
+fn names(table: &Table, columns: Option<&[ProjectColumn]>, aggregates: bool) -> Vec {
+ let Some(columns) = columns else {
+ let declared = table.meta.columns.iter();
+ return declared.map(|column| column.name.clone()).collect();
+ };
+ columns
+ .iter()
+ .map(|column| match (&column.alias, &column.expr) {
+ (Some(alias), _) => alias.clone(),
+ (None, Expr::Column(column)) => column.column.clone(),
+ (None, Expr::FunctionCall { name, .. }) if aggregates => name.clone(),
+ (None, _) => "expr".to_owned(),
+ })
+ .collect()
+}
+
/// One planned SELECT, as the planner nests it: LIMIT over the select list over ORDER BY over
/// one table with its filter.
struct Select<'a> {
diff --git a/v2/contextdb/src/query/search.rs b/v2/contextdb/src/query/search.rs
new file mode 100644
index 00000000..757c304c
--- /dev/null
+++ b/v2/contextdb/src/query/search.rs
@@ -0,0 +1,300 @@
+//! A similarity search: the rows of a table that pass the statement's filter and lie nearest a
+//! vector. This part settles what is searched for, which partitions the filter selects and
+//! which rows may be seen, and works out each row's distance from its stored vector; the vector
+//! part finds the rows.
+
+use std::collections::HashMap;
+
+use super::access::{Source, as_key};
+use super::expression::{column_index, eval, reads};
+use super::{QueryResult, names};
+use crate::memory::Budget;
+use crate::schema::{Catalog, Table};
+use crate::sql::ast::{BinOp, ColumnRef};
+use crate::sql::{Expr, Plan};
+use crate::store::{Key, Read};
+use crate::types::{ColumnType, Error, Result, Value, VectorIndexRef, VectorSearchMode};
+use crate::vector::{self, Nearest};
+
+/// The most key tuples a filter may name; past that the search reads every partition.
+const MOST_TUPLES: usize = 4096;
+
+/// Answers a planned similarity search, a `VectorSearch` under the select list unless the
+/// statement is `SELECT *`: the `k` rows that pass the filter and have a vector nearest the one
+/// searched for, nearest first, rows at one distance in key order. `None` for any other plan.
+///
+/// The column, the mode, the vector and every name of the statement are settled before a row
+/// is read, so a refusal does not depend on what the table holds. Then the vector part searches
+/// the partitions the filter selects and asks, row by row, for the distance: the row is read,
+/// the whole filter applied, and the distance worked out from the row's stored vector. Each row
+/// of the answer is read once more for the select list, in which every `<=>` is the distance
+/// the row was ordered by. Every one of those reads is counted.
+pub(super) fn run(
+ plan: &Plan,
+ params: &HashMap,
+ catalog: &Catalog,
+ read: &dyn Read,
+ budget: &Budget,
+ now: i64,
+) -> Result> {
+ let (plan, list) = match plan {
+ Plan::Project { input, columns } => (&**input, Some(columns.as_slice())),
+ plan => (plan, None),
+ };
+ let Plan::VectorSearch {
+ table,
+ column,
+ query,
+ k,
+ filter,
+ mode,
+ } = plan
+ else {
+ return Ok(None);
+ };
+ let filter = filter.as_ref();
+ let table = catalog
+ .table(table)
+ .ok_or_else(|| Error::TableNotFound(table.clone()))?;
+ let named = ColumnRef {
+ table: None,
+ column: column.clone(),
+ };
+ let at = column_index(table, &named)?;
+ let index = VectorIndexRef {
+ table: table.name.clone(),
+ column: named.column,
+ };
+ let Some(vector) = table.vector(column) else {
+ return Err(Error::UnknownVectorIndex { index });
+ };
+ if mode.unwrap_or_else(|| vector.mode()) == VectorSearchMode::Indexed {
+ return Err(Error::VectorIndexedRouteUnavailable { index });
+ }
+ let near = match eval(query, table, &[], params, now)? {
+ Value::Vector(vector) => Some(vector),
+ Value::Text(text) => vector_from_text(&text),
+ _ => None,
+ };
+ let Some(near) = near else {
+ return Err(Error::PlanError(
+ "the right side of <=> is not a vector: write [1.0, 0.0], its text '[1, 0]', or a \
+ $name bound to a vector"
+ .to_owned(),
+ ));
+ };
+ let actual = near.len();
+ if let ColumnType::Vector(expected) = table.meta.columns[at].column_type
+ && expected != actual
+ {
+ return Err(Error::VectorIndexDimensionMismatch {
+ index,
+ expected,
+ actual,
+ });
+ }
+ if let Some(position) = near.iter().position(|number| !number.is_finite()) {
+ return Err(Error::InvalidVectorValue { index, position });
+ }
+
+ let width = table.meta.columns.len();
+ let (mut filtered, mut wanted) = (vec![false; width], vec![false; width]);
+ if let Some(filter) = filter {
+ reads(filter, table, params, &mut filtered)?;
+ }
+ match list {
+ None => wanted.fill(true),
+ Some(list) => {
+ for column in list {
+ reads(&column.expr, table, params, &mut wanted)?;
+ }
+ }
+ }
+ let columns = names(table, list, false);
+ let k = usize::try_from(*k).unwrap_or(usize::MAX);
+ if k == 0 {
+ return Ok(Some(QueryResult::from_rows(columns, Vec::new())));
+ }
+
+ let key = vector.partition_key.iter();
+ let key = key.filter_map(|name| table.meta.columns.iter().position(|c| c.name == *name));
+ let key = key.collect();
+ let scope = Scope {
+ table,
+ key,
+ params,
+ now,
+ }
+ .keys(filter)?;
+ // A row the vector part asks about is read for the filter and its vector alone; a row of
+ // the answer for what the select list reads, so a large value is loaded only then.
+ let mut its_vector = vec![false; width];
+ its_vector[at] = true;
+ let asked = Source::new(table, filter, params, now, read, filtered, &its_vector);
+ let returned = Source::new(table, None, params, now, read, vec![false; width], &wanted);
+ // What the vector part holds is given back once the rows are built; the rows stay counted
+ // with the result.
+ let mut searching = budget.reserve(0, "query")?;
+ let mut reservation = budget.reserve(0, "query")?;
+ let mut examined = 0;
+
+ let nearest = Nearest {
+ table: table.id,
+ vector: vector.id,
+ scope: &scope,
+ query: &near,
+ k,
+ };
+ let mut distance_of = |key: &Key| -> Result > {
+ examined += 1;
+ let Some((row, bytes)) = asked.row(key, &mut reservation)? else {
+ return Ok(None);
+ };
+ let distance = match &row[at] {
+ Value::Vector(stored) => Some(vector::distance(stored, &near)),
+ _ => None,
+ };
+ reservation.shrink(bytes);
+ Ok(distance)
+ };
+ let hits = vector::search(&nearest, read, &mut searching, &mut distance_of)?;
+
+ let moved = returned.moved(list)?;
+ let mut rows = Vec::new();
+ for hit in &hits {
+ examined += 1;
+ if let Some((row, bytes)) = returned.row(&hit.key, &mut reservation)? {
+ let distance = Some(hit.distance);
+ rows.push(returned.output(list, &moved, row, bytes, distance, &mut reservation)?);
+ }
+ }
+ Ok(Some(QueryResult {
+ columns,
+ rows,
+ rows_affected: 0,
+ rows_examined: examined,
+ _reservation: Some(reservation),
+ }))
+}
+
+/// The values a filter fixes for the PARTITION_KEY columns, in their order; `None` for a
+/// column it leaves open.
+type Tuple = Vec >;
+
+/// Reads a filter for the partitions it selects.
+struct Scope<'a> {
+ table: &'a Table,
+ /// Where each PARTITION_KEY column is in a row, in declared order.
+ key: Vec,
+ params: &'a HashMap,
+ now: i64,
+}
+
+impl Scope<'_> {
+ /// The partitions to search: for each key tuple the filter names, the key of the leading
+ /// key columns it fixes, which every partition with those values begins with. Empty for
+ /// every partition: no filter, a tuple that fixes no leading column, or more than 4,096
+ /// tuples. It may select too much and never too little: the whole filter is still applied
+ /// to every row.
+ fn keys(&self, filter: Option<&Expr>) -> Result> {
+ let (Some(filter), false) = (filter, self.key.is_empty()) else {
+ return Ok(Vec::new());
+ };
+ let tuples = self.said(filter)?;
+ if tuples.len() > MOST_TUPLES {
+ return Ok(Vec::new());
+ }
+ let mut keys = Vec::with_capacity(tuples.len());
+ for tuple in tuples {
+ let leading: Vec = tuple.into_iter().map_while(|value| value).collect();
+ if leading.is_empty() {
+ return Ok(Vec::new());
+ }
+ keys.push(Key::from_values(&leading, &[])?);
+ }
+ // A partition is named once, and not again under a key that begins as another does.
+ keys.sort();
+ keys.dedup_by(|later, kept| later.starts_with(kept));
+ Ok(keys)
+ }
+
+ /// What a filter says about the key columns: a row passes it only under one of these
+ /// tuples. `AND` joins what its two sides say, `OR` gives the tuples of both, and
+ /// `column = value`, `column IN (values)` and `(columns) IN (row values)` say which values;
+ /// anything else leaves every column open.
+ fn said(&self, filter: &Expr) -> Result> {
+ let open = || vec![vec![None; self.key.len()]];
+ Ok(match filter {
+ Expr::BinaryOp { left, op, right } if *op == BinOp::And => {
+ let (left, right) = (self.said(left)?, self.said(right)?);
+ if left.len().saturating_mul(right.len()) > MOST_TUPLES {
+ return Ok(open());
+ }
+ let join = |left: &Tuple, right: &Tuple| -> Tuple {
+ let both = left.iter().zip(right);
+ both.map(|(left, right)| left.clone().or_else(|| right.clone()))
+ .collect()
+ };
+ let pairs = left.iter().flat_map(|l| right.iter().map(move |r| (l, r)));
+ pairs.map(|(left, right)| join(left, right)).collect()
+ }
+ Expr::BinaryOp { left, op, right } if *op == BinOp::Or => {
+ [self.said(left)?, self.said(right)?].concat()
+ }
+ Expr::BinaryOp { left, op, right } if *op == BinOp::Eq => {
+ let (column, value) = match **left {
+ Expr::Column(_) => (left, right),
+ _ => (right, left),
+ };
+ vec![self.tuple(sides(column), sides(value))?]
+ }
+ Expr::InList {
+ expr,
+ list,
+ negated: false,
+ } => {
+ let tuples = list.iter();
+ let tuples = tuples.map(|item| self.tuple(sides(expr), sides(item)));
+ tuples.collect::>()?
+ }
+ _ => open(),
+ })
+ }
+
+ /// The tuple `columns = values` fixes, pair by pair. A pair counts when it is a key column
+ /// beside a literal or a `$name` whose value a key of the column's type holds; any other
+ /// pair leaves its column open.
+ fn tuple(&self, columns: &[Expr], values: &[Expr]) -> Result {
+ let mut tuple = vec![None; self.key.len()];
+ for pair in columns.iter().zip(values) {
+ let (Expr::Column(column), value @ (Expr::Literal(_) | Expr::Parameter(_))) = pair
+ else {
+ continue;
+ };
+ let at = column_index(self.table, column)?;
+ if let Some(place) = self.key.iter().position(|key| *key == at) {
+ let value = eval(value, self.table, &[], self.params, self.now)?;
+ tuple[place] = as_key(self.table.meta.columns[at].column_type, value);
+ }
+ }
+ Ok(tuple)
+ }
+}
+
+/// The expressions of one side of `=` or IN: those of a row value, or the one expression.
+fn sides(expr: &Expr) -> &[Expr] {
+ match expr {
+ Expr::Row(items) => items,
+ one => std::slice::from_ref(one),
+ }
+}
+
+/// Reads the text spelling of a vector, `[0.1, 0.2, 0.3]`: the one reader of it, for a value
+/// written into a VECTOR column and for the vector of a search.
+pub(crate) fn vector_from_text(text: &str) -> Option> {
+ let inside = text.trim().strip_prefix('[')?.strip_suffix(']')?;
+ inside
+ .split(',')
+ .map(|number| number.trim().parse().ok())
+ .collect()
+}
diff --git a/v2/contextdb/src/query/tests.rs b/v2/contextdb/src/query/tests.rs
index 19290c4c..41ff41fd 100644
--- a/v2/contextdb/src/query/tests.rs
+++ b/v2/contextdb/src/query/tests.rs
@@ -19,6 +19,7 @@ fn worked_out(expr: &Expr) -> Value {
indexes: Vec::new(),
},
index_ids: Vec::new(),
+ vectors: Vec::new(),
};
eval(expr, &table, &[], &HashMap::new(), 0).expect("an expression of literals")
}
diff --git a/v2/contextdb/src/read/mod.rs b/v2/contextdb/src/read/mod.rs
index 0cc5d7f3..6b751556 100644
--- a/v2/contextdb/src/read/mod.rs
+++ b/v2/contextdb/src/read/mod.rs
@@ -26,9 +26,10 @@ pub struct ReadSessionOptions {
/// The most memory, in bytes, the session may use for its page cache and for the results
/// it has returned and that are still alive. `None`, the default, is no ceiling: a reader
/// does not take the limit the store remembers for its writer, so a reader that needs a
- /// cap sets it here. Every open sets aside 8 MiB for the page cache and 1 MiB for the
- /// small fixed needs of a statement, so a limit under 9 MiB is refused at open with
- /// [`crate::Error::MemoryBudgetExceeded`].
+ /// cap sets it here. Every open sets aside its page cache and 1 MiB for the small fixed
+ /// needs of a statement. The page cache is a quarter of the limit and never less than
+ /// 8 MiB, so a limit under 9 MiB is refused at open with
+ /// [`crate::Error::MemoryBudgetExceeded`]; with no limit the page cache is 8 MiB.
pub memory_limit: Option,
/// Where the session gets the time that `NOW()` in a statement answers with. `None`, the
/// default, is the machine's clock.
@@ -85,7 +86,7 @@ impl ReadSession {
/// # Errors
///
/// Those of [`ReadSession::open`], and [`crate::Error::MemoryBudgetExceeded`] when
- /// [`ReadSessionOptions::memory_limit`] is under the 9 MiB every open sets aside.
+ /// [`ReadSessionOptions::memory_limit`] is under 9 MiB, the least an open sets aside.
pub fn open_with_options(
path: impl AsRef,
options: ReadSessionOptions,
diff --git a/v2/contextdb/src/schema/check.rs b/v2/contextdb/src/schema/check.rs
index 4fff4efd..b48abbac 100644
--- a/v2/contextdb/src/schema/check.rs
+++ b/v2/contextdb/src/schema/check.rs
@@ -1,20 +1,16 @@
//! Checking a declaration: what CREATE TABLE and CREATE INDEX may say, and the table each gives.
-use super::{Catalog, RESERVED_PREFIXES, Table};
-use crate::sql::ast::{self, DataType, Expr, Literal, UnaryOp};
+use super::{Catalog, RESERVED_PREFIXES, Table, VectorColumn};
+use crate::sql::ast::{self, DataType, Expr, Literal};
+use crate::sql::not_supported;
use crate::sql::plan::{CreateIndexPlan, CreateTablePlan};
-use crate::store::{IndexId, TableId};
+use crate::store::{IndexId, TableId, VectorId};
use crate::types::{
- ColumnDef, ColumnDefault, ColumnType, Error, IndexDecl, Result, SortDirection, TableMeta, Value,
+ ColumnDef, ColumnDefault, ColumnType, Error, IndexDecl, Result, SortDirection, TableMeta,
+ Value, VectorIndexRef, VectorPartitionDeclarationIssue as PartitionIssue,
+ VectorPolicyDeclarationIssue,
};
-/// The refusal of a statement or clause that arrives with a later version.
-pub(super) fn not_supported(clause: &str) -> Error {
- Error::PlanError(format!(
- "{clause} is not supported in this version of the library"
- ))
-}
-
fn invalid(reason: String) -> Error {
Error::SchemaInvalid { reason }
}
@@ -76,6 +72,17 @@ pub(super) fn new_table(create: &CreateTablePlan, catalog: &Catalog) -> Result Result Result {
+ let index = VectorIndexRef {
+ table: table.to_owned(),
+ column: declared.name.clone(),
+ };
+ if declared.data_type == DataType::Vector(0) {
+ let issue = VectorPolicyDeclarationIssue::DimensionsNotPositive;
+ return Err(Error::InvalidVectorPolicyDeclaration { index, issue });
+ }
+ let max_partitions = partitions(declared, columns)
+ .map_err(|issue| Error::InvalidVectorPartitionDeclaration { index, issue })?;
+ Ok(VectorColumn {
+ id,
+ column: declared.name.clone(),
+ partition_key: declared.vector.partition_key.clone(),
+ max_partitions,
+ search_mode: declared.vector.search_mode,
})
}
+/// Checks the PARTITION_KEY and the MAX_PARTITIONS of a VECTOR column and gives the limit it
+/// declares, `None` when it declares none.
+fn partitions(
+ declared: &ast::ColumnDef,
+ columns: &[ColumnDef],
+) -> std::result::Result, PartitionIssue> {
+ let key = &declared.vector.partition_key;
+ for (at, name) in key.iter().enumerate() {
+ let column = columns.iter().find(|column| column.name == *name);
+ let issue = match column {
+ _ if key[..at].contains(name) => PartitionIssue::DuplicatePartitionKeyColumn,
+ _ if *name == declared.name => PartitionIssue::VectorColumnInPartitionKey,
+ None => PartitionIssue::UnknownPartitionKeyColumn,
+ // Two values of a key column are one partition or they are not: a REAL has two
+ // zeros and a NaN, and JSON and VECTOR values have no order to be keyed by.
+ Some(ColumnDef {
+ column_type: ColumnType::Real | ColumnType::Json | ColumnType::Vector(_),
+ ..
+ }) => PartitionIssue::UnsupportedPartitionKeyColumnType,
+ // The primary key column holds no NULL, written NOT NULL or not.
+ Some(column) if column.nullable && !column.primary_key => {
+ PartitionIssue::NullablePartitionKeyColumn
+ }
+ Some(_) => continue,
+ };
+ return Err(issue);
+ }
+ let Some(written) = &declared.vector.max_partitions else {
+ return Ok(None);
+ };
+ if key.is_empty() {
+ return Err(PartitionIssue::MaxPartitionsWithoutPartitionKey);
+ }
+ match written.parse::() {
+ Ok(limit) if limit > 0 => Ok(Some(limit)),
+ Ok(_) => Err(PartitionIssue::MaxPartitionsNotPositive),
+ Err(_) if written.starts_with('-') => Err(PartitionIssue::MaxPartitionsNotPositive),
+ Err(_) => Err(PartitionIssue::MaxPartitionsOutOfRange),
+ }
+}
+
/// The table a CREATE INDEX names, with the new index as its last.
pub(super) fn with_index(create: &CreateIndexPlan, catalog: &Catalog) -> Result {
let reserved = RESERVED_PREFIXES
@@ -206,14 +282,6 @@ pub(super) fn default(expr: &Expr) -> Option {
Expr::Literal(Literal::Integer(whole)) => Value::Int64(*whole),
Expr::Literal(Literal::Real(real)) => Value::Float64(*real),
Expr::Literal(Literal::Text(words)) => Value::Text(words.clone()),
- Expr::UnaryOp {
- op: UnaryOp::Neg,
- operand,
- } => match **operand {
- Expr::Literal(Literal::Integer(whole)) => Value::Int64(-whole),
- Expr::Literal(Literal::Real(real)) => Value::Float64(-real),
- _ => return None,
- },
_ => return None,
};
Some(ColumnDefault::Literal(value))
diff --git a/v2/contextdb/src/schema/mod.rs b/v2/contextdb/src/schema/mod.rs
index 0faace21..34260dda 100644
--- a/v2/contextdb/src/schema/mod.rs
+++ b/v2/contextdb/src/schema/mod.rs
@@ -3,7 +3,8 @@
//! It never changes a row.
//!
//! A table's declaration is kept as one schema record under the table's name: its number, its
-//! columns and rules as they were declared, and its indexes with their numbers.
+//! columns and rules as they were declared, its indexes with their numbers, and each VECTOR
+//! column's number and clauses.
//!
//! A table's indexes come in one order: the index behind the primary key (`__pk_`),
//! the index behind each UNIQUE column in column order (`__unique_`; the key column has
@@ -20,8 +21,8 @@ mod tests;
use std::collections::BTreeMap;
use crate::sql::Plan;
-use crate::store::{ChangeSet, IndexId, TableId, View};
-use crate::types::{Error, Result, TableMeta};
+use crate::store::{ChangeSet, IndexId, Read, TableId, VectorId, View};
+use crate::types::{ColumnType, Error, Result, TableMeta, VectorIndexRef, VectorSearchMode};
/// What the name of an index the engine makes for itself begins with. An index of CREATE INDEX
/// may not take one of these.
@@ -35,6 +36,58 @@ pub(crate) struct Table {
pub meta: TableMeta,
/// One id per entry of `meta.indexes`, in the same order.
pub index_ids: Vec,
+ /// One entry per VECTOR column, in column order.
+ pub vectors: Vec,
+}
+
+/// How many partitions a VECTOR column with a PARTITION_KEY may have when its declaration
+/// names no MAX_PARTITIONS.
+const DEFAULT_MAX_PARTITIONS: u32 = 256;
+
+/// What a table keeps about one of its VECTOR columns: its number in the store and the clauses
+/// it was declared with. The column's dimension is in its type.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct VectorColumn {
+ pub id: VectorId,
+ /// The column's name.
+ pub column: String,
+ /// The columns of PARTITION_KEY, in declared order; empty when none was declared.
+ pub partition_key: Vec,
+ /// MAX_PARTITIONS as declared; `None` when the declaration names none.
+ pub max_partitions: Option,
+ /// SEARCH_MODE as declared; `None` when the declaration names none.
+ pub search_mode: Option,
+}
+
+impl VectorColumn {
+ /// The most partitions the column may have: what was declared, 256 when a PARTITION_KEY
+ /// was declared with no MAX_PARTITIONS, and `None` for a column with no PARTITION_KEY,
+ /// which has its one partition and no limit to meet.
+ pub(crate) fn limit(&self) -> Option {
+ let partitioned = !self.partition_key.is_empty();
+ partitioned.then(|| self.max_partitions.unwrap_or(DEFAULT_MAX_PARTITIONS))
+ }
+
+ /// The mode a search of the column runs in when its statement has no USE VECTOR.
+ pub(crate) fn mode(&self) -> VectorSearchMode {
+ self.search_mode.unwrap_or_default()
+ }
+}
+
+impl Table {
+ /// What the table keeps about the VECTOR column of this name, `None` when it has no such
+ /// column or the column is of another type.
+ pub(crate) fn vector(&self, column: &str) -> Option<&VectorColumn> {
+ self.vectors.iter().find(|vector| vector.column == column)
+ }
+
+ /// The column as a refusal names it.
+ pub(crate) fn vector_ref(&self, vector: &VectorColumn) -> VectorIndexRef {
+ VectorIndexRef {
+ table: self.name.clone(),
+ column: vector.column.clone(),
+ }
+ }
}
/// Every table declared in a store at one moment.
@@ -49,6 +102,14 @@ impl Catalog {
let mut tables = BTreeMap::new();
for bytes in view.schema_records()? {
let table = record::decode(&bytes);
+ // No declaration names, as a VECTOR column, a column that is not one of its table.
+ let is_vector = |vector: &VectorColumn| {
+ let column = table.meta.columns.iter().find(|c| c.name == vector.column);
+ column.is_some_and(|c| matches!(c.column_type, ColumnType::Vector(_)))
+ };
+ if !table.vectors.iter().all(is_vector) {
+ return Err(view.damaged("a schema record's VECTOR column is not one"));
+ }
tables.insert(table.name.clone(), table);
}
Ok(Catalog { tables })
@@ -63,6 +124,14 @@ impl Catalog {
pub(crate) fn tables(&self) -> Vec<&Table> {
self.tables.values().collect()
}
+
+ /// The table that has the VECTOR column with this number, and what it keeps about it.
+ pub(crate) fn vector(&self, id: VectorId) -> Option<(&Table, &VectorColumn)> {
+ self.tables.values().find_map(|table| {
+ let vector = table.vectors.iter().find(|vector| vector.id == id)?;
+ Some((table, vector))
+ })
+ }
}
/// Applies one declaration (CREATE TABLE, CREATE INDEX, ALTER TABLE, DROP) to a change set.
@@ -88,7 +157,7 @@ pub(crate) fn declare(plan: &Plan, catalog: &Catalog, changes: &mut ChangeSet) -
});
}
Plan::CreateIndex(create) => check::with_index(create, catalog)?,
- _ => return Err(check::not_supported("this declaration")),
+ _ => return Err(crate::sql::not_supported("this declaration")),
};
changes.put_schema(&table.name, record::encode(&table))?;
tables.insert(table.name.clone(), table);
diff --git a/v2/contextdb/src/schema/record.rs b/v2/contextdb/src/schema/record.rs
index 0650e5cd..a74cf567 100644
--- a/v2/contextdb/src/schema/record.rs
+++ b/v2/contextdb/src/schema/record.rs
@@ -8,10 +8,11 @@
//! every page it reads. Where a byte says which of several kinds follows, the last kind is read
//! for any byte the earlier ones do not claim.
-use super::Table;
-use crate::store::{IndexId, TableId};
+use super::{Table, VectorColumn};
+use crate::store::{IndexId, TableId, VectorId};
use crate::types::{
ColumnDef, ColumnDefault, ColumnType, IndexDecl, SortDirection, TableMeta, Value,
+ VectorSearchMode,
};
/// The schema record of a table.
@@ -77,6 +78,23 @@ pub(super) fn encode(table: &Table) -> Vec {
out.push(u8::from(*direction == SortDirection::Desc));
}
}
+ number(&mut out, table.vectors.len());
+ for vector in &table.vectors {
+ number(&mut out, vector.id.0 as usize);
+ text(&mut out, &vector.column);
+ number(&mut out, vector.partition_key.len());
+ for column in &vector.partition_key {
+ text(&mut out, column);
+ }
+ // A declared MAX_PARTITIONS is never zero, so zero says that none was declared.
+ number(&mut out, vector.max_partitions.unwrap_or(0) as usize);
+ out.push(match vector.search_mode {
+ None => 0,
+ Some(VectorSearchMode::Auto) => 1,
+ Some(VectorSearchMode::Exact) => 2,
+ Some(VectorSearchMode::Indexed) => 3,
+ });
+ }
out
}
@@ -133,6 +151,18 @@ pub(super) fn decode(record: &[u8]) -> Table {
});
IndexDecl { name, columns }
});
+ let vectors = read.list(|read| VectorColumn {
+ id: VectorId(read.number() as u32),
+ column: read.text(),
+ partition_key: read.list(Reader::text),
+ max_partitions: Some(read.number() as u32).filter(|limit| *limit != 0),
+ search_mode: match read.byte() {
+ 0 => None,
+ 1 => Some(VectorSearchMode::Auto),
+ 2 => Some(VectorSearchMode::Exact),
+ _ => Some(VectorSearchMode::Indexed),
+ },
+ });
Table {
id,
name,
@@ -142,6 +172,7 @@ pub(super) fn decode(record: &[u8]) -> Table {
indexes,
},
index_ids,
+ vectors,
}
}
diff --git a/v2/contextdb/src/schema/render.rs b/v2/contextdb/src/schema/render.rs
index 0e0ed6bd..5920090f 100644
--- a/v2/contextdb/src/schema/render.rs
+++ b/v2/contextdb/src/schema/render.rs
@@ -1,14 +1,18 @@
//! Printing a declaration back as SQL.
use super::{RESERVED_PREFIXES, Table};
-use crate::types::{ColumnDef, ColumnDefault, ColumnType, SortDirection, Value};
+use crate::types::{ColumnDef, ColumnDefault, ColumnType, SortDirection, Value, VectorSearchMode};
/// A table's declaration as SQL: the CREATE TABLE, then one CREATE INDEX for each index the
/// author made, every statement ended by `;` and a new line. A clause left at its default is
/// not printed, and neither are the indexes the engine keeps behind a key or a UNIQUE rule.
pub(super) fn declaration(table: &Table) -> String {
let meta = &table.meta;
- let mut lines: Vec = meta.columns.iter().map(column).collect();
+ let mut lines: Vec = meta
+ .columns
+ .iter()
+ .map(|declared| column(table, declared))
+ .collect();
for rule in &meta.unique_constraints {
let columns: Vec = rule.iter().map(|column| name(column)).collect();
lines.push(format!("UNIQUE ({})", columns.join(", ")));
@@ -40,7 +44,7 @@ pub(super) fn declaration(table: &Table) -> String {
}
/// One column's line: its name, its type, and the clauses it was given.
-fn column(column: &ColumnDef) -> String {
+fn column(table: &Table, column: &ColumnDef) -> String {
let mut text = name(&column.name);
text.push(' ');
match column.column_type {
@@ -53,6 +57,23 @@ fn column(column: &ColumnDef) -> String {
ColumnType::Timestamp => text.push_str("TIMESTAMP"),
ColumnType::Vector(size) => text.push_str(&format!("VECTOR({size})")),
}
+ if let Some(vector) = table.vector(&column.name) {
+ // The limit on partitions refuses writes, so it is printed for every column with a
+ // PARTITION_KEY, whether or not it was written. SEARCH_MODE is printed only when it was.
+ if let Some(limit) = vector.limit() {
+ let key: Vec = vector.partition_key.iter().map(|key| name(key)).collect();
+ text.push_str(&format!(
+ " PARTITION_KEY ({}) MAX_PARTITIONS {limit}",
+ key.join(", ")
+ ));
+ }
+ text.push_str(match vector.search_mode {
+ None => "",
+ Some(VectorSearchMode::Auto) => " SEARCH_MODE AUTO",
+ Some(VectorSearchMode::Exact) => " SEARCH_MODE EXACT",
+ Some(VectorSearchMode::Indexed) => " SEARCH_MODE INDEXED",
+ });
+ }
if column.primary_key {
text.push_str(" PRIMARY KEY");
}
diff --git a/v2/contextdb/src/schema/tests.rs b/v2/contextdb/src/schema/tests.rs
index b648333b..2166feb4 100644
--- a/v2/contextdb/src/schema/tests.rs
+++ b/v2/contextdb/src/schema/tests.rs
@@ -1,7 +1,7 @@
use std::collections::BTreeMap;
use super::{Catalog, Table, check, record, render};
-use crate::sql::ast::{self, DataType, Expr, Literal, UnaryOp};
+use crate::sql::ast::{self, DataType, Expr, Literal};
use crate::sql::plan::{CreateIndexPlan, CreateTablePlan};
use crate::store::{IndexId, TableId};
use crate::types::{
@@ -85,6 +85,7 @@ fn notes() -> Table {
],
},
index_ids: [7, 8, 9, 12, 15].map(IndexId).to_vec(),
+ vectors: Vec::new(),
}
}
@@ -121,32 +122,49 @@ CREATE INDEX by_body ON notes (body);
);
}
-#[test]
-fn a_default_is_kept_in_the_type_the_author_wrote() {
- let literal = Expr::Literal;
- let minus = |literal| Expr::UnaryOp {
- op: UnaryOp::Neg,
- operand: Box::new(Expr::Literal(literal)),
+/// The DEFAULT of a column as the parser hands it over, for this text after the word.
+fn written_default(text: &str) -> Expr {
+ let statement = format!("CREATE TABLE t (c INTEGER DEFAULT {text})");
+ let parsed = match crate::sql::parse(&statement) {
+ Ok(ast::Statement::CreateTable(create)) => create,
+ other => panic!("{statement}\nwas read as {other:?}"),
};
- let now = Expr::FunctionCall {
- name: "now".to_owned(),
- args: Vec::new(),
+ let [column] = parsed.columns.as_slice() else {
+ panic!(
+ "{statement}\nwas read with {} columns",
+ parsed.columns.len()
+ );
};
- for (expr, value) in [
- (literal(Literal::Null), Value::Null),
- (literal(Literal::Bool(true)), Value::Bool(true)),
- (literal(Literal::Integer(3)), Value::Int64(3)),
- (literal(Literal::Real(0.0)), Value::Float64(0.0)),
- (literal(Literal::Text("it's".to_owned())), "it's".into()),
- (minus(Literal::Integer(1)), Value::Int64(-1)),
- (minus(Literal::Real(2.5)), Value::Float64(-2.5)),
+ column
+ .default
+ .clone()
+ .unwrap_or_else(|| panic!("{statement}\nwas read with no default"))
+}
+
+#[test]
+fn a_default_is_kept_in_the_type_the_author_wrote() {
+ for (text, value) in [
+ ("NULL", Value::Null),
+ ("TRUE", Value::Bool(true)),
+ ("3", Value::Int64(3)),
+ ("0.0", Value::Float64(0.0)),
+ ("'it''s'", "it's".into()),
+ ("-1", Value::Int64(-1)),
+ ("-2.5", Value::Float64(-2.5)),
] {
- assert_eq!(check::default(&expr), Some(ColumnDefault::Literal(value)));
+ assert_eq!(
+ check::default(&written_default(text)),
+ Some(ColumnDefault::Literal(value)),
+ "DEFAULT {text}"
+ );
}
- assert_eq!(check::default(&now), Some(ColumnDefault::Now));
+ assert_eq!(
+ check::default(&written_default("NOW()")),
+ Some(ColumnDefault::Now)
+ );
// Anything that is not a literal or NOW() is no default this version keeps.
- assert_eq!(check::default(&Expr::Parameter("p".to_owned())), None);
- assert_eq!(check::default(&minus(Literal::Text("x".to_owned()))), None);
+ assert_eq!(check::default(&written_default("$p")), None);
+ assert_eq!(check::default(&written_default("-'x'")), None);
}
/// A column as the parser hands over one that wrote no clause.
@@ -158,6 +176,7 @@ fn declared(name: &str, data_type: DataType) -> ast::ColumnDef {
primary_key: false,
unique: false,
default: None,
+ vector: ast::VectorClauses::default(),
}
}
diff --git a/v2/contextdb/src/sql/ast.rs b/v2/contextdb/src/sql/ast.rs
index fbc96b75..074ef877 100644
--- a/v2/contextdb/src/sql/ast.rs
+++ b/v2/contextdb/src/sql/ast.rs
@@ -1,6 +1,6 @@
//! A statement as it was written: what `parse` returns and `plan` reads.
-pub use crate::types::SortDirection;
+pub use crate::types::{SortDirection, VectorSearchMode};
#[derive(Debug, Clone)]
pub enum Statement {
@@ -26,7 +26,11 @@ pub struct Select {
pub columns: Vec,
pub table: String,
pub where_clause: Option,
+ /// The ORDER BY items. A similarity search has one, a distance ([`Expr::Distance`]) or the
+ /// name the select list gives one.
pub order_by: Vec,
+ /// `USE VECTOR AUTO | EXACT | INDEXED`, when it was written.
+ pub use_vector: Option,
pub limit: Option,
}
@@ -66,6 +70,26 @@ pub enum Expr {
expr: Box,
negated: bool,
},
+ /// `left <=> right`: the cosine distance between a VECTOR column and a vector, a REAL that
+ /// is lower for a closer row. It binds tighter than a comparison and looser than `+` and
+ /// `-`, as in Postgres. This version works it out for the rows of a similarity search: the
+ /// planner reads it as the one ORDER BY key of a SELECT with LIMIT and, written the same
+ /// way, inside that statement's select list; anywhere else it is refused by name.
+ Distance {
+ left: Box,
+ right: Box,
+ },
+ /// A row value, `(a, b)`: two or more expressions in parentheses. It is read wherever an
+ /// expression is, and has a meaning only on either side of `IN`: the planner refuses it
+ /// anywhere else.
+ Row(Vec),
+ /// `expr [NOT] IN (item, ...)`. Beside a row value every item is a row value of the same
+ /// length: the planner refuses any other list.
+ InList {
+ expr: Box,
+ list: Vec,
+ negated: bool,
+ },
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -140,6 +164,21 @@ pub struct ColumnDef {
pub primary_key: bool,
pub unique: bool,
pub default: Option,
+ /// The clauses after `VECTOR(n)`. Left as it starts on a column of any other type.
+ pub vector: VectorClauses,
+}
+
+/// The clauses a VECTOR column may carry, as they were written.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct VectorClauses {
+ /// The columns of `PARTITION_KEY (...)`, in the order written; empty when it was not.
+ pub partition_key: Vec,
+ /// The number after `MAX_PARTITIONS`, kept as written: `schema` judges it, so that zero, a
+ /// negative number and one too large are refused by their own kind and not as a syntax
+ /// error.
+ pub max_partitions: Option,
+ /// `SEARCH_MODE AUTO | EXACT | INDEXED`, when it was written.
+ pub search_mode: Option,
}
#[derive(Debug, Clone, PartialEq, Eq)]
diff --git a/v2/contextdb/src/sql/grammar.pest b/v2/contextdb/src/sql/grammar.pest
index 909af2ae..827cc715 100644
--- a/v2/contextdb/src/sql/grammar.pest
+++ b/v2/contextdb/src/sql/grammar.pest
@@ -20,28 +20,32 @@ stmt = _{
// first words alone and the rest of the text is left unread, so that the statement is refused by
// that name and not as a syntax error. `later` stands only where nothing this version reads can
// begin with one of these words: after a whole statement or clause, and after an expression,
-// where IN, LIKE, BETWEEN and the distance operator `<=>` would follow. DISTINCT and GRAPH_TABLE
-// stand where a name could, so they are looked for first.
+// where LIKE and BETWEEN would follow. DISTINCT and GRAPH_TABLE stand where a name could, so
+// they are looked for first.
later = { later_name ~ ANY* }
later_name = @{
- "<=>"
- | (
- ^"ALTER" | ^"DROP" | ^"WITH" | ^"BEGIN" | ^"COMMIT" | ^"ROLLBACK" | ^"PURGE" | ^"DISCARD"
+ (
+ ^"SHOW" ~ SPACE+ ~ (^"VECTOR_INDEXES" | ^"VECTOR_PARTITIONS")
+ | ^"ALTER" | ^"DROP" | ^"WITH" | ^"BEGIN" | ^"COMMIT" | ^"ROLLBACK" | ^"PURGE" | ^"DISCARD"
| ^"DECLARE" | ^"DISTINCT" | ^"GRAPH_TABLE"
| ((^"LEFT" | ^"INNER" | ^"CROSS") ~ SPACE+)? ~ ^"JOIN"
| ^"GROUP" ~ SPACE+ ~ ^"BY" | ^"HAVING" | ^"UNION" | ^"OFFSET"
- | ^"USE" ~ SPACE+ ~ (^"VECTOR" | ^"RANK")
+ | ^"USE" ~ SPACE+ ~ ^"RANK"
| ^"ON" ~ SPACE+ ~ ^"CONFLICT"
- | (^"NOT" ~ SPACE+)? ~ (^"IN" | ^"LIKE" | ^"BETWEEN")
+ | (^"NOT" ~ SPACE+)? ~ (^"LIKE" | ^"BETWEEN")
| ^"FOREIGN" ~ SPACE+ ~ ^"KEY" | ^"REFERENCES" | ^"EXPIRES" | ^"IMMUTABLE"
| ^"RANK_POLICY" | ^"CONTEXT_ID" | ^"SCOPE_LABEL_READ" | ^"SCOPE_LABEL" | ^"ACL"
- | ^"PARTITION_KEY" | ^"MAX_PARTITIONS" | ^"SEARCH_MODE" | ^"AUTO_INDEX_AT" | ^"SEARCH"
- | ^"IVF" | ^"CONSOLIDATION"
+ | ^"AUTO_INDEX_AT" | ^"SEARCH" | ^"IVF" | ^"CONSOLIDATION"
| ^"RETAIN" | ^"HISTORY" | ^"SYNC" | ^"STATE_MACHINE" | ^"STATE" ~ SPACE+ ~ ^"MACHINE"
| ^"DAG" | ^"PROPAGATE" | ^"DELIVERY" ~ SPACE+ ~ ^"MANIFEST"
| ^"EDGE" ~ SPACE+ ~ ^"DISCARD" | ^"MAINTAIN" ~ SPACE+ ~ ^"COUNT"
) ~ !ident_char
}
+// Two forms of a later version that begin with words this version reads, so each is known by
+// where it stands: a SELECT as the list of IN, and the options after the mode of USE VECTOR.
+// Like `later`, each leaves the rest of the text unread.
+subquery = @{ ^"SELECT" ~ !ident_char }
+vector_options = { "(" }
set_memory_limit = { ^"SET" ~ ^"MEMORY_LIMIT" ~ "="? ~ ("'" ~ memory_limit_value ~ "'" | memory_limit_value) }
show_memory_limit = { ^"SHOW" ~ ^"MEMORY_LIMIT" }
@@ -55,7 +59,8 @@ select_stmt = {
| select_list ~ ^"FROM"
~ (
&(^"GRAPH_TABLE" ~ "(") ~ later
- | identifier ~ where_clause? ~ order_by_clause? ~ limit_clause? ~ later?
+ | identifier ~ where_clause? ~ order_by_clause? ~ use_vector_clause? ~ limit_clause?
+ ~ later?
)
)
}
@@ -66,6 +71,8 @@ where_clause = { ^"WHERE" ~ expr }
order_by_clause = { ^"ORDER" ~ ^"BY" ~ order_item ~ ("," ~ order_item)* }
order_item = { expr ~ sort_dir? }
sort_dir = { ^"ASC" | ^"DESC" }
+use_vector_clause = { ^"USE" ~ ^"VECTOR" ~ search_mode ~ (vector_options ~ ANY*)? }
+search_mode = @{ (^"AUTO" | ^"EXACT" | ^"INDEXED") ~ !ident_char }
limit_clause = { ^"LIMIT" ~ integer }
create_table_stmt = {
@@ -83,7 +90,13 @@ data_type = {
| ^"UUID" | ^"TEXT" | ^"INTEGER" | ^"INT" | ^"REAL" | ^"FLOAT" | ^"BOOLEAN" | ^"BOOL"
| ^"TIMESTAMP" | ^"JSON"
}
-vector_type = { ^"VECTOR" ~ "(" ~ integer ~ ")" }
+vector_type = {
+ ^"VECTOR" ~ "(" ~ integer ~ ")"
+ ~ partition_key? ~ (^"MAX_PARTITIONS" ~ partition_count)? ~ (^"SEARCH_MODE" ~ search_mode)?
+}
+partition_key = { ^"PARTITION_KEY" ~ "(" ~ identifier ~ ("," ~ identifier)* ~ ")" }
+// Read with its sign, so that `schema` refuses zero and a negative number by their kind.
+partition_count = @{ "-"? ~ ASCII_DIGIT+ }
column_constraint = _{ not_null | nullable_marker | primary_key | unique | default_clause }
not_null = { ^"NOT" ~ ^"NULL" }
nullable_marker = { ^"NULL" }
@@ -112,16 +125,20 @@ update_stmt = { ^"UPDATE" ~ identifier ~ ^"SET" ~ assignment ~ ("," ~ assignment
// One level of rule here is one level of the parser's recursion for every parenthesis, so the
// boolean operators share one flat rule and the arithmetic operators another, and the builder
// applies their precedence: NOT, then AND, then OR; `*` and `/` before `+` and `-`.
-expr = { not_op* ~ additive_expr ~ comparison_suffix? ~ (bool_op ~ not_op* ~ additive_expr ~ comparison_suffix?)* }
+expr = { not_op* ~ distance_expr ~ comparison_suffix? ~ (bool_op ~ not_op* ~ distance_expr ~ comparison_suffix?)* }
bool_op = _{ and_op | or_op }
or_op = @{ ^"OR" ~ !ident_char }
and_op = @{ ^"AND" ~ !ident_char }
not_op = @{ ^"NOT" ~ !ident_char }
-comparison_suffix = _{ is_null_suffix | cmp_suffix | later }
-cmp_suffix = { cmp_op ~ additive_expr }
+comparison_suffix = _{ is_null_suffix | in_suffix | cmp_suffix | later }
+cmp_suffix = { cmp_op ~ distance_expr }
cmp_op = { "=" | "!=" | "<>" | "<=" | ">=" | "<" | ">" }
is_null_suffix = { ^"IS" ~ not_op? ~ ^"NULL" }
+in_suffix = { not_op? ~ ^"IN" ~ "(" ~ (subquery ~ ANY* | expr ~ ("," ~ expr)* ~ ")") }
+
+// The distance operator binds tighter than a comparison and looser than `+` and `-`.
+distance_expr = { additive_expr ~ ("<=>" ~ additive_expr)? }
additive_expr = { unary_minus* ~ primary_expr ~ (math_op ~ unary_minus* ~ primary_expr)* }
math_op = { "+" | "-" | "*" | "/" }
@@ -137,8 +154,12 @@ primary_expr = _{
| string
| vector_lit
| column_ref
- | "(" ~ expr ~ ")"
+ | parenthesized
}
+// One expression in parentheses is that expression; two or more are a row value. One rule reads
+// both: a rule that read the first expression, failed and read it again would double the work
+// at every level of nesting.
+parenthesized = { "(" ~ expr ~ ("," ~ expr)* ~ ")" }
vector_lit = { "[" ~ vector_component ~ ("," ~ vector_component)* ~ "]" }
vector_component = @{ "-"? ~ ASCII_DIGIT+ ~ ("." ~ ASCII_DIGIT+)? }
function_call = { identifier ~ "(" ~ (star | (expr ~ ("," ~ expr)*))? ~ ")" }
diff --git a/v2/contextdb/src/sql/mod.rs b/v2/contextdb/src/sql/mod.rs
index 91a5f3c7..efd9c3cc 100644
--- a/v2/contextdb/src/sql/mod.rs
+++ b/v2/contextdb/src/sql/mod.rs
@@ -14,7 +14,7 @@ mod planner;
pub(crate) use ast::{Expr, Statement};
pub(crate) use plan::{Effect, PhysicalPlan as Plan};
-use crate::types::Result;
+use crate::types::{Error, Result};
/// Reads one SQL statement. Refuses with `ParseError`, or with `PlanError` when the text begins
/// a statement or a clause that a later version of the library reads. Costs time in proportion
@@ -27,3 +27,11 @@ pub(crate) fn parse(text: &str) -> Result {
pub(crate) fn plan(statement: &Statement) -> Result {
planner::plan(statement)
}
+
+/// The refusal of a statement, a clause or a form that a later version of the library reads,
+/// by its name.
+pub(crate) fn not_supported(what: &str) -> Error {
+ Error::PlanError(format!(
+ "{what} is not supported in this version of the library"
+ ))
+}
diff --git a/v2/contextdb/src/sql/parser/expr.rs b/v2/contextdb/src/sql/parser/expr.rs
index 15249888..be63b271 100644
--- a/v2/contextdb/src/sql/parser/expr.rs
+++ b/v2/contextdb/src/sql/parser/expr.rs
@@ -1,9 +1,9 @@
//! Expressions. The grammar reads the boolean operators as one flat run and the arithmetic
//! operators as another; the functions here apply their precedence.
//!
-//! `expr`, `comparison_expr`, `additive_expr` and `primary_expr` call each other once for every
-//! level of nesting, to the limit, on the caller's stack. So each is kept small: what is not on
-//! that path is in a function of its own, and a list is gathered with a loop.
+//! `expr`, `comparison_expr`, `distance_expr`, `additive_expr` and `primary_expr` call each other
+//! once for every level of nesting, to the limit, on the caller's stack. So each is kept small:
+//! what is not on that path is in a function of its own, and a list is gathered with a loop.
use super::{MAX_EXPR_NESTING, Pair, Rule, depth_error, has, ident, number, string};
use crate::sql::ast::*;
@@ -58,6 +58,12 @@ fn depth_of(expr: &Expr) -> usize {
Expr::UnaryOp { operand, .. } => depth_of(operand),
Expr::FunctionCall { args, .. } => args.iter().map(depth_of).max().unwrap_or(0),
Expr::IsNull { expr, .. } => depth_of(expr),
+ Expr::Distance { left, right } => depth_of(left).max(depth_of(right)),
+ Expr::Row(items) => items.iter().map(depth_of).max().unwrap_or(0),
+ Expr::InList { expr, list, .. } => {
+ let items = list.iter().map(depth_of).max().unwrap_or(0);
+ depth_of(expr).max(items)
+ }
Expr::Column(_) | Expr::Literal(_) | Expr::Parameter(_) => return 0,
};
below + 1
@@ -91,7 +97,7 @@ fn not(operand: Expr) -> Expr {
/// One comparison: its left side and what the grammar read after it, if anything.
fn comparison_expr(left: Pair<'_>, suffix: Option>) -> Result {
- let left = Box::new(additive_expr(left)?);
+ let left = Box::new(distance_expr(left)?);
let Some(suffix) = suffix else {
return Ok(*left);
};
@@ -101,6 +107,13 @@ fn comparison_expr(left: Pair<'_>, suffix: Option>) -> Result {
negated: has(&suffix, Rule::not_op),
});
}
+ if suffix.as_rule() == Rule::in_suffix {
+ return Ok(Expr::InList {
+ expr: left,
+ negated: has(&suffix, Rule::not_op),
+ list: exprs(suffix)?,
+ });
+ }
let mut parts = suffix.into_inner();
let op = match parts.next().expect("an operator").as_str() {
"=" => BinOp::Eq,
@@ -113,7 +126,20 @@ fn comparison_expr(left: Pair<'_>, suffix: Option>) -> Result {
Ok(Expr::BinaryOp {
left,
op,
- right: Box::new(additive_expr(parts.next().expect("an operand"))?),
+ right: Box::new(distance_expr(parts.next().expect("an operand"))?),
+ })
+}
+
+/// `sum [<=> sum]`: a sum alone, or the distance between two.
+fn distance_expr(pair: Pair<'_>) -> Result {
+ let mut sides = pair.into_inner();
+ let left = additive_expr(sides.next().expect("an operand"))?;
+ let Some(right) = sides.next() else {
+ return Ok(left);
+ };
+ Ok(Expr::Distance {
+ left: Box::new(left),
+ right: Box::new(additive_expr(right)?),
})
}
@@ -163,10 +189,7 @@ fn additive_expr(pair: Pair<'_>) -> Result {
_ => {
let mut operand = primary_expr(part)?;
for _ in 0..std::mem::take(&mut minuses) {
- operand = Expr::UnaryOp {
- op: UnaryOp::Neg,
- operand: Box::new(operand),
- };
+ operand = negated(operand);
}
product = Some(match product.take() {
Some((left, times_or_over)) => (arithmetic(times_or_over, left, operand)?, ""),
@@ -182,6 +205,19 @@ fn additive_expr(pair: Pair<'_>) -> Result {
}
}
+/// `-operand`. A number written with a minus sign is that number: a literal, as a number
+/// written without a sign is, so whatever reads a literal reads it.
+fn negated(operand: Expr) -> Expr {
+ match operand {
+ Expr::Literal(Literal::Integer(whole)) => Expr::Literal(Literal::Integer(-whole)),
+ Expr::Literal(Literal::Real(real)) => Expr::Literal(Literal::Real(-real)),
+ operand => Expr::UnaryOp {
+ op: UnaryOp::Neg,
+ operand: Box::new(operand),
+ },
+ }
+}
+
/// The `*` of `SELECT *` and of `COUNT(*)`: a column of that name.
pub(super) fn star() -> Expr {
Expr::Column(ColumnRef {
@@ -192,8 +228,14 @@ pub(super) fn star() -> Expr {
fn primary_expr(primary: Pair<'_>) -> Result {
match primary.as_rule() {
- // A parenthesised expression.
- Rule::expr => expr(primary),
+ // One expression in parentheses is that expression; two or more are a row value.
+ Rule::parenthesized => {
+ let mut items = exprs(primary)?;
+ Ok(match items.len() {
+ 1 => items.swap_remove(0),
+ _ => Expr::Row(items),
+ })
+ }
Rule::function_call => function_call(primary),
Rule::column_ref => Ok(column_ref(primary)),
_ => literal(&primary),
diff --git a/v2/contextdb/src/sql/parser/mod.rs b/v2/contextdb/src/sql/parser/mod.rs
index 3a06ff0a..287970cd 100644
--- a/v2/contextdb/src/sql/parser/mod.rs
+++ b/v2/contextdb/src/sql/parser/mod.rs
@@ -13,6 +13,7 @@ use pest::Parser;
use pest::error::{InputLocation, LineColLocation};
use super::ast::*;
+use super::not_supported;
use crate::types::{Error, Result};
#[derive(pest_derive::Parser)]
@@ -29,16 +30,17 @@ pub(super) fn parse(text: &str) -> Result {
check_source_nesting(text)?;
let read =
SqlParser::parse(Rule::statement, text).map_err(|error| syntax_error(text, &error))?;
- if let Some(later) = read
- .clone()
- .flatten()
- .find(|part| part.as_rule() == Rule::later_name)
- {
- let words: Vec<&str> = later.as_str().split_whitespace().collect();
- return Err(Error::PlanError(format!(
- "{} is not supported in this version of the library",
- words.join(" ").to_ascii_uppercase()
- )));
+ for part in read.clone().flatten() {
+ let later = match part.as_rule() {
+ Rule::later_name => {
+ let words: Vec<&str> = part.as_str().split_whitespace().collect();
+ words.join(" ").to_ascii_uppercase()
+ }
+ Rule::subquery => "IN (SELECT ...)".to_owned(),
+ Rule::vector_options => "USE VECTOR with options".to_owned(),
+ _ => continue,
+ };
+ return Err(not_supported(&later));
}
let pair = read
.flatten()
@@ -288,6 +290,15 @@ fn where_clause(pair: Pair<'_>) -> Result> {
.transpose()
}
+/// AUTO, EXACT or INDEXED, after SEARCH_MODE and after USE VECTOR.
+fn search_mode(pair: &Pair<'_>) -> VectorSearchMode {
+ match pair.as_str().to_ascii_uppercase().as_str() {
+ "AUTO" => VectorSearchMode::Auto,
+ "EXACT" => VectorSearchMode::Exact,
+ _ => VectorSearchMode::Indexed,
+ }
+}
+
/// ASC when nothing is written.
fn direction(written: Option>) -> SortDirection {
match written {
@@ -302,6 +313,7 @@ fn select(pair: Pair<'_>) -> Result {
table: first_ident(pair.clone()),
where_clause: where_clause(pair.clone())?,
order_by: Vec::new(),
+ use_vector: None,
limit: None,
};
for part in pair.into_inner() {
@@ -332,6 +344,10 @@ fn select(pair: Pair<'_>) -> Result {
});
}
}
+ Rule::use_vector_clause => {
+ let mode = part.into_inner().next().expect("a mode");
+ select.use_vector = Some(search_mode(&mode));
+ }
Rule::limit_clause => {
let count = part.into_inner().next().expect("a count");
select.limit = Some(number(&count, "LIMIT")?);
@@ -407,11 +423,23 @@ fn column_def(pair: Pair<'_>) -> Result {
let name = ident(&parts.next().expect("a column name"));
let type_pair = parts.next().expect("a column type");
let type_name = type_pair.as_str().to_ascii_uppercase();
+ let mut vector = VectorClauses::default();
let data_type = match (type_pair.into_inner().next(), type_name.as_str()) {
- (Some(vector), _) => DataType::Vector(number(
- &vector.into_inner().next().expect("a dimension"),
- "the VECTOR dimension",
- )?),
+ (Some(declared), _) => {
+ let mut parts = declared.into_inner();
+ let dimension = parts.next().expect("a dimension");
+ for clause in parts {
+ match clause.as_rule() {
+ Rule::partition_key => vector.partition_key = idents(clause),
+ Rule::partition_count => {
+ vector.max_partitions = Some(clause.as_str().to_owned());
+ }
+ // The one clause left: SEARCH_MODE.
+ _ => vector.search_mode = Some(search_mode(&clause)),
+ }
+ }
+ DataType::Vector(number(&dimension, "the VECTOR dimension")?)
+ }
(None, "UUID") => DataType::Uuid,
(None, "TEXT") => DataType::Text,
(None, "INTEGER" | "INT") => DataType::Integer,
@@ -427,6 +455,7 @@ fn column_def(pair: Pair<'_>) -> Result {
primary_key: false,
unique: false,
default: None,
+ vector,
};
// A constraint written twice is refused.
let once = |written: bool, clause: &str| match written {
diff --git a/v2/contextdb/src/sql/parser/tests.rs b/v2/contextdb/src/sql/parser/tests.rs
index ddc52424..82aaa818 100644
--- a/v2/contextdb/src/sql/parser/tests.rs
+++ b/v2/contextdb/src/sql/parser/tests.rs
@@ -1,8 +1,10 @@
use super::parse;
-use crate::sql::ast::{DataType, Expr, Literal, Statement};
+use crate::sql::ast::{
+ BinOp, ColumnRef, DataType, Expr, Literal, Select, Statement, VectorClauses,
+};
use crate::sql::plan;
use crate::sql::plan::{Effect, PhysicalPlan};
-use crate::types::Error;
+use crate::types::{Error, SortDirection, VectorSearchMode};
fn refusal(sql: &str) -> String {
match parse(sql) {
@@ -217,8 +219,6 @@ fn what_a_later_version_reads_is_refused_by_its_name() {
"SELECT a FROM GRAPH_TABLE(g MATCH (a)-[e]->(b))",
"GRAPH_TABLE",
),
- ("SELECT id FROM t ORDER BY v <=> $q LIMIT 3", "<=>"),
- ("SELECT id FROM t WHERE a NOT IN (1, 2)", "NOT IN"),
("SELECT id FROM t WHERE a LIKE 'x%'", "LIKE"),
("UPDATE t SET a = 1 WHERE b BETWEEN 1 AND 2", "BETWEEN"),
(
@@ -260,5 +260,314 @@ fn what_a_later_version_reads_is_refused_by_its_name() {
planned("SELECT state, history FROM t WHERE immutable ORDER BY state");
// What is neither read nor named is a syntax error.
assert!(refusal("SELECT a FROM t x").contains("unexpected 'x'"));
- assert!(refusal("SELECT (a, b) FROM t").contains("unexpected ','"));
+ // A row value is read wherever an expression is. In a select list it is refused by name
+ // when the statement is planned.
+ let read = parse("SELECT (a, b) FROM t")
+ .unwrap_or_else(|error| panic!("a row value in a select list was not read: {error:?}"));
+ match plan(&read) {
+ Err(Error::PlanError(text)) => {
+ assert!(text.to_lowercase().contains("row value"), "{text}");
+ }
+ other => panic!("a row value in a select list was planned as {other:?}"),
+ }
+}
+
+fn select(sql: &str) -> Select {
+ match parse(sql) {
+ Ok(Statement::Select(select)) => select,
+ other => panic!("{sql}\nwas not read as a SELECT: {other:?}"),
+ }
+}
+
+/// True for ` <=> $`.
+fn is_distance(expr: &Expr, column: &str, parameter: &str) -> bool {
+ let Expr::Distance { left, right } = expr else {
+ return false;
+ };
+ let of_column = matches!(
+ &**left,
+ Expr::Column(ColumnRef { table: None, column: name }) if name == column
+ );
+ of_column && matches!(&**right, Expr::Parameter(name) if name == parameter)
+}
+
+#[test]
+fn a_distance_is_read_as_an_expression() {
+ // In a select item under a name, with the search ordered by that name.
+ let read = select("SELECT id, v <=> $q AS distance FROM t ORDER BY distance LIMIT 3");
+ assert!(is_distance(&read.columns[1].expr, "v", "q"));
+ assert_eq!(read.columns[1].alias.as_deref(), Some("distance"));
+ assert!(matches!(
+ &read.order_by[..],
+ [item] if item.direction == SortDirection::Asc
+ && matches!(
+ &item.expr,
+ Expr::Column(ColumnRef { table: None, column }) if column == "distance"
+ )
+ ));
+ assert_eq!((read.limit, read.use_vector), (Some(3), None));
+
+ // Under no name, and as the ORDER BY item itself, with ASC written or not.
+ for order in ["v <=> $q", "v <=> $q ASC", "v<=>$q asc"] {
+ let read = select(&format!(
+ "SELECT id, v <=> $q FROM t ORDER BY {order} LIMIT 3"
+ ));
+ assert!(is_distance(&read.columns[1].expr, "v", "q"), "{order}");
+ assert_eq!(read.columns[1].alias, None, "{order}");
+ assert!(
+ matches!(
+ &read.order_by[..],
+ [item] if item.direction == SortDirection::Asc && is_distance(&item.expr, "v", "q")
+ ),
+ "{order}"
+ );
+ }
+
+ // Inside another expression: one minus the distance.
+ let read = select("SELECT id, 1 - (v <=> $q) AS similarity FROM t ORDER BY v <=> $q LIMIT 3");
+ assert!(matches!(
+ &read.columns[1].expr,
+ Expr::FunctionCall { name, args }
+ if name == "__sub"
+ && matches!(&args[..], [Expr::Literal(Literal::Integer(1)), distance]
+ if is_distance(distance, "v", "q"))
+ ));
+ assert_eq!(read.columns[1].alias.as_deref(), Some("similarity"));
+
+ // The vector on the right is a literal, its quoted text or a bound name.
+ let read = select("SELECT id FROM t ORDER BY v <=> [1.0, 0.0] LIMIT 3");
+ assert!(matches!(
+ &read.order_by[0].expr,
+ Expr::Distance { right, .. }
+ if matches!(&**right, Expr::Literal(Literal::Vector(vector)) if vector == &[1.0, 0.0])
+ ));
+ let read = select("SELECT id FROM t ORDER BY v <=> '[1,0]' LIMIT 3");
+ assert!(matches!(
+ &read.order_by[0].expr,
+ Expr::Distance { right, .. }
+ if matches!(&**right, Expr::Literal(Literal::Text(text)) if text == "[1,0]")
+ ));
+}
+
+#[test]
+fn a_distance_binds_tighter_than_a_comparison_and_looser_than_a_sum() {
+ let item = |sql: &str| {
+ let read = select(&format!("SELECT {sql} FROM t"));
+ format!("{:?}", read.columns[0].expr)
+ };
+ assert_eq!(item("v <=> $q < 0.5"), item("(v <=> $q) < 0.5"));
+ assert_eq!(item("0.5 >= v <=> $q"), item("0.5 >= (v <=> $q)"));
+ assert_eq!(item("v <=> $q + $r"), item("v <=> ($q + $r)"));
+ assert_eq!(item("a + b <=> $q - $r"), item("(a + b) <=> ($q - $r)"));
+ assert_eq!(item("1 - v <=> $q"), item("(1 - v) <=> $q"));
+ assert_eq!(
+ item("v <=> $q < w <=> $q AND NOT v <=> $q = 1"),
+ item("((v <=> $q) < (w <=> $q)) AND (NOT ((v <=> $q) = 1))")
+ );
+ // The first of them is a comparison of a distance, and the third a distance to a sum.
+ let read = select("SELECT v <=> $q < 0.5, v <=> $q + $r FROM t");
+ assert!(matches!(
+ &read.columns[0].expr,
+ Expr::BinaryOp { left, op: BinOp::Lt, .. } if is_distance(left, "v", "q")
+ ));
+ assert!(matches!(
+ &read.columns[1].expr,
+ Expr::Distance { right, .. }
+ if matches!(&**right, Expr::FunctionCall { name, .. } if name == "__add")
+ ));
+}
+
+#[test]
+fn a_search_is_planned_as_one_step_with_its_limit() {
+ // Under a projection when the statement names its columns: the search holds the column,
+ // the vector, the limit, the filter and the mode, and no limit wraps it.
+ let planned_search = planned(
+ "SELECT id, v <=> $q AS distance FROM t WHERE a = 1 ORDER BY distance USE VECTOR EXACT \
+ LIMIT 3",
+ );
+ assert_eq!(planned_search.effect(), Effect::Reads);
+ let (input, columns) = match planned_search {
+ PhysicalPlan::Project { input, columns } => (input, columns),
+ other => panic!("a search with a select list is not under a projection: {other:?}"),
+ };
+ assert!(is_distance(&columns[1].expr, "v", "q"));
+ assert_eq!(columns[1].alias.as_deref(), Some("distance"));
+ assert!(matches!(
+ &*input,
+ PhysicalPlan::VectorSearch {
+ table,
+ column,
+ query: Expr::Parameter(query),
+ k: 3,
+ filter: Some(_),
+ mode: Some(VectorSearchMode::Exact),
+ } if table == "t" && column == "v" && query == "q"
+ ));
+ // `SELECT *` is the search itself, and with no USE VECTOR the mode is left to the column.
+ for order in ["v <=> $q", "v <=> $q ASC"] {
+ let sql = format!("SELECT * FROM t ORDER BY {order} LIMIT 10");
+ assert!(
+ matches!(
+ planned(&sql),
+ PhysicalPlan::VectorSearch {
+ k: 10,
+ filter: None,
+ mode: None,
+ ..
+ }
+ ),
+ "{sql}"
+ );
+ }
+ for (mode, read) in [
+ ("AUTO", VectorSearchMode::Auto),
+ ("exact", VectorSearchMode::Exact),
+ ("Indexed", VectorSearchMode::Indexed),
+ ] {
+ let sql = format!("SELECT * FROM t ORDER BY v <=> $q USE VECTOR {mode} LIMIT 1");
+ assert_eq!(select(&sql).use_vector, Some(read), "{sql}");
+ assert!(
+ matches!(
+ planned(&sql),
+ PhysicalPlan::VectorSearch { mode: Some(found), .. } if found == read
+ ),
+ "{sql}"
+ );
+ }
+ // What is refused without looking at any table, when the statement is read or planned.
+ let refused = |sql: &str| match parse(sql) {
+ Ok(statement) => plan(&statement).expect_err(sql),
+ Err(error) => error,
+ };
+ // A search with no LIMIT has no kind of its own: it is refused as every statement of a
+ // later version is.
+ match refused("SELECT id FROM t ORDER BY v <=> $q") {
+ Error::PlanError(text) => assert_eq!(
+ text,
+ "a vector search without LIMIT is not supported in this version of the library"
+ ),
+ other => panic!("a search with no LIMIT was refused as {other:?}"),
+ }
+ assert!(matches!(
+ refused("SELECT id FROM t ORDER BY id USE VECTOR EXACT LIMIT 3"),
+ Error::UseVectorRequiresVectorOrder
+ ));
+ for sql in [
+ "SELECT id FROM t ORDER BY v <=> $q DESC LIMIT 3",
+ "SELECT id FROM t ORDER BY v <=> $q, id LIMIT 3",
+ "SELECT id FROM t ORDER BY $q <=> v LIMIT 3",
+ "SELECT id FROM t WHERE v <=> $q < 0.5",
+ "SELECT id, v <=> $q AS distance FROM t",
+ "SELECT id, v <=> $q AS distance FROM t ORDER BY id LIMIT 3",
+ ] {
+ match refused(sql) {
+ Error::PlanError(text) => assert!(text.contains("<=>"), "{sql}: {text}"),
+ other => panic!("{sql}\nwas refused as {other:?}"),
+ }
+ }
+}
+
+#[test]
+fn a_row_value_and_any_right_side_of_a_distance_are_read_and_refused_when_planned() {
+ // Each is read by the parser, and refused by the planner in a sentence that names it.
+ // The refusal of ROW_VECTOR(...) names the function and the `<=>` it stands beside.
+ let names: [(&str, &[&str]); 4] = [
+ ("SELECT id FROM t WHERE (a, b) = (1, 2)", &["row value"]),
+ (
+ "SELECT id FROM t WHERE (a, b) IN ((1, 2, 3))",
+ &["row value"],
+ ),
+ ("SELECT id FROM t ORDER BY v <=> w LIMIT 3", &["<=>"]),
+ (
+ "SELECT id FROM t ORDER BY v <=> ROW_VECTOR('t', 'v', 1) LIMIT 3",
+ &["<=>", "row_vector"],
+ ),
+ ];
+ for (sql, names) in names {
+ let read = parse(sql).unwrap_or_else(|error| panic!("{sql}\nwas not read: {error:?}"));
+ match plan(&read) {
+ Err(Error::PlanError(text)) => {
+ for name in names {
+ assert!(text.to_lowercase().contains(name), "{sql}: {text}");
+ }
+ }
+ other => panic!("{sql}\nwas planned as {other:?}"),
+ }
+ }
+ // The two statements that describe an index and its partitions are a later version's.
+ for (sql, name) in [
+ ("SHOW VECTOR_INDEXES", "VECTOR_INDEXES"),
+ ("show vector_partitions docs", "VECTOR_PARTITIONS"),
+ ] {
+ match parse(sql) {
+ Err(Error::PlanError(text)) => assert!(
+ text.to_uppercase().contains(name)
+ && text.ends_with("is not supported in this version of the library"),
+ "{sql}: {text}"
+ ),
+ other => panic!("{sql}\nwas not refused by name: {other:?}"),
+ }
+ }
+}
+
+#[test]
+fn the_clauses_of_a_vector_column_and_in_lists_are_read() {
+ let Statement::CreateTable(table) = parse(
+ "CREATE TABLE t (id INTEGER PRIMARY KEY, ctx TEXT NOT NULL, kind INT NOT NULL, \
+ plain VECTOR(3), v VECTOR(4) PARTITION_KEY (ctx, kind) MAX_PARTITIONS 8 \
+ SEARCH_MODE EXACT NOT NULL, w VECTOR(2) partition_key (ctx) max_partitions -1, \
+ x VECTOR(2) SEARCH_MODE indexed)",
+ )
+ .unwrap() else {
+ panic!("not a CREATE TABLE");
+ };
+ let clauses: Vec<&VectorClauses> = table.columns.iter().map(|column| &column.vector).collect();
+ let keyed = |key: &[&str], max: Option<&str>, mode: Option| VectorClauses {
+ partition_key: key.iter().map(|column| (*column).to_owned()).collect(),
+ max_partitions: max.map(str::to_owned),
+ search_mode: mode,
+ };
+ let none = VectorClauses::default();
+ assert_eq!(
+ clauses,
+ [
+ &none,
+ &none,
+ &none,
+ &none,
+ &keyed(&["ctx", "kind"], Some("8"), Some(VectorSearchMode::Exact)),
+ // The number is kept as it was written: it is `schema` that judges it.
+ &keyed(&["ctx"], Some("-1"), None),
+ &keyed(&[], None, Some(VectorSearchMode::Indexed)),
+ ]
+ );
+ assert_eq!(table.columns[4].data_type, DataType::Vector(4));
+ assert!(!table.columns[4].nullable && table.columns[3].nullable);
+
+ // IN and NOT IN over a list, and over a list of row values.
+ let filter = |sql: &str| {
+ let read = select(&format!("SELECT id FROM t WHERE {sql}"));
+ read.where_clause.expect("a WHERE")
+ };
+ assert!(matches!(
+ filter("a IN (1, 2, $p)"),
+ Expr::InList { expr, list, negated: false }
+ if matches!(&*expr, Expr::Column(_)) && list.len() == 3
+ ));
+ assert!(matches!(
+ filter("a not in ('x')"),
+ Expr::InList { list, negated: true, .. } if list.len() == 1
+ ));
+ assert!(matches!(
+ filter("(a, b) IN ((1, 'x'), (2, 'y'))"),
+ Expr::InList { expr, list, negated: false }
+ if matches!(&*expr, Expr::Row(row) if row.len() == 2)
+ && list.len() == 2
+ && list.iter().all(|item| matches!(item, Expr::Row(row) if row.len() == 2))
+ ));
+ // A list is an operand of AND and OR like any comparison.
+ assert!(matches!(
+ filter("a IN (1, 2) AND NOT b IN (3) OR c = 4"),
+ Expr::BinaryOp { op: BinOp::Or, .. }
+ ));
}
diff --git a/v2/contextdb/src/sql/plan.rs b/v2/contextdb/src/sql/plan.rs
index 14aded19..41b1a68f 100644
--- a/v2/contextdb/src/sql/plan.rs
+++ b/v2/contextdb/src/sql/plan.rs
@@ -1,6 +1,6 @@
//! The planned statement: what `schema`, `write` and `query` each take.
-use super::ast::{ColumnDef, Expr, SetMemoryLimitValue, SortDirection};
+use super::ast::{ColumnDef, Expr, SetMemoryLimitValue, SortDirection, VectorSearchMode};
#[derive(Debug, Clone)]
pub enum PhysicalPlan {
@@ -26,6 +26,23 @@ pub enum PhysicalPlan {
input: Box,
count: u64,
},
+ /// The `k` rows of a table that pass the filter and lie nearest `query` by cosine distance
+ /// in a VECTOR column, nearest first. `query` decides which partitions the filter selects
+ /// and the vector part finds the rows. It stands where `Sort` over `Scan` stands in other
+ /// plans; its LIMIT is `k`, so no `Limit` wraps it. A `Project` over it may hold the
+ /// statement's distance expression, `column <=> query`, any number of times: each stands
+ /// for the distance the search found for the row.
+ VectorSearch {
+ table: String,
+ /// The VECTOR column on the left of `<=>`.
+ column: String,
+ /// The right of `<=>`.
+ query: Expr,
+ k: u64,
+ filter: Option,
+ /// What `USE VECTOR` asked for; `None` leaves it to the column's declaration.
+ mode: Option,
+ },
SetMemoryLimit(SetMemoryLimitValue),
ShowMemoryLimit,
}
@@ -61,6 +78,7 @@ impl PhysicalPlan {
| PhysicalPlan::Sort { .. }
| PhysicalPlan::Project { .. }
| PhysicalPlan::Limit { .. }
+ | PhysicalPlan::VectorSearch { .. }
| PhysicalPlan::ShowMemoryLimit => Effect::Reads,
}
}
diff --git a/v2/contextdb/src/sql/planner.rs b/v2/contextdb/src/sql/planner.rs
index 2e7d52b7..cdbbed2e 100644
--- a/v2/contextdb/src/sql/planner.rs
+++ b/v2/contextdb/src/sql/planner.rs
@@ -4,7 +4,8 @@
//! chooses no index: a SELECT's plan names the table to read and the filter to apply, and
//! `query` decides how to reach the rows.
-use super::ast::{ColumnRef, Expr, Select, SelectColumn, Statement};
+use super::ast::{ColumnRef, Expr, Literal, Select, SelectColumn, SortDirection, Statement};
+use super::not_supported;
use super::plan::*;
use crate::types::{Error, Result};
@@ -25,20 +26,31 @@ pub(super) fn plan(statement: &Statement) -> Result {
table: index.table.clone(),
columns: index.columns.clone(),
}),
- Statement::Insert(insert) => PhysicalPlan::Insert(InsertPlan {
- table: insert.table.clone(),
- columns: insert.columns.clone(),
- values: insert.values.clone(),
- }),
- Statement::Delete(delete) => PhysicalPlan::Delete(DeletePlan {
- table: delete.table.clone(),
- where_clause: delete.where_clause.clone(),
- }),
- Statement::Update(update) => PhysicalPlan::Update(UpdatePlan {
- table: update.table.clone(),
- assignments: update.assignments.clone(),
- where_clause: update.where_clause.clone(),
- }),
+ Statement::Insert(insert) => {
+ insert.values.iter().flatten().try_for_each(rows_stand)?;
+ PhysicalPlan::Insert(InsertPlan {
+ table: insert.table.clone(),
+ columns: insert.columns.clone(),
+ values: insert.values.clone(),
+ })
+ }
+ Statement::Delete(delete) => {
+ delete.where_clause.iter().try_for_each(in_a_write)?;
+ PhysicalPlan::Delete(DeletePlan {
+ table: delete.table.clone(),
+ where_clause: delete.where_clause.clone(),
+ })
+ }
+ Statement::Update(update) => {
+ let values = update.assignments.iter().map(|(_, value)| value);
+ let mut written = values.chain(&update.where_clause);
+ written.try_for_each(in_a_write)?;
+ PhysicalPlan::Update(UpdatePlan {
+ table: update.table.clone(),
+ assignments: update.assignments.clone(),
+ where_clause: update.where_clause.clone(),
+ })
+ }
Statement::Select(select) => plan_select(select)?,
Statement::SetMemoryLimit(limit) => PhysicalPlan::SetMemoryLimit(limit.clone()),
Statement::ShowMemoryLimit => PhysicalPlan::ShowMemoryLimit,
@@ -46,38 +58,74 @@ pub(super) fn plan(statement: &Statement) -> Result {
}
/// A scan with its filter, sorted, then projected unless the statement is `SELECT *`, then cut
-/// to the limit.
+/// to the limit. A similarity search stands where the scan, the sort and the limit would.
fn plan_select(select: &Select) -> Result {
- let mut current = PhysicalPlan::Scan {
- table: select.table.clone(),
- filter: select.where_clause.clone(),
- };
- if !select.order_by.is_empty() {
- let mut keys = Vec::with_capacity(select.order_by.len());
- for item in &select.order_by {
- let expr = resolve_output_alias(&item.expr, &select.columns);
+ let written = select.columns.iter().map(|column| &column.expr);
+ let written = written.chain(&select.where_clause);
+ let written = written.chain(select.order_by.iter().map(|item| &item.expr));
+ let written: Vec<&Expr> = written.collect();
+ written.iter().copied().try_for_each(rows_stand)?;
+ let keys: Vec = select
+ .order_by
+ .iter()
+ .map(|item| SortKey {
+ expr: resolve_output_alias(&item.expr, &select.columns),
+ direction: item.direction,
+ })
+ .collect();
+ // A distance among several ORDER BY items is a search of a later version.
+ let distance = |key: &SortKey| matches!(key.expr, Expr::Distance { .. });
+ if keys.len() > 1 && keys.iter().any(distance) {
+ return Err(not_supported("ORDER BY <=> with a second sort key"));
+ }
+ // A similarity search: the one ORDER BY item is a distance.
+ let mut search = None;
+ if let [key] = keys.as_slice()
+ && let Expr::Distance { left, right } = &key.expr
+ {
+ search = Some(plan_search(select, left, right, key)?);
+ }
+ let searching = search.is_some();
+ let mut current = match search {
+ Some(search) => search,
+ None => {
+ if select.use_vector.is_some() {
+ return Err(Error::UseVectorRequiresVectorOrder);
+ }
+ for expr in written {
+ distances(expr, &mut |_, _| {
+ Err(not_supported(
+ "<=> anywhere but as the one ORDER BY item of a SELECT with LIMIT, and in \
+ that SELECT's select list,",
+ ))
+ })?;
+ }
// A key `query` cannot work out for every row is refused here: sorting by it would
// treat every row as equal and hand the rows back unsorted.
- let sortable = match &expr {
+ let sortable = |key: &SortKey| match &key.expr {
Expr::Column(_) => true,
Expr::FunctionCall { name, .. } => SORTABLE_FUNCTIONS
.iter()
.any(|known| name.eq_ignore_ascii_case(known)),
_ => false,
};
- if !sortable {
+ if !keys.iter().all(sortable) {
return Err(Error::OrderByExpressionNotSupported);
}
- keys.push(SortKey {
- expr,
- direction: item.direction,
- });
+ let scan = PhysicalPlan::Scan {
+ table: select.table.clone(),
+ filter: select.where_clause.clone(),
+ };
+ if keys.is_empty() {
+ scan
+ } else {
+ PhysicalPlan::Sort {
+ input: Box::new(scan),
+ keys,
+ }
+ }
}
- current = PhysicalPlan::Sort {
- input: Box::new(current),
- keys,
- };
- }
+ };
let star = matches!(
select.columns.as_slice(),
[SelectColumn { expr: Expr::Column(ColumnRef { table: None, column }), alias: None }]
@@ -96,7 +144,7 @@ fn plan_select(select: &Select) -> Result {
.collect(),
};
}
- if let Some(count) = select.limit {
+ if let (Some(count), false) = (select.limit, searching) {
current = PhysicalPlan::Limit {
input: Box::new(current),
count,
@@ -120,3 +168,149 @@ fn resolve_output_alias(expr: &Expr, columns: &[SelectColumn]) -> Expr {
};
aliased.map_or_else(|| expr.clone(), |select| select.expr.clone())
}
+
+/// The search a SELECT asks for when its one ORDER BY item is `left <=> right`: the rows nearest
+/// `right` in the column `left`. Refuses the forms of a search, and of a distance beside it,
+/// that a later version reads.
+fn plan_search(select: &Select, left: &Expr, right: &Expr, key: &SortKey) -> Result {
+ let Some(k) = select.limit else {
+ return Err(not_supported("a vector search without LIMIT"));
+ };
+ if key.direction == SortDirection::Desc {
+ return Err(not_supported("ORDER BY <=> DESC"));
+ }
+ let Some(column) = searched_column(select, left)? else {
+ // No search: the statement is the sort it is written as, which `query` refuses by the
+ // column's name once it has found the table.
+ let (table, filter, keys) = (select.table.clone(), None, vec![key.clone()]);
+ let input = Box::new(PhysicalPlan::Scan { table, filter });
+ return Ok(PhysicalPlan::Sort { input, keys });
+ };
+ match right {
+ Expr::Parameter(_) | Expr::Literal(Literal::Text(_) | Literal::Vector(_)) => {}
+ // `+` and its like are calls too, under names no statement writes.
+ Expr::FunctionCall { name, .. } if !name.starts_with("__") => {
+ let what = format!("<=> with the function {name} on its right");
+ return Err(not_supported(&what));
+ }
+ _ => {
+ return Err(not_supported(
+ "<=> with anything but a vector literal, its quoted text or a $name on its right",
+ ));
+ }
+ }
+ if let Some(filter) = &select.where_clause {
+ distances(filter, &mut |_, _| Err(not_supported("<=> in WHERE")))?;
+ }
+ for item in &select.columns {
+ distances(&item.expr, &mut |other: &Expr, vector: &Expr| {
+ let Some(other) = searched_column(select, other)? else {
+ return Ok(());
+ };
+ if other == column && same_vector(vector, right) {
+ return Ok(());
+ }
+ Err(not_supported(&format!(
+ "<=> on the column {other} in the select list, where it is not the distance the \
+ search is ordered by,"
+ )))
+ })?;
+ }
+ Ok(PhysicalPlan::VectorSearch {
+ table: select.table.clone(),
+ column: column.to_owned(),
+ query: right.clone(),
+ k,
+ filter: select.where_clause.clone(),
+ mode: select.use_vector,
+ })
+}
+
+/// The column on the left of a distance: a column of the statement's table, by its name alone or
+/// with the table's before it. `None` under the name of any other table: `query` says that it
+/// is not found, as it does anywhere else in the statement, after it has found the table.
+fn searched_column<'a>(select: &Select, left: &'a Expr) -> Result> {
+ match left {
+ Expr::Column(ColumnRef { table, column })
+ if table.as_ref().is_none_or(|table| *table == select.table) =>
+ {
+ Ok(Some(column.as_str()))
+ }
+ Expr::Column(_) => Ok(None),
+ _ => Err(not_supported(
+ "<=> with anything but a column of the table on its left",
+ )),
+ }
+}
+
+/// Whether two right sides of `<=>` are written the same: the same `$name`, the same quoted text
+/// or the same vector literal.
+fn same_vector(one: &Expr, other: &Expr) -> bool {
+ match (one, other) {
+ (Expr::Parameter(one), Expr::Parameter(other)) => one == other,
+ (Expr::Literal(Literal::Text(one)), Expr::Literal(Literal::Text(other))) => one == other,
+ (Expr::Literal(Literal::Vector(one)), Expr::Literal(Literal::Vector(other))) => {
+ one == other
+ }
+ _ => false,
+ }
+}
+
+/// The expressions directly inside `expr`.
+fn inside(expr: &Expr) -> Vec<&Expr> {
+ match expr {
+ Expr::Column(_) | Expr::Literal(_) | Expr::Parameter(_) => Vec::new(),
+ Expr::BinaryOp { left, right, .. } | Expr::Distance { left, right } => {
+ vec![&**left, &**right]
+ }
+ Expr::UnaryOp { operand: inner, .. } | Expr::IsNull { expr: inner, .. } => vec![&**inner],
+ Expr::FunctionCall { args: items, .. } | Expr::Row(items) => items.iter().collect(),
+ Expr::InList { expr, list, .. } => std::iter::once(&**expr).chain(list).collect(),
+ }
+}
+
+/// Hands `each` the left and right side of every distance written in `expr`, at any depth.
+fn distances(expr: &Expr, each: &mut dyn FnMut(&Expr, &Expr) -> Result<()>) -> Result<()> {
+ if let Expr::Distance { left, right } = expr {
+ each(left, right)?;
+ }
+ inside(expr)
+ .into_iter()
+ .try_for_each(|inner| distances(inner, each))
+}
+
+/// What an expression of an UPDATE or DELETE must pass: [`rows_stand`], and no distance, which
+/// only a search gives a row. Refused here, the statement is refused whatever its table holds.
+fn in_a_write(expr: &Expr) -> Result<()> {
+ rows_stand(expr)?;
+ distances(expr, &mut |_, _| {
+ Err(not_supported("<=> in an UPDATE or DELETE"))
+ })
+}
+
+/// Refuses a row value that stands anywhere but on either side of `IN`, and one beside `IN`
+/// that has another number of values than its partner.
+fn rows_stand(expr: &Expr) -> Result<()> {
+ let (left, list) = match expr {
+ Expr::InList { expr, list, .. } => (&**expr, list),
+ Expr::Row(_) => return Err(not_supported("a row value anywhere but beside IN")),
+ _ => return inside(expr).into_iter().try_for_each(rows_stand),
+ };
+ let width = |side: &Expr| match side {
+ Expr::Row(values) => values.len(),
+ _ => 1,
+ };
+ for side in std::iter::once(left).chain(list) {
+ if width(side) != width(left) {
+ return Err(Error::PlanError(
+ "a row value beside IN must have as many values as its partner".to_owned(),
+ ));
+ }
+ // What a side holds is checked as any expression is: a row value holds no row value.
+ match side {
+ Expr::Row(values) => values.iter().try_for_each(rows_stand)?,
+ _ => rows_stand(side)?,
+ }
+ }
+ Ok(())
+}
diff --git a/v2/contextdb/src/store/disk.rs b/v2/contextdb/src/store/disk.rs
index a1550b81..b1e684f7 100644
--- a/v2/contextdb/src/store/disk.rs
+++ b/v2/contextdb/src/store/disk.rs
@@ -1,14 +1,18 @@
//! What the data file holds, how one committed moment of it is read, and how a commit is
//! written into it.
//!
-//! Five tables of the storage library hold everything. Rows and index entries are keyed by the
-//! number of their table or index and then their key, so one table's rows are one run of keys.
-//! Large values sit under a place number of their own. The last table holds the few numbers the
-//! store keeps: the newest commit, the commit that last changed the schema, the next free place,
-//! the declared memory limit, and each table's row count.
+//! Seven tables of the storage library hold everything. Rows and index entries are keyed by
+//! the number of their table or index and then their key, so one table's rows are one run of
+//! keys. A large value is cut into slices of 243 bytes, each under a place number of its own:
+//! the slices of one value are under consecutive places, and its row says how many bytes there
+//! are and which place is the first. The vector part's blocks have a table to themselves, and
+//! its partition entries and pending entries share another. The last table holds the few
+//! numbers the store keeps: the newest commit, the commit that last changed the schema, the
+//! next free place, the declared memory limit, each table's row count, and the vector part's
+//! three numbers.
use std::io::{self, ErrorKind};
-use std::ops::Bound;
+use std::ops::{Bound, Range};
use std::path::Path;
use std::sync::Arc;
@@ -17,6 +21,7 @@ use redb::{ReadOnlyTable, ReadTransaction, ReadableDatabase, ReadableTable, Tabl
use super::folder::Hold;
use super::keys::{self, Span, stored};
use super::{Beside, Cell, ChangeSet, EntryIter, IndexId, Key, Read, Row, RowIter, TableId, View};
+use super::{VectorNumber, VectorRecord};
use super::{record, split};
use crate::memory::Reservation;
use crate::types::{Error, Result, TxId, Value};
@@ -28,12 +33,25 @@ pub(super) type Stored = std::result::Result;
const ROWS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("rows");
/// Index entries: index number, entry and row key, to the length of the entry.
const INDEX: TableDefinition<&[u8], &[u8]> = TableDefinition::new("index");
-/// Large values: place, to the value as `record` writes it.
+/// Large values: place, to one slice of the record `record` stores for the value.
const BESIDE: TableDefinition = TableDefinition::new("beside");
/// Schema records by name, as `schema` wrote them.
const SCHEMA: TableDefinition<&str, &[u8]> = TableDefinition::new("schema");
/// The numbers the store keeps.
const NUMBERS: TableDefinition = TableDefinition::new("numbers");
+/// The vector part's blocks, under their keys as `keys::vector` writes them, and nothing else.
+/// The storage library rewrites a whole leaf when any record in it changes, and it divides a
+/// leaf by bytes, so a small record that shared a leaf with a block of 256 KiB would have the
+/// block rewritten every time it changed.
+const BLOCKS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("vector_blocks");
+/// The vector part's partition entries and pending entries, under their keys as `keys::vector`
+/// writes them.
+const VECTORS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("vectors");
+
+/// The bytes of one slice of a large value; the last slice of a value may be shorter. A leaf
+/// of the storage library is 4 bytes and, for each slice, 8 of key, 4 of length and the slice,
+/// so sixteen slices fill a page of 4 KiB: 4,084 of its 4,096 bytes.
+const SLICE: usize = 243;
const COMMIT: u64 = 0;
const SCHEMA_COMMIT: u64 = 1;
@@ -46,6 +64,21 @@ fn count_of(table: u32) -> u64 {
(1 << 32) | u64::from(table)
}
+/// Where a number of the vector part is kept among the numbers.
+pub(super) fn vector_number_at(number: VectorNumber) -> u64 {
+ match number {
+ VectorNumber::Partitions(column) => (2 << 32) | u64::from(column.0),
+ VectorNumber::NextPartition(column) => (3 << 32) | u64::from(column.0),
+ VectorNumber::Pending => 4 << 32,
+ }
+}
+
+/// The places of the slices of a large value: its cell gives them, so nothing is read to
+/// find them.
+fn places(kept: &Beside) -> Range {
+ kept.place..kept.place + kept.bytes.div_ceil(SLICE as u64)
+}
+
/// Tells a failure of the storage library or of a file as the engine tells it: as damage when
/// stored data failed its check, and otherwise as a failure of `operation`, which is what the
/// store was doing.
@@ -78,10 +111,25 @@ pub(super) fn failed(
}
}
+/// What a record that is not one the store wrote is told as: a failed check.
+fn unsound() -> redb::Error {
+ redb::Error::Corrupted("a stored record is not one the store wrote".to_owned())
+}
+
fn number(numbers: &impl ReadableTable, key: u64) -> Stored {
Ok(numbers.get(key)?.map_or(0, |number| number.value()))
}
+/// The memory limit the store remembers, in bytes; `None` when none is declared.
+fn memory_limit(numbers: &impl ReadableTable) -> Stored> {
+ Ok(numbers.get(MEMORY_LIMIT)?.map(|limit| limit.value()))
+}
+
+/// The memory limit the newest commit of a data file remembers.
+pub(super) fn remembered_limit(data: &redb::Database) -> Stored > {
+ memory_limit(&data.begin_read()?.open_table(NUMBERS)?)
+}
+
/// Makes the tables in a data file that does not have them yet.
pub(super) fn prepare(data: &redb::Database) -> Stored<()> {
match data.begin_read()?.open_table(NUMBERS) {
@@ -95,17 +143,21 @@ pub(super) fn prepare(data: &redb::Database) -> Stored<()> {
write.open_table(BESIDE)?;
write.open_table(SCHEMA)?;
write.open_table(NUMBERS)?;
+ write.open_table(BLOCKS)?;
+ write.open_table(VECTORS)?;
write.commit()?;
Ok(())
}
-/// One committed moment: the five tables as one read of the storage library shows them.
+/// One committed moment: the seven tables as one read of the storage library shows them.
pub(super) struct Moment {
rows: ReadOnlyTable<&'static [u8], &'static [u8]>,
index: ReadOnlyTable<&'static [u8], &'static [u8]>,
beside: ReadOnlyTable,
schema: ReadOnlyTable<&'static str, &'static [u8]>,
numbers: ReadOnlyTable,
+ blocks: ReadOnlyTable<&'static [u8], &'static [u8]>,
+ vectors: ReadOnlyTable<&'static [u8], &'static [u8]>,
commit: TxId,
folder: Arc,
/// In a reading process, the file that keeps this moment's pages from being reused.
@@ -124,6 +176,8 @@ impl Moment {
index: read.open_table(INDEX)?,
beside: read.open_table(BESIDE)?,
schema: read.open_table(SCHEMA)?,
+ blocks: read.open_table(BLOCKS)?,
+ vectors: read.open_table(VECTORS)?,
commit: TxId(number(&numbers, COMMIT)?),
numbers,
folder,
@@ -144,7 +198,7 @@ impl Moment {
read: F,
) -> Result> + use>
where
- F: Fn(&[u8]) -> T,
+ F: Fn(&[u8]) -> Option,
{
let folder = self.folder.clone();
let range = table
@@ -152,7 +206,8 @@ impl Moment {
.map_err(|error| failed(&folder, "read", error))?;
Ok(range.map(move |entry| {
let (key, record) = entry.map_err(|error| failed(&folder, "read", error))?;
- Ok((Key(key.value()[4..].to_vec()), read(record.value())))
+ let read = read(record.value()).ok_or_else(|| failed(&folder, "read", unsound()))?;
+ Ok((Key(key.value()[4..].to_vec()), read))
}))
}
@@ -162,8 +217,10 @@ impl Moment {
table: u32,
span: &Span,
) -> Result> + use<>> {
- self.scan(&self.rows, table, span, |row| Row {
- cells: record::decode_row(row),
+ self.scan(&self.rows, table, span, |row| {
+ Some(Row {
+ cells: record::decode_row(row),
+ })
})
}
@@ -175,7 +232,7 @@ impl Moment {
span: &Span,
) -> Result> + use<>> {
self.scan(&self.index, index, span, |length| {
- u32::from_le_bytes(std::array::from_fn(|at| length[at])) as usize
+ Some(u32::from_le_bytes(*length.first_chunk()?) as usize)
})
}
@@ -195,10 +252,29 @@ impl Moment {
}))
}
- fn kept_beside(&self, place: u64) -> Stored {
- let kept = self.beside.get(place)?;
- let kept = kept.expect("the commit that keeps a row keeps its large values");
- Ok(record::decode_value(kept.value()))
+ /// Tells of a record that is not what was stored: damage, told as a failed check of the
+ /// storage library is.
+ fn damaged(&self, reason: &str) -> Error {
+ let reason = redb::Error::Corrupted(reason.to_owned());
+ failed(&self.folder, "read", reason)
+ }
+
+ /// Loads a large value: grows `reservation` by the bytes stored for it, gathers its slices
+ /// with one range read into one buffer of that length, and has `record` make the value.
+ fn kept_beside(&self, kept: &Beside, reservation: &mut Reservation) -> Result {
+ reservation.grow(kept.bytes)?;
+ let mut stored = Vec::with_capacity(kept.bytes as usize);
+ let slices = self.beside.range::(places(kept));
+ for slice in slices.map_err(|error| failed(&self.folder, "read", error))? {
+ let (_, slice) = slice.map_err(|error| failed(&self.folder, "read", error))?;
+ stored.extend_from_slice(slice.value());
+ }
+ if stored.len() as u64 != kept.bytes {
+ return Err(self.damaged("a large value's slices are not the bytes its row records"));
+ }
+ // A record its own bytes do not support is damage.
+ let loaded = record::load(stored, reservation)?;
+ loaded.map_err(|reason| self.damaged(reason))
}
}
@@ -226,8 +302,52 @@ impl Read for View {
}
fn load(&self, beside: &Beside, reservation: &mut Reservation) -> Result {
- reservation.grow(beside.bytes)?;
- self.moment.told(self.moment.kept_beside(beside.place))
+ let held = reservation.bytes();
+ let loaded = self.moment.kept_beside(beside, reservation);
+ if loaded.is_err() {
+ // A load that made no value holds nothing: what it grew is given back.
+ reservation.shrink(reservation.bytes() - held);
+ }
+ loaded
+ }
+
+ fn staged(&self, _table: TableId, _each: &mut dyn FnMut(&Key) -> Result) -> Result<()> {
+ Ok(())
+ }
+
+ fn vectors(
+ &self,
+ from: VectorRecord<'_>,
+ each: &mut dyn FnMut(VectorRecord<'_>, &[u8]) -> Result,
+ ) -> Result<()> {
+ let moment = &*self.moment;
+ // Every key of a kind begins with the kind's byte, so the kind ends before the next.
+ let start = keys::vector(from);
+ let end = [start[0] + 1];
+ let table = match start[0] {
+ keys::BLOCK => &moment.blocks,
+ _ => &moment.vectors,
+ };
+ let records = table.range::<&[u8]>(start.as_slice()..end.as_slice());
+ let mut key = Key(Vec::new());
+ for record in records.map_err(|error| failed(&moment.folder, "read", error))? {
+ let (stored, value) = record.map_err(|error| failed(&moment.folder, "read", error))?;
+ // The value is the bytes of the storage library's own page, lent for the call.
+ let record = keys::vector_of(stored.value(), &mut key).ok_or_else(unsound);
+ if !each(moment.told(record)?, value.value())? {
+ break;
+ }
+ }
+ Ok(())
+ }
+
+ fn vector_number(&self, number: VectorNumber) -> Result {
+ let at = vector_number_at(number);
+ self.moment.told(self::number(&self.moment.numbers, at))
+ }
+
+ fn damaged(&self, reason: &str) -> Error {
+ self.moment.damaged(reason)
}
}
@@ -247,15 +367,13 @@ impl View {
/// The memory limit the store remembers, in bytes; `None` when none is declared.
pub(crate) fn memory_limit(&self) -> Result> {
- let kept = self.moment.numbers.get(MEMORY_LIMIT);
- let kept = kept.map(|kept| kept.map(|limit| limit.value()));
- self.moment.told(kept.map_err(redb::Error::from))
+ self.moment.told(memory_limit(&self.moment.numbers))
}
}
/// Writes a change set as one transaction of the storage library and waits for the disk.
/// Returns the number of the commit it made.
-pub(super) fn write(data: &redb::Database, changes: &ChangeSet) -> Stored {
+pub(super) fn write(data: &redb::Database, changes: &mut ChangeSet) -> Stored {
// The storage library has a commit option, `set_quick_repair`, that lets reopening after a
// kill skip its repair. It is not used: with it every commit rewrites the library's whole
// free-page table, about 133 KB for each GiB of file. So an open after a kill reads every
@@ -287,9 +405,15 @@ pub(super) fn write(data: &redb::Database, changes: &ChangeSet) -> Stored {
let before = match &row.values {
Some(values) => {
let row = record::encode_row(values, |large| -> Stored {
- beside.insert(free, large)?;
- free += 1;
- Ok(free - 1)
+ // The compressor's buffer is counted until the value is written.
+ let room = record::compress(large, &mut changes.reserved);
+ let first = free;
+ for slice in large.chunks(SLICE) {
+ beside.insert(free, slice)?;
+ free += 1;
+ }
+ changes.reserved.shrink(room);
+ Ok(first)
})?;
let before = rows.insert(key.as_slice(), row.as_slice())?;
before.map(|row| record::decode_row(row.value()))
@@ -301,7 +425,9 @@ pub(super) fn write(data: &redb::Database, changes: &ChangeSet) -> Stored {
};
for cell in before.into_iter().flatten() {
if let Cell::Beside(kept) = cell {
- beside.remove(kept.place)?;
+ for place in places(&kept) {
+ beside.remove(place)?;
+ }
}
}
}
@@ -342,6 +468,22 @@ pub(super) fn write(data: &redb::Database, changes: &ChangeSet) -> Stored {
Some(None) => drop(numbers.remove(MEMORY_LIMIT)?),
None => {}
}
+
+ let mut blocks = write.open_table(BLOCKS)?;
+ let mut vectors = write.open_table(VECTORS)?;
+ for (key, value) in &changes.vectors {
+ let table = match key.0[0] {
+ keys::BLOCK => &mut blocks,
+ _ => &mut vectors,
+ };
+ match value {
+ Some(value) => drop(table.insert(key.0.as_slice(), value.as_slice())?),
+ None => drop(table.remove(key.0.as_slice())?),
+ }
+ }
+ for (at, number) in &changes.vector_numbers {
+ numbers.insert(*at, *number)?;
+ }
}
write.commit()?;
Ok(commit)
diff --git a/v2/contextdb/src/store/folder.rs b/v2/contextdb/src/store/folder.rs
index d0a96b17..081ef588 100644
--- a/v2/contextdb/src/store/folder.rs
+++ b/v2/contextdb/src/store/folder.rs
@@ -6,6 +6,11 @@
//! holds it, shared, for the moment it takes to find the newest commit and leave a file naming
//! it; the writer holds it, alone, while it looks through those files. So the writer never
//! sees a reader between its choice of a commit and the file that says so.
+//!
+//! Such a lock belongs to the open file, and a child process has a copy of every file its
+//! parent had open when it was started, until it replaces itself with its program. So every
+//! lock is let go by unlocking the file before it is closed: a copy a child still has then
+//! holds nothing.
use std::fs::{self, File, OpenOptions, TryLockError};
use std::io::{self, Read as _, Write as _};
@@ -83,9 +88,18 @@ impl StorageBackend for Plain {
}
}
+/// A file this process holds locked. Dropping it unlocks the file and then closes it.
+pub(super) struct Locked(File);
+
+impl Drop for Locked {
+ fn drop(&mut self) {
+ let _ = self.0.unlock();
+ }
+}
+
/// Takes the folder's `lock` for this process and writes the process id into it. When another
/// process holds it, answers at once with the id that process wrote.
-pub(super) fn take_lock(folder: &Path) -> io::Result> {
+pub(super) fn take_lock(folder: &Path) -> io::Result> {
let mut lock = OpenOptions::new()
.read(true)
.write(true)
@@ -94,8 +108,10 @@ pub(super) fn take_lock(folder: &Path) -> io::Result> {
.open(folder.join("lock"))?;
match lock.try_lock() {
Ok(()) => {
- lock.set_len(0)?;
- lock.write_all(std::process::id().to_string().as_bytes())?;
+ let mut lock = Locked(lock);
+ lock.0.set_len(0)?;
+ lock.0
+ .write_all(std::process::id().to_string().as_bytes())?;
Ok(Ok(lock))
}
Err(TryLockError::WouldBlock) => {
@@ -112,7 +128,7 @@ pub(super) fn take_lock(folder: &Path) -> io::Result> {
pub(super) struct Hold {
pub(super) commit: u64,
path: PathBuf,
- _locked: File,
+ _locked: Locked,
}
impl Hold {
@@ -126,9 +142,10 @@ impl Hold {
NEXT.fetch_add(1, Ordering::Relaxed)
);
let path = readers.join(name);
- let mut file = File::create(&path)?;
+ let file = File::create(&path)?;
file.lock()?;
- file.write_all(commit.to_string().as_bytes())?;
+ let mut file = Locked(file);
+ file.0.write_all(commit.to_string().as_bytes())?;
Ok(Hold {
commit,
path,
@@ -164,7 +181,10 @@ fn look_through(readers: &Path) -> io::Result> {
};
match file.try_lock() {
// Nobody holds it: its reader is gone, and may have removed it already.
- Ok(()) => drop(fs::remove_file(&path)),
+ Ok(()) => {
+ let _ = fs::remove_file(&path);
+ file.unlock()?;
+ }
Err(TryLockError::WouldBlock) => {
let mut named = String::new();
file.read_to_string(&mut named)?;
diff --git a/v2/contextdb/src/store/keys.rs b/v2/contextdb/src/store/keys.rs
index aec5d20f..f382547f 100644
--- a/v2/contextdb/src/store/keys.rs
+++ b/v2/contextdb/src/store/keys.rs
@@ -11,7 +11,7 @@
use std::ops::Bound;
-use super::Key;
+use super::{Key, VectorId, VectorRecord};
use crate::types::{Error, Result, SortDirection, Value};
/// No value's form begins with this byte, turned or not, so a key followed by it sorts after
@@ -111,3 +111,50 @@ pub(super) fn span(from: Bound<&Key>, to: Bound<&Key>) -> Span {
pub(super) fn stored(id: u32, key: &[u8]) -> Vec {
[&id.to_be_bytes(), key].concat()
}
+
+/// The first byte of the stored key of a block of the vector part. Blocks are kept in a table
+/// of their own.
+pub(super) const BLOCK: u8 = 0;
+const PARTITION: u8 = 1;
+const PENDING: u8 = 2;
+
+/// A record of the vector part as the data file keys it: one byte for its kind, the column,
+/// and then the rest of its fields in order, every number most significant byte first. So the
+/// records of one kind are one run of keys in the order of their fields: the columns follow
+/// each other by number, and within a column a partition's pending rows or blocks are one run.
+pub(super) fn vector(record: VectorRecord<'_>) -> Vec {
+ let number = u32::to_be_bytes;
+ match record {
+ VectorRecord::Block(column, partition, block) => {
+ let numbers = [number(column.0), number(partition), number(block)];
+ [&[BLOCK][..], numbers.as_flattened()].concat()
+ }
+ VectorRecord::Partition(column, tuple) => {
+ [&[PARTITION][..], &number(column.0), &tuple.0].concat()
+ }
+ VectorRecord::Pending(column, partition, row) => {
+ let numbers = [number(column.0), number(partition)];
+ [&[PENDING][..], numbers.as_flattened(), &row.0].concat()
+ }
+ }
+}
+
+/// The record a stored key of the vector part stands for. A key tuple or a row key is read
+/// into `key`, which the record then borrows.
+pub(super) fn vector_of<'a>(stored: &[u8], key: &'a mut Key) -> Option> {
+ let number = |at: usize| Some(u32::from_be_bytes(*stored.get(at..)?.first_chunk()?));
+ let column = VectorId(number(1)?);
+ key.0.clear();
+ Some(match stored[0] {
+ BLOCK => VectorRecord::Block(column, number(5)?, number(9)?),
+ PARTITION => {
+ key.0.extend_from_slice(&stored[5..]);
+ VectorRecord::Partition(column, key)
+ }
+ _ => {
+ let partition = number(5)?;
+ key.0.extend_from_slice(&stored[9..]);
+ VectorRecord::Pending(column, partition, key)
+ }
+ })
+}
diff --git a/v2/contextdb/src/store/mod.rs b/v2/contextdb/src/store/mod.rs
index d4a6b62c..7f9d6b6e 100644
--- a/v2/contextdb/src/store/mod.rs
+++ b/v2/contextdb/src/store/mod.rs
@@ -1,8 +1,9 @@
//! store: the folder and the data file; how each kind of record is keyed; views of one moment;
//! reader files and locks.
//!
-//! It never interprets SQL or a table's rules: it keeps rows, index entries, counts and schema
-//! records under the keys it is given and hands them back exactly as they were written.
+//! It never interprets SQL or a table's rules: it keeps rows, index entries, counts, schema
+//! records and the vector part's records under the keys it is given and hands them back exactly
+//! as they were written.
//!
//! Two of the things the parts meet at are defined here: [`Read`], the view to read from, and
//! [`ChangeSet`], the change set to write with.
@@ -28,12 +29,16 @@ use redb::{ReadTransaction, ReadableDatabase};
use crate::memory::{Budget, Reservation};
use crate::types::{Error, Result, SortDirection, TxId, Value};
use disk::{Moment, Stored};
-use folder::{DataFile, Hold};
+use folder::{DataFile, Hold, Locked};
use keys::Span;
-/// The size of the page cache of one open store, in bytes. It is reserved from the budget when
-/// the store is opened and does not change.
-const CACHE_BYTES: u64 = 8 << 20;
+/// The size of the page cache of a store opened under this memory limit, in bytes: a quarter
+/// of the limit and never less than 8 MiB, which is also its size when there is no limit. It is
+/// reserved from the budget when the store is opened and does not change until the store is
+/// closed, because the storage library fixes its cache when the data file is opened.
+fn cache_under(limit: Option) -> u64 {
+ (limit.unwrap_or(0) / 4).max(8 << 20)
+}
/// What a refusal names when the memory limit cannot hold the page cache.
const CACHE_NEEDED: &str = "page cache needed to open the store";
@@ -43,11 +48,14 @@ const CACHE_NEEDED: &str = "page cache needed to open the store";
///
/// It is given in [`crate::DatabaseOpenOptions::storage`] or
/// [`crate::ReadSessionOptions::storage`]; with none given the file is read and written as it
-/// is. The library opens and locks the data file itself, calls the function once for each open
-/// with the open file, and from then on reads and writes the file only through the
-/// [`StorageBackend`] the function returned. A session opened for reading hands over a file
-/// opened for reading only. A host wraps the file to keep the bytes somewhere else or to change
-/// them on the way; a test wraps it to count the calls, to fail one or to stop at one.
+/// is. The library opens and locks the data file itself, calls the function with the open file
+/// each time it has opened it, and from then on reads and writes the file only through the
+/// [`StorageBackend`] the function returned. That is once for each open, and twice for a writer
+/// that names no memory limit over a store that remembers one: the data file is opened to
+/// read the limit, and again with the page cache the limit calls for when that is another
+/// size. A session opened for reading hands over a file opened for reading only. A host wraps
+/// the file to keep the bytes somewhere else or to change them on the way; a test wraps it to
+/// count the calls, to fail one or to stop at one.
///
/// An error returned by the function, or by a call of what it returned, is reported as
/// [`crate::Error::StoreIo`]. The two exceptions are the kinds `UnexpectedEof` and
@@ -64,6 +72,40 @@ pub(crate) struct TableId(pub u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct IndexId(pub u32);
+/// A VECTOR column's number inside one store, given by `schema` like a [`TableId`]. Every record
+/// the vector part keeps for the column begins with it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub(crate) struct VectorId(pub u32);
+
+/// A record the vector part keeps. `store` decides the bytes of its key and where it is held;
+/// the value is the vector part's own and comes back exactly as it was put.
+///
+/// Records of one kind are in the order of their fields, left to right: every record of one
+/// column is one run of keys, and inside it every pending row or block of one partition is.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum VectorRecord<'a> {
+ /// What the column keeps about one partition, under the partition's key tuple: the values
+ /// of the PARTITION_KEY columns encoded as [`Key::from_values`] encodes them, ascending. A
+ /// column with no PARTITION_KEY has one partition, under the key of no values.
+ Partition(VectorId, &'a Key),
+ /// A row whose vector is in no block yet: the partition's number, then the row's key. Its
+ /// value is empty.
+ Pending(VectorId, u32, &'a Key),
+ /// One block of a partition: the partition's number, then the block's.
+ Block(VectorId, u32, u32),
+}
+
+/// A number the store keeps for the vector part.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum VectorNumber {
+ /// How many partitions a column has.
+ Partitions(VectorId),
+ /// The number the column's next new partition takes. It only ever rises.
+ NextPartition(VectorId),
+ /// How many pending records there are, over every column.
+ Pending,
+}
+
/// A primary key or an index entry, encoded so that comparing the bytes compares the values.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct Key(Vec);
@@ -83,6 +125,22 @@ impl Key {
keys::encode(values, directions).map(Key)
}
+ /// The bytes of the key, for a part that keeps keys inside a record of its own.
+ pub(crate) fn as_bytes(&self) -> &[u8] {
+ &self.0
+ }
+
+ /// The key that [`Key::as_bytes`] gave these bytes for.
+ pub(crate) fn from_bytes(bytes: &[u8]) -> Key {
+ Key(bytes.to_vec())
+ }
+
+ /// True when this key begins with every value of `prefix`, in order. Every key begins with
+ /// the key of no values.
+ pub(crate) fn starts_with(&self, prefix: &Key) -> bool {
+ self.0.starts_with(&prefix.0)
+ }
+
/// The first key after every key that begins with this one, `None` when there is none.
pub(crate) fn prefix_end(&self) -> Option {
let last = self.0.iter().rposition(|byte| *byte != 0xFF)?;
@@ -107,10 +165,13 @@ pub(crate) enum Cell {
Beside(Beside),
}
-/// Where a large value is kept and how many bytes loading it takes.
+/// Where a large value is kept and how many bytes are stored for it.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Beside {
+ /// The bytes of the value's stored record. A large text or JSON value may be stored
+ /// compressed, so this can be less than the bytes the loaded value takes.
bytes: u64,
+ /// The place of the first slice of the record; the others are under the places after it.
place: u64,
}
@@ -153,10 +214,46 @@ pub(crate) trait Read {
/// How many rows the table holds at this moment. One small record, never a scan.
fn count(&self, table: TableId) -> Result;
- /// Loads a value kept beside its row. Grows `reservation` by the bytes the value takes
- /// first and refuses with `MemoryBudgetExceeded` before reading anything if that does not
- /// fit.
+ /// Loads a value kept beside its row. Grows `reservation` by the bytes stored for the
+ /// value first and refuses with `MemoryBudgetExceeded` before reading anything if that does
+ /// not fit. A value that is stored compressed can be refused a second time, after its
+ /// stored bytes are read and before any byte of the value exists: the reservation is then
+ /// grown by the bytes the value really takes.
+ ///
+ /// After a load the reservation holds the bytes the value takes, however it was stored;
+ /// while a JSON or vector value is made it holds them twice. A load that fails gives back
+ /// all it grew. A stored length the stored bytes do not support is `StoreCorrupted`.
fn load(&self, beside: &Beside, reservation: &mut Reservation) -> Result;
+
+ /// Calls `each` with the key of every row of the table that this reader has staged over
+ /// its committed moment, in key order, until `each` answers false: the rows a transaction
+ /// has written or removed and not yet committed. A [`View`] has none.
+ fn staged(&self, table: TableId, each: &mut dyn FnMut(&Key) -> Result) -> Result<()>;
+
+ /// Calls `each` with the vector part's records of the kind of `from`, in key order, from
+ /// `from` itself or the first record after it, until `each` answers false or the kind has
+ /// no more records. Records of other columns follow those of `from`'s column, so the
+ /// caller stops where its own run ends.
+ ///
+ /// The value is lent, not copied: a block is handed over in place from the page cache, and
+ /// costs one read of the file when it is not there. A partition's key tuple compares with
+ /// `from` as a whole key, so the tuple of no values starts at a column's first partition.
+ ///
+ /// Vector records are staged only inside the commit function and by upkeep, after the last
+ /// read of their change set. A [`ChangeSet`] therefore answers from its committed moment.
+ fn vectors(
+ &self,
+ from: VectorRecord<'_>,
+ each: &mut dyn FnMut(VectorRecord<'_>, &[u8]) -> Result,
+ ) -> Result<()>;
+
+ /// One of the numbers kept for the vector part; zero when it was never put.
+ fn vector_number(&self, number: VectorNumber) -> Result;
+
+ /// The refusal of a record that its own bytes, or the records it goes with, do not
+ /// support: `StoreCorrupted`, naming the store's folder, with this reason. A part that
+ /// keeps records of its own tells the damage it finds in them with this.
+ fn damaged(&self, reason: &str) -> Error;
}
/// One committed moment of a store.
@@ -189,6 +286,11 @@ pub(crate) struct ChangeSet {
schema: BTreeMap>,
/// The memory limit the store is to remember, when one was staged: `Some(None)` for none.
memory_limit: Option>,
+ /// Staged records of the vector part by their stored key: the value, or `None` for a
+ /// removal.
+ vectors: BTreeMap>>,
+ /// Staged numbers of the vector part by their place among the store's numbers.
+ vector_numbers: BTreeMap,
/// Every staging call of a statement in order, each with what it put aside and the bytes it
/// was counted as, so that the calls after a mark can be undone.
calls: Vec<(Undo, u64)>,
@@ -212,6 +314,8 @@ enum Undo {
Entry(u32, Key, Option>),
Count(u32, i64),
Schema(String, Option>),
+ Vector(Key, Option>>),
+ VectorNumber(u64, Option),
}
/// What one staging call is counted as beside its key and its values: its place in the map
@@ -228,6 +332,8 @@ impl ChangeSet {
counts: BTreeMap::new(),
schema: BTreeMap::new(),
memory_limit: None,
+ vectors: BTreeMap::new(),
+ vector_numbers: BTreeMap::new(),
calls: Vec::new(),
reserved: budget.reserve(0, "transaction")?,
})
@@ -255,13 +361,16 @@ impl ChangeSet {
pub(crate) fn rewind(&mut self, mark: usize) {
for (undo, bytes) in self.calls.drain(mark..).rev() {
match undo {
- Undo::Row(table, key, before) => put_back(&mut self.rows, table, key, before),
+ Undo::Row(table, key, before) => {
+ put_back(self.rows.entry(table).or_default(), key, before);
+ }
Undo::Entry(index, joined, before) => {
- put_back(&mut self.index, index, joined, before);
+ put_back(self.index.entry(index).or_default(), joined, before);
}
Undo::Count(table, delta) => *self.counts.entry(table).or_default() -= delta,
- Undo::Schema(name, Some(before)) => drop(self.schema.insert(name, before)),
- Undo::Schema(name, None) => drop(self.schema.remove(&name)),
+ Undo::Schema(name, before) => put_back(&mut self.schema, name, before),
+ Undo::Vector(key, before) => put_back(&mut self.vectors, key, before),
+ Undo::VectorNumber(at, before) => put_back(&mut self.vector_numbers, at, before),
}
self.reserved.shrink(bytes);
}
@@ -349,6 +458,38 @@ impl ChangeSet {
Ok(())
}
+ /// Stages a record of the vector part, new or in place of the record with that key. The
+ /// value's bytes are charged to the change set's reservation until it is committed or
+ /// dropped.
+ pub(crate) fn put_vector(&mut self, record: VectorRecord<'_>, value: Vec) -> Result<()> {
+ self.stage_vector(record, Some(value))
+ }
+
+ /// Stages the removal of a record of the vector part. Removing a record that is not there
+ /// changes nothing.
+ pub(crate) fn delete_vector(&mut self, record: VectorRecord<'_>) -> Result<()> {
+ self.stage_vector(record, None)
+ }
+
+ fn stage_vector(&mut self, record: VectorRecord<'_>, value: Option>) -> Result<()> {
+ let key = Key(keys::vector(record));
+ let held = value.as_ref().map_or(0, Vec::len);
+ let bytes = self.counted(&key, held as u64)?;
+ let before = self.vectors.insert(key.clone(), value);
+ self.calls.push((Undo::Vector(key, before), bytes));
+ Ok(())
+ }
+
+ /// Stages one of the numbers kept for the vector part.
+ pub(crate) fn put_vector_number(&mut self, number: VectorNumber, value: u64) -> Result<()> {
+ self.reserved.grow(STAGED_BYTES)?;
+ let at = disk::vector_number_at(number);
+ let before = self.vector_numbers.insert(at, value);
+ self.calls
+ .push((Undo::VectorNumber(at, before), STAGED_BYTES));
+ Ok(())
+ }
+
/// Counts one staging call against the budget before anything is kept: its key twice, once
/// in the map and once in the list of calls, and what its values hold. Returns the bytes.
fn counted(&mut self, key: &Key, held: u64) -> Result {
@@ -374,8 +515,7 @@ impl ChangeSet {
}
/// Puts a staged record back as it was before one staging call.
-fn put_back(staged: &mut Staged, id: u32, key: Key, before: Option) {
- let records = staged.entry(id).or_default();
+fn put_back(records: &mut BTreeMap, key: K, before: Option) {
match before {
Some(before) => drop(records.insert(key, before)),
None => drop(records.remove(&key)),
@@ -496,12 +636,39 @@ impl Read for ChangeSet {
fn load(&self, beside: &Beside, reservation: &mut Reservation) -> Result {
self.base.load(beside, reservation)
}
+
+ fn staged(&self, table: TableId, each: &mut dyn FnMut(&Key) -> Result) -> Result<()> {
+ for key in self.rows.get(&table.0).into_iter().flat_map(BTreeMap::keys) {
+ if !each(key)? {
+ break;
+ }
+ }
+ Ok(())
+ }
+
+ fn vectors(
+ &self,
+ from: VectorRecord<'_>,
+ each: &mut dyn FnMut(VectorRecord<'_>, &[u8]) -> Result,
+ ) -> Result<()> {
+ self.base.vectors(from, each)
+ }
+
+ fn vector_number(&self, number: VectorNumber) -> Result {
+ self.base.vector_number(number)
+ }
+
+ fn damaged(&self, reason: &str) -> Error {
+ self.base.damaged(reason)
+ }
}
/// One open store folder, held either for writing or for reading.
pub(crate) struct Store {
held: HeldFor,
folder: Arc,
+ /// The budget the page cache is reserved from.
+ budget: Budget,
_cache: Reservation,
}
@@ -510,7 +677,7 @@ pub(crate) struct Store {
enum HeldFor {
Writing {
data: redb::Database,
- _lock: File,
+ _lock: Locked,
},
Reading {
data: redb::ReadOnlyDatabase,
@@ -556,10 +723,17 @@ impl Fixing {
impl HeldFor {
/// Takes the folder for writing, or answers with the id of the process that holds it.
+ /// With the folder comes the size of the page cache its data file was opened with.
+ ///
+ /// `limit` is the memory limit of the open's budget, and the cache is the one it calls
+ /// for. With none, the limit in force is the one the store remembers, which only the open
+ /// data file tells: the file is opened with the cache of no limit and, when the remembered
+ /// limit calls for another size, let go and opened again with that size.
fn writing(
folder: &Path,
storage: Option<&WrapStorage>,
- ) -> Stored> {
+ limit: Option,
+ ) -> Stored> {
fs::create_dir_all(folder)?;
let lock = match folder::take_lock(folder)? {
Ok(lock) => lock,
@@ -567,9 +741,26 @@ impl HeldFor {
};
let readers = folder.join("readers");
fs::create_dir_all(&readers)?;
- let gate = File::open(&readers)?;
// Removes what readers that are gone left behind.
- folder::oldest_held(&readers, &gate)?;
+ folder::oldest_held(&readers, &File::open(&readers)?)?;
+ let mut cache = cache_under(limit);
+ let mut data = HeldFor::data(folder, storage, cache)?;
+ disk::prepare(&data)?;
+ if limit.is_none() {
+ let remembered = cache_under(disk::remembered_limit(&data)?);
+ if remembered != cache {
+ drop(data);
+ cache = remembered;
+ data = HeldFor::data(folder, storage, cache)?;
+ }
+ }
+ Ok(Ok((HeldFor::Writing { data, _lock: lock }, cache)))
+ }
+
+ /// Opens the data file of a folder held for writing, with a page cache of `cache` bytes.
+ fn data(folder: &Path, storage: Option<&WrapStorage>, cache: u64) -> Stored {
+ let readers = folder.join("readers");
+ let gate = File::open(&readers)?;
let file = OpenOptions::new()
.read(true)
.write(true)
@@ -577,25 +768,29 @@ impl HeldFor {
.truncate(false)
.open(folder.join("data"))?;
let data = redb::Builder::new()
- .set_cache_size(CACHE_BYTES as usize)
+ .set_cache_size(cache as usize)
// When the files cannot be looked through, every commit counts as held.
.set_held_commits_callback(move || {
folder::oldest_held(&readers, &gate).unwrap_or(Some(0))
})
.create_with_backend(DataFile::over(file, storage)?)?;
- disk::prepare(&data)?;
- Ok(Ok(HeldFor::Writing { data, _lock: lock }))
+ Ok(data)
}
- /// Opens the folder for reading, or answers `None` when it has no data file.
- fn reading(folder: &Path, storage: Option<&WrapStorage>) -> Stored> {
+ /// Opens the folder for reading with a page cache of `cache` bytes, or answers `None` when
+ /// it has no data file.
+ fn reading(
+ folder: &Path,
+ storage: Option<&WrapStorage>,
+ cache: u64,
+ ) -> Stored > {
let file = match File::open(folder.join("data")) {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
let data = redb::Builder::new()
- .set_cache_size(CACHE_BYTES as usize)
+ .set_cache_size(cache as usize)
.open_read_only_with_backend(DataFile::over(file, storage)?)?;
let readers = folder.join("readers");
let fixing = Mutex::new(Fixing {
@@ -612,21 +807,33 @@ impl Store {
///
/// Takes the folder's `lock` for the life of the value and refuses at once with
/// `DatabaseLocked`, naming the holder, when another process has it. Clears what a killed
- /// reader or writer left behind. Reserves the page cache from `budget`. Costs a few
- /// milliseconds whatever the store's size: nothing is loaded.
+ /// reader or writer left behind. Costs a few milliseconds whatever the store's size:
+ /// nothing is loaded.
+ ///
+ /// Reserves the page cache from `budget`: the cache the budget's limit calls for, and with
+ /// no limit there the cache that the limit the store remembers calls for. The caller sets
+ /// the remembered limit on the budget afterwards, and the cache fits under it.
pub(crate) fn open_for_writing(
folder: &Path,
budget: &Budget,
storage: Option<&WrapStorage>,
) -> Result