0

I'm building an application in CakePHP 3.8 which uses Console Commands to execute several processes.

These processes are quite resource intensive so I've written them with Commands because they would easily time-out if executed in a browser.

There are 5 different scripts that do different tasks: src/Command/Stage1Command.php, ... src/Command/Stage5Command.php.

The scripts are being executed in order (Stage 1 ... Stage 5) manually, i.e. src/Command/Stage1Command.php is executed with:

$ php bin/cake.php stage1

All 5 commands accept one parameter - an ID - and then perform some work. This has been set up as follows (the code in buildOptionsParser() exists in each command):

class Stage1Command extends Command
{
    protected function buildOptionParser(ConsoleOptionParser $parser)
    {
        $parser->addArgument('filter_id', [
            'help' => 'Filter ID must be passed as an argument',
            'required' => true
        ]);
        return $parser;
    }
}

So I can execute "Stage 1" as follows, assuming 428 is the ID I want to pass.

$ php bin/cake.php stage1 428

Instead of executing these manually, I want to achieve the following:

  1. Create a new Command which loops through a set of Filter ID's and then calls each of the 5 commands, passing the ID.

  2. Update a table to show the outcome (success, error) of each command.

For (1) I have created src/Command/RunAllCommand.php and then used a loop on my table of Filters to generate the IDs, and then execute the 5 commands, passing the ID. The script looks like this:

namespace App\Command;
use Cake\ORM\TableRegistry;
// ...

class RunAllCommand extends Command
{
    
    public function execute(Arguments $args, ConsoleIo $io)
    {
        $FiltersTable = TableRegistry::getTableLocator()->get('Filters');

        $all_filters = $FiltersTable->find()->toArray();

        foreach ($all_filters as $k => $filter) {
            $io->out($filter['id']);
        
            // execute Stage1Command.php        
            $command = new Stage1Command(['filter_id' => $filter['id']]);
            $this->executeCommand($command);
     
            // ...

            // execute Stage5Command.php
            $command5 = new Stage5Command(['filter_id' => $filter['id']]);
            $this->executeCommand($command5);
        }
    }
}

This doesn't work. It gives an error:

Filter ID must be passed as an argument

I can tell that the commands are being called because these are my own error messages from buildOptionsParser().

This makes no sense because the line $io->out($filter['id']) in RunAllCommand.php is showing that the filter IDs are being read from my database. How do you pass an argument in this way? I'm following the docs on Calling Other Commands (https://book.cakephp.org/3/en/console-and-shells/commands.html#calling-other-commands).

I don't understand how to achieve (2). In each of the Commands I've added code such as this when an error occurs which stops execution of the rest of that Command. For example if this gets executed in Stage1Command it should abort and move to Stage2Command:

// e.g. this code can be anywhere in execute() in any of the 5 commands where an error occurs.
$io->error('error message');
$this->abort();

If $this->abort() gets called anywhere I need to log this into another table in my database. Do I need to add code before $this->abort() to write this to a database, or is there some other way, e.g. try...catch in RunAllCommand?

Background information: The idea with this is that RunAllCommand.php would be executed via Cron. This means that the processes carried out by each Stage would occur at regular intervals without requiring manual execution of any of the scripts - or passing IDs manually as command parameters.

Andy
  • 5,142
  • 11
  • 58
  • 131

1 Answers1

2

The arguments sent to the "main" command are not automatically being passed to the "sub" commands that you're invoking with executeCommand(), the reason for that being that they might very well be incompatible, the "main" command has no way of knowing which arguments should or shouldn't be passed. The last thing you want is a sub command do something that you haven't asked it to do just because of an argument that the main command makes use of.

So you need to pass the arguments that you want your sub commands to receive manually, that would be the second argument of \Cake\Console\BaseCommand::executeCommand(), not the command constructor, it doesn't take any arguments at all (unless you've overwritten the base constructor).

$this->executeCommand($stage1, [$filter['id']]);

Note that the arguments array is not associative, the values are passed as single value entries, just like PHP would receive them in the $argv variable, ie:

['positional argument value', '--named', 'named option value']

With regards to errors, executeCommand() returns the exit code of the command. Calling $this->abort() in your sub command will trigger an exception, which is being catched in executeCommand() and has its code returned just like the normal exit code from your sub command's execute() method.

So if you just need to log a failure, then you could simply evaluate the return code, like:

$result = $this->executeCommand($stage1, [$filter['id']]);
// assuming your sub commands do always return a code, and do not 
// rely on `null` (ie no return value) being treated as success too
if ($result !== static::CODE_SUCCESS) {
    $this->log('Stage 1 failed');
}

If you need additional information to be logged, then you could of course log inside of your sub commands where that information is available, or maybe store error info in the command and expose a method to read that info, or throw an exception with error details that your main command could catch and evaluate. However, throwing an exception would not be overly nice when running the commands standalone, so you'll have to figure what the best option is in your case.

ndm
  • 59,784
  • 9
  • 71
  • 110
  • Thanks. The linked documentation on Calling Other Commands gives `$command = new OtherCommand($otherArgs);` as an example. It's not clear what `$otherArgs` is in the docs but it makes it look as though you pass them in the constructor to `OtherCommand`. – Andy Jul 10 '20 at 15:31
  • 1
    @Andy Indeed, wouldn't hurt to make the example a bit more clear, it's easy to misinterpret it. I've added another option to my answer regarding error details, that is buffering error details in the command object and exposing it. – ndm Jul 10 '20 at 15:35
  • I don't think the codes are returned as per the documentation or your example. For instance if I use `$this->abort('Test abort', 99);` or `$io->abort('Test abort', 99);` in one of my subcommands, it will error with `Exception: Wrong parameters for Cake\Console\Exception\StopException([string $message [, long $code [, Throwable $previous = NULL]]])`. Equally if I use `$io->success('Success message', 12)` it doesn't return the code (12 in this example) in the `RunAll` command – Andy Jul 14 '20 at 10:40
  • 1
    @Andy `\Cake\Console\BaseCommand::abort()` only accepts a single argument, an integer representing the exit code, what you're doing will cause a string to be passed to the expection, hence the failure. Using `\Cake\Console\ConsoleIo::abort()` that way should work fine, it's basically the same as doing `$io->error('Test abort'); $this->abort(99);`. – ndm Jul 14 '20 at 11:05
  • @Andy `\Cake\Console\ConsoleIo::success()` doesn't accept any exit codes at all, as it doesn't halt anything, the additional arguments are for number of newlines and the output level. You need to explicitly return the success code from your sub command's `execute()` method. In any case your main command must return the returned exit codes (`$result`) too in case you want to expose them to the CLI. – ndm Jul 14 '20 at 11:31