feat(core): RecordHash/AggregateHash and the record index in DataStore
ADR 002's surrogate, and the reverse index a server resolves it through.
The hash is specified rather than inherited: SipHash-2-4 under a constant
key over an explicit length-prefixed byte serialization of the sorted
(category, item) pairs. Not DefaultHasher/RandomState (randomly seeded per
process), not #[derive(Hash)] (no stability guarantee). 128 bits, argued in
the doc comment: a collision is a silent write to the wrong record and no
later check catches it, while the cost of the wider hash is a bounded 8
bytes per visible cell. The wire form is hex-as-a-string, because a JSON
number above 2^53 comes back changed in a JavaScript host.
RecordHash::of normalizes, so a cell key and its record hash alike -- the
_Measure-drop is a property of the constructor, not a rule callers follow.
The index unifies ADR 002's resolution with ADR 001's record_versions:
RecordHash -> RecordEntry { record, cells, version }, one lookup answering
both "which record do you mean?" and "is your basis stale?". A zero cell
count is a tombstone rather than a removal, so a delete-and-recreate cannot
hand a stale basis its own version number back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
This commit is contained in:
Generated
+2
@@ -880,6 +880,8 @@ dependencies = [
|
||||
"indexmap",
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -11,6 +11,12 @@ improvise-formula = { path = "../improvise-formula" }
|
||||
anyhow = "1"
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
# SipHash-2-4 under a constant key: the record surrogate must not move
|
||||
# between processes, which rules out anything built on `RandomState`
|
||||
# (ADR 002). Already in the tree as a transitive dependency; no_std and
|
||||
# wasm-clean.
|
||||
siphasher = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
serde_json = "1"
|
||||
|
||||
@@ -2,6 +2,7 @@ use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::record::{RecordHash, record_of};
|
||||
use super::symbol::{Symbol, SymbolTable};
|
||||
|
||||
/// A cell key is a sorted vector of (category_name, item_name) pairs.
|
||||
@@ -101,6 +102,45 @@ impl std::fmt::Display for CellValue {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct InternedKey(pub Vec<(Symbol, Symbol)>);
|
||||
|
||||
/// What the record index knows about one record: the coordinates a
|
||||
/// surrogate resolves back to, and the write counter a basis is checked
|
||||
/// against (ADR 001 + ADR 002, unified — see [`DataStore::records`]).
|
||||
///
|
||||
/// The two questions a client's surrogate raises — "which record do you
|
||||
/// mean?" and "is your basis stale?" — are answered from this one entry, in
|
||||
/// one lookup, because both are keyed by the record and both are
|
||||
/// invalidated at the same moment.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecordEntry {
|
||||
record: CellKey,
|
||||
/// How many stored cells belong to this record. Zero is a **tombstone**,
|
||||
/// not a reason to forget the entry: a record that is deleted and then
|
||||
/// re-created must not hand a client holding the old basis its own
|
||||
/// version number back. Dropping the entry would reset the counter to
|
||||
/// zero and make a stale basis look current, which is the silent-accept
|
||||
/// this index exists to prevent.
|
||||
cells: usize,
|
||||
version: u64,
|
||||
}
|
||||
|
||||
impl RecordEntry {
|
||||
/// The coordinate tuple, `_Measure` already excluded.
|
||||
pub fn record(&self) -> &CellKey {
|
||||
&self.record
|
||||
}
|
||||
|
||||
/// ADR 001's per-record write counter. Zero for a record nothing has
|
||||
/// written.
|
||||
pub fn version(&self) -> u64 {
|
||||
self.version
|
||||
}
|
||||
|
||||
/// Whether any stored cell still belongs to this record.
|
||||
pub fn is_live(&self) -> bool {
|
||||
self.cells > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialized as a list of (key, value) pairs so CellKey doesn't need
|
||||
/// to implement the `Serialize`-as-string requirement for JSON object keys.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -112,6 +152,28 @@ pub struct DataStore {
|
||||
pub symbols: SymbolTable,
|
||||
/// Secondary index: interned (category, item) → set of interned keys.
|
||||
index: HashMap<(Symbol, Symbol), HashSet<InternedKey>>,
|
||||
/// Reverse index for the client-facing surrogate (ADR 002): the one-way
|
||||
/// [`RecordHash`] back to the record it names, plus that record's write
|
||||
/// counter.
|
||||
///
|
||||
/// It lives here because this is where the same job is already done. The
|
||||
/// store already interns every name and already maintains a secondary
|
||||
/// index updated at exactly these two points, and it is that existing
|
||||
/// `(Symbol, Symbol)` index which makes **invalidation** tractable: when
|
||||
/// a category or an item disappears, the cells naming it are found
|
||||
/// without a scan, so the records built from them are too.
|
||||
///
|
||||
/// It is deliberately not regenerated from the layout on demand. That
|
||||
/// would be O(visible cells) per resolve *and* would only ever cover the
|
||||
/// visible window, so a record scrolled out of view would look identical
|
||||
/// to a record that no longer exists — conflating "reject, nothing to
|
||||
/// write to" with "apply".
|
||||
///
|
||||
/// Not serialized: [`Deserialize`] rebuilds it through `set`, and every
|
||||
/// version starts at zero, which is correct because a freshly loaded
|
||||
/// document is a new `document_generation` and no client holds a basis
|
||||
/// against it.
|
||||
records: HashMap<RecordHash, RecordEntry>,
|
||||
}
|
||||
|
||||
impl Serialize for DataStore {
|
||||
@@ -183,12 +245,23 @@ impl DataStore {
|
||||
}
|
||||
|
||||
pub fn set(&mut self, key: CellKey, value: CellValue) {
|
||||
let hash = RecordHash::of(&key);
|
||||
let record = record_of(&key);
|
||||
let ikey = self.intern_key(&key);
|
||||
// Update index for each coordinate pair
|
||||
for pair in &ikey.0 {
|
||||
self.index.entry(*pair).or_default().insert(ikey.clone());
|
||||
}
|
||||
self.cells.insert(ikey, value);
|
||||
let is_new_cell = self.cells.insert(ikey, value).is_none();
|
||||
let entry = self.records.entry(hash).or_insert(RecordEntry {
|
||||
record,
|
||||
cells: 0,
|
||||
version: 0,
|
||||
});
|
||||
// Overwriting a cell does not add one; the count tracks liveness,
|
||||
// not writes, and the version is bumped by the observed diff in
|
||||
// `apply_effects` rather than from here.
|
||||
entry.cells += usize::from(is_new_cell);
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &CellKey) -> Option<&CellValue> {
|
||||
@@ -221,9 +294,55 @@ impl DataStore {
|
||||
set.remove(&ikey);
|
||||
}
|
||||
}
|
||||
if let Some(entry) = self.records.get_mut(&RecordHash::of(key)) {
|
||||
entry.cells -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a client's surrogate back to the record it names (ADR 002).
|
||||
///
|
||||
/// `None` means **the record is gone** — either it was never stored or
|
||||
/// every cell of it has been removed, which is what happens when a
|
||||
/// category or item it named is deleted. That is one of the two arms of
|
||||
/// staleness and it needs no version comparison: there is nothing to
|
||||
/// write to, so the edit is rejected.
|
||||
pub fn resolve_record(&self, hash: RecordHash) -> Option<&CellKey> {
|
||||
self.records
|
||||
.get(&hash)
|
||||
.filter(|entry| entry.is_live())
|
||||
.map(RecordEntry::record)
|
||||
}
|
||||
|
||||
/// The write counter for the record a surrogate names — the other arm:
|
||||
/// it resolves, but against a version the client did not compose
|
||||
/// against (ADR 001).
|
||||
///
|
||||
/// Zero for a record nothing has written, which is deliberately also
|
||||
/// what an unknown surrogate reports: a basis composed against an
|
||||
/// absent record and one composed against an untouched record are the
|
||||
/// same claim.
|
||||
pub fn record_version(&self, hash: RecordHash) -> u64 {
|
||||
self.records.get(&hash).map_or(0, RecordEntry::version)
|
||||
}
|
||||
|
||||
/// Record that the record `hash` names has been written. No-op for a
|
||||
/// record the store has never held a cell for — there is no basis
|
||||
/// anybody could hold against it.
|
||||
pub fn bump_record(&mut self, hash: RecordHash) {
|
||||
if let Some(entry) = self.records.get_mut(&hash) {
|
||||
entry.version += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Every record that still holds at least one cell, with its surrogate.
|
||||
pub fn live_records(&self) -> impl Iterator<Item = (RecordHash, &CellKey)> {
|
||||
self.records
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.is_live())
|
||||
.map(|(hash, entry)| (*hash, entry.record()))
|
||||
}
|
||||
|
||||
/// Values of all cells where every coordinate in `partial` matches.
|
||||
/// Hot path: avoids allocating CellKey for each result.
|
||||
pub fn matching_values(&self, partial: &[(String, String)]) -> Vec<&CellValue> {
|
||||
@@ -648,3 +767,168 @@ mod prop_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The reverse index a client's surrogate is resolved through (ADR 002),
|
||||
/// and the write counter ADR 001 checks a basis against — one structure,
|
||||
/// because both are keyed by the record and invalidated together.
|
||||
#[cfg(test)]
|
||||
mod record_index {
|
||||
use super::{CellKey, CellValue, DataStore};
|
||||
use crate::model::record::RecordHash;
|
||||
|
||||
fn key(pairs: &[(&str, &str)]) -> CellKey {
|
||||
CellKey::new(
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(c, i)| (c.to_string(), i.to_string()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Two records over two categories, so "it resolved" cannot pass by
|
||||
/// naming the only record there is.
|
||||
fn store() -> DataStore {
|
||||
let mut store = DataStore::new();
|
||||
store.set(
|
||||
key(&[
|
||||
("Region", "East"),
|
||||
("Product", "Shirts"),
|
||||
("_Measure", "Revenue"),
|
||||
]),
|
||||
CellValue::Number(1200.0),
|
||||
);
|
||||
store.set(
|
||||
key(&[
|
||||
("Region", "West"),
|
||||
("Product", "Shirts"),
|
||||
("_Measure", "Revenue"),
|
||||
]),
|
||||
CellValue::Number(900.0),
|
||||
);
|
||||
store
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_surrogate_resolves_to_the_record_it_names() {
|
||||
let store = store();
|
||||
let east = key(&[("Product", "Shirts"), ("Region", "East")]);
|
||||
assert_eq!(store.resolve_record(RecordHash::of(&east)), Some(&east));
|
||||
}
|
||||
|
||||
/// Gotcha 1's unit seen from the index side: two measures on one
|
||||
/// coordinate tuple are one record, so they share one entry.
|
||||
#[test]
|
||||
fn a_second_measure_on_a_record_resolves_to_the_same_record() {
|
||||
let mut store = store();
|
||||
store.set(
|
||||
key(&[
|
||||
("Region", "East"),
|
||||
("Product", "Shirts"),
|
||||
("_Measure", "Cost"),
|
||||
]),
|
||||
CellValue::Number(400.0),
|
||||
);
|
||||
let record = key(&[("Product", "Shirts"), ("Region", "East")]);
|
||||
assert_eq!(store.resolve_record(RecordHash::of(&record)), Some(&record));
|
||||
assert_eq!(store.live_records().count(), 2, "still two records");
|
||||
}
|
||||
|
||||
/// The first arm of staleness: a surrogate for a record whose cells are
|
||||
/// all gone must fail to resolve, rather than resolve to some other
|
||||
/// record. Two records exist, so a bug returning "any record" is visible.
|
||||
#[test]
|
||||
fn a_surrogate_for_a_removed_record_fails_to_resolve() {
|
||||
let mut store = store();
|
||||
let east_cell = key(&[
|
||||
("Region", "East"),
|
||||
("Product", "Shirts"),
|
||||
("_Measure", "Revenue"),
|
||||
]);
|
||||
let east = key(&[("Product", "Shirts"), ("Region", "East")]);
|
||||
let west = key(&[("Product", "Shirts"), ("Region", "West")]);
|
||||
store.remove(&east_cell);
|
||||
assert_eq!(store.resolve_record(RecordHash::of(&east)), None);
|
||||
assert_eq!(
|
||||
store.resolve_record(RecordHash::of(&west)),
|
||||
Some(&west),
|
||||
"the surviving record must be untouched, not stand in for the gone one"
|
||||
);
|
||||
}
|
||||
|
||||
/// A record loses its last cell only when *every* measure has gone, so
|
||||
/// a surrogate still resolves while another measure holds the row up.
|
||||
#[test]
|
||||
fn removing_one_measure_leaves_the_record_resolvable() {
|
||||
let mut store = store();
|
||||
let record = key(&[("Product", "Shirts"), ("Region", "East")]);
|
||||
store.set(
|
||||
key(&[
|
||||
("Region", "East"),
|
||||
("Product", "Shirts"),
|
||||
("_Measure", "Cost"),
|
||||
]),
|
||||
CellValue::Number(400.0),
|
||||
);
|
||||
store.remove(&key(&[
|
||||
("Region", "East"),
|
||||
("Product", "Shirts"),
|
||||
("_Measure", "Revenue"),
|
||||
]));
|
||||
assert_eq!(store.resolve_record(RecordHash::of(&record)), Some(&record));
|
||||
}
|
||||
|
||||
/// The tombstone earns its place. If the entry were dropped when the
|
||||
/// last cell went, a delete-and-recreate would reset the counter to
|
||||
/// zero — and a client holding version 0 against the *old* record would
|
||||
/// be told its basis is current and its write applied, which is exactly
|
||||
/// the silent accept the basis check exists to prevent.
|
||||
#[test]
|
||||
fn deleting_and_recreating_a_record_does_not_reset_its_version() {
|
||||
let mut store = store();
|
||||
let cell = key(&[
|
||||
("Region", "East"),
|
||||
("Product", "Shirts"),
|
||||
("_Measure", "Revenue"),
|
||||
]);
|
||||
let hash = RecordHash::of(&cell);
|
||||
store.bump_record(hash);
|
||||
store.bump_record(hash);
|
||||
assert_eq!(store.record_version(hash), 2);
|
||||
|
||||
store.remove(&cell);
|
||||
assert_eq!(
|
||||
store.record_version(hash),
|
||||
2,
|
||||
"the version outlives the record"
|
||||
);
|
||||
|
||||
store.set(cell, CellValue::Number(5.0));
|
||||
assert_eq!(
|
||||
store.record_version(hash),
|
||||
2,
|
||||
"and a re-created record does not start over"
|
||||
);
|
||||
}
|
||||
|
||||
/// Nothing can hold a basis against a record the store has never seen,
|
||||
/// so there is nothing to invalidate and no entry to invent.
|
||||
#[test]
|
||||
fn bumping_an_unknown_surrogate_is_a_no_op() {
|
||||
let mut store = store();
|
||||
let stranger = RecordHash::of(&key(&[("Region", "North")]));
|
||||
store.bump_record(stranger);
|
||||
assert_eq!(store.record_version(stranger), 0);
|
||||
assert_eq!(store.resolve_record(stranger), None);
|
||||
}
|
||||
|
||||
/// The index is derived state, so it must come back with the data
|
||||
/// rather than being written alongside it.
|
||||
#[test]
|
||||
fn a_deserialized_store_can_resolve_its_records() {
|
||||
let json = serde_json::to_string(&store()).expect("serializes");
|
||||
let back: DataStore = serde_json::from_str(&json).expect("deserializes");
|
||||
let east = key(&[("Product", "Shirts"), ("Region", "East")]);
|
||||
assert_eq!(back.resolve_record(RecordHash::of(&east)), Some(&east));
|
||||
assert_eq!(back.live_records().count(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod category;
|
||||
pub mod cell;
|
||||
pub mod record;
|
||||
pub mod symbol;
|
||||
pub mod types;
|
||||
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
//! Surrogate identities for the things a client may name (ADR 002).
|
||||
//!
|
||||
//! A client sees a grid of label strings indexed by viewport position. It
|
||||
//! holds no `Model`, no category names and no page/filter constraints, so it
|
||||
//! cannot build a `CellKey` — which is why `improvise-q08` found that a thin
|
||||
//! client cannot compose `SetCell`. The answer is an opaque surrogate: the
|
||||
//! server projects one per visible cell and the client echoes it back.
|
||||
//!
|
||||
//! Two surrogates, because two kinds of thing are nameable and only one of
|
||||
//! them is settable:
|
||||
//!
|
||||
//! - [`RecordHash`] names a **record** — the coordinate tuple minus
|
||||
//! `_Measure` ([`record_of`]). That unit is not a choice made here: it is
|
||||
//! already ADR 001's unit of atomicity and already what the write counters
|
||||
//! are keyed by, because renaming a category coordinate in wide records
|
||||
//! mode moves every measure cell in the row (gotcha 1). One identity
|
||||
//! therefore serves addressing *and* staleness.
|
||||
//! - [`AggregateHash`] names an **aggregate's coordinate constraint** — the
|
||||
//! row and column coordinates plus the page and filter selections that
|
||||
//! decide which stored cells it sums (gotcha 27). An aggregate is not the
|
||||
//! absence of an identity; it is a different kind of identity, and that is
|
||||
//! what makes `Drill` addressable at all.
|
||||
//!
|
||||
//! # The hash is specified here, not inherited
|
||||
//!
|
||||
//! Three ways this breaks silently, each producing a *wrong write* rather
|
||||
//! than an error, and each closed deliberately:
|
||||
//!
|
||||
//! 1. **Not `DefaultHasher`, not `RandomState`.** `std`'s default `HashMap`
|
||||
//! hasher is randomly seeded per process, so a surrogate would not
|
||||
//! survive a server restart or agree between two processes. The hash here
|
||||
//! is SipHash-2-4 under a **constant key**.
|
||||
//! 2. **Not `#[derive(Hash)]`.** Rust gives no stability guarantee for a
|
||||
//! derived `Hash` across compiler versions or platforms. What is hashed
|
||||
//! is the explicit byte serialization written out in [`hash_coords`], so
|
||||
//! the surrogate is defined by that format rather than by whatever the
|
||||
//! compiler did this week.
|
||||
//! 3. **Width by consequence, not by convenience.** See [`RecordHash`].
|
||||
|
||||
use std::hash::Hasher;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use siphasher::sip128::{Hasher128, SipHasher24};
|
||||
|
||||
use super::category::MEASURE_CATEGORY;
|
||||
use super::cell::CellKey;
|
||||
|
||||
/// The record one cell belongs to: its coordinate tuple with `_Measure`
|
||||
/// dropped.
|
||||
///
|
||||
/// `record_row_identity(wide, coord_group_excluding_measure)` made into a
|
||||
/// function. `CellKey` is canonically sorted by construction (gotcha 5) and
|
||||
/// dropping a coordinate preserves that order, so "the normalized record" is
|
||||
/// a property the type guarantees rather than a convention to maintain.
|
||||
pub fn record_of(key: &CellKey) -> CellKey {
|
||||
key.without(MEASURE_CATEGORY)
|
||||
}
|
||||
|
||||
/// Domain separation. A record and an aggregate may be built from the same
|
||||
/// coordinates — an aggregate over a single record is the degenerate case —
|
||||
/// and they must not hash alike, so the kind is the first byte hashed.
|
||||
const TAG_RECORD: u8 = 0x01;
|
||||
const TAG_AGGREGATE: u8 = 0x02;
|
||||
|
||||
/// The SipHash key, fixed forever. `b"improvis"` and `b"e-record"` read as
|
||||
/// little-endian `u64`s would depend on the reader's endianness; these are
|
||||
/// the big-endian readings, written as literals so the constant is the
|
||||
/// constant on every platform.
|
||||
const KEY_0: u64 = 0x696d_7072_6f76_6973; // "improvis"
|
||||
const KEY_1: u64 = 0x652d_7265_636f_7264; // "e-record"
|
||||
|
||||
/// The one byte serialization both surrogates are defined by.
|
||||
///
|
||||
/// `tag ‖ count ‖ (len(category) ‖ category ‖ len(item) ‖ item)*`, with every
|
||||
/// length a little-endian `u64` and every string its UTF-8 bytes. The
|
||||
/// lengths are what make it injective: without them `("ab", "c")` and
|
||||
/// `("a", "bc")` serialize alike, and a collision here is a silent write to
|
||||
/// the wrong record.
|
||||
///
|
||||
/// Only `Hasher::write` is used, never `write_u64`/`write_u8`: those are
|
||||
/// free to feed the hasher in native byte order, which would make the
|
||||
/// surrogate endianness-dependent.
|
||||
fn hash_coords(tag: u8, coords: &[(String, String)]) -> u128 {
|
||||
let mut hasher = SipHasher24::new_with_keys(KEY_0, KEY_1);
|
||||
hasher.write(&[tag]);
|
||||
hasher.write(&(coords.len() as u64).to_le_bytes());
|
||||
for (category, item) in coords {
|
||||
hasher.write(&(category.len() as u64).to_le_bytes());
|
||||
hasher.write(category.as_bytes());
|
||||
hasher.write(&(item.len() as u64).to_le_bytes());
|
||||
hasher.write(item.as_bytes());
|
||||
}
|
||||
hasher.finish128().as_u128()
|
||||
}
|
||||
|
||||
/// Hex, not a JSON number.
|
||||
///
|
||||
/// `serde_json` writes a `u128` as a bare number, and a JavaScript host
|
||||
/// parses every JSON number as an `f64` — so anything above 2^53 comes back
|
||||
/// changed, which for a surrogate means resolving to a different record or
|
||||
/// to none. The wire form is a fixed 32-character lowercase hex string.
|
||||
fn serialize_hex<S: Serializer>(value: u128, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&format!("{value:032x}"))
|
||||
}
|
||||
|
||||
fn deserialize_hex<'de, D: Deserializer<'de>>(d: D) -> Result<u128, D::Error> {
|
||||
let text = String::deserialize(d)?;
|
||||
u128::from_str_radix(&text, 16).map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
/// An opaque name for one record — the coordinate tuple minus `_Measure`.
|
||||
///
|
||||
/// **128 bits, argued rather than defaulted.** A collision is a silent write
|
||||
/// to the wrong record, which is precisely the failure that got `_Index`
|
||||
/// rejected as a surrogate, and no later check catches it: a colliding
|
||||
/// surrogate resolves *successfully* to a record the client never saw. 64
|
||||
/// bits reaches birthday risk around 2^32 records, which a pivot tool will
|
||||
/// not see by record count — but the risk is not over the records alive at
|
||||
/// one instant; it is over every record a long-lived document ever names,
|
||||
/// and the consequence is unbounded and undetectable. The cost of the other
|
||||
/// choice is bounded and small: 8 more bytes per *visible* cell, so a 50×20
|
||||
/// viewport pays 8 KB on a full projection and far less on the diffs that
|
||||
/// carry steady-state traffic. Bounded cost against silent corruption is not
|
||||
/// a close call.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct RecordHash(u128);
|
||||
|
||||
impl RecordHash {
|
||||
/// The surrogate for the record `key` belongs to.
|
||||
///
|
||||
/// Normalizes: a full cell key and the record it belongs to hash the
|
||||
/// same, because `_Measure` is dropped first. That is why two cells
|
||||
/// differing only in their measure share a surrogate, and it is a
|
||||
/// property of the constructor rather than a rule callers must follow.
|
||||
pub fn of(key: &CellKey) -> Self {
|
||||
Self(hash_coords(TAG_RECORD, &record_of(key).0))
|
||||
}
|
||||
|
||||
/// The 32-character hex form — also the wire form.
|
||||
pub fn to_hex(self) -> String {
|
||||
format!("{:032x}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RecordHash {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:032x}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for RecordHash {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
serialize_hex(self.0, s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for RecordHash {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
deserialize_hex(d).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
/// An opaque name for one aggregate's coordinate constraint.
|
||||
///
|
||||
/// Same width and same hash as [`RecordHash`], under a different domain tag.
|
||||
/// What it hashes is the whole constraint — row and column coordinates plus
|
||||
/// the page *and filter* selections, which is `View::filtering_cats()`'s
|
||||
/// question and not `categories_on(Axis::Page)`'s (gotcha 27) — because that
|
||||
/// is exactly the set of cells the aggregate sums, and two aggregates that
|
||||
/// sum different cells must not share a name.
|
||||
///
|
||||
/// Unlike a record, an aggregate has no existence outside the layout that
|
||||
/// derives it, so nothing maintains an index of these; resolution is against
|
||||
/// the current layout, and a layout change correctly makes an old one
|
||||
/// unresolvable.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct AggregateHash(u128);
|
||||
|
||||
impl AggregateHash {
|
||||
/// The surrogate for the constraint `key` expresses.
|
||||
///
|
||||
/// Deliberately *not* normalized: a measure on Row or Column is part of
|
||||
/// what the aggregate sums, so dropping `_Measure` here would give two
|
||||
/// visibly different totals one name.
|
||||
pub fn of(constraint: &CellKey) -> Self {
|
||||
Self(hash_coords(TAG_AGGREGATE, &constraint.0))
|
||||
}
|
||||
|
||||
pub fn to_hex(self) -> String {
|
||||
format!("{:032x}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AggregateHash {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:032x}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for AggregateHash {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
serialize_hex(self.0, s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for AggregateHash {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
deserialize_hex(d).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(pairs: &[(&str, &str)]) -> CellKey {
|
||||
CellKey::new(
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(c, i)| (c.to_string(), i.to_string()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The surrogate must mean the same thing in the process that minted it
|
||||
/// and in the one that restarts under it, so it is pinned to a literal
|
||||
/// rather than to "whatever this build computes".
|
||||
///
|
||||
/// A failure here is not a bug in the test: it means the hash, the key
|
||||
/// or the byte serialization moved, and every surrogate any client holds
|
||||
/// is now a name for something else. That is a wire-format break.
|
||||
#[test]
|
||||
fn a_records_surrogate_is_pinned_to_a_literal() {
|
||||
let record = key(&[("Region", "East"), ("Product", "Shirts")]);
|
||||
assert_eq!(
|
||||
RecordHash::of(&record).to_hex(),
|
||||
"fe4c8e5e9cbd578e0d388f4b2b5a7879"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_aggregates_surrogate_is_pinned_to_a_literal() {
|
||||
let constraint = key(&[("Region", "East"), ("Product", "Shirts")]);
|
||||
assert_eq!(
|
||||
AggregateHash::of(&constraint).to_hex(),
|
||||
"5b05bbcd2364099f68a49dbf9099e075"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same coordinates under the two kinds must not collide: a client
|
||||
/// holding an aggregate id must not be able to spend it as a record id
|
||||
/// by accident.
|
||||
#[test]
|
||||
fn the_two_kinds_do_not_share_a_hash() {
|
||||
let coords = key(&[("Region", "East")]);
|
||||
assert_ne!(
|
||||
RecordHash::of(&coords).to_hex(),
|
||||
AggregateHash::of(&coords).to_hex()
|
||||
);
|
||||
}
|
||||
|
||||
/// Gotcha 1: two measures on one coordinate tuple are one record, and
|
||||
/// one legal edit moves both. The unit of identity must be that record.
|
||||
#[test]
|
||||
fn two_cells_differing_only_in_measure_share_a_record_hash() {
|
||||
let revenue = key(&[("Region", "East"), ("_Measure", "Revenue")]);
|
||||
let cost = key(&[("Region", "East"), ("_Measure", "Cost")]);
|
||||
assert_eq!(RecordHash::of(&revenue), RecordHash::of(&cost));
|
||||
// …and it is the record's own hash, so the server can go from a
|
||||
// projected cell straight to the write counter without rebuilding
|
||||
// a key.
|
||||
assert_eq!(
|
||||
RecordHash::of(&revenue),
|
||||
RecordHash::of(&key(&[("Region", "East")]))
|
||||
);
|
||||
}
|
||||
|
||||
/// Two categories, not one: a one-coordinate fixture cannot tell "the
|
||||
/// item moved" from "the category moved", which is the same blind spot
|
||||
/// as the one-category fixture that could not distinguish
|
||||
/// `TransposeAxes` from `CycleAxis`.
|
||||
#[test]
|
||||
fn renaming_either_half_of_a_coordinate_changes_the_hash() {
|
||||
let base = key(&[("Region", "East"), ("Product", "Shirts")]);
|
||||
let item_renamed = key(&[("Region", "Eastern"), ("Product", "Shirts")]);
|
||||
let category_renamed = key(&[("Zone", "East"), ("Product", "Shirts")]);
|
||||
assert_ne!(RecordHash::of(&base), RecordHash::of(&item_renamed));
|
||||
assert_ne!(RecordHash::of(&base), RecordHash::of(&category_renamed));
|
||||
assert_ne!(
|
||||
RecordHash::of(&item_renamed),
|
||||
RecordHash::of(&category_renamed)
|
||||
);
|
||||
}
|
||||
|
||||
/// The length prefixes earn their place: without them these two
|
||||
/// serialize to the same bytes.
|
||||
#[test]
|
||||
fn coordinates_that_concatenate_alike_do_not_hash_alike() {
|
||||
let a = key(&[("Ab", "c")]);
|
||||
let b = key(&[("A", "bc")]);
|
||||
assert_ne!(RecordHash::of(&a), RecordHash::of(&b));
|
||||
}
|
||||
|
||||
/// A surrogate that changes shape on the wire resolves to nothing or,
|
||||
/// worse, to something else. Hex-as-a-string is the guard against a JSON
|
||||
/// number losing its low bits in a JavaScript host.
|
||||
#[test]
|
||||
fn a_surrogate_round_trips_through_json_as_a_string() {
|
||||
let hash = RecordHash::of(&key(&[("Region", "East")]));
|
||||
let json = serde_json::to_string(&hash).expect("serializes");
|
||||
assert!(json.starts_with('"'), "must be a string, got {json}");
|
||||
assert_eq!(json.len(), 34, "32 hex digits plus quotes: {json}");
|
||||
let back: RecordHash = serde_json::from_str(&json).expect("deserializes");
|
||||
assert_eq!(back, hash);
|
||||
}
|
||||
|
||||
/// Dropping a coordinate preserves the canonical sort, so a record built
|
||||
/// by stripping `_Measure` from an out-of-order construction is the same
|
||||
/// record as one built directly.
|
||||
#[test]
|
||||
fn construction_order_does_not_reach_the_hash() {
|
||||
let one = key(&[("Region", "East"), ("Product", "Shirts")]);
|
||||
let other = key(&[("Product", "Shirts"), ("Region", "East")]);
|
||||
assert_eq!(RecordHash::of(&one), RecordHash::of(&other));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user