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
lib/connections, lib/config: Bandwidth throttling per remote device (fixes #4516) #4603
Conversation
lib/connections/limiter.go
Outdated
} | ||
|
||
func (w *limitedWriter) Write(buf []byte) (int, error) { | ||
if !w.isLAN || w.limiter.limitsLAN.get() { | ||
take(w.limiter.write, len(buf)) | ||
deviceLimiter, ok := w.limiter.deviceWriteLimiter[w.remoteID] | ||
if ok == false { |
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.
!ok
lib/connections/limiter.go
Outdated
lim.deviceWriteLimiter[v.DeviceID] = rate.NewLimiter(rate.Inf, limiterBurstSize) | ||
lim.deviceReadLimiter[v.DeviceID] = rate.NewLimiter(rate.Inf, limiterBurstSize) | ||
} | ||
l.Debugln("Rebuild finished") |
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 has no context, rebuild of what, where, why
lib/connections/limiter.go
Outdated
l.CommitConfiguration(prev, cfg.RawCopy()) | ||
return l | ||
} | ||
|
||
func (lim *limiter) newReadLimiter(r io.Reader, isLAN bool) io.Reader { | ||
return &limitedReader{reader: r, limiter: lim, isLAN: isLAN} | ||
func getInitialDevicesConfiguration(cfgCopy config.Configuration) config.DeviceConfigurationList { |
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 is only called once, I'd say that it's fine to wedge in the loop inline in the constructor.
lib/connections/limiter.go
Outdated
} | ||
|
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.
Could just call rebuildMap here?
lib/connections/limiter.go
Outdated
// Compare read/write limits in configurations | ||
func (lim *limiter) checkDeviceLimits(from, to config.Configuration) bool { | ||
for i := range from.Devices { | ||
if from.Devices[i].DeviceID.Compare(to.Devices[i].DeviceID) != 0 { |
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.
You can just use equality for device ID's they should be comparable just fine.
lib/connections/limiter.go
Outdated
if to.Options.MaxRecvKbps > 0 { | ||
recvLimitStr = fmt.Sprintf("limit is %d KiB/s", to.Options.MaxRecvKbps) | ||
} | ||
l.Infof("Send rate %s, receive rate %s", sendLimitStr, recvLimitStr) | ||
l.Infof("Global send rate %s, receive rate %s", sendLimitStr, recvLimitStr) |
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.
Overall or Total. Global usually talks about locations.
lib/connections/limiter.go
Outdated
deviceLimiter, ok := r.limiter.deviceReadLimiter[r.remoteID] | ||
if ok == false { | ||
l.Debugln("deviceLimiter was not in the map") | ||
deviceLimiter = rate.NewLimiter(rate.Inf, limiterBurstSize) |
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 can this happen?
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 happens after removing remote device. I'm not sure about limitedWriter but I added it just to be safe.
lib/connections/limiter.go
Outdated
"golang.org/x/net/context" | ||
"golang.org/x/time/rate" | ||
) | ||
|
||
type deviceLimiters map[protocol.DeviceID]*rate.Limiter |
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 probably needs to be sync.Map (atomic map) as you have not implemented any locking, and there are definately multiple routines poking at it. Also, the atomic map should only be initialized once, because if you re-assign the map on the limiter struct, you still need a lock.
Now you are using both locks and The |
lib/connections/limiter.go
Outdated
continue | ||
} | ||
|
||
readLimiter, _ := lim.deviceReadLimiters.Load(v.DeviceID) |
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.
You should not throw away the ok, in case the value is not there.
Also, you are not locking here, so you might be accessing deviceReadLimiters as the map is being swapped out.
This is also a reason why rebuildMap should potentially do it without swapping the maps.
lib/connections/limiter.go
Outdated
// A device has been added or removed | ||
if len(from.Devices) != len(to.Devices) { | ||
lim.rebuildMap(to) | ||
} |
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.
Also, this is unnecessery if you handle it properly in checkDeviceLimits, or stop it from having side-effects.
lib/connections/limiter.go
Outdated
lim.deviceWriteLimiters = deviceWriteLimiters | ||
lim.deviceReadLimiters = deviceReadLimiters | ||
|
||
l.Debugln("Rebuild of device limiters map finished") |
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.
Wouldn't it be better for rebuild to happen in place, this way getting rid of the lock that we have to get on every read and write.
lib/connections/limiter.go
Outdated
// Compare read/write limits in configurations | ||
func (lim *limiter) checkDeviceLimits(from, to config.Configuration) bool { | ||
for i := range from.Devices { | ||
if from.Devices[i].DeviceID != to.Devices[i].DeviceID { |
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 should blow up if a device is removed, as len(to) < len(from).
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.
You should probably write a test case for this logic.
Test case for adding
Test case for removing
Test case for adding and removing in the same config commit cycle.
lib/connections/limiter.go
Outdated
for i := range from.Devices { | ||
if from.Devices[i].DeviceID != to.Devices[i].DeviceID { | ||
// Something has changed in device configuration | ||
lim.rebuildMap(to) |
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.
To be honest, this function as it states should only do checking, and not rebuilding (as a side-effect), and rebuilding and other cruft should happen in the CommitConfiguration.
lib/connections/limiter.go
Outdated
if v.MaxRecvKbps > 0 { | ||
recvLimitStr = fmt.Sprintf("limit is %d KiB/s", v.MaxRecvKbps) | ||
} | ||
l.Infof("Device %s: send rate %s, receive rate %s", v.DeviceID, sendLimitStr, recvLimitStr) |
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 will always print stuff, even if someones rate hasn't changed since before.
It will further always mess around with the rate limiters, regardless if the value changed or not.
lib/connections/limiter.go
Outdated
r.limiter.mu.Lock() | ||
deviceLimiter, ok := r.limiter.deviceReadLimiters.Load(r.remoteID) | ||
r.limiter.mu.Unlock() | ||
if !ok { |
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.
To be honest, if !ok, I'd panic (or do nothing), as it should clearly be there.
Now it's like a bandaid for a problem we can't explain.
I'd do a if deviceLimiter != nil check in take
lib/connections/limiter.go
Outdated
} | ||
return w.writer.Write(buf) | ||
} | ||
|
||
// take is a utility function to consume tokens from a rate.Limiter. No call | ||
// to WaitN can be larger than the limiter burst size so we split it up into | ||
// several calls when necessary. | ||
func take(l *rate.Limiter, tokens int) { | ||
func take(l, deviceLimiter *rate.Limiter, tokens int) { |
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.
the l could do with a better name now, and the doc string should be updated.
lib/connections/limiter.go
Outdated
|
||
// copy *limiter in case remote device is still connected, when remote device is added we create new read/write limiters | ||
for _, v := range to.Devices { | ||
if readLimiter, ok := lim.deviceReadLimiters.Load(v.DeviceID); ok { |
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.
What if there are two rebuildMap calls in parallel due to frequent config save or something like that, this might be halfway through a swap too.
@AudriusButkevicius You're right. I assumed (which was pretty stupid) that only one routine at the time visits CommitConfiguration. I guess the right way will be to rewrite it so that "rebuild" happens without swapping map instances and definitely implement mentioned tests and fix other issues pointed by you. Thanks for feedback! @imsodin My intention was to use lock in order to ensure atomic assignment in rebuildMap. |
@qepasa thanks for working on this. |
lib/connections/limiter.go
Outdated
read *rate.Limiter | ||
limitsLAN atomicBool | ||
deviceReadLimiters *sync.Map | ||
deviceWriteLimiters *sync.Map |
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.
sync.Map
is Go 1.9 and we're currently targeting 1.8. This is the build failure reported by the tests.
Although we could bump our requirements I think there should be little enough churn on these that we can actually use regular maps and locking...
Hello everyone. Sorry for delay with coding. I tried to keep your suggestions in mind. If there is anything you would like to be done differently I'll make sure to implement it faster this time. |
lib/connections/limiter.go
Outdated
l.deviceWriteLimiters[value.DeviceID] = rate.NewLimiter(rate.Inf, limiterBurstSize) | ||
l.deviceReadLimiters[value.DeviceID] = rate.NewLimiter(rate.Inf, limiterBurstSize) | ||
} | ||
prev := config.Configuration{Options: config.OptionsConfiguration{MaxRecvKbps: -1, MaxSendKbps: -1}, Devices: devices} |
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 the entire above block isn't necessary, as CommitConfiguration will add all missing devices anyway, so https://github.com/syncthing/syncthing/pull/4603/files#diff-da45d4f40dfa16a9aae760dd650fe79aL35 should be sufficient.
lib/connections/limiter.go
Outdated
// Pass pointer to avoid copying. Pointer already points to copy of configuration | ||
// so we don't have to worry about modifying real config. | ||
func (lim *limiter) processDevicesConfiguration(from, to *config.Configuration) { | ||
|
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.
empty
lib/connections/limiter.go
Outdated
if v.DeviceID != to.MyID { | ||
devicesToRemove[v.DeviceID] = true | ||
} | ||
} |
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.
Could be map[protocol.DeviceID]struct{}
and then later instead of = false
do delete(devicesToRemove, v.DeviceID)
. Then you can just iterate over the final map in the end without checking a bool.
lib/connections/limiter.go
Outdated
} else { | ||
writeLimiter.SetLimit(1024 * rate.Limit(v.MaxSendKbps)) | ||
} | ||
} |
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.
Now this lives on the lim struct, but doesn't actually use it, meaning it should either be a utility function not part of lim, or use lim and reach into lim.device{Write,Read}Limiters directly.
lib/connections/limiter.go
Outdated
// Pass pointer to avoid copying. Pointer already points to copy of configuration | ||
// so we don't have to worry about modifying real config. | ||
func (lim *limiter) processDevicesConfiguration(from, to *config.Configuration) { | ||
|
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.
Noop space.
lib/connections/limiter.go
Outdated
func (lim *limiter) newWriteLimiter(w io.Writer, isLAN bool) io.Writer { | ||
return &limitedWriter{writer: w, limiter: lim, isLAN: isLAN} | ||
// Compare read/write limits in configurations | ||
func (lim *limiter) checkDeviceLimits(from, to config.Configuration) bool { |
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.
Again, doesn't need to live on lim.
Also the function name doesn't explain well what this does. Should probably be called deviceLimitsChanged() and invert what it returns.
lib/connections/limiter.go
Outdated
} | ||
// len(from.Devices) == len(to.Devices) so we can do range from.Devices | ||
for i := range from.Devices { | ||
if from.Devices[i].DeviceID != to.Devices[i].DeviceID { |
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 is a nit-pick, but we don't really need to rely on the device order here. It's possible that rates aren't changed, just the order gets changed.
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.
There should be some sort of cfg.DeviceMap() or something like that utility function.
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.
Yes, that should be moved from model to config: https://github.com/syncthing/syncthing/blob/master/lib/model/model.go#L2569
That also makes it easier to remove limiters, just treat it like folders in models CommitConfiguration.
lib/connections/limiter.go
Outdated
// so we don't have to worry about modifying real config. | ||
func (lim *limiter) processDevicesConfiguration(from, to *config.Configuration) { | ||
|
||
devicesToRemove := make(map[protocol.DeviceID]bool) |
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 could be map[DeviceID]struct{}
, and devicesToRemove[x] = false
is essentially delete(devicesToRemove, x)
Or better would be:
for dev in to.Devices {
seen[dev] = struct{}{}
}
for dev in from.Devices {
if _, ok := seen[dev]; !ok {
lim.deleteLimiters(dev)
}
}
lib/connections/limiter.go
Outdated
@@ -73,22 +217,25 @@ func (lim *limiter) CommitConfiguration(from, to config.Configuration) bool { | |||
|
|||
lim.limitsLAN.set(to.Options.LimitBandwidthInLan) | |||
|
|||
// Delete, add or update limiters for devices | |||
lim.processDevicesConfiguration(&from, &to) |
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.
Why is this a pointer?
lib/connections/limiter.go
Outdated
// already marked it as false in devicesToRemove | ||
panic("broken symmetry in device read/write limiters") | ||
} | ||
} |
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 is too complicated. The only way these things get set are under a lock, so it's very unlikely they get out of sync.
Also, perhaps it's better to get getDevice{Read,Write}Limiter always return a new limiter even if one does not exists (and add it to the map).
I am thinking about a case where a new device gets added, and there is a race between CommitConfiguration here, and us making a connection. It's probably always better wrap every connection in a limiter (and create one if it doesn't exist) and then adjust the rates later.
lib/connections/limiter.go
Outdated
|
||
l.Debugf("okR: %t, okW: %t, prevWriteLim: %d, prevReadLim: %d", okR, okW, previousReadLimit, previousWriteLimit) | ||
|
||
lim.setLimitsForDevice(v, readLimiter, writeLimiter) |
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.
Is it not better not to do this if the limits are the same as previously?
As in, move this after the if?
lib/connections/limiter.go
Outdated
} | ||
|
||
const limiterBurstSize = 4 * 128 << 10 | ||
|
||
func newLimiter(cfg *config.Wrapper) *limiter { | ||
func newLimiter(deviceID protocol.DeviceID, cfg *config.Wrapper) *limiter { | ||
|
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.
empty
lib/connections/limiter.go
Outdated
if from.Options.MaxRecvKbps == to.Options.MaxRecvKbps && | ||
from.Options.MaxSendKbps == to.Options.MaxSendKbps && | ||
from.Options.LimitBandwidthInLan == to.Options.LimitBandwidthInLan { | ||
from.Options.LimitBandwidthInLan == to.Options.LimitBandwidthInLan && | ||
lim.checkDeviceLimits(from, to) { | ||
return true | ||
} |
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 this global check is not very useful as global limits are reset even if just a device changed and whether device change is checked again later on. So I would either not check at all and just reset everything or check the different parameters separately.
lib/connections/limiter.go
Outdated
} | ||
// len(from.Devices) == len(to.Devices) so we can do range from.Devices | ||
for i := range from.Devices { | ||
if from.Devices[i].DeviceID != to.Devices[i].DeviceID { |
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.
Yes, that should be moved from model to config: https://github.com/syncthing/syncthing/blob/master/lib/model/model.go#L2569
That also makes it easier to remove limiters, just treat it like folders in models CommitConfiguration.
Argh this github reviews... Probably better look at the comments in the diff, this comment view is fractured badly.
Even if you weren't occupied with Christmas holidays and family reunions there is no reason at all to apologize for ~1week (and also if longer) followup times after a round of review. Reviewers will nag if it comes to the point of being too quiet ;) |
lib/config/config.go
Outdated
|
||
// mapDeviceConfigs returns a map of device ID to device configuration for the given | ||
// slice of folder configurations. | ||
func MapDeviceConfigs(devices []DeviceConfiguration) map[protocol.DeviceID]DeviceConfiguration { |
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 propose func (cfg *Configuration) DeviceMap() map[protocol.DeviceID]DeviceConfiguration
.
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.
Agree. And we do this in a few other places (in the model I think), so we should use this instead.
lib/connections/limiter.go
Outdated
from.Devices[i].MaxRecvKbps != to.Devices[i].MaxRecvKbps { | ||
return false | ||
if fromMap[k].MaxSendKbps != toMap[k].MaxSendKbps || | ||
fromMap[k].MaxRecvKbps != toMap[k].MaxRecvKbps { |
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.
No manual line breaks (I was/am a fan of them too, but it's not the Syncthing/Go style ;) ).
lib/connections/limiter.go
Outdated
// Nothing about this device has changed. Start processing next device | ||
if okR && okW && | ||
rate.Limit(v.MaxRecvKbps)*1024 == previousReadLimit && | ||
rate.Limit(v.MaxSendKbps)*1024 == previousWriteLimit { |
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.
line breaks
lib/connections/limiter.go
Outdated
// to ensure atomic update of configuration | ||
lim.mu.Lock() | ||
defer lim.mu.Unlock() | ||
if deviceLimitsChanged(from, to) { |
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 would remove this check. You already do the same checks in processDevicesConfiguration
and skip any devices which don't change.
lib/connections/limiter.go
Outdated
// This function handles removing, adding and updating of device limiters. | ||
// Pass pointer to avoid copying. Pointer already points to copy of configuration | ||
// so we don't have to worry about modifying real config. | ||
func (lim *limiter) processDevicesConfiguration(from, to *config.Configuration) { |
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.
Doesn't need to pass configs as pointers.
My previous comments on always creating limiters and always wrapping each device connection in two limiters still apply |
I added suggested fixes. Thank you guys for patience with this pull request and for quick reviews. |
lib/connections/limiter.go
Outdated
readLimitStr := "is unlimited" | ||
if dev.MaxRecvKbps > 0 { | ||
readLimitStr = fmt.Sprintf("limit is %d KiB/s", dev.MaxRecvKbps) | ||
if limitsChanged := lim.setLimits(dev); limitsChanged { |
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 just be if lim.setLimits(dev) {
.
lib/connections/limiter.go
Outdated
lim.mu.Lock() | ||
defer lim.mu.Unlock() | ||
deviceLimiter := lim.getWriteLimiter(remoteID) | ||
return &limitedWriter{writer: w, limiter: lim, deviceLimiter: deviceLimiter, isLAN: isLAN} |
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.
The fact that processblabla does locking outside the function, and this does inside, feels wrong.
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.
@AudriusButkevicius We are starting to contradict each other once again x-): My reasoning to suggest this was that the locking now happens in CommitConfiguration
and in these newLimited...
functions, which are all "top-level", i.e. called "externally" (not all outside of the package, but outside of the limiter
code). Also locking the entirety of CommitConfiguration
was considered necessary to disallow concurrent change and thus with a single lock the locking cannot happen on get...Limiter
anymore (single lock is not a requirement obviously, I just consider it cleaner).
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.
Fine, but why can't we lock lim.mu before calling the newblabla twice in the connection service, this way we lock the same way in both places. Also a function that needs locking before called, we should add the locked word into the name like we do in the model.
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.
lib/connections/limiter.go
Outdated
seen[dev.DeviceID] = struct{}{} | ||
|
||
// Nothing about this device has changed. Start processing next device | ||
if limitsChanged := lim.setLimits(dev); limitsChanged { |
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.
The variable assignment is pointless, you can straight do if setLimits(dev) {
lib/connections/limiter_test.go
Outdated
} | ||
|
||
func initConfig(t *testing.T) { | ||
t.Helper() |
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.
Don't need t.helper as you are not asserting anything here
lib/connections/limiter_test.go
Outdated
@@ -169,8 +169,7 @@ func TestAddAndRemove(t *testing.T) { | |||
checkActualAndExpected(t, actualR, actualW, expectedR, expectedW) | |||
} | |||
|
|||
func initConfig(t *testing.T) { | |||
t.Helper() | |||
func initConfig() { |
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 just return a config, rather than setting some global that leaks between the tests.
Implementation wise looks ok to me, needs to resolve merge conflicts and pass the tests (which I am not sure if they are failing due to anything this PR is doing). |
I agree that this is probably a nice enough functionality to get a place there. If @qepasa wants to do it in this PR, cool, but otherwise I think it's fine to leave it in advanced settings and open an enhancement issue for someone else (or qepasa) to do that eventually. |
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'm sure you've already discussed this in the 90+ comments above, but all of this juggling of mutexes and maps and stuff does not seem worth it to me compared to just creating the limiter with the appropriate settings at connection time. Yeah then we can't change the per device rate limit on the fly, but this will almost never happen and we anyway disconnect peers most of the time when we change folders or devices. That would make this whole thing like a four line diff instead.
lib/connections/limiter.go
Outdated
"golang.org/x/net/context" | ||
"golang.org/x/time/rate" | ||
"sync" |
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 should probably be our lib/sync
lib/connections/limiter.go
Outdated
limitsLAN atomicBool | ||
deviceReadLimiters map[protocol.DeviceID]*rate.Limiter | ||
deviceWriteLimiters map[protocol.DeviceID]*rate.Limiter | ||
mu *sync.Mutex |
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.
Just sync.Mutex
, no need for a pointer (regardless of which sync package)
lib/connections/limiter.go
Outdated
read: rate.NewLimiter(rate.Inf, limiterBurstSize), | ||
write: rate.NewLimiter(rate.Inf, limiterBurstSize), | ||
read: rate.NewLimiter(rate.Inf, limiterBurstSize), | ||
mu: &sync.Mutex{}, |
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.
Adjust here to nothing at all (standard lib sync
) or sync.NewMutex()
(our sync
)
lib/connections/limiter.go
Outdated
func (lim *limiter) newWriteLimiter(w io.Writer, isLAN bool) io.Writer { | ||
return &limitedWriter{writer: w, limiter: lim, isLAN: isLAN} | ||
// This function handles removing, adding and updating of device limiters. | ||
// Pass pointer to avoid copying. Pointer already points to copy of configuration |
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 clear what the pointer comments are about. The configuration parameters are not pointers.
lib/connections/service.go
Outdated
@@ -265,8 +265,10 @@ next: | |||
// keep up with config changes to the rate and whether or not LAN | |||
// connections are limited. | |||
isLAN := s.isLAN(c.RemoteAddr()) | |||
wr := s.limiter.newWriteLimiter(c, isLAN) | |||
rd := s.limiter.newReadLimiter(c, isLAN) | |||
s.limiter.mu.Lock() |
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 is unusual, we do don't want to take locks on mutexes inside other objects and then call their ...Locked
functions. Wrap in a method.
rd, wr := s.limiter.getLimiters(remoteID, c, isLAN)
@calmh Isn't this dropping of connection one of the long-standing issues nobody wants to tackle because it is so entangled? What I mean is that not needing a "complete reset" and thus at least not increasing the entanglement, seems to already provide quite a bit of merit. |
I don't really think the connection resetting is a big deal, but on the other hand we already have the |
LGTM apart from above mutex nits, and that the parameters to Has anyone tested that this actually works? |
lib/connections/limiter.go
Outdated
@@ -173,6 +169,14 @@ func (lim *limiter) String() string { | |||
return "connections.limiter" | |||
} | |||
|
|||
func (lim *limiter) getLimiters(remoteID protocol.DeviceID, c internalConn, isLAN bool) (io.Reader, io.Writer) { | |||
lim.mu.Lock() | |||
defer lim.mu.Unlock() |
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.
no point using here defer as the scope is small, as this just prevents potential inlining.
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.
Apart from the last nitpick this looks good to me, if it works :)
lib/connections/limiter.go
Outdated
|
||
func (lim *limiter) newLimitedWriterLocked(remoteID protocol.DeviceID, w io.Writer, isLAN bool) io.Writer { | ||
deviceLimiter := lim.getWriteLimiterLocked(remoteID) | ||
return &limitedWriter{writer: w, limiter: lim, deviceLimiter: deviceLimiter, isLAN: isLAN} |
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.
Merge these two two line functions into the callee to reduce the mental stack depth.
I can try to make UI for this but I would prefer it to be a new issue. |
I have nits to pick, but i'll pick them myself in a followup at some point |
* master: (24 commits) lib/model: Prevent warning on request in paused folder (fixes syncthing#4870) docker: Add README from old Docker repo (fixes syncthing#4868) (syncthing#4869) cmd/strelaypoolsrv: Move metric scraping to the server itself (syncthing#4866) gui, man: Update docs & translations assets: Use icon from synctrayzor (ref syncthing#4839) (syncthing#4859) cmd/strelaypoolsrv: Handle portless X-Forwarded-For (syncthing#4856) lib/fs: Don't panic when watching a folder with symlinked root (syncthing#4846) cmd/syncthing: Set Content-Type header regardless of asset location (syncthing#4847) authors: Add fuzzybear3965 cmd/syncthing: Correct --paths in some cases (syncthing#4845) gui, man: Update docs & translations lib/osutil: Use unix lowprio implementation on Android (syncthing#4844) lib/scanner, lib/model: Actually assign version when un-ignoring (fixes syncthing#4841) (syncthing#4842) lib/scanner, lib/model: Actually assign version when un-ignoring (fixes syncthing#4841) (syncthing#4842) cmd/stdiscosrv, vendor: Remove remnants of golang.org/x/net/context (syncthing#4843) lib/connections: Wrong context snuck in somehow build: Add icon & file info to syncthing.exe (syncthing#4839) lib/connections: Slightly refactor limiter juggling lib/connections, lib/config: Bandwidth throttling per remote device (fixes syncthing#4516) (syncthing#4603) gui: Add folder label to global changes, use bootstrap table (fixes syncthing#4828) (syncthing#4838) ...
Purpose
Hello everyone. As described in the issue. This fix enables read/write bandwidth throttling per remote device alongside current global limiting. It can be set via device tab in advanced settings.
Testing
Testing was done using 2 remote devices. Tested scenarios include adding and removing devices and various configurations of r/w rates:
device1: 300/300, device2: unlimited/unlimited, global: unlimited/unlimited
device1: 300/300, device2: unlimited/unlimited, global: 150/150
device1: 300/300, device2: 50/50, global: 150/150
device1: unlimited/unlimited, device2: 50/50, global: 150/150
device1: unlimited/unlimited, device2: unlimited/unlimited, global: 700/700
Of course these tests may not be comprehensive. I'll be thankful for any additional suggestions or scenarios.