Skip to content

Commit 95a76e3

Browse files
authored
feat: add cursor-based pagination to all list endpoints (#1085)
Add opt-in cursor-based pagination to all list API endpoints: messages, posts (v0/v1), aggregates, files, credit history, balances, credit balances, and address stats (v1). Cursors are opaque base64url-encoded JSON with ISO 8601 timestamps. Pass cursor= (empty) to start cursor mode from the first page. Page/offset mode is fully preserved for backward compatibility.
1 parent 503d657 commit 95a76e3

18 files changed

Lines changed: 1230 additions & 223 deletions

src/aleph/db/accessors/aggregates.py

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,10 @@ def get_aggregates(
320320
sort_order: SortOrder = SortOrder.DESCENDING,
321321
page: int = 1,
322322
pagination: int = 100,
323+
after_time: Optional[dt.datetime] = None,
324+
after_key: Optional[str] = None,
325+
after_owner: Optional[str] = None,
326+
cursor_mode: bool = False,
323327
) -> Iterable[AggregateDb]:
324328
where_clause = []
325329
if keys:
@@ -328,15 +332,15 @@ def get_aggregates(
328332
where_clause.append(AggregateDb.owner.in_(addresses))
329333

330334
if sort_by == SortByAggregate.CREATION_TIME:
331-
order_by_column: Any = AggregateDb.creation_datetime
335+
order_by_raw_column: Any = AggregateDb.creation_datetime
332336
else:
333337
# last_modified
334-
order_by_column = AggregateElementDb.creation_datetime
338+
order_by_raw_column = AggregateElementDb.creation_datetime
335339

336340
if sort_order == SortOrder.DESCENDING:
337-
order_by_column = order_by_column.desc()
341+
order_by_column = order_by_raw_column.desc()
338342
else:
339-
order_by_column = order_by_column.asc()
343+
order_by_column = order_by_raw_column.asc()
340344

341345
query = (
342346
select(AggregateDb)
@@ -346,10 +350,45 @@ def get_aggregates(
346350
)
347351
.where(*where_clause)
348352
.order_by(order_by_column)
349-
.limit(pagination)
350-
.offset((page - 1) * pagination)
351353
)
352354

355+
if after_time is not None:
356+
if sort_order == SortOrder.DESCENDING:
357+
query = query.where(
358+
(order_by_raw_column < after_time)
359+
| (
360+
(order_by_raw_column == after_time)
361+
& (
362+
(AggregateDb.key > after_key)
363+
| (
364+
(AggregateDb.key == after_key)
365+
& (AggregateDb.owner > after_owner)
366+
)
367+
)
368+
)
369+
)
370+
else:
371+
query = query.where(
372+
(order_by_raw_column > after_time)
373+
| (
374+
(order_by_raw_column == after_time)
375+
& (
376+
(AggregateDb.key > after_key)
377+
| (
378+
(AggregateDb.key == after_key)
379+
& (AggregateDb.owner > after_owner)
380+
)
381+
)
382+
)
383+
)
384+
elif page > 1:
385+
query = query.offset((page - 1) * pagination)
386+
387+
if pagination:
388+
query = query.limit(
389+
pagination + 1 if after_time is not None or cursor_mode else pagination
390+
)
391+
353392
return (
354393
session.execute(query.options(selectinload(AggregateDb.last_revision)))
355394
).scalars()

src/aleph/db/accessors/balances.py

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ def make_balances_by_chain_query(
5353
page: int = 1,
5454
pagination: int = 100,
5555
min_balance: int = 0,
56+
after_address: Optional[str] = None,
57+
cursor_mode: bool = False,
5658
) -> Select:
5759
query = select(AlephBalanceDb.address, AlephBalanceDb.balance, AlephBalanceDb.chain)
5860

@@ -62,11 +64,19 @@ def make_balances_by_chain_query(
6264
if min_balance > 0:
6365
query = query.filter(AlephBalanceDb.balance >= min_balance)
6466

65-
query = query.offset((page - 1) * pagination)
67+
query = query.order_by(AlephBalanceDb.address.asc())
6668

67-
# If pagination == 0, return all matching results
68-
if pagination:
69-
query = query.limit(pagination)
69+
if after_address is not None:
70+
query = query.where(AlephBalanceDb.address > after_address)
71+
72+
if after_address is not None or cursor_mode:
73+
if pagination:
74+
query = query.limit(pagination + 1)
75+
else:
76+
query = query.offset((page - 1) * pagination)
77+
# If pagination == 0, return all matching results
78+
if pagination:
79+
query = query.limit(pagination)
7080

7181
return query
7282

@@ -387,6 +397,8 @@ def get_credit_balances(
387397
page: int = 1,
388398
pagination: int = 100,
389399
min_balance: int = 0,
400+
after_address: Optional[str] = None,
401+
cursor_mode: bool = False,
390402
) -> list[tuple[str, int]]:
391403
"""
392404
Get paginated credit balances for all addresses.
@@ -397,10 +409,18 @@ def get_credit_balances(
397409
if min_balance > 0:
398410
query = query.filter(AlephCreditBalanceDb.balance >= min_balance)
399411

400-
query = query.offset((page - 1) * pagination)
412+
query = query.order_by(AlephCreditBalanceDb.address.asc())
413+
414+
if after_address is not None:
415+
query = query.where(AlephCreditBalanceDb.address > after_address)
401416

402-
if pagination:
403-
query = query.limit(pagination)
417+
if after_address is not None or cursor_mode:
418+
if pagination:
419+
query = query.limit(pagination + 1)
420+
else:
421+
query = query.offset((page - 1) * pagination)
422+
if pagination:
423+
query = query.limit(pagination)
404424

405425
# Return results in the expected format (address, credits)
406426
results = session.execute(query).all()
@@ -734,6 +754,10 @@ def get_address_credit_history(
734754
origin: Optional[str] = None,
735755
origin_ref: Optional[str] = None,
736756
payment_method: Optional[str] = None,
757+
after_time: Optional[dt.datetime] = None,
758+
after_credit_ref: Optional[str] = None,
759+
after_credit_index: Optional[int] = None,
760+
cursor_mode: bool = False,
737761
) -> Sequence[AlephCreditHistoryDb]:
738762
"""
739763
Get paginated credit history entries for a specific address, ordered from newest to oldest.
@@ -750,14 +774,21 @@ def get_address_credit_history(
750774
origin: Filter by origin
751775
origin_ref: Filter by origin reference
752776
payment_method: Filter by payment method
777+
after_time: Cursor-based: only return entries older than this timestamp
778+
after_credit_ref: Cursor-based: tiebreaker credit_ref for entries with the same timestamp
779+
after_credit_index: Cursor-based: tiebreaker credit_index for entries with the same timestamp and credit_ref
753780
754781
Returns:
755782
List of credit history entries ordered by message_timestamp desc
756783
"""
757784
query = (
758785
select(AlephCreditHistoryDb)
759786
.where(AlephCreditHistoryDb.address == address)
760-
.order_by(AlephCreditHistoryDb.message_timestamp.desc())
787+
.order_by(
788+
AlephCreditHistoryDb.message_timestamp.desc(),
789+
AlephCreditHistoryDb.credit_ref.desc(),
790+
AlephCreditHistoryDb.credit_index.desc(),
791+
)
761792
)
762793

763794
# Apply filters
@@ -776,7 +807,25 @@ def get_address_credit_history(
776807
if payment_method is not None:
777808
query = query.where(AlephCreditHistoryDb.payment_method == payment_method)
778809

779-
if pagination > 0:
810+
if after_time is not None:
811+
query = query.where(
812+
(AlephCreditHistoryDb.message_timestamp < after_time)
813+
| (
814+
(AlephCreditHistoryDb.message_timestamp == after_time)
815+
& (
816+
(AlephCreditHistoryDb.credit_ref < after_credit_ref)
817+
| (
818+
(AlephCreditHistoryDb.credit_ref == after_credit_ref)
819+
& (AlephCreditHistoryDb.credit_index < after_credit_index)
820+
)
821+
)
822+
)
823+
)
824+
825+
if after_time is not None or cursor_mode:
826+
if pagination > 0:
827+
query = query.limit(pagination + 1)
828+
elif pagination > 0:
780829
query = query.offset((page - 1) * pagination).limit(pagination)
781830

782831
return session.execute(query).scalars().all()

src/aleph/db/accessors/files.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,9 @@ def get_address_files_for_api(
234234
pagination: int = 0,
235235
page: int = 1,
236236
sort_order: SortOrder = SortOrder.DESCENDING,
237+
after_time: Optional[dt.datetime] = None,
238+
after_hash: Optional[str] = None,
239+
cursor_mode: bool = False,
237240
) -> Iterable[Row]:
238241
select_stmt = (
239242
select(
@@ -247,22 +250,47 @@ def get_address_files_for_api(
247250
.where(MessageFilePinDb.owner == owner)
248251
)
249252

250-
if pagination:
251-
select_stmt = select_stmt.limit(pagination).offset((page - 1) * pagination)
252-
253253
if sort_order == SortOrder.DESCENDING:
254254
order_by_columns: Tuple[UnaryExpression[Any], UnaryExpression[Any]] = (
255255
MessageFilePinDb.created.desc(),
256256
MessageFilePinDb.item_hash.asc(),
257257
)
258258
else: # ASCENDING
259259
order_by_columns = (
260-
MessageFilePinDb.item_hash.asc(),
260+
MessageFilePinDb.created.asc(),
261261
MessageFilePinDb.item_hash.asc(),
262262
)
263263

264264
select_stmt = select_stmt.order_by(*order_by_columns)
265265

266+
if after_time is not None:
267+
if sort_order == SortOrder.DESCENDING:
268+
select_stmt = select_stmt.where(
269+
(MessageFilePinDb.created < after_time)
270+
| (
271+
(MessageFilePinDb.created == after_time)
272+
& (MessageFilePinDb.item_hash > after_hash)
273+
)
274+
)
275+
else:
276+
select_stmt = select_stmt.where(
277+
(MessageFilePinDb.created > after_time)
278+
| (
279+
(MessageFilePinDb.created == after_time)
280+
& (MessageFilePinDb.item_hash > after_hash)
281+
)
282+
)
283+
284+
if after_time is not None or cursor_mode:
285+
if pagination:
286+
select_stmt = select_stmt.limit(pagination + 1)
287+
elif pagination and page > 1:
288+
select_stmt = select_stmt.offset((page - 1) * pagination)
289+
if pagination:
290+
select_stmt = select_stmt.limit(pagination)
291+
elif pagination:
292+
select_stmt = select_stmt.limit(pagination)
293+
266294
return session.execute(select_stmt).all()
267295

268296

src/aleph/db/accessors/messages.py

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from aleph.db.accessors.address_stats import escape_like_pattern
1313
from aleph.db.accessors.cost import delete_costs_for_message
1414
from aleph.db.models.message_counts import MessageCountsDb
15-
from aleph.toolkit.cursor import decode_cursor
1615
from aleph.toolkit.timestamp import coerce_to_datetime, utc_now
1716
from aleph.types.channel import Channel
1817
from aleph.types.db_session import DbSession
@@ -84,7 +83,9 @@ def make_matching_messages_query(
8483
page: int = 1,
8584
pagination: int = 20,
8685
include_confirmations: bool = False,
87-
cursor: Optional[str] = None,
86+
after_time: Optional[dt.datetime] = None,
87+
after_hash: Optional[str] = None,
88+
cursor_mode: bool = False,
8889
# TODO: remove once all filters are supported
8990
**kwargs,
9091
) -> Select:
@@ -178,19 +179,17 @@ def make_matching_messages_query(
178179
MessageDb.item_hash.asc(),
179180
)
180181

181-
# Cursor pagination (if cursor provided, ignore page)
182-
if cursor:
183-
time_val, hash_val = decode_cursor(cursor)
184-
cursor_time = coerce_to_datetime(time_val)
182+
# Cursor pagination (if cursor values provided, ignore page)
183+
if after_time is not None:
185184
if sort_order == SortOrder.DESCENDING:
186185
select_stmt = select_stmt.where(
187-
(MessageDb.time < cursor_time)
188-
| ((MessageDb.time == cursor_time) & (MessageDb.item_hash > hash_val))
186+
(MessageDb.time < after_time)
187+
| ((MessageDb.time == after_time) & (MessageDb.item_hash > after_hash))
189188
)
190189
else:
191190
select_stmt = select_stmt.where(
192-
(MessageDb.time > cursor_time)
193-
| ((MessageDb.time == cursor_time) & (MessageDb.item_hash > hash_val))
191+
(MessageDb.time > after_time)
192+
| ((MessageDb.time == after_time) & (MessageDb.item_hash > after_hash))
194193
)
195194
elif page > 1:
196195
select_stmt = select_stmt.offset((page - 1) * pagination)
@@ -199,8 +198,9 @@ def make_matching_messages_query(
199198

200199
# If pagination == 0, return all matching results
201200
if pagination:
202-
# Fetch +1 for has_more detection when using cursor
203-
select_stmt = select_stmt.limit(pagination + 1 if cursor else pagination)
201+
select_stmt = select_stmt.limit(
202+
pagination + 1 if after_time is not None or cursor_mode else pagination
203+
)
204204

205205
return select_stmt
206206

@@ -309,6 +309,9 @@ def get_message_stats_by_address(
309309
sort_order: SortOrder = SortOrder.DESCENDING,
310310
page: int = 1,
311311
pagination: int = 0,
312+
after_sort_value: Optional[Any] = None,
313+
after_address: Optional[str] = None,
314+
cursor_mode: bool = False,
312315
) -> Sequence[Any]:
313316
"""
314317
Get message stats for user addresses using the message_counts table.
@@ -388,7 +391,28 @@ def get_message_stats_by_address(
388391
else:
389392
stmt = stmt.order_by(sort_column.desc(), subquery.c.address.asc())
390393

391-
if pagination:
394+
if after_sort_value is not None:
395+
if sort_order == SortOrder.DESCENDING:
396+
stmt = stmt.where(
397+
(sort_column < after_sort_value)
398+
| (
399+
(sort_column == after_sort_value)
400+
& (subquery.c.address > after_address)
401+
)
402+
)
403+
else:
404+
stmt = stmt.where(
405+
(sort_column > after_sort_value)
406+
| (
407+
(sort_column == after_sort_value)
408+
& (subquery.c.address > after_address)
409+
)
410+
)
411+
412+
if after_sort_value is not None or cursor_mode:
413+
if pagination:
414+
stmt = stmt.limit(pagination + 1)
415+
elif pagination:
392416
stmt = stmt.limit(pagination).offset((page - 1) * pagination)
393417

394418
return session.execute(stmt).all()

0 commit comments

Comments
 (0)