A practice prompt we wrote. No company or candidate report names it, so it carries no company tag.
How to answer
The loop is easy. The answer is one invariant, said early: “The saved cursor always points just past committed rows, and storing a page twice changes nothing.”
- Pin the API’s contract. Is the cursor opaque, and how long does it live? What signals the end: a missing cursor,
has_more, an empty page? Is the order stable? What are the rate limits? - Commit rows and cursor together. Write the page’s rows and the next cursor in one transaction. A crash before the commit replays that page; a crash after it resumes on the next.
- Make the write idempotent. Upsert on the item’s id (SQLite and Postgres both have
ON CONFLICT). Replayed pages become harmless, and so does an item seen on two pages because an update moved it in the sort order, or because the cursor is an offset underneath. If anything else writes these rows, update only when the record’s ownupdated_atis not older, so an older copy never replaces a newer one. - Retry only what can succeed. A GET is idempotent (RFC 9110, section 9.2.2), so it is safe to repeat: retry timeouts, 429 and the server errors that mean try again (500, 502, 503, 504) with jittered backoff, and honor
Retry-After(RFC 9110, section 10.2.3). Stop on other4xxerrors. Asked to wait past your budget, exit: the saved cursor makes stopping cheap. - Guard the loop’s edges. End on a missing cursor, not an empty page. Fail loudly on a cursor you have already seen. On an expired cursor, resume from what you stored if the pages run in an order the API can filter on (in creation order, ask for
created_afteryour newest stored record); rescan from the start only if it can’t. Upserts make both safe. - Say what changes when the sink is elsewhere. No shared transaction: the sink write must be idempotent itself, and the cursor saved only after the sink confirms.
The trap is saving progress and data as two independent steps in an unstated order.
Follow-ups
What the interviewer may ask next, once your first answer is on the table.
- The process is killed after the rows are written but before the cursor is saved. What happens on restart?
- The rows go to a warehouse load API, not the database that holds your cursor. What changes?
- The API’s cursor expires while your job is down for the weekend. What does your client do?
- Two copies of the job start at once. How do you stop them from both paging?
- Records change while you page. Which changes can your backfill miss, and how does the next sync catch them?
Where answers go wrong
- Saving the cursor before the page’s rows are stored, so a crash between the two loses a page for good.
- Appending rows with a plain insert, so the page replayed after a restart is stored twice.
- Stopping on an empty page instead of a missing cursor, or looping forever when the API hands back a cursor it already sent.
- Retrying every error, including the expired-cursor or permission error that will fail the same way each time.
Answer this in two minutes
Write the answer you would say out loud. The clock starts with your first word.
Model answer
“I’ll assume the API returns {"data": [...], "next_cursor": "..." | null}, takes cursor and limit as query parameters, and returns 400 with {"error": "invalid_cursor"} when a cursor has expired. Each item carries its own updated_at, an ISO 8601 timestamp with a UTC offset.