Skip to content

Commit 14edbf4

Browse files
authored
refactor: make Schema trait support composite key in the future (#442)
* refactor: make Schema trait support composite key in the future **Primary Key API** - Removed: primary_key_index() and primary_key_path(). - Added/standardized: - primary_key_indices(): returns &[usize] for all PK columns. - primary_key_paths_and_sorting(): returns (&[ColumnPath], &[SortingColumn]) t o avoid allocations. **Call Sites Updated** - src/lib.rs: get() and Scan projection paths now include all PK indices (fixed projection = [0, 1] ∪ PKs). - src/transaction.rs: local record projection uses all PK indices. - src/option.rs: DbOption::new() reads slices from Schema, clones sorting column s once, configures stats/bloom per PK path. **Macro Generation** - tonbo_macros/src/record.rs: - Generates primary_key_indices() returning a static one-element slice for sin gle-PK records. - Generates primary_key_paths_and_sorting() returning slices backed by once_ce ll::Lazy<Vec<...>>. - Stops generating primary_key_index() and primary_key_path(). **Dynamic Records** - DynSchema: - Struct: now stores primary_index_arrow, pk_paths, sorting, arrow_schema. - Removed stored schema/primary_index fields (leaner and avoids dead_code). - API: primary_key_indices() returns &[primary_index_arrow], primary_key_paths _and_sorting() returns (&pk_paths, &sorting). - DynSchema::new(&[DynamicField], usize): accepts a slice; no owned Vec. - from_arrow_schema(): builds temporary fields vector only to derive PK column name. - Macros (dyn_schema!, make_dyn_schema!): pass slice literals to DynSchema::new. **Bindings** - Python (bindings/python/src/db.rs): DynSchema::new(&desc, idx). - JS/WASM (bindings/js/src/db.rs): DynSchema::new(&desc, idx). **Tests & Examples** - tests/macros_correctness.rs: switched to primary_key_indices()[0] and slice-ba sed primary_key_paths_and_sorting() assertions. - Updated internal test schemas (src/inmem/immutable.rs, src/record/test.rs) to implement slice-based APIs using once_cell::Lazy. - Adjusted internal tests to pass slices to DynSchema::new. **Docs** - guide/src/contribution/composite_primary_keys.md: - Documented removal of primary_key_index() and use of primary_key_indices(). - Clarified projections: fixed projection = [0, 1] ∪ PKs. - Noted new slice-based primary_key_paths_and_sorting(). **Quality Gates** - cargo test (workspace): all pass (lib: 123 passed; 0 failed; 2 ignored). - cargo clippy --all-targets -- -D warnings: clean. **Impact** - Backward compatible for single-PK users via generated defaults and static slic es. - APIs now ready for multi-PK (composite key) support with minimal churn. - Less allocation in hot paths (slices instead of Vecs for PK metadata). * fix test * fix(dynamic/array): preserve primary key on tombstone rows - Root cause: tombstone (`row == None`) wrote defaults for all columns, includin g PK, causing missing/incorrect keys and panics during reads. - Change: derive PK index from schema metadata and append `key.value` for the PK column; keep default/null behavior for non-PK columns. - Tests: add `test_tombstone_keeps_primary_key`; `tests::test_read_write_dyn` no w passes. - Affected: `src/record/dynamic/array.rs` (DynRecordBuilder::push)
1 parent f9cd727 commit 14edbf4

File tree

21 files changed

+509
-156
lines changed

21 files changed

+509
-156
lines changed

AGENTS.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Repository Guidelines
2+
3+
## Project Structure & Module Organization
4+
- `src/`: Core Tonbo library (LSM tree, records, storage, executors).
5+
- `tests/`: Integration, macro (trybuild), and optional WASM tests.
6+
- `examples/`: Ready-to-run samples (`declare`, `datafusion`, `dynamic`).
7+
- `benches/`: Micro/criterion benchmarks; feature-gated targets.
8+
- Workspace members: `parquet-lru/` and `tonbo_macros/`.
9+
- Language bindings: `bindings/js` (WASM) and `bindings/python` (PyO3).
10+
11+
## Build, Test, and Development Commands
12+
- Build: `cargo build` (add flags via `--features ...`).
13+
- Examples: `cargo run --example declare --features tokio,bytes`
14+
| `cargo run --example datafusion --features datafusion`.
15+
- Test: `cargo test` for Rust tests.
16+
- WASM: `rustup target add wasm32-unknown-unknown` then
17+
`cargo test --target wasm32-unknown-unknown --features opfs`.
18+
- Benchmarks: `cargo bench --features bench` (general),
19+
`cargo bench --bench writes --features sled` (criterion).
20+
- Lint/format: `cargo clippy -D warnings` and `cargo +nightly fmt`.
21+
- Toolchain: Rust 1.85 (see `rust-toolchain.toml`), MSRV 1.79.
22+
23+
## Coding Style & Naming Conventions
24+
- Rust 2021; format with repository `rustfmt.toml` (100 col, grouped imports).
25+
- Prefer explicit modules and crate-private visibility where possible.
26+
- Names: types/traits CamelCase, functions/vars snake_case, crates/modules kebab/snake_case.
27+
- Keep public APIs documented; add examples for new features.
28+
29+
## Testing Guidelines
30+
- Add unit tests near code; integration in `tests/` (name `*_test.rs`).
31+
- Macro changes should include trybuild cases under `tests/{success,fail}`.
32+
- When touching async/I/O paths, include Tokio-based tests where feasible.
33+
- Optional storage/features: test with relevant flags (e.g., `datafusion`, `opfs`).
34+
35+
## Commit & Pull Request Guidelines
36+
- Use Conventional Commits: `feat:`, `fix:`, `docs:`, `refactor:`, `ci:`, `chore:`.
37+
- PRs should include: clear description, linked issues, tests/benches for behavior or perf,
38+
docs updates, and instructions to reproduce.
39+
- CI must pass: `cargo fmt`, `clippy`, tests, and benches where applicable.
40+
41+
## Security & Configuration Tips
42+
- Feature flags control backends: `aws`, `opfs`, `tokio`, `datafusion`, `bench`, `sled`, `rocksdb`, `redb`.
43+
- For S3 examples/tests, export `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `BUCKET_NAME`.
44+
- Do not commit secrets; prefer env vars and local config.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ required-features = ["bytes", "tokio"]
5555
name = "datafusion"
5656
required-features = ["datafusion"]
5757

58+
[[example]]
59+
name = "dynamic"
60+
required-features = ["tokio"]
61+
5862
[[bench]]
5963
harness = false
6064
name = "write_bench"

bindings/js/src/db.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ impl TonboDB {
7878
#[wasm_bindgen(constructor)]
7979
pub async fn new(option: DbOption, schema: Object) -> Self {
8080
let (desc, primary_key_index) = Self::parse_schema(schema);
81-
let schema = DynSchema::new(desc.clone(), primary_key_index);
81+
let schema = DynSchema::new(&desc, primary_key_index);
8282

8383
let db = DB::new(option.into_option(&schema), JsExecutor::new(), schema)
8484
.await

bindings/python/src/db.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ impl TonboDB {
5555
desc.push(DynamicField::from(col));
5656
}
5757
}
58-
let schema = DynSchema::new(desc, primary_key_index.unwrap());
58+
let schema = DynSchema::new(&desc, primary_key_index.unwrap());
5959
let option = option.into_option(&schema);
6060
let db = get_runtime()
6161
.block_on(async { DB::new(option, TokioExecutor::default(), schema).await })

guide/src/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- [Contribution]()
1616
- [Building](./contribution/build.md)
1717
- [Submitting PR](./contribution/pr.md)
18+
- [Composite Primary Keys](./contribution/composite_primary_keys.md)
1819
- [TonboLite](./tonbolite/index.md)
1920
- [Getting Started](./tonbolite/start.md)
2021
- [Building and Testing](./tonbolite/build.md)
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# RFC: Composite Primary Keys
2+
3+
This document outlines a practical, incremental plan to add composite (multi-column) primary key support to Tonbo while maintaining backward compatibility. It explains design goals, changes required across the codebase, and a step-by-step implementation and validation plan.
4+
5+
## Goals
6+
7+
- Support multi-column primary keys with lexicographic ordering of PK components.
8+
- Preserve existing single-column PK behavior and public APIs (backward compatible).
9+
- Keep zero-copy reads and projection pushdown guarantees for PK columns.
10+
- Ensure on-disk layout (Parquet) remains sorted by `_ts` then PK(s), with statistics/bloom filters enabled for PK columns.
11+
- Make it easy to use via the `#[derive(Record)]` macro by allowing multiple `#[record(primary_key)]` fields.
12+
13+
## Non-Goals (for this RFC)
14+
15+
- Foreign keys, cascades, or relational constraints.
16+
- Secondary indexes.
17+
- Schema migrations for existing data files.
18+
- Composite keys in dynamic records in the first phase (can be added subsequently).
19+
20+
## High-Level Design
21+
22+
1) Schema trait changes (completed)
23+
24+
- Now: `Schema` exposes `primary_key_indices()` and `primary_key_path()`.
25+
- `primary_key_index()` was removed in favor of the slice-based `primary_key_indices()`.
26+
- Additive helper: `primary_key_paths_and_sorting()` returns all PK column paths plus sorting columns.
27+
- For single-column PKs, implementations return a one-element slice from `primary_key_indices()`.
28+
29+
2) Composite key type(s)
30+
31+
- Introduce a composite key in `src/record/key/composite/` with lexicographic `Ord`:
32+
- Option A (preferred): The macro generates a record-specific key struct, e.g., `UserKey { k1: u64, k2: String }` and `UserKeyRef<'r> { ... }`.
33+
- Option B (interim): Provide generic tuple implementations for `(K1, K2)`, `(K1, K2, K3)`, … up to a small N. Each implements `Key` and `KeyRef` with lexicographic `Ord`, plus `Encode`/`Decode`, `Hash`, `Clone`.
34+
- For string/bytes components, `KeyRef` holds borrowed forms, mirroring current single-PK behavior.
35+
36+
3) Macro updates (tonbo_macros)
37+
38+
- Allow multiple `#[record(primary_key)]` fields. Order of appearance in struct determines comparison order (later we can add `order = i` if needed).
39+
- Generate:
40+
- Record-specific key struct and ref struct (Option A), or map to tuple (Option B).
41+
- `type Key = <GeneratedKey>` in `Schema` impl.
42+
- `fn key(&self) -> <GeneratedKeyRef>` in `Record` impl.
43+
- `fn primary_key_indices(&self) -> Vec<usize>` in `Schema` impl (indices are offset by 2 for `_null`, `_ts`).
44+
- Ensure `RecordRef::from_record_batch` and projection logic always keep all PK columns, even if they are not listed in the projection.
45+
- Keep encoding/arrays builders unchanged in signature; they already append values per-field.
46+
47+
4) Projections and read paths
48+
49+
- Replace single-index assumptions with multi-index collections:
50+
- Use `[0, 1] ∪ primary_key_indices()` to build fixed projections in `src/lib.rs` and `src/transaction.rs`.
51+
- In all `RecordRef::projection` usages, ensure all PK columns are always retained (already implied by fixed mask).
52+
53+
5) Parquet writer configuration
54+
55+
- In `DbOption::new`, use `primary_key_paths_and_sorting()` to:
56+
- Enable stats and bloom filters for each PK column path via `.set_column_statistics_enabled()` and `.set_column_bloom_filter_enabled()` (invoke once per path).
57+
- Set sorting columns as `[ SortingColumn(_ts, …), SortingColumn(pk1, …), SortingColumn(pk2, …), … ]`.
58+
59+
6) Dynamic records (phase 2)
60+
61+
- Extend `DynSchema` to track `primary_indices: Vec<usize>` in metadata (replacing the single `primary_key_index`).
62+
- Update `DynRecordRef::new` and readers to honor multiple PK indices.
63+
- Define a composite key wrapper for `Value`/`ValueRef` (or generate a per-dyn-schema composite type if feasible). Initially out-of-scope for phase 1.
64+
65+
## Step-by-Step Plan
66+
67+
Phase 1: Core plumbing (single-PK stays working)
68+
69+
1. Extend `Schema` trait
70+
- Add `primary_key_indices()` and `primary_key_paths_and_sorting()` with default impls wrapping existing methods.
71+
- Update call sites in `DbOption::new`, `src/lib.rs`, and `src/transaction.rs` to use the plural forms.
72+
- Acceptance: All tests pass; no behavior change for single-PK users.
73+
74+
2. Fixed projection refactor
75+
- Replace single `primary_key_index` usage with iteration over `primary_key_indices()` to construct `fixed_projection` = `[0, 1] ∪ PKs`.
76+
- Acceptance: Existing tests and scan/get projections still behave identically for single-PK.
77+
78+
3. Parquet writer properties
79+
- Replace single `primary_key_path()` usage with plural variant to configure stats, bloom filters, and sorting columns for `_ts` plus all PK components.
80+
- Acceptance: Files write successfully; read paths unchanged.
81+
82+
Phase 2: Macro + key types
83+
84+
4. Composite key data structure
85+
- Implement composite key(s) in `src/record/key/composite/` with `Encode`/`Decode`, `Ord`, `Hash`, `Key`/`KeyRef`.
86+
- Start with tuples `(K1, K2)`, `(K1, K2, K3)` etc. (Option B) for faster delivery; later switch default macro to per-record key type (Option A).
87+
- Acceptance: Unit tests confirm lexicographic ordering and encode/decode round-trip for composite keys.
88+
89+
5. Update `#[derive(Record)]`
90+
- Allow multiple `#[record(primary_key)]` fields and generate:
91+
- `type Key = (<K1>, <K2>, …)` (Option B) or `<RecordName>Key` (Option A).
92+
- `fn key(&self) -> (<K1Ref>, <K2Ref>, …)`.
93+
- `fn primary_key_indices(&self) -> Vec<usize>` with +2 offset.
94+
- Ensure `from_record_batch` and projection retain all PK columns.
95+
- Acceptance: trybuild tests covering multi-PK compile and run; single-PK tests unchanged.
96+
97+
6. Integration tests
98+
- Add end-to-end tests: insert/get/remove, range scans, projection, and ordering on 2+ PK fields (e.g., `tenant_id: u64, name: String`).
99+
- Acceptance: All new tests pass.
100+
101+
Phase 3: Dynamic records (optional)
102+
103+
7. `DynSchema` multi-PK
104+
- Store `primary_indices` metadata; update dynamic arrays/refs to keep all PK columns in projections.
105+
- Provide a composite `ValueRef` key wrapper for in-memory operations.
106+
- Acceptance: dynamic tests mirroring integration scenarios pass.
107+
108+
## Code Touchpoints
109+
110+
- Traits/APIs: `src/record/mod.rs` (Schema), `src/option.rs` (DbOption::new)
111+
- Read paths: `src/lib.rs` (get/scan/package), `src/transaction.rs` (get/scan)
112+
- Macro codegen: `tonbo_macros/src/record.rs`, `tonbo_macros/src/keys.rs`, `tonbo_macros/src/data_type.rs`
113+
- Key types: `src/record/key/composite/`
114+
- Dynamic (phase 3): `src/record/dynamic/*`
115+
116+
## Testing Strategy
117+
118+
- Unit tests:
119+
- Composite key `Ord`, `Eq`, `Hash`, `Encode`/`Decode` round-trip.
120+
- `Schema` default impl compatibility.
121+
- trybuild tests:
122+
- Multiple `#[record(primary_key)]` in a struct compiles and generates expected APIs.
123+
- Reject nullable PK components.
124+
- Integration tests:
125+
- Insert/get/remove by composite key; range scans across composite key ranges; projection keeps PK columns.
126+
- WAL/compaction unaffected (basic smoke tests).
127+
- (Optional) Property tests: ordering equivalence vs. native tuple lexicographic ordering when Option B is used.
128+
129+
## Backward Compatibility & Migration
130+
131+
- All existing single-PK code continues to work without changes due to default-impl fallbacks.
132+
- Users opting into composite PKs need only annotate multiple fields with `#[record(primary_key)]`.
133+
- No on-disk migration is required for existing tables; new tables with composite PKs will write Parquet sorting columns for all PK components.
134+
135+
## Risks and Mitigations
136+
137+
- API surface increase: keep new APIs additive with conservative defaults.
138+
- Projection bugs: comprehensive tests to ensure PK columns are always included.
139+
- Performance: lexicographic compare is standard; Arrow array lengths are uniform, so no extra bounds checks needed.
140+
- Dynamic records complexity: staged to a later phase to avoid blocking initial delivery.
141+
142+
## Example (target macro UX)
143+
144+
```rust
145+
#[derive(Record, Debug)]
146+
pub struct User {
147+
#[record(primary_key)]
148+
pub tenant_id: u64,
149+
#[record(primary_key)]
150+
pub name: String,
151+
pub email: Option<String>,
152+
pub age: u8,
153+
}
154+
155+
// Generated (conceptually):
156+
// type Key = (u64, String);
157+
// fn key(&self) -> (u64, &str);
158+
// fn primary_key_indices(&self) -> Vec<usize> { vec![2, 3] }
159+
```
160+
161+
## Delivery Checklist
162+
163+
- [ ] Add Schema plural APIs and refactor call sites.
164+
- [ ] Implement composite key types (tuples first).
165+
- [ ] Enable multiple PK fields in macro; generate composite key/ref and PK indices.
166+
- [ ] Update projection logic to retain all PK columns.
167+
- [ ] Configure Parquet sorting/statistics for all PK components.
168+
- [ ] Add unit/trybuild/integration tests.
169+
- [ ] Update user guide (mention composite PK support and examples).

