From c054faf90288a75fc3680480434ba93b7f5b287b Mon Sep 17 00:00:00 2001
From: Elmar Ludwig <elmar.ludwig@uni-osnabrueck.de>
Date: Tue, 10 May 2022 22:36:06 +0000
Subject: [PATCH] rework locking and drop CRONJOBS_ESCALATION setting, fixes
 #771

Closes #771

Merge request studip/studip!592
---
 app/controllers/web_migrate.php               | 62 +++++++-------
 app/views/web_migrate/index.php               | 18 -----
 .../5.2.5_drop_cronjobs_escalation.php        | 31 +++++++
 lib/classes/CronjobScheduler.class.php        | 59 +-------------
 lib/classes/FileLock.class.php                | 80 +++++++------------
 tests/unit/lib/classes/FileLockTest.php       | 44 ++++++++++
 6 files changed, 135 insertions(+), 159 deletions(-)
 create mode 100644 db/migrations/5.2.5_drop_cronjobs_escalation.php
 create mode 100644 tests/unit/lib/classes/FileLockTest.php

diff --git a/app/controllers/web_migrate.php b/app/controllers/web_migrate.php
index 2637578d98d..3553f9041a4 100644
--- a/app/controllers/web_migrate.php
+++ b/app/controllers/web_migrate.php
@@ -26,9 +26,6 @@ class WebMigrateController extends StudipController
             true
         );
 
-        FileLock::setDirectory($GLOBALS['TMP_PATH']);
-        $this->lock = new FileLock('web-migrate');
-
         $this->setupSidebar($action);
 
         PageLayout::setTitle(_('Stud.IP Web-Migrator'));
@@ -41,38 +38,37 @@ class WebMigrateController extends StudipController
 
     public function migrate_action()
     {
-        ob_start();
-        set_time_limit(0);
-
-        $this->lock->lock(['timestamp' => time(), 'user_id' => $GLOBALS['user']->id]);
-
-        $this->migrator->migrateTo($this->target);
-
-        $this->lock->release();
-
-        $announcements = ob_get_clean();
-        PageLayout::postSuccess(
-            _('Die Datenbank wurde erfolgreich migriert.'),
-            array_filter(explode("\n", $announcements))
-        );
-
-        $_SESSION['migration-check'] = [
-            'timestamp' => time(),
-            'count'     => 0,
-        ];
-
-        $this->redirect('index');
-    }
-
-    public function release_action($target)
-    {
-        if ($this->lock->isLocked()) {
-            $this->lock->release();
-
-            PageLayout::postSuccess(_('Die Sperre wurde aufgehoben.'));
+        $lock = new FileLock('web-migrate');
+        $lock_data = ['timestamp' => time(), 'user_id' => $GLOBALS['user']->id];
+
+        if ($lock->tryLock($lock_data)) {
+            ob_start();
+            set_time_limit(0);
+
+            $this->migrator->migrateTo($this->target);
+
+            $lock->release();
+
+            $announcements = ob_get_clean();
+            PageLayout::postSuccess(
+                _('Die Datenbank wurde erfolgreich migriert.'),
+                array_filter(explode("\n", $announcements))
+            );
+
+            $_SESSION['migration-check'] = [
+                'timestamp' => time(),
+                'count'     => 0,
+            ];
+        } else {
+            $user = User::find($lock_data['user_id']);
+            PageLayout::postError(sprintf(
+                _('Die Migration wurde %s von %s bereits angestossen und läuft noch.'),
+                reltime($lock_data['timestamp']),
+                htmlReady($user ? $user->getFullName() : _('unbekannt'))
+            ));
         }
 
-        $this->redirect($this->url_for('index', compact('target')));
+        $this->redirect('index');
     }
 
     public function history_action()
diff --git a/app/views/web_migrate/index.php b/app/views/web_migrate/index.php
index 6bba52fbf24..5de26791c10 100644
--- a/app/views/web_migrate/index.php
+++ b/app/views/web_migrate/index.php
@@ -54,25 +54,7 @@
         <tfoot>
             <tr>
                 <td colspan="4">
-                <? if ($lock->isLocked($lock_data)):
-                    $user = User::find($lock_data['user_id']);
-                ?>
-                    <?= MessageBox::info(sprintf(
-                        _('Die Migration wurde %s von %s bereits angestossen und läuft noch.'),
-                        reltime($lock_data['timestamp']),
-                        htmlReady($user ? $user->getFullName() : _('unbekannt'))
-                    ), [
-                        sprintf(
-                            _('Sollte während der Migration ein Fehler aufgetreten sein, so können Sie '
-                            . 'diese Sperre durch den unten stehenden Link oder das Löschen der Datei '
-                            . '<em>%s</em> auflösen.'),
-                            $lock->getFilename()
-                        )
-                    ]) ?>
-                    <?= Studip\LinkButton::create(_('Sperre aufheben'), $controller->url_for('release', $target)) ?>
-                <? else: ?>
                     <?= Studip\Button::createAccept(_('Starten'), 'start')?>
-                <? endif; ?>
                 </td>
             </tr>
         </tfoot>
diff --git a/db/migrations/5.2.5_drop_cronjobs_escalation.php b/db/migrations/5.2.5_drop_cronjobs_escalation.php
new file mode 100644
index 00000000000..848eddd4bf3
--- /dev/null
+++ b/db/migrations/5.2.5_drop_cronjobs_escalation.php
@@ -0,0 +1,31 @@
+<?php
+
+class DropCronjobsEscalation extends Migration
+{
+    public function description()
+    {
+        return 'Drop CRONJOBS_ESCALATION system setting';
+    }
+
+    public function up()
+    {
+        $query = "DELETE `config`, `config_values`
+                  FROM `config` LEFT JOIN `config_values` USING (`field`)
+                  WHERE `field` = 'CRONJOBS_ESCALATION'";
+        DBManager::get()->exec($query);
+    }
+
+    public function down()
+    {
+        $query = 'INSERT INTO `config` (`field`, `value`, `type`, `section`, `mkdate`, `chdate`, `description`)
+                  VALUES (:name, :value, :type, :section, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), :description)';
+        $statement = DBManager::get()->prepare($query);
+        $statement->execute([
+            ':name'        => 'CRONJOBS_ESCALATION',
+            ':description' => 'Gibt an, nach wievielen Sekunden ein Cronjob als steckengeblieben angesehen wird',
+            ':section'     => 'global',
+            ':type'        => 'integer',
+            ':value'       => '300'
+        ]);
+    }
+}
diff --git a/lib/classes/CronjobScheduler.class.php b/lib/classes/CronjobScheduler.class.php
index 86f14b5d5ff..67db94c6d4d 100644
--- a/lib/classes/CronjobScheduler.class.php
+++ b/lib/classes/CronjobScheduler.class.php
@@ -46,15 +46,11 @@ class CronjobScheduler
         return self::$instance;
     }
 
