Skip to main content

plugmem_core/memory/
shards.rs

1//! How many shards each arena gets.
2//!
3//! There is no correct constant. Too few shards on a large database lengthens
4//! the sorted page directory each insert memmoves within; too many on a small
5//! one buys a 4 KiB page for every shard that holds a single record, which is
6//! how a thousand facts came to occupy fourteen megabytes. The right number
7//! follows the data, so it is derived here rather than configured.
8//!
9//! Three properties this module owes the rest of the engine:
10//!
11//! 1. **It is a pure function of engine state.** `maintain` may re-shard, and
12//!    the journal records only *that* a maintenance pass ran, not what layout
13//!    it chose — so replay recomputes the layout from the same state and must
14//!    reach the same answer. A rule that consulted the clock, the caller's
15//!    config, or anything else outside the engine would make a journal replay
16//!    diverge from the run it replays.
17//! 2. **It does not depend on pointer width.** Every product below overflows a
18//!    32-bit `usize` at sizes a database can really reach (90M facts is enough),
19//!    so the arithmetic is `u64` throughout and the clamp happens before the
20//!    single cast. wasm32 and a 64-bit host must agree, for the same replay
21//!    reason.
22//! 3. **It reads only true payload, never occupancy.** Sizing from
23//!    `pool_bytes()` would feed page slack back into the rule: an over-sharded
24//!    arena reports more bytes, which would ask for more shards still. Counts
25//!    times slot widths are exact and have no such loop.
26//!
27//! Only the *sharded* structures matter. A [`PostingStore`](crate::index) keeps
28//! its bulk in an unsharded chunk pool and shards only the per-term handles, so
29//! the postings count follows the number of terms and documents, not the size
30//! of the posting lists.
31
32use plugmem_arena::Slot;
33
34use crate::config::{Config, MAX_SHARDS, MIN_SHARDS, SHARD_TARGET_BYTES};
35use crate::index::bm25::DocLenSlot;
36use crate::index::postings::IdListSlot;
37use crate::model::{
38    EdgeHistorySlot, EdgeSlot, EntityByName, EntityRecord, FactAux, FactRecord, TemporalSlot,
39};
40
41/// Shards for one arena group holding `bytes` of slot payload.
42///
43/// Clamping before `next_power_of_two` is deliberate: it keeps the result
44/// inside `[MIN_SHARDS, MAX_SHARDS]` (both powers of two) without the rounding
45/// ever overflowing, and makes the final cast provably in range on a 32-bit
46/// target.
47fn shards_for(bytes: u64) -> usize {
48    let want = bytes
49        .div_ceil(SHARD_TARGET_BYTES as u64)
50        .clamp(MIN_SHARDS as u64, MAX_SHARDS as u64);
51    debug_assert!(want <= MAX_SHARDS as u64);
52    want.next_power_of_two() as usize
53}
54
55/// Payload of `count` records of one slot type, in `u64` so the product cannot
56/// overflow the way `count * T::SIZE` would in a 32-bit `usize`.
57fn payload<T: Slot>(count: u64) -> u64 {
58    count.saturating_mul(T::SIZE as u64)
59}
60
61/// What the engine holds, in records — the only input the layout rule takes.
62///
63/// Counts, not bytes: see the module note on why occupancy must not feed back
64/// into the rule.
65#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
66pub(crate) struct Population {
67    /// Fact records that survive a purge (tombstones excluded).
68    pub facts: u64,
69    /// Entities. Never purged.
70    pub entities: u64,
71    /// Currently open edges, counted once per mirror arena.
72    pub edges: u64,
73    /// Edge history versions, open and closed.
74    pub edge_versions: u64,
75    /// Interned terms carrying postings.
76    pub terms: u64,
77    /// Distinct tags carrying id lists.
78    pub tags: u64,
79    /// Documents in the BM25 length arena.
80    pub documents: u64,
81}
82
83/// Shard counts for every arena group.
84///
85/// Public because it is observable: it appears in [`Stats`](crate::Stats) and
86/// in a [`MaintainReport`](crate::MaintainReport), which is how a caller sees
87/// that a database re-sharded itself and what it moved to.
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
90pub struct ShardLayout {
91    /// Facts and their cold sidecar.
92    pub facts: usize,
93    /// Entities, the by-name index and the per-entity fact lists.
94    pub entities: usize,
95    /// Both current edge mirrors and both history mirrors.
96    pub edges: usize,
97    /// The `recorded_at` index.
98    pub temporal: usize,
99    /// BM25 term handles and document lengths, plus the tag lists.
100    pub postings: usize,
101}
102
103impl Default for ShardLayout {
104    /// The floor — what an empty database is laid out with.
105    fn default() -> Self {
106        Self {
107            facts: MIN_SHARDS,
108            entities: MIN_SHARDS,
109            edges: MIN_SHARDS,
110            temporal: MIN_SHARDS,
111            postings: MIN_SHARDS,
112        }
113    }
114}
115
116impl ShardLayout {
117    /// How far a database may drift below its ideal layout before a
118    /// maintenance pass moves it.
119    ///
120    /// Observable rather than internal: it is what tells a caller how much
121    /// under-sharding to expect between rebuilds, and it is the figure a test
122    /// needs to know how much data provokes one. Under-sharding is the cheap
123    /// direction — see `ShardLayout::group_earns_rebuild` — so this is wide
124    /// on purpose.
125    pub const GROWTH_MARGIN: usize = GROW_FACTOR;
126
127    /// How far it may drift above before one shrinks it. Narrower, because
128    /// over-sharding is the direction that costs memory.
129    pub const SHRINK_MARGIN: usize = SHRINK_FACTOR;
130
131    /// The layout `population` calls for.
132    ///
133    /// Arenas that share a shard count are sized by the largest of them: that
134    /// one sets the page-directory length, and the smaller ones simply end up
135    /// with fewer pages per shard, which costs nothing.
136    pub(crate) fn for_population(population: &Population) -> Self {
137        let Population {
138            facts,
139            entities,
140            edges,
141            edge_versions,
142            terms,
143            tags,
144            documents,
145        } = *population;
146        Self {
147            // `facts` and `fact_aux`.
148            facts: shards_for(payload::<FactRecord>(facts).max(payload::<FactAux>(facts))),
149            // `entities`, `by_name` and the `entity_facts` id lists.
150            entities: shards_for(
151                payload::<EntityRecord>(entities)
152                    .max(payload::<EntityByName>(entities))
153                    .max(payload::<IdListSlot>(entities)),
154            ),
155            // Four arenas: both current mirrors and both history mirrors.
156            edges: shards_for(
157                payload::<EdgeSlot>(edges).max(payload::<EdgeHistorySlot>(edge_versions)),
158            ),
159            temporal: shards_for(payload::<TemporalSlot>(facts)),
160            // BM25 term handles, BM25 document lengths, and the tag id lists.
161            postings: shards_for(
162                payload::<IdListSlot>(terms)
163                    .max(payload::<DocLenSlot>(documents))
164                    .max(payload::<IdListSlot>(tags)),
165            ),
166        }
167    }
168
169    /// The layout a config records.
170    pub(crate) fn of_config(cfg: &Config) -> Self {
171        Self {
172            facts: cfg.shards_facts,
173            entities: cfg.shards_entities,
174            edges: cfg.shards_edges,
175            temporal: cfg.shards_temporal,
176            postings: cfg.shards_postings,
177        }
178    }
179
180    /// Writes this layout into `cfg`.
181    pub(crate) fn apply(&self, cfg: &mut Config) {
182        cfg.shards_facts = self.facts;
183        cfg.shards_entities = self.entities;
184        cfg.shards_edges = self.edges;
185        cfg.shards_temporal = self.temporal;
186        cfg.shards_postings = self.postings;
187    }
188
189    /// Whether one group's move from `have` to `want` earns a rebuild.
190    ///
191    /// Asymmetric, and in the direction the measurement points rather than the
192    /// intuitive one. Being *under*-sharded costs almost nothing: the sweep
193    /// behind [`PAGES_PER_SHARD`](crate::Config) found write throughput flat
194    /// out to 1465 pages per shard, and fewer shards also means fewer pages,
195    /// so it costs less memory too. Being *over*-sharded is the expensive
196    /// direction — that is the whole defect this rule exists to fix. So growth
197    /// waits for a wide margin and shrinking does not.
198    ///
199    /// The margin is not free to widen indefinitely: every crossing is a full
200    /// rebuild, and a bulk load that climbs through the size classes pays one
201    /// at each. A three-doubling band keeps a growing database under two
202    /// rebuilds on the way to a million facts while never letting it past
203    /// ~512 pages per shard, still well inside where the sweep measured
204    /// nothing.
205    ///
206    /// The gap between the two thresholds is also what stops a database
207    /// sitting near a boundary from rebuilding on every pass, and guarantees
208    /// the count a rebuild lands on is itself stable.
209    fn group_earns_rebuild(have: usize, want: usize) -> bool {
210        want >= have.saturating_mul(GROW_FACTOR) || have >= want.saturating_mul(SHRINK_FACTOR)
211    }
212
213    /// Whether the groups a compacting pass rebuilds are far enough out.
214    ///
215    /// Deliberately excludes the edges: a compaction does not touch the edge
216    /// arenas, so including them here would make every pass report work,
217    /// rebuild everything else, leave the edges as they were, and be asked
218    /// again immediately.
219    pub(crate) fn compacted_groups_earn_rebuild(&self, target: &Self) -> bool {
220        [
221            (self.facts, target.facts),
222            (self.entities, target.entities),
223            (self.temporal, target.temporal),
224            (self.postings, target.postings),
225        ]
226        .iter()
227        .any(|&(have, want)| Self::group_earns_rebuild(have, want))
228    }
229
230    /// Whether the four edge arenas are far enough out to be worth repacking.
231    pub(crate) fn edges_earn_rebuild(&self, target: &Self) -> bool {
232        Self::group_earns_rebuild(self.edges, target.edges)
233    }
234
235    /// This layout with the groups a compaction rebuilt taken from `target`,
236    /// and the edges taken from it only when they too were rebuilt.
237    ///
238    /// What a pass may claim in the config is exactly what it actually laid
239    /// out. Claiming more would leave the config describing a shape the file
240    /// does not have — the loader would then read those arenas with the wrong
241    /// shard count, which is corruption, not inefficiency.
242    pub(crate) fn realized(&self, target: &Self, edges_rebuilt: bool) -> Self {
243        Self {
244            facts: target.facts,
245            entities: target.entities,
246            temporal: target.temporal,
247            postings: target.postings,
248            edges: if edges_rebuilt {
249                target.edges
250            } else {
251                self.edges
252            },
253        }
254    }
255}
256
257/// How far a group must outgrow its shard count before growing it: three
258/// doublings. Wide because under-sharding is the cheap direction and each
259/// crossing costs a full rebuild — see `ShardLayout::group_earns_rebuild`.
260const GROW_FACTOR: usize = 8;
261/// How far it must fall below before shrinking: two doublings. Narrower than
262/// [`GROW_FACTOR`] because over-sharding is the direction that actually costs
263/// memory, and still wide enough that the two thresholds cannot both be true
264/// at once.
265const SHRINK_FACTOR: usize = 4;
266
267impl super::Memory<'_> {
268    /// What this engine currently holds, as the layout rule counts it.
269    ///
270    /// Facts are counted **live**: a rebuild does not re-insert tombstoned
271    /// records, so sizing for them would leave the fresh arenas over-sharded
272    /// by exactly the amount just purged.
273    pub(crate) fn population(&self) -> Population {
274        Population {
275            // `saturating_sub` rather than `-`: the tombstone count is carried
276            // state, and a wrapped subtraction here would not be caught, it
277            // would be *acted on* — a huge count sends the rule straight to
278            // `MAX_SHARDS` and the next pass allocates for it.
279            facts: self.facts.len().saturating_sub(self.tombstones) as u64,
280            entities: self.entities.len() as u64,
281            edges: self.edges_out.len() as u64,
282            edge_versions: self.edges_hist_out.len() as u64,
283            terms: self.bm25.postings().keys() as u64,
284            tags: self.tags_idx.keys() as u64,
285            documents: self.bm25.doc_len_arena().len() as u64,
286        }
287    }
288
289    /// The layout this engine's contents call for.
290    pub(crate) fn target_layout(&self) -> ShardLayout {
291        ShardLayout::for_population(&self.population())
292    }
293
294    /// Whether the arenas are laid out for a database this one is no longer.
295    ///
296    /// **O(1)** — every input is a stored record count, so a host may ask on
297    /// every write. That is the point: without a trigger of its own, a growing
298    /// database would keep the layout it was created with until somebody ran
299    /// `maintain` by hand, and the automatic maintenance that does exist
300    /// (`maintain_every_forgets`) is off by default.
301    ///
302    /// Self-limiting, which is what makes it safe to act on: the thresholds are
303    /// a doubling up and a fourfold drop, so this turns true a handful of times
304    /// over a database's whole life, not continuously.
305    pub fn shard_layout_is_stale(&self) -> bool {
306        let stored = ShardLayout::of_config(&self.cfg);
307        let target = self.target_layout();
308        stored.compacted_groups_earn_rebuild(&target) || stored.edges_earn_rebuild(&target)
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    /// A population, spelled out so the table below reads as data.
317    fn population(
318        facts: u64,
319        entities: u64,
320        edges: u64,
321        edge_versions: u64,
322        terms: u64,
323        tags: u64,
324    ) -> Population {
325        Population {
326            facts,
327            entities,
328            edges,
329            edge_versions,
330            terms,
331            tags,
332            // Every live fact is one BM25 document.
333            documents: facts,
334        }
335    }
336
337    fn layout(p: Population) -> (usize, usize, usize, usize, usize) {
338        let l = ShardLayout::for_population(&p);
339        (l.facts, l.entities, l.edges, l.temporal, l.postings)
340    }
341
342    /// The rule, pinned. Change these numbers only on purpose: they decide how
343    /// much memory every database on every host occupies, and a silent shift
344    /// re-shards every database in the field on its next maintenance pass.
345    #[test]
346    fn the_layout_rule_is_a_fixed_table() {
347        // Empty: nothing to hold, so every group sits on the floor.
348        assert_eq!(layout(population(0, 0, 0, 0, 0, 0)), (4, 4, 4, 4, 4));
349        // One fact. Same floor — a shard costs a page, and one page is plenty.
350        assert_eq!(layout(population(1, 1, 0, 0, 3, 1)), (4, 4, 4, 4, 4));
351        // A personal memory: thousands of facts, still entirely on the floor.
352        // This is the case the whole change exists for.
353        assert_eq!(
354            layout(population(1_000, 50, 20, 25, 1_500, 5)),
355            (4, 4, 4, 4, 4)
356        );
357        assert_eq!(
358            layout(population(5_000, 200, 100, 120, 6_000, 12)),
359            (4, 4, 4, 4, 4)
360        );
361        // 100k facts.
362        assert_eq!(
363            layout(population(100_000, 4_096, 30_000, 60_000, 34_000, 32)),
364            (32, 4, 16, 8, 8)
365        );
366        // The 1M benchmark corpus, the point the target was calibrated on.
367        assert_eq!(
368            layout(population(910_051, 4_096, 287_972, 549_482, 34_604, 64)),
369            (256, 4, 128, 64, 64)
370        );
371        // Ten million: the rule keeps pages-per-shard flat rather than letting
372        // either the directory or the page floor run away.
373        assert_eq!(
374            layout(population(
375                10_000_000, 40_000, 3_000_000, 6_000_000, 200_000, 128
376            )),
377            (2048, 4, 2048, 512, 1024)
378        );
379    }
380
381    /// Products in this rule pass `u32::MAX` at populations a database can
382    /// really reach, so they are computed in `u64` and the clamp happens before
383    /// the one cast. On wasm32 a `usize` product would have wrapped here, and a
384    /// wrapped layout means a journal replays differently there than on the
385    /// host that wrote it.
386    #[test]
387    fn the_rule_does_not_depend_on_pointer_width() {
388        // 100M facts x 48 B = 4.8 GB, past u32::MAX.
389        let big = population(100_000_000, 1_000, 0, 0, 1_000_000, 256);
390        assert!(u64::from(u32::MAX) < 100_000_000u64 * 48);
391        assert_eq!(layout(big).0, 32768);
392        // Saturating all the way up still lands on the ceiling, not on garbage.
393        let absurd = population(u64::MAX, u64::MAX, u64::MAX, u64::MAX, u64::MAX, u64::MAX);
394        assert_eq!(
395            layout(absurd),
396            (MAX_SHARDS, MAX_SHARDS, MAX_SHARDS, MAX_SHARDS, MAX_SHARDS)
397        );
398    }
399
400    /// Every value the rule can produce is a legal shard count.
401    #[test]
402    fn every_produced_count_is_a_usable_shard_count() {
403        let mut facts = 0u64;
404        while facts < 40_000_000 {
405            let l = ShardLayout::for_population(&population(facts, facts / 8, 0, facts, facts, 16));
406            for n in [l.facts, l.entities, l.edges, l.temporal, l.postings] {
407                assert!(n.is_power_of_two(), "{n} is not a power of two");
408                assert!((MIN_SHARDS..=MAX_SHARDS).contains(&n), "{n} out of range");
409            }
410            facts = (facts + 1) * 3 / 2;
411        }
412    }
413
414    fn at(n: usize) -> ShardLayout {
415        ShardLayout {
416            facts: n,
417            entities: n,
418            edges: n,
419            temporal: n,
420            postings: n,
421        }
422    }
423
424    #[test]
425    fn growth_is_eager_and_shrinking_is_lazy() {
426        // Unchanged: nothing to do.
427        assert!(!at(64).compacted_groups_earn_rebuild(&at(64)));
428        // Growth waits for a wide margin: a doubling is not enough.
429        assert!(!at(64).compacted_groups_earn_rebuild(&at(128)));
430        assert!(!at(64).compacted_groups_earn_rebuild(&at(256)));
431        assert!(at(64).compacted_groups_earn_rebuild(&at(512)));
432        // Shrinking waits for four times over, so the two thresholds leave a
433        // band in which neither fires and a database near a boundary rests.
434        assert!(!at(64).compacted_groups_earn_rebuild(&at(32)));
435        assert!(at(64).compacted_groups_earn_rebuild(&at(16)));
436        // And the band means a rebuild cannot bounce back: having acted, the
437        // new count is itself stable against the population that caused it.
438        assert!(!at(16).compacted_groups_earn_rebuild(&at(16)));
439        assert!(!at(128).compacted_groups_earn_rebuild(&at(128)));
440        // One group out of step is enough.
441        let mut skewed = at(64);
442        skewed.postings = 4;
443        assert!(at(64).compacted_groups_earn_rebuild(&skewed));
444    }
445
446    /// The edges are asked about separately because only a repack rebuilds
447    /// them. Were they folded into the compaction question, a database whose
448    /// edges alone were out of layout would rebuild everything else on every
449    /// pass, never fix the edges, and be asked again forever.
450    #[test]
451    fn the_edge_arenas_are_judged_on_their_own() {
452        let mut edges_only = at(64);
453        edges_only.edges = 1024;
454        assert!(!at(64).compacted_groups_earn_rebuild(&edges_only));
455        assert!(at(64).edges_earn_rebuild(&edges_only));
456    }
457
458    /// A pass may only claim the layout it actually built.
459    #[test]
460    fn a_pass_claims_only_what_it_laid_out() {
461        let stored = at(64);
462        let target = at(512);
463        // Compaction alone: four groups move, the untouched edges do not.
464        let without = stored.realized(&target, false);
465        assert_eq!(
466            (
467                without.facts,
468                without.entities,
469                without.temporal,
470                without.postings
471            ),
472            (512, 512, 512, 512)
473        );
474        assert_eq!(without.edges, stored.edges);
475        // With a repack, the edges move too.
476        assert_eq!(stored.realized(&target, true), target);
477    }
478
479    /// The floor and the ceiling are both reachable, and neither is crossed.
480    #[test]
481    fn the_clamp_holds_at_both_ends() {
482        assert_eq!(shards_for(0), MIN_SHARDS);
483        assert_eq!(shards_for(1), MIN_SHARDS);
484        assert_eq!(
485            shards_for(SHARD_TARGET_BYTES as u64 * MIN_SHARDS as u64),
486            MIN_SHARDS
487        );
488        assert_eq!(shards_for(u64::MAX), MAX_SHARDS);
489        // Just past the floor the rule starts following the data.
490        assert_eq!(
491            shards_for(SHARD_TARGET_BYTES as u64 * MIN_SHARDS as u64 + 1),
492            MIN_SHARDS * 2
493        );
494    }
495}