diff --git a/DESIGN.md b/DESIGN.md index 00ca175e..6da31bfc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -420,7 +420,7 @@ M9 (0.5.0) moves loaded versions into dynamic shared memory so backends share on ### 5.7 Identity, lifecycle and visibility (M10 design) -*Designed 2026-10-23 for M10 (0.6.0), after 0.5.0's release; built so far: step 1 (the spike) and step 5 (ownership transfer). The decisions are §14 items 41–49: items 44, 45 and 46 were decided by the owner on 2026-10-23, as recommended, and the rest are decided here. Claims about Postgres were read in the PG 15–18 sources (`tcop/utility.c`, `tcop/cmdtaglist.h`, `catalog/pg_shdepend.c`, `commands/event_trigger.c`, `utils/backend_progress.h`, `utils/adt/pgstatfuncs.c`, `utils/wait_event.h`, `utils/misc/guc_tables.c`); where a claim needed code to confirm it, step 1 of the work breakdown did so on PG 15, 16 and 18 (Spike results, below), and the text reflects what it found.* M10 has five items, which share little code. `text` node ids change the id map and everything that maps external ids. Ownership transfer, cleanup after a dropped role, and quotas change the catalog's lifecycle. Observability adds a per-backend table in shared memory beside the registry of §5.6. Each part states what Postgres allows an extension on PG 15–18 and where pg_warren stops. Nothing in M10 changes a kernel, the CSR layout, the segment format of existing id types, or the registry's format, so 0.6.0 needs no restart to keep sharing on PG 17 and 18 (§5.6). +*Designed 2026-10-23 for M10 (0.6.0), after 0.5.0's release; built so far: steps 1 (the spike), 2 (`IdMap`) and 5 (ownership transfer). The decisions are §14 items 41–49: items 44, 45 and 46 were decided by the owner on 2026-10-23, as recommended, and the rest are decided here. Claims about Postgres were read in the PG 15–18 sources (`tcop/utility.c`, `tcop/cmdtaglist.h`, `catalog/pg_shdepend.c`, `commands/event_trigger.c`, `utils/backend_progress.h`, `utils/adt/pgstatfuncs.c`, `utils/wait_event.h`, `utils/misc/guc_tables.c`); where a claim needed code to confirm it, step 1 of the work breakdown did so on PG 15, 16 and 18 (Spike results, below), and the text reflects what it found.* M10 has five items, which share little code. `text` node ids change the id map and everything that maps external ids. Ownership transfer, cleanup after a dropped role, and quotas change the catalog's lifecycle. Observability adds a per-backend table in shared memory beside the registry of §5.6. Each part states what Postgres allows an extension on PG 15–18 and where pg_warren stops. Nothing in M10 changes a kernel, the CSR layout, the segment format of existing id types, or the registry's format, so 0.6.0 needs no restart to keep sharing on PG 17 and 18 (§5.6). #### Text node ids (§14 items 41, 42) @@ -448,7 +448,7 @@ Dense → external is O(1): two adjacent offsets and a slice. External → dense The exact-type rule stays (§9.3). A `varchar` argument to a function whose result holds ids is `datatype_mismatch`, with `::text` in the hint. Lookups whose result holds no id (`degree`, `has_edge`, `reachable`) accept `varchar`, as they widen `int2` and `int4` today. `node_filter`, and the `filter` of a `warren.step` (already `text[]`, parsed per id type at walk time), take text ids as they are. `vector_from_query`'s id column must cast implicitly to `text`: `varchar` does, integers do not. The library's PL/pgSQL builds its type witnesses from `info ->> 'id_type'`, which `text` satisfies. -**Code (item 41).** Text cannot be a fourth `E`. `warren_core::graph::ExternalId` is `Copy` and fixed-width, and `pg_warren`'s `IdDatum` promises that an id can be read in place at a fixed width (§5.6). So the id map becomes generic over its *key storage*: `IdMap`, with `FixedKeys` (today's pairs and inverse, unchanged) and `TextKeys` (the three arrays). `IdKeys::Key` is `E` for a fixed type and the unsized `[u8]` for text, so `dense_id(&K::Key)` and `external_id(dense) -> Option<&K::Key>` serve both. Everything in `warren-core` that is generic over `E: ExternalId` becomes generic over `K`: the build's `rebuild_within` and `EdgeIntake`, `compact`'s renumbering, `diff`'s node delta and its shared-dense-id check, `distance`'s `align_ids`, and locality renumbering. Tombstones, CSRs, vectors and every kernel are untouched, since they see only dense ids. In `pg_warren`, `with_id_type!` gains a `Text` arm. `IdDatum` splits in two: the conversion between datum and key (borrowed for text: an argument is detoasted in place, and an id returned is a `text` datum built from its bytes), and the promise of an in-place layout (fixed types only). `external_ids` does the rest as today. +**Code (item 41).** Text cannot be a fourth `E`. `warren_core::graph::ExternalId` is `Copy` and fixed-width, and `pg_warren`'s `IdDatum` promises that an id can be read in place at a fixed width (§5.6). So the id map becomes generic over its *key storage*: `IdMap`, with `FixedKeys` (today's pairs and inverse, unchanged) and `TextKeys` (the three arrays). `IdKeys::Key` is `E` for a fixed type and the unsized `[u8]` for text, so `dense_id(&K::Key)` and `external_id(dense) -> Option<&K::Key>` serve both. *Built (step 2):* `IdMap` holds the keys and the tombstones, and `IdKeys` has what the shared logic needs of a key storage: `id_space`, `bytes`, lookups both ways (tombstoned nodes included), `in_order` (every key with its dense id, in key order, which `align_ids` and compaction walk), `renumber` by a permutation, `compacted` (the keys at given dense ids, already in key order, as dense ids `0..n`), and `rebuild`. The rebuild's merge walk is `IdMap`'s, shared by every key storage: it sorts and deduplicates the node ids (`IdKeys::Ids`, a `NodeIds`: `BuildBuffer` for a fixed type, an arena for text), counts the fresh ones, and feeds each key, kept or fresh, in key order to a `KeysRebuild` that `IdKeys::rebuild` reserved exactly, then frees the ids before the rebuild finishes. Segment encoding and `from_segments` stay on `IdMap>`. Until step 4, `pg_warren` names `IdMap>` where it named `IdMap`. Everything in `warren-core` that is generic over `E: ExternalId` becomes generic over `K`: the build's `rebuild_within` and `EdgeIntake`, `compact`'s renumbering, `diff`'s node delta and its shared-dense-id check, `distance`'s `align_ids`, and locality renumbering. Tombstones, CSRs, vectors and every kernel are untouched, since they see only dense ids. In `pg_warren`, `with_id_type!` gains a `Text` arm. `IdDatum` splits in two: the conversion between datum and key (borrowed for text: an argument is detoasted in place, and an id returned is a `text` datum built from its bytes), and the promise of an in-place layout (fixed types only). `external_ids` does the rest as today. **Build, diff and compaction.** - *Reading ids.* Node and edge ids are read in place from the tuple (`source::text_in_place`, as relations are since M7), each batch in a memory context reset after it. @@ -643,7 +643,7 @@ Each step is a pull request that leaves `main` green. - `WaitEventExtensionNew` and the `MaxBackends` binding. - A pgrx-sized change-count slot under Miri. - The text id map at 10M nodes: build, sort, merge and hash-index time, lookup latency, and memory, at *L* = 24, 48 and 64. -2. *`warren-core`: `IdMap` with `FixedKeys`,* with no behaviour change: every existing test and proptest passes unchanged, and Miri. +2. *`warren-core`: `IdMap` with `FixedKeys`,* with no behaviour change: every existing test and proptest passes unchanged, and Miri. *Done:* tests changed only where the types did (`IdMap>`, and `external_id` returning a reference). 3. *`warren-core`: `TextKeys`:* the rebuild with its arena and hash index, the three segment kinds, validation, renumbering, compaction, `diff` and alignment, with the tests above. 4. *`pg_warren`: text graphs end to end:* `IdType::Text`; the collation, type and encoding checks; the build's intake; the region's arrays and the loader; `external_ids`; every SQL function. Also the `text_ids` tests, and the soak at 10M nodes with synthetic IRIs (`bench/soak.py --ids text`), recorded in §12. 5. *Ownership transfer:* `set_owner`, ownership re-checked after the lock, and `set_owner_race`. **Done** (see "Ownership transfer", *Implemented*). diff --git a/crates/pg_warren/src/build/pipeline.rs b/crates/pg_warren/src/build/pipeline.rs index 8f319b25..cbfc0cbc 100644 --- a/crates/pg_warren/src/build/pipeline.rs +++ b/crates/pg_warren/src/build/pipeline.rs @@ -8,8 +8,9 @@ //! CSRs are built where the plan shows it, else where it reserves. use warren_core::graph::{ - BuildCharge, BuildMemory, BuildPlan, CsrLayout, EdgeIntake, EdgeList, ExternalId, GraphCsr, - GraphError, IdMap, Membership, NodeOrder, Permutation, RelationDictionary, RelationIndex, + BuildCharge, BuildMemory, BuildPlan, CsrLayout, EdgeIntake, EdgeList, ExternalId, FixedKeys, + GraphCsr, GraphError, IdMap, Membership, NodeOrder, Permutation, RelationDictionary, + RelationIndex, }; use super::error::BuildError; @@ -97,7 +98,7 @@ pub(crate) fn build( /// contraction made them. pub(crate) struct Contents { /// The id map, tombstones included. - pub(crate) id_map: IdMap, + pub(crate) id_map: IdMap>, /// The relation dictionary. pub(crate) relations: RelationDictionary, /// The edges, in dense ids. @@ -164,7 +165,7 @@ pub(crate) fn write_contents( /// NULL, unknown or tombstoned endpoint. fn edges( graph: &GraphDefinition, - id_map: &IdMap, + id_map: &IdMap>, implicit: Option, relations: &mut Relations, source: &mut impl SourceReader, @@ -216,7 +217,7 @@ struct EdgeCollector<'b, 'm, E> { /// The one relation of a graph without relations. implicit: Option, relations: &'b mut Relations, - intake: EdgeIntake<'m, E>, + intake: EdgeIntake<'m, FixedKeys>, /// Rows dropped for a NULL relation or weight. incomplete_rows: u64, } @@ -275,7 +276,7 @@ mod tests { use crate::segment::{read_segment, MemorySegmentStore}; use pgrx::pg_sys; use warren_core::graph::{ - AnyCsr, EdgeIntake, GraphCsr, GraphError, IdMap, RelationDictionary, Tombstones, + AnyCsr, EdgeIntake, FixedKeys, GraphCsr, GraphError, IdMap, RelationDictionary, Tombstones, }; fn graph() -> GraphDefinition { @@ -353,7 +354,7 @@ mod tests { read_segment(&mut self.store, version, name).is_ok() } - fn id_map(&mut self, version: VersionId) -> IdMap { + fn id_map(&mut self, version: VersionId) -> IdMap> { let tombstones = Tombstones::decode(&self.segment(version, "tombstones")).unwrap(); IdMap::decode(&self.segment(version, "id_map"), tombstones).unwrap() } @@ -384,9 +385,9 @@ mod tests { } } - fn neighbours(csr: &AnyCsr, id_map: &IdMap, external: i64) -> Vec { + fn neighbours(csr: &AnyCsr, id_map: &IdMap>, external: i64) -> Vec { let dense = id_map.dense_id(&external).unwrap(); - csr.neighbours(dense).map(|d| id_map.external_id(d).unwrap()).collect() + csr.neighbours(dense).map(|d| *id_map.external_id(d).unwrap()).collect() } #[test] @@ -485,7 +486,7 @@ mod tests { } assert_eq!(after.dense_id(&20), None); assert!(after.tombstones().contains(1)); - assert_eq!(after.external_id(1), Some(20)); + assert_eq!(after.external_id(1), Some(&20)); assert_eq!(after.dense_id(&5), Some(3)); assert_eq!(after.dense_id(&40), Some(4)); diff --git a/crates/pg_warren/src/build/refresh.rs b/crates/pg_warren/src/build/refresh.rs index ad554c9c..794227b3 100644 --- a/crates/pg_warren/src/build/refresh.rs +++ b/crates/pg_warren/src/build/refresh.rs @@ -60,7 +60,9 @@ fn refresh_graph(name: &str, promote: bool, label: Option<&str>) -> Result Option> { + fn stored_id_map(version: i64) -> Option>> { let tombstones = Tombstones::decode(&segment(version, "tombstones")).ok()?; IdMap::decode(&segment(version, "id_map"), tombstones).ok() } @@ -247,8 +249,8 @@ mod tests { let uuid = refresh("SELECT warren.refresh('u')")?; assert_eq!(version_row(uuid)?.as_deref(), Some("1|f|2|2|1|0|0|4|t|")); let tombstones = Tombstones::decode(&segment(uuid, "tombstones")).ok(); - let id_map = - tombstones.and_then(|t| IdMap::<[u8; 16]>::decode(&segment(uuid, "id_map"), t).ok()); + let id_map = tombstones + .and_then(|t| IdMap::>::decode(&segment(uuid, "id_map"), t).ok()); let mut first = [0; 16]; first[15] = 1; assert_eq!(id_map.and_then(|m| m.dense_id(&first)), Some(0)); diff --git a/crates/pg_warren/src/cache/load/mod.rs b/crates/pg_warren/src/cache/load/mod.rs index 1bccb672..ff9b66b3 100644 --- a/crates/pg_warren/src/cache/load/mod.rs +++ b/crates/pg_warren/src/cache/load/mod.rs @@ -19,7 +19,8 @@ use std::sync::{Arc, Weak}; use pgrx::PgSqlErrorCode; use thiserror::Error; use warren_core::graph::{ - BuildCharge, BuildMemory, ExternalId, GraphCsr, GraphError, IdMap, RelationDictionary, + BuildCharge, BuildMemory, ExternalId, FixedKeys, GraphCsr, GraphError, IdMap, + RelationDictionary, }; use warren_core::version::{GraphVersion, VersionError}; @@ -69,7 +70,7 @@ impl SqlError for LoadError { #[derive(Debug, Clone, PartialEq)] pub(crate) struct LoadedVersion { /// Its id map, tombstones included. - pub(crate) id_map: IdMap, + pub(crate) id_map: IdMap>, /// Its relation dictionary. pub(crate) relations: RelationDictionary, /// Its CSRs. A loaded graph reports no deduplicated edges: that count @@ -79,7 +80,7 @@ pub(crate) struct LoadedVersion { impl LoadedVersion { /// The version as `warren-core`'s view of it. - pub(crate) fn version(&self) -> Result, VersionError> { + pub(crate) fn version(&self) -> Result>, VersionError> { GraphVersion::new(&self.id_map, &self.relations, &self.graph) } @@ -99,7 +100,7 @@ impl LoadedVersion { /// memory it holds. pub(crate) struct Identity { /// The id map. - pub(crate) id_map: IdMap, + pub(crate) id_map: IdMap>, /// The charge for the id map. pub(crate) id_map_charge: BuildCharge, /// The relation dictionary. @@ -165,13 +166,13 @@ mod tests { use crate::segment::{write_version, MemorySegmentStore}; use crate::source::IdDatum; use warren_core::graph::{ - AnyCsr, CsrLayout, EdgeIntake, GraphCsr, IdMap, RelationCsr, RelationDictionary, + AnyCsr, CsrLayout, EdgeIntake, FixedKeys, GraphCsr, IdMap, RelationCsr, RelationDictionary, }; /// A version built in memory: ids 10, 20, 30, 40 (or as given) with /// the second tombstoned. struct Built { - id_map: IdMap, + id_map: IdMap>, relations: RelationDictionary, graph: GraphCsr, } diff --git a/crates/pg_warren/src/cache/shared/dsm/tests.rs b/crates/pg_warren/src/cache/shared/dsm/tests.rs index 0b6272a0..7f512eae 100644 --- a/crates/pg_warren/src/cache/shared/dsm/tests.rs +++ b/crates/pg_warren/src/cache/shared/dsm/tests.rs @@ -276,7 +276,7 @@ mod tests { assert_eq!(port()?.evict(&|evicted| *evicted == key), 1); end_message(); assert_eq!(slot(key), Some((State::Draining, 0, 1)), "the piece still reads the segment"); - assert_eq!(piece.external_id(99), Some(100), "and reads it"); + assert_eq!(piece.external_id(99), Some(&100), "and reads it"); drop(piece); end_message(); assert_eq!(slot(key), None, "released once nothing read through it is held"); diff --git a/crates/pg_warren/src/cache/versions.rs b/crates/pg_warren/src/cache/versions.rs index df2f7568..5b188e74 100644 --- a/crates/pg_warren/src/cache/versions.rs +++ b/crates/pg_warren/src/cache/versions.rs @@ -202,7 +202,7 @@ mod tests { use super::*; use crate::cache::load; use crate::segment::{write_version, MemorySegmentStore}; - use warren_core::graph::{EdgeIntake, GraphCsr, IdMap, RelationDictionary}; + use warren_core::graph::{EdgeIntake, FixedKeys, GraphCsr, IdMap, RelationDictionary}; /// The cached (not merely held) version ids, ascending. fn cached(cache: &VersionCache) -> Vec { @@ -213,7 +213,7 @@ mod tests { /// (uuid ids). fn store() -> MemorySegmentStore { let mut store = MemorySegmentStore::default(); - let id_map = IdMap::::new().rebuild(vec![1, 2, 3]).unwrap(); + let id_map = IdMap::>::new().rebuild(vec![1, 2, 3]).unwrap(); let mut relations = RelationDictionary::new(); let relation = relations.intern("").unwrap(); let mut intake = EdgeIntake::directed(&id_map, false); @@ -222,7 +222,7 @@ mod tests { for version in [1, 2] { write_version(&mut store, version, &id_map, &relations, &graph, 64).unwrap(); } - let uuids = IdMap::<[u8; 16]>::new().rebuild(vec![[1; 16]]).unwrap(); + let uuids = IdMap::>::new().rebuild(vec![[1; 16]]).unwrap(); let empty = GraphCsr::build(EdgeIntake::directed(&uuids, false).finish().0, 1, 1, true).unwrap(); write_version(&mut store, 3, &uuids, &relations, &empty, 64).unwrap(); diff --git a/crates/pg_warren/src/catalog/version.rs b/crates/pg_warren/src/catalog/version.rs index 7f531ddf..e1971add 100644 --- a/crates/pg_warren/src/catalog/version.rs +++ b/crates/pg_warren/src/catalog/version.rs @@ -3,7 +3,7 @@ //! catalog owner. use pgrx::prelude::*; -use warren_core::graph::{ExternalId, GraphCsr, IdMap, TargetWidth}; +use warren_core::graph::{ExternalId, FixedKeys, GraphCsr, IdMap, TargetWidth}; use super::retention::{retention, Kept, VersionSummary}; use super::{CatalogError, GraphDefinition}; @@ -36,7 +36,7 @@ impl VersionStats { /// The statistics of a version with this id map and these CSRs, and /// the given dropped and deduplicated edge counts. pub(crate) fn measure( - id_map: &IdMap, + id_map: &IdMap>, graph: &GraphCsr, dropped_edges: u64, deduplicated_edges: u64, @@ -49,7 +49,7 @@ impl VersionStats { /// over its relations, and the given dropped and deduplicated edge /// counts. pub(crate) fn new( - id_map: &IdMap, + id_map: &IdMap>, edge_count: u64, dropped_edges: u64, deduplicated_edges: u64, diff --git a/crates/pg_warren/src/community_sql/contraction.rs b/crates/pg_warren/src/community_sql/contraction.rs index 8d85e6e1..9ca253f5 100644 --- a/crates/pg_warren/src/community_sql/contraction.rs +++ b/crates/pg_warren/src/community_sql/contraction.rs @@ -88,7 +88,7 @@ pub(crate) fn contract_version( mod tests { use pgrx::pg_sys; use warren_core::compute::{ComputeMemory, Vector}; - use warren_core::graph::{EdgeIntake, Membership, NodeOrder, Tombstones}; + use warren_core::graph::{EdgeIntake, FixedKeys, Membership, NodeOrder, Tombstones}; use warren_core::interrupts::Uninterrupted; use super::*; @@ -183,7 +183,7 @@ mod tests { ) } - fn id_map(&mut self, version: VersionId) -> IdMap { + fn id_map(&mut self, version: VersionId) -> IdMap> { let tombstones = Tombstones::decode(&read_segment(&mut self.store, version, "tombstones").unwrap()) .unwrap(); diff --git a/crates/pg_warren/src/external_ids/mod.rs b/crates/pg_warren/src/external_ids/mod.rs index c33471df..e1172bb7 100644 --- a/crates/pg_warren/src/external_ids/mod.rs +++ b/crates/pg_warren/src/external_ids/mod.rs @@ -14,7 +14,7 @@ pub(crate) use error::ExternalIdError; use pgrx::prelude::*; use pgrx::{AnyArray, AnyElement, FromDatum, IntoDatum}; -use warren_core::graph::{DenseId, IdMap}; +use warren_core::graph::{DenseId, FixedKeys, IdMap}; use crate::source::{type_name, IdDatum}; @@ -77,12 +77,15 @@ pub(crate) fn elements( } /// The dense ids of the live nodes among `ids`, in order. -pub(crate) fn dense_ids(id_map: &IdMap, ids: &[E]) -> Vec { +pub(crate) fn dense_ids(id_map: &IdMap>, ids: &[E]) -> Vec { ids.iter().filter_map(|id| id_map.dense_id(id)).collect() } /// The external id of `dense` as a SQL value of the graph's id type. -pub(crate) fn external(id_map: &IdMap, dense: DenseId) -> Option { +pub(crate) fn external( + id_map: &IdMap>, + dense: DenseId, +) -> Option { let id = id_map.external_id(dense)?; let datum = id.to_sql().into_datum()?; // SAFETY: the datum was just made from E's SQL type, whose oid is @@ -91,9 +94,16 @@ pub(crate) fn external(id_map: &IdMap, dense: DenseId) -> Option< } /// The external ids of `path` as a SQL array of the graph's id type. -pub(crate) fn external_array(id_map: &IdMap, path: &[DenseId]) -> Option { - let ids: Vec = - path.iter().filter_map(|&dense| id_map.external_id(dense)).map(E::to_sql).collect(); +pub(crate) fn external_array( + id_map: &IdMap>, + path: &[DenseId], +) -> Option { + let ids: Vec = path + .iter() + .filter_map(|&dense| id_map.external_id(dense)) + .copied() + .map(E::to_sql) + .collect(); let datum = ids.into_datum()?; // SAFETY: get_array_type only reads the type cache. let array_type = unsafe { pg_sys::get_array_type(E::Sql::type_oid()) }; diff --git a/crates/pg_warren/src/segment/layout.rs b/crates/pg_warren/src/segment/layout.rs index 816a487d..44581273 100644 --- a/crates/pg_warren/src/segment/layout.rs +++ b/crates/pg_warren/src/segment/layout.rs @@ -7,8 +7,8 @@ //! not the segment. use warren_core::graph::{ - AnyCsr, BuildCharge, BuildMemory, BuiltCsr, ChunkSink, CsrSink, ExternalId, GraphCsr, IdMap, - Membership, RelationCsr, RelationDictionary, + AnyCsr, BuildCharge, BuildMemory, BuiltCsr, ChunkSink, CsrSink, ExternalId, FixedKeys, + GraphCsr, IdMap, Membership, RelationCsr, RelationDictionary, }; use super::{ @@ -22,7 +22,7 @@ use crate::catalog::VersionId; pub(crate) fn write_version( segments: &mut impl SegmentStore, version: VersionId, - id_map: &IdMap, + id_map: &IdMap>, relations: &RelationDictionary, graph: &GraphCsr, chunk_size: usize, @@ -86,7 +86,7 @@ impl<'s, S: SegmentStore> SegmentWriter<'s, S> { /// The `id_map`, `tombstones` and `rel_dict` segments. pub(crate) fn write_identity( &mut self, - id_map: &IdMap, + id_map: &IdMap>, relations: &RelationDictionary, ) -> Result<(), SegmentError> { let (chunk_size, memory) = (self.chunk_size, self.memory.clone()); diff --git a/crates/pg_warren/src/traversal_sql/bfs.rs b/crates/pg_warren/src/traversal_sql/bfs.rs index 94fa4175..d1916d5b 100644 --- a/crates/pg_warren/src/traversal_sql/bfs.rs +++ b/crates/pg_warren/src/traversal_sql/bfs.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use pgrx::prelude::*; use pgrx::{AnyArray, AnyElement}; -use warren_core::graph::{GraphCsr, GraphError, IdMap}; +use warren_core::graph::{FixedKeys, GraphCsr, GraphError, IdMap}; use warren_core::traversal::{BfsCursor, Limits, NodeSet, TraversalError, Visit}; use super::args::bound; @@ -138,13 +138,13 @@ struct Request<'a> { pull: bool, } -fn bfs_row(id_map: &IdMap, visit: Visit) -> Option { +fn bfs_row(id_map: &IdMap>, visit: Visit) -> Option { let via = visit.via.and_then(|via| external(id_map, via)); let depth = i32::try_from(visit.depth).unwrap_or(i32::MAX); Some((external(id_map, visit.node)?, depth, via)) } -fn node_row(id_map: &IdMap, visit: Visit) -> Option { +fn node_row(id_map: &IdMap>, visit: Visit) -> Option { external(id_map, visit.node) } @@ -161,7 +161,7 @@ impl Borrow for VersionGraph { struct LevelRows { loaded: Arc>, cursor: BfsCursor>, - row: fn(&IdMap, Visit) -> Option, + row: fn(&IdMap>, Visit) -> Option, /// The visits of the current level not yet returned, in reverse. level: Vec, more: bool, @@ -173,7 +173,7 @@ impl LevelRows { request: &Request<'_>, version: i64, loaded: Arc>, - row: fn(&IdMap, Visit) -> Option, + row: fn(&IdMap>, Visit) -> Option, ) -> Result { let direction = direction(request.direction)?; let relations = relations(&loaded.relations, request.rels.as_deref()); diff --git a/crates/pg_warren/src/version_sql/compact.rs b/crates/pg_warren/src/version_sql/compact.rs index 1e80be8e..1594db08 100644 --- a/crates/pg_warren/src/version_sql/compact.rs +++ b/crates/pg_warren/src/version_sql/compact.rs @@ -134,7 +134,7 @@ mod unit { use crate::segment::{write_version, MemorySegmentStore}; use crate::vector::MemoryVectorStore; use pgrx::pg_sys; - use warren_core::graph::{EdgeIntake, GraphCsr, IdMap, RelationDictionary}; + use warren_core::graph::{EdgeIntake, FixedKeys, GraphCsr, IdMap, RelationDictionary}; fn graph() -> GraphDefinition { GraphDefinition { @@ -162,7 +162,7 @@ mod unit { #[test] fn compaction_records_a_promoted_version_in_a_new_id_space() { // 10, 20 (tombstoned), 30, 40; edges 10 → 30, 40 → 10. - let id_map = IdMap::::new() + let id_map = IdMap::>::new() .rebuild(vec![10, 20, 30, 40]) .and_then(|m| m.rebuild(vec![10, 30, 40])) .unwrap(); diff --git a/crates/pg_warren/src/version_sql/diff.rs b/crates/pg_warren/src/version_sql/diff.rs index 01a64402..0b4182ea 100644 --- a/crates/pg_warren/src/version_sql/diff.rs +++ b/crates/pg_warren/src/version_sql/diff.rs @@ -92,11 +92,11 @@ fn degree_json(shift: &DegreeShift) -> Value { #[allow(clippy::disallowed_methods)] // Test code may assign a transaction id. mod unit { use super::*; - use warren_core::graph::{EdgeIntake, GraphCsr, IdMap, RelationDictionary}; + use warren_core::graph::{EdgeIntake, FixedKeys, GraphCsr, IdMap, RelationDictionary}; use warren_core::version::{diff, GraphVersion}; struct Built { - id_map: IdMap, + id_map: IdMap>, relations: RelationDictionary, graph: GraphCsr, } @@ -115,7 +115,7 @@ mod unit { Built { id_map, relations, graph } } - fn view(b: &Built) -> GraphVersion<'_, i64> { + fn view(b: &Built) -> GraphVersion<'_, FixedKeys> { GraphVersion::new(&b.id_map, &b.relations, &b.graph).unwrap() } diff --git a/crates/warren-core/benches/version_diff.rs b/crates/warren-core/benches/version_diff.rs index 7e678fea..555409b8 100644 --- a/crates/warren-core/benches/version_diff.rs +++ b/crates/warren-core/benches/version_diff.rs @@ -3,7 +3,7 @@ //! with 1% of nodes removed, 1% added and about 5% of edges replaced. use criterion::{criterion_group, criterion_main, Criterion, Throughput}; -use warren_core::graph::{EdgeIntake, GraphCsr, GraphError, IdMap, RelationDictionary}; +use warren_core::graph::{EdgeIntake, FixedKeys, GraphCsr, GraphError, IdMap, RelationDictionary}; use warren_core::version::{diff, GraphVersion, VersionError}; const NODES: i64 = 100_000; @@ -23,13 +23,13 @@ impl Lcg { /// One built version. struct Version { - id_map: IdMap, + id_map: IdMap>, relations: RelationDictionary, graph: GraphCsr, } impl Version { - fn view(&self) -> Result, VersionError> { + fn view(&self) -> Result>, VersionError> { GraphVersion::new(&self.id_map, &self.relations, &self.graph) } } @@ -37,7 +37,7 @@ impl Version { /// Builds the next version of `previous` from external-id node and edge /// rows, as `refresh` would. fn build( - previous: &IdMap, + previous: &IdMap>, relations: &RelationDictionary, nodes: Vec, edges: &[(u64, i64, i64)], diff --git a/crates/warren-core/examples/locality.rs b/crates/warren-core/examples/locality.rs index 24cd1c41..52142200 100644 --- a/crates/warren-core/examples/locality.rs +++ b/crates/warren-core/examples/locality.rs @@ -25,7 +25,7 @@ use std::time::Instant; use warren_core::compute::{spmv, AnyVector, ComputeMemory, Product, Semiring, Vector, Workers}; use warren_core::graph::{ - BuildBuffer, BuildMemory, DenseId, Direction, EdgeIntake, EdgeList, GraphCsr, IdMap, + BuildBuffer, BuildMemory, DenseId, Direction, EdgeIntake, EdgeList, FixedKeys, GraphCsr, IdMap, Permutation, RelationDictionary, RelationSelection, }; use warren_core::interrupts::Uninterrupted; @@ -133,7 +133,7 @@ fn first_build_peaks(edges: &EdgeList, nodes: u64) -> Result<(u64, u64), Box>::new().rebuild_within(ids)?; let relation = RelationDictionary::new().intern("edge")?; let mut intake = EdgeIntake::directed(&id_map, false).within(&memory); intake.reserve(edges.len())?; diff --git a/crates/warren-core/src/community/labels.rs b/crates/warren-core/src/community/labels.rs index 073e55eb..39b4b91c 100644 --- a/crates/warren-core/src/community/labels.rs +++ b/crates/warren-core/src/community/labels.rs @@ -5,8 +5,8 @@ use crate::compute::{AnyVector, Element}; use crate::graph::build_memory::bytes_of; use crate::graph::{ - memory, BuildBuffer, BuildCharge, BuildMemory, DenseId, IdMap, Membership, Tombstones, - NO_COMMUNITY, + memory, BuildBuffer, BuildCharge, BuildMemory, DenseId, FixedKeys, IdMap, Membership, + Tombstones, NO_COMMUNITY, }; use super::CommunityError; @@ -65,7 +65,7 @@ impl Labels { /// its charge to `memory`. pub fn membership( &self, - quotient: &IdMap, + quotient: &IdMap>, memory: &BuildMemory, ) -> Result<(Membership, BuildCharge), CommunityError> { let nodes = self.labels.len(); diff --git a/crates/warren-core/src/graph/edge_list.rs b/crates/warren-core/src/graph/edge_list.rs index a652cde6..e5fbc4f4 100644 --- a/crates/warren-core/src/graph/edge_list.rs +++ b/crates/warren-core/src/graph/edge_list.rs @@ -6,7 +6,7 @@ use std::mem::size_of; use super::build_memory::growth; use super::{ - BuildBuffer, BuildMemory, DenseId, ExternalId, GraphError, IdMap, Permutation, RelationIndex, + BuildBuffer, BuildMemory, DenseId, GraphError, IdKeys, IdMap, Permutation, RelationIndex, Target, TargetWidth, }; @@ -249,27 +249,27 @@ fn renumber_endpoints( /// Remaps edges from external ids to dense ids through an id map. #[derive(Debug)] -pub struct EdgeIntake<'m, E> { - id_map: &'m IdMap, +pub struct EdgeIntake<'m, K> { + id_map: &'m IdMap, directed: bool, edges: EdgeList, dropped_edges: u64, } -impl<'m, E: ExternalId> EdgeIntake<'m, E> { +impl<'m, K: IdKeys> EdgeIntake<'m, K> { /// An intake for a directed graph. - pub fn directed(id_map: &'m IdMap, weighted: bool) -> Self { + pub fn directed(id_map: &'m IdMap, weighted: bool) -> Self { Self::new(id_map, true, weighted) } /// An intake for an undirected graph: each edge is emitted in both /// directions with the same weight, except a self-loop, which is emitted /// once. - pub fn undirected(id_map: &'m IdMap, weighted: bool) -> Self { + pub fn undirected(id_map: &'m IdMap, weighted: bool) -> Self { Self::new(id_map, false, weighted) } - fn new(id_map: &'m IdMap, directed: bool, weighted: bool) -> Self { + fn new(id_map: &'m IdMap, directed: bool, weighted: bool) -> Self { let edges = EdgeList::for_id_space(id_map.id_space(), weighted); Self { id_map, directed, edges, dropped_edges: 0 } } @@ -295,8 +295,8 @@ impl<'m, E: ExternalId> EdgeIntake<'m, E> { pub fn push( &mut self, relation: RelationIndex, - source: Option<&E>, - target: Option<&E>, + source: Option<&K::Key>, + target: Option<&K::Key>, weight: f64, ) -> Result<(), GraphError> { let source = source.and_then(|id| self.id_map.dense_id(id)); @@ -323,9 +323,9 @@ impl<'m, E: ExternalId> EdgeIntake<'m, E> { #[cfg(test)] mod tests { use super::*; - use crate::graph::{Permutation, RelationDictionary, TargetWidth}; + use crate::graph::{FixedKeys, Permutation, RelationDictionary, TargetWidth}; - fn id_map() -> IdMap { + fn id_map() -> IdMap> { // 10 → 0, 20 → 1 (tombstoned), 30 → 2 IdMap::new().rebuild(vec![10, 20, 30]).unwrap().rebuild(vec![10, 30]).unwrap() } diff --git a/crates/warren-core/src/graph/id_map.rs b/crates/warren-core/src/graph/id_map.rs deleted file mode 100644 index c414960e..00000000 --- a/crates/warren-core/src/graph/id_map.rs +++ /dev/null @@ -1,616 +0,0 @@ -//! The id map: stable dense ids for external ids across versions of a graph -//! (DESIGN.md §5.2). - -use std::cmp::Ordering; -use std::fmt::Debug; - -use super::{ - memory, BuildBuffer, BuildCharge, BuildMemory, DenseId, GraphError, Permutation, SegmentBuffer, - Tombstones, -}; - -/// An external id type: the type of a graph's node ids in its user tables -/// (`id_type`). Dense ids follow this type's order, which must match the -/// Postgres sort order of the column type. -pub trait ExternalId: Copy + Ord + Debug { - /// The encoded width in bytes. - const WIDTH: usize; - - /// Appends the segment encoding of `self` (little-endian for integers). - fn write_le(&self, out: &mut Vec); - - /// Decodes an id from exactly [`Self::WIDTH`] bytes. - fn read_le(bytes: &[u8]) -> Self; -} - -macro_rules! integer_external_id { - ($type:ty, $doc:literal) => { - #[doc = $doc] - impl ExternalId for $type { - const WIDTH: usize = std::mem::size_of::<$type>(); - - fn write_le(&self, out: &mut Vec) { - out.extend_from_slice(&self.to_le_bytes()); - } - - fn read_le(bytes: &[u8]) -> Self { - let mut array = [0; std::mem::size_of::<$type>()]; - array.copy_from_slice(bytes); - <$type>::from_le_bytes(array) - } - } - }; -} - -integer_external_id!(i32, "`int4`."); -integer_external_id!(i64, "`int8`."); - -/// `uuid`, in its 16 bytes: Postgres compares uuids bytewise, as `[u8; 16]` -/// does. -impl ExternalId for [u8; 16] { - const WIDTH: usize = 16; - - fn write_le(&self, out: &mut Vec) { - out.extend_from_slice(self); - } - - fn read_le(bytes: &[u8]) -> Self { - let mut uuid = [0; 16]; - uuid.copy_from_slice(bytes); - uuid - } -} - -/// An (external id, dense id) pair of the id map, in a fixed layout so that -/// a shared version can hold the pairs in place (DESIGN.md §5.6). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[repr(C)] -pub struct IdPair { - /// The node's external id. - pub external: E, - /// The dense id assigned to it. - pub dense: DenseId, -} - -/// The id map of one graph version: (external id, dense id) pairs sorted by -/// external id, an inverse array from dense id to external id, and the -/// tombstones of dense ids whose nodes have disappeared. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct IdMap { - by_external: SegmentBuffer>, - by_dense: SegmentBuffer, - tombstones: Tombstones, -} - -impl Default for IdMap { - fn default() -> Self { - Self::new() - } -} - -impl IdMap { - /// The empty id map that precedes a graph's first build. - pub fn new() -> Self { - Self { - by_external: SegmentBuffer::default(), - by_dense: SegmentBuffer::default(), - tombstones: Tombstones::default(), - } - } - - /// The id map of the next build, whose live nodes are `externals` - /// (duplicates allowed). Nodes in both keep their dense id; new nodes are - /// appended at the high-water mark in external-id order; nodes that have - /// disappeared are tombstoned. Dense ids are never reused, so a node that - /// reappears after being tombstoned gets its old dense id back. - pub fn rebuild(&self, externals: Vec) -> Result { - let externals = BuildBuffer::from_vec(&BuildMemory::unlimited(), externals); - Ok(self.rebuild_within(externals)?.0) - } - - /// [`Self::rebuild`], charging every buffer to the memory `externals` - /// is charged to; returns the charge for the id map it holds. The - /// externals are sorted in place, the new ids among them counted, and - /// every buffer reserved exactly; they are freed before the inverse map - /// is built. - pub fn rebuild_within( - &self, - mut externals: BuildBuffer, - ) -> Result<(Self, BuildCharge), GraphError> { - let memory = externals.memory().clone(); - externals.sort_unstable(); - externals.dedup(); - let fresh_count = self.fresh(&externals); - let mut by_external = - BuildBuffer::with_capacity(&memory, self.by_external.len() + fresh_count)?; - let mut fresh = BuildBuffer::with_capacity(&memory, fresh_count)?; - let words = Tombstones::bytes_for(self.id_space() + fresh_count as u64); - let tombstones_charge = memory.charge(words)?; - let mut tombstones = self.tombstones.try_clone()?; - let mut next_dense = self.id_space(); - let mut previous = self.by_external.iter().peekable(); - let mut current = externals.iter().copied().peekable(); - loop { - let order = match (previous.peek(), current.peek()) { - (None, None) => break, - (Some(_), None) => Ordering::Less, - (None, Some(_)) => Ordering::Greater, - (Some(old), Some(new)) => old.external.cmp(new), - }; - match order { - Ordering::Less => { - if let Some(&pair) = previous.next() { - tombstones.insert(pair.dense)?; - by_external.push(pair)?; - } - } - Ordering::Equal => { - if let Some(&pair) = previous.next() { - tombstones.remove(pair.dense)?; - by_external.push(pair)?; - } - current.next(); - } - Ordering::Greater => { - if let Some(new) = current.next() { - by_external.push(IdPair { external: new, dense: next_dense })?; - fresh.push(new)?; - next_dense += 1; - } - } - } - } - drop(externals); - let mut by_dense = BuildBuffer::with_capacity(&memory, self.by_dense.len() + fresh.len())?; - by_dense.extend(self.by_dense.iter().copied()); - by_dense.extend(fresh.iter().copied()); - drop(fresh); - tombstones.grow_to(next_dense)?; - let (by_external, mut charge) = by_external.into_parts(); - let (by_dense, by_dense_charge) = by_dense.into_parts(); - charge.absorb(by_dense_charge); - charge.absorb(tombstones_charge); - let (by_external, by_dense) = (by_external.into(), by_dense.into()); - Ok((Self { by_external, by_dense, tombstones }, charge)) - } - - /// How many of the sorted, distinct `externals` this map does not hold. - fn fresh(&self, externals: &[E]) -> usize { - let mut known = self.by_external.iter().map(|pair| &pair.external).peekable(); - externals - .iter() - .filter(|external| { - while known.next_if(|known| known < external).is_some() {} - known.peek() != Some(external) - }) - .count() - } - - /// An id map from its (external id, dense id) pairs and tombstones, - /// checking that the pairs are strictly sorted by external id, that the - /// dense ids are exactly `0..id_space`, and that the tombstones cover - /// the same id space. - pub(crate) fn from_parts( - by_external: Vec>, - tombstones: Tombstones, - ) -> Result { - check_pairs(&by_external, &tombstones)?; - let mut by_dense = memory::filled(by_external.len(), None)?; - for &IdPair { external, dense } in &by_external { - match usize::try_from(dense).ok().and_then(|d| by_dense.get_mut(d)) { - Some(slot @ None) => *slot = Some(external), - _ => return Err(GraphError::IdMapNotPermutation), - } - } - let mut dense_to_external = memory::with_capacity(by_dense.len())?; - for external in by_dense { - dense_to_external.push(external.ok_or(GraphError::IdMapNotPermutation)?); - } - let (by_external, by_dense) = (by_external.into(), dense_to_external.into()); - Ok(Self { by_external, by_dense, tombstones }) - } - - /// An id map from its pairs, its inverse map and its tombstones, as - /// segment buffers, owned or shared views. It checks that the pairs are - /// strictly sorted by external id, that the dense ids are exactly - /// `0..id_space`, that the tombstones cover the same id space, and that - /// `by_dense` is the pairs' inverse, which it reads rather than builds. - pub fn from_segments( - by_external: SegmentBuffer>, - by_dense: SegmentBuffer, - tombstones: Tombstones, - ) -> Result { - check_pairs(&by_external, &tombstones)?; - if by_dense.len() != by_external.len() { - return Err(GraphError::SegmentLength { - expected: by_external.len() as u64, - found: by_dense.len() as u64, - }); - } - // Distinct externals, each the inverse's entry at its dense id, so - // the dense ids are distinct too: a permutation of `0..id_space`. - let inverse = |pair: &IdPair| { - usize::try_from(pair.dense).ok().and_then(|dense| by_dense.get(dense)) - == Some(&pair.external) - }; - if !by_external.iter().all(inverse) { - return Err(GraphError::IdMapNotPermutation); - } - Ok(Self { by_external, by_dense, tombstones }) - } - - /// Renumbers every dense id, tombstones included, by `permutation`, - /// which must permute this id map's id space; else changes nothing. - /// The renumbered tombstones, if there are any, are charged to `memory` - /// while they are built. A shared id map's copy is not charged: only a - /// first build renumbers, and its id map is one it built and owns. - pub fn renumber( - &mut self, - permutation: &Permutation, - memory: &BuildMemory, - ) -> Result<(), GraphError> { - if permutation.id_space() != self.id_space() { - return Err(GraphError::IdSpaceMismatch { - expected: self.id_space(), - found: permutation.id_space(), - }); - } - let id_space = self.id_space(); - // A shared id map is copied before anything changes. - let by_external = self.by_external.make_mut()?; - let by_dense = self.by_dense.make_mut()?; - if self.tombstones.count() > 0 { - // Built beside the old bitmap, which it then replaces. - let _charge = memory.charge(Tombstones::bytes_for(id_space))?; - let mut tombstones = Tombstones::new(id_space)?; - for old in (0..id_space).filter(|&old| self.tombstones.contains(old)) { - tombstones.insert(permutation.new_dense_id(old))?; - } - self.tombstones = tombstones; - } - for pair in by_external { - pair.dense = permutation.new_dense_id(pair.dense); - by_dense[pair.dense as usize] = pair.external; - } - Ok(()) - } - - /// The (external id, dense id) pairs, sorted by external id. - pub(crate) fn pairs(&self) -> &[IdPair] { - &self.by_external - } - - /// The dense id of a live node, by binary search. - pub fn dense_id(&self, external: &E) -> Option { - let index = self.by_external.binary_search_by(|pair| pair.external.cmp(external)).ok()?; - let dense = self.by_external[index].dense; - (!self.tombstones.contains(dense)).then_some(dense) - } - - /// The external id that `dense` was assigned to, tombstoned or not. - pub fn external_id(&self, dense: DenseId) -> Option { - usize::try_from(dense).ok().and_then(|index| self.by_dense.get(index)).copied() - } - - /// The high-water mark of dense ids, tombstones included. - pub fn id_space(&self) -> u64 { - self.by_dense.len() as u64 - } - - /// The number of live nodes. - pub fn node_count(&self) -> u64 { - self.id_space() - self.tombstones.count() - } - - /// The tombstones of this id map. - pub fn tombstones(&self) -> &Tombstones { - &self.tombstones - } - - /// The heap bytes it holds: both directions of the map and the - /// tombstones. - pub fn bytes(&self) -> u64 { - self.by_external.bytes() + self.by_dense.bytes() + self.tombstones.bytes() - } -} - -#[cfg(test)] -impl IdMap { - /// The same id map with every buffer a shared view of a heap segment. - pub(crate) fn shared_views(&self) -> Self { - Self { - by_external: SegmentBuffer::shared(super::HeapSegment::of(&self.by_external)), - by_dense: SegmentBuffer::shared(super::HeapSegment::of(&self.by_dense)), - tombstones: self.tombstones.shared_views(), - } - } -} - -/// Checks that the pairs are strictly sorted by external id and that the -/// tombstones cover their id space. -fn check_pairs(pairs: &[IdPair], tombstones: &Tombstones) -> Result<(), GraphError> { - let id_space = pairs.len() as u64; - if tombstones.id_space() != id_space { - return Err(GraphError::TombstonesLength { - expected: id_space, - found: tombstones.id_space(), - }); - } - if pairs.windows(2).any(|pair| pair[0].external >= pair[1].external) { - return Err(GraphError::IdMapNotSorted); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::graph::HeapSegment; - use proptest::prelude::*; - use std::collections::{BTreeMap, BTreeSet}; - use std::mem::{offset_of, size_of}; - - fn first_build(externals: Vec) -> IdMap { - IdMap::new().rebuild(externals).unwrap() - } - - #[test] - fn the_first_build_assigns_dense_ids_in_external_id_sort_order() { - let id_map = first_build(vec![30, 10, 20, 10]); - assert_eq!(id_map.id_space(), 3); - assert_eq!(id_map.node_count(), 3); - assert_eq!(id_map.dense_id(&10), Some(0)); - assert_eq!(id_map.dense_id(&20), Some(1)); - assert_eq!(id_map.dense_id(&30), Some(2)); - assert_eq!(id_map.external_id(2), Some(30)); - } - - #[test] - fn an_id_map_holds_both_directions_and_its_tombstones() { - let id_map = first_build(vec![30, 10, 20]); - let pair = size_of::>() as u64; - assert_eq!(id_map.bytes(), 3 * pair + 3 * 8 + id_map.tombstones().bytes()); - assert_eq!(IdMap::::new().bytes(), 0); - } - - /// A rebuild reserves exactly what the result holds: with a stable - /// node set, its peak is the externals and the result, not a second - /// copy of the base and a fresh-id buffer for every external. - #[test] - fn a_rebuild_charges_no_more_than_its_externals_and_its_result() { - let nodes = 10_000i64; - let base = first_build((0..nodes).collect()); - for externals in [(0..nodes).collect::>(), (nodes / 2..nodes * 3 / 2).collect()] { - let memory = BuildMemory::unlimited(); - let externals = BuildBuffer::from_vec(&memory, externals); - let held = externals.bytes(); - let (rebuilt, charge) = base.rebuild_within(externals).unwrap(); - assert_eq!(charge.bytes(), rebuilt.bytes()); - assert!( - memory.peak() <= held + rebuilt.bytes(), - "{} > {held} + {}", - memory.peak(), - rebuilt.bytes() - ); - } - } - - /// The locality order of the path 0 - 2 - 1 (`new = [2, 0, 1]`), over - /// an id space of 3. - fn path_permutation() -> Permutation { - let relation = crate::graph::RelationDictionary::new().intern("").unwrap(); - let mut edges = crate::graph::EdgeList::for_id_space(3, false); - edges.push(relation, 0, 2, 1.0).unwrap(); - edges.push(relation, 2, 1, 1.0).unwrap(); - Permutation::locality(&edges, 3, &BuildMemory::unlimited()).unwrap() - } - - #[test] - fn renumbering_moves_both_directions_and_the_tombstones() { - // 10 → 0, 20 → 1 (tombstoned), 30 → 2. - let mut id_map = first_build(vec![10, 20, 30]).rebuild(vec![10, 30]).unwrap(); - id_map.renumber(&path_permutation(), &BuildMemory::unlimited()).unwrap(); - assert_eq!(id_map.dense_id(&10), Some(2)); - assert_eq!(id_map.dense_id(&30), Some(1)); - assert_eq!(id_map.dense_id(&20), None); - assert_eq!( - (0..3).map(|dense| id_map.external_id(dense).unwrap()).collect::>(), - [20, 30, 10] - ); - assert!(id_map.tombstones().contains(0)); - assert_eq!(id_map.tombstones().count(), 1); - assert_eq!((id_map.id_space(), id_map.node_count()), (3, 2)); - assert!(id_map.pairs().windows(2).all(|pair| pair[0].external < pair[1].external)); - } - - /// The renumbered tombstones are a new bitmap, charged while it is - /// built beside the old one; without tombstones nothing is allocated. - #[test] - fn renumbering_charges_the_new_tombstones() { - let tombstoned = first_build(vec![10, 20, 30]).rebuild(vec![10, 30]).unwrap(); - let none = BuildMemory::new(0); - let mut id_map = tombstoned.clone(); - assert!(matches!( - id_map.renumber(&path_permutation(), &none), - Err(GraphError::BuildMemoryLimit { .. }) - )); - assert_eq!(id_map, tombstoned, "nothing is renumbered"); - let memory = BuildMemory::unlimited(); - id_map.renumber(&path_permutation(), &memory).unwrap(); - assert_eq!(memory.peak(), Tombstones::bytes_for(3)); - assert_eq!(memory.in_use(), 0); - - let mut live = first_build(vec![10, 20, 30]); - live.renumber(&path_permutation(), &none).unwrap(); - assert_eq!(live.dense_id(&10), Some(2)); - } - - #[test] - fn renumbering_refuses_a_permutation_of_another_id_space() { - let mut id_map = first_build(vec![10, 20]); - assert_eq!( - id_map.renumber(&path_permutation(), &BuildMemory::unlimited()), - Err(GraphError::IdSpaceMismatch { expected: 2, found: 3 }) - ); - assert_eq!(id_map, first_build(vec![10, 20])); - } - - /// The pairs and the inverse map as shared views of heap segments. - fn shared_parts( - id_map: &IdMap, - ) -> (SegmentBuffer>, SegmentBuffer) { - let externals: Vec = - (0..id_map.id_space()).filter_map(|dense| id_map.external_id(dense)).collect(); - let pairs = SegmentBuffer::shared(HeapSegment::of(id_map.pairs())); - (pairs, SegmentBuffer::shared(HeapSegment::of(&externals))) - } - - #[test] - fn an_id_map_of_shared_views_reads_as_the_owned_one() { - let owned = first_build(vec![10, 20, 30]).rebuild(vec![10, 30, 5]).unwrap(); - let (pairs, by_dense) = shared_parts(&owned); - let shared = IdMap::from_segments(pairs, by_dense, owned.tombstones().clone()).unwrap(); - assert_eq!(shared, owned); - assert_eq!(shared.dense_id(&5), Some(3)); - assert_eq!(shared.dense_id(&20), None); - assert_eq!(shared.external_id(1), Some(20)); - assert_eq!((shared.id_space(), shared.node_count()), (4, 3)); - assert_eq!(shared.bytes(), owned.bytes()); - } - - #[test] - fn shared_views_of_an_id_map_are_validated() { - let pair = |external, dense| IdPair { external, dense }; - let pairs = |pairs: &[IdPair]| SegmentBuffer::shared(HeapSegment::of(pairs)); - let externals = |externals: &[i64]| SegmentBuffer::shared(HeapSegment::of(externals)); - let check = |p, e, id_space| { - IdMap::from_segments(p, e, Tombstones::new(id_space).unwrap()).map(|_| ()) - }; - let sorted = [pair(10, 1), pair(20, 0)]; - assert_eq!(check(pairs(&sorted), externals(&[20, 10]), 2), Ok(())); - assert_eq!( - check(pairs(&[pair(20, 0), pair(10, 1)]), externals(&[20, 10]), 2), - Err(GraphError::IdMapNotSorted) - ); - assert_eq!( - check(pairs(&sorted), externals(&[10, 20]), 2), - Err(GraphError::IdMapNotPermutation) - ); - assert_eq!( - check(pairs(&[pair(10, 2), pair(20, 0)]), externals(&[20, 10]), 2), - Err(GraphError::IdMapNotPermutation) - ); - assert_eq!( - check(pairs(&sorted), externals(&[20]), 2), - Err(GraphError::SegmentLength { expected: 2, found: 1 }) - ); - assert_eq!( - check(pairs(&sorted), externals(&[20, 10]), 3), - Err(GraphError::TombstonesLength { expected: 2, found: 3 }) - ); - } - - #[test] - fn renumbering_a_shared_id_map_copies_it() { - let owned = first_build(vec![10, 20, 30]).rebuild(vec![10, 30]).unwrap(); - let (pairs, by_dense) = shared_parts(&owned); - let region = owned.pairs().to_vec(); - let mut shared = IdMap::from_segments(pairs, by_dense, owned.tombstones().clone()).unwrap(); - let mut expected = owned.clone(); - let memory = BuildMemory::unlimited(); - shared.renumber(&path_permutation(), &memory).unwrap(); - expected.renumber(&path_permutation(), &memory).unwrap(); - assert_eq!(shared, expected); - assert_eq!(owned.pairs(), region, "the shared pairs are unchanged"); - } - - #[test] - fn a_pair_has_a_fixed_layout() { - assert_eq!((size_of::>(), offset_of!(IdPair, dense)), (16, 8)); - assert_eq!((size_of::>(), offset_of!(IdPair, dense)), (16, 8)); - assert_eq!((size_of::>(), offset_of!(IdPair<[u8; 16]>, dense)), (24, 16)); - } - - #[test] - fn unknown_ids_have_no_mapping() { - let id_map = first_build(vec![1, 2]); - assert_eq!(id_map.dense_id(&3), None); - assert_eq!(id_map.external_id(2), None); - } - - #[test] - fn a_rebuild_keeps_survivors_appends_new_ids_and_tombstones_the_missing() { - let previous = first_build(vec![10, 20, 30]); - let id_map = previous.rebuild(vec![40, 5, 30, 10]).unwrap(); - assert_eq!(id_map.dense_id(&10), Some(0)); - assert_eq!(id_map.dense_id(&30), Some(2)); - assert_eq!(id_map.dense_id(&5), Some(3)); - assert_eq!(id_map.dense_id(&40), Some(4)); - assert_eq!(id_map.dense_id(&20), None); - assert_eq!(id_map.external_id(1), Some(20)); - assert!(id_map.tombstones().contains(1)); - assert_eq!(id_map.id_space(), 5); - assert_eq!(id_map.node_count(), 4); - } - - #[test] - fn a_resurrected_id_gets_its_old_dense_id_back() { - let id_map = - first_build(vec![10, 20]).rebuild(vec![10]).unwrap().rebuild(vec![10, 20]).unwrap(); - assert_eq!(id_map.dense_id(&20), Some(1)); - assert!(!id_map.tombstones().contains(1)); - assert_eq!(id_map.id_space(), 2); - } - - #[test] - fn uuids_sort_bytewise() { - let low = [0u8; 16]; - let mut high = [0u8; 16]; - high[0] = 0xff; - let id_map = IdMap::new().rebuild(vec![high, low]).unwrap(); - assert_eq!(id_map.dense_id(&low), Some(0)); - assert_eq!(id_map.dense_id(&high), Some(1)); - } - - fn builds() -> impl Strategy>> { - prop::collection::vec(prop::collection::btree_set(0i32..40, 0..30), 1..8) - } - - proptest! { - #[test] - fn dense_ids_are_stable_across_a_sequence_of_builds(builds in builds()) { - let mut id_map = IdMap::::new(); - let mut ever: BTreeMap = BTreeMap::new(); - for live in builds { - let mut input: Vec = live.iter().copied().collect(); - input.extend(live.iter().take(3)); // duplicates - input.reverse(); - let previous_id_space = id_map.id_space(); - id_map = id_map.rebuild(input).unwrap(); - - prop_assert!(id_map.id_space() >= previous_id_space); - prop_assert_eq!(id_map.node_count(), live.len() as u64); - prop_assert_eq!(id_map.tombstones().count(), id_map.id_space() - live.len() as u64); - let mut fresh = Vec::new(); - for external in &live { - let dense = id_map.dense_id(external); - prop_assert!(dense.is_some()); - match ever.get(external) { - Some(&kept) => prop_assert_eq!(dense, Some(kept)), - None => fresh.push((*external, dense.unwrap_or_default())), - } - } - // New ids are appended at the high-water mark in sort order. - for (offset, (external, dense)) in fresh.iter().enumerate() { - prop_assert_eq!(*dense, previous_id_space + offset as u64); - ever.insert(*external, *dense); - } - prop_assert_eq!(id_map.id_space(), ever.len() as u64); - for (external, dense) in &ever { - prop_assert_eq!(id_map.external_id(*dense), Some(*external)); - prop_assert_eq!(id_map.tombstones().contains(*dense), !live.contains(external)); - } - } - } - } -} diff --git a/crates/warren-core/src/graph/id_map/fixed.rs b/crates/warren-core/src/graph/id_map/fixed.rs new file mode 100644 index 00000000..5aa51dfe --- /dev/null +++ b/crates/warren-core/src/graph/id_map/fixed.rs @@ -0,0 +1,294 @@ +//! The key storage of fixed-width external ids (`int4`, `int8`, `uuid`): +//! (external id, dense id) pairs sorted by external id and an inverse +//! array from dense id to external id (DESIGN.md §5.2). + +use std::fmt::Debug; + +use super::{check_tombstones, IdKeys, IdMap, KeysRebuild, NodeIds}; +use crate::graph::{ + memory, BuildBuffer, BuildCharge, BuildMemory, DenseId, GraphError, Permutation, SegmentBuffer, + Tombstones, +}; + +/// An external id type: the type of a graph's node ids in its user tables +/// (`id_type`). Dense ids follow this type's order, which must match the +/// Postgres sort order of the column type. +pub trait ExternalId: Copy + Ord + Debug { + /// The encoded width in bytes. + const WIDTH: usize; + + /// Appends the segment encoding of `self` (little-endian for integers). + fn write_le(&self, out: &mut Vec); + + /// Decodes an id from exactly [`Self::WIDTH`] bytes. + fn read_le(bytes: &[u8]) -> Self; +} + +macro_rules! integer_external_id { + ($type:ty, $doc:literal) => { + #[doc = $doc] + impl ExternalId for $type { + const WIDTH: usize = std::mem::size_of::<$type>(); + + fn write_le(&self, out: &mut Vec) { + out.extend_from_slice(&self.to_le_bytes()); + } + + fn read_le(bytes: &[u8]) -> Self { + let mut array = [0; std::mem::size_of::<$type>()]; + array.copy_from_slice(bytes); + <$type>::from_le_bytes(array) + } + } + }; +} + +integer_external_id!(i32, "`int4`."); +integer_external_id!(i64, "`int8`."); + +/// `uuid`, in its 16 bytes: Postgres compares uuids bytewise, as `[u8; 16]` +/// does. +impl ExternalId for [u8; 16] { + const WIDTH: usize = 16; + + fn write_le(&self, out: &mut Vec) { + out.extend_from_slice(self); + } + + fn read_le(bytes: &[u8]) -> Self { + let mut uuid = [0; 16]; + uuid.copy_from_slice(bytes); + uuid + } +} + +/// An (external id, dense id) pair of the id map, in a fixed layout so that +/// a shared version can hold the pairs in place (DESIGN.md §5.6). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(C)] +pub struct IdPair { + /// The node's external id. + pub external: E, + /// The dense id assigned to it. + pub dense: DenseId, +} + +/// The keys of a fixed-width id type `E`: (external id, dense id) pairs +/// sorted by external id, and an inverse array from dense id to external +/// id, each owned or a shared view. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FixedKeys { + by_external: SegmentBuffer>, + by_dense: SegmentBuffer, +} + +impl Default for FixedKeys { + fn default() -> Self { + Self { by_external: SegmentBuffer::default(), by_dense: SegmentBuffer::default() } + } +} + +impl IdKeys for FixedKeys { + type Key = E; + type Ids = BuildBuffer; + type Rebuild = FixedRebuild; + + fn id_space(&self) -> u64 { + self.by_dense.len() as u64 + } + + fn bytes(&self) -> u64 { + self.by_external.bytes() + self.by_dense.bytes() + } + + fn dense_id(&self, key: &E) -> Option { + let index = self.by_external.binary_search_by(|pair| pair.external.cmp(key)).ok()?; + Some(self.by_external[index].dense) + } + + fn key(&self, dense: DenseId) -> Option<&E> { + usize::try_from(dense).ok().and_then(|index| self.by_dense.get(index)) + } + + fn in_order(&self) -> impl Iterator { + self.by_external.iter().map(|pair| (&pair.external, pair.dense)) + } + + fn renumber(&mut self, permutation: &Permutation) -> Result<(), GraphError> { + // A shared id map is copied before anything changes. + let by_external = self.by_external.make_mut()?; + let by_dense = self.by_dense.make_mut()?; + for pair in by_external { + pair.dense = permutation.new_dense_id(pair.dense); + by_dense[pair.dense as usize] = pair.external; + } + Ok(()) + } + + fn compacted(&self, old_dense_ids: &[DenseId]) -> Result { + let mut pairs = memory::with_capacity(old_dense_ids.len())?; + for (new, &old) in old_dense_ids.iter().enumerate() { + let external = *self.key(old).ok_or(GraphError::IdMapNotPermutation)?; + pairs.push(IdPair { external, dense: new as DenseId }); + } + Self::from_pairs(pairs) + } + + fn rebuild(&self, fresh: usize, memory: &BuildMemory) -> Result, GraphError> { + Ok(FixedRebuild { + by_external: BuildBuffer::with_capacity(memory, self.by_external.len() + fresh)?, + fresh: BuildBuffer::with_capacity(memory, fresh)?, + }) + } +} + +impl FixedKeys { + /// The keys of `by_external`, checking that the pairs are strictly + /// sorted by external id and that the dense ids are exactly + /// `0..id_space`; builds the inverse. + fn from_pairs(by_external: Vec>) -> Result { + check_sorted(&by_external)?; + let mut by_dense = memory::filled(by_external.len(), None)?; + for &IdPair { external, dense } in &by_external { + match usize::try_from(dense).ok().and_then(|d| by_dense.get_mut(d)) { + Some(slot @ None) => *slot = Some(external), + _ => return Err(GraphError::IdMapNotPermutation), + } + } + let mut dense_to_external = memory::with_capacity(by_dense.len())?; + for external in by_dense { + dense_to_external.push(external.ok_or(GraphError::IdMapNotPermutation)?); + } + Ok(Self { by_external: by_external.into(), by_dense: dense_to_external.into() }) + } +} + +/// The keys of a fixed-width rebuild: the pairs in external-id order and +/// the fresh ids in dense-id order, which extend the base's inverse. +#[derive(Debug)] +pub struct FixedRebuild { + by_external: BuildBuffer>, + fresh: BuildBuffer, +} + +impl KeysRebuild> for FixedRebuild { + fn keep(&mut self, key: &E, dense: DenseId) -> Result<(), GraphError> { + self.by_external.push(IdPair { external: *key, dense }) + } + + fn add(&mut self, key: &E, dense: DenseId) -> Result<(), GraphError> { + self.by_external.push(IdPair { external: *key, dense })?; + self.fresh.push(*key) + } + + fn finish(self, base: &FixedKeys) -> Result<(FixedKeys, BuildCharge), GraphError> { + let Self { by_external, fresh } = self; + let memory = by_external.memory().clone(); + let mut by_dense = BuildBuffer::with_capacity(&memory, base.by_dense.len() + fresh.len())?; + by_dense.extend(base.by_dense.iter().copied()); + by_dense.extend(fresh.iter().copied()); + drop(fresh); + let (by_external, mut charge) = by_external.into_parts(); + let (by_dense, by_dense_charge) = by_dense.into_parts(); + charge.absorb(by_dense_charge); + let keys = FixedKeys { by_external: by_external.into(), by_dense: by_dense.into() }; + Ok((keys, charge)) + } +} + +impl NodeIds for BuildBuffer { + type Key = E; + + fn memory(&self) -> &BuildMemory { + BuildBuffer::memory(self) + } + + fn sort_distinct(&mut self) { + self.sort_unstable(); + self.dedup(); + } + + fn keys(&self) -> impl Iterator { + self.iter() + } +} + +impl IdMap> { + /// The id map of the next build, whose live nodes are `externals` + /// (duplicates allowed). Nodes in both keep their dense id; new nodes are + /// appended at the high-water mark in external-id order; nodes that have + /// disappeared are tombstoned. Dense ids are never reused, so a node that + /// reappears after being tombstoned gets its old dense id back. + pub fn rebuild(&self, externals: Vec) -> Result { + let externals = BuildBuffer::from_vec(&BuildMemory::unlimited(), externals); + Ok(self.rebuild_within(externals)?.0) + } + + /// An id map from its (external id, dense id) pairs and tombstones, + /// checking that the pairs are strictly sorted by external id, that the + /// dense ids are exactly `0..id_space`, and that the tombstones cover + /// the same id space. + pub(crate) fn from_parts( + by_external: Vec>, + tombstones: Tombstones, + ) -> Result { + check_tombstones(by_external.len() as u64, &tombstones)?; + Ok(Self { keys: FixedKeys::from_pairs(by_external)?, tombstones }) + } + + /// An id map from its pairs, its inverse map and its tombstones, as + /// segment buffers, owned or shared views. It checks that the pairs are + /// strictly sorted by external id, that the dense ids are exactly + /// `0..id_space`, that the tombstones cover the same id space, and that + /// `by_dense` is the pairs' inverse, which it reads rather than builds. + pub fn from_segments( + by_external: SegmentBuffer>, + by_dense: SegmentBuffer, + tombstones: Tombstones, + ) -> Result { + check_tombstones(by_external.len() as u64, &tombstones)?; + check_sorted(&by_external)?; + if by_dense.len() != by_external.len() { + return Err(GraphError::SegmentLength { + expected: by_external.len() as u64, + found: by_dense.len() as u64, + }); + } + // Distinct externals, each the inverse's entry at its dense id, so + // the dense ids are distinct too: a permutation of `0..id_space`. + let inverse = |pair: &IdPair| { + usize::try_from(pair.dense).ok().and_then(|dense| by_dense.get(dense)) + == Some(&pair.external) + }; + if !by_external.iter().all(inverse) { + return Err(GraphError::IdMapNotPermutation); + } + Ok(Self { keys: FixedKeys { by_external, by_dense }, tombstones }) + } + + /// The (external id, dense id) pairs, sorted by external id. + pub(crate) fn pairs(&self) -> &[IdPair] { + &self.keys.by_external + } +} + +#[cfg(test)] +impl IdMap> { + /// The same id map with every buffer a shared view of a heap segment. + pub(crate) fn shared_views(&self) -> Self { + use crate::graph::HeapSegment; + let keys = FixedKeys { + by_external: SegmentBuffer::shared(HeapSegment::of(&self.keys.by_external)), + by_dense: SegmentBuffer::shared(HeapSegment::of(&self.keys.by_dense)), + }; + Self { keys, tombstones: self.tombstones.shared_views() } + } +} + +/// Checks that the pairs are strictly sorted by external id. +fn check_sorted(pairs: &[IdPair]) -> Result<(), GraphError> { + if pairs.windows(2).any(|pair| pair[0].external >= pair[1].external) { + return Err(GraphError::IdMapNotSorted); + } + Ok(()) +} diff --git a/crates/warren-core/src/graph/id_map/keys.rs b/crates/warren-core/src/graph/id_map/keys.rs new file mode 100644 index 00000000..df8a2257 --- /dev/null +++ b/crates/warren-core/src/graph/id_map/keys.rs @@ -0,0 +1,73 @@ +//! The key storage of an id map (DESIGN.md §5.7): how a version holds its +//! external ids, apart from the dense-id logic every id type shares. + +use crate::graph::{BuildCharge, BuildMemory, DenseId, GraphError, Permutation}; + +/// How an id map stores its external ids: both directions between an +/// external id ([`Self::Key`]) and its dense id, over one id space, +/// tombstoned nodes included. [`IdMap`](super::IdMap) adds the tombstones. +pub trait IdKeys: Default { + /// An external id as lookups take and return it. + type Key: ?Sized + Ord; + /// The node ids a rebuild takes. + type Ids: NodeIds; + /// The keys of the next build, as a rebuild fills them. + type Rebuild: KeysRebuild; + + /// The high-water mark of dense ids, tombstones included. + fn id_space(&self) -> u64; + + /// The heap bytes the keys hold. + fn bytes(&self) -> u64; + + /// The dense id assigned to `key`, tombstoned or not. + fn dense_id(&self, key: &Self::Key) -> Option; + + /// The key assigned to `dense`, tombstoned or not. + fn key(&self, dense: DenseId) -> Option<&Self::Key>; + + /// Every (key, dense id), in key order. + fn in_order(&self) -> impl Iterator; + + /// Moves every key to its dense id under `permutation`, which permutes + /// this id space; on an error, nothing has changed. + fn renumber(&mut self, permutation: &Permutation) -> Result<(), GraphError>; + + /// The keys at `old_dense_ids`, which are in key order, given dense ids + /// `0..old_dense_ids.len()` in that order. + fn compacted(&self, old_dense_ids: &[DenseId]) -> Result; + + /// Starts the keys of a rebuild that adds `fresh` keys to these, + /// reserving exactly what they will hold, charged to `memory`. + fn rebuild(&self, fresh: usize, memory: &BuildMemory) -> Result; +} + +/// The node ids a rebuild takes: external ids in any order, duplicates +/// allowed, charged to a build's memory. +pub trait NodeIds { + /// An external id. + type Key: ?Sized + Ord; + + /// The memory the ids are charged to. + fn memory(&self) -> &BuildMemory; + + /// Sorts the ids and removes duplicates, in place. + fn sort_distinct(&mut self); + + /// The ids, in their current order. + fn keys(&self) -> impl Iterator; +} + +/// The keys of a rebuild, filled in key order with every key of the next +/// build: the base's, then fresh ones, interleaved by key. +pub trait KeysRebuild { + /// Takes a key of the base keys, at its dense id. + fn keep(&mut self, key: &K::Key, dense: DenseId) -> Result<(), GraphError>; + + /// Takes a fresh key, at its new dense id. + fn add(&mut self, key: &K::Key, dense: DenseId) -> Result<(), GraphError>; + + /// The rebuilt keys, from the base keys `base` and the keys taken, and + /// the charge for what they hold. + fn finish(self, base: &K) -> Result<(K, BuildCharge), GraphError>; +} diff --git a/crates/warren-core/src/graph/id_map/mod.rs b/crates/warren-core/src/graph/id_map/mod.rs new file mode 100644 index 00000000..d941fccb --- /dev/null +++ b/crates/warren-core/src/graph/id_map/mod.rs @@ -0,0 +1,208 @@ +//! The id map: stable dense ids for external ids across versions of a graph +//! (DESIGN.md §5.2), generic over how it stores its external ids (§5.7). + +mod fixed; +mod keys; + +use std::cmp::Ordering; + +pub use fixed::{ExternalId, FixedKeys, FixedRebuild, IdPair}; +pub use keys::{IdKeys, KeysRebuild, NodeIds}; + +use super::{BuildCharge, BuildMemory, DenseId, GraphError, Permutation, Tombstones}; + +/// The id map of one graph version: its keys, which map each external id +/// to its dense id and back, and the tombstones of dense ids whose nodes +/// have disappeared. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IdMap { + keys: K, + tombstones: Tombstones, +} + +impl Default for IdMap { + fn default() -> Self { + Self::new() + } +} + +impl IdMap { + /// The empty id map that precedes a graph's first build. + pub fn new() -> Self { + Self { keys: K::default(), tombstones: Tombstones::default() } + } + + /// The id map of the next build, whose live nodes are `ids` (duplicates + /// allowed), charging every buffer to the memory `ids` is charged to; + /// returns the charge for the id map it holds. Nodes in both keep their + /// dense id; new nodes are appended at the high-water mark in + /// external-id order; nodes that have disappeared are tombstoned. Dense + /// ids are never reused, so a node that reappears after being + /// tombstoned gets its old dense id back. The ids are sorted in place, + /// the new ids among them counted, and every buffer reserved exactly; + /// they are freed before the keys are finished. + pub fn rebuild_within(&self, mut ids: K::Ids) -> Result<(Self, BuildCharge), GraphError> { + let memory = ids.memory().clone(); + ids.sort_distinct(); + let fresh_count = self.fresh(&ids); + let mut keys = self.keys.rebuild(fresh_count, &memory)?; + let words = Tombstones::bytes_for(self.id_space() + fresh_count as u64); + let tombstones_charge = memory.charge(words)?; + let mut tombstones = self.tombstones.try_clone()?; + let next_dense = self.merge(&ids, &mut keys, &mut tombstones)?; + drop(ids); + let (keys, mut charge) = keys.finish(&self.keys)?; + tombstones.grow_to(next_dense)?; + charge.absorb(tombstones_charge); + Ok((Self { keys, tombstones }, charge)) + } + + /// Walks these keys and the sorted, distinct `ids` in key order, into + /// `keys`: a key only in these is tombstoned, a key in both is live, and + /// a key only in `ids` is fresh, at the next dense id. Returns the new + /// id space. + fn merge( + &self, + ids: &K::Ids, + keys: &mut K::Rebuild, + tombstones: &mut Tombstones, + ) -> Result { + let mut next_dense = self.id_space(); + let mut previous = self.keys.in_order().peekable(); + let mut current = ids.keys().peekable(); + loop { + let order = match (previous.peek(), current.peek()) { + (None, None) => return Ok(next_dense), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (Some((old, _)), Some(new)) => (*old).cmp(*new), + }; + match order { + Ordering::Less => { + if let Some((key, dense)) = previous.next() { + tombstones.insert(dense)?; + keys.keep(key, dense)?; + } + } + Ordering::Equal => { + if let Some((key, dense)) = previous.next() { + tombstones.remove(dense)?; + keys.keep(key, dense)?; + } + current.next(); + } + Ordering::Greater => { + if let Some(new) = current.next() { + keys.add(new, next_dense)?; + next_dense += 1; + } + } + } + } + } + + /// How many of the sorted, distinct `ids` this map does not hold. + fn fresh(&self, ids: &K::Ids) -> usize { + let mut known = self.keys.in_order().map(|(key, _)| key).peekable(); + ids.keys() + .filter(|&external| { + while known.next_if(|&known| known < external).is_some() {} + known.peek() != Some(&external) + }) + .count() + } + + /// Renumbers every dense id, tombstones included, by `permutation`, + /// which must permute this id map's id space; else changes nothing. + /// The renumbered tombstones, if there are any, are charged to `memory` + /// while they are built. A shared id map's copy is not charged: only a + /// first build renumbers, and its id map is one it built and owns. + pub fn renumber( + &mut self, + permutation: &Permutation, + memory: &BuildMemory, + ) -> Result<(), GraphError> { + if permutation.id_space() != self.id_space() { + return Err(GraphError::IdSpaceMismatch { + expected: self.id_space(), + found: permutation.id_space(), + }); + } + let id_space = self.id_space(); + let tombstones = if self.tombstones.count() > 0 { + // Built beside the old bitmap, which it then replaces. + let _charge = memory.charge(Tombstones::bytes_for(id_space))?; + let mut tombstones = Tombstones::new(id_space)?; + for old in (0..id_space).filter(|&old| self.tombstones.contains(old)) { + tombstones.insert(permutation.new_dense_id(old))?; + } + Some(tombstones) + } else { + None + }; + self.keys.renumber(permutation)?; + if let Some(tombstones) = tombstones { + self.tombstones = tombstones; + } + Ok(()) + } + + /// The id map of the live nodes at `old_dense_ids`, which are in + /// external-id order, given dense ids `0..old_dense_ids.len()` in that + /// order and no tombstones. + pub(crate) fn compacted(&self, old_dense_ids: &[DenseId]) -> Result { + let keys = self.keys.compacted(old_dense_ids)?; + Ok(Self { keys, tombstones: Tombstones::new(old_dense_ids.len() as u64)? }) + } + + /// The keys of this id map, tombstoned nodes' included. + pub fn keys(&self) -> &K { + &self.keys + } + + /// The dense id of a live node, by binary search. + pub fn dense_id(&self, external: &K::Key) -> Option { + let dense = self.keys.dense_id(external)?; + (!self.tombstones.contains(dense)).then_some(dense) + } + + /// The external id that `dense` was assigned to, tombstoned or not. + pub fn external_id(&self, dense: DenseId) -> Option<&K::Key> { + self.keys.key(dense) + } + + /// The high-water mark of dense ids, tombstones included. + pub fn id_space(&self) -> u64 { + self.keys.id_space() + } + + /// The number of live nodes. + pub fn node_count(&self) -> u64 { + self.id_space() - self.tombstones.count() + } + + /// The tombstones of this id map. + pub fn tombstones(&self) -> &Tombstones { + &self.tombstones + } + + /// The heap bytes it holds: both directions of the map and the + /// tombstones. + pub fn bytes(&self) -> u64 { + self.keys.bytes() + self.tombstones.bytes() + } +} + +/// Checks that the tombstones cover an id space of `id_space`. +fn check_tombstones(id_space: u64, tombstones: &Tombstones) -> Result<(), GraphError> { + if tombstones.id_space() != id_space { + return Err(GraphError::TombstonesLength { + expected: id_space, + found: tombstones.id_space(), + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/warren-core/src/graph/id_map/tests.rs b/crates/warren-core/src/graph/id_map/tests.rs new file mode 100644 index 00000000..7c72454f --- /dev/null +++ b/crates/warren-core/src/graph/id_map/tests.rs @@ -0,0 +1,268 @@ +use super::*; +use crate::graph::{BuildBuffer, HeapSegment, SegmentBuffer}; +use proptest::prelude::*; +use std::collections::{BTreeMap, BTreeSet}; +use std::mem::{offset_of, size_of}; + +fn first_build(externals: Vec) -> IdMap> { + IdMap::new().rebuild(externals).unwrap() +} + +#[test] +fn the_first_build_assigns_dense_ids_in_external_id_sort_order() { + let id_map = first_build(vec![30, 10, 20, 10]); + assert_eq!(id_map.id_space(), 3); + assert_eq!(id_map.node_count(), 3); + assert_eq!(id_map.dense_id(&10), Some(0)); + assert_eq!(id_map.dense_id(&20), Some(1)); + assert_eq!(id_map.dense_id(&30), Some(2)); + assert_eq!(id_map.external_id(2), Some(&30)); +} + +#[test] +fn an_id_map_holds_both_directions_and_its_tombstones() { + let id_map = first_build(vec![30, 10, 20]); + let pair = size_of::>() as u64; + assert_eq!(id_map.bytes(), 3 * pair + 3 * 8 + id_map.tombstones().bytes()); + assert_eq!(IdMap::>::new().bytes(), 0); +} + +/// A rebuild reserves exactly what the result holds: with a stable +/// node set, its peak is the externals and the result, not a second +/// copy of the base and a fresh-id buffer for every external. +#[test] +fn a_rebuild_charges_no_more_than_its_externals_and_its_result() { + let nodes = 10_000i64; + let base = first_build((0..nodes).collect()); + for externals in [(0..nodes).collect::>(), (nodes / 2..nodes * 3 / 2).collect()] { + let memory = BuildMemory::unlimited(); + let externals = BuildBuffer::from_vec(&memory, externals); + let held = externals.bytes(); + let (rebuilt, charge) = base.rebuild_within(externals).unwrap(); + assert_eq!(charge.bytes(), rebuilt.bytes()); + assert!( + memory.peak() <= held + rebuilt.bytes(), + "{} > {held} + {}", + memory.peak(), + rebuilt.bytes() + ); + } +} + +/// The locality order of the path 0 - 2 - 1 (`new = [2, 0, 1]`), over +/// an id space of 3. +fn path_permutation() -> Permutation { + let relation = crate::graph::RelationDictionary::new().intern("").unwrap(); + let mut edges = crate::graph::EdgeList::for_id_space(3, false); + edges.push(relation, 0, 2, 1.0).unwrap(); + edges.push(relation, 2, 1, 1.0).unwrap(); + Permutation::locality(&edges, 3, &BuildMemory::unlimited()).unwrap() +} + +#[test] +fn renumbering_moves_both_directions_and_the_tombstones() { + // 10 → 0, 20 → 1 (tombstoned), 30 → 2. + let mut id_map = first_build(vec![10, 20, 30]).rebuild(vec![10, 30]).unwrap(); + id_map.renumber(&path_permutation(), &BuildMemory::unlimited()).unwrap(); + assert_eq!(id_map.dense_id(&10), Some(2)); + assert_eq!(id_map.dense_id(&30), Some(1)); + assert_eq!(id_map.dense_id(&20), None); + assert_eq!( + (0..3).map(|dense| *id_map.external_id(dense).unwrap()).collect::>(), + [20, 30, 10] + ); + assert!(id_map.tombstones().contains(0)); + assert_eq!(id_map.tombstones().count(), 1); + assert_eq!((id_map.id_space(), id_map.node_count()), (3, 2)); + assert!(id_map.pairs().windows(2).all(|pair| pair[0].external < pair[1].external)); +} + +/// The renumbered tombstones are a new bitmap, charged while it is +/// built beside the old one; without tombstones nothing is allocated. +#[test] +fn renumbering_charges_the_new_tombstones() { + let tombstoned = first_build(vec![10, 20, 30]).rebuild(vec![10, 30]).unwrap(); + let none = BuildMemory::new(0); + let mut id_map = tombstoned.clone(); + assert!(matches!( + id_map.renumber(&path_permutation(), &none), + Err(GraphError::BuildMemoryLimit { .. }) + )); + assert_eq!(id_map, tombstoned, "nothing is renumbered"); + let memory = BuildMemory::unlimited(); + id_map.renumber(&path_permutation(), &memory).unwrap(); + assert_eq!(memory.peak(), Tombstones::bytes_for(3)); + assert_eq!(memory.in_use(), 0); + + let mut live = first_build(vec![10, 20, 30]); + live.renumber(&path_permutation(), &none).unwrap(); + assert_eq!(live.dense_id(&10), Some(2)); +} + +#[test] +fn renumbering_refuses_a_permutation_of_another_id_space() { + let mut id_map = first_build(vec![10, 20]); + assert_eq!( + id_map.renumber(&path_permutation(), &BuildMemory::unlimited()), + Err(GraphError::IdSpaceMismatch { expected: 2, found: 3 }) + ); + assert_eq!(id_map, first_build(vec![10, 20])); +} + +/// The pairs and the inverse map as shared views of heap segments. +fn shared_parts( + id_map: &IdMap>, +) -> (SegmentBuffer>, SegmentBuffer) { + let externals: Vec = + (0..id_map.id_space()).filter_map(|dense| id_map.external_id(dense)).copied().collect(); + let pairs = SegmentBuffer::shared(HeapSegment::of(id_map.pairs())); + (pairs, SegmentBuffer::shared(HeapSegment::of(&externals))) +} + +#[test] +fn an_id_map_of_shared_views_reads_as_the_owned_one() { + let owned = first_build(vec![10, 20, 30]).rebuild(vec![10, 30, 5]).unwrap(); + let (pairs, by_dense) = shared_parts(&owned); + let shared = IdMap::from_segments(pairs, by_dense, owned.tombstones().clone()).unwrap(); + assert_eq!(shared, owned); + assert_eq!(shared.dense_id(&5), Some(3)); + assert_eq!(shared.dense_id(&20), None); + assert_eq!(shared.external_id(1), Some(&20)); + assert_eq!((shared.id_space(), shared.node_count()), (4, 3)); + assert_eq!(shared.bytes(), owned.bytes()); +} + +#[test] +fn shared_views_of_an_id_map_are_validated() { + let pair = |external, dense| IdPair { external, dense }; + let pairs = |pairs: &[IdPair]| SegmentBuffer::shared(HeapSegment::of(pairs)); + let externals = |externals: &[i64]| SegmentBuffer::shared(HeapSegment::of(externals)); + let check = + |p, e, id_space| IdMap::from_segments(p, e, Tombstones::new(id_space).unwrap()).map(|_| ()); + let sorted = [pair(10, 1), pair(20, 0)]; + assert_eq!(check(pairs(&sorted), externals(&[20, 10]), 2), Ok(())); + assert_eq!( + check(pairs(&[pair(20, 0), pair(10, 1)]), externals(&[20, 10]), 2), + Err(GraphError::IdMapNotSorted) + ); + assert_eq!( + check(pairs(&sorted), externals(&[10, 20]), 2), + Err(GraphError::IdMapNotPermutation) + ); + assert_eq!( + check(pairs(&[pair(10, 2), pair(20, 0)]), externals(&[20, 10]), 2), + Err(GraphError::IdMapNotPermutation) + ); + assert_eq!( + check(pairs(&sorted), externals(&[20]), 2), + Err(GraphError::SegmentLength { expected: 2, found: 1 }) + ); + assert_eq!( + check(pairs(&sorted), externals(&[20, 10]), 3), + Err(GraphError::TombstonesLength { expected: 2, found: 3 }) + ); +} + +#[test] +fn renumbering_a_shared_id_map_copies_it() { + let owned = first_build(vec![10, 20, 30]).rebuild(vec![10, 30]).unwrap(); + let (pairs, by_dense) = shared_parts(&owned); + let region = owned.pairs().to_vec(); + let mut shared = IdMap::from_segments(pairs, by_dense, owned.tombstones().clone()).unwrap(); + let mut expected = owned.clone(); + let memory = BuildMemory::unlimited(); + shared.renumber(&path_permutation(), &memory).unwrap(); + expected.renumber(&path_permutation(), &memory).unwrap(); + assert_eq!(shared, expected); + assert_eq!(owned.pairs(), region, "the shared pairs are unchanged"); +} + +#[test] +fn a_pair_has_a_fixed_layout() { + assert_eq!((size_of::>(), offset_of!(IdPair, dense)), (16, 8)); + assert_eq!((size_of::>(), offset_of!(IdPair, dense)), (16, 8)); + assert_eq!((size_of::>(), offset_of!(IdPair<[u8; 16]>, dense)), (24, 16)); +} + +#[test] +fn unknown_ids_have_no_mapping() { + let id_map = first_build(vec![1, 2]); + assert_eq!(id_map.dense_id(&3), None); + assert_eq!(id_map.external_id(2), None); +} + +#[test] +fn a_rebuild_keeps_survivors_appends_new_ids_and_tombstones_the_missing() { + let previous = first_build(vec![10, 20, 30]); + let id_map = previous.rebuild(vec![40, 5, 30, 10]).unwrap(); + assert_eq!(id_map.dense_id(&10), Some(0)); + assert_eq!(id_map.dense_id(&30), Some(2)); + assert_eq!(id_map.dense_id(&5), Some(3)); + assert_eq!(id_map.dense_id(&40), Some(4)); + assert_eq!(id_map.dense_id(&20), None); + assert_eq!(id_map.external_id(1), Some(&20)); + assert!(id_map.tombstones().contains(1)); + assert_eq!(id_map.id_space(), 5); + assert_eq!(id_map.node_count(), 4); +} + +#[test] +fn a_resurrected_id_gets_its_old_dense_id_back() { + let id_map = + first_build(vec![10, 20]).rebuild(vec![10]).unwrap().rebuild(vec![10, 20]).unwrap(); + assert_eq!(id_map.dense_id(&20), Some(1)); + assert!(!id_map.tombstones().contains(1)); + assert_eq!(id_map.id_space(), 2); +} + +#[test] +fn uuids_sort_bytewise() { + let low = [0u8; 16]; + let mut high = [0u8; 16]; + high[0] = 0xff; + let id_map = IdMap::new().rebuild(vec![high, low]).unwrap(); + assert_eq!(id_map.dense_id(&low), Some(0)); + assert_eq!(id_map.dense_id(&high), Some(1)); +} + +fn builds() -> impl Strategy>> { + prop::collection::vec(prop::collection::btree_set(0i32..40, 0..30), 1..8) +} + +proptest! { + #[test] + fn dense_ids_are_stable_across_a_sequence_of_builds(builds in builds()) { + let mut id_map = IdMap::>::new(); + let mut ever: BTreeMap = BTreeMap::new(); + for live in builds { + let mut input: Vec = live.iter().copied().collect(); + input.extend(live.iter().take(3)); // duplicates + input.reverse(); + let previous_id_space = id_map.id_space(); + id_map = id_map.rebuild(input).unwrap(); + + prop_assert!(id_map.id_space() >= previous_id_space); + prop_assert_eq!(id_map.node_count(), live.len() as u64); + prop_assert_eq!(id_map.tombstones().count(), id_map.id_space() - live.len() as u64); + let mut fresh = Vec::new(); + for external in &live { + let dense = id_map.dense_id(external); + prop_assert!(dense.is_some()); + match ever.get(external) { + Some(&kept) => prop_assert_eq!(dense, Some(kept)), + None => fresh.push((*external, dense.unwrap_or_default())), + } + } + // New ids are appended at the high-water mark in sort order. + for (offset, (external, dense)) in fresh.iter().enumerate() { + prop_assert_eq!(*dense, previous_id_space + offset as u64); + ever.insert(*external, *dense); + } + prop_assert_eq!(id_map.id_space(), ever.len() as u64); + for (external, dense) in &ever { + prop_assert_eq!(id_map.external_id(*dense), Some(external)); + prop_assert_eq!(id_map.tombstones().contains(*dense), !live.contains(external)); + } + } + } +} diff --git a/crates/warren-core/src/graph/mod.rs b/crates/warren-core/src/graph/mod.rs index 84a02b2f..21241d2d 100644 --- a/crates/warren-core/src/graph/mod.rs +++ b/crates/warren-core/src/graph/mod.rs @@ -33,7 +33,9 @@ pub(crate) use csr::{AnyCsrSlices, CsrSlices}; pub use direction::Direction; pub use edge_list::{EdgeIntake, EdgeList}; pub use error::GraphError; -pub use id_map::{ExternalId, IdMap, IdPair}; +pub use id_map::{ + ExternalId, FixedKeys, FixedRebuild, IdKeys, IdMap, IdPair, KeysRebuild, NodeIds, +}; pub use membership::Membership; pub(crate) use membership::NO_COMMUNITY; pub use node_order::{NodeOrder, Permutation}; diff --git a/crates/warren-core/src/graph/segment/id_map.rs b/crates/warren-core/src/graph/segment/id_map.rs index 3de8dbbf..c4e22bb1 100644 --- a/crates/warren-core/src/graph/segment/id_map.rs +++ b/crates/warren-core/src/graph/segment/id_map.rs @@ -3,9 +3,11 @@ use std::mem::size_of; use super::{decode, encode, encode_chunked, ChunkSink, Element, SegmentKind, SEGMENT_HEADER_LEN}; -use crate::graph::{BuildCharge, BuildMemory, ExternalId, GraphError, IdMap, IdPair, Tombstones}; +use crate::graph::{ + BuildCharge, BuildMemory, ExternalId, FixedKeys, GraphError, IdMap, IdPair, Tombstones, +}; -impl IdMap { +impl IdMap> { /// Encodes the (external id, dense id) pairs as the `id_map` segment. /// The tombstones are a segment of their own. pub fn encode(&self) -> Result, GraphError> { diff --git a/crates/warren-core/src/graph/segment/mod.rs b/crates/warren-core/src/graph/segment/mod.rs index 9b066962..857df927 100644 --- a/crates/warren-core/src/graph/segment/mod.rs +++ b/crates/warren-core/src/graph/segment/mod.rs @@ -246,7 +246,9 @@ pub(crate) fn decode(kind: SegmentKind, bytes: &[u8]) -> Result AnyCsr { @@ -384,9 +386,12 @@ mod tests { let id_map = IdMap::new().rebuild(vec![1i64, 2]).unwrap().rebuild(vec![2]).unwrap(); let bytes = id_map.encode().unwrap(); let tombstones = id_map.tombstones().clone(); - assert_eq!(IdMap::::decode(&bytes, tombstones.clone()).as_ref(), Ok(&id_map)); assert_eq!( - IdMap::::decode(&bytes, Tombstones::new(3).unwrap()), + IdMap::>::decode(&bytes, tombstones.clone()).as_ref(), + Ok(&id_map) + ); + assert_eq!( + IdMap::>::decode(&bytes, Tombstones::new(3).unwrap()), Err(GraphError::TombstonesLength { expected: 2, found: 3 }) ); let pair = @@ -398,15 +403,15 @@ mod tests { bytes }; assert_eq!( - IdMap::::decode(&encode(&[pair(2, 0), pair(1, 1)]), tombstones.clone()), + IdMap::>::decode(&encode(&[pair(2, 0), pair(1, 1)]), tombstones.clone()), Err(GraphError::IdMapNotSorted) ); assert_eq!( - IdMap::::decode(&encode(&[pair(1, 1), pair(2, 1)]), tombstones), + IdMap::>::decode(&encode(&[pair(1, 1), pair(2, 1)]), tombstones), Err(GraphError::IdMapNotPermutation) ); assert!(matches!( - IdMap::::decode(&bytes, Tombstones::new(2).unwrap()), + IdMap::>::decode(&bytes, Tombstones::new(2).unwrap()), Err(GraphError::SegmentWidth { expected: 12, found: 16 }) )); } @@ -484,7 +489,7 @@ mod tests { id_map = id_map.rebuild(externals).unwrap(); } let tombstones = Tombstones::decode(&id_map.tombstones().encode().unwrap()).unwrap(); - let decoded = IdMap::<[u8; 16]>::decode(&id_map.encode().unwrap(), tombstones); + let decoded = IdMap::>::decode(&id_map.encode().unwrap(), tombstones); prop_assert_eq!(decoded.as_ref(), Ok(&id_map)); } @@ -506,9 +511,9 @@ mod tests { let _ = AnyCsr::decode(id_space, input, input, Some(input)); let _ = AnyCsr::decode(id_space, csr_example().encode().unwrap().offsets(), input, None); let tombstones = Tombstones::decode(input); - let _ = IdMap::::decode(input, tombstones.clone().unwrap_or_default()); - let _ = IdMap::::decode(input, tombstones.clone().unwrap_or_default()); - let _ = IdMap::<[u8; 16]>::decode(input, tombstones.unwrap_or_default()); + let _ = IdMap::>::decode(input, tombstones.clone().unwrap_or_default()); + let _ = IdMap::>::decode(input, tombstones.clone().unwrap_or_default()); + let _ = IdMap::>::decode(input, tombstones.unwrap_or_default()); let _ = RelationDictionary::decode(input); } } diff --git a/crates/warren-core/src/spectral/distance/graphs.rs b/crates/warren-core/src/spectral/distance/graphs.rs index 08da2a5f..ac471397 100644 --- a/crates/warren-core/src/spectral/distance/graphs.rs +++ b/crates/warren-core/src/spectral/distance/graphs.rs @@ -9,7 +9,7 @@ use super::super::{Resources, SpectralError}; use crate::compute::{ComputeError, MemoryCharge}; use crate::graph::{ - CsrLayout, DenseId, EdgeList, ExternalId, GraphCsr, IdPair, RelationDictionary, RelationIndex, + CsrLayout, DenseId, EdgeList, GraphCsr, IdKeys, RelationDictionary, RelationIndex, }; use crate::interrupts::Interrupts; use crate::version::GraphVersion; @@ -41,18 +41,18 @@ pub struct AlignedGraph { } /// How `other` maps into `this`'s id space by external id. -pub fn align_ids( +pub fn align_ids( resources: &mut Resources<'_, I>, - this: &GraphVersion<'_, E>, - other: &GraphVersion<'_, E>, + this: &GraphVersion<'_, K>, + other: &GraphVersion<'_, K>, ) -> Result { let (mut dense_ids, charge) = resources.memory.filled(other.id_map().id_space() as usize, 0)?; let mut id_space = this.id_map().id_space(); - for (row, IdPair { external, dense }) in other.id_map().pairs().iter().enumerate() { + for (row, (external, dense)) in other.id_map().keys().in_order().enumerate() { if (row as u64).is_multiple_of(ROWS_PER_POLL) { resources.interrupts.check().map_err(ComputeError::from)?; } - dense_ids[*dense as usize] = this.id_map().dense_id(external).unwrap_or_else(|| { + dense_ids[dense as usize] = this.id_map().dense_id(external).unwrap_or_else(|| { id_space += 1; id_space - 1 }); @@ -71,9 +71,9 @@ pub fn align_ids( /// for `other`), charged to the budget (the edge list it is rebuilt from, /// the new CSRs and the build's temporaries) and polling the interrupts /// every 64K rows. -pub fn align_graph( +pub fn align_graph( resources: &mut Resources<'_, I>, - other: &GraphVersion<'_, E>, + other: &GraphVersion<'_, K>, alignment: &IdAlignment, ) -> Result { if alignment.dense_ids.len() as u64 != other.id_map().id_space() { diff --git a/crates/warren-core/src/version/compaction.rs b/crates/warren-core/src/version/compaction.rs index b855a431..60e7e78a 100644 --- a/crates/warren-core/src/version/compaction.rs +++ b/crates/warren-core/src/version/compaction.rs @@ -3,8 +3,8 @@ use super::{GraphVersion, VersionError}; use crate::graph::{ - memory, BuildMemory, DenseId, EdgeList, ExternalId, GraphCsr, GraphError, IdMap, IdPair, - NodeOrder, Permutation, Tombstones, + memory, BuildMemory, DenseId, EdgeList, GraphCsr, GraphError, IdKeys, IdMap, NodeOrder, + Permutation, }; /// The old → new dense-id mapping of a compaction. @@ -57,10 +57,10 @@ impl Renumbering { /// A compacted version: a new id space with no tombstones. #[derive(Debug, Clone, PartialEq)] -pub struct Compaction { +pub struct Compaction { /// Dense ids `0..node_count` in the compaction's node order, no /// tombstones. - pub id_map: IdMap, + pub id_map: IdMap, /// The version's CSRs in the new dense ids. Its relation dictionary is /// unchanged. pub graph: GraphCsr, @@ -78,11 +78,11 @@ pub struct Compaction { /// old version, its edge list in new ids and the new version, plus, for /// [`NodeOrder::Locality`], the order's scratch and permutation, which are /// charged to `memory`. -pub fn compact( - version: &GraphVersion<'_, E>, +pub fn compact( + version: &GraphVersion<'_, K>, order: NodeOrder, memory: &BuildMemory, -) -> Result, VersionError> { +) -> Result, VersionError> { let (mut id_map, mut renumbering) = renumbered(version.id_map())?; let mut edges = edge_list(version, &renumbering)?; if order == NodeOrder::Locality { @@ -100,27 +100,24 @@ pub fn compact( /// The compacted id map and the renumbering, from one walk over the id map /// in external-id order. -fn renumbered(id_map: &IdMap) -> Result<(IdMap, Renumbering), GraphError> { +fn renumbered(id_map: &IdMap) -> Result<(IdMap, Renumbering), GraphError> { let live = (id_map.node_count()) as usize; let mut new_by_old = memory::filled(id_map.id_space() as usize, None)?; let mut old_by_new = memory::with_capacity(live)?; - let mut pairs = memory::with_capacity(live)?; - for &IdPair { external, dense: old } in id_map.pairs() { + for (_, old) in id_map.keys().in_order() { if id_map.tombstones().contains(old) { continue; } - let new = old_by_new.len() as DenseId; - new_by_old[old as usize] = Some(new); + new_by_old[old as usize] = Some(old_by_new.len() as DenseId); old_by_new.push(old); - pairs.push(IdPair { external, dense: new }); } - let id_map = IdMap::from_parts(pairs, Tombstones::new(live as u64)?)?; + let id_map = id_map.compacted(&old_by_new)?; Ok((id_map, Renumbering { new_by_old, old_by_new })) } /// The version's per-relation forward edges in new ids. -fn edge_list( - version: &GraphVersion<'_, E>, +fn edge_list( + version: &GraphVersion<'_, K>, renumbering: &Renumbering, ) -> Result { let graph = version.graph(); @@ -304,6 +301,7 @@ mod tests { let live: Vec = (0..old.id_space()) .filter(|&dense| !old.tombstones().contains(dense)) .filter_map(|dense| old.external_id(dense)) + .copied() .collect(); let mut sorted = live.clone(); sorted.sort_unstable(); @@ -321,8 +319,8 @@ mod tests { assigned.sort_unstable(); prop_assert_eq!(assigned, (0..old.node_count()).collect::>()); - let old_external = |dense| old.external_id(dense).unwrap(); - let new_external = |dense| new.external_id(dense).unwrap(); + let old_external = |dense| *old.external_id(dense).unwrap(); + let new_external = |dense| *new.external_id(dense).unwrap(); let pairs = build.graph.relations().iter().zip(compacted.graph.relations()) .chain(build.graph.merged().zip(compacted.graph.merged())); prop_assert_eq!(compacted.graph.relations().len(), build.graph.relations().len()); diff --git a/crates/warren-core/src/version/dense_ids.rs b/crates/warren-core/src/version/dense_ids.rs index ea25c489..7e0df4e1 100644 --- a/crates/warren-core/src/version/dense_ids.rs +++ b/crates/warren-core/src/version/dense_ids.rs @@ -5,11 +5,11 @@ //! different nodes. Relations play no part. use super::VersionError; -use crate::graph::{ExternalId, IdMap}; +use crate::graph::{IdKeys, IdMap}; /// `Ok` if every dense id of both `a` and `b` names the same external id /// in each; else the first that does not. -pub fn shared_dense_ids(a: &IdMap, b: &IdMap) -> Result<(), VersionError> { +pub fn shared_dense_ids(a: &IdMap, b: &IdMap) -> Result<(), VersionError> { let shared = a.id_space().min(b.id_space()); match (0..shared).find(|&dense| a.external_id(dense) != b.external_id(dense)) { Some(dense) => Err(VersionError::IdSpacesDiverge { dense }), diff --git a/crates/warren-core/src/version/diff/degree.rs b/crates/warren-core/src/version/diff/degree.rs index dbf65ebf..d352dc2d 100644 --- a/crates/warren-core/src/version/diff/degree.rs +++ b/crates/warren-core/src/version/diff/degree.rs @@ -3,7 +3,7 @@ //! statistic between them. use super::nodes::is_live; -use crate::graph::{memory, CsrRows, ExternalId, GraphError, IdMap}; +use crate::graph::{memory, CsrRows, GraphError, IdKeys, IdMap}; /// How many live nodes have one degree, before and after. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -31,8 +31,8 @@ pub struct DegreeShift { } /// Live nodes per degree (index) of the out-degrees in `csr`. -pub(super) fn out_degree_counts( - id_map: &IdMap, +pub(super) fn out_degree_counts( + id_map: &IdMap, csr: CsrRows<'_>, ) -> Result, GraphError> { counts_by_degree(id_map, |dense| csr.degree(dense)) @@ -40,8 +40,8 @@ pub(super) fn out_degree_counts( /// Live nodes per degree (index) of the in-degrees of `csr`, counted from /// its targets so that no reverse CSR is needed. -pub(super) fn in_degree_counts( - id_map: &IdMap, +pub(super) fn in_degree_counts( + id_map: &IdMap, csr: CsrRows<'_>, ) -> Result, GraphError> { let mut in_degrees = memory::filled(csr.id_space() as usize, 0u64)?; @@ -54,8 +54,8 @@ pub(super) fn in_degree_counts( } /// Live nodes per degree, indexed by degree, for a degree function. -fn counts_by_degree( - id_map: &IdMap, +fn counts_by_degree( + id_map: &IdMap, degree: impl Fn(u64) -> u64, ) -> Result, GraphError> { let live = || (0..id_map.id_space()).filter(|&dense| is_live(id_map, dense)); diff --git a/crates/warren-core/src/version/diff/mod.rs b/crates/warren-core/src/version/diff/mod.rs index 8d525650..7f6f4231 100644 --- a/crates/warren-core/src/version/diff/mod.rs +++ b/crates/warren-core/src/version/diff/mod.rs @@ -17,7 +17,7 @@ use edges::edge_delta; use nodes::node_delta; use super::{shared_dense_ids, GraphVersion, VersionError}; -use crate::graph::ExternalId; +use crate::graph::IdKeys; /// The structural delta from an earlier version of a graph to a later one. #[derive(Debug, Clone, PartialEq)] @@ -40,9 +40,9 @@ pub struct VersionDiff { /// the same external id to each of `before`'s dense ids. That fails across /// a compaction boundary (DESIGN.md §5.2) unless the compaction renumbered /// nothing. -pub fn diff( - before: &GraphVersion<'_, E>, - after: &GraphVersion<'_, E>, +pub fn diff( + before: &GraphVersion<'_, K>, + after: &GraphVersion<'_, K>, ) -> Result { let (earlier, later) = (before.id_map(), after.id_map()); if later.id_space() < earlier.id_space() { diff --git a/crates/warren-core/src/version/diff/nodes.rs b/crates/warren-core/src/version/diff/nodes.rs index e1621160..4f7d911d 100644 --- a/crates/warren-core/src/version/diff/nodes.rs +++ b/crates/warren-core/src/version/diff/nodes.rs @@ -1,7 +1,7 @@ //! The node delta of a diff: live nodes added and removed, from the two //! versions' tombstones and id spaces. -use crate::graph::{DenseId, ExternalId, IdMap}; +use crate::graph::{DenseId, IdKeys, IdMap}; /// Live nodes before and after, and how many were added and removed. A node /// whose tombstone was cleared counts as added. @@ -18,13 +18,13 @@ pub struct NodeDelta { } /// Whether `dense` is a live node of `id_map`. -pub(super) fn is_live(id_map: &IdMap, dense: DenseId) -> bool { +pub(super) fn is_live(id_map: &IdMap, dense: DenseId) -> bool { dense < id_map.id_space() && !id_map.tombstones().contains(dense) } /// The node delta between two id maps of the same id space, `after`'s /// covering `before`'s. O(id space). -pub(super) fn node_delta(before: &IdMap, after: &IdMap) -> NodeDelta { +pub(super) fn node_delta(before: &IdMap, after: &IdMap) -> NodeDelta { let mut delta = NodeDelta { before: before.node_count(), after: after.node_count(), diff --git a/crates/warren-core/src/version/history.rs b/crates/warren-core/src/version/history.rs index 93b3e869..57a8bbb0 100644 --- a/crates/warren-core/src/version/history.rs +++ b/crates/warren-core/src/version/history.rs @@ -4,7 +4,9 @@ use proptest::prelude::*; use super::GraphVersion; -use crate::graph::{CsrLayout, EdgeIntake, EdgeList, GraphCsr, IdMap, RelationDictionary}; +use crate::graph::{ + CsrLayout, EdgeIntake, EdgeList, FixedKeys, GraphCsr, IdMap, RelationDictionary, +}; /// One edge row: (relation name index, source, target, weight). pub(crate) type EdgeRow = (u8, i64, i64, u8); @@ -12,7 +14,7 @@ pub(crate) type EdgeRow = (u8, i64, i64, u8); /// The structures of one built version. #[derive(Debug, Clone)] pub(crate) struct Build { - pub(crate) id_map: IdMap, + pub(crate) id_map: IdMap>, pub(crate) relations: RelationDictionary, pub(crate) graph: GraphCsr, } @@ -77,7 +79,7 @@ impl Build { } /// This build as a version view. - pub(crate) fn version(&self) -> GraphVersion<'_, i64> { + pub(crate) fn version(&self) -> GraphVersion<'_, FixedKeys> { GraphVersion::new(&self.id_map, &self.relations, &self.graph).unwrap() } } diff --git a/crates/warren-core/src/version/mod.rs b/crates/warren-core/src/version/mod.rs index c2bdec62..774ac931 100644 --- a/crates/warren-core/src/version/mod.rs +++ b/crates/warren-core/src/version/mod.rs @@ -23,22 +23,32 @@ pub use diff::{ }; pub use error::VersionError; -use crate::graph::{ExternalId, GraphCsr, IdMap, RelationDictionary}; +use crate::graph::{GraphCsr, IdKeys, IdMap, RelationDictionary}; /// A borrowed view of one graph version: its id map, relation dictionary and /// CSRs, checked to agree with each other. -#[derive(Debug, Clone, Copy)] -pub struct GraphVersion<'v, E> { - id_map: &'v IdMap, +#[derive(Debug)] +pub struct GraphVersion<'v, K> { + id_map: &'v IdMap, relations: &'v RelationDictionary, graph: &'v GraphCsr, } -impl<'v, E: ExternalId> GraphVersion<'v, E> { +// Not derived: a derive would require `K: Clone`, which the view does not +// need. +impl Clone for GraphVersion<'_, K> { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for GraphVersion<'_, K> {} + +impl<'v, K: IdKeys> GraphVersion<'v, K> { /// A view of one version, after checking that the CSRs cover the id /// map's id space and hold one relation per dictionary entry. pub fn new( - id_map: &'v IdMap, + id_map: &'v IdMap, relations: &'v RelationDictionary, graph: &'v GraphCsr, ) -> Result { @@ -56,7 +66,7 @@ impl<'v, E: ExternalId> GraphVersion<'v, E> { } /// The id map of the version. - pub fn id_map(&self) -> &'v IdMap { + pub fn id_map(&self) -> &'v IdMap { self.id_map } diff --git a/crates/warren-core/tests/node_order.rs b/crates/warren-core/tests/node_order.rs index 22f89405..9cd07e15 100644 --- a/crates/warren-core/tests/node_order.rs +++ b/crates/warren-core/tests/node_order.rs @@ -15,8 +15,8 @@ mod locality { spmv, AnyVector, ComputeMemory, Product, Semiring, Vector, Workers, }; use warren_core::graph::{ - AnyCsr, BuildMemory, CsrLayout, DenseId, Direction, EdgeIntake, GraphCsr, IdMap, NodeOrder, - Permutation, RelationDictionary, RelationSelection, + AnyCsr, BuildMemory, CsrLayout, DenseId, Direction, EdgeIntake, FixedKeys, GraphCsr, IdMap, + NodeOrder, Permutation, RelationDictionary, RelationSelection, }; use warren_core::interrupts::Uninterrupted; use warren_core::traversal::{bfs, components, Adjacency, Limits}; @@ -26,7 +26,7 @@ mod locality { /// A graph built from external ids in one node order. struct Built { - id_map: IdMap, + id_map: IdMap>, graph: GraphCsr, } @@ -63,7 +63,7 @@ mod locality { impl Built { fn external(&self, dense: DenseId) -> i64 { - self.id_map.external_id(dense).unwrap() + *self.id_map.external_id(dense).unwrap() } fn dense(&self, external: i64) -> DenseId { diff --git a/fuzz/fuzz_targets/decode_id_map.rs b/fuzz/fuzz_targets/decode_id_map.rs index 09fd6956..20ef6bf9 100644 --- a/fuzz/fuzz_targets/decode_id_map.rs +++ b/fuzz/fuzz_targets/decode_id_map.rs @@ -6,12 +6,12 @@ #![no_main] use libfuzzer_sys::fuzz_target; -use warren_core::graph::{ExternalId, IdMap, Tombstones}; +use warren_core::graph::{ExternalId, FixedKeys, IdMap, Tombstones}; fn check(id_map: &[u8], tombstones: &Tombstones) { - if let Ok(decoded) = IdMap::::decode(id_map, tombstones.clone()) { + if let Ok(decoded) = IdMap::>::decode(id_map, tombstones.clone()) { let bytes = decoded.encode().expect("a decoded id map re-encodes"); - assert_eq!(IdMap::::decode(&bytes, tombstones.clone()), Ok(decoded)); + assert_eq!(IdMap::>::decode(&bytes, tombstones.clone()), Ok(decoded)); } }