Skip to content

Commit cfef3ff

Browse files
authored
Adds runtime toggle to enable/disable retries (#13)
Introduces Artisan commands to enable/disable database transaction retries at runtime without modifying configuration files. This is achieved by creating/removing a marker file. Adds RetryToggle class to handle the logic for checking and managing the marker file. Updates the transaction retrier to respect the runtime toggle status.
1 parent de3b605 commit cfef3ff

File tree

11 files changed

+526
-4
lines changed

11 files changed

+526
-4
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ vendor/
55
.vscode/
66
.DS_Store
77
resources/
8+
9+
storage/runtime/retry-disabled.marker

.php-cs-fixer.cache

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"php":"8.2.29","version":"3.89.0:v3.89.0#4dd6768cb7558440d27d18f54909eee417317ce9","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"align_single_space_minimal"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"single_quote":true,"no_unused_imports":true,"no_superfluous_phpdoc_tags":true,"phpdoc_trim":true,"phpdoc_align":{"align":"left"},"blank_line_before_statement":{"statements":["return"]},"simplified_null_return":true,"void_return":true},"hashes":{"tests\/bootstrap.php":"4ae74313e457f6662f4831ee2140eb34","src\/Providers\/DatabaseTransactionRetryServiceProvider.php":"1071a19b80835472e035374cc17b7056","src\/Services\/TransactionRetrier.php":"7d99d773c44861e3f12524a5119f5240","src\/Support\/BindingStringifier.php":"bbead2bae37761124652320cd28db412","src\/Support\/TransactionRetryLogWriter.php":"2bc60103437973d7b471ad6bc91c43cf","src\/Support\/TraceFormatter.php":"e640e32b17149f1cfec9a1d990f04c84","tests\/TestCase.php":"a45ed4c82e3b3f4ad47544b81fda41f5","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087","tests\/Unit\/DBTransactionRetryHelperTest.php":"07bb6b8e6c8b3ce61a7e67f128f12f4a","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b"}}
1+
{"php":"8.2.29","version":"3.89.0:v3.89.0#4dd6768cb7558440d27d18f54909eee417317ce9","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"align_single_space_minimal"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"single_quote":true,"no_unused_imports":true,"no_superfluous_phpdoc_tags":true,"phpdoc_trim":true,"phpdoc_align":{"align":"left"},"blank_line_before_statement":{"statements":["return"]},"simplified_null_return":true,"void_return":true},"hashes":{"tests\/Unit\/DBTransactionRetryHelperTest.php":"27f37268e9ae100d356ca1c06c756616","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b","tests\/bootstrap.php":"4ae74313e457f6662f4831ee2140eb34","src\/Console\/StartRetryCommand.php":"b0e6c76186a59d1341fa6600e096f9a3","src\/Console\/StopRetryCommand.php":"ad3d7cbb9841006db54f12168c58d369","src\/Providers\/DatabaseTransactionRetryServiceProvider.php":"638c2f84c78a86c8e8c5e4daef07c46e","src\/Services\/TransactionRetrier.php":"504c45ab8315c7b3d60a64e0a52759af","src\/Support\/RetryToggle.php":"2516f4c290940019b1f1c069fec64be8","src\/Support\/BindingStringifier.php":"bbead2bae37761124652320cd28db412","src\/Support\/TransactionRetryLogWriter.php":"2bc60103437973d7b471ad6bc91c43cf","src\/Support\/TraceFormatter.php":"e640e32b17149f1cfec9a1d990f04c84","tests\/TestCase.php":"897cfbd81822f4b71075ccb1739df70d","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087"}}

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
<a href="LICENSE">
1313
<img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT License">
1414
</a>
15-
<img src="https://img.shields.io/badge/Laravel-%5E11-red.svg" alt="Laravel ^11">
15+
<img src="https://img.shields.io/badge/Laravel-11%20%7C%7C%2012-red.svg" alt="Laravel 11 or 12">
1616
<img src="https://img.shields.io/badge/PHP-%5E8.2-blue.svg" alt="PHP ^8.2">
1717
<img src="https://img.shields.io/badge/style-PHP%20CS%20Fixer-informational.svg" alt="PHP CS Fixer">
1818
</p>
@@ -117,6 +117,19 @@ Each log entry includes:
117117

118118
Set `logFileName` to segment logs by feature or workload (e.g., `logFileName: 'database/queues/payments'`).
119119

120+
## Runtime Toggle
121+
122+
Use the built-in Artisan commands to temporarily disable or re-enable retries without touching configuration files:
123+
124+
```bash
125+
php artisan db-transaction-retry:stop # disable retries
126+
php artisan db-transaction-retry:start # enable retries
127+
```
128+
129+
The commands write a small marker file inside the package (`storage/runtime/retry-disabled.marker`). As long as that file exists retries stay off; removing it or running `db-transaction-retry:start` brings them back. You can still set the `DB_TRANSACTION_RETRY_ENABLED` environment variable for a permanent default.
130+
131+
> **Heads up:** The `db-transaction-retry:start` command only removes the disable marker—it does not override an explicit `database-transaction-retry.enabled=false` configuration (including the `DB_TRANSACTION_RETRY_ENABLED=false` environment variable). Update that setting to `true` if you want retries to remain enabled after the current process.
132+
120133
## Helper Utilities
121134

122135
The package exposes dedicated support classes you can reuse in your own instrumentation:

config/database-transaction-retry.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
<?php
22

33
return [
4+
/*
5+
|--------------------------------------------------------------------------
6+
| Toggle
7+
|--------------------------------------------------------------------------
8+
|
9+
| Enable or disable the retry helper at runtime. This can be overridden
10+
| through the provided Artisan commands when a cache store is available.
11+
|
12+
*/
13+
14+
'enabled' => env('DB_TRANSACTION_RETRY_ENABLED', true),
15+
416
/*
517
|--------------------------------------------------------------------------
618
| Retry Defaults

src/Console/StartRetryCommand.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace DatabaseTransactions\RetryHelper\Console;
4+
5+
use DatabaseTransactions\RetryHelper\Support\RetryToggle;
6+
use Illuminate\Console\Command;
7+
8+
class StartRetryCommand extends Command
9+
{
10+
protected $signature = 'db-transaction-retry:start';
11+
12+
protected $description = 'Enable database transaction retry handling.';
13+
14+
public function handle(): int
15+
{
16+
$configEnabled = config('database-transaction-retry.enabled');
17+
$explicitlyDisabled = RetryToggle::isExplicitlyDisabledValue($configEnabled);
18+
if ($explicitlyDisabled) {
19+
$this->warn('Base configuration keeps retries disabled. Update `database-transaction-retry.enabled` (or `DB_TRANSACTION_RETRY_ENABLED`) if you want retries to stay on after this run.');
20+
21+
return self::SUCCESS;
22+
}
23+
config(['database-transaction-retry.enabled' => true]);
24+
25+
$persisted = RetryToggle::enable();
26+
$marker = RetryToggle::markerPath();
27+
28+
if ($persisted) {
29+
$this->info('Database transaction retries have been enabled.');
30+
$this->line("Cleared toggle marker: {$marker}");
31+
} else {
32+
$this->warn('Database transaction retries could not be fully enabled because the toggle marker could not be removed.');
33+
$this->line("Please delete {$marker} manually or adjust permissions.");
34+
}
35+
36+
$state = RetryToggle::isEnabled(config('database-transaction-retry', []))
37+
? 'ENABLED'
38+
: 'DISABLED';
39+
40+
if ($persisted) {
41+
$this->line("Current status: {$state}");
42+
} else {
43+
$this->line("Current status: {$state} (marker still present; retries remain disabled)");
44+
}
45+
46+
return self::SUCCESS;
47+
}
48+
}

src/Console/StopRetryCommand.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
namespace DatabaseTransactions\RetryHelper\Console;
4+
5+
use DatabaseTransactions\RetryHelper\Support\RetryToggle;
6+
use Illuminate\Console\Command;
7+
8+
class StopRetryCommand extends Command
9+
{
10+
protected $signature = 'db-transaction-retry:stop';
11+
12+
protected $description = 'Disable database transaction retry handling.';
13+
14+
public function handle(): int
15+
{
16+
$configEnabled = config('database-transaction-retry.enabled');
17+
$explicitlyDisabled = RetryToggle::isExplicitlyDisabledValue($configEnabled);
18+
if ($explicitlyDisabled) {
19+
$this->warn('Base configuration already disables retries via `database-transaction-retry.enabled` or `DB_TRANSACTION_RETRY_ENABLED`.');
20+
21+
return self::SUCCESS;
22+
}
23+
config(['database-transaction-retry.enabled' => false]);
24+
25+
$persisted = RetryToggle::disable();
26+
$marker = RetryToggle::markerPath();
27+
28+
if ($persisted) {
29+
$this->info('Database transaction retries have been disabled.');
30+
$this->line("Created toggle marker: {$marker}");
31+
} else {
32+
$this->warn('Database transaction retries disabled for this process, but the toggle marker could not be written.');
33+
$this->line("Please create {$marker} manually or adjust permissions.");
34+
}
35+
36+
$state = RetryToggle::isEnabled(config('database-transaction-retry', []))
37+
? 'ENABLED'
38+
: 'DISABLED';
39+
40+
if ($persisted) {
41+
$this->line("Current status: {$state}");
42+
} else {
43+
$this->line("Current status: {$state} (runtime only; persistence failed)");
44+
$this->warn('Retries will be re-enabled on the next run unless the marker is created manually.');
45+
}
46+
47+
return self::SUCCESS;
48+
}
49+
}

src/Providers/DatabaseTransactionRetryServiceProvider.php

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

33
namespace DatabaseTransactions\RetryHelper\Providers;
44

5+
use DatabaseTransactions\RetryHelper\Console\StartRetryCommand;
6+
use DatabaseTransactions\RetryHelper\Console\StopRetryCommand;
57
use Illuminate\Support\ServiceProvider;
68

79
class DatabaseTransactionRetryServiceProvider extends ServiceProvider
@@ -27,6 +29,11 @@ public function boot(): void
2729
$this->publishes([
2830
__DIR__ . '/../../config/database-transaction-retry.php' => $configPath,
2931
], 'database-transaction-retry-config');
32+
33+
$this->commands([
34+
StartRetryCommand::class,
35+
StopRetryCommand::class,
36+
]);
3037
}
3138
}
3239
}

src/Services/TransactionRetrier.php

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace DatabaseTransactions\RetryHelper\Services;
44

55
use Closure;
6+
use DatabaseTransactions\RetryHelper\Support\RetryToggle;
67
use DatabaseTransactions\RetryHelper\Support\TraceFormatter;
78
use DatabaseTransactions\RetryHelper\Support\TransactionRetryLogWriter;
89
use Illuminate\Database\QueryException;
@@ -31,7 +32,15 @@ public static function runWithRetry(
3132
?string $logFileName = null,
3233
string $trxLabel = ''
3334
): mixed {
34-
$config = function_exists('config') ? config('database-transaction-retry', []) : [];
35+
$config = function_exists('config') ? config('database-transaction-retry', []) : [];
36+
$config = is_array($config) ? $config : [];
37+
$trxLabel = $trxLabel ?? '';
38+
39+
if (! RetryToggle::isEnabled($config)) {
40+
static::exposeTransactionLabel($trxLabel);
41+
42+
return DB::transaction($callback);
43+
}
3544

3645
$maxRetries ??= (int) ($config['max_retries'] ?? 3);
3746
$retryDelay ??= (int) ($config['retry_delay'] ?? 2);
@@ -53,7 +62,7 @@ public static function runWithRetry(
5362
static::applyLockWaitTimeout($config);
5463

5564
// Expose the transaction label if the app wants to read it during the callback.
56-
$trxLabel === '' || app()->instance('tx.label', $trxLabel);
65+
static::exposeTransactionLabel($trxLabel);
5766

5867
return DB::transaction($callback);
5968
} catch (Throwable $exception) {
@@ -340,4 +349,17 @@ protected static function isLockWaitRetryEnabled(array $config): bool
340349

341350
return in_array(1205, $driverCodes, true);
342351
}
352+
353+
protected static function exposeTransactionLabel(string $trxLabel): void
354+
{
355+
if ($trxLabel === '') {
356+
return;
357+
}
358+
359+
if (! function_exists('app')) {
360+
return;
361+
}
362+
363+
app()->instance('tx.label', $trxLabel);
364+
}
343365
}

src/Support/RetryToggle.php

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<?php
2+
3+
namespace DatabaseTransactions\RetryHelper\Support;
4+
5+
class RetryToggle
6+
{
7+
protected const MARKER_FILENAME = 'retry-disabled.marker';
8+
9+
protected static ?bool $override = null;
10+
11+
public static function isEnabled(array $config = []): bool
12+
{
13+
if (file_exists(static::markerPath())) {
14+
return false;
15+
}
16+
17+
if (is_bool(static::$override)) {
18+
return static::$override;
19+
}
20+
21+
$fallback = static::normalizeBoolean($config['enabled'] ?? null, true);
22+
23+
return $fallback;
24+
}
25+
26+
public static function enable(): bool
27+
{
28+
static::$override = null;
29+
30+
return static::removeMarker();
31+
}
32+
33+
public static function disable(): bool
34+
{
35+
static::$override = false;
36+
37+
return static::writeMarker();
38+
}
39+
40+
public static function markerPath(): string
41+
{
42+
return static::stateDirectory() . '/' . static::MARKER_FILENAME;
43+
}
44+
45+
public static function isExplicitlyDisabledValue(mixed $value): bool
46+
{
47+
if ($value === null) {
48+
return false;
49+
}
50+
51+
if (is_string($value) && trim($value) === '') {
52+
return false;
53+
}
54+
55+
return static::normalizeBoolean($value, true) === false;
56+
}
57+
58+
protected static function writeMarker(): bool
59+
{
60+
$directory = static::stateDirectory();
61+
62+
if (! is_dir($directory) && ! @mkdir($directory, 0777, true) && ! is_dir($directory)) {
63+
return false;
64+
}
65+
66+
return file_put_contents(static::markerPath(), (string) time()) !== false;
67+
}
68+
69+
protected static function removeMarker(): bool
70+
{
71+
$path = static::markerPath();
72+
73+
if (file_exists($path) && ! @unlink($path)) {
74+
return false;
75+
}
76+
77+
static::cleanupStateDirectory();
78+
79+
return true;
80+
}
81+
82+
protected static function stateDirectory(): string
83+
{
84+
return dirname(__DIR__, 2) . '/storage/runtime';
85+
}
86+
87+
protected static function normalizeBoolean(mixed $value, bool $fallback): bool
88+
{
89+
if (is_bool($value)) {
90+
return $value;
91+
}
92+
93+
if (is_string($value)) {
94+
$value = strtolower(trim($value));
95+
96+
if ($value === '') {
97+
return $fallback;
98+
}
99+
100+
if (in_array($value, ['false', '0', 'off', 'no'], true)) {
101+
return false;
102+
}
103+
104+
if (in_array($value, ['true', '1', 'on', 'yes'], true)) {
105+
return true;
106+
}
107+
}
108+
109+
if (is_numeric($value)) {
110+
return (int) $value !== 0;
111+
}
112+
113+
return $fallback;
114+
}
115+
116+
protected static function cleanupStateDirectory(): void
117+
{
118+
$directory = static::stateDirectory();
119+
120+
if (is_dir($directory)) {
121+
$entries = @scandir($directory) ?: [];
122+
$entries = array_diff($entries, ['.', '..']);
123+
124+
if (count($entries) === 0) {
125+
@rmdir($directory);
126+
}
127+
}
128+
129+
$root = dirname($directory);
130+
131+
if (is_dir($root)) {
132+
$entries = @scandir($root) ?: [];
133+
$entries = array_diff($entries, ['.', '..']);
134+
135+
if (count($entries) === 0) {
136+
@rmdir($root);
137+
}
138+
}
139+
}
140+
}

tests/TestCase.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,9 @@ public function storagePath($path = ''): string
5151

5252
return $path === '' ? $base : $base . '/' . ltrim((string) $path, '/');
5353
}
54+
55+
public function runningUnitTests(): bool
56+
{
57+
return true;
58+
}
5459
}

0 commit comments

Comments
 (0)