Design a generateSequence-based pipeline to consume a cursor-paginated API until exhaustion, and explain the laziness and termination guarantees you rely on.
answer
- Element = Page; seed = first page
- next: page.nextCursor?.let { fetch(it) }
- null nextCursor terminates
- flatMap { it.items } then take/first
- suspend fetch -> use Flow, not Sequence
basics
~20 sMake each element a page. Start by fetching the first page (seed), then next fetches the page after the current one using its cursor, returning null when there's no next page. Flatten the pages into items and take what you need.
solid answer
~50 sModel a page as the element and use the seeded overload: generateSequence(fetch(firstCursor = null)) { page -> page.nextCursor?.let { fetch(it) } }. Each element is a Page; nextFunction follows page.nextCursor, returning null when nextCursor is null, which terminates the sequence at the last page. Then flatMap { it.items } gives a lazy stream of items, and because sequences are pull-based, you can take(100) or first { match } to stop early and avoid fetching further pages. Guarantees you lean on: laziness means no HTTP call happens until a terminal pulls; termination is the null nextCursor; short-circuiting terminals bound network work. Caveats: each pull triggers I/O (side-effecting, so single-pass and not safely re-iterable), you may want backpressure/retry around fetch, and a totally consuming terminal walks every page. For suspend fetch you'd use Flow instead, since Sequence operators aren't suspending.
code
kotlin · 7 linesdata class Page(val items: List<Item>, val nextCursor: String?)
val items = generateSequence(fetch(null)) { page ->
page.nextCursor?.let { fetch(it) }
}.flatMap { it.items }
val firstHundred = items.take(100).toList()go deeper
Grasps that each element is a page and null cursor ends the loop.
Writes the seeded generator with nextCursor?.let and flatMaps items lazily.
Explains laziness/short-circuit guarantees, eager-seed nuance, and single-pass I/O caveats.
Chooses Sequence vs Flow per sync/async, designs retry/backpressure/snapshot boundaries, and reasons about consistency under live data changes.
## Goal Consume a cursor-paginated endpoint (each response carries `items` plus a `nextCursor` that is `null` on the last page) as a single lazy stream, fetching pages **only as needed**. ## Shape the element as a page ```kotlin data class Page(val items: List<Item>, val nextCursor: String?) fun fetch(cursor: String?): Page = api.list(cursor) // one HTTP call val items: Sequence<Item> = generateSequence(fetch(null)) { page -> page.nextCursor?.let { fetch(it) } // null nextCursor -> stop } .flatMap { it.items } ``` - **Seed** = the first page, `fetch(null)`. - **nextFunction** follows `page.nextCursor`. `?.let { fetch(it) }` returns `null` exactly when `nextCursor` is `null`, which **terminates** the sequence at the final page. - **`flatMap { it.items }`** lazily unrolls each page's items into a flat `Sequence<Item>`. ## Laziness guarantee No HTTP call beyond the seed happens until a **terminal** pulls elements. Consumers control how far we go: ```kotlin items.take(100).toList() // fetches only enough pages to yield 100 items items.first { it.id == target } // stops at the first match, no further pages ``` This is the key win over eagerly looping all pages into a list: **short-circuiting terminals bound the network work**. ## Termination guarantee The sequence ends the moment `nextFunction` returns `null` — i.e., when a page reports `nextCursor == null`. A fully-consuming terminal (`toList`, `count`) walks **every** page until that null; that's correct but does all the I/O. ## Caveats and production hardening - **Side-effecting / single-pass**: each pull performs I/O, so the sequence is **not** safely re-iterable — iterating twice re-fetches and may differ if data changed. Materialize with `toList()` if you need a stable snapshot. - **Errors / retries / backoff** belong inside `fetch`; an exception there propagates out of the terminal. - **Eager seed**: note the **seed page is fetched immediately** when the sequence is *constructed* (the `fetch(null)` argument is evaluated eagerly). If you must defer even the first call, wrap construction in a lambda or use the seedless overload with an external cursor holder. - **Suspend functions**: `Sequence` operators are **not** `suspend`. If `fetch` is a coroutine `suspend fun`, use **`Flow`** (`flow { ... emit ... }`) instead of `generateSequence` — that's the idiomatic async analogue. ## Seedless variant You can also express it seedlessly with a captured cursor: ```kotlin var cursor: String? = null var done = false generateSequence { if (done) null else fetch(cursor).also { cursor = it.nextCursor; done = it.nextCursor == null } }.flatMap { it.items } ``` The seeded version is usually cleaner because the recurrence is a pure function of the previous page.
- Why does take(100) avoid fetching all pages?Sequences are pull-based: take stops requesting elements once 100 are produced, so nextFunction (and its fetch) is only invoked for as many pages as needed.
- What if fetch is a suspend function?Use Flow instead. Sequence operators aren't suspending, so you can't call a suspend fetch inside generateSequence; flow { emit(...) } is the async equivalent.
- Is this sequence safe to iterate twice?No. Each pull performs HTTP I/O, so it's effectively single-pass; re-iterating re-fetches and may see changed data. Materialize with toList() for a snapshot.
saying these in an interview costs you the question
- Eagerly looping every page into a list, defeating short-circuiting
- Forgetting null nextCursor is what terminates
- Calling a suspend fetch inside generateSequence
- Assuming the pipeline is re-iterable despite side-effecting I/O
- Not realizing the seed page is fetched eagerly at construction