-
Notifications
You must be signed in to change notification settings - Fork 249
Persistence implementations for list pagination #1555
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
1c383cf
to
66d20aa
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think that the approach implemented here yields correct results. The code assumes strict ordering of integer IDs, which is from general experience w/ relational DBs and in particular looking at org.apache.polaris.extension.persistence.relational.jdbc.IdGenerator
not the case.
@@ -414,6 +415,11 @@ public <T> Page<T> listEntities( | |||
// Limit can't be pushed down, due to client side filtering | |||
// absence of transaction. | |||
String query = QueryGenerator.generateSelectQuery(new ModelEntity(), params); | |||
|
|||
if (pageToken instanceof EntityIdPageToken entityIdPageToken) { | |||
query += String.format(" AND id > %d ORDER BY id ASC", entityIdPageToken.getId()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How would this work with org.apache.polaris.extension.persistence.relational.jdbc.IdGenerator
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you're right that it won't; this logic is copied from EclipseLink where IDs are always increasing but does not work with the current way that the JDBC metastore creates IDs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd propose to change the Page/PageToken contract in a way to push the parameter "as is" down to the persistence layer and let the persistence implementation deal with it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I spoke with @singhpk234 who noted this is probably the same discussion as here on the old PR. With that context, I think we might be OK here.
IMO it's alright that the list ordering you'd get across metastores won't be the same. Other than that difference, seems like everything should work with JDBC's IdGenerator
. Although the IDs aren't generated sequentially, pagination only uses the entity ID as an essentially arbitrary consistent ordering.
The key implication here is that if an entity gets created in the middle of a listing operation (e.g. between list calls 2 and 3) it may or may not show up in the next page. An alternative would be to try to filter it out so that the behavior is more obvious & consistent, but I think the simple approach that ultimately gives the user a chance to see these new entities is good.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Losing new entities that are stored after pagination start is fine from my POV. The JDBC persistence does not implement catalog-level versioning, so this is unavoidable, I guess.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed that we will naturally lose some entities, the question is whether we are OK with entities stored after pagination start being lost nondeterministically rather than always. Right now, whether the new entity is lost or not depends on what entity ID it gets. If it gets a high entity ID you might see it in a later page and if it gets a low ID you might not.
My thought on this question is "yes", because it's better to show the entity if we can and it simplifies the code.
But if we feel like this is too unintuitive, we can add a secondary filter on the entity's creation time to try and get rid of these entities (on a best-effort basis, since clocks are not perfect).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think current pagination behaviour wrt concurrent changes is fine.
Making it deterministic would be a great addition to Polaris, but that, I think, has a much broader scope. For example, if an entry is deleted after pagination starts, but a client re-submits a page request using an old token, the new response would still be inconsistent with the old response.
From my POV a complete and deterministic pagination solution implies catalog-level versioning.
On this note, I think it's not true actually. The code assumes that IDs are sortable but it doesn't rely on any kind of semantic meaning behind this comparison. So IDs can be created totally randomly and you can still paginate simply by breaking that random key space into pages of some size. There's no assumption that new entries will appear at the end, either. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Small comments, overall LGTM
try { | ||
String[] parts = token.split("/"); | ||
if (parts.length < 1) { | ||
throw new IllegalArgumentException("Invalid token format: " + token); | ||
} else if (parts[0].equals(EntityIdPageToken.PREFIX)) { | ||
int resolvedPageSize = pageSize == null ? Integer.parseInt(parts[2]) : pageSize; | ||
return new EntityIdPageToken(Long.parseLong(parts[1]), resolvedPageSize); | ||
} else { | ||
throw new IllegalArgumentException("Unrecognized page token: " + token); | ||
} | ||
} catch (NumberFormatException | IndexOutOfBoundsException e) { | ||
LOGGER.debug(e.getMessage()); | ||
throw new IllegalArgumentException("Invalid token format: " + token); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I, personally, find this fragment a bit more complex than it may need to be. Is there a reason why we cannot defensively check for the right amount array length after splitting it right away? Same for the NumberFormationException?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wanted to structure the code in a way that obviously leaves the door open for other PageToken
implementations -- those would have different array length expectations. So we check the prefix first, and then parse the token using the logic appropriate for the PageToken
implementation that the prefix corresponds to. Ideally we could even push this parsing logic down into some method in the PageToken
.
In the old PR, there were 2 parseable PageToken
implementations. I do agree that it looks a little clunky with the single PageToken
implementation we have now. If this really is too confusing I can simplify this logic and then we can re-complicate it if/when we add a new PageToken
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this really is too confusing I can simplify this logic and then we can re-complicate it if/when we add a new PageToken.
I'd personally prefer this - but don't care enough if we do this or not, I can understand the reasoning.
...ce/common/src/test/java/org/apache/polaris/service/persistence/pagination/PageTokenTest.java
Show resolved
Hide resolved
...ce/common/src/test/java/org/apache/polaris/service/persistence/pagination/PageTokenTest.java
Show resolved
Hide resolved
@snazy : could you have another look? I believe (random) ID sorting/ordering is not an issue (at least not in the latest code). Thx! |
...n/java/org/apache/polaris/extension/persistence/relational/jdbc/JdbcBasePersistenceImpl.java
Outdated
Show resolved
Hide resolved
@@ -428,7 +443,7 @@ public <T> Page<T> listEntities( | |||
List<T> resultsOrEmpty = | |||
results == null | |||
? Collections.emptyList() | |||
: results.stream().filter(entityFilter).map(transformer).collect(Collectors.toList()); | |||
: results.stream().map(transformer).collect(Collectors.toList()); | |||
return Page.fromItems(resultsOrEmpty); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not part of this PR, but Page.fromItems()
always uses DonePageToken
. This looks very confusing... I'll try and review closer again later 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it make sense to rename Page.fromItems()
to Page.finalPage()
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not in this PR, but why is ListEntitiesResult.pageTokenOpt
optional? Shouldn't all list operations have a page token now (a "done" token if the result set is not paginated)?
My concern about implementing pagination in |
Hey @snazy, persistence implementations are not forced to implement pagination at all here. I don't think we should block a functioning implementation of pagination for the existing metastores because a future metastore implementation may require changes in order to support pagination. |
I don't "block" it - I'm advocating to make it agnostic. |
Got it @snazy -- my point was more that we can always change the implementation if it doesn't work for some case down the road. What could we do to make this more agnostic now? From what I understand, the nosql metastore could still work with an EntityIdPageToken. If it doesn't, we'd just implement a new PageToken in |
You can make the token opaque to the service, only interpreted and produced by the metastore implementations. |
Is there a way I can do that without a bunch of redundant code? Right now we have 3 metastores that can all use the same token implementation, so I wouldn't want to have 3 copies of that code. Maybe there's a common module I can use? |
I suppose shared page token classes can be in |
@dimas-b, would this also necessitate the creation of a new type which covers both the opaque (string) token and the page-size? I am worried that we are introducing a lot of complexity given that currently all page token types are in fact shared. |
I think it is preferable to delegate all handling of tokens to the Persistence implementations. We can certainly have a generic holder type (e.g. This should also allow for layered token (should we ever have to paginate over derivatives of lower-level paginated lists). Also, I think we should distinguish token data in requests and responses. Requests need to provide two pieces of data: A1) a flag whether pagination is requested; A2) page size hint; A3) previous page token. Responses provide B1) actual page size, B2) next page token. Token A3 is parsed by the same code that produced token B2. B1 may not equal A2. Implementations may limit repose sizes when A1 is With that, I think the complexity will actually be reduced and specific token details only need to be considered by the places in code that have to take action based on pagination parameters. WDYT? |
@dimas-b per the spec, the request provides Turning this I'll make some changes to put some of this logic back -- but since the page token types are all shared, I'll try to keep the actual types in core for now. |
We can certainly keep the parsing / encoding logic in Here's the pseudo-code that might work, I think:
|
np @dimas-b, happy to iterate and try to get it right. I tried to push some new changes based on your guidance above which is very helpful. I think this is still cleaner than what we had before, too. Let me know what you think! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the update, @eric-maynard ! From my POV the PR is moving in the right direction :) some more comments below.
*/ | ||
public class PageRequest { | ||
private final Optional<String> pageTokenStringOpt; | ||
private final Optional<Integer> pageSizeOpt; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OptionalInt
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are they functionally different? I haven't come across OptionalInt
before but since pageTokenStringOpt
is an Optional<String>
I thought to just keep both Optional
s the same rather than introducing some new type.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Integer
can create fluff on the heap for larger values. The Optional
side is the same.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are we really concerned about a few bytes on the heap per each request? By "fluff" you just mean a reference right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I mean a short-lived Integer
object... Not a big deal in this case, just a bit sloppy :)
polaris-core/src/main/java/org/apache/polaris/core/persistence/BasePersistence.java
Outdated
Show resolved
Hide resolved
public static PageToken fromPageRequest(PageRequest pageRequest) { | ||
if (pageRequest.getPageTokenString().isEmpty()) { | ||
if (pageRequest.getPageSize().isEmpty()) { | ||
return PageToken.readEverything(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is a bit awkward for a specific conversion method (in class EntityIdPageToken
) to return a less specific type (PageToken
).
WDYT about using boolean PageRequest.readEverything()
instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah so this is exactly why previously this logic lived in PageToken
which is the less specific type.
We actually should have even more types here, since fromLimit
could be optimized return you a PageToken
that limits but doesn't sort, instead of always sorting as we have now.
WDYT about using boolean PageRequest.readEverything() instead?
Can you say more about this? We need a way to be able to call buildPageToken
in the persistence layer and to get back a PageToken
. In the future, we may also have different methods within a given persistence implementation using different page token types, which is why previously the parsing happened up the call stack.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I mean, the code that needs to extract a specific token from PageRequest
already knows the exact token class. The "read everything" case is the only exception.
I do not see a need for polymorphic tokens at this state of the code. Each Persistence uses only one token type.
If we later have polymorphic token use cases, let's handle it then and keep the code simple for now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I mean, the code that needs to extract a specific token from PageRequest already knows the exact token class. The "read everything" case is the only exception.
It's not the only exception -- to make this more clear I've added the limit token type I mentioned above. Now in the current state of the code we have two "exceptions".
Each Persistence uses only one token type.
Each one uses 3 now, and even if you exclude the 2 simple types it's obvious that we will need another type soon for paginating non-entity requests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Happy to go back to this just being PageToken.buildPageToken
if the objection is specifically to EntityIdPageToken
letting you build different token types. I think the logic is better encapsulated in PageToken
anyway.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, I guess we're not understanding each other here 😅
I believe page size should not be part of PageToken
at all. Instead, let's keep it in PageRequest
.
From a higher level view: a client gets some page token, after that the client is able to request the next page of any size. The next page size is not restricted by the token from the previous response.
I'd like to have a similar delineation of concerns on the server side too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Page size is in the string page-token. The reasons for this were discussed in the previous PR. PageToken
is the deserialized string page token.
: results.stream().filter(entityFilter).map(transformer).collect(Collectors.toList()); | ||
return Page.fromItems(resultsOrEmpty); | ||
List<T> resultsOrEmpty = results.stream().map(transformer).collect(Collectors.toList()); | ||
return pageToken.buildNextPage(resultsOrEmpty); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like buildNextPage
does not have to be a function of the previous page token. It is a function of page request + data + persistence impl. WDYT about: PageRequest.buildPage(List<T> data, Function<T, PageToken> nextToken)
?
Here, the call would look like: return pageRequest.buildPage(resultsOrEmpty, EntityIdPageToken::fromLastItem)
Note: the nextToken
function will be invoked only when the next page is expected (i.e. not "done" and not "everything").
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Side note: if we want to avoid empty last pages (when the list ends exactly on the last element from the query) we may need to request one more entry from the database, but not return it to the caller.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like buildNextPage does not have to be a function of the previous page token.
In this particular implementation, no. In other implementations, such as the previous OffsetPageToken
, it is a function of the previous token. Beyond that, this seems intuitive to me because you're essentially adding data to one page to get a new page.
Empty last pages are fine.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OffsetPageToken
does not exist (anymore).
That aside, what information would need to flow from the previous page token to the next directly? I suppose all of that is inside PageRequest
now 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That aside, what information would need to flow from the previous page token to the next directly?
In the case of OffsetPageToken
, the new token's offset was data.size + currentToken.offset
. You need the current token, not just the data.
In the case of EntityIdPageToken
, you can use the current token's entity ID to validate the new data comes after the current token.
I suppose all of that is inside PageRequest now
In the sense that PageRequest
is entirely duplicative of PageToken
, yes.
polaris-core/src/main/java/org/apache/polaris/core/persistence/pagination/Page.java
Show resolved
Hide resolved
...-core/src/main/java/org/apache/polaris/core/persistence/AtomicOperationMetaStoreManager.java
Show resolved
Hide resolved
public static final long BASE_ID = MINIMUM_ID - 1; | ||
|
||
private final long entityId; | ||
private final int pageSize; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the new code it looks like pageSize
is redundant here. This information is defined by PageRequest
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PageRequest
is really only used to get you to a PageToken
, so I think it's okay to have both. The page size is fundamentally part of the token.
If anything, PageRequest
in its entirety seems redundant since all the relevant information can immediately be represented in a PageToken
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMHO, a page token is a pointer into the continuation of the response stream. How much is fetched from that stream (size) is not part of the token. This is why I suggested introducing PageRequest
.
As I commented elsewhere, in general, the page size may be limited by other factors (sometimes outside user or server's control), e.g. message sizes at the protocol level. Filtering (some example in this PR) affect the page size too. Also, if (in general) two paginated streams are merged, the resultant page size is not necessarily related to the lower stream page sizes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How much is fetched from that stream (size) is not part of the token.
Why not?
the page size may be limited by other factors
Limits, i.e. running out of data, are not relevant here. The PageToken
is a request from the user -- the server may ignore the requested page size just like it may ignore the requested starting point (e.g. entity ID). It does both of these things today.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As I've just commented in another thread, I think it would be nice to track request data in PageRequest
and use PageToken
only for dealing with "pointer to the next piece of data" aspects.
this.pageSizeOpt = Optional.ofNullable(pageSize); | ||
} | ||
|
||
public static PageRequest readEverything() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess ReadEverythingPageToken
is no longer necessary now?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's used in quite a few places, wdym? We could try to rely on isPaginationRequested
everywhere but the code currently actually never calls that method. There could be places in the code that have a PageToken
and not a PageRequest
, and in that case we'd need ReadEverythingPageToken
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tend to think that "read everything" is represented by PageRequest
. Implementations then implement those requests and provide the appropriate token in the response.
In my mind a page token represents a "continuation" of data, but then there's no continuation to "everything" :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tend to think that "read everything" is represented by PageRequest
PageToken
still describes the page of data a user wants. ReadEverythingPageToken
means they want to read everything. We could model this as a lack of a page token, but this simplifies all the code so that it can just handle a PageToken
.
We need a way to represent that there's no more continuation, too, which ReadEverythingPageToken
lets us do.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the effort to simplify things.
I've got a proposal to further simplify it a bit:
The page token is from a REST API point-of-view an opaque response field from the server so that the client can send a follow-up request with that token to skip already seen results.
The page size is a client (proposal) for the amount of items it wants for a list request.
Bundling those two fields in PageRequest
is completely fine, implementations can check whether a token and/or page-size is present - and also enforce a hard limit on the page-size if needed.
However, I think that the PageToken
should only contain the opaque token, as it's rather a response-only thing. It cannot "force" or "ask" a client to use a page-size.
WDYT?
So This doesn't force the client to do anything, but it lets the client re-use the page size from the previous request if it wants to. |
Following up on apache#1555 * Refactor pagination code to delineate page requests and tokens. * Requests deal with the "previous" token, user-provided page size (optional) and the previous request's page size. * Inner page tokens deal only with the Persistence-specific way to point into the (logically) sorted dataset to connected previous and next pages. * Concentrate the logic of combining page size requests and previous tokens in PageTokenUtil * Only one inner page token impl. is necessary now: EntityIdPageToken.
In #1528, we introduced the interface changes necessary to paginate requests to listTables, listViews, and listNamespaces. This PR adds the persistence-level logic for pagination and a new PageToken type
EntityIdPageToken
used to paginate requests based on entity ID.