tripal_biodb/src/Task/BioTaskBase.php

Summary

Maintainability
A
1 hr
Test Coverage
<?php

namespace Drupal\tripal_biodb\Task;

use Drupal\tripal_biodb\Lock\SharedLockBackendInterface;
use Drupal\tripal_biodb\Exception\TaskException;
use Drupal\tripal_biodb\Exception\ParameterException;
use Drupal\tripal_biodb\Exception\LockException;
use Drupal\tripal\TripalDBX\TripalDbxConnection;

/**
 * Defines the base class for tasks on one or more biological schemas.
 */
abstract class BioTaskBase implements BioTaskInterface {

  /**
   * Name of the task.
   *
   * Should be overridden by implementing classes.
   */
  public const TASK_NAME = 'task';

  /**
   * Prefix for state keys to store data in Drupal State API.
   */
  public const STATE_KEY_DATA_PREFIX = 'tripal_biodb_';

  /**
   * Task identifier.
   *
   * @var string
   */
  protected $id;

  /**
   * The main database used.
   *
   * @var \Drupal\Core\Database\Connection
   */
  protected $connection;

  /**
   * The logger.
   *
   * @var \Psr\Log\LoggerInterface
   */
  protected $logger;

  /**
   * The schema and task locker.
   *
   * @var \Drupal\tripal_biodb\Lock\SharedLockBackendInterface
   */
  protected $locker;

  /**
   * The state storage service.
   *
   * @var \Drupal\Core\State\StateInterface
   */
  protected $state;

  /**
   * Task parameters.
   *
   * @var array
   */
  protected $parameters = ['input_schemas' => [], 'output_schemas' => [], ];

  /**
   * Input schemas as an array of \Drupal\tripal\TripalDBX\TripalDbxConnection.
   *
   * @var array
   */
  protected $inputSchemas = [];

  /**
   * Output schemas as an array of \Drupal\tripal\TripalDBX\TripalDbxConnection.
   *
   * @var array
   */
  protected $outputSchemas = [];

  /**
   * Creates a BioTaskBase object.
   *
   * @param ?\Drupal\Core\Database\Connection $connection
   *   The main database connection.
   * @param ?\Psr\Log\LoggerInterface $logger
   *   The logger.
   * @param ?\Drupal\tripal_biodb\Lock\SharedLockBackendInterface $locker
   *   The lock backend used to lock task and used schemas.
   * @param \Drupal\Core\State\StateInterface $state
   *   Drupal state service.
   *
   * @see https://api.drupal.org/api/drupal/core!lib!Drupal!Core!Database!Database.php/function/Database%3A%3AgetConnection/9.3.x
   * @see https://www.drupal.org/docs/8/api/database-api/database-configuration
   */
  public function __construct(
    ?\Drupal\Core\Database\Connection $database = NULL,
    ?\Psr\Log\LoggerInterface $logger = NULL,
    ?\Drupal\tripal_biodb\Lock\SharedLockBackendInterface $locker = NULL,
    ?\Drupal\Core\State\StateInterface $state = NULL
  ) {
    // Database.
    if (!isset($database)) {
      $database = \Drupal::database();
    }
    $this->connection = $database;
    // Logger.
    if (!isset($logger)) {
      $logger = \Drupal::service('tripal_biodb.logger');
    }
    $this->logger = $logger;
    // Locker.
    if (!isset($locker)) {
      $locker = \Drupal::service('tripal_biodb.lock');
    }
    $this->locker = $locker;
    // State.
    if (!isset($state)) {
      $state = \Drupal::state();
    }
    $this->state = $state;

    // Initializes task identifer.
    $this->initId();
  }

  /**
   * {@inheritdoc}
   */
  public function setParameters(array $parameters = []) :void {
    // Task parameters.
    $this->parameters =
      $parameters
      + ['input_schemas' => [], 'output_schemas' => [], ]
    ;

    // Initializes schema data.
    $this->inputSchemas = $this->prepareSchemas(
      $this->parameters['input_schemas']
    );
    $this->outputSchemas = $this->prepareSchemas(
      $this->parameters['output_schemas']
    );

    // Initializes task identifer.
    $this->initId();
  }

