Skip to content

Cron jobs

Area: operations. Cross-cutting background workers, not a single module. They must be scheduled on the server in the system crontab. They live in the top-level cron/ directory as standalone PHP scripts run by the OS cron daemon, never from the app UI.

How this page was verified

The directory listing was read on the production branch of each of the big five on 2026-09-16. The Multiple Sign Off job was read on master at the INA01-866 commits e230a25be and c63dd6658, which were still local and not yet on origin/main when this page was written. The other jobs were read on master on 2026-09-06.

Several jobs call an in-app HTTP endpoint with cURL, so the app base URL ($inweb_baseurl, the baseurl row of inweb_config) must be reachable from the server itself and TLS must work. These calls disable SSL_VERIFYPEER.

Minimal production crontab

The master README.md installs the first two entries. The Multiple Sign Off job is needed wherever users sign off several routings at once. The last two are the config-gated notification jobs.

text
# every minute — flush the outgoing email queue (SMTP via PHPMailer)
* * * * * php /path/to/app/cron/inact_mailgun_curl.php >> /dev/null 2>&1

# every minute — procurement submission queue
* * * * * php /path/to/app/cron/cron_submit_queue.php >> /dev/null 2>&1

# every minute — finish Multiple Sign Off (approved document, transmittals, library publish)
* * * * * php /path/to/app/cron/cron_multiple_signoff_v2.php >> /var/log/inact_multisignoff.log 2>&1

# hourly — routing digest (only acts when enable_routing_notif_digest=1)
0 * * * * php /path/to/app/cron/inact_routing_digest.php

# daily — overdue / upcoming routing notifications
0 7 * * * php /path/to/app/cron/cron_overdue_mail.php

Adjust the PHP binary and path per environment and redirect output to a log so failures are visible. Enable the retention and digest crons only if their feature flags are on. No crontab line is installed by a deploy: the master deploy/ scripts and .github/workflows/deploy.yml do not touch cron. Every line above is a manual step on every server. Production crontabs are the client's, see each instance page.

The jobs

1. inact_mailgun_curl.php calls inact_mailgun2.php: outgoing email queue, every minute

All outgoing email is queued into ts_email; nothing is sent synchronously. The wrapper calls inact_mailgun2.php, which selects pending rows (ts_email WHERE email_sent='n', oldest first, batch size from EMAIL_TOTAL_PER_BATCH), marks them 'q', and sends each via SMTP (PHPMailer) using the $inweb_smtp_* settings. On success email_sent='y' plus a sent timestamp; on failure 'e'. Respects a daily quota (EMAIL_QUOTA_PER_DAY, -1 = unlimited). Despite the "mailgun" name, delivery is SMTP.

  • Tables: ts_email (email_sent: n, then q, then y or e).
  • Config: .env keys EMAIL_QUOTA_PER_DAY (default -1) and EMAIL_TOTAL_PER_BATCH (default 10), read with $_ENV[...] ?? default in inact_mailgun2.php. Neither is in the example env file; set them only to change the defaults.
  • If not running: no email is ever sent. Every notification piles up unsent.

The wrapper targets inact_mailgun2.php in all five forks.

2. cron_multiple_signoff_v2.php: finish Multiple Sign Off, every minute

Multiple Sign Off records every sign off at once, then queues the slow work in ts_multiple_signoff_list, one row per signed-off performer (rout_id, map_id). This job does that slow work. It is the master version since INA01-866; the old cron_multiple_signoff.php, cron_create_routing_slip.php and getPDFMultipleSignoffCron.php are gone from master.

