-
Notifications
You must be signed in to change notification settings - Fork 242
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
…laris into pagination-persistence
@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.
@@ -413,4 +415,7 @@ boolean hasChildren( | |||
default BasePersistence detach() { | |||
return this; | |||
} | |||
|
|||
/** Construct a {@link PageToken} from a {@link PageRequest} */ | |||
PageToken buildPageToken(PageRequest 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.
I guess this does not have to be in the BasePersistence
interface 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.
Since all 3 subtypes use it, I think it should be there? It also makes it explicit that when implementing a new BasePersistence
, you should implement this method.
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.
: 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.
|
||
public Page<T> filter(Predicate<T> predicate) { | ||
return new Page<>( | ||
this.pageToken, this.items.stream().filter(predicate).collect(Collectors.toList())); |
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.
This can result in empty pages... Is that intended?
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.
Yep! It can happen today, too, with subtype filtering
resultPage.items.stream() | ||
.filter(rec -> rec.getSubTypeCode() == entitySubType.getCode()) | ||
.collect(Collectors.toList())); | ||
resultPage.filter( |
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 believe it is preferable to keep invoking ms.listEntities(...)
until we either exhaust the result set or build a full page... but this can be fixed later if you prefer.
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.
In metastores where it matters like JdbcBasePersistenceImpl
, we do exactly that by pushing the filter down. This is therefore currently modeled a persistence concern though, not a MetastoreManager concern.
FWIW, this method is very rarely called and soon I think it will never be called.
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
.
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
.
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.