Skip to Content
InfraCron jobs

Cron jobs

The IBA API ships a set of scheduled background jobs: currency-expiry alerts, payment reminders, and a data-integrity safety net. Each job is a standalone Node script under api/src/crons/, registered as a row in the op_cron_configs table. This page covers what runs, why, and how it’s triggered.

This is the conceptual reference. The auto-generated endpoint schema for the manual trigger lives at API → IBA API → Crons; don’t hand-edit that page — it’s regenerated from the Bruno collection.

How crons run

Scheduling and execution are two decoupled halves:

  1. Scheduler — GitHub Actions. .github/workflows/crons.yml runs on a schedule: (cron) and curls the API’s trigger endpoint for the due job, authenticating with the CRONS_API_KEY repo secret. The schedule is in version control — there’s no scheduler box to SSH into. It can also be fired on demand from the Actions tab (Run workflow → pick a job).
  2. Executor — the API. GET /crons/run/:cron_name looks the job up in op_cron_configs, runs node <cron_path> as a child process via exec() inside the API App Platform container, and writes a row to op_cron_logs when it finishes.

A third mechanism exists but is dormant: the in-process node-cron scheduler (api/src/crons/index.js) that cronInit() would start is commented out in api/index.js. So schedule_pattern in op_cron_configs is documentation only — the workflow’s cron: lines are the real cadence.

Rollout status (2026-06-01). reconcile_parent_skills runs from the workflow. The currency/payment jobs are mid-cutover from the legacy droplet crontab — each moves to the workflow as its droplet line is removed, so until that finishes some still fire from the droplet. Tracking: TUN-783.

History. The scheduler used to be a hand-maintained crontab on the Dev-DB-Redis DigitalOcean droplet that curled the same endpoint, with the token in cleartext on the box. The GitHub Actions workflow replaces it: version-controlled, secret-managed, no SSH.

Because each job is launched as node <script>, every handler bootstraps its own environment (pulling secrets from Infisical when not in production), guards on IS_PRODUCTION === 'Yes' before it mutates anything, and exits 0 on success / 1 on failure.

At a glance

Cadences below are the workflow’s cron: schedules (UTC). The name is the value passed to /crons/run/<name>; the currency names carry a _currency suffix.

Job (name)What it doesCadence (GitHub Actions, UTC)Source
reconcile_parent_skillsSafety net that heals parent-skill auto-approval inconsistencies (TNBUGS-1728).Hourly — 0 * * * *data-integrity/reconcile-parent-skills
flyer_currencyCurrency-expiry alerts for flyers (regular flying members). Wired to flyer.service.js, not the shared CurrencyCronClass engine.Daily 08:30currency/flyer
trainer_currencyCurrency-expiry alerts for trainers; can disable lapsed accounts.07:45 on the 16th of Jan & Deccurrency/trainer
coach_currencySame, for coaches.08:55 on the 16th of Jan/Jun/Jul/Deccurrency/coach
instructor_currencySame, for instructors (extra last-safety-period guard before revoking).08:15 on the 16th of Jan/Jun/Jul/Deccurrency/instructors
military_currencySame, for military personnel (no manager emails).09:55 on the 16th of Jan/Jun/Jul/Deccurrency/military
payment_reminderTiered payment-deadline notices to members.Daily 06:00payment/reminder
test_cronNo-op fixture for validating the cron harness.No schedule — manual (workflow_dispatch) only.test-cron

Only reconcile_parent_skills is registered via a committed migration (0003). The currency and payment rows pre-date the migration system and live directly in the production op_cron_configs table.

reconcile_parent_skills was previously dormant. Under the old droplet crontab it had no trigger line (and cronInit() is off), so until the GitHub Actions workflow only the one-shot back-fill in migration 0001 had ever run. The workflow now schedules it hourly (0 * * * *), closing that gap.

Currency-expiry alerts