The performer who signed offWhat the job runsResult
Inside the Document Controller part of the routing (D - Transmit)publishTransmitSignOff()Document Library record, outgoing Transmittal when the issued status is external and enable_transmittal = 1, library folder. One DB transaction.
Anyone else (A - Approval, a rejecting C / S, the last I - Information)finishApprovalSignOff()curl to getPDFAfterApprove.php (the _R document), return Transmittal when external and enable_transmittal = 1, library folder after an approved sign off
  • Batch: 10 rows, oldest multi_id first, only multi_status = 'n', cron_ongoing 'n' or NULL, and map_id not NULL.
  • Claim: each row is set to cron_ongoing = 'y' before its work starts. Success sets multi_status = 'y', cron_ongoing = 'n'. Any exception or PHP Error sets both back to 'n', so the row is retried next minute. There is no retry limit.
  • Each row is claimed on its own, with one conditional UPDATE that only matches a row still free. Two runs that overlap never take the same row, so no lock file or flock is needed. A skipped row prints queue row 12: taken by another run, skipped.
  • Each row runs as the performer who signed off (getSignOffPerformer()), so the document author and logs show that person, not a system user.
  • Flags: the publish needs enable_autopublish = 1; the _R document for A needs it too. I - Information always builds its document.
  • If not running: sign offs are recorded and routings close, but no approved _R document, no Transmittal and no Document Library record appear. Rows pile up at multi_status = 'n'.
  • Detail: Multiple Sign Off.

3. inact_routing_digest.php: routing digest, hourly, flag-gated

When enable_routing_notif_digest='1', routing_notif_digest_hour is not empty, and the current hour is in that CSV, it groups outstanding ts_map_routing_to rows by project and recipient and sends one consolidated digest email per user, queued via ts_email.

  • Config: enable_routing_notif_digest, routing_notif_digest_hour, see inweb_config.
  • If the flag is off: exits immediately.
  • Present in: master, Jadestone, Medco. Not in JOTRE or Prima Energy.

4. inact_routing_overdue_digest.php: overdue digest, flag-gated

Reads enable_routing_overdue_notif_digest straight from inweb_config and, when on, sends the overdue-items digest. Present in master and Jadestone only; the key exists only in their seeds.

5. cron_overdue_mail.php: overdue and upcoming notifications, daily

Sends per-recipient "overdue" and "due soon" notices for routings near or past rout_due_date, honouring working days and holidays. The look-ahead window is the .env key NOTIF_OVERDUE_SINCE_DAY (1 = warn one day ahead, the default when empty; 0 = today; negative = past due). Present in all five forks.

Set up the Multiple Sign Off cron

Do this on every server that runs master after INA01-866: dev, staging and production. A deploy does not do it for you.

1. Check the prerequisites

CheckWhyHow to check
The migration 20260915090000_alter_ts_multiple_signoff_list_add_map_id has runThe job reads ts_multiple_signoff_list.map_idDev and staging deploys run phinx migrate on their own. Production needs a manual run: php vendor/bin/phinx migrate
PHP CLI, not PHP-FPMThe script exits at once, with no output, under any other SAPI (PHP_SAPI !== 'cli')php -v
PHP CLI loads the same extensions as the web PHPsetting.php loads Composer classes, creates a MongoDB client and needs curlphp -m and compare with the web phpinfo()
The app .env is in the app rootsetting.php loads .env from the app root. The script changes to that directory itself, so the crontab needs no cdls /path/to/app/.env
baseurl in inweb_config is reachable from the server itselffinishApprovalSignOff() calls <baseurl>/includes/javascripts/tracking/pdfviewer/web/api/getPDFAfterApprove.php, timeout 600 s, SSL verify offSee the curl command below
MongoDB is reachableEvery logged write ($pdo->log()) inserts into the inweb_logs collection. A failed insert is caught and sent to the PHP error log, so it does not stop the jobMONGO_* keys in .env
The cron user can write the upload foldersTransmittal PDFs are written under docUpload/transmittalRun the job once as that user (step 3)

Check that the base URL answers from the server. First read it from the database:

sql
SELECT config_value FROM inweb_config WHERE config_name = 'baseurl';

Then call the page from the server, with that value in place of <baseurl>:

bash
curl -k -s -o /dev/null -w '%{http_code}\n' "<baseurl>/includes/javascripts/tracking/pdfviewer/web/api/getPDFAfterApprove.php"

Any HTTP code below 400 is fine. The job treats a curl error or an HTTP code of 400 or more as a failure.

2. Install the line and remove the old one

On Linux and macOS, add the line with crontab -e as the user that owns the app files:

text
* * * * * php /path/to/app/cron/cron_multiple_signoff_v2.php >> /var/log/inact_multisignoff.log 2>&1

No lock file and no flock. Each queue row is claimed with one conditional UPDATE, so two runs that overlap never take the same row. That is also why the line works the same on Windows.

On Windows (Tomori runs on IIS) there is no crontab. Make a Task Scheduler task that repeats every minute and runs the same command. Tomori's paths, from its instance page: PHP is C:\php\php.exe and the app sits in C:\inetpub\wwwroot\inact, so the program is C:\php\php.exe and the argument is C:\inetpub\wwwroot\inact\cron\cron_multiple_signoff_v2.php.

TODO: the exact Task Scheduler settings for this job on the Windows servers — task name, the account it runs as, and where its output is written — are not documented anywhere yet.

Remove any cron_multiple_signoff.php line (the v1 job). Its file no longer exists in master, so the line only fills the cron mail or log with errors. Make sure the log file is writable by that user.

3. Run it once by hand

bash
php /path/to/app/cron/cron_multiple_signoff_v2.php

4. Check that it runs

Watch the log:

bash
tail -f /var/log/inact_multisignoff.log

What the job prints, taken from the script:

LineMeaning
multiple sign off queue is emptyHealthy, nothing to do. Printed once a minute when idle
multiple sign off queue: 3 row(s)A batch was read
queue row 12: taken by another run, skippedAnother run already claimed that row. Harmless
routing 120 (map 845): publish then published as doc no 9031D - Transmit row. nothing to publish means enable_autopublish is off or the routing has no document file
routing 121 (map 850): approved documentA / C / S / I row, curl to getPDFAfterApprove.php
doneThe row is finished (multi_status = 'y')
WARNING: return transmittal failed: ...The return Transmittal failed, but the row is still marked done, as a single Sign Off does
ERROR: ...The row went back to 'n' and runs again next minute
doneEnd of the batch
ERROR: cannot read ts_multiple_signoff_list: ..., or an uncaught PDOExceptionThe queue query failed. Usually the migration has not run, so map_id does not exist

Check the queue in the database (portable SQL):

sql
SELECT multi_id, rout_id, map_id, multi_status, cron_ongoing FROM ts_multiple_signoff_list WHERE multi_status = 'n' ORDER BY multi_id;

Healthy: rows with map_id set leave this list within a minute or two of a Multiple Sign Off.

5. Clear a stuck claim

A run killed part way (a PHP fatal error, out of memory, a server restart) leaves its rows at cron_ongoing = 'y', and later runs skip them. First make sure no run is active (pgrep -f cron_multiple_signoff_v2.php prints nothing), then:

sql
UPDATE ts_multiple_signoff_list SET cron_ongoing = 'n' WHERE multi_status = 'n' AND cron_ongoing = 'y';

Troubleshooting

SymptomCauseFix
Log shows ERROR: getPDFAfterApprove failed: ... every minute, rows stay at nbaseurl is not reachable from the server (DNS, firewall, a public URL that does not loop back), or the page returns HTTP 400 or moreRun the curl check in step 1. Fix DNS / hosts or inweb_config.baseurl
Nothing in the log at allThe line is not installed, the log path is not writable, or PHP is not the CLI binarycrontab -l, check the log file owner, php -v
Rows stay at n with cron_ongoing = 'y'A run was killed part wayStep 5
Rows stay at n with map_id NULLOld rows written before INA01-866. The job skips them by designCheck each routing by hand, then set multi_status = 'y'
ERROR: cannot read ts_multiple_signoff_list or an uncaught PDOException about map_idMigration not runRun phinx migrate. Until then every Multiple Sign Off in the browser also fails with "Sign off failed"
ERROR: performer row N not foundThe performer row was deleted after the sign offCheck the routing, then set the row to multi_status = 'y'
The queue drains but A routings get no _R documentenable_autopublish is off. The job then builds only the return Transmittal (when external and enable_transmittal = 1)Expected. Turn the flag on only if the instance should publish
A run takes longer than a minute and the next one startsNormal. Each row is claimed on its own, so the second run only picks up rows the first has not takenNothing to do

TODO: after a curl timeout (600 s) the row is retried, but the web request may still finish on the server. Whether a retry can build the _R document twice has not been checked.

TODO: a failed D - Transmit publish rolls back the database, but a Transmittal PDF already written to docUpload/transmittal stays on disk. Whether that file is cleaned up anywhere has not been checked.

The rest of cron/

Master holds 18 files. Beyond the five above:

ScriptPurposeGated byPresent in
inact_doclib_retention.phpFlag and notify documents approaching their retention or expiryenable_doclib_retentionmaster, Jadestone, Medco, JOTRE
inact_direct_update_data.phpSuper-admin maintenance, fixes stale routing inbox markerssuper-admin onlyall five
cron_bypass_overdue.phpOverdue-bypass handlingall five
cron_create_routing_slip.phpGenerate routing slips for the old multiple sign-off cronJadestone, Medco, JOTRE, Prima Energy. Removed from master by INA01-866
cron_issue_staging_oversize.phpEmail about staged document file sizes. Not an oversize-page converter.all five
cron_submit_queue.php, procurement_submit_attachment.php, pl_submit_attachment.phpProcurement and packing-list submission and attachment queuesall five
expediting_weekly_cron.phpWeekly expediting job (procurement)all five
cron_dump_db.phpScheduled database dumpall five
cron_delete_empty_emailto.phpEmail cleanupall five
cron_pool_idapd.phpSends an email; purpose not documented in the fileall five
setting.phpBootstrap shared by the scripts, not a joball five

TODO: what cron_pool_idapd.php is for.

Operational notes and gotchas

  • Email is fully asynchronous. Everything funnels through ts_email and job 1. The digest and overdue jobs also queue into ts_email, so job 1 stays critical.
  • cURL dependency. Job 1 and the A / C / S / I half of job 2 call back into the app over HTTP. A wrong or unreachable $inweb_baseurl makes job 1 do nothing silently, and makes job 2 retry the same rows every minute.
  • Flag-gated crons do nothing silently when off. inact_routing_digest.php, inact_routing_overdue_digest.php and inact_doclib_retention.php exit early unless their feature flags are set. Easy to mistake for "broken".
  • The idle Multiple Sign Off job still writes one log line a minute. Rotate /var/log/inact_multisignoff.log.

Instance differences

Checked on 2026-09-16 on each fork's origin/main. Master is counted after INA01-866.

InstanceMultiple Sign Off cronOther differences
Master (after INA01-866)cron_multiple_signoff_v2.php only18 files
Jadestoneboth cron_multiple_signoff.php and cron_multiple_signoff_v2.php, plus cron_create_routing_slip.php. Its v2 is its own version, not the master file20 files
Medcocron_multiple_signoff_v2.php (its own version), cron_create_routing_slip.php and cron_create_routing_slip_old.php21 files. cron_daily_summary.php still present, plus inact_archive_mail.php. No inact_routing_overdue_digest.php.
JOTREcron_multiple_signoff.php (v1) and cron_create_routing_slip.php32 files. Adds twelve accurate_* scripts (sync and backfill against the Accurate accounting system) and backfill_expediting_chain.php, backfill_mrr_no.php. No digest crons.
Prima Energycron_multiple_signoff.php (v1) and cron_create_routing_slip.php16 files. No inact_doclib_retention.php, no digest crons.

TODO: which file Jadestone's servers schedule, v1 or v2, when both are present.