armdb 0.11.0

sharded bitcask key-value storage optimized for NVMe
Documentation
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
//! The set of index rows every declared source says should exist.
//!
//! [`validate`](super::validate) and [`repair`](super::repair) must agree on
//! this to the byte: if they don't, CI fails on an index the repair tool
//! considers correct, or repair "fixes" one the validator was happy with. They
//! genuinely differ only in what they do with a problem — report it and keep
//! going, or stop before touching anything — so that is the one thing injected.

use std::collections::{HashMap, HashSet};
use std::sync::Arc;

use crate::counter::{CounterSink, ErasedCounterTarget};
use crate::error::{DbError, DbResult};
use crate::unique::ClaimTarget;

use super::registry::{BuiltRegistry, Decl, IndexTargetDecl};

/// `(source collection, hex source key)` — the row that projected an entry.
pub(super) type Owner = (String, String);

/// A problem found while building the expected set.
pub(super) enum BuildIssue {
    /// Two source rows own the same `(index, key)`, so its value is a function
    /// of scan order.
    Collision {
        index: &'static str,
        key: Vec<u8>,
        owners: [Owner; 2],
    },
    /// A declaration's projection closure failed on a source row.
    Projection {
        source: &'static str,
        error: DbError,
    },
}

/// A source whose scan skipped rows it could not read or decode.
///
/// Their projections are missing from the expected set, so anything derived
/// from it is short — see [`super::repair`], which refuses to delete orphans on
/// the targets they feed.
pub(super) struct SkippedSource {
    pub name: &'static str,
    pub rows: u64,
    pub targets: Vec<&'static str>,
}

/// One target's expected rows: index key -> (encoded value, owner).
pub(super) type TargetRows = HashMap<Vec<u8>, (Vec<u8>, Owner)>;

/// Expected index rows, keyed by target and then by index key.
///
/// Keyed by *key*, not by emitted row: a projection may emit the same index key
/// more than once and the last value wins. Consuming rows as they are emitted
/// instead makes a correct index look mismatched, and writes it twice.
#[derive(Default)]
pub(super) struct ExpectedRows {
    by_target: HashMap<&'static str, TargetRows>,
}

impl ExpectedRows {
    pub(super) fn rows(&self, target: &str) -> Option<&TargetRows> {
        self.by_target.get(target)
    }

    pub(super) fn contains(&self, target: &str, key: &[u8]) -> bool {
        self.by_target
            .get(target)
            .is_some_and(|m| m.contains_key(key))
    }
}

/// Every target named by an index declaration, whether or not it has rows —
/// an empty target still needs its orphan sweep.
pub(super) fn declared_targets(reg: &BuiltRegistry) -> HashSet<&'static str> {
    reg.reg
        .decls
        .iter()
        .filter_map(|d| match d {
            Decl::Index { targets, .. } => Some(targets.as_slice()),
            _ => None,
        })
        .flat_map(|ts| ts.iter().map(|t| t.name))
        .collect()
}

/// First declaration of `name`. Several sources may declare the same target;
/// each declaration holds an `Arc` of the same index and equivalent codecs, so
/// any one of them drives the target's scan, compare and write.
pub(super) fn target_decl<'r>(reg: &'r BuiltRegistry, name: &str) -> Option<&'r IndexTargetDecl> {
    reg.reg
        .decls
        .iter()
        .filter_map(|d| match d {
            Decl::Index { targets, .. } => Some(targets),
            _ => None,
        })
        .flat_map(|ts| ts.iter())
        .find(|t| t.name == name)
}

/// Scan every index declaration and collapse its projection into one expected
/// row per `(target, key)`.
///
/// `on_issue` decides the policy: returning `Ok(())` records the problem and
/// keeps scanning, returning `Err` aborts the build — which is how `repair`
/// refuses a registry before it has mutated anything.
pub(super) fn build(
    reg: &BuiltRegistry,
    on_issue: &mut dyn FnMut(BuildIssue) -> DbResult<()>,
) -> DbResult<(ExpectedRows, Vec<SkippedSource>)> {
    let mut expected = ExpectedRows::default();
    let mut skipped: Vec<SkippedSource> = Vec::new();

    for d in &reg.reg.decls {
        let Decl::Index {
            source,
            targets,
            project,
        } = d
        else {
            continue;
        };
        let Some(src) = reg.reg.entries.iter().find(|e| e.name == *source) else {
            continue;
        };

        // The scan callback cannot return, so an abort is stashed and checked
        // once the scan is over.
        let mut abort: Option<DbError> = None;
        let mut issue = |i: BuildIssue, abort: &mut Option<DbError>| {
            if let Err(e) = on_issue(i) {
                *abort = Some(e);
            }
        };
        let limits = src.projection_limits;
        (src.scan_any)(&mut |src_kb, k, v| {
            if abort.is_some() {
                return;
            }
            let mut rows = Vec::new();
            if let Err(e) = project(k, v, limits, &mut rows) {
                issue(BuildIssue::Projection { source, error: e }, &mut abort);
                return;
            }
            let owner = (source.to_string(), super::validate::hex(src_kb));
            for (target_idx, key, value) in rows {
                let Some(t) = targets.get(target_idx) else {
                    issue(
                        BuildIssue::Projection {
                            source,
                            error: DbError::Internal("index decl: target index out of range"),
                        },
                        &mut abort,
                    );
                    continue;
                };
                let rows = expected.by_target.entry(t.name).or_default();
                if let Some((_, existing)) = rows.get(&key)
                    && existing != &owner
                {
                    let mut owners = [existing.clone(), owner.clone()];
                    owners.sort();
                    issue(
                        BuildIssue::Collision {
                            index: t.name,
                            key: key.clone(),
                            owners,
                        },
                        &mut abort,
                    );
                    continue;
                }
                rows.insert(key, (value, owner.clone()));
            }
        });
        if let Some(e) = abort {
            return Err(e);
        }

        // The scan drops rows it cannot read or decode, so this source's half of
        // the expected set is short by their projections.
        let rows = (src.undecodable_rows)();
        if rows > 0 {
            let names: Vec<&'static str> = targets.iter().map(|t| t.name).collect();
            tracing::warn!(
                source = source,
                rows,
                targets = ?names,
                "schema: source rows could not be read; the expected index set is incomplete"
            );
            skipped.push(SkippedSource {
                name: source,
                rows,
                targets: names,
            });
        }
    }

    Ok((expected, skipped))
}

/// `(target collection, target field, key bytes)` — the field discriminator
/// is required: [`SchemaRegistry::counted`](super::registry::SchemaRegistry::counted)'s
/// own contract allows several *different* `(target, field)` pairs to be fed
/// by their own sources onto the very same target collection (only one
/// declared source per pair, enforced by
/// [`super::registry::RegistryBuildError::DuplicateCounterSource`]). Keying by
/// collection alone would let two such declarations collide on the same map
/// slot — see the regression fixed in the T9 Task 11 review.
pub(super) type CounterKey = (&'static str, &'static str, Vec<u8>);

