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

Wait for owned series recomputation before lowering local series limit #7411

Merged
merged 31 commits into from
Feb 29, 2024

Conversation

pr00se
Copy link
Contributor

@pr00se pr00se commented Feb 16, 2024

What this PR does

The existing owned series implementation prevents discards when the local series limit is lowered due to a shard size increase. It does this by caching the current shard size until after the owned series are recomputed, only using the new (higher) shard size once the computation successfully completes.

However, it does not prevent discards when the lowered local limit is due to an increase in ingester count (e.g.: when shuffle sharding is disabled); the lower local limit takes effect immediately, and samples are discarded until the next owned series computation (at most ingester.owned-series-update-interval seconds later). This is due to the limiter fetching the ingester count from the ring lifecycler directly, meaning it picks up changes before the owned series service has a change to run and update the owned series count.

To address this, this PR caches the calculated local series limit on each tenant's userDB whenever owned series are recomputed, and uses this value as a minimum when calculating local limits during push requests. Because the cached value functions as a minimum, local limits are able to increase instantaneously, but can't be lowered until after the next owned series computation.

Summary of changes:

  • pkg/ingester/limiter.go: limiter takes minLocalLimit as a parameter instead of userShardSize when calculating local limits. This is 0 for every limit except for the user series limit.
  • pkg/ingester/user_tsdb.go: when we recompute the owned series count, we get the local series limit from the limiter and cache this value on the userDB. This value is passed as a minimum to the limiter when checking the series limit in PreCreation.
  • pkg/ingester/owned_series.go: added a new owned series recompute reason, recomputeOwnedSeriesReasonLocalLimitChanged, which is used when the user's calculated local series limit changes without any change to ingester or shard counts. This means that we'll recompute the owned series whenever ONLY the global series limit for a user changes, but this is necessary to correctly enforce lowered series limits (without this it wouldn't be possible to have a lowered series limit take effect until the user's actual subring changed).

The bulk of the changes, by line count, are to tests:

  • added the option to pass the ingesterRing to the ingester creation helpers in ingester_test.go
  • added two e2e tests for owned series, checking for discards when local limits are lowered by shard and ingester count increases
  • added an array of tests for the owned series service, both for when recomputation is triggered, and the limiting behavior

Which issue(s) this PR fixes or relates to

Fixes #5578

Checklist

  • Tests updated.
  • Documentation added.
  • CHANGELOG.md updated - the order of entries should be [CHANGE], [FEATURE], [ENHANCEMENT], [BUGFIX].
  • about-versioning.md updated with experimental features.

@pr00se pr00se force-pushed the owned-series-limiting branch 4 times, most recently from e9e1b92 to 4edbf9b Compare February 17, 2024 17:11
@pr00se pr00se changed the title Owned series limiting Wait for owned series recomputation before lowering local series limit Feb 18, 2024
@pr00se pr00se marked this pull request as ready for review February 18, 2024 23:13
@pr00se pr00se requested review from a team and jdbaldry as code owners February 18, 2024 23:13
Copy link
Member

@jdbaldry jdbaldry left a comment

Choose a reason for hiding this comment

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

Documentation changes look good to me.

Copy link
Collaborator

@pracucci pracucci left a comment

Choose a reason for hiding this comment

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

Good job! The logic changes make sense to me, but I haven't reviewed tests yet. Also @pstibrany is more familiar with all such work, so would be great if you could review it too. Tomorrow I will review tests.

pkg/ingester/user_tsdb.go Show resolved Hide resolved
@@ -135,7 +137,7 @@ func (l *Limiter) convertGlobalToLocalLimit(userShardSize int, globalLimit int)
// Global limit is equally distributed among all the active zones.
// The portion of global limit related to each zone is then equally distributed
// among all the ingesters belonging to that zone.
return int((float64(globalLimit*l.replicationFactor) / float64(zonesCount)) / float64(ingestersInZoneCount))
return max(int((float64(globalLimit*l.replicationFactor)/float64(zonesCount))/float64(ingestersInZoneCount)), minLocalLimit)
Copy link
Collaborator

