Problem/Motivation

Submissions exports have the following format 'dir/webfrom/[webform_id].ext or ..., the filename is the save on every export.
\Drupal\webform\Plugin\WebformExporterBase::getBaseFileName

  /**
   * {@inheritdoc}
   */
  public function getBaseFileName() {
    $webform = $this->getWebform();
    $source_entity = $this->getSourceEntity();
    if ($source_entity) {
      return $webform->id() . '.' . $source_entity->getEntityTypeId() . '.' . $source_entity->id();
    }
    else {
      return $webform->id();
    }
  }

On sites with several web nodes sharing the export directory over NFS, each batch request can hit a different node. When one node deletes and recreates the file, another node may still hold the old file handle in its NFS client cache, so `fopen()` fails:


Warning: fopen(/app/tmp/webform/my_form.csv): Failed to open stream: Stale file handle in
Drupal\webform\Plugin\WebformExporter\TabularBaseWebformExporter->createExport()
TypeError: fputcsv(): Argument #1 ($stream) must be of type resource, false given in fputcsv()
(line 116 of src/Plugin/WebformExporter/DelimitedWebformExporter.php).

The shared file name also makes two concurrent exports of the same webform write to (and delete) the same file.

Steps to reproduce

  • Run Drupal on 2+ web nodes with the Webform export temp directory on a shared NFS mount.
  • Export the results of a webform with enough submissions to require a batch, then download the file.
  • Export the same webform again right away. Some batch requests fail with the error above on the node that cached the deleted file.

Proposed resolution

Use a token for each batch to avoid filename collisions between batch executions.

Remaining tasks

TBD.

User interface changes

TBD.

API changes

TBD.

Data model changes

TBD.

Issue fork webform-3632332

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

eduardo morales alberti’s picture

Status: Active » Needs review

Created MR:

Generate a token prefix:

$export_options['export_token'] = bin2hex(random_bytes(8));

The token is only internal; it will never download the file with it:

    if ($this->submissionExporter->getExporter()) {
      $base_file_name = $this->submissionExporter->getExporter()->getBaseFileName();
      $file_name = preg_replace('#^(' . preg_quote($base_file_name, '#') . ')\.[a-f0-9]{16}\.#', '$1.', basename($file_path));
      $response->setContentDisposition($download ? 'attachment' : 'inline', $file_name);
    }

Added a custom method openFileHandle, to catch the file when it is not available

 protected function openFileHandle($mode) {
    $file_path = $this->getExportFilePath();
    $file_handle = fopen($file_path, $mode);
    if ($file_handle === FALSE) {
      throw new \RuntimeException(sprintf('Unable to open the export file %s.', $file_path));
    }
    return $file_handle;
  }

Delete orphan export files using cron:

    $directory = \Drupal::service('webform_submission.exporter')->getFileTempDirectory();
    $expire = \Drupal::time()->getRequestTime() - 86400;
    foreach (scandir($directory) ?: [] as $file_name) {
      $file_path = $directory . '/' . $file_name;
      if (preg_match('/\.[a-f0-9]{16}\.(tar\.gz|[a-z0-9]+)$/', $file_name) && is_file($file_path) && filemtime($file_path) < $expire) {
        @unlink($file_path);
      }
    }