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.
Billing Access Reconciliation
Section titled “Billing Access Reconciliation”Command: php /var/www/whity/public/index.php billing:reconcile-access [--limit=100]
Purpose: Re-asks the billing service which tenants may use paid features, and writes the answer locally.
Why: notifications from the billing service are best-effort with a bounded number of retries. That endpoint will be down during a deploy, or behind a certificate that expired on a Sunday, or simply slow enough that an event exhausts its attempts. A product whose access control depends only on notifications arriving will eventually charge somebody and not let them in. This sweep is what makes a missed delivery cost a delay instead of a wrong answer, so scheduling it is not optional on a deployment that sells anything.
Recommended Schedule: */15 * * * * php /var/www/whity/public/index.php billing:reconcile-access
Expected Output:
Reconciled access: checked=12 changed=0 unreachable=0 skipped=0
It never revokes on silence. When the billing service cannot be reached the honest local answer is “I do not know”, and nothing follows from not knowing — so a short interval is safe even while the billing service is having a bad day. The alternative would turn their outage into ours, amplified: every tenant revoked at once, at exactly the moment nobody can process a payment to fix it.
Watch changed. A persistently non-zero value means notifications are not
arriving and the sweep is carrying the whole system on its own. That is worth
investigating long before a customer complains.
Exit code 1 means it stopped early because the billing service rate limited it. A sweep that keeps stopping early never reaches the tail of the tenant list, and those tenants quietly stop being reconciled at all.
A deployment with no billing service prints
No billing service configured; nothing to reconcile. and exits 0. Self-hosted
installations bill nobody; that is a supported state, not a misconfiguration.
Device Quantity Sync
Section titled “Device Quantity Sync”Command: php /var/www/whity/public/index.php billing:sync-device-quantity [--limit=100]
Purpose: Keeps a per-device subscription billing for the devices actually in service.
Why: without it, “per device” is a label on a price rather than a thing that happens. A tenant buys the add-on with three scanners, activates nine more, and is billed for three — next month, and the month after, until somebody adds up a year of invoices. It fails the other way too: a customer who retires half their fleet keeps paying for it, and that is the half that gets noticed and asked for back.
Recommended Schedule: 17 */6 * * * php /var/www/whity/public/index.php billing:sync-device-quantity
Four times a day, not every quarter-hour. Devices are billed monthly, so hours of drift cost nothing — and each pass spends one request per candidate tenant from the same allowance a customer’s checkout is waiting on. That is a reason to be less eager here than with the access sweep, not more.
Expected Output:
Synced device quantities: checked=4 changed=1 unchanged=3 skipped=0 unreachable=0
Which devices count is a per-tenant setting (licensing.billing_basis):
billed from provisioning, from activation, or only for units actually seen in the
period. The same setting drives the invoice line, so a tenant’s subscription and
their invoice cannot disagree about the size of their fleet.
Tenants billed active_in_period are skipped, by design. That basis counts
units seen between two dates, and how many will be seen before the month ends is
not knowable now. The billing service applies decreases only at renewal, so a
running tally would ratchet the customer’s bill upward permanently. Those tenants
are billed correctly by the invoice run, which counts the period after it closes.
Read — N paying for devices they no longer run. It appears only when it
happens, and it is the line that asks a human to act: the billing service’s
minimum quantity is one, so a tenant whose fleet has gone to nothing cannot be
resized down to match. What they need is the subscription cancelled.
The sweep will not cancel it, and that is a decision rather than a gap. The billing service does expose cancellation, so this could be automated — and should not be. A count of zero can mean a customer has retired their fleet, or that every device happened to be offline, or that a provisioning job failed overnight; only the first is a reason to end somebody’s service, and the three are indistinguishable from here. Ending it wrongly is not recoverable by running the sweep again. So the number is reported, and a person decides.
It never resizes on silence, and running it twice is safe. It compares the count against the quantity and acts only on the difference — which matters more here than for most jobs, because the thing a careless retry would repeat is charging somebody.
Affiliate Commission Accrual
Section titled “Affiliate Commission Accrual”Command: php /var/www/whity/public/index.php affiliate:accrue-commissions [--limit=200]
Purpose: Turns what referred customers have paid into what affiliates have earned.
Why: without something calling it, the affiliate programme is a schema. Affiliates get codes, codes attach to workspaces, workspaces pay — and the commission ledger stays empty. That failure is unusually quiet: every screen works, every test passes, and a balance of zero looks exactly like a programme nobody has used yet.
Recommended Schedule: 40 3 * * * php /var/www/whity/public/index.php affiliate:accrue-commissions
Nightly. Commissions are paid out on a human schedule — monthly, by somebody approving a payout — so there is nothing to gain from sweeping hourly, and each pass spends one request per referred workspace from the same billing-service allowance a customer’s checkout is waiting on.
Expected Output:
Accrued affiliate commissions: accrued=3 reversed=0 already=12 skipped=0 outside_window=1
It reads both places the money can be, and not one or the other: a workspace
with locally raised invoices from before it moved to the billing service and
receipts from after earned its referrer money in both. already counting most of
the rows is the normal steady state — the sweep re-reads each workspace’s whole
payment history every night and collides on what it has already paid for, which
is what lets it converge after a missed notification or a failed run.
Read — N partly refunded, needing a decision. It appears only when it
happens. The ledger holds one commission per payment and reverses it whole, so it
cannot express giving back a third of one: the commission stands, and somebody
has to settle the difference by hand. Silently reversing it whole would underpay
an affiliate whose customer kept most of what they bought; silently ignoring it
would pay on money that went back.
Read — N workspaces unreadable, so these numbers are incomplete, and note
that this exits non-zero. An unreadable history and a customer who has never
paid produce the same empty answer and need opposite responses, so the sweep
refuses to guess: those workspaces accrue nothing this pass and the next
successful pass pays everything owed. A run that keeps reporting them is a
billing service that has been unreachable long enough for affiliates to notice.
Whether a refund claws its commission back is a setting
(affiliate.clawback_on_refund, global, default on). Clawing back is correct —
the revenue did not happen — and it is also the term affiliates like least, so a
company that can afford to absorb refunds may choose to. Absorbing means paying
out of money nobody collected, which is a decision to make deliberately rather
than inherit.
Nothing pays anybody automatically, and that is where this schedule stops. The sweep accrues; a person assembles a payout on the affiliates screen, makes the transfer, and records the bank reference. There is deliberately no cron for that step: no schedule should be able to move money out of the company without somebody deciding to. What the sweep guarantees is that the number they are looking at is current.
affiliate.withholding_bp is what gets kept back from each payout and
remitted on the affiliate’s behalf — basis points, global, default 0, capped
at 5000. It is snapshot onto every payout row, so changing it never restates one
already made. The zero default is the honest starting point (withholding money
nobody instructed us to withhold takes cash from a person who must then reclaim
it) and it is also the risky one, so it is stated on every payout row rather than
left implicit. Set it before the first payout if a withholding obligation
applies — a payout already made cannot be re-rated.
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>&1
# Re-ask the billing service who may use paid features, every 15 minutes.# Not optional on a deployment that sells anything: it is what makes a missed# notification cost a delay instead of a customer who paid and cannot get in.*/15 * * * * php /var/www/whity/public/index.php billing:reconcile-access >> /var/log/whity-billing.log 2>&1
# Bill per-device subscriptions for the devices in service, four times a day.17 */6 * * * php /var/www/whity/public/index.php billing:sync-device-quantity >> /var/log/whity-billing.log 2>&1
# Turn referred customers' payments into affiliate commissions, nightly.# Only needed on a deployment running an affiliate programme — it costs one# request per referred workspace and does nothing at all when there are none.40 3 * * * php /var/www/whity/public/index.php affiliate:accrue-commissions >> /var/log/whity-billing.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