Skip to content

Cron Operations Guide

This guide describes the cron jobs and maintenance tasks for Whity Core.

Whity Core includes several automated maintenance commands that should be scheduled via cron to keep the system healthy and performant.

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 0
  • 2 = 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 + UNIQUE jti constraint are pinned against regression by tests/Database/MigrationSchemaTest.php.

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.

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.

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.

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.

The cleanup is genuinely wired into the running stack, not just documented:

  • Dev / demo (docker-compose.yml): a dedicated cron service runs the command on a daily loop. It is an opt-in profile (kept out of the default stack so docker compose up --wait does not gate readiness on a long-running maintenance loop). Start it with docker compose --profile cron up -d and inspect it with docker 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. The docker-compose.staging.yml stack expects the deploy environment to register this schedule.

The manual crontab setup below remains valid for any host-cron deployment.

Before adding to cron, test the command manually:

Terminal window
php /var/www/whity/public/index.php revoked-tokens:cleanup

You should see output like: Cleaned 5 expired revocation entries

Edit the root crontab (or appropriate user):

Terminal window
crontab -e

Add the revoked tokens cleanup job:

# Whity Core Maintenance Tasks
# Clean expired revoked tokens daily at 2:00 AM UTC
0 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 UTC
30 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>&1

Check that the job is registered:

Terminal window
crontab -l

Monitor the logs:

Terminal window
tail -f /var/log/whity-cleanup.log
  1. Verify cron daemon is running: systemctl status cron
  2. Check crontab syntax: crontab -l
  3. Check system logs: journalctl -u cron
  4. Verify PHP path: which php
  5. Verify file permissions: ls -la /var/www/whity/public/index.php

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

If the command fails with database errors:

  1. Verify .env file has correct database credentials
  2. Ensure database server is running
  3. Check database user has DELETE permission on revoked_tokens table
  4. Test database connection: php /var/www/whity/public/index.php migrate status

Additional maintenance commands may be added in future releases. Check the CLI help for available commands:

Terminal window
php /var/www/whity/public/index.php