Skip to main content

reconstruct_on

Function reconstruct_on 

Source
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 instantmin(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:

snapshotsfork depthfold-per-boundthis
off15.6 ms2.9 ms
off824.1 ms3.2 ms
on12.0 ms2.9 ms
on87.5 ms3.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,

D-260)

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.