# Scheduled Jobs and Background Workflows | Volcano

URL: https://volcano.dev/solutions/background-jobs

> Run background work on Volcano: cron schedules on any function, durable functions that checkpoint each step and retry on their own, project locks so only one run works at a time, and Postgres for queues of rows.

## Background work without a worker fleet

Run the long jobs as durable functions that resume after a failure, put them on a cron schedule, and take a lock when only one run may work at a time.

```console
$ volcano cloud functions schedulers create reap-sessions \
$   --name every-15m --cron "*/15 * * * *"
✓ every-15m · */15 * * * * · enabled
$ volcano cloud durable schedulers create nightly-sync \
$   --name nightly --cron "0 2 * * *"
✓ nightly · each tick starts one durable execution
# no worker fleet to keep alive between runs
```

## The scheduler is not the hard part

Firing a job on a cron expression is easy. What bites is everything after: two instances waking at once and doing the same work twice, a run that dies halfway and leaves rows half-updated, and a retry that sends the same email a second time.

Volcano runs the trigger, so there is no box to keep alive, and gives you the pieces that make the rest safe. A durable function records each step as it finishes, so a run that dies resumes where it stopped and a retry never repeats a finished step. A project lock lets exactly one run hold the work at a time. When the work is a queue of rows, claim them in Postgres with FOR UPDATE SKIP LOCKED.

## A schedule is part of the function

*Triggers*

Declare it in volcano-config.yaml so it is reviewed with the code, or create it from the CLI when you need one now. Standard and durable functions both take schedules, and there is no scheduler service to operate.

- 5-field UTC cron, per function, named
- Enable or disable without deleting
- An optional JSON payload per schedule

Image: Schedulers with their cron expression, region and next run.

## Jobs that resume instead of restarting

*Durability*

Run a long job as a durable function and each page, batch or API call becomes a step. A failed step retries with backoff on its own, a crash resumes at the first unfinished step, and a finished step never runs again. One execution can take hours, wait for days, or hold a step until a person approves it, and still pick up where it left off.

- Retries with backoff, per step
- atMostOnce with retry: false for one-time emails
- Up to a year per execution

```console
step  fetch-1 … store-30    succeeded
step  fetch-31              failed · 502 from partner
step  fetch-31              attempt 2 · succeeded
step  fetch-32 … store-48   succeeded
✓ one execution · pages 1 to 30 were not fetched twice
```

## One run at a time, when it matters

*Safety*

When two runs must not overlap, such as a rollup or a sync against a rate-limited API, wrap the work in a project lock. One run holds a renewable lease and the others get acquired: false and return. A fencing token lets the protected write refuse a holder that lost its lease.

- withLock renews the lease while the work runs
- Fencing tokens so a displaced holder's writes fail
- FOR UPDATE SKIP LOCKED for queues of rows

```console
# both wake at 02:30
run A  withLock('nightly-rollup')  acquired · lease 60s, renewing
run B  withLock('nightly-rollup')  acquired: false · skipped
no overlapping work, no coordination service
```

## How it works

1. **Pick the shape**: A short batch is a standard function. Anything with several steps, retries or waits is a durable function.
2. **Write the work**: In a durable function, make each page, batch or call a step. In a standard one, take a bounded batch and return.
3. **Attach the schedule**: Declare it in the manifest or create it with the CLI. Each tick invokes the function or starts an execution.
4. **Watch and adjust**: List executions, follow runtime logs, and disable a schedule without deleting it while you investigate.

## The long job, the locked job, the schedule, the operating

### Durable job

```javascript
const { durable } = require('@volcano.dev/sdk/durable');

// Started by a cron tick. Each page from the partner API is its own step, so
// a failure on page 31 retries page 31 rather than the whole sync.
exports.handler = durable(async (input, ctx) => {
  let cursor = null;
  let pages = 0;

  do {
    const page = await ctx.step(`fetch-${pages + 1}`, () => fetchPartnerPage(cursor), {
      retry: { attempts: 5, initialDelay: '10s', maxDelay: '5m' },
    });
    await ctx.step(`store-${pages + 1}`, () => upsertRows(page.rows));
    cursor = page.next_cursor;
    pages += 1;
  } while (cursor);

  // Checkpointed before it runs and never retried, so the summary goes once.
  await ctx.step('notify', () => sendSummary(pages), { atMostOnce: true, retry: false });

  return { pages };
});
```

### Locked job

```javascript
const { VolcanoAuth } = require('@volcano.dev/sdk');

// Locks take a service key, so these three are project variables.
const volcano = new VolcanoAuth({
  apiUrl: process.env.VOLCANO_API_URL,
  anonKey: process.env.VOLCANO_ANON_KEY,
  accessToken: process.env.VOLCANO_SERVICE_KEY,
});

exports.handler = async () => {
  const result = await volcano.locks.withLock(
    'nightly-rollup',
    { ttl: 60 },
    // withLock renews the lease while this runs and aborts signal if the
    // lease is lost. Pass the fencing token to the write it protects.
    ({ signal, lease }) => rollUp({ signal, fencingToken: lease.fencingToken }),
  );

  if (result.error) throw result.error;

  if (!result.acquired) {
    return { statusCode: 200, body: JSON.stringify({ skipped: true }) };
  }
  return { statusCode: 200, body: JSON.stringify(result.data) };
};
```

