sqlrite 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sqlrite-0.3.0 → sqlrite-0.4.0}/Cargo.lock +7 -7
- {sqlrite-0.3.0 → sqlrite-0.4.0}/Cargo.toml +2 -2
- {sqlrite-0.3.0 → sqlrite-0.4.0}/PKG-INFO +1 -1
- {sqlrite-0.3.0 → sqlrite-0.4.0}/desktop/package.json +1 -1
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/file-format.md +7 -4
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/pager.md +14 -1
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/supported-sql.md +20 -1
- {sqlrite-0.3.0 → sqlrite-0.4.0}/pyproject.toml +1 -1
- {sqlrite-0.3.0 → sqlrite-0.4.0}/scripts/bump-version.sh +38 -3
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sdk/python/Cargo.toml +1 -1
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sqlrite-ask/Cargo.toml +1 -1
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/executor.rs +54 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/mod.rs +30 -1
- sqlrite-0.4.0/src/sql/pager/allocator.rs +222 -0
- sqlrite-0.4.0/src/sql/pager/freelist.rs +258 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/pager/header.rs +26 -7
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/pager/mod.rs +664 -112
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/pager/overflow.rs +39 -22
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/pager/pager.rs +21 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/.github/workflows/ci.yml +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/.github/workflows/release-pr.yml +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/.github/workflows/release.yml +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/.github/workflows/rust.yml +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/.gitignore +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/CLAUDE.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/CODE_OF_CONDUCT.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/LICENSE +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/MAINTAINERS +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/Makefile +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/README.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/desktop/index.html +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/desktop/package-lock.json +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/desktop/src/App.svelte +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/desktop/src/app.css +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/desktop/src/main.ts +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/desktop/src/vite-env.d.ts +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/desktop/svelte.config.js +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/desktop/tsconfig.json +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/desktop/vite.config.ts +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/_index.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/architecture.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/ask-backend-examples.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/ask.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/design-decisions.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/desktop.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/embedding.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/fts.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/getting-started.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/mcp.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/phase-7-plan.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/phase-8-plan.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/release-plan.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/release-secrets.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/roadmap.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/smoke-test.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/sql-engine.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/storage-model.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/docs/usage.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/examples/README.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/examples/c/Makefile +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/examples/c/hello.c +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/examples/go/go.mod +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/examples/go/hello.go +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/examples/hybrid-retrieval/README.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/examples/hybrid-retrieval/hybrid_retrieval.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/examples/nodejs/hello.mjs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/examples/python/hello.py +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/examples/rust/quickstart.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/examples/wasm/Makefile +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/examples/wasm/index.html +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/examples/wasm/server.mjs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/images/SQLRite - Desktop.png +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/images/SQLRite Data Structures.png +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/images/SQLRite Simple SQL Execution High Level Diagram.png +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/images/SQLRite Simple SQL INSERT Execution High Level Diagram (Insert Row).png +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/images/SQLRite Simple SQL INSERT Execution High Level Diagram.png +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/images/SQLRite_logo.png +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/images/architecture.png +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/rust-toolchain.toml +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/samples/AST.delete.example +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/samples/AST.insert.exemple +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/samples/AST.select.example +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/samples/AST.update.example +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/samples/CREATE TABLE sqlrite_schema.sql +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/samples/CREATE_TABLE with duplicate.sql +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/samples/CREATE_TABLE.sql +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/samples/INSERT.sql +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sdk/go/README.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sdk/go/ask.go +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sdk/go/ask_test.go +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sdk/go/conn.go +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sdk/go/go.mod +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sdk/go/rows.go +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sdk/go/sqlrite.go +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sdk/go/sqlrite_test.go +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sdk/go/stmt.go +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sdk/python/README.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sdk/python/src/lib.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sdk/python/tests/test_ask.py +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sdk/python/tests/test_sqlrite.py +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sqlrite-ask/README.md +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sqlrite-ask/src/lib.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sqlrite-ask/src/prompt.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sqlrite-ask/src/provider/anthropic.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sqlrite-ask/src/provider/mock.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sqlrite-ask/src/provider/mod.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/sqlrite-ask/tests/anthropic_http.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/ask/mod.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/ask/schema.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/connection.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/error.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/lib.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/main.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/meta_command/mod.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/repl/mod.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/db/database.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/db/mod.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/db/secondary_index.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/db/table.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/fts/bm25.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/fts/mod.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/fts/posting_list.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/fts/tokenizer.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/hnsw.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/pager/cell.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/pager/file.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/pager/fts_cell.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/pager/hnsw_cell.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/pager/index_cell.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/pager/interior_page.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/pager/page.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/pager/table_page.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/pager/varint.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/pager/wal.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/parser/create.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/parser/insert.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/parser/mod.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/parser/select.rs +0 -0
- {sqlrite-0.3.0 → sqlrite-0.4.0}/src/sql/tokenizer.rs +0 -0
|
@@ -3817,7 +3817,7 @@ dependencies = [
|
|
|
3817
3817
|
|
|
3818
3818
|
[[package]]
|
|
3819
3819
|
name = "sqlrite-ask"
|
|
3820
|
-
version = "0.
|
|
3820
|
+
version = "0.4.0"
|
|
3821
3821
|
dependencies = [
|
|
3822
3822
|
"serde",
|
|
3823
3823
|
"serde_json",
|
|
@@ -3828,7 +3828,7 @@ dependencies = [
|
|
|
3828
3828
|
|
|
3829
3829
|
[[package]]
|
|
3830
3830
|
name = "sqlrite-desktop"
|
|
3831
|
-
version = "0.
|
|
3831
|
+
version = "0.4.0"
|
|
3832
3832
|
dependencies = [
|
|
3833
3833
|
"serde",
|
|
3834
3834
|
"serde_json",
|
|
@@ -3840,7 +3840,7 @@ dependencies = [
|
|
|
3840
3840
|
|
|
3841
3841
|
[[package]]
|
|
3842
3842
|
name = "sqlrite-engine"
|
|
3843
|
-
version = "0.
|
|
3843
|
+
version = "0.4.0"
|
|
3844
3844
|
dependencies = [
|
|
3845
3845
|
"clap",
|
|
3846
3846
|
"env_logger",
|
|
@@ -3857,7 +3857,7 @@ dependencies = [
|
|
|
3857
3857
|
|
|
3858
3858
|
[[package]]
|
|
3859
3859
|
name = "sqlrite-ffi"
|
|
3860
|
-
version = "0.
|
|
3860
|
+
version = "0.4.0"
|
|
3861
3861
|
dependencies = [
|
|
3862
3862
|
"cbindgen",
|
|
3863
3863
|
"serde",
|
|
@@ -3867,7 +3867,7 @@ dependencies = [
|
|
|
3867
3867
|
|
|
3868
3868
|
[[package]]
|
|
3869
3869
|
name = "sqlrite-mcp"
|
|
3870
|
-
version = "0.
|
|
3870
|
+
version = "0.4.0"
|
|
3871
3871
|
dependencies = [
|
|
3872
3872
|
"clap",
|
|
3873
3873
|
"libc",
|
|
@@ -3878,7 +3878,7 @@ dependencies = [
|
|
|
3878
3878
|
|
|
3879
3879
|
[[package]]
|
|
3880
3880
|
name = "sqlrite-nodejs"
|
|
3881
|
-
version = "0.
|
|
3881
|
+
version = "0.4.0"
|
|
3882
3882
|
dependencies = [
|
|
3883
3883
|
"napi",
|
|
3884
3884
|
"napi-build",
|
|
@@ -3888,7 +3888,7 @@ dependencies = [
|
|
|
3888
3888
|
|
|
3889
3889
|
[[package]]
|
|
3890
3890
|
name = "sqlrite-python"
|
|
3891
|
-
version = "0.
|
|
3891
|
+
version = "0.4.0"
|
|
3892
3892
|
dependencies = [
|
|
3893
3893
|
"pyo3",
|
|
3894
3894
|
"sqlrite-engine",
|
|
@@ -27,7 +27,7 @@ resolver = "3"
|
|
|
27
27
|
# `package =` key so the import name stays `sqlrite` internally:
|
|
28
28
|
# sqlrite = { package = "sqlrite-engine", path = "…" }
|
|
29
29
|
name = "sqlrite-engine"
|
|
30
|
-
version = "0.
|
|
30
|
+
version = "0.4.0"
|
|
31
31
|
authors = ["Joao Henrique Machado Silva <joaoh82@gmail.com>"]
|
|
32
32
|
edition = "2024"
|
|
33
33
|
rust-version = "1.85"
|
|
@@ -138,4 +138,4 @@ fs2 = { version = "0.4", optional = true }
|
|
|
138
138
|
# crate publishes to crates.io, and a path-only dep without a
|
|
139
139
|
# version field fails the manifest verification step. See PR #58
|
|
140
140
|
# retrospective in docs/roadmap.md.
|
|
141
|
-
sqlrite-ask = { version = "0.
|
|
141
|
+
sqlrite-ask = { version = "0.4.0", path = "sqlrite-ask", optional = true }
|
|
@@ -4,7 +4,7 @@ A SQLRite database is a single file, by convention named `*.sqlrite`. The file i
|
|
|
4
4
|
|
|
5
5
|
All multi-byte integers in this format are **little-endian**.
|
|
6
6
|
|
|
7
|
-
The current on-disk format is **version 4** (Phase 7) by default, with **version 5** written on demand whenever an FTS index is attached to the database (Phase 8c). Decoders accept
|
|
7
|
+
The current on-disk format is **version 4** (Phase 7) by default, with **version 5** written on demand whenever an FTS index is attached to the database (Phase 8c) and **version 6** written on demand whenever a save produces a non-empty freelist (SQLR-6). Decoders accept v4, v5, and v6; writers preserve the existing version on no-op resaves so a v4 database without FTS or freelist stays v4. Files produced by versions 1 – 3 are rejected on open.
|
|
8
8
|
|
|
9
9
|
## Page 0 — the database header
|
|
10
10
|
|
|
@@ -15,17 +15,18 @@ The first 4096 bytes of every file are the header page. Only the first 28 bytes
|
|
|
15
15
|
│ offset │ length │ content │
|
|
16
16
|
├────────┼────────┼─────────────────────────────────────────────────┤
|
|
17
17
|
│ 0 │ 16 │ magic: "SQLRiteFormat\0\0\0" │
|
|
18
|
-
│ 16 │ 2 │ format version (u16 LE) = 4 or
|
|
18
|
+
│ 16 │ 2 │ format version (u16 LE) = 4, 5, or 6 │
|
|
19
19
|
│ 18 │ 2 │ page size (u16 LE) = 4096 │
|
|
20
20
|
│ 20 │ 4 │ total page count (u32 LE), includes page 0 │
|
|
21
21
|
│ 24 │ 4 │ root page of sqlrite_master (u32 LE) │
|
|
22
|
-
│ 28 │
|
|
22
|
+
│ 28 │ 4 │ freelist head (u32 LE; 0 = empty) — v6 only │
|
|
23
|
+
│ 32 │ 4064 │ reserved / zero │
|
|
23
24
|
└────────┴────────┴─────────────────────────────────────────────────┘
|
|
24
25
|
```
|
|
25
26
|
|
|
26
27
|
The magic string is 14 ASCII bytes (`SQLRiteFormat`) padded with two NUL bytes to fill 16 bytes. It's deliberately different from SQLite's `"SQLite format 3\0"` so the two formats can't be confused on inspection.
|
|
27
28
|
|
|
28
|
-
`decode_header` in [`src/sql/pager/header.rs`](../src/sql/pager/header.rs) validates all three of (magic, format version, page size) on open. A wrong magic produces `not a SQLRite database`; a wrong version or page size produces `unsupported ...` errors. The decoder accepts
|
|
29
|
+
`decode_header` in [`src/sql/pager/header.rs`](../src/sql/pager/header.rs) validates all three of (magic, format version, page size) on open. A wrong magic produces `not a SQLRite database`; a wrong version or page size produces `unsupported ...` errors. The decoder accepts v4, v5, and v6 (anything else is rejected); the parsed `format_version` is propagated through the in-memory `DbHeader` so the writer can preserve it on resave when no version-bumping feature has been added. `freelist_head` is read from bytes [28..32]: v4/v5 files leave that region zero so it always decodes as `0` (an empty freelist), and v6 files store the page number of the first freelist trunk there.
|
|
29
30
|
|
|
30
31
|
## Pages 1..page_count — payload pages
|
|
31
32
|
|
|
@@ -53,6 +54,7 @@ Every non-header page starts with a 7-byte header:
|
|
|
53
54
|
| `2` | `TableLeaf` | Holds a slot directory and a set of cells representing rows of a table. Leaves for one table are linked by sibling `next_page` pointers. |
|
|
54
55
|
| `3` | `Overflow` | Continuation page carrying the spilled body of a single oversized cell. |
|
|
55
56
|
| `4` | `InteriorNode` | Interior B-Tree node. Holds a slot directory of divider cells routing to child pages plus a rightmost-child pointer in the payload header. |
|
|
57
|
+
| `5` | `FreelistTrunk` | One link of the persisted free-page list (SQLR-6). Payload carries `count: u16` followed by `count × u32` free leaf-page numbers; `next_page` chains to the next trunk (0 = end). |
|
|
56
58
|
|
|
57
59
|
Tag `1` is reserved (it was `SchemaRoot` in format v1; unused in v2). Any other tag on open is a corruption error.
|
|
58
60
|
|
|
@@ -307,6 +309,7 @@ These are not all enforced on open — we validate the header strictly and rely
|
|
|
307
309
|
- **v3** (Phase 3e) — `sqlrite_master` gains a `type` column; secondary indexes persist as their own cell-based B-Trees whose leaves carry `KIND_INDEX` cells.
|
|
308
310
|
- **v4** (Phase 7a) — value block dispatch gains the `0x04 Vector` tag for the new `VECTOR(N)` column type. Per the [Phase 7 plan's Q8](phase-7-plan.md#q8-file-format-version-bump), later Phase 7 sub-phases (JSON storage, HNSW indexes) added their own value/cell tags inside this same v4 envelope. The `CREATE TABLE` SQL stored in `sqlrite_master` carries vector columns as `VECTOR(N)` in the type position; on open, the engine re-parses that SQL and reconstructs `DataType::Vector(N)` from the `Custom` AST node sqlparser produces.
|
|
309
311
|
- **v5** (Phase 8c, current for FTS-bearing files) — adds the `KIND_FTS_POSTING` cell tag for persisted FTS posting lists. Bumped **on demand** per the [Phase 8 plan's Q10](phase-8-plan.md#q10-file-format-version-bump-strategy): existing v4 databases without FTS keep writing v4 across non-FTS saves; the first save with at least one FTS index attached promotes the file to v5. Decoders accept both v4 and v5; opening a v4 file with a build that supports v5 is a no-op until the user creates an FTS index.
|
|
312
|
+
- **v6** (SQLR-6, current for files with persisted free-page lists) — adds the `freelist_head` field at header bytes [28..32] and the `FreelistTrunk` page tag (`5`). Bumped **on demand**: a save that ends with an empty freelist preserves the existing version; the first save that produces a non-empty freelist promotes the file to v6. Decoders accept v4, v5, and v6; v6 is a strict superset, so opening a v4/v5 file with a v6-aware build is a no-op until the user creates a freelist (e.g., by dropping a table or index). VACUUM clears the freelist but doesn't downgrade.
|
|
310
313
|
|
|
311
314
|
The page header (7 bytes) and chaining mechanism are stable across future phases. Phase 4's WAL introduces a sibling file (`.sqlrite-wal`) rather than changing the main file format.
|
|
312
315
|
|
|
@@ -187,10 +187,23 @@ Without the diff, step 3's "re-serialize every table" would trigger a full file
|
|
|
187
187
|
|
|
188
188
|
This only works because `save_database` iterates tables in sorted order — if the order were random, a table that didn't change might land at a different page number, appearing dirty. See [Design decisions §7](design-decisions.md#7-deterministic-page-number-ordering-when-saving).
|
|
189
189
|
|
|
190
|
+
## Free-page list and VACUUM (SQLR-6)
|
|
191
|
+
|
|
192
|
+
Save now uses a [`PageAllocator`](../src/sql/pager/allocator.rs) instead of a bare `next_free_page` counter. The allocator pulls pages from three sources, in preference order:
|
|
193
|
+
|
|
194
|
+
1. **Per-table preferred pool** — every table/index/master is given the page numbers it occupied last save (collected by walking from its old `rootpage`). An unchanged table re-stages byte-identical pages at the same numbers, so the diff pager skips every write for it.
|
|
195
|
+
2. **Global freelist** — pages from dropped tables/indexes that are recorded in the persisted freelist (rooted at `header.freelist_head`).
|
|
196
|
+
3. **Extend** — `next_extend++`, monotonic past the high-water mark.
|
|
197
|
+
|
|
198
|
+
After staging, pages that were live before this save but didn't get restaged this round (e.g., the leaves of a dropped table) move onto the new freelist. The freelist itself is encoded into a chain of `FreelistTrunk` pages — each trunk holds up to 1021 free leaf-page numbers plus a `next_page` pointer to the following trunk. Trunks consume some of the free pages they describe (a trunk page IS a free page borrowed for metadata), so a freelist of N pages takes `ceil(N / 1022)` trunks and persists `N − T` leaf entries.
|
|
199
|
+
|
|
200
|
+
`VACUUM;` (a SQL statement) calls [`vacuum_database`](../src/sql/pager/mod.rs), which is `save_database` with empty per-table preferred pools and an empty initial freelist. Allocation falls through to extend on every page → contiguous layout from page 1, no freelist trunks, file truncates to the new high-water mark on the next checkpoint.
|
|
201
|
+
|
|
202
|
+
Format-version side effect: a save that produces a non-empty freelist promotes the file from v4/v5 to v6 (mirrors Phase 8c's v4→v5 FTS rule). VACUUM clears the freelist but doesn't downgrade — v6 is a strict superset.
|
|
203
|
+
|
|
190
204
|
## What it doesn't do (yet)
|
|
191
205
|
|
|
192
206
|
- **No LRU eviction.** `on_disk` + `wal_cache` together grow with the page count. For a 1 GiB database, that's ~1 GiB of page cache. Bounded cache is future work.
|
|
193
|
-
- **No free-page management.** When a table shrinks, the main file's tail pages are truncated at checkpoint, but there's no free-list to reuse pages inside a grown file.
|
|
194
207
|
- **No per-statement granularity.** The whole database is re-serialized on every commit; the diff keeps the *written* set small but the CPU cost of reserialization is unchanged.
|
|
195
208
|
- **No concurrent reader-and-writer.** Phase 4e graduated to shared/exclusive lock modes (multi-reader *or* single-writer), but POSIX flock can't give us both at once. True concurrent access would need a shared-memory coordination file with read marks — not on the roadmap.
|
|
196
209
|
- **Savepoints / nested transactions.** Phase 4f added top-level `BEGIN` / `COMMIT` / `ROLLBACK` (snapshot-based rollback, auto-save suppressed inside a transaction), but nested `BEGIN` is rejected — real savepoints aren't on the roadmap.
|
|
@@ -17,6 +17,7 @@ If you're looking for _how_ to use SQLRite (REPL flow, meta-commands, history, e
|
|
|
17
17
|
| [`ALTER TABLE`](#alter-table) | `RENAME TO`, `RENAME COLUMN`, `ADD COLUMN`, `DROP COLUMN` (one operation per statement) |
|
|
18
18
|
| [`DROP TABLE`](#drop-table) / [`DROP INDEX`](#drop-index) | `IF EXISTS`; single target; auto-indexes refused for `DROP INDEX` |
|
|
19
19
|
| [`BEGIN`](#transactions) / [`COMMIT`](#transactions) / [`ROLLBACK`](#transactions) | Snapshot-based; single-level; WAL-backed commit; auto-rollback on COMMIT disk failure |
|
|
20
|
+
| [`VACUUM`](#vacuum) | Compacts the file: rewrites every live B-Tree contiguously from page 1 and clears the freelist. Bare `VACUUM;` only — no modifiers. |
|
|
20
21
|
|
|
21
22
|
Statements the parser accepts (because sqlparser understands them in the SQLite dialect) but SQLRite doesn't execute yet return `SQL Statement not supported yet`. The [Not yet supported](#not-yet-supported) section below enumerates the common ones.
|
|
22
23
|
|
|
@@ -259,7 +260,7 @@ DROP TABLE [IF EXISTS] <table>;
|
|
|
259
260
|
- Reserved-name rejection: `DROP TABLE sqlrite_master` errors with the same message `CREATE TABLE` uses.
|
|
260
261
|
- All indexes attached to the table (auto, explicit, HNSW, FTS) disappear with the table — they live inside the `Table` struct and ride along.
|
|
261
262
|
- Without `IF EXISTS`, dropping a table that doesn't exist errors. With it, that's a benign 0-tables-dropped no-op.
|
|
262
|
-
- **Disk pages
|
|
263
|
+
- **Disk pages move onto the freelist.** Pages the dropped table occupied are pushed onto a persisted free-page list (SQLR-6) so subsequent `CREATE TABLE` or inserts can reuse them. The file doesn't shrink until [`VACUUM;`](#vacuum) compacts it.
|
|
263
264
|
|
|
264
265
|
---
|
|
265
266
|
|
|
@@ -428,6 +429,24 @@ ROLLBACK; -- nothing was actually deleted
|
|
|
428
429
|
|
|
429
430
|
---
|
|
430
431
|
|
|
432
|
+
## `VACUUM`
|
|
433
|
+
|
|
434
|
+
```sql
|
|
435
|
+
VACUUM;
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
Compacts the database file: rewrites every live table, index, HNSW graph, FTS posting tree, and `sqlrite_master` itself contiguously from page 1, drops the freelist, and lets the next checkpoint truncate the tail.
|
|
439
|
+
|
|
440
|
+
- **Bare `VACUUM;` only.** Modifiers — `VACUUM FULL`, `VACUUM REINDEX`, table targets, `TO ... PERCENT`, `BOOST` — are parsed (sqlparser supports them) but rejected at execution with `VACUUM modifiers (FULL, REINDEX, table targets, etc.) are not supported`.
|
|
441
|
+
- **Refused inside a transaction.** `BEGIN; VACUUM;` errors with `VACUUM cannot run inside a transaction`. Use `COMMIT;` first, then `VACUUM;`.
|
|
442
|
+
- **No-op on in-memory databases.** Returns a `VACUUM is a no-op for in-memory databases` status string and does nothing — there's no file to compact.
|
|
443
|
+
- **Status string** carries pages and bytes reclaimed: `VACUUM completed. <N> pages reclaimed (<B> bytes).`
|
|
444
|
+
- **Format-version side effect.** A v4/v5 file that has been promoted to v6 by an earlier drop stays at v6 after VACUUM (v6 is a strict superset; we don't downgrade). A file that's already at v4/v5 because no drop ever happened on it doesn't get bumped by VACUUM.
|
|
445
|
+
|
|
446
|
+
When to run it: any time after a string of `DROP TABLE` / `DROP INDEX` / `ALTER TABLE DROP COLUMN` operations if you care about file size. SQLRite reuses freelist pages on subsequent inserts, so a write-heavy workload may not need VACUUM at all — its main use is reclaiming space when you don't expect to grow back.
|
|
447
|
+
|
|
448
|
+
---
|
|
449
|
+
|
|
431
450
|
## Read-only databases
|
|
432
451
|
|
|
433
452
|
A REPL launched with `sqlrite --readonly foo.sqlrite` (or `sqlrite::open_database_read_only(path, name)` programmatically) takes a shared POSIX advisory lock instead of an exclusive one. In that mode:
|
|
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sqlrite"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Python bindings for SQLRite — a small, embeddable SQLite clone written in Rust."
|
|
9
9
|
authors = [{ name = "Joao Henrique Machado Silva", email = "joaoh82@gmail.com" }]
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
# scripts/bump-version.sh 0.2.0
|
|
7
7
|
#
|
|
8
8
|
# Rewrites the version field in every manifest that carries one
|
|
9
|
-
# (
|
|
10
|
-
# —
|
|
9
|
+
# (nine Cargo.toml / pyproject.toml files, plus three JSON manifests
|
|
10
|
+
# — twelve files total). Then you run `cargo build` yourself to
|
|
11
11
|
# refresh Cargo.lock. Idempotent: running twice with the same version
|
|
12
12
|
# is a no-op; running twice with different versions lands on the
|
|
13
13
|
# second.
|
|
@@ -84,6 +84,25 @@ for file in "${TOML_FILES[@]}"; do
|
|
|
84
84
|
fi
|
|
85
85
|
sed "s/^version = \"[^\"]*\"/version = \"${VERSION}\"/" "$file" > "$file.tmp"
|
|
86
86
|
mv "$file.tmp" "$file"
|
|
87
|
+
|
|
88
|
+
# Inter-workspace dep pins — lines like:
|
|
89
|
+
# sqlrite-ask = { version = "0.3", path = "sqlrite-ask", ... }
|
|
90
|
+
# sqlrite = { package = "sqlrite-engine", path = "..", version = "0.3", ... }
|
|
91
|
+
#
|
|
92
|
+
# These carry BOTH version and path because crates.io publishing
|
|
93
|
+
# rejects path-only deps (see PR #58 retrospective). The version
|
|
94
|
+
# field has to track the workspace bump or `cargo build` fails to
|
|
95
|
+
# resolve a candidate (SQLR-9; failed run for v0.3.0 hit exactly
|
|
96
|
+
# this — `failed to select a version for the requirement
|
|
97
|
+
# sqlrite-ask = "^0.2"`).
|
|
98
|
+
#
|
|
99
|
+
# Detection: any line containing both `version = "..."` and
|
|
100
|
+
# `path = "..."`. The package-level `^version = "..."` line at
|
|
101
|
+
# the top of each manifest has no `path` on it and can't match.
|
|
102
|
+
# Both inline-table orderings (version-first and path-first) work
|
|
103
|
+
# because sed acts per-line, not per-token-order.
|
|
104
|
+
sed -E '/path *= *"[^"]*"/ s/version *= *"[^"]*"/version = "'"${VERSION}"'"/' "$file" > "$file.tmp"
|
|
105
|
+
mv "$file.tmp" "$file"
|
|
87
106
|
done
|
|
88
107
|
|
|
89
108
|
# ---------------------------------------------------------------------------
|
|
@@ -147,6 +166,22 @@ for file in "${JSON_FILES[@]}"; do
|
|
|
147
166
|
fi
|
|
148
167
|
done
|
|
149
168
|
|
|
169
|
+
# Inter-workspace pin sweep — any surviving `version = "X"` on a TOML
|
|
170
|
+
# line that also has `path = "..."` and isn't already at $VERSION is a
|
|
171
|
+
# pin we missed. Catches future refactors that change pin shape (e.g.
|
|
172
|
+
# someone splits a long dep line across multiple TOML lines, where the
|
|
173
|
+
# single-line address would no longer match).
|
|
174
|
+
for file in "${TOML_FILES[@]}"; do
|
|
175
|
+
bad="$(grep -nE 'path *= *"[^"]*"' "$file" \
|
|
176
|
+
| grep -E 'version *= *"[^"]*"' \
|
|
177
|
+
| grep -vE "version *= *\"${VERSION}\"" || true)"
|
|
178
|
+
if [[ -n "$bad" ]]; then
|
|
179
|
+
echo " ✗ $file — inter-workspace pin not at ${VERSION}:" >&2
|
|
180
|
+
echo "$bad" | sed 's/^/ /' >&2
|
|
181
|
+
FAILURES=$((FAILURES + 1))
|
|
182
|
+
fi
|
|
183
|
+
done
|
|
184
|
+
|
|
150
185
|
if [[ $FAILURES -gt 0 ]]; then
|
|
151
186
|
echo
|
|
152
187
|
echo "error: $FAILURES file(s) did not update as expected." >&2
|
|
@@ -157,5 +192,5 @@ fi
|
|
|
157
192
|
echo
|
|
158
193
|
echo "Done. Next steps:"
|
|
159
194
|
echo " cargo build # refresh Cargo.lock with the new versions"
|
|
160
|
-
echo " git diff # inspect the
|
|
195
|
+
echo " git diff # inspect the twelve-file bump"
|
|
161
196
|
echo " git checkout . # or back out if it looks wrong"
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
# Published to crates.io as `sqlrite-ask`. Joins the lockstep release
|
|
11
11
|
# wave (`sqlrite-ask-vX.Y.Z` tag) — see `docs/release-plan.md`.
|
|
12
12
|
name = "sqlrite-ask"
|
|
13
|
-
version = "0.
|
|
13
|
+
version = "0.4.0"
|
|
14
14
|
authors = ["Joao Henrique Machado Silva <joaoh82@gmail.com>"]
|
|
15
15
|
edition = "2024"
|
|
16
16
|
rust-version = "1.85"
|
|
@@ -717,6 +717,60 @@ pub fn execute_alter_table(alter: AlterTable, db: &mut Database) -> Result<Strin
|
|
|
717
717
|
}
|
|
718
718
|
}
|
|
719
719
|
|
|
720
|
+
/// Executes `VACUUM;` (SQLR-6). Compacts the database file: rewrites
|
|
721
|
+
/// every live table, index, and the catalog contiguously from page 1,
|
|
722
|
+
/// drops the freelist, and truncates the tail at the next checkpoint.
|
|
723
|
+
///
|
|
724
|
+
/// Refuses to run inside a transaction (would publish in-flight writes
|
|
725
|
+
/// out of band); refuses on read-only databases (handled upstream by
|
|
726
|
+
/// the read-only mutation gate); and is a no-op on in-memory databases
|
|
727
|
+
/// (no file to compact). Bare `VACUUM;` only — non-default options
|
|
728
|
+
/// (`FULL`, `REINDEX`, table targets, etc.) are rejected.
|
|
729
|
+
pub fn execute_vacuum(db: &mut Database) -> Result<String> {
|
|
730
|
+
if db.in_transaction() {
|
|
731
|
+
return Err(SQLRiteError::General(
|
|
732
|
+
"VACUUM cannot run inside a transaction".to_string(),
|
|
733
|
+
));
|
|
734
|
+
}
|
|
735
|
+
let path = match db.source_path.clone() {
|
|
736
|
+
Some(p) => p,
|
|
737
|
+
None => {
|
|
738
|
+
return Ok("VACUUM is a no-op for in-memory databases".to_string());
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
// Checkpoint before AND after VACUUM so the main-file size we report
|
|
742
|
+
// reflects only what VACUUM actually reclaimed — without the leading
|
|
743
|
+
// checkpoint, `size_before` would be the stale main-file snapshot
|
|
744
|
+
// (typically 2 pages) while WAL holds the live bytes, making the
|
|
745
|
+
// bytes-reclaimed delta meaningless.
|
|
746
|
+
if let Some(pager) = db.pager.as_mut() {
|
|
747
|
+
let _ = pager.checkpoint();
|
|
748
|
+
}
|
|
749
|
+
let size_before = std::fs::metadata(&path).ok().map(|m| m.len()).unwrap_or(0);
|
|
750
|
+
let pages_before = db
|
|
751
|
+
.pager
|
|
752
|
+
.as_ref()
|
|
753
|
+
.map(|p| p.header().page_count)
|
|
754
|
+
.unwrap_or(0);
|
|
755
|
+
crate::sql::pager::vacuum_database(db, &path)?;
|
|
756
|
+
// Second checkpoint so the main file shrinks now — VACUUM's whole
|
|
757
|
+
// purpose is to reclaim bytes, so paying the I/O up front is fair.
|
|
758
|
+
if let Some(pager) = db.pager.as_mut() {
|
|
759
|
+
let _ = pager.checkpoint();
|
|
760
|
+
}
|
|
761
|
+
let size_after = std::fs::metadata(&path).ok().map(|m| m.len()).unwrap_or(0);
|
|
762
|
+
let pages_after = db
|
|
763
|
+
.pager
|
|
764
|
+
.as_ref()
|
|
765
|
+
.map(|p| p.header().page_count)
|
|
766
|
+
.unwrap_or(0);
|
|
767
|
+
let pages_reclaimed = pages_before.saturating_sub(pages_after);
|
|
768
|
+
let bytes_reclaimed = size_before.saturating_sub(size_after);
|
|
769
|
+
Ok(format!(
|
|
770
|
+
"VACUUM completed. {pages_reclaimed} pages reclaimed ({bytes_reclaimed} bytes)."
|
|
771
|
+
))
|
|
772
|
+
}
|
|
773
|
+
|
|
720
774
|
/// Renames a table in `db.tables`. Updates `tb_name`, every secondary
|
|
721
775
|
/// index's `table_name` field, and any auto-index whose name embedded
|
|
722
776
|
/// the old table name. HNSW / FTS index entries don't carry a
|
|
@@ -160,6 +160,9 @@ pub fn process_command_with_render(query: &str, db: &mut Database) -> Result<Com
|
|
|
160
160
|
|
|
161
161
|
// Statements that mutate state — trigger auto-save on success. Read-only
|
|
162
162
|
// SELECTs skip the save entirely to avoid pointless file writes.
|
|
163
|
+
// VACUUM is a write statement (rewrites the entire file) but it does
|
|
164
|
+
// its own save internally, so it's also explicitly excluded from the
|
|
165
|
+
// post-dispatch auto-save block at the bottom.
|
|
163
166
|
let is_write_statement = matches!(
|
|
164
167
|
&query,
|
|
165
168
|
Statement::CreateTable(_)
|
|
@@ -169,7 +172,9 @@ pub fn process_command_with_render(query: &str, db: &mut Database) -> Result<Com
|
|
|
169
172
|
| Statement::Delete(_)
|
|
170
173
|
| Statement::Drop { .. }
|
|
171
174
|
| Statement::AlterTable(_)
|
|
175
|
+
| Statement::Vacuum(_)
|
|
172
176
|
);
|
|
177
|
+
let is_vacuum = matches!(&query, Statement::Vacuum(_));
|
|
173
178
|
|
|
174
179
|
// Early-reject mutations on a read-only database before they touch
|
|
175
180
|
// in-memory state. Phase 4e: without this, a user running INSERT
|
|
@@ -338,6 +343,27 @@ pub fn process_command_with_render(query: &str, db: &mut Database) -> Result<Com
|
|
|
338
343
|
Statement::AlterTable(alter) => {
|
|
339
344
|
message = executor::execute_alter_table(alter, db)?;
|
|
340
345
|
}
|
|
346
|
+
Statement::Vacuum(vac) => {
|
|
347
|
+
// SQLR-6 — only bare `VACUUM;` is supported. The crate-level
|
|
348
|
+
// `VacuumStatement` carries Redshift-style modifiers we don't
|
|
349
|
+
// implement; reject any non-default flag rather than silently
|
|
350
|
+
// ignoring it.
|
|
351
|
+
if vac.full
|
|
352
|
+
|| vac.sort_only
|
|
353
|
+
|| vac.delete_only
|
|
354
|
+
|| vac.reindex
|
|
355
|
+
|| vac.recluster
|
|
356
|
+
|| vac.boost
|
|
357
|
+
|| vac.table_name.is_some()
|
|
358
|
+
|| vac.threshold.is_some()
|
|
359
|
+
{
|
|
360
|
+
return Err(SQLRiteError::NotImplemented(
|
|
361
|
+
"VACUUM modifiers (FULL, REINDEX, table targets, etc.) are not supported; use bare VACUUM;"
|
|
362
|
+
.to_string(),
|
|
363
|
+
));
|
|
364
|
+
}
|
|
365
|
+
message = executor::execute_vacuum(db)?;
|
|
366
|
+
}
|
|
341
367
|
_ => {
|
|
342
368
|
return Err(SQLRiteError::NotImplemented(
|
|
343
369
|
"SQL Statement not supported yet.".to_string(),
|
|
@@ -355,7 +381,10 @@ pub fn process_command_with_render(query: &str, db: &mut Database) -> Result<Com
|
|
|
355
381
|
// mutated, so the caller should know disk is out of sync. The
|
|
356
382
|
// Pager held on `db` diffs against its last-committed snapshot,
|
|
357
383
|
// so only pages whose bytes actually changed are written.
|
|
358
|
-
|
|
384
|
+
//
|
|
385
|
+
// VACUUM is a write-shaped statement but already wrote the file
|
|
386
|
+
// internally — skip the second save to avoid undoing the compact.
|
|
387
|
+
if is_write_statement && !is_vacuum && db.source_path.is_some() && !db.in_transaction() {
|
|
359
388
|
let path = db.source_path.clone().unwrap();
|
|
360
389
|
pager::save_database(db, &path)?;
|
|
361
390
|
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
//! Page allocator for `save_database` (SQLR-6).
|
|
2
|
+
//!
|
|
3
|
+
//! Replaces the bare `next_free_page: u32` counter that the staging code
|
|
4
|
+
//! used to thread through every `stage_*_btree` function. The allocator
|
|
5
|
+
//! draws from three pools, in order of preference:
|
|
6
|
+
//!
|
|
7
|
+
//! 1. **Per-table preferred pool** — pages the table previously occupied,
|
|
8
|
+
//! seeded by [`set_preferred`]. An unchanged table's stage produces
|
|
9
|
+
//! byte-identical pages at the same numbers, so the diff pager skips
|
|
10
|
+
//! every write for it.
|
|
11
|
+
//! 2. **Global freelist** — pages dropped tables/indexes used to occupy
|
|
12
|
+
//! plus the trunk pages of the previously-persisted freelist.
|
|
13
|
+
//! 3. **Extend** — `next_extend++`, monotonic past the current high water.
|
|
14
|
+
//!
|
|
15
|
+
//! After staging finishes, [`high_water`] is the new `page_count` and
|
|
16
|
+
//! [`used`] enumerates every page actually written this save (so the
|
|
17
|
+
//! caller can compute the new freelist as `old_live − used`).
|
|
18
|
+
|
|
19
|
+
use std::collections::{HashSet, VecDeque};
|
|
20
|
+
|
|
21
|
+
/// Hands out page numbers during a save.
|
|
22
|
+
///
|
|
23
|
+
/// Lifetime: one allocator per `save_database` call. Not thread-safe; not
|
|
24
|
+
/// shared across saves.
|
|
25
|
+
pub struct PageAllocator {
|
|
26
|
+
/// Pages available globally. Drained after the per-table pool is empty.
|
|
27
|
+
/// Stored as a VecDeque so callers can append (push_back) and we always
|
|
28
|
+
/// hand them out front-first for ascending-order determinism.
|
|
29
|
+
freelist: VecDeque<u32>,
|
|
30
|
+
/// The current table's preferred pool (its previous-rootpage pages).
|
|
31
|
+
/// Drained before [`freelist`]. Cleared between tables by
|
|
32
|
+
/// [`finish_preferred`].
|
|
33
|
+
preferred: VecDeque<u32>,
|
|
34
|
+
/// Next page number for fresh extension. Page 0 is the header, so
|
|
35
|
+
/// the first alloc always returns ≥ 1.
|
|
36
|
+
next_extend: u32,
|
|
37
|
+
/// Every page handed out this save. Used to compute the newly-freed
|
|
38
|
+
/// set after staging completes.
|
|
39
|
+
used: HashSet<u32>,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
impl PageAllocator {
|
|
43
|
+
/// `freelist` carries the pages from the previously-persisted
|
|
44
|
+
/// freelist (sorted ascending by the caller). `next_extend` is
|
|
45
|
+
/// typically `1` for a brand-new save.
|
|
46
|
+
pub fn new(freelist: VecDeque<u32>, next_extend: u32) -> Self {
|
|
47
|
+
let mut alloc = Self {
|
|
48
|
+
freelist,
|
|
49
|
+
preferred: VecDeque::new(),
|
|
50
|
+
next_extend,
|
|
51
|
+
used: HashSet::new(),
|
|
52
|
+
};
|
|
53
|
+
// Defensive: a corrupt freelist could push the high-water mark
|
|
54
|
+
// higher than `next_extend` claims. Bump so we never hand out a
|
|
55
|
+
// duplicate page on extend.
|
|
56
|
+
let max_free = alloc.freelist.iter().copied().max().unwrap_or(0);
|
|
57
|
+
if max_free + 1 > alloc.next_extend {
|
|
58
|
+
alloc.next_extend = max_free + 1;
|
|
59
|
+
}
|
|
60
|
+
alloc
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Seeds the per-table preferred pool. Drained on subsequent
|
|
64
|
+
/// [`allocate`] calls before any other source.
|
|
65
|
+
pub fn set_preferred(&mut self, mut pool: Vec<u32>) {
|
|
66
|
+
// Sort ascending so the order matches the linear staging order
|
|
67
|
+
// and unchanged tables get byte-identical leaves.
|
|
68
|
+
pool.sort_unstable();
|
|
69
|
+
pool.dedup();
|
|
70
|
+
// Filter out anything the allocator has already handed out
|
|
71
|
+
// (defensive — shouldn't happen but keeps the invariant tidy).
|
|
72
|
+
pool.retain(|p| !self.used.contains(p));
|
|
73
|
+
self.preferred = VecDeque::from(pool);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Empties the per-table preferred pool, returning any leftover
|
|
77
|
+
/// pages to the global freelist (they're now free again).
|
|
78
|
+
pub fn finish_preferred(&mut self) {
|
|
79
|
+
while let Some(p) = self.preferred.pop_front() {
|
|
80
|
+
if !self.used.contains(&p) {
|
|
81
|
+
self.freelist.push_back(p);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// Returns the next page to write. Picks from preferred → freelist →
|
|
87
|
+
/// extend. Records the result in `used` and bumps `next_extend` if
|
|
88
|
+
/// the page came from one of the pools and was past the current
|
|
89
|
+
/// high water.
|
|
90
|
+
pub fn allocate(&mut self) -> u32 {
|
|
91
|
+
let page = if let Some(p) = self.preferred.pop_front() {
|
|
92
|
+
p
|
|
93
|
+
} else if let Some(p) = self.freelist.pop_front() {
|
|
94
|
+
p
|
|
95
|
+
} else {
|
|
96
|
+
let p = self.next_extend;
|
|
97
|
+
self.next_extend += 1;
|
|
98
|
+
p
|
|
99
|
+
};
|
|
100
|
+
if page >= self.next_extend {
|
|
101
|
+
self.next_extend = page + 1;
|
|
102
|
+
}
|
|
103
|
+
// A double-allocation is an internal bug; assert in debug.
|
|
104
|
+
debug_assert!(
|
|
105
|
+
!self.used.contains(&page),
|
|
106
|
+
"PageAllocator handed out page {page} twice"
|
|
107
|
+
);
|
|
108
|
+
self.used.insert(page);
|
|
109
|
+
page
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// Adds pages to the global freelist. Used to drop pages that the
|
|
113
|
+
/// caller traversed but didn't end up restaging (e.g., a dropped
|
|
114
|
+
/// table's leaves; the previous freelist's trunk pages).
|
|
115
|
+
///
|
|
116
|
+
/// Bumps `next_extend` past any added page so the final page_count
|
|
117
|
+
/// covers freelist trunks even if they live past the highest used
|
|
118
|
+
/// payload page.
|
|
119
|
+
pub fn add_to_freelist(&mut self, pages: impl IntoIterator<Item = u32>) {
|
|
120
|
+
for p in pages {
|
|
121
|
+
// Skip pages already used (we already restaged them) or
|
|
122
|
+
// already on the list.
|
|
123
|
+
if !self.used.contains(&p) && !self.freelist.contains(&p) {
|
|
124
|
+
self.freelist.push_back(p);
|
|
125
|
+
if p + 1 > self.next_extend {
|
|
126
|
+
self.next_extend = p + 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// Page-count to publish in the new header. Equal to
|
|
133
|
+
/// `1 + max page handed out` after staging.
|
|
134
|
+
pub fn high_water(&self) -> u32 {
|
|
135
|
+
self.next_extend
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// Every page handed out this save.
|
|
139
|
+
pub fn used(&self) -> &HashSet<u32> {
|
|
140
|
+
&self.used
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// Snapshot of pages still on the global freelist (i.e., free pages
|
|
144
|
+
/// that need to be persisted into trunk pages). Sorted ascending so
|
|
145
|
+
/// the encoded freelist trunks are deterministic.
|
|
146
|
+
pub fn drain_freelist(&mut self) -> Vec<u32> {
|
|
147
|
+
let mut v: Vec<u32> = self.freelist.drain(..).collect();
|
|
148
|
+
v.sort_unstable();
|
|
149
|
+
v.dedup();
|
|
150
|
+
v
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#[cfg(test)]
|
|
155
|
+
mod tests {
|
|
156
|
+
use super::*;
|
|
157
|
+
|
|
158
|
+
#[test]
|
|
159
|
+
fn allocate_extends_when_pools_empty() {
|
|
160
|
+
let mut a = PageAllocator::new(VecDeque::new(), 1);
|
|
161
|
+
assert_eq!(a.allocate(), 1);
|
|
162
|
+
assert_eq!(a.allocate(), 2);
|
|
163
|
+
assert_eq!(a.allocate(), 3);
|
|
164
|
+
assert_eq!(a.high_water(), 4);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#[test]
|
|
168
|
+
fn preferred_pool_drains_first() {
|
|
169
|
+
let mut a = PageAllocator::new(VecDeque::from([8, 9]), 1);
|
|
170
|
+
a.set_preferred(vec![3, 4]);
|
|
171
|
+
assert_eq!(a.allocate(), 3);
|
|
172
|
+
assert_eq!(a.allocate(), 4);
|
|
173
|
+
// After preferred drains, freelist takes over.
|
|
174
|
+
assert_eq!(a.allocate(), 8);
|
|
175
|
+
assert_eq!(a.allocate(), 9);
|
|
176
|
+
// Then extend.
|
|
177
|
+
assert_eq!(a.allocate(), 10);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#[test]
|
|
181
|
+
fn freelist_drains_after_preferred() {
|
|
182
|
+
let mut a = PageAllocator::new(VecDeque::from([5, 7]), 1);
|
|
183
|
+
assert_eq!(a.allocate(), 5);
|
|
184
|
+
assert_eq!(a.allocate(), 7);
|
|
185
|
+
assert_eq!(a.allocate(), 8); // extend bumped because max free was 7
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#[test]
|
|
189
|
+
fn finish_preferred_returns_leftovers_to_freelist() {
|
|
190
|
+
let mut a = PageAllocator::new(VecDeque::new(), 1);
|
|
191
|
+
a.set_preferred(vec![3, 4, 5]);
|
|
192
|
+
assert_eq!(a.allocate(), 3); // used 3
|
|
193
|
+
a.finish_preferred();
|
|
194
|
+
// Now 4 and 5 should be on the freelist.
|
|
195
|
+
assert_eq!(a.allocate(), 4);
|
|
196
|
+
assert_eq!(a.allocate(), 5);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#[test]
|
|
200
|
+
fn add_to_freelist_skips_already_used() {
|
|
201
|
+
let mut a = PageAllocator::new(VecDeque::new(), 1);
|
|
202
|
+
let p = a.allocate(); // 1
|
|
203
|
+
a.add_to_freelist([p, 5, 6]);
|
|
204
|
+
let drained = a.drain_freelist();
|
|
205
|
+
assert!(
|
|
206
|
+
!drained.contains(&p),
|
|
207
|
+
"used page should not land on freelist"
|
|
208
|
+
);
|
|
209
|
+
assert_eq!(drained, vec![5, 6]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#[test]
|
|
213
|
+
fn next_extend_respects_max_free() {
|
|
214
|
+
// High pages on the freelist should bump next_extend so the
|
|
215
|
+
// allocator never collides with them on extend.
|
|
216
|
+
let mut a = PageAllocator::new(VecDeque::from([100]), 1);
|
|
217
|
+
// First alloc draws from freelist.
|
|
218
|
+
assert_eq!(a.allocate(), 100);
|
|
219
|
+
// Subsequent extend lands at 101, not 1.
|
|
220
|
+
assert_eq!(a.allocate(), 101);
|
|
221
|
+
}
|
|
222
|
+
}
|