Skip to content

Execution and Control ​

The run() method is the entry point for your job's logic. Sidequest calls it when the job is claimed from the queue. Inside it, you write the work — and you use flow control methods to explicitly drive the job into the next lifecycle state when the default behavior (return = completed, throw = retry/failed) isn't expressive enough.

Basic implementation ​

run() receives the arguments you passed to .enqueue():

typescript
import { Job } from "sidequest";

export class ProcessOrderJob extends Job {
  async run(orderId: number, options: ProcessingOptions) {
    const order = await db.orders.findById(orderId);
    await fulfillOrder(order, options);
    return { orderId, fulfilledAt: new Date() };
  }
}

By default:

  • Return a value → job transitions to completed, return value stored as result.
  • Throw an error → job transitions back to waiting for retry (if attempts remain) or to failed (if not). Error stored in errors.

When you need finer control — fail without retrying, retry with a custom delay, pause until later — use the flow control methods below.

Runtime metadata ​

Before run() executes, Sidequest injects read-only properties onto this:

PropertyTypeDescription
this.idstringJob ID
this.attemptnumberCurrent attempt number (1-based)
this.max_attemptsnumberMaximum allowed attempts
this.queuestringQueue the job is running in
this.statestringCurrent state ("running")
this.inserted_atDateWhen the job was first enqueued
this.argsunknown[]The run arguments
this.abortSignalAbortSignalAborts when the job times out or is canceled. See below.

WARNING

These properties are only available inside run(). They are undefined in the constructor.

Flow control ​

These methods let you explicitly transition the job to a specific lifecycle state from inside run(). Each one is a convenient shorthand for a state transition — hence the name "convenience methods" you may see in older docs.

DANGER

You must return the result of every flow control method. Calling one without returning it is a no-op — the transition won't happen.

typescript
this.fail("reason"); // ❌ does nothing
return this.fail("reason"); // ✅ transitions to failed

return this.complete(result) ​

Explicitly transition the job to completed with a given result. Functionally equivalent to a plain return, but useful when branching:

typescript
async run(data: unknown) {
  if (!isValid(data)) {
    return this.complete({ skipped: true, reason: "invalid input" });
  }
  return this.complete(await process(data));
}

return this.fail(reason) ​

Immediately transition the job to failed, bypassing any remaining retry attempts. Use this when the error is permanent and retrying would be pointless:

typescript
async run(userId: string) {
  const user = await db.users.findById(userId);
  if (!user) {
    return this.fail(`User ${userId} not found`);
  }
  await processUser(user);
}

reason can be a string or an Error object — stored in the job's errors field.

return this.retry(reason, delay?) ​

Explicitly request a retry with an optional delay in milliseconds. Unlike throwing, this lets you control the retry delay independently of the global retryDelay config:

typescript
async run(endpoint: string) {
  const response = await fetch(endpoint);

  if (response.status === 429) {
    const retryAfter = parseInt(response.headers.get("Retry-After") ?? "30") * 1000;
    return this.retry("Rate limited", retryAfter);
  }

  if (!response.ok) {
    return this.fail(`HTTP ${response.status} — not retrying`);
  }

  return await response.json();
}

retry() consumes a retry attempt. If this is the last attempt, the job transitions to failed.

return this.snooze(delay) ​

Transition the job back to waiting and make it available again after delay milliseconds. Unlike retry(), snooze does not consume a retry attempt — it's a lifecycle pause, not a failure:

typescript
async run(payload: unknown) {
  if (!isBusinessHours()) {
    return this.snooze(msUntilNextBusinessHour()); // come back when the office opens
  }
  return await processPayload(payload);
}

Use snooze for time-based deferrals: rate limit windows, maintenance modes, business hours.

Responding to timeout and cancellation ​

When a job exceeds its timeout, or is canceled (via the dashboard or Sidequest.job.cancel(id)), Sidequest aborts this.abortSignal. Use it to stop your work promptly:

typescript
async run(url: string) {
  // Pass it to any abort-aware API; it cancels automatically.
  const res = await fetch(url, { signal: this.abortSignal });

  // Or check it cooperatively in loops / between steps.
  for (const item of await res.json()) {
    this.abortSignal.throwIfAborted(); // throws if timed out / canceled
    await process(item);
  }
}

this.abortSignal.reason is a JobTimeout or JobCanceled (both exported from sidequest) so you can react differently to each.

Whether the signal can actually stop the job depends on the execution mode

  • In the default thread pool with abortGracePeriodMs: 0, the worker is terminated, so honoring the signal is optional (it just lets you clean up; set a grace period to get a cooperative window).
  • In runner: "inline" mode there is no way to forcibly stop a job. If your job ignores this.abortSignal, timeouts and cancellation will not stop it: it runs to completion. Honoring the signal is mandatory for long-running inline jobs.

See Execution Modes for the full behavior across modes.

Choosing the right method ​

SituationUse
Normal completionreturn result or return this.complete(result)
Permanent, unrecoverable errorreturn this.fail(reason)
Transient error, controlled retry delayreturn this.retry(reason, delay)
Not the right time — try again laterreturn this.snooze(delay)
Unexpected error — let Sidequest decidethrow error

Best practices ​

  • Make jobs idempotent — safe to run more than once with the same arguments.
  • Use fail() for errors that won't resolve on retry. Use throw or retry() for transient ones.
  • Keep run() focused on a single responsibility. Use job chaining for multi-step workflows.
  • Add logging for key events so failures are debuggable from the dashboard.

Next: Enqueueing Jobs — the JobBuilder fluent API

Released under the LGPL-3.0 License.