|
| 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). |
0 commit comments