-    protected $lock;
-
     /**
      * Private constructor to ensure the singleton pattern is used correctly.
      */
     private function __construct()
     {
-        FileLock::setDirectory($GLOBALS['TMP_PATH']);
-        $this->lock = new FileLock('studip-cronjob');
     }
 
     /**
@@ -254,54 +250,16 @@ class CronjobScheduler
             return;
         }
 
-        $escalation_time = Config::get()->CRONJOBS_ESCALATION;
+        $lock = new FileLock('studip-cronjob');
 
         // Check whether a previous cronjob worker is still running.
-        if ($this->lock->isLocked($data)) {
-            // Running but not yet escalated -> let it run
-            if ($data['timestamp'] + $escalation_time > time()) {
-                return;
-            }
-
-            // Load locked schedule
-            $schedule = CronjobSchedule::find($data['schedule_id']);
-
-            // If we discovered a deadlock release it
-            if ($schedule) {
-                // Deactivate schedule
-                $schedule->deactivate();
-
-                // Adjust log
-                $log = CronjobLog::find($data['log_id']);
-                if ($log) {
-                    $log->duration  = time() - $data['timestamp'];
-                    $log->exception = new Exception('Cronjob has escalated');
-                    $log->store();
-                }
-
-                // Inform roots about the escalated cronjob
-                $subject = sprintf('[Cronjobs] %s: %s',
-                                   _('Eskalierte Ausführung'),
-                                   $schedule->title);
-
-                $message = sprintf(_('Der Cronjob "%s" wurde deaktiviert, da '
-                                    .'seine Ausführungsdauer die maximale '
-                                    .'Ausführungszeit von %u Sekunden '
-                                    .'überschritten hat.') . "\n",
-                                   $schedule->title,
-                                   $escalation_time);
-
-                $this->sendMailToRoots($subject, $message);
-            }
-
-            // Release lock
-            $this->lock->release();
+        if (!$lock->tryLock()) {
+            return;
         }
 
         // Find all schedules that are due to execute and which task is active
         $temp = CronjobSchedule::findBySQL('active = 1 AND next_execution <= UNIX_TIMESTAMP() '
                                           .'ORDER BY priority DESC, next_execution ASC');
-#        $temp = SimpleORMapCollection::createFromArray($temp);
         $schedules = array_filter($temp, function ($schedule) { return $schedule->task->active; });
 
         if (count($schedules) === 0) {
@@ -323,15 +281,6 @@ class CronjobScheduler
                 $log->duration    = -1;
                 $log->store();
 
-                set_time_limit($escalation_time);
-
-                // Activate the file lock and store the current timestamp,
-                // schedule id and according log id in it
-                $this->lock->lock([
-                    'schedule_id' => $schedule->schedule_id,
-                    'log_id'      => $log->log_id,
-                ]);
-
                 // Start capturing output and measuring duration
                 ob_start();
                 $start_time = microtime(true);
@@ -372,7 +321,7 @@ class CronjobScheduler
         }
 
         // Release lock
-        $this->lock->release();
+        $lock->release();
     }
 
     /**
diff --git a/lib/classes/FileLock.class.php b/lib/classes/FileLock.class.php
index cf6c00ae6e7..064d41ef9cf 100644
--- a/lib/classes/FileLock.class.php
+++ b/lib/classes/FileLock.class.php
@@ -20,24 +20,7 @@
 
 class FileLock
 {
-    protected static $directory = '';
-
-    /**
-     * Sets a new base path for the locks.
-     *
-     * @param String $directory
-     * @throws RuntimeException if provided directory is either not existant
-     *         or is not a directory or is not writable.
-     */
-    public static function setDirectory($directory)
-    {
-        if (!file_exists($directory) || !is_dir($directory) || !is_writable($directory)) {
-            throw new RuntimeException('Passed directory is not an actual directory or is not writable.');
-        }
-        self::$directory = rtrim($directory, '/') . '/';
-    }
-
-    protected $filename;
+    protected $file;
 
     /**
      * Constructs a new lock object with the provided id.
@@ -46,55 +29,46 @@ class FileLock
      */
     public function __construct($id)
     {
-        $this->filename = self::$directory . '.' . $id . '.json';
-    }
-    
-    /**
-     * Returns the filename of the lock.
-     *
-     * @return String Filename of the lock
-     */
-    public function getFilename()
-    {
-        return $this->filename;
+        $this->file = fopen("{$GLOBALS['TMP_PATH']}/$id.json", 'c+');
+
+        if (!$this->file) {
+            throw new RuntimeException('failed to create lock file.');
+        }
     }
     
     /**
-     * Establish or renew the current lock. Provided lock information will
-     * be stored with the lock.
+     * Try to aquire a file lock. The provided lock information will
+     * be stored with the lock. If the lock cannot be aquired, the
+     * lock information in $data is updated from the lock file.
      *
-     * @param Array $data Additional information to bestore with the lock
+     * @param array $data additional data to be stored with the lock
+     * @return boolean true on success or false on failure
      */
