|
1 | 1 | # Laravel Queue Debouncer |
2 | | -### Really easy debouncing of Laravel queue jobs via a wrapper job |
| 2 | +### Easy queue job debouncing |
3 | 3 |
|
4 | 4 | ## Requirements |
5 | | -* PHP >= 7.0 |
6 | | -* Laravel >= 5.5 |
7 | | -* A correctly configured asynchronous queue driver (tested with Redis and database drivers, but anything should work fine) |
8 | | -* A correctly configured cache driver |
| 5 | +* Laravel >= 6.0 |
| 6 | +* An async queue driver |
| 7 | + |
| 8 | +**Note:** v2.0 requires at least Laravel 6. For Laravel 5.5 ..< 6.0 support, check out [v1.0.2](https://github.com/mpbarlow/laravel-queue-debouncer/tree/1.0.2) |
9 | 9 |
|
10 | 10 | ## Installation |
11 | 11 | `composer require mpbarlow/laravel-queue-debouncer` |
12 | 12 |
|
| 13 | +## Background |
| 14 | +This package allows any queue job in your Laravel application to be debounced, meaning that no matter how many times it’s dispatched within the specified timeout window, it will only actually be ran once. |
| 15 | + |
| 16 | +For example, imagine you want to send an email each time a user changes their contact details, but you don’t want to spam them if they make several changes in a short space of time. Debouncing the job with a five minute wait would ensure they only receive a single email, no matter how many changes they make within that five minutes. |
| 17 | + |
| 18 | +Version 2.0 is a complete rewrite and brings numerous improvements to the capabilities and ergonomics of the package, as well as thorough test coverage. |
| 19 | + |
13 | 20 | ## Usage |
14 | | -The package comes in two main parts: a wrapper job `Debounce` that is dispatched immediately, and an abstract superclass |
15 | | -`DebouncedJob` that your debounced jobs should extend. |
16 | | - |
17 | | -`Debounce`’s constructor takes at least two arguments; `string $debounceable`, which is the fully-qualified class name of |
18 | | -the job you wish to debounce, and `int $waitTime`, which is the time in seconds that will be waited after the last dispatch of the job before it is fired. Any additional arguments will be passed directly to the constructor of the debounced job. |
19 | | -`Debounce` is also responsible for generating the unique ID that will be assigned to your job; out of the box it uses |
20 | | -[ramsey/uuid](https://github.com/ramsey/uuid), which ships with Laravel, to generate a random UUID4 string. However, if |
21 | | -this does not suit your needs, you can subclass `Debounced` and override `generateUniqueId()`. |
22 | | - |
23 | | -If you would prefer to implement your own wrapper class, the interface `DebouncesJobs` contains the methods your |
24 | | -customer wrapper must implement and call from its `handle()` method. You are responsible for caching the unique ID in a |
25 | | -way that the debounced job can retreive, however the `ProvidesCacheKey` trait contains the default implementation used by |
26 | | -both `Debounce` and `DebouncedJob`. |
27 | | - |
28 | | -`DebouncedJob` is the class that your debounced jobs should extend. It receives the unique ID from the |
29 | | -wrapper job via the constructor (if you need to override the default constructor be sure to call `parent::__construct($uniqueId)` |
30 | | -— `$uniqueId` is always the first argument your constructor receives, followed by any additional parameters you may have |
31 | | -passed). By default it takes responsibility for your job’s `handle()` method, checking whether the job should run, and |
32 | | -if so, calling the `debounced()` method. This is the method you should implement with your job logic. If the job should |
33 | | -not run, it is automatically deleted and `debounced()` is never called. |
34 | | - |
35 | | -If you can’t subclass `DebouncedJob` for your jobs, this package also provides a trait `InteractsWithDebouncer`, |
36 | | -which provides the default implementation for `handle()`, and a method `shouldRun()` which returns a boolean value |
37 | | -indicating whether the job should run. |
38 | | - |
39 | | -If you prefer or need to implement your logic in the normal `handle()` method (for example if you need access to objects |
40 | | -via dependency injection), override the provided implementation and call `shouldRun()` manually to determine your course |
41 | | -of action. |
42 | | - |
43 | | -## Example |
44 | | -SimpleDebouncedJob.php |
| 21 | +Debounced jobs can largely be treated like any other dispatched job. The debouncer takes two arguments, the actual `$job` you want to run, and the `$wait` period. |
| 22 | + |
| 23 | +As with a regular dispatch, `$job` can be either a class implementing `Illuminate\Foundation\Bus\Dispatchable` or a closure. `$wait` accepts any argument that the `delay` method on a dispatch accepts (i.e. a `DateTimeInterface` or a number of seconds). |
| 24 | + |
| 25 | +The debouncer returns an instance of `Illuminate\Foundation\Bus\PendingDispatch`, meaning the debouncing process itself may be assigned to a different queue or otherwise manipulated. |
| 26 | + |
| 27 | +### Calling the debouncer |
| 28 | +There are several ways to initiate debouncing of a job: |
| 29 | + |
| 30 | +#### Dependency Injection |
45 | 31 | ```php |
46 | | -... |
| 32 | +use App\Jobs\MyJob; |
| 33 | +use Mpbarlow\LaravelQueueDebouncer\Debouncer; |
47 | 34 |
|
48 | | -public function debounced() |
| 35 | +class MyController |
49 | 36 | { |
50 | | - echo "Hello, I am debounced. I have no data though :(\n"; |
| 37 | + public function doTheThing(Debouncer $debouncer) |
| 38 | + { |
| 39 | + $debouncer->debounce(new MyJob(), 30); |
| 40 | + |
| 41 | + // The class is also invokable: |
| 42 | + $debouncer(new MyJob(), 30); |
| 43 | + } |
51 | 44 | } |
52 | 45 | ``` |
53 | 46 |
|
54 | | -DebouncedJobWithExtraArguments.php |
| 47 | +#### Facade |
55 | 48 | ```php |
56 | | -... |
| 49 | +use App\Jobs\MyJob; |
| 50 | +use Mpbarlow\LaravelQueueDebouncer\Facade\Debouncer; |
57 | 51 |
|
58 | | - protected $some; |
59 | | - protected $extra; |
60 | | - protected $arguments; |
| 52 | +Debouncer::debounce(new MyJob, 30); |
| 53 | +``` |
61 | 54 |
|
62 | | - public function __construct($uniqueId, $some, $extra, $arguments) |
63 | | - { |
64 | | - // We must make sure to provide the superclass constructor with the unique ID |
65 | | - parent::__construct($uniqueId); |
| 55 | +#### Helper function |
| 56 | +```php |
| 57 | +use App\Jobs\MyJob; |
66 | 58 |
|
67 | | - $this->some = $some; |
68 | | - $this->extra = $extra; |
69 | | - $this->arguments = $arguments; |
70 | | - } |
| 59 | +debounce(new MyJob(), 30); |
| 60 | +``` |
| 61 | + |
| 62 | +Of course, you may substitute the job class for a closure. |
| 63 | + |
| 64 | +When monitoring the queue, you will see an entry for the package’s internal dispatcher each time the debounced job is queued, but the job itself will only run once, when the final wait time has expired. |
| 65 | + |
| 66 | +For example, assuming the following code was ran at exactly 9am: |
71 | 67 |
|
72 | | - public function debounced() |
| 68 | +```php |
| 69 | +class MyJob |
| 70 | +{ |
| 71 | + use Dispatchable; |
| 72 | + |
| 73 | + public function handle() |
73 | 74 | { |
74 | | - echo "Hello, I am debounced. I have {$this->some} {$this->extra} {$this->arguments} though! :D\n"; |
| 75 | + echo “Hello!\n”; |
75 | 76 | } |
| 77 | +} |
| 78 | + |
| 79 | +$job = new MyJob(); |
76 | 80 |
|
| 81 | +debounce($job, now()->addSeconds(5)); |
| 82 | +sleep(3); |
| 83 | + |
| 84 | +debounce($job, now()->addSeconds(5)); |
| 85 | +sleep(3); |
| 86 | + |
| 87 | +debounce($job, now()->addSeconds(5)); |
77 | 88 | ``` |
78 | 89 |
|
79 | | -```php |
80 | | -dispatch(new Debounce(SimpleDebouncedJob::class, 10)); |
81 | | -sleep(1); |
82 | | -dispatch(new Debounce(SimpleDebouncedJob::class, 10)); |
83 | | -sleep(1); |
84 | | -dispatch(new Debounce(SimpleDebouncedJob::class, 10)); |
85 | | -sleep(1); |
86 | | - |
87 | | -dispatch(new Debounce(DebouncedJobWithExtraArguments::class, 10, 'some', 'extra', 'arguments')); |
88 | | -sleep(1); |
89 | | -dispatch(new Debounce(DebouncedJobWithExtraArguments::class, 10, 'some', 'extra', 'arguments')); |
90 | | -sleep(1); |
91 | | -dispatch(new Debounce(DebouncedJobWithExtraArguments::class, 10, 'some', 'extra', 'arguments')); |
92 | | -sleep(1); |
| 90 | +you should expect the following activity on your queue: |
| 91 | + |
93 | 92 | ``` |
| 93 | +[2020-03-11 09:00:05][vHmqrBYeLtK3Lbiq5TsTZxBo2igaCZHC] Processing: Closure (DispatcherFactory.php:28) |
| 94 | +[2020-03-11 09:00:05][vHmqrBYeLtK3Lbiq5TsTZxBo2igaCZHC] Processed: Closure (DispatcherFactory.php:28) |
| 95 | +[2020-03-11 09:00:08][LXdzLvilh5qhew7akNDnibCjaXksG81X] Processing: Closure (DispatcherFactory.php:28) |
| 96 | +[2020-03-11 09:00:08][LXdzLvilh5qhew7akNDnibCjaXksG81X] Processed: Closure (DispatcherFactory.php:28) |
| 97 | +[2020-03-11 09:00:11][MnPIqk5fCwXjiVzuwPjkkOdPPBn0xR4d] Processing: Closure (DispatcherFactory.php:28) |
| 98 | +[2020-03-11 09:00:11][MnPIqk5fCwXjiVzuwPjkkOdPPBn0xR4d] Processed: Closure (DispatcherFactory.php:28) |
| 99 | +[2020-03-11 09:00:11][I2hvBoCB71qZQeD4umn5dd90zJUCAlJ5] Processing: App\Jobs\MyJob |
| 100 | +Hello! |
| 101 | +[2020-03-11 09:00:11][I2hvBoCB71qZQeD4umn5dd90zJUCAlJ5] Processed: App\Jobs\MyJob |
| 102 | +``` |
| 103 | + |
| 104 | +## Customising Behaviour |
| 105 | +This package provides a few knobs and dials to customise things to your needs. To override the default behaviour, you should publish the config file: |
| 106 | + |
| 107 | +``` |
| 108 | +php artisan vendor:publish --provider="Mpbarlow\LaravelQueueDebouncer\ServiceProvider" |
| 109 | +``` |
| 110 | + |
| 111 | +This will copy `queue_debouncer.php` to your config directory. |
| 112 | + |
| 113 | +### Cache key provider |
| 114 | +To support multiple jobs being debounced, the package generates a unique key in the cache for each job type. This key is used as a lookup when the wait for a given dispatch expires, to determine whether the job should be ran or not. |
| 115 | + |
| 116 | +The default implementation works as follows: |
| 117 | +* For class-based jobs, use the fully-qualified class name |
| 118 | +* For closure-based jobs, compute the SHA1 hash of a reflected representation of the closure |
| 119 | +* Prepend the `cache_prefix` value from the config, separated by a colon (‘:’) |
| 120 | + |
| 121 | +If the default behaviour works for you, but you’d like to use a different cache prefix, you may simply override that value. |
94 | 122 |
|
95 | | -`SimpleDebouncedJob` and `DebouncedJobWithExtraArguments` will each run approximately 10 seconds after their last dispatch, |
96 | | -respectively, producing the following output: |
| 123 | +Alternatively, you can provide your own class to produce a key for a given job. This is useful in cases where you have jobs that are implemented as different classes but should be considered the same job for the purposes of debouncing. |
97 | 124 |
|
98 | | - |
| 125 | +Your provider class must implement `Mpbarlow\LaravelQueueDebouncer\Contracts\CacheKeyProvider`. Please note that your class is responsible for fetching and prepending the cache prefix should you still desire this behaviour—it will not be added automatically. |
99 | 126 |
|
100 | | -It's worth noting that if your job does not produce any output it may appear that it is running every single time. In a |
101 | | -sense it is, but only to determine whether or not execution should continue. The actual `debounced()` method containing |
102 | | -your logic is only executed when the bouncing has settled. |
| 127 | +**Note:** Because of the limited ways we have to produce a value representation of a closure, only the _exact same closure_ will produce the same cache key. So, while closures originating from the same line in the same file will produce the same key on subsequent requests, two closures with the same content dispatched from different places will not. |
103 | 128 |
|
104 | | -See also the included Examples folder. |
| 129 | +### Unique identifier provider |
| 130 | +Each time a debounced job is dispatched, a unique identifier is stored against the cache key for the job. When the wait time expires, if that identifier matches the value inserted, the debouncer knows that no more recent instances of the job have been dispatched, and therefore it is safe to dispatch it. |
105 | 131 |
|
106 | | -## Potential Issues |
107 | | -Race conditions with certain drivers, particularly when multiple processes work the queue, or when wait times are very |
108 | | -short, can mean that the job that executes is not always the last one that was dispatched. It will still only execute |
109 | | -once, but it is important to bear this in mind if your job is strictly dependent on dispatch order. |
| 132 | +The default implementation produces a UUID v4 for each dispatch. If you need to override this you may do so via the config. As with the cache key provider, your class must implement `Mpbarlow\LaravelQueueDebouncer\Contracts\UniqueIdentifierProvider`. |
110 | 133 |
|
111 | | -## License |
| 134 | +## Licence |
112 | 135 | This package is open-source software provided under the [The MIT License](https://opensource.org/licenses/MIT). |
0 commit comments