The four currency jobs (trainer, coach, instructor, military) share one engine — CurrencyCronClass in api/src/crons/currency/index.js — and differ only in the role they target. A fifth currency job, flyer (regular flying members), is wired separately to flyer.service.js under the newer api/src/features/shared/currency/ implementation rather than CurrencyCronClass. For members whose currency is expiring or has expired, the job sends localised notifications (Mandrill email + app push) to a fan-out of recipients:

  • the member themselves, in their own language;
  • their manager(s), grouped by tunnel (military skips this — it has a different approval structure);
  • the regional ops team (USA / EU / AU split);
  • admin (info@tunnelflight.com).

It runs in two modes driven by how far out the expiry is: a renewal reminder ahead of the deadline, and an expired notice on/after it. When configured to, it also disables the lapsed account. The instructor variant adds a lastSafetyPeriodActive() check so a still-valid safety period doesn’t get revoked by accident.

Payment reminders

payment_reminder queries members with upcoming payment deadlines (joining fees where status is succeeded against fees_mapping where the mapping is Active), computes days remaining, and sends one of three tiered notices: 31 days (last month), 7 days (last week), and overdue. A single run handles all three tiers — the tier is chosen per member from the days-remaining figure, not from separate schedules.

reconcile_parent_skills

The hourly safety net behind the parent-skill auto-approval work (TNBUGS-1728). It delegates to ReconciliationService.run(), which heals any member-upgrade rows left inconsistent, and logs the count it fixed. Like the others, it only mutates data in production. This is the one cron registered through a database migration, so it travels with the code.

Configuration & logging

  • op_cron_configs — one row per job: name (unique), cron_path (relative to api/src/crons/), schedule_pattern, is_active.
  • op_cron_logs — one row per run: cron_config_id, start_at, end_at, duration_seconds. Written by the HTTP trigger after the child process exits.
  • Admin view — run history is surfaced in the admin app’s logs section, backed by GET /admin/logs/cron-logs (filterable by cron_name).

To activate or pause a job without a deploy, flip is_active (and ensure the workflow’s cron: schedule is or isn’t enabled for it).

Triggering a cron manually

curl --request GET \ --url 'https://api.tunnelflight.com/api/crons/run/test_cron' \ --header 'token: <CRONS_API_KEY>'

The endpoint authenticates on a token header matched against the CRONS_API_KEY environment variable. It returns immediately (success: true) after spawning the child process — it does not wait for the job to finish, so check op_cron_logs (or the admin view) for the outcome. Full request/response schema: API → IBA API → Crons.

You can also trigger a job from the crons workflow  with Run workflow (workflow_dispatch) and pick the cron name — handy for testing without waiting for the schedule.

Gotchas

  • The DB schedule isn’t the trigger. While cronInit() is commented out, schedule_pattern is documentation only. The cadence that actually fires a job lives in the workflow’s cron: lines (see How crons run), not in op_cron_configs. The two can and do disagree.
  • Most currency jobs run only twice a year. Per the workflow schedule, the trainer/coach/instructor/military currency jobs fire on the 16th of a few specific months (e.g. Jan & Dec for trainers), not continuously. Only flyer_currency and payment_reminder run daily. If you expected rolling daily currency checks, that is not what’s scheduled.
  • The trigger token is a secret. The workflow passes CRONS_API_KEY (a repo secret) in the token: header. Rotate it in Infisical and update the GitHub Actions secret together. (The legacy droplet crontab held this token in cleartext — rotate it if that box is still reachable.)
  • Nothing runs outside production. Every handler exits early unless IS_PRODUCTION === 'Yes', so triggering one locally or in a preview is a safe no-op (handy for testing the harness with test_cron).
  • Fire-and-forget. The HTTP trigger returns before the job completes. A success: true response means “started”, not “succeeded”.
  • Most configs aren’t in the repo. Only reconcile_parent_skills has a migration. The currency and payment rows exist only in the production DB, so they won’t appear in a fresh local database.

Assistant crons (separate)

The Assistant app has its own, unrelated cron infrastructure (a NodeCronScheduler, a JobRegistry, and a token-guarded POST /api/cron/run/[name] endpoint that logs to its own MySQL table). It ships with no jobs registered yet — the scheduling machinery is in place for future phases. It does not share op_cron_configs or the IBA API harness described above.

See also

Last updated on