Implement paginated retrieval of transactions
Company: Lyft
Role: Software Engineer
Category: Coding & Algorithms
Difficulty: Medium
Interview Round: Onsite
Quick Answer: Implement paginated retrieval of transactions evaluates algorithm design, data structures, correctness, complexity, edge cases, and implementation details in a realistic interview setting. A strong answer states assumptions, handles edge cases, explains trade-offs, and shows how to validate the result clearly.
Constraints
- 0 <= number of transactions <= 10^6
- Each record is [id, userId, amount, createdAt]; id values are unique integers
- createdAt is an integer timestamp; multiple records may share the same createdAt
- 1 <= pageSize
- mode is either "offset" or "cursor"
- offset >= 0 (negative offsets are clamped to 0); cursor is null or [createdAt, id]
Examples
Input: ([[1, 10, 5.0, 100], [2, 10, 7.0, 200], [3, 11, 2.0, 150]], 'offset', 0, 2)
Expected Output: {'page': [[2, 10, 7.0, 200], [3, 11, 2.0, 150]], 'hasNext': True, 'nextCursor': [150, 3], 'total': 3}
Explanation: Offset mode, first page of 2. Sorted by createdAt DESC: 200, 150, 100. Returns the top two; hasNext true (1 row remains); nextCursor is the last row's (createdAt, id).
Input: ([[1, 10, 5.0, 100], [2, 10, 7.0, 200], [3, 11, 2.0, 150]], 'offset', 2, 2)
Expected Output: {'page': [[1, 10, 5.0, 100]], 'hasNext': False, 'nextCursor': [100, 1], 'total': 3}
Explanation: Offset mode, last partial page (offset 2). Only 1 row left; hasNext false since 2 + 2 >= 3.
Input: ([[1, 10, 5.0, 100], [2, 10, 7.0, 100], [3, 11, 2.0, 100]], 'offset', 0, 2)
Expected Output: {'page': [[3, 11, 2.0, 100], [2, 10, 7.0, 100]], 'hasNext': True, 'nextCursor': [100, 2], 'total': 3}
Explanation: All three share createdAt=100, so the id DESC tiebreak orders them 3, 2, 1. Demonstrates stable ordering on equal timestamps.
Input: ([[1, 10, 5.0, 100], [2, 10, 7.0, 200], [3, 11, 2.0, 150]], 'cursor', None, 2)
Expected Output: {'page': [[2, 10, 7.0, 200], [3, 11, 2.0, 150]], 'hasNext': True, 'nextCursor': [150, 3], 'total': None}
Explanation: Cursor mode with a null cursor starts from the beginning; total is null in cursor mode.
Input: ([[1, 10, 5.0, 100], [2, 10, 7.0, 200], [3, 11, 2.0, 150]], 'cursor', [150, 3], 2)
Expected Output: {'page': [[1, 10, 5.0, 100]], 'hasNext': False, 'nextCursor': None, 'total': None}
Explanation: Continuing from cursor [150, 3], the only row strictly after is [1,...,100]. Stream exhausted, so nextCursor is null.
Input: ([[1, 10, 5.0, 100], [2, 10, 7.0, 100], [3, 11, 2.0, 100]], 'cursor', [100, 3], 2)
Expected Output: {'page': [[2, 10, 7.0, 100], [1, 10, 5.0, 100]], 'hasNext': False, 'nextCursor': None, 'total': None}
Explanation: Cursor on a tied timestamp: [100, 3] excludes id 3 and returns id 2 then id 1 via the (createdAt, id) tuple comparison.
Input: ([], 'offset', 0, 5)
Expected Output: {'page': [], 'hasNext': False, 'nextCursor': None, 'total': 0}
Explanation: Empty input, offset mode: empty page, total 0, no cursor.
Input: ([], 'cursor', None, 5)
Expected Output: {'page': [], 'hasNext': False, 'nextCursor': None, 'total': None}
Explanation: Empty input, cursor mode: empty page, total null.
Input: ([[1, 10, 5.0, 100]], 'cursor', [100, 1], 3)
Expected Output: {'page': [], 'hasNext': False, 'nextCursor': None, 'total': None}
Explanation: Cursor points at the only row, so nothing is strictly after it — empty page, no further cursor.
Hints
- Define a single total order: createdAt DESC, then id DESC. The id tiebreak is what makes pagination stable across equal timestamps.
- In cursor mode, 'strictly after the cursor' means the first row whose (createdAt, id) tuple is lexicographically less than the cursor tuple under DESC ordering — compare the pair, not just createdAt.
- Only emit nextCursor in cursor mode when hasNext is true; once the stream is exhausted, return null so the caller knows to stop.
- Cursor pagination is resilient to inserts between requests because the (createdAt, id) anchor doesn't move; offset pagination can skip or repeat rows when new records arrive.