Hedronite · Dev Lesson · Polyglot-Dev / Rust · Wed 2026-10-07

Rust borrowing iterator with explicit lifetimes Gaps<'a> over recovery points and an RPO breach filter

The iterator borrows the slice. The gaps borrow the slice. Neither borrows the other.

Lesson Class: Dev (T1 · Rust lifetimes and iterator traits)
Language Idiom: struct Gaps<'a> · impl<'a> Iterator · IntoIterator for &'a Timeline · slice patterns · impl Trait + '_
Verified: cargo test 11 passed (4 unit · 6 fixture · 1 compile_fail E0207) · clippy clean
Fixtures: Synthetic, documented ListRecoveryPointsByResource shape (credential hold)
Lag rule: TRPL whole canon shipped at Duha
Paired Ops: AWS Backup coverage census
Paired Cert: AWS SAP multi-account backup
Borrow the Slice, Not the Iterator
'a on the struct, 'a on Item, copy the slice out of self.
E0207
An owning iterator has no lifetime to lend from.
The Open Gap
The pair walk never yields now minus the newest restorable point.
The lifetime points at the data, never at the cursor.

The iterator borrows the slice. The gaps borrow the slice. Neither borrows the other.

§I. Frame

The Ops census asks one question per resource: is the newest restorable recovery point older than the RPO? A sharper question sits under it. Across the last week, which interval between two restorable points was longer than the RPO? That is a walk over consecutive pairs.

slice.windows(2) would do the walk. Today we write it by hand, because the hand-written version shows where the lifetime lives. The 06-26 and 09-23 lessons chained adapters that std already had. This one implements Iterator on a struct that holds a borrow. Crate: recovery_gaps.

§II. Borrow the Slice, Not the Iterator

Borrow the Slice, Not the Iterator (named technique). Put the lifetime on the data the items point into, and let the iterator carry nothing else.

pub struct Gap<'a> { pub before: &'a RecoveryPoint, pub after: &'a RecoveryPoint }

pub struct Gaps<'a> { points: &'a [RecoveryPoint] }

impl<'a> Gaps<'a> {
    pub fn remaining(&self) -> &'a [RecoveryPoint] { self.points }
}

impl<'a> Iterator for Gaps<'a> {
    type Item = Gap<'a>;
    fn next(&mut self) -> Option<Gap<'a>> {
        let points = self.remaining();
        match points {
            [before, after, ..] => { self.points = &points[1..]; Some(Gap { before, after }) }
            _ => None,
        }
    }
}

Three facts make this compile.

  1. Gaps<'a> names 'a in its type, so impl<'a> has a parameter to bind (TRPL ch10.3, lifetimes in struct definitions).
  2. type Item = Gap<'a> ties every item to that same 'a, not to the &mut self borrow of one next call.
  3. remaining returns &'a [RecoveryPoint], written out. The slice pattern then binds before and after as &'a RecoveryPoint. Advancing is a reslice: &points[1..].

Fact 3 is where elision bites. Matching on self.points in place compiles, because a shared reference is Copy and the bindings reach through it to 'a. Route the same read through a helper with the lifetime elided, fn remaining(&self) -> &[RecoveryPoint], and the elision rule ties the result to &self. rustc 1.99.0:

error: lifetime may not live long enough
8  |     fn next(&mut self) -> Option<Gap<'a>> {
   |             - let's call the lifetime of this reference `'1`
10 |             [before, after, ..] => Some(Gap { before, after }),
   |                                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^ method was supposed to return data with lifetime `'a` but it is returning data with lifetime `'1`

No error code, and the fix is four characters: -> &'a [RecoveryPoint].

Programming Rust ch15 builds the same shape for a binary tree (book p.356): TreeIter<'a, T> keeps a Vec<&'a TreeNode<T>>, so what it yields borrows the tree. The lifetime always points at the collection.

The test that proves it: let kept: Vec<Gap<'_>> = t.gaps().collect();. The Gaps value is a temporary that dies at the semicolon. The gaps live on, because they never borrowed it.

§III. The version that cannot compile

Move the data into the iterator and the lifetime has nowhere to attach:

struct OwnedGaps { points: Vec<RecoveryPoint>, i: usize }
impl<'a> Iterator for OwnedGaps { type Item = Gap<'a>; /* ... */ }

rustc 1.99.0 on the lab Mac:

error[E0207]: the lifetime parameter `'a` is not constrained by the impl trait, self type, or predicates
3 | impl<'a> Iterator for OwnedGaps {
  |      ^^ unconstrained lifetime parameter

