Skip to content

Commit fbd8533

Browse files
authored
feat: improve cursor best practices (#814, #849)
Signed-off-by: Tronje Krop <tronje.krop@jactors.de>
1 parent 26fecc7 commit fbd8533

File tree

2 files changed

+59
-22
lines changed

2 files changed

+59
-22
lines changed

assets/cursor.png

27.2 KB
Loading

chapters/best-practices.adoc

Lines changed: 59 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,22 @@ implementing RESTful APIs.
1313
Cursor-based pagination is a very powerful and valuable technique (see also
1414
<<160>>) that allows to efficiently provide a stable view on changing data.
1515
This is obtained by using an anchor element that allows to retrieve all page
16-
elements directly via an ordering combined-index, usually based on `created_at`
17-
or `modified_at`. Simple said, the cursor is the information set needed to
18-
reconstruct the database query that retrieves the minimal page information from
19-
the data storage.
16+
elements directly via an ordering index, usually based on `created_at` or
17+
`modified_at` in combination with a tie breaker, mostly the resource `id` or a
18+
unique `key`. In addition, the cursor contains all other information needed
19+
to reconstruct a database query that retrieves a minimal information set from
20+
the data storage to create a response page.
2021

2122
The {cursor} itself is an opaque string, transmitted forth and back between
2223
service and clients, that must never be *inspected* or *constructed* by
2324
clients. Therefore, it is good practice to encode (encrypt) its content in a
2425
non-human-readable form.
2526

2627
The {cursor} content usually consists of a pointer to the anchor element
27-
defining the page position in the collection, a flag whether the element is
28-
included or excluded into/from the page, the retrieval direction, and a hash
28+
defining the page position in the collection, a flag whether the anchor element
29+
is included or excluded into/from the page, the retrieval direction, and a hash
2930
over the applied query filters (or the query filter itself) to safely re-create
30-
the collection. It is important to note, that a {cursor} should be always
31-
defined in relation to the current page to anticipate all occurring changes
32-
when progressing.
31+
the collection.
3332

3433
The {cursor} is usually defined as an encoding of the following information:
3534

@@ -64,8 +63,8 @@ Cursor:
6463
description: >
6564
Flag for the retrieval direction that is defining which elements to
6665
choose from the collection resource starting from the anchor elements
67-
position. It is either *ascending* or *descending* based on the
68-
ordering combined index.
66+
position. It is either *ascending* or *descending* in relation to the
67+
ordering index - usually in sync with the pagination direction.
6968
type: string
7069
enum: [ ASCENDING, DESCENDING ]
7170
@@ -88,17 +87,55 @@ Cursor:
8887
- direction
8988
----
9089

91-
*Note:* In case of complex and long search requests, e.g. when {GET-with-body}
92-
is already required, the {cursor} may not be able to include the `query` because
93-
of common HTTP parameter size restrictions. In this case the `query` filters
94-
should be transported via body - in the request as well as in the response,
95-
while the pagination consistency should be ensured via the `query_hash`.
96-
97-
*Remark:* It is also important to check the efficiency of the data-access.
98-
You need to make sure that you have a fully ordered stable index, that allows
99-
to efficiently resolve all elements of a page. If necessary, you need to
100-
provide a combined index that includes the `id` to ensure the full order and
101-
additional filter criteria to ensure efficiency.
90+
It is important to note, that {cursor} should always point to the last or first
91+
element of the current page - also `next` and `prev` cursors, depending on the
92+
retrieval direction. This allows the pagination to react on db-changes when
93+
(re-)evaluating a cursor, e.g. when elements are added or removed after the
94+
cursor was created.
95+
96+
image::assets/cursor.png[Cursor pagination illustration]
97+
98+
[cols="15%,15%,25%,25%",options="header",]
99+
|=========================================
100+
|Cursor | Position | Element | Direction
101+
| prev | F | EXCLUDED | DESCENDING
102+
| self~next~ | F | INCLUDED | ASCENDING
103+
| self~prev~ | I | INCLUDED | DESCENDING
104+
| next | I | EXCLUDED | ASCENDING
105+
|=========================================
106+
107+
*Remark:* In case of complex and long search requests, e.g. when
108+
{GET-with-body} is already required, the {cursor} may not be able to include
109+
the `query` because of common HTTP parameter size restrictions. In this case
110+
the `query` filters should be transported via body - in the request as well as
111+
in the response, while the pagination consistency should be ensured via the
112+
`query_hash`.
113+
114+
*Note:* The efficiency of cursor-based pagination depends on the efficiency
115+
of the ordering index. In most cases a single ordering index, e.g. based on
116+
`modified_at` is sufficient. Only in case of a low selectivity a combined
117+
index including the `id` or other filter criteria is needed. This should be
118+
carefully evaluated regularly on a case-by-case basis to avoid unnecessary
119+
complexity but also to avoid scalability and performance issues.
120+
121+
=== Example
122+
123+
A real-world example can be found in
124+
https://github.com/zalando-build/api-repository/[api-repository] (internal
125+
link).
126+
127+
* The actual {cursor} implementation in
128+
https://github.com/zalando-build/api-repository/blob/main/src/main/java/org/zalando/infrastructure/api/repository/domain/model/ApiRevisions.kt#L16-L132[api-repository/domain/model/ApiRevisions.kt#L16-L132]
129+
(internal) is varying from the above pattern using a cursor-`type` instead of
130+
`element` to simplify cursor representation following above figure.
131+
* The {cursor} is passed down to data access object implementation in
132+
https://github.com/zalando-build/api-repository/blob/main/src/main/java/org/zalando/infrastructure/api/repository/repository/ApiRevisionViewRepository.kt#L48-L76[api-repository/repository/ApiRevisionViewRepository.kt#L48-L76]
133+
(internal) to create the SQL request.
134+
135+
The API repository, in this case does not need any combined index, since the
136+
primary ordering based on `modified_at` allows to efficiently access the data
137+
in combination with the additional filter criteria such as `states`, `teams`,
138+
and `applications`.
102139

103140
=== Further reading
104141

0 commit comments

Comments
 (0)