pub async fn reconstruct_on(
conn: &Connection,
ts: &str,
branch: &str,
archive_path: Option<&Path>,
snapshots_dir: Option<&Path>,
) -> Result<MaterializedState>Expand description
State at ts as one lineage saw it (0.15.17, D-259, review C-10).
reconstruct answers a whole-ledger question — what did the ledger hold
at ts — and the ledger held every lineage’s belief at once. This answers
the narrower one a caller usually means: what did branch hold at ts,
with the ancestry resolved and each ancestor bounded at its fork point.
§How it is assembled
One fold, with the ancestry bound into it: the JOIN against the lineage
relation drops every lineage the reader cannot see, recorded_at <= g.cutoff bounds each ancestor at its own fork point, and resolve_beliefs
then picks the nearest holder of each key. The cut is applied to the
window’s input, which is the part that cannot be done any other way —
see bounded_hot_fold.
§The shape this is not, and the measurement that decided it
The first version folded once per distinct effective instant — min(ts, cutoff) for the reader and each ancestor — and kept from each fold the
lineages whose instant it was. That form reuses reconstruct whole,
snapshot composition included, and the argument for it was that a fork depth
of 1 is two cheap folds where this one is a single expensive one.
Measured (examples/reconstruct_on_probe.rs, 400 concepts), the argument
holds at exactly one of the four configurations tried:
| snapshots | fork depth | fold-per-bound | this |
|---|---|---|---|
| off | 1 | 5.6 ms | 2.9 ms |
| off | 8 | 24.1 ms | 3.2 ms |
| on | 1 | 2.0 ms | 2.9 ms |
| on | 8 | 7.5 ms | 3.2 ms |
Both shapes run in one process against one build, alternating, because the first version of this comparison ran them in two processes against two builds and that is thin evidence for reversing a design. The probe also asserts that the two shapes return the same edges before it times them, and counts the snapshot files on disk rather than trusting that asking for a cadence produced one — the whole argument for fold-per-bound rests on composition actually being available.
The per-bound form is linear in fork depth and this one is flat, so the crossover is at depth 2 with snapshots configured and below depth 1 without them. Losing about 1 ms at the one point where the other shape wins buys a cost that does not depend on how deeply a caller has forked, and one code path instead of two — the same call D-056’s guard made earlier in this release for the same reason.
§What that costs: no snapshot composition
A snapshot is a materialised state with no recorded_at left in it, so
there is nothing for a cutoff to compare against and no way to anchor a
bounded fold on one. This therefore folds from genesis every time, which is
where the flat ~3 ms comes from — and why, on a database with snapshots
configured, this is 4x reconstruct rather than 1.2x. The absolute
cost is the same in both configurations; it is reconstruct that gets
faster, not this that gets slower.
An unforked database never pays any of it: the shape is Trunk, there is
one lineage and nothing to resolve, and this delegates to reconstruct
unchanged, snapshots and all.
§Concepts need no distance rule, because the tie cannot happen (0.15.18,
MaterializedState::concepts is keyed by concept id alone, so once a row
is folded there is no lineage left on it to pick a nearest one by. The fold
here is narrowed — a lineage outside the ancestry contributes nothing, and
an ancestor’s post-cutoff concept writes are cut like its edges — and that
narrowing is all a concept needs, because two visible lineages cannot both
hold one concept id.
That is the schema’s guarantee and not this function’s. concepts.id is
NOT NULL UNIQUE — identity, not identity-per-lineage — and
trg_concepts_cross_lineage turns the index’s refusal into
DbError::CrossLineage so it says which
rule was broken; trg_concepts_branch_immutable stops a concept being moved
to another lineage afterwards. A branch therefore inherits its parent’s
concepts and cannot restate them (§15.2, D-225), which is the same rule
read from the other side.
The one route past that guard is archive_branch — it reads the live
table, and archiving moves rows out of it — so archiving a lineage and then
minting its id on the trunk does leave two lineages’ rows for one id in
hot-plus-cold history. It still reaches no reader: an archived lineage is
gone from branches, so it is in nobody’s ancestry, so the JOIN above
drops its rows on both arms (the cold one joins the union, not each
file). rehydrate refuses to bring the concept back while its lineage is
forgotten (D-253). examples/concept_lineage_probe.rs walks all five
routes and prints which the database refuses.
So this is not a resolution the caller must compensate for. It is a rule
with nothing to decide, and if concepts ever gained per-lineage rows —
the overlay design D-214 defers — it would need one, along with a lineage
on the folded row to apply it to.
Only MaterializedState::edges gets the distance rule, which is the field
review C-10 named and the only one the rule has ever been needed for.
§This result is not a snapshot
It is one lineage’s view, so it is missing beliefs the ledger holds. Do not
pass it to save_snapshot: a later
reconstruct anchoring on it would compose a whole-ledger answer on top of
a partial base and return the other lineages’ rows only where something
touched them again. seq_anchor is the
highest seq_id among the rows this lineage can see, which is the honest
number for what was folded and is not a licence to anchor on it.
§Errors
DbError::UnknownBranch, naming it, when
branch is not registered — refused rather than answered for the trunk, for
the reason graph::lineage::Lineages::shape gives.
Otherwise the same refusals reconstruct raises at ts, and for the same
reasons: reach is decided by ts alone, because the cutoffs are a predicate
inside one query rather than instants of their own.
§Where this narrows silently, named rather than left to be found
An ancestor’s inherited row is the last one it wrote at or before the fork
point, and the fold finds it in the hot log. LOG_ARCHIVABLE archives an
entry once a later one supersedes it for the same entity — so a pre-fork
assertion that the ancestor corrected afterwards is archivable, and once the
retention horizon passes the fork point it can be cold. The reader then
loses an edge it should have inherited, and nothing raises, because ts
is well inside the hot log and reach was asked about ts.
That is not new and not this function’s: it is the same degradation
graph::lineage’s module docs describe for links_cut, reached from the
fold side instead of the projection side, and it is bounded to keys an
ancestor churned after forking. Passing archive_path closes it — the cold
arm unions both files before it cuts.