Choose a reason for hiding this comment

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

[nit] May be slightly easier to read:

Suggested change
return max(int((float64(globalLimit*l.replicationFactor)/float64(zonesCount))/float64(ingestersInZoneCount)), minLocalLimit)
desiredLocalLimit := int((float64(globalLimit*l.replicationFactor)/float64(zonesCount))/float64(ingestersInZoneCount))
return max(desiredLocalLimit, minLocalLimit)

Copy link
Member

Choose a reason for hiding this comment

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

I would actually suggest moving this into convertGlobalToLocalLimitOrUnlimited. This is not part of "conversion", and in my PR #7424 I'm introducing different conversion when partition-ring is used.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Moved the max up a level in 8ce33a75fdf7a0e5b0801e6be16fa6e392defdb5

@@ -289,8 +294,8 @@ func (u *userTSDB) PreCreation(metric labels.Labels) error {
}

// Total series limit.
series, shards := u.getSeriesAndShardsForSeriesLimit()
if !u.limiter.IsWithinMaxSeriesPerUser(u.userID, series, shards) {
series, minLimit := u.getSeriesAndMinForSeriesLimit()
Copy link
Collaborator

Choose a reason for hiding this comment

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

[nit] I suggest to be consistent with naming. It's called minLocalLimit in other places and I suggest to call it minLocalLimit here as well (it's clearer and keep consistency).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

changed in 82e5e8229ddce202770927c7eda43a06a502ac63

