[12.x] Add schedule:why-not command for debugging scheduler failures#59129
[12.x] Add schedule:why-not command for debugging scheduler failures#59129OthmanHaba wants to merge 2 commits intolaravel:masterfrom
Conversation
This probably means, your PR should target |
| */ | ||
| public function __construct( | ||
| protected Filesystem $files, | ||
| protected int $maxEntries = 1000, |
There was a problem hiding this comment.
Will this be configurable?
There was a problem hiding this comment.
The constructor already accepts $maxEntries as a parameter, so it's configurable at the code level when instantiating the logger. Adding a config file entry for this felt unnecessary since it's a framework-internal default.
| * Create a new failure logger instance. | ||
| * | ||
| * @param \Illuminate\Filesystem\Filesystem $files | ||
| * @param int $maxEntries |
There was a problem hiding this comment.
You can be more explicit here:
| * @param int $maxEntries | |
| * @param non-negative-int $maxEntries |
There was a problem hiding this comment.
Done — updated to non-negative-int.
| /** | ||
| * Write an entry to the failure log. | ||
| * | ||
| * @param array $entry |
There was a problem hiding this comment.
You can type the array:
| * @param array $entry | |
| * @param array{ | |
| * timestamp: string, | |
| * command: string, | |
| * description: string, | |
| * type: 'failed'|'skipped', | |
| * exit_code: int|null, | |
| * exception: string, | |
| * mutex: string | |
| * } $entry |
There was a problem hiding this comment.
Done — added array shape annotation. Adjusted slightly since exit_code and exception are only present on failed entries, so those keys are marked optional.
| * | ||
| * @param \Illuminate\Console\Scheduling\Event $event | ||
| * @param \Illuminate\Support\Collection $failures | ||
| * @return array |
There was a problem hiding this comment.
Same here:
| * @return array | |
| * @return array{ | |
| * command: string, | |
| * status: 'OK'|'FAILED'|'SKIPPED', | |
| * diagnostics: string, | |
| * last_failure: string, | |
| * last_failed_at: string | |
| * } |
There was a problem hiding this comment.
Done — added return array shape and typed $failures param.
| /** | ||
| * Get the real-time diagnostics for an event. | ||
| * | ||
| * @param \Illuminate\Console\Scheduling\Event $event | ||
| * @return string | ||
| */ | ||
| protected function getDiagnostics(Event $event) | ||
| { | ||
| $issues = []; | ||
|
|
||
| if (! $event->runsInEnvironment($this->laravel->environment())) { | ||
| $issues[] = 'Wrong environment'; | ||
| } | ||
|
|
||
| if (! $event->runsInMaintenanceMode() && $this->laravel->isDownForMaintenance()) { | ||
| $issues[] = 'In maintenance'; | ||
| } | ||
|
|
||
| if (! $event->filtersPass($this->laravel)) { | ||
| $issues[] = 'Filters failing'; | ||
| } | ||
|
|
||
| if (! $event->expressionPasses()) { | ||
| $issues[] = 'Not due'; | ||
| } else { | ||
| $issues[] = 'Due'; | ||
| } | ||
|
|
||
| if ($event->mutex->exists($event)) { | ||
| $issues[] = 'Mutex active'; | ||
| } else { | ||
| $issues[] = 'No mutex'; | ||
| } | ||
|
|
||
| return implode(', ', $issues); | ||
| } |
There was a problem hiding this comment.
It might be interesting to explore having the diagnostics as enum array and concat them in buildRow():
namespace Illuminate\Console\Scheduling;
enum ScheduleDiagnostic: string
{
case WrongEnvironment = 'Wrong environment';
case InMaintenance = 'In maintenance';
case FiltersFailing = 'Filters failing';
case NotDue = 'Not due';
case Due = 'Due';
case MutexActive = 'Mutex active';
case NoMutex = 'No mutex';
}| /** | |
| * Get the real-time diagnostics for an event. | |
| * | |
| * @param \Illuminate\Console\Scheduling\Event $event | |
| * @return string | |
| */ | |
| protected function getDiagnostics(Event $event) | |
| { | |
| $issues = []; | |
| if (! $event->runsInEnvironment($this->laravel->environment())) { | |
| $issues[] = 'Wrong environment'; | |
| } | |
| if (! $event->runsInMaintenanceMode() && $this->laravel->isDownForMaintenance()) { | |
| $issues[] = 'In maintenance'; | |
| } | |
| if (! $event->filtersPass($this->laravel)) { | |
| $issues[] = 'Filters failing'; | |
| } | |
| if (! $event->expressionPasses()) { | |
| $issues[] = 'Not due'; | |
| } else { | |
| $issues[] = 'Due'; | |
| } | |
| if ($event->mutex->exists($event)) { | |
| $issues[] = 'Mutex active'; | |
| } else { | |
| $issues[] = 'No mutex'; | |
| } | |
| return implode(', ', $issues); | |
| } | |
| /** | |
| * Get the real-time diagnostics for an event. | |
| * | |
| * @param \Illuminate\Console\Scheduling\Event $event | |
| * @return \Illuminate\Console\Scheduling\ScheduleDiagnostic[]; | |
| */ | |
| protected function getDiagnostics(Event $event) | |
| { | |
| $issues = []; | |
| if (! $event->runsInEnvironment($this->laravel->environment())) { | |
| $issues[] = 'Wrong environment'; | |
| } | |
| if (! $event->runsInMaintenanceMode() && $this->laravel->isDownForMaintenance()) { | |
| $issues[] = 'In maintenance'; | |
| } | |
| if (! $event->filtersPass($this->laravel)) { | |
| $issues[] = 'Filters failing'; | |
| } | |
| if (! $event->expressionPasses()) { | |
| $issues[] = 'Not due'; | |
| } else { | |
| $issues[] = 'Due'; | |
| } | |
| if ($event->mutex->exists($event)) { | |
| $issues[] = 'Mutex active'; | |
| } else { | |
| $issues[] = 'No mutex'; | |
| } | |
| return implode(', ', $issues); | |
| } |
return [
'command' => $command,
'status' => $status,
- 'diagnostics' => $diagnostics,
+ 'diagnostics' => implode(', ', array_map(enum_value(...), $diagnostics)),
'last_failure' => $lastFailure['exception'] ?? $lastFailure['reason'] ?? '—',
'last_failed_at' => $lastFailure['timestamp'] ?? '—',
];There was a problem hiding this comment.
Interesting idea, but these strings are only used for display output — they're not consumed programmatically or compared anywhere. Introducing an enum for internal display-only values adds a new file and indirection without a clear consumer benefit. If the diagnostics become part of a public API or need to be matched on, an enum would make sense then.
| * Build a row of diagnostic data for the given event. | ||
| * | ||
| * @param \Illuminate\Console\Scheduling\Event $event | ||
| * @param \Illuminate\Support\Collection $failures |
There was a problem hiding this comment.
and here:
| * @param \Illuminate\Support\Collection $failures | |
| * @param \Illuminate\Support\Collection<int, array{ | |
| * mutex: string|null, | |
| * command: string|null, | |
| * exception: string|null, | |
| * reason: string|null, | |
| * timestamp: string|null | |
| * }> $failures |
There was a problem hiding this comment.
Done — covered in the same commit as the buildRow return type.
| return $value; | ||
| } | ||
|
|
||
| return mb_substr($value, 0, $length - 3).'...'; |
There was a problem hiding this comment.
Ellipsis (U+2026) would be more correct:
| return mb_substr($value, 0, $length - 3).'...'; | |
| return mb_substr($value, 0, $length - 3).'…'; |
Technically, if it is cut right at the end of the word, there has to be a space between the last character and the ellipsis.
There was a problem hiding this comment.
Done — switched to … (U+2026), consistent with RouteListCommand.
Add a new `php artisan schedule:why-not` Artisan command that helps diagnose why scheduled tasks are failing or not running. The command combines two sources of information: 1. A failure log (JSON lines at storage/logs/schedule-failures.json) that records ScheduledTaskFailed and ScheduledTaskSkipped events during schedule:run execution. 2. Real-time diagnostics that check each event's current state: whether it's due, environment restrictions, maintenance mode, filter conditions, and mutex status. New files: - ScheduleFailureLogger: listens to scheduler events and writes failure/skip entries to a JSON log with automatic rotation - ScheduleWhyNotCommand: reads the log and runs diagnostics, outputs a table or JSON Also makes Event::expressionPasses() public so the command can check whether a task's cron expression currently matches.
e0fbb73 to
c1297e8
Compare
- Use non-negative-int for $maxEntries parameter - Add array shape annotations for $entry, $failures, and buildRow return - Use Unicode ellipsis (U+2026) for truncation, matching RouteListCommand
Summary
This PR adds a new
php artisan schedule:why-notArtisan command that helps developers diagnose why their scheduled tasks are failing or not running. It addresses a common pain point: when a scheduled task silently fails or gets skipped, there's currently no built-in way to understand what went wrong.The command combines two sources of information:
Failure history — A
ScheduleFailureLoggerlistens toScheduledTaskFailedandScheduledTaskSkippedevents duringschedule:runand writes JSON line entries tostorage/logs/schedule-failures.json. The log auto-rotates at 1,000 entries.Real-time diagnostics — For each registered scheduled event, the command checks:
Example output
Command options
--json--event=<name>--limit=NNew files
src/Illuminate/Console/Scheduling/ScheduleFailureLogger.php— Event listener that writes failure/skip entries to the JSON log with automatic rotationsrc/Illuminate/Console/Scheduling/ScheduleWhyNotCommand.php— The Artisan command that reads the log and runs real-time diagnosticstests/Integration/Console/Scheduling/ScheduleFailureLoggerTest.php— 3 tests covering failure logging, skip logging, and log rotationtests/Integration/Console/Scheduling/ScheduleWhyNotCommandTest.php— 7 tests covering empty schedule, no-failure display, failure display, skip display, JSON output, event filtering, and the limit optionModified files
Event.php— MadeexpressionPasses()public (wasprotected) so the diagnostic command can check if a task's cron expression currently matchesScheduleRunCommand.php— AddedregisterFailureLogger()method that wires up theScheduleFailureLoggeras a listener forScheduledTaskFailedandScheduledTaskSkippedevents duringschedule:runArtisanServiceProvider.php— Registered the newScheduleWhyNotCommandin the$commandsarrayDesign decisions
ScheduleRunCommand::handle()rather than globally — the logger only needs to be active during scheduler execution, keeping it scoped and avoiding boot overhead for non-scheduler requestsTest plan
ScheduleFailureLoggerTest— 3 tests, all passingScheduleWhyNotCommandTest— 7 tests, all passing