/// Every counter source's declared contributions, summed per source row and
/// resolved to the concrete target row they belong to:
/// [`CounterKey`] `-> expected absolute value`.
///
/// Shared by [`super::validate`] and [`super::repair`] for the same reason
/// [`build`] is (see the module doc): if the two computed this independently
/// they could disagree about what a counter should hold.
///
/// Unlike an index projection, a counter's contributions are meant to be
/// *summed*, not compared for a same-owner collision, so there is no
/// `BuildIssue`-style "record and keep going" callback here — the only real
/// failures are hard ones: the declaration's projection closure erroring on a
/// row, or an accumulated sum overflowing `u64`. Either aborts the whole
/// build — mirrors `repair`'s index pass aborting on a key collision: there
/// is nothing safe to fall back to, so both callers treat a failure here as
/// fatal to the counter section rather than comparing against a map that
/// silently under-reports (see [`super::run_decls`] and [`super::repair`]).
///
/// A contribution resolving to no live target is **not** treated as fatal:
/// [`crate::counter::CounterTargets::resolve_group`]'s `Ok(None)` is skipped
/// (reserved, not reachable today) and its `CounterTargetMissing` — a
/// dangling contribution, e.g. a vacated `one_of` group — is warned and
/// skipped, exactly mirroring [`crate::counter::recount`]'s pass 2. That
/// ruling exists so a single dangling contribution cannot make a database
/// unopenable (see `counter.rs`); propagating it as an error here would
/// re-break that guarantee the moment `validate`/`repair` run on a database
/// an open-time recount already warned about. `AmbiguousCounterTarget` (more
/// than one current holder) has no safe skip target and stays a hard error,
/// same as `recount`.
///
/// A source whose scan skipped undecodable rows contributes a short sum here
/// (the skipped rows' contributions are simply absent) — the caller must not
/// treat that as ground truth for a destructive repair. `repair` checks the
/// same source's `undecodable_rows` separately and declines to rewrite its
/// targets; `validate` reports the row skip as its own
/// [`super::Finding::UndecodableRows`] (via the per-collection check every
/// registered entry already gets) alongside whatever `CounterMismatch`
/// findings the short sum produces.
///
/// A declaration whose source is not a registered entry is skipped here —
/// exactly like [`build`]'s index half — but that is **not** license to read
/// its targets as "expected zero": both callers check
/// `reg.reg.entries.iter().any(|e| e.name == *source)` themselves before
/// comparing or repairing a declaration's targets, so a merely-unregistered
/// source protects its targets the same way an undecodable one does, instead
/// of the missing data being mistaken for a confirmed count.
pub(super) fn build_counters(reg: &BuiltRegistry) -> DbResult<HashMap<CounterKey, u64>> {
    let mut out: HashMap<CounterKey, u64> = HashMap::new();

    for d in &reg.reg.decls {
        let Decl::Counter {
            source,
            targets,
            project,
        } = d
        else {
            continue;
        };
        let Some(src) = reg.reg.entries.iter().find(|e| e.name == *source) else {
            continue;
        };
        let (max_rows, max_bytes) = src.projection_limits.unwrap_or((
            crate::projection::DEFAULT_MAX_PROJECTED_ROWS,
            crate::projection::DEFAULT_MAX_PROJECTED_BYTES,
        ));

        // Pass 1 (scoped to this one declaration's `CounterTargets`): sum
        // every source row's contributions into (group index, key) -> u128 —
        // the same shape `counter::recount`'s pass 1 builds.
        let mut sums: HashMap<(usize, Vec<u8>), u128> = HashMap::new();
        let mut abort: Option<DbError> = None;
        (src.scan_any)(&mut |_src_kb, k, v| {
            if abort.is_some() {
                return;
            }
            let mut sink = CounterSink::new(targets, max_rows, max_bytes);
            if let Err(e) = project(k, v, &mut sink) {
                abort = Some(e);
                return;
            }
            for (slot, w) in sink.into_acc() {
                let entry = sums.entry(slot).or_insert(0u128);
                match entry.checked_add(w) {
                    Some(sum) => *entry = sum,
                    None => abort = Some(DbError::Internal("counter expected-sum overflow")),
                }
            }
        });
        if let Some(e) = abort {
            return Err(e);
        }

        // Resolve each (group, key) to the concrete target member holding it
        // — exactly as the write path resolves a live delta
        // (`counter::into_tx_counter_deltas`) and `counter::recount`'s pass 2
        // do, including the same dangling-contribution leniency (see the
        // rustdoc above).
        for ((gi, key), sum) in sums {
            let member: &std::sync::Arc<dyn ErasedCounterTarget> = match targets
                .resolve_group(gi, &key)
            {
                Ok(Some(m)) => m,
                // Reserved by `resolve_group`, not reachable today — see its
                // own rustdoc. Kept a no-op rather than an `unwrap`.
                Ok(None) => continue,
                Err(DbError::CounterTargetMissing { targets, key }) => {
                    tracing::warn!(
                        group = gi,
                        targets = %targets,
                        key = %key,
                        "schema: dangling contribution — no live target row holds this key, skipping (see counter::recount)"
                    );
                    continue;
                }
                Err(e) => return Err(e),
            };
            // `OnMissingTarget::Skip` on an absent row means the write path
            // **dropped** this contribution — deliberately, by declaration.
            // Recording it as expected would report a `CounterMismatch` on a
            // database that is exactly right, forever, and send `repair` at a
            // row it can only no-op on. (`Error` and `Create` both stay: the
            // first would have failed the write, the second would have
            // materialised the row, so in either case an absent row is real
            // drift. A `one_of` group never reaches this branch — its own
            // `contains` probe above already resolved the holder.)
            if member.on_missing() == crate::counter::OnMissingTarget::Skip
                && !member.contains(&key)?
            {
                continue;
            }
            let want = u64::try_from(sum).map_err(|_| DbError::CounterOverflow {
                collection: member.collection_name(),
                field: member.field(),
                key: super::validate::hex(&key),
            })?;
            out.insert((member.collection_name(), member.field(), key), want);
        }
    }

    Ok(out)
}

