Skip to content

Commit 9619187

Browse files
authored
Default the worker to sending a full batch concurrently (#166)
A worker cycle is bounded by maximumSecondsPerWorkerCycle, but sending a batch one request at a time costs batchSize * timeoutInSeconds, so the previous defaults could not deliver a full batch inside their own budget: the worker would consider itself stuck and exit, leaving the request it was sending to be recovered minutes later. The concurrent chunk size now defaults to the batch size, which is the only value that satisfies the budget regardless of how batchSize is tuned, and concurrentHttpEnabled becomes redundant next to a chunk size of 1 - it is still honoured when explicitly disabled. Both settings, and maximumSecondsPerWorkerCycle, are now readable from the environment. Per-cycle database reconnects are also no longer the default. Lost connections are instead detected and reconnected where they surface, quietly for the first few in a row and reported once they start looking like an outage rather than an idle timeout.
1 parent 42f05fd commit 9619187

4 files changed

Lines changed: 235 additions & 15 deletions

File tree

README.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ own process dies mid-request — webhooks, downstream service calls, third-party
1919
|-----------------|------------------------|--------|
2020
| ^1 | ^7.4, ^8.0 | Security and bug fixes only |
2121
| ^2 | ^8.3 | Security and bug fixes only |
22-
| ^3 | ^8.3 | Active development |
22+
| ^3 | ^8.3 | Security and bug fixes only |
23+
| ^4 | ^8.3 | Active development |
2324

2425
## Installation
2526

@@ -62,8 +63,30 @@ php artisan process:request-insurances
6263
```
6364

6465
Run one or many — workers coordinate through row locking (`SELECT … FOR UPDATE SKIP LOCKED` on
65-
MySQL 8+), pick requests in priority order, and process them in batches (`batchSize`, optionally
66-
with concurrent HTTP via `concurrentHttpEnabled`).
66+
MySQL 8+) and claim requests in priority order, `batchSize` at a time.
67+
68+
### Cycle budget
69+
70+
Each batch is sent concurrently in chunks of `concurrentHttpChunkSize`, which defaults to the whole
71+
batch. A cycle has `maximumSecondsPerWorkerCycle` (120s) to finish, after which the worker considers
72+
itself stuck and exits — leaving the chunk it was sending to be recovered by
73+
`request-insurance:unstuck-processing` some minutes later. The worst case is:
74+
75+
```
76+
ceil(batchSize / concurrentHttpChunkSize) * timeoutInSeconds
77+
```
78+
79+
The defaults therefore need a single `timeoutInSeconds` (20s) at worst, well inside the budget, while
80+
lowering the chunk size means checking that arithmetic against it. A chunk size of `1` sends requests
81+
strictly one at a time in priority order, which only fits a small batch or a short timeout.
82+
83+
Concurrency also means requests within a batch complete in whatever order the receivers respond, so
84+
`priority` orders which requests get claimed, not which arrive first. The same goes for running more
85+
than one worker: a single worker with a chunk size of `1` is the only setup that delivers in strict
86+
order.
87+
88+
The deprecated `concurrentHttpEnabled` setting is still honoured when set to `false`, which does the
89+
same thing as a chunk size of `1`.
6790

6891
### Lifecycle
6992

publishable/config/request-insurance.php

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,23 @@
6565
'batchSize' => env('REQUEST_INSURANCE_BATCH_SIZE', 100),
6666

6767
/*
68-
| Determines if concurrent http requests are enabled or not
68+
| The maximum number of http requests to send concurrently.
69+
|
70+
| Null sends the entire batch at once. A value of 1 sends requests one at a time,
71+
| in priority order, at the cost of a worker cycle long enough to do so - see
72+
| maximumSecondsPerWorkerCycle.
6973
*/
7074

71-
'concurrentHttpEnabled' => false,
75+
'concurrentHttpChunkSize' => env('REQUEST_INSURANCE_CONCURRENT_HTTP_CHUNK_SIZE'),
7276

7377
/*
74-
| The maximum number of http requests to send concurrently
78+
| Sets how long a single worker cycle may take, before the worker considers itself
79+
| stuck and exits. A cycle must be able to send a full batch within the budget:
80+
|
81+
| ceil(batchSize / concurrentHttpChunkSize) * timeoutInSeconds
7582
*/
7683

77-
'concurrentHttpChunkSize' => 5,
84+
'maximumSecondsPerWorkerCycle' => env('REQUEST_INSURANCE_MAX_SECONDS_PER_WORKER_CYCLE', 120),
7885

7986
/*
8087
| Set the concrete implementation for HttpRequest
@@ -106,7 +113,15 @@
106113
'table_edits' => null,
107114
'table_edit_approvals' => null,
108115

109-
'useDbReconnect' => env('REQUEST_INSURANCE_WORKER_USE_DB_RECONNECT', true),
116+
/*
117+
| Sets if the worker should reconnect to the database at the start of every cycle.
118+
|
119+
| Lost connections are detected and recovered from either way, so this is only needed
120+
| to stop workers from holding on to a connection they should not keep - such as one
121+
| pinned to a single node behind a load balancer.
122+
*/
123+
124+
'useDbReconnect' => env('REQUEST_INSURANCE_WORKER_USE_DB_RECONNECT', false),
110125

111126
/*
112127
| Using skip locked optimizes request insurance to run with multiple worker threads,

src/RequestInsurance/RequestInsuranceWorker.php

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,23 @@
1313
use Illuminate\Support\Facades\Log;
1414
use Cego\RequestInsurance\Enums\State;
1515
use Illuminate\Support\Facades\Config;
16+
use Illuminate\Database\DetectsLostConnections;
1617
use Cego\RequestInsurance\Models\RequestInsurance;
1718
use Cego\RequestInsurance\AsyncRequests\RequestInsuranceClient;
1819
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
1920

2021
class RequestInsuranceWorker
2122
{
23+
use DetectsLostConnections;
24+
2225
private const TIMEOUT_EXIT_CODE = 124;
2326

27+
/**
28+
* The number of consecutive lost database connections to recover from quietly,
29+
* before treating them as an outage worth reporting
30+
*/
31+
private const QUIET_LOST_CONNECTION_RECOVERIES = 3;
32+
2433
/**
2534
* Holds a hash identifier for the service instance once set
2635
*
@@ -49,6 +58,8 @@ class RequestInsuranceWorker
4958

5059
protected ?string $currentRequestInsuranceIds = null;
5160

61+
protected int $consecutiveLostConnections = 0;
62+
5263
/**
5364
* RequestInsuranceService constructor.
5465
*/
@@ -79,7 +90,7 @@ public function run(bool $runOnlyOnce = false): void
7990

8091
if (Config::get('request-insurance.useDbReconnect')) {
8192
$this->setWorkerPhase('database_reconnect');
82-
DB::reconnect();
93+
$this->reconnectToDatabase();
8394
}
8495

8596
$start = hrtime(true);
@@ -90,6 +101,8 @@ public function run(bool $runOnlyOnce = false): void
90101
$this->readyWaitingRequestInsurances();
91102
});
92103

104+
$this->consecutiveLostConnections = 0;
105+
93106
$executionTimeNs = hrtime(true) - $start;
94107

95108
$waitTime = (int) max(Config::get('request-insurance.microSecondsToWait') - ($executionTimeNs / 1000), 0);
@@ -99,7 +112,7 @@ public function run(bool $runOnlyOnce = false): void
99112
} catch (Throwable $throwable) {
100113
$this->resetTimeoutHandler(); // We need to reset here before logging the error and sleeping, otherwise the timeout handler might trigger while we are sleeping/logging, which is not desirable.
101114

102-
Log::error($throwable);
115+
$this->handleCycleFailure($throwable);
103116

104117
if ($runOnlyOnce) {
105118
throw $throwable;
@@ -114,6 +127,72 @@ public function run(bool $runOnlyOnce = false): void
114127
Log::info(sprintf('RequestInsurance Worker (#%s) has gracefully stopped', $this->runningHash));
115128
}
116129

130+
/**
131+
* Handles a failed worker cycle
132+
*
133+
* A dropped database connection is expected of a long-lived worker, and is recovered from
134+
* without noise, until it keeps happening and starts looking like an outage instead.
135+
*
136+
* @param Throwable $throwable
137+
*
138+
* @return void
139+
*/
140+
protected function handleCycleFailure(Throwable $throwable): void
141+
{
142+
if ( ! $this->wasCausedByLostConnection($throwable)) {
143+
$this->consecutiveLostConnections = 0;
144+
145+
Log::error($throwable);
146+
147+
return;
148+
}
149+
150+
$this->consecutiveLostConnections++;
151+
152+
$message = sprintf(
153+
'RequestInsurance Worker (#%s) lost its database connection during %s and is reconnecting (%d in a row)',
154+
$this->runningHash,
155+
$this->currentPhase,
156+
$this->consecutiveLostConnections
157+
);
158+
159+
if ($this->consecutiveLostConnections > self::QUIET_LOST_CONNECTION_RECOVERIES) {
160+
Log::error($message, ['exception' => $throwable]);
161+
} else {
162+
Log::debug($message);
163+
}
164+
165+
rescue(fn () => $this->reconnectToDatabase(), null, false);
166+
}
167+
168+
/**
169+
* Tells if the given throwable, or anything it wraps, was caused by a lost database connection
170+
*
171+
* @param Throwable $throwable
172+
*
173+
* @return bool
174+
*/
175+
protected function wasCausedByLostConnection(Throwable $throwable): bool
176+
{
177+
for ($exception = $throwable; $exception !== null; $exception = $exception->getPrevious()) {
178+
if ($this->causedByLostConnection($exception)) {
179+
return true;
180+
}
181+
}
182+
183+
return false;
184+
}
185+
186+
/**
187+
* Reconnects the database connection holding the request insurance tables
188+
*
189+
* @return void
190+
*/
191+
protected function reconnectToDatabase(): void
192+
{
193+
DB::reconnect(resolve(RequestInsurance::class)->getConnectionName());
194+
}
195+
117196
/**
118197
* Sets up signal handler to make sure that request insurance can shutdown gracefully.
119198
*
@@ -264,17 +343,21 @@ protected function setStateToProcessingAndIncrementAttempts(EloquentCollection $
264343
}
265344

266345
/**
267-
* Returns the concurrent request chunk size
346+
* Returns the number of requests to send concurrently
268347
*
269348
* @return int
270349
*/
271350
protected function getRequestChunkSize(): int
272351
{
273-
if (Config::get('request-insurance.concurrentHttpEnabled', false)) {
274-
return Config::get('request-insurance.concurrentHttpChunkSize', 5);
352+
// Deprecated flag, still honoured for configs that set it explicitly
353+
if (Config::get('request-insurance.concurrentHttpEnabled') === false) {
354+
return 1;
275355
}
276356

277-
return 1;
357+
$chunkSize = Config::get('request-insurance.concurrentHttpChunkSize')
358+
?? Config::get('request-insurance.batchSize', 100);
359+
360+
return max(1, (int) $chunkSize);
278361
}
279362

280363
/**
@@ -416,7 +499,7 @@ private function registerTimeoutHandler()
416499
pcntl_signal(SIGALRM, function () {
417500
$this->handleTimeoutSignal();
418501
});
419-
pcntl_alarm(Config::integer('request-insurance.maximumSecondsPerWorkerCycle', 120));
502+
pcntl_alarm((int) Config::get('request-insurance.maximumSecondsPerWorkerCycle', 120));
420503
}
421504

422505
private function resetTimeoutHandler(): void

tests/Unit/RequestInsuranceWorkerTest.php

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
namespace Tests\Unit;
44

5+
use Exception;
6+
use Throwable;
57
use Tests\TestCase;
68
use GuzzleHttp\Psr7\Request;
9+
use Illuminate\Support\Facades\Log;
710
use Illuminate\Support\Facades\Http;
811
use Illuminate\Support\Facades\Crypt;
912
use Illuminate\Support\Facades\Event;
@@ -434,4 +437,100 @@ protected function terminateWorkerAfterTimeout(): void
434437
['terminate'],
435438
], $worker->events);
436439
}
440+
441+
public function test_the_request_chunk_size_defaults_to_the_batch_size(): void
442+
{
443+
Config::set('request-insurance.batchSize', 42);
444+
Config::set('request-insurance.concurrentHttpChunkSize', null);
445+
446+
$this->assertSame(42, $this->getWorkerProbe()->exposeGetRequestChunkSize());
447+
}
448+
449+
public function test_the_request_chunk_size_can_be_set_below_the_batch_size(): void
450+
{
451+
Config::set('request-insurance.batchSize', 42);
452+
Config::set('request-insurance.concurrentHttpChunkSize', 7);
453+
454+
$this->assertSame(7, $this->getWorkerProbe()->exposeGetRequestChunkSize());
455+
}
456+
457+
public function test_the_deprecated_concurrent_http_enabled_flag_still_disables_concurrency(): void
458+
{
459+
Config::set('request-insurance.batchSize', 42);
460+
Config::set('request-insurance.concurrentHttpEnabled', false);
461+
462+
$this->assertSame(1, $this->getWorkerProbe()->exposeGetRequestChunkSize());
463+
}
464+
465+
public function test_it_recovers_quietly_from_a_lost_database_connection(): void
466+
{
467+
$worker = $this->getWorkerProbe();
468+
469+
Log::shouldReceive('debug')
470+
->once()
471+
->with('RequestInsurance Worker (#' . $worker->exposeRunningHash() . ') lost its database connection during idle and is reconnecting (1 in a row)');
472+
473+
Log::shouldReceive('error')->never();
474+
475+
$worker->exposeReportCycleFailure(new Exception('Cycle failed', 0, new Exception('MySQL server has gone away')));
476+
477+
$this->assertSame(1, $worker->reconnects);
478+
}
479+
480+
public function test_it_reports_lost_database_connections_that_keep_happening(): void
481+
{
482+
$worker = $this->getWorkerProbe();
483+
484+
Log::shouldReceive('debug')->times(3);
485+
Log::shouldReceive('error')->twice();
486+
487+
foreach (range(1, 5) as $ignored) {
488+
$worker->exposeReportCycleFailure(new Exception('Lost connection to server'));
489+
}
490+
491+
$this->assertSame(5, $worker->reconnects);
492+
}
493+
494+
public function test_it_reports_cycle_failures_that_are_not_lost_connections(): void
495+
{
496+
$worker = $this->getWorkerProbe();
497+
$throwable = new Exception('Something else went wrong');
498+
499+
Log::shouldReceive('error')->once()->with($throwable);
500+
Log::shouldReceive('debug')->never();
501+
502+
$worker->exposeReportCycleFailure($throwable);
503+
504+
$this->assertSame(0, $worker->reconnects);
505+
}
506+
507+
/**
508+
* Returns a worker exposing the internals needed to assert on cycle failure handling
509+
*/
510+
private function getWorkerProbe(): RequestInsuranceWorker
511+
{
512+
return new class () extends RequestInsuranceWorker {
513+
public int $reconnects = 0;
514+
515+
public function exposeGetRequestChunkSize(): int
516+
{
517+
return $this->getRequestChunkSize();
518+
}
519+
520+
public function exposeReportCycleFailure(Throwable $throwable): void
521+
{
522+
$this->handleCycleFailure($throwable);
523+
}
524+
525+
public function exposeRunningHash(): ?string
526+
{
527+
return $this->runningHash;
528+
}
529+
530+
protected function reconnectToDatabase(): void
531+
{
532+
$this->reconnects++;
533+
}
534+
};
535+
}
437536
}

0 commit comments

Comments
 (0)