  /**
   * Gets the task-specific class for the specified category.
   *
   * Returns the task-specific override class if any for the specified class
   * category.
   *
   * @param string $class
   *   The class category for which we want the specific class.
   *
   * @return string
   *   The name of the class that should be used.
   */
  public function getTripalDbxClass($class) {
    static $classes = [
      'Connection' => TripalDbxConnection::class,
    ];
    if (!array_key_exists($class, $classes)) {
      throw new ConnectionException("Invalid Tripal DBX class '$class'.");
    }
    return $classes[$class];
  }

  /**
   * Parses schema names and extract the database name if one.
   *
   * @param array $schema_list
   *   An ordered array of biological schema names that may be prefixed by a
   *   Drupal database key followed by a dot (see
   *   \Drupal\Core\Database\Database::getConnection()).
   *
   * @return array
   *   An ordered array of \Drupal\tripal\TripalDBX\TripalDbxConnection objects.
   *
   * @see https://api.drupal.org/api/drupal/core!lib!Drupal!Core!Database!Database.php/function/Database%3A%3AgetConnection/9.3.x
   *
   * @throws \Drupal\tripal_biodb\Exception\ParameterException
   */
  protected function prepareSchemas(array $schema_list) :array {
    $schemas = [];
    foreach ($schema_list as $schema) {
      // We need to take into account schemas prefixed with a database key.
      // The following regex separate the key from the schema name and also do a
      // pre-check on schema names that will be fully validated when the
      // connection object will be instanciated.
      if (!preg_match(
            '/^((?:.+\.)?)([a-z_\\xA0-\\xFF][a-z_\\xA0-\\xFF0-9]*)$/',
            $schema,
            $match
          )
      ) {
        throw new ParameterException("Invalid schema specification: '$schema'.");
      }
      $schema_name = $match[2];
      $db_key = $match[1];
      // Check if a specific database has been specified.
      $class = $this->getTripalDbxClass('Connection');

      if (!empty($db_key)) {
        // Yes, remove trailing dot from key.
        $db_key = substr($db_key, 0, -1);
        $schema_db = new $class($schema_name, $db_key);
      }
      else {
        // No, use default database.
        $schema_db = new $class($schema_name);
      }
      $schemas[] = $schema_db;
    }
    return $schemas;
  }

  /**
   * Returns the lock name to use for the given schema.
   *
   * @param \Drupal\tripal\TripalDBX\TripalDbxConnection $db
   *   A schema connection.
   *
   * @return string
   *   The lock name.
   */
  protected function getSchemaLockName(
    \Drupal\tripal\TripalDBX\TripalDbxConnection $db
  ) :string {
    return $db->getDatabaseName() . '.' . $db->getSchemaName();
  }

  /**
   * Initializes task identifier.
   */
  protected function initId() :void {
    $raw_id =
      static::TASK_NAME
      . '-'
      . $this->connection->getConnectionOptions()['database']
    ;
    if (!empty($this->inputSchemas)) {
      $raw_id .= '-' . count($this->inputSchemas) . 'i';
    }
    foreach ($this->inputSchemas as $schema) {
      if (!empty($schema->getDatabaseKey())
          && ('default' != $schema->getDatabaseKey())
      ) {
        $raw_id .=
          '-'
          . $schema->getDatabaseName()
          . '.'
          . $schema->getSchemaName()
        ;
      }
      else {
        $raw_id .= '-' . $schema->getSchemaName();
      }
    }
    if (!empty($this->outputSchemas)) {
      $raw_id .= '-' . count($this->outputSchemas) . 'o';
    }
    foreach ($this->outputSchemas as $schema) {
      if (!empty($schema->getDatabaseKey())
          && ('default' != $schema->getDatabaseKey())
      ) {
        $raw_id .=
          '-'
          . $schema->getDatabaseName()
          . '.'
          . $schema->getSchemaName()
        ;
      }
      else {
        $raw_id .= '-' . $schema->getSchemaName();
      }
    }
    // Note: we may consider using md5() if people report issues on lock
    // conflicts when using schema or db names that contains special characters.
    $this->id = preg_replace(
      '/[^\w\.\-]+/',
      '_',
      $raw_id
    );
  }