/// One row where a counter target's stored value differs from what
/// [`build_counters`] expects.
pub(super) struct CounterDrift {
    pub key: Vec<u8>,
    pub want: u64,
    pub stored: u64,
}

/// Union `target`'s currently-stored rows with the keys `expected` has for
/// it, and return every key where the two differ — the counter twin of the
/// index compare loop above, shared verbatim by `validate` and `repair` so
/// the two cannot compute drift differently one level below
/// [`build_counters`] itself (that was exactly how the shape of this bug
/// hid: the map was already shared, but each caller re-derived "what differs"
/// from it independently).
///
/// Not only keys the source contributed to: a target row nobody contributes
/// to any more has no entry in `expected`, and the union with its physically
/// stored rows is what catches it — comparing it against the implied zero,
/// mirroring `counter::recount`'s pass 2 unioning `expected` with each
/// target's currently-stored rows.
pub(super) fn counter_drift(
    target: &std::sync::Arc<dyn ErasedCounterTarget>,
    expected: &HashMap<CounterKey, u64>,
) -> DbResult<Vec<CounterDrift>> {
    let mut existing: HashMap<Vec<u8>, u64> = HashMap::new();
    target.for_each_counter_row(&mut |key, stored| {
        existing.insert(key.to_vec(), stored);
        Ok(())
    })?;

    let name = target.collection_name();
    let field = target.field();
    let mut keys: HashSet<Vec<u8>> = existing.keys().cloned().collect();
    keys.extend(
        expected
            .keys()
            .filter(|(n, f, _)| *n == name && *f == field)
            .map(|(_, _, key)| key.clone()),
    );

    let mut out = Vec::new();
    for key in keys {
        let want = expected
            .get(&(name, field, key.clone()))
            .copied()
            .unwrap_or(0);
        let stored = existing.get(&key).copied().unwrap_or(0);
        if want != stored {
            out.push(CounterDrift { key, want, stored });
        }
    }
    Ok(out)
}

// -- T10 Task 9: unique-namespace expected claim set -------------------------
//
// Shared by `validate` (this task) and `repair` (T10 Task 10) for the same
// reason `build`/`build_counters` above are: if the two computed "what a
// namespace should hold" independently, they could disagree about it.

/// One source row's claim on a unique-namespace key — the unique-namespace
/// twin of [`Owner`], keeping the decoded [`crate::UniqueOwner`] alongside so
/// a namespace write does not need to reconstruct it. `validate` only reads
/// `source`/`source_key` for its findings; `schema::unique_repair::repair_namespaces`
/// (T10 Task 10) is what reads `owner` — it is the claimant `owner` passed to
/// [`crate::unique::resolve::decide`] whenever a `UniqueMissing`-shaped key
/// needs a `Write` decision turned into an actual row.
pub(super) struct UniqueClaim {
    pub source: &'static str,
    pub source_key: String,
    pub owner: crate::UniqueOwner,
}

/// `(namespace, key bytes) -> every source row that claims it`.
///
/// Unlike [`TargetRows`] (the index half), a second claimant is **not**
/// first-wins-plus-collision-report during the build: this module's `build`
/// aborts recording a second owner and reports a [`BuildIssue::Collision`]
/// because an index target genuinely can only hold one value per key at a
/// time. A unique namespace has the same one-row-per-key constraint, but
/// getting *two claimants in the expected set* here is not a build-time
/// anomaly — it is the exact condition [`super::Finding::UniqueConflict`]
/// exists to report (see this namespace's own module doc: unreachable under
/// live enforcement, so it only shows up on data written before adoption, a
/// source written outside the registry, or a bug). So every claimant is kept.
pub(super) type UniqueExpected = HashMap<(&'static str, Vec<u8>), Vec<UniqueClaim>>;

/// Every namespace named by any `Decl::Unique` declaration, mapped to its
/// live [`ClaimTarget`] handle — deduplicated, since several sources may
/// declare into the same namespace. Unlike [`target_decl`] (the index half),
/// this does **not** go through `reg.reg.entries`: a namespace registers with
/// [`super::registry::SchemaRegistry::register_external`] (no handle —
/// `UniqueIndex` is not a [`super::access::SchemaCollection`], so it cannot
/// go through `register`/`register_entity`), so the declaration itself
/// (`Decl::Unique.targets`, a [`crate::UniqueTargets`]) is the only place a
/// live handle to scan it lives.
pub(super) fn declared_unique_handles(
    reg: &BuiltRegistry,
) -> HashMap<&'static str, Arc<dyn ClaimTarget>> {
    let mut out: HashMap<&'static str, Arc<dyn ClaimTarget>> = HashMap::new();
    for d in &reg.reg.decls {
        if let Decl::Unique { targets, .. } = d {
            for t in targets.all() {
                out.entry(t.collection_name())
                    .or_insert_with(|| Arc::clone(t));
            }
        }
    }
    out
}

