Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/desync/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type cacheOptions struct {
cache string
ignoreIndexes []string
ignoreChunks []string
throttleRateMillis int
}

func newCacheCommand(ctx context.Context) *cobra.Command {
Expand Down
8 changes: 8 additions & 0 deletions copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import (
// store to populate a cache. If progress is provided, it'll be called when a
// chunk has been processed. Used to draw a progress bar, can be nil.
func Copy(ctx context.Context, ids []ChunkID, src Store, dst WriteStore, n int, pb ProgressBar) error {

in := make(chan ChunkID)
g, ctx := errgroup.WithContext(ctx)


// Setup and start the progressbar if any
pb.SetTotal(len(ids))
pb.Start()
Expand All @@ -22,6 +24,9 @@ func Copy(ctx context.Context, ids []ChunkID, src Store, dst WriteStore, n int,
// Start the workers
for i := 0; i < n; i++ {
g.Go(func() error {



for id := range in {
pb.Increment()
hasChunk, err := dst.HasChunk(id)
Expand All @@ -31,7 +36,10 @@ func Copy(ctx context.Context, ids []ChunkID, src Store, dst WriteStore, n int,
if hasChunk {
continue
}


chunk, err := src.GetChunk(id)

if err != nil {
return err
}
Expand Down
36 changes: 36 additions & 0 deletions copy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package desync

import (
"context"
"testing"

"github.com/stretchr/testify/require"
)

func TestCopy(t *testing.T) {
src_store_dir := t.TempDir()
dst_store_dir := t.TempDir()

src_store, err := NewLocalStore(src_store_dir, StoreOptions{})
require.NoError(t, err)

dst_store, err := NewLocalStore(dst_store_dir, StoreOptions{})
require.NoError(t, err)

first_chunk_data := []byte("some data")
first_chunk := NewChunk(first_chunk_data)
first_chunk_id := first_chunk.ID()

src_store.StoreChunk(first_chunk)
has_the_stored_chunk, _ := src_store.HasChunk(first_chunk_id)
require.True(t, has_the_stored_chunk)

chunks := make([]ChunkID, 1)
chunks[0] = first_chunk_id

Copy(context.Background(), chunks, src_store, dst_store, 1, NewProgressBar(""))
require.NoError(t, err)
has_the_chunk, _ := dst_store.HasChunk(first_chunk_id)

require.True(t, has_the_chunk)
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ require (
golang.org/x/oauth2 v0.7.0 // indirect
golang.org/x/term v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
Expand Down
77 changes: 77 additions & 0 deletions ratelimitstore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package desync

import (
"context"
"fmt"
"time"

"github.com/pkg/errors"
"golang.org/x/time/rate"
)

type ThrottleOptions struct {
eventRate float64
burstRate int
timeout time.Duration
immediateOrFail bool
}

type RateLimitedLocalStore struct {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Why the Local in the name. This could limit any kind of store.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct. I'll fix. I think I was originally wrapping a local store only.


wrappedStore WriteStore

limiter *rate.Limiter

options ThrottleOptions

}

var RateLimitedExceeded = errors.New("Rate Limit Exceeded")

func NewRateLimitedLocalStore(s WriteStore, options ThrottleOptions) *RateLimitedLocalStore {

limiter := rate.NewLimiter(rate.Limit(options.eventRate), options.burstRate)
return &RateLimitedLocalStore{wrappedStore: s,limiter: limiter, options: options }
}

func (s RateLimitedLocalStore) GetChunk(id ChunkID) (*Chunk, error) {

return s.wrappedStore.GetChunk(id)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

If you wanted to limit reads as well (and I think we should), it would make sense to first call GetChunk() on the underlying store, then wait after we have the data. That isn't super-precise in terms of limiting since there could be bursts of maxChunkSize * n at startup but over time it should even out.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. I'll invert the order and add in support for reads.

}

func (s RateLimitedLocalStore) HasChunk(id ChunkID) (bool, error) {


return s.wrappedStore.HasChunk(id)
}


func (s RateLimitedLocalStore) StoreChunk(chunk *Chunk) error {

// This isn't ideal because what I'm really interested is in size over the wire.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I think you can actually achieve this by using a limit of "bytes per second" (must be equal or larger than max-chunk-size). The here in StoreChunk() you can

	b, err := chunk.Data()
	if err != nil {
		return err
	}
	err = s.limiter.WaitN(ctx, len(b))
	if err != nil {
		return RateLimitedExceeded
	}
        return s.wrappedStore.StoreChunk(chunk)

It's a little more interesting in GetChunk() since you don't know how large it is. One way would be to WaitN(ctx, <avg-chunk-size>) before calling the wrapped store, or WaitN(ctx, <size of data>) after actually pulling the chunk from the wrapped store. The latter can cause spikes early on when concurrency is used, but should average to the rate you set over time.

As for HasChunk(), not sure if you really need to limit it, it's very small. You can just use 1 like you have now if you want to limit it too, could do that before calling the wrappedStore.

_, err := chunk.Data()
if err != nil {
return err
}

//size := len(b)
ctx, cancel:= context.WithTimeout(context.Background(), s.options.timeout)
defer cancel()

if s.options.immediateOrFail{

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Do you actually need immediateOrFail ? Seems unexpected to cause a failure because the rate-limit was exceeded.

@bearrito bearrito Mar 10, 2024

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I could go either way. I guess I'd lean towards removal.

Related:
I'm not sure what the overall failure handling should be, I guess a client normally wants to wait, but I don't like naked waits with no timeout ability. Perhaps timeouts should be default large.

We could have two paths, one where we use a context with timeout and one without. But that's another code path.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

It'd certainly be nice to have a context on all these functions but that's a major refactoring of the library that should be done in a separate PR.

The wait really depends on the rate-limit here, and I don't think it should hang forever. Adding a timeout seems overly complex and I'd remove that. It might be possible to calculate some upper limit based on the rate-limit used and the max chunk size perhaps.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

To confirm my understanding:

  1. Remove the timeout from the context.
  2. Allow the ops to wait indefinitely, with no timeout.

I'm ultimately only concerned with throttling cache operations against my local store, so I think that's fine for me. However, I haven't looked at the serving code much, is there a chance that this affects HTTP handling?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Correct, no custom timeout. This can be implemented some day when contexts are added to all store operations.

This shouldn't affect HTTP stores at all, since we're not really limiting the data-rate, but the speed at which whole chunks can be read/stored. So if the rate-limiter allows one chunk to be accessed, that will be transferred at max speed which means the HTTP timeouts apply like normal. Chunk downloading is limited to the rate-limit on average, not for individual chunks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I added a test with the HttpHandler. There is a way to reproduce an error.

goroutine 125 [select]:
golang.org/x/time/rate.(*Limiter).wait(0xc0001df360, {0xe27338, 0x13ceb80}, 0x1, {0x0?, 0x0?, 0x139e0c0?}, 0xd56f30)
        /home/bstrausser/go/pkg/mod/golang.org/x/time@v0.5.0/rate/rate.go:285 +0x3e5
golang.org/x/time/rate.(*Limiter).WaitN(0x79e845e27a40b76?, {0xe27338, 0x13ceb80}, 0x269063b6f56504d5?)
        /home/bstrausser/go/pkg/mod/golang.org/x/time@v0.5.0/rate/rate.go:248 +0x50
github.com/folbricht/desync.RateLimitedStore.HasChunk({{0xe27dc0, 0xc0003f2b00}, 0xc0001df360, {0x3fc999999999999a, 0x1}}, {0x76, 0xb, 0xa4, 0x27, 0x5e, ...})
        /home/bstrausser/Git/desync/ratelimitstore.go:59 +0x7e
github.com/folbricht/desync.HTTPHandler.head({{{0xceccfb, 0x5}, 0x1, {0x0, 0x0}}, {0xe273e0, 0xc00040cd50}, 0x0, {0xc0003f0990, 0x1, ...}, ...}, ...)
        /home/bstrausser/Git/desync/httphandler.go:77 +0x4e
github.com/folbricht/desync.HTTPHandler.ServeHTTP({{{0xceccfb, 0x5}, 0x1, {0x0, 0x0}}, {0xe273e0, 0xc00040cd50}, 0x0, {0xc0003f0990, 0x1, ...}, ...}, ...)
        /home/bstrausser/Git/desync/httphandler.go:47 +0x266
net/http.serverHandler.ServeHTTP({0xc001bda720?}, {0xe25808?, 0xc00061c2a0?}, 0x6?)
        /usr/local/go/src/net/http/server.go:2938 +0x8e
net/http.(*conn).serve(0xc0006a0c60, {0xe275d8, 0xc00040d1a0})
        /usr/local/go/src/net/http/server.go:2009 +0x5f4
created by net/http.(*Server).Serve in goroutine 9
        /usr/local/go/src/net/http/server.go:3086 +0x5cb

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

That's just part of the stacktrace. Is there a repro on your branch? What test can I run to see it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

When I went back to look I couldn't repro.

if !s.limiter.AllowN(time.Now(),1){
err = errors.New("Unable to immediately store")
}
} else{
err = s.limiter.WaitN(ctx,1)
}

if err != nil {

fmt.Println("Rate limit context error:", err)
return RateLimitedExceeded
}

return s.wrappedStore.StoreChunk(chunk)

}
196 changes: 196 additions & 0 deletions ratelimitstore_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package desync

import (
"context"
"crypto/rand"
"errors"
"fmt"
"testing"
"time"

"github.com/stretchr/testify/require"
"golang.org/x/time/rate"
)


func NewTestRateLimitedLocalStore(t *testing.T, eventRate float64, burstRate int, timeout time.Duration, immediateOrFail bool) *RateLimitedLocalStore{

src_store_dir := t.TempDir()
src_store, err := NewLocalStore(src_store_dir, StoreOptions{})
require.NoError(t, err)

throttleOptions := ThrottleOptions{eventRate,burstRate,timeout,immediateOrFail}
store :=NewRateLimitedLocalStore(src_store, throttleOptions)
require.Equal(t,store.options.burstRate,burstRate )
return store

}

func random_data() ([]byte,error){

b := make([]byte, 16)
_, err := rand.Read(b)
if err != nil {
fmt.Println("Error: ", err)
return b,err
}

return b,nil
}


func storeLoop(t *testing.T, max int, chunk_ids []ChunkID, store RateLimitedLocalStore){

for i := 0; i < max; i++ {

data,err := random_data()
require.NoError(t,err)
chunk := NewChunk(data)
chunk_ids[i] = chunk.ID()
err = store.StoreChunk(chunk)
require.Nil(t,err)
}


}

func chunkCheck(t *testing.T, max int, chunk_ids []ChunkID, store RateLimitedLocalStore) {
for i := 0; i < max; i++ {

has,err := store.HasChunk(chunk_ids[i])
require.Nil(t,err)
require.True(t,has)

}
}

func TestLimiter(t *testing.T){

// This is testing the framework. So not great, but I needed to get my head around what the relation
// between events and burst do
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*1000)
defer cancel()

limiter := rate.NewLimiter(rate.Limit(50), 50)
err := limiter.WaitN(ctx,1)
require.Nil(t, err)

limiter = rate.NewLimiter(rate.Limit(2), 1)
_ = limiter.WaitN(ctx,1)
require.Nil(t, err)
err = limiter.WaitN(ctx,1)
require.Nil(t, err)

limiter = rate.NewLimiter(rate.Limit(1), 1)
_ = limiter.WaitN(ctx,1)
require.Nil(t, err)
err = limiter.WaitN(ctx,1)
require.NotNil(t, err)




}

func TestCopyWithNoLimit(t *testing.T) {


throttledStore := NewTestRateLimitedLocalStore(t,1,1,time.Second*60, false)
chunk_data := []byte("different datas")
chunk := NewChunk(chunk_data)


start := time.Now()
// We start with 1 token in the bucket and replenish at 1 token per second
// This should take ~10 seconds.
// We test it takes 8s to guard against flakiness
for i := 0; i < 10; i++ {
err := throttledStore.StoreChunk(chunk)
// This test will eventually fail when I get deadlines enabled
require.Nil(t,err)
}
finish := time.Now()

require.True(t, finish.Sub(start).Seconds() > 8)
}


func TestForAFullBucketNoWait(t *testing.T) {

chunk_count := 10
// Bucket has initial size chunk_count
throttledStore := NewTestRateLimitedLocalStore(t,1,chunk_count + 1,time.Second*60, false)

chunk_ids := make([]ChunkID, chunk_count)
start := time.Now()
// The bucket is full, we shouldn't have to wait
storeLoop(t,chunk_count,chunk_ids,*throttledStore)
finish := time.Now()
require.True(t, finish.Sub(start).Seconds() < 2)
chunkCheck(t,chunk_count,chunk_ids,*throttledStore)
}

func TestForAFastReplenishmentRateLittleWait(t *testing.T) {

chunk_count := 10
// Bucket only has one token, but we replenish chunk_count tokens every second
throttledStore := NewTestRateLimitedLocalStore(t,float64( chunk_count + 1),1,time.Second*60,false)

start := time.Now()


chunk_ids := make([]ChunkID, chunk_count)
storeLoop(t,chunk_count,chunk_ids,*throttledStore)

finish := time.Now()
require.True(t, finish.Sub(start).Seconds() < 2)
chunkCheck(t,chunk_count,chunk_ids,*throttledStore)


}

func TestTimeout(t *testing.T) {

// Bucket only has one token, and we replenish very slowly. We timeout, so second invocation will fail
throttledStore := NewTestRateLimitedLocalStore(t,float64(1) /100,1,time.Millisecond*1000, false)



data,err := random_data()
require.NoError(t,err)
chunk := NewChunk(data)
err = throttledStore.StoreChunk(chunk)
require.Nil(t,err)
err = throttledStore.StoreChunk(chunk)
require.NotNil(t,err)
require.True(t, errors.Is(err,RateLimitedExceeded))
}

func TestNoTimeout(t *testing.T) {
chunk_count := 10
// Bucket only has one token, replenish 1 per second. Timeout is 11 seconds.
throttledStore := NewTestRateLimitedLocalStore(t,1,1,time.Second*11, false)


chunk_ids := make([]ChunkID, chunk_count)
storeLoop(t,chunk_count,chunk_ids,*throttledStore)
}

func TestImmediateOrFail(t *testing.T) {

// Bucket only has one token, and we replenish very slowly. Second invocation will fail
throttledStore := NewTestRateLimitedLocalStore(t,float64(1) /100,1,time.Second*60, true)



data,err := random_data()
require.NoError(t,err)
chunk := NewChunk(data)

err = throttledStore.StoreChunk(chunk)
require.Nil(t,err)

err = throttledStore.StoreChunk(chunk)
require.NotNil(t,err)

}