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.
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.
Gaps<'a>names'ain its type, soimpl<'a>has a parameter to bind (TRPL ch10.3, lifetimes in struct definitions).type Item = Gap<'a>ties every item to that same'a, not to the&mut selfborrow of onenextcall.remainingreturns&'a [RecoveryPoint], written out. The slice pattern then bindsbeforeandafteras&'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:
DoubleEndedIterator:[.., before, after]from the back, so.rev()walks newest first.ExactSizeIteratorwith an exactsize_hintoflen - 1, so.len()is free.FusedIterator: once the slice is shorter than two,nextreturnsNoneforever.
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
- Tome: TRPL ch10.3 lifetimes in structs (grounded-in) · ch13.2 Iterator and next (grounded-in) · ch19.3 pattern syntax (referenced)
- Tome: Programming Rust ch15 Implementing Your Own Iterators, book pp.354-357 (grounded-in)
- Prior Dev: Quantity newtype 10-06 · TRPL ch13 09-23 · iterator adapters 06-26
- Web: std Iterator · rustc E0207 · AWS Backup DescribeRecoveryPoint