-    public function lock($data = [])
+    public function tryLock(&$data = [])
     {
-        $data['timestamp'] = time();
-        file_put_contents($this->filename, json_encode($data));
-    }
+        rewind($this->file);
+
+        if (flock($this->file, LOCK_EX | LOCK_NB)) {
+            ftruncate($this->file, 0);
+            fwrite($this->file, json_encode($data));
+            fflush($this->file);
+
+            return true;
+        } else {
+            $json = stream_get_contents($this->file);
+            $data = json_decode($json, true);
 
-    /**
-     * Tests whether the lock is in use. Returns lock information in
-     * $lock_data.
-     *
-     * @param mixed $lock_data Information stored in lock
-     * @return bool Indicates whether the lock is active or not
-     */
-    public function isLocked(&$lock_data = null)
-    {
-        if (!file_exists($this->filename)) {
             return false;
         }
-
-        $lock_data = json_decode(file_get_contents($this->filename), true);
-        return true;
     }
 
     /**
      * Releases a previously obtained lock
+     *
+     * @return boolean true on success or false on failure
      */
     public function release()
     {
-        if (file_exists($this->filename)) {
-            unlink($this->filename);
-        }
+        return flock($this->file, LOCK_UN);
     }
-}
\ No newline at end of file
+}
diff --git a/tests/unit/lib/classes/FileLockTest.php b/tests/unit/lib/classes/FileLockTest.php
new file mode 100644
index 00000000000..b593794e32d
--- /dev/null
+++ b/tests/unit/lib/classes/FileLockTest.php
@@ -0,0 +1,44 @@
+<?php
+/*
+ * Copyright (c) 2022  Elmar Ludwig
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+class FileLockTest extends \Codeception\Test\Unit
+{
+    public function testAquireLock()
+    {
+        $lock = new FileLock('test');
+        $lock2 = new FileLock('test');
+
+        $this->assertTrue($lock->tryLock());
+        $lock->release();
+
+        $this->assertTrue($lock2->tryLock());
+        $lock2->release();
+    }
+
+    public function testBusyLock()
+    {
+        $lock = new FileLock('test');
+        $lock2 = new FileLock('test');
+
+        $data = ['foo' => '42'];
+        $this->assertTrue($lock->tryLock($data));
+
+        // test updating a lock
+        $data = ['foo' => 'bar'];
+        $this->assertTrue($lock->tryLock($data));
+
+        // aquiring the lock should fail
+        $data = [];
+        $this->assertFalse($lock2->tryLock($data));
+        $this->assertEquals('bar', $data['foo']);
+
+        $lock->release();
+    }
+}
-- 
GitLab