Background thread debouncing for Ruby.
Installation
Add this line to your application's Gemfile:
gem 'debouncer'
And then execute:
$ bundle
Or install it yourself as:
$ gem install debouncer
Usage
The easy way
Generally all you want to do is debounce an instance or class method. The Debounceable
module gives you everything you need to get your bouncing methods debounced.
require 'debouncer/debounceable'
class DangerZone
extend Debouncer::Debounceable
def send_warning
system "echo We are about to get crazy! | wall"
end
debounce :send_warning, 2
end
Each call to send_warning
will be delayed on a background thread for 2 seconds before firing. If, during those two seconds, it's called again, the count-down will restart, and only one warning will actually be sent.
If you want to send your warning immediately, your original method has been given a suffix so you can still access it:
DangerZone.new.send_warning_immediately
A couple of methods have also been added to help work with background threads:
dz = DangerZone.new
dz.join_send_warning # Wait for all warnings to be sent
dz.flush_send_warning # Send warnings immediately if any are waiting
dz.cancel_send_warning # Cancel all waiting warnings
You can also debounce your calls in groups. By setting the grouped: true
option, the first argument passed to the debounced method will be used to create a separate debouncer thread.
class DangerZone
extend Debouncer::Debounceable
def send_warning(message)
system "echo #{message.shellescape} | wall"
end
debounce :send_warning, 2, grouped: true
end
Now, each unique message will have its own separate timeout. You can also pass group identifiers to join_
, flush_
, and cancel_
methods to affect only those groups.
Arguments are always passed to your original method intact, and by default, the last set of arguments in a group wins. If the example above didn't use grouping:
d = DangerZone.new
d.send_warning "We're going down!"
d.send_warning "Spiders are attacking!"
The first warning would be replaced by the second when the method is eventually run.
You can combine arguments to produce an end result however you like, using a reducer:
class DangerZone
extend Debouncer::Debounceable
def send_warning(*messages)
system "echo #{messages.map(&:shellescape).join ';'} | wall"
end
debounce :send_warning, 2, reduce_with: :combine_messages
def combine_messages(memo, messages)
memo + messages
end
end
The combine_messages
method will be called whenever send_warning
is called. The first argument is the last value it returned, or an empty array if this is the first call for the thread. The second argument is an array of the arguments supplied to send_warning
. It should return an array of arguments that will ultimately be passed to the original method.
If grouping is enabled, the first two arguments will not include the ID. Instead, it will be passed as the first argument. The reducer should not include the ID in the array it returns.
A reducer method is a good place to call flush_*
if you hit some sort of limit or threshold. Just remember to still return the array of arguments you want to call.
def combine_messages(memo, messages)
result = memo + messages
flush_send_warning if result.length >= 5
result
end
If you're using grouping, calling flush_send_warning
followed by join_send_warning
won't have the effect you'd expect. When a group begins execution, the group's queue is cleared and a new queue is created, so joining that queue won't join the already-running thread. Instead, flush_and_join_send_message
serves this purpose.
Finally, you can also debounce class/module methods using the mdebounce
method. If you want to combine calls on various instances of a class, a sound pattern is to debounce a class method and have instances call it. For example, consider broadcasting data changes to browsers, where you want to group changes to the same model together into single broadcasts:
class Record
extend Debouncer::Debounceable
def self.broadcast(record_id)
look_up(record_id).broadcast
end
mdebounce :broadcast, 0.5, grouped: true
def save
write_to_database
Record.broadcast id
end
end
In a web application, it's common for several instances of a model representing the same data record to existing during the course of a request. Debouncing the broadcast
method on the instance wouldn't be effective, since each instance would have its own debouncer. By using a debounced class method, records are grouped by their ID instead of the Ruby objects that represent them.
The clean(er) way
Under the hood, the Debounceable
module uses a Debouncer
instance as a thread controller. If you prefer not to have any extra methods defined on your class, you can use a Debouncer
instance to achieve the same results.
require 'debouncer'
d = Debouncer.new(2) { |message| puts message }
d.call 'I have arrived'
This will print the message "I have arrived!" after 2 seconds.
Grouping is simple with a debouncer:
d.group(:warnings).call 'I am about to arrive...'
d.group(:alerts).call 'I have arrived!'
d.group(:alerts).flush
When adding an argument reducer, you can also specify an initial value:
d = Debouncer.new(2) { |*messages| puts messages }
d.reducer('Here are some messages:', '') { |memo, messages| memo + messages }
d.call "Evented Ruby isn't so bad"
d.call "And it's threaded, too!"
After 2 seconds, the above code will print:
Here are some messages:
Evented Ruby isn't so bad
And it's threaded, too!
If your reducer simply adds or OR's two arrays together, you can use a symbol instead:
d.reducer 'Here are some messages:', '', :+
This will have the same result as the block form. If you use :|
instead of :+
, it will be like memo | messages
, so messages won't be repeated between calls.
Methods like flush
, kill
, and join
are available too, and to exactly what you'd expect. You can call them directly on your debouncer, or after a group(id)
call if you want to target a specific group.
# These lines are equivalent:
d.join :messages
d.group(:messages).join
# This will join all threads:
d.join
As mentioned in the previous section, calling flush
followed by join
on a group won't join the thread that started executing when you called flush
.
# This flushes and then joins an empty queue, returning instantly:
d.group(:messages).flush.join
# This flushes and waits for messages to be sent:
d.group(:messages).flush!
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run rake spec
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/hx/debouncer. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
License
The gem is available as open source under the terms of the MIT License.