Skip to content

Commit d0bd049

Browse files
committed
Webhook request debouncing
Resolves #27
1 parent f9c0691 commit d0bd049

File tree

8 files changed

+142
-9
lines changed

8 files changed

+142
-9
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Added
66
- Added a settings page in the control panel.
7+
- Webhooks have a new “Debounce Key Format” setting, which can be used to debouncing similar webhook requests. ([#27](https://github.com/craftcms/webhooks/issues/27))
78
- Webhook URLs can now be set to environment variables or Twig code. ([#18](https://github.com/craftcms/webhooks/issues/18))
89
- Webhooks can now send PUT requests. ([#21](https://github.com/craftcms/webhooks/issues/21))
910

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,14 @@ class ArticleFilter implements FilterInterface
136136
}
137137
```
138138

139+
#### Debouncing Webhooks
140+
141+
You can prevent multiple similar webhooks from being sent by setting a “Debounce Key Format” on your webhook. This is a Twig template that defines a “debounce key” for the webhook. If two webhooks generate the same debounce key, only the second one will actually be sent.
142+
143+
An `event` variable will be available to it that references the event that was triggered.
144+
145+
For example, if your webhook is for an entry (`craft\elements\Entry`), then you could set the Debounce Key Format to `{{ event.sender.id }}` to prevent multiple webhook requests from being queued up at the same time.
146+
139147
#### Sending Custom Headers
140148

141149
You can send custom headers along with webhook requests using the Custom Headers setting.

src/Plugin.php

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use craft\webhooks\filters\RevisionFilter;
2121
use GuzzleHttp\Exception\RequestException;
2222
use GuzzleHttp\RequestOptions;
23+
use yii\base\Application;
2324
use yii\base\Arrayable;
2425
use yii\base\Event;
2526
use yii\base\Exception;
@@ -78,7 +79,14 @@ class Plugin extends \craft\base\Plugin
7879
/**
7980
* @inheritdoc
8081
*/
81-
public $schemaVersion = '2.2.0';
82+
public $schemaVersion = '2.3.0';
83+
84+
/**
85+
* @var SendRequestJob[] The request jobs that should be queued up at the end of the web request.
86+
* @see request()
87+
* @since 2.3.0
88+
*/
89+
private $_pendingJobs = [];
8290

8391
/**
8492
* @inheritdoc
@@ -180,7 +188,15 @@ public function init()
180188
$url = $view->renderString($url, [
181189
'event' => $e,
182190
]);
183-
$this->request($webhook->method, $url, $headers, $body, $webhook->id);
191+
192+
if ($webhook->debounceKeyFormat) {
193+
$debounceKey = Craft::parseEnv($webhook->debounceKeyFormat);
194+
$debounceKey = $webhook->id . ':' . $view->renderString($debounceKey, [
195+
'event' => $e,
196+
]);
197+
}
198+
199+
$this->request($webhook->method, $url, $headers, $body, $webhook->id, $debounceKey ?? null);
184200
});
185201
}
186202

@@ -225,14 +241,45 @@ public function getCpNavItem()
225241
* @param array|null $headers
226242
* @param string|null $body
227243
* @param int|null $webhookId
244+
* @param string|null $debounceKey
228245
* @throws \yii\db\Exception
229246
*/
230-
public function request(string $method, string $url, array $headers = null, string $body = null, int $webhookId = null)
247+
public function request(string $method, string $url, array $headers = null, string $body = null, int $webhookId = null, string $debounceKey = null)
231248
{
232249
$db = Craft::$app->getDb();
250+
251+
if ($debounceKey !== null) {
252+
// See if there is an existing pending request with the same key
253+
$requestId = (new Query())
254+
->select(['id'])
255+
->from(['{{%webhookrequests}}'])
256+
->where([
257+
'debounceKey' => $debounceKey,
258+
'status' => self::STATUS_PENDING,
259+
])
260+
->scalar();
261+
262+
// If so and we get a lock on it, update that request instead of creating a new one
263+
if ($requestId && $this->_lockRequest($requestId)) {
264+
$db->createCommand()
265+
->update('{{%webhookrequests}}', [
266+
'method' => $method,
267+
'url' => $url,
268+
'requestHeaders' => $headers ? Json::encode($headers) : null,
269+
'requestBody' => $body,
270+
'dateCreated' => Db::prepareDateForDb(new \DateTime()),
271+
], [
272+
'id' => $requestId,
273+
], [], false);
274+
$this->_unlockRequest($requestId);
275+
return;
276+
}
277+
}
278+
233279
$db->createCommand()
234280
->insert('{{%webhookrequests}}', [
235281
'webhookId' => $webhookId,
282+
'debounceKey' => $debounceKey,
236283
'status' => self::STATUS_PENDING,
237284
'method' => $method,
238285
'url' => $url,
@@ -243,10 +290,27 @@ public function request(string $method, string $url, array $headers = null, stri
243290
], false)
244291
->execute();
245292

246-
Craft::$app->getQueue()->push(new SendRequestJob([
293+
$this->_pendingJobs[] = new SendRequestJob([
247294
'requestId' => $db->getLastInsertID('{{%webhookrequests}}'),
248295
'webhookId' => $webhookId,
249-
]));
296+
]);
297+
298+
if (count($this->_pendingJobs) === 1) {
299+
Craft::$app->on(Application::EVENT_AFTER_REQUEST, [$this, 'pushPendingJobs'], null, false);
300+
}
301+
}
302+
303+
/**
304+
* Pushes any pending jobs to the queue.
305+
*
306+
* @since 2.3.0
307+
*/
308+
public function pushPendingJobs()
309+
{
310+
$queue = Craft::$app->getQueue();
311+
while ($job = array_shift($this->_pendingJobs)) {
312+
$queue->push($job);
313+
}
250314
}
251315

252316
/**
@@ -287,9 +351,7 @@ public function getRequestData(int $requestId): array
287351
public function sendRequest(int $requestId): bool
288352
{
289353
// Acquire a lock on the request
290-
$lockName = 'webhook.' . $requestId;
291-
$mutex = Craft::$app->getMutex();
292-
if (!$mutex->acquire($lockName)) {
354+
if (!$this->_lockRequest($requestId, 1)) {
293355
throw new Exception('Could not acquire a lock for the webhook request ' . $requestId);
294356
}
295357

@@ -338,7 +400,7 @@ public function sendRequest(int $requestId): bool
338400
->execute();
339401

340402
// Release the lock
341-
$mutex->release($lockName);
403+
$this->_unlockRequest($requestId);
342404

343405
return $success;
344406
}
@@ -426,4 +488,14 @@ public function getAllFilters(): array
426488

427489
return $event->types;
428490
}
491+
492+
private function _lockRequest(int $requestId, int $timeout = 0): bool
493+
{
494+
return Craft::$app->getMutex()->acquire("webhook:$requestId", $timeout);
495+
}
496+
497+
private function _unlockRequest(int $requestId): bool
498+
{
499+
return Craft::$app->getMutex()->release("webhook:$requestId");
500+
}
429501
}

src/Webhook.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ class Webhook extends Model
5252
*/
5353
public $filters = [];
5454

55+
/**
56+
* @var string|null
57+
*/
58+
public $debounceKeyFormat;
59+
5560
/**
5661
* @var string
5762
*/
@@ -151,6 +156,7 @@ function(string $attribute, array $params = null, Validator $validator) {
151156
}
152157
}
153158
],
159+
[['debounceKeyFormat'], 'string'],
154160
[
155161
['filters'],
156162
function() {

src/WebhookManager.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ public function saveWebhook(Webhook $webhook, bool $runValidation = true): bool
198198
'class' => $webhook->class,
199199
'event' => $webhook->event,
200200
'filters' => $webhook->filters ? Json::encode($webhook->filters) : null,
201+
'debounceKeyFormat' => $webhook->debounceKeyFormat,
201202
'method' => $webhook->method,
202203
'url' => $webhook->url,
203204
'headers' => $webhook->headers ? Json::encode($webhook->headers) : null,
@@ -247,6 +248,7 @@ private function _createWebhookQuery(): Query
247248
'class',
248249
'event',
249250
'filters',
251+
'debounceKeyFormat',
250252
'method',
251253
'url',
252254
'headers',

src/migrations/Install.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public function safeUp()
3434
'class' => $this->string()->notNull(),
3535
'event' => $this->string()->notNull(),
3636
'filters' => $this->text(),
37+
'debounceKeyFormat' => $this->string(),
3738
'method' => $this->string(10)->notNull(),
3839
'url' => $this->string()->notNull(),
3940
'headers' => $this->text(),
@@ -51,6 +52,7 @@ public function safeUp()
5152
$this->createTable('{{%webhookrequests}}', [
5253
'id' => $this->primaryKey(),
5354
'webhookId' => $this->integer(),
55+
'debounceKey' => $this->string(),
5456
'status' => $this->string()->notNull(),
5557
'attempts' => $this->tinyInteger(),
5658
'method' => $this->string(),
@@ -69,6 +71,7 @@ public function safeUp()
6971
$this->createIndex(null, '{{%webhooks}}', ['enabled']);
7072
$this->createIndex(null, '{{%webhooks}}', ['groupId', 'name']);
7173
$this->createIndex(null, '{{%webhooks}}', ['name'], true);
74+
$this->createIndex(null, '{{%webhookrequests}}', ['debounceKey', 'status']);
7275
$this->addForeignKey(null, '{{%webhooks}}', ['groupId'], '{{%webhookgroups}}', ['id'], 'SET NULL');
7376
$this->addForeignKey(null, '{{%webhookrequests}}', ['webhookId'], '{{%webhooks}}', ['id'], 'SET NULL');
7477
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace craft\webhooks\migrations;
4+
5+
use Craft;
6+
use craft\db\Migration;
7+
8+
/**
9+
* m200617_212615_debounce_keys migration.
10+
*/
11+
class m200617_212615_debounce_keys extends Migration
12+
{
13+
/**
14+
* @inheritdoc
15+
*/
16+
public function safeUp()
17+
{
18+
$this->addColumn('{{%webhooks}}', 'debounceKeyFormat', $this->string()->after('filters'));
19+
$this->addColumn('{{%webhookrequests}}', 'debounceKey', $this->string()->after('webhookId'));
20+
$this->createIndex(null, '{{%webhookrequests}}', ['debounceKey', 'status']);
21+
}
22+
23+
/**
24+
* @inheritdoc
25+
*/
26+
public function safeDown()
27+
{
28+
echo "m200617_212615_debounce_keys cannot be reverted.\n";
29+
return false;
30+
}
31+
}

src/templates/_manage/edit.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,16 @@
126126
</div>
127127
{% endset %}
128128

129+
{{ forms.textField({
130+
label: 'Debounce Key Format'|t('webhooks'),
131+
instructions: 'Template that defines the webhook’s “debounce key” format. If two webhooks generate the same debounce key, only the latter will be sent.'|t('webhooks') ~ ' ' ~ 'Can be set to an environment variable (`$VARIABLE_NAME`) or Twig code.'|t('webhooks'),
132+
id: 'debounce-key-format',
133+
class: 'code',
134+
name: 'debounceKeyFormat',
135+
value: webhook.debounceKeyFormat,
136+
errors: webhook.getErrors('debounceKeyFormat')
137+
}) }}
138+
129139
{{ forms.field({
130140
label: 'Request Method & URL'|t('webhooks'),
131141
required: true,

0 commit comments

Comments
 (0)