src/compaction/leveled.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -680,11 +680,11 @@ pub(crate) mod tests {
680680
let temp_dir = tempfile::tempdir().unwrap();
681681
let manager = StoreManager::new(FsOptions::Local, vec![]).unwrap();
682682
let schema = DynSchema::new(
683-
vec![DynamicField::new(
683+
&vec![DynamicField::new(
684684
"id".to_owned(),
685685
ArrayDataType::Int32,
686686
false,
687-
)],
687+
)][..],
688688
0,
689689
);
690690
let option = DbOption::new(

src/inmem/immutable.rs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -237,23 +237,31 @@ pub(crate) mod tests {
237237
&SCHEMA
238238
}
239239

240-
fn primary_key_index(&self) -> usize {
241-
2
240+
fn primary_key_indices(&self) -> &[usize] {
241+
const INDICES: [usize; 1] = [2];
242+
&INDICES
242243
}
243244

244-
fn primary_key_path(
245+
fn primary_key_paths_and_sorting(
245246
&self,
246247
) -> (
247-
parquet::schema::types::ColumnPath,
248-
Vec<parquet::format::SortingColumn>,
248+
&[parquet::schema::types::ColumnPath],
249+
&[parquet::format::SortingColumn],
249250
) {
250-
(
251-
ColumnPath::new(vec![magic::TS.to_string(), "vstring".to_string()]),
251+
use once_cell::sync::Lazy;
252+
static PATHS: Lazy<Vec<ColumnPath>> = Lazy::new(|| {
253+
vec![ColumnPath::new(vec![
254+
magic::TS.to_string(),
255+
"vstring".to_string(),
256+
])]
257+
});
258+
static SORTING: Lazy<Vec<SortingColumn>> = Lazy::new(|| {
252259
vec![
253260
SortingColumn::new(1, true, true),
254261
SortingColumn::new(2, false, true),
255-
],
256-
)
262+
]
263+
});
264+
(&PATHS[..], &SORTING[..])
257265
}
258266
}
259267

src/inmem/mutable.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -456,10 +456,10 @@ mod tests {
456456
async fn test_dyn_read() {
457457
let temp_dir = tempfile::tempdir().unwrap();
458458
let schema = DynSchema::new(
459-
vec![
459+
&vec![
460460
DynamicField::new("age".to_string(), ArrayDataType::Int8, false),
461461
DynamicField::new("height".to_string(), ArrayDataType::Int16, true),
462-
],
462+
][..],
463463
0,
464464
);
465465
let option = DbOption::new(

src/lib.rs

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -704,20 +704,22 @@ where
704704
ts: Timestamp,
705705
projection: Projection<'get>,
706706
) -> Result<Option<Entry<'get, R>>, DbError> {
707-
let primary_key_index = self.record_schema.primary_key_index();
707+
let pk_indices = self.record_schema.primary_key_indices();
708708
let schema = ctx.arrow_schema();
709709

710710
let projection = match projection {
711711
Projection::All => ProjectionMask::all(),
712712
Projection::Parts(projection) => {
713-
let mut fixed_projection: Vec<usize> = [0, 1, primary_key_index]
714-
.into_iter()
715-
.chain(projection.into_iter().map(|name| {
716-
schema
717-
.index_of(name)
718-
.unwrap_or_else(|_| panic!("unexpected field {name}"))
719-
}))
720-
.collect();
713+
let mut fixed_projection: Vec<usize> =
714+
Vec::with_capacity(2 + pk_indices.len() + projection.len());
715+
fixed_projection.push(0);
716+
fixed_projection.push(1);
717+
fixed_projection.extend_from_slice(pk_indices);
718+
fixed_projection.extend(projection.into_iter().map(|name| {
719+
schema
720+
.index_of(name)
721+
.unwrap_or_else(|_| panic!("unexpected field {name}"))
722+
}));
721723
fixed_projection.dedup();
722724

723725
ProjectionMask::roots(
@@ -905,10 +907,12 @@ where
905907
})
906908
.collect::<Vec<usize>>();
907909

908-
let primary_key_index = self.mem_storage.record_schema.primary_key_index();
910+
let pk_indices = self.mem_storage.record_schema.primary_key_indices();
909911

910-
// The scan uses a fixed projection of 0: `_null` field, 1: `_ts` field, 3: primary key
911-
let mut fixed_projection = vec![0, 1, primary_key_index];
912+
// The scan uses a fixed projection of 0: `_null` field, 1: `_ts` field, and all primary key
913+
// columns
914+
let mut fixed_projection = vec![0, 1];
915+
fixed_projection.extend_from_slice(pk_indices);
912916
fixed_projection.append(&mut projection);
913917
fixed_projection.dedup();
914918

@@ -931,10 +935,12 @@ where
931935
*p += USER_COLUMN_OFFSET;
932936
}
933937

934-
let primary_key_index = self.mem_storage.record_schema.primary_key_index();
938+
let pk_indices = self.mem_storage.record_schema.primary_key_indices();
935939

936-
// The scan uses a fixed projection of 0: `_null` field, 1: `_ts` field, 3: primary key
937-
let mut fixed_projection = vec![0, 1, primary_key_index];
940+
// The scan uses a fixed projection of 0: `_null` field, 1: `_ts` field, and all primary key
941+
// columns
942+
let mut fixed_projection = vec![0, 1];
943+
fixed_projection.extend_from_slice(pk_indices);
938944
fixed_projection.append(&mut projection);
939945
fixed_projection.dedup();
940946

@@ -1727,7 +1733,6 @@ pub(crate) mod tests {
17271733
}
17281734
}
17291735

1730-
dbg!(db.ctx.manifest.current().await);
17311736
// test get
17321737
{
17331738
let tx = db.transaction().await;

0 commit comments

Comments
 (0)