4

I'm looking for the Rx method that will take an observable and put the latest item on a 'cooldown', so that when items are coming in slower than the cooldown they're just forwarded but when they're coming in faster you just get the latest value after each cooldown period.

Said a different way, I want to switch to sampling with period t when items are separated by less than t time (and switch back when they're spread out).

This is really similar to what Observable.Throttle does, except that the timer is not reset whenever a new item arrives.

The application I have in mind is for sending 'latest value' updates across the network. I don't want to communicate a value unless it has changed, and I don't want to spam a rapidly changing value so much that I swamp out other data.

Is there a standard method that does what I need?

Charles
  • 50,943
  • 13
  • 104
  • 142
Craig Gidney
  • 17,763
  • 5
  • 68
  • 136

4 Answers4

4

Strilanc, given your concern about unwanted activity when the source stream is quiet, you might be interested in this method of pacing events - I wasn't going to add this otherwise, as I think J. Lennon's implementation is perfectly reasonable (and much simpler), and the performance of the timer isn't going to hurt.

There is one other interesting difference in this implementation - it differs from the Sample approach because it emits events occurring outside the cooldown period immediately rather than at the next sampling interval. It maintains no timer outside the cooldown.

EDIT - Here is v3 solving the issue Chris mentioned in the comments - it ensures that changes occurring during the cool-down themselves trigger a new cool-down period.

    public static IObservable<T> LimitRate<T>(
        this IObservable<T> source, TimeSpan duration, IScheduler scheduler)
    {
        return source.DistinctUntilChanged()
                     .GroupByUntil(k => 0,
                                   g => Observable.Timer(duration, scheduler))
            .SelectMany(x => x.FirstAsync()
                              .Merge(x.Skip(1)
                                      .TakeLast(1)))
                              .Select(x => Observable.Return(x)
                                .Concat(Observable.Empty<T>()
                                    .Delay(duration, scheduler)))
                                    .Concat();
    }

This works by initially using a GroupByUntil to pack all events into the same group for the duration of the cool-down period. It watches for changes and emits the final change (if any) as the group expires.

Then the resulting events are projected into a streams whose OnCompleted is delayed by the cool-down period. These streams are then concatenated together. This prevents events being any closer together than the cool-down, but otherwise they are emitted as soon as possible.

Here are the unit tests (updated for v3 edit), which you can run using nuget packages rx-testing and nunit:

public class LimitRateTests : ReactiveTest
{
    [Test]
    public void SlowerThanRateIsUnchanged()
    {
        var scheduler = new TestScheduler();

        var source = scheduler.CreateColdObservable(
            OnNext(200, 1),
            OnNext(400, 2),
            OnNext(700, 3));

        var results = scheduler.CreateObserver<int>();

        source.LimitRate(TimeSpan.FromTicks(100), scheduler).Subscribe(results);

        scheduler.Start();

        results.Messages.AssertEqual(
            OnNext(200, 1),
            OnNext(400, 2),
            OnNext(700, 3));
    }

    [Test]
    public void FasterThanRateIsSampled()
    {
        var scheduler = new TestScheduler();

        var source = scheduler.CreateColdObservable(
            OnNext(100, 1),
            OnNext(140, 5),
            OnNext(150, 2),
            OnNext(300, 3),
            OnNext(350, 4));

        var results = scheduler.CreateObserver<int>();

        source.LimitRate(TimeSpan.FromTicks(100), scheduler).Subscribe(results);

        scheduler.Start();

        results.Messages.AssertEqual(
            OnNext(100, 1),
            OnNext(200, 2),
            OnNext(300, 3),
            OnNext(400, 4));
    }

    [Test]
    public void DuplicatesAreOmitted()
    {
        var scheduler = new TestScheduler();

        var source = scheduler.CreateColdObservable(
            OnNext(100, 1),
            OnNext(150, 1),
            OnNext(300, 1),
            OnNext(350, 1));

        var results = scheduler.CreateObserver<int>();

        source.LimitRate(TimeSpan.FromTicks(100), scheduler).Subscribe(results);

        scheduler.Start();

        results.Messages.AssertEqual(
            OnNext(100, 1));
    }

    [Test]
    public void CoolResetsCorrectly()
    {
        var scheduler = new TestScheduler();

        var source = scheduler.CreateColdObservable(
            OnNext(100, 1),
            OnNext(150, 2),
            OnNext(205, 3));

        var results = scheduler.CreateObserver<int>();

        source.LimitRate(TimeSpan.FromTicks(100), scheduler).Subscribe(results);

        scheduler.Start();

        results.Messages.AssertEqual(
            OnNext(100, 1),
            OnNext(200, 2),
            OnNext(300, 3));
    }

    [Test]
    public void MixedPacingWorks()
    {
        var scheduler = new TestScheduler();

        var source = scheduler.CreateColdObservable(
            OnNext(100, 1),
            OnNext(150, 1),
            OnNext(450, 3),
            OnNext(750, 4),
            OnNext(825, 5));

        var results = scheduler.CreateObserver<int>();

        source.LimitRate(TimeSpan.FromTicks(100), scheduler).Subscribe(results);

        scheduler.Start();

        results.Messages.AssertEqual(
            OnNext(100, 1),
            OnNext(450, 3),
            OnNext(750, 4),
            OnNext(850, 5));
    }
}
James World
  • 29,019
  • 9
  • 86
  • 120
  • Interesting. GroupByUntil looks like a handy method to keep in mind. I'll likely extract the 'skipping interior items' bit but otherwise it's completely usable and even *tested*. Nice. – Craig Gidney Dec 18 '13 at 18:19
  • What about `SelectMany(g => g.FirstAsync().Concat(g.IgnoreElements()))` ? Is that doing the same thing as your `SelectMany` ? – cwharris Dec 18 '13 at 19:34
  • That will miss changes during the cool-down period won't it? My `SelectMany` causes the last change during the cool-down to be reported immediately following the cool-down, as requested. – James World Dec 18 '13 at 19:34
  • Ahh, I see what you're saying. In case an event occurs between the cool-down period, we wouldn't yield that after the cool-down period. – cwharris Dec 18 '13 at 19:35
  • Yeah, there are some interesting subtleties to this question; at first I thought it must be a duplicate, but I don't think it is. – James World Dec 18 '13 at 19:37
  • However, in your case, the last item will be yielded at the end of the cool-down period, but a new item could be yielded immediately after that... do we also want to "cool down" again after the `TakeLast(1)` item is yielded? OP accepted the answer, but is that edge case considered? – cwharris Dec 18 '13 at 19:41
  • Yes indeed... at that point I decided to have a cup of tea. :) I'm sure there must be an elegant way to handle that, I'll think on it. Nearly bedtime here! – James World Dec 18 '13 at 19:43
  • Revised description and removed the buggy original implementation, as leaving it in was just confusing things. – James World Dec 18 '13 at 20:03
  • 1
    I think the new implementation, using Select and Delay and Concat, will not discard intermediate items when items are arriving quickly and instead build up huge delays on the latest item. – Craig Gidney Dec 18 '13 at 21:04
  • Doh! Rollback... I'll have a think about it some more - in the meantime I've restored the version that doesn't add the cool-down to changes picked up during the cool-down period. – James World Dec 18 '13 at 21:26
  • Wow - this is so nuanced - I think by combining both my first two approaches I've caught the edge cases now. Can't help thinking there must be an easier way to pass those unit tests though! And really all this is doing over the J Lennon's answer is avoiding continuous timing and emitting changes immediately rather than on the sample interval - is it worth the extra complexity? – James World Dec 18 '13 at 21:43
  • v3 doesn't work correctly. It emits a message at the beginning and ending of each period that contains two messages, and delays each message for the length of a period. If saturated with messages at a rate of at least 2/period, it gets further and further behind, taking about 1.3 times as much wall time to send messages as the elapsed wall time. – Cirdec Mar 19 '14 at 00:21