// series limit.
func (u *userTSDB) getSeriesAndShardsForSeriesLimit() (int, int) {
func (u *userTSDB) getSeriesAndMinForSeriesLimit() (int, int) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

[nit] WDYT?

Suggested change
func (u *userTSDB) getSeriesAndMinForSeriesLimit() (int, int) {
func (u *userTSDB) getSeriesCountAndMinLocalLimit() (int, int) {

Copy link
Contributor Author

Choose a reason for hiding this comment

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

renamed in 5e79268c2ca3ab4227e0ff563420f507342c5dd0

pracucci
pracucci previously approved these changes Feb 20, 2024
Copy link
Collaborator

@pracucci pracucci left a comment

Choose a reason for hiding this comment

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

I also reviewed the tests. Nice job on unit tests! Not super excited about all the sleeps in the integration tests and I'm wondering if these new tests are really necessary (looks your unit tests are relatively high level anyway) or we can improve the integration tests to remove some of the sleeps.

@@ -419,6 +419,7 @@ func TestLimiter_AssertMaxSeriesPerUser(t *testing.T) {
ringReplicationFactor int
Copy link
Collaborator

Choose a reason for hiding this comment

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

[nit] Can you rename the test function to TestLimiter_IsWithinMaxSeriesPerUser() given that's what we test here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done in cbd21dcd289e38b358f65326a809a4b1ef992849. I also renamed the other tests that were similarly misnamed.

// initial limit
c.checkCalculatedLocalLimit(t, ownedServiceTestUserSeriesLimit, "")

// we'd normally scale up all zones at once, but doing it one by one lets us
Copy link
Collaborator

Choose a reason for hiding this comment

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

we'd normally scale up all zones at once

Scaling is never atomic. Ingesters are added to the ring at different times. So even if we increase the replicas count of different zones at different times, the new ingesters will register to the ring at slightly different time. That's just to say that your test LGTM, but I would clarify this comment to match what happens in reality.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Same comment applies below.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

These tests were deleted

Comment on lines 1201 to 1202
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

These tests are a bit slow. WDYT parallelising them?

Suggested change
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
for name, tc := range testCases {
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done in 53836caef913ecb8305c79dd22d18c675a2ce8d6

gen.Wait()

// Wait for owned series service to run and metrics to get updated
// Metrics are updated only every 15 seconds :/
Copy link
Collaborator

Choose a reason for hiding this comment

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

Where is the 15s hardcoded?

Copy link
Member

Choose a reason for hiding this comment

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

Is that referring to 15s default value in -ingester.owned-series-update-interval?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We hard-code it here:

limitMetricsUpdateTicker := time.NewTicker(time.Second * 15)

Copy link
Member

@pstibrany pstibrany left a comment

Choose a reason for hiding this comment

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

Great work, I've left some minor comments and suggestions.

CHANGELOG.md Outdated Show resolved Hide resolved
pkg/ingester/user_tsdb.go Outdated Show resolved Hide resolved
pkg/ingester/user_tsdb.go Outdated Show resolved Hide resolved
@@ -135,7 +137,7 @@ func (l *Limiter) convertGlobalToLocalLimit(userShardSize int, globalLimit int)
// Global limit is equally distributed among all the active zones.
// The portion of global limit related to each zone is then equally distributed
// among all the ingesters belonging to that zone.
return int((float64(globalLimit*l.replicationFactor) / float64(zonesCount)) / float64(ingestersInZoneCount))
return max(int((float64(globalLimit*l.replicationFactor)/float64(zonesCount))/float64(ingestersInZoneCount)), minLocalLimit)
Copy link
Member

Choose a reason for hiding this comment

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

I would actually suggest moving this into convertGlobalToLocalLimitOrUnlimited. This is not part of "conversion", and in my PR #7424 I'm introducing different conversion when partition-ring is used.

pkg/ingester/owned_series.go Outdated Show resolved Hide resolved
pkg/ingester/owned_series_test.go Show resolved Hide resolved
Comment on lines 212 to 215
// wait for the ingester to see the updated ring
test.Poll(t, 2*c.ringHeartbeatPeriod, 2, func() interface{} {
return c.ing.lifecycler.InstancesCount()
})
Copy link
Member

Choose a reason for hiding this comment

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

Can we add these waits into register methods? Simple idea is to check instances count before registration, and after, and check if it's before+1. It would simplify tests a bit.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed these checks entirely, since the limiter no longer uses the lifecycler

c.updateOwnedSeriesAndCheckResult(t, false, 1, recomputeOwnedSeriesReasonShardSizeChanged)
c.checkUpdateReasonForUser(t, "")
c.checkUserSeriesOwnedAndShardsByTestedIngester(t, ownedServiceSeriesCount/2, 2)
"shard size = 0, scale ingesters up and down": {
Copy link
Member

Choose a reason for hiding this comment

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

or shard size = 100

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should i run a separate test with shard size >> number of ingesters?

Copy link
Member

Choose a reason for hiding this comment

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

Maybe just for completeness, to make sure it doesn't break in the future.

pkg/ingester/owned_series_test.go Outdated Show resolved Hide resolved
@@ -374,6 +756,529 @@ func TestOwnedSeriesRingChanged(t *testing.T) {
})
}

func TestOwnedSeriesLimiting(t *testing.T) {
Copy link
Member

Choose a reason for hiding this comment

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

Is this test useful? Limiter already has its own limits, and we also check computed min-local limit in TestOwnedSeriesService test.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed -- deleted the test

@pr00se pr00se force-pushed the owned-series-limiting branch 2 times, most recently from d3d975d to acc9668 Compare February 27, 2024 15:38
@pracucci pracucci self-requested a review February 28, 2024 07:19
@pracucci pracucci dismissed their stale review February 28, 2024 07:20

Dismissing my own review because of recent changes that I still have to review

Copy link
Member

@pstibrany pstibrany left a comment

Choose a reason for hiding this comment

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

Great work, thank you!

require.NoError(t, err)

strategy := newPartitionRingLimiterStrategy(&partitionRingHolder{pr: pr}, limits.IngestionTenantShardSize)
strategy := newPartitionRingLimiterStrategy(&partitionRingHolder{pr: pr}, limits.IngestionPartitionsTenantShardSize)
Copy link
Member

Choose a reason for hiding this comment

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

Thanks for catching this bug.

@pr00se pr00se merged commit 6190ed0 into main Feb 29, 2024
29 checks passed
@pr00se pr00se deleted the owned-series-limiting branch February 29, 2024 15:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
4 participants