162 Implementing the Genealogy Example
The genealogy example in the Event Sourcing chapter contains a graph of people and events. A marriage event can occur under both spouses, an event refers back to its spouses, and people refer to parents and children.
This chapter applies the persistent object store described in the preceding chapter. Persistent entity references are encoded as OIDs. The POS maps each OID to an opaque byte sequence, and genealogy deserialisation functions reconstruct the corresponding in-memory object. The representation therefore does not require compiler-placed ownership arenas or serialised memory pointers.
Representation
The following structures illustrate one possible lowering of the logical schema. They describe serialised records rather than a required C++ ABI:
using OID = uint64_t;
struct GenealogySystemRecord
{
// Person.id is the logical lookup key; the OID names its serialised record.
btree<PersonId, OID> person;
};
struct PersonRecord
{
PersonId id;
string name;
Gender gender;
optional<BirthEventRecord> birth;
optional<DeathEventRecord> death;
vector<OID> marriage;
vector<OID> divorce;
vector<OID> child;
};
struct BirthEventRecord
{
DateTime datetime;
optional<string> location;
optional<OID> mother;
optional<OID> father;
};
struct DeathEventRecord
{
DateTime datetime;
optional<string> location;
};
struct MarriageEventRecord
{
array<OID, 2> spouse;
DateTime datetime;
optional<string> location;
};
struct DivorceEventRecord
{
array<OID, 2> spouse;
DateTime datetime;
};
GenealogySystem.person* is represented by a B-tree because Person.id is its
declared key. The B-tree maps that logical key to the OID of a person record.
The POS stores the serialised PersonRecord bytes under that OID. This
separation permits the storage engine to reorganise those bytes without changing
the B-tree key or any OIDs stored elsewhere.
Birth and death events are optional and belong to only one person in this schema, so they can be encoded inline in the person record. Mothers, fathers, children, marriages, divorces, and spouses are references to persistent entities and are encoded as OIDs.
The child, marriage, and divorce collections are vectors because they are normally small and are commonly traversed in full. Although a vector has a physical order, the corresponding Predica attributes are set-valued. The implementation must prevent duplicate OIDs, and insertion order has no logical meaning. A sorted vector, B-tree, or another collection can be substituted for an unusual workload without changing the schema.
Logical keys and physical identity
An OID is not a declared Predica key. The two have different purposes:
| Identifier | Purpose |
|---|---|
{spouse, datetime} |
Logical uniqueness and lookup of a relationship event. |
OID |
Stable physical reference to a stored object. |
A physical index can map a canonical relationship-event key to an event OID.
Because spouse[2] is set-valued, the two spouse OIDs must first be placed in a
canonical order. The pairs {Alice, Bob} and {Bob, Alice} must generate the
same index key.
struct RelationshipEventKey
{
array<OID, 2> spouse; // Ascending OID order
DateTime datetime;
RelationshipEventKey(OID a, OID b, DateTime dt)
: spouse{min(a, b), max(a, b)}, datetime(dt)
{
assert(a != b);
}
friend bool operator==(const RelationshipEventKey& a,
const RelationshipEventKey& b)
{
return tie(a.spouse, a.datetime) == tie(b.spouse, b.datetime);
}
friend bool operator<(const RelationshipEventKey& a,
const RelationshipEventKey& b)
{
return tie(a.spouse, a.datetime) < tie(b.spouse, b.datetime);
}
};
The assertion represents the cardinality requirement that the spouse set contain two distinct people. Production code reports a constraint violation instead of relying on an assertion. Equality and ordering compare the same canonical fields, so the ordering is suitable for a map or B-tree index.
Shared events
Alice and Bob can both store the OID of one marriage event:
Alice.marriage ──┐
├── OID 8341 ──→ MarriageEventRecord
Bob.marriage ────┘
The serialised payload is stored once. There is no persistent shared_ptr, and the
compiler does not have to choose an arena owned by Alice, Bob, or their
GenealogySystem. The POS stores one byte sequence under the event OID, while
the two person records contain ordinary serialisable OIDs.
The object store is a physical mechanism. Its storing all marriage records
does not make GenealogySystem.marriage* a logical base population.
GenealogySystem.marriage* remains derived by navigating the people and
eliminating duplicate event OIDs. A physical index over relationship events is
likewise an access structure rather than an independent source of logical
facts.
Sharing and symmetric membership are separate requirements. The existing
Person.marriage constraint says that the containing person is one of the
event's spouses, but it does not require the other spouse to contain the same
event OID. An additional constraint, a derived inverse, or an atomic update
operation is needed to maintain that symmetry.
Navigation through the object graph
Once a PersonRecord has been loaded, the representation exposes several useful
directions of navigation directly:
childcontains the OIDs of the person's children;birth.motherandbirth.fathercontain the OIDs of both parents;marriagecontains the OIDs of marriage events in which the person participates;divorcecontains the OIDs of divorce events in which the person participates; and- each relationship event contains the OIDs of both spouses.
The vectors are normally small, so navigation commonly consists of reading one person record, iterating a short vector, and resolving its OIDs through the POS snapshot. The strings, optional birth and death events, and the vectors themselves are decoded with the person rather than requiring a separate lookup for every in-memory allocation.
Current and previous spouses are reached through the same event graph. Previous
spouses are obtained by following marriage-event OIDs and selecting the other
spouse recorded by each event. The current spouse is determined by ordering the
person's marriage and divorce events by datetime and interpreting the last
relationship event. The physical vector order is irrelevant; the ordering is
the logical event-time order declared by the query.
Person ├── child[] ──────────────────────────────→ Person ├── birth.mother ─────────────────────────→ Person ├── birth.father ─────────────────────────→ Person └── marriage[] / divorce[] ──→ Event ──→ spouse[2] ──→ Person
These directions compose. A sibling path follows a parent OID and then that parent's child vector. A grandparent path follows a parent and then another parent. Cousins, descendants, in-laws, and other derived relationships are built from the same small set of typed navigation steps. The compiler therefore begins with known routes through the data rather than having to rediscover every relationship during query planning.
Efficient navigation in both parent-to-child and child-to-parent directions is not free. The child vector and the parent OIDs in the birth event must describe the same relationship, and updates must preserve that constraint. The additional direction consumes storage and update work in the same way that an additional index does. Its benefit is that both common directions become explicit, type-checked access paths.
Wide entities as navigation hubs
In a complex information system it can be entirely appropriate for an entity type to declare one hundred or more attributes. Predica does not treat a wide entity as evidence that its facts should be distributed among unrelated top-level populations. The usual design principle is to place each fact in the most specific domain to which it belongs.
Many such attributes are scalars, optional values, small strings, or OIDs. A serialised entity containing a hundred of them can still be only a few kilobytes, well within the useful I/O granularity discussed in the chapter on chunk size. Reading the entity then obtains a substantial amount of related information with one object-store read and one deserialisation operation.
A wide entity can also act as a navigation hub. Its OID-valued attributes and small collections expose typed routes to many other entities:
parent
↑
supplier ← account ← Customer → address → region
↓
order[] / contact[]
The compiler knows the type and cardinality of every outgoing route. A query can read the hub once and then follow only the references it needs. Several paths that begin at the same entity can reuse the already deserialised record.
This does not mean that every attribute should always be encoded in one persistent object. A large document, an unbounded collection, or infrequently used data with very different access characteristics may deserve its own OID. The physical boundary should reflect encoded size, update frequency, and access locality rather than an automatic objection to a large number of logical attributes.
A typed object graph is not a generic graph database
This representation is graph-shaped, but it is not a generic property-graph
representation made from untyped vertices, edges, labels, and dynamic property
maps. The compiler knows the record type at each OID-valued attribute, the field
offset after deserialisation, and the cardinality of the relationship. A path
such as person.birth.mother does not need to search a generic edge-label index
or interpret a property value dynamically.
Nor does a Predica path require the implementation to execute one pointer-like step at a time. Typed OID navigation is attractive for a selective query that visits a small neighbourhood. A query that processes most of the population may instead be compiled into batched OID reads, sequential scans, vectorised operations, or set-oriented joins.
| Workload | Likely useful execution style |
|---|---|
| One person's parents, children, or spouses | Direct local navigation and OID resolution |
| A selective multi-hop relationship query | Typed navigation combined with indexes and filters |
| Whole-population or iterative graph analysis | Bulk scans, batching, vectorisation, and set-oriented operations |
The performance claim is therefore not that graph databases are inherently faster than relational databases. It is that a typed, chunk-local representation avoids unnecessary reconstruction for common entity-centred navigation while retaining enough logical information for the compiler to choose a bulk execution strategy when the workload calls for one.
Contrast with a relational representation
A conventional relational representation is likely to use relations for people,
births, relationship events, event spouses, and possibly parent-child links.
Navigating several generations or roles then requires many joins. The same
Person relation must be renamed for each role, such as person, mother, maternal
grandmother, father, child, sibling, and spouse.
For example, finding a person's siblings can require joining a person to a birth
record, joining the recorded parents to other birth records, and joining those
records back to differently renamed occurrences of Person. Grandparents and
cousins add further occurrences and join paths. Indexes can make each individual
lookup efficient, but the relational optimiser must still select access methods,
choose join orders, estimate intermediate cardinalities, and recognise when
different aliases represent repeated navigation through the same relationships.
The contrast becomes stronger in a large, fully normalised, unnested schema. Facts that are local attributes in Predica can be spread across many relations. Partial information can require additional optional-fact relations rather than one locally encoded presence marker. Reconstructing one conceptual entity may therefore require many indexed lookups and joins before navigation to another entity even begins.
For workloads centred on selective entity access and local navigation, a direct physical mapping of that schema often has worse locality than reading a chunk-sized hub object and following its OIDs. A relational implementation can close or reverse the gap with clustered storage, covering indexes, materialised views, column groups, bulk columnar execution, or deliberate denormalisation. Those are valid physical optimisations; several work by recovering or materialising locality that the Predica type structure already makes explicit, while bulk execution can be intrinsically better for scans over much of the population.
The object representation does not make every genealogy query constant-time. Recursive ancestor and descendant queries can visit large parts of the graph, OID resolution can require I/O, and indexes or cached derived relationships may still be valuable. Its advantage is that common relationship directions are already embodied by typed OID references and local collections. Query implementation can follow those routes directly or batch them into set-oriented work, leaving the optimiser to concentrate on genuinely set-valued choices, filtering, ordering, cardinality, and I/O planning.
Lifetime in the genealogy database
The database catalog contains the OID of each named GenealogySystemRecord.
That record reaches person records through its B-tree, people reach other
people and relationship events through their OID fields, and relationship
events refer back to their spouses. The genealogy object layer traces this graph
using the record-layout metadata generated by the compiler and reports live OIDs
to the POS.
database catalog
↓
GenealogySystemRecord
↓
PersonRecord
↙ ↘
Person RelationshipEvent
The cycles between people and relationship events do not require special ownership rules. If an entire genealogy system becomes unreachable from the catalog, tracing can identify its unreachable component. If an individual event becomes unreachable, the object layer can remove its weak index entries and mark its OID dead. The POS can reclaim its obsolete byte-sequence versions after no transaction snapshot can still observe them.
Event retention remains an application policy. Audit or temporal requirements may keep committed birth, death, marriage, and divorce events even after ordinary application references disappear. Reachability determines which objects could be collected; the retention policy determines which historical events may actually be deleted.
Generated implementation
For this schema the compiler generates serialisation and deserialisation functions for the records above, schema-version handling, tracing metadata for their OID fields, comparison code for the declared keys, and navigation code that reads referenced byte sequences and reconstructs objects. The POS supplies OID allocation, stream access, partition actors, transactions, snapshot visibility, and safe reclamation of obsolete versions. Neither component needs to infer a person-owned, event-owned, or system-owned arena.