Skip to content
Club system guide

Scheduled jobs

How the scheduler starts seven API jobs without running two copies at once.

Every thirty minutes, the Scheduler Worker sends seven private requests to the API at the same time. It records each result and then stops.

The scheduler starts the jobs. The API decides what changes. The Discord Worker handles Discord updates.

System flow

How the seven scheduled jobs run

Every thirty minutes, the Scheduler Worker starts seven private API jobs at the same time. The API claims a D1 lease and decides what each job changes.

Start each job

  1. Cloudflare Cron */30 * * * *
  2. Scheduler Worker Builds seven POST requests
  3. Internal check Checks the source, token, and scheduled time
  4. D1 lease Allows one active run for each job
  5. API job Makes the required changes

Seven jobs

  • Memberships Expires memberships that have ended
  • Membership roles Updates Discord roles from receipt memberships
  • Darkroom schedule Applies scheduled darkroom changes
  • Studio schedule Applies scheduled studio changes
  • Darkroom stats Updates the current totals
  • Equipment Sends reminders that are due
  • Photographer requests Expires old requests
The D1 lease prevents a repeated Cron trigger from running the same job twice. Discord changes still use signed internal events.
JobPrivate API pathWhat it does
Expire memberships/internal/jobs/memberships/expireFind expired memberships, update access, and start any role sync.
Reconcile membership roles/internal/jobs/memberships/roles/reconcileUpdate Discord roles from receipt membership data.
Update the darkroom schedule/internal/jobs/darkroom/schedule/sweepCheck the schedule, apply timed changes, and update Discord messages.
Update the studio schedule/internal/jobs/studio/schedule/sweepMove the studio schedule forward and publish the changes.
Update darkroom stats/internal/jobs/darkroom/stats/syncRecalculate the latest stats and update them in Discord.
Send equipment reminders/internal/jobs/equipment/reminders/runFind reminders that are due, claim each send, and notify the right member or staff member.
Expire photographer requests/internal/jobs/photographer-requests/expireExpire old requests and update their public or Discord status.

The seven paths are fixed in the scheduler source. Adding another job requires a code change and a new deployment. A database row cannot tell the Worker to call another internal URL.

Cloudflare Cron calls the Worker’s scheduled handler with the cron string and scheduled timestamp. The Worker checks its INTERNAL_TOKEN and API_WORKER binding, then sends all seven requests with Promise.all.

The jobs do not depend on each other, so they can run at the same time. Darkroom stats do not need to wait for membership expiry, and equipment reminders do not hold up the studio schedule.

Each result includes:

  • API path
  • Success or failure
  • Status code
  • A limited response body
  • Time in milliseconds
  • A safe error message if the request failed before a response

The final summary counts the jobs that passed and failed. If one job fails, the other six results are still kept.

Each request is a POST to an internal URL. It includes three headers:

Source

x-pcc-internal-source names the caller as scheduler-worker.

Token

x-internal-token must match the shared secret in the API.

Scheduled time

x-pcc-scheduled-at holds the original Cron timestamp.

The source header is not proof by itself because a browser can make the same header. The internal token and service binding prove that the request came from a trusted service.

The API handles these job paths before normal browser session checks. They are private service endpoints, not staff dashboard routes or public Cron URLs.

Cron may send the same trigger again after a delay. A deployment may also overlap with a scheduled run. Before a job starts, the API claims a D1 lease for that job.

A lease records:

  1. Whether the same job is already running.
  2. When the claim expires if the Worker stops before it finishes.
  3. Whether the job finished or failed.

If another request arrives while the lease is active, the API does not run that job again. When the job ends, the API records success or failure and updates the lease state.

The scheduler sends the request. The API decides whether the job can run.

Each job must still avoid duplicate changes

Section titled “Each job must still avoid duplicate changes”

A lease stops two copies of a job from running at once. The job’s code must also make each record update idempotent.

For example, an equipment job must know which reminder is due before it sends a Discord event. A membership job must update only expired memberships. A photographer request job must update only requests that can expire.

The lease protects the whole job. The job’s rules protect each record.

Discord changes still use the Discord Worker

Section titled “Discord changes still use the Discord Worker”

Some jobs change messages, roles, or other items in Discord. The scheduler never calls Discord REST and does not have a Discord token.

The request follows this path:

  1. The scheduler calls the private API job.
  2. The API claims the job lease.
  3. The API finds and saves the required changes.
  4. The API signs a typed event for each Discord change.
  5. The Discord Worker checks the event, claims its nonce, and calls Discord REST.

Scheduled and manual changes use the same event contract.

The Worker’s health response shows its service name and configured job count. The health route is rate limited.

The scheduler configuration sets a limit of ten requests per minute. The code uses this limit only for public GET / and GET /health requests. The scheduled handler does not use it. Cron runs through Cloudflare’s scheduled trigger, and the API requests use the private binding and internal token.

There are two kinds of failure.

An API call can return an error status or fail before it gets a response. The Worker saves that result and waits for the other jobs. The summary logs how many passed and failed.

The API can claim a lease and then fail while running the job. The job state records the failure, the API logs the job details, and the Scheduler Worker receives an error response.

A Cron trigger can succeed while one of the seven jobs fails. The summary keeps a separate result for each path.

The Email Worker’s five-minute Cron is separate from these seven jobs.

Every five minutes, it checks the receipt KV namespace for intake records that can be retried. Email parsing and delivery recovery stay in the Email Worker. The thirty-minute Scheduler Worker runs the club workflows owned by the API.

Expanded photograph