Skip to content

Lifetimes & zero-copy

This is the lesson that ties the whole data layer together. Back in 2.3 you saw the engine reads fields by borrowingresolve returns &Value. But a borrow raises an obvious danger: what if the record is gone and you still hold the reference? The answer is lifetimes, and they’re what make Clinker’s zero-copy reads not just fast but safe.

You’ll be able to: read a lifetime annotation, explain how it prevents a dangling reference, and describe how RecordView reads a field with zero allocation.

A lifetime is “how long this borrow is valid”

Section titled “A lifetime is “how long this borrow is valid””

A reference can’t outlive the thing it points at. A lifetime (written 'a) is the compiler’s name for “the span during which this borrow is valid.” Most of the time you never write one — the compiler infers them. But when a type holds a borrow, it must say so, and that annotation is what guarantees the borrow can’t dangle.

The engine’s zero-copy field reader is exactly such a type. RecordView<'a, S> is a tiny, Copy handle — a pointer plus an index — that borrows into record storage for the lifetime 'a:

clinker-record ·record_view.rs ·RecordView type @47d2e12
#[derive(Clone, Copy)]
pub struct RecordView<'a, S: RecordStorage + ?Sized> {
storage: &'a S, // borrows the record storage for the lifetime 'a
index: u64, // which record
}

The 'a ties the view to the storage it came from. The compiler will not let a RecordView outlive that storage — so a view onto a record that’s been dropped is not a runtime crash you debug, it’s a program that won’t compile. The storage it reads through is itself a trait, so the same view works over the real arena in production and a stub in tests:

clinker-record ·storage.rs ·RecordStorage trait @47d2e12
pub trait RecordStorage: Send + Sync {
fn resolve_field(&self, index: u64, name: &str) -> Option<&Value>;
// ... resolve_qualified, available_fields, record_count
}

Put the pieces together. Reading a field returns &Value (a borrow, 2.3), the absent case is None (2.4), and shared backing data lives behind Arc (2.7). So evaluating a CXL expression — coalescing, comparing, filtering — walks through fields without copying a single one. The string "active" is read in place, compared, and discarded; it’s never duplicated. A copy is paid exactly once, and only at the spot that must keep a value past the borrow (an output that writes it, say) — not on every read along the way.

There’s even a small trick for the absent case: rather than allocate an empty value to return, the resolver can hand back a borrow of a single shared static “null” value — a reference with a 'static lifetime that’s always valid, so None-ish reads cost nothing either.

rust // editable

Run it, then uncomment the two lines. The borrow checker refuses to compile a program where field could be read after record is gone. That refusal — at compile time, every time — is what lets the engine borrow fearlessly.

// quick check

What does the lifetime 'a in RecordView<'a, S> guarantee?

That’s Phase 2. You’ve gone from “a record is a row of cells” to the real machinery: a 32-byte closed Value, exhaustive match, borrow-don’t-copy field access, Option/Result for absence and failure, Arc-shared schemas and context, and lifetimes that make all the borrowing provably safe. Phase 3 — Planning & Expressions moves up a layer: how the engine turns YAML and CXL into the validated CompiledPlan you’ve been running.