### Schedule

```yaml
version: 1

functions:
  - name: nightly-rollup      # the runtime comes from the entrypoint file
    visibility: private       # only service keys and schedulers reach it
    schedulers:
      - name: rollup          # unique per function
        cron: "30 2 * * *"
        enabled: true
        payload: { kind: rollup }

  - name: reap-sessions
    visibility: private
    schedulers:
      - name: every-15m
        cron: "*/15 * * * *"
        enabled: true

  - name: nightly-sync
    kind: durable             # this example schedules it from the CLI

# Apply with:
#   volcano cloud config deploy --dry-run
#   volcano cloud config deploy
```

### Operating

```bash
volcano use my-project

# Short jobs are standard functions, long ones are durable
volcano cloud functions deploy -f nightly-rollup
volcano cloud durable deploy -f nightly-sync

# Create a schedule directly when you need one now
volcano cloud functions schedulers create nightly-rollup \
  --name rollup --cron "30 2 * * *" --payload '{"kind":"rollup"}'
volcano cloud durable schedulers create nightly-sync --name nightly --cron "0 2 * * *"

# Pause without losing the definition, then bring it back. list prints the id.
volcano cloud functions schedulers list nightly-rollup
volcano cloud functions schedulers disable nightly-rollup <scheduler-id>
volcano cloud functions schedulers enable  nightly-rollup <scheduler-id>

# See what last night did
volcano cloud durable executions list nightly-sync
volcano cloud durable logs nightly-sync --type runtime --follow
```

## What you avoid running

- **0 workers kept warm**: A tick invokes the function or starts an execution, and nothing runs in between.
- **366 days for one durable job**: An execution can wait, retry and resume across that whole span without holding a runtime.
- **1 call to hold a lock**: withLock takes the lease, renews it and releases it, so overlapping runs skip instead of colliding.

## Outcomes

- **No overlapping work**: A durable replay skips finished steps, and a locked job never overlaps itself.
- **Failures that resume**: A failed step retries on its own, and a crash picks up at the next step.
- **Visible state**: Executions, schedules and logs, from the CLI or the dashboard.

## Built on Volcano

- [The short jobs](https://volcano.dev/features/edge-functions): Functions with named cron schedules, declared in the manifest or created from the CLI.
- [The long ones](https://volcano.dev/features/durable-functions): Durable functions that checkpoint each step, retry what fails and wait, for a timer or a person's approval, without holding a runtime.
- [The queue](https://volcano.dev/features/postgres): Postgres holding job state, claimed with FOR UPDATE SKIP LOCKED when a queue of rows is the right shape.
- [Telling the UI](https://volcano.dev/features/realtime): A job's write reaches subscribers over the change stream, with nothing to publish.

## Frequently asked questions

### How do I schedule a function?

Declare a schedulers block on the function in volcano-config.yaml and deploy, or run volcano cloud functions schedulers create \<function\> --name \<name\> --cron "\*/5 \* \* \* \*". A durable function takes a schedule through volcano cloud durable schedulers create. All of them use 5-field UTC cron.

### Can I pause a job without deleting it?

Yes. volcano cloud functions schedulers list \<function\> prints the scheduler id, and disable \<function\> \<scheduler-id\> stops it firing while keeping the definition. enable turns it back on. Durable schedules have the same commands under volcano cloud durable schedulers.

### What stops two runs doing the same work?

For a job that must not overlap, a project lock: volcano.locks.withLock gives the lease to one run, and the others get acquired: false and return. For a queue of rows, claim them with FOR UPDATE SKIP LOCKED and a time limit on each claim, so a second run picks up different rows and a run that dies gives its rows back.

### How long can a job run?

A standard function gets up to 30 seconds per invocation on Hobby or 180 seconds on Superagent, so it should process a batch and leave the rest for the next tick. A durable-function step gets up to 300 seconds on Hobby or 900 seconds on Superagent, and one execution can run for up to 366 days.

### Are jobs retried automatically?

In a durable function, yes: a failed step retries with exponential backoff, and you set the attempts and delays per step. A standard function is not retried for you, so keep an attempts count on the row, back off with run\_after, and mark the job failed once it exceeds its limit.

### Which region does a schedule run in?

One region, defaulting to a region the function is deployed in. You can pin it with --regions when you create the scheduler.

## Get started

Ready to schedule your first job? Create a free account at https://volcano.dev/signup, or have your coding agent follow the setup playbook at https://volcano.dev/setup.md.