OwnedGaps mentions no 'a, so nothing fixes what 'a is. The only borrow available inside next is &mut self, and Iterator::next gives that borrow no name you can put in Item. An iterator that hands out references into itself needs a lending-iterator trait, which std does not have. The crate keeps this as a compile_fail,E0207 doctest, so the claim is tested on every cargo test.

§IV. IntoIterator, the extra traits, and the filter

impl<'a> IntoIterator for &'a Timeline {
    type Item = Gap<'a>;
    type IntoIter = Gaps<'a>;
    fn into_iter(self) -> Gaps<'a> { self.gaps() }
}

Now for gap in &timeline works, and the loop borrows the timeline for exactly as long as the loop runs. Three more impls are short and pay off downstream:

The RPO filter is one line, with the elided lifetime written out as '_:

pub fn breaches(&self, rpo_secs: i64) -> impl Iterator<Item = Gap<'_>> + '_ {
    self.gaps().filter(move |g| g.secs() > rpo_secs)
}

move copies rpo_secs into the closure. + '_ tells the caller the returned iterator borrows self.

§V. The Open Gap

The Open Gap (named technique). Gaps yields closed intervals only. The interval from the newest restorable point to now is never a pair, so the iterator never sees it, and it is the interval that can be in breach right now. Timeline::open_gap(now) returns it, and None when no restorable point exists, which Report::open_breach treats as unbounded exposure.

Timeline::new keeps COMPLETED and AVAILABLE points, sorts by creation time, and parks the rest in skipped. Fixtures (synthetic, documented shape; credentials are on hold):

$ cargo run -q -- ../fixture/orders-db.json --rpo-hours 24 --now 2026-10-07T05:40:00Z --resource arn:aws:rds:us-east-1:111122223333:db:orders
resource arn:aws:rds:us-east-1:111122223333:db:orders
restorable 6  skipped 2 [Creating@2026-10-07T05:01Z Expired@2026-08-01T05:03Z]
  gap  2026-09-30T05:04Z -> 2026-10-01T05:02Z   23h58m  ok
  gap  2026-10-01T05:02Z -> 2026-10-02T05:02Z   23h59m  ok
  gap  2026-10-02T05:02Z -> 2026-10-04T05:06Z   48h03m  BREACH
  gap  2026-10-04T05:06Z -> 2026-10-05T05:03Z   23h57m  ok
  gap  2026-10-05T05:03Z -> 2026-10-06T05:02Z   23h58m  ok
  open 2026-10-06T05:02Z -> now               24h37m  BREACH
summary rpo=24h00m gaps=5 breaches=1 worst=48h03m open_breach=true
exit 2

The CREATING point is 38 minutes old. Count it and the open gap reads as fresh. app-stack.json shows the same trap on a CloudFormation composite: count the PARTIAL point and every gap sits under 12 hours; drop it and one gap is 23h59m.

§VI. Tests

cargo test: 12 passed. Unit tests cover RFC 3339 offsets and fractions, zero, one, and three points, len and next_back, and status filtering. Fixture tests cover the missing day, the CREATING open gap, the PARTIAL composite, a clean EFS timeline, an empty one, and gaps outliving their iterator. Two compile_fail doctests pin the failures: E0207 for OwnedGaps, and the elided remaining helper.

§VII. Close

Borrow the Slice, Not the Iterator: write 'a on the struct that holds the borrow, write it again on Item, and spell out 'a on any helper that hands the slice back. Then ask the Open Gap question, because the pair walk will not.

Drill: add pub fn gaps_over(&self, rpo_secs: i64) -> Gaps<'_> that skips to the first breach with skip_while, then explain in one sentence why its return type cannot be Gaps<'_> and has to become impl Iterator.

Related