Adds a shared flock guard used by all five cron entry points, keyed per

script and per install. Replaces the mail queue's own lock file, which
was not atomic and could be deleted out from under a long run. Bounds
the SMTP conversation so an unresponsive server cannot hold the lock.
Recovers rows left at Sending by a run that died, which nothing
previously picked up.
This commit is contained in:
johnnyq
2026-07-27 17:35:39 -04:00
parent 686fca99e1
commit a844d7b428
6 changed files with 80 additions and 36 deletions

View File

@@ -8,6 +8,10 @@ if (php_sapi_name() !== 'cli') {
die("This script must be run from the command line.\n");
}
// Prevent overlapping runs of this script
$cron_lock_script = __FILE__;
require_once "../includes/cron_lock.php";
require_once "../config.php";
// Set Timezone

View File

@@ -8,18 +8,9 @@ if (php_sapi_name() !== 'cli') {
die("This script must be run from the command line.\n");
}
// Only one run at a time. Autopay charges cards, so an overlapping run (the previous
// run still going when the next one fires) could bill the same invoice twice. The lock
// file is named per install so separate ITFlow instances on one host don't block each
// other, and the handle is held for the life of the process, released when it exits.
$cron_lock_file = sys_get_temp_dir() . '/itflow_cron_' . md5(__DIR__) . '.lock';
$cron_lock_handle = fopen($cron_lock_file, 'c');
if ($cron_lock_handle === false) {
die("Cannot open the cron lock file at $cron_lock_file - check permissions and open_basedir.\n");
}
if (!flock($cron_lock_handle, LOCK_EX | LOCK_NB)) {
die("Cron is already running - exiting.\n");
}
// Prevent overlapping runs of this script
$cron_lock_script = __FILE__;
require_once "../includes/cron_lock.php";
require_once "../config.php";

View File

@@ -8,6 +8,10 @@ if (php_sapi_name() !== 'cli') {
die("This script must be run from the command line.\n");
}
// Prevent overlapping runs of this script
$cron_lock_script = __FILE__;
require_once "../includes/cron_lock.php";
require_once "../config.php";
// Set Timezone

View File

@@ -7,6 +7,10 @@ if (php_sapi_name() !== 'cli') {
die("This script must be run from the command line.\n");
}
// Prevent overlapping runs of this script
$cron_lock_script = __FILE__;
require_once "../includes/cron_lock.php";
require_once "../config.php";
require_once "../includes/inc_set_timezone.php";
require_once "../functions.php";
@@ -81,25 +85,6 @@ if (empty($config_smtp_provider)) {
exit(0);
}
/** =======================================================================
* Lock file
* ======================================================================= */
$temp_dir = sys_get_temp_dir();
$lock_file_path = "{$temp_dir}/itflow_mail_queue_{$installation_id}.lock";
if (file_exists($lock_file_path)) {
$file_age = time() - filemtime($lock_file_path);
if ($file_age > 600) {
unlink($lock_file_path);
logApp("Cron-Mail-Queue", "warning", "Cron Mail Queue detected a lock file was present but was over 10 minutes old so it removed it.");
} else {
logApp("Cron-Mail-Queue", "info", "Cron Mail Queue attempted to execute but was already executing so instead it terminated.");
exit("Script is already running. Exiting.");
}
}
file_put_contents($lock_file_path, "Locked");
/** =======================================================================
* Mail OAuth helpers + sender function
* ======================================================================= */
@@ -248,6 +233,9 @@ function sendQueueEmail(
$mail->isSMTP();
$mail->Host = $host;
$mail->Port = $port;
// Bound the SMTP conversation. Without this an unresponsive mail server can
// hold the cron lock open indefinitely and stall the whole queue.
$mail->Timeout = 30;
$enc = strtolower($encryption);
if ($enc === '' || $enc === 'none') {
@@ -301,6 +289,26 @@ function sendQueueEmail(
return true;
}
/** =======================================================================
* RECOVER: status = 1 (Sending) left behind by a run that died
*
* Nothing else in the codebase ever selects status 1, so without this a row
* claimed by a run that was killed mid-send stays 'Sending' forever and is never
* delivered. The cron lock above guarantees no other run of this script is in
* progress, so any row still sitting at status 1 is by definition orphaned and
* safe to reclaim. It is moved to failed rather than queued so it inherits the
* retry pass's 30 minute backoff and attempt cap instead of retrying instantly.
*
* This can re-send a message that did go out but died before being marked sent.
* That trade is deliberate: a duplicate is recoverable, an invoice that silently
* never arrives is not.
* ======================================================================= */
mysqli_query($mysqli, "UPDATE email_queue SET email_status = 2, email_failed_at = NOW(), email_attempts = email_attempts + 1 WHERE email_status = 1");
$orphaned_emails = mysqli_affected_rows($mysqli);
if ($orphaned_emails > 0) {
logApp("Cron-Mail-Queue", "warning", "Recovered $orphaned_emails email(s) left in a sending state by a previous run - queued for retry.");
}
/** =======================================================================
* SEND: status = 0 (Queued)
* ======================================================================= */
@@ -464,8 +472,3 @@ if (mysqli_num_rows($sql_failed_queue) > 0) {
}
}
}
/** =======================================================================
* Unlock
* ======================================================================= */
unlink($lock_file_path);

View File

@@ -15,6 +15,10 @@ if (php_sapi_name() !== 'cli') {
die("This script must be run from the command line.\n");
}
// Prevent overlapping runs of this script
$cron_lock_script = __FILE__;
require_once "../includes/cron_lock.php";
// Autoload (Webklex & any composer deps)
require_once "../libs/vendor/autoload.php";