Theme
Housekeeping: the timer and the cron
Everything scheduled in Jua runs from one housekeeping pass, started every minute. It is what expires orders, closes and settles markets, sends matches to the chains, pays commissions, translates listings and keeps Demo moving. If it stops, customers' trades stop settling, so a watchdog pages staff when it does.
What a pass does
In order, each step isolated so one failing never stops the next (apps/api/src/lib/housekeeping.ts):
- Market housekeeping (
runHousekeeping): expires good-till-date orders, closes markets whose closing time has passed (cancelling their resting orders), finalises undisputed proposals whose window has ended, opens the next edition of recurring events. - Retention (
runRetention): once a day across every instance (theretention:dailyclaim injob_leases;skipped: truethe rest of the day), deletes what nothing reads any more: sessions past their expiry (customers', staff's, the Mini App's), notifications read and older than 90 days, portfolio snapshots older than the two years the chart shows. Price points and the audit log are kept. The pass that did it logs[housekeeping] retention: deleted …. - The house market maker (
runMarketMaker): play money only; re-quotes every open market. - Crypto housekeeping (
runCryptoHousekeeping): the vault model's watcher, payout queue, cancel follow-ups, provider upkeep, the hourly reconciliation, and delivery of durable custody alerts. Runs only withCRYPTO_PROVIDERset; otherwise it returns at once. - The settlement worker (
runSettlements): matches to CTF Exchange, confirmations, undoing refused ones, conditions, results, EVM commission payouts. Null while the exchange is off. - The TON worker (
runTonExchange): the Mini App's exchange. Null while TON trading is off. - The heartbeat is recorded, and
HOUSEKEEPING_HEARTBEAT_URLpinged. - Demo housekeeping: fills resting demo orders against the books, settles demo positions, advances simulated payments.
- The Claude jobs, each started in the background and not awaited (a request can take minutes), each one at a time under its own lease, each a no-op without
ANTHROPIC_API_KEY: listing translations; the listing scout, which runs when its last finished run (heartbeatlisting-scout) is two hours old; and automatic resolution, which looks at a few closed events a pass (the AI). The cron route starts the same three, so an external scheduler translates and runs the AI too; their outcome is in the API's log ([translations],[listing-scout],[auto-resolution]), not in the route's answer.
At most two passes of one process overlap, so one slow pass never stops the next from settling markets, and slow passes never pile up.
Two ways to run it
The API's own timer (the default)
Every API process runs the pass on a one-minute timer, five seconds after it starts, and also nudges it from GET /api/events so a browsing customer keeps things moving in development. While TON trading is on, the same process also runs the TON worker every five seconds, so a Mini App order is booked and settled within moments. Across several API instances, each worker's database lease keeps it to one instance at a time.
Nothing to configure. This is how the compose deployment runs today.
An external scheduler (HOUSEKEEPING_TIMER=off)
For several API instances or none long-lived, set HOUSEKEEPING_TIMER=off on the API and call the cron route every minute from a scheduler. Then only the scheduler runs the jobs: a page view never does, so anonymous traffic cannot start chain work in every instance.
bash
curl -fsS -X POST https://juamarkets.com/api/cron/housekeeping \
-H "Authorization: Bearer $CRON_SECRET"CRON_SECRET is required in production: an unset secret closes the route rather than opening it (finalising settles money). The route answers with what the pass did (closed, finalized, settlements, ton, demo…). Schedulers that work: Supabase pg_cron + pg_net (an example is at the end of packages/db/drizzle/0002_realtime_storage.sql), a systemd timer on the VM, or any cron.
TON needs more than a minute's cadence
With the API's timer off, nothing runs the TON worker between passes: Mini App orders are booked and settled only when the cron route is called. Call it more often than once a minute (every 5 to 10 seconds) while TON trading is on, or keep the API's own timer.
The heartbeat and the watchdog
- Every finished pass records a heartbeat (
job_heartbeats, jobhousekeeping) and, when set, pingsHOUSEKEEPING_HEARTBEAT_URLwith a GET: point a dead man's switch at it (healthchecks.io, Cronitor, Better Stack), which raises the alarm when the pings stop, even with the whole machine down. - Every API process checks the heartbeat every minute, whoever runs housekeeping. When no pass has finished for
HOUSEKEEPING_STALE_MINUTES(10;0turns it off), one instance claims the alert and raises it, URGENT, in the portal and throughALERT_WEBHOOK_URL: "URGENT: housekeeping has not run for N minutes", with where the last pass finished and what to check. "Housekeeping is running again" follows when a pass finishes. - When the database itself cannot be read for that long, nothing can be recorded or claimed, so "URGENT: the API cannot reach its database" goes straight to the webhook, once, until the database answers.
Test both before real money by stopping the scheduler (or the API) for ten minutes.
Leases
Workers that must run one at a time across instances take a row in job_leases (exchange:operator, ton-exchange:operator, the translation lease…) with an expiry. An instance that dies holding a lease is simply waited out: the exchange worker's lease lasts two minutes, after which another instance takes over. The leases are also how the watchdog's alert is claimed once per silence.
Reading the logs
bash
cd /opt/jua && docker compose --env-file .env.server --env-file .env.release logs --since 10m api | grep -E '\[(housekeeping|exchange|ton-exchange|crypto|demo|translations|market-maker)\]'A quiet pass logs nothing; a pass that did something logs one line per worker ("[exchange] sent 3, confirmed 2, failed 0 settlements"). Errors are [housekeeping] failed: …, [exchange] …, [ton-exchange] the pass failed: …, with URLs and keys redacted.