diff --git a/composer.json b/composer.json
index f7dd56b9cf6cf577d393897bbbf5d0697d533ca5..3ab95dc2ff9c9b6f80e8984f67e3b6ea39052d75 100644
--- a/composer.json
+++ b/composer.json
@@ -165,6 +165,7 @@
         "drupal/token": "1.9",
         "drupal/twig_tweak": "2.9",
         "drupal/twitter_block": "3.0-alpha1",
+        "drupal/ultimate_cron": "^2.0@alpha",
         "drupal/userprotect": "1.1",
         "drupal/video_embed_field": "2.4",
         "drupal/view_unpublished": "1.0",
diff --git a/composer.lock b/composer.lock
index cdcf77dde73b120bdd4fff287c28246358ff89dd..070408ff58948fd79d5bb53415ec098bc5c37f4b 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "39b546aa80d3dd2636b7dd5693e7c94e",
+    "content-hash": "dee17df1727a8fa0b885fa33c86cafba",
     "packages": [
         {
             "name": "alchemy/zippy",
@@ -7380,6 +7380,75 @@
                 "source": "https://git.drupalcode.org/project/twitter_block"
             }
         },
+        {
+            "name": "drupal/ultimate_cron",
+            "version": "2.0.0-alpha5",
+            "source": {
+                "type": "git",
+                "url": "https://git.drupalcode.org/project/ultimate_cron.git",
+                "reference": "8.x-2.0-alpha5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://ftp.drupal.org/files/projects/ultimate_cron-8.x-2.0-alpha5.zip",
+                "reference": "8.x-2.0-alpha5",
+                "shasum": "0f10464fff29eca89024e7afa5b6d8d07bd52f75"
+            },
+            "require": {
+                "drupal/core": "^8.7.7 || ^9"
+            },
+            "type": "drupal-module",
+            "extra": {
+                "drupal": {
+                    "version": "8.x-2.0-alpha5",
+                    "datestamp": "1600928948",
+                    "security-coverage": {
+                        "status": "not-covered",
+                        "message": "Alpha releases are not covered by Drupal security advisories."
+                    }
+                },
+                "drush": {
+                    "services": {
+                        "drush.services.yml": "^9 || ^10"
+                    }
+                }
+            },
+            "notification-url": "https://packages.drupal.org/8/downloads",
+            "license": [
+                "GPL-2.0+"
+            ],
+            "authors": [
+                {
+                    "name": "Berdir",
+                    "homepage": "https://www.drupal.org/user/214652"
+                },
+                {
+                    "name": "Dane Powell",
+                    "homepage": "https://www.drupal.org/user/339326"
+                },
+                {
+                    "name": "Primsi",
+                    "homepage": "https://www.drupal.org/user/282629"
+                },
+                {
+                    "name": "arnested",
+                    "homepage": "https://www.drupal.org/user/245635"
+                },
+                {
+                    "name": "gielfeldt",
+                    "homepage": "https://www.drupal.org/user/366993"
+                },
+                {
+                    "name": "miro_dietiker",
+                    "homepage": "https://www.drupal.org/user/227761"
+                }
+            ],
+            "description": "Ultimate cron",
+            "homepage": "https://www.drupal.org/project/ultimate_cron",
+            "support": {
+                "source": "https://git.drupalcode.org/project/ultimate_cron"
+            }
+        },
         {
             "name": "drupal/userprotect",
             "version": "1.1.0",
@@ -15512,7 +15581,8 @@
     "minimum-stability": "dev",
     "stability-flags": {
         "drupal/cache_control_override": 15,
-        "drupal/multiple_fields_remove_button": 15
+        "drupal/multiple_fields_remove_button": 15,
+        "drupal/ultimate_cron": 15
     },
     "prefer-stable": true,
     "prefer-lowest": false,
diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json
index deb1af93f8513c0b1e5e175d29465c3859968a77..d63e3b3beab3ee7f9d5ee62c98b3c1992618afec 100644
--- a/vendor/composer/installed.json
+++ b/vendor/composer/installed.json
@@ -7682,6 +7682,78 @@
             },
             "install-path": "../../web/modules/twitter_block"
         },
+        {
+            "name": "drupal/ultimate_cron",
+            "version": "2.0.0-alpha5",
+            "version_normalized": "2.0.0.0-alpha5",
+            "source": {
+                "type": "git",
+                "url": "https://git.drupalcode.org/project/ultimate_cron.git",
+                "reference": "8.x-2.0-alpha5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://ftp.drupal.org/files/projects/ultimate_cron-8.x-2.0-alpha5.zip",
+                "reference": "8.x-2.0-alpha5",
+                "shasum": "0f10464fff29eca89024e7afa5b6d8d07bd52f75"
+            },
+            "require": {
+                "drupal/core": "^8.7.7 || ^9"
+            },
+            "type": "drupal-module",
+            "extra": {
+                "drupal": {
+                    "version": "8.x-2.0-alpha5",
+                    "datestamp": "1600928948",
+                    "security-coverage": {
+                        "status": "not-covered",
+                        "message": "Alpha releases are not covered by Drupal security advisories."
+                    }
+                },
+                "drush": {
+                    "services": {
+                        "drush.services.yml": "^9 || ^10"
+                    }
+                }
+            },
+            "installation-source": "dist",
+            "notification-url": "https://packages.drupal.org/8/downloads",
+            "license": [
+                "GPL-2.0+"
+            ],
+            "authors": [
+                {
+                    "name": "Berdir",
+                    "homepage": "https://www.drupal.org/user/214652"
+                },
+                {
+                    "name": "Dane Powell",
+                    "homepage": "https://www.drupal.org/user/339326"
+                },
+                {
+                    "name": "Primsi",
+                    "homepage": "https://www.drupal.org/user/282629"
+                },
+                {
+                    "name": "arnested",
+                    "homepage": "https://www.drupal.org/user/245635"
+                },
+                {
+                    "name": "gielfeldt",
+                    "homepage": "https://www.drupal.org/user/366993"
+                },
+                {
+                    "name": "miro_dietiker",
+                    "homepage": "https://www.drupal.org/user/227761"
+                }
+            ],
+            "description": "Ultimate cron",
+            "homepage": "https://www.drupal.org/project/ultimate_cron",
+            "support": {
+                "source": "https://git.drupalcode.org/project/ultimate_cron"
+            },
+            "install-path": "../../web/modules/ultimate_cron"
+        },
         {
             "name": "drupal/userprotect",
             "version": "1.1.0",
diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php
index 9677e42e695059a08db80e246180bd1385a3928e..00ac08d532572f3fa9f2117af81c6af305763b4f 100644
--- a/vendor/composer/installed.php
+++ b/vendor/composer/installed.php
@@ -5,7 +5,7 @@
         'type' => 'project',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
-        'reference' => 'd2ba02375d2963b4d365129930f11ab04573ee3e',
+        'reference' => '2bc2e7748e927bf884a27dc8ab9beca9910c1aad',
         'name' => 'osu-asc-webservices/d8-upstream',
         'dev' => true,
     ),
@@ -1723,6 +1723,15 @@
             'reference' => '8.x-3.0-alpha1',
             'dev_requirement' => false,
         ),
+        'drupal/ultimate_cron' => array(
+            'pretty_version' => '2.0.0-alpha5',
+            'version' => '2.0.0.0-alpha5',
+            'type' => 'drupal-module',
+            'install_path' => __DIR__ . '/../../web/modules/ultimate_cron',
+            'aliases' => array(),
+            'reference' => '8.x-2.0-alpha5',
+            'dev_requirement' => false,
+        ),
         'drupal/update' => array(
             'dev_requirement' => false,
             'replaced' => array(
@@ -2095,7 +2104,7 @@
             'type' => 'project',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),
-            'reference' => 'd2ba02375d2963b4d365129930f11ab04573ee3e',
+            'reference' => '2bc2e7748e927bf884a27dc8ab9beca9910c1aad',
             'dev_requirement' => false,
         ),
         'pantheon-systems/quicksilver-pushback' => array(
diff --git a/web/modules/ultimate_cron/.travis.yml b/web/modules/ultimate_cron/.travis.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4281883026ce83baddfe6bbd0f26d4c2be278fb7
--- /dev/null
+++ b/web/modules/ultimate_cron/.travis.yml
@@ -0,0 +1,117 @@
+# @file
+# .travis.yml - Drupal for Travis CI Integration
+#
+# Template provided by https://github.com/LionsAd/drupal_ti.
+#
+# Based for simpletest upon:
+#   https://github.com/sonnym/travis-ci-drupal-module-example
+
+language: php
+
+sudo: false
+
+php:
+  - 5.5
+  - 5.6
+  - 7
+  - hhvm
+
+matrix:
+  fast_finish: true
+  allow_failures:
+    - php: 7
+    - php: hhvm
+
+env:
+  global:
+    # add composer's global bin directory to the path
+    # see: https://github.com/drush-ops/drush#install---composer
+    - PATH="$PATH:$HOME/.composer/vendor/bin"
+
+    # Configuration variables.
+    - DRUPAL_TI_MODULE_NAME="ultimate_cron"
+    - DRUPAL_TI_SIMPLETEST_GROUP="ultimate_cron"
+
+    # Define runners and environment vars to include before and after the
+    # main runners / environment vars.
+    #- DRUPAL_TI_SCRIPT_DIR_BEFORE="./.drupal_ti/before"
+    #- DRUPAL_TI_SCRIPT_DIR_AFTER="./drupal_ti/after"
+
+    # The environment to use, supported are: drupal-7, drupal-8
+    - DRUPAL_TI_ENVIRONMENT="drupal-8"
+
+    # Drupal specific variables.
+    - DRUPAL_TI_DB="drupal_travis_db"
+    - DRUPAL_TI_DB_URL="mysql://root:@127.0.0.1/drupal_travis_db"
+    # Note: Do not add a trailing slash here.
+    - DRUPAL_TI_WEBSERVER_URL="http://127.0.0.1"
+    - DRUPAL_TI_WEBSERVER_PORT="8080"
+
+    # Simpletest specific commandline arguments, the DRUPAL_TI_SIMPLETEST_GROUP is appended at the end.
+    - DRUPAL_TI_SIMPLETEST_ARGS="--verbose --color --concurrency 4 --url $DRUPAL_TI_WEBSERVER_URL:$DRUPAL_TI_WEBSERVER_PORT"
+
+    # === Behat specific variables.
+    # This is relative to $TRAVIS_BUILD_DIR
+    - DRUPAL_TI_BEHAT_DIR="./tests/behat"
+    # These arguments are passed to the bin/behat command.
+    - DRUPAL_TI_BEHAT_ARGS=""
+    # Specify the filename of the behat.yml with the $DRUPAL_TI_DRUPAL_DIR variables.
+    - DRUPAL_TI_BEHAT_YML="behat.yml.dist"
+    # This is used to setup Xvfb.
+    - DRUPAL_TI_BEHAT_SCREENSIZE_COLOR="1280x1024x16"
+    # The version of seleniumthat should be used.
+    - DRUPAL_TI_BEHAT_SELENIUM_VERSION="2.44"
+    # Set DRUPAL_TI_BEHAT_DRIVER to "selenium" to use "firefox" or "chrome" here.
+    - DRUPAL_TI_BEHAT_DRIVER="phantomjs"
+    - DRUPAL_TI_BEHAT_BROWSER="firefox"
+
+    # PHPUnit specific commandline arguments.
+    - DRUPAL_TI_PHPUNIT_ARGS="--verbose --debug"
+    # Specifying the phpunit-core src/ directory is useful when e.g. a vendor/
+    # directory is present in the module directory, which phpunit would then
+    # try to find tests in. This option is relative to $TRAVIS_BUILD_DIR.
+    #- DRUPAL_TI_PHPUNIT_CORE_SRC_DIRECTORY="./tests/src"
+
+    # Code coverage via coveralls.io
+    - DRUPAL_TI_COVERAGE="satooshi/php-coveralls:0.6.*"
+    # This needs to match your .coveralls.yml file.
+    - DRUPAL_TI_COVERAGE_FILE="build/logs/clover.xml"
+
+    # Debug options
+    #- DRUPAL_TI_DEBUG="-x -v"
+    # Set to "all" to output all files, set to e.g. "xvfb selenium" or "selenium",
+    # etc. to only output those channels.
+    #- DRUPAL_TI_DEBUG_FILE_OUTPUT="selenium xvfb webserver"
+
+  matrix:
+    # [[[ SELECT ANY OR MORE OPTIONS ]]]
+    #- DRUPAL_TI_RUNNERS="phpunit"
+    #- DRUPAL_TI_RUNNERS="simpletest"
+    #- DRUPAL_TI_RUNNERS="behat"
+    - DRUPAL_TI_RUNNERS="phpunit-core simpletest"
+
+# This will create the database
+mysql:
+  database: drupal_travis_db
+  username: root
+  encoding: utf8
+
+# To be able to run a webbrowser
+# If we need anything more powerful
+# than e.g. phantomjs
+before_install:
+  - composer self-update
+  - composer global require "lionsad/drupal_ti:1.*"
+  - drupal-ti before_install
+
+install:
+  - drupal-ti install
+
+before_script:
+  - drupal-ti before_script
+
+script:
+  - drupal-ti script
+
+after_script:
+  - drupal-ti after_script
diff --git a/web/modules/ultimate_cron/INSTALL.txt b/web/modules/ultimate_cron/INSTALL.txt
new file mode 100644
index 0000000000000000000000000000000000000000..bd74c964288c48de9335076f2a2395d80c23b175
--- /dev/null
+++ b/web/modules/ultimate_cron/INSTALL.txt
@@ -0,0 +1,2 @@
+Set your crontab to wget cron.php once per minute (* * * * *). If this is not possible, activate the Poormans cron function in the Ultimate Cron settings.
+
diff --git a/web/modules/ultimate_cron/LICENSE.txt b/web/modules/ultimate_cron/LICENSE.txt
new file mode 100644
index 0000000000000000000000000000000000000000..d159169d1050894d3ea3b98e1c965c4058208fe1
--- /dev/null
+++ b/web/modules/ultimate_cron/LICENSE.txt
@@ -0,0 +1,339 @@
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The licenses for most software are designed to take away your
+freedom to share and change it.  By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users.  This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it.  (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.)  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+  To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have.  You must make sure that they, too, receive or can get the
+source code.  And you must show them these terms so they know their
+rights.
+
+  We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+  Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software.  If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+  Finally, any free program is threatened constantly by software
+patents.  We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary.  To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                    GNU GENERAL PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License.  The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language.  (Hereinafter, translation is included without limitation in
+the term "modification".)  Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope.  The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+  1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+  2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+    a) You must cause the modified files to carry prominent notices
+    stating that you changed the files and the date of any change.
+
+    b) You must cause any work that you distribute or publish, that in
+    whole or in part contains or is derived from the Program or any
+    part thereof, to be licensed as a whole at no charge to all third
+    parties under the terms of this License.
+
+    c) If the modified program normally reads commands interactively
+    when run, you must cause it, when started running for such
+    interactive use in the most ordinary way, to print or display an
+    announcement including an appropriate copyright notice and a
+    notice that there is no warranty (or else, saying that you provide
+    a warranty) and that users may redistribute the program under
+    these conditions, and telling the user how to view a copy of this
+    License.  (Exception: if the Program itself is interactive but
+    does not normally print such an announcement, your work based on
+    the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole.  If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works.  But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+  3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+    a) Accompany it with the complete corresponding machine-readable
+    source code, which must be distributed under the terms of Sections
+    1 and 2 above on a medium customarily used for software interchange; or,
+
+    b) Accompany it with a written offer, valid for at least three
+    years, to give any third party, for a charge no more than your
+    cost of physically performing source distribution, a complete
+    machine-readable copy of the corresponding source code, to be
+    distributed under the terms of Sections 1 and 2 above on a medium
+    customarily used for software interchange; or,
+
+    c) Accompany it with the information you received as to the offer
+    to distribute corresponding source code.  (This alternative is
+    allowed only for noncommercial distribution and only if you
+    received the program in object code or executable form with such
+    an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it.  For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable.  However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+  4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License.  Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+  5. You are not required to accept this License, since you have not
+signed it.  However, nothing else grants you permission to modify or
+distribute the Program or its derivative works.  These actions are
+prohibited by law if you do not accept this License.  Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+  6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions.  You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+  7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all.  For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices.  Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+  8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded.  In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+  9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number.  If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation.  If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+  10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission.  For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this.  Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+                            NO WARRANTY
+
+  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    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.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License along
+    with this program; if not, write to the Free Software Foundation, Inc.,
+    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+    Gnomovision version 69, Copyright (C) year name of author
+    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary.  Here is a sample; alter the names:
+
+  Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+  `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+  <signature of Ty Coon>, 1 April 1989
+  Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs.  If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/web/modules/ultimate_cron/README.txt b/web/modules/ultimate_cron/README.txt
new file mode 100644
index 0000000000000000000000000000000000000000..a785b5d2af855a0ae6c415ad5a0359885159bc87
--- /dev/null
+++ b/web/modules/ultimate_cron/README.txt
@@ -0,0 +1,118 @@
+CONTENTS OF THIS FILE
+---------------------
+
+ * Introduction
+ * Requirements
+ * Installation
+ * Configuration
+ * Maintainers
+
+
+INTRODUCTION
+------------
+
+The Ultimate Cron module runs cron jobs individually in parallel using
+configurable rules, pool management and load balancing.
+
+ * For a full description of the module, visit the project page:
+   https://www.drupal.org/project/ultimate_cron
+
+ * To submit bug reports and feature suggestions, or to track changes:
+   https://www.drupal.org/project/issues/ultimate_cron
+
+
+REQUIREMENTS
+------------
+
+This module requires no modules outside of Drupal core.
+
+
+INSTALLATION
+------------
+
+ * Install the Ultimate Cron module as you would normally install a
+   contributed Drupal module. Visit
+   https://www.drupal.org/node/1897420 for further information.
+
+
+CONFIGURATION
+-------------
+
+To add a cron job you can use either hook_cron() or use configuration files with
+custom parameters for multiple/additional cron jobs in a module.
+
+The easiest way to declare a cron job is to use hook_cron() and then configure
+the cron job through the UI and export it, then change the cron jobs callback
+method.
+
+Another way to register a cron job is to add a cron configuration object in a
+custom module. In your custom module add in the sub directory
+my_module/config/optional a yaml file named
+ultimate_cron.job.my_custom_cron_job_name.yml
+
+For an example see the cron configuration of the simplenews module:
+http://cgit.drupalcode.org/simplenews/tree/config/optional/ultimate_cron.job.simplenews_cron.yml
+
+After installing the custom module the configuration will become available.
+During development you can use the config_devel module to import configuration.
+
+The cron configuration yaml file could look like:
+
+```
+langcode: en
+status: true
+dependencies:
+  module:
+    - user
+title: 'Pings users'
+id: user_ping
+module: my_module
+callback: _my_module_user_ping_cron
+scheduler:
+  id: simple
+  configuration:
+    rules:
+      - '*/5@ * * * *'
+launcher:
+  id: serial
+  configuration:
+    timeouts:
+      lock_timeout: 3600
+      max_execution_time: 3600
+    launcher:
+      max_threads: 1
+logger:
+  id: database
+  configuration:
+    method: '3'
+    expire: 1209600
+    retain: 1000
+```
+
+The following details of the cron job can be specified:
+ - "title": The title of the cron job. If not provided, the
+   name of the cron job will be used.
+ - "module": The module where this job lives.
+ - "callback": The callback to call when running the job.
+   Defaults to the job name.
+ - "scheduler": Default scheduler (plugin type) for this job.
+ - "launcher": Default launcher (plugin type) for this job.
+ - "logger": Default logger (plugin type) for this job.
+
+
+MAINTAINERS
+-----------
+
+ * Sascha Grossenbacher (Berdir) - https://www.drupal.org/u/berdir
+ * Arne Jørgensen (arnested) - https://www.drupal.org/u/arnested
+ * Thomas Gielfeldt (gielfeldt) - https://www.drupal.org/u/gielfeldt
+ * Lukas Schneider (LKS90) - https://www.drupal.org/u/lks90
+
+Supporting organizations:
+
+ * MD Systems - https://www.drupal.org/md-systems
+ * Reload! - https://www.drupal.org/reload
+
+Thanks to Mark James for the icons:
+
+ * http://www.famfamfam.com/lab/icons/silk/
diff --git a/web/modules/ultimate_cron/composer.json b/web/modules/ultimate_cron/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..7fc89ffd922116154e4e22e94b56b568f4e173b7
--- /dev/null
+++ b/web/modules/ultimate_cron/composer.json
@@ -0,0 +1,16 @@
+{
+  "name": "drupal/ultimate_cron",
+  "type": "drupal-module",
+  "license": "GPL-2.0+",
+  "description": "Ultimate cron",
+  "require": {
+      "drupal/core": "^8.7.7 || ^9"
+  },
+  "extra": {
+    "drush": {
+      "services": {
+        "drush.services.yml": "^9 || ^10"
+      }
+    }
+  }
+}
diff --git a/web/modules/ultimate_cron/config/install/ultimate_cron.settings.yml b/web/modules/ultimate_cron/config/install/ultimate_cron.settings.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4b8d42e43fb71bc8a0e389cea9107efe171191d4
--- /dev/null
+++ b/web/modules/ultimate_cron/config/install/ultimate_cron.settings.yml
@@ -0,0 +1,33 @@
+bypass_transactional_safe_connection: FALSE
+queue:
+  enabled: FALSE
+  timeouts:
+    lease_time: 30
+    time: 15
+  delays:
+    empty_delay: 0
+    item_delay: 0
+  throttle:
+    enabled: TRUE
+    threads: 4
+    threshold: 10
+launcher:
+  thread: 'any'
+  max_threads: 1
+  lock_timeout: 3600
+  max_execution_time: 3600
+logger:
+  cache:
+    bin: 'ultimate_cron_logger'
+    timeout: -1
+  database:
+    method: 3
+    expire: 1209600
+    retain: 1000
+scheduler:
+  crontab:
+    catch_up: 86400
+    rules:
+      - '*/10+@ * * * *'
+  simple:
+    rule: '*/15+@ * * * *'
diff --git a/web/modules/ultimate_cron/config/schema/ultimate_cron.schema.yml b/web/modules/ultimate_cron/config/schema/ultimate_cron.schema.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f2d1da2afef7602e04a0442423739ed9a3365253
--- /dev/null
+++ b/web/modules/ultimate_cron/config/schema/ultimate_cron.schema.yml
@@ -0,0 +1,236 @@
+ultimate_cron.settings:
+  type: config_object
+  label: Ultimate cron settings
+  mapping:
+    bypass_transactional_safe_connection:
+      type: boolean
+      label: 'Bypass transactional connection'
+    queue:
+      type: mapping
+      label: Queue settings
+      mapping:
+        enabled:
+          type: boolean
+          label: Toggle queing jobs
+        timeouts:
+          type: mapping
+          label: Timeouts
+          mapping:
+            lease_time:
+              type: float
+              label: Time to claim item
+            time:
+              type: float
+              label: Time to process item
+        delays:
+          type: mapping
+          label: Delays
+          mapping:
+            empty_delay:
+              type: float
+              lebel: Time to idle when queue is empty
+            item_delay:
+              type: float
+              label: Time to idle when finished with item
+        throttle:
+          type: mapping
+          label: Timeouts
+          mapping:
+            enabled:
+              type: boolean
+              label: Toggles throttling of queues
+            threads:
+              type: integer
+              label: Number of threads for queues
+            threshold:
+              type: integer
+              label: Number of items needed for a queue
+    launcher:
+      type: mapping
+      label: Launcher
+      mapping:
+        thread:
+          type: string
+          label: Which thread to use
+        max_threads:
+          type: integer
+          label: Maximum number of threads
+        lock_timeout:
+          type: integer
+          label: Time to lock job
+        max_execution_time:
+          type: integer
+          label: Maximum time for the job to run
+    logger:
+      type: mapping
+      label: Logger
+      mapping:
+        cache:
+          type: mapping
+          label: Cache
+          mapping:
+            bin:
+              type: string
+              label: The cache bin to use
+            timeout:
+              type: integer
+              label: Time until cache expires
+        database:
+          type: mapping
+          label: Database
+          mapping:
+            method:
+              type: integer
+              label: The method used for log retention
+            expire:
+              type: integer
+              label: Time until expiration
+            retain:
+              type: integer
+              label: Maximum number of logs to keep
+    scheduler:
+      type: mapping
+      label: Schedulr
+      mapping:
+        crontab:
+          type: mapping
+          label: Crontab
+          mapping:
+            catch_up:
+              type: integer
+              label: Stop between job runs
+            rules:
+              type: sequence
+              label: Array with rules
+              sequence:
+                type: string
+                label: Formatted rule
+        simple:
+          type: mapping
+          label: Simple
+          mapping:
+            rule:
+              type: string
+              label: Rule for Simple scheduler
+
+ultimate_cron.job.*:
+  type: config_entity
+  label: 'Cron Job'
+  mapping:
+    title:
+      type: label
+      label: 'Title'
+    id:
+      type: string
+      label: 'Machine-readable name'
+    weight:
+      type: integer
+      label: 'Weight'
+    module:
+      type: string
+      label: 'Module Name'
+    callback:
+      type: string
+      label: 'Callback'
+    scheduler:
+      type: mapping
+      label: 'Scheduler'
+      mapping:
+        id:
+          type: string
+          label: 'Scheduler ID'
+        configuration:
+          type: ultimate_cron.plugin.scheduler.[%parent.id]
+    launcher:
+      type: mapping
+      label: 'Scheduler'
+      mapping:
+        id:
+          type: string
+          label: 'Launcher ID'
+        configuration:
+          type: ultimate_cron.plugin.launcher.[%parent.id]
+    logger:
+      type: mapping
+      label: 'Scheduler'
+      mapping:
+        id:
+          type: string
+          label: 'Logger ID'
+        configuration:
+          type: ultimate_cron.plugin.logger.[%parent.id]
+
+ultimate_cron.plugin.scheduler.simple:
+  type: mapping
+  label: 'Scheduler configuration'
+  mapping:
+    rules:
+      type: sequence
+      label: 'Scheduler rules'
+      sequence:
+        type: string
+        label: 'Scheduling rule'
+
+ultimate_cron.plugin.scheduler.crontab:
+  type: mapping
+  label: 'Scheduler configuration'
+  mapping:
+    rules:
+      type: sequence
+      label: 'Scheduler rules'
+      sequence:
+        type: string
+        label: 'Scheduling rule'
+    catch_up:
+      type: integer
+      label: 'Timeout (s) after job run'
+
+ultimate_cron.plugin.launcher.serial:
+  type: mapping
+  label: 'Scheduler configuration'
+  mapping:
+    timeouts:
+      type: mapping
+      label: 'Timeout settings'
+      mapping:
+        lock_timeout:
+          type: integer
+          label: 'Lock timeout'
+        max_execution_time:
+          type: integer
+          label: 'Max execution time'
+    launcher:
+      type: mapping
+      label: 'Launcher settings'
+      mapping:
+        max_threads:
+          type: integer
+          label: 'Max threads'
+        thread:
+          type: integer
+          label: 'Thread'
+
+ultimate_cron.plugin.logger.database:
+  type: mapping
+  label: 'Scheduler configuration'
+  mapping:
+    method:
+      type: string
+      label: 'Method'
+    expire:
+      type: integer
+      label: 'Expiration'
+    retain:
+      type: integer
+      label: 'Retain X amount of logs'
+
+ultimate_cron.plugin.logger.cache:
+  type: mapping
+  label: 'Scheduler configuration'
+  mapping:
+    bin:
+      type: string
+      label: 'Cache bin to use for storing logs'
+    timeout:
+      type: integer
+      label: 'Timeout (s) before cache entry expires'
diff --git a/web/modules/ultimate_cron/css/ultimate_cron.admin.css b/web/modules/ultimate_cron/css/ultimate_cron.admin.css
new file mode 100755
index 0000000000000000000000000000000000000000..9f63e04319ee18df7b312a35e82422f83c4363e4
--- /dev/null
+++ b/web/modules/ultimate_cron/css/ultimate_cron.admin.css
@@ -0,0 +1,65 @@
+.ultimate-cron-admin-status a {
+  width: 16px;
+  height: 16px;
+  display: block;
+}
+
+.ultimate-cron-admin-status span {
+  display: none;
+}
+
+.ultimate-cron-admin-status-info,
+.ultimate-cron-admin-status-error,
+.ultimate-cron-admin-status-success,
+.ultimate-cron-admin-status-warning,
+.ultimate-cron-admin-status-running {
+  background-position: center;
+  background-repeat: no-repeat;
+}
+
+a.ultimate-cron-admin-status-info,
+a.ultimate-cron-admin-status-info:hover,
+a.ultimate-cron-admin-status-error,
+a.ultimate-cron-admin-status-error:hover,
+a.ultimate-cron-admin-status-success,
+a.ultimate-cron-admin-status-success:hover,
+a.ultimate-cron-admin-status-warning,
+a.ultimate-cron-admin-status-warning:hover,
+a.ultimate-cron-admin-status-running,
+a.ultimate-cron-admin-status-running:hover {
+  padding-left: 24px !important;
+  background-position: 5px center !important;
+  background-repeat: no-repeat !important;
+}
+
+
+.ultimate-cron-admin-status-info {
+  background-image: url(../icons/message-16-info.png) !important;
+}
+
+.ultimate-cron-admin-status-error {
+  background-image: url(../icons/message-16-error.png) !important;
+}
+
+.ultimate-cron-admin-status-success {
+  background-image: url(../icons/message-16-ok.png) !important;
+}
+
+.ultimate-cron-admin-status-warning {
+  background-image: url(../icons/message-16-warning.png) !important;
+}
+
+.ultimate-cron-admin-status-running {
+  background-image: url(../icons/hourglass.png) !important;
+}
+
+.ultimate-cron-admin-start,
+.ultimate-cron-admin-end,
+.ultimate-cron-admin-duration,
+.ultimate-cron-admin-rules {
+  white-space: nowrap;
+}
+
+.ultimate-cron-admin-message {
+  white-space: pre-line;
+}
diff --git a/web/modules/ultimate_cron/drush.services.yml b/web/modules/ultimate_cron/drush.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..daa66a5005e7a44e708704e11d316d16c9b3d1af
--- /dev/null
+++ b/web/modules/ultimate_cron/drush.services.yml
@@ -0,0 +1,6 @@
+services:
+  ultimate_cron.commands:
+    class: Drupal\ultimate_cron\Commands\UltimateCronCommands
+    arguments: ['@logger.factory']
+    tags:
+      - { name: drush.command }
diff --git a/web/modules/ultimate_cron/help/about.html b/web/modules/ultimate_cron/help/about.html
new file mode 100644
index 0000000000000000000000000000000000000000..1901b3dcd5a067f61460cb234b177782f874da76
--- /dev/null
+++ b/web/modules/ultimate_cron/help/about.html
@@ -0,0 +1 @@
+<p>To be written</p>
diff --git a/web/modules/ultimate_cron/help/hooks.html b/web/modules/ultimate_cron/help/hooks.html
new file mode 100644
index 0000000000000000000000000000000000000000..eec3d3966fd5a9f789d5cb3103aed8bace4acf03
--- /dev/null
+++ b/web/modules/ultimate_cron/help/hooks.html
@@ -0,0 +1 @@
+<p>This section explains how to use the hooks implemented by Ultimate Cron.</p>
diff --git a/web/modules/ultimate_cron/help/launchers-background-process.html b/web/modules/ultimate_cron/help/launchers-background-process.html
new file mode 100644
index 0000000000000000000000000000000000000000..1901b3dcd5a067f61460cb234b177782f874da76
--- /dev/null
+++ b/web/modules/ultimate_cron/help/launchers-background-process.html
@@ -0,0 +1 @@
+<p>To be written</p>
diff --git a/web/modules/ultimate_cron/help/launchers-create.html b/web/modules/ultimate_cron/help/launchers-create.html
new file mode 100644
index 0000000000000000000000000000000000000000..1901b3dcd5a067f61460cb234b177782f874da76
--- /dev/null
+++ b/web/modules/ultimate_cron/help/launchers-create.html
@@ -0,0 +1 @@
+<p>To be written</p>
diff --git a/web/modules/ultimate_cron/help/launchers-serial.html b/web/modules/ultimate_cron/help/launchers-serial.html
new file mode 100644
index 0000000000000000000000000000000000000000..1901b3dcd5a067f61460cb234b177782f874da76
--- /dev/null
+++ b/web/modules/ultimate_cron/help/launchers-serial.html
@@ -0,0 +1 @@
+<p>To be written</p>
diff --git a/web/modules/ultimate_cron/help/launchers.html b/web/modules/ultimate_cron/help/launchers.html
new file mode 100644
index 0000000000000000000000000000000000000000..1901b3dcd5a067f61460cb234b177782f874da76
--- /dev/null
+++ b/web/modules/ultimate_cron/help/launchers.html
@@ -0,0 +1 @@
+<p>To be written</p>
diff --git a/web/modules/ultimate_cron/help/loggers-cache.html b/web/modules/ultimate_cron/help/loggers-cache.html
new file mode 100644
index 0000000000000000000000000000000000000000..1901b3dcd5a067f61460cb234b177782f874da76
--- /dev/null
+++ b/web/modules/ultimate_cron/help/loggers-cache.html
@@ -0,0 +1 @@
+<p>To be written</p>
diff --git a/web/modules/ultimate_cron/help/loggers-create.html b/web/modules/ultimate_cron/help/loggers-create.html
new file mode 100644
index 0000000000000000000000000000000000000000..1901b3dcd5a067f61460cb234b177782f874da76
--- /dev/null
+++ b/web/modules/ultimate_cron/help/loggers-create.html
@@ -0,0 +1 @@
+<p>To be written</p>
diff --git a/web/modules/ultimate_cron/help/loggers-database.html b/web/modules/ultimate_cron/help/loggers-database.html
new file mode 100644
index 0000000000000000000000000000000000000000..1901b3dcd5a067f61460cb234b177782f874da76
--- /dev/null
+++ b/web/modules/ultimate_cron/help/loggers-database.html
@@ -0,0 +1 @@
+<p>To be written</p>
diff --git a/web/modules/ultimate_cron/help/loggers.html b/web/modules/ultimate_cron/help/loggers.html
new file mode 100644
index 0000000000000000000000000000000000000000..1901b3dcd5a067f61460cb234b177782f874da76
--- /dev/null
+++ b/web/modules/ultimate_cron/help/loggers.html
@@ -0,0 +1 @@
+<p>To be written</p>
diff --git a/web/modules/ultimate_cron/help/plugins.html b/web/modules/ultimate_cron/help/plugins.html
new file mode 100644
index 0000000000000000000000000000000000000000..1901b3dcd5a067f61460cb234b177782f874da76
--- /dev/null
+++ b/web/modules/ultimate_cron/help/plugins.html
@@ -0,0 +1 @@
+<p>To be written</p>
diff --git a/web/modules/ultimate_cron/help/rules.html b/web/modules/ultimate_cron/help/rules.html
new file mode 100644
index 0000000000000000000000000000000000000000..6abf8e874b360182a6cbf7a9485b13d7b97d85d4
--- /dev/null
+++ b/web/modules/ultimate_cron/help/rules.html
@@ -0,0 +1,41 @@
+<h3>Fields order</h3>
+<pre>
+ +---------------- minute (0 - 59)
+ |  +------------- hour (0 - 23)
+ |  |  +---------- day of month (1 - 31)
+ |  |  |  +------- month (1 - 12)
+ |  |  |  |  +---- day of week (0 - 7) (Sunday=0)
+ |  |  |  |  |
+ *  *  *  *  *
+</pre>
+<p>Each of the patterns from the first five fields may be either * (an asterisk),
+which matches all legal values, or a list of elements separated by commas (see below).</p>
+<p>For "day of the week" (field 5), 0 is considered Sunday, 6 is Saturday and 7 is also
+considered Sunday. Literal names (e.g. mon, tue, wed, etc.) are also accepted.</p>
+<p>Literal names are also accepted for months (e.g. jan, feb, mar, etc.).
+<p>A job is executed when the time/date specification fields all match the current
+time and date. There is one exception: if both "day of month" and "day of week"
+are restricted (not "*"), then either the "day of month" field (3) or the "day of week"
+field (5) must match the current day (even though the other of the two fields
+need not match the current day).</p>
+
+<h3>Fields operators</h3>
+<p>There are several ways of specifying multiple date/time values in a field:</p>
+<ul>
+<li>The comma (',') operator specifies a list of values, for example: "1,3,4,7,8"</li>
+<li>The dash ('-') operator specifies a range of values, for example: "1-6", which is equivalent to "1,2,3,4,5,6"</li>
+<li>The asterisk ('*') operator specifies all possible values for a field. For example, an asterisk in the hour time field would be equivalent to 'every hour' (subject to matching other specified fields).</li>
+<li>The slash ('/') operator (called "step") can be used to skip a given number of values. For example, "*/3" in the hour time field is equivalent to "0,3,6,9,12,15,18,21".</li>
+<li>The plus ('+') operator (called "offset") can be used as an offset to a given range. For example, "*/10+2" in the hour minute field is equivalent to "2,12,22,32,42,52".</li>
+<li>The at ('@') operator (called "skew") can be used as an auto calculated value for a given range. The value is calculated per job and is fixed per job. For example, if one job has the skew "2", the rule "*/10+@" in the hour minute field is equivalent to "2,12,22,32,42,52".</li>
+</ul>
+
+<h3>Examples</h3>
+<pre>
+ */15 * * * : Execute job every 15 minutes
+ */15+@ * * * : Execute job every 15 minutes, but at a different offset for each job
+ 0 2,14 * * *: Execute job every day at 2:00 and 14:00
+ 0 2 * * 1-5: Execute job at 2:00 of every working day
+ 0 12 1 */2 1: Execute job every 2 month, at 12:00 of first day of the month OR at every monday.
+</pre>
+
diff --git a/web/modules/ultimate_cron/help/schedulers-create.html b/web/modules/ultimate_cron/help/schedulers-create.html
new file mode 100644
index 0000000000000000000000000000000000000000..1901b3dcd5a067f61460cb234b177782f874da76
--- /dev/null
+++ b/web/modules/ultimate_cron/help/schedulers-create.html
@@ -0,0 +1 @@
+<p>To be written</p>
diff --git a/web/modules/ultimate_cron/help/schedulers-crontab.html b/web/modules/ultimate_cron/help/schedulers-crontab.html
new file mode 100644
index 0000000000000000000000000000000000000000..1901b3dcd5a067f61460cb234b177782f874da76
--- /dev/null
+++ b/web/modules/ultimate_cron/help/schedulers-crontab.html
@@ -0,0 +1 @@
+<p>To be written</p>
diff --git a/web/modules/ultimate_cron/help/schedulers-simple.html b/web/modules/ultimate_cron/help/schedulers-simple.html
new file mode 100644
index 0000000000000000000000000000000000000000..1901b3dcd5a067f61460cb234b177782f874da76
--- /dev/null
+++ b/web/modules/ultimate_cron/help/schedulers-simple.html
@@ -0,0 +1 @@
+<p>To be written</p>
diff --git a/web/modules/ultimate_cron/help/schedulers.html b/web/modules/ultimate_cron/help/schedulers.html
new file mode 100644
index 0000000000000000000000000000000000000000..1901b3dcd5a067f61460cb234b177782f874da76
--- /dev/null
+++ b/web/modules/ultimate_cron/help/schedulers.html
@@ -0,0 +1 @@
+<p>To be written</p>
diff --git a/web/modules/ultimate_cron/help/settings-create.html b/web/modules/ultimate_cron/help/settings-create.html
new file mode 100644
index 0000000000000000000000000000000000000000..1901b3dcd5a067f61460cb234b177782f874da76
--- /dev/null
+++ b/web/modules/ultimate_cron/help/settings-create.html
@@ -0,0 +1 @@
+<p>To be written</p>
diff --git a/web/modules/ultimate_cron/help/settings-general.html b/web/modules/ultimate_cron/help/settings-general.html
new file mode 100644
index 0000000000000000000000000000000000000000..1901b3dcd5a067f61460cb234b177782f874da76
--- /dev/null
+++ b/web/modules/ultimate_cron/help/settings-general.html
@@ -0,0 +1 @@
+<p>To be written</p>
diff --git a/web/modules/ultimate_cron/help/settings-queue.html b/web/modules/ultimate_cron/help/settings-queue.html
new file mode 100644
index 0000000000000000000000000000000000000000..1901b3dcd5a067f61460cb234b177782f874da76
--- /dev/null
+++ b/web/modules/ultimate_cron/help/settings-queue.html
@@ -0,0 +1 @@
+<p>To be written</p>
diff --git a/web/modules/ultimate_cron/help/settings.html b/web/modules/ultimate_cron/help/settings.html
new file mode 100644
index 0000000000000000000000000000000000000000..b21a39d9efeca9973bc465169c7a494b29f92bee
--- /dev/null
+++ b/web/modules/ultimate_cron/help/settings.html
@@ -0,0 +1 @@
+<p>This section describes the settings plugin type</p>
diff --git a/web/modules/ultimate_cron/help/ultimate_cron.help.ini b/web/modules/ultimate_cron/help/ultimate_cron.help.ini
new file mode 100644
index 0000000000000000000000000000000000000000..58c596362c1f851c01f93e476420789623748612
--- /dev/null
+++ b/web/modules/ultimate_cron/help/ultimate_cron.help.ini
@@ -0,0 +1,94 @@
+[advanced help settings]
+line break = TRUE
+
+[about]
+title = About Ultimate Cron
+weight = -100
+
+[plugins]
+title = Plugins
+weight = 10
+
+[schedulers]
+title = Schedulers
+parent = plugins
+weight = 10
+
+[schedulers-crontab]
+title = Crontab Scheduler
+parent = schedulers
+weight = 10
+
+[schedulers-simple]
+title = Simple Scheduler
+parent = schedulers
+weight = 20
+
+[schedulers-create]
+title = Creating a Scheduler plugin
+parent = schedulers
+weight = 30
+
+[launchers]
+title = Launchers
+parent = plugins
+weight = 20
+
+[launchers-serial]
+title = Serial Launcher
+parent = launchers
+weight = 10
+
+[launchers-background-process]
+title = Background Process Launcher
+parent = launchers
+weight = 20
+
+[launchers-create]
+title = Creating a Launcher plugin
+parent = launchers
+weight = 30
+
+[loggers]
+title = Loggers
+parent = plugins
+weight = 30
+
+[loggers-database]
+title = Database Logger
+parent = loggers
+weight = 10
+
+[loggers-cache]
+title = Cache Logger
+parent = loggers
+weight = 20
+
+[loggers-create]
+title = Creating a Logger plugin
+parent = loggers
+weight = 30
+
+[settings]
+title = Settings
+parent = plugins
+weight = 40
+
+[settings-general]
+title = General Settings
+parent = settings
+weight = 10
+
+[settings-queue]
+title = Queue Settings
+parent = settings
+weight = 20
+
+[settings-create]
+title = Creating a Settings plugin
+parent = settings
+weight = 30
+
+[hooks]
+title = Hooks
+weight = 20
diff --git a/web/modules/ultimate_cron/icons/add.png b/web/modules/ultimate_cron/icons/add.png
new file mode 100644
index 0000000000000000000000000000000000000000..6332fefea4be19eeadf211b0b202b272e8564898
--- /dev/null
+++ b/web/modules/ultimate_cron/icons/add.png
@@ -0,0 +1,4 @@
+�PNG
+
+���
IHDR�����������a���gAMA����7�����tEXtSoftware�Adobe ImageReadyq�e<��oIDAT8˥��K�a��[�/���Y(�)%X(o�l��Nۖsk���n.���-����h�;8�f���E��P��"jï��MGˈ�}yພ羹�$�I���.t�u���lu���	AX��:�𼂒Z�H�h1�D�nZJ�OJB��{�Z����?�`2`��S���=�N$��ő�=;��a��&j�w��q�JG�#��<"N���2h8�޵`��6���x�ցn_+~��Zto��}`���x%XЛ͈	hXѿ�ƻ/��}���B�J�_G�&�|Q�r-��6��AރEL�⬡\�U3:WUh[�C6+�	6.f� �*���K͸ܝF��q�����ou4܄?�d�|X���ҥ�Mv��D`�
*_���[
���#A���2��0li��R�|x�q`4w=\�������u�Q	��m+G��|%$��5��Թ���5�RO*��YGM��UO��G�qj4ְ(X�&
+s1�c�˭(LV�f�
R���d�j��Q	'-1��A�TA>U	�j4,�p�V�"4L$e�@.ArB���Y a~m�y����Y])Q8tN�L����ܞt2��"��I	���
�o=C�S��d�)�_��_�AF��(������IEND�B`�
\ No newline at end of file
diff --git a/web/modules/ultimate_cron/icons/application_form_edit.png b/web/modules/ultimate_cron/icons/application_form_edit.png
new file mode 100644
index 0000000000000000000000000000000000000000..af486c940c60b746d56e757686d51801bf18ae72
--- /dev/null
+++ b/web/modules/ultimate_cron/icons/application_form_edit.png
@@ -0,0 +1,5 @@
+�PNG
+
+���
IHDR�����������a���gAMA����7�����tEXtSoftware�Adobe ImageReadyq�e<��\IDAT8˥��KSQ�����U/"��/���HA���L͕bk���b���miI�bk������֦��Ӷ��v�c�nw�w���v�݊4
+��s��=�9��(�eSM�5�7=@��`�Ճ\�C�Y4�yQo��%�(n��V����E�d��sڑ5lE�XN2o��;F!��~���m#o��
T���祹����	���j0�w��E�X�d2���ORd-
+~mcW"�}�ˆ{-p�8�F��Np��K"��@����7cc�)���g�-�����%>���ǥ(gId����^C.�D:X�u�E��p���he{���i�b1D��X4&e�u��Q!�qAT"�R����Ө�ޛ/��g�HdS
����v0�ʂ��Og��6a�p�ω�FKޠN��0��Bd�{g���F��d����r	�I@_9Z:s���7�վG8L 	��
�1���ݥ�˱>u�����8	��ѐ՛�Ln:��U�f���(�a�P�z'��)�p�!Ԩ�$�'�f�F�$l����E�
��[pk�\�+5����ݸu����Lk�ެ��8�����3��k��=�!��;L[����IEND�B`�
\ No newline at end of file
diff --git a/web/modules/ultimate_cron/icons/application_go.png b/web/modules/ultimate_cron/icons/application_go.png
new file mode 100644
index 0000000000000000000000000000000000000000..5cc2b0dd36978513f3ca009c68ebc4150976fc80
--- /dev/null
+++ b/web/modules/ultimate_cron/icons/application_go.png
@@ -0,0 +1,7 @@
+�PNG
+
+���
IHDR�����������a���gAMA����7�����tEXtSoftware�Adobe ImageReadyq�e<��IDAT��͋Nq����;�4È�fb�x��)2��XX�HY���b�(Ij��ac��$aa#/%�R3ϋs~��5gbor]&��a���ݲ�u�{G��"�p"ȝ��;������S7��+[��k�����9�D��׀��S+u�O2S�Z�)�\�?���:|�=�R
+Uv(�"Bx��g�
+*ˇ���4J$��P2d��H�0K�9V�"e��N#y8����"�0,A�`VQp��nN�܌��Fx�H
+1͌F�)Ea)��\�xp��7R�N��p�23�I���
)SE�ʙEV�jx#�6{�FX��4�\�L3��I�9*v�9�+�p���G�-e���{����/�A���3���
+W�����䨩���	6,��d����ʈ�/�)f�r�왡���p\��X8w�����:N��Z�h��;���#���**z�b��j6����G���e���\>��$�/֞K����}�^��T;>_�+��$���Yk{}���?~��4{<,Lp����IEND�B`�
\ No newline at end of file
diff --git a/web/modules/ultimate_cron/icons/chart_line.png b/web/modules/ultimate_cron/icons/chart_line.png
new file mode 100644
index 0000000000000000000000000000000000000000..85020f3205adc903896aae3ac8b2431d81d25a92
--- /dev/null
+++ b/web/modules/ultimate_cron/icons/chart_line.png
@@ -0,0 +1,4 @@
+�PNG
+
+���
IHDR�����������a���gAMA����7�����tEXtSoftware�Adobe ImageReadyq�e<���IDAT8�c���?%�,M_�|��z<�l�\@�_����9��'K���kvrc�l�t������Ɩ�����c�Gs�]�,�	��k����s��N�5[;~4�y���\���3��NO��+9i=��5g���x�������מ}a���7���<{[�iƦ�i�
+~�����u�����DD�!4��w�<�����������o�sM�f��u�I���=�7HZ���gb���11��G\�r��qM��ʏ=�+;b2X3?�n�n���� i�#������/[���g|�>}�u~�k����^���{
~�		�_	ǪS��ˏ-�-9�	,``��7m��w��p�D׺@�>��
п�Ȓ﵍ֿS���VI{=N���<�����_��*�ؕ~`S���n��������A�������IEND�B`�
\ No newline at end of file
diff --git a/web/modules/ultimate_cron/icons/cog.png b/web/modules/ultimate_cron/icons/cog.png
new file mode 100644
index 0000000000000000000000000000000000000000..67de2c6ccbeac17742f56cf7391e72b2bf5033ba
--- /dev/null
+++ b/web/modules/ultimate_cron/icons/cog.png
@@ -0,0 +1,7 @@
+�PNG
+
+���
IHDR�����������7����gAMA����7�����tEXtSoftware�Adobe ImageReadyq�e<���IDAT(�UQMKa^���	���t��T�2�
+�.E���QС:�EeQ�PR����麖QR�q��%!O^w��m!c3��3�3���To^�%�ۚ�\�Y���|���g��������z�)�,{�b,�پ���7�g|�	�fʨ�'��T�ǂ���L�GUMW�0F�DN�C	\`��BA���S�J�k��4$0���S��rI 
���ZH�d���(c��O�:/B&6��9I#M�:�X6��1đ�|���hK���5AJ�T���Ȑ�9��Z�f)
+9+�<�OQ
+�
����**4K��rH�k�����?�WDj
+�L�>c�!�i��������9BL%�^���0��n9ֈs�>i֓����i붻����k>�Gj�����
	�]������IEND�B`�
\ No newline at end of file
diff --git a/web/modules/ultimate_cron/icons/delete.png b/web/modules/ultimate_cron/icons/delete.png
new file mode 100644
index 0000000000000000000000000000000000000000..08f249365afd29594b51210c6e21ba253897505d
--- /dev/null
+++ b/web/modules/ultimate_cron/icons/delete.png
@@ -0,0 +1,5 @@
+�PNG
+
+���
IHDR�����������a���gAMA����7�����tEXtSoftware�Adobe ImageReadyq�e<��]IDAT8˥��KSa��[���n��QP��2w��ܦγL�[,bi��a�A��\�C��v��_2Ml�ZFjס���NMjm��kʷ�`&.#z�������<ϓ ���bV��P��T3�%�I��{G��qRiv�ȅ�
+�tz�#E��6����Edd���J�`���DR�2<]N��;�4�Ѿ;���m>�7��8��ɀQe6�L�I���t��殷c�q!z�|v��j�/Xi���@��
�%1|h���l� !���|������!
�Y#�u�U�N�w]�˼H3��u�	t]E��>k%�I�f��o���R��D:�0��`�~�|�
���(r�
+�on�3oG0!�$����V��
�*[W0_������-+���� d��W�&�2�ZfMF��VJp�iF&B��
>��R���g�-� �~	C�m��ڴ���ER�
ឫ� p�5ްy����+��21���K�aw�h�`� ��#���a�Z񽞆�T�Zo���L��ѓ���`"�(?��'��ˎJv�K�ކ��|�:�G9[�a�w8�2
Jw��f'��y����m�zsӘ��Tsw��_��_��ιIr�����IEND�B`�
\ No newline at end of file
diff --git a/web/modules/ultimate_cron/icons/error.png b/web/modules/ultimate_cron/icons/error.png
new file mode 100644
index 0000000000000000000000000000000000000000..628cf2dae3d419ae220c8928ac71393b480745a3
--- /dev/null
+++ b/web/modules/ultimate_cron/icons/error.png
@@ -0,0 +1,4 @@
+�PNG
+
+���
IHDR�����������a���gAMA����7�����tEXtSoftware�Adobe ImageReadyq�e<��,IDAT8˥SKH�������k�TC[R�L��5
o=��c�k�)�c�.$B��D�P��,,��NY(FFf�����������k��af>�o�1Hb=f�U�~�Ϣ�=US�l.��Z���kP�(9X(>�H�3kR �����x_�Oqg覆8�t����]�t�i����X�a��s������_��j'{��Љ_��袺I~}����^�OT��j5՟�羇}v�M��悥�]b�7�(�V�l9��o� XoCn��%M���+ci�ѐ��+�C�i@��I�z���W���^��5�
+@E��e�6dV�K>@dW������2�U���/zW���ѳ'B�O��lYxo�T3#��Yd�(,���a��PG�+�_��Rr�\:�m;gS� �o�*����0�>N� @���aΣ��5�;�5�0�?�k��kz65�ß}	/qoI��0- R`Z�Uކ�1̌�U�� rj�1B
�C�eqƊ������@�޳H

\�):�xu�4E���3�'ǃ�xǃ8>��!�@}����X�;��Lc٧�S/����IEND�B`�
\ No newline at end of file
diff --git a/web/modules/ultimate_cron/icons/hourglass.png b/web/modules/ultimate_cron/icons/hourglass.png
new file mode 100644
index 0000000000000000000000000000000000000000..57b03ce7a61aa3b47d20235987a1c1918c5cb535
--- /dev/null
+++ b/web/modules/ultimate_cron/icons/hourglass.png
@@ -0,0 +1,6 @@
+�PNG
+
+���
IHDR�����������a���gAMA����7�����tEXtSoftware�Adobe ImageReadyq�e<��zIDAT8ˍ��OSa�;28�����ĕ8�����J�1&�F�xO"
P(-
+ZiK
+�G�^h�^i)(�^��j��������b�|��>�|��	��GEM$m$��!a]�w`_�«ˬ��3q�<������d[�M��``h;Y� �x�����1Ė���"���t'<�v�70��"�Xx�\���MN2��y?�Jн��ƺ�9�������q�)W`Z
+m�ElieXS>Ʋ��W@�
�l��>i�i���U��00���,l�u�p	�l�2�b�C:W�Z��k0�:YŒ=��9�n�W�By�(o��a��"��`=Z��M?v1D���Y���N��c�yڑ���Y�~���3�A*��7�k><�`?ʥ��d`�g!��G�	�X���a6���
P���+��j(�4SEt��Yk]�[/T����SOZ�/Q�7Q�r�-����:�x��ŵ�:����Qjޝ�v��\�E���,���<����)�h��ˍkTͻ�����ح�e���|�cE�l���Aǘ�n���%��l�y#�p2��Ya4�?�}p�l�!z�yz�х�2ׅÀ��Da�(��)̻@f��)�$������B������IEND�B`�
\ No newline at end of file
diff --git a/web/modules/ultimate_cron/icons/hourglass_go.png b/web/modules/ultimate_cron/icons/hourglass_go.png
new file mode 100644
index 0000000000000000000000000000000000000000..b2d3a98bc4688cdfa1dc3ae3723a5a65e0e0df9b
--- /dev/null
+++ b/web/modules/ultimate_cron/icons/hourglass_go.png
@@ -0,0 +1,7 @@
+�PNG
+
+���
IHDR�����������a���gAMA����7�����tEXtSoftware�Adobe ImageReadyq�e<���IDAT8˅�[H�a����PARRI�B�Yօ$u�P�P2I-A���H*O�Ӝ�t�5��N�a��Ι:χȦN�4�<l��ӷ?94�.����}~���X�LHH�$2%��+��n�}~]���,:-����3�����������r�HU&��Pt�a�����L��b\���������	E#�%!�@��@?'�k0-�%y�II6��31"H������ht�>��;
`��w��n��T\�oU9�+�D��q. g��
��Qdy[ ��$�bo"'=��*k@��j�15f�ְ��auM�9�:�&V�&_���5��g�^9,�(����K��*�YV�j11����U����"2���W�%��[���:��:�e�eK���˕�s���*���,ol��I��.W�mx	L�\���sΩ����gc�_@qӴ�sL	)��5�g�,;U4�6\��Åy����B��U!�]B �o�Ï}^y��(��w��~�+)��I{Ͷ�8
S��Ӻ!�
+��d��3�Q�N�[`|
+[�?�3��kmb�v��Ѥ��g����Zh�u '����@$���Q|/�T�"K����zB�SE5=�܆	����bq�.ku���x�����%�M����Cd�b�ý
+�HJq0���]�f�Q"���,�f�����F�\K=������,d�G���Œ�桔�6����ӝ�d;�T�1B<vI7�hЙ(,#
+++L�@���V��"|��Fo��;��E,�\�����IEND�B`�
\ No newline at end of file
diff --git a/web/modules/ultimate_cron/icons/information.png b/web/modules/ultimate_cron/icons/information.png
new file mode 100644
index 0000000000000000000000000000000000000000..12cd1aef900803abba99b26920337ec01ad5c267
--- /dev/null
+++ b/web/modules/ultimate_cron/icons/information.png
@@ -0,0 +1,6 @@
+�PNG
+
+���
IHDR�����������a���gAMA����7�����tEXtSoftware�Adobe ImageReadyq�e<���IDAT8˥��k�W�?wL�dL&1
+��6�Ѩ(h��R,�M!R;TA�.\t�m\�
+>V���t���Z���΢��0��4��3�$������BH|�=�/_>8�U�}T��!su4-�WNV��8
(��w����O��o^u�ŕ���r�#�ɞ�F֮`!r�pzy����e�����Hn�V��Z�Ԝ��გ�[C*��³2??\��S���+��K�;E�С�����zrc%���5�*��cb�]���3_槻�i4|��v�Q@���h�ԎdÅ���"�@��Iz�S�lՒ�,Ѿ1A�ֆ�F��ޟ����Xq	A��Ǐ�d�'�b�βE��.r`o��+)�ȶ6��P�)�G��!�w��G���Cqn���f�G���
�S��J����y��8�ux�8q8+��g�~jn�Bs��14(�{^&�xq�X�x�XƘ0�
+���`~��Mq�rd;;?l�n]-�G� "8:Z
�
��&V��#�_������M�����_G���_�8T����-��y�/L��Z��O��r���_�wn����Yf��	.����m[�/-q�_�1��r�dߪr����^L�J&�KӼ~-�<]�0��(�Œ1������n��+iU
'	�4`�)7
r珁s�?�w��?�{Y�!�����IEND�B`�
\ No newline at end of file
diff --git a/web/modules/ultimate_cron/icons/lock_open.png b/web/modules/ultimate_cron/icons/lock_open.png
new file mode 100644
index 0000000000000000000000000000000000000000..a471765ff1432092e7113e56b42f2798968b443f
--- /dev/null
+++ b/web/modules/ultimate_cron/icons/lock_open.png
@@ -0,0 +1,10 @@
+�PNG
+
+���
IHDR�����������a���gAMA����7�����tEXtSoftware�Adobe ImageReadyq�e<��iIDAT8�u��kQ��;i���
+)R5��p�B�7�+)
+��a�����܈�@@Ņ�ʕ�4�j����h�>�3�q\��!��{8�����{����j�z�Z{+��c�c�c̽ ^�Pi@�R�������!�y�+++���I�պ199��Зz|(��O�J%�h4h6����O�TRaN����z�q��)���b������l6/A�� P����fgg)
+����;�
+�T�3ư���A�Osjj��:���oh�Xk�{ibbb�
B��WW���Q�SXk9s�
ш5�IRk��u�O��
+9���w*Q ���c(<Tf��ip
+�6V�z�,p`���=��ςX������ʃ뇁���#'F��y�Y�8J�'`cڵ�x�
+�����]��dx���5�p	�vD,��4@�b5���;@9ldy�`��¾�������<��V��a�v�5x���$������Įs��)Z'TSؿ���"x1�㔵����"��O8����Ĵk52�}ؔ��,8�6��O��2��V��ND,�]W؝Aes�z��,�!~����l2/cR�8���v2c���{bm�X�&�K���qW.rOX����IEND�B`�
\ No newline at end of file
diff --git a/web/modules/ultimate_cron/icons/message-16-error.png b/web/modules/ultimate_cron/icons/message-16-error.png
new file mode 100755
index 0000000000000000000000000000000000000000..486390c9b0ae6870eb81f39ca8206ad93dadd84d
--- /dev/null
+++ b/web/modules/ultimate_cron/icons/message-16-error.png
@@ -0,0 +1,7 @@
+�PNG
+
+���
IHDR�����������a���IDATxڥ�?HBQƣ��r�� ��#�p(2h���Ak
+�!G�����P�������2ISiъ
+�:�W_O"���s��w����^���g���H�^��3���כT(P��������pc�"31!��h�>_�af���
Epd����!����H�������߯�!��LNj M��p7<��ǃ�p�l��2��L�����Ilo#6;��a�!���vp�˅r4
+u
+�Rp�NE��>�yHn���l�G.���RWR�B?�%a�<�a2#=4�}��D�rO�u_�	TW�XT���P��jm�������A(`%�x�ZZj�KjFU�tNjX���''�;GWX��C�W83�p~z�o�$�B{;���r�t��Aj�X)�	|��!uws��ֆ��h�x-=2��ة������Q-�riI�M��Q��(�r?6�{L�~�_�ͧ��G�����IEND�B`�
\ No newline at end of file
diff --git a/web/modules/ultimate_cron/icons/message-16-help.png b/web/modules/ultimate_cron/icons/message-16-help.png
new file mode 100755
index 0000000000000000000000000000000000000000..fc44326e46b705778b66c9aa27994fb703d7c775
--- /dev/null
+++ b/web/modules/ultimate_cron/icons/message-16-help.png
@@ -0,0 +1,5 @@
+�PNG
+
+���
IHDR�����������a��cIDATxڥ��KSQNj(	��"�|�;��ܩ�Y��'��f��zkOnn�Z�a�SI�P��&ڋ�fsk�����E�0�	�I귻�-�����8���|8p~g�m�6<(��%��BZ�c�]��;?�2��_r�]M]�%�c1zP`􁩛Ua�iE��
+r/��օ���3:P��P��D�yL��`�)${�
+�],��B٭0�w���c}}��kx��P��P��u.�:�[R�}]1�\l
�L�L��'�&����"~���(��An
��YY��)AAu>����ʉȯs�Y78����_��uU�C�	vEUw���k�Z�և�&n��C(�:0�����=q��E$:�ء��a�6����s����㐭���|㰢=&d!P��!J�\3`K	T���;�}9�B�@������%(��o�'PY�d)Aoo 3�f������C�0��G�is�4D6�a�G�@�[�Qi��2ZST�č<�(�O-�1� Y�Q�B��lM7H{���D�AncҖ7XH���]Bn�k�{�����Q�Wum���~��+� i�c�h�`.9���a[��_�i��w�tOW��ʫ���m5��$k��¶�	qa��Tbn����IEND�B`�
\ No newline at end of file
diff --git a/web/modules/ultimate_cron/icons/message-16-info.png b/web/modules/ultimate_cron/icons/message-16-info.png
new file mode 100755
index 0000000000000000000000000000000000000000..f47924fc8ab0c800363774a555ce2da7186cf787
--- /dev/null
+++ b/web/modules/ultimate_cron/icons/message-16-info.png
@@ -0,0 +1,5 @@
+�PNG
+
+���
IHDR�����������a���IDATx�m��KSa���PBP�� z�v.;�RѴ�AT:�Ew�&N��t�Hc-�\��y�,I����奝3�S��*_	60�o������<����s�yd���JI�q��Ih�^���P+t��˦}Pw<�im蛼�5Ȋ	�1���*F!���.y1�*�=V����"��{�=�/�	��Ϩh��!�,�	=J߽���i�J��5,B�
+x#����F�M���i]Ȑ�Ȟ���$`��L�,�z9���M������}h�i=!�������_��'�c�FF�$�>�|Q8���;ǯs4q(�s�Ad�v^���=�u��X	]`3����XCI�{�C"d��[BY�9<Lٿ#����DT.��$(
&q��C�;q@C�}ٖ��|k��6��ig���C^GQ�$ky5߳�|�v$@�pȱt��nWE����x��$(�o�K��f������?�JZށp,�����	Z�@:WY!�oaj�B���ʵ��Q�%P�k"�s�Ф$p��BY�
+ڵ~�/�4w���9h����4��f]�m��Ē)�������P�m�*���v�0iG��p)��)�0�cz�q e����8z�N��L�f����i�}AR���;X��ȴD�ڢ�7���_�tNw�����޼���}�B��h�9��Q�N(`Y����IEND�B`�
\ No newline at end of file
diff --git a/web/modules/ultimate_cron/icons/message-16-ok.png b/web/modules/ultimate_cron/icons/message-16-ok.png
new file mode 100755
index 0000000000000000000000000000000000000000..9edebe6bd22fa0cbc17a3d68507b47142458aa24
--- /dev/null
+++ b/web/modules/ultimate_cron/icons/message-16-ok.png
@@ -0,0 +1,7 @@
+�PNG
+
+���
IHDR�����������a��FIDATxڥ��KSaǍ(	���K�9s�c?���D<8v1s[��8�""j�����<�ۜ�rg�
�6kV&���M]+[Taԅ��o����}���>��|x�}�:��f��r�1݄Τ��N�y�8��
M����t�;��]�it���_�C{G:H˲��
+tS�
�����n�¾i1?1C��@��6j
+�<]�ԼC�C`��`�q������P�*��z�>�u�z���w
�k�?���tڏ�H����1B�iv����TQ����E]p�_�ʀI38{׎��mA�|�bʨ}������"
+^oj�R	��۸>w	����1�x��:W:Ѿ�E\�^�7$
+L��� Oȱ�rI(x��-n�8�������@f���r^yL#g�E�ph�tK���R�p.���‹�1RDvK[�vJ���MeQH�V�?��T���yP��)�:�Z�~�6�{Fv��ȦeB1G8�N�2��>\E꓂
��Z�t��1�f3�Y�&��D��'��O�||$4�ƨ"�&!��@2)�%@]���[�}�C;�wN��]-���``0�����Q9���O[x�>P�d����IEND�B`�
\ No newline at end of file
diff --git a/web/modules/ultimate_cron/icons/message-16-warning.png b/web/modules/ultimate_cron/icons/message-16-warning.png
new file mode 100755
index 0000000000000000000000000000000000000000..ffc43177abdc983dd2d9d46c25cb0ad697e2b3f0
--- /dev/null
+++ b/web/modules/ultimate_cron/icons/message-16-warning.png
@@ -0,0 +1,6 @@
+�PNG
+
+���
IHDR�����������a���IDAT8Oc���?%���_��r��@�a��P�
�op��|���W���58�%ɀ�K������<���7M���Pl,B�]UvG>��~��+8�>������Q4�k˭�����1��^.Z��]�������k(�P h@s����G��N�
+�c����x
h�4�Xѯ�{Q����T��0������
+��qP�k|��an���r����p��#�+2�oa5�2[�|i�
+�v^ة�Y��d�=���sU,0(N�{��0��K���_]ˇ"�����d͇(Td��-hS@Q©��`�.��C�i��܀�X���r`(\?I����`����n��������1�¯����tT�7���aIQ��=l�0;n)�[���`�£4g������IEND�B`�
\ No newline at end of file
diff --git a/web/modules/ultimate_cron/icons/tick.png b/web/modules/ultimate_cron/icons/tick.png
new file mode 100644
index 0000000000000000000000000000000000000000..a9925a06ab02db30c1e7ead9c701c15bc63145cb
--- /dev/null
+++ b/web/modules/ultimate_cron/icons/tick.png
@@ -0,0 +1,5 @@
+�PNG
+
+���
IHDR�����������a���gAMA����7�����tEXtSoftware�Adobe ImageReadyq�e<���IDAT8˽��.Ca�{�8�bn�SB��T'��)E)����V�CJǥj��� �ZՆg/����h…�ݿ����k����n�^k�[�ꝿ2P�6�c=�XH�*�G�`?xԅ�{7�7V�Ԩ�پ%V�Hy��q��Ntn���[���J2^�5�3��X�,�S-OƜ�o����D�X��x����2Oܵ	�r�]L`�}�Z��࿳��T��U�(��Si������P��/�a:6͖,A`
�%S�=���[
+���b[�a�='�L�a�W�{����x���D����[�u9J�—B�GqzfGN��0��os��6�"f��fh�ZR��".��2H-[��{���(7�h�@`%E��[I��W�u3��e�+� ����l�GQ&���'� ������������k|���<R
+H����IEND�B`�
\ No newline at end of file
diff --git a/web/modules/ultimate_cron/js/jquery.tableSort.js b/web/modules/ultimate_cron/js/jquery.tableSort.js
new file mode 100644
index 0000000000000000000000000000000000000000..601d46cd17f0fc8cd10cc520aa9773873e660eaa
--- /dev/null
+++ b/web/modules/ultimate_cron/js/jquery.tableSort.js
@@ -0,0 +1,211 @@
+/* ===============================
+| TABLESORT.JS
+| Copyright, Andy Croxall (mitya@mitya.co.uk)
+| For documentation and demo see http://mitya.co.uk/scripts/Animated-table-sort-REGEXP-friendly-111
+|
+| USAGE
+| This script may be used, distributed and modified freely but this header must remain in tact.
+| For usage info and demo, including info on args and params, see www.mitya.co.uk/scripts
+=============================== */
+
+
+jQuery.fn.sortTable = function(params) {
+
+
+	/*-----------
+	| STOP right now if anim already in progress
+	-----------*/
+
+	if ($(this).find(':animated').length > 0) return;
+	
+	/*-----------
+	| VALIDATE TABLE & PARAMS
+	|	- if no col to sort on passed, complain and return
+	|	- if table doesn't contain requested col, complain and return
+	| If !sortType or invalid sortType, assume ascii sort
+	-----------*/
+	
+	var error = null;
+	var complain = null;
+	if (!params.onCol) { error = "No column specified to search on"; complain = true; }
+	else if ($(this).find('td:nth-child('+params.onCol+')').length == 0) { error = "The requested column wasn't found in the table"; complain = true; }
+	if (error) { if (complain) alert(error); return; }
+	if (!params.sortType || params.sortType != 'numeric') params.sortType = 'ascii';
+
+
+	/*-----------
+	| PREP
+	| 	- declare array to store the contents of each <td>, or, if sorting on regexp, the pattern match of the regexp in each <td>
+	| 	- Give the <table> position: relative to aid animation
+	| 	- Mark the col we're sorting on with an identifier class
+	-----------*/
+	
+	var valuesToSort = [];
+	$(this).css('position', 'relative');
+	var doneAnimating = 0;
+	var tdSelectorText = 'td'+(!params.onCol ? '' : ':nth-child('+params.onCol+')');
+	$(this).find('td:nth-child('+params.onCol+')').addClass('sortOnThisCol');
+	var thiss = this;
+
+
+	/*-----------
+	| Iterate over table and. For each:
+	| 	- append its content / regexp match (see above) to valuesToSort[]
+	| 	- create a new <div>, give it position: absolute and copy over the <td>'s content into it
+	| 	- fix the <td>'s width/height to its offset width/height so that, when we remove its html, it won't change shape
+	|	- clear the <td>'s content
+	| 	- clear the <td>'s content
+	| There is no visual effect in this. But it means each <td>'s content is now 'animatable', since it's position: absolute.
+	-----------*/	
+	
+	var counter = 0;
+	$(this).find('td').each(function() {
+		if ($(this).is('.sortOnThisCol') || (!params.onCol && !params.keepRelationships)) {
+			var valForSort = !params.child ? $(this).text() : (params.child != 'input' ? $(this).find(params.child).text() : $(this).find(params.child).val());
+			if (params.regexp) {
+				valForSort = valForSort.match(new RegExp(params.regexp))[!params.regexpIndex ? 0 : params.regexpIndex];
+			}
+			valuesToSort.push(valForSort);
+		}
+		var thisTDHTMLHolder = document.createElement('div');
+		with($(thisTDHTMLHolder)) {
+			html($(this).html());
+			if (params.child && params.child == 'input') html(html().replace(/<input /, "<input value='"+$(this).find(params.child).val()+"'", html()));
+			css({position: 'relative', left: 0, top: 0});
+		}
+		$(this).html('');
+		$(this).append(thisTDHTMLHolder);
+		counter++;
+	});
+	
+	
+	/*-----------
+	| Sort values array.
+	|	- Sort (either simply, on ascii, or numeric if sortNumeric == true)
+	|	- If descending == true, reverse after sort
+	-----------*/
+
+	params.sortType == 'numeric' ? valuesToSort.sort(function(a, b) { return (a.replace(/[^\d\.]/g, '', a)-b.replace(/[^\d\.]/g, '', b)); }) : valuesToSort.sort();
+	if (params.sortDesc) {
+		valuesToSort_tempCopy = [];
+		for(var u=valuesToSort.length; u--; u>=0) valuesToSort_tempCopy.push(valuesToSort[u]);
+		valuesToSort = valuesToSort_tempCopy;
+		delete(valuesToSort_tempCopy)
+	}
+	
+
+	
+	/*-----------
+	| Now, for each:
+	-----------*/
+	
+	for(var k in valuesToSort) {
+		
+		//establish current <td> relating to this value of the array
+		var currTD = $($(this).find(tdSelectorText).filter(function() {
+			return (
+				(
+					!params.regexp
+					&&
+					(
+						(
+							params.child
+							&&
+							(
+								(
+									params.child != 'input'
+									&&
+									valuesToSort[k] == $(this).find(params.child).text()
+								)
+								||
+								params.child == 'input'
+								&&
+								valuesToSort[k] == $(this).find(params.child).val()
+							)
+						)
+						||
+						(
+							!params.child
+							&&
+							valuesToSort[k] == $(this).children('div').html()
+						)
+					)
+				)
+				||
+				(
+					params.regexp
+					&&
+					$(this).children('div').html().match(new RegExp(params.regexp))[!params.regexpIndex ? 0 : params.regexpIndex] == valuesToSort[k]
+				)
+			)
+			&&
+			!$(this).hasClass('tableSort_TDRepopulated');
+		}).get(0));
+		
+		//give current <td> a class to mark it as having been used, so we don't get confused with duplicate values
+		currTD.addClass('tableSort_TDRepopulated');
+		
+		//establish target <td> for this value and store as a node reference on this <td>
+		var targetTD = $($(this).find(tdSelectorText).get(k));
+		currTD.get(0).toTD = targetTD;
+		
+		//if we're sorting on a particular column and maintaining relationships, also give the other <td>s in rows a node reference
+		//denoting ITS target, so they move with their lead siibling
+		if (params.keepRelationships) {
+			var counter = 0;
+			$(currTD).parent().children('td').each(function() {
+				$(this).get(0).toTD = $(targetTD.parent().children().get(counter));
+				counter++;
+			});
+		}
+		
+		//establish current relative positions for the current and target <td>s and use this to calculate how far each <div> needs to move
+		var currPos = currTD.position();
+		var targetPos = targetTD.position();
+		var moveBy_top = targetPos.top - currPos.top;
+		
+		//invert values if going backwards/upwards
+		if (targetPos.top > currPos.top) moveBy_top = Math.abs(moveBy_top);
+		
+		/*-----------
+		| ANIMATE
+		| 	- work out what to animate on.
+		| 		- if !keepRelationships, animate only <td>s in the col we're sorting on (identified by .sortOnThisCol)
+		| 		- if keepRelationships, animate all cols but <td>s that aren't .sortOnThisCol follow lead sibiling with .sortOnThisCol
+		| 	- run animation. On callback, update each <td> with content of <div> that just moved into it and remove <div>s
+		|	- If noAnim, we'll still run aniamte() but give it a low duration so it appears instant
+		-----------*/		
+		
+		var animateOn = params.keepRelationships ? currTD.add(currTD.siblings()) : currTD;
+		var done = 0;
+		animateOn.children('div').animate({top: moveBy_top}, !params.noAnim ? 500 : 0, null, function() {
+			if ($(this).parent().is('.sortOnThisCol') || !params.keepRelationships) {
+				done++;
+				if (done == valuesToSort.length-1) thiss.tableSort_cleanUp();
+			}
+		});
+		
+	}
+		
+};
+
+
+jQuery.fn.tableSort_cleanUp = function() {
+
+	/*-----------
+	| AFTER ANIM
+	| 	- assign each <td> its new content as property of it (DON'T populate it yet - this <td> may still need to be read by
+	|	  other <td>s' toTD node references
+	|	- once new contents for each <td> gathered, populate
+	|	- remove some identifier classes and properties
+	-----------*/
+	$(this).find('td').each(function() {
+		if($(this).get(0).toTD) $($(this).get(0).toTD).get(0).newHTML = $(this).children('div').html();
+	});
+	$(this).find('td').each(function() { $(this).html($(this).get(0).newHTML); });
+	$('td.tableSort_TDRepopulated').removeClass('tableSort_TDRepopulated');
+	$(this).find('.sortOnThisCol').removeClass('sortOnThisCol');
+	$(this).find('td[newHTML]').attr('newHTML', '');
+	$(this).find('td[toTD]').attr('toTD', '');
+	
+};
\ No newline at end of file
diff --git a/web/modules/ultimate_cron/js/jquery.tablesorter.js b/web/modules/ultimate_cron/js/jquery.tablesorter.js
new file mode 100644
index 0000000000000000000000000000000000000000..a14d071a8a728706c8af1d456c50d7a9af2cf05d
--- /dev/null
+++ b/web/modules/ultimate_cron/js/jquery.tablesorter.js
@@ -0,0 +1,1031 @@
+/*
+ * 
+ * TableSorter 2.0 - Client-side table sorting with ease!
+ * Version 2.0.5b
+ * @requires jQuery v1.2.3
+ * 
+ * Copyright (c) 2007 Christian Bach
+ * Examples and docs at: http://tablesorter.com
+ * Dual licensed under the MIT and GPL licenses:
+ * http://www.opensource.org/licenses/mit-license.php
+ * http://www.gnu.org/licenses/gpl.html
+ * 
+ */
+/**
+ * 
+ * @description Create a sortable table with multi-column sorting capabilitys
+ * 
+ * @example $('table').tablesorter();
+ * @desc Create a simple tablesorter interface.
+ * 
+ * @example $('table').tablesorter({ sortList:[[0,0],[1,0]] });
+ * @desc Create a tablesorter interface and sort on the first and secound column column headers.
+ * 
+ * @example $('table').tablesorter({ headers: { 0: { sorter: false}, 1: {sorter: false} } });
+ *          
+ * @desc Create a tablesorter interface and disableing the first and second  column headers.
+ *      
+ * 
+ * @example $('table').tablesorter({ headers: { 0: {sorter:"integer"}, 1: {sorter:"currency"} } });
+ * 
+ * @desc Create a tablesorter interface and set a column parser for the first
+ *       and second column.
+ * 
+ * 
+ * @param Object
+ *            settings An object literal containing key/value pairs to provide
+ *            optional settings.
+ * 
+ * 
+ * @option String cssHeader (optional) A string of the class name to be appended
+ *         to sortable tr elements in the thead of the table. Default value:
+ *         "header"
+ * 
+ * @option String cssAsc (optional) A string of the class name to be appended to
+ *         sortable tr elements in the thead on a ascending sort. Default value:
+ *         "headerSortUp"
+ * 
+ * @option String cssDesc (optional) A string of the class name to be appended
+ *         to sortable tr elements in the thead on a descending sort. Default
+ *         value: "headerSortDown"
+ * 
+ * @option String sortInitialOrder (optional) A string of the inital sorting
+ *         order can be asc or desc. Default value: "asc"
+ * 
+ * @option String sortMultisortKey (optional) A string of the multi-column sort
+ *         key. Default value: "shiftKey"
+ * 
+ * @option String textExtraction (optional) A string of the text-extraction
+ *         method to use. For complex html structures inside td cell set this
+ *         option to "complex", on large tables the complex option can be slow.
+ *         Default value: "simple"
+ * 
+ * @option Object headers (optional) An array containing the forces sorting
+ *         rules. This option let's you specify a default sorting rule. Default
+ *         value: null
+ * 
+ * @option Array sortList (optional) An array containing the forces sorting
+ *         rules. This option let's you specify a default sorting rule. Default
+ *         value: null
+ * 
+ * @option Array sortForce (optional) An array containing forced sorting rules.
+ *         This option let's you specify a default sorting rule, which is
+ *         prepended to user-selected rules. Default value: null
+ * 
+ * @option Boolean sortLocaleCompare (optional) Boolean flag indicating whatever
+ *         to use String.localeCampare method or not. Default set to true.
+ * 
+ * 
+ * @option Array sortAppend (optional) An array containing forced sorting rules.
+ *         This option let's you specify a default sorting rule, which is
+ *         appended to user-selected rules. Default value: null
+ * 
+ * @option Boolean widthFixed (optional) Boolean flag indicating if tablesorter
+ *         should apply fixed widths to the table columns. This is usefull when
+ *         using the pager companion plugin. This options requires the dimension
+ *         jquery plugin. Default value: false
+ * 
+ * @option Boolean cancelSelection (optional) Boolean flag indicating if
+ *         tablesorter should cancel selection of the table headers text.
+ *         Default value: true
+ * 
+ * @option Boolean debug (optional) Boolean flag indicating if tablesorter
+ *         should display debuging information usefull for development.
+ * 
+ * @type jQuery
+ * 
+ * @name tablesorter
+ * 
+ * @cat Plugins/Tablesorter
+ * 
+ * @author Christian Bach/christian.bach@polyester.se
+ */
+
+(function ($) {
+    $.extend({
+        tablesorter: new
+        function () {
+
+            var parsers = [],
+                widgets = [];
+
+            this.defaults = {
+                cssHeader: "header",
+                cssAsc: "headerSortUp",
+                cssDesc: "headerSortDown",
+                cssChildRow: "expand-child",
+                sortInitialOrder: "asc",
+                sortMultiSortKey: "shiftKey",
+                sortForce: null,
+                sortAppend: null,
+                sortLocaleCompare: true,
+                textExtraction: "simple",
+                parsers: {}, widgets: [],
+                widgetZebra: {
+                    css: ["even", "odd"]
+                }, headers: {}, widthFixed: false,
+                cancelSelection: true,
+                sortList: [],
+                headerList: [],
+                dateFormat: "us",
+                decimal: '/\.|\,/g',
+                onRenderHeader: null,
+                selectorHeaders: 'thead th',
+                debug: false
+            };
+
+            /* debuging utils */
+
+            function benchmark(s, d) {
+                log(s + "," + (new Date().getTime() - d.getTime()) + "ms");
+            }
+
+            this.benchmark = benchmark;
+
+            function log(s) {
+                if (typeof console != "undefined" && typeof console.debug != "undefined") {
+                    console.log(s);
+                } else {
+                    alert(s);
+                }
+            }
+
+            /* parsers utils */
+
+            function buildParserCache(table, $headers) {
+
+                if (table.config.debug) {
+                    var parsersDebug = "";
+                }
+
+                if (table.tBodies.length == 0) return; // In the case of empty tables
+                var rows = table.tBodies[0].rows;
+
+                if (rows[0]) {
+
+                    var list = [],
+                        cells = rows[0].cells,
+                        l = cells.length;
+
+                    for (var i = 0; i < l; i++) {
+
+                        var p = false;
+
+                        if ($.metadata && ($($headers[i]).metadata() && $($headers[i]).metadata().sorter)) {
+
+                            p = getParserById($($headers[i]).metadata().sorter);
+
+                        } else if ((table.config.headers[i] && table.config.headers[i].sorter)) {
+
+                            p = getParserById(table.config.headers[i].sorter);
+                        }
+                        if (!p) {
+
+                            p = detectParserForColumn(table, rows, -1, i);
+                        }
+
+                        if (table.config.debug) {
+                            parsersDebug += "column:" + i + " parser:" + p.id + "\n";
+                        }
+
+                        list.push(p);
+                    }
+                }
+
+                if (table.config.debug) {
+                    log(parsersDebug);
+                }
+
+                return list;
+            };
+
+            function detectParserForColumn(table, rows, rowIndex, cellIndex) {
+                var l = parsers.length,
+                    node = false,
+                    nodeValue = false,
+                    keepLooking = true;
+                while (nodeValue == '' && keepLooking) {
+                    rowIndex++;
+                    if (rows[rowIndex]) {
+                        node = getNodeFromRowAndCellIndex(rows, rowIndex, cellIndex);
+                        nodeValue = trimAndGetNodeText(table.config, node);
+                        if (table.config.debug) {
+                            log('Checking if value was empty on row:' + rowIndex);
+                        }
+                    } else {
+                        keepLooking = false;
+                    }
+                }
+                for (var i = 1; i < l; i++) {
+                    if (parsers[i].is(nodeValue, table, node)) {
+                        return parsers[i];
+                    }
+                }
+                // 0 is always the generic parser (text)
+                return parsers[0];
+            }
+
+            function getNodeFromRowAndCellIndex(rows, rowIndex, cellIndex) {
+                return rows[rowIndex].cells[cellIndex];
+            }
+
+            function trimAndGetNodeText(config, node) {
+                return $.trim(getElementText(config, node));
+            }
+
+            function getParserById(name) {
+                var l = parsers.length;
+                for (var i = 0; i < l; i++) {
+                    if (parsers[i].id.toLowerCase() == name.toLowerCase()) {
+                        return parsers[i];
+                    }
+                }
+                return false;
+            }
+
+            /* utils */
+
+            function buildCache(table) {
+
+                if (table.config.debug) {
+                    var cacheTime = new Date();
+                }
+
+                var totalRows = (table.tBodies[0] && table.tBodies[0].rows.length) || 0,
+                    totalCells = (table.tBodies[0].rows[0] && table.tBodies[0].rows[0].cells.length) || 0,
+                    parsers = table.config.parsers,
+                    cache = {
+                        row: [],
+                        normalized: []
+                    };
+
+                for (var i = 0; i < totalRows; ++i) {
+
+                    /** Add the table data to main data array */
+                    var c = $(table.tBodies[0].rows[i]),
+                        cols = [];
+
+                    // if this is a child row, add it to the last row's children and
+                    // continue to the next row
+                    if (c.hasClass(table.config.cssChildRow)) {
+                        cache.row[cache.row.length - 1] = cache.row[cache.row.length - 1].add(c);
+                        // go to the next for loop
+                        continue;
+                    }
+
+                    cache.row.push(c);
+
+                    for (var j = 0; j < totalCells; ++j) {
+                        cols.push(parsers[j].format(getElementText(table.config, c[0].cells[j]), table, c[0].cells[j]));
+                    }
+
+                    cols.push(cache.normalized.length); // add position for rowCache
+                    cache.normalized.push(cols);
+                    cols = null;
+                };
+
+                if (table.config.debug) {
+                    benchmark("Building cache for " + totalRows + " rows:", cacheTime);
+                }
+
+                return cache;
+            };
+
+            function getElementText(config, node) {
+
+                var text = "";
+
+                if (!node) return "";
+
+                if (!config.supportsTextContent) config.supportsTextContent = node.textContent || false;
+
+                if (config.textExtraction == "simple") {
+                    if (config.supportsTextContent) {
+                        text = node.textContent;
+                    } else {
+                        if (node.childNodes[0] && node.childNodes[0].hasChildNodes()) {
+                            text = node.childNodes[0].innerHTML;
+                        } else {
+                            text = node.innerHTML;
+                        }
+                    }
+                } else {
+                    if (typeof(config.textExtraction) == "function") {
+                        text = config.textExtraction(node);
+                    } else {
+                        text = $(node).text();
+                    }
+                }
+                return text;
+            }
+
+            function appendToTable(table, cache) {
+
+                if (table.config.debug) {
+                    var appendTime = new Date()
+                }
+
+                var c = cache,
+                    r = c.row,
+                    n = c.normalized,
+                    totalRows = n.length,
+                    checkCell = (n[0].length - 1),
+                    tableBody = $(table.tBodies[0]),
+                    rows = [];
+
+
+                for (var i = 0; i < totalRows; i++) {
+                    var pos = n[i][checkCell];
+
+                    rows.push(r[pos]);
+
+                    if (!table.config.appender) {
+
+                        //var o = ;
+                        var l = r[pos].length;
+                        for (var j = 0; j < l; j++) {
+                            tableBody[0].appendChild(r[pos][j]);
+                        }
+
+                        // 
+                    }
+                }
+
+
+
+                if (table.config.appender) {
+
+                    table.config.appender(table, rows);
+                }
+
+                rows = null;
+
+                if (table.config.debug) {
+                    benchmark("Rebuilt table:", appendTime);
+                }
+
+                // apply table widgets
+                applyWidget(table);
+
+                // trigger sortend
+                setTimeout(function () {
+                    $(table).trigger("sortEnd");
+                }, 0);
+
+            };
+
+            function buildHeaders(table) {
+
+                if (table.config.debug) {
+                    var time = new Date();
+                }
+
+                var meta = ($.metadata) ? true : false;
+                
+                var header_index = computeTableHeaderCellIndexes(table);
+
+                $tableHeaders = $(table.config.selectorHeaders, table).each(function (index) {
+
+                    this.column = header_index[this.parentNode.rowIndex + "-" + this.cellIndex];
+                    // this.column = index;
+                    this.order = formatSortingOrder(table.config.sortInitialOrder);
+                    
+          
+          this.count = this.order;
+
+                    if (checkHeaderMetadata(this) || checkHeaderOptions(table, index)) this.sortDisabled = true;
+          if (checkHeaderOptionsSortingLocked(table, index)) this.order = this.lockedOrder = checkHeaderOptionsSortingLocked(table, index);
+
+                    if (!this.sortDisabled) {
+                        var $th = $(this).addClass(table.config.cssHeader);
+                        if (table.config.onRenderHeader) table.config.onRenderHeader.apply($th);
+                    }
+
+                    // add cell to headerList
+                    table.config.headerList[index] = this;
+                });
+
+                if (table.config.debug) {
+                    benchmark("Built headers:", time);
+                    log($tableHeaders);
+                }
+
+                return $tableHeaders;
+
+            };
+
+            // from:
+            // http://www.javascripttoolbox.com/lib/table/examples.php
+            // http://www.javascripttoolbox.com/temp/table_cellindex.html
+
+
+            function computeTableHeaderCellIndexes(t) {
+                var matrix = [];
+                var lookup = {};
+                var thead = t.getElementsByTagName('THEAD')[0];
+                var trs = thead.getElementsByTagName('TR');
+
+                for (var i = 0; i < trs.length; i++) {
+                    var cells = trs[i].cells;
+                    for (var j = 0; j < cells.length; j++) {
+                        var c = cells[j];
+
+                        var rowIndex = c.parentNode.rowIndex;
+                        var cellId = rowIndex + "-" + c.cellIndex;
+                        var rowSpan = c.rowSpan || 1;
+                        var colSpan = c.colSpan || 1
+                        var firstAvailCol;
+                        if (typeof(matrix[rowIndex]) == "undefined") {
+                            matrix[rowIndex] = [];
+                        }
+                        // Find first available column in the first row
+                        for (var k = 0; k < matrix[rowIndex].length + 1; k++) {
+                            if (typeof(matrix[rowIndex][k]) == "undefined") {
+                                firstAvailCol = k;
+                                break;
+                            }
+                        }
+                        lookup[cellId] = firstAvailCol;
+                        for (var k = rowIndex; k < rowIndex + rowSpan; k++) {
+                            if (typeof(matrix[k]) == "undefined") {
+                                matrix[k] = [];
+                            }
+                            var matrixrow = matrix[k];
+                            for (var l = firstAvailCol; l < firstAvailCol + colSpan; l++) {
+                                matrixrow[l] = "x";
+                            }
+                        }
+                    }
+                }
+                return lookup;
+            }
+
+            function checkCellColSpan(table, rows, row) {
+                var arr = [],
+                    r = table.tHead.rows,
+                    c = r[row].cells;
+
+                for (var i = 0; i < c.length; i++) {
+                    var cell = c[i];
+
+                    if (cell.colSpan > 1) {
+                        arr = arr.concat(checkCellColSpan(table, headerArr, row++));
+                    } else {
+                        if (table.tHead.length == 1 || (cell.rowSpan > 1 || !r[row + 1])) {
+                            arr.push(cell);
+                        }
+                        // headerArr[row] = (i+row);
+                    }
+                }
+                return arr;
+            };
+
+            function checkHeaderMetadata(cell) {
+                if (($.metadata) && ($(cell).metadata().sorter === false)) {
+                    return true;
+                };
+                return false;
+            }
+
+            function checkHeaderOptions(table, i) {
+                if ((table.config.headers[i]) && (table.config.headers[i].sorter === false)) {
+                    return true;
+                };
+                return false;
+            }
+      
+       function checkHeaderOptionsSortingLocked(table, i) {
+                if ((table.config.headers[i]) && (table.config.headers[i].lockedOrder)) return table.config.headers[i].lockedOrder;
+                return false;
+            }
+      
+            function applyWidget(table) {
+                var c = table.config.widgets;
+                var l = c.length;
+                for (var i = 0; i < l; i++) {
+
+                    getWidgetById(c[i]).format(table);
+                }
+
+            }
+
+            function getWidgetById(name) {
+                var l = widgets.length;
+                for (var i = 0; i < l; i++) {
+                    if (widgets[i].id.toLowerCase() == name.toLowerCase()) {
+                        return widgets[i];
+                    }
+                }
+            };
+
+            function formatSortingOrder(v) {
+                if (typeof(v) != "Number") {
+                    return (v.toLowerCase() == "desc") ? 1 : 0;
+                } else {
+                    return (v == 1) ? 1 : 0;
+                }
+            }
+
+            function isValueInArray(v, a) {
+                var l = a.length;
+                for (var i = 0; i < l; i++) {
+                    if (a[i][0] == v) {
+                        return true;
+                    }
+                }
+                return false;
+            }
+
+            function setHeadersCss(table, $headers, list, css) {
+                // remove all header information
+                $headers.removeClass(css[0]).removeClass(css[1]);
+
+                var h = [];
+                $headers.each(function (offset) {
+                    if (!this.sortDisabled) {
+                        h[this.column] = $(this);
+                    }
+                });
+
+                var l = list.length;
+                for (var i = 0; i < l; i++) {
+                    h[list[i][0]].addClass(css[list[i][1]]);
+                }
+            }
+
+            function fixColumnWidth(table, $headers) {
+                var c = table.config;
+                if (c.widthFixed) {
+                    var colgroup = $('<colgroup>');
+                    $("tr:first td", table.tBodies[0]).each(function () {
+                        colgroup.append($('<col>').css('width', $(this).width()));
+                    });
+                    $(table).prepend(colgroup);
+                };
+            }
+
+            function updateHeaderSortCount(table, sortList) {
+                var c = table.config,
+                    l = sortList.length;
+                for (var i = 0; i < l; i++) {
+                    var s = sortList[i],
+                        o = c.headerList[s[0]];
+                    o.count = s[1];
+                    o.count++;
+                }
+            }
+
+            /* sorting methods */
+
+            function multisort(table, sortList, cache) {
+
+                if (table.config.debug) {
+                    var sortTime = new Date();
+                }
+
+                var dynamicExp = "var sortWrapper = function(a,b) {",
+                    l = sortList.length;
+
+                // TODO: inline functions.
+                for (var i = 0; i < l; i++) {
+
+                    var c = sortList[i][0];
+                    var order = sortList[i][1];
+                    // var s = (getCachedSortType(table.config.parsers,c) == "text") ?
+                    // ((order == 0) ? "sortText" : "sortTextDesc") : ((order == 0) ?
+                    // "sortNumeric" : "sortNumericDesc");
+                    // var s = (table.config.parsers[c].type == "text") ? ((order == 0)
+                    // ? makeSortText(c) : makeSortTextDesc(c)) : ((order == 0) ?
+                    // makeSortNumeric(c) : makeSortNumericDesc(c));
+                    var s = (table.config.parsers[c].type == "text") ? ((order == 0) ? makeSortFunction("text", "asc", c) : makeSortFunction("text", "desc", c)) : ((order == 0) ? makeSortFunction("numeric", "asc", c) : makeSortFunction("numeric", "desc", c));
+                    var e = "e" + i;
+
+                    dynamicExp += "var " + e + " = " + s; // + "(a[" + c + "],b[" + c
+                    // + "]); ";
+                    dynamicExp += "if(" + e + ") { return " + e + "; } ";
+                    dynamicExp += "else { ";
+
+                }
+
+                // if value is the same keep orignal order
+                var orgOrderCol = cache.normalized[0].length - 1;
+                dynamicExp += "return a[" + orgOrderCol + "]-b[" + orgOrderCol + "];";
+
+                for (var i = 0; i < l; i++) {
+                    dynamicExp += "}; ";
+                }
+
+                dynamicExp += "return 0; ";
+                dynamicExp += "}; ";
+
+                if (table.config.debug) {
+                    benchmark("Evaling expression:" + dynamicExp, new Date());
+                }
+
+                eval(dynamicExp);
+
+                cache.normalized.sort(sortWrapper);
+
+                if (table.config.debug) {
+                    benchmark("Sorting on " + sortList.toString() + " and dir " + order + " time:", sortTime);
+                }
+
+                return cache;
+            };
+
+            function makeSortFunction(type, direction, index) {
+                var a = "a[" + index + "]",
+                    b = "b[" + index + "]";
+                if (type == 'text' && direction == 'asc') {
+                    return "(" + a + " == " + b + " ? 0 : (" + a + " === null ? Number.POSITIVE_INFINITY : (" + b + " === null ? Number.NEGATIVE_INFINITY : (" + a + " < " + b + ") ? -1 : 1 )));";
+                } else if (type == 'text' && direction == 'desc') {
+                    return "(" + a + " == " + b + " ? 0 : (" + a + " === null ? Number.POSITIVE_INFINITY : (" + b + " === null ? Number.NEGATIVE_INFINITY : (" + b + " < " + a + ") ? -1 : 1 )));";
+                } else if (type == 'numeric' && direction == 'asc') {
+                    return "(" + a + " === null && " + b + " === null) ? 0 :(" + a + " === null ? Number.POSITIVE_INFINITY : (" + b + " === null ? Number.NEGATIVE_INFINITY : " + a + " - " + b + "));";
+                } else if (type == 'numeric' && direction == 'desc') {
+                    return "(" + a + " === null && " + b + " === null) ? 0 :(" + a + " === null ? Number.POSITIVE_INFINITY : (" + b + " === null ? Number.NEGATIVE_INFINITY : " + b + " - " + a + "));";
+                }
+            };
+
+            function makeSortText(i) {
+                return "((a[" + i + "] < b[" + i + "]) ? -1 : ((a[" + i + "] > b[" + i + "]) ? 1 : 0));";
+            };
+
+            function makeSortTextDesc(i) {
+                return "((b[" + i + "] < a[" + i + "]) ? -1 : ((b[" + i + "] > a[" + i + "]) ? 1 : 0));";
+            };
+
+            function makeSortNumeric(i) {
+                return "a[" + i + "]-b[" + i + "];";
+            };
+
+            function makeSortNumericDesc(i) {
+                return "b[" + i + "]-a[" + i + "];";
+            };
+
+            function sortText(a, b) {
+                if (table.config.sortLocaleCompare) return a.localeCompare(b);
+                return ((a < b) ? -1 : ((a > b) ? 1 : 0));
+            };
+
+            function sortTextDesc(a, b) {
+                if (table.config.sortLocaleCompare) return b.localeCompare(a);
+                return ((b < a) ? -1 : ((b > a) ? 1 : 0));
+            };
+
+            function sortNumeric(a, b) {
+                return a - b;
+            };
+
+            function sortNumericDesc(a, b) {
+                return b - a;
+            };
+
+            function getCachedSortType(parsers, i) {
+                return parsers[i].type;
+            }; /* public methods */
+            this.construct = function (settings) {
+                return this.each(function () {
+                    // if no thead or tbody quit.
+                    if (!this.tHead || !this.tBodies) return;
+                    // declare
+                    var $this, $document, $headers, cache, config, shiftDown = 0,
+                        sortOrder;
+                    // new blank config object
+                    this.config = {};
+                    // merge and extend.
+                    config = $.extend(this.config, $.tablesorter.defaults, settings);
+                    // store common expression for speed
+                    $this = $(this);
+                    // save the settings where they read
+                    $.data(this, "tablesorter", config);
+                    // build headers
+                    $headers = buildHeaders(this);
+                    // try to auto detect column type, and store in tables config
+                    this.config.parsers = buildParserCache(this, $headers);
+                    // build the cache for the tbody cells
+                    cache = buildCache(this);
+                    // get the css class names, could be done else where.
+                    var sortCSS = [config.cssDesc, config.cssAsc];
+                    // fixate columns if the users supplies the fixedWidth option
+                    fixColumnWidth(this);
+                    // apply event handling to headers
+                    // this is to big, perhaps break it out?
+                    $headers.click(
+
+                    function (e) {
+                        var totalRows = ($this[0].tBodies[0] && $this[0].tBodies[0].rows.length) || 0;
+                        if (!this.sortDisabled && totalRows > 0) {
+                            // Only call sortStart if sorting is
+                            // enabled.
+                            $this.trigger("sortStart");
+                            // store exp, for speed
+                            var $cell = $(this);
+                            // get current column index
+                            var i = this.column;
+                            // get current column sort order
+                            this.order = this.count++ % 2;
+              // always sort on the locked order.
+              if(this.lockedOrder) this.order = this.lockedOrder;
+              
+              // user only whants to sort on one
+                            // column
+                            if (!e[config.sortMultiSortKey]) {
+                                // flush the sort list
+                                config.sortList = [];
+                                if (config.sortForce != null) {
+                                    var a = config.sortForce;
+                                    for (var j = 0; j < a.length; j++) {
+                                        if (a[j][0] != i) {
+                                            config.sortList.push(a[j]);
+                                        }
+                                    }
+                                }
+                                // add column to sort list
+                                config.sortList.push([i, this.order]);
+                                // multi column sorting
+                            } else {
+                                // the user has clicked on an all
+                                // ready sortet column.
+                                if (isValueInArray(i, config.sortList)) {
+                                    // revers the sorting direction
+                                    // for all tables.
+                                    for (var j = 0; j < config.sortList.length; j++) {
+                                        var s = config.sortList[j],
+                                            o = config.headerList[s[0]];
+                                        if (s[0] == i) {
+                                            o.count = s[1];
+                                            o.count++;
+                                            s[1] = o.count % 2;
+                                        }
+                                    }
+                                } else {
+                                    // add column to sort list array
+                                    config.sortList.push([i, this.order]);
+                                }
+                            };
+                            setTimeout(function () {
+                                // set css for headers
+                                setHeadersCss($this[0], $headers, config.sortList, sortCSS);
+                                appendToTable(
+                                  $this[0], multisort(
+                                  $this[0], config.sortList, cache)
+                );
+                            }, 1);
+                            // stop normal event by returning false
+                            return false;
+                        }
+                        // cancel selection
+                    }).mousedown(function () {
+                        if (config.cancelSelection) {
+                            this.onselectstart = function () {
+                                return false
+                            };
+                            return false;
+                        }
+                    });
+                    // apply easy methods that trigger binded events
+                    $this.bind("update", function () {
+                        var me = this;
+                        setTimeout(function () {
+                            // rebuild parsers.
+                            me.config.parsers = buildParserCache(
+                            me, $headers);
+                            // rebuild the cache map
+                            cache = buildCache(me);
+                        }, 1);
+                    }).bind("updateCell", function (e, cell) {
+                        var config = this.config;
+                        // get position from the dom.
+                        var pos = [(cell.parentNode.rowIndex - 1), cell.cellIndex];
+                        // update cache
+                        cache.normalized[pos[0]][pos[1]] = config.parsers[pos[1]].format(
+                        getElementText(config, cell), cell);
+                    }).bind("sorton", function (e, list) {
+                        $(this).trigger("sortStart");
+                        config.sortList = list;
+                        // update and store the sortlist
+                        var sortList = config.sortList;
+                        // update header count index
+                        updateHeaderSortCount(this, sortList);
+                        // set css for headers
+                        setHeadersCss(this, $headers, sortList, sortCSS);
+                        // sort the table and append it to the dom
+                        appendToTable(this, multisort(this, sortList, cache));
+                    }).bind("appendCache", function () {
+                        appendToTable(this, cache);
+                    }).bind("applyWidgetId", function (e, id) {
+                        getWidgetById(id).format(this);
+                    }).bind("applyWidgets", function () {
+                        // apply widgets
+                        applyWidget(this);
+                    });
+                    if ($.metadata && ($(this).metadata() && $(this).metadata().sortlist)) {
+                        config.sortList = $(this).metadata().sortlist;
+                    }
+                    // if user has supplied a sort list to constructor.
+                    if (config.sortList.length > 0) {
+                        $this.trigger("sorton", [config.sortList]);
+                    }
+                    // apply widgets
+                    applyWidget(this);
+                });
+            };
+            this.addParser = function (parser) {
+                var l = parsers.length,
+                    a = true;
+                for (var i = 0; i < l; i++) {
+                    if (parsers[i].id.toLowerCase() == parser.id.toLowerCase()) {
+                        a = false;
+                    }
+                }
+                if (a) {
+                    parsers.push(parser);
+                };
+            };
+            this.addWidget = function (widget) {
+                widgets.push(widget);
+            };
+            this.formatFloat = function (s) {
+                var i = parseFloat(s);
+                return (isNaN(i)) ? 0 : i;
+            };
+            this.formatInt = function (s) {
+                var i = parseInt(s);
+                return (isNaN(i)) ? 0 : i;
+            };
+            this.isDigit = function (s, config) {
+                // replace all an wanted chars and match.
+                return /^[-+]?\d*$/.test($.trim(s.replace(/[,.']/g, '')));
+            };
+            this.clearTableBody = function (table) {
+                if ($.browser.msie) {
+                    function empty() {
+                        while (this.firstChild)
+                        this.removeChild(this.firstChild);
+                    }
+                    empty.apply(table.tBodies[0]);
+                } else {
+                    table.tBodies[0].innerHTML = "";
+                }
+            };
+        }
+    });
+
+    // extend plugin scope
+    $.fn.extend({
+        tablesorter: $.tablesorter.construct
+    });
+
+    // make shortcut
+    var ts = $.tablesorter;
+
+    // add default parsers
+    ts.addParser({
+        id: "text",
+        is: function (s) {
+            return true;
+        }, format: function (s) {
+            return $.trim(s.toLocaleLowerCase());
+        }, type: "text"
+    });
+
+    ts.addParser({
+        id: "digit",
+        is: function (s, table) {
+            var c = table.config;
+            return $.tablesorter.isDigit(s, c);
+        }, format: function (s) {
+            return $.tablesorter.formatFloat(s);
+        }, type: "numeric"
+    });
+
+    ts.addParser({
+        id: "currency",
+        is: function (s) {
+            return /^[£$€?.]/.test(s);
+        }, format: function (s) {
+            return $.tablesorter.formatFloat(s.replace(new RegExp(/[£$€]/g), ""));
+        }, type: "numeric"
+    });
+
+    ts.addParser({
+        id: "ipAddress",
+        is: function (s) {
+            return /^\d{2,3}[\.]\d{2,3}[\.]\d{2,3}[\.]\d{2,3}$/.test(s);
+        }, format: function (s) {
+            var a = s.split("."),
+                r = "",
+                l = a.length;
+            for (var i = 0; i < l; i++) {
+                var item = a[i];
+                if (item.length == 2) {
+                    r += "0" + item;
+                } else {
+                    r += item;
+                }
+            }
+            return $.tablesorter.formatFloat(r);
+        }, type: "numeric"
+    });
+
+    ts.addParser({
+        id: "url",
+        is: function (s) {
+            return /^(https?|ftp|file):\/\/$/.test(s);
+        }, format: function (s) {
+            return jQuery.trim(s.replace(new RegExp(/(https?|ftp|file):\/\//), ''));
+        }, type: "text"
+    });
+
+    ts.addParser({
+        id: "isoDate",
+        is: function (s) {
+            return /^\d{4}[\/-]\d{1,2}[\/-]\d{1,2}$/.test(s);
+        }, format: function (s) {
+            return $.tablesorter.formatFloat((s != "") ? new Date(s.replace(
+            new RegExp(/-/g), "/")).getTime() : "0");
+        }, type: "numeric"
+    });
+
+    ts.addParser({
+        id: "percent",
+        is: function (s) {
+            return /\%$/.test($.trim(s));
+        }, format: function (s) {
+            return $.tablesorter.formatFloat(s.replace(new RegExp(/%/g), ""));
+        }, type: "numeric"
+    });
+
+    ts.addParser({
+        id: "usLongDate",
+        is: function (s) {
+            return s.match(new RegExp(/^[A-Za-z]{3,10}\.? [0-9]{1,2}, ([0-9]{4}|'?[0-9]{2}) (([0-2]?[0-9]:[0-5][0-9])|([0-1]?[0-9]:[0-5][0-9]\s(AM|PM)))$/));
+        }, format: function (s) {
+            return $.tablesorter.formatFloat(new Date(s).getTime());
+        }, type: "numeric"
+    });
+
+    ts.addParser({
+        id: "shortDate",
+        is: function (s) {
+            return /\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4}/.test(s);
+        }, format: function (s, table) {
+            var c = table.config;
+            s = s.replace(/\-/g, "/");
+            if (c.dateFormat == "us") {
+                // reformat the string in ISO format
+                s = s.replace(/(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})/, "$3/$1/$2");
+            } else if (c.dateFormat == "uk") {
+                // reformat the string in ISO format
+                s = s.replace(/(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})/, "$3/$2/$1");
+            } else if (c.dateFormat == "dd/mm/yy" || c.dateFormat == "dd-mm-yy") {
+                s = s.replace(/(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2})/, "$1/$2/$3");
+            }
+            return $.tablesorter.formatFloat(new Date(s).getTime());
+        }, type: "numeric"
+    });
+    ts.addParser({
+        id: "time",
+        is: function (s) {
+            return /^(([0-2]?[0-9]:[0-5][0-9])|([0-1]?[0-9]:[0-5][0-9]\s(am|pm)))$/.test(s);
+        }, format: function (s) {
+            return $.tablesorter.formatFloat(new Date("2000/01/01 " + s).getTime());
+        }, type: "numeric"
+    });
+    ts.addParser({
+        id: "metadata",
+        is: function (s) {
+            return false;
+        }, format: function (s, table, cell) {
+            var c = table.config,
+                p = (!c.parserMetadataName) ? 'sortValue' : c.parserMetadataName;
+            return $(cell).metadata()[p];
+        }, type: "numeric"
+    });
+    // add default widgets
+    ts.addWidget({
+        id: "zebra",
+        format: function (table) {
+            if (table.config.debug) {
+                var time = new Date();
+            }
+            var $tr, row = -1,
+                odd;
+            // loop through the visible rows
+            $("tr:visible", table.tBodies[0]).each(function (i) {
+                $tr = $(this);
+                // style children rows the same way the parent
+                // row was styled
+                if (!$tr.hasClass(table.config.cssChildRow)) row++;
+                odd = (row % 2 == 0);
+                $tr.removeClass(
+                table.config.widgetZebra.css[odd ? 0 : 1]).addClass(
+                table.config.widgetZebra.css[odd ? 1 : 0])
+            });
+            if (table.config.debug) {
+                $.tablesorter.benchmark("Applying Zebra widget", time);
+            }
+        }
+    });
+})(jQuery);
diff --git a/web/modules/ultimate_cron/js/jquery.tablesorter.min.js b/web/modules/ultimate_cron/js/jquery.tablesorter.min.js
new file mode 100644
index 0000000000000000000000000000000000000000..b8605df1e7277e2f88237eb7bee7c7e38d80a232
--- /dev/null
+++ b/web/modules/ultimate_cron/js/jquery.tablesorter.min.js
@@ -0,0 +1,4 @@
+
+(function($){$.extend({tablesorter:new
+function(){var parsers=[],widgets=[];this.defaults={cssHeader:"header",cssAsc:"headerSortUp",cssDesc:"headerSortDown",cssChildRow:"expand-child",sortInitialOrder:"asc",sortMultiSortKey:"shiftKey",sortForce:null,sortAppend:null,sortLocaleCompare:true,textExtraction:"simple",parsers:{},widgets:[],widgetZebra:{css:["even","odd"]},headers:{},widthFixed:false,cancelSelection:true,sortList:[],headerList:[],dateFormat:"us",decimal:'/\.|\,/g',onRenderHeader:null,selectorHeaders:'thead th',debug:false};function benchmark(s,d){log(s+","+(new Date().getTime()-d.getTime())+"ms");}this.benchmark=benchmark;function log(s){if(typeof console!="undefined"&&typeof console.debug!="undefined"){console.log(s);}else{alert(s);}}function buildParserCache(table,$headers){if(table.config.debug){var parsersDebug="";}if(table.tBodies.length==0)return;var rows=table.tBodies[0].rows;if(rows[0]){var list=[],cells=rows[0].cells,l=cells.length;for(var i=0;i<l;i++){var p=false;if($.metadata&&($($headers[i]).metadata()&&$($headers[i]).metadata().sorter)){p=getParserById($($headers[i]).metadata().sorter);}else if((table.config.headers[i]&&table.config.headers[i].sorter)){p=getParserById(table.config.headers[i].sorter);}if(!p){p=detectParserForColumn(table,rows,-1,i);}if(table.config.debug){parsersDebug+="column:"+i+" parser:"+p.id+"\n";}list.push(p);}}if(table.config.debug){log(parsersDebug);}return list;};function detectParserForColumn(table,rows,rowIndex,cellIndex){var l=parsers.length,node=false,nodeValue=false,keepLooking=true;while(nodeValue==''&&keepLooking){rowIndex++;if(rows[rowIndex]){node=getNodeFromRowAndCellIndex(rows,rowIndex,cellIndex);nodeValue=trimAndGetNodeText(table.config,node);if(table.config.debug){log('Checking if value was empty on row:'+rowIndex);}}else{keepLooking=false;}}for(var i=1;i<l;i++){if(parsers[i].is(nodeValue,table,node)){return parsers[i];}}return parsers[0];}function getNodeFromRowAndCellIndex(rows,rowIndex,cellIndex){return rows[rowIndex].cells[cellIndex];}function trimAndGetNodeText(config,node){return $.trim(getElementText(config,node));}function getParserById(name){var l=parsers.length;for(var i=0;i<l;i++){if(parsers[i].id.toLowerCase()==name.toLowerCase()){return parsers[i];}}return false;}function buildCache(table){if(table.config.debug){var cacheTime=new Date();}var totalRows=(table.tBodies[0]&&table.tBodies[0].rows.length)||0,totalCells=(table.tBodies[0].rows[0]&&table.tBodies[0].rows[0].cells.length)||0,parsers=table.config.parsers,cache={row:[],normalized:[]};for(var i=0;i<totalRows;++i){var c=$(table.tBodies[0].rows[i]),cols=[];if(c.hasClass(table.config.cssChildRow)){cache.row[cache.row.length-1]=cache.row[cache.row.length-1].add(c);continue;}cache.row.push(c);for(var j=0;j<totalCells;++j){cols.push(parsers[j].format(getElementText(table.config,c[0].cells[j]),table,c[0].cells[j]));}cols.push(cache.normalized.length);cache.normalized.push(cols);cols=null;};if(table.config.debug){benchmark("Building cache for "+totalRows+" rows:",cacheTime);}return cache;};function getElementText(config,node){var text="";if(!node)return"";if(!config.supportsTextContent)config.supportsTextContent=node.textContent||false;if(config.textExtraction=="simple"){if(config.supportsTextContent){text=node.textContent;}else{if(node.childNodes[0]&&node.childNodes[0].hasChildNodes()){text=node.childNodes[0].innerHTML;}else{text=node.innerHTML;}}}else{if(typeof(config.textExtraction)=="function"){text=config.textExtraction(node);}else{text=$(node).text();}}return text;}function appendToTable(table,cache){if(table.config.debug){var appendTime=new Date()}var c=cache,r=c.row,n=c.normalized,totalRows=n.length,checkCell=(n[0].length-1),tableBody=$(table.tBodies[0]),rows=[];for(var i=0;i<totalRows;i++){var pos=n[i][checkCell];rows.push(r[pos]);if(!table.config.appender){var l=r[pos].length;for(var j=0;j<l;j++){tableBody[0].appendChild(r[pos][j]);}}}if(table.config.appender){table.config.appender(table,rows);}rows=null;if(table.config.debug){benchmark("Rebuilt table:",appendTime);}applyWidget(table);setTimeout(function(){$(table).trigger("sortEnd");},0);};function buildHeaders(table){if(table.config.debug){var time=new Date();}var meta=($.metadata)?true:false;var header_index=computeTableHeaderCellIndexes(table);$tableHeaders=$(table.config.selectorHeaders,table).each(function(index){this.column=header_index[this.parentNode.rowIndex+"-"+this.cellIndex];this.order=formatSortingOrder(table.config.sortInitialOrder);this.count=this.order;if(checkHeaderMetadata(this)||checkHeaderOptions(table,index))this.sortDisabled=true;if(checkHeaderOptionsSortingLocked(table,index))this.order=this.lockedOrder=checkHeaderOptionsSortingLocked(table,index);if(!this.sortDisabled){var $th=$(this).addClass(table.config.cssHeader);if(table.config.onRenderHeader)table.config.onRenderHeader.apply($th);}table.config.headerList[index]=this;});if(table.config.debug){benchmark("Built headers:",time);log($tableHeaders);}return $tableHeaders;};function computeTableHeaderCellIndexes(t){var matrix=[];var lookup={};var thead=t.getElementsByTagName('THEAD')[0];var trs=thead.getElementsByTagName('TR');for(var i=0;i<trs.length;i++){var cells=trs[i].cells;for(var j=0;j<cells.length;j++){var c=cells[j];var rowIndex=c.parentNode.rowIndex;var cellId=rowIndex+"-"+c.cellIndex;var rowSpan=c.rowSpan||1;var colSpan=c.colSpan||1
+var firstAvailCol;if(typeof(matrix[rowIndex])=="undefined"){matrix[rowIndex]=[];}for(var k=0;k<matrix[rowIndex].length+1;k++){if(typeof(matrix[rowIndex][k])=="undefined"){firstAvailCol=k;break;}}lookup[cellId]=firstAvailCol;for(var k=rowIndex;k<rowIndex+rowSpan;k++){if(typeof(matrix[k])=="undefined"){matrix[k]=[];}var matrixrow=matrix[k];for(var l=firstAvailCol;l<firstAvailCol+colSpan;l++){matrixrow[l]="x";}}}}return lookup;}function checkCellColSpan(table,rows,row){var arr=[],r=table.tHead.rows,c=r[row].cells;for(var i=0;i<c.length;i++){var cell=c[i];if(cell.colSpan>1){arr=arr.concat(checkCellColSpan(table,headerArr,row++));}else{if(table.tHead.length==1||(cell.rowSpan>1||!r[row+1])){arr.push(cell);}}}return arr;};function checkHeaderMetadata(cell){if(($.metadata)&&($(cell).metadata().sorter===false)){return true;};return false;}function checkHeaderOptions(table,i){if((table.config.headers[i])&&(table.config.headers[i].sorter===false)){return true;};return false;}function checkHeaderOptionsSortingLocked(table,i){if((table.config.headers[i])&&(table.config.headers[i].lockedOrder))return table.config.headers[i].lockedOrder;return false;}function applyWidget(table){var c=table.config.widgets;var l=c.length;for(var i=0;i<l;i++){getWidgetById(c[i]).format(table);}}function getWidgetById(name){var l=widgets.length;for(var i=0;i<l;i++){if(widgets[i].id.toLowerCase()==name.toLowerCase()){return widgets[i];}}};function formatSortingOrder(v){if(typeof(v)!="Number"){return(v.toLowerCase()=="desc")?1:0;}else{return(v==1)?1:0;}}function isValueInArray(v,a){var l=a.length;for(var i=0;i<l;i++){if(a[i][0]==v){return true;}}return false;}function setHeadersCss(table,$headers,list,css){$headers.removeClass(css[0]).removeClass(css[1]);var h=[];$headers.each(function(offset){if(!this.sortDisabled){h[this.column]=$(this);}});var l=list.length;for(var i=0;i<l;i++){h[list[i][0]].addClass(css[list[i][1]]);}}function fixColumnWidth(table,$headers){var c=table.config;if(c.widthFixed){var colgroup=$('<colgroup>');$("tr:first td",table.tBodies[0]).each(function(){colgroup.append($('<col>').css('width',$(this).width()));});$(table).prepend(colgroup);};}function updateHeaderSortCount(table,sortList){var c=table.config,l=sortList.length;for(var i=0;i<l;i++){var s=sortList[i],o=c.headerList[s[0]];o.count=s[1];o.count++;}}function multisort(table,sortList,cache){if(table.config.debug){var sortTime=new Date();}var dynamicExp="var sortWrapper = function(a,b) {",l=sortList.length;for(var i=0;i<l;i++){var c=sortList[i][0];var order=sortList[i][1];var s=(table.config.parsers[c].type=="text")?((order==0)?makeSortFunction("text","asc",c):makeSortFunction("text","desc",c)):((order==0)?makeSortFunction("numeric","asc",c):makeSortFunction("numeric","desc",c));var e="e"+i;dynamicExp+="var "+e+" = "+s;dynamicExp+="if("+e+") { return "+e+"; } ";dynamicExp+="else { ";}var orgOrderCol=cache.normalized[0].length-1;dynamicExp+="return a["+orgOrderCol+"]-b["+orgOrderCol+"];";for(var i=0;i<l;i++){dynamicExp+="}; ";}dynamicExp+="return 0; ";dynamicExp+="}; ";if(table.config.debug){benchmark("Evaling expression:"+dynamicExp,new Date());}eval(dynamicExp);cache.normalized.sort(sortWrapper);if(table.config.debug){benchmark("Sorting on "+sortList.toString()+" and dir "+order+" time:",sortTime);}return cache;};function makeSortFunction(type,direction,index){var a="a["+index+"]",b="b["+index+"]";if(type=='text'&&direction=='asc'){return"("+a+" == "+b+" ? 0 : ("+a+" === null ? Number.POSITIVE_INFINITY : ("+b+" === null ? Number.NEGATIVE_INFINITY : ("+a+" < "+b+") ? -1 : 1 )));";}else if(type=='text'&&direction=='desc'){return"("+a+" == "+b+" ? 0 : ("+a+" === null ? Number.POSITIVE_INFINITY : ("+b+" === null ? Number.NEGATIVE_INFINITY : ("+b+" < "+a+") ? -1 : 1 )));";}else if(type=='numeric'&&direction=='asc'){return"("+a+" === null && "+b+" === null) ? 0 :("+a+" === null ? Number.POSITIVE_INFINITY : ("+b+" === null ? Number.NEGATIVE_INFINITY : "+a+" - "+b+"));";}else if(type=='numeric'&&direction=='desc'){return"("+a+" === null && "+b+" === null) ? 0 :("+a+" === null ? Number.POSITIVE_INFINITY : ("+b+" === null ? Number.NEGATIVE_INFINITY : "+b+" - "+a+"));";}};function makeSortText(i){return"((a["+i+"] < b["+i+"]) ? -1 : ((a["+i+"] > b["+i+"]) ? 1 : 0));";};function makeSortTextDesc(i){return"((b["+i+"] < a["+i+"]) ? -1 : ((b["+i+"] > a["+i+"]) ? 1 : 0));";};function makeSortNumeric(i){return"a["+i+"]-b["+i+"];";};function makeSortNumericDesc(i){return"b["+i+"]-a["+i+"];";};function sortText(a,b){if(table.config.sortLocaleCompare)return a.localeCompare(b);return((a<b)?-1:((a>b)?1:0));};function sortTextDesc(a,b){if(table.config.sortLocaleCompare)return b.localeCompare(a);return((b<a)?-1:((b>a)?1:0));};function sortNumeric(a,b){return a-b;};function sortNumericDesc(a,b){return b-a;};function getCachedSortType(parsers,i){return parsers[i].type;};this.construct=function(settings){return this.each(function(){if(!this.tHead||!this.tBodies)return;var $this,$document,$headers,cache,config,shiftDown=0,sortOrder;this.config={};config=$.extend(this.config,$.tablesorter.defaults,settings);$this=$(this);$.data(this,"tablesorter",config);$headers=buildHeaders(this);this.config.parsers=buildParserCache(this,$headers);cache=buildCache(this);var sortCSS=[config.cssDesc,config.cssAsc];fixColumnWidth(this);$headers.click(function(e){var totalRows=($this[0].tBodies[0]&&$this[0].tBodies[0].rows.length)||0;if(!this.sortDisabled&&totalRows>0){$this.trigger("sortStart");var $cell=$(this);var i=this.column;this.order=this.count++%2;if(this.lockedOrder)this.order=this.lockedOrder;if(!e[config.sortMultiSortKey]){config.sortList=[];if(config.sortForce!=null){var a=config.sortForce;for(var j=0;j<a.length;j++){if(a[j][0]!=i){config.sortList.push(a[j]);}}}config.sortList.push([i,this.order]);}else{if(isValueInArray(i,config.sortList)){for(var j=0;j<config.sortList.length;j++){var s=config.sortList[j],o=config.headerList[s[0]];if(s[0]==i){o.count=s[1];o.count++;s[1]=o.count%2;}}}else{config.sortList.push([i,this.order]);}};setTimeout(function(){setHeadersCss($this[0],$headers,config.sortList,sortCSS);appendToTable($this[0],multisort($this[0],config.sortList,cache));},1);return false;}}).mousedown(function(){if(config.cancelSelection){this.onselectstart=function(){return false};return false;}});$this.bind("update",function(){var me=this;setTimeout(function(){me.config.parsers=buildParserCache(me,$headers);cache=buildCache(me);},1);}).bind("updateCell",function(e,cell){var config=this.config;var pos=[(cell.parentNode.rowIndex-1),cell.cellIndex];cache.normalized[pos[0]][pos[1]]=config.parsers[pos[1]].format(getElementText(config,cell),cell);}).bind("sorton",function(e,list){$(this).trigger("sortStart");config.sortList=list;var sortList=config.sortList;updateHeaderSortCount(this,sortList);setHeadersCss(this,$headers,sortList,sortCSS);appendToTable(this,multisort(this,sortList,cache));}).bind("appendCache",function(){appendToTable(this,cache);}).bind("applyWidgetId",function(e,id){getWidgetById(id).format(this);}).bind("applyWidgets",function(){applyWidget(this);});if($.metadata&&($(this).metadata()&&$(this).metadata().sortlist)){config.sortList=$(this).metadata().sortlist;}if(config.sortList.length>0){$this.trigger("sorton",[config.sortList]);}applyWidget(this);});};this.addParser=function(parser){var l=parsers.length,a=true;for(var i=0;i<l;i++){if(parsers[i].id.toLowerCase()==parser.id.toLowerCase()){a=false;}}if(a){parsers.push(parser);};};this.addWidget=function(widget){widgets.push(widget);};this.formatFloat=function(s){var i=parseFloat(s);return(isNaN(i))?0:i;};this.formatInt=function(s){var i=parseInt(s);return(isNaN(i))?0:i;};this.isDigit=function(s,config){return/^[-+]?\d*$/.test($.trim(s.replace(/[,.']/g,'')));};this.clearTableBody=function(table){if($.browser.msie){function empty(){while(this.firstChild)this.removeChild(this.firstChild);}empty.apply(table.tBodies[0]);}else{table.tBodies[0].innerHTML="";}};}});$.fn.extend({tablesorter:$.tablesorter.construct});var ts=$.tablesorter;ts.addParser({id:"text",is:function(s){return true;},format:function(s){return $.trim(s.toLocaleLowerCase());},type:"text"});ts.addParser({id:"digit",is:function(s,table){var c=table.config;return $.tablesorter.isDigit(s,c);},format:function(s){return $.tablesorter.formatFloat(s);},type:"numeric"});ts.addParser({id:"currency",is:function(s){return/^[£$€?.]/.test(s);},format:function(s){return $.tablesorter.formatFloat(s.replace(new RegExp(/[£$€]/g),""));},type:"numeric"});ts.addParser({id:"ipAddress",is:function(s){return/^\d{2,3}[\.]\d{2,3}[\.]\d{2,3}[\.]\d{2,3}$/.test(s);},format:function(s){var a=s.split("."),r="",l=a.length;for(var i=0;i<l;i++){var item=a[i];if(item.length==2){r+="0"+item;}else{r+=item;}}return $.tablesorter.formatFloat(r);},type:"numeric"});ts.addParser({id:"url",is:function(s){return/^(https?|ftp|file):\/\/$/.test(s);},format:function(s){return jQuery.trim(s.replace(new RegExp(/(https?|ftp|file):\/\//),''));},type:"text"});ts.addParser({id:"isoDate",is:function(s){return/^\d{4}[\/-]\d{1,2}[\/-]\d{1,2}$/.test(s);},format:function(s){return $.tablesorter.formatFloat((s!="")?new Date(s.replace(new RegExp(/-/g),"/")).getTime():"0");},type:"numeric"});ts.addParser({id:"percent",is:function(s){return/\%$/.test($.trim(s));},format:function(s){return $.tablesorter.formatFloat(s.replace(new RegExp(/%/g),""));},type:"numeric"});ts.addParser({id:"usLongDate",is:function(s){return s.match(new RegExp(/^[A-Za-z]{3,10}\.? [0-9]{1,2}, ([0-9]{4}|'?[0-9]{2}) (([0-2]?[0-9]:[0-5][0-9])|([0-1]?[0-9]:[0-5][0-9]\s(AM|PM)))$/));},format:function(s){return $.tablesorter.formatFloat(new Date(s).getTime());},type:"numeric"});ts.addParser({id:"shortDate",is:function(s){return/\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4}/.test(s);},format:function(s,table){var c=table.config;s=s.replace(/\-/g,"/");if(c.dateFormat=="us"){s=s.replace(/(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})/,"$3/$1/$2");}else if(c.dateFormat=="uk"){s=s.replace(/(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})/,"$3/$2/$1");}else if(c.dateFormat=="dd/mm/yy"||c.dateFormat=="dd-mm-yy"){s=s.replace(/(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2})/,"$1/$2/$3");}return $.tablesorter.formatFloat(new Date(s).getTime());},type:"numeric"});ts.addParser({id:"time",is:function(s){return/^(([0-2]?[0-9]:[0-5][0-9])|([0-1]?[0-9]:[0-5][0-9]\s(am|pm)))$/.test(s);},format:function(s){return $.tablesorter.formatFloat(new Date("2000/01/01 "+s).getTime());},type:"numeric"});ts.addParser({id:"metadata",is:function(s){return false;},format:function(s,table,cell){var c=table.config,p=(!c.parserMetadataName)?'sortValue':c.parserMetadataName;return $(cell).metadata()[p];},type:"numeric"});ts.addWidget({id:"zebra",format:function(table){if(table.config.debug){var time=new Date();}var $tr,row=-1,odd;$("tr:visible",table.tBodies[0]).each(function(i){$tr=$(this);if(!$tr.hasClass(table.config.cssChildRow))row++;odd=(row%2==0);$tr.removeClass(table.config.widgetZebra.css[odd?0:1]).addClass(table.config.widgetZebra.css[odd?1:0])});if(table.config.debug){$.tablesorter.benchmark("Applying Zebra widget",time);}}});})(jQuery);
\ No newline at end of file
diff --git a/web/modules/ultimate_cron/js/ultimate_cron.job.js b/web/modules/ultimate_cron/js/ultimate_cron.job.js
new file mode 100644
index 0000000000000000000000000000000000000000..fa34358654a5189864129a71732b6f134e707080
--- /dev/null
+++ b/web/modules/ultimate_cron/js/ultimate_cron.job.js
@@ -0,0 +1,18 @@
+(function ($) {
+  'use strict';
+
+  Drupal.behaviors.ultimateCronJobFieldsetSummaries = {
+    attach: function (context) {
+      $('#edit-settings-scheduler', context).drupalSetSummary(function (context) {
+        return $('#edit-settings-scheduler-name', context).find(':selected').text();
+      });
+      $('#edit-settings-launcher', context).drupalSetSummary(function (context) {
+        return $('#edit-settings-launcher-name', context).find(':selected').text();
+      });
+      $('#edit-settings-logger', context).drupalSetSummary(function (context) {
+        return $('#edit-settings-logger-name', context).find(':selected').text();
+      });
+    }
+  };
+
+})(jQuery);
diff --git a/web/modules/ultimate_cron/js/ultimate_cron.js b/web/modules/ultimate_cron/js/ultimate_cron.js
new file mode 100644
index 0000000000000000000000000000000000000000..d463cc918846b94f96a6fbbd99bd2a913d761fd0
--- /dev/null
+++ b/web/modules/ultimate_cron/js/ultimate_cron.js
@@ -0,0 +1,9 @@
+jQuery(document).ready(function ($) {
+  'use strict';
+
+  /*
+  setInterval(function() {
+    $("#ctools-export-ui-list-items-reload").click();
+  }, 1000);
+  */
+});
diff --git a/web/modules/ultimate_cron/js/ultimate_cron.nodejs.js b/web/modules/ultimate_cron/js/ultimate_cron.nodejs.js
new file mode 100644
index 0000000000000000000000000000000000000000..683b8b34005607d7a651834fca19fd8a29ead716
--- /dev/null
+++ b/web/modules/ultimate_cron/js/ultimate_cron.nodejs.js
@@ -0,0 +1,76 @@
+(function ($) {
+  'use strict';
+  Drupal.Nodejs.callbacks.nodejsUltimateCron = {
+    disabled: false,
+    runningJobs: {},
+    callback: function (message) {
+      if (this.disabled) {return;}
+      var action = message.data.action;
+      var job = message.data.job;
+      var elements = message.data.elements;
+
+      switch (action) {
+        case 'lock':
+          job.started = new Date().getTime();
+          this.runningJobs[job.name] = job;
+          break;
+
+        case 'unlock':
+          delete this.runningJobs[job.name];
+          break;
+
+        case 'progress':
+          if (!this.runningJobs[job.name]) {
+            $('#ctools-export-ui-list-items-reload').click();
+            return;
+          }
+          break;
+
+      }
+
+      for (var key in elements) {
+        if (elements.hasOwnProperty(key)) {
+          var value = elements[key];
+          $(key).replaceWith(value);
+          Drupal.attachBehaviors($(key));
+        }
+      }
+    }
+  };
+
+  Drupal.behaviors.ultimateCronJobNodejs = {
+    attach: function (context) {
+      $('tr td.ctools-export-ui-status', context).each(function () {
+        var row = $(this).parent('tr');
+        var name = $(row).attr('id');
+        if ($(this).attr('title') === 'running') {
+          var duration = $('tr#' + name + ' td.ctools-export-ui-duration span.duration-time').attr('data-src');
+          Drupal.Nodejs.callbacks.nodejsUltimateCron.runningJobs[name] = {
+            started: (new Date().getTime()) - (duration * 1000)
+          };
+        }
+        else {
+          delete Drupal.Nodejs.callbacks.nodejsUltimateCron.runningJobs[name];
+        }
+      });
+    }
+  };
+
+  setInterval(function () {
+    var time = new Date().getTime();
+    var jobs = Drupal.Nodejs.callbacks.nodejsUltimateCron.runningJobs;
+
+    for (var name in jobs) {
+      if (jobs.hasOwnProperty(name)) {
+        var job = jobs[name];
+        var date = new Date(time - job.started);
+        var minutes = '00' + date.getUTCMinutes();
+        var seconds = '00' + date.getUTCSeconds();
+        var formatted = minutes.substring(minutes.length - 2) + ':' + seconds.substring(seconds.length - 2);
+        $('tr#' + name + ' td.ctools-export-ui-duration .duration-time').html(formatted);
+      }
+    }
+  }, 1000);
+
+}(jQuery));
+
diff --git a/web/modules/ultimate_cron/src/Annotation/LauncherPlugin.php b/web/modules/ultimate_cron/src/Annotation/LauncherPlugin.php
new file mode 100644
index 0000000000000000000000000000000000000000..6af0ace6203abc91efb1eaeacf60fd8a1eae102e
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Annotation/LauncherPlugin.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\ultimate_cron\Annotation;
+
+use Drupal\Component\Annotation\Plugin;
+
+/**
+ * Defines a Launcher plugin annotation object.
+ *
+ * @Annotation
+ *
+ * @see \Drupal\ultimate_cron\LauncherManager
+ */
+class LauncherPlugin extends Plugin {
+
+  /**
+   * The plugin ID.
+   *
+   * @var string
+   */
+  public $id;
+
+  /**
+   * The human-readable title of the scheduler.
+   *
+   * @ingroup plugin_translatable
+   *
+   * @var \Drupal\Core\Annotation\Translation
+   */
+  public $title;
+
+  /**
+   * A short description of the scheduler.
+   *
+   * @ingroup plugin_translatable
+   *
+   * @var \Drupal\Core\Annotation\Translation
+   */
+  public $description;
+
+
+
+  public $weight = 0;
+
+}
diff --git a/web/modules/ultimate_cron/src/Annotation/LoggerPlugin.php b/web/modules/ultimate_cron/src/Annotation/LoggerPlugin.php
new file mode 100644
index 0000000000000000000000000000000000000000..85e16498977d84db67cca6dba350684f31069ace
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Annotation/LoggerPlugin.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Drupal\ultimate_cron\Annotation;
+
+use Drupal\Component\Annotation\Plugin;
+
+/**
+ * Defines a Logger plugin annotation object.
+ *
+ * @Annotation
+ *
+ * @see \Drupal\ultimate_cron\LoggerManager
+ */
+class LoggerPlugin extends Plugin {
+
+  /**
+   * The plugin ID.
+   *
+   * @var string
+   */
+  public $id;
+
+  /**
+   * The human-readable title of the scheduler.
+   *
+   * @ingroup plugin_translatable
+   *
+   * @var \Drupal\Core\Annotation\Translation
+   */
+  public $title;
+
+  /**
+   * A short description of the scheduler.
+   *
+   * @ingroup plugin_translatable
+   *
+   * @var \Drupal\Core\Annotation\Translation
+   */
+  public $description;
+
+}
diff --git a/web/modules/ultimate_cron/src/Annotation/SchedulerPlugin.php b/web/modules/ultimate_cron/src/Annotation/SchedulerPlugin.php
new file mode 100644
index 0000000000000000000000000000000000000000..5642953eaaaa3168931f4a2b4ed8f727390777bf
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Annotation/SchedulerPlugin.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Drupal\ultimate_cron\Annotation;
+
+use Drupal\Component\Annotation\Plugin;
+
+/**
+ * Defines a Scheduler plugin annotation object.
+ *
+ * @Annotation
+ *
+ * @see \Drupal\ultimate_cron\SchedulerManager
+ */
+class SchedulerPlugin extends Plugin {
+
+  /**
+   * The plugin ID.
+   *
+   * @var string
+   */
+  public $id;
+
+  /**
+   * The human-readable title of the scheduler.
+   *
+   * @ingroup plugin_translatable
+   *
+   * @var \Drupal\Core\Annotation\Translation
+   */
+  public $title;
+
+  /**
+   * A short description of the scheduler.
+   *
+   * @ingroup plugin_translatable
+   *
+   * @var \Drupal\Core\Annotation\Translation
+   */
+  public $description;
+
+}
diff --git a/web/modules/ultimate_cron/src/Commands/UltimateCronCommands.php b/web/modules/ultimate_cron/src/Commands/UltimateCronCommands.php
new file mode 100644
index 0000000000000000000000000000000000000000..243c5c21e4f0dd535f38ee11d507625f6dc2d90d
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Commands/UltimateCronCommands.php
@@ -0,0 +1,428 @@
+<?php
+
+namespace Drupal\ultimate_cron\Commands;
+
+use Consolidation\OutputFormatters\StructuredData\RowsOfFields;
+use Drupal\Core\Logger\LoggerChannelFactoryInterface;
+use Drupal\ultimate_cron\CronPlugin;
+use Drupal\ultimate_cron\Entity\CronJob;
+use Drush\Commands\DrushCommands;
+
+/**
+ * Class UltimateCronCommands.
+ *
+ * @package Drupal\ultimate_cron\Commands
+ */
+class UltimateCronCommands extends DrushCommands {
+
+  /**
+   * Logger object.
+   *
+   * @var \Drupal\Core\Logger\LoggerChannelInterface
+   */
+  protected $logger;
+
+  /**
+   * Constructs an UltimateCronCommands object.
+   *
+   * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger
+   *   Logger factory object.
+   */
+  public function __construct(LoggerChannelFactoryInterface $logger) {
+    $this->logger = $logger->get('ultimate_cron');
+  }
+
+  /**
+   * Show a cron jobs logs.
+   *
+   * @param string $name
+   *   Job to show logs for.
+   * @param array $options
+   *   Options array.
+   *
+   * @command cron:logs
+   *
+   * @option limit Number of log entries to show
+   * @option compact Only show the first line of each log entry
+   * @usage drush cron-logs node_cron --limit=20
+   *   Show 20 last logs for the node_cron job
+   * @aliases cron-logs
+   * @format table
+   */
+  public function logs($name, array $options = ['limit' => NULL, 'compact' => NULL]) {
+    if (!$name) {
+      throw new \Exception(dt('No job specified?'));
+    }
+
+    /** @var \Drupal\ultimate_cron\Entity\CronJob $job */
+    $job = CronJob::load($name);
+
+    if (!$job) {
+      throw new \Exception(dt('@name not found', ['@name' => $name]));
+    }
+
+    $compact = $options['compact'];
+    $limit = $options['limit'];
+    $limit = $limit ? $limit : 10;
+
+    $table = [];
+    $table[] = [
+      '',
+      dt('Started'),
+      dt('Duration'),
+      dt('User'),
+      dt('Initial message'),
+      dt('Message'),
+      dt('Status'),
+    ];
+
+    $lock_id = $job->isLocked();
+    $log_entries = $job->getLogEntries(ULTIMATE_CRON_LOG_TYPE_ALL, $limit);
+
+    /** @var \Drupal\ultimate_cron\Logger\LogEntry $log_entry */
+    foreach ($log_entries as $log_entry) {
+      $progress = '';
+      if ($log_entry->lid && $lock_id && $log_entry->lid === $lock_id) {
+        $progress = $job->getProgress();
+        $progress = is_numeric($progress) ? sprintf(' (%d%%)', round($progress * 100)) : '';
+      }
+
+      $legend = '';
+      if ($lock_id && $log_entry->lid == $lock_id) {
+        $legend .= 'R';
+        list(, $status) = $job->getPlugin('launcher')->formatRunning($job);
+      }
+      elseif ($log_entry->start_time && !$log_entry->end_time) {
+        list(, $status) = $job->getPlugin('launcher')->formatUnfinished($job);
+      }
+      else {
+        list(, $status) = $log_entry->formatSeverity();
+      }
+
+      $table[$log_entry->lid][] = $legend;
+      $table[$log_entry->lid][] = $log_entry->formatStartTime();
+      $table[$log_entry->lid][] = $log_entry->formatDuration() . $progress;
+      $table[$log_entry->lid][] = $log_entry->formatUser();
+      if ($compact) {
+        $table[$log_entry->lid][] = trim(reset(explode("\n", $log_entry->init_message)), "\n");
+        $table[$log_entry->lid][] = trim(reset(explode("\n", $log_entry->message)), "\n");
+      }
+      else {
+        $table[$log_entry->lid][] = trim($log_entry->init_message, "\n");
+        $table[$log_entry->lid][] = trim($log_entry->message, "\n");
+      }
+      $table[$log_entry->lid][] = $status;
+    }
+
+    return new RowsOfFields($table);
+  }
+
+  /**
+   * List cron jobs.
+   *
+   * @param array $options
+   *   Options array.
+   *
+   * @command cron:list
+   * @option module Comma separated list of modules to show jobs from
+   * @option enabled Show enabled jobs
+   * @option disabled Show enabled jobs
+   * @option behind Show jobs that are behind schedule
+   * @option status Comma separated list of statuses to show jobs from
+   * @option extended Show extended information
+   * @option name Show name instead of title
+   * @option scheduled Show scheduled jobs
+   * @usage drush cron-list --status=running --module=node
+   *   Show jobs from the node module that are currently running
+   * @aliases crl cron-list
+   * @format table
+   */
+  public function cronList(
+    array $options = [
+      'module' => NULL,
+      'enabled' => NULL,
+      'disabled' => NULL,
+      'behind' => NULL,
+      'status' => NULL,
+      'extended' => NULL,
+      'name' => NULL,
+      'scheduled' => NULL,
+    ]
+  ) {
+    $modules = $options['module'];
+    $enabled = $options['enabled'];
+    $disabled = $options['disabled'];
+    $behind = $options['behind'];
+    $extended = $options['extended'];
+    $statuses = $options['status'];
+    $scheduled = $options['scheduled'];
+    $showname = $options['name'];
+
+    $modules = $modules ? explode(',', $modules) : [];
+    $statuses = $statuses ? explode(',', $statuses) : [];
+
+    $title = $showname ? dt('Name') : dt('Title');
+
+    $table = [];
+    $table[] = [
+      '',
+      dt('ID'),
+      dt('Module'),
+      $title,
+      dt('Scheduled'),
+      dt('Started'),
+      dt('Duration'),
+      dt('Status'),
+    ];
+
+    $print_legend = FALSE;
+
+    /** @var \Drupal\ultimate_cron\Entity\CronJob $job */
+    foreach (CronJob::loadMultiple() as $name => $job) {
+      if ($modules && !in_array($job->getModule(), $modules)) {
+        continue;
+      }
+
+      if ($enabled && FALSE === $job->status()) {
+        continue;
+      }
+
+      if ($disabled && TRUE === $job->status()) {
+        continue;
+      }
+
+      if ($scheduled && !$job->isScheduled()) {
+        continue;
+      }
+
+      $legend = '';
+
+      if (FALSE === $job->status()) {
+        $legend .= 'D';
+        $print_legend = TRUE;
+      }
+
+      $lock_id = $job->isLocked();
+      $log_entry = $job->loadLogEntry($lock_id);
+
+      if ($time = $job->isBehindSchedule()) {
+        $legend .= 'B';
+        $print_legend = TRUE;
+      }
+
+      if ($behind && !$time) {
+        continue;
+      }
+
+      if ($lock_id && $log_entry->lid == $lock_id) {
+        $legend .= 'R';
+        list(, $status) = $job->getPlugin('launcher')->formatRunning($job);
+        $print_legend = TRUE;
+      }
+      elseif ($log_entry->start_time && !$log_entry->end_time) {
+        list(, $status) = $job->getPlugin('launcher')->formatUnfinished($job);
+      }
+      else {
+        list(, $status) = $log_entry->formatSeverity();
+      }
+
+      if ($statuses && !in_array($status, $statuses)) {
+        continue;
+      }
+
+      $progress = $lock_id ? $job->formatProgress() : '';
+
+      $table[$name][] = $legend;
+      $table[$name][] = $job->id();
+      $table[$name][] = $job->getModuleName();
+      $table[$name][] = $showname ? $job->id() : $job->getTitle();
+      $table[$name][] = $job->getPlugin('scheduler')->formatLabel($job);
+      $table[$name][] = $log_entry->formatStartTime();
+      $table[$name][] = $log_entry->formatDuration() . ' ' . $progress;
+      $table[$name][] = $status;
+
+      if ($extended) {
+        $table['extended:' . $name][] = '';
+        $table['extended:' . $name][] = '';
+        $table['extended:' . $name][] = $job->id();
+        $table['extended:' . $name][] = $job->getPlugin('scheduler')->formatLabelVerbose($job);
+        $table['extended:' . $name][] = $log_entry->init_message;
+        $table['extended:' . $name][] = $log_entry->message;
+      }
+    }
+
+    if ($print_legend) {
+      $this->output->writeln("\n" . dt('Legend: D = Disabled, R = Running, B = Behind schedule'));
+    }
+
+    return new RowsOfFields($table);
+  }
+
+  /**
+   * Run cron job.
+   *
+   * @param string $name
+   *   Job to run.
+   * @param array $options
+   *   Options array.
+   *
+   * @command cron:run
+   *
+   * @option force Skip the schedule check for each job. Locks are still respected.
+   * @option options Custom options for plugins, e.g. --options=thread=1 for serial launcher
+   * @usage drush cron-run node_cron
+   *   Run the node_cron job
+   * @aliases crun cron-run
+   */
+  public function run($name = NULL, array $options = ['force' => NULL, 'options' => NULL]) {
+    if ($o = $options['options']) {
+      $pairs = explode(',', $o);
+      foreach ($pairs as $pair) {
+        list($key, $value) = explode('=', $pair);
+        CronPlugin::setGlobalOption(trim($key), trim($value));
+      }
+    }
+
+    $force = $options['force'];
+
+    if (!$name) {
+      throw new \Exception(dt("Running all cronjobs is not supported by Ultimate Cron's cron:run - please use Drupal Core's core:cron command!"));
+    }
+
+    // Run a specific job.
+    $job = CronJob::load($name);
+
+    if (!$job) {
+      throw new \Exception(dt('@name not found', ['@name' => $name]));
+    }
+
+    if ($force || $job->isScheduled()) {
+      $job->run(t('Launched by drush'));
+    }
+
+  }
+
+  /**
+   * Enable cron job.
+   *
+   * @param string $name
+   *   Job to enable.
+   * @param array $options
+   *   Options array.
+   *
+   * @command cron:enable
+   *
+   * @option all Enabled all jobs
+   * @usage drush cron-enable node_cron
+   *   Enable the node_cron job
+   * @aliases cre cron-enable
+   */
+  public function enable($name, array $options = ['all' => NULL]) {
+    if (!$name) {
+      if (!$options['all']) {
+        throw new \Exception(dt('No job specified?'));
+      }
+      /** @var \Drupal\ultimate_cron\Entity\CronJob $job */
+      foreach (CronJob::loadMultiple() as $job) {
+        $job->enable()->save();
+      }
+      return;
+    }
+
+    $job = CronJob::load($name);
+    if ($job->enable()->save()) {
+      $this->output->writeln(dt('@name enabled', ['@name' => $name]));
+    }
+  }
+
+  /**
+   * Disable cron job.
+   *
+   * @param string $name
+   *   Job to disable.
+   * @param array $options
+   *   Options array.
+   *
+   * @command cron:disable
+   *
+   * @option all Enabled all jobs
+   * @usage drush cron-disable node_cron
+   *   Disable the node_cron job
+   * @aliases crd cron-disable
+   */
+  public function disable($name, array $options = ['all' => NULL]) {
+    if (!$name) {
+      if (!$options['all']) {
+        throw new \Exception(dt('No job specified?'));
+      }
+      foreach (CronJob::loadMultiple() as $job) {
+        $job->disable()->save();
+      }
+      return;
+    }
+
+    $job = CronJob::load($name);
+    if ($job->disable()->save()) {
+      $this->output->writeln(dt('@name disabled', ['@name' => $name]));
+    }
+  }
+
+  /**
+   * Unlock cron job.
+   *
+   * @param string $name
+   *   Job to unlock.
+   * @param array $options
+   *   Options array.
+   *
+   * @command cron:unlock
+   *
+   * @option all Enabled all jobs
+   * @usage drush cron-unlock node_cron
+   *   Unlock the node_cron job
+   * @aliases cru cron-unlock
+   */
+  public function unlock($name, array $options = ['all' => NULL]) {
+    if (!$name) {
+      if (!$options['all']) {
+        throw new \Exception(dt('No job specified?'));
+      }
+      /** @var \Drupal\ultimate_cron\Entity\CronJob $job */
+      foreach (CronJob::loadMultiple() as $job) {
+        if ($job->isLocked()) {
+          $job->unlock();
+        }
+      }
+      return;
+    }
+
+    /** @var \Drupal\ultimate_cron\Entity\CronJob $job */
+    $job = CronJob::load($name);
+    if (!$job) {
+      throw new \Exception(dt('@name not found', ['@name' => $name]));
+    }
+
+    $lock_id = $job->isLocked();
+    if (!$lock_id) {
+      throw new \Exception(dt('@name is not running', ['@name' => $name]));
+    }
+
+    // Unlock the process.
+    if ($job->unlock($lock_id, TRUE)) {
+      $log_entry = $job->resumeLog($lock_id);
+      global $user;
+      $this->logger->warning('@name manually unlocked by user @username (@uid)', [
+        '@name' => $job->id(),
+        '@username' => $user->getDisplayName(),
+        '@uid' => $user->id(),
+      ]);
+      $log_entry->finish();
+
+      $this->output->writeln(dt('Cron job @name unlocked', ['@name' => $name]));
+    }
+    else {
+      throw new \Exception(dt('Could not unlock cron job @name', ['@name' => $name]));
+    }
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/Controller/JobController.php b/web/modules/ultimate_cron/src/Controller/JobController.php
new file mode 100644
index 0000000000000000000000000000000000000000..2ce189df3607265dbfadc654f1789f854661d842
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Controller/JobController.php
@@ -0,0 +1,124 @@
+<?php
+
+namespace Drupal\ultimate_cron\Controller;
+
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\ultimate_cron\Entity\CronJob;
+
+/**
+ * A controller to interact with CronJob entities.
+ */
+class JobController extends ControllerBase {
+
+  /**
+   * Runs a single cron job.
+   *
+   * @param \Drupal\ultimate_cron\Entity\CronJob $ultimate_cron_job
+   *   The cron job which will be run.
+   *
+   * @return \Symfony\Component\HttpFoundation\RedirectResponse
+   *   Redirects to the job listing after running a job.
+   */
+  public function runCronJob(CronJob $ultimate_cron_job) {
+    $ultimate_cron_job->run($this->t('Launched manually'));
+    $this->messenger()
+      ->addStatus($this->t('Cron job @job_label (@module) was successfully run.', [
+        '@job_label' => $ultimate_cron_job->label(),
+        '@module' => $ultimate_cron_job->getModuleName(),
+      ]));
+    return $this->redirect('entity.ultimate_cron_job.collection');
+  }
+
+  /**
+   * Discovers new default cron jobs.
+   */
+  public function discoverJobs() {
+    \Drupal::service('ultimate_cron.discovery')->discoverCronJobs();
+    $this->messenger()
+      ->addStatus($this->t('Completed discovery for new cron jobs.'));
+    return $this->redirect('entity.ultimate_cron_job.collection');
+  }
+
+  /**
+   * Displays a detailed cron job logs table.
+   *
+   * @param \Drupal\ultimate_cron\Entity\CronJob $ultimate_cron_job
+   *   The cron job which will be run.
+   *
+   * @return array
+   *   A render array as expected by drupal_render().
+   */
+  public function showLogs(CronJob $ultimate_cron_job) {
+
+    $header = array(
+      $this->t('Severity'),
+      $this->t('Start Time'),
+      $this->t('End Time'),
+      $this->t('Message'),
+      $this->t('Duration'),
+    );
+
+    $build['ultimate_cron_job_logs_table'] = [
+      '#type' => 'table',
+      '#header' => $header,
+      '#empty' => $this->t('No log information available.'),
+    ];
+
+    $log_entries = $ultimate_cron_job->getLogEntries();
+    foreach ($log_entries as $log_entry) {
+      list($status, $title) = $log_entry->formatSeverity();
+      $title = $log_entry->message ? $log_entry->message : $title;
+
+      $row = [];
+      $row['severity'] = $status;
+      $row['severity']['#wrapper_attributes']['title'] = strip_tags($title);
+      $row['start_time']['#markup'] = $log_entry->formatStartTime();
+      $row['end_time']['#markup'] = $log_entry->formatEndTime();
+      $row['message']['#markup'] = $log_entry->message ?: $log_entry->formatInitMessage();
+      $row['duration']['#markup'] = $log_entry->formatDuration();
+
+      $build['ultimate_cron_job_logs_table'][] = $row;
+    }
+    $build['#title'] = $this->t('Logs for %label', ['%label' => $ultimate_cron_job->label()]);
+    return $build;
+
+  }
+
+  /**
+   * Unlocks a single cron job.
+   *
+   * @param \Drupal\ultimate_cron\Entity\CronJob $ultimate_cron_job
+   *   The cron job which will be unlocked.
+   *
+   * @return \Symfony\Component\HttpFoundation\RedirectResponse
+   *   Redirects to the job listing after running a job.
+   */
+  public function unlockCronJob(CronJob $ultimate_cron_job) {
+    $lock_id = $ultimate_cron_job->isLocked();
+    $name = $ultimate_cron_job->label();
+
+    // Unlock the process.
+    if ($ultimate_cron_job->unlock($lock_id, TRUE)) {
+      $user = \Drupal::currentUser();
+      \Drupal::logger('ultimate_cron')->warning('@name manually unlocked by user @username (@uid)', array(
+        '@name' => $ultimate_cron_job->id(),
+        '@username' => $user->getDisplayName(),
+        '@uid' => $user->id(),
+      ));
+
+      $this->messenger()
+        ->addStatus($this->t('Cron job @name unlocked successfully.', [
+          '@name' => $name,
+        ]));
+    }
+    else {
+      $this->messenger()
+        ->addError($this->t('Could not unlock cron job @name', [
+          '@name' => $name,
+        ]));
+    }
+
+    return $this->redirect('entity.ultimate_cron_job.collection');
+  }
+}
diff --git a/web/modules/ultimate_cron/src/CronJobAccessControlHandler.php b/web/modules/ultimate_cron/src/CronJobAccessControlHandler.php
new file mode 100644
index 0000000000000000000000000000000000000000..3f0015fe792df0fad717bdc710a8ff198a33b89d
--- /dev/null
+++ b/web/modules/ultimate_cron/src/CronJobAccessControlHandler.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Drupal\ultimate_cron;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Entity\EntityAccessControlHandler;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Session\AccountInterface;
+
+/**
+ * Defines a class to check whether a cron job is valid and should be deletable.
+ */
+class CronJobAccessControlHandler extends EntityAccessControlHandler {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
+    if ($operation === 'delete') {
+      if (!$entity->isValid()) {
+        return AccessResult::allowedIfHasPermission($account, 'administer ultimate cron');
+      }
+      return AccessResult::forbidden();
+    }
+    if ($operation === 'update') {
+      if ($entity->isValid()) {
+        return AccessResult::allowedIfHasPermission($account, 'administer ultimate cron');
+      }
+      return AccessResult::forbidden();
+    }
+    return parent::checkAccess($entity, $operation, $account);
+  }
+}
diff --git a/web/modules/ultimate_cron/src/CronJobDiscovery.php b/web/modules/ultimate_cron/src/CronJobDiscovery.php
new file mode 100644
index 0000000000000000000000000000000000000000..0f8a0d6a312acbdf4f3b0a27d270338bdfe2cf7c
--- /dev/null
+++ b/web/modules/ultimate_cron/src/CronJobDiscovery.php
@@ -0,0 +1,201 @@
+<?php
+
+namespace Drupal\ultimate_cron;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Extension\ModuleExtensionList;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Queue\QueueWorkerManagerInterface;
+use Drupal\ultimate_cron\Entity\CronJob;
+
+/**
+ * Discovery and instantiation of default cron jobs.
+ */
+class CronJobDiscovery {
+
+  /**
+   * @var \Drupal\Core\Extension\ModuleHandlerInterface
+   */
+  protected $moduleHandler;
+
+  /**
+   * @var \Drupal\Core\Queue\QueueWorkerManagerInterface
+   */
+  protected $queueManager;
+
+  /**
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  protected $configFactory;
+
+  /**
+   * The module extension list service.
+   *
+   * @var \Drupal\Core\Extension\ModuleExtensionList
+   */
+  protected $moduleExtensionList;
+
+  /**
+   * CronJobDiscovery constructor.
+   *
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler.
+   * @param \Drupal\Core\Queue\QueueWorkerManagerInterface $queue_manager
+   *   The queue manager.
+   * @param \Drupal\Core\Extension\ModuleExtensionList $module_extension_list
+   *   The module extension list service.
+   */
+  public function __construct(ModuleHandlerInterface $module_handler, QueueWorkerManagerInterface $queue_manager, ConfigFactoryInterface $config_factory, ModuleExtensionList $module_extension_list) {
+    $this->moduleHandler = $module_handler;
+    $this->queueManager = $queue_manager;
+    $this->configFactory = $config_factory;
+    $this->moduleExtensionList = $module_extension_list;
+  }
+
+  /**
+   * Automatically discovers and creates default cron jobs.
+   */
+  public function discoverCronJobs() {
+    // Create cron jobs for hook_cron() implementations.
+    foreach ($this->getHooks() as $id => $info) {
+      $this->ensureCronJobExists($info, $id);
+    }
+
+    if (!$this->configFactory->get('ultimate_cron.settings')->get('queue.enabled')) {
+      return;
+    }
+
+    // Create cron jobs for queue plugins.
+    foreach ($this->queueManager->getDefinitions() as $id => $definition) {
+      if (!isset($definition['cron'])) {
+        continue;
+      }
+
+      $job_id = str_replace(':', '__', CronJobInterface::QUEUE_ID_PREFIX . $id);
+      if (!CronJob::load($job_id)) {
+        $values = [
+          'title' => t('Queue: @title', ['@title' => $definition['title']]),
+          'id' => $job_id,
+          'module' => $definition['provider'],
+          // Process queue jobs later by default.
+          'weight' => 10,
+          'callback' => 'ultimate_cron.queue_worker:queueCallback',
+          'scheduler' => [
+            'id' => 'simple',
+            'configuration' => [
+              'rules' => ['* * * * *'],
+            ],
+          ]
+        ];
+
+        $job = CronJob::create($values);
+        $job->save();
+      }
+    }
+  }
+
+  /**
+   * Creates a new cron job with specific values.
+   *
+   * @param array $info
+   *   Module info.
+   * @param string $id
+   *   Module name.
+   */
+  protected function ensureCronJobExists($info, $id) {
+    $job = NULL;
+    if (!CronJob::load($id)) {
+      $values = array(
+        'title' => $this->getJobTitle($id),
+        'id' => $id,
+        'module' => $info['module'],
+        'callback' => $info['callback'],
+      );
+
+      $job = CronJob::create($values);
+
+      $job->save();
+    }
+  }
+
+  /**
+   * Returns the job title for a given ID.
+   *
+   * @param string $id
+   *   The default cron job ID.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup
+   *   The default job title.
+   */
+  protected function getJobTitle($id) {
+    $titles = array();
+
+    $titles['comment_cron'] = t('Store the maximum possible comments per thread');
+    $titles['dblog_cron'] = t('Remove expired log messages and flood control events');
+    $titles['field_cron'] = t('Purges deleted Field API data');
+    $titles['file_cron'] = t('Deletes temporary files');
+    $titles['history_cron'] = t('Deletes history');
+    $titles['search_cron'] = t('Updates indexable active search pages');
+    $titles['system_cron'] = t('Cleanup (caches, batch, flood, temp-files, etc.)');
+    $titles['update_cron'] = t('Update indexes');
+    $titles['node_cron'] = t('Updates search rankings for nodes');
+    $titles['aggregator_cron'] = t('Refresh feeds');
+    $titles['ultimate_cron_cron'] = t('Runs internal cleanup operations');
+    $titles['statistics_cron'] = t('Reset counts and clean up');
+    $titles['tracker_cron'] = t('Update tracker index');
+
+    if (isset($titles[$id])) {
+      return $titles[$id];
+    }
+    return t('Default cron handler');
+  }
+
+  /**
+   * Get all cron hooks defined.
+   *
+   * @return array
+   *   All hook definitions available.
+   */
+  protected function getHooks() {
+    $hooks = array();
+    // Generate list of jobs provided by modules.
+    $modules = array_keys($this->moduleHandler->getModuleList());
+    foreach ($modules as $module) {
+      $hooks += $this->getModuleHooks($module);
+    }
+
+    return $hooks;
+  }
+
+  /**
+   * Get cron hooks declared by a module.
+   *
+   * @param string $module
+   *   Name of module.
+   *
+   * @return array
+   *   Hook definitions for the specified module.
+   */
+  protected function getModuleHooks($module) {
+    $items = array();
+
+    // Add hook_cron() if applicable.
+    if ($this->moduleHandler->implementsHook($module, 'cron')) {
+      $info = $this->moduleExtensionList->getExtensionInfo($module);
+      $callback = "{$module}_cron";
+      $items[$callback] = array(
+        'module' => $module,
+        'title' =>  isset($titles[$callback]) ? $titles[$callback] : 'Default cron handler',
+        'configure' => empty($info['configure']) ? NULL : $info['configure'],
+        'callback' => $callback,
+        'tags' => array(),
+        'pass job argument' => FALSE,
+      );
+      $items["{$module}_cron"]['tags'][] = 'core';
+    }
+
+    return $items;
+  }
+
+}
+
diff --git a/web/modules/ultimate_cron/src/CronJobInterface.php b/web/modules/ultimate_cron/src/CronJobInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..7dc4daefac935084f96589ba7c0d3857dea0d4fe
--- /dev/null
+++ b/web/modules/ultimate_cron/src/CronJobInterface.php
@@ -0,0 +1,356 @@
+<?php
+
+namespace Drupal\ultimate_cron;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\ultimate_cron\Logger\LogEntry;
+use Drupal\ultimate_cron\Logger\LoggerBase;
+
+interface CronJobInterface extends ConfigEntityInterface {
+
+  /**
+   * Cron job ID prefix for queue jobs.
+   */
+  const QUEUE_ID_PREFIX = 'ultimate_cron_queue_';
+
+  /**
+   * Get locked state for multiple jobs.
+   *
+   * @param array $jobs
+   *   Jobs to check locks for.
+   */
+  public static function isLockedMultiple($jobs);
+
+  /**
+   * Load latest log entries.
+   *
+   * @param array $jobs
+   *   Jobs to load log entries for.
+   *
+   * @return array
+   *   Array of UltimateCronLogEntry objects.
+   */
+  public static function loadLatestLogEntries($jobs, $log_types = array(ULTIMATE_CRON_LOG_TYPE_NORMAL));
+
+  /**
+   * Get multiple job progresses.
+   *
+   * @param array $jobs
+   *   Jobs to get progress for.
+   *
+   * @return array
+   *   Progress of jobs, keyed by job name.
+   */
+  public static function getProgressMultiple($jobs);
+
+  /**
+   * Gets the title of the created cron job.
+   *
+   * @return mixed
+   *  Cron job title.
+   */
+  public function getTitle();
+
+  /**
+   * Gets the cron job callback string.
+   *
+   * @return string
+   *  Callback string.
+   */
+  public function getCallback();
+
+  /**
+   * Gets the cron job module name used for the callback string.
+   *
+   * @return string
+   *  Module name.
+   */
+  public function getModule();
+
+  /**
+   * Gets scheduler array which holds info about the scheduler settings.
+   *
+   * @return array
+   *  Scheduler settings
+   */
+  public function getSchedulerId();
+
+  /**
+   * Gets launcher array which holds info about the launcher settings.
+   *
+   * @return array
+   *  Launcher settings
+   */
+  public function getLauncherId();
+
+  /**
+   * Gets logger array which holds info about the logger settings.
+   *
+   * @return array
+   *  Logger settings
+   */
+  public function getLoggerId();
+
+  /**
+   * Sets the title of the created cron job.
+   *
+   * @param $title
+   * @return mixed
+   *  Cron job title.
+   */
+  public function setTitle($title);
+
+  /**
+   * Sets the cron job callback string.
+   *
+   * @param $callback
+   * @return string
+   *  Callback string.
+   */
+  public function setCallback($callback);
+
+  /**
+   * Sets the cron job module name used for the callback string.
+   *
+   * @param $module
+   * @return string
+   *  Module name.
+   */
+  public function setModule($module);
+
+  /**
+   * Sets scheduler array which holds info about the scheduler settings.
+   *
+   * @param $scheduler_id
+   * @return array
+   *  Scheduler settings
+   */
+  public function setSchedulerId($scheduler_id);
+
+  /**
+   * Sets launcher array which holds info about the launcher settings.
+   *
+   * @param $launcher_id
+   * @return array
+   *  Launcher settings
+   */
+  public function setLauncherId($launcher_id);
+
+  /**
+   * Sets logger array which holds info about the logger settings.
+   *
+   * @param $logger_id
+   * @return array
+   *  Logger settings
+   */
+  public function setLoggerId($logger_id);
+
+  /**
+   * Check if the cron job is callable.
+   *
+   * @return bool
+   *   TRUE if the job is callable, FALSE otherwise.
+   */
+  public function isValid();
+
+  /**
+   * Get a signal without affecting it.
+   *
+   * @see UltimateCronSignal::peek()
+   */
+  public function peekSignal($signal);
+
+  /**
+   * Get a signal and clear it if found.
+   *
+   * @see UltimateCronSignal::get()
+   */
+  public function getSignal($signal);
+
+  /**
+   * Send a signal.
+   *
+   * @see UltimateCronSignal::set()
+   */
+  public function sendSignal($signal, $persist = FALSE);
+
+  /**
+   * Clear a signal.
+   *
+   * @see UltimateCronSignal::clear()
+   */
+  public function clearSignal($signal);
+
+  /**
+   * Send all signal for the job.
+   *
+   * @see UltimateCronSignal::flush()
+   */
+  public function clearSignals();
+
+  /**
+   * Check job schedule.
+   */
+  public function isScheduled();
+
+  /**
+   * Check if job is behind its schedule.
+   */
+  public function isBehindSchedule();
+
+  /**
+   * Lock job.
+   */
+  public function lock();
+
+  /**
+   * Unlock job.
+   *
+   * @param string $lock_id
+   *   The lock id to unlock.
+   * @param boolean $manual
+   *   Whether or not this is a manual unlock.
+   */
+  public function unlock($lock_id = NULL, $manual = FALSE);
+
+  /**
+   * Get locked state of job.
+   */
+  public function isLocked();
+
+  /**
+   * Run job.
+   *
+   * @param string $init_message
+   *   (optional) The launch message. If left NULL, a default message will be
+   *   displayed.
+   *
+   * @return bool
+   *   TRUE if the job is ran, FALSE otherwise.
+   */
+  public function run($init_message = NULL);
+
+  /**
+   * Get log entries.
+   *
+   * @param integer $limit
+   *   (optional) Number of log entries per page.
+   *
+   * @return array
+   *   Array of UltimateCronLogEntry objects.
+   */
+  public function getLogEntries($log_types = ULTIMATE_CRON_LOG_TYPE_ALL, $limit = 10);
+
+  /**
+   * Load log entry.
+   *
+   * @param string $lock_id
+   *   The lock id of the log entry.
+   *
+   * @return LogEntry
+   *   The log entry.
+   */
+  public function loadLogEntry($lock_id);
+
+  /**
+   * Load latest log.
+   *
+   * @return LogEntry
+   *   The latest log entry for this job.
+   */
+  public function loadLatestLogEntry($log_types = array(ULTIMATE_CRON_LOG_TYPE_NORMAL));
+
+  /**
+   * Start logging.
+   *
+   * @param string $lock_id
+   *   The lock id to use.
+   * @param string $init_message
+   *   Initial message for the log.
+   *
+   * @return \Drupal\ultimate_cron\Logger\LogEntry
+   *   The log object.
+   */
+  public function startLog($lock_id, $init_message = '', $log_type = ULTIMATE_CRON_LOG_TYPE_NORMAL);
+
+  /**
+   * Resume a previosly saved log.
+   *
+   * @param string $lock_id
+   *   The lock id of the log to resume.
+   *
+   * @return LogEntry
+   *   The log entry object.
+   */
+  public function resumeLog($lock_id);
+
+  /**
+   * Get module name for this job.
+   */
+  public function getModuleName();
+
+  /**
+   * Get module description for this job.
+   */
+  public function getModuleDescription();
+
+  /**
+   * Initialize progress.
+   */
+  public function initializeProgress();
+
+  /**
+   * Finish progress.
+   */
+  public function finishProgress();
+
+  /**
+   * Get job progress.
+   *
+   * @return float
+   *   The progress of this job.
+   */
+  public function getProgress();
+
+  /**
+   * Set job progress.
+   *
+   * @param float $progress
+   *   The progress (0 - 1).
+   */
+  public function setProgress($progress);
+
+  /**
+   * Format progress.
+   *
+   * @param float $progress
+   *   (optional) The progress to format. Uses the progress on the object
+   *              if not specified.
+   *
+   * @return string
+   *   Formatted progress.
+   */
+  public function formatProgress($progress = NULL);
+
+  /**
+   * Get a "unique" id for a job.
+   */
+  public function getUniqueID();
+
+  /**
+   * Get job plugin.
+   *
+   * If no plugin name is provided current plugin of the specified type will
+   * be returned.
+   *
+   * @param string $plugin_type
+   *   Name of plugin type.
+   * @param string $name
+   *   (optional) The name of the plugin.
+   *
+   * @return \Drupal\ultimate_cron\CronPlugin
+   *   Plugin instance of the specified type.
+   */
+  public function getPlugin($plugin_type, $name = NULL);
+
+}
diff --git a/web/modules/ultimate_cron/src/CronJobListBuilder.php b/web/modules/ultimate_cron/src/CronJobListBuilder.php
new file mode 100644
index 0000000000000000000000000000000000000000..e6e9c72854423fd03b93dc07607e4397a21f285f
--- /dev/null
+++ b/web/modules/ultimate_cron/src/CronJobListBuilder.php
@@ -0,0 +1,133 @@
+<?php
+
+namespace Drupal\ultimate_cron;
+
+use Drupal\Core\Config\Entity\DraggableListBuilder;
+use Drupal\Core\Entity\EntityInterface;
+
+/**
+ * Defines a class to build a listing of cron jobs.
+ *
+ * @see \Drupal\ultimate_cron\Entity\CronJob
+ */
+class CronJobListBuilder extends DraggableListBuilder {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'ultimate_cron_job_list';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildHeader() {
+    $header = array();
+    $header['label'] = array('data' => t('Title'));
+    $header['module'] = array('data' => t('Module'));
+    $header['scheduled'] = array('data' => t('Scheduled'));
+    $header['started'] = array('data' => t('Last Run'));
+    $header['duration'] = array('data' => t('Duration'));
+    $header['status'] = array('data' => t('Status'));
+    return $header + parent::buildHeader();
+  }
+  /**
+   * {@inheritdoc}
+   */
+  public function buildRow(EntityInterface $entity) {
+    /* @var \Drupal\ultimate_cron\CronJobInterface $entity */
+    $icon = drupal_get_path('module', 'ultimate_cron') . '/icons/hourglass.png';
+    $behind_icon = ['#prefix' => ' ', '#theme' => 'image', '#uri' => file_create_url($icon), '#title' => t('Job is behind schedule!')];
+
+    $log_entry = $entity->loadLatestLogEntry();
+    $row['label'] = $entity->label();
+    $row['module']['#markup'] = $entity->getModuleName();
+    $row['module']['#wrapper_attributes']['title'] = $entity->getModuleDescription();
+    $row['scheduled']['label']['#markup'] = $entity->getPlugin('scheduler')->formatLabel($entity);
+    if ($entity->isScheduled()) {
+      $row['scheduled']['behind'] = $behind_icon;
+    }
+    // If the start time is 0, the jobs have never been run.
+    $row['started']['#markup'] = $log_entry->start_time ? \Drupal::service('date.formatter')->format($log_entry->start_time, "short") : $this->t('Never');
+
+    // Display duration
+    $progress = $entity->isLocked() ? $entity->formatProgress() : '';
+    $row['duration'] = [
+      '#markup' => '<span class="duration-time" data-src="' . $log_entry->getDuration() . '">' . $log_entry->formatDuration() . '</span> <span class="duration-progress">' . $progress . '</span>',
+      '#wrapper_attributes' => ['title' => $log_entry->formatEndTime()],
+     ];
+
+    if (!$entity->isValid()) {
+      $row['status']['#markup'] = $this->t('Missing');
+    }
+    elseif (!$entity->status()) {
+      $row['status']['#markup'] = $this->t('Disabled');
+    }
+    else {
+      // Get the status from the launcher when running, otherwise use the last
+      // log entry.
+      if ($entity->isLocked() && $log_entry->lid == $entity->isLocked()) {
+        list($status, $title) = $entity->getPlugin('launcher')->formatRunning($entity);
+      }
+      elseif ($log_entry->start_time && !$log_entry->end_time) {
+        list($status, $title) = $entity->getPlugin('launcher')->formatUnfinished($entity);
+      }
+      else {
+        list($status, $title) = $log_entry->formatSeverity();
+        $title = $log_entry->message ? $log_entry->message : $title;
+      }
+
+      $row['status'] = $status;
+      $row['status']['#wrapper_attributes']['title'] = $title;
+    }
+
+    $row += parent::buildRow($entity);
+    $row['weight']['#delta'] = 50;
+    return $row;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDefaultOperations(EntityInterface $entity) {
+    $operations = parent::getDefaultOperations($entity);
+    if ($entity->status() && $entity->isValid()) {
+      if (!$entity->isLocked()) {
+        $operations += [
+          'run' => [
+            'title' => t('Run'),
+            'weight' => 9,
+            'url' => $entity->toUrl('run'),
+          ]
+        ];
+      }
+      else {
+        $operations += [
+          'unlock' => [
+            'title' => t('Unlock'),
+            'weight' => 9,
+            'url' => $entity->toUrl('unlock'),
+          ]
+        ];
+      }
+    }
+
+    $operations += [
+      'logs' => [
+        'title' => t('Logs'),
+        'weight' => 10,
+        'url' => $entity->toUrl('logs'),
+      ],
+    ];
+
+    // Invalid jobs can not be enabled nor disabled.
+    if (!$entity->isValid()) {
+      unset($operations['disable']);
+      unset($operations['enable']);
+    }
+
+    return $operations;
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/CronPlugin.php b/web/modules/ultimate_cron/src/CronPlugin.php
new file mode 100644
index 0000000000000000000000000000000000000000..7d9863617e7a8aaf8761c786dae2282ea4563337
--- /dev/null
+++ b/web/modules/ultimate_cron/src/CronPlugin.php
@@ -0,0 +1,272 @@
+<?php
+
+namespace Drupal\ultimate_cron;
+use Drupal\Component\Plugin\ConfigurableInterface;
+use Drupal\Component\Plugin\DependentPluginInterface;
+use Drupal\Component\Plugin\PluginInspectionInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Plugin\PluginBase;
+use Drupal\Core\Plugin\PluginFormInterface;
+
+/**
+ * This is the base class for all Ultimate Cron plugins.
+ *
+ * This class handles all the load/save settings for a plugin as well as the
+ * forms, etc.
+ */
+class CronPlugin extends PluginBase implements PluginInspectionInterface, ConfigurableInterface, DependentPluginInterface, PluginFormInterface {
+  static public $multiple = FALSE;
+  static public $instances = array();
+  public $weight = 0;
+  static public $globalOptions = array();
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->setConfiguration($configuration);
+  }
+
+  /**
+   * Returns a list of plugin types.
+   *
+   * @return array
+   */
+  public static function getPluginTypes() {
+    return array(
+      'scheduler' => t('Scheduler'),
+      'launcher' => t('Launcher'),
+      'logger' => t('Logger')
+    );
+  }
+
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConfiguration() {
+    return $this->configuration;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setConfiguration(array $configuration) {
+    $this->configuration = array_merge(
+      $this->defaultConfiguration(),
+      $configuration
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return array();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function calculateDependencies() {
+    return array();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
+
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    return array();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
+
+  }
+
+  /**
+   * Get global plugin option.
+   *
+   * @param string $name
+   *   Name of global plugin option to get.
+   *
+   * @return mixed
+   *   Value of option if any, NULL if not found.
+   */
+  static public function getGlobalOption($name) {
+    return isset(static::$globalOptions[$name]) ? static::$globalOptions[$name] : NULL;
+  }
+
+  /**
+   * Get all global plugin options.
+   *
+   * @return array
+   *   All options currently set, keyed by name.
+   */
+  static public function getGlobalOptions() {
+    return static::$globalOptions;
+  }
+
+  /**
+   * Set global plugin option.
+   *
+   * @param string $name
+   *   Name of global plugin option to get.
+   * @param string $value
+   *   The value to give it.
+   */
+  static public function setGlobalOption($name, $value) {
+    static::$globalOptions[$name] = $value;
+  }
+
+  /**
+   * Get label for a specific setting.
+   */
+  public function settingsLabel($name, $value) {
+    if (is_array($value)) {
+      return implode(', ', $value);
+    }
+    else {
+      return $value;
+    }
+  }
+
+  /**
+   * Default plugin valid for all jobs.
+   */
+  public function isValid($job = NULL) {
+    return TRUE;
+  }
+
+  /**
+   * Modified version drupal_array_get_nested_value().
+   *
+   * Removes the specified parents leaf from the array.
+   *
+   * @param array $array
+   *   Nested associative array.
+   * @param array $parents
+   *   Array of key names forming a "path" where the leaf will be removed
+   *   from $array.
+   */
+  public function drupal_array_remove_nested_value(array &$array, array $parents) {
+    $ref = & $array;
+    $last_parent = array_pop($parents);
+    foreach ($parents as $parent) {
+      if (is_array($ref) && array_key_exists($parent, $ref)) {
+        $ref = & $ref[$parent];
+      }
+      else {
+        return;
+      }
+    }
+    unset($ref[$last_parent]);
+  }
+
+  /**
+   * Clean form of empty fallback values.
+   */
+  public function cleanForm($elements, &$values, $parents) {
+    if (empty($elements)) {
+      return;
+    }
+
+    foreach (element_children($elements) as $child) {
+      if (empty($child) || empty($elements[$child]) || is_numeric($child)) {
+        continue;
+      }
+      // Process children.
+      $this->cleanForm($elements[$child], $values, $parents);
+
+      // Determine relative parents.
+      $rel_parents = array_diff($elements[$child]['#parents'], $parents);
+      $key_exists = NULL;
+      $value = drupal_array_get_nested_value($values, $rel_parents, $key_exists);
+
+      // Unset when applicable.
+      if (!empty($elements[$child]['#markup'])) {
+        static::drupal_array_remove_nested_value($values, $rel_parents);
+      }
+      elseif (
+        $key_exists &&
+        empty($value) &&
+        !empty($elements[$child]['#fallback']) &&
+        $value !== '0'
+      ) {
+        static::drupal_array_remove_nested_value($values, $rel_parents);
+      }
+    }
+  }
+
+  /**
+   * Process fallback form parameters.
+   *
+   * @param array $elements
+   *   Elements to process.
+   * @param array $defaults
+   *   Default values to add to description.
+   * @param bool $remove_non_fallbacks
+   *   If TRUE, non fallback elements will be removed.
+   */
+  public function fallbackalize(&$elements, &$values, $defaults, $remove_non_fallbacks = FALSE) {
+    if (empty($elements)) {
+      return;
+    }
+    foreach (element_children($elements) as $child) {
+      $element = & $elements[$child];
+      if (empty($element['#tree'])) {
+        $param_values = & $values;
+        $param_defaults = & $defaults;
+      }
+      else {
+        $param_values = & $values[$child];
+        $param_defaults = & $defaults[$child];
+      }
+      $this->fallbackalize($element, $param_values, $param_defaults, $remove_non_fallbacks);
+
+      if (empty($element['#type']) || $element['#type'] == 'fieldset') {
+        continue;
+      }
+
+      if (!empty($element['#fallback'])) {
+        if (!$remove_non_fallbacks) {
+          if ($element['#type'] == 'radios') {
+            $label = $this->settingsLabel($child, $defaults[$child]);
+            $element['#options'] = array(
+              '' => t('Default (@default)', array('@default' => $label)),
+            ) + $element['#options'];
+          }
+          elseif ($element['#type'] == 'select' && empty($element['#multiple'])) {
+            $label = $this->settingsLabel($child, $defaults[$child]);
+            $element['#options'] = array(
+              '' => t('Default (@default)', array('@default' => $label)),
+            ) + $element['#options'];
+          }
+          elseif ($defaults[$child] !== '') {
+            $element['#description'] .= ' ' . t('(Blank = @default).', array('@default' => $this->settingsLabel($child, $defaults[$child])));
+          }
+          unset($element['#required']);
+        }
+      }
+      elseif (!empty($element['#type']) && $remove_non_fallbacks) {
+        unset($elements[$child]);
+      }
+      elseif (!isset($element['#default_value']) || $element['#default_value'] === '') {
+        $empty = $element['#type'] == 'checkbox' ? FALSE : '';
+        $values[$child] = !empty($defaults[$child]) ? $defaults[$child] : $empty;
+        $element['#default_value'] = $values[$child];
+      }
+    }
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/CronPluginMultiple.php b/web/modules/ultimate_cron/src/CronPluginMultiple.php
new file mode 100644
index 0000000000000000000000000000000000000000..ca8f9d5955c0a162e1b51231d388508fb6f4c057
--- /dev/null
+++ b/web/modules/ultimate_cron/src/CronPluginMultiple.php
@@ -0,0 +1,117 @@
+<?php
+
+namespace Drupal\ultimate_cron;
+
+class CronPluginMultiple extends \Drupal\ultimate_cron\CronPlugin {
+  static public $multiple = TRUE;
+
+  /**
+   * Default settings form.
+   */
+  static public function defaultSettingsForm(&$form, &$form_state, $plugin_info) {
+    $plugin_type = $plugin_info['type'];
+    foreach (ultimate_cron_plugin_load_all($plugin_type) as $name => $plugin) {
+      if ($plugin->isValid()) {
+        $plugins[] = l($plugin->title, "admin/config/system/cron/$plugin_type/$name");
+      }
+    }
+    $form['available'] = array(
+      '#markup' => theme('item_list', array(
+        'title' => $plugin_info['defaults']['static']['title plural proper'] . ' available',
+        'items' => $plugins
+      ))
+    );
+  }
+
+  /**
+   * Job settings form.
+   */
+  static public function jobSettingsForm(&$form, &$form_state, $plugin_type, $job) {
+    // Check valid plugins.
+    $plugins = ultimate_cron_plugin_load_all($plugin_type);
+    foreach ($plugins as $name => $plugin) {
+      if (!$plugin->isValid($job)) {
+        unset($plugins[$name]);
+      }
+    }
+
+    // No plugins = no settings = no vertical tabs for you mister!
+    if (empty($plugins)) {
+      return;
+    }
+
+    $weight = 10;
+    $form_state['default_values']['settings'][$plugin_type] = array();
+    $form['settings'][$plugin_type]['#tree'] = TRUE;
+    foreach ($plugins as $name => $plugin) {
+      $form_state['default_values']['settings'][$plugin_type][$name] = array();
+      if (empty($form_state['values']['settings'][$plugin_type][$name])) {
+        $form_state['values']['settings'][$plugin_type][$name] = array();
+      }
+      $form['settings'][$plugin_type][$name] = array(
+        '#title' => $plugin->title,
+        '#group' => 'settings_tabs',
+        '#type' => 'fieldset',
+        '#tree' => TRUE,
+        '#visible' => TRUE,
+        '#collapsible' => TRUE,
+        '#collapsed' => TRUE,
+        '#weight' => $weight++,
+      );
+
+      $defaults = $plugin->getDefaultSettings($job);
+
+      $form_state['default_values']['settings'][$plugin_type][$name] += $defaults;
+      $form_state['values']['settings'][$plugin_type][$name] += ultimate_cron_blank_values($defaults);
+
+      $plugin->settingsForm($form, $form_state, $job);
+      if (empty($form['settings'][$plugin_type][$name]['no_settings'])) {
+        $plugin->fallbackalize(
+          $form['settings'][$plugin_type][$name],
+          $form_state['values']['settings'][$plugin_type][$name],
+          $form_state['default_values']['settings'][$plugin_type][$name],
+          FALSE
+        );
+      }
+      else {
+        unset($form['settings'][$plugin_type][$name]);
+      }
+    }
+  }
+
+  /**
+   * Job settings form validate handler.
+   */
+  static public function jobSettingsFormValidate($form, &$form_state, $plugin_type, $job = NULL) {
+    $plugins = ultimate_cron_plugin_load_all($plugin_type);
+    foreach ($plugins as $plugin) {
+      if ($plugin->isValid($job)) {
+        $plugin->settingsFormValidate($form, $form_state, $job);
+      }
+    }
+  }
+
+  /**
+   * Job settings form submit handler.
+   */
+  static public function jobSettingsFormSubmit($form, &$form_state, $plugin_type, $job = NULL) {
+    $plugins = ultimate_cron_plugin_load_all($plugin_type);
+    foreach ($plugins as $name => $plugin) {
+      if ($plugin->isValid($job)) {
+        $plugin->settingsFormSubmit($form, $form_state, $job);
+
+        // Weed out blank values that have fallbacks.
+        $elements = & $form['settings'][$plugin_type][$name];
+        $values = & $form_state['values']['settings'][$plugin_type][$name];
+        $plugin->cleanForm($elements, $values, array(
+          'settings',
+          $plugin_type,
+          $name
+        ));
+      }
+      else {
+        unset($form_state['values']['settings'][$plugin_type][$name]);
+      }
+    }
+  }
+}
diff --git a/web/modules/ultimate_cron/src/CronRule.php b/web/modules/ultimate_cron/src/CronRule.php
new file mode 100644
index 0000000000000000000000000000000000000000..8b0ecab1214cfb45f568bfe6eca2d82ef5146c3f
--- /dev/null
+++ b/web/modules/ultimate_cron/src/CronRule.php
@@ -0,0 +1,475 @@
+<?php
+
+namespace Drupal\ultimate_cron;
+
+class CronRule {
+
+  public $rule = NULL;
+  public $time = NULL;
+  public $skew = 0;
+
+  public $allow_shorthand = FALSE;
+  private static $ranges = array(
+    'minutes' => array(0, 59),
+    'hours' => array(0, 23),
+    'days' => array(1, 31),
+    'months' => array(1, 12),
+    'weekdays' => array(0, 6),
+  );
+
+  private $type = NULL;
+  static private $cache = array();
+  static private $instances = array();
+  private $last_run;
+  private $next_run;
+
+  /**
+   * Factory method for CronRule instance.
+   *
+   * @param string $rule
+   *   The crontab rule to use.
+   * @param integer $time
+   *   The time to test against.
+   * @param integer $skew
+   *   Skew for @ flag.
+   *
+   * @return CronRule
+   *   CronRule object.
+   */
+  static public function factory($rule, $time = NULL, $skew = 0) {
+    if (strpos($rule, '@') === FALSE) {
+      $skew = 0;
+    }
+
+    $time = isset($time) ? (int) $time : time();
+
+    $key = "$rule:$time:$skew";
+    if (isset(self::$instances[$key])) {
+      return self::$instances[$key];
+    }
+    self::$instances[$key] = new CronRule($rule, $time, $skew);
+    return self::$instances[$key];
+  }
+
+  /**
+   * Constructor.
+   *
+   * @param string $rule
+   *   The crontab rule to use.
+   * @param integer $time
+   *   The time to test against.
+   * @param integer $skew
+   *   Skew for @ flag.
+   */
+  public function __construct($rule, $time, $skew) {
+    $this->rule = $rule;
+    $this->time = $time;
+    $this->skew = $skew;
+  }
+
+  /**
+   * Expand interval from cronrule part.
+   *
+   * @param array $matches
+   *   (e.g. 4-43/5+2).
+   *   array of matches:
+   *     [1] = lower
+   *     [2] = upper
+   *     [5] = step
+   *     [7] = offset
+   *
+   * @return string
+   *   Comma-separated list of values.
+   */
+  public function expandInterval($matches) {
+    $result = array();
+
+    $lower = $matches[1];
+    $upper = isset($matches[2]) && $matches[2] != '' ? $matches[2] : $lower;
+    $step = isset($matches[5]) && $matches[5] != '' ? $matches[5] : 1;
+    $offset = isset($matches[7]) && $matches[7] != '' ? $matches[7] : 0;
+
+    if ($step <= 0) {
+      return '';
+    }
+
+    $step = ($step > 0) ? $step : 1;
+    for ($i = $lower; $i <= $upper; $i += $step) {
+      $result[] = ($i + $offset) % (self::$ranges[$this->type][1] + 1);
+    }
+    return implode(',', $result);
+  }
+
+  /**
+   * Prepare part
+   *
+   * @param string $part
+   *   The part.
+   * @param string $type
+   *   Type of part.
+   *
+   * @return string
+   *   The prepared part.
+   */
+  public function preparePart($part, $type) {
+    $max = implode('-', self::$ranges[$type]);
+    $part = str_replace("*", $max, $part);
+    $part = str_replace("@", $this->skew % (self::$ranges[$type][1] + 1), $part);
+    return $part;
+  }
+
+  /**
+   * Expand range from cronrule part.
+   *
+   * @param string $rule
+   *   Cronrule part, e.g.: 1,2,3,4-43/5.
+   * @param string $type
+   *   Type of range (minutes, hours, etc.)
+   *
+   * @return array
+   *   Valid values for this range.
+   */
+  public function expandRange($part, $type) {
+    $this->type = $type;
+    $part = preg_replace_callback('!(\d+)(?:-(\d+))?((/(\d+))?(\+(\d+))?)?!', array(
+      $this,
+      'expandInterval'
+    ), $part);
+    if (!preg_match('/([^0-9\,])/', $part)) {
+      $part = explode(',', $part);
+      rsort($part);
+    }
+    else {
+      $part = array();
+    }
+    return $part;
+  }
+
+  /**
+   * Pre process rule.
+   *
+   * @param array &$parts
+   *   Parts of rules to pre process.
+   */
+  public function preProcessRule(&$parts) {
+    // Allow JAN-DEC.
+    $months = array(
+      1 => 'jan',
+      'feb',
+      'mar',
+      'apr',
+      'may',
+      'jun',
+      'jul',
+      'aug',
+      'sep',
+      'oct',
+      'nov',
+      'dec'
+    );
+    $parts[3] = strtr(strtolower($parts[3]), array_flip($months));
+
+    // Allow SUN-SUN.
+    $days = array('sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat');
+    $parts[4] = strtr(strtolower($parts[4]), array_flip($days));
+    $parts[4] = str_replace('7', '0', $parts[4]);
+
+    $i = 0;
+    foreach (self::$ranges as $type => $range) {
+      $part =& $parts[$i++];
+      $max = implode('-', $range);
+      $part = str_replace("*", $max, $part);
+      $part = str_replace("@", $this->skew % ($range[1] + 1), $part);
+    }
+  }
+
+  /**
+   * Post process rule.
+   *
+   * @param array &$intervals
+   *   Intervals to post process.
+   */
+  public function postProcessRule(&$intervals) {
+  }
+
+  /**
+   * Generate regex rules.
+   *
+   * @return array
+   *   Date and time regular expression for mathing rule.
+   */
+  public function getIntervals() {
+    if (isset(self::$cache['intervals'][$this->rule][$this->skew])) {
+      return self::$cache['intervals'][$this->rule][$this->skew];
+    }
+
+    $parts = preg_split('/\s+/', $this->rule);
+    if ($this->allow_shorthand) {
+      // Allow short rules by appending wildcards?
+      $parts += array('*', '*', '*', '*', '*');
+      $parts = array_slice($parts, 0, 5);
+    }
+    if (count($parts) != 5) {
+      return self::$cache['intervals'][$this->rule][$this->skew] = FALSE;
+    }
+    $this->preProcessRule($parts);
+    $intervals = array();
+    $intervals['parts'] = $parts;
+    $intervals['minutes'] = $this->expandRange($parts[0], 'minutes');
+    if (empty($intervals['minutes'])) {
+      return self::$cache['intervals'][$this->rule][$this->skew] = FALSE;
+    }
+    $intervals['hours'] = $this->expandRange($parts[1], 'hours');
+    if (empty($intervals['hours'])) {
+      return self::$cache['intervals'][$this->rule][$this->skew] = FALSE;
+    }
+    $intervals['days'] = $this->expandRange($parts[2], 'days');
+    if (empty($intervals['days'])) {
+      return self::$cache['intervals'][$this->rule][$this->skew] = FALSE;
+    }
+    $intervals['months'] = $this->expandRange($parts[3], 'months');
+    if (empty($intervals['months'])) {
+      return self::$cache['intervals'][$this->rule][$this->skew] = FALSE;
+    }
+    $intervals['weekdays'] = $this->expandRange($parts[4], 'weekdays');
+    if (empty($intervals['weekdays'])) {
+      return self::$cache['intervals'][$this->rule][$this->skew] = FALSE;
+    }
+    $intervals['weekdays'] = array_flip($intervals['weekdays']);
+    $this->postProcessRule($intervals);
+
+    return self::$cache['intervals'][$this->rule][$this->skew] = $intervals;
+  }
+
+  /**
+   * Convert intervals back into crontab rule format.
+   *
+   * @param array $intervals
+   *   Intervals to convert.
+   *
+   * @return string
+   *   Crontab rule.
+   */
+  public function rebuildRule($intervals) {
+    return implode(' ', $intervals['parts']);
+  }
+
+  /**
+   * Parse rule. Run through parser expanding expression, and recombine into crontab syntax.
+   */
+  public function parseRule() {
+    if (isset($this->parsed)) {
+      return $this->parsed;
+    }
+    $this->parsed = $this->rebuildRule($this->getIntervals());
+    return $this->parsed;
+  }
+
+  /**
+   * Get last schedule time of rule in UNIX timestamp format.
+   *
+   * @return integer
+   *   UNIX timestamp of last schedule time.
+   */
+  public function getLastSchedule() {
+    if (isset($this->last_ran)) {
+      return $this->last_ran;
+    }
+
+    // Current time round to last minute.
+    $time = floor($this->time / 60) * 60;
+
+    // Generate regular expressions from rule.
+    $intervals = $this->getIntervals();
+    if ($intervals === FALSE) {
+      return FALSE;
+    }
+
+    // Get starting points.
+    $start_year = date('Y', $time);
+    // Go back max 28 years (leapyear * weekdays).
+    $end_year = $start_year - 28;
+    $start_month = date('n', $time);
+    $start_day = date('j', $time);
+    $start_hour = date('G', $time);
+    $start_minute = (int) date('i', $time);
+
+    // If both weekday and days are restricted, then use either or
+    // otherwise, use and ... when using or, we have to try out all the days in the month
+    // and not just to the ones restricted.
+    $check_weekday = count($intervals['weekdays']) != 7;
+    $check_both = $check_weekday && (count($intervals['days']) != 31);
+    $days = $check_both ? range(31, 1) : $intervals['days'];
+
+    // Find last date and time this rule was run.
+    for ($year = $start_year; $year > $end_year; $year--) {
+      foreach ($intervals['months'] as $month) {
+        if ($month < 1 || $month > 12) {
+          continue;
+        }
+        if ($year >= $start_year && $month > $start_month) {
+          continue;
+        }
+
+        foreach ($days as $day) {
+          if ($day < 1 || $day > 31) {
+            continue;
+          }
+          if ($year >= $start_year && $month >= $start_month && $day > $start_day) {
+            continue;
+          }
+          if (!checkdate($month, $day, $year)) {
+            continue;
+          }
+
+          // Check days and weekdays using and/or logic.
+          if ($check_weekday) {
+            $date_array = getdate(mktime(0, 0, 0, $month, $day, $year));
+            if ($check_both) {
+              if (
+                !in_array($day, $intervals['days']) &&
+                !isset($intervals['weekdays'][$date_array['wday']])
+              ) {
+                continue;
+              }
+            }
+            else {
+              if (!isset($intervals['weekdays'][$date_array['wday']])) {
+                continue;
+              }
+            }
+          }
+
+          if ($day != $start_day || $month != $start_month || $year != $start_year) {
+            $start_hour = 23;
+            $start_minute = 59;
+          }
+          foreach ($intervals['hours'] as $hour) {
+            if ($hour < 0 || $hour > 23) {
+              continue;
+            }
+            if ($hour > $start_hour) {
+              continue;
+            }
+            if ($hour < $start_hour) {
+              $start_minute = 59;
+            }
+            foreach ($intervals['minutes'] as $minute) {
+              if ($minute < 0 || $minute > 59) {
+                continue;
+              }
+              if ($minute > $start_minute) {
+                continue;
+              }
+              break 5;
+            }
+          }
+        }
+      }
+    }
+
+    // Create UNIX timestamp from derived date+time.
+    $this->last_ran = mktime($hour, $minute, 0, $month, $day, $year);
+
+    return $this->last_ran;
+  }
+
+  /**
+   * Get next schedule time of rule in UNIX timestamp format.
+   *
+   * @return integer
+   *   UNIX timestamp of next schedule time.
+   */
+  public function getNextSchedule() {
+    if (isset($this->next_run)) {
+      return $this->next_run;
+    }
+
+    $intervals = $this->getIntervals();
+    $last_schedule = $this->getLastSchedule();
+
+    $next['minutes'] = (int) date('i', $last_schedule);
+    $next['hours'] = date('G', $last_schedule);
+    $next['days'] = date('j', $last_schedule);
+    $next['months'] = date('n', $last_schedule);
+    $year = date('Y', $last_schedule);
+
+    $check_weekday = count($intervals['weekdays']) != 7;
+    $check_both = $check_weekday && (count($intervals['days']) != 31) ? TRUE : FALSE;
+    $days = $intervals['days'];
+    $intervals['days'] = $check_both ? range(31, 1) : $intervals['days'];
+
+    $ranges = self::$ranges;
+    unset($ranges['weekdays']);
+
+    foreach ($ranges as $type => $range) {
+      $found = array_keys($intervals[$type], $next[$type]);
+      $idx[$type] = reset($found);
+    }
+
+    reset($ranges);
+    while ($type = key($ranges)) {
+      next($ranges);
+      $idx[$type]--;
+      if ($idx[$type] < 0) {
+        $found = array_keys($intervals[$type], end($intervals[$type]));
+        $idx[$type] = reset($found);
+        if ($type == 'months') {
+          $year--;
+          reset($ranges);
+        }
+        continue;
+      }
+
+      if ($type == 'days' && $check_weekday) {
+        // Check days and weekdays using and/or logic.
+        $date_array = getdate(mktime(
+          $intervals['hours'][$idx['hours']],
+          $intervals['minutes'][$idx['minutes']],
+          0,
+          $intervals['months'][$idx['months']],
+          $intervals['days'][$idx['days']],
+          $year
+        ));
+        if ($check_both) {
+          if (
+            !in_array($intervals['days'][$idx['days']], $days) &&
+            !isset($intervals['weekdays'][$date_array['wday']])
+          ) {
+            reset($ranges);
+            next($ranges);
+            next($ranges);
+            continue;
+          }
+        }
+        else {
+          if (!isset($intervals['weekdays'][$date_array['wday']])) {
+            reset($ranges);
+            next($ranges);
+            next($ranges);
+            continue;
+          }
+        }
+      }
+
+      break;
+    }
+
+    $this->next_run = mktime(
+      $intervals['hours'][$idx['hours']],
+      $intervals['minutes'][$idx['minutes']],
+      0,
+      $intervals['months'][$idx['months']],
+      $intervals['days'][$idx['days']],
+      $year
+    );
+    return $this->next_run;
+  }
+
+  /**
+   * Check if a rule is valid.
+   */
+  public function isValid() {
+    return $this->getLastSchedule() === FALSE ? FALSE : TRUE;
+  }
+}
diff --git a/web/modules/ultimate_cron/src/CronSignal.php b/web/modules/ultimate_cron/src/CronSignal.php
new file mode 100644
index 0000000000000000000000000000000000000000..344669e996694df5176af4cba35e0e7613af887d
--- /dev/null
+++ b/web/modules/ultimate_cron/src/CronSignal.php
@@ -0,0 +1,109 @@
+<?php
+
+namespace Drupal\ultimate_cron;
+
+class CronSignal {
+  /**
+   * Get a signal without claiming it.
+   *
+   * @param string $name
+   *   The name of the job.
+   * @param string $signal
+   *   The name of the signal.
+   *
+   * @return string
+   *   The signal if any.
+   */
+  static public function peek($name, $signal) {
+    $database = \Drupal::service('ultimate_cron.database_factory');
+    return $database->select('ultimate_cron_signal', 's')
+      ->fields('s', array('job_name'))
+      ->condition('job_name', $name)
+      ->condition('signal_name', $signal)
+      ->condition('claimed', 0)
+      ->execute()
+      ->fetchField();
+  }
+
+  /**
+   * Get and claim signal.
+   *
+   * @param string $name
+   *   The name of the job.
+   * @param string $signal
+   *   The name of the signal.
+   *
+   * @return string
+   *   The signal if any. If a signal is found, it is "claimed" and therefore
+   *   cannot be claimed again.
+   */
+  static public function get($name, $signal) {
+    $database = \Drupal::service('ultimate_cron.database_factory');
+    $claimed = $database->update('ultimate_cron_signal')
+      ->fields(array('claimed' => 1))
+      ->condition('job_name', $name)
+      ->condition('signal_name', $signal)
+      ->condition('claimed', 0)
+      ->execute();
+    if ($claimed) {
+      $database->delete('ultimate_cron_signal')
+        ->condition('job_name', $name)
+        ->condition('signal_name', $signal)
+        ->condition('claimed', 1)
+        ->execute();
+    }
+    return $claimed;
+  }
+
+  /**
+   * Set signal.
+   *
+   * @param string $name
+   *   The name of the job.
+   * @param string $signal
+   *   The name of the signal.
+   *
+   * @return boolean
+   *   TRUE if the signal was set.
+   * @throws \Exception
+   */
+  static public function set($name, $signal) {
+    $database = \Drupal::service('ultimate_cron.database_factory');
+    return $database->merge('ultimate_cron_signal')
+      ->keys(array(
+        'job_name' => $name,
+        'signal_name' => $signal,
+      ))
+      ->fields(array('claimed' => 0))
+      ->execute();
+  }
+
+  /**
+   * Clear signal.
+   *
+   * @param string $name
+   *   The name of the job.
+   * @param string $signal
+   *   The name of the signal.
+   */
+  static public function clear($name, $signal) {
+    $database = \Drupal::service('ultimate_cron.database_factory');
+    $database->delete('ultimate_cron_signal')
+      ->condition('job_name', $name)
+      ->condition('signal_name', $signal)
+      ->execute();
+  }
+
+  /**
+   * Clear signals.
+   *
+   * @param string $name
+   *   The name of the job.
+   */
+  static public function flush($name) {
+    $database = \Drupal::service('ultimate_cron.database_factory');
+    $database->delete('ultimate_cron_signal')
+      ->condition('job_name', $name)
+      ->execute();
+  }
+}
diff --git a/web/modules/ultimate_cron/src/Entity/CronJob.php b/web/modules/ultimate_cron/src/Entity/CronJob.php
new file mode 100644
index 0000000000000000000000000000000000000000..837e8775d3d872f20a66d210ed0acb0805c083d6
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Entity/CronJob.php
@@ -0,0 +1,888 @@
+<?php
+
+namespace Drupal\ultimate_cron\Entity;
+
+use Drupal\Core\Config\Entity\ConfigEntityBase;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\Core\Session\AnonymousUserSession;
+use Drupal\Core\Utility\Error;
+use Drupal\ultimate_cron\CronJobInterface;
+
+/**
+ * Class for handling cron jobs.
+ *
+ * This class represents the jobs available in the system.
+ *
+ * @ConfigEntityType(
+ *   id = "ultimate_cron_job",
+ *   label = @Translation("Cron Job"),
+ *   handlers = {
+ *     "access" = "Drupal\ultimate_cron\CronJobAccessControlHandler",
+ *     "list_builder" = "Drupal\ultimate_cron\CronJobListBuilder",
+ *     "form" = {
+ *       "default" = "Drupal\ultimate_cron\Form\CronJobForm",
+ *       "delete" = "\Drupal\Core\Entity\EntityDeleteForm",
+ *       "disable" = "Drupal\ultimate_cron\Form\CronJobDisableForm",
+ *       "enable" = "Drupal\ultimate_cron\Form\CronJobEnableForm",
+ *     }
+ *   },
+ *   config_prefix = "job",
+ *   admin_permission = "administer ultimate cron",
+ *   entity_keys = {
+ *     "id" = "id",
+ *     "label" = "title",
+ *     "status" = "status",
+ *     "weight" = "weight",
+ *   },
+ *   config_export = {
+ *     "title",
+ *     "id",
+ *     "status",
+ *     "weight",
+ *     "module",
+ *     "callback",
+ *     "scheduler",
+ *     "launcher",
+ *     "logger",
+ *   },
+ *   links = {
+ *     "edit-form" = "/admin/config/system/cron/jobs/manage/{ultimate_cron_job}",
+ *     "delete-form" = "/admin/config/system/cron/jobs/manage/{ultimate_cron_job}/delete",
+ *     "collection" = "/admin/config/system/cron/jobs",
+ *     "run" = "/admin/config/system/cron/jobs/{ultimate_cron_job}/run",
+ *     "disable" = "/admin/config/system/cron/jobs/manage/{ultimate_cron_job}/disable",
+ *     "enable" = "/admin/config/system/cron/jobs/manage/{ultimate_cron_job}/enable",
+ *     "logs" = "/admin/config/system/cron/jobs/logs/{ultimate_cron_job}",
+ *     "unlock" = "/admin/config/system/cron/jobs/{ultimate_cron_job}/unlock",
+ *   }
+ * )
+ */
+class CronJob extends ConfigEntityBase implements CronJobInterface {
+  static public $signals;
+  static public $currentJob;
+  public $progressUpdated = 0;
+  public $settings;
+
+  /**
+   * @var int
+   */
+  protected $id;
+
+  /**
+   * @var int
+   */
+  protected $uuid;
+
+  /**
+   * @var bool
+   */
+  protected $status = TRUE;
+
+  /**
+   * The weight.
+   *
+   * @var int
+   */
+  protected $weight = 0;
+
+  /**
+   * @var string
+   */
+  protected $title;
+
+  /**
+   * @var string
+   */
+  protected $callback;
+
+  /**
+   * @var string
+   */
+  protected $module;
+
+  /**
+   * @var array
+   */
+  protected $scheduler = array('id' => 'simple');
+
+  /**
+   * @var array
+   */
+  protected $launcher = array('id' => 'serial');
+
+  /**
+   * @var array
+   */
+  protected $logger = array('id' => 'database');
+
+  /**
+   * @var \Drupal\ultimate_cron\CronPlugin
+   */
+  protected $plugins = [];
+
+  /**
+   * The class resolver.
+   *
+   * @var \Drupal\Core\DependencyInjection\ClassResolverInterface
+   */
+  protected $classResolver;
+
+  /**
+   * The module extension list service.
+   *
+   * @var \Drupal\Core\Extension\ModuleExtensionList
+   */
+  protected $moduleExtensionList;
+
+  /**
+   * CronJob constructor.
+   *
+   * @param array $values
+   * @param string $entity_type
+   */
+  public function __construct(array $values, $entity_type) {
+    parent::__construct($values, $entity_type);
+    $this->classResolver = \Drupal::service('class_resolver');
+    $this->moduleExtensionList = \Drupal::service('extension.list.module');
+
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function postSave(EntityStorageInterface $storage, $update = TRUE) {
+    parent::postSave($storage, $update);
+    if ($update && empty($this->dont_log)) {
+      $log = $this->startLog(uniqid($this->id(), TRUE), '', ULTIMATE_CRON_LOG_TYPE_ADMIN);
+      $log->log('Job modified by ' . $log->formatUser(), array(), RfcLogLevel::INFO);
+      $log->finish();
+    }
+  }
+
+  /**
+   * Set configuration for a given plugin type.
+   *
+   * @param string $plugin_type
+   *   launcher, logger or scheduler.
+   * @param array $configuration
+   *   The configuration array.
+   */
+  public function setConfiguration($plugin_type, array $configuration) {
+    $this->{$plugin_type}['configuration'] = $configuration;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function postDelete(EntityStorageInterface $storage, array $entities) {
+    foreach ($entities as $entity) {
+      if (empty($entity->dont_log)) {
+        /** @var \Drupal\ultimate_cron\Entity\CronJob $entity */
+        $log = $entity->startLog(uniqid($entity->id(), TRUE), 'modification', ULTIMATE_CRON_LOG_TYPE_ADMIN);
+        $log->log('Job deleted by ' . $log->formatUser(), array(), RfcLogLevel::INFO);
+        $log->finish();
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isValid() {
+    return is_callable($this->getCallback());
+  }
+
+  /**
+   * Get a signal without affecting it.
+   *
+   * @see UltimateCronSignal::peek()
+   */
+  public function peekSignal($signal) {
+    if (isset(self::$signals[$this->id()][$signal])) {
+      return TRUE;
+    }
+    $signal = \Drupal::service('ultimate_cron.signal');;
+    return $signal->peek($this->id(), $signal);
+  }
+
+  /**
+   * Get a signal and clear it if found.
+   *
+   * @see UltimateCronSignal::get()
+   */
+  public function getSignal($signal) {
+    if (isset(self::$signals[$this->id()][$signal])) {
+      unset(self::$signals[$this->id()][$signal]);
+      return TRUE;
+    }
+    $service = \Drupal::service('ultimate_cron.signal');;
+    return $service->get($this->id(), $signal);
+  }
+
+  /**
+   * Send a signal.
+   *
+   * @see UltimateCronSignal::set()
+   */
+  public function sendSignal($signal, $persist = FALSE) {
+    if ($persist) {
+      $signal = \Drupal::service('ultimate_cron.signal');;
+      $signal->set($this->id(), $signal);
+    }
+    else {
+      self::$signals[$this->id()][$signal] = TRUE;
+    }
+  }
+
+  /**
+   * Clear a signal.
+   *
+   * @see UltimateCronSignal::clear()
+   */
+  public function clearSignal($signal) {
+    unset(self::$signals[$this->id()][$signal]);
+    $signal = \Drupal::service('ultimate_cron.signal');;
+    $signal->clear($this->id(), $signal);
+  }
+
+  /**
+   * Send all signal for the job.
+   *
+   * @see UltimateCronSignal::flush()
+   */
+  public function clearSignals() {
+    unset(self::$signals[$this->id()]);
+    $signal = \Drupal::service('ultimate_cron.signal');;
+    $signal->flush($this->id());
+  }
+
+  /**
+   * Get job plugin.
+   *
+   * If no plugin name is provided current plugin of the specified type will
+   * be returned.
+   *
+   * @param string $plugin_type
+   *   Name of plugin type.
+   * @param string $name
+   *   (optional) The name of the plugin.
+   *
+   * @return mixed
+   *   Plugin instance of the specified type.
+   */
+  public function getPlugin($plugin_type, $name = NULL) {
+    if ($name) {
+      return ultimate_cron_plugin_load($plugin_type, $name);
+    }
+    // @todo: enable static cache, needs unset when values change.
+    //    if (isset($this->plugins[$plugin_type])) {
+    //      return $this->plugins[$plugin_type];
+    //    }
+    if ($name) {
+    }
+    elseif (!empty($this->{$plugin_type}['id'])) {
+      $name = $this->{$plugin_type}['id'];
+    }
+    else {
+      $name = $this->hook[$plugin_type]['name'];
+    }
+    /* @var \Drupal\Core\Plugin\DefaultPluginManager $manager */
+    $manager = \Drupal::service('plugin.manager.ultimate_cron.' . $plugin_type);
+    $this->plugins[$plugin_type] = $manager->createInstance($name, isset($this->{$plugin_type}['configuration']) ? $this->{$plugin_type}['configuration'] : array());
+    return $this->plugins[$plugin_type];
+  }
+
+  /**
+   * Gets this plugin's configuration.
+   *
+   * @param $plugin_type
+   *   The type of plugin.
+   * @return array
+   *   An array of this plugin's configuration.
+   */
+  public function getConfiguration($plugin_type) {
+    if (!isset($this->{$plugin_type}['configuration'])) {
+      $this->{$plugin_type}['configuration'] = $this->getPlugin($plugin_type)->defaultConfiguration();
+    }
+
+    return $this->{$plugin_type}['configuration'];
+  }
+
+  /**
+   * Signal page for plugins.
+   */
+  public function signal($item, $plugin_type, $plugin_name, $signal) {
+    $plugin = ultimate_cron_plugin_load($plugin_type, $plugin_name);
+    return $plugin->signal($item, $signal);
+  }
+
+  /**
+   * Invokes the jobs callback.
+   */
+  protected function invokeCallback() {
+    $callback = $this->getCallback();
+    return call_user_func($callback, $this);
+  }
+
+  /**
+   * Returns a callable for the given controller.
+   *
+   * @param string $callback
+   *   A callback string.
+   *
+   * @return mixed
+   *   A PHP callable.
+   *
+   * @throws \InvalidArgumentException
+   *   If the callback class does not exist.
+   */
+  protected function resolveCallback($callback) {
+    // Controller in the service:method notation.
+    $count = substr_count($callback, ':');
+    if ($count == 1) {
+      list($class_or_service, $method) = explode(':', $callback, 2);
+    }
+    // Controller in the class::method notation.
+    elseif (strpos($callback, '::') !== FALSE) {
+      list($class_or_service, $method) = explode('::', $callback, 2);
+    }
+    else {
+      return $callback;
+    }
+
+    $callback = $this->classResolver->getInstanceFromDefinition($class_or_service);
+
+    return array($callback, $method);
+  }
+
+  /**
+   * Check job schedule.
+   */
+  public function isScheduled() {
+    \Drupal::moduleHandler()->invokeAll('cron_pre_schedule', array($this));
+    $result = $this->status() && !$this->isLocked() && $this->getPlugin('scheduler')
+        ->isScheduled($this);
+    \Drupal::moduleHandler()->invokeAll('cron_post_schedule', array($this));
+    return $result;
+  }
+
+  /**
+   * Check if job is behind its schedule.
+   *
+   * @return bool|int
+   *   FALSE if job is behind its schedule or number of seconds behind.
+   */
+  public function isBehindSchedule() {
+    return $this->getPlugin('scheduler')->isBehind($this);
+  }
+
+  /**
+   * Lock job.
+   */
+  public function lock() {
+    $launcher = $this->getPlugin('launcher');
+    $lock_id = $launcher->lock($this);
+    if (!$lock_id) {
+      \Drupal::logger('ultimate_cron')->error('Could not get lock for job @name', array(
+        '@name' => $this->id(),
+      ));
+      return FALSE;
+    }
+    $this->sendMessage('lock', array(
+      'lock_id' => $lock_id,
+    ));
+    return $lock_id;
+  }
+
+  /**
+   * Unlock job.
+   *
+   * @param string $lock_id
+   *   The lock id to unlock.
+   * @param bool $manual
+   *   Whether or not this is a manual unlock.
+   */
+  public function unlock($lock_id = NULL, $manual = FALSE) {
+    $result = NULL;
+    if (!$lock_id) {
+      $lock_id = $this->isLocked();
+    }
+    if ($lock_id) {
+      $result = $this->getPlugin('launcher')->unlock($lock_id, $manual);
+    }
+    $this->sendMessage('unlock', array(
+      'lock_id' => $lock_id,
+    ));
+    return $result;
+  }
+
+  /**
+   * Get locked state of job.
+   */
+  public function isLocked() {
+    return $this->getPlugin('launcher')->isLocked($this);
+  }
+
+  /**
+   * Get locked state for multiple jobs.
+   *
+   * @param array $jobs
+   *   Jobs to check locks for.
+   */
+  static public function isLockedMultiple($jobs) {
+    $launchers = array();
+    foreach ($jobs as $job) {
+      $launchers[$job->getPlugin('launcher')->name][$job->id()] = $job;
+    }
+    $locked = array();
+    foreach ($launchers as $launcher => $jobs) {
+      $locked += ultimate_cron_plugin_load('launcher', $launcher)->isLockedMultiple($jobs);
+    }
+    return $locked;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function run($init_message = NULL) {
+    if (!$init_message) {
+      $init_message = t('Launched manually');
+    }
+
+    $lock_id = $this->lock();
+    if (!$lock_id) {
+      return FALSE;
+    }
+    $log_entry = $this->startLog($lock_id, $init_message);
+
+    $accountSwitcher = \Drupal::service('account_switcher');
+
+    try {
+      $this->clearSignals();
+      $this->initializeProgress();
+      \Drupal::moduleHandler()->invokeAll('cron_pre_run', array($this));
+
+      // Force the current user to anonymous to ensure consistent permissions
+      // on cron runs.
+      $accountSwitcher->switchTo(new AnonymousUserSession());
+
+      self::$currentJob = $this;
+      $this->invokeCallback();
+    }
+    catch (\Error $e) {
+      // PHP 7 throws Error objects in case of a fatal error. It will also call
+      // the finally block below and close the log entry. Because of that,
+      // the global fatal error catching will not work and we have to log it
+      // explicitly here instead. The advantage is that this will not
+      // interrupt the process.
+      $variables = Error::decodeException($e);
+      unset($variables['backtrace']);
+      $log_entry->log('%type: @message in %function (line %line of %file).', $variables, RfcLogLevel::ERROR);
+      return FALSE;
+    }
+    catch (\Exception $e) {
+      $variables = Error::decodeException($e);
+      unset($variables['backtrace']);
+      $log_entry->log('%type: @message in %function (line %line of %file).', $variables, RfcLogLevel::ERROR);
+      return FALSE;
+    }
+    finally {
+      self::$currentJob = NULL;
+      \Drupal::moduleHandler()->invokeAll('cron_post_run', array($this));
+      $this->finishProgress();
+
+      // Restore original user account.
+      $accountSwitcher->switchBack();
+      $log_entry->finish();
+      $this->unlock($lock_id);
+    }
+    return TRUE;
+  }
+
+  /**
+   * Get log entries.
+   *
+   * @param int $limit
+   *   (optional) Number of log entries per page.
+   *
+   * @return \Drupal\ultimate_cron\Logger\LogEntry[]
+   *   Array of UltimateCronLogEntry objects.
+   */
+  public function getLogEntries($log_types = ULTIMATE_CRON_LOG_TYPE_ALL, $limit = 10) {
+    $log_types = $log_types == ULTIMATE_CRON_LOG_TYPE_ALL ? _ultimate_cron_define_log_type_all() : $log_types;
+    return $this->getPlugin('logger')
+      ->getLogEntries($this->id(), $log_types, $limit);
+  }
+
+  /**
+   * Load log entry.
+   *
+   * @param string $lock_id
+   *   The lock id of the log entry.
+   *
+   * @return LogEntry
+   *   The log entry.
+   */
+  public function loadLogEntry($lock_id) {
+    return $this->getPlugin('logger')->load($this->id(), $lock_id);
+  }
+
+  /**
+   * Load latest log.
+   *
+   * @return LogEntry
+   *   The latest log entry for this job.
+   */
+  public function loadLatestLogEntry($log_types = array(ULTIMATE_CRON_LOG_TYPE_NORMAL)) {
+    return $this->getPlugin('logger')->load($this->id(), NULL, $log_types);
+  }
+
+  /**
+   * Load latest log entries.
+   *
+   * @param array $jobs
+   *   Jobs to load log entries for.
+   *
+   * @return array
+   *   Array of UltimateCronLogEntry objects.
+   */
+  static public function loadLatestLogEntries($jobs, $log_types = array(ULTIMATE_CRON_LOG_TYPE_NORMAL)) {
+    $loggers = array();
+    foreach ($jobs as $job) {
+      $loggers[$job->getPlugin('logger')->name][$job->id()] = $job;
+    }
+    $log_entries = array();
+    foreach ($loggers as $logger => $jobs) {
+      $log_entries += ultimate_cron_plugin_load('logger', $logger)->loadLatestLogEntries($jobs, $log_types);
+    }
+    return $log_entries;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function startLog($lock_id, $init_message = '', $log_type = ULTIMATE_CRON_LOG_TYPE_NORMAL) {
+    $logger = $this->getPlugin('logger');
+    $log_entry = $logger->createEntry($this->id(), $lock_id, $init_message, $log_type);
+    \Drupal::service('logger.ultimate_cron')->catchMessages($log_entry);
+    return $log_entry;
+  }
+
+  /**
+   * Resume a previosly saved log.
+   *
+   * @param string $lock_id
+   *   The lock id of the log to resume.
+   *
+   * @return LogEntry
+   *   The log entry object.
+   */
+  public function resumeLog($lock_id) {
+    $logger = $this->getPlugin('logger');
+    $log_entry = $logger->load($this->id(), $lock_id);
+    $log_entry->finished = FALSE;
+    \Drupal::service('logger.ultimate_cron')->catchMessages($log_entry);
+    return $log_entry;
+  }
+
+  /**
+   * Get module name for this job.
+   */
+  public function getModuleName() {
+    static $names = array();
+    if (!isset($names[$this->module])) {
+      $info = $this->moduleExtensionList->getExtensionInfo($this->module);
+      $names[$this->module] = $info && !empty($info['name']) ? $info['name'] : $this->module;
+    }
+    return $names[$this->module];
+  }
+
+  /**
+   * Get module description for this job.
+   */
+  public function getModuleDescription() {
+    static $descs = array();
+    if (!isset($descs[$this->module])) {
+      $info = $this->moduleExtensionList->getExtensionInfo($this->module);
+      $descs[$this->module] = $info && !empty($info['description']) ? $info['description'] : '';
+    }
+    return $descs[$this->module];
+  }
+
+  /**
+   * Initialize progress.
+   */
+  public function initializeProgress() {
+    return $this->getPlugin('launcher')->initializeProgress($this);
+  }
+
+  /**
+   * Finish progress.
+   */
+  public function finishProgress() {
+    return $this->getPlugin('launcher')->finishProgress($this);
+  }
+
+  /**
+   * Get job progress.
+   *
+   * @return float
+   *   The progress of this job.
+   */
+  public function getProgress() {
+    return $this->getPlugin('launcher')->getProgress($this);
+  }
+
+  /**
+   * Get multiple job progresses.
+   *
+   * @param array $jobs
+   *   Jobs to get progress for.
+   *
+   * @return array
+   *   Progress of jobs, keyed by job name.
+   */
+  static public function getProgressMultiple($jobs) {
+    $launchers = array();
+    foreach ($jobs as $job) {
+      $launchers[$job->getPlugin('launcher')->name][$job->id()] = $job;
+    }
+    $progresses = array();
+    foreach ($launchers as $launcher => $jobs) {
+      $progresses += ultimate_cron_plugin_load('launcher', $launcher)->getProgressMultiple($jobs);
+    }
+    return $progresses;
+  }
+
+  /**
+   * Set job progress.
+   *
+   * @param float $progress
+   *   The progress (0 - 1).
+   */
+  public function setProgress($progress) {
+    if ($this->getPlugin('launcher')->setProgress($this, $progress)) {
+      $this->sendMessage('setProgress', array(
+        'progress' => $progress,
+      ));
+      return TRUE;
+    }
+    return FALSE;
+  }
+
+  /**
+   * Format progress.
+   *
+   * @param float $progress
+   *   (optional) The progress to format. Uses the progress on the object
+   *              if not specified.
+   *
+   * @return string
+   *   Formatted progress.
+   */
+  public function formatProgress($progress = NULL) {
+    if (!isset($progress)) {
+      $progress = isset($this->progress) ? $this->progress : $this->getProgress();
+    }
+    return $this->getPlugin('launcher')->formatProgress($this, $progress);
+  }
+
+  /**
+   * Get a "unique" id for a job.
+   */
+  public function getUniqueID() {
+    return isset($this->ids[$this->id()]) ? $this->ids[$this->id()] : $this->ids[$this->id()] = hexdec(substr(sha1($this->id()), -8));
+  }
+
+  /**
+   * Send a nodejs message.
+   *
+   * @param string $action
+   *   The action performed.
+   * @param array $data
+   *   Data blob for the given action.
+   */
+  public function sendMessage($action, $data = array()) {
+    // @TODO: Nodejs integration has not been ported to 8.x yet.
+    if (FALSE && \Drupal::moduleHandler()->moduleExists('nodejs')) {
+      $settings = ultimate_cron_plugin_load('settings', 'general')->getDefaultSettings();
+      if (empty($settings['nodejs'])) {
+        return;
+      }
+
+      $elements = array();
+
+      $build = clone $this;
+
+      $cell_idxs = array();
+
+      switch ($action) {
+        case 'lock':
+          $logger = $build->getPlugin('logger');
+          if (empty($data['log_entry'])) {
+            $build->lock_id = $data['lock_id'];
+            $build->log_entry = $logger->factoryLogEntry($build->name);
+            $build->log_entry->setData(array(
+              'lid' => $data['lock_id'],
+              'start_time' => microtime(TRUE),
+            ));
+          }
+          else {
+            $build->log_entry = $data['log_entry'];
+          }
+          $cell_idxs = array(
+            'tr#' . $build->name . ' .ctools-export-ui-start-time' => 3,
+            'tr#' . $build->name . ' .ctools-export-ui-duration' => 4,
+            'tr#' . $build->name . ' .ctools-export-ui-status' => 5,
+            'tr#' . $build->name . ' .ctools-export-ui-operations' => 7,
+          );
+          break;
+
+        case 'unlock':
+          $build->log_entry = $build->loadLogEntry($data['lock_id']);
+          $build->lock_id = FALSE;
+          $cell_idxs = array(
+            'tr#' . $build->name . ' .ctools-export-ui-start-time' => 3,
+            'tr#' . $build->name . ' .ctools-export-ui-duration' => 4,
+            'tr#' . $build->name . ' .ctools-export-ui-status' => 5,
+            'tr#' . $build->name . ' .ctools-export-ui-operations' => 7,
+          );
+          break;
+
+        case 'setProgress':
+          $build->lock_id = $build->isLocked();
+          $build->log_entry = $build->loadLogEntry($build->lock_id);
+          $cell_idxs = array(
+            'tr#' . $build->name . ' .ctools-export-ui-start-time' => 3,
+            'tr#' . $build->name . ' .ctools-export-ui-duration' => 4,
+            'tr#' . $build->name . ' .ctools-export-ui-status' => 5,
+          );
+          break;
+      }
+      $cells = $build->rebuild_ctools_export_ui_table_row();
+      foreach ($cell_idxs as $selector => $cell_idx) {
+        $elements[$selector] = $cells[$cell_idx];
+      }
+
+      $message = (object) array(
+        'channel' => 'ultimate_cron',
+        'data' => (object) array(
+          'action' => $action,
+          'job' => $build,
+          'timestamp' => microtime(TRUE),
+          'elements' => $elements,
+        ),
+        'callback' => 'nodejsUltimateCron',
+      );
+      nodejs_send_content_channel_message($message);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function calculateDependencies() {
+    parent::calculateDependencies();
+
+    $this->addDependency('module', $this->getModule());
+
+    return $this->dependencies;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getTitle() {
+    return $this->title;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCallback() {
+    if (is_callable($this->callback)) {
+      return $this->callback;
+    }
+    else {
+      return $this->resolveCallback($this->callback);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getModule() {
+    return $this->module;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getSchedulerId() {
+    return $this->scheduler;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getLauncherId() {
+    return $this->launcher['id'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getLoggerId() {
+    return $this->logger['id'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setTitle($title) {
+    $this->set('title', $title);
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setCallback($callback) {
+    $this->set('callback', $callback);
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setModule($module) {
+    $this->set('module', $module);
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setSchedulerId($scheduler_id) {
+    $this->scheduler['id'] = $scheduler_id;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setLauncherId($launcher_id) {
+    $this->launcher['id'] = $launcher_id;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setLoggerId($logger_id) {
+    $this->logger['id'] = $logger_id;
+    return $this;
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/Form/CronJobDisableForm.php b/web/modules/ultimate_cron/src/Form/CronJobDisableForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..6eacf01a29eca173fc691780ec7427c44aaed48c
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Form/CronJobDisableForm.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Drupal\ultimate_cron\Form;
+
+use Drupal\Core\Entity\EntityConfirmFormBase;
+use Drupal\Core\Form\FormStateInterface;
+
+
+class CronJobDisableForm extends EntityConfirmFormBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getQuestion() {
+    return $this->t('Do you really want to disable @cronjob_id cron job?', array(
+      '@cronjob_id' => $this->getEntity()->label(),
+    ));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDescription() {
+    return $this->t('This cron job will no longer be executed.');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCancelUrl() {
+    return $this->getEntity()->toUrl('collection');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConfirmText() {
+    return $this->t('Disable');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $this->entity->disable()->save();
+    $this->messenger()
+      ->addStatus($this->t('Disabled cron job %cronjob.', ['%cronjob' => $this->entity->label()]));
+    $form_state->setRedirectUrl($this->getCancelUrl());
+  }
+  
+}
diff --git a/web/modules/ultimate_cron/src/Form/CronJobEnableForm.php b/web/modules/ultimate_cron/src/Form/CronJobEnableForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..16b23c63cfb2f04cf9b5c89bc7ce6d5440b57572
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Form/CronJobEnableForm.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Drupal\ultimate_cron\Form;
+
+use Drupal\Core\Entity\EntityConfirmFormBase;
+use Drupal\Core\Form\FormStateInterface;
+
+
+class CronJobEnableForm extends EntityConfirmFormBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getQuestion() {
+    return $this->t('Do you really want to enable @cronjob_id cron job?', array(
+      '@cronjob_id' => $this->getEntity()->label(),
+    ));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDescription() {
+    return $this->t('This cron job will be executed again.');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCancelUrl() {
+    return $this->getEntity()->toUrl('collection');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConfirmText() {
+    return $this->t('Enable');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $this->entity->enable()->save();
+    $this->messenger()
+      ->addStatus($this->t('Enabled cron job %cronjob.', ['%cronjob' => $this->entity->label()]));
+    $form_state->setRedirectUrl($this->getCancelUrl());
+  }
+  
+}
diff --git a/web/modules/ultimate_cron/src/Form/CronJobForm.php b/web/modules/ultimate_cron/src/Form/CronJobForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..9819bd176b8e0a30c93533c03614249229634d80
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Form/CronJobForm.php
@@ -0,0 +1,174 @@
+<?php
+
+namespace Drupal\ultimate_cron\Form;
+
+use Drupal\Core\Entity\EntityForm;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\ultimate_cron\CronPlugin;
+use Drupal\ultimate_cron\CronRule;
+
+/**
+ * Base form controller for cron job forms.
+ */
+class CronJobForm extends EntityForm {
+
+  protected $selected_option;
+
+  /**
+   * @var \Drupal\ultimate_cron\CronJobInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function form(array $form, FormStateInterface $form_state) {
+    $form = parent::form($form, $form_state);
+    /* @var \Drupal\ultimate_cron\Entity\CronJob $job */
+    $job = $this->entity;
+
+    $form['title'] = array(
+      '#title' => t('Title'),
+      '#description' => t('This will appear in the administrative interface to easily identify it.'),
+      '#type' => 'textfield',
+      '#default_value' => $job->getTitle(),
+    );
+
+    $form['id'] = array(
+      '#type' => 'machine_name',
+      '#default_value' => $job->id(),
+      '#machine_name' => array(
+        'exists' => '\Drupal\ultimate_cron\Entity\CronJob::load',
+        'source' => array('title'),
+      ),
+      '#disabled' => !$job->isNew(),
+    );
+
+    $form['status'] = array(
+      '#type' => 'checkbox',
+      '#title' => t('Enabled'),
+      '#default_value' => $job->status(),
+      '#description' => t('This checkbox enables the cron job. Disabled Cron jobs are not run.'),
+    );
+
+    $form['module_info'] = array(
+      '#type' => 'item',
+      '#title' => $this->t('Module'),
+      '#markup' => $job->getModule(),
+    );
+
+    $callback = $job->getCallback();
+    if (is_array($callback)) {
+      $callback = get_class($callback[0]) . '::' . $callback[1];
+    }
+
+    $form['callback_info'] = array(
+      '#type' => 'item',
+      '#title' => $this->t('Callback'),
+      '#markup' => $callback,
+    );
+
+    // Setup vertical tabs.
+    $form['settings_tabs'] = array(
+      '#type' => 'vertical_tabs',
+    );
+
+    // Load settings for each plugin in its own vertical tab.
+    $plugin_types = CronPlugin::getPluginTypes();
+    foreach ($plugin_types as $plugin_type => $plugin_label) {
+      /* @var \Drupal\Core\Plugin\DefaultPluginManager $manager */
+      $manager = \Drupal::service('plugin.manager.ultimate_cron.' . $plugin_type);
+      $plugins = $manager->getDefinitions();
+
+      $plugin_settings = $job->get($plugin_type);
+
+      // Generate select options.
+      $options = array();
+      foreach ($plugins as $value => $key) {
+        if (!empty($key['default']) && $key['default'] == TRUE) {
+          $options = array($value => t('@title (Default)', array('@title' => $key['title']))) + $options;
+        }
+        else {
+          $options[$value] = $key['title'];
+        }
+      }
+
+      $form[$plugin_type] = array(
+        '#type' => 'details',
+        '#title' => $plugin_label,
+        '#group' => 'settings_tabs',
+        '#tree' => TRUE,
+      );
+
+      $form[$plugin_type]['id'] = array(
+        '#type' => 'select',
+        '#title' => $plugin_label,
+        '#options' => $options,
+        '#plugin_type' => $plugin_type,
+        '#default_value' => $plugin_settings['id'],
+        '#description' => $this->t("Select which @plugin to use for this job.", array('@plugin' => $plugin_type)),
+        '#group' => 'settings_tabs',
+        '#executes_submit_callback' => TRUE,
+        '#ajax' => array(
+          'callback' => array($this, 'updateSelectedPluginType'),
+          'wrapper' => $plugin_type . '_settings',
+          'method' => 'replace',
+        ),
+        '#submit' => array('::submitForm', '::rebuild'),
+        '#limit_validation_errors' => array(array($plugin_type, 'id')),
+      );
+
+      $form[$plugin_type]['select'] = array(
+        '#type' => 'submit',
+        '#name' => $plugin_type . '_select',
+        '#value' => t('Select'),
+        '#submit' => array('::submitForm', '::rebuild'),
+        '#limit_validation_errors' => array(array($plugin_type, 'id')),
+        '#attributes' => array('class' => array('js-hide')),
+      );
+
+      $plugin = $job->getPlugin($plugin_type);
+      $temp_form = array();
+      $form[$plugin_type]['configuration'] = $plugin->buildConfigurationForm($temp_form, $form_state);
+      $form[$plugin_type]['configuration']['#prefix'] = '<div id="' . $plugin_type . '_settings' . '">';
+      $form[$plugin_type]['configuration']['#suffix'] = '</div>';
+    }
+
+    //$form['#attached']['js'][] = drupal_get_path('module', 'ultimate_cron') . '/js/ultimate_cron.job.js';
+
+    return $form;
+  }
+
+  public function updateSelectedPluginType(array $form, FormStateInterface $form_state) {
+    return $form[$form_state->getTriggeringElement()['#plugin_type']]['configuration'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rebuild(array $form, FormStateInterface $form_state) {
+    $form_state->setRebuild(TRUE);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateForm(array &$form, FormStateInterface $form_state) {
+    parent::validateForm($form, $form_state);
+
+    $this->entity->getPlugin('scheduler')->validateConfigurationForm($form, $form_state);
+
+  }
+
+  /**
+   * Overrides Drupal\Core\Entity\EntityForm::save().
+   */
+  public function save(array $form, FormStateInterface $form_state) {
+    parent::save($form, $form_state);
+    $this->messenger()
+      ->addStatus(t('job %label has been updated.', ['%label' => $this->entity->label()]));
+    $form_state->setRedirect('entity.ultimate_cron_job.collection');
+
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/Form/GeneralSettingsForm.php b/web/modules/ultimate_cron/src/Form/GeneralSettingsForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..b1f24b1dad18824b34cf1b34d614f05a95da22a7
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Form/GeneralSettingsForm.php
@@ -0,0 +1,223 @@
+<?php
+
+namespace Drupal\ultimate_cron\Form;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Datetime\DateFormatter;
+use Drupal\Core\Form\ConfigFormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\State\StateInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Form for general cron settings.
+ */
+class GeneralSettingsForm extends ConfigFormBase {
+
+  /**
+   * Stores the state storage service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * The cron service.
+   *
+   * @var \Drupal\Core\CronInterface
+   */
+  protected $cron;
+
+  /**
+   * The date formatter service.
+   *
+   * @var \Drupal\Core\Datetime\DateFormatter
+   */
+  protected $dateFormatter;
+
+  /**
+   * Constructs a GeneralSettingsForm object.
+   *
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The factory for configuration objects.
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state key value store.
+   * @param \Drupal\Core\Datetime\DateFormatter $date_formatter
+   *   The date formatter service.
+   */
+  public function __construct(ConfigFactoryInterface $config_factory, StateInterface $state, DateFormatter $date_formatter) {
+    parent::__construct($config_factory);
+    $this->state = $state;
+    $this->dateFormatter = $date_formatter;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('config.factory'),
+      $container->get('state'),
+      $container->get('date.formatter')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'ultimate_cron_general_settings';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getEditableConfigNames() {
+    return ['ultimate_cron.settings'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $config = $this->config('ultimate_cron.settings');
+    // Setup vertical tabs.
+    $form['settings_tabs'] = [
+      '#type' => 'vertical_tabs',
+    ];
+
+    // @todo enable this when supported again
+    $form['nodejs'] = array(
+      '#type' => 'checkbox',
+      '#title' => t('nodejs'),
+      '#default_value' => $config->get('nodejs'),
+      '#description' => t('Enable nodejs integration (Live reload on jobs page. Requires the nodejs module to be installed and configured).'),
+      '#fallback' => TRUE,
+
+      '#access' => FALSE,
+    );
+
+    // Queue settings. Visual hierarchy disabled since this is currently
+    // the only general settings group.
+    $form['queue'] = [
+      //'#type' => 'details',
+      //'#title' => 'queue',
+      //'#group' => 'settings_tabs',
+      '#tree' => TRUE,
+    ];
+
+    $form['queue']['enabled'] = array(
+      '#title' => t('Override cron queue processing'),
+      '#description' => t('If enabled, queue workers are exposed as cron jobs and can be configured separately. When disabled, the standard queue processing is used. <strong>This feature is currently experimental, do not enable unless you need it.</strong>'),
+      '#type' => 'checkbox',
+      '#default_value' => $config->get('queue.enabled'),
+      '#fallback' => TRUE,
+    );
+
+    $queue_states = array(
+      '#states' => array(
+        'visible' => array(':input[name="queue[enabled]"]' => array('checked' => TRUE)),
+      ),
+    );
+
+    $form['queue']['timeouts'] = array(
+      '#type' => 'fieldset',
+      '#title' => t('Timeouts'),
+    ) + $queue_states;
+    $form['queue']['timeouts']['lease_time'] = array(
+      '#title' => t("Queue lease time"),
+      '#type' => 'number',
+      '#default_value' => $config->get('queue.timeouts.lease_time'),
+      '#description' => t('Seconds to claim a cron queue item.'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+      '#min' => 0,
+      '#step' => 0.01
+    );
+    $form['queue']['timeouts']['time'] = array(
+      '#title' => t('Time'),
+      '#type' => 'number',
+      '#default_value' => $config->get('queue.timeouts.time'),
+      '#description' => t('Time in seconds to process items during a cron run.'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+      '#min' => 0,
+      '#step' => 0.01
+    );
+
+    $form['queue']['delays'] = array(
+      '#type' => 'fieldset',
+      '#title' => t('Delays'),
+    ) + $queue_states;
+    $form['queue']['delays']['empty_delay'] = array(
+      '#title' => t("Empty delay"),
+      '#type' => 'number',
+      '#default_value' => $config->get('queue.delays.empty_delay'),
+      '#description' => t('Seconds to delay processing of queue if queue is empty (0 = end job).'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+      '#min' => 0,
+      '#step' => 0.01
+    );
+    $form['queue']['delays']['item_delay'] = array(
+      '#title' => t("Item delay"),
+      '#type' => 'number',
+      '#default_value' => $config->get('queue.delays.item_delay'),
+      '#description' => t('Seconds to wait between processing each item in a queue.'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+      '#min' => 0,
+      '#step' => 0.01
+    );
+
+    $throttle_states = array(
+      '#states' => array(
+        'visible' => array(':input[name="queue[throttle][enabled]"]' => array('checked' => TRUE)),
+      ),
+    );
+
+    $form['queue']['throttle'] = array(
+      '#type' => 'fieldset',
+      '#title' => t('Throttling'),
+      // @todo Show when throttling is implemented.
+      '#access' => FALSE,
+    ) + $queue_states;
+    $form['queue']['throttle']['enabled'] = array(
+      '#title' => t('Throttle'),
+      '#type' => 'checkbox',
+      '#default_value' => $config->get('queue.throttle.enabled'),
+      '#description' => t('Throttle queues using multiple threads.'),
+    );
+    $form['queue']['throttle']['threads'] = array(
+      '#title' => t('Threads'),
+      '#type' => 'number',
+      '#default_value' => $config->get('queue.throttle.threads'),
+      '#description' => t('Number of threads to use for queues.'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+      '#min' => 0,
+    ) + $throttle_states;
+    $form['queue']['throttle']['threshold'] = array(
+      '#title' => t('Threshold'),
+      '#type' => 'number',
+      '#default_value' => $config->get('queue.throttle.threshold'),
+      '#description' => t('Number of items in queue required to activate the next cron job.'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+      '#min' => 0,
+    ) + $throttle_states;
+
+    return parent::buildForm($form, $form_state);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $this->config('ultimate_cron.settings')
+      ->set('queue', $form_state->getValue('queue'))
+      ->save();
+
+    parent::submitForm($form, $form_state);
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/Form/LauncherSettingsForm.php b/web/modules/ultimate_cron/src/Form/LauncherSettingsForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..9c0c528ecf3f8571801c1e25dd5f11824ea703ec
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Form/LauncherSettingsForm.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace Drupal\ultimate_cron\Form;
+use Drupal\Core\Form\ConfigFormBase;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Form for launcher settings.
+ */
+class LauncherSettingsForm extends ConfigFormBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'ultimate_cron_launcher_settings';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getEditableConfigNames() {
+    return ['ultimate_cron.settings'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $values = $this->config('ultimate_cron.settings');
+
+    $form['timeouts'] = [
+      '#type' => 'fieldset',
+      '#title' => t('Timeouts'),
+    ];
+    $form['launcher'] = [
+      '#type' => 'fieldset',
+      '#title' => t('Launching options'),
+    ];
+    $form['timeouts']['lock_timeout'] = [
+      '#title' => t('Job lock timeout'),
+      '#type' => 'textfield',
+      '#default_value' => $values->get('launcher.lock_timeout'),
+      '#description' => t('Number of seconds to keep lock on job.'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    ];
+    $form['timeouts']['max_execution_time'] = [
+      '#title' => t('Maximum execution time'),
+      '#type' => 'textfield',
+      '#default_value' => $values->get('launcher.max_execution_time'),
+      '#description' => t('Maximum execution time for a cron run in seconds.'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    ];
+    $form['launcher']['max_threads'] = [
+      '#title' => t('Maximum number of launcher threads'),
+      '#type' => 'textfield',
+      '#default_value' => $values->get('launcher.max_threads'),
+      '#description' => t('The maximum number of launch threads that can be running at any given time.'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+      '#weight' => 1,
+    ];
+
+    $options = ['any', '-- fixed --', '1'];
+
+    $form['launcher']['thread'] = [
+      '#title' => t('Run in thread'),
+      '#type' => 'select',
+      '#default_value' => $values->get('launcher.thread'),
+      '#options' => $options,
+      '#description' => t('Which thread to run jobs in.') . '<br/>' .
+        t('<strong>Any</strong>: Just use any available thread') . '<br/>' .
+        t('<strong>Fixed</strong>: Only run in one specific thread. The maximum number of threads is spread across the jobs.') . '<br/>' .
+        t('<strong>1-?</strong>: Only run when a specific thread is invoked. This setting only has an effect when cron is run through cron.php with an argument ?thread=N or through Drush with --options=thread=N.'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+      '#weight' => 2,
+    ];
+
+    return parent::buildForm($form, $form_state);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $this->config('ultimate_cron.settings')
+      ->set('launcher.lock_timeout', $form_state->getValue('lock_timeout'))
+      ->set('launcher.max_execution_time', $form_state->getValue('max_execution_time'))
+      ->set('launcher.max_threads', $form_state->getValue('max_threads'))
+      ->set('launcher.thread', $form_state->getValue('thread'))
+      ->save();
+
+    parent::submitForm($form, $form_state);
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/Form/LoggerSettingsForm.php b/web/modules/ultimate_cron/src/Form/LoggerSettingsForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..8c56dbbbe2ebd92f4c04ad54b7106880304e9903
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Form/LoggerSettingsForm.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace Drupal\ultimate_cron\Form;
+use Drupal\Core\Form\ConfigFormBase;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Form for logger settings.
+ */
+class LoggerSettingsForm extends ConfigFormBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'ultimate_cron_logger_settings';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getEditableConfigNames() {
+    return ['ultimate_cron.settings'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $config = $this->config('ultimate_cron.settings');
+
+    // Setup vertical tabs.
+    $form['settings_tabs'] = [
+      '#type' => 'vertical_tabs',
+    ];
+
+    // Settings for Cache logger.
+    $form['cache'] = [
+      '#type' => 'details',
+      '#title' => t('Cache'),
+      '#group' => 'settings_tabs',
+      '#tree' => TRUE,
+    ];
+
+    $form['cache']['bin'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Cache bin'),
+      '#description' => t('Select which cache bin to use for storing logs.'),
+      '#default_value' => $config->get('logger.cache.bin'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    );
+    $form['cache']['timeout'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Cache timeout'),
+      '#description' => t('Seconds before cache entry expires (0 = never, -1 = on next general cache wipe).'),
+      '#default_value' => $config->get('logger.cache.timeout'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    );
+
+    // Settings for Database logger.
+    $form['database'] = [
+      '#type' => 'details',
+      '#title' => t('Database'),
+      '#group' => 'settings_tabs',
+      '#tree' => TRUE,
+    ];
+    $options['method'] = [
+      1 => t('Disabled'),
+      2 => t('Remove logs older than a specified age'),
+      3 => t('Retain only a specific amount of log entries'),
+    ];
+    $form['database']['method'] = array(
+      '#type' => 'select',
+      '#title' => t('Log entry cleanup method'),
+      '#description' => t('Select which method to use for cleaning up logs.'),
+      '#options' => $options['method'],
+      '#default_value' => $config->get('logger.database.method'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    );
+
+    $states = array('expire' => array(), 'retain' => array());
+    $form['database']['method_expire'] = array(
+      '#type' => 'fieldset',
+      '#title' => t('Remove logs older than a specified age'),
+    ) + $states['expire'];
+    $form['database']['method_expire']['expire'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Log entry expiration'),
+      '#description' => t('Remove log entries older than X seconds.'),
+      '#default_value' => $config->get('logger.database.method_expire.expire'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    ) + $states['expire'];
+
+    $form['database']['method_retain'] = array(
+      '#type' => 'fieldset',
+      '#title' => t('Retain only a specific amount of log entries'),
+    ) + $states['retain'];
+    $form['database']['method_retain']['retain'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Retain logs'),
+      '#description' => t('Retain X amount of log entries.'),
+      '#default_value' => $config->get('logger.database.method_retain.retain'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    ) + $states['retain'];
+
+    return parent::buildForm($form, $form_state);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $this->config('ultimate_cron.settings')
+      ->set('logger.cache', $form_state->getValue('cache'))
+      ->set('logger.database', $form_state->getValue('database'))
+      ->save('');
+
+    parent::submitForm($form, $form_state);
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/Form/SchedulerSettingsForm.php b/web/modules/ultimate_cron/src/Form/SchedulerSettingsForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..c9bdc716b7c4cfb5f73dfc49b75304ff4d993791
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Form/SchedulerSettingsForm.php
@@ -0,0 +1,119 @@
+<?php
+
+namespace Drupal\ultimate_cron\Form;
+
+use Drupal\Core\Form\ConfigFormBase;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Form for scheduler settings.
+ */
+class SchedulerSettingsForm extends ConfigFormBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'ultimate_cron_scheduler_settings';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getEditableConfigNames() {
+    return ['ultimate_cron.settings'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $values = $this->config('ultimate_cron.settings');
+    $rules = is_array($values->get('rules')) ? implode(';', $values->get('rules')) : '';
+
+    // Setup vertical tabs.
+    $form['settings_tabs'] = array(
+      '#type' => 'vertical_tabs',
+    );
+
+    // Settings for crontab.
+    $form['crontab'] = [
+      '#type' => 'details',
+      '#title' => 'Crontab',
+      '#group' => 'settings_tabs',
+      '#tree' => TRUE,
+    ];
+
+    $form['crontab']['catch_up'] = array(
+      '#title' => t("Catch up"),
+      '#type' => 'textfield',
+      '#default_value' => $values->get('catch_up'),
+      '#description' => t("Don't run job after X seconds of rule."),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    );
+
+    $form['crontab']['rules'] = array(
+      '#title' => t("Rules"),
+      '#type' => 'textfield',
+      '#default_value' => $rules,
+      '#description' => t('Semi-colon separated list of crontab rules.'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+      '#element_validate' => array('ultimate_cron_plugin_crontab_element_validate_rule'),
+    );
+    $form['crontab']['rules_help'] = array(
+      '#type' => 'fieldset',
+      '#title' => t('Rules help'),
+      '#collapsible' => TRUE,
+      '#collapsed' => TRUE,
+    );
+    $form['crontab']['rules_help']['info'] = array(
+      '#markup' => file_get_contents(drupal_get_path('module', 'ultimate_cron') . '/help/rules.html'),
+    );
+
+    // Settings for Simple scheduler.
+    $form['simple'] = [
+      '#type' => 'details',
+      '#title' => t('Simple'),
+      '#group' => 'settings_tabs',
+      '#tree' => TRUE,
+    ];
+
+    $options = [
+      '* * * * *' => 'Every minute',
+      '*/15+@ * * * *' => 'Every 15 minutes',
+      '*/30+@ * * * *' => 'Every 30 minutes',
+      '0+@ * * * *' => 'Every hour',
+      '0+@ */3 * * *' => 'Every 3 hours',
+      '0+@ */6 * * *' => 'Every 6 hours',
+      '0+@ */12 * * *' => 'Every 12 hours',
+      '0+@ 0 * * *' => 'Every day',
+      '0+@ 0 * * 0' => 'Every week',
+    ];
+    $form['simple']['rule'] = array(
+      '#type' => 'select',
+      '#title' => t('Run cron every'),
+      '#default_value' => $values->get('rule'),
+      '#description' => t('Select the interval you wish cron to run on.'),
+      '#options' => $options,
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    );
+
+    parent::buildForm($form, $form_state);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $this->config('ultimate_cron.settings')
+      ->set('scheduler.crontab', $form_state->getValue('crontab'))
+      ->set('scheduler.simple', explode(';', $form_state->getValue('simple')))
+      ->save();
+
+    parent::submitForm($form, $form_state);
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/Launcher/LauncherBase.php b/web/modules/ultimate_cron/src/Launcher/LauncherBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..f83f695f060a50fe1a9ed37128edc791157550b3
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Launcher/LauncherBase.php
@@ -0,0 +1,129 @@
+<?php
+
+namespace Drupal\ultimate_cron\Launcher;
+
+use Drupal\ultimate_cron\CronPlugin;
+use Drupal\ultimate_cron\CronJobInterface;
+
+/**
+ * Abstract class for Ultimate Cron launchers.
+ *
+ * A launcher is responsible for locking and launching/running a job.
+ *
+ * Abstract methods:
+ *   lock($job)
+ *     - Lock a job. This method must return the lock_id on success
+ *       or FALSE on failure.
+ *
+ *   unlock($lock_id, $manual = FALSE)
+ *     - Release a specific lock id. If $manual is set, then the release
+ *       was triggered manually by a user.
+ *
+ *   isLocked($job)
+ *     - Check if a job is locked. This method must return the current
+ *     - lock_id for the given job, or FALSE if it is not locked.
+ *
+ *   launch($job)
+ *     - This method launches/runs the given job. This method must handle
+ *       the locking of job before launching it. Returns TRUE on successful
+ *       launch, FALSE if not.
+ *
+ * Important methods:
+ *   isLockedMultiple($jobs)
+ *     - Check locks for multiple jobs. Each launcher should implement an
+ *       optimized version of this method if possible.
+ *
+ *   launchJobs($jobs)
+ *     - Launches the jobs provided to it. A default implementation of this
+ *       exists, but can be overridden. It is assumed that this function
+ *       checks the jobs schedule before launching and that it also handles
+ *       locking wrt concurrency for the launcher itself.
+ *
+ */
+abstract class LauncherBase extends CronPlugin implements LauncherInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isLockedMultiple(array $jobs) {
+    $lock_ids = array();
+    foreach ($jobs as $name => $job) {
+      $lock_ids[$name] = $this->isLocked($job);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function launchJobs(array $jobs) {
+    foreach ($jobs as $job) {
+      if ($job->isScheduled()) {
+        $job->launch();
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function formatRunning(CronJobInterface $job) {
+    $file = drupal_get_path('module', 'ultimate_cron') . '/icons/hourglass.png';
+    $status = ['#theme' => 'image', '#uri' => $file];
+    $title = t('running');
+    return array($status, $title);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function formatUnfinished(CronJobInterface $job) {
+    $file = drupal_get_path('module', 'ultimate_cron') . '/icons/lock_open.png';
+    $status = ['#theme' => 'image', '#uri' => $file];
+    $title = t('unfinished but not locked?');
+    return array($status, $title);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function formatProgress(CronJobInterface $job, $progress) {
+    $progress = $progress ? sprintf("(%d%%)", round($progress * 100)) : '';
+    return $progress;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function initializeProgress(CronJobInterface $job) {
+    \Drupal::service('ultimate_cron.progress')->setProgress($job->id(), FALSE);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function finishProgress(CronJobInterface $job) {
+    \Drupal::service('ultimate_cron.progress')->setProgress($job->id(), FALSE);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getProgress(CronJobInterface $job) {
+    return \Drupal::service('ultimate_cron.progress')->getProgress($job->id());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getProgressMultiple(array $jobs) {
+    return \Drupal::service('ultimate_cron.progress')->getProgressMultiple(array_keys($jobs));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setProgress(CronJobInterface $job, $progress) {
+    \Drupal::service('ultimate_cron.progress')->setProgress($job->id(), $progress);
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/Launcher/LauncherInterface.php b/web/modules/ultimate_cron/src/Launcher/LauncherInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..ab2b10e582d638bd0373b81712587060dd73b374
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Launcher/LauncherInterface.php
@@ -0,0 +1,166 @@
+<?php
+
+namespace Drupal\ultimate_cron\Launcher;
+
+use Drupal\Component\Plugin\ConfigurableInterface;
+use Drupal\Component\Plugin\DependentPluginInterface;
+use Drupal\Component\Plugin\PluginInspectionInterface;
+use Drupal\Core\Plugin\PluginFormInterface;
+use Drupal\ultimate_cron\CronJobInterface;
+
+/**
+ * Defines a launcher method.
+ */
+interface LauncherInterface extends PluginInspectionInterface, ConfigurableInterface, DependentPluginInterface, PluginFormInterface {
+
+  /**
+   * Default settings.
+   *
+   * @return array
+   *   Returns array with default configuration of the object.
+   */
+  public function defaultConfiguration();
+
+  /**
+   * Lock job.
+   *
+   * @param \Drupal\ultimate_cron\CronJobInterface $job
+   *   The job to lock.
+   *
+   * @return string|FALSE
+   *   Lock ID or FALSE.
+   */
+  public function lock(CronJobInterface $job);
+
+  /**
+   * Unlock a lock.
+   *
+   * @param string $lock_id
+   *   The lock id to unlock.
+   * @param bool $manual
+   *   Whether this is a manual unlock or not.
+   *
+   * @return bool
+   *   TRUE on successful unlock.
+   */
+  public function unlock($lock_id, $manual = FALSE);
+
+  /**
+   * Check if a job is locked.
+   *
+   * @param \Drupal\ultimate_cron\CronJobInterface $job
+   *   The job to check.
+   *
+   * @return string
+   *   Lock ID of the locked job, FALSE if not locked.
+   */
+  public function isLocked(CronJobInterface $job);
+
+  /**
+   * Launch job.
+   *
+   * @param \Drupal\ultimate_cron\CronJobInterface $job
+   *   The job to launch.
+   *
+   * @return bool
+   *   TRUE on successful launch.
+   */
+  public function launch(CronJobInterface $job);
+
+  /**
+   * Fallback implementation of multiple lock check.
+   *
+   * Each launcher should implement an optimized version of this method
+   * if possible.
+   *
+   * @param \Drupal\ultimate_cron\CronJobInterface[] $jobs
+   *   Array of UltimateCronJobs to check.
+   *
+   * @return array
+   *   Array of lock ids, keyed by job name.
+   */
+  public function isLockedMultiple(array $jobs);
+
+  /**
+   * Default implementation of jobs launcher.
+   *
+   * @param \Drupal\ultimate_cron\CronJobInterface[] $jobs
+   *   Array of UltimateCronJobs to launch.
+   */
+  public function launchJobs(array $jobs);
+
+  /**
+   * Format running state.
+   *
+   * @param \Drupal\ultimate_cron\CronJobInterface $job
+   *   The running job to format.
+   */
+  public function formatRunning(CronJobInterface $job);
+
+  /**
+   * Format unfinished state.
+   *
+   * @param \Drupal\ultimate_cron\CronJobInterface $job
+   *   The running job to format.
+   */
+  public function formatUnfinished(CronJobInterface $job);
+
+  /**
+   * Default implementation of formatProgress().
+   *
+   * @param \Drupal\ultimate_cron\CronJobInterface $job
+   *   Job to format progress for.
+   * @param string $progress
+   *   Progress value for the Job.
+   */
+  public function formatProgress(CronJobInterface $job, $progress);
+
+  /**
+   * Default implementation of initializeProgress().
+   *
+   * @param \Drupal\ultimate_cron\CronJobInterface $job
+   *   Job to initialize progress for.
+   */
+  public function initializeProgress(CronJobInterface $job);
+
+  /**
+   * Default implementation of finishProgress().
+   *
+   * @param \Drupal\ultimate_cron\CronJobInterface $job
+   *   Job to finish progress for.
+   */
+  public function finishProgress(CronJobInterface $job);
+
+  /**
+   * Default implementation of getProgress().
+   *
+   * @param \Drupal\ultimate_cron\CronJobInterface $job
+   *   Job to get progress for.
+   *
+   * @return float
+   *   Progress for the job.
+   */
+  public function getProgress(CronJobInterface $job);
+
+  /**
+   * Default implementation of getProgressMultiple().
+   *
+   * @param \Drupal\ultimate_cron\CronJobInterface[] $jobs
+   *   Jobs to get progresses for, keyed by job name.
+   *
+   * @return array
+   *   Progresses, keyed by job name.
+   */
+  public function getProgressMultiple(array $jobs);
+
+  /**
+   * Default implementation of setProgress().
+   *
+   * @param \Drupal\ultimate_cron\CronJobInterface $job
+   *   Job to set progress for.
+   * @param float $progress
+   *   Progress (0-1).
+   */
+  public function setProgress(CronJobInterface $job, $progress);
+
+}
diff --git a/web/modules/ultimate_cron/src/Launcher/LauncherManager.php b/web/modules/ultimate_cron/src/Launcher/LauncherManager.php
new file mode 100644
index 0000000000000000000000000000000000000000..bea3cb46c5b6ce9b30e07af5bcc4b7e1087a638e
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Launcher/LauncherManager.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Drupal\ultimate_cron\Launcher;
+
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Plugin\DefaultPluginManager;
+
+/**
+ * A plugin manager for launcher plugins.
+ */
+class LauncherManager extends DefaultPluginManager {
+
+  /**
+   * Constructs a LauncherManager object.
+   *
+   * @param \Traversable $namespaces
+   *   An object that implements \Traversable which contains the root paths
+   *   keyed by the corresponding namespace to look for plugin implementations.
+   * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
+   *   Cache backend instance to use.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler to invoke the alter hook with.
+   */
+  public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
+    parent::__construct('Plugin/ultimate_cron/Launcher', $namespaces, $module_handler, '\Drupal\ultimate_cron\Launcher\LauncherInterface', 'Drupal\ultimate_cron\Annotation\LauncherPlugin');
+    $this->alterInfo('ultimate_cron_launcher_info');
+    $this->setCacheBackend($cache_backend, 'ultimate_cron_launcher');
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/Lock/Lock.php b/web/modules/ultimate_cron/src/Lock/Lock.php
new file mode 100644
index 0000000000000000000000000000000000000000..5e5e7c2a98a0059da162efef0949da3ac7fe819b
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Lock/Lock.php
@@ -0,0 +1,248 @@
+<?php
+
+namespace Drupal\ultimate_cron\Lock;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Database\IntegrityConstraintViolationException;
+use PDOException;
+
+/**
+ * Class for handling lock functions.
+ *
+ * This is a pseudo namespace really. Should probably be refactored...
+ */
+class Lock implements LockInterface {
+  public $locks = NULL;
+
+  public $killable = TRUE;
+
+  private $connection;
+
+  public function __construct(Connection $connection) {
+    $this->connection = $connection;
+  }
+
+  /**
+   * Shutdown handler for releasing locks.
+   */
+  public function shutdown() {
+    if ($this->locks) {
+      foreach (array_keys($this->locks) as $lock_id) {
+        $this->unlock($lock_id);
+      }
+    }
+  }
+
+  /**
+   * Dont release lock on shutdown.
+   *
+   * @param string $lock_id
+   *   The lock id to persist.
+   */
+  public function persist($lock_id) {
+    if (isset($this->locks)) {
+      unset($this->locks[$lock_id]);
+    }
+  }
+
+  /**
+   * Acquire lock.
+   *
+   * @param string $job_id
+   *   The name of the lock to acquire.
+   * @param float $timeout
+   *   The timeout in seconds for the lock.
+   *
+   * @return string
+   *   The lock id acquired.
+   */
+  public function lock($job_id, $timeout = 30.0) {
+    // First, ensure cleanup.
+    if (!isset($this->locks)) {
+      $this->locks = array();
+      ultimate_cron_register_shutdown_function(array(
+        $this,
+        'shutdown'
+      ));
+    }
+
+    $this->connection->setTarget(_ultimate_cron_get_transactional_safe_connection());
+
+    try {
+      // First we ensure that previous locks are "removed"
+      // if they are expired.
+      $this->expire($job_id);
+
+      // Ensure that the timeout is at least 1 ms.
+      $timeout = max($timeout, 0.001);
+      $expire = microtime(TRUE) + $timeout;
+
+      // Now we try to acquire the lock.
+      $lock_id = $this->connection->insert('ultimate_cron_lock')
+        ->fields(array(
+          'name' => $job_id,
+          'current' => 0,
+          'expire' => $expire,
+        ))
+      ->execute();
+
+      $this->locks[$lock_id] = TRUE;
+
+      return $lock_id;
+    } catch (PDOException $e) {
+      return FALSE;
+    } catch (IntegrityConstraintViolationException $e) {
+      return FALSE;
+    }
+  }
+
+  /**
+   * Release lock if expired.
+   *
+   * Checks if expiration time has been reached, and releases the lock if so.
+   *
+   * @param string $job_id
+   *   The name of the lock.
+   */
+  public function expire($job_id) {
+    if ($lock_id = $this->isLocked($job_id, TRUE)) {
+      $now = microtime(TRUE);
+      $this->connection->update('ultimate_cron_lock')
+        ->expression('current', 'lid')
+        ->condition('lid', $lock_id)
+        ->condition('expire', $now, '<=')
+        ->execute();
+    }
+  }
+
+  /**
+   * Release lock.
+   *
+   * @param string $lock_id
+   *   The lock id to release.
+   */
+  public function unlock($lock_id) {
+    $unlocked = $this->connection->update('ultimate_cron_lock')
+      ->expression('current', 'lid')
+      ->condition('lid', $lock_id)
+      ->condition('current', 0)
+      ->execute();
+    $this->persist($lock_id);
+    return $unlocked;
+  }
+
+  /**
+   * Relock.
+   *
+   * @param string $lock_id
+   *   The lock id to relock.
+   * @param float $timeout
+   *   The timeout in seconds for the lock.
+   *
+   * @return boolean
+   *   TRUE if relock was successful.
+   */
+  public function reLock($lock_id, $timeout = 30.0) {
+    // Ensure that the timeout is at least 1 ms.
+    $timeout = max($timeout, 0.001);
+    $expire = microtime(TRUE) + $timeout;
+    return (bool) $this->connection->update('ultimate_cron_lock')
+      ->fields(array(
+        'expire' => $expire,
+      ))
+      ->condition('lid', $lock_id)
+      ->condition('current', 0)
+      ->execute();
+  }
+
+  /**
+   * Check if lock is taken.
+   *
+   * @param string $job_id
+   *   Name of the lock.
+   * @param boolean $ignore_expiration
+   *   Ignore expiration, just check if it's present.
+   *   Used for retrieving the lock id of an expired lock.
+   *
+   * @return mixed
+   *   The lock id if found, otherwise FALSE.
+   */
+  public function isLocked($job_id, $ignore_expiration = FALSE) {
+    $now = microtime(TRUE);
+    $result = $this->connection->select('ultimate_cron_lock', 'l')
+      ->fields('l', array('lid', 'expire'))
+      ->condition('name', $job_id)
+      ->condition('current', 0)
+      ->execute()
+      ->fetchObject();
+    return $result && ($result->expire > $now || $ignore_expiration) ? $result->lid : FALSE;
+  }
+
+  /**
+   * Check multiple locks.
+   *
+   * @param array $job_ids
+   *   The names of the locks to check.
+   *
+   * @return array
+   *   Array of lock ids.
+   */
+  public function isLockedMultiple($job_ids) {
+    $now = microtime(TRUE);
+    $result = $this->connection->select('ultimate_cron_lock', 'l')
+      ->fields('l', array('lid', 'name', 'expire'))
+      ->condition('name', $job_ids, 'IN')
+      ->condition('current', 0)
+      ->execute()
+      ->fetchAllAssoc('name');
+    foreach ($job_ids as $job_id) {
+      if (!isset($result[$job_id])) {
+        $result[$job_id] = FALSE;
+      }
+      else {
+        $result[$job_id] = $result[$job_id]->expire > $now ? $result[$job_id]->lid : FALSE;
+      }
+    }
+    return $result;
+  }
+
+  /**
+   * Cleanup expired locks.
+   */
+  public function cleanup() {
+    $count = 0;
+    $class = \Drupal::entityTypeManager()->getDefinition('ultimate_cron_job')->getClass();
+    $now = microtime(TRUE);
+
+    $this->connection->update('ultimate_cron_lock')
+      ->expression('current', 'lid')
+      ->condition('expire', $now, '<=')
+      ->execute();
+
+    do {
+      $lids = $this->connection->select('ultimate_cron_lock', 'l')
+        ->fields('l', array('lid'))
+        ->where('l.current = l.lid')
+        ->range(0, 100)
+        ->execute()
+        ->fetchCol();
+
+      if ($lids) {
+        $count += count($lids);
+        $this->connection->delete('ultimate_cron_lock')
+          ->condition('lid', $lids, 'IN')
+          ->execute();
+      }
+      if ($job = $class::$currentJob) {
+        if ($job->getSignal('kill')) {
+          \Drupal::logger('ultimate_cron')->warning('kill signal recieved');
+          return;
+        }
+      }
+    } while ($lids);
+
+    if ($count) {
+      \Drupal::logger('ultimate_cron_lock')->info('Cleaned up @count expired locks', ['@count' => $count]);
+    }
+  }
+}
diff --git a/web/modules/ultimate_cron/src/Lock/LockInterface.php b/web/modules/ultimate_cron/src/Lock/LockInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..cfd3544ba87a3a16a86b0082995cbec25753e269
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Lock/LockInterface.php
@@ -0,0 +1,98 @@
+<?php
+
+namespace Drupal\ultimate_cron\Lock;
+
+
+/**
+ * Class for handling lock functions.
+ *
+ * This is a pseudo namespace really. Should probably be refactored...
+ */
+interface LockInterface {
+  /**
+   * Shutdown handler for releasing locks.
+   */
+  public function shutdown();
+
+  /**
+   * Relock.
+   *
+   * @param string $lock_id
+   *   The lock id to relock.
+   * @param float $timeout
+   *   The timeout in seconds for the lock.
+   *
+   * @return boolean
+   *   TRUE if relock was successful.
+   */
+  public function reLock($lock_id, $timeout = 30.0);
+
+  /**
+   * Dont release lock on shutdown.
+   *
+   * @param string $lock_id
+   *   The lock id to persist.
+   */
+  public function persist($lock_id);
+
+  /**
+   * Check multiple locks.
+   *
+   * @param array $job_ids
+   *   The names of the locks to check.
+   *
+   * @return array
+   *   Array of lock ids.
+   */
+  public function isLockedMultiple($job_ids);
+
+  /**
+   * Acquire lock.
+   *
+   * @param string $job_id
+   *   The name of the lock to acquire.
+   * @param float $timeout
+   *   The timeout in seconds for the lock.
+   *
+   * @return string
+   *   The lock id acquired.
+   */
+  public function lock($job_id, $timeout = 30.0);
+
+  /**
+   * Check if lock is taken.
+   *
+   * @param string $job_id
+   *   Name of the lock.
+   * @param boolean $ignore_expiration
+   *   Ignore expiration, just check if it's present.
+   *   Used for retrieving the lock id of an expired lock.
+   *
+   * @return mixed
+   *   The lock id if found, otherwise FALSE.
+   */
+  public function isLocked($job_id, $ignore_expiration = FALSE);
+
+  /**
+   * Release lock.
+   *
+   * @param string $lock_id
+   *   The lock id to release.
+   */
+  public function unlock($lock_id);
+
+  /**
+   * Release lock if expired.
+   *
+   * Checks if expiration time has been reached, and releases the lock if so.
+   *
+   * @param string $job_id
+   *   The name of the lock.
+   */
+  public function expire($job_id);
+
+  /**
+   * Cleanup expired locks.
+   */
+  public function cleanup();
+}
diff --git a/web/modules/ultimate_cron/src/Lock/LockMemcache.php b/web/modules/ultimate_cron/src/Lock/LockMemcache.php
new file mode 100644
index 0000000000000000000000000000000000000000..07ad11441e6091f22af6509453ccd266b1ad5c54
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Lock/LockMemcache.php
@@ -0,0 +1,205 @@
+<?php
+
+namespace Drupal\ultimate_cron\Lock;
+/**
+ * Class for handling lock functions.
+ *
+ * This is a pseudo namespace really. Should probably be refactored...
+ */
+class LockMemcache {
+  private static $locks = NULL;
+
+  /**
+   * Shutdown handler for releasing locks.
+   */
+  static public function shutdown() {
+    if (self::$locks) {
+      foreach (array_keys(self::$locks) as $lock_id) {
+        self::unlock($lock_id);
+      }
+    }
+  }
+
+  /**
+   * Dont release lock on shutdown.
+   *
+   * @param string $lock_id
+   *   The lock id to persist.
+   */
+  static public function persist($lock_id) {
+    if (isset(self::$locks)) {
+      unset(self::$locks[$lock_id]);
+    }
+  }
+
+  /**
+   * Acquire lock.
+   *
+   * @param string $name
+   *   The name of the lock to acquire.
+   * @param float $timeout
+   *   The timeout in seconds for the lock.
+   *
+   * @return string
+   *   The lock id acquired.
+   */
+  static public function lock($name, $timeout = 30.0) {
+    // First, ensure cleanup.
+    if (!isset(self::$locks)) {
+      self::$locks = array();
+      ultimate_cron_register_shutdown_function(array(
+        'Drupal\ultimate_cron\Lock\LockMemcache',
+        'shutdown'
+      ));
+    }
+
+    // Ensure that the timeout is at least 1 sec. This is a limitation
+    // imposed by memcached.
+    $timeout = (int) max($timeout, 1);
+
+    $bin = variable_get('ultimate_cron_lock_memcache_bin', 'semaphore');
+    $lock_id = $name . ':' . uniqid('memcache-lock', TRUE);
+    if (dmemcache_add($name, $lock_id, $timeout, $bin)) {
+      self::$locks[$lock_id] = $lock_id;
+      return $lock_id;
+    }
+    return FALSE;
+  }
+
+  /**
+   * Release lock if expired.
+   *
+   * Checks if expiration time has been reached, and releases the lock if so.
+   *
+   * @param string $name
+   *   The name of the lock.
+   */
+  static public function expire($name) {
+    // Nothing to do here. Memcache handles this internally.
+  }
+
+  /**
+   * Release lock.
+   *
+   * @param string $lock_id
+   *   The lock id to release.
+   */
+  static public function unlock($lock_id) {
+    if (!preg_match('/(.*):memcache-lock.*/', $lock_id, $matches)) {
+      return FALSE;
+    }
+    $name = $matches[1];
+
+    $result = FALSE;
+    if (!($semaphore = self::lock("lock-$name"))) {
+      return FALSE;
+    }
+
+    if (self::isLocked($name) == $lock_id) {
+      $result = self::unlockRaw($name);
+      unset(self::$locks[$lock_id]);
+    }
+    else {
+      unset(self::$locks[$lock_id]);
+    }
+    self::unlockRaw("lock-$name", $semaphore);
+
+    return $result;
+  }
+
+  /**
+   * Real unlock.
+   *
+   * @param string $name
+   *   Name of lock.
+   *
+   * @return boolean
+   *   Result of unlock.
+   */
+  static private function unlockRaw($name, $lock_id = NULL) {
+    $bin = variable_get('ultimate_cron_lock_memcache_bin', 'semaphore');
+    if ($lock_id) {
+      unset(self::$locks[$lock_id]);
+    }
+    return dmemcache_delete($name, $bin);
+  }
+
+  /**
+   * Relock.
+   *
+   * @param string $lock_id
+   *   The lock id to relock.
+   * @param float $timeout
+   *   The timeout in seconds for the lock.
+   *
+   * @return boolean
+   *   TRUE if relock was successful.
+   */
+  static public function reLock($lock_id, $timeout = 30.0) {
+    if (!preg_match('/(.*):memcache-lock.*/', $lock_id, $matches)) {
+      return FALSE;
+    }
+    $name = $matches[1];
+
+    if (!($semaphore = self::lock("lock-$name"))) {
+      return FALSE;
+    }
+
+    $bin = variable_get('ultimate_cron_lock_memcache_bin', 'semaphore');
+    $current_lock_id = self::isLocked($name);
+    if ($current_lock_id === FALSE || $current_lock_id === $lock_id) {
+      if (dmemcache_set($name, $lock_id, $timeout, $bin)) {
+        self::unlockRaw("lock-$name", $semaphore);
+        self::$locks[$lock_id] = TRUE;
+        return self::$locks[$lock_id];
+      }
+    }
+    self::unlockRaw("lock-$name", $semaphore);
+    return FALSE;
+  }
+
+  /**
+   * Check if lock is taken.
+   *
+   * @param string $name
+   *   Name of the lock.
+   * @param boolean $ignore_expiration
+   *   Ignore expiration, just check if it's present.
+   *   Used for retrieving the lock id of an expired lock.
+   *
+   * @return mixed
+   *   The lock id if found, otherwise FALSE.
+   */
+  static public function isLocked($name, $ignore_expiration = FALSE) {
+    $bin = variable_get('ultimate_cron_lock_memcache_bin', 'semaphore');
+    $result = dmemcache_get($name, $bin);
+    return $result ? $result : FALSE;
+  }
+
+  /**
+   * Check multiple locks.
+   *
+   * @param array $names
+   *   The names of the locks to check.
+   *
+   * @return array
+   *   Array of lock ids.
+   */
+  static public function isLockedMultiple($names) {
+    $bin = variable_get('ultimate_cron_lock_memcache_bin', 'semaphore');
+    $locks = dmemcache_get_multi($names, $bin);
+    foreach ($names as $name) {
+      if (!isset($locks[$name])) {
+        $locks[$name] = FALSE;
+      }
+    }
+    return $locks;
+  }
+
+  /**
+   * Cleanup expired locks.
+   */
+  static public function cleanup() {
+    // Nothing to do here. Memcache handles this internally.
+  }
+}
diff --git a/web/modules/ultimate_cron/src/Logger/LogEntry.php b/web/modules/ultimate_cron/src/Logger/LogEntry.php
new file mode 100644
index 0000000000000000000000000000000000000000..b7e6d4622f106fbd6bca3aa9df8c0700f9636db5
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Logger/LogEntry.php
@@ -0,0 +1,252 @@
+<?php
+
+namespace Drupal\ultimate_cron\Logger;
+
+use Drupal\Component\Render\FormattableMarkup;
+use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\user\Entity\User;
+
+/**
+ * Class for Ultimate Cron log entries.
+ *
+ * Important properties:
+ *   $log_entry_size
+ *     - The maximum number of characters of the message in the log entry.
+ */
+class LogEntry {
+  public $lid = NULL;
+  public $name = '';
+  public $log_type = ULTIMATE_CRON_LOG_TYPE_NORMAL;
+  public $uid = NULL;
+  public $start_time = 0;
+  public $end_time = 0;
+  public $init_message = '';
+  public $message = '';
+  public $severity = -1;
+
+  // Default 1MiB log entry.
+  public $log_entry_size = 1048576;
+
+  public $log_entry_fields = array(
+    'lid',
+    'uid',
+    'log_type',
+    'start_time',
+    'end_time',
+    'init_message',
+    'message',
+    'severity',
+  );
+
+  public $logger;
+  public $job;
+  public $finished = FALSE;
+
+  /**
+   * Constructor.
+   *
+   * @param string $name
+   *   Name of log.
+   * @param \Drupal\ultimate_cron\Logger\LoggerBase $logger
+   *   A logger object.
+   */
+  public function __construct($name, $logger, $log_type = ULTIMATE_CRON_LOG_TYPE_NORMAL) {
+    $this->name = $name;
+    $this->logger = $logger;
+    $this->log_type = $log_type;
+    if (!isset($this->uid)) {
+      $this->uid = \Drupal::currentUser()->id();
+    }
+  }
+
+  /**
+   * Get current log entry data as an associative array.
+   *
+   * @return array
+   *   Log entry data.
+   */
+  public function getData() {
+    $result = array();
+    foreach ($this->log_entry_fields as $field) {
+      $result[$field] = $this->$field;
+    }
+    return $result;
+  }
+
+  /**
+   * Set current log entry data from an associative array.
+   *
+   * @param array $data
+   *   Log entry data.
+   */
+  public function setData($data) {
+    foreach ($this->log_entry_fields as $field) {
+      if (array_key_exists($field, $data)) {
+        $this->$field = $data[$field];
+      }
+    }
+  }
+
+  /**
+   * Finish a log and save it if applicable.
+   */
+  public function finish() {
+    if (!$this->finished) {
+      \Drupal::service('logger.ultimate_cron')->unCatchMessages($this);
+      $this->end_time = microtime(TRUE);
+      $this->finished = TRUE;
+      $this->save();
+    }
+  }
+
+  /**
+   * Logs a message.
+   *
+   * @param string $message
+   *   The message to log.
+   * @param array $variables
+   *   Replacement variables for t().
+   * @param int $level
+   *   The log level, see \Drupal\Core\Logger\RfcLogLevel.
+   */
+  public function log($message, $variables = array(), $level = RfcLogLevel::NOTICE) {
+
+    if ($variables !== NULL && gettype($message) === 'string') {
+      $message = t($message, $variables);
+    }
+
+    $this->message .= $message;
+    if ($this->severity < 0 || $this->severity > $level) {
+      $this->severity = $level;
+    }
+    // Make sure that message doesn't become too big.
+    if (mb_strlen($this->message) > $this->log_entry_size) {
+      while (mb_strlen($this->message) > $this->log_entry_size) {
+        $firstline = mb_strpos(rtrim($this->message, "\n"), "\n");
+        if ($firstline === FALSE || $firstline == mb_strlen($this->message)) {
+          // Only one line? That's a big line ... truncate it without mercy!
+          $this->message = mb_substr($this->message, -$this->log_entry_size);
+          break;
+        }
+        $this->message = mb_substr($this->message, $firstline + 1);
+      }
+      $this->message = '.....' . $this->message;
+    }
+  }
+
+  /**
+   * Get duration.
+   */
+  public function getDuration() {
+    $duration = 0;
+    if ($this->start_time && $this->end_time) {
+      $duration = (int) ($this->end_time - $this->start_time);
+    }
+    elseif ($this->start_time) {
+      $duration = (int) (microtime(TRUE) - $this->start_time);
+    }
+    return $duration;
+  }
+
+  /**
+   * Format duration.
+   */
+  public function formatDuration() {
+    $duration = $this->getDuration();
+    switch (TRUE) {
+      case $duration >= 86400:
+        $format = 'd H:i:s';
+        break;
+
+      case $duration >= 3600:
+        $format = 'H:i:s';
+        break;
+
+      default:
+        $format = 'i:s';
+    }
+    return isset($duration) ? gmdate($format, $duration) : t('N/A');
+  }
+
+  /**
+   * Format start time.
+   */
+  public function formatStartTime() {
+    return $this->start_time ? \Drupal::service('date.formatter')->format((int) $this->start_time, 'custom', 'Y-m-d H:i:s') : t('Never');
+  }
+
+  /**
+   * Format end time.
+   */
+  public function formatEndTime() {
+    return $this->end_time ? \Drupal::service('date.formatter')->format((int) $this->end_time, 'custom', 'Y-m-d H:i:s') : '';
+  }
+
+  /**
+   * Format user.
+   */
+  public function formatUser() {
+    $username = t('anonymous') . ' (0)';
+    if ($this->uid) {
+      $user = User::load($this->uid);
+      $username = $user ? new FormattableMarkup('@username (@uid)', array('@username' => $user->getDisplayName(), '@uid' => $user->id())) : t('N/A');
+    }
+    return $username;
+  }
+
+  /**
+   * Format initial message.
+   */
+  public function formatInitMessage() {
+    if ($this->start_time) {
+      return $this->init_message ? $this->init_message . ' ' . t('by') . ' ' . $this->formatUser() : t('N/A');
+    }
+    else {
+      $registered = variable_get('ultimate_cron_hooks_registered', array());
+      return !empty($registered[$this->name]) ? t('Registered at @datetime', array(
+        '@datetime' => \Drupal::service('date.formatter')->format($registered[$this->name], 'custom', 'Y-m-d H:i:s'),
+      )) : t('N/A');
+    }
+  }
+
+  /**
+   * Format severity.
+   */
+  public function formatSeverity() {
+    switch ($this->severity) {
+      case RfcLogLevel::EMERGENCY:
+      case RfcLogLevel::ALERT:
+      case RfcLogLevel::CRITICAL:
+      case RfcLogLevel::ERROR:
+        $file = 'core/misc/icons/e32700/error.svg';
+        break;
+
+      case RfcLogLevel::WARNING:
+        $file = 'core/misc/icons/e29700/warning.svg';
+        break;
+
+      case RfcLogLevel::NOTICE:
+        // @todo Look for a better icon.
+        $file = 'core/misc/icons/008ee6/twistie-up.svg';
+        break;
+
+      case RfcLogLevel::INFO:
+      case RfcLogLevel::DEBUG:
+      default:
+        $file = 'core/misc/icons/73b355/check.svg';
+    }
+    $status = ['#theme' => 'image', '#uri' => $file];
+    $severity_levels = array(
+        -1 => t('no info'),
+      ) + RfcLogLevel::getLevels();
+    $title = $severity_levels[$this->severity];
+    return array($status, $title);
+  }
+
+  /**
+   * Save log entry.
+   */
+  public function save() {
+    $this->logger->save($this);
+  }
+}
diff --git a/web/modules/ultimate_cron/src/Logger/LoggerBase.php b/web/modules/ultimate_cron/src/Logger/LoggerBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..1e8f97d29703c685b7bc51b8e0379587948c26f9
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Logger/LoggerBase.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Drupal\ultimate_cron\Logger;
+
+use Drupal\ultimate_cron\CronPlugin;
+
+/**
+ * Abstract class for Ultimate Cron loggers
+ *
+ * Each logger must implement its own functions for getting/setting data
+ * from the its storage backend.
+ *
+ * Abstract methods:
+ *   load($name, $lock_id = NULL)
+ *     - Load a log entry. If no $lock_id is provided, this method should
+ *       load the latest log entry for $name.
+ *
+ * "Abstract" properties:
+ *   $logEntryClass
+ *     - The class name of the log entry class associated with this logger.
+ */
+abstract class LoggerBase extends CronPlugin implements LoggerInterface {
+  static public $log_entries = NULL;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function factoryLogEntry($name) {
+    return new LogEntry($name, $this);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function createEntry($name, $lock_id, $init_message = '', $log_type = ULTIMATE_CRON_LOG_TYPE_NORMAL) {
+    $log_entry = new LogEntry($name, $this, $log_type);
+
+    $log_entry->lid = $lock_id;
+    $log_entry->start_time = microtime(TRUE);
+    $log_entry->init_message = $init_message;
+    //$log_entry->save();
+    return $log_entry;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function loadLatestLogEntries(array $jobs, array $log_types) {
+    $logs = array();
+    foreach ($jobs as $job) {
+      $logs[$job->id()] = $job->loadLatestLogEntry($log_types);
+    }
+    return $logs;
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/Logger/LoggerInterface.php b/web/modules/ultimate_cron/src/Logger/LoggerInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..b567f0a559d439a15db5beb794369ae00ae86826
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Logger/LoggerInterface.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace Drupal\ultimate_cron\Logger;
+
+use Drupal\Component\Plugin\ConfigurableInterface;
+use Drupal\Component\Plugin\DependentPluginInterface;
+use Drupal\Component\Plugin\PluginInspectionInterface;
+use Drupal\Core\Plugin\PluginFormInterface;
+
+/**
+ * Defines a logger method.
+ */
+interface LoggerInterface extends PluginInspectionInterface, ConfigurableInterface, DependentPluginInterface, PluginFormInterface {
+
+  /**
+   * Returns the default configuration.
+   *
+   * @return mixed
+   */
+  public function defaultConfiguration();
+
+  /**
+   * Factory method for creating a new unsaved log entry object.
+   *
+   * @param string $name
+   *   Name of the log entry (name of the job).
+   *
+   * @return LogEntry
+   *   The log entry.
+   */
+  public function factoryLogEntry($name);
+
+  /**
+   * Create a new log entry.
+   *
+   * @param string $name
+   *   Name of the log entry (name of the job).
+   * @param string $lock_id
+   *   The lock id.
+   * @param string $init_message
+   *   (optional) The initial message for the log entry.
+   * @param int $log_type
+   *   (optional) The log_type for the log entry.
+   *
+   * @return LogEntry
+   *   The log entry created.
+   */
+  public function createEntry($name, $lock_id, $init_message = '', $log_type = ULTIMATE_CRON_LOG_TYPE_NORMAL);
+
+  /**
+   * Load latest log entry for multiple jobs.
+   *
+   * This is the fallback method. Loggers should implement an optimized
+   * version if possible.
+   *
+   * @param array $jobs
+   *   Jobs for which the log entries should be loaded.
+   * @param array $log_types
+   *   Type of log messages to load.
+   */
+  public function loadLatestLogEntries(array $jobs, array $log_types);
+
+  /**
+   * Load a log.
+   *
+   * @param string $name
+   *   Name of log.
+   * @param string $lock_id
+   *   Specific lock id.
+   *
+   * @return \Drupal\ultimate_cron\Logger\LogEntry
+   *   Log entry
+   */
+  public function load($name, $lock_id = NULL, array $log_types = [ULTIMATE_CRON_LOG_TYPE_NORMAL]);
+
+  /**
+   * Get page with log entries for a job.
+   *
+   * @param string $name
+   *   Name of job.
+   * @param array $log_types
+   *   Log types to get.
+   * @param int $limit
+   *   (optional) Number of log entries per page.
+   *
+   * @return array
+   *   Log entries.
+   */
+  public function getLogEntries($name, array $log_types, $limit = 10);
+
+  /**
+   * Saves a log entry.
+   *
+   * @param \Drupal\ultimate_cron\Logger\LogEntry $log_entry
+   *   The log entry to save.
+   */
+  public function save(LogEntry $log_entry);
+
+}
diff --git a/web/modules/ultimate_cron/src/Logger/LoggerManager.php b/web/modules/ultimate_cron/src/Logger/LoggerManager.php
new file mode 100644
index 0000000000000000000000000000000000000000..0ac89d9c467585fe4d56355464940224a8379fb1
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Logger/LoggerManager.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Drupal\ultimate_cron\Logger;
+
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Language\LanguageManager;
+use Drupal\Core\Plugin\DefaultPluginManager;
+
+/**
+ * A plugin manager for logger plugins.
+ */
+class LoggerManager extends DefaultPluginManager {
+
+  /**
+   * Constructs a LoggerManager object.
+   *
+   * @param \Traversable $namespaces
+   *   An object that implements \Traversable which contains the root paths
+   *   keyed by the corresponding namespace to look for plugin implementations.
+   * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
+   *   Cache backend instance to use.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler to invoke the alter hook with.
+   */
+  public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
+    parent::__construct('Plugin/ultimate_cron/Logger', $namespaces, $module_handler, '\Drupal\ultimate_cron\Logger\LoggerInterface', 'Drupal\ultimate_cron\Annotation\LoggerPlugin');
+    $this->alterInfo('ultimate_cron_logger_info');
+    $this->setCacheBackend($cache_backend, 'ultimate_cron_logger');
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/Logger/WatchdogLogger.php b/web/modules/ultimate_cron/src/Logger/WatchdogLogger.php
new file mode 100644
index 0000000000000000000000000000000000000000..56d69ec37eedcd49878ae776c1436562f06bd513
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Logger/WatchdogLogger.php
@@ -0,0 +1,148 @@
+<?php
+
+namespace Drupal\ultimate_cron\Logger;
+
+use Drupal\Core\Logger\LogMessageParserInterface;
+use Drupal\Core\Logger\RfcLoggerTrait;
+use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Psr\Log\LoggerInterface as PsrLoggerInterface;
+
+/**
+ * Logs events in currently running cronjobs.
+ */
+class WatchdogLogger implements PsrLoggerInterface {
+  use RfcLoggerTrait;
+  use StringTranslationTrait;
+
+  /**
+   * Log entries for currently running cron jobs.
+   *
+   * @var \Drupal\ultimate_cron\Logger\LogEntry[]
+   */
+  protected $logEntries = [];
+
+  /**
+   * The message's placeholders parser.
+   *
+   * @var \Drupal\Core\Logger\LogMessageParserInterface
+   */
+  protected $parser;
+
+  /**
+   * Whether the shutdown handler is registered or not.
+   *
+   * @var bool
+   */
+  protected $shutdownRegistered = FALSE;
+
+  /**
+   * Constructs a WatchdogLogger object.
+   *
+   * @param \Drupal\Core\Logger\LogMessageParserInterface $parser
+   *   The parser to use when extracting message variables.
+   */
+  public function __construct(LogMessageParserInterface $parser) {
+    $this->parser = $parser;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function log($level, $message, array $context = array()) {
+
+    if ($this->logEntries) {
+
+      // Remove any backtraces since they may contain an unserializable variable.
+      unset($context['backtrace']);
+
+      // Convert PSR3-style messages to
+      // \Drupal\Component\Render\FormattableMarkup style, so they can be
+      // translated too in runtime.
+      $message_placeholders = $this->parser->parseMessagePlaceholders($message, $context);
+
+      foreach ($this->logEntries as $log_entry) {
+        $log_entry->log($message, $message_placeholders, $level);
+      }
+    }
+  }
+
+  /**
+   * Begin capturing messages.
+   *
+   * @param LogEntry $log_entry
+   *   The log entry that should capture messages.
+   */
+  public function catchMessages(LogEntry $log_entry) {
+    // Since we may already be inside a drupal_register_shutdown_function()
+    // we cannot use that. Use PHPs register_shutdown_function() instead.
+    if (!$this->shutdownRegistered) {
+      ultimate_cron_register_shutdown_function(
+        array(
+          $this,
+          'catchMessagesShutdownWrapper'
+        ), 'catch_messages'
+      );
+      $this->shutdownRegistered = TRUE;
+    }
+
+    $this->logEntries[$log_entry->lid] = $log_entry;
+  }
+
+  /**
+   * End message capturing.
+   *
+   * Effectively disables the shutdown function for the given log entry.
+   *
+   * @param \Drupal\ultimate_cron\Logger\LogEntry $log_entry
+   *   The log entry.
+   */
+  public function unCatchMessages(LogEntry $log_entry) {
+    unset($this->logEntries[$log_entry->lid]);
+  }
+
+  /**
+   * Shutdown handler wrapper for catching messages.
+   */
+  public function catchMessagesShutdownWrapper() {
+    foreach ($this->logEntries as $log_entry) {
+      $this->catchMessagesShutdown($log_entry);
+    }
+  }
+
+  /**
+   * Shutdown function callback for a single log entry.
+   *
+   * Ensures that a log entry has been closed properly on shutdown.
+   *
+   * @param LogEntry $log_entry
+   *   The log entry to close.
+   */
+  public function catchMessagesShutdown(LogEntry $log_entry) {
+    $this->unCatchMessages($log_entry);
+
+    if ($log_entry->finished) {
+      return;
+    }
+
+    // Get error messages.
+    $error = error_get_last();
+    if ($error) {
+      $message = $error['message'] . ' (line ' . $error['line'] . ' of ' . $error['file'] . ').' . "\n";
+      $severity = RfcLogLevel::INFO;
+      if ($error['type'] && (E_NOTICE || E_USER_NOTICE || E_USER_WARNING)) {
+        $severity = RfcLogLevel::NOTICE;
+      }
+      if ($error['type'] && (E_WARNING || E_CORE_WARNING || E_USER_WARNING)) {
+        $severity = RfcLogLevel::WARNING;
+      }
+      if ($error['type'] && (E_ERROR || E_CORE_ERROR || E_USER_ERROR || E_RECOVERABLE_ERROR)) {
+        $severity = RfcLogLevel::ERROR;
+      }
+
+      $log_entry->log($message, NULL, $severity);
+    }
+    $log_entry->finish();
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/Plugin/ultimate_cron/Launcher/SerialLauncher.php b/web/modules/ultimate_cron/src/Plugin/ultimate_cron/Launcher/SerialLauncher.php
new file mode 100644
index 0000000000000000000000000000000000000000..7c1beee922e263c9e51cc2ac7db8052bc6274661
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Plugin/ultimate_cron/Launcher/SerialLauncher.php
@@ -0,0 +1,355 @@
+<?php
+
+namespace Drupal\ultimate_cron\Plugin\ultimate_cron\Launcher;
+
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\ultimate_cron\CronJobInterface;
+use Drupal\ultimate_cron\Launcher\LauncherBase;
+use Drupal\ultimate_cron\PluginCleanupInterface;
+
+/**
+ * Ultimate Cron launcher plugin class.
+ *
+ * @LauncherPlugin(
+ *   id = "serial",
+ *   title = @Translation("Serial"),
+ *   description = @Translation("Launches scheduled jobs in the same thread and runs them consecutively."),
+ * )
+ */
+class SerialLauncher extends LauncherBase implements PluginCleanupInterface {
+
+  public $currentThread = NULL;
+
+  /**
+   * Implements hook_cron_alter().
+   */
+  public function cron_alter(&$jobs) {
+    $lock = \Drupal::service('ultimate_cron.lock');
+    if (!empty($lock->{$killable})) {
+      $jobs['ultimate_cron_plugin_launcher_serial_cleanup']->hook['tags'][] = 'killable';
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return array(
+      'timeouts' => array(
+        'lock_timeout' => 3600,
+        'max_execution_time' => 3600,
+      ),
+      'launcher' => array(
+        'max_threads' => 1,
+        'thread' => 'any',
+      ),
+    ) + parent::defaultConfiguration();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $form['timeouts'] = array(
+      '#type' => 'fieldset',
+      '#title' => t('Timeouts'),
+    );
+
+    $form['launcher'] = array(
+      '#type' => 'fieldset',
+      '#title' => t('Launching options'),
+    );
+
+    $form['timeouts']['lock_timeout'] = array(
+      '#title' => t("Job lock timeout"),
+      '#type' => 'textfield',
+      '#default_value' => $this->configuration['timeouts']['lock_timeout'],
+      '#description' => t('Number of seconds to keep lock on job.'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    );
+
+    // @todo: Figure this out when converting global settings to use plugin
+    // classes.
+    if (FALSE) {
+      $form['timeouts']['max_execution_time'] = array(
+        '#title' => t("Maximum execution time"),
+        '#type' => 'textfield',
+        '#default_value' => $this->configuration['timeouts']['max_execution_time'],
+        '#description' => t('Maximum execution time for a cron run in seconds.'),
+        '#fallback' => TRUE,
+        '#required' => TRUE,
+      );
+      $form['launcher']['max_threads'] = array(
+        '#title' => t("Maximum number of launcher threads"),
+        '#type' => 'number',
+        '#default_value' => $this->configuration['launcher']['max_threads'],
+        '#description' => t('The maximum number of launch threads that can be running at any given time.'),
+        '#fallback' => TRUE,
+        '#required' => TRUE,
+        '#weight' => 1,
+      );
+
+      return $form;
+    }
+    else {
+      $max_threads = isset($this->configuration['launcher']['max_threads']) ? $this->configuration['launcher']['max_threads'] : 1;
+    }
+
+    $options = array(
+      'any' => t('-- Any -- '),
+      'fixed' => t('-- Fixed -- '),
+    );
+    for ($i = 1; $i <= $max_threads; $i++) {
+      $options[$i] = $i;
+    }
+
+
+    $form['launcher']['thread'] = array(
+      '#title' => t("Run in thread"),
+      '#type' => 'select',
+      '#default_value' => isset($this->configuration['launcher']['thread']) ? $this->configuration['launcher']['thread'] : 'any',
+      '#options' => $options,
+      '#description' => t('Which thread to run in when invoking with ?thread=N. Note: This setting only has an effect when cron is run through cron.php with an argument ?thread=N or through Drush with --options=thread=N.'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+      '#weight' => 2,
+    );
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function settingsFormValidate(&$form, &$form_state, $job = NULL) {
+    $elements = & $form['configuration'][$this->type][$this->name];
+    $values = & $form_state['values']['configuration'][$this->type][$this->name];
+    if (!$job) {
+      if (intval($values['max_threads']) <= 0) {
+        form_set_error("settings[$this->type][$this->name", t('%title must be greater than 0', array(
+          '%title' => $elements['launcher']['max_threads']['#title']
+        )));
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function lock(CronJobInterface $job) {
+    if (array_key_exists('timeouts', $this->configuration)) {
+      $timeout = $this->configuration['timeouts']['lock_timeout'];
+    }
+    else {
+      $timeout = 0;
+    }
+
+    $lock = \Drupal::service('ultimate_cron.lock');
+    if ($lock_id = $lock->lock($job->id(), $timeout)) {
+      $lock_id = $this->getPluginId() . '-' . $lock_id;
+      return $lock_id;
+    }
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function unlock($lock_id, $manual = FALSE) {
+    list($launcher, $lock_id) = explode('-', $lock_id, 2);
+    $lock = \Drupal::service('ultimate_cron.lock');
+    return $lock->unlock($lock_id);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isLocked(CronJobInterface $job) {
+    $lock = \Drupal::service('ultimate_cron.lock');
+    $lock_id = $lock->isLocked($job->id());
+    return $lock_id ? $this->pluginId . '-' . $lock_id : $lock_id;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isLockedMultiple(array $jobs) {
+    $names = array();
+    foreach ($jobs as $job) {
+      $names[] = $job->id();
+    }
+    $lock = \Drupal::service('ultimate_cron.lock');
+    $lock_ids = $lock->isLockedMultiple($names);
+    foreach ($lock_ids as &$lock_id) {
+      $lock_id = $lock_id ? $this->pluginId . '-' . $lock_id : $lock_id;
+    }
+    return $lock_ids;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function cleanup() {
+    $lock = \Drupal::service('ultimate_cron.lock');
+    $lock->cleanup();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function launch(CronJobInterface $job) {
+    \Drupal::moduleHandler()->invokeAll('cron_pre_launch', array($this));
+
+    if ($this->currentThread) {
+      $init_message = t('Launched in thread @current_thread', array(
+        '@current_thread' => $this->currentThread,
+      ));
+    }
+    else {
+      $init_message = t('Launched manually');
+    }
+
+    // Run job.
+    $job_launch = $job->run($init_message);
+    \Drupal::moduleHandler()->invokeAll('cron_post_launch', array($this));
+
+    return $job_launch;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function findFreeThread($lock, $lock_timeout = NULL, $timeout = 3) {
+    $configuration = $this->getConfiguration();
+
+    // Find a free thread, try for 3 seconds.
+    $delay = $timeout * 1000000;
+    $sleep = 25000;
+
+    $lock_service = \Drupal::service('ultimate_cron.lock');
+    do {
+      for ($thread = 1; $thread <= $configuration['launcher']['max_threads']; $thread++) {
+        if ($thread != $this->currentThread) {
+          $lock_name = 'ultimate_cron_serial_launcher_' . $thread;
+          if (!$lock_service->isLocked($lock_name)) {
+            if ($lock) {
+              if ($lock_id = $lock_service->lock($lock_name, $lock_timeout)) {
+                return array($thread, $lock_id);
+              }
+            }
+            else {
+              return array($thread, FALSE);
+            }
+          }
+        }
+      }
+      if ($delay > 0) {
+        usleep($sleep);
+        // After each sleep, increase the value of $sleep until it reaches
+        // 500ms, to reduce the potential for a lock stampede.
+        $delay = $delay - $sleep;
+        $sleep = min(500000, $sleep + 25000, $delay);
+      }
+    } while ($delay > 0);
+    return array(FALSE, FALSE);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function launchJobs(array $jobs) {
+    $lock = \Drupal::service('ultimate_cron.lock');
+    $configuration = $this->getConfiguration();
+
+    // Set proper max execution time.
+    $max_execution_time = ini_get('max_execution_time');
+    $lock_timeout = max($max_execution_time, $configuration['timeouts']['max_execution_time']);
+
+    // We only lock for 55 seconds at a time, to give room for other cron
+    // runs.
+    // @todo: Why hard-code this?
+    $lock_timeout = 55;
+
+    if (!empty($_GET['thread'])) {
+      self::setGlobalOption('thread', $_GET['thread']);
+    }
+
+    if ($thread = intval(self::getGlobalOption('thread'))) {
+      if ($thread < 1 || $thread > $configuration['launcher']['max_threads']) {
+        \Drupal::logger('serial_launcher')->warning("Invalid thread available for starting launch thread");
+        return;
+      }
+
+      $lock_name = 'ultimate_cron_serial_launcher_' . $thread;
+      $lock_id = NULL;
+      if (!$lock->isLocked($lock_name)) {
+        $lock_id = $lock->lock($lock_name, $lock_timeout);
+      }
+      if (!$lock_id) {
+        \Drupal::logger('serial_launcher')->warning("Thread @thread is already running", array(
+          '@thread' => $thread,
+        ));
+      }
+    }
+    else {
+      $timeout = 1;
+      list($thread, $lock_id) = $this->findFreeThread(TRUE, $lock_timeout, $timeout);
+    }
+    $this->currentThread = $thread;
+
+    if (!$thread) {
+      \Drupal::logger('serial_launcher')->warning("No free threads available for launching jobs");
+      return;
+    }
+
+    if ($max_execution_time && $max_execution_time < $configuration['timeouts']['max_execution_time']) {
+      set_time_limit($configuration['timeouts']['max_execution_time']);
+    }
+
+    $this->runThread($lock_id, $thread, $jobs);
+    $lock->unlock($lock_id);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function runThread($lock_id, $thread, $jobs) {
+    $lock = \Drupal::service('ultimate_cron.lock');
+    $lock_name = 'ultimate_cron_serial_launcher_' . $thread;
+    foreach ($jobs as $job) {
+      $configuration = $job->getConfiguration('launcher');
+      $configuration['launcher'] += array(
+        'thread' => 'any',
+      );
+      switch ($configuration['launcher']['thread']) {
+        case 'any':
+          $configuration['launcher']['thread'] = $thread;
+          break;
+
+        case 'fixed':
+          $configuration['launcher']['thread'] = ($job->getUniqueID() % $configuration['launcher']['max_threads']) + 1;
+          break;
+      }
+      if ((!self::getGlobalOption('thread') || $configuration['launcher']['thread'] == $thread) && $job->isScheduled()) {
+        $this->launch($job);
+        // Be friendly, and check if someone else has taken the lock.
+        // If they have, bail out, since someone else is now handling
+        // this thread.
+        if ($current_lock_id = $lock->isLocked($lock_name)) {
+          if ($current_lock_id !== $lock_id) {
+            return;
+          }
+        }
+        else {
+          // If lock is free, then take the lock again.
+          $lock_id = $lock->lock($lock_name);
+          if (!$lock_id) {
+            // Race-condition, someone beat us to it.
+            return;
+          }
+        }
+      }
+    }
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/Plugin/ultimate_cron/Logger/CacheLogger.php b/web/modules/ultimate_cron/src/Plugin/ultimate_cron/Logger/CacheLogger.php
new file mode 100644
index 0000000000000000000000000000000000000000..a97070c223682337927b8c907c1f6cb41f734c69
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Plugin/ultimate_cron/Logger/CacheLogger.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace Drupal\ultimate_cron\Plugin\ultimate_cron\Logger;
+
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\ultimate_cron\Logger\LogEntry;
+use Drupal\ultimate_cron\Logger\LoggerBase;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Cache Logger.
+ *
+ * @LoggerPlugin(
+ *   id = "cache",
+ *   title = @Translation("Cache"),
+ *   description = @Translation("Stores the last log entry (and only the last) in the cache."),
+ * )
+ */
+class CacheLogger extends LoggerBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * @var \Drupal\Core\Cache\CacheBackendInterface
+   */
+  protected $cache;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, CacheBackendInterface $cache) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->cache = $cache;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition)  {
+    $bin = isset($configuration['bin']) ? $configuration['bin'] : 'ultimate_cron_logger';
+    return new static ($configuration, $plugin_id, $plugin_definition, $container->get('cache.' . $bin));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return array(
+      'bin' => 'ultimate_cron_logger',
+      'timeout' => Cache::PERMANENT,
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function load($name, $lock_id = NULL, array $log_types = [ULTIMATE_CRON_LOG_TYPE_NORMAL]) {
+    $log_entry = new LogEntry($name, $this);
+    if (!$lock_id) {
+      $cache =  $this->cache->get('uc-name:' . $name, TRUE);
+      if (empty($cache) || empty($cache->data)) {
+        return $log_entry;
+      }
+      $lock_id = $cache->data;
+    }
+    $cache = $this->cache->get('uc-lid:' . $lock_id, TRUE);
+
+    if (!empty($cache->data)) {
+      $log_entry->setData((array) $cache->data);
+      $log_entry->finished = TRUE;
+    }
+    return $log_entry;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getLogEntries($name, array $log_types, $limit = 10) {
+    $log_entry = $this->load($name);
+    return $log_entry->lid ? array($log_entry) : array();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $form['bin'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Cache bin'),
+      '#description' => t('Select which cache bin to use for storing logs.'),
+      '#default_value' => $this->configuration['bin'],
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    );
+
+    $form['timeout'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Cache timeout'),
+      '#description' => t('Seconds before cache entry expires (0 = never, -1 = on next general cache wipe).'),
+      '#default_value' => $this->configuration['timeout'],
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    );
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function save(LogEntry $log_entry) {
+    if (!$log_entry->lid) {
+      return;
+    }
+
+    $settings = $this->getConfiguration();
+
+    $expire = $settings['timeout'] != Cache::PERMANENT ? REQUEST_TIME + $settings['timeout'] : $settings['timeout'];
+
+    $this->cache->set('uc-name:' . $log_entry->name, $log_entry->lid, $expire);
+    $this->cache->set('uc-lid:' . $log_entry->lid, $log_entry->getData(), $expire);
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/Plugin/ultimate_cron/Logger/DatabaseLogger.php b/web/modules/ultimate_cron/src/Plugin/ultimate_cron/Logger/DatabaseLogger.php
new file mode 100644
index 0000000000000000000000000000000000000000..dbd5dfba77a1ca96f9824ce37fe599a38807bfa6
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Plugin/ultimate_cron/Logger/DatabaseLogger.php
@@ -0,0 +1,400 @@
+<?php
+
+namespace Drupal\ultimate_cron\Plugin\ultimate_cron\Logger;
+
+use Drupal\Component\Utility\Unicode;
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Database\Database;
+use Drupal\Core\Database\DatabaseException;
+use Drupal\Core\Database\IntegrityConstraintViolationException;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\ultimate_cron\CronJobInterface;
+use Drupal\ultimate_cron\Entity\CronJob;
+use Drupal\ultimate_cron\Logger\LogEntry;
+use Drupal\ultimate_cron\Logger\LoggerBase;
+use Drupal\ultimate_cron\PluginCleanupInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Database logger.
+ *
+ * @LoggerPlugin(
+ *   id = "database",
+ *   title = @Translation("Database"),
+ *   description = @Translation("Stores logs in the database."),
+ *   default = TRUE,
+ * )
+ */
+class DatabaseLogger extends LoggerBase implements PluginCleanupInterface, ContainerFactoryPluginInterface {
+
+  const CLEANUP_METHOD_DISABLED = 1;
+  const CLEANUP_METHOD_EXPIRE = 2;
+  const CLEANUP_METHOD_RETAIN = 3;
+
+  /**
+   * Max length for message and init message fields.
+   */
+  const MAX_TEXT_LENGTH = 5000;
+
+  /**
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $connection;
+
+  /**
+   * Constructor.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, Connection $connection) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->connection = $connection;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static ($configuration, $plugin_id, $plugin_definition, $container->get('database'));
+  }
+
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return array(
+      'method' => static::CLEANUP_METHOD_RETAIN,
+      'expire' => 86400 * 14,
+      'retain' => 1000,
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function cleanup() {
+    $jobs = CronJob::loadMultiple();
+    $current = 1;
+    $max = 0;
+    $counter = 0;
+    $count_deleted = [];
+    foreach ($jobs as $job) {
+      if ($job->getLoggerId() === $this->getPluginId()) {
+        $max++;
+      }
+    }
+    foreach ($jobs as $job) {
+      if ($job->getLoggerId() === $this->getPluginId()) {
+        // Get the plugin through the job so it has the right configuration.
+        $counter = $job->getPlugin('logger')->cleanupJob($job);
+        $class = \Drupal::entityTypeManager()->getDefinition('ultimate_cron_job')->getClass();
+        if ($class::$currentJob) {
+          $class::$currentJob->setProgress($current / $max);
+          $current++;
+        }
+        if ($counter) {
+          // Store number of deleted messages for each job.
+          $count_deleted[$job->id()] = $counter;
+        }
+      }
+    }
+    if ($count_deleted) {
+      \Drupal::logger('database_logger')
+        ->info('@count_entries log entries removed for @jobs_count jobs', array(
+          '@count_entries' => array_sum($count_deleted),
+          '@jobs_count' => count($count_deleted),
+        ));
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function cleanupJob(CronJobInterface $job) {
+    switch ($this->configuration['method']) {
+      case static::CLEANUP_METHOD_DISABLED:
+        return;
+
+      case static::CLEANUP_METHOD_EXPIRE:
+        $expire = $this->configuration['expire'];
+        // Let's not delete more than ONE BILLION log entries :-o.
+        $max = 10000000000;
+        $chunk = 100;
+        break;
+
+      case static::CLEANUP_METHOD_RETAIN:
+        $expire = 0;
+        $max = $this->connection->query("SELECT COUNT(lid) FROM {ultimate_cron_log} WHERE name = :name", array(
+          ':name' => $job->id(),
+        ))->fetchField();
+        $max -= $this->configuration['retain'];
+        if ($max <= 0) {
+          return;
+        }
+        $chunk = min($max, 100);
+        break;
+
+      default:
+        \Drupal::logger('ultimate_cron')->warning('Invalid cleanup method: @method', array(
+          '@method' => $this->configuration['method'],
+        ));
+        return;
+    }
+
+    // Chunked delete.
+    $count = 0;
+    do {
+      $lids = $this->connection->select('ultimate_cron_log', 'l')
+        ->fields('l', array('lid'))
+        ->condition('l.name', $job->id())
+        ->condition('l.start_time', microtime(TRUE) - $expire, '<')
+        ->range(0, $chunk)
+        ->orderBy('l.start_time', 'ASC')
+        ->orderBy('l.end_time', 'ASC')
+        ->execute()
+        ->fetchCol();
+      if ($lids) {
+        $count += count($lids);
+        $max -= count($lids);
+        $chunk = min($max, 100);
+        $this->connection->delete('ultimate_cron_log')
+          ->condition('lid', $lids, 'IN')
+          ->execute();
+      }
+    } while ($lids && $max > 0);
+    return $count;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $form['method'] = array(
+      '#type' => 'select',
+      '#title' => t('Log entry cleanup method'),
+      '#description' => t('Select which method to use for cleaning up logs.'),
+      '#options' => $this->getMethodOptions(),
+      '#default_value' => $this->configuration['method'],
+    );
+
+    $form['expire'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Log entry expiration'),
+      '#description' => t('Remove log entries older than X seconds.'),
+      '#default_value' => $this->configuration['expire'],
+      '#fallback' => TRUE,
+      '#states' => array(
+        'visible' => array(
+          ':input[name="logger[settings][method]"]' => array('value' => static::CLEANUP_METHOD_EXPIRE),
+        ),
+        'required' => array(
+          ':input[name="logger[settings][method]"]' => array('value' => static::CLEANUP_METHOD_EXPIRE),
+        ),
+      ),
+    );
+
+    $form['retain'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Retain logs'),
+      '#description' => t('Retain X amount of log entries.'),
+      '#default_value' => $this->configuration['retain'],
+      '#fallback' => TRUE,
+      '#states' => array(
+        'visible' => array(
+          ':input[name="logger[settings][method]"]' => array('value' => static::CLEANUP_METHOD_RETAIN),
+        ),
+        'required' => array(
+          ':input[name="logger[settings][method]"]' => array('value' => static::CLEANUP_METHOD_RETAIN),
+        ),
+      ),
+    );
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function load($name, $lock_id = NULL, array $log_types = [ULTIMATE_CRON_LOG_TYPE_NORMAL]) {
+    if ($lock_id) {
+      $log_entry = $this->connection->select('ultimate_cron_log', 'l')
+        ->fields('l')
+        ->condition('l.lid', $lock_id)
+        ->execute()
+        ->fetchObject(LogEntry::class, array($name, $this));
+    }
+    else {
+      $log_entry = $this->connection->select('ultimate_cron_log', 'l')
+        ->fields('l')
+        ->condition('l.name', $name)
+        ->condition('l.log_type', $log_types, 'IN')
+        ->orderBy('l.start_time', 'DESC')
+        ->orderBy('l.end_time', 'DESC')
+        ->range(0, 1)
+        ->execute()
+        ->fetchObject(LogEntry::class, array($name, $this));
+    }
+    if ($log_entry) {
+      $log_entry->finished = TRUE;
+    }
+    else {
+      $log_entry = new LogEntry($name, $this);
+    }
+    return $log_entry;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function loadLatestLogEntries(array $jobs, array $log_types) {
+    if ($this->connection->databaseType() !== 'mysql') {
+      return parent::loadLatestLogEntries($jobs, $log_types);
+    }
+
+    $result = $this->connection->query("SELECT l.*
+    FROM {ultimate_cron_log} l
+    JOIN (
+      SELECT l3.name, (
+        SELECT l4.lid
+        FROM {ultimate_cron_log} l4
+        WHERE l4.name = l3.name
+        AND l4.log_type IN (:log_types)
+        ORDER BY l4.name desc, l4.start_time DESC
+        LIMIT 1
+      ) AS lid FROM {ultimate_cron_log} l3
+      GROUP BY l3.name
+    ) l2 on l2.lid = l.lid", array(':log_types' => $log_types));
+
+    $log_entries = array();
+    while ($object = $result->fetchObject()) {
+      if (isset($jobs[$object->name])) {
+        $log_entries[$object->name] = new LogEntry($object->name, $this);
+        $log_entries[$object->name]->setData((array) $object);
+      }
+    }
+    foreach ($jobs as $name => $job) {
+      if (!isset($log_entries[$name])) {
+        $log_entries[$name] = new LogEntry($name, $this);
+      }
+    }
+
+    return $log_entries;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getLogEntries($name, array $log_types, $limit = 10) {
+    $result = $this->connection->select('ultimate_cron_log', 'l')
+      ->fields('l')
+      ->extend('Drupal\Core\Database\Query\PagerSelectExtender')
+      ->condition('l.name', $name)
+      ->condition('l.log_type', $log_types, 'IN')
+      ->limit($limit)
+      ->orderBy('l.start_time', 'DESC')
+      ->execute();
+
+    $log_entries = array();
+    while ($object = $result->fetchObject(LogEntry::class, array(
+      $name,
+      $this
+    ))) {
+      $log_entries[$object->lid] = $object;
+    }
+
+    return $log_entries;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function save(LogEntry $log_entry) {
+    if (!$log_entry->lid) {
+      return;
+    }
+
+    try {
+      $this->connection->insert('ultimate_cron_log')
+        ->fields([
+          'lid' => $log_entry->lid,
+          'name' => $log_entry->name,
+          'log_type' => $log_entry->log_type,
+          'start_time' => $log_entry->start_time,
+          'end_time' => $log_entry->end_time,
+          'uid' => $log_entry->uid,
+          'init_message' => Unicode::truncate((string) $log_entry->init_message, static::MAX_TEXT_LENGTH, FALSE, TRUE),
+          'message' => Unicode::truncate((string) $log_entry->message, static::MAX_TEXT_LENGTH, FALSE, TRUE),
+          'severity' => $log_entry->severity,
+        ])
+        ->execute();
+    }
+    catch (IntegrityConstraintViolationException $e) {
+      // Row already exists. Let's update it, if we can.
+      $updated = $this->connection->update('ultimate_cron_log')
+        ->fields([
+          'name' => $log_entry->name,
+          'log_type' => $log_entry->log_type,
+          'start_time' => $log_entry->start_time,
+          'end_time' => $log_entry->end_time,
+          'init_message' => Unicode::truncate((string) $log_entry->init_message, static::MAX_TEXT_LENGTH, FALSE, TRUE),
+          'message' => Unicode::truncate((string) $log_entry->message, static::MAX_TEXT_LENGTH, FALSE, TRUE),
+          'severity' => $log_entry->severity,
+        ])
+        ->condition('lid', $log_entry->lid)
+        ->condition('end_time', 0)
+        ->execute();
+      if (!$updated) {
+        // Row was not updated, someone must have beaten us to it.
+        // Let's create a new log entry.
+        $lid = $log_entry->lid . '-' . uniqid('', TRUE);
+        $log_entry->message = (string) t('Lock #@original_lid was already closed and logged. Creating a new log entry #@lid', [
+            '@original_lid' => $log_entry->lid,
+            '@lid' => $lid,
+          ]) . "\n" . $log_entry->message;
+        $log_entry->severity = $log_entry->severity >= 0 && $log_entry->severity < RfcLogLevel::ERROR ? $log_entry->severity : RfcLogLevel::ERROR;
+        $log_entry->lid = $lid;
+
+        $this->save($log_entry);
+      }
+    }
+    catch (\Exception $e) {
+      // In case the insert statement above results in a database exception.
+      // To ensure that the causal error is written to the log,
+      // we try once to open a dedicated connection and write again.
+      if (
+        // Only handle database related exceptions.
+        ($e instanceof DatabaseException || $e instanceof \PDOException) &&
+        // Avoid an endless loop of re-write attempts.
+        $this->connection->getTarget() != 'ultimate_cron' &&
+        !\Drupal::config('ultimate_cron')->get('bypass_transactional_safe_connection')
+      ) {
+
+        $key = $this->connection->getKey();
+        $info = Database::getConnectionInfo($key);
+        Database::addConnectionInfo($key, 'ultimate_cron', $info['default']);
+        $this->connection = Database::getConnection('ultimate_cron', $key);
+
+        // Now try once to log the error again.
+        $this->save($log_entry);
+      }
+      else {
+        throw $e;
+      }
+    }
+  }
+
+  /**
+   * Returns the method options.
+   *
+   * @return array
+   */
+  protected function getMethodOptions() {
+    return array(
+      static::CLEANUP_METHOD_DISABLED => t('Disabled'),
+      static::CLEANUP_METHOD_EXPIRE => t('Remove logs older than a specified age'),
+      static::CLEANUP_METHOD_RETAIN => t('Retain only a specific amount of log entries'),
+    );
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/Plugin/ultimate_cron/Scheduler/Crontab.php b/web/modules/ultimate_cron/src/Plugin/ultimate_cron/Scheduler/Crontab.php
new file mode 100644
index 0000000000000000000000000000000000000000..1795ad55a873ffaf26be5e55dad58c55724c70c1
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Plugin/ultimate_cron/Scheduler/Crontab.php
@@ -0,0 +1,193 @@
+<?php
+
+namespace Drupal\ultimate_cron\Plugin\ultimate_cron\Scheduler;
+
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\ultimate_cron\CronJobInterface;
+use Drupal\ultimate_cron\CronRule;
+use Drupal\ultimate_cron\Entity\CronJob;
+
+/**
+ * Crontab scheduler.
+ *
+ * @SchedulerPlugin(
+ *   id = "crontab",
+ *   title = @Translation("Crontab"),
+ *   description = @Translation("Use crontab rules for scheduling jobs."),
+ * )
+ */
+class Crontab extends SchedulerBase {
+  /**
+   * Default settings.
+   * @todo: $catch_up is randomly failing when value is low in some situation. 0 value is ignoring catch_up checks.
+   */
+  public function defaultConfiguration() {
+    return array(
+      'rules' => array('0+@ */3 * * *'),
+      'catch_up' => '0',
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function formatLabel(CronJob $job) {
+    return implode("\n", $this->configuration['rules']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function formatLabelVerbose(CronJob $job) {
+    $parsed = '';
+    $next_schedule = NULL;
+    $time = REQUEST_TIME;
+    $skew = $this->getSkew($job);
+    foreach ($this->configuration['rules'] as $rule) {
+      $cron = CronRule::factory($rule, $time, $skew);
+      $parsed .= $cron->parseRule() . "\n";
+      $result = $cron->getNextSchedule();
+      $next_schedule = is_null($next_schedule) || $next_schedule > $result ? $result : $next_schedule;
+      $result = $cron->getLastSchedule();
+      if ($time < $result + $this->configuration['catch_up']) {
+        $result = floor($time / 60) * 60 + 60;
+        $next_schedule = $next_schedule > $result ? $result : $next_schedule;
+      }
+    }
+    $parsed .= t('Next scheduled run at @datetime', array(
+      '@datetime' => \Drupal::service('date.formatter')->format($next_schedule, 'custom', 'Y-m-d H:i:s')
+    ));
+    return $parsed;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $form['rules'][0] = array(
+      '#title' => t("Rules"),
+      '#type' => 'textfield',
+      '#default_value' =>  $this->configuration['rules'],
+      '#description' => t('Comma separated list of crontab rules.'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+      // @todo: check this out.
+      //'#element_validate' => array('ultimate_cron_plugin_crontab_element_validate_rule'),
+    );
+
+    $form['rules_help'] = array(
+      '#type' => 'fieldset',
+      '#title' => t('Rules help'),
+      '#collapsible' => TRUE,
+      '#collapsed' => TRUE,
+    );
+
+    $form['rules_help']['info'] = array(
+      '#markup' => file_get_contents(drupal_get_path('module', 'ultimate_cron') . '/help/rules.html'),
+    );
+
+    $form['catch_up'] = array(
+      '#title' => t("Catch up"),
+      '#type' => 'textfield',
+      '#default_value' => $this->configuration['catch_up'],
+      '#description' => t("Don't run job after X seconds of rule."),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    );
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
+    parent::validateConfigurationForm($form, $form_state);
+    $rule = $form_state->getValues()['scheduler']['configuration']['rules'][0];
+    $cron = CronRule::factory($rule);
+    if (!$cron->isValid()) {
+      $form_state->setErrorByName('scheduler][configuration][rules][0', t('Rule is invalid'));
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function settingsFormSubmit(&$form, &$form_state, CronJob $job = NULL) {
+    $values = & $form_state['values']['settings'][$this->type][$this->name];
+
+    if (!empty($values['rules'])) {
+      $rules = explode(',', $values['rules']);
+      $values['rules'] = array_map('trim', $rules);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isScheduled(CronJob $job) {
+    $log_entry = isset($job->log_entry) ? $job->log_entry : $job->loadLatestLogEntry();
+    $skew = $this->getSkew($job);
+    $class = get_class($this);
+    return $class::shouldRun($this->configuration['rules'], $log_entry->start_time, NULL, $this->configuration['catch_up'], $skew) ? TRUE : FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  static public function shouldRun($rules, $job_last_ran, $time = NULL, $catch_up = 0, $skew = 0) {
+    $time = is_null($time) ? time() : $time;
+    foreach ($rules as $rule) {
+      $cron = CronRule::factory($rule, $time, $skew);
+      $cron_last_ran = $cron->getLastSchedule();
+
+      // @todo: Right now second test is failing randomly on low $catch_up value.
+      if ($job_last_ran < $cron_last_ran && $cron_last_ran <= $time) {
+        if ($time <= $cron_last_ran + $catch_up || $catch_up == 0) {
+          return $time - $job_last_ran;
+        }
+      }
+    }
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isBehind(CronJob $job) {
+    // Disabled jobs are not behind!
+    if (!$job->status()) {
+      return FALSE;
+    }
+
+    $log_entry = isset($job->log_entry) ? $job->log_entry : $job->loadLatestLogEntry();
+    // If job hasn't run yet, then who are we to say it's behind its schedule?
+    // Check the registered time, and use that if it's available.
+    $job_last_ran = $log_entry->start_time;
+    if (!$job_last_ran) {
+      $registered = \Drupal::config('ultimate_cron')->get('ultimate_cron_hooks_registered');
+      if (empty($registered[$job->id()])) {
+        return FALSE;
+      }
+      $job_last_ran = $registered[$job->id()];
+    }
+
+    $skew = $this->getSkew($job);
+    $next_schedule = NULL;
+    foreach ($this->configuration['rules'] as $rule) {
+      $cron = CronRule::factory($rule, $job_last_ran, $skew);
+      $time = $cron->getNextSchedule();
+      $next_schedule = is_null($next_schedule) || $time < $next_schedule ? $time : $next_schedule;
+    }
+    $behind = REQUEST_TIME - $next_schedule;
+
+    return $behind > $this->configuration['catch_up'] ? $behind : FALSE;
+  }
+
+  /**
+   * Get a "unique" skew for a job.
+   */
+  protected function getSkew(CronJob $job) {
+    return $job->getUniqueID() & 0xff;
+  }
+}
diff --git a/web/modules/ultimate_cron/src/Plugin/ultimate_cron/Scheduler/SchedulerBase.php b/web/modules/ultimate_cron/src/Plugin/ultimate_cron/Scheduler/SchedulerBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..8411c6e883ee72031dd730f21191419732c6de15
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Plugin/ultimate_cron/Scheduler/SchedulerBase.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\ultimate_cron\Plugin\ultimate_cron\Scheduler;
+use Drupal\ultimate_cron\Entity\CronJob;
+use Drupal\ultimate_cron\CronPlugin;
+use Drupal\ultimate_cron\Scheduler\SchedulerInterface;
+
+/**
+ * Abstract class for Ultimate Cron schedulers
+ *
+ * A scheduler is responsible for telling Ultimate Cron whether a job should
+ * run or not.
+ *
+ * Abstract methods:
+ *   isScheduled($job)
+ *     - Check if the given job is scheduled for launch at this time.
+ *       TRUE if it's scheduled for launch, otherwise FALSE.
+ *
+ *   isBehind($job)
+ *     - Check if the given job is behind its schedule.
+ *       FALSE if not behind, otherwise the amount of time it's behind
+ *       in seconds.
+ */
+abstract class SchedulerBase extends CronPlugin implements SchedulerInterface {
+  /**
+   * Check job schedule.
+   *
+   * @param CronJob $job
+   *   The job to check schedule for.
+   *
+   * @return boolean
+   *   TRUE if job is scheduled to run.
+   */
+  abstract public function isScheduled(CronJob $job);
+
+  /**
+   * Check if job is behind schedule.
+   *
+   * @param \Drupal\ultimate_cron\Entity\CronJob $job
+   *   The job to check schedule for.
+   *
+   * @return bool|int
+   *   FALSE if job is behind its schedule or number of seconds behind.
+   */
+  abstract public function isBehind(CronJob $job);
+}
diff --git a/web/modules/ultimate_cron/src/Plugin/ultimate_cron/Scheduler/Simple.php b/web/modules/ultimate_cron/src/Plugin/ultimate_cron/Scheduler/Simple.php
new file mode 100644
index 0000000000000000000000000000000000000000..b65b217104705991beb15c899c766405fa60b941
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Plugin/ultimate_cron/Scheduler/Simple.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace Drupal\ultimate_cron\Plugin\ultimate_cron\Scheduler;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\ultimate_cron\CronRule;
+use Drupal\ultimate_cron\Entity\CronJob;
+
+/**
+ * Simple scheduler.
+ *
+ * @SchedulerPlugin(
+ *   id = "simple",
+ *   title = @Translation("Simple"),
+ *   description = @Translation("Provides a set of predefined intervals for scheduling."),
+ * )
+ */
+class Simple extends Crontab {
+
+  public $presets = array(
+    '* * * * *' => 60,
+    '*/5+@ * * * *' => 300,
+    '*/15+@ * * * *' => 900,
+    '*/30+@ * * * *' => 1800,
+    '0+@ * * * *' => 3600,
+    '0+@ */3 * * *' => 10800,
+    '0+@ */6 * * *' => 21600,
+    '0+@ */12 * * *' => 43200,
+    '0+@ 0 * * *' => 86400,
+    '0+@ 0 * * 0' => 604800,
+  );
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return array(
+      'rules' => array('*/15+@ * * * *'),
+    ) + parent::defaultConfiguration();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function settingsLabel($name, $value) {
+    switch ($name) {
+      case 'rules':
+        return isset($value[0]) ? \Drupal::service('date.formatter')->formatInterval($this->presets[$value[0]]) : $value;
+    }
+    return parent::settingsLabel($name, $value);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function formatLabel(CronJob $job) {
+    return t('Every @interval', array(
+      '@interval' => \Drupal::service('date.formatter')->formatInterval($this->presets[$this->configuration['rules'][0]]),
+    ));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $date_formatter = \Drupal::service('date.formatter');
+    $intervals = array_map(array($date_formatter, 'formatInterval'), $this->presets);
+
+    $form['rules'][0] = array(
+      '#type' => 'select',
+      '#title' => t('Run cron every'),
+      '#default_value' => $this->configuration['rules'][0],
+      '#description' => t('Select the interval you wish cron to run on.'),
+      '#options' => $intervals,
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    );
+
+    return $form;
+  }
+}
diff --git a/web/modules/ultimate_cron/src/PluginCleanupInterface.php b/web/modules/ultimate_cron/src/PluginCleanupInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..4df5eee6f9c3bd1db4df0aba140dd881a08ce9c9
--- /dev/null
+++ b/web/modules/ultimate_cron/src/PluginCleanupInterface.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Drupal\ultimate_cron;
+
+/**
+ * Plugins can implement this to be invoked for regular cleanup tasks.
+ */
+interface PluginCleanupInterface {
+
+  /**
+   * Cleans and purges data stored by this plugin.
+   */
+  function cleanup();
+
+}
diff --git a/web/modules/ultimate_cron/src/Progress/Progress.php b/web/modules/ultimate_cron/src/Progress/Progress.php
new file mode 100644
index 0000000000000000000000000000000000000000..859a370c6ef4fde30dea915a1462ae3e91919132
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Progress/Progress.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Drupal\ultimate_cron\Progress;
+
+use Drupal\Core\KeyValueStore\KeyValueFactory;
+use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
+
+class Progress implements ProgressInterface {
+  protected $progressUpdated = 0;
+  protected $interval = 1;
+
+  /**
+   * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface
+   */
+  protected $keyValue;
+
+  /**
+   * Constructor.
+   *
+   * @param float $interval
+   *   How often the database should be updated with the progress.
+   */
+  public function __construct(KeyValueFactoryInterface $key_value_factory, $interval = 1) {
+    $this->keyValue = $key_value_factory->get('uc-progress');
+    $this->interval = $interval;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getProgress($job_id) {
+    $value = $this->keyValue->get($job_id);
+    return $value;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+   public function getProgressMultiple($job_ids) {
+    $values = $this->keyValue->getMultiple($job_ids);
+
+    return $values;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setProgress($job_id, $progress) {
+    if (microtime(TRUE) >= $this->progressUpdated + $this->interval) {
+      $this->keyValue->set($job_id, $progress);
+
+      $this->progressUpdated = microtime(TRUE);
+      return TRUE;
+    }
+    return FALSE;
+  }
+}
diff --git a/web/modules/ultimate_cron/src/Progress/ProgressInterface.php b/web/modules/ultimate_cron/src/Progress/ProgressInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..e4661c1f36017d7fdf75a53b103e5b465e81df50
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Progress/ProgressInterface.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Drupal\ultimate_cron\Progress;
+
+interface ProgressInterface {
+
+  /**
+   * Set job progress.
+   *
+   * @param string $job_id
+   *   Cron Job id.
+   *
+   * @param float $progress
+   *   The progress (0 - 1).
+   */
+  public function setProgress($job_id, $progress);
+
+  /**
+   * Get multiple job progresses.
+   *
+   * @param array $job_ids
+   *   Job ids to get progress for.
+   *
+   * @return array
+   *   Progress of jobs, keyed by job name.
+   */
+  public function getProgressMultiple($job_ids);
+
+  /**
+   * Get job progress.
+   *
+   * @param string $job_id
+   *   Cron Job id.
+   *
+   * @return float
+   *   The progress of this job.
+   */
+  public function getProgress($job_id);
+}
diff --git a/web/modules/ultimate_cron/src/Progress/ProgressMemcache.php b/web/modules/ultimate_cron/src/Progress/ProgressMemcache.php
new file mode 100644
index 0000000000000000000000000000000000000000..dedb9c3b4319c1ecd175f699b28180f06154fcde
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Progress/ProgressMemcache.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Drupal\ultimate_cron\Progress;
+
+use Drupal\ultimate_cron\Progress;
+
+class ProgressMemcache {
+  public $name;
+  public $progressUpdated = 0;
+  public $interval = 1;
+  static public $instances = array();
+
+  /**
+   * Constructor.
+   *
+   * @param string $name
+   *   Name of job.
+   * @param float $interval
+   *   How often the database should be updated with the progress.
+   */
+  public function __construct($name, $interval = 1) {
+    $this->name = $name;
+    $this->interval = $interval;
+  }
+
+  /**
+   * Singleton factory.
+   *
+   * @param string $name
+   *   Name of job.
+   * @param float $interval
+   *   How often the database should be updated with the progress.
+   *
+   * @return Progress
+   *   The object.
+   */
+  static public function factory($name, $interval = 1) {
+    if (!isset(self::$instances[$name])) {
+      self::$instances[$name] = new ProgressMemcache($name, $interval);
+    }
+    self::$instances[$name]->interval = $interval;
+    return self::$instances[$name];
+  }
+
+  /**
+   * Get job progress.
+   *
+   * @return float
+   *   The progress of this job.
+   */
+  public function getProgress() {
+    $name = 'uc-progress:' . $this->name;
+    $bin = variable_get('ultimate_cron_progress_memcache_bin', 'progress');
+    return dmemcache_get($name, $bin);
+  }
+
+  /**
+   * Get multiple job progresses.
+   *
+   * @param array $names
+   *   Job names to get progress for.
+   *
+   * @return array
+   *   Progress of jobs, keyed by job name.
+   */
+  static public function getProgressMultiple($names) {
+    $keys = array();
+    foreach ($names as $name) {
+      $keys[] = 'uc-progress:' . $name;
+    }
+    $bin = variable_get('ultimate_cron_progress_memcache_bin', 'progress');
+    $values = dmemcache_get_multi($keys, $bin);
+
+    $result = array();
+    foreach ($names as $name) {
+      $result[$name] = isset($values['uc-progress:' . $name]) ? $values['uc-progress:' . $name] : FALSE;
+    }
+    return $result;
+  }
+
+  /**
+   * Set job progress.
+   *
+   * @param float $progress
+   *   The progress (0 - 1).
+   */
+  public function setProgress($progress) {
+    if (microtime(TRUE) >= $this->progressUpdated + $this->interval) {
+      $name = 'uc-progress:' . $this->name;
+      $bin = variable_get('ultimate_cron_progress_memcache_bin', 'progress');
+      dmemcache_set($name, $progress, 0, $bin);
+      $this->progressUpdated = microtime(TRUE);
+      return TRUE;
+    }
+    return FALSE;
+  }
+}
diff --git a/web/modules/ultimate_cron/src/ProxyClass/UltimateCron.php b/web/modules/ultimate_cron/src/ProxyClass/UltimateCron.php
new file mode 100644
index 0000000000000000000000000000000000000000..ec959a614c94774c4e9036d2b611060119545c22
--- /dev/null
+++ b/web/modules/ultimate_cron/src/ProxyClass/UltimateCron.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\ultimate_cron\ProxyClass {
+
+    /**
+     * Provides a proxy class for \Drupal\ultimate_cron\UltimateCron.
+     *
+     * @see \Drupal\Component\ProxyBuilder
+     */
+    class UltimateCron implements \Drupal\Core\CronInterface
+    {
+
+        use \Drupal\Core\DependencyInjection\DependencySerializationTrait;
+
+        /**
+         * The id of the original proxied service.
+         *
+         * @var string
+         */
+        protected $drupalProxyOriginalServiceId;
+
+        /**
+         * The real proxied service, after it was lazy loaded.
+         *
+         * @var \Drupal\ultimate_cron\UltimateCron
+         */
+        protected $service;
+
+        /**
+         * The service container.
+         *
+         * @var \Symfony\Component\DependencyInjection\ContainerInterface
+         */
+        protected $container;
+
+        /**
+         * Constructs a ProxyClass Drupal proxy object.
+         *
+         * @param \Symfony\Component\DependencyInjection\ContainerInterface $container
+         *   The container.
+         * @param string $drupal_proxy_original_service_id
+         *   The service ID of the original service.
+         */
+        public function __construct(\Symfony\Component\DependencyInjection\ContainerInterface $container, $drupal_proxy_original_service_id)
+        {
+            $this->container = $container;
+            $this->drupalProxyOriginalServiceId = $drupal_proxy_original_service_id;
+        }
+
+        /**
+         * Lazy loads the real service from the container.
+         *
+         * @return object
+         *   Returns the constructed real service.
+         */
+        protected function lazyLoadItself()
+        {
+            if (!isset($this->service)) {
+                $this->service = $this->container->get($this->drupalProxyOriginalServiceId);
+            }
+
+            return $this->service;
+        }
+
+        /**
+         * {@inheritdoc}
+         */
+        public function run()
+        {
+            return $this->lazyLoadItself()->run();
+        }
+
+    }
+
+}
diff --git a/web/modules/ultimate_cron/src/QueueSettings.php b/web/modules/ultimate_cron/src/QueueSettings.php
new file mode 100644
index 0000000000000000000000000000000000000000..cb334d4b554483f019686b28eca0af6fcd696b26
--- /dev/null
+++ b/web/modules/ultimate_cron/src/QueueSettings.php
@@ -0,0 +1,377 @@
+<?php
+
+namespace Drupal\ultimate_cron;
+
+use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\ultimate_cron\Entity\CronJob;
+use Drupal\ultimate_cron\TaggedSettings;
+
+/**
+ * Queue settings plugin class.
+ */
+class QueueSettings extends TaggedSettings {
+  static private $throttled = array();
+  static private $queues = NULL;
+
+  /**
+   * Get cron queues and static cache them.
+   *
+   * Works like module_invoke_all('cron_queue_info'), but adds
+   * a 'module' to each item.
+   *
+   * @return array
+   *   Cron queue definitions.
+   */
+  private function get_queues() {
+    if (!isset(self::$queues)) {
+      $queues = array();
+      foreach (module_implements('cron_queue_info') as $module) {
+        $items = module_invoke($module, 'cron_queue_info');
+        if (is_array($items)) {
+          foreach ($items as &$item) {
+            $item['module'] = $module;
+          }
+          $queues += $items;
+        }
+      }
+      drupal_alter('cron_queue_info', $queues);
+      self::$queues = $queues;
+    }
+    return $queues;
+  }
+
+  /**
+   * Implements hook_cronapi().
+   */
+  public function cronapi() {
+    $items = array();
+    if (!variable_get($this->key . '_enabled', TRUE)) {
+      return $items;
+    }
+
+    // Grab the defined cron queues.
+    $queues = self::get_queues();
+
+    foreach ($queues as $name => $info) {
+      if (!empty($info['skip on cron'])) {
+        continue;
+      }
+
+      $items['queue_' . $name] = array(
+        'title' => t('Queue: @name', array('@name' => $name)),
+        'callback' => array(get_class($this), 'worker_callback'),
+        'scheduler' => array(
+          'simple' => array(
+            'rules' => array('* * * * *'),
+          ),
+          'crontab' => array(
+            'rules' => array('* * * * *'),
+          ),
+        ),
+        'settings' => array(
+          'queue' => array(
+            'name' => $name,
+            'worker callback' => $info['worker callback'],
+          ),
+        ),
+        'tags' => array('queue', 'core', 'killable'),
+        'module' => $info['module'],
+      );
+      if (isset($info['time'])) {
+        $items['queue_' . $name]['settings']['queue']['time'] = $info['time'];
+      }
+    }
+
+    return $items;
+  }
+
+  /**
+   * Process a cron queue.
+   *
+   * This is a wrapper around the cron queues "worker callback".
+   *
+   * @param CronJob $job
+   *   The job being run.
+   */
+  static public function worker_callback($job) {
+    $settings = $job->getPluginSettings('settings');
+    $queue = DrupalQueue::get($settings['queue']['name']);
+    $function = $settings['queue']['worker callback'];
+
+    $end = microtime(TRUE) + $settings['queue']['time'];
+    $items = 0;
+    while (microtime(TRUE) < $end) {
+      if ($job->getSignal('kill')) {
+        \Drupal::logger('ultimate_cron')->warning('kill signal recieved');
+        break;
+      }
+
+      $item = $queue->claimItem($settings['queue']['lease_time']);
+      if (!$item) {
+        if ($settings['queue']['empty_delay']) {
+          usleep($settings['queue']['empty_delay'] * 1000000);
+          continue;
+        }
+        else {
+          break;
+        }
+      }
+      try {
+        if ($settings['queue']['item_delay']) {
+          if ($items == 0) {
+            // Move the boundary if using a throttle, to avoid waiting for nothing.
+            $end -= $settings['queue']['item_delay'] * 1000000;
+          }
+          else {
+            // Sleep before retrieving.
+            usleep($settings['queue']['item_delay'] * 1000000);
+          }
+        }
+        $function($item->data);
+        $queue->deleteItem($item);
+        $items++;
+      }
+      catch (Exception $e) {
+        // Just continue ...
+        \Drupal::logger($job->hook['module'])->error("Queue item @item_id from queue @queue failed with message @message", array(
+          '@item_id' => $item->item_id,
+          '@queue' => $settings['queue']['name'],
+          '@message' => $e->getMessage()
+        ));
+      }
+    }
+    \Drupal::logger($job->hook['module'])->info('Processed @items items from queue @queue', array(
+      '@items' => $items,
+      '@queue' => $settings['queue']['name'],
+    ));
+
+    // Re-throttle.
+    $job->getPlugin('settings', 'queue')->throttle($job);
+
+    return;
+  }
+
+  /**
+   * Implements hook_cron_alter().
+   */
+  public function cron_alter(&$jobs) {
+    $new_jobs = array();
+    foreach ($jobs as $job) {
+      if (!$this->isValid($job)) {
+        continue;
+      }
+      $settings = $job->getSettings();
+      if (isset($settings['settings']['queue']['name'])) {
+        if ($settings['settings']['queue']['throttle']) {
+          for ($i = 2; $i <= $settings['settings']['queue']['threads']; $i++) {
+            $name = $job->id() . '_' . $i;
+            $hook = $job->hook;
+            $hook['settings']['queue']['master'] = $job->id();
+            $hook['settings']['queue']['thread'] = $i;
+            $hook['name'] = $name;
+            $hook['title'] .= " (#$i)";
+            $hook['immutable'] = TRUE;
+            $new_jobs[$name] = ultimate_cron_prepare_job($name, $hook);
+            $new_jobs[$name]->settings = $settings + $new_jobs[$name]->settings;
+            $new_jobs[$name]->title = $job->title . " (#$i)";
+          }
+        }
+      }
+    }
+    $jobs += $new_jobs;
+  }
+
+  /**
+   * Implements hook_cron_alter().
+   */
+  public function cron_pre_schedule($job) {
+    $queue_name = !empty($job->hook['settings']['queue']['name']) ? $job->hook['settings']['queue']['name'] : FALSE;
+    if ($queue_name) {
+      if (empty(self::$throttled[$job->id()])) {
+        self::$throttled[$job->id()] = TRUE;
+        $this->throttle($job);
+      }
+    }
+  }
+
+  /**
+   * Default settings.
+   */
+  public function defaultSettings() {
+    return array(
+      'lease_time' => 30,
+      'empty_delay' => 0,
+      'item_delay' => 0,
+      'throttle' => FALSE,
+      'threads' => 4,
+      'threshold' => 10,
+      'time' => 15,
+    );
+  }
+
+  /**
+   * Settings form.
+   */
+  public function settingsForm(&$form, &$form_state, $job = NULL) {
+    $elements = &$form['settings'][$this->type][$this->name];
+    $values = &$form_state['values']['settings'][$this->type][$this->name];
+
+    $states = array();
+    if (!$job) {
+      $elements['enabled'] = array(
+        '#title' => t('Enable cron queue processing'),
+        '#description' => t('If enabled, cron queues will be processed by this plugin. If another cron queue plugin is installed, it may be necessary/beneficial to disable this plugin.'),
+        '#type' => 'checkbox',
+        '#default_value' => variable_get($this->key . '_enabled', TRUE),
+        '#fallback' => TRUE,
+      );
+      $states = array(
+        '#states' => array(
+          'visible' => array(
+            ':input[name="settings[' . $this->type . '][' . $this->name . '][enabled]"]' => array(
+              'checked' => TRUE,
+            ),
+          ),
+        ),
+      );
+    }
+
+    $elements['timeouts'] = array(
+      '#type' => 'fieldset',
+      '#title' => t('Timeouts'),
+    ) + $states;
+    $elements['timeouts']['lease_time'] = array(
+      '#parents' => array('settings', $this->type, $this->name, 'lease_time'),
+      '#title' => t("Queue lease time"),
+      '#type' => 'textfield',
+      '#default_value' => $values['lease_time'],
+      '#description' => t('Seconds to claim a cron queue item.'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    );
+    $elements['timeouts']['time'] = array(
+      '#parents' => array('settings', $this->type, $this->name, 'time'),
+      '#title' => t('Time'),
+      '#type' => 'textfield',
+      '#default_value' => $values['time'],
+      '#description' => t('Time in seconds to process items during a cron run.'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    );
+
+    $elements['delays'] = array(
+      '#type' => 'fieldset',
+      '#title' => t('Delays'),
+    ) + $states;
+    $elements['delays']['empty_delay'] = array(
+      '#parents' => array('settings', $this->type, $this->name, 'empty_delay'),
+      '#title' => t("Empty delay"),
+      '#type' => 'textfield',
+      '#default_value' => $values['empty_delay'],
+      '#description' => t('Seconds to delay processing of queue if queue is empty (0 = end job).'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    );
+    $elements['delays']['item_delay'] = array(
+      '#parents' => array('settings', $this->type, $this->name, 'item_delay'),
+      '#title' => t("Item delay"),
+      '#type' => 'textfield',
+      '#default_value' => $values['item_delay'],
+      '#description' => t('Seconds to wait between processing each item in a queue.'),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    );
+
+    $elements['throttle'] = array(
+      '#title' => t('Throttle'),
+      '#type' => 'checkbox',
+      '#default_value' => $values['throttle'],
+      '#description' => t('Throttle queues using multiple threads.'),
+    );
+
+    $states = !$job ? $states : array(
+      '#states' => array(
+        'visible' => array(':input[name="settings[' . $this->type . '][' . $this->name . '][throttle]"]' => array('checked' => TRUE))
+      ),
+    );
+
+    $elements['throttling'] = array(
+      '#type' => 'fieldset',
+      '#title' => t('Throttling'),
+    ) + $states;
+    $elements['throttling']['threads'] = array(
+      '#parents' => array('settings', $this->type, $this->name, 'threads'),
+      '#title' => t('Threads'),
+      '#type' => 'textfield',
+      '#default_value' => $values['threads'],
+      '#description' => t('Number of threads to use for queues.'),
+      '#states' => array(
+        'visible' => array(':input[name="settings[' . $this->type . '][' . $this->name . '][throttle]"]' => array('checked' => TRUE))
+      ),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    );
+    $elements['throttling']['threshold'] = array(
+      '#parents' => array('settings', $this->type, $this->name, 'threshold'),
+      '#title' => t('Threshold'),
+      '#type' => 'textfield',
+      '#default_value' => $values['threshold'],
+      '#description' => t('Number of items in queue required to activate the next cron job.'),
+      '#states' => array(
+        'visible' => array(':input[name="settings[' . $this->type . '][' . $this->name . '][throttle]"]' => array('checked' => TRUE))
+      ),
+      '#fallback' => TRUE,
+      '#required' => TRUE,
+    );
+  }
+
+  /**
+   * Form submit handler.
+   */
+  public function settingsFormSubmit(&$form, &$form_state, $job = NULL) {
+    if (!$job) {
+      $values = &$form_state['values']['settings'][$this->type][$this->name];
+      variable_set($this->key . '_enabled', $values['enabled']);
+      unset($values['enabled']);
+    }
+  }
+
+  /**
+   * Throttle queues.
+   *
+   * Enables or disables queue threads depending on remaining items in queue.
+   */
+  public function throttle($job) {
+    if (!empty($job->hook['settings']['queue']['master'])) {
+      // We always base the threads on the master.
+      $master_job = ultimate_cron_job_load($job->hook['settings']['queue']['master']);
+      $settings = $master_job->getSettings('settings');
+    }
+    else {
+      return;
+    }
+    if ($settings['queue']['throttle']) {
+      $queue = DrupalQueue::get($settings['queue']['name']);
+      $items = $queue->numberOfItems();
+      $thread = $job->hook['settings']['queue']['thread'];
+
+      $name = $master_job->name . '_' . $thread;
+      $status = empty($master_job->disabled) && ($items >= ($thread - 1) * $settings['queue']['threshold']);
+      $new_status = !$status ? TRUE : FALSE;
+      $old_status = ultimate_cron_job_get_status($name) ? TRUE : FALSE;
+      if ($old_status !== $new_status) {
+        $log_entry = $job->startLog(uniqid($job->id(), TRUE), 'throttling', ULTIMATE_CRON_LOG_TYPE_ADMIN);
+        $log_entry->log('Job @status by queue throttling (items:@items, boundary:@boundary, threshold:@threshold)', array(
+          '@status' => $new_status ? t('disabled') : t('enabled'),
+          '@items' => $items,
+          '@boundary' => ($thread - 1) * $settings['queue']['threshold'],
+          '@threshold' => $settings['queue']['threshold'],
+        ), RfcLogLevel::INFO);
+        $log_entry->finish();
+        $job->dont_log = TRUE;
+        ultimate_cron_job_set_status($job, $new_status);
+        $job->disabled = $new_status;
+      }
+    }
+  }
+}
diff --git a/web/modules/ultimate_cron/src/QueueWorker.php b/web/modules/ultimate_cron/src/QueueWorker.php
new file mode 100644
index 0000000000000000000000000000000000000000..d5a819e53097b9e221a27ca9a0085225a3a1e490
--- /dev/null
+++ b/web/modules/ultimate_cron/src/QueueWorker.php
@@ -0,0 +1,133 @@
+<?php
+
+namespace Drupal\ultimate_cron;
+
+use Drupal\Core\Queue\QueueWorkerManager;
+use Drupal\Core\Queue\QueueFactory;
+use Drupal\Core\Config\ConfigFactory;
+use Drupal\Core\Queue\RequeueException;
+use Drupal\Core\Queue\SuspendQueueException;
+
+/**
+ * Defines the queue worker.
+ */
+class QueueWorker {
+
+  /**
+   * Queue worker plugin manager
+   *
+   * @var Drupal\Core\Queue\QueueWorkerManager
+   */
+  protected $pluginManagerQueueWorker;
+
+  /**
+   * Queue Factory.
+   *
+   * @var Drupal\Core\Queue\QueueFactory
+   */
+  protected $queue;
+
+  /**
+   * Config Factory.
+   *
+   * @var Drupal\Core\Config\ConfigFactory
+   */
+  protected $configFactory;
+
+  /**
+   * Constructs a QueueWorker object.
+   *
+   * @param \Drupal\Core\Queue\QueueWorkerManager $plugin_manager_queue_worker
+   * @param \Drupal\Core\Queue\QueueFactory $queue
+   * @param \Drupal\Core\Config\ConfigFactory $config_factory
+   */
+  public function __construct(QueueWorkerManager $plugin_manager_queue_worker, QueueFactory $queue, ConfigFactory $config_factory) {
+    $this->pluginManagerQueueWorker = $plugin_manager_queue_worker;
+    $this->queue = $queue;
+    $this->configFactory = $config_factory;
+  }
+
+  /**
+   * Cron callback for queue worker cron jobs.
+   */
+  public function queueCallback(CronJobInterface $job) {
+    $queue_name = str_replace(CronJobInterface::QUEUE_ID_PREFIX, '', $job->id());
+
+    $queue_manager = $this->pluginManagerQueueWorker;
+    $queue_factory = $this->queue;
+
+    $config = $this->configFactory->get('ultimate_cron.settings');
+
+    $info = $queue_manager->getDefinition($queue_name);
+
+    // Make sure every queue exists. There is no harm in trying to recreate
+    // an existing queue.
+    $queue_factory->get($queue_name)->createQueue();
+
+    /** @var \Drupal\Core\Queue\QueueWorkerInterface $queue_worker */
+    $queue_worker = $queue_manager->createInstance($queue_name);
+    $end = microtime(TRUE) + (isset($info['cron']['time']) ? $info['cron']['time'] : $config->get('queue.timeouts.time'));
+
+    /** @var \Drupal\Core\Queue\QueueInterface $queue */
+    $queue = $queue_factory->get($queue_name);
+    $items = 0;
+    while (microtime(TRUE) < $end) {
+      // Check kill signal.
+      if ($job->getSignal('kill')) {
+        \Drupal::logger('ultimate_cron')->warning('Kill signal received for job @job_id', ['@job_id' => $job->id()]);
+        break;
+      }
+
+      $item = $queue->claimItem($config->get('queue.timeouts.lease_time'));
+
+      // If there is no item, check the empty delay setting and wait if
+      // configured.
+      if (!$item) {
+        if ($config->get('queue.delays.empty_delay')) {
+          usleep($config->get('queue.delays.empty_delay') * 1000000);
+          continue;
+        }
+        else {
+          break;
+        }
+      }
+
+      try {
+        // We have an item, check if we need to wait.
+        if ($config->get('queue.delays.item_delay')) {
+          if ($items == 0) {
+            // Move the boundary if using a throttle,
+            // to avoid waiting for nothing.
+            $end -= $config->get('queue.delays.item_delay');
+          }
+          else {
+            // Sleep before retrieving.
+            usleep($config->get('queue.delays.item_delay') * 1000000);
+          }
+        }
+
+        $queue_worker->processItem($item->data);
+        $queue->deleteItem($item);
+        $items++;
+      }
+      catch (RequeueException $e) {
+        // The worker requested the task be immediately requeued.
+        $queue->releaseItem($item);
+      }
+      catch (SuspendQueueException $e) {
+        // If the worker indicates there is a problem with the whole queue,
+        // release the item and skip to the next queue.
+        $queue->releaseItem($item);
+
+        watchdog_exception('cron', $e);
+
+      }
+      catch (\Exception $e) {
+        // In case of any other kind of exception, log it and leave the item
+        // in the queue to be processed again later.
+        watchdog_exception('ultimate_cron_queue', $e);
+      }
+    }
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/Scheduler/SchedulerInterface.php b/web/modules/ultimate_cron/src/Scheduler/SchedulerInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..6b878c9550ccc835ffbb7ebe92c07428ab2d8141
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Scheduler/SchedulerInterface.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Drupal\ultimate_cron\Scheduler;
+
+use Drupal\Component\Plugin\ConfigurableInterface;
+use Drupal\Component\Plugin\DependentPluginInterface;
+use Drupal\Component\Plugin\PluginInspectionInterface;
+use Drupal\Core\Plugin\PluginFormInterface;
+use Drupal\ultimate_cron\Entity\CronJob;
+
+/**
+ * Defines a scheduler method.
+ */
+interface SchedulerInterface extends PluginInspectionInterface, ConfigurableInterface, DependentPluginInterface, PluginFormInterface {
+
+  /**
+   * Returns the default configuration.
+   *
+   * @return mixed
+   */
+  public function defaultConfiguration();
+  /**
+   * Label for schedule.
+   *
+   * @param \Drupal\ultimate_cron\Entity\CronJob $job
+   *   The job whose label should be formatted.
+   */
+  public function formatLabel(CronJob $job);
+
+  /**
+   * Label for schedule.
+   *
+   * @param \Drupal\ultimate_cron\Entity\CronJob $job
+   *   The job whose label should be formatted.
+   */
+  public function formatLabelVerbose(CronJob $job);
+
+  /**
+   * Check job schedule.
+   *
+   * @param \Drupal\ultimate_cron\Entity\CronJob $job
+   *   The job to check schedule for.
+   *
+   * @return bool
+   *   TRUE if job is scheduled to run.
+   */
+  public function isScheduled(CronJob $job);
+
+  /**
+   * Check if job is behind schedule.
+   *
+   * @param \Drupal\ultimate_cron\Entity\CronJob $job
+   *   The job to check schedule for.
+   *
+   * @return bool|int
+   *   FALSE if job is behind its schedule or number of seconds behind.
+   */
+  public function isBehind(CronJob $job);
+
+}
diff --git a/web/modules/ultimate_cron/src/Scheduler/SchedulerManager.php b/web/modules/ultimate_cron/src/Scheduler/SchedulerManager.php
new file mode 100644
index 0000000000000000000000000000000000000000..a115234d2b09e0e8695f98da60c2d389dfdf5983
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Scheduler/SchedulerManager.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\ultimate_cron\Scheduler;
+
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Plugin\DefaultPluginManager;
+use Drupal\payment\Plugin\Payment\OperationsProviderPluginManagerTrait;
+
+/**
+ * A plugin manager for scheduler plugins.
+ *
+ *  @see \Drupal\ultimate_cron\Scheduler\SchedulerInterface
+ */
+class SchedulerManager extends DefaultPluginManager {
+
+  /**
+   * Constructs a SchedulerManager object.
+   *
+   * @param \Traversable $namespaces
+   *   An object that implements \Traversable which contains the root paths
+   *   keyed by the corresponding namespace to look for plugin implementations.
+   * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
+   *   Cache backend instance to use.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler to invoke the alter hook with.
+   */
+  public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
+    parent::__construct('Plugin/ultimate_cron/Scheduler', $namespaces, $module_handler, '\Drupal\ultimate_cron\Scheduler\SchedulerInterface', 'Drupal\ultimate_cron\Annotation\SchedulerPlugin');
+    $this->alterInfo('ultimate_cron_scheduler_info');
+    $this->setCacheBackend($cache_backend, 'ultimate_cron_scheduler');
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/Settings.php b/web/modules/ultimate_cron/src/Settings.php
new file mode 100644
index 0000000000000000000000000000000000000000..b31aee3fe2b22e3d0b63ba0e625b3c00537e5bea
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Settings.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Drupal\ultimate_cron;
+
+use Drupal\Core\Form\FormBase;
+
+/**
+ * Base class for settings.
+ *
+ * There's nothing special about this plugin.
+ */
+class Settings extends CronPluginMultiple {
+}
diff --git a/web/modules/ultimate_cron/src/Signal/SignalCache.php b/web/modules/ultimate_cron/src/Signal/SignalCache.php
new file mode 100644
index 0000000000000000000000000000000000000000..6542f02a10d9328413f37c5ca8d05126693282db
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Signal/SignalCache.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace Drupal\ultimate_cron\Signal;
+
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Lock\LockBackendInterface;
+use Drupal\ultimate_cron\Signal\SignalInterface;
+
+class SignalCache implements SignalInterface {
+
+  /**
+   * @var \Drupal\Core\Cache\CacheBackendInterface
+   */
+  public $cacheBackend;
+
+  /**
+   * @var \Drupal\Core\Lock\LockBackendInterface
+   */
+  public $lockBackend;
+
+  public function __construct(CacheBackendInterface $cache_backend, LockBackendInterface $lock_backend) {
+    $this->cacheBackend = $cache_backend;
+    $this->lockBackend = $lock_backend;
+  }
+
+  /**
+   * Get a signal without claiming it.
+   *
+   * @param string $job_id
+   *   The name of the job.
+   * @param string $signal
+   *   The name of the signal.
+   *
+   * @return string
+   *   The signal if any.
+   */
+  public function peek($job_id, $signal) {
+    $cache = $this->cacheBackend->get("signal-$job_id-$signal");
+    if ($cache) {
+      $flushed = $this->cacheBackend->get("flushed-$job_id");
+      if (!$flushed || $cache->created > $flushed->created) {
+        return $cache->data;
+      }
+    }
+    return FALSE;
+  }
+
+  /**
+   * Get and claim signal.
+   *
+   * @param string $name
+   *   The name of the job.
+   * @param string $signal
+   *   The name of the signal.
+   *
+   * @return string
+   *   The signal if any. If a signal is found, it is "claimed" and therefore
+   *   cannot be claimed again.
+   */
+  public function get($job_id, $signal) {
+    if ($this->lockBackend->acquire("signal-$job_id-$signal")) {
+      $result = self::peek($job_id, $signal);
+      self::clear($job_id, $signal);
+      $this->lockBackend->release("signal-$job_id-$signal");
+      return $result;
+    }
+    return FALSE;
+  }
+
+  /**
+   * Set signal.
+   *
+   * @param string $job_id
+   *   The name of the job.
+   * @param string $signal
+   *   The name of the signal.
+   *
+   * @return boolean
+   *   TRUE if the signal was set.
+   */
+  public function set($job_id, $signal) {
+    $this->cacheBackend->set("signal-$job_id-$signal", TRUE);
+  }
+
+  /**
+   * Clear signal.
+   *
+   * @param string $job_id
+   *   The name of the job.
+   * @param string $signal
+   *   The name of the signal.
+   */
+  public function clear($job_id, $signal) {
+    $this->cacheBackend->delete("signal-$job_id-$signal");
+  }
+
+  /**
+   * Clear signals.
+   *
+   * @param string $job_id
+   *   The name of the job.
+   */
+  public function flush($job_id) {
+    $this->cacheBackend->set("flushed-$job_id", microtime(TRUE));
+  }
+}
diff --git a/web/modules/ultimate_cron/src/Signal/SignalInterface.php b/web/modules/ultimate_cron/src/Signal/SignalInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..3da154001ebbe64a896f164140658aed38f77308
--- /dev/null
+++ b/web/modules/ultimate_cron/src/Signal/SignalInterface.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Drupal\ultimate_cron\Signal;
+
+interface SignalInterface {
+  /**
+   * Get a signal without claiming it.
+   *
+   * @param string $job_id
+   *   The name of the job.
+   * @param string $signal
+   *   The name of the signal.
+   *
+   * @return string
+   *   The signal if any.
+   */
+  public function peek($job_id, $signal);
+
+  /**
+   * Set signal.
+   *
+   * @param string $job_id
+   *   The name of the job.
+   * @param string $signal
+   *   The name of the signal.
+   *
+   * @return boolean
+   *   TRUE if the signal was set.
+   */
+  public function set($job_id, $signal);
+
+  /**
+   * Get and claim signal.
+   *
+   * @param $job_id
+   * @param string $signal
+   *   The name of the signal.
+   *
+   * @internal param string $name The name of the job.*   The name of the job.
+   * @return string
+   *   The signal if any. If a signal is found, it is "claimed" and therefore
+   *   cannot be claimed again.
+   */
+  public function get($job_id, $signal);
+
+  /**
+   * Clear signals.
+   *
+   * @param string $job_id
+   *   The name of the job.
+   */
+  public function flush($job_id);
+
+  /**
+   * Clear signal.
+   *
+   * @param string $job_id
+   *   The name of the job.
+   * @param string $signal
+   *   The name of the signal.
+   */
+  public function clear($job_id, $signal);
+}
diff --git a/web/modules/ultimate_cron/src/TaggedSettings.php b/web/modules/ultimate_cron/src/TaggedSettings.php
new file mode 100644
index 0000000000000000000000000000000000000000..4cae649a736a126d553fee5c81092cdd7a23c11d
--- /dev/null
+++ b/web/modules/ultimate_cron/src/TaggedSettings.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Drupal\ultimate_cron;
+
+use Drupal\ultimate_cron\Entity\CronJob;
+
+/**
+ * Base class for tagged settings.
+ *
+ * Settings plugins using this as a base class, will only be available
+ * to jobs having the same tag as the name of the plugin.
+ */
+class TaggedSettings extends Settings {
+  /**
+   * Only valid for jobs tagged with the proper tag.
+   */
+  public function isValid($job = NULL) {
+    return $job ? in_array($this->name, $job->hook['tags']) : \Drupal\ultimate_cron\parent::isValid();
+  }
+}
diff --git a/web/modules/ultimate_cron/src/UltimateCron.php b/web/modules/ultimate_cron/src/UltimateCron.php
new file mode 100644
index 0000000000000000000000000000000000000000..c8010621e5846ef63419840934410c3def50f094
--- /dev/null
+++ b/web/modules/ultimate_cron/src/UltimateCron.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Drupal\ultimate_cron;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Cron;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Lock\LockBackendInterface;
+use Drupal\Core\Queue\QueueFactory;
+use Drupal\Core\Queue\QueueWorkerManagerInterface;
+use Drupal\Core\Session\AccountSwitcherInterface;
+use Drupal\Core\State\StateInterface;
+use Drupal\ultimate_cron\Entity\CronJob;
+use Psr\Log\LoggerInterface;
+
+/**
+ * The Ultimate Cron service.
+ */
+class UltimateCron extends Cron {
+
+  /**
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  protected $configFactory;
+
+  /**
+   * Sets the config factory for ultimate cron service.
+   *
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory.
+   */
+  public function setConfigFactory(ConfigFactoryInterface $config_factory) {
+    $this->configFactory = $config_factory;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function run() {
+
+    // Load the cron jobs in the right order.
+    $job_ids = \Drupal::entityQuery('ultimate_cron_job')
+      ->condition('status', TRUE)
+      ->sort('weight', 'ASC')
+
+      ->execute();
+
+    $launcher_jobs = array();
+    foreach (CronJob::loadMultiple($job_ids) as $job) {
+      /* @var \Drupal\Core\Plugin\DefaultPluginManager $manager */
+      $manager = \Drupal::service('plugin.manager.ultimate_cron.' . 'launcher');
+      $launcher = $manager->createInstance($job->getLauncherId());
+      $launcher_definition = $launcher->getPluginDefinition();
+
+      if (!isset($launchers) || in_array($launcher->getPluginId(), $launchers)) {
+        $launcher_jobs[$launcher_definition['id']]['launcher'] = $launcher;
+        $launcher_jobs[$launcher_definition['id']]['sort'] = array($launcher_definition['weight']);
+        $launcher_jobs[$launcher_definition['id']]['jobs'][$job->id()] = $job;
+        $launcher_jobs[$launcher_definition['id']]['jobs'][$job->id()]->sort = array($job->loadLatestLogEntry()->start_time);
+      }
+    }
+
+    foreach ($launcher_jobs as $name => $launcher_job) {
+      $launcher_job['launcher']->launchJobs($launcher_job['jobs']);
+    }
+
+    // Run standard queue processing if our own handling is disabled.
+    if (!$this->configFactory->get('ultimate_cron.settings')->get('queue.enabled')) {
+      $this->processQueues();
+    }
+
+    $this->setCronLastTime();
+
+    return TRUE;
+  }
+}
diff --git a/web/modules/ultimate_cron/src/UltimateCronDatabaseFactory.php b/web/modules/ultimate_cron/src/UltimateCronDatabaseFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..7dad5ff3925e712d2dd740264332086924b2c007
--- /dev/null
+++ b/web/modules/ultimate_cron/src/UltimateCronDatabaseFactory.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Drupal\ultimate_cron;
+
+use Drupal\Core\Database\Database;
+
+/**
+ * Class DatabaseFactory
+ */
+class UltimateCronDatabaseFactory {
+  /**
+   * Factory method that returns a Connection object with the correct target.
+   *
+   * @return \Drupal\Core\Database\Connection
+   *   The connection object.
+   */
+  public static function getConnection() {
+    $target = _ultimate_cron_get_transactional_safe_connection();
+    return Database::getConnection($target);
+  }
+
+}
diff --git a/web/modules/ultimate_cron/src/UltimateCronServiceProvider.php b/web/modules/ultimate_cron/src/UltimateCronServiceProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..2c4fcb5a47cd56112832f8e5ca0558a106604def
--- /dev/null
+++ b/web/modules/ultimate_cron/src/UltimateCronServiceProvider.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\ultimate_cron;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\DependencyInjection\ServiceProviderBase;
+use Symfony\Component\DependencyInjection\Reference;
+
+/**
+ * Defines service provider for ultimate cron.
+ */
+class UltimateCronServiceProvider extends ServiceProviderBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function alter(ContainerBuilder $container) {
+    // Overrides cron class to use our own cron manager.
+    $container->getDefinition('cron')
+      ->setClass('Drupal\ultimate_cron\UltimateCron')
+      ->addMethodCall('setConfigFactory', [new Reference('config.factory')]);
+  }
+
+}
diff --git a/web/modules/ultimate_cron/templates/page-admin-settings-cron-log.tpl.php b/web/modules/ultimate_cron/templates/page-admin-settings-cron-log.tpl.php
new file mode 100644
index 0000000000000000000000000000000000000000..c8a49fa4a0d496c186607eeef9f238d87b4a0983
--- /dev/null
+++ b/web/modules/ultimate_cron/templates/page-admin-settings-cron-log.tpl.php
@@ -0,0 +1,17 @@
+<?php
+/**
+ * @file
+ */
+?><!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="<?php print $language->language ?>" lang="<?php print $language->language ?>" dir="<?php print $language->dir ?>">
+  <head>
+    <?php print $head ?>
+    <title><?php print $head_title ?></title>
+    <?php print $styles ?>
+    <?php print $scripts ?>
+  </head>
+  <body>
+    <?php print $content ?>
+  </body>
+</html>
diff --git a/web/modules/ultimate_cron/tests/src/Functional/CronJobFormTest.php b/web/modules/ultimate_cron/tests/src/Functional/CronJobFormTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..5c0d13109ea30abd886f71e6fbedcd9e127f38a6
--- /dev/null
+++ b/web/modules/ultimate_cron/tests/src/Functional/CronJobFormTest.php
@@ -0,0 +1,218 @@
+<?php
+
+namespace Drupal\Tests\ultimate_cron\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+use Drupal\Tests\Traits\Core\CronRunTrait;
+use Drupal\ultimate_cron\Entity\CronJob;
+
+/**
+ * Cron Job Form Testing.
+ *
+ * @group ultimate_cron
+ */
+class CronJobFormTest extends BrowserTestBase {
+
+  use CronRunTrait;
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = array('ultimate_cron', 'block', 'cron_queue_test');
+
+  /**
+   * A user with permission to create and edit books and to administer blocks.
+   *
+   * @var \Drupal\user\Entity\User
+   */
+  protected $adminUser;
+
+  /**
+   * Cron job name.
+   *
+   * @var string
+   */
+  protected $jobName;
+
+  /**
+   * Cron job machine id.
+   *
+   * @var string
+   */
+  protected $jobId = 'system_cron';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'classy';
+
+  /**
+   * Tests adding and editing a cron job.
+   */
+  public function testManageJob() {
+    $this->drupalPlaceBlock('local_tasks_block');
+    $this->drupalPlaceBlock('local_actions_block');
+
+    // Create user with correct permission.
+    $this->adminUser = $this->drupalCreateUser(array('administer ultimate cron', 'administer site configuration'));
+    $this->drupalLogin($this->adminUser);
+
+    // Cron Jobs overview.
+    $this->drupalGet('admin/config/system/cron/jobs');
+    $this->assertResponse('200');
+
+    // Check for the default schedule message in Job list.
+    $this->assertText('Every 15 min');
+    // Check for the Last Run default value.
+    $this->assertText('Never');
+
+    // Start editing added job.
+    $this->drupalGet('admin/config/system/cron/jobs/manage/' . $this->jobId);
+    $this->assertResponse('200');
+
+    // Set new cron job configuration and save the old job name.
+    $job = CronJob::load($this->jobId);
+    $old_job_name = $job->label();
+    $this->jobName = 'edited job name';
+    $edit = array('title' => $this->jobName);
+
+    // Save the new job.
+    $this->drupalPostForm(NULL, $edit, t('Save'));
+    // Assert the edited Job hasn't run yet.
+    $this->assertText('Never');
+    // Assert messenger service message for successful updated job.
+    $this->assertText(t('job @name has been updated.', array('@name' => $this->jobName)));
+
+    // Run the Jobs.
+    $this->cronRun();
+
+    // Assert the cron jobs have been run by checking the time.
+    $this->drupalGet('admin/config/system/cron/jobs');
+    $this->assertText(\Drupal::service('date.formatter')->format(\Drupal::state()->get('system.cron_last'), 'short'), 'Created Cron jobs have been run.');
+
+    // Check that all jobs have been run.
+    $this->assertNoText("Never");
+
+    // Assert cron job overview for recently updated job.
+    $this->drupalGet('admin/config/system/cron/jobs');
+    $this->assertNoText($old_job_name);
+    $this->assertText($this->jobName);
+
+    // Change time when cron runs, check the 'Scheduled' label is updated.
+    $this->clickLink(t('Edit'));
+    $this->drupalPostForm(NULL, ['scheduler[configuration][rules][0]' => '0+@ */6 * * *'], t('Save'));
+    $this->assertText('Every 6 hours');
+
+    // Test disabling a job.
+    $this->clickLink(t('Disable'), 0);
+    $this->assertText('This cron job will no longer be executed.');
+    $this->drupalPostForm(NULL, NULL, t('Disable'));
+
+    // Assert messenger service message for successful disabled job.
+    $this->assertText(t('Disabled cron job @name.', array('@name' => $this->jobName)));
+    $this->drupalGet('admin/config/system/cron/jobs');
+    $this->assertFieldByXPath('//table/tbody/tr[1]/td[6]', 'Disabled');
+    $this->assertFieldByXPath('//table/tbody/tr[1]/td[8]/div/div/ul/li[1]/a', 'Enable');
+    $this->assertNoFieldByXPath('//table/tbody/tr[1]/td[8]/div/div/ul/li[1]/a', 'Run');
+
+    // Test enabling a job.
+    $this->clickLink(t('Enable'), 0);
+    $this->assertText('This cron job will be executed again.');
+    $this->drupalPostForm(NULL, NULL, t('Enable'));
+
+    // Assert messenger service message for successful enabled job.
+    $this->assertText(t('Enabled cron job @name.', array('@name' => $this->jobName)));
+    $this->drupalGet('admin/config/system/cron/jobs');
+    $expected_checkmark_image_url = file_url_transform_relative(file_create_url('core/misc/icons/73b355/check.svg'));
+    $this->assertEquals($expected_checkmark_image_url, $this->xpath('//table/tbody/tr[1]/td[6]/img')[0]->getAttribute('src'));
+    $this->assertFieldByXPath('//table/tbody/tr[1]/td[8]/div/div/ul/li[1]/a', 'Run');
+
+    // Test disabling a job with the checkbox on the edit page.
+    $edit = array(
+      'status' => FALSE,
+    );
+    $this->drupalPostForm('admin/config/system/cron/jobs/manage/' . $this->jobId, $edit, t('Save'));
+    $this->assertFieldByXPath('//table/tbody/tr[1]/td[6]', 'Disabled');
+    $this->assertFieldByXPath('//table/tbody/tr[1]/td[8]/div/div/ul/li[1]/a', 'Enable');
+    $this->assertNoFieldByXPath('//table/tbody/tr[1]/td[8]/div/div/ul/li[1]/a', 'Run');
+
+    // Test enabling a job with the checkbox on the edit page.
+    $edit = array(
+      'status' => TRUE,
+    );
+    $this->drupalPostForm('admin/config/system/cron/jobs/manage/' . $this->jobId, $edit, t('Save'));
+    $this->assertEquals($expected_checkmark_image_url, $this->xpath('//table/tbody/tr[1]/td[6]/img')[0]->getAttribute('src'));
+    $this->assertFieldByXPath('//table/tbody/tr[1]/td[8]/div/div/ul/li[1]/a', 'Run');
+
+    $this->drupalGet('admin/config/system/cron/jobs');
+
+    // Save new job.
+    $this->clickLink(t('Edit'), 0);
+    $job_configuration = array(
+      'scheduler[id]' => 'crontab',
+    );
+    $this->drupalPostForm(NULL, $job_configuration, t('Save'));
+    $this->drupalPostForm('admin/config/system/cron/jobs/manage/' . $this->jobId, ['scheduler[configuration][rules][0]' => '0+@ * * * *'], t('Save'));
+    $this->assertText('0+@ * * * *');
+
+    // Try editing the rule to an invalid one.
+    $this->clickLink('Edit');
+    $this->drupalPostForm(NULL, ['scheduler[configuration][rules][0]' => '*//15+@ *-2 * * *'], t('Save'));
+    $this->assertText('Rule is invalid');
+    $this->assertTitle('Edit job | Drupal');
+
+    // Assert that there is no Delete link on the details page.
+    $this->assertNoLink('Delete');
+
+    // Force a job to be invalid by changing the callback.
+    $job = CronJob::load($this->jobId);
+    $job->setCallback('non_existing_function')
+      ->save();
+    $this->drupalGet('admin/config/system/cron/jobs');
+
+    // Assert that the invalid cron job is displayed properly.
+    $this->assertFieldByXPath('//table/tbody/tr[1]/td[6]', 'Missing');
+    $this->assertFieldByXPath('//table/tbody/tr[1]/td[8]/div/div/ul/li/a', 'Delete');
+
+    // Test deleting a job (only possible if invalid cron job).
+    $this->clickLink(t('Delete'), 0);
+    $this->drupalPostForm(NULL, NULL, t('Delete'));
+    $this->assertText(t('The cron job @name has been deleted.', array('@name' => $job->label())));
+    $this->drupalGet('admin/config/system/cron/jobs');
+    $this->assertNoText($job->label());
+
+    $job = CronJob::load('ultimate_cron_cron');
+
+    $log_entries = $job->getLogEntries();
+    $log_entry = reset($log_entries);
+
+    // Test logs details page.
+    $this->drupalGet('admin/config/system/cron/jobs');
+    $this->clickLink('Logs');
+    $xpath = $this->xpath('//tbody/tr[@class="odd"]/td');
+    $start_time = \Drupal::service('date.formatter')->format($log_entry->start_time, 'custom', 'Y-m-d H:i:s');
+    $end_time = \Drupal::service('date.formatter')->format($log_entry->end_time, 'custom', 'Y-m-d H:i:s');
+    $this->assertEqual($xpath[1]->getText(), $start_time);
+    $this->assertEqual($xpath[2]->getText(), $end_time);
+    // The message logged depends on timing, do not hardcode that.
+    $this->assertEqual($xpath[3]->getText(), $log_entry->message ?: $log_entry->formatInitMessage());
+    $this->assertEqual($xpath[4]->getText(), '00:00');
+
+    // Assert queue cron jobs.
+    $this->config('ultimate_cron.settings')
+      ->set('queue.enabled', TRUE)
+      ->save();
+
+    \Drupal::service('ultimate_cron.discovery')->discoverCronJobs();
+    $this->drupalGet('admin/config/system/cron/jobs');
+    $this->assertText('Queue: Broken queue test');
+
+    $this->drupalGet('admin/config/system/cron/jobs/manage/ultimate_cron_queue_cron_queue_test_broken_queue');
+    $this->assertFieldByName('title', 'Queue: Broken queue test');
+    $this->drupalPostForm(NULL, [], 'Save');
+    $this->assertText('job Queue: Broken queue test has been updated.');
+  }
+
+}
diff --git a/web/modules/ultimate_cron/tests/src/Functional/CronJobInstallTest.php b/web/modules/ultimate_cron/tests/src/Functional/CronJobInstallTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f89dd34c12b9c1cf9b73b9d7f858573b8777d3fe
--- /dev/null
+++ b/web/modules/ultimate_cron/tests/src/Functional/CronJobInstallTest.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace Drupal\Tests\ultimate_cron\Functional;
+
+use Drupal\Core\Url;
+use Drupal\Tests\BrowserTestBase;
+use Drupal\ultimate_cron\CronRule;
+use Drupal\ultimate_cron\Entity\CronJob;
+
+/**
+ * Cron Job Form Testing
+ *
+ * @group ultimate_cron
+ */
+class CronJobInstallTest extends BrowserTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = array('ultimate_cron');
+
+  /**
+   * A user with permission to create and edit books and to administer blocks.
+   *
+   * @var \Drupal\user\Entity\User
+   */
+  protected $adminUser;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * Tests adding and editing a cron job.
+   */
+  public function testManageJob() {
+    // Create user with correct permission.
+    $this->adminUser = $this->drupalCreateUser(array('administer ultimate cron'));
+    $this->drupalLogin($this->adminUser);
+
+    // Check default modules
+    \Drupal::service('module_installer')->install(array('field'));
+    $this->drupalGet('admin/config/system/cron/jobs');
+    $this->assertText('Purges deleted Field API data');
+    $this->assertText('Cleanup (caches, batch, flood, temp-files, etc.)');
+    $this->assertNoText('Deletes temporary files');
+
+    // Install new module.
+    \Drupal::service('module_installer')->install(array('file'));
+    $this->drupalGet('admin/config/system/cron/jobs');
+    $this->assertText('Deletes temporary files');
+
+    // Uninstall new module.
+    \Drupal::service('module_installer')->uninstall(array('file'));
+    $this->drupalGet('admin/config/system/cron/jobs');
+    $this->assertNoText('Deletes temporary files');
+  }
+
+  /**
+   * Tests the requirements checking of ultimate_cron.
+   */
+  public function testRequirements() {
+    $element = ultimate_cron_requirements('runtime')['cron_jobs'];
+    $this->assertEqual($element['value'], t("Cron is running properly."));
+    $this->assertEqual($element['severity'], REQUIREMENT_OK);
+
+
+    $values = array(
+      'title' => 'ultimate cron fake cronjob title',
+      'id' => 'ultimate_cron_fake_job',
+      'module' => 'ultimate_cron_fake',
+      'callback' => 'ultimate_cron_fake_cron',
+    );
+
+    $job = new CronJob($values, 'ultimate_cron_job');
+    $job->save();
+
+    \Drupal::service('cron')->run();
+
+    // Generate an initial scheduled cron time.
+    $cron = CronRule::factory('*/15+@ * * * *', time(), $job->getUniqueID() & 0xff);
+    $scheduled_cron_time = $cron->getLastSchedule();
+    // Generate a new start time by adding two seconds to the initial scheduled cron time.
+    $log_entry_past = $scheduled_cron_time - 10000;
+      \Drupal::database()->update('ultimate_cron_log')
+      ->fields([
+        'start_time' => $log_entry_past,
+      ])
+      ->condition('name', $values['id'])
+      ->execute();
+
+    // Check run counter, at this point there should be 0 run.
+    $this->assertEqual(1, \Drupal::state()->get('ultimate_cron.cron_run_counter'), 'Job has run once.');
+    $this->assertNotEmpty($job->isBehindSchedule(), 'Job is behind schedule.');
+
+    $element = ultimate_cron_requirements('runtime')['cron_jobs'];
+    $this->assertEqual($element['value'], '1 job is behind schedule', '"1 job is behind schedule." is displayed');
+    $this->assertEqual($element['description']['#markup'], 'Some jobs are behind their schedule. Please check if <a href="' .
+      Url::fromRoute('system.cron', ['key' => \Drupal::state()->get('system.cron_key')])->toString() .
+      '">Cron</a> is running properly.', 'Description is correct.');
+    $this->assertEqual($element['severity'], REQUIREMENT_WARNING, 'Severity is of level "Error"');
+  }
+
+}
diff --git a/web/modules/ultimate_cron/tests/src/Functional/LoggerWebTest.php b/web/modules/ultimate_cron/tests/src/Functional/LoggerWebTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..1b274551ec19dcb9e5659a347fb624a5cc439228
--- /dev/null
+++ b/web/modules/ultimate_cron/tests/src/Functional/LoggerWebTest.php
@@ -0,0 +1,208 @@
+<?php
+
+namespace Drupal\Tests\ultimate_cron\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+use Drupal\Tests\Listeners\DeprecationListenerTrait;
+use Drupal\Tests\Traits\Core\CronRunTrait;
+
+/**
+ * Tests that scheduler plugins are discovered correctly.
+ *
+ * @group ultimate_cron
+ */
+class LoggerWebTest extends BrowserTestBase {
+
+  use CronRunTrait;
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = ['ultimate_cron', 'ultimate_cron_logger_test'];
+
+  /**
+   * A user with permissions to administer and run cron jobs.
+   *
+   * @var \Drupal\user\Entity\User
+   */
+  protected $user;
+
+  /**
+   * Flag to control if errors should be ignored or not.
+   *
+   * @var bool
+   */
+  protected $ignoreErrors = FALSE;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->user = $this->createUser([
+      'administer ultimate cron',
+      'view cron jobs',
+      'run cron jobs',
+    ]);
+    $this->drupalLogin($this->user);
+  }
+
+  /**
+   * Tests that the logger handles an exception correctly.
+   */
+  public function testLoggerException() {
+
+    \Drupal::state()->set('ultimate_cron_logger_test_cron_action', 'exception');
+
+    // Run cron to get an exception from ultimate_cron_logger_test module.
+    $this->cronRun();
+
+    // Check that the error message is displayed in its log page.
+    $this->drupalGet('admin/config/system/cron/jobs/logs/ultimate_cron_logger_test_cron');
+    $this->assertRaw('/core/misc/icons/e32700/error.svg');
+    $this->assertRaw('<em class="placeholder">Exception</em>: Test cron exception in <em class="placeholder">ultimate_cron_logger_test_cron()</em> (line');
+  }
+
+  /**
+   * Tests that the logger handles an exception correctly.
+   */
+  public function testLoggerFatal() {
+
+    \Drupal::state()->set('ultimate_cron_logger_test_cron_action', 'fatal');
+
+    // Run cron to get an exception from ultimate_cron_logger_test module.
+    $this->ignoreErrors = TRUE;
+    $this->cronRun();
+    $this->ignoreErrors = FALSE;
+
+    // Check that the error message is displayed in its log page.
+    $this->drupalGet('admin/config/system/cron/jobs/logs/ultimate_cron_logger_test_cron');
+    $this->assertRaw('/core/misc/icons/e32700/error.svg');
+    $this->assertRaw('Call to undefined function call_to_undefined_function');
+
+    // Empty the logfile, our fatal errors are expected.
+    $filename = DRUPAL_ROOT . '/' . $this->siteDirectory . '/error.log';
+    file_put_contents($filename, '');
+  }
+
+  /**
+   * Tests that the logger handles long message correctly.
+   */
+  public function testLoggerLongMessage() {
+
+    \Drupal::state()->set('ultimate_cron_logger_test_cron_action', 'long_message');
+
+    // Run cron to get a long message log from ultimate_cron_logger_test.
+    $this->cronRun();
+
+    // Check that the long log message is properly trimmed.
+    $this->drupalGet('admin/config/system/cron/jobs/logs/ultimate_cron_logger_test_cron');
+    $xpath = $this->xpath('//table/tbody/tr/td[4]');
+    // The last 2 chars from xpath are not related to the message.
+    $this->assertTrue(strlen(substr($xpath[0]->getText(), 0, -2)) == 5000);
+    $this->assertRaw('This is a v…');
+  }
+
+  /**
+   * Tests that the logger handles an exception correctly.
+   */
+  public function testLoggerLogWarning() {
+
+    \Drupal::state()->set('ultimate_cron_logger_test_cron_action', 'log_warning');
+
+    // Run cron to get an exception from ultimate_cron_logger_test module.
+    $this->cronRun();
+
+    // Check that the error message is displayed in its log page.
+    $this->drupalGet('admin/config/system/cron/jobs/logs/ultimate_cron_logger_test_cron');
+    $this->assertRaw('/core/misc/icons/e29700/warning.svg');
+    $this->assertRaw('This is a warning message');
+  }
+
+
+  /**
+   * Tests that the logger handles an exception correctly.
+   */
+  public function testLoggerNormal() {
+    // Run cron to get an exception from ultimate_cron_logger_test module.
+    $this->cronRun();
+
+    // Check that the error message is displayed in its log page.
+    $this->drupalGet('admin/config/system/cron/jobs/logs/ultimate_cron_logger_test_cron');
+    $this->assertRaw('/core/misc/icons/73b355/check.svg');
+    $this->assertText('Launched in thread 1');
+  }
+
+  /**
+   * Reads headers and registers errors received from the tested site.
+   *
+   * Overriden to not report fatal errors if $this->ignoreErrors is set to TRUE.
+   *
+   * @param $curlHandler
+   *   The cURL handler.
+   * @param $header
+   *   An header.
+   *
+   * @see _drupal_log_error()
+   */
+  protected function curlHeaderCallback($curlHandler, $header) {
+    // Header fields can be extended over multiple lines by preceding each
+    // extra line with at least one SP or HT. They should be joined on receive.
+    // Details are in RFC2616 section 4.
+    if ($header[0] == ' ' || $header[0] == "\t") {
+      // Normalize whitespace between chucks.
+      $this->headers[] = array_pop($this->headers) . ' ' . trim($header);
+    }
+    else {
+      $this->headers[] = $header;
+    }
+
+    // Errors are being sent via X-Drupal-Assertion-* headers,
+    // generated by _drupal_log_error() in the exact form required
+    // by \Drupal\simpletest\WebTestBase::error().
+    if (!$this->ignoreErrors && preg_match('/^X-Drupal-Assertion-[0-9]+: (.*)$/', $header, $matches)) {
+      $parameters = unserialize(urldecode($matches[1]));
+      // Handle deprecation notices triggered by system under test.
+      if ($parameters[1] === 'User deprecated function') {
+        if (getenv('SYMFONY_DEPRECATIONS_HELPER') !== 'disabled') {
+          $message = (string) $parameters[0];
+          if (!in_array($message, DeprecationListenerTrait::getSkippedDeprecations())) {
+            call_user_func_array([&$this, 'error'], $parameters);
+          }
+        }
+      }
+      else {
+        // Call \Drupal\simpletest\WebTestBase::error() with the parameters from
+        // the header.
+        call_user_func_array([&$this, 'error'], $parameters);
+      }
+    }
+
+    // Save cookies.
+    if (preg_match('/^Set-Cookie: ([^=]+)=(.+)/', $header, $matches)) {
+      $name = $matches[1];
+      $parts = array_map('trim', explode(';', $matches[2]));
+      $value = array_shift($parts);
+      $this->cookies[$name] = ['value' => $value, 'secure' => in_array('secure', $parts)];
+      if ($name === $this->getSessionName()) {
+        if ($value != 'deleted') {
+          $this->sessionId = $value;
+        }
+        else {
+          $this->sessionId = NULL;
+        }
+      }
+    }
+
+    // This is required by cURL.
+    return strlen($header);
+  }
+
+}
diff --git a/web/modules/ultimate_cron/tests/src/Kernel/CronJobKernelTest.php b/web/modules/ultimate_cron/tests/src/Kernel/CronJobKernelTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..2124810dccbc393c3676024da998150d08a9e59f
--- /dev/null
+++ b/web/modules/ultimate_cron/tests/src/Kernel/CronJobKernelTest.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Drupal\Tests\ultimate_cron\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\ultimate_cron\Entity\CronJob;
+
+/**
+ * Tests CRUD for cron jobs.
+ *
+ * @group ultimate_cron
+ */
+class CronJobKernelTest extends KernelTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = array('system', 'ultimate_cron');
+
+  protected function setup() {
+    parent::setUp();
+
+    $this->installSchema('ultimate_cron', [
+      'ultimate_cron_log',
+      'ultimate_cron_lock',
+    ]);
+  }
+
+  /**
+   * Tests CRUD operations for cron jobs.
+   */
+  public function testCRUD() {
+    $values = array(
+      'id' => 'example',
+      'title' => $this->randomMachineName(),
+      'description' => $this->randomMachineName(),
+    );
+
+    /** @var \Drupal\ultimate_cron\Entity\CronJob $cron_job */
+    $cron_job = CronJob::create($values);
+    $cron_job->save();
+
+    $this->assertEquals('example', $cron_job->id());
+    $this->assertEquals($values['title'], $cron_job->label());
+    $this->assertTrue($cron_job->status());
+
+    $cron_job->disable();
+    $cron_job->save();
+
+    $cron_job = CronJob::load('example');
+    $this->assertEquals('example', $cron_job->id());
+    $this->assertFalse($cron_job->status());
+  }
+
+}
diff --git a/web/modules/ultimate_cron/tests/src/Kernel/CronJobTest.php b/web/modules/ultimate_cron/tests/src/Kernel/CronJobTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..fdea092712160af725745cd4f6e286ae269225b9
--- /dev/null
+++ b/web/modules/ultimate_cron/tests/src/Kernel/CronJobTest.php
@@ -0,0 +1,98 @@
+<?php
+
+namespace Drupal\Tests\ultimate_cron\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\ultimate_cron\CronRule;
+use Drupal\ultimate_cron\Entity\CronJob;
+
+/**
+ * Cron Job Testing if a job runs when it is supposed to.
+ *
+ * @group ultimate_cron
+ */
+class CronJobTest extends KernelTestBase {
+
+  public static $modules = array('ultimate_cron');
+
+  public function setup() {
+    parent::setUp();
+
+    $this->installSchema('ultimate_cron', array(
+        'ultimate_cron_log',
+        'ultimate_cron_lock'
+      ));
+  }
+
+  /**
+   * Tests adding and editing a cron job.
+   */
+  function testGeneratedJob() {
+    $values = array(
+      'title' => 'ultimate cron fake cronjob title',
+      'id' => 'ultimate_cron_fake_job',
+      'module' => 'ultimate_cron_fake',
+      'callback' => 'ultimate_cron_fake_cron',
+    );
+
+    $job = CronJob::create($values);
+    $job->save();
+
+    // Check the latest log entry, there should be none.
+    $this->assertEquals(0, CronJob::load($values['id'])->loadLatestLogEntry()->start_time);
+
+    // Check run counter, at this point there should be 0 runs.
+    $this->assertEquals(0, \Drupal::state()->get('ultimate_cron.cron_run_counter'));
+
+    // Run cron manually for the first time.
+    \Drupal::service('cron')->run();
+
+    // Check run counter, at this point there should be 1 run.
+    $this->assertEquals(1, \Drupal::state()->get('ultimate_cron.cron_run_counter'));
+
+    // Generate an initial scheduled cron time.
+    $cron = CronRule::factory('*/15+@ * * * *', time(), $job->getUniqueID() & 0xff);
+    $scheduled_cron_time = $cron->getLastSchedule();
+
+    // Load Latest log entry time.
+    $latest_log_entry = CronJob::load($values['id'])->loadLatestLogEntry()->start_time;
+
+    // Latest log entry should not be 0 because it ran already.
+    $this->assertNotEquals(0, $latest_log_entry);
+
+    // Generate a new start time by adding two seconds to the initial scheduled cron time.
+    $log_entry_future = $scheduled_cron_time + 2;
+
+    // Update new start_time in the future so the next cron job should not run.
+    \Drupal::database()->update('ultimate_cron_log')
+      ->fields(array('start_time' => $log_entry_future))
+      ->condition('name', $values['id'])
+      ->execute();
+
+    // Cron job should not run.
+    \Drupal::service('cron')->run();
+
+    // Load latest log entry.
+    $cron_last_ran = CronJob::load($values['id'])->loadLatestLogEntry()->start_time;
+
+    // Check if job ran, it shouldn't have.
+    $this->assertEquals($log_entry_future, $cron_last_ran);
+    $this->assertEquals(1, \Drupal::state()->get('ultimate_cron.cron_run_counter'));
+
+    // Generate a new start time by deducting two seconds from the initial scheduled cron time.
+    $log_entry_past = $scheduled_cron_time - 2;
+
+    // Update new start_time in the past so the next cron job should run.
+    \Drupal::database()->update('ultimate_cron_log')
+      ->fields(array('start_time' => $log_entry_past))
+      ->condition('name', $values['id'])
+      ->execute();
+
+    // Cron job should run.
+    \Drupal::service('cron')->run();
+
+    // Check if the cron job has run, it should have.
+    $this->assertNotEquals($log_entry_past, $latest_log_entry);
+    $this->assertEquals(2, \Drupal::state()->get('ultimate_cron.cron_run_counter'));
+  }
+}
diff --git a/web/modules/ultimate_cron/tests/src/Kernel/LauncherPluginTest.php b/web/modules/ultimate_cron/tests/src/Kernel/LauncherPluginTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..1c170a8c88abffa75504d9412003dbfcd9cd11f4
--- /dev/null
+++ b/web/modules/ultimate_cron/tests/src/Kernel/LauncherPluginTest.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\Tests\ultimate_cron\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\ultimate_cron\Plugin\ultimate_cron\Launcher\SerialLauncher;
+
+/**
+ * Tests the default scheduler plugins.
+ *
+ * @group ultimate_cron
+ */
+class LauncherPluginTest extends KernelTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = array('ultimate_cron');
+
+  /**
+   * Tests that scheduler plugins are discovered correctly.
+   */
+  function testDiscovery() {
+    /* @var \Drupal\Core\Plugin\DefaultPluginManager $manager */
+    $manager = \Drupal::service('plugin.manager.ultimate_cron.launcher');
+
+    $plugins = $manager->getDefinitions();
+    $this->assertCount(1, $plugins);
+
+    $serial = $manager->createInstance('serial');
+    $this->assertTrue($serial instanceof SerialLauncher);
+    $this->assertEquals('serial', $serial->getPluginId());
+  }
+}
diff --git a/web/modules/ultimate_cron/tests/src/Kernel/LoggerPluginTest.php b/web/modules/ultimate_cron/tests/src/Kernel/LoggerPluginTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..3d0f2f8059e61899af9d7059a5bf2cd29e28c4f8
--- /dev/null
+++ b/web/modules/ultimate_cron/tests/src/Kernel/LoggerPluginTest.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace Drupal\Tests\ultimate_cron\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\ultimate_cron\Entity\CronJob;
+use Drupal\ultimate_cron\Logger\LogEntry;
+use Drupal\ultimate_cron\Plugin\ultimate_cron\Logger\CacheLogger;
+use Drupal\ultimate_cron\Plugin\ultimate_cron\Logger\DatabaseLogger;
+
+/**
+ * Tests the default scheduler plugins.
+ *
+ * @group ultimate_cron
+ */
+class LoggerPluginTest extends KernelTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = array('ultimate_cron', 'ultimate_cron_logger_test', 'system');
+
+  /**
+   * Tests that scheduler plugins are discovered correctly.
+   */
+  function testDiscovery() {
+    /* @var \Drupal\Core\Plugin\DefaultPluginManager $manager */
+    $manager = \Drupal::service('plugin.manager.ultimate_cron.logger');
+
+    $plugins = $manager->getDefinitions();
+    $this->assertCount(2, $plugins);
+
+    $cache = $manager->createInstance('cache');
+    $this->assertTrue($cache instanceof CacheLogger);
+    $this->assertEquals('cache', $cache->getPluginId());
+
+    $database = $manager->createInstance('database');
+    $this->assertTrue($database instanceof DatabaseLogger);
+    $this->assertEquals('database', $database->getPluginId());
+  }
+
+  /**
+   * Tests log cleanup of the database logger.
+   */
+  function testCleanup() {
+
+    $this->installSchema('ultimate_cron', ['ultimate_cron_log', 'ultimate_cron_lock']);
+
+    \Drupal::service('ultimate_cron.discovery')->discoverCronJobs();
+
+    $job = CronJob::load('ultimate_cron_logger_test_cron');
+    $job->setConfiguration('logger', [
+      'retain' => 10,
+    ]);
+    $job->save();
+
+    // Run the job 12 times.
+    for ($i = 0; $i < 12; $i++) {
+      $job->getPlugin('launcher')->launch($job);
+    }
+
+    // There are 12 run log entries and one from the modified job.
+    $log_entries = $job->getLogEntries(ULTIMATE_CRON_LOG_TYPE_ALL, 15);
+    $this->assertCount(13, $log_entries);
+
+    // Run cleanup.
+    ultimate_cron_cron();
+
+    // There should be exactly 10 log entries now.
+    $log_entries = $job->getLogEntries(ULTIMATE_CRON_LOG_TYPE_ALL, 15);
+    $this->assertCount(10, $log_entries);
+
+    // Switch to expire-based cleanup.
+    $job->setConfiguration('logger', [
+      'expire' => 60,
+      'method' => DatabaseLogger::CLEANUP_METHOD_EXPIRE,
+    ]);
+    $job->save();
+
+    $ids = array_slice(array_keys($log_entries), 5);
+
+    // Date back 5 log entries.
+      \Drupal::database()->update('ultimate_cron_log')
+      ->expression('start_time', 'start_time - 65')
+      ->condition('lid', $ids, 'IN')
+      ->execute();
+
+    // Run cleanup.
+    ultimate_cron_cron();
+
+    // There should be exactly 6 log entries now, as saving caused another
+    // modified entry to be saved.
+    $log_entries = $job->getLogEntries(ULTIMATE_CRON_LOG_TYPE_ALL, 15);
+    $this->assertCount(6, $log_entries);
+  }
+
+  /**
+   * Tests cache logger.
+   */
+  function testCacheLogger() {
+    // @todo Set default logger and do not enable the log table.
+    $this->installSchema('ultimate_cron', ['ultimate_cron_log', 'ultimate_cron_lock']);
+
+    \Drupal::service('ultimate_cron.discovery')->discoverCronJobs();
+
+    $job = CronJob::load('ultimate_cron_logger_test_cron');
+    $job->setLoggerId('cache');
+    $job->save();
+
+    // Launch the job twice.
+    $job->getPlugin('launcher')->launch($job);
+    $job->getPlugin('launcher')->launch($job);
+
+    // There is only one log entry.
+    $log_entries = $job->getLogEntries(ULTIMATE_CRON_LOG_TYPE_ALL, 3);
+    $this->assertCount(1, $log_entries);
+
+    $log_entry = reset($log_entries);
+    $this->assertTrue($log_entry instanceof LogEntry);
+    $this->assertEquals('ultimate_cron_logger_test_cron', $log_entry->name);
+    $this->assertEquals('Launched manually by anonymous (0)', (string) $log_entry->formatInitMessage());
+  }
+
+}
diff --git a/web/modules/ultimate_cron/tests/src/Kernel/SchedulerPluginTest.php b/web/modules/ultimate_cron/tests/src/Kernel/SchedulerPluginTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..9ea0fe88cc1bcae1621e118dd683b5161a3c3e07
--- /dev/null
+++ b/web/modules/ultimate_cron/tests/src/Kernel/SchedulerPluginTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Drupal\Tests\ultimate_cron\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\ultimate_cron\Plugin\ultimate_cron\Scheduler\Crontab;
+use Drupal\ultimate_cron\Plugin\ultimate_cron\Scheduler\Simple;
+
+/**
+ * Tests the default scheduler plugins.
+ *
+ * @group ultimate_cron
+ */
+class SchedulerPluginTest extends KernelTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = array('ultimate_cron');
+
+  /**
+   * Tests that scheduler plugins are discovered correctly.
+   */
+  function testDiscovery() {
+    /* @var \Drupal\Core\Plugin\DefaultPluginManager $manager */
+    $manager = \Drupal::service('plugin.manager.ultimate_cron.scheduler');
+
+    $plugins = $manager->getDefinitions();
+    $this->assertCount(2, $plugins);
+
+    $simple = $manager->createInstance('simple');
+    $this->assertTrue($simple instanceof Simple);
+    $this->assertEquals('simple', $simple->getPluginId());
+
+    $crontab = $manager->createInstance('crontab');
+    $this->assertTrue($crontab instanceof Crontab);
+    $this->assertEquals('crontab', $crontab->getPluginId());
+  }
+}
diff --git a/web/modules/ultimate_cron/tests/src/Kernel/UltimateCronQueueTest.php b/web/modules/ultimate_cron/tests/src/Kernel/UltimateCronQueueTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..39cbfb8fd542af397d8a967f7440a7b036445443
--- /dev/null
+++ b/web/modules/ultimate_cron/tests/src/Kernel/UltimateCronQueueTest.php
@@ -0,0 +1,157 @@
+<?php
+
+namespace Drupal\Tests\ultimate_cron\Kernel;
+
+use Drupal\Tests\system\Kernel\System\CronQueueTest;
+use Drupal\ultimate_cron\CronJobInterface;
+use Drupal\ultimate_cron\Entity\CronJob;
+
+/**
+ * Update feeds on cron.
+ *
+ * @group ultimate_cron
+ */
+class UltimateCronQueueTest extends CronQueueTest {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = array('ultimate_cron');
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    module_load_install('ultimate_cron');
+    ultimate_cron_install();
+    $this->installSchema('ultimate_cron', [
+      'ultimate_cron_log',
+      'ultimate_cron_lock',
+    ]);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testExceptions() {
+    // Get the queue to test the normal Exception.
+    $queue = $this->container->get('queue')->get('cron_queue_test_exception');
+
+    // Enqueue an item for processing.
+    $queue->createItem(array($this->randomMachineName() => $this->randomMachineName()));
+
+    // Run cron; the worker for this queue should throw an exception and handle
+    // it.
+    $this->cron->run();
+    $this->assertEquals(1, \Drupal::state()->get('cron_queue_test_exception'));
+
+    // The item should be left in the queue.
+    $this->assertEquals(1, $queue->numberOfItems(), 'Failing item still in the queue after throwing an exception.');
+
+    // Expire the queue item manually. system_cron() relies in REQUEST_TIME to
+    // find queue items whose expire field needs to be reset to 0. This is a
+    // Kernel test, so REQUEST_TIME won't change when cron runs.
+    // @see system_cron()
+    // @see \Drupal\Core\Cron::processQueues()
+    $this->connection->update('queue')
+      ->condition('name', 'cron_queue_test_exception')
+      ->fields(['expire' => REQUEST_TIME - 1])
+      ->execute();
+
+    // Has to be manually called for Ultimate Cron.
+    system_cron();
+
+    $this->cron->run();
+    $this->assertEquals(2, \Drupal::state()->get('cron_queue_test_exception'));
+
+    $this->assertEquals(0, $queue->numberOfItems(), 'Item was processed and removed from the queue.');
+    // Get the queue to test the specific SuspendQueueException.
+    $queue = $this->container->get('queue')->get('cron_queue_test_broken_queue');
+
+    // Enqueue several item for processing.
+    $queue->createItem('process');
+    $queue->createItem('crash');
+    $queue->createItem('ignored');
+
+    // Run cron; the worker for this queue should process as far as the crashing
+    // item.
+    $this->cron->run();
+
+    // Only one item should have been processed.
+    $this->assertEquals(2, $queue->numberOfItems(), 'Failing queue stopped processing at the failing item.');
+
+    // Check the items remaining in the queue. The item that throws the
+    // exception gets released by cron, so we can claim it again to check it.
+    $item = $queue->claimItem();
+    $this->assertEquals('crash', $item->data, 'Failing item remains in the queue.');
+    $item = $queue->claimItem();
+    $this->assertEquals('ignored', $item->data, 'Item beyond the failing item remains in the queue.');
+
+    // Test the requeueing functionality.
+    $queue = $this->container->get('queue')->get('cron_queue_test_requeue_exception');
+    $queue->createItem([]);
+    $this->cron->run();
+
+    $this->assertEquals(2, \Drupal::state()->get('cron_queue_test_requeue_exception'));
+    $this->assertEquals(0, $queue->numberOfItems());
+  }
+
+
+  /**
+   * Tests behavior when ultimate_cron overrides the cron processing.
+   */
+  public function testOverriddenProcessing() {
+
+    $job = CronJob::load(CronJobInterface::QUEUE_ID_PREFIX . 'cron_queue_test_broken_queue');
+    $this->assertNull($job);
+
+    $this->config('ultimate_cron.settings')
+      ->set('queue.enabled', TRUE)
+      ->save();
+
+    \Drupal::service('ultimate_cron.discovery')->discoverCronJobs();
+
+    $job = CronJob::load(CronJobInterface::QUEUE_ID_PREFIX . 'cron_queue_test_broken_queue');
+    $this->assertTrue($job instanceof CronJobInterface);
+
+    /** @var \Drupal\Core\Queue\QueueInterface $queue */
+    $queue = $this->container->get('queue')->get('cron_queue_test_broken_queue');
+
+    // Enqueue several item for processing.
+    $queue->createItem('process');
+    $queue->createItem('process');
+    $queue->createItem('process');
+    $this->assertEquals(3, $queue->numberOfItems());
+
+    // Run the job, this should process them.
+    $job->run(t('Test launch'));
+    $this->assertEquals(0, $queue->numberOfItems());
+
+    // Check item delay feature.
+    $this->config('ultimate_cron.settings')
+      ->set('queue.delays.item_delay', 0.5)
+      ->save();
+
+    $queue->createItem('process');
+    $queue->createItem('process');
+    $queue->createItem('process');
+    $this->assertEquals(3, $queue->numberOfItems());
+
+    // There are 3 items, the implementation doesn't wait for the first, that
+    // means this should between 1 and 1.5 seconds.
+    $before = microtime(TRUE);
+    $job->run(t('Test launch'));
+    $after = microtime(TRUE);
+
+    $this->assertEquals(0, $queue->numberOfItems());
+    $this->assertTrue($after - $before > 1);
+    $this->assertTrue($after - $after < 1.5);
+
+    // @todo Test empty delay, causes a wait of 60 seconds with the test queue
+    //   worker.
+  }
+
+}
diff --git a/web/modules/ultimate_cron/tests/src/Unit/RulesUnitTest.php b/web/modules/ultimate_cron/tests/src/Unit/RulesUnitTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..5fafc4a0a9848b6c7da7bed8de84b35a13263203
--- /dev/null
+++ b/web/modules/ultimate_cron/tests/src/Unit/RulesUnitTest.php
@@ -0,0 +1,769 @@
+<?php
+
+namespace Drupal\Tests\ultimate_cron\Unit;
+
+use Drupal\Tests\UnitTestCase;
+use Drupal\ultimate_cron\CronRule;
+use Drupal\ultimate_cron\Plugin\ultimate_cron\Scheduler\Crontab;
+
+/**
+ * Tests Drupal\ultimate_cron\CronRule.
+ *
+ * @group ultimate_cron
+ */
+class RulesUnitTest extends UnitTestCase {
+
+  private function getIntervals($rule) {
+    $cron = CronRule::factory($rule, $_SERVER['REQUEST_TIME']);
+    return $cron->getIntervals();
+  }
+
+  private function assertRule($options) {
+    // Setup values
+    $options['rules'] = is_array($options['rules']) ? $options['rules'] : array($options['rules']);
+    $options['catch_up'] = isset($options['catch_up']) ? $options['catch_up'] : 86400 * 365; // @todo Adapting Elysia Cron test cases with a catchup of 1 year
+
+    // Generate result message
+    $message = array();
+    foreach ($options['rules'] as $rule) {
+      $cron = CronRule::factory($rule, strtotime($options['now']));
+      $intervals = $cron->getIntervals();
+      $parsed_rule = '';
+      foreach ($intervals as $key => $value) {
+        $parsed_rule .= "$key: " . implode(',', $value) . "\n";
+      }
+      #$parsed_rule = str_replace(" ", "\n", $cron->rebuildRule($cron->getIntervals()));
+      $last_scheduled = $cron->getLastSchedule();
+      $message[] = "<span title=\"$parsed_rule\">$rule</span> @ " . date('Y-m-d H:i:s', $last_scheduled);
+    }
+    $message[] = 'now      @ ' . $options['now'];
+    $message[] = 'last-run @ ' . $options['last_run'];
+    $message[] = 'catch-up @ ' . $options['catch_up'];
+    $message[] = ($options['result'] ? '' : 'not ') . 'expected to run';
+
+    // Do the actual test
+    $result = Crontab::shouldRun($options['rules'], strtotime($options['last_run']), strtotime($options['now']), $options['catch_up']);
+
+    return array($options['result'] == $result, implode('<br/>', $message));
+  }
+
+  function testIntervals2MinuteRange() {
+    $intervals = $this->getIntervals('10-11 12 * * *');
+    $this->assertEquals(range(11, 10, -1), $intervals['minutes'], 'Expected minutes to be 10, 11');
+    $intervals = $this->getIntervals('0-1 12 * * *');
+    $this->assertEquals(range(1, 0, -1), $intervals['minutes'], 'Expected minutes to be 0, 1');
+    $intervals = $this->getIntervals('58-59 12 * * *');
+    $this->assertEquals(range(59, 58, -1), $intervals['minutes'], 'Expected minutes to be 58, 59');
+  }
+
+  function testIntervals2MinuteRangeWithOffset() {
+    $intervals = $this->getIntervals('0-1+1 12 * * *');
+    $this->assertEquals(range(2, 1, -1), $intervals['minutes'], 'Expected minutes to be 1, 2');
+    $intervals = $this->getIntervals('10-11+1 12 * * *');
+    $this->assertEquals(range(12, 11, -1), $intervals['minutes'], 'Expected minutes to be 11, 12');
+    // Note, this test is testing for correct behaviour when the minutes wrap around
+    // Previously, this test would generate 43, 0 due to a bug in expandRange/expandInterval
+    $intervals = $this->getIntervals('42-43+1 12 * * *');
+    $this->assertEquals(array(44, 43), $intervals['minutes'], 'Expected minutes to be 43, 44');
+    // Note, this test is testing for correct behaviour when the minutes wrap around
+    $intervals = $this->getIntervals('58-59+1 12 * * *');
+    $this->assertEquals(array(59, 0), $intervals['minutes'], 'Expected minutes to be 59, 0');
+  }
+
+  function testIntervalsSpecificMinute() {
+    $intervals = $this->getIntervals('0 12 * * *');
+    $this->assertEquals(array(0), $intervals['minutes'], 'Expected minutes to be 0');
+    $intervals = $this->getIntervals('10 12 * * *');
+    $this->assertEquals(array(10), $intervals['minutes'], 'Expected minutes to be 10');
+    $intervals = $this->getIntervals('59 12 * * *');
+    $this->assertEquals(array(59), $intervals['minutes'], 'Expected minutes to be 59');
+  }
+
+  function testRules() {
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '0 12 * * *',
+      'last_run' => '2008-01-02 12:00:00',
+      'now' => '2008-01-02 12:01:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '0 12 * * *',
+      'last_run' => '2008-01-02 12:00:00',
+      'now' => '2008-01-02 15:00:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '0 12 * * *',
+      'last_run' => '2008-01-02 12:00:00',
+      'now' => '2008-01-03 11:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '0 12 * * *',
+      'last_run' => '2008-01-02 12:00:00',
+      'now' => '2008-01-03 12:00:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '59 23 * * *',
+      'last_run' => '2008-01-02 23:59:00',
+      'now' => '2008-01-03 00:00:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59 23 * * *',
+      'last_run' => '2008-01-02 23:59:00',
+      'now' => '2008-01-03 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59 23 * * *',
+      'last_run' => '2008-01-02 23:59:00',
+      'now' => '2008-01-04 00:00:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59 23 * * *',
+      'last_run' => '2008-01-02 23:58:00',
+      'now' => '2008-01-02 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59 23 * * *',
+      'last_run' => '2008-01-02 23:58:00',
+      'now' => '2008-01-03 00:00:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '59 23 * * 0',
+      'last_run' => '2008-01-05 23:58:00',
+      'now' => '2008-01-05 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '59 23 * * 0',
+      'last_run' => '2008-01-05 23:58:00',
+      'now' => '2008-01-06 00:00:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59 23 * * 0',
+      'last_run' => '2008-01-05 23:58:00',
+      'now' => '2008-01-06 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59 23 * * 0',
+      'last_run' => '2008-01-05 23:58:00',
+      'now' => '2008-01-07 00:00:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '29,59 23 * * 0',
+      'last_run' => '2008-01-05 23:58:00',
+      'now' => '2008-01-06 23:29:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '29,59 23 * * 0',
+      'last_run' => '2008-01-05 23:58:00',
+      'now' => '2008-01-06 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '29,59 23 * * 0',
+      'last_run' => '2008-01-05 23:58:00',
+      'now' => '2008-01-05 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '29,59 23 * * 0',
+      'last_run' => '2008-01-05 23:58:00',
+      'now' => '2008-01-06 23:58:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '29,59 23 * * 0',
+      'last_run' => '2008-01-05 23:58:00',
+      'now' => '2008-01-06 23:28:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '29,59 23 * * 0',
+      'last_run' => '2008-01-05 23:28:00',
+      'now' => '2008-01-05 23:29:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '29,59 23 * * 0',
+      'last_run' => '2008-01-05 23:28:00',
+      'now' => '2008-01-05 23:30:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '29,59 23 * * 0',
+      'last_run' => '2008-01-05 23:28:00',
+      'now' => '2008-01-05 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '29,59 23 * * 0',
+      'last_run' => '2008-01-05 23:28:00',
+      'now' => '2008-01-06 23:29:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '29,59 23 * * 5',
+      'last_run' => '2008-02-22 23:59:00',
+      'now' => '2008-02-28 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '29,59 23 * * 5',
+      'last_run' => '2008-02-22 23:59:00',
+      'now' => '2008-02-29 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '29,59 23 * * 5',
+      'last_run' => '2008-02-22 23:59:00',
+      'now' => '2008-03-01 00:00:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '59 23 * * 3',
+      'last_run' => '2008-12-31 23:59:00',
+      'now' => '2009-01-01 00:00:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '59 23 * * 3',
+      'last_run' => '2008-12-31 23:59:00',
+      'now' => '2009-01-07 00:00:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59 23 * * 3',
+      'last_run' => '2008-12-31 23:59:00',
+      'now' => '2009-01-07 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59 23 * 2 5',
+      'last_run' => '2008-02-22 23:59:00',
+      'now' => '2008-02-29 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59 23 * 2 5',
+      'last_run' => '2008-02-22 23:59:00',
+      'now' => '2008-03-01 00:00:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '59 23 * 2 5',
+      'last_run' => '2008-02-29 23:59:00',
+      'now' => '2008-03-07 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '59 23 * 2 5',
+      'last_run' => '2008-02-29 23:59:00',
+      'now' => '2009-02-06 23:58:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59 23 * 2 5',
+      'last_run' => '2008-02-29 23:59:00',
+      'now' => '2009-02-06 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '59 23 */10 * *',
+      'last_run' => '2008-01-10 23:58:00',
+      'now' => '2008-01-10 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59 23 */10 * *',
+      'last_run' => '2008-01-10 23:59:00',
+      'now' => '2008-01-11 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59 23 */10 * *',
+      'last_run' => '2008-01-10 23:59:00',
+      'now' => '2008-01-20 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59 23 1-5,10-15 * *',
+      'last_run' => '2008-01-04 23:59:00',
+      'now' => '2008-01-05 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59 23 1-5,10-15 * *',
+      'last_run' => '2008-01-04 23:59:00',
+      'now' => '2008-01-06 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '59 23 1-5,10-15 * *',
+      'last_run' => '2008-01-05 23:59:00',
+      'now' => '2008-01-06 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '59 23 1-5,10-15 * *',
+      'last_run' => '2008-01-05 23:59:00',
+      'now' => '2008-01-10 23:58:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59 23 1-5,10-15 * *',
+      'last_run' => '2008-01-05 23:59:00',
+      'now' => '2008-01-10 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59 23 1-5,10-15 * *',
+      'last_run' => '2008-01-05 23:59:00',
+      'now' => '2008-01-16 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59 23 1-5 1 0',
+      'last_run' => '2008-01-04 23:59:00',
+      'now' => '2008-01-05 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59 23 1-5 1 0',
+      'last_run' => '2008-01-05 23:59:00',
+      'now' => '2008-01-06 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '59 23 1-5 1 0',
+      'last_run' => '2008-01-06 23:59:00',
+      'now' => '2008-01-07 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59 23 1-5 1 0',
+      'last_run' => '2008-01-06 23:59:00',
+      'now' => '2008-01-13 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '59 23 1-5 1 0',
+      'last_run' => '2008-02-04 23:59:00',
+      'now' => '2008-02-05 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '59 23 1-5 1 0',
+      'last_run' => '2008-02-05 23:59:00',
+      'now' => '2008-02-10 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '59 23 1-5 1 0',
+      'last_run' => '2008-02-10 23:59:00',
+      'now' => '2008-02-17 23:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '* 0,1,2,3,4,5,6,7,8,18,19,20,21,22,23 * * *',
+      'last_run' => '2008-02-10 08:58:00',
+      'now' => '2008-02-10 08:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '* 0,1,2,3,4,5,6,7,8,18,19,20,21,22,23 * * *',
+      'last_run' => '2008-02-10 08:59:00',
+      'now' => '2008-02-10 09:00:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '* 0,1,2,3,4,5,6,7,8,18,19,20,21,22,23 * * *',
+      'last_run' => '2008-02-10 08:59:00',
+      'now' => '2008-02-10 17:59:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '* 0,1,2,3,4,5,6,7,8,18,19,20,21,22,23 * * *',
+      'last_run' => '2008-02-10 08:59:00',
+      'now' => '2008-02-10 18:00:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '* 0,1,2,3,4,5,6,7,8,18,19,20,21,22,23 * * *',
+      'last_run' => '2008-02-10 18:00:00',
+      'now' => '2008-02-10 18:01:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '* 0,1,2,3,4,5,6,7,8,18,19,20,21,22,23 * * *',
+      'last_run' => '2008-02-10 18:00:00',
+      'now' => '2008-02-10 19:00:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '* 0,1,2,3,4,5,6,7,8,18,19,20,21,22,23 * * *',
+      'last_run' => '2008-02-10 18:00:00',
+      'now' => '2008-03-10 09:00:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+  }
+
+  function testRules1MinuteRange() {
+    // Test a 1 minute range
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '10-10 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:09:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '10-10 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:10:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '10-10 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:11:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+  }
+
+  function testRules2MinuteRange() {
+    // Test a 1 minute range
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '10-11 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:09:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '10-11 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:10:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '10-11 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:11:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '10-11 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:12:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+  }
+
+  function testRules2MinuteRangeWithOffset() {
+    // Test a 1 minute range
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '10-11+1 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:10:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '10-11+1 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:11:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '10-11+1 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:12:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '10-11+1 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:13:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+  }
+
+  function testRules5MinuteRange() {
+    // Test a 5 minute range
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '10-15 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:09:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '10-15 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:10:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '10-15 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:11:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '10-15 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:12:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '10-15 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:13:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '10-15 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:14:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '10-15 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:15:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    // This should not run, as it last ran one minute ago
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '10-15 12 * * *',
+      'last_run' => '2008-01-03 12:15:00',
+      'now' => '2008-01-03 12:16:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    // This should run, as catch_up defaults to 1 year and it last ran 16 minutes ago.
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '10-15 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:16:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+  }
+
+  function testRules5MinuteStep() {
+    // Test a 5 minute step
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '*/5 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:01:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '*/5 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:02:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '*/5 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:03:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '*/5 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:04:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '*/5 12 * * *',
+      'last_run' => '2008-01-03 12:00:00',
+      'now' => '2008-01-03 12:05:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+  }
+
+  function testRulesExtended() {
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '0 0 * jan,oct *',
+      'last_run' => '2008-01-31 00:00:00',
+      'now' => '2008-03-10 09:00:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '0 0 * jan,oct *',
+      'last_run' => '2008-01-31 00:00:00',
+      'now' => '2008-10-01 00:00:00'
+    ));
+    $this->assertTrue($result[0], $result[1]);
+  }
+
+  function testRulesMinuteWithOffset() {
+    // Test a 1 minute range
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '10+1 12 * * *',
+      'last_run' => '2008-01-01 12:00:00',
+      'now' => '2008-01-03 12:10:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '10+1 12 * * *',
+      'last_run' => '2008-01-01 12:00:00',
+      'now' => '2008-01-03 12:11:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '10+1 12 * * *',
+      'last_run' => '2008-01-01 12:00:00',
+      'now' => '2008-01-03 12:12:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '59+1 12 * * *',
+      'last_run' => '2008-01-01 12:00:00',
+      'now' => '2008-01-03 12:59:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => True,
+      'rules' => '59+1 12 * * *',
+      'last_run' => '2008-01-01 12:00:00',
+      'now' => '2008-01-03 12:00:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '59+1 12 * * *',
+      'last_run' => '2008-01-01 12:00:00',
+      'now' => '2008-01-03 13:00:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+    $result = $this->assertRule(array(
+      'result' => FALSE,
+      'rules' => '59+1 12 * * *',
+      'last_run' => '2008-01-01 12:00:00',
+      'now' => '2008-01-03 13:01:00',
+      'catch_up' => 1
+    ));
+    $this->assertTrue($result[0], $result[1]);
+  }
+}
+
diff --git a/web/modules/ultimate_cron/tests/ultimate_cron_logger_test/ultimate_cron_logger_test.info.yml b/web/modules/ultimate_cron/tests/ultimate_cron_logger_test/ultimate_cron_logger_test.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..9081ead804845be08bb5a49d7ccc6fd384ef0639
--- /dev/null
+++ b/web/modules/ultimate_cron/tests/ultimate_cron_logger_test/ultimate_cron_logger_test.info.yml
@@ -0,0 +1,12 @@
+name: Ultimate Cron Logger Test
+type: module
+description: Ultimate Cron Logger Test
+core_version_requirement: ^8.7.7 || ^9
+package: Tests
+dependencies:
+  - ultimate_cron
+
+# Information added by Drupal.org packaging script on 2020-09-24
+version: '8.x-2.0-alpha5'
+project: 'ultimate_cron'
+datestamp: 1600928951
diff --git a/web/modules/ultimate_cron/tests/ultimate_cron_logger_test/ultimate_cron_logger_test.module b/web/modules/ultimate_cron/tests/ultimate_cron_logger_test/ultimate_cron_logger_test.module
new file mode 100644
index 0000000000000000000000000000000000000000..bb36bc7c84c49fff0eb050d07752b03f5282637b
--- /dev/null
+++ b/web/modules/ultimate_cron/tests/ultimate_cron_logger_test/ultimate_cron_logger_test.module
@@ -0,0 +1,27 @@
+<?php
+/**
+ * @file
+ * Contains ultimate_cron_logger_test.module..
+ */
+
+/**
+ * Implements hook_cron().
+ */
+function ultimate_cron_logger_test_cron() {
+  $action = \Drupal::state()->get('ultimate_cron_logger_test_cron_action');
+
+  if ($action == 'exception') {
+    throw new Exception('Test cron exception');
+  }
+  elseif ($action == 'fatal') {
+    call_to_undefined_function();
+  }
+  elseif ($action == 'long_message') {
+    // This long message text length is 5800 long.
+    $long_message = str_repeat('This is a very long message. ', 200);
+    \Drupal::logger('ultimate_cron_logger_test_cron')->notice($long_message);
+  }
+  elseif ($action == 'log_warning') {
+    \Drupal::logger('ultimate_cron_logger_test_cron')->warning('This is a warning message');
+  }
+}
diff --git a/web/modules/ultimate_cron/ultimate_cron.api.php b/web/modules/ultimate_cron/ultimate_cron.api.php
new file mode 100644
index 0000000000000000000000000000000000000000..8f3e7d7a643437dca99a7b22930a9e94135f8eb0
--- /dev/null
+++ b/web/modules/ultimate_cron/ultimate_cron.api.php
@@ -0,0 +1,274 @@
+<?php
+/**
+ * @file
+ * Hooks provided by Ultimate Cron.
+ */
+use Drupal\ultimate_cron\Entity\CronJob;
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Inform Ultimate Cron about cron jobs.
+ *
+ * To add additional/multiple cron jobs from a custom module, provide
+ * a default configuration in the module with the needed settings.
+ * Example: ultimate_cron.job.custom_module_cron.yml
+ *
+ * Note that the result of this hook is cached.
+ *
+ * @return array
+ *   Array of cron jobs, keyed by name.
+ *    - "title": (optional) The title of the cron job. If not provided, the
+ *      name of the cron job will be used.
+ *    - "file": (optional) The file where the callback lives.
+ *    - "module": The module where this job lives.
+ *    - "file path": (optional) The path to the directory containing the file
+ *      specified in "file". This defaults to the path to the module
+ *      implementing the hook.
+ *    - "callback": (optional) The callback to call when running the job.
+ *      Defaults to the job name.
+ *    - "callback arguments": (optional) Arguments for the callback. Defaults
+ *      to array().
+ *    - "enabled": (optional) Initial state of the job. Defaults to TRUE.
+ *    - "tags": (optional) Tags for the job. Defaults to array().
+ *    - "settings": (optional) Default settings (plugin type) for this job.
+ *      Example of a job declaring some default settings for a plugin called
+ *      "some_plugin":
+ *      'settings' => array(
+ *        'some_plugin' => array(
+ *          'some_value' => 60,
+ *        ),
+ *      ),
+ *    - "scheduler": (optional) Default scheduler (plugin type) for this job.
+ *      Example of a job using the crontab scheduler as default:
+ *      'scheduler' => array(
+ *        'name' => 'crontab',
+ *        'crontab' => array(
+ *          'rules' => array('* * * * *'),
+ *        ),
+ *      ),
+ *    - "launcher": (optional) Default launcher (plugin type) for this job.
+ *      Example of a job using the serial launcher as default:
+ *      'launcher' => array(
+ *        'name' => 'serial',
+ *        'serial' => array(
+ *          'thread' => 'any',
+ *        ),
+ *      ),
+ *    - "logger": (optional) Default logger (plugin type) for this job.
+ *      Example of a job using the cache logger as default:
+ *      'logger' => array(
+ *        'name' => 'cache',
+ *        'cache' => array(
+ *          'bin' => 'mycachebin',
+ *        ),
+ *      ),
+ */
+function hook_cronapi() {
+  $items = array();
+
+  $items['example_my_cron_job_1'] = array(
+    'title' => t('This is my cron job #1'),
+    'file' => 'example.jobs.inc',
+    'file path' => drupal_get_path('module', 'example') . '/cron',
+    'callback' => 'example_my_cron_job_callback',
+    'callback arguments' => array('cronjob1'),
+    'enabled' => FALSE,
+    'tags' => array('example'),
+    'settings' => array(
+      'example_plugin' => array(
+        'example_setting' => 'example_value',
+      ),
+    ),
+    'scheduler' => array(
+      'name' => 'crontab',
+      'crontab' => array(
+        'rules' => array('* * * * *'),
+      ),
+    ),
+    'launcher' => array(
+      'name' => 'serial',
+      'serial' => array(
+        'thread' => 'any',
+      ),
+    ),
+    'logger' => array(
+      'name' => 'cache',
+      'cache' => array(
+        'bin' => 'my_cache_bin',
+      ),
+    ),
+  );
+
+  return $items;
+}
+
+/**
+ * Alter the output of hook_cronapi() and hook_cron().
+ *
+ * Note that the result of this hook is cached just like hook_cronapi().
+ *
+ * This can hook can also be implemented inside a plugin, but with a
+ * slight difference. Inside the plugin, the hook is not cached and it operates
+ * on an array of UltimateCronJob objects instead of hook definitions.
+ *
+ * @param array &$items
+ *   Hooks defined in the system.
+ */
+function hook_cron_alter(&$items) {
+  $items['example_my_cron_job_1']['title'] = 'NEW TITLE FOR EXAMPLE CRON JOB #1! HA!';
+}
+
+/**
+ * Provide easy hooks for Ultimate Cron.
+ *
+ * Ultimate Cron has a built-in set of easy hooks:
+ *  - hook_cron_hourly().
+ *  - hook_cron_daily().
+ *  - hook_cron_nightly().
+ *  - hook_cron_weekly().
+ *  - hook_cron_monthly().
+ *  - hook_cron_yearly().
+ *
+ * This hook makes it possible to provide custom easy hooks.
+ *
+ * @return array
+ *   Array of easy hook definitions.
+ */
+function hook_cron_easy_hooks() {
+  return array(
+    'cron_fullmoonly' => array(
+      'title' => 'Run at full moon',
+      'scheduler' => array(
+        'name' => 'moonphase',
+        'moonphase' => array(
+          'phase' => 'full',
+        ),
+      ),
+    )
+  );
+}
+
+/**
+ * Alter easy hooks.
+ *
+ * @param array &$easy_hooks
+ *   Easy hook definitions.
+ */
+function hook_cron_easy_hooks_alter(&$easy_hooks) {
+  $easy_hooks['cron_fullmoonly']['scheduler']['moonphase']['phase'] = 'new';
+}
+
+/**
+ * The following hooks are invoked during the jobs life cycle,
+ * from schedule to finish. The chronological order is:
+ *
+ * cron_pre_schedule
+ * cron_post_schedule
+ * cron_pre_launch
+ * cron_pre_launch(*)
+ * cron_pre_run
+ * cron_pre_invoke
+ * cron_post_invoke
+ * cron_post_run
+ * cron_post_launch(*)
+ *
+ * Depending on how the launcher works, the hook_cron_post_launch() may be
+ * invoked before or after hook_cron_post_run() or somewhere in between.
+ * An example of this is the Background Process launcher, which launches
+ * the job in a separate thread. After the launch, hook_cron_post_launch()
+ * is invoked, but the run/invoke hooks are invoked simultaneously in a
+ * separate thread.
+ *
+ * All of these hooks can also be implemented inside a plugin as a method.
+ */
+
+/**
+ * Invoked just before a job is asked for its schedule.
+ *
+ * @param CronJob $job
+ *   The job being queried.
+ */
+function hook_pre_schedule($job) {
+}
+
+/**
+ * Invoked after a job has been asked for its schedule.
+ *
+ * @param CronJob $job
+ *   The job being queried.
+ */
+function hook_post_schedule($job) {
+}
+
+/**
+ * Invoked just before a job is launched.
+ *
+ * @param CronJob $job
+ *   The job being launched.
+ */
+function hook_pre_launch($job) {
+}
+
+/**
+ * Invoked after a job has been launched.
+ *
+ * @param CronJob $job
+ *   The job that was launched.
+ */
+function hook_post_launch($job) {
+}
+
+/**
+ * Invoked just before a job is being run.
+ *
+ * @param CronJob $job
+ *   The job being run.
+ */
+function hook_pre_run($job) {
+}
+
+/**
+ * Invoked after a job has been run.
+ *
+ * @param CronJob $job
+ *   The job that was run.
+ */
+function hook_post_run($job) {
+}
+
+/**
+ * Invoked just before a job is asked for its schedule.
+ *
+ * @param CronJob $job
+ *   The job being invoked.
+ */
+function hook_pre_invoke($job) {
+}
+
+/**
+ * Invoked after a job has been invoked.
+ *
+ * @param CronJob $job
+ *   The job that was invoked.
+ */
+function hook_post_invoke($job) {
+}
+
+/**
+ * Alter the allowed operations for a given job on the export UI page.
+ *
+ * This hook can also be implemented inside a plugin as a method:
+ * build_operations_alter($job, &$allowed_operations). It will only be
+ * run for the currently active plugin for the job.
+ *
+ * @param CronJob $job
+ *   The job in question.
+ * @param array &$allowed_operations
+ *   Allowed operations for this job.
+ */
+function hook_ultimate_cron_plugin_build_operations($job, &$allowed_operations) {
+}
diff --git a/web/modules/ultimate_cron/ultimate_cron.drush.inc b/web/modules/ultimate_cron/ultimate_cron.drush.inc
new file mode 100644
index 0000000000000000000000000000000000000000..06100834ba55be0f6b79cae1a4594bc533cb960c
--- /dev/null
+++ b/web/modules/ultimate_cron/ultimate_cron.drush.inc
@@ -0,0 +1,471 @@
+<?php
+/**
+ * @file
+ * Drush commands for Ultimate Cron!
+ */
+use Drupal\ultimate_cron\CronPlugin;
+use Drupal\ultimate_cron\Entity\CronJob;
+
+/**
+ * Implements hook_drush_command().
+ */
+function ultimate_cron_drush_command() {
+  $items = array();
+
+  $items['cron-logs'] = array(
+    'description' => 'Show a cron jobs logs',
+    'arguments' => array(
+      'name' => 'Job to show logs for',
+    ),
+    'options' => array(
+      'limit' => 'Number of log entries to show',
+      'compact' => 'Only show the first line of each log entry',
+    ),
+    'examples' => array(
+      'drush cron-logs node_cron --limit=20' => 'Show 20 last logs for the node_cron job',
+    ),
+  );
+
+  $items['cron-list'] = array(
+    'description' => 'List cron jobs',
+    'options' => array(
+      'module' => 'Comma separated list of modules to show jobs from',
+      'enabled' => 'Show enabled jobs',
+      'disabled' => 'Show enabled jobs',
+      'behind' => 'Show jobs that are behind schedule',
+      'status' => 'Comma separated list of statuses to show jobs from',
+      'extended' => 'Show extended information',
+      'name' => 'Show name instead of title',
+      'scheduled' => 'Show scheduled jobs',
+    ),
+    'examples' => array(
+      'drush cron-list --status=running --module=node' => 'Show jobs from the node module that are currently running',
+    ),
+    'aliases' => array('cl'),
+  );
+
+  $items['cron-run'] = array(
+    'description' => 'Run cron job',
+    'arguments' => array(
+      'name' => 'Job to run',
+    ),
+    'options' => array(
+      'force' => 'Skip the schedule check for each job. Locks are still respected.',
+      'options' => 'Custom options for plugins, e.g. --options=thread=1 for serial launcher',
+    ),
+    'examples' => array(
+      'drush cron-run node_cron' => 'Run the node_cron job',
+      'drush cron-run --options=thread=1' => 'Run all scheduled jobs and instruct serial launcher only to launch thread 1 jobs',
+    ),
+    'aliases' => array('crun'),
+  );
+
+  $items['cron-is-running'] = array(
+    'description' => 'Tell whether cron is running. Exit status is set in concordance with the cron running status.',
+    'examples' => array(
+      'drush cron-is-running' => 'Check if cron is running.',
+      'drush cron-is-running --quiet' => 'Check if cron is running and don\'t show an informative message.',
+      'while `drush cron-is-running --quiet`; do echo "Waiting cron to finish"; sleep 1; done' => 'Bash loop to wait until cron finishes.',
+    ),
+    'aliases' => array('cir'),
+  );
+
+  $items['cron-enable'] = array(
+    'description' => 'Enable cron job',
+    'arguments' => array(
+      'name' => 'Job to enable',
+    ),
+    'options' => array(
+      'all' => 'Enabled all jobs',
+    ),
+    'examples' => array(
+      'drush cron-enable node_cron' => 'Enable the node_cron job',
+    ),
+    'aliases' => array('ce'),
+  );
+
+  $items['cron-disable'] = array(
+    'description' => 'Disable cron job',
+    'arguments' => array(
+      'name' => 'Job to disable',
+    ),
+    'options' => array(
+      'all' => 'Enabled all jobs',
+    ),
+    'examples' => array(
+      'drush cron-disable node_cron' => 'Disable the node_cron job',
+    ),
+    'aliases' => array('cd'),
+  );
+
+  $items['cron-unlock'] = array(
+    'description' => 'Unlock cron job',
+    'arguments' => array(
+      'name' => 'Job to unlock',
+    ),
+    'options' => array(
+      'all' => 'Enabled all jobs',
+    ),
+    'examples' => array(
+      'drush cron-unlock node_cron' => 'Unlock the node_cron job',
+    ),
+    'aliases' => array('cu'),
+  );
+
+  return $items;
+}
+
+/**
+ * Implements hook_drush_help().
+ */
+function ultimate_cron_drush_help($section) {
+  switch ($section) {
+    case 'drush:cron-list':
+      return dt('This command will list cron jobs');
+
+    case 'drush:cron-run':
+      return dt('This command will run a cron job');
+
+    case 'drush:cron-enable':
+      return dt('This command will enable a cron job');
+
+    case 'drush:cron-disable':
+      return dt('This command will disable a cron job');
+
+    case 'drush:cron-unlock':
+      return dt('This command will unlock a cron job');
+  }
+}
+
+/**
+ * List cron jobs.
+ */
+function drush_ultimate_cron_cron_list() {
+  $modules = drush_get_option('module');
+  $enabled = drush_get_option('enabled');
+  $disabled = drush_get_option('disabled');
+  $behind = drush_get_option('behind');
+  $extended = drush_get_option('extended');
+  $statuses = drush_get_option('status');
+  $scheduled = drush_get_option('scheduled');
+  $showname = drush_get_option('name');
+
+  $modules = $modules ? explode(',', $modules) : array();
+  $statuses = $statuses ? explode(',', $statuses) : array();
+
+  $title = $showname ? dt('Name') : dt('Title');
+
+  $table = array();
+  $table[] = array(
+    '',
+    dt('ID'),
+    dt('Module'),
+    $title,
+    dt('Scheduled'),
+    dt('Started'),
+    dt('Duration'),
+    dt('Status'),
+  );
+
+  $print_legend = FALSE;
+
+  /** @var \Drupal\ultimate_cron\Entity\CronJob $job */
+  foreach (CronJob::loadMultiple() as $name => $job) {
+    if ($modules && !in_array($job->getModule(), $modules)) {
+      continue;
+    }
+
+    if ($enabled && FALSE === $job->status()) {
+      continue;
+    }
+
+    if ($disabled && TRUE === $job->status()) {
+      continue;
+    }
+
+    if ($scheduled && !$job->isScheduled()) {
+      continue;
+    }
+
+    $legend = '';
+
+    if (FALSE === $job->status()) {
+      $legend .= 'D';
+      $print_legend = TRUE;
+    }
+
+    $lock_id = $job->isLocked();
+    $log_entry = $job->loadLogEntry($lock_id);
+
+    if ($time = $job->isBehindSchedule()) {
+      $legend .= 'B';
+      $print_legend = TRUE;
+    }
+
+    if ($behind && !$time) {
+      continue;
+    }
+
+    if ($lock_id && $log_entry->lid == $lock_id) {
+      $legend .= 'R';
+      list(, $status) = $job->getPlugin('launcher')->formatRunning($job);
+      $print_legend = TRUE;
+    }
+    elseif ($log_entry->start_time && !$log_entry->end_time) {
+     list(, $status) = $job->getPlugin('launcher')->formatUnfinished($job);
+    }
+    else {
+      list(, $status) = $log_entry->formatSeverity();
+    }
+
+    if ($statuses && !in_array($status, $statuses)) {
+      continue;
+    }
+
+    $progress = $lock_id ? $job->formatProgress() : '';
+
+    $table[$name][] = $legend;
+    $table[$name][] = $job->id();
+    $table[$name][] = $job->getModuleName();
+    $table[$name][] = $showname ? $job->id() : $job->getTitle();
+    $table[$name][] = $job->getPlugin('scheduler')->formatLabel($job);
+    $table[$name][] = $log_entry->formatStartTime();
+    $table[$name][] = $log_entry->formatDuration() . ' ' . $progress;
+    $table[$name][] = $status;
+
+    if ($extended) {
+      $table['extended:' . $name][] = '';
+      $table['extended:' . $name][] = '';
+      $table['extended:' . $name][] = $job->id();
+      $table['extended:' . $name][] = $job->getPlugin('scheduler')->formatLabelVerbose($job);
+      $table['extended:' . $name][] = $log_entry->init_message;
+      $table['extended:' . $name][] = $log_entry->message;
+    }
+  }
+  drush_print_table($table);
+  if ($print_legend) {
+    drush_print("\n" . dt('Legend: D = Disabled, R = Running, B = Behind schedule'));
+  }
+}
+
+/**
+ * List cron jobs.
+ */
+function drush_ultimate_cron_cron_logs($name = NULL) {
+  if (!$name) {
+    return drush_set_error(dt('No job specified?'));
+  }
+
+  /** @var CronJob $job */
+  $job = Cronjob::load($name);
+
+  if (!$job) {
+    return drush_set_error(dt('@name not found', array('@name' => $name)));
+  }
+
+  $compact = drush_get_option('compact');
+  $limit = drush_get_option('limit');
+  $limit = $limit ? $limit : 10;
+
+  $table = array();
+  $table[] = array(
+    '',
+    dt('Started'),
+    dt('Duration'),
+    dt('User'),
+    dt('Initial message'),
+    dt('Message'),
+    dt('Status'),
+  );
+
+  $lock_id = $job->isLocked();
+  $log_entries = $job->getLogEntries(ULTIMATE_CRON_LOG_TYPE_ALL, $limit);
+
+  /** @var \Drupal\ultimate_cron\Logger\LogEntry $log_entry */
+  foreach ($log_entries as $log_entry) {
+    $progress = '';
+    if ($log_entry->lid && $lock_id && $log_entry->lid === $lock_id) {
+      $progress = $job->getProgress();
+      $progress = is_numeric($progress) ? sprintf(' (%d%%)', round($progress * 100)) : '';
+    }
+
+    $legend = '';
+    if ($lock_id && $log_entry->lid == $lock_id) {
+      $legend .= 'R';
+      list(, $status) = $job->getPlugin('launcher')->formatRunning($job);
+    }
+    elseif ($log_entry->start_time && !$log_entry->end_time) {
+      list(, $status) = $job->getPlugin('launcher')->formatUnfinished($job);
+    }
+    else {
+      list(, $status) = $log_entry->formatSeverity();
+    }
+
+    $table[$log_entry->lid][] = $legend;
+    $table[$log_entry->lid][] = $log_entry->formatStartTime();
+    $table[$log_entry->lid][] = $log_entry->formatDuration() . $progress;
+    $table[$log_entry->lid][] = $log_entry->formatUser();
+    if ($compact) {
+      $table[$log_entry->lid][] = trim(reset(explode("\n", $log_entry->init_message)), "\n");
+      $table[$log_entry->lid][] = trim(reset(explode("\n", $log_entry->message)), "\n");
+    }
+    else {
+      $table[$log_entry->lid][] = trim($log_entry->init_message, "\n");
+      $table[$log_entry->lid][] = trim($log_entry->message, "\n");
+    }
+    $table[$log_entry->lid][] = $status;
+  }
+  drush_print_table($table);
+}
+
+/**
+ * Run cron job(s).
+ */
+function drush_ultimate_cron_cron_run($name = NULL) {
+
+  if ($options = drush_get_option('options')) {
+    $pairs = explode(',', $options);
+    foreach ($pairs as $pair) {
+      list($key, $value) = explode('=', $pair);
+      CronPlugin::setGlobalOption(trim($key), trim($value));
+    }
+  }
+
+  $force = drush_get_option('force');
+
+  if (!$name) {
+    // Run all jobs.
+    $jobs = CronJob::loadMultiple();
+
+    /** @var CronJob $job */
+    foreach($jobs as $job) {
+      if ($force || $job->isScheduled()) {
+        $job->run(t('Launched by drush'));
+      }
+
+    }
+  }
+  else {
+    // Run a specific job.
+    $job = CronJob::load($name);
+
+    if (!$job) {
+      return drush_set_error(dt('@name not found', array('@name' => $name)));
+    }
+
+    if ($force || $job->isScheduled()) {
+      $job->run(t('Launched by drush'));
+    }
+  }
+}
+
+/**
+ * Tell whether cron is running.
+ */
+function drush_ultimate_cron_cron_is_running() {
+  $locked = FALSE;
+  foreach (CronJob::loadMultiple() as $name => $job) {
+    if ($job->isLocked()) {
+      $locked = TRUE;
+      break;
+    }
+  }
+
+  if ($locked) {
+    $msg = dt('Cron is running.');
+    drush_set_context('DRUSH_EXIT_CODE', 0);
+  }
+  else {
+    $msg = dt('Cron is not running.');
+    drush_set_context('DRUSH_EXIT_CODE', 1);
+  }
+
+  return $msg;
+}
+
+/**
+ * Enable a cron job.
+ */
+function drush_ultimate_cron_cron_enable($name = NULL) {
+  if (!$name) {
+    if (!drush_get_option('all')) {
+      return drush_set_error(dt('No job specified?'));
+    }
+    /** @var CronJob $job */
+    foreach (CronJob::loadMultiple() as $job) {
+      $job->enable()->save();
+    }
+    return;
+  }
+
+  $job = CronJob::load($name);
+  if ($job->enable()->save()) {
+    drush_print(dt('@name enabled', array('@name' => $name)));
+  }
+}
+
+/**
+ * Disable a cron job.
+ */
+function drush_ultimate_cron_cron_disable($name = NULL) {
+  if (!$name) {
+    if (!drush_get_option('all')) {
+      return drush_set_error(dt('No job specified?'));
+    }
+    foreach (CronJob::loadMultiple() as $job) {
+      $job->disable()->save();
+    }
+    return;
+  }
+
+  $job = CronJob::load($name);
+  if ($job->disable()->save()) {
+    drush_print(dt('@name disabled', array('@name' => $name)));
+  }
+}
+
+/**
+ * Unlock a cron job.
+ */
+function drush_ultimate_cron_cron_unlock($name = NULL) {
+  if (!$name) {
+    if (!drush_get_option('all')) {
+      return drush_set_error(dt('No job specified?'));
+    }
+    /** @var CronJob $job */
+    foreach (CronJob::loadMultiple() as $job) {
+      if ($job->isLocked()) {
+        $job->unlock();
+      }
+    }
+    return;
+  }
+
+  /** @var CronJob $job */
+  $job = CronJob::load($name);
+  if (!$job) {
+    return drush_set_error(dt('@name not found', array('@name' => $name)));
+  }
+
+  $lock_id = $job->isLocked();
+  if (!$lock_id) {
+    return drush_set_error(dt('@name is not running', array('@name' => $name)));
+  }
+
+  // Unlock the process.
+  if ($job->unlock($lock_id, TRUE)) {
+    $log_entry = $job->resumeLog($lock_id);
+    global $user;
+    \Drupal::logger('ultimate_cron')->warning('@name manually unlocked by user @username (@uid)', array(
+      '@name' => $job->id(),
+      '@username' => $user->getDisplayName(),
+      '@uid' => $user->id(),
+    ));
+    $log_entry->finish();
+
+    drush_print(dt('Cron job @name unlocked', array('@name' => $name)));
+  }
+  else {
+    drush_set_error(dt('Could not unlock cron job @name', array('@name' => $name)));
+  }
+}
diff --git a/web/modules/ultimate_cron/ultimate_cron.info.yml b/web/modules/ultimate_cron/ultimate_cron.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ef825f2a5a67a7fa1637ea409f352628df6aa752
--- /dev/null
+++ b/web/modules/ultimate_cron/ultimate_cron.info.yml
@@ -0,0 +1,10 @@
+type: module
+name: "Ultimate Cron"
+description: "Cron"
+core_version_requirement: ^8.7.7 || ^9
+configure: entity.ultimate_cron_job.collection
+
+# Information added by Drupal.org packaging script on 2020-09-24
+version: '8.x-2.0-alpha5'
+project: 'ultimate_cron'
+datestamp: 1600928951
diff --git a/web/modules/ultimate_cron/ultimate_cron.install b/web/modules/ultimate_cron/ultimate_cron.install
new file mode 100755
index 0000000000000000000000000000000000000000..2e6ed179e607b3c82ba52bebe23f991a13dcf894
--- /dev/null
+++ b/web/modules/ultimate_cron/ultimate_cron.install
@@ -0,0 +1,200 @@
+<?php
+/**
+ * @file
+ * Installation file for Ultimate Cron
+ */
+
+use Drupal\Core\Url;
+use Drupal\ultimate_cron\Entity\CronJob;
+
+/**
+ * Implements hook_schema().
+ */
+function ultimate_cron_schema() {
+  $schema = array();
+
+  $schema['ultimate_cron_log'] = array(
+    'description' => 'Logs',
+    'fields' => array(
+      'lid' => array(
+        'description' => 'Lock ID',
+        'type' => 'varchar',
+        'length' => 176,
+        'not null' => TRUE,
+      ),
+      'name' => array(
+        'description' => 'Name',
+        'type' => 'varchar',
+        'length' => 166,
+        'not null' => TRUE,
+      ),
+      'log_type' => array(
+        'description' => 'Log type',
+        'type' => 'int',
+        'size' => 'normal',
+        'not null' => TRUE,
+        'default' => 0,
+      ),
+      'start_time' => array(
+        'description' => 'Timestamp of execution start',
+        'type' => 'float',
+        'size' => 'big',
+        'not null' => TRUE,
+        'default' => 0,
+      ),
+      'end_time' => array(
+        'description' => 'Timestamp of execution end',
+        'type' => 'float',
+        'size' => 'big',
+        'not null' => TRUE,
+        'default' => 0,
+      ),
+      'uid' => array(
+        'description' => 'User ID',
+        'type' => 'int',
+        'size' => 'normal',
+        'not null' => TRUE,
+        'default' => 0,
+      ),
+      'init_message' => array(
+        'description' => 'Initial message',
+        'type' => 'text',
+        'not null' => FALSE,
+      ),
+      'message' => array(
+        'description' => 'Message',
+        'type' => 'text',
+        'not null' => FALSE,
+      ),
+      'severity' => array(
+        'description' => 'Max severity level of the execution',
+        'type' => 'int',
+        'size' => 'normal',
+        'not null' => FALSE,
+        'default' => -1,
+      ),
+    ),
+    'primary key' => array('lid'),
+    'indexes' => array(
+      'idx_last' => array(
+        array('name', 80),
+        'start_time',
+        'end_time',
+        'log_type',
+      ),
+    ),
+  );
+
+  $schema['ultimate_cron_lock'] = array(
+    'description' => 'Locks',
+    'fields' => array(
+      'lid' => array(
+        'description' => 'Lock ID',
+        'type' => 'serial',
+        'size' => 'big',
+        'not null' => TRUE,
+      ),
+      'name' => array(
+        'description' => 'Name',
+        'type' => 'varchar',
+        'length' => 166,
+        'not null' => TRUE,
+      ),
+      'current' => array(
+        'description' => 'Current lock',
+        'type' => 'int',
+        'size' => 'big',
+        'not null' => TRUE,
+        'default' => 0,
+      ),
+      'expire' => array(
+        'description' => 'Expiration time of lock',
+        'type' => 'float',
+        'size' => 'big',
+        'not null' => TRUE,
+        'default' => 0,
+      ),
+    ),
+    'primary key' => array('lid'),
+    'unique keys' => array(
+      'idx_name' => array('name', 'current'),
+    ),
+  );
+
+  $schema['ultimate_cron_signal'] = array(
+    'description' => 'Signals',
+    'fields' => array(
+      'job_name' => array(
+        'description' => 'Name of job',
+        'type' => 'varchar',
+        'length' => 166,
+        'not null' => TRUE,
+      ),
+      'signal_name' => array(
+        'description' => 'Name of signal',
+        'type' => 'varchar',
+        'length' => 166,
+        'not null' => TRUE,
+      ),
+      'claimed' => array(
+        'description' => 'Is signal claimed',
+        'type' => 'int',
+        'size' => 'tiny',
+        'not null' => TRUE,
+        'default' => 0,
+      ),
+    ),
+    'primary key' => array('job_name', 'signal_name'),
+  );
+
+  return $schema;
+}
+
+/**
+ * Implements hook_install().
+ */
+function ultimate_cron_install() {
+  \Drupal::service('ultimate_cron.discovery')->discoverCronJobs();
+}
+
+/**
+ * Implements hook_requirements().
+ */
+function ultimate_cron_requirements($phase) {
+  $requirements = array();
+
+  switch ($phase) {
+    case 'runtime':
+      $requirements['cron_jobs']['title'] = 'Ultimate Cron';
+      $requirements['cron_jobs']['severity'] = REQUIREMENT_OK;
+
+      // Check if any jobs are behind.
+      $jobs_behind = 0;
+      $jobs = CronJob::loadMultiple();
+
+      foreach ($jobs as $job) {
+        if ($job->isBehindSchedule()) {
+          $jobs_behind++;
+        }
+      }
+
+      if ($jobs_behind) {
+        $requirements['cron_jobs']['severity'] = REQUIREMENT_WARNING;
+        $requirements['cron_jobs']['value'] = \Drupal::translation()->formatPlural(
+          $jobs_behind,
+          '@count job is behind schedule',
+          '@count jobs are behind schedule'
+        );
+        $requirements['cron_jobs']['description'] = [
+          '#markup' => t('Some jobs are behind their schedule. Please check if <a href=":system_cron_url">Cron</a> is running properly.', [
+            ':system_cron_url' => Url::fromRoute('system.cron', ['key' => \Drupal::state()->get('system.cron_key')])->toString()
+          ])
+        ];
+      }
+      else {
+        $requirements['cron_jobs']['value'] = t('Cron is running properly.');
+      }
+  }
+
+  return $requirements;
+}
diff --git a/web/modules/ultimate_cron/ultimate_cron.links.action.yml b/web/modules/ultimate_cron/ultimate_cron.links.action.yml
new file mode 100644
index 0000000000000000000000000000000000000000..fe8c5580f5fb8f7129c731c3eb2c3ce013bcf643
--- /dev/null
+++ b/web/modules/ultimate_cron/ultimate_cron.links.action.yml
@@ -0,0 +1,6 @@
+ultimate_cron.job_add:
+  route_name: ultimate_cron.discover_jobs
+  title: 'Discover jobs'
+  weight: 1
+  appears_on:
+    - entity.ultimate_cron_job.collection
diff --git a/web/modules/ultimate_cron/ultimate_cron.links.menu.yml b/web/modules/ultimate_cron/ultimate_cron.links.menu.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b0c967263141f353709aceeaa26c14d2f52f0ae5
--- /dev/null
+++ b/web/modules/ultimate_cron/ultimate_cron.links.menu.yml
@@ -0,0 +1,5 @@
+system.cron_settings:
+  title: 'Cron'
+  description: 'Manage automatic site maintainenance tasks.'
+  route_name: entity.ultimate_cron_job.collection
+  parent: system.admin_config_system
diff --git a/web/modules/ultimate_cron/ultimate_cron.links.task.yml b/web/modules/ultimate_cron/ultimate_cron.links.task.yml
new file mode 100644
index 0000000000000000000000000000000000000000..54e0a3755330d89cdc2a7574d44bd75d070cc431
--- /dev/null
+++ b/web/modules/ultimate_cron/ultimate_cron.links.task.yml
@@ -0,0 +1,34 @@
+ultimate_cron.job_list:
+  title: 'Cron Jobs'
+  route_name: entity.ultimate_cron_job.collection
+  base_route: entity.ultimate_cron_job.collection
+
+ultimate_cron.run_cron:
+  title: 'Run cron'
+  route_name: system.cron_settings
+  base_route: entity.ultimate_cron_job.collection
+
+ultimate_cron.settings:
+  title: 'Cron settings'
+  route_name: ultimate_cron.settings
+  base_route: entity.ultimate_cron_job.collection
+
+ultimate_cron.general_settings:
+  title: Queue
+  route_name: ultimate_cron.general_settings
+  parent_id: ultimate_cron.settings
+
+ultimate_cron.launcher_settings:
+  title: Launcher
+  route_name: ultimate_cron.launcher_settings
+  parent_id: ultimate_cron.settings
+
+ultimate_cron.logger_settings:
+  title: Logger
+  route_name: ultimate_cron.logger_settings
+  parent_id: ultimate_cron.settings
+
+ultimate_cron.scheduler_settings:
+  title: Scheduler
+  route_name: ultimate_cron.scheduler_settings
+  parent_id: ultimate_cron.settings
diff --git a/web/modules/ultimate_cron/ultimate_cron.module b/web/modules/ultimate_cron/ultimate_cron.module
new file mode 100755
index 0000000000000000000000000000000000000000..15035785d26094d267d9b95b5b83ca5aa98a4e56
--- /dev/null
+++ b/web/modules/ultimate_cron/ultimate_cron.module
@@ -0,0 +1,260 @@
+<?php
+/**
+ * @file
+ * Ultimate Cron. Extend cron functionality in Drupal.
+ */
+
+use Drupal\Core\Queue\RequeueException;
+use Drupal\ultimate_cron\CronPlugin;
+use Drupal\ultimate_cron\PluginCleanupInterface;
+
+define('ULTIMATE_CRON_LOG_TYPE_NORMAL', 0);
+define('ULTIMATE_CRON_LOG_TYPE_ADMIN', 1);
+define('ULTIMATE_CRON_LOG_TYPE_ALL', -1);
+
+/**
+ * Pseudo define.
+ */
+function _ultimate_cron_define_log_type_all() {
+  return array(ULTIMATE_CRON_LOG_TYPE_NORMAL, ULTIMATE_CRON_LOG_TYPE_ADMIN);
+}
+
+$GLOBALS['ultimate_cron_shutdown_functions'] = array();
+
+/**
+ * The shutdown function itself is also overridable.
+ *
+ * In case it is necessary to add it earlier, say settings.php.
+ * Remeber to invoke the registered ultimate cron shutdown handlers.
+ * If the function exists, we assume that the register_shutdown_handler() has
+ * also been setup correctly.
+ *
+ * @todo: Move _ultimate_cron_out_of_memory_protection() to a service.
+ */
+if (!function_exists('_ultimate_cron_out_of_memory_protection')) {
+  /**
+   * Shutdown hander that unleash the memory reserved.
+   */
+  function _ultimate_cron_out_of_memory_protection() {
+    // error_log("RELEASING MEMORY");
+    unset($GLOBALS['__RESERVED_MEMORY']);
+    // error_log(print_r($GLOBALS['ultimate_cron_shutdown_functions'], TRUE));
+    foreach ($GLOBALS['ultimate_cron_shutdown_functions'] as $function) {
+      call_user_func_array($function['callback'], $function['arguments']);
+    }
+  }
+
+
+  // The minor overhead in _drupal_shutdown_function() can mean the
+  // difference between life and death for our shutdown handlers in
+  // a memory exhaust situation. We want our shutdown handler to be
+  // called as early as possible. If no callbacks have been registrered
+  // yet, we use PHPs built-in register_shutdown_function() otherwise
+  // we ensure, that we are the first in the list of Drupals registered
+  // shutdown functions.
+  $callbacks = &drupal_register_shutdown_function();
+  if (empty($callbacks)) {
+    register_shutdown_function('_ultimate_cron_out_of_memory_protection');
+  }
+  else {
+    array_unshift($callbacks, array('callback' => '_ultimate_cron_out_of_memory_protection', 'arguments' => array()));
+    // Reset internal array pointer just in case ...
+    reset($callbacks);
+  }
+}
+/**
+ * Registers a function for execution on shutdown.
+ *
+ * Wrapper for register_shutdown_function() that catches thrown exceptions to
+ * avoid "Exception thrown without a stack frame in Unknown".
+ *
+ * This is a duplicate of the built-in functionality in Drupal, however we
+ * need to perform our tasks before that.
+ *
+ * @param callback $callback
+ *   The shutdown function to register.
+ * @param ...
+ *   Additional arguments to pass to the shutdown function.
+ *
+ * @see register_shutdown_function()
+ *
+ * @ingroup php_wrappers
+ */
+function ultimate_cron_register_shutdown_function($callback) {
+  $args = func_get_args();
+  array_shift($args);
+  $GLOBALS['ultimate_cron_shutdown_functions'][] = array(
+    'callback' => $callback,
+    'arguments' => $args,
+  );
+}
+
+/**
+ * Load callback for plugins.
+ *
+ * @param string $type
+ *   Type of the plugin (settings, scheduler, launcher, logger).
+ * @param string $name
+ *   Name of the plugin (general, queue, serial, database, etc.).
+ *
+ * @return object
+ *   The instance of the plugin (singleton).
+ */
+function ultimate_cron_plugin_load($type, $name) {
+  $cache = &drupal_static('ultimate_cron_plugin_load_all', array());
+  if (!isset($cache[$type][$name])) {
+    ultimate_cron_plugin_load_all($type);
+    $cache[$type][$name] = isset($cache[$type][$name]) ? $cache[$type][$name] : FALSE;
+  }
+  return $cache[$type][$name];
+}
+
+
+function ultimate_cron_fake_cron() {
+  $counter = \Drupal::state()->get('ultimate_cron.cron_run_counter', 0);
+  $counter++;
+  \Drupal::state()->set('ultimate_cron.cron_run_counter', $counter);
+}
+
+/**
+ * Load all callback for plugins.
+ *
+ * @param string $type
+ *   Type of the plugin (settings, scheduler, launcher, logger).
+ *
+ * @return array
+ *   The instances of the plugin type (singletons).
+ */
+function ultimate_cron_plugin_load_all($type, $reset = FALSE) {
+  $cache = &drupal_static('ultimate_cron_plugin_load_all', array());
+  if (!$reset && isset($cache[$type])) {
+    return $cache[$type];
+  }
+  /* @var \Drupal\Core\Plugin\DefaultPluginManager $manager */
+  $manager = \Drupal::service('plugin.manager.ultimate_cron.' . $type);
+  $plugins = $manager->getDefinitions();
+
+
+  foreach ($plugins as $plugin_id => $definition) {
+    if ($object = $manager->createInstance($plugin_id)) {
+      $plugins[$plugin_id] = $object;
+    }
+  }
+  $cache[$type] = $plugins;
+  return $cache[$type];
+}
+// ---------- HOOKS ----------
+/**
+ * Implements hook_hook_info().
+ */
+function ultimate_cron_hook_info() {
+  $hooks = array();
+  $hooks['background_process_shutdown'] = array(
+    'group' => 'background_process_legacy',
+  );
+  return $hooks;
+}
+
+/**
+ * Implements hook_help().
+ *
+ */
+function ultimate_cron_help($route_name, \Drupal\Core\Routing\RouteMatchInterface $route_match) {
+  switch ($route_name) {
+    case 'help.page.ultimate_cron':
+      // Return a line-break version of the module README.
+      return '<pre>' . file_get_contents(dirname(__FILE__) . '/README.txt') . '</pre>';
+  }
+}
+
+/**
+ * Implements hook_cronapi().
+ *
+ * Adds clean up jobs for plugins.
+ * */
+function ultimate_cron_cron() {
+  $plugin_types = CronPlugin::getPluginTypes();
+  foreach ($plugin_types as $plugin_type => $info) {
+    foreach (ultimate_cron_plugin_load_all($plugin_type) as $name => $plugin) {
+      if ($plugin->isValid() && ($plugin instanceof PluginCleanupInterface)) {
+        $plugin->cleanup();
+      }
+    }
+  }
+}
+
+/**
+ * * Implements hook_modules_installed().
+ */
+function ultimate_cron_modules_installed($modules) {
+  \Drupal::service('ultimate_cron.discovery')->discoverCronJobs();
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter() for the system_cron_settings() form.
+ */
+function ultimate_cron_form_system_cron_settings_alter(&$form, &$form_state) {
+  $options = [60, 300, 900, 1800, 3600, 10800, 21600, 43200, 86400, 604800];
+  $form['automated_cron']['interval']['#options'] = [0 => t('Never')] + array_map([
+      \Drupal::service('date.formatter'),
+      'formatInterval',
+    ], array_combine($options, $options));
+
+}
+
+// ---------- CRON RULE FUNCTIONS ----------
+/**
+ * Return blank values for all keys in an array.
+ *
+ * @param array $array
+ *   Array to generate blank values from.
+ *
+ * @return array
+ *   Array with same keys as input, but with blank values (empty string).
+ */
+function ultimate_cron_blank_values($array) {
+  $result = array();
+  foreach ($array as $key => $value) {
+    switch (gettype($value)) {
+      case 'array':
+        $result[$key] = array();
+        break;
+
+      default:
+        $result[$key] = '';
+    }
+  }
+  return $result;
+}
+
+/**
+ * Custom sort callback for sorting cron jobs by start time.
+ */
+function _ultimate_cron_sort_jobs_by_start_time($a, $b) {
+  return $a->log_entry->start_time == $b->log_entry->start_time ? 0 : ($a->log_entry->start_time > $b->log_entry->start_time ? 1 : -1);
+}
+
+/**
+ * Sort callback for multiple column sort.
+ */
+function _ultimate_cron_multi_column_sort($a, $b) {
+  $a = (array) $a;
+  $b = (array) $b;
+  foreach ($a['sort'] as $i => $sort) {
+    if ($a['sort'][$i] == $b['sort'][$i]) {
+      continue;
+    }
+    return $a['sort'][$i] < $b['sort'][$i] ? -1 : 1;
+  }
+  return 0;
+}
+
+/**
+ * Get transactional safe connection.
+ *
+ * @return string
+ *   Connection target.
+ */
+function _ultimate_cron_get_transactional_safe_connection() {
+  return !\Drupal::config('ultimate_cron.settings')->get('bypass_transactional_safe_connection') && \Drupal\Core\Database\Database::getConnection()->inTransaction() ? 'ultimate_cron' : 'default';
+}
diff --git a/web/modules/ultimate_cron/ultimate_cron.nagios.inc b/web/modules/ultimate_cron/ultimate_cron.nagios.inc
new file mode 100644
index 0000000000000000000000000000000000000000..e600c93425272fdc8deaa42831abd7fba20bd118
--- /dev/null
+++ b/web/modules/ultimate_cron/ultimate_cron.nagios.inc
@@ -0,0 +1,245 @@
+<?php
+use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\ultimate_cron\CronJobDiscovery;
+
+/**
+ * Implements hook_nagios_info().
+ */
+function ultimate_cron_nagios_info() {
+  return array(
+    'name'   => t('Ultimate Cron Monitoring'),
+    'id'     => 'ULTIMATE_CRON',
+  );
+}
+
+/**
+ * Implementation of hook_nagios().
+ */
+function ultimate_cron_nagios($check = 'nagios') {
+  $status = array();
+  foreach(ultimate_cron_nagios_functions() as $function => $description) {
+    if (variable_get('ultimate_cron_nagios_func_' . $function, TRUE) && ($check == 'nagios' || $check == $function)) {
+      $func = $function . '_check';
+      $result = $func();
+      $status[$result['key']] = $result['data'];
+    }
+  }
+
+  return $status;
+}
+
+/**
+ * Implementation of hook_nagios_settings().
+ */
+function ultimate_cron_nagios_settings() {
+  $form = array();
+  
+  foreach(ultimate_cron_nagios_functions() as $function => $description) {
+    $var = 'ultimate_cron_nagios_func_' . $function;
+    $form[$var] = array(
+      '#type'          => 'checkbox',
+      '#title'         => $function,
+      '#default_value' => variable_get($var, TRUE),
+      '#description' => $description,
+    );
+  }
+ 
+  $group = 'thresholds';
+  $form[$group] = array(
+    '#type'        => 'fieldset',
+    '#collapsible' => TRUE,
+    '#collapsed'   => FALSE,
+    '#title'       => t('Thresholds'),
+    '#description' => t('Thresholds for reporting critical alerts to Nagios.'),
+  );
+
+  $form[$group]['ultimate_cron_nagios_running_threshold'] = array(
+    '#type'          => 'textfield',
+    '#title'         => t('Running jobs count'),
+    '#default_value' => variable_get('ultimate_cron_nagios_running_threshold', 50),
+    '#description'   => t('Issue a critical alert when more than this number of jobs are running. Default is 50.'),
+  );
+  
+  $form[$group]['ultimate_cron_nagios_failed_threshold'] = array(
+    '#type'          => 'textfield',
+    '#title'         => t('Failed jobs count'),
+    '#default_value' => variable_get('ultimate_cron_nagios_failed_threshold', 10),
+    '#description'   => t('Issue a critical alert when more than this number of jobs failed their last run. Default is 10.'),
+  );
+  
+  $form[$group]['ultimate_cron_nagios_longrunning_threshold'] = array(
+    '#type'          => 'textfield',
+    '#title'         => t('Long running jobs'),
+    '#default_value' => variable_get('ultimate_cron_nagios_longrunning_threshold', 0),
+    '#description'   => t('Issue a critical alert when more than this number of jobs are running longer than usual. Default is 0.')
+  );
+  
+  return $form;
+}
+
+/**
+ * Implementation of hook_nagios_checks().
+ */
+function ultimate_cron_nagios_checks() {
+  return ultimate_cron_nagios_functions();
+}
+
+/**
+ * Implementation of drush hook_nagios_check().
+ */
+function ultimate_cron_nagios_check($function) {
+  // We don't bother to check if the function has been enabled by the user.
+  // Since this runs via drush, web security is not an issue.
+  $func = $function . '_check';
+  $result = $func();
+  $status[$result['key']] = $result['data'];
+  return $status;
+}
+
+/************** HELPER FUNCTIONS ***********************************/
+/**
+ * Return a list of nagios check functions 
+ * @see ultimate_cron_nagios()
+ */
+function ultimate_cron_nagios_functions() {
+  return array(
+    'ultimate_cron_running' => t('Check number of currently running jobs'),
+    'ultimate_cron_failed' => t('Check the number of jobs that failed last run'),
+    'ultimate_cron_longrunning' => t('Check the number of jobs that are running longer than usual'),
+  );
+}
+
+/**
+ * Get information about running jobs - currently running or failed.
+ *
+ * @staticvar array $overview
+ * @param string $mode Which mode to get info about; 'running' or 'error'
+ * @return int 
+ */
+function ultimate_cron_nagios_get_job_info($mode = 'running') {
+  // Ensure valid mode
+  if (!in_array($mode, array('running', 'error'))) {
+    $mode = 'running';
+  }
+  static $overview = array();
+
+  if (!isset($overview[$mode])) {
+    $overview[$mode] = 0;
+    // Get hooks and their data
+    $hooks = CronJobDiscovery::getHooks();
+
+    $modules = array();
+    foreach ($hooks as $name => $hook) {
+      if (!$module || $module == $hook['module']) {
+        $log = ultimate_cron_get_log($name);
+        
+        if ($hook['background_process']) {
+          $overview['running']++;
+        }
+        $severity_type = $log['severity'] < 0 ? 'success' : ($log['severity'] >= RfcLogLevel::NOTICE ? 'info' : ($log['severity'] >= RfcLogLevel::NOTICE ? 'warning' : 'error'));
+        $overview[$severity_type]++;
+      }
+    }
+  }
+  
+  return $overview[$mode];
+}
+
+/*************** NAGIOS CHECK FUNCTIONS ********************************/
+/**
+ * Check number of running jobs.
+ * 
+ * @return array
+ */
+function ultimate_cron_running_check() {
+  $running = ultimate_cron_nagios_get_job_info('running');
+  $threshold = variable_get('ultimate_cron_nagios_running_threshold', 50);
+  if (count($running) > $threshold) {
+    $data = array(
+      'status' => NAGIOS_STATUS_CRITICAL,
+      'type'   => 'state',
+      'text'   => t('@jobs currently running - it is more than @threshold', array('@jobs' => $running, '@threshold' => $threshold)),
+    );
+  }
+  else {
+    $data = array(
+      'status' => NAGIOS_STATUS_OK, 
+      'type'   => 'state',
+      'text'   => t('@jobs currently running', array('@jobs' => $running)),
+    );
+  }
+
+  return array(
+    'key' => 'ULTIMATE_CRON_RUNNING',
+    'data' => $data,
+  );
+}
+
+/**
+ * Check number of jobs that failed last run.
+ * 
+ * @return array
+ */
+function ultimate_cron_failed_check() {
+  $failed = ultimate_cron_nagios_get_job_info('errors');
+  $threshold = variable_get('ultimate_cron_nagios_failed_threshold', 10);
+  if (count($failed) > $threshold) {
+    $data = array(
+      'status' => NAGIOS_STATUS_CRITICAL,
+      'type'   => 'state',
+      'text'   => t('@jobs failed their last run - it is more than @threshold', array('@jobs' => $failed, '@threshold' => $threshold)),
+    );
+  }
+  else {
+    $data = array(
+      'status' => NAGIOS_STATUS_OK, 
+      'type'   => 'state',
+      'text'   => t('@jobs failed their last run', array('@jobs' => $failed)),
+    );
+  }
+
+  return array(
+    'key' => 'ULTIMATE_CRON_FAILED',
+    'data' => $data,
+  );
+}
+
+/**
+ * Check number of jobs running longer than usual.
+ * 
+ * @return array
+ * 
+ * @todo Implement the logic
+ */
+function ultimate_cron_longrunning_check() {
+  $longrunning = 0;
+  
+  // Get running jobs
+  
+  // Find out how long they have been running
+  
+  // Calculate average run time per job (over a threshold? E.g. queues run very fast if there is nothing to process)
+  
+  // If 
+  
+  $threshold = variable_get('ultimate_cron_nagios_longrunning_threshold', 0);
+  if ($longrunning > $threshold) {
+    $data = array(
+      'status' => NAGIOS_STATUS_CRITICAL,
+      'type'   => 'state',
+      'text'   => t('@jobs jobs are running longer than usual - it is more than @threshold', array('@jobs' => $longrunning, '@threshold' => $threshold)),
+    );
+  }
+  else {
+    $data = array(
+      'status' => NAGIOS_STATUS_OK, 
+      'type'   => 'state',
+      'text'   => t('@jobs jobs are running longer than usual', array('@jobs' => $longrunning)),
+    );
+  }
+
+  return array(
+    'key' => 'ULTIMATE_CRON_LONGRUNNING',
+    'data' => $data,
+  );
+}
diff --git a/web/modules/ultimate_cron/ultimate_cron.permissions.yml b/web/modules/ultimate_cron/ultimate_cron.permissions.yml
new file mode 100644
index 0000000000000000000000000000000000000000..760a45275e80edd361ec55fb806c18822d36455d
--- /dev/null
+++ b/web/modules/ultimate_cron/ultimate_cron.permissions.yml
@@ -0,0 +1,9 @@
+administer ultimate cron:
+  title: 'Administer Ultimate Cron'
+  description: 'Lets you configure everything in Ultimate Cron'
+view cron jobs:
+  title: 'View cron jobs'
+  description: 'Lets you view cron jobs and their logs'
+run cron jobs:
+  title: 'Run cron jobs'
+  description: 'Lets you run cron jobs'
diff --git a/web/modules/ultimate_cron/ultimate_cron.routing.yml b/web/modules/ultimate_cron/ultimate_cron.routing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1185f49688db1744b0b13a2c343a1f7df08b0904
--- /dev/null
+++ b/web/modules/ultimate_cron/ultimate_cron.routing.yml
@@ -0,0 +1,112 @@
+ultimate_cron.settings:
+  path: '/admin/config/system/cron/settings'
+  defaults:
+    _form: '\Drupal\ultimate_cron\Form\GeneralSettingsForm'
+    _title: 'Manage Cron settings'
+  requirements:
+    _permission: 'administer ultimate cron'
+
+ultimate_cron.general_settings:
+  path: '/admin/config/system/cron/settings'
+  defaults:
+    _form: '\Drupal\ultimate_cron\Form\GeneralSettingsForm'
+    _title: 'General Cron Settings'
+  requirements:
+    _permission: 'administer ultimate cron'
+
+ultimate_cron.launcher_settings:
+  path: '/admin/config/system/cron/settings/launcher'
+  defaults:
+    _form: '\Drupal\ultimate_cron\Form\LauncherSettingsForm'
+    _title: 'Launcher settings'
+  requirements:
+    _permission: 'administer ultimate cron'
+
+ultimate_cron.logger_settings:
+  path: '/admin/config/system/cron/settings/logger'
+  defaults:
+    _form: '\Drupal\ultimate_cron\Form\LoggerSettingsForm'
+    _title: 'Logger settings'
+  requirements:
+    _permission: 'administer ultimate cron'
+
+ultimate_cron.scheduler_settings:
+  path: '/admin/config/system/cron/settings/scheduler'
+  defaults:
+    _form: '\Drupal\ultimate_cron\Form\SchedulerSettingsForm'
+    _title: 'Scheduler settings'
+  requirements:
+    _permission: 'administer ultimate cron'
+
+entity.ultimate_cron_job.collection:
+  path: '/admin/config/system/cron/jobs'
+  defaults:
+    _entity_list: 'ultimate_cron_job'
+    _title: 'Cron jobs'
+  requirements:
+    _permission: 'administer ultimate cron'
+
+ultimate_cron.discover_jobs:
+  path: '/admin/config/system/cron/jobs/discover'
+  defaults:
+    _controller: '\Drupal\ultimate_cron\Controller\JobController::discoverJobs'
+    _title: 'Discover jobs'
+  requirements:
+    _permission: 'administer ultimate cron'
+    _csrf: 'TRUE'
+
+entity.ultimate_cron_job.edit_form:
+  path: '/admin/config/system/cron/jobs/manage/{ultimate_cron_job}'
+  defaults:
+    _entity_form: 'ultimate_cron_job.default'
+    _title: 'Edit job'
+  requirements:
+    _entity_access: 'ultimate_cron_job.update'
+
+entity.ultimate_cron_job.run:
+  path: '/admin/config/system/cron/jobs/{ultimate_cron_job}/run'
+  defaults:
+    _controller: '\Drupal\ultimate_cron\Controller\JobController::runCronJob'
+    _title: Run Cron job
+  requirements:
+    _permission: 'run cron jobs'
+
+entity.ultimate_cron_job.delete_form:
+  path: '/admin/config/system/cron/jobs/manage/{ultimate_cron_job}/delete'
+  defaults:
+    _entity_form: 'ultimate_cron_job.delete'
+    _title: 'Delete job'
+  requirements:
+    _entity_access: 'ultimate_cron_job.delete'
+
+entity.ultimate_cron_job.disable:
+  path: '/admin/config/system/cron/jobs/manage/{ultimate_cron_job}/disable'
+  defaults:
+    _entity_form: 'ultimate_cron_job.disable'
+    _title: 'Disable cron job'
+  requirements:
+    _entity_access: 'ultimate_cron_job.disable'
+
+entity.ultimate_cron_job.enable:
+  path: '/admin/config/system/cron/jobs/manage/{ultimate_cron_job}/enable'
+  defaults:
+    _entity_form: 'ultimate_cron_job.enable'
+    _title: 'Enable cron job'
+  requirements:
+    _entity_access: 'ultimate_cron_job.enable'
+
+entity.ultimate_cron_job.logs:
+  path: '/admin/config/system/cron/jobs/logs/{ultimate_cron_job}'
+  defaults:
+    _controller: '\Drupal\ultimate_cron\Controller\JobController::showLogs'
+    _title: 'Cron jobs logs'
+  requirements:
+    _entity_access: 'ultimate_cron_job.views'
+
+entity.ultimate_cron_job.unlock:
+  path: '/admin/config/system/cron/jobs/{ultimate_cron_job}/unlock'
+  defaults:
+    _controller: '\Drupal\ultimate_cron\Controller\JobController::unlockCronJob'
+    _title: Unlock Cron job
+  requirements:
+    _permission: 'run cron jobs'
diff --git a/web/modules/ultimate_cron/ultimate_cron.services.yml b/web/modules/ultimate_cron/ultimate_cron.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..3863d99cc9cace033499184f7183e37b14d64286
--- /dev/null
+++ b/web/modules/ultimate_cron/ultimate_cron.services.yml
@@ -0,0 +1,45 @@
+services:
+  cache.signal:
+      class: Drupal\Core\Cache\CacheBackendInterface
+      tags:
+        - { name: cache.bin }
+      factory: cache_factory:get
+      arguments: [signal]
+  cache.ultimate_cron_logger:
+      class: Drupal\Core\Cache\CacheBackendInterface
+      tags:
+        - { name: cache.bin }
+      factory: cache_factory:get
+      arguments: [ultimate_cron_logger]
+  plugin.manager.ultimate_cron.launcher:
+    class: Drupal\ultimate_cron\Launcher\LauncherManager
+    parent: default_plugin_manager
+  plugin.manager.ultimate_cron.logger:
+    class: Drupal\ultimate_cron\Logger\LoggerManager
+    parent: default_plugin_manager
+  plugin.manager.ultimate_cron.scheduler:
+    class: Drupal\ultimate_cron\Scheduler\SchedulerManager
+    parent: default_plugin_manager
+  ultimate_cron.lock:
+    class: Drupal\ultimate_cron\Lock\Lock
+    arguments: ['@ultimate_cron.database_factory']
+  ultimate_cron.progress:
+    class: Drupal\ultimate_cron\Progress\Progress
+    arguments: ['@keyvalue']
+  ultimate_cron.signal:
+    class: Drupal\ultimate_cron\Signal\SignalCache
+    arguments: ['@cache.signal', '@lock']
+  ultimate_cron.discovery:
+    class: Drupal\ultimate_cron\CronJobDiscovery
+    arguments: ['@module_handler', '@plugin.manager.queue_worker', '@config.factory', '@extension.list.module']
+  logger.ultimate_cron:
+    class: Drupal\ultimate_cron\Logger\WatchdogLogger
+    arguments: ['@logger.log_message_parser']
+    tags:
+      - { name: logger }
+  ultimate_cron.queue_worker:
+    class: Drupal\ultimate_cron\QueueWorker
+    arguments: ["@plugin.manager.queue_worker", "@queue", "@config.factory"]
+  ultimate_cron.database_factory:
+    class: Drupal\Core\Database\Connection
+    factory: Drupal\ultimate_cron\UltimateCronDatabaseFactory::getConnection