  /**
   * Lock what is needed before performing the task.
   *
   * This method should be called by extending classes before starting their
   * job on schemas (ie. in `performTask()`) as it will make sure a same
   * task is not already running and it will also lock the schemas as needed.
   *
   * @return bool
   *   TRUE if all the needed locks have been acquired, FALSE otherwise.
   */
  protected function acquireTaskLocks() :bool {
    $all_locked = FALSE;
    try {
      // Lock task.
      $success = $this->locker->acquire($this->id);
      if (!$success) {
        throw new TaskException(
          "Unable to lock task '"
          . $this->id
          . "'. Another process may be already running this task."
        );
      }

      // Lock output schemas (exclusive).
      foreach ($this->outputSchemas as $schema) {
        $success = $this->locker->acquire($this->getSchemaLockName($schema));
        if (!$success) {
          throw new TaskException(
            "Unable to lock (exclusive) output schema '"
            . $schema->getSchemaName()
            . "'."
          );
        }
      }

      // Lock input schemas (shared).
      foreach ($this->inputSchemas as $schema) {
        $lock_name = $this->getSchemaLockName($schema);
        $success = $this->locker->acquireShared($lock_name);
        if (!$success) {
          throw new TaskException(
            "Unable to lock (shared) input schema '"
            . $schema->getSchemaName()
            . "' with the lock '$lock_name'."
          );
        }
      }

      $all_locked = TRUE;
    }
    catch (\Exception $e) {
      $this->logger->error(
        'Unable to acquire all locks for task "'
        . $this->id
        . '". '
        . $e->getMessage()
      );
      // Release what was locked.
      $this->locker->releaseAll();
    }
    return $all_locked;
  }

  /**
   * Example implementation of performTask().
   *
   * This implementation should be replaced by extending classes (do not call
   * parent::performTask method as it throws an error). It is provided as
   * an example skeleton and for testing: first, it check parameters by calling
   * ::validateParameters, it acquires required locks by calling
   * ::acquireTaskLocks, manages lock failures, then it performs the task and
   * finally releases the locks by calling ::releaseTaskLocks.
   *
   * @return bool
   *   TRUE if the task was performed with success and FALSE otherwise. In
   *   some cases, exceptions can also be thrown in order to report major
   *   failures. FALSE would be returned if the task was completed but without
   *   the expected success.
   *
   * @throws \Drupal\tripal_biodb\Exception\TaskException
   *   Thrown when a major failure prevents the task from being performed.
   *
   * @throws \Drupal\tripal_biodb\Exception\ParameterException
   *   Thrown if parameters are incorrect.
   *
   * @throws \Drupal\tripal_biodb\Exception\LockException
   *   Thrown when the locks can't be acquired.
   */
  public function performTask() :bool {
    // Task return status.
    $task_success = FALSE;

    // Validate parameters.
    $this->validateParameters();

    // Acquire locks.
    $success = $this->acquireTaskLocks();
    if (!$success) {
      throw new LockException(
        "Unable to acquire all locks for task. See logs for details."
      );
    }

    // Perform the actual task.
    // The following exception must be removed in implementations.
    throw new TaskException("Not implemented.");
    // Do long stuff here.
    // ...
    $task_success = TRUE;

    // Release all locks.
    $this->releaseTaskLocks();
    return $task_success;
  }

  /**
   * Release the locks used by the task.
   *
   * This method should be called by extending classes once their job on schemas
   * (ie. in `performTask()`) is over.
   */
  protected function releaseTaskLocks() :void {
    $this->locker->releaseAll();
  }

  /**
   * {@inheritdoc}
   */
  public function getId() :string {
    return $this->id;
  }

  /**
   * {@inheritdoc}
   */
  public function getStatus() :string {
    $progress = $this->getProgress();
    if (0 == $progress) {
      $status = t('Not started yet.');
    }
    elseif (1 <= $progress) {
      $status = t('Done.');
    }
    elseif (0 > $progress) {
      $status = t('An error occurred.');
    }
    else {
      $status = t('In progress');
    }
    return $status;
  }

  /**
   * {@inheritdoc}
   */
  public function getLogger() :\Psr\Log\LoggerInterface {
    return $this->logger;
  }

}