-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathwt-user-guide.typ
More file actions
1381 lines (1142 loc) · 63.6 KB
/
Copy pathwt-user-guide.typ
File metadata and controls
1381 lines (1142 loc) · 63.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#set document(title: "WorkTable User Guide", author: "PathScale")
#set page(paper: "a4", margin: (x: 2.2cm, y: 2.4cm), numbering: "1")
#set text(font: ("Helvetica", "Arial"), size: 10pt)
#set par(justify: true, leading: 0.62em)
#show heading: set block(above: 1.4em, below: 0.7em)
#show heading.where(level: 1): set text(size: 17pt, weight: "bold")
#show heading.where(level: 2): set text(size: 12.5pt, weight: "bold")
#show heading.where(level: 3): set text(size: 10.5pt, weight: "bold")
#show raw.where(block: true): it => block(
fill: rgb("#f4f4f2"), inset: 9pt, radius: 3pt, width: 100%, breakable: false, text(size: 8.5pt, it),
)
#show raw.where(block: false): it => box(fill: rgb("#f0f0ee"), inset: (x: 2.5pt, y: 0pt), outset: (y: 2.5pt), radius: 2pt, text(size: 9pt, it))
#show link: set text(fill: rgb("#1a4f8a"))
#let note(title, body) = block(
fill: rgb("#fbf6e8"), stroke: (left: 2.5pt + rgb("#c8a13a")), inset: 9pt, radius: 2pt, width: 100%,
[*#title.* #body],
)
#align(center)[
#text(size: 26pt, weight: "bold")[WorkTable]
#v(-0.4em)
#text(size: 11pt, style: "italic")[Absolutely not a database.]
#v(0.6em)
#text(size: 9.5pt)[A user's guide to the `worktable!` macro, its queries, its indexes and its
persistence tier. Written against 1.10.0-beta1.]
]
#v(1.2em)
= What this is
Embedded table storage for Rust. Declare a table with a macro, get a typed struct back:
a primary key, secondary indexes, generated queries. Rows live in memory as paged,
zero-copy records. Persisting them to local disk or S3 is opt-in.
#note("What it is not")[No transaction journal, no fsync per batch. A mutation
returning means the change was accepted and queued, not that it is on stable storage.
See #link(<persistence>)[Persistence].]
= Getting started
```sh
cargo add worktable@1.10.0-beta1
```
Until this beta is published, depend on the reviewed checkout with
`worktable = { path = "../WorkTable" }`. A plain `cargo add worktable` selects the
published release and may not include the APIs described here.
```rust
use worktable::prelude::*;
use worktable::worktable;
```
Everything the macro emits resolves through `worktable::prelude`, so that one import is
the whole setup.
= Examples <examples>
Every clause the macro accepts appears below, labelled where it is used.
== 1. The smallest table
```rust
worktable! (
name: Order, // required, and must come first. CamelCase.
columns: {
id: u64 primary_key, // this table has a single-column primary key
total: u64,
},
);
let table = OrderWorkTable::default();
table.insert(OrderRow { id: 1, total: 500 }).await?; // errors if the key exists
table.upsert(OrderRow { id: 1, total: 600 }).await?; // overwrites instead
let row = table.select(1).expect("just inserted");
```
`name: Order` generates `OrderWorkTable`, `OrderRow`, `OrderPrimaryKey`, and for a
persisted table `OrderPersistenceEngine`.
== 2. Column clauses
```rust
worktable! (
name: Account,
columns: {
id: u64 primary_key autoincrement, // the table assigns keys
email: String, // any sized type
nickname: String optional, // becomes Option<String>
balance: i64,
},
);
```
Clause order inside a column is fixed by the grammar and is not the order you might
guess: `<name>: <Type> [primary_key [autoincrement|custom]] [optional] [columnar(..)]
[using <backend>]`. The generator binds to `primary_key`, and `optional` follows both.
```rust
let id = table.insert(AccountRow {
id: table.get_next_pk().into(),
email: "a@b.c".to_string(),
nickname: None, // optional column
balance: 0,
}).await?;
```
`custom` replaces `autoincrement` when you generate keys yourself and still want the
table to track the high-water mark.
== 3. Composite primary keys
```rust
worktable! (
name: Quote,
columns: {
exchange: u32 primary_key, // both columns carry primary_key
symbol: u32 primary_key, // one generator is shared between them
price: f64,
},
);
let row = table.select((1_u32, 42_u32).into()).expect("present");
```
A composite key keeps `worktables_index` even though the default is `arctic`, because
arctic cannot represent a tuple key.
== 4. Secondary indexes
```rust
worktable! (
name: Customer,
columns: {
id: u64 primary_key,
email: String,
country: u16,
},
indexes: {
// <name>: <column> [unique] [using <backend>]
email_idx: email unique using worktables_index, // one row back
country_idx: country using arctic, // many rows back
},
);
let one = table.select_by_email("a@b.c".to_string()); // Option<Row>
let many = table.select_by_country(44).execute()?; // Vec<Row>
```
`using` is optional and defaults to `arctic`. An index over an
optional column must say `using worktables_index`; Arctic supports `String` keys,
but does not support optional keys.
== 5. Mutations and declared queries
These operations are deliberately different. Choose by how much of the row the caller
owns and whether an absent key is valid:
#text(size: 7.5pt)[
#table(
columns: (1.7fr, 1.05fr, 0.85fr, 2.7fr),
stroke: 0.4pt + rgb("#cccccc"),
inset: 5pt,
[*operation*], [*input*], [*key absent*], [*meaning*],
[`insert(row)`], [complete `Row`], [insert], [Create a new row. An existing primary key returns `PrimaryAlreadyExists`; the caller does not authorize replacement.],
[`upsert(row)`], [complete `Row`], [insert], [Insert or replace. The caller declares the complete row authoritative. A row selected earlier can overwrite newer fields if it is later passed here.],
[`replace(row)`], [complete `Row`], [`NotFound`], [Replace every field of an existing row. It never creates a missing row, but the supplied row is still a complete authoritative snapshot.],
[#stack(spacing: 0.22em, [`update_by_<key>(`], [`key, Columns::`], [`FIELD_SET, value)`])], [declared fields], [`NotFound`], [Change only the selector's declared fields. WorkTable rereads under its mutation lock when safe reconstruction needs the complete row, preserving concurrent changes to other fields.],
[#stack(spacing: 0.22em, [`update_in_place_`], [`by_<pk>(key,`], [`Columns::FIELD_SET,`], [`closure)`])], [#stack(spacing: 0.22em, [declared mutable], [archived fields])], [`NotFound`], [Directly mutate a declared, unindexed field set of an existing row. This is the lowest-work path and is restricted to primary-key lookup.],
)
]
#text(size: 7.5pt)[
#block(
fill: rgb("#f7f7f4"),
inset: 8pt,
radius: 2pt,
width: 100%,
breakable: false,
)[
*Pays shipping-schema cost, Apple M4 Max, `taskpolicy -b`.* Persisted
`CustomerPayment`: autoincrement `u64` private key, packed 16-character Base62
`payment_id` public key, four secondary indexes, 32,768 rows, 9 balanced
fresh-process samples. Monetary columns are strings; rerun after typed money.
Base `b1b9546` uses historical `update(row)` / generated query structs. Final
`45c015d` uses `replace` and typed selectors. Strings and custom archived
wrappers take the conservative complete-row `update` path, not in-place.
#table(
columns: (1.45fr, 0.95fr, 0.95fr, 0.95fr, 0.85fr),
stroke: 0.4pt + rgb("#cccccc"),
inset: 4pt,
[*operation*], [*base ns/op*], [*final ns/op*], [*final updates/s*], [*vs in-place*],
[`upsert`], [76175], [74430], [13435], [9.83x],
[`replace`], [71118], [69518], [14385], [9.18x],
[`update`], [11907], [11561], [86500], [1.53x],
[`update_in_place`], [7340], [7571], [132082], [1.00x],
)
#let bar(label, ns, max-ns) = {
let frac = ns / max-ns
grid(
columns: (3.4cm, 1fr, 1.8cm),
column-gutter: 6pt,
text(size: 7pt, raw(label)),
box(width: 100%, height: 7pt, fill: rgb("#e6e6e1"),
box(width: frac * 100%, height: 7pt, fill: rgb("#3a6ea5"))),
align(right, text(size: 7pt, [#ns ns])),
)
}
#v(0.3em)
*Final `45c015d` median ns/op*
#v(0.15em)
#bar("upsert", 74430, 74430)
#v(0.1em)
#bar("replace", 69518, 74430)
#v(0.1em)
#bar("update", 11561, 74430)
#v(0.1em)
#bar("update_in_place", 7571, 74430)
]
]
#text(size: 7.5pt)[
#block(
fill: rgb("#f7f7f4"),
inset: 8pt,
radius: 2pt,
width: 100%,
breakable: false,
)[
*Same shipping row, extended campaign, 5 samples, `d4b8aac`.* Indexes on
that table: unique `payment_id` and `app_id` are WTI; non-unique `symbol` and
`endpoint_address` are Arctic. There is no `update_range` primitive.
`range_ids` is consecutive private keys the caller already holds;
`range_scan` is `range_on` then update each. Workers are disjoint keys.
`using fxhash` is refused on a persisted table.
The first 8-worker numbers were taken under `taskpolicy -b`. Eight tokio
tasks did overlap (`overlap_max=8`) but Darwin background QoS held the
process at ~1.3 cores, so the table looked like "concurrency does almost
nothing". Inherit policy, same JoinSet:
#table(
columns: (0.7fr, 1.5fr, 1.0fr, 1.1fr, 0.8fr),
stroke: 0.4pt + rgb("#cccccc"),
inset: 4pt,
[*workers*], [*mutation*], [*updates/s*], [*cores*], [*vs 1*],
[1], [`replace`], [139920], [2.00], [1.00x],
[8], [`replace`], [177017], [6.14], [1.27x],
[12], [`replace`], [159189], [7.50], [1.14x],
[1], [`update_in_place`], [714567], [1.95], [1.00x],
[8], [`update_in_place`], [552544], [6.74], [0.77x],
[12], [`update_in_place`], [490803], [9.03], [0.69x],
)
Eight workers burn six cores for 27% more replace/s. In-place gets *worse*.
On a unique `u64` secondary, 1-thread Congee/WTI/Arctic replace are within a
few percent; at 8 workers all three lose ~22% (Congee least-bad, not Arctic).
#table(
columns: (1.6fr, 1.2fr, 1.2fr),
stroke: 0.4pt + rgb("#cccccc"),
inset: 4pt,
[*range (256 keys)*], [*ns/row*], [*updates/s*],
[`range_ids`], [6264], [159634],
[`range_scan`], [58262], [17164],
)
#table(
columns: (1.1fr, 1.3fr, 1.1fr, 1.2fr),
stroke: 0.4pt + rgb("#cccccc"),
inset: 4pt,
[*backend*], [*mutation*], [*ns/op*], [*updates/s*],
[WTI persist], [`replace`], [5636], [177444],
[Arctic persist], [`replace`], [3923], [254934],
[Congee persist], [`replace`], [3938], [253928],
[FxHash `vec`], [`upsert`], [56], [17733214],
)
FxHash is in-memory only. Arctic/Congee persisted `replace` on a unique
`u64` secondary sit together; WTI `replace` is slower on that fixture.
]
]
Declare targeted updates, deletes and direct archived-field mutations with the table:
```rust
worktable! (
name: Invoice,
columns: {
id: u64 primary_key,
amount: u64,
state: u8,
},
queries: {
update: {
AmountById(amount) by id, // <Name>(<columns>) by <key>
},
delete: {
ById() by id, // empty parens: names no columns
},
update_in_place: {
StateById(state) by id, // only `by <primary key>` is supported
AmountAndStateById(amount, state) by id,
},
},
);
```
The lookup column names the method and the typed selector names the changed
column. A one-column update takes that column's Rust value directly:
```rust
table.update_by_id(1, InvoiceColumns::AMOUNT, 900).await?;
table.delete_by_id(1).await?;
table.update_in_place_by_id(1, InvoiceColumns::STATE, |state| *state = 2.into()).await?;
table.update_in_place_by_id(
1,
InvoiceColumns::AMOUNT_AND_STATE,
|(amount, state)| {
*amount = 925.into();
*state = 3.into();
},
).await?;
```
`InvoiceColumns::AMOUNT` is a generated zero-sized selector. Its sealed dispatch
implementation exists only for the declared `amount by id` combination and its
value type is `u64`, so an undeclared selector/key combination or wrong value
type fails to compile. The selector is monomorphized; it allocates nothing and
uses no dynamic dispatch. A declaration over `name, amount` exposes the atomic
selector `InvoiceColumns::NAME_AND_AMOUNT` and takes its generated query struct.
Selector constants preserve source spelling in uppercase: `attr1` becomes
`ATTR1`, while `some_field` becomes `SOME_FIELD`.
A declared `update` reads, changes and writes only its named fields. Normal declared
updates accept owned Rust values and are the safe default for strings, options and
application-defined wrappers. An archived string contains a relative pointer, so
WorkTable may reconstruct the complete row rather than move only that field's archived
bytes. It rereads under the full mutation lock first, preserving concurrent changes to
other fields. The macro cannot inspect an external type such as `EncryptedSecret` and
prove whether its archived form contains relative pointers, so unknown custom types take
that conservative path.
`update_in_place` mutates one declared field set without selecting first and locks internally.
A multi-column declaration passes a tuple of mutable archived fields to one closure, so the
set changes under the same row lock and persistence operation. Use it when the application
can safely edit the archived representation directly, as with scalars or fixed `#[repr(u8)]`
enums. Do not copy an archived string, vector or pointer-bearing wrapper
from another buffer into an `update_in_place` closure. Persisted in-place queries enqueue the
changed slot bytes; they do not skip durability.
== 6. Selects you do not declare
Generated from the columns and indexes, so none of these appear in the macro:
```rust
table.select(id) // primary key
table.select_by_email("a@b.c".to_string()) // unique index
table.select_by_country(44).execute()? // non-unique index
table.select_by_pk_range(10..=20).execute()? // range over the primary key
table.select_by_country_range(40..=50).execute()? // range over an indexed column
table.select_all().execute()?
table.select_all()
.order_on(InvoiceRowFields::Amount, Order::Desc) // generated field enum
.limit(10)
.execute()?
```
== 7. Columnar fields and indexes
```rust
worktable! (
name: Reading,
columns: {
id: u64 primary_key, // must NOT say columnar: implicit already
host_id: u64 columnar(chunk_rows(2), compression(none)),
timestamp: i64 columnar, // bare form, not the same as columnar(..)
payload: String, // row-wise only
},
columnar_indexes: {
host_time: { // <name>: { cluster_by: [..] }
cluster_by: [host_id, timestamp], // every field must be columnar
},
},
config: {
columnar_slot_id: ColumnSlotId16, // slot width, default ColumnSlotId32
columnar_chunk_rows: 4096, // default chunk size, default 65536
},
);
```
A `columnar` column is stored column-wise as well as row-wise, so a scan over that field
reads only that field's bytes.
== 8. Page size and row derives
```rust
worktable! (
name: Small,
columns: { id: u64 primary_key, v: u64 },
config: {
page_size: 4096, // 512 minimum, 65535 max under arctic
row_derives: Clone, Debug, // bare identifiers, NOT [Clone, Debug]
}, // row_derives must be written last
);
```
`row_derives` reads identifiers until it meets another config key, which is why it goes
last. The `config` block takes no trailing comma after its closing brace.
== 9. Partitioned tables
```rust
worktable! (
name: Book,
persist: false,
partition_by: symbol_id: u16, // <name>: <unsigned type>, stored per partition
partition_max_size: u8, // required: rows per partition, as an index width
columns: {
exchange_id: u8 primary_key,
bid: f64,
ask: f64,
},
);
```
The partition key is stored once per partition rather than once per row, and no query
can name it.
`partition_max_size` is required whenever `partition_by` is present, and it is a *type*
rather than a count, because it is an index width. It is how the declaration says how
many rows one partition holds:
#table(
columns: (auto, auto, 1fr),
stroke: 0.4pt + rgb("#cccccc"),
inset: 6pt,
[*width*], [*rows per partition*], [*shape*],
[`bool`], [2], [dense],
[`u8`], [256], [dense],
[`u16`], [65,536], [dense],
[`u32`, `u64`], [unbounded in practice], [a full table per partition],
)
There is no `unbounded` keyword: the widths run out of smallness, so `u64` is the escape
and generates exactly what a partitioned table generated before this key existed.
It is required rather than defaulted because without it the declaration says nothing
about the shape being generated. A reader seeing `exchange_id: u8 primary_key` in a
partitioned table reads "big table with a suspiciously tiny key", when the truth is
"twenty thousand little tables, each of which only needs a byte". Two declarations
differing by 28 KB a partition would otherwise look identical.
A count is not accepted in its place. A count is not an index width, it is not a power
of two, and it duplicates a constant that lives in the caller's code and will drift.
=== A narrow primary key off a partition is linted
`u8` or `bool` as the primary key of a table with no `partition_by` means a table that
can never hold more than 256 or 2 rows. That is occasionally what someone means and
usually a key that was meant to be wider, so it warns rather than failing:
```text
warning: use of deprecated constant `_::NARROW_PRIMARY_KEY`: `id: u8` is the
primary key of an unpartitioned table, so this table can never hold more than
256 rows...
```
Beside `partition_by` it is silent, because there it is correct: the routing key does the
spreading and the inner key only separates the rows inside one partition. A narrow key is
what makes the dense shape below possible.
To keep it, put `#[allow(deprecated)]` on the module holding the declaration. The warning
is a deprecation because a procedural macro cannot emit a warning any other way.
=== What a dense width actually generates
`bool`, `u8` and `u16` generate `<Name>DenseTable` as the partition payload instead of
the full table. It addresses rows by *position*: the primary key is the row's index, so
there is no primary index, no pages, no links, no free list, no lock map and no CDC. A
lookup is a bounds check and a load.
Measured on one declaration at two widths, 200 partitions of 23 rows each, counting what
the allocator was asked for:
#table(
columns: (1fr, auto),
stroke: 0.4pt + rgb("#cccccc"),
inset: 6pt,
[*shape*], [*bytes per partition*],
[full table, empty], [28,404],
[*dense, empty*], [*108*],
[full table, 23 rows of an 88-byte row], [32,900],
[*dense, same*], [*3,180*],
)
Read the empty row. The saving is fixed apparatus allocated when a partition is created,
so it is roughly 28 KB per partition whatever the rows weigh; the ratio falls for wider
rows only because the rows themselves grow. At 2,000 symbols that is about 56 MB.
The width is a *bound, not a reservation*. The row vector grows to the highest key used,
so a `u16` partition holding three rows holds three slots, and an empty one allocates
nothing at all.
Every method takes `&self`, because `partition_or_create` hands out an `Arc`. There is a
generated `update_<column>` per column, which edits one field in place rather than
cloning the row out and back. Writes serialise per partition rather than per cell: the
full table needs cell-level locking because its writes are async and a query can hold a
column across an await, and nothing here is async.
A dense width is refused, by name, for a primary key that is not a single unsigned
column, for a width the key cannot count to (`u16` beside a `u8` key declares 65,536 rows
into a partition that holds 256), and for `persist: true`, which it has no engine to
honour.
`queries:` works. An `update` keyed by the primary key uses the same
`update_by_<key>(key, Columns::FIELD_SET, value)` form as the paged table; a
multi-column selector takes the same generated query struct. A delete keeps its
declared method name. Dense calls are synchronous and return `Option`, so a call
cannot move between the shapes by accident. A query keyed by any other column is refused, because a
dense partition has no secondary index and scanning instead would turn a keyed operation
into a linear one without saying so. `update_in_place` is refused: every update
here is already in place.
Note that `memory_by_key` and `memory_total` cannot see any of this. They report
`used_bytes`, which is rows plus indexes and excludes the fixed floor by definition, so
both shapes measure the same through them.
=== A partition here is a whole table, which is a choice
WorkTable's partitioning is Postgres-shaped: a partition is a complete table with its own
storage, index and locks. That is a real decision with a real cost rather than an
implementation detail, and `docs/partition-models.md` compares it against PostgreSQL,
Kafka, ClickHouse, Cassandra, HBase and Snowflake, with each claim checked against those
systems' current documentation.
One thing from it belongs here. The isolation is stronger than Postgres's, because there
is no shared lock manager to contend on: a partition is an independent generated table
behind its own handle. What it is *not* is free, which is what `partition_max_size`
exists to let you decline.
== 9b. `vec: true`, a table with no pages
```rust
worktable! (
name: Lookup,
vec: true, // positional: after `version`, before `persist`
columns: {
id: u64 primary_key,
value: u64,
},
);
```
The rows live in one contiguous `Vec` with an index of positions into it. It pays for
none of the paging, archived rows, lock map, change-data-capture or async surface a paged
table carries.
It is a key rather than a second macro. `worktable_vec!` existed for a day, emitted
`<Name>VecRow` and `<Name>VecTable`, and is deleted: one macro means one `<Name>Row` and
one `<Name>WorkTable` whatever the storage is.
=== The two are deliberately not interchangeable
Moving a declaration between them breaks every call site, which is the safety property
rather than an omission. A swap that changed a table's concurrency and durability
guarantees while everything still compiled is the hazard worth having:
#table(
columns: (auto, 1fr, 1fr),
stroke: 0.4pt + rgb("#cccccc"),
inset: 6pt,
[], [*paged*], [*`vec: true`*],
[`insert`], [`async fn(&self, Row) -> Result<Pk, WorkTableError>`], [`fn(&mut self, Row) -> Result<(), Row>`],
[`upsert`], [`async fn(&self, Row) -> Result<(), WorkTableError>`], [`fn(&mut self, Row)`],
[`delete`], [`async fn(&self, Pk) -> Result<(), WorkTableError>`], [`fn(&mut self, &Pk) -> Option<Row>`],
[`select`], [`fn(&self, Pk) -> Option<Row>`, cloned], [`fn(&self, &Pk) -> Option<&Row>`, borrowed],
)
A missing `.await`, `&self` against `&mut self`, an owned row against a borrowed one: the
compiler rejects the swap four different ways.
=== What it refuses, and why
`persist`, `runtime`, `config` and columnar fields are each refused with an
error naming what to use instead, rather than being accepted and ignored.
`partition_by` is *not* refused: see section 9, where partitioning is what makes the
`Vec` shape correct.
Declared `queries` are supported. They use equality on a primary or secondary index,
including `fxhash`, and run synchronously through `&mut self`. An update declaration
such as `StateById(state) by id` emits
`update_by_id(id, TicketColumns::STATE, 7) -> usize`; a delete declaration
`ByOwner() by owner` emits `delete_by_owner(&owner) -> usize`. The return value counts
affected rows. `update_in_place: { Status(state) by id }` emits
`update_in_place_by_id(id, TicketColumns::STATE, |state| *state = 42) -> usize`
for one column. A declaration such as `StateAndRevisionById(state, revision) by id`
uses `TicketColumns::STATE_AND_REVISION` and passes `|(state, revision)| ...` to
one closure.
These methods belong to the table, not mutable wrappers on the shared partition set.
Vec edits validate a cloned candidate before replacing a row. A primary or unique
secondary-key collision panics with that row and its indexes unchanged; a panicking
edit closure also leaves the stored row unchanged. Replacing an existing row through
`upsert` checks unique secondary keys first. Multi-row queries apply one row at a time
and are not transactions: earlier successful edits remain if a later edit fails.
Cloning owned fields is part of this mutation cost, including Vec `update_in_place` queries.
=== Bytes and back: `unload` and `load`
There is no persistence engine, no background task and no flush. When you want the rows
as bytes you ask for them:
```rust
let pages: Vec<u8> = table.unload()?; // 16 KiB self-describing pages
let table = LookupWorkTable::load(&pages)?; // and back
```
Each 16 KiB page has a 28-byte header, an archived row batch and a 12-byte trailer:
row count at byte 16,372, row-type fingerprint at 16,376 and CRC-32 at 16,380.
The CRC covers the header, archive, padding, count and fingerprint. Page type 4
identifies archived rows; the space id is zero. Ordinary persisted tables use a
different page type and directory, so these containers cannot be interchanged.
The reader checks page links, detects incomplete chains and rebuilds indexes from rows.
The fingerprint hashes Rust's type name; it catches obvious foreign row types, but is
neither a complete schema hash nor stable across compiler versions. Renaming a type
can invalidate a snapshot; changing fields under the same name still requires an
explicit data cutover. The codec is `worktable::vec_hydrate`.
For an append-only table, save the number of live rows already written and append only
new rows. `first` counts live rows in insertion order, skipping ghosts:
```rust
let first = table.len();
let mut bytes = table.unload()?;
// Insert new rows, without updating or deleting earlier rows.
let pages_before = u32::try_from(bytes.len() / worktable::vec_hydrate::PAGE_SIZE)?;
bytes.extend_from_slice(&table.unload_appending(first, pages_before)?);
```
`unload_appending` reports oversized rows and page-number overflow. The previous
terminal page stays unchanged. Independent `unload()` segments can also be concatenated.
At load, the first accepted primary or unique key wins; an appended duplicate cannot
replace a row. Updates, deletes or invalidated cursors require a full snapshot.
=== Picking an index backend
`using` selects the physical index, and four of the five choices are ordered trees:
#table(
columns: (auto, 1fr, auto),
stroke: 0.4pt + rgb("#cccccc"),
inset: 6pt,
[*clause*], [*stores*], [*ranges*],
[absent, or `using arctic`], [`ArcticIndex`, the default], [yes],
[`using worktables_index`], [WTI's `IndexMap`, leaf width tunable at the call site], [yes],
[`using indexset`], [a plain `BTreeMap`], [yes],
[`using congee`], [`CongeeIndex`], [yes],
[`using fxhash`], [`FxHashMap`], [*no*],
)
`fxhash` is the odd one and is worth what it costs to explain. Measured on a million
rows against the default (`perf-benchmarks/benchmarks/fx-index.rs`): *build 4.9x, lookup
4.0x*. Nothing else in the backend list moves a number that far.
What you give up is order. A table `using fxhash` has no `range` and no `range_by_`
methods at all — not a method that panics, not one that returns insertion order and calls
it key order; the methods are simply not generated, so asking for one is a compile error
at your call site.
It is accepted on `vec: true` and *refused on a paged table*, with an error saying so. Two
reasons, neither negotiable: a paged table generates `select_by_<column>_range` for every
index, and a persisted index's on-disk form is sorted pages, rebuilt with `attach_nodes`
on load. A hash map has neither an order to walk nor a page form to write.
`with_capacity` reserves an `fxhash` index along with the row vector, and that is most of
the build win: without it the same table managed 2.4x rather than 4.9x. It reserves
nothing for the tree backends, because there is nothing to gain — making allocation
completely free measures at *0.92x* for Arctic, below one, since it changes where nodes
land and sequential order is worse for a tree walked in key order.
=== Ranges
The ordered backends expose primary-key ranges. `fxhash` has no range API:
```rust
for row in table.range(100..200) { .. } // by primary key, in key order
for row in table.range(..).rev() { .. } // backwards
for row in table.range_by_code(&10..&20) { .. } // by a unique secondary index
```
This is not a sorted vector. The keys come out in order and the rows they name are
wherever insertion put them, so a long range is a walk of random accesses into the row
vector rather than a sequential read. `range_by_` is emitted for unique secondary indexes
only; a non-unique one holds a posting list per key and has no single row to yield.
=== Deleting, and the ghosts it leaves
`delete` empties one slot and removes its index entries. It avoids shifting all later
rows; index removal still has the selected backend's cost:
```rust
table.delete(&7); // no vector-wide shift; returns the removed row
table.ghost_count(); // 1
table.slots(); // unchanged
table.compact(); // reclaims the slot, renumbers the indexes
```
It used to close the hole with `Vec::remove`, which meant moving every row above it *and*
rewriting every index entry above it. At a million rows that cost 21 milliseconds per
delete, so two hundred deletes took four seconds.
What you pay instead is a slot that stays allocated until you ask for it back. That is the
paged table's ghost-and-vacuum model applied to a vector, and the same judgement applies:
`ghost_count` and `slots` are there so a caller decides when compaction is worth its cost.
`compact` keeps the row vector's capacity for reuse; `shrink_to_fit` is separate, because a
table that compacts in order to keep inserting wants the capacity it already has.
`select_all` returns an iterator rather than a `&[Row]` for this reason: with a hole in it
the live rows are not a contiguous slice, and handing one back would mean paying the
compaction the design exists to defer.
=== Sizing it
`with_capacity`, `capacity` and `reserve` size the row vector. `with_capacity` also
reserves the primary FxHash index when selected. Tree indexes do not reserve nodes
through this callsite. `with_capacity_and_node_size` combines row reserve with WTI
leaf width on tables that use WTI. Measure build and lookup separately before choosing
capacity or leaf width.
== 10. Choosing a runtime
The table declaration selects the default executor for owned async selects and the
backend identity required by named profiles. Ordinary borrowed mutations execute
where their caller polls them. Table locks remain portable; persistence uses a private
I/O pool, and engine background work follows the process runtime setting.
```rust
runtimes! { scheduled: nagoya(shared_slot), wide: nagoya(spread), }
worktable! {
name: Orders,
runtime: nagoya(shared_slot),
columns: { id: u64 primary_key, total: u64 },
queries: {
update runtime scheduled: { TotalById(total) by id },
update_in_place runtime scheduled: { TotalById(total) by id },
}
}
let table = Arc::new(OrdersWorkTable::default());
table.insert(OrdersRow { id: 1, total: 10 }).await?;
table.update_by_id(1, OrdersColumns::TOTAL, 20).await?;
table.update_in_place_by_id(1, OrdersColumns::TOTAL, |total| *total = 21.into()).await?;
let rows = table.select_all()
.order_on(OrdersRowFields::Total, Order::Desc)
.limit(100).runtime(wide).execute_async().await?;
```
Omitting the declaration defaults to Nagoya locality. A profile must match the declared backend family; Nagoya profiles may select a different flavor at the callsite. Tokio requires the `tokio-runtime` feature and an
entered Tokio runtime. `WT_DEFAULT_RUNTIME` overrides Nagoya flavors process-wide;
`WT_RUNTIME_WORKERS` sets pool size on first use. Keep these fixed when comparing runs.
`execute()` stays synchronous. With an explicit `.runtime(profile)`, it returns
`RuntimeRequiresAsync` instead of silently ignoring the profile. `execute_async()`
uses the table default when no profile was supplied. It materializes borrowed iterators
and `where_by` predicates on the caller before returning its future; range filters,
sorting, offset and limit execute on the worker over those owned rows. The full input
is materialized even for a small limit. This boundary releases borrowed table guards
and permits predicates that borrow local state, but adds allocation and dispatch cost.
It does not parallelize a scan or split sorting across workers.
Runtime-annotated update, delete and in-place sections generate methods on
`Arc<Table>`. Pass owned keys and `Send + 'static` closures; the cloned table handle
keeps storage alive. Unannotated methods retain their borrowed receivers and arguments.
Dropping a pending dispatch cancels it at the next suspension. Synchronous work already
running can finish; cancellation is not transaction rollback. Nested async dispatch
progresses even on one worker. Avoid blocking joins from a pool worker.
Vec tables remain synchronous and reject runtime annotations. Without default features,
explicit hosted profiles are unavailable and `execute_async()` runs its owned plan inline.
The existing dependency closure still needs std; this is not a freestanding-target claim.
== 11. A persisted table, end to end
```rust
worktable! (
name: Ledger,
version: 2, // optional, defaults to 1. Must precede persist.
persist: true, // generates LedgerPersistenceEngine
columns: {
id: u64 primary_key,
amount: i64,
},
);
let config = DiskConfig::new_with_table_name(
dir,
LedgerWorkTable::name_snake_case(),
LedgerWorkTable::version(),
);
let engine = LedgerPersistenceEngine::new(config).await?;
let table = LedgerWorkTable::load(engine).await?; // replays what is on disk
table.upsert(LedgerRow { id: 1, amount: 42 }).await?; // queued, not durable
table.close().await?; // the only thing that proves the queue drained
```
== 12. Everything at once
The prefix is ordered. Everything after `partition_max_size` is free-order.
```rust
worktable! (
name: Kitchen, // 1, required
version: 3, // 2, optional
// vec: true, // 3, optional, and excludes `persist`
persist: false, // 4, optional
partition_by: shard: u16, // 5, optional
partition_max_size: u64, // 6, required with `partition_by`
runtime: nagoya(locality), // free-order from here down
columns: {
id: u64 primary_key autoincrement,
nickname: String optional,
bucket: u32 columnar,
score: i64,
},
indexes: {
nickname_idx: nickname unique using worktables_index,
score_idx: score,
},
columnar_indexes: {
by_bucket: { cluster_by: [bucket] },
},
queries: {
update: { ScoreById(score) by id },
delete: { ById() by id },
update_in_place: { ScoreById(score) by id },
},
config: {
page_size: 4096,
columnar_chunk_rows: 4096,
row_derives: Clone, Debug,
},
);
```
#note("Writing `version` or `persist` late")[The prefix keys are positional and the
error says so rather than reporting an unexpected token. `version` after `columns` is
refused; so is `persist` or `partition_by`.]
= Index backends
`using` names the physical structure. They differ in what they can express, not only in
speed.
#table(
columns: (auto, 1fr),
stroke: 0.4pt + rgb("#cccccc"),
inset: 6pt,
[*Backend*], [*When it fits*],
[`worktables_index`], [The general ordered backend, including composite and optional keys.],
[`indexset`], [Vanilla IndexSet, selectable explicitly while keeping the same disk representation.],
[`arctic`], [*The default.* Supported integer keys and `String`; packs a row link into a single `u64`. Page stride must fit its 16-bit offset and length fields.],
[`congee`], [Fixed-width integer keys. Refuses `String` and other variable-width types.],
)
Rules:
- Omitting `using` gives `arctic`. A composite primary key keeps `worktables_index`,
because arctic cannot represent a tuple key.
- Congee must state `persist` explicitly. Its persistence uses native checkpoint and WAL
adapters rather than the shared page format.
- Arctic supports `String`, but not optional keys. `nickname_idx: nickname unique`
over a `String optional` is rejected, and the message names the type rather than the
omission. Say `using worktables_index`.
- Arctic caps page size at 65535: it packs a link into 64 bits with 16-bit offset and
length fields. The macro refuses the combination.
= Building without default features
Set `default-features = false` on the WorkTable dependency for the in-memory
API and generated calls with `no_std` and `alloc`. An allocator and supported
Unix or Windows OS services are required. Locks, entropy and the change-event
clock may use libc or Windows APIs without linking Rust's standard library.
Hosted persistence, background vacuum, runtime thread creation and the
`worktable_dsl` parser re-export require `std`. Embedded schema strings and
compile-time macro parsing remain available without it: proc macros run on the
build host. `tokio-runtime`, `vanilla-index`, `s3-support`, `perf_measurements` and `wti-superslice-search` enable `std`.
Point reads retain the fixed page directory. The no-std fallback page-list
snapshot clones an Arc under a short lock and releases the lock before visiting
rows. Standard builds retain ArcSwap. Change-event identifiers retain UUID v7
ordering, using OS time and a shared context when std is disabled.
CI removes Rust std from the target sysroot and compiles both the library and
an isolated consumer. A positive core/alloc control and a failing std control
verify the test environment. Host proc macros retain their normal sysroot.
```sh
sh scripts/check-no-std.sh -p worktable --lib --no-default-features
sh scripts/check-no-std.sh --manifest-path tests/nostd-consumer/Cargo.toml
cargo test --manifest-path tests/nostd-consumer/Cargo.toml
```
The consumer runs generated insertion, selection, scanning and deletion,
concurrent growth, and change-event identifier checks. Its tests supply a host
allocator and executor while WorkTable remains built without std.
= Page size
A page has two sizes and they are not interchangeable. The *stride* is what one page
occupies on disk, header included, and every file offset is computed from it. The
*payload size* is the stride less the 28-byte header. Persisted row pages also
reserve space for a live-row directory and checksum. Their row allocator budget
is smaller and depends on the minimum archived row size. Index and metadata
pages use the full payload budget.
Set it in the `config` block:
```rust
worktable! (
name: Small,
columns: { id: u64 primary_key, v: u64 },
config: { page_size: 4096 }
);
```
#note("Persisted tables have a floor, not a fixed size")[`page_size` works for a
persisted table, and the only rule is a 512-byte minimum: a page on disk carries a
28-byte header, so anything much smaller is mostly header. An Arctic-backed table is
also capped at 65535. In-memory tables have neither limit.
It was refused outright until recently, because the seeks computed offsets from a
hardcoded constant while the table threaded the configured one. Every location that
decides a page size, and the three silent bugs found while making them agree, are in
`docs/page-size.md`.]
= Columnar rules
Syntax is in #link(<examples>)[Example 7]. The constraints:
- Every field in `cluster_by` must itself declare `columnar`.
- A primary-key column must not declare `columnar`. It participates in columnar identity
implicitly, and declaring it again generates duplicate scan methods.
- `columnar_indexes` requires at least one `columnar` field.
- A columnar index must not take the name of a columnar field, which would generate two
scan methods with one name.
- `columnar_slot_id` and `columnar_chunk_rows` live in `config` because they apply to the
table. Defaults are `ColumnSlotId32` and 65,536.
= Persistence <persistence>
Persistence is implemented, not planned. Add `persist: true` and load the table through
an engine.
```rust
let config = DiskConfig::new_with_table_name(dir, OrderWorkTable::name_snake_case(), OrderWorkTable::version());
let engine = OrderPersistenceEngine::new(config).await?;
let table = OrderWorkTable::load(engine).await?;
```
With `s3-support`, the recommended hosted path groups persisted tables into one database
storage domain. DataBucket owns the generation protocol and S3 adapter; WorkTable supplies
one generated, read-only system catalog that maps every table, index and durable page.
The S3 engine still uses the disk engine as its local working copy.
== The durability contract
#table(
columns: (auto, 1fr),
stroke: 0.4pt + rgb("#cccccc"),
inset: 6pt,
[*Boundary*], [*What you actually get*],
[A mutation returns], [The in-memory change was accepted and its persistence operation was queued.],
[`wait_for_ops()` returns], [The engine completed the queued operations. No fsync, no stable-storage guarantee.],
[`close()` returns], [Intake stopped, the queue drained, the engine task joined. Still no fsync guarantee.],
[Process crash or `SIGKILL`], [Acknowledged rows may be lost and the file may be torn.],
[Power loss], [No atomic-batch or stable-storage guarantee.],
)
Call `close()` on orderly shutdown. `wait_for_ops()` is not a shutdown boundary: it does
not stop another task queueing more work, so it means nothing without writer quiescence.
Persistence failure is terminal. An event gap, queue-analysis error, batch-apply error or
engine-task failure fails the table, and the original error goes to waiters, to `close()`
and to later mutations.
== Loading a torn store
A normal load audits archived rows and both primary and secondary index consistency
before exposing the table, and refuses torn state with `PersistenceLoadError` rather
than opening plausible-but-invented rows.
`LoadMode::Recovery` exists for offline tools only. It copies individually validated
rows through a surviving index into a clean table, which must then pass a normal strict
load before anyone reads it. It is not an in-place repair and must never serve live
traffic.