2

You can use the Observable.DistinctUntilChanged and Observable.Sample.

Observable.DistinctUntilChanged

This method will surface values only if they are different from the previous value. (http://www.introtorx.com/content/v1.0.10621.0/05_Filtering.html)

Observable.Sample

The Sample method simply takes the last value for every specified TimeSpan. (http://www.introtorx.com/content/v1.0.10621.0/13_TimeShiftedSequences.html#Sample)

To generate the desired effect, you can combine the first item generated with those described above.

J. Lennon
  • 3,311
  • 4
  • 33
  • 64
  • Won't this method turn every value into constant background work, proportional to the sampling rate, even when the value is not changing? It will work, but that seems like a tricky downside. – Craig Gidney Dec 18 '13 at 15:39
  • 1
    **Observable.Sample** method is not related to "statistics", if you do not produce a new value nothing is fired. – J. Lennon Dec 18 '13 at 15:55
  • 1
    That's not at all what I meant. I meant that if the value I am observing is not changing, the periodic sampling and discarding-as-not-different is still occurring in the background. Creating unnecessary constant work out of thin air seems like a bad idea. – Craig Gidney Dec 18 '13 at 16:28
  • Maybe I don't exactly what you want, but I don't know how **Observable.Sample** method works internally, I believe in most cases that will work fine, and yes, he will always respect your clock and schedules (based on TimeSpan). – J. Lennon Dec 18 '13 at 16:38
  • This isn't about how it works internally, it's about what it actually says it does: forward the latest value at a given rate, regardless of whether or not it changed. Then, in your solution, DistinctUntilChanged discards it. Which is the external behavior I want, but with a hidden cost. – Craig Gidney Dec 18 '13 at 16:48
  • this doesn't answer the question. – cwharris Dec 20 '13 at 23:48
2

I realize this has already been answered for some time, but I'd like to provide an alternate solution which I think more accurately matches the original requirement. This solution introduces 2 custom operators.

First is SampleImmediate, which works exactly like Sample, except it sends the first item immediately. This is accomplished via a number of operators. Materialize / Dematerialize and DistinctUntilChanged work together to ensure no duplicate notifications are sent. Merge, Take(1), and Sample provide the basic "Sample Immediately" functionality. Publish and Connect tie those together. GroupBy and SelectMany makes sure we wait for the first event to yield before starting our timer. Create helps us properly dispose of everything.

public static IObservable<T> SampleImmediate<T>(this IObservable<T> source, TimeSpan dueTime)
{
    return source
        .GroupBy(x => 0)
        .SelectMany(group =>
        {
            return Observable.Create<T>(o =>
            {
                var connectable = group.Materialize().Publish();

                var sub = Observable.Merge(
                        connectable.Sample(dueTime),
                        connectable.Take(1)
                    )
                    .DistinctUntilChanged()
                    .Dematerialize()
                    .Subscribe(o);

                return new CompositeDisposable(connectable.Connect(), sub);
            });
        });
}

After we have SampleImmediate we can create Cooldown by using GroupByUntil to group all events that occur until our sliding Throttle window has closed. Once we have our group, we simply SampleImmediate the whole thing.

public static IObservable<T> Cooldown<T>(this IObservable<T> source, TimeSpan dueTime)
{
    return source
        .GroupByUntil(x => 0, group => group.Throttle(dueTime))
        .SelectMany(group => group.SampleImmediate(dueTime));
}

In no way am I suggesting this solution is better or faster, I just thought it might be nice to see an alternative approach.

cwharris
  • 17,835
  • 4
  • 44
  • 64
  • Interesting! I like the Merge idea. Couple of things - why the Materialize/Dematerialize? I took them out and it seemed fine without them. I ran this through my unit tests and all passed except `DuplicatesAreOmitted` - OP said "I don't want to communicate a value unless it has changed" - I think easily fixed with an overall `DistinctUntilChanged`. – James World Dec 22 '13 at 00:31
  • I used Materialize/Dematerialize so the notifications themselves would be Distinct (not the values inside of them), since Take(1) and Sample could potential yield the same notification if only one value is yielded for the group. This implementation isn't concerned with the distinctness of values, since DistinctUntilChanged could be used *before or after* the operators I've presented. – cwharris Dec 22 '13 at 01:08
  • More simply put, if I used Merge(Take(1), Sample) and the source observable only yielded one value, Merge would yield two values. If I just used DistinctUntilChanged on the Merge, it might miss *real* notifications. If I combine that with Materialize/Dematerialize, then we are distinct by notification instead of value. – cwharris Dec 22 '13 at 01:15
  • I see what you are saying; I wasn't thinking of `SampleImmediate` in isolation. With the `CoolDown` in play, it all cancels out in the end. So `SampleImmediate` is intended to emit just the first event "off the beat", and then continue like Sample. – James World Dec 22 '13 at 02:35
  • Indeed, that's how SampleImmediate is intended to work, but I'm not sure what you mean by "it all cancels out in the end." – cwharris Dec 22 '13 at 03:27
0

Self answer.

Although I asked in terms of Rx, my actual case is in terms of a port of it (ReactiveCocoa). More people know Rx, and I could translate.

Anyways, I ended up implementing it directly so that it could satisfy the latency/performance properties I wanted:

-(RACSignal*)cooldown:(NSTimeInterval)cooldownPeriod onScheduler:(RACScheduler *)scheduler {
    need(cooldownPeriod >= 0);
    need(!isnan(cooldownPeriod));
    need(scheduler != nil);
    need(scheduler != RACScheduler.immediateScheduler);

    force(cooldownPeriod != 0); //todo: bother with no-cooldown case?
    force(!isinf(cooldownPeriod)); //todo: bother with infinite case?

    return [[RACSignal createSignal:^(id<RACSubscriber> subscriber) {
        need(subscriber != nil);

        NSObject* lock = [NSObject new];
        __block bool isCoolingDown = false;
        __block bool hasDelayedValue = false;
        __block id delayedValue = nil;
        __block RACDisposable *cooldownDisposer = nil;
        void (^onCanSendValue)(void) = ^{
            @synchronized (lock) {
                // check that we were actually cooling down
                // (e.g. what if the system thrashed before we could dispose the running-down timer, causing a redundant call?)
                if (!isCoolingDown) {
                    return;
                }

                // if no values arrived during the cooldown, we do nothing and can stop the timer for now
                if (!hasDelayedValue) {
                    isCoolingDown = false;
                    [cooldownDisposer dispose];
                    return;
                }

                // forward latest value
                id valueToSend = delayedValue;
                hasDelayedValue = false;
                delayedValue = nil;
                // todo: can this be avoided?
                // holding a lock while triggering arbitrary actions cam introduce subtle deadlock cases...
                [subscriber sendNext:valueToSend];
            }
        };
        void (^preemptivelyEndCooldown)(void) = ^{
            // forward latest value AND ALSO force cooldown to run out (disposing timer)
            onCanSendValue();
            onCanSendValue();
        };

        RACDisposable *selfDisposable = [self subscribeNext:^(id x) {
            bool didStartCooldown;
            @synchronized (lock) {
                hasDelayedValue = true;
                delayedValue = x;
                didStartCooldown = !isCoolingDown;
                isCoolingDown = true;
            }

            if (didStartCooldown) {
                // first item gets sent right away
                onCanSendValue();
                // coming items have to wait for the timer to run down
                cooldownDisposer = [[RACSignal interval:cooldownPeriod onScheduler:scheduler]
                                    subscribeNext:^(id _) { onCanSendValue(); }];
            }
        } error:^(NSError *error) {
            preemptivelyEndCooldown();
            [subscriber sendError:error];
        } completed:^{
            preemptivelyEndCooldown();
            [subscriber sendCompleted];
        }];

        return [RACDisposable disposableWithBlock:^{
            [selfDisposable dispose];
            @synchronized (lock) {
                isCoolingDown = false;
                [cooldownDisposer dispose];
            }
        }];
    }] setNameWithFormat:@"[%@ cooldown:%@]", self.name, @(cooldownPeriod)];
}

It should translate almost directly to .Net RX. It will stop doing any work when items stop arriving, and it will forward items as soon as possible while respecting the cooldown.

Craig Gidney
  • 17,763
  • 5
  • 68
  • 136