Skip to content
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

api: paginated results with different ordering #12128

Merged
merged 3 commits into from
Mar 1, 2022
Merged

Conversation

lgfa29
Copy link
Contributor

@lgfa29 lgfa29 commented Feb 24, 2022

The paginator logic was built when go-memdb iterators would return items
ordered lexicographically by their ID prefixes, but #12054 added the
option for some tables to return results ordered by their CreateIndex
instead, which invalidated the previous paginator assumption.

The iterator used for pagination must still return results in some order
so that the paginator can properly handle requests where the next_token
value is not present in the results anymore (e.g., the eval was GC'ed).

In these situations, the paginator will start the returned page in the
first element right after where the requested token should've been.

This commit moves the logic to generate pagination tokens from the
elements being paginated to the iterator itself so that callers can have
more control over the token format to make sure they are properly
ordered and stable.

It also allows configuring the paginator as being ordered in ascending
or descending order, which is relevant when looking for a token that may
not be present anymore.


Note to reviewers: this PR was re-worked a few times, so I squashed and force-pused a single commit because the commit history was a mess. The resulting changes turned out to be not that big, but let me know if it would help to break it into multiple commits instead of just one.

nomad/state/paginator_test.go Outdated Show resolved Hide resolved
@lgfa29 lgfa29 requested review from tgross and shoenig February 24, 2022 20:52
@@ -88,7 +88,7 @@ func (p *Paginator) next() (interface{}, paginatorState) {
// have we found the token we're seeking (if any)?
id := raw.(IDGetter).GetID()
p.nextToken = id
if !p.nextTokenFound && id < p.seekingToken {
if !p.nextTokenFound && id != p.seekingToken {
Copy link
Member

@tgross tgross Feb 24, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if the X-Nomad-NextToken is no longer valid at the time we make this call? Don't we end up skipping all the way to the end?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hum...that's right. I may have to go with my plan B then, which was to make GetID configurable to return either ID or CreateIndex. It does create a weird API though, because you sometimes paginate with an UUID and other times with a Raft index.

Back to the drawing board 😅

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think it's the same problem either way, just sorting lexicographically vs numerically.

The paginator logic was built when go-memdb iterators would return items
ordered lexicographically by their ID prefixes, but #12054 added the
option for some tables to return results ordered by their `CreateIndex`
instead, which invalidated the previous paginator assumption.

The iterator used for pagination must still return results in some order
so that the paginator can properly handle requests where the next_token
value is not present in the results anymore (e.g., the eval was GC'ed).

In these situations, the paginator will start the returned page in the
first element right after where the requested token should've been.

This commit moves the logic to generate pagination tokens from the
elements being paginated to the iterator itself so that callers can have
more control over the token format to make sure they are properly
ordered and stable.

It also allows configuring the paginator as being ordered in ascending
or descending order, which is relevant when looking for a token that may
not be present anymore.
@lgfa29 lgfa29 force-pushed the fix-paginator-comparison branch from 463c220 to 5942f32 Compare February 28, 2022 23:16
@vercel vercel bot temporarily deployed to Preview – nomad February 28, 2022 23:17 Inactive
@lgfa29 lgfa29 changed the title fix paginator logic when IDs are not ordered api: paginated results with different ordering Feb 28, 2022
@lgfa29 lgfa29 requested a review from tgross February 28, 2022 23:20
@lgfa29 lgfa29 marked this pull request as ready for review February 28, 2022 23:20
@lgfa29 lgfa29 requested a review from schmichael February 28, 2022 23:21
@lgfa29 lgfa29 added the theme/api HTTP API and SDK issues label Feb 28, 2022
@@ -10548,14 +10548,6 @@ type Evaluation struct {
ModifyTime int64
}

// GetID implements the IDGetter interface, required for pagination
func (e *Evaluation) GetID() string {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deployment.GetID is actually used for other things, so we can't remove it.

Copy link
Member

@tgross tgross left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! I've left some comments but nothing blocking.

@@ -13,6 +13,30 @@ import (
"github.com/hashicorp/nomad/nomad/structs"
)

// DeploymentPaginationIterator is a wrapper over a go-memdb iterator that
Copy link
Member

@tgross tgross Mar 1, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API musing, feel free to ignore:

I don't love an API where we have to wrap the go-memdb iterator for every struct we want to paginate. But the only alternative that I can see is a new PageTokenizer interface implemented by Deployment, Evaluation, etc. And then Paginator could expect all the ResultIterators return results that are PageTokenizers. So that's probably not a good path to go down?

Or maybe Paginator could take a ResultIterator and a function to get the token (it'd close over byCreateIndex)? That'd be called something like:

paginator, err := state.NewPaginator(iter, args.QueryOptions,
	func(raw interface{}) error {
		deploy := raw.(*structs.Deployment)
		deploys = append(deploys, deploy)
		return nil
	},
	func(raw interface{}) string {
		deploy := raw.(*structs.Deployment)
		token := deploy.ID
		if byCreateIndex {
				token = fmt.Sprintf("%v-%v", deploy.CreateIndex, deploy.ID)
		}
	},
)

But maybe having two function parameters starts calling into question whether Paginator should be generic over the type intended to be returned by the ResultIterator. (Oh no, the G-word! 😀 )

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah actually I think generics could clean this up quite a bit. Maybe 1.4 hackathon 🥳

Copy link
Contributor Author

@lgfa29 lgfa29 Mar 1, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a new PageTokenizer interface implemented by Deployment, Evaluation, etc. And then Paginator could expect all the ResultIterators return results that are PageTokenizers.

This is similar to the approach we had before, with the GetID right? The problem I found is that the struct doesn't know about the request details, so it can't tell if it should ID or CreateIndex unless we add a new field, which sounded not great.

Or maybe Paginator could take a ResultIterator and a function to get the token

This was also something that I experimented with, but it felt like I was writing JavasScript 😅

Another interface I tried was to have a separate method in the Iterator, so like:

type Iterator interface {
	Next() interface{}
        GetToken(interface{}) string
}

But that didn't help much because you would still need to wrap the ResultIterator.

Maybe we can do it with two interfaces? If the iterator implements an interface with GetToken we would use that, otherwise we use GetID from the raw element like before.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem I found is that the struct doesn't know about the request details, so it can't tell if it should ID or CreateIndex unless we add a new field, which sounded not great.

That's a good point. We'd have to thread the CreateIndex through somehow, which ends up being the same as the "pass a function to get the token" implementation.

This was also something that I experimented with, but it felt like I was writing JavasScript 😅

😆

If the iterator implements an interface

I don't know if you can actually check that without reflection or calling a possibly non-existent method?

In any case, I think what we've got here will get the job done. We can always come back and refine it as we have more paginated API examples. (Ex. if we end up wanting to paginate an API by neither ID nor CreateIndex)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In any case, I think what we've got here will get the job done. We can always come back and refine it as we have more paginated API examples. (Ex. if we end up wanting to paginate an API by neither ID nor CreateIndex)

+1. The two interfaces worked, but it was weird to have two very different ways to integrate with the Paginator.

api/evaluations_test.go Outdated Show resolved Hide resolved
Copy link
Member

@shoenig shoenig left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Don't forget we also need to stack #12090 on top of this

@@ -13,6 +13,30 @@ import (
"github.com/hashicorp/nomad/nomad/structs"
)

// DeploymentPaginationIterator is a wrapper over a go-memdb iterator that
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah actually I think generics could clean this up quite a bit. Maybe 1.4 hackathon 🥳

@vercel vercel bot temporarily deployed to Preview – nomad March 1, 2022 20:04 Inactive
@lgfa29 lgfa29 merged commit b246227 into main Mar 1, 2022
@lgfa29 lgfa29 deleted the fix-paginator-comparison branch March 1, 2022 20:36
@github-actions
Copy link

I'm going to lock this pull request because it has been closed for 120 days ⏳. This helps our maintainers find and focus on the active contributions.
If you have found a problem that seems related to this change, please open a new issue and complete the issue template so we can capture all the details necessary to investigate further.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Oct 27, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
theme/api HTTP API and SDK issues
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants