Cron Operations Guide
This guide describes the cron jobs and maintenance tasks for Whity Core.
Overview
Section titled “Overview”Whity Core includes several automated maintenance commands that should be scheduled via cron to keep the system healthy and performant.
Available Cron Commands
Section titled “Available Cron Commands”Revoked Tokens Cleanup
Section titled “Revoked Tokens Cleanup”Command: php /var/www/whity/public/index.php revoked-tokens:cleanup
Purpose: Deletes expired JWT revocation entries from the revoked_tokens table.
Why: The revocation table grows over time as tokens are revoked. Expired entries can safely be deleted since they can no longer be used. This keeps the table small and query performance fast.
Recommended Schedule: 0 2 * * * php /var/www/whity/public/index.php revoked-tokens:cleanup
Schedule Explanation:
0= minute 02= hour 2 (2:00 AM)*= every day of month*= every month*= every day of week
This runs daily at 2:00 AM UTC (off-peak time).
Expected Output: Cleaned {count} expired revocation entries
Retention policy: a revocation row only needs to outlive the token it
revokes. Once expires_at is in the past the underlying token is already dead
(the exp/epoch checks reject it without consulting this table), so the row is
safe to delete. Pruning runs daily, so at steady state the table holds only
not-yet-expired revocations plus any recently-expired rows still awaiting the
next cron pass — it never grows without bound. The delete uses
WHERE expires_at < CURRENT_TIMESTAMP (standard SQL, portable across PostgreSQL
and SQLite) and is backed by idx_revoked_tokens_expires_at (migration 011), so
it stays cheap even on a large table.
revoked_tokens is a sanctioned GLOBAL table: a JWT jti is unique
platform-wide, so the table has no tenant_id column and the cleanup delete
intentionally carries no tenant predicate — by design, not by omission. It is
listed in \Whity\Core\Tenant\SanctionedGlobalTables, the single source of
truth the tenant-predicate guard consults. See
TENANT_ISOLATION.
The cleanup behaviour is verified end-to-end (delete expired / retain non-expired / report count) on a real SQL engine — SQLite locally and PostgreSQL in CI — by
tests/Commands/RevokedTokensCleanupCommandTest.php, and the supporting indexes + UNIQUEjticonstraint are pinned against regression bytests/Database/MigrationSchemaTest.php.
Form Upload Sweep
Section titled “Form Upload Sweep”Command: php /var/www/whity/public/index.php form-uploads:sweep [--ttl=86400] [--limit=500]
Purpose: Deletes form attachments that nobody ever submitted — both the
form_uploads row and the object in storage.
Why: a file answer’s bytes are written before the submission exists.
They have to be: a person attaches the file while they are still filling the form
in. So every abandoned form — a closed tab, a passed deadline, a wrong file
picked and replaced — leaves an object in a tenant’s storage that no row will
ever reference. On a form opened to the public, the party abandoning the most
forms is a stranger, so “just accept the cost” is a bill with an
attacker-controlled magnitude. This job is the other half of the upload feature,
not an optimisation.
Recommended Schedule: 30 3 * * * php /var/www/whity/public/index.php form-uploads:sweep
Expected Output:
Swept {n} unclaimed form uploads (TTL 86400s); {m} objects could not be deleted
Retention policy: an upload is deleted when claimed_at IS NULL and
created_at is older than the TTL (24 hours by default). A row in that state is
unreachable by construction: the only path that could ever reference it is
FormUploadRepository::claim(), which refuses an already-claimed row, and a
submission claims its uploads inside the same transaction that writes the
document_artifacts row. So a swept upload can never be one a document depends
on. The TTL is generous on purpose — it has to outlast somebody who attaches a
paper, goes to find the co-author list, and comes back after lunch.
Read the second number. objects could not be deleted counts files whose
row was removed but whose bytes the storage backend refused to delete. Those
bytes are now costing money and no later sweep will find them, because the
row that named them is gone. A non-zero value there is a storage-backend problem
to investigate, not noise. The exit code stays 0 so a scheduler does not alert on
every run; the number is the signal.
Ordering is deliberate: rows are deleted first, objects second. The reverse
would leave a claimable row pointing at bytes that are gone — a
document_artifacts row minted over an empty address, reporting success and
404ing the first time anybody opens the evidence. A leaked object costs money; a
claimable row with no bytes costs the truth of the record.
It builds the same storage driver the uploads used — the per-tenant routing driver, not the platform default. A sweep holding only the default would delete the rows of an entitled tenant’s uploads and then fail to find the objects in that tenant’s own bucket, reporting a successful sweep while storage kept growing.
How it is scheduled
Section titled “How it is scheduled”The cleanup is genuinely wired into the running stack, not just documented:
- Dev / demo (
docker-compose.yml): a dedicatedcronservice runs the command on a daily loop. It is an opt-in profile (kept out of the default stack sodocker compose up --waitdoes not gate readiness on a long-running maintenance loop). Start it withdocker compose --profile cron up -dand inspect it withdocker compose --profile cron logs cron. - Staging / production: schedule the SAME command via the host crontab (or
the orchestrator’s scheduler — Kubernetes
CronJob, systemd timer, etc.) using the crontab entry below. Thedocker-compose.staging.ymlstack expects the deploy environment to register this schedule.
The manual crontab setup below remains valid for any host-cron deployment.
Setup Instructions
Section titled “Setup Instructions”1. Verify Command Works
Section titled “1. Verify Command Works”Before adding to cron, test the command manually:
php /var/www/whity/public/index.php revoked-tokens:cleanupYou should see output like: Cleaned 5 expired revocation entries
2. Add to Crontab
Section titled “2. Add to Crontab”Edit the root crontab (or appropriate user):
crontab -eAdd the revoked tokens cleanup job:
# Whity Core Maintenance Tasks
# Clean expired revoked tokens daily at 2:00 AM UTC0 2 * * * php /var/www/whity/public/index.php revoked-tokens:cleanup >> /var/log/whity-cleanup.log 2>&1
# Delete form attachments nobody ever submitted, daily at 3:30 AM UTC30 3 * * * php /var/www/whity/public/index.php form-uploads:sweep >> /var/log/whity-cleanup.log 2>&13. Verify Cron Setup
Section titled “3. Verify Cron Setup”Check that the job is registered:
crontab -lMonitor the logs:
tail -f /var/log/whity-cleanup.logMonitoring and Troubleshooting
Section titled “Monitoring and Troubleshooting”Cron Job Not Running?
Section titled “Cron Job Not Running?”- Verify cron daemon is running:
systemctl status cron - Check crontab syntax:
crontab -l - Check system logs:
journalctl -u cron - Verify PHP path:
which php - Verify file permissions:
ls -la /var/www/whity/public/index.php
Cleanup Deleting Too Many Rows?
Section titled “Cleanup Deleting Too Many Rows?”If the cleanup is removing many rows at once:
- This is normal if tokens have been accumulating
- Expired entries are safe to delete
- Cleanup will be faster in future runs as the backlog is cleared
- No user impact as the tokens are already expired and unusable
Database Connection Issues?
Section titled “Database Connection Issues?”If the command fails with database errors:
- Verify
.envfile has correct database credentials - Ensure database server is running
- Check database user has DELETE permission on revoked_tokens table
- Test database connection:
php /var/www/whity/public/index.php migrate status
Future Cron Jobs
Section titled “Future Cron Jobs”Additional maintenance commands may be added in future releases. Check the CLI help for available commands:
php /var/www/whity/public/index.php