/// Union every declared source's claims into one expected set per namespace —
/// a namespace fed by three sources has one expected set, not three (see the
/// module doc). Mirrors [`build_counters`]'s shape (a hard `DbResult` failure
/// on a projection error, no per-issue callback) rather than [`build`]'s: see
/// [`UniqueExpected`]'s doc for why a second claimant is not itself a
/// build-time problem here. A declaration whose source is not a registered
/// entry is skipped — exactly like `build`'s and `build_counters`'s own guard
/// against reading an absent scan as "confirmed empty".
pub(super) fn build_unique(reg: &BuiltRegistry) -> DbResult<(UniqueExpected, Vec<SkippedSource>)> {
    use crate::unique::{ClaimSink, UniqueOwner, collection_tag};

    let mut expected: UniqueExpected = HashMap::new();
    let mut skipped: Vec<SkippedSource> = Vec::new();

    for d in &reg.reg.decls {
        let Decl::Unique {
            source,
            targets,
            project,
        } = d
        else {
            continue;
        };
        let Some(src) = reg.reg.entries.iter().find(|e| e.name == *source) else {
            continue;
        };
        let (max_rows, max_bytes) = src.projection_limits.unwrap_or((
            crate::projection::DEFAULT_MAX_PROJECTED_ROWS,
            crate::projection::DEFAULT_MAX_PROJECTED_BYTES,
        ));

        let mut abort: Option<DbError> = None;
        (src.scan_any)(&mut |src_kb, k, v| {
            if abort.is_some() {
                return;
            }
            let mut sink = ClaimSink::new(targets, max_rows, max_bytes);
            if let Err(e) = project(k, v, &mut sink) {
                abort = Some(e);
                return;
            }
            let Some(owner) = UniqueOwner::new(collection_tag(source), src_kb, 0) else {
                abort = Some(DbError::Internal(
                    "unique decl: source key exceeds the owner capacity",
                ));
                return;
            };
            // Dedup this one row's own claims before recording them: a row
            // whose projection calls `sink.claim` twice for the same
            // `(namespace, key)` (e.g. two aliases that normalise to the same
            // claim, or a duplicate list entry) makes exactly one claim at
            // commit time — `unique::diff::claim_set` collapses into a
            // `BTreeSet` for the same reason. Recording it twice here would
            // manufacture a false `UniqueConflict` against itself.
            let mut this_row: std::collections::BTreeSet<(&'static str, Vec<u8>)> =
                std::collections::BTreeSet::new();
            for intent in sink.claims() {
                this_row.insert((intent.namespace, intent.key.clone()));
            }
            for (namespace, key) in this_row {
                expected
                    .entry((namespace, key))
                    .or_default()
                    .push(UniqueClaim {
                        source,
                        source_key: super::validate::hex(src_kb),
                        owner,
                    });
            }
        });
        if let Some(e) = abort {
            return Err(e);
        }

        // The scan drops rows it cannot read or decode, so this source's half
        // of the expected set is short by their claims.
        let rows = (src.undecodable_rows)();
        if rows > 0 {
            let names: Vec<&'static str> = targets.all().map(|t| t.collection_name()).collect();
            tracing::warn!(
                source = source,
                rows,
                targets = ?names,
                "schema: source rows could not be read; the expected unique-claim set is incomplete"
            );
            skipped.push(SkippedSource {
                name: source,
                rows,
                targets: names,
            });
        }
    }

    Ok((expected, skipped))
}