---
title: "Background Jobs - Async Processing System - PerfLocale"
description: "PerfLocale's background-processing system — dispatch, retry, pause, and GC. Settings, WP-CLI, REST, hooks, and permissions for imports, migrations, and scans."
canonical: "https://perflocale.com/docs/background-jobs/"
source: "https://perflocale.com/docs/background-jobs/"
format: "markdown"
---
# Background Jobs

Long-running operations — full-site data imports and exports, bulk machine translation of posts and strings, WPML / Polylang / TranslatePress migrations, full-site string scans — can take minutes on a real site. PerfLocale's background-jobs layer moves them off the request path so the admin UI stays responsive and PHP-FPM timeouts on shared hosting never decide whether your import succeeds.

**Dedicated state table.** Job state lives in a single custom table (`{$wpdb->prefix}perflocale_jobs`) with typed columns and indexes on `uuid`, `(status, updated_at)`, `(type, status)`, and `created_by`. The table is garbage-collected daily and contributes no overhead on frontend requests — visitors never touch it. Concurrency locks live in `wp_options` via atomic `INSERT IGNORE` (race-free row-level mutex), which is the right shape for short-lived advisory locks.

## How it works

Nine user-triggered operations are wrapped as "tier-2" jobs (the [table below](#job-types) enumerates them). When you trigger one:

1.  **Dispatch** — the plugin chooses sync (inline) or async based on settings + payload size. Small inputs still run inline so you see the result immediately; big ones queue and the admin redirects to the Jobs page.
2.  **Engine** — async jobs run on **Action Scheduler** when WooCommerce or the standalone AS plugin is loaded, or **WP-Cron** otherwise. The choice is automatic; an operator can force WP-Cron from the settings page (escape hatch when AS misbehaves).
3.  **Worker** — the worker re-checks the job type's required capability against the dispatching user recorded in the row's `created_by` column (defense-in-depth against role downgrades between dispatch and execution), takes an atomic lock so a job can't run twice, and calls the operation's `execute()`.
4.  **Retry** — unhandled exceptions are caught; the worker reschedules with exponential backoff up to 5 attempts (configurable).

Each job is one row in `{$wpdb->prefix}perflocale_jobs` with typed columns: `uuid` (CHAR(36), UNIQUE), `type`, `hook`, `engine` (`action_scheduler` / `wp_cron`), `status`, `progress`, `total`, `processed`, `attempts`, `created_by` (indexed, used for cap re-validation), `blog_id`, `version` (optimistic-concurrency CAS column), and the timestamps. Larger payloads — `args`, `result` (truncated to 64 KB) and a `log` ring buffer (20 entries) — live in LONGTEXT columns on the same row, alongside a `VARCHAR(2000)` `error` column. Status and stuck-job sweeps use the `(status, updated_at)` index; admin filters use `(type, status)`.

**`updated_at` is written by PHP, in UTC.** The column deliberately carries no `ON UPDATE CURRENT_TIMESTAMP` — that would stamp the MySQL server's clock, while every sibling datetime is written with `current_time( 'mysql', true )` and every consumer (stuck-job watchdog, GC sweeps, job hydration) compares against `gmdate()`. On a database whose timezone isn't UTC the two clocks disagree, and a row touched a second ago can look hours stale — enough for the watchdog to force-fail a job that is still running. `JobState` writes `updated_at` explicitly on every state change instead.

Two short-lived advisory lock options remain in `wp_options`: `perflocale_job_lock_<uuid>` (per-job worker mutex) and `perflocale_type_lock_<type>` (per-type concurrency cap). They use atomic `INSERT IGNORE` on the UNIQUE `option_name` key so the DB enforces mutual exclusion at the row-lock layer. Stored expiry timestamps let a daily sweep clean up dead locks left behind by crashed workers.

All of the above is per-blog on multisite — each subsite has its own jobs table view (each subsite gets its own table via `$wpdb->prefix`), settings, and Jobs page.

## Job types

Nine operations are background-capable:

| Type slug | Triggered by | Required capability |
| --- | --- | --- |
| data_import | Settings → Export & Import (admin form) | perflocale_import_export |
| data_export | Settings → Export & Import. Completed exports show a single-use Download button on the Jobs page; the file is deleted after the first download. | perflocale_import_export |
| bulk_translate | Translations admin page → Bulk actions → Translate via <provider> | perflocale_manage_translations |
| bulk_string_translate | Strings admin page → the machine-translation toolbar (Translate selected / Translate filtered / Translate all). Hard ceiling of 5,000 strings per dispatch (filterable via perflocale/mt/bulk_string_max_per_dispatch); threshold default 50. | perflocale_use_mt |
| site_translate | "Translate the entire site" panel, REST site_wide mode, or wp perflocale translate --all --async | perflocale_manage_translations |
| wpml_migration | Settings → Export & Import (WPML migration) | manage_options |
| polylang_migration | Settings → Export & Import (Polylang migration) | manage_options |
| translatepress_migration | Settings → Export & Import (TranslatePress migration) | manage_options |
| string_scan | Strings → Scan for Strings button | perflocale_translate |

### What's _not_ on the unified Jobs queue

Some operations have their own scheduler and are **not** visible on PerfLocale → Jobs:

-   **Exchange-rate sync** (WooCommerce) — runs on a separate daily `perflocale_exchange_rate_sync` WP-Cron event.
-   **Webhook delivery + retry** — per-delivery one-shot events routed through `BackgroundEvents`, not Dispatcher.
-   **Concurrency-lock cleanup** — daily cron for the standalone Lock subsystem.

All three schedule their own events via the same engine selector (Action Scheduler when loaded, WP-Cron otherwise), so the engine setting affects them too — they just aren't tracked on the JobState index.

## Settings (Performance tab)

**Background Processing**

-   **Auto** (default) — small operations run inline; large ones queue. The threshold is per-job, with sensible defaults (e.g. 1000 files for string scan, 1000 rows for data import).
-   **Always** — every operation queues. Use on large sites where even "small" operations can spike during peak hours.
-   **Never** — everything runs inline. Not recommended on a real site — long imports will hit PHP-FPM timeouts.

**Background Engine** (only shown when Action Scheduler is loaded)

-   **Auto** — use Action Scheduler when available (recommended). AS has its own admin UI under Tools → Scheduled Actions filtered to the `perflocale` group, persists actions in its own tables, and handles concurrency / claim semantics natively.
-   **Force WP-Cron** — skip AS and use WordPress's built-in cron. Escape hatch for sites where AS is misbehaving (rare).

**Background Thresholds**

Per-job override of the default item-count threshold for Auto mode. Leave blank to use the default. Programmatic equivalent: [`perflocale/jobs/threshold/<type>` filter](#filter-jobs-threshold).

**Pause Queue**

Operator brake. New dispatches are accepted but workers immediately re-queue them every 5 minutes instead of running. Use as an emergency stop when something is mis-dispatching; uncheck to resume. Already-in-flight jobs finish naturally; only future ticks defer.

## Recovery and garbage collection

Three automatic recovery paths run on the daily `perflocale_jobs_gc` cron:

1.  **Stuck-job sweep.** Any job in `queued` or `running` whose `updated_at` hasn't moved in 6 hours (configurable) is marked `failed` with a clear message. Catches PHP-FPM kills, OOM crashes, host reboots, or broken cron environments where dispatched events never fire.
2.  **Terminal-state prune.** Jobs in `complete`, `failed`, or `canceled` older than 24 hours have their row deleted from the jobs table.
3.  **Stale lock sweep.** Per-job and per-type lock rows (`perflocale_job_lock_*` and `perflocale_type_lock_*`) whose stored expiry timestamp is in the past are deleted. Without this, every dispatched job would leave a dead lock row behind forever.

One additional recovery path runs on plugin activation (not on the daily cron):

-   **Reactivation resume.** The deactivation hook unschedules every worker event so a deactivated plugin doesn't leave cron firing against missing callbacks. The JobState rows survive deactivation, though — the operator's history shouldn't vanish. On the next activate, a one-shot `perflocale_resume_jobs` event runs the `Resumer` handler: it scans the index, re-enqueues each `queued`/`running` survivor via the configured runner, flips any `running` rows back to `queued` (without bumping `attempts` — the previous run didn't fail on its own merits), and logs "Resumed after plugin reactivation." on each job.

## WP-CLI

Full subcommand surface mirroring the REST endpoints. See [WP-CLI → Jobs](https://perflocale.com/docs/wp-cli/#jobs) for the complete reference. Quick examples:

```
# List all active jobs (queued or running)
wp perflocale jobs list

# Filter by status
wp perflocale jobs list --status=failed --format=json

# Inspect, cancel, retry, delete
wp perflocale jobs get    <uuid>
wp perflocale jobs cancel <uuid>
wp perflocale jobs retry  <uuid>
wp perflocale jobs delete <uuid>

# Operator brake
wp perflocale jobs pause
wp perflocale jobs unpause

# Manual maintenance
wp perflocale jobs gc        # Run garbage collection now
wp perflocale jobs resume    # Re-enqueue queued/running jobs (post-reactivation recovery)
```

## REST API

Five endpoints under `/wp-json/perflocale/v1/jobs/`. See [REST API → Jobs](https://perflocale.com/docs/rest-api/#jobs) for the complete reference with response shapes.

| Method & path | Capability | Notes |
| --- | --- | --- |
| GET /jobs | perflocale_translate | List active jobs. Args are redacted for non-supervisors (only your own jobs show args). |
| GET /jobs/{id} | perflocale_translate | Single job state. |
| POST /jobs/{id}/cancel | perflocale_translate + creator OR perflocale_manage_translations | Per-job mutation check: you can cancel your own jobs; supervisor cap unlocks all jobs. |
| POST /jobs/{id}/retry | same | Only on failed or canceled jobs. |
| DELETE /jobs/{id} | same | Hard delete. Also deletes the export file the job produced, if it still exists. |

## Permissions

The bg-jobs layer integrates with PerfLocale's standard capability system. Two angles:

### Who can dispatch a job

Each job type declares the cap it requires (via `get_required_capability()`). The `Dispatcher` checks `current_user_can()` against that cap before doing anything. The worker re-checks the cap at execution time against the originating user — if a user's role was downgraded between dispatch and run, the job fails with "Permission revoked or dispatching user no longer has access."

The mapping is in the [job types table](#job-types) above.

### Who can cancel / retry / delete a job

The REST and CLI mutation endpoints (`cancel`, `retry`, `delete`) require BOTH:

-   `perflocale_translate` — coarse "you can touch the jobs API at all" gate.
-   You must either be the user who originally dispatched the job, OR hold the `perflocale_manage_translations` supervisor cap.

This prevents a translator who can dispatch their own string-scan from cancelling another translator's in-flight migration. Supervisors and administrators (who naturally have `perflocale_manage_translations`) can manage any job.

See the [Permissions & Roles](https://perflocale.com/docs/permissions/) doc for the full cap matrix.

## Hooks

### `perflocale/jobs/threshold/<type>` (filter)

Per-type override of the Auto-mode threshold. The default for each job comes from `get_default_threshold()` on the job class (1000 for `string_scan` and `data_import`, 500 for `wpml_migration` / `polylang_migration`, 200 for `translatepress_migration`, 5000 for `data_export`, 50 for `bulk_string_translate`, 1 for `site_translate`, and 25 for `bulk_translate` — counted as _source posts × target languages_).

```php
// Force string scans to ALWAYS run async, regardless of file count.
add_filter( 'perflocale/jobs/threshold/string_scan', static fn(): int => 0 );

// Per-args dynamic threshold for the data importer: bigger files
// queue, small ones stay inline.
add_filter( 'perflocale/jobs/threshold/data_import', static function ( int $base, array $args ): int {
	$file = $args['file_path'] ?? '';
	return file_exists( $file ) && filesize( $file ) > 5 * MB_IN_BYTES ? 0 : 999999;
}, 10, 2 );
```

**Args:** `int $base` (resolved from settings or default), `array $args` (the dispatch args).  
**Returns:** int. When 0, every dispatch goes async. When PHP\_INT\_MAX, every dispatch goes sync.

### `perflocale/jobs/max_attempts` (filter)

Maximum retry count before a failed job stops being rescheduled. Default 5 (initial attempt + 4 retries).

```
// One-shot only — never retry.
add_filter( 'perflocale/jobs/max_attempts', static fn(): int => 1 );
```

**Args:** `int $max`.  
**Returns:** int.

### `perflocale/jobs/retry_delay` (filter)

Seconds to wait before the next retry attempt. Default: exponential backoff capped at 1 hour — `min( 3600, 60 * 2^(attempts-1) )`.

```
// Linear backoff: 60, 120, 180, ...
add_filter( 'perflocale/jobs/retry_delay', static function ( int $delay, int $attempts ): int {
	return 60 * max( 1, $attempts );
}, 10, 2 );
```

**Args:** `int $delay` (default), `int $attempts` (current attempt count).  
**Returns:** int (seconds).

### `perflocale/jobs/max_concurrent/<type>` (filter)

Per-type serialisation switch. Default 1 — only one worker of each type runs at a time, enforced by a per-type lock. In practice it behaves as a boolean rather than a counter: any value **above 1 removes the lock entirely** instead of capping parallelism at that number, so use it only where a job type is safe to run unbounded in parallel.

```
// Let string scans run in parallel (drops the per-type lock).
add_filter( 'perflocale/jobs/max_concurrent/string_scan', static fn(): int => PHP_INT_MAX );

// Same for data_export.
add_filter( 'perflocale/jobs/max_concurrent/data_export', static fn(): int => PHP_INT_MAX );
```

**Args:** `int $max` (default 1).  
**Returns:** int. Values ≤ 1 keep the lock; anything > 1 removes it.

### `perflocale/jobs/deduplicate_admission` (filter)

Whether an identical job that is still queued or running blocks a new dispatch. Default `true`. A double-clicked button, a retried request or the same `--async` command run twice returns the job already in flight (its ID, with `duplicate => true`) rather than queueing a second one.

“Identical” is the logical operation, not the type: same job type, byte-identical arguments, same site. Unrelated work of the same type still runs in parallel, and the chunked site-translation chain — whose cursor advances on every link — is never mistaken for a repeat of itself. This matters most if you have raised [`max_concurrent`](#filter-jobs-max-concurrent) for a type: with overlap enabled, two identical translation jobs call the provider twice for every string and you pay twice.

```
// Let a custom job type be dispatched repeatedly with the same args.
add_filter( 'perflocale/jobs/deduplicate_admission', static function ( bool $on, string $type ): bool {
	return 'my_addon_reindex' === $type ? false : $on;
}, 10, 2 );
```

**Args:** `bool $enabled`, `string $type`, `array $args`.  
**Returns:** bool.

### `perflocale/jobs/max_args_bytes` (filter)

Maximum JSON-encoded size of the `args` payload accepted by `Dispatcher::enqueue()`. Default 100 KB. Caller-side hardening against bloating the `args` LONGTEXT column.

```
// Bump to 500 KB for custom jobs that legitimately carry larger payloads.
add_filter( 'perflocale/jobs/max_args_bytes', static fn(): int => 500 * 1024 );
```

**Args:** `int $bytes`.  
**Returns:** int. Clamped to a minimum of 1024 bytes.

### `perflocale/jobs/active_index_max` (filter)

Cap on how many rows appear on **PerfLocale → Jobs** at once. Default 50. The cap drives a `LIMIT` on the listing query plus a daily-GC eviction pass: oldest _terminal_ rows (complete/failed/canceled) are evicted first; queued and running rows are never evicted. Each row is ~1 KB on disk (mostly the LONGTEXT log + result columns) so even a cap of 250 stays well under typical InnoDB row-overhead noise.

```
// High-throughput site that dispatches hundreds of jobs/day.
add_filter( 'perflocale/jobs/active_index_max', static fn(): int => 250 );
```

**Args:** `int $max`.  
**Returns:** int (floor-clamped to 10).

### Per-job throughput filters

The unified dispatch decision (sync vs async, retry count, threshold) is the same for every job, but each job has its own internal batch size that controls throughput once it's running. These are independent of the bg-jobs threshold filters and live with the job's underlying class. All clamped to safe ranges so a filter typo can't break the operation.

| Filter | Default | Range | Affects |
| --- | --- | --- | --- |
| perflocale/import/max_file_bytes | 50 MB | ≥1 MB | data import upload cap |
| perflocale/export/batch_size | 1000 rows | 50–10000 | data export (DB LIMIT per chunk) |
| perflocale/migration/translatepress/batch_size | 50 posts | 5–500 | TranslatePress migration (posts per txn) |
| perflocale/migration/wpml/batch_size | 100 trids | 10–1000 | WPML migration (translation groups fetched per SELECT) |
| perflocale/migration/polylang/batch_size | 100 terms | 10–1000 | Polylang migration (taxonomy terms fetched per SELECT) |
| perflocale/strings/scanner/batch_size | 500 strings | 50–5000 | string scan (DB flush every N strings) |
| perflocale/strings/scanner/max_file_bytes | 2 MB | ≥64 KB | string scan (skip-large-file threshold) |
| perflocale/jobs/active_index_max | 50 rows | ≥10 | Jobs admin page row cap |

### `perflocale/jobs/stuck_timeout_seconds` (filter)

How long a job may sit in `queued` or `running` without its `updated_at` being bumped before the daily GC declares it stuck and marks it failed. Default 6 hours.

```
// More aggressive sweep — 1 hour.
add_filter( 'perflocale/jobs/stuck_timeout_seconds', static fn(): int => HOUR_IN_SECONDS );
```

**Args:** `int $seconds`.  
**Returns:** int.

### `perflocale/jobs/pause_recheck_seconds` (filter)

When the queue is paused, a worker that picks up a job immediately re-schedules it for this many seconds later instead of running. Default 300 (5 minutes). Lower values poll the pause flag more often at the cost of more cron / AS churn.

```php
add_filter( 'perflocale/jobs/pause_recheck_seconds', static fn(): int => 60 );
```

**Args:** `int $seconds`.  
**Returns:** int.

### `perflocale/jobs/type_busy_retry_seconds` (filter)

When the per-type concurrency lock is held by another worker, the new attempt re-queues this many seconds later. Default 60.

```php
add_filter( 'perflocale/jobs/type_busy_retry_seconds', static fn(): int => 10 );
```

**Args:** `int $seconds`.  
**Returns:** int.

### `perflocale/jobs/runner` (filter)

Globally override the runner instance. Mostly for tests and custom deployments backed by an external queue (Sidekiq, SQS, etc.). Returning a `JobRunnerInterface` instance from this filter bypasses the engine setting and Action Scheduler detection.

```php
add_filter( 'perflocale/jobs/runner', static function ( $default ) {
	return new MyCustomQueueRunner();
}, 99 );
```

**Args:** `JobRunnerInterface|null $override`.  
**Returns:** `JobRunnerInterface|null`.

### `perflocale/jobs/enqueued` (action)

Fires after a job has been successfully enqueued for async execution. Useful for hooking external monitoring / observability.

```php
add_action( 'perflocale/jobs/enqueued', static function ( string $job_id, string $type, string $engine, array $args ): void {
	error_log( "PerfLocale enqueued $type job $job_id on $engine" );
}, 10, 4 );
```

**Args:**

-   `string $job_id` — UUID v4 of the new job.
-   `string $type` — job type slug (e.g. `string_scan`).
-   `string $engine` — runner engine: `action_scheduler` or `wp_cron`.
-   `array $args` — the dispatch args.

### `perflocale/jobs/completed` (action)

Fires after a worker finishes a job successfully.

```php
add_action( 'perflocale/jobs/completed', static function ( string $job_id, string $type, array $result ): void {
	// Push to your metrics pipeline...
}, 10, 3 );
```

**Args:** `string $job_id`, `string $type`, `array $result` (worker's return value, already stored on the job row, truncated to MAX\_RESULT\_BYTES = 64 KB).

### `perflocale/jobs/failed` (action)

Fires when a worker throws an exception and the retry-with-backoff is about to be scheduled (or skipped because the attempt cap has been hit).

```php
add_action( 'perflocale/jobs/failed', static function ( string $job_id, string $type, \Throwable $e ): void {
	// Send to Sentry / Bugsnag / etc.
	sentry_capture_exception( $e, [
		'tags' => [ 'perflocale_job_id' => $job_id, 'perflocale_job_type' => $type ],
	] );
}, 10, 3 );
```

**Args:** `string $job_id`, `string $type`, `\Throwable $e` (the original exception, untruncated — useful for monitoring; the version stored on the job row is path-redacted and truncated).

### `perflocale/jobs/canceled` (action)

Fires when a long-running worker cooperatively aborts itself in response to an operator cancel mid-flight (distinct from `failed` because it isn't an error). Useful for distinguishing operator-canceled from worker-errored in monitoring dashboards.

```php
add_action( 'perflocale/jobs/canceled', static function ( string $job_id, string $type ): void {
	// Record cancellation without alerting...
}, 10, 2 );
```

**Args:** `string $job_id`, `string $type`.

### `perflocale/strings/after_scan` (action)

Fires after the all-mode string scan finishes iterating every theme + plugin directory. Addons hook here to register their non-gettext strings (e.g. attribute labels, email subjects).

```php
add_action( 'perflocale/strings/after_scan', static function (): void {
	// Register any strings your code generates dynamically...
} );
```

## Multisite

The bg-jobs system is fully per-blog. On both subdir and subdomain installs, each subsite:

-   Keeps its rows in a per-subsite table — `$wpdb->prefix` resolves to the blog's own prefix, so `wp_2_perflocale_jobs` and so on — with the dispatching `blog_id` also stored as a column for cross-blog worker handoffs.
-   Sees and manages only its own queue via the Jobs admin page and CLI (`wp perflocale jobs list --url=<blog>`).
-   Has its own settings (processing mode, engine, thresholds, paused flag) stored as per-subsite options.
-   Runs its own GC + Resumer (per-blog daily cron + per-blog activation handler).

Network activation iterates all sites in chunks of 100 (filterable via `perflocale/activation/chunk_size`) and runs `Activator::activate()` per blog. The `wp_initialize_site` hook auto-provisions a brand-new subsite — it runs `Activator::activate()` (all nine `perflocale_*` tables) plus `Bootstrap::ensure_recurring_schedules()`, so a subsite that is never opened in wp-admin still gets its GC + watchdog crons. The `wp_uninitialize_site` hook (priority 5, ahead of core’s own table-dropper at priority 10) runs `SiteCleanup::purge_current_site()` when a subsite is permanently deleted — and it **forces a full purge regardless of the `delete_data_on_uninstall` setting**, because blog deletion is irreversible and the plugin’s per-blog tables would just be orphans. That is the one place the “keep my data” preference does not apply; plugin uninstall (`uninstall.php`) does respect each subsite’s own setting.

## Performance

The dedicated jobs table is touched only by the admin Jobs page, the daily GC + stuck-job watchdog crons, and the worker code paths. **No code path on a public pageview ever queries it.** Loading the homepage, a single post, or a category archive runs zero queries against `perflocale_jobs` regardless of how many jobs are queued.

The two short-lived lock options (`perflocale_job_lock_*` and `perflocale_type_lock_*`) live in `wp_options` with `autoload=no`, so even on the small subset of admin requests that exercise the locking path, the rows are not pulled into the alloptions blob.

Row size on the jobs table is dominated by the `args`, `result`, and `log` LONGTEXT columns; the typed scalar columns (status, attempts, timestamps, indexes) are ~150 bytes. A typical 50-row backlog occupies well under 1 MB on disk.

## Action Scheduler integration

When Action Scheduler ≥ 3.4 is loaded (WooCommerce ships it; the standalone [Action Scheduler plugin](https://wordpress.org/plugins/action-scheduler/) works too), PerfLocale enqueues async jobs via `as_enqueue_async_action()` / `as_schedule_single_action()` in the `perflocale` group. The Jobs admin page shows a link to **Tools → Scheduled Actions** (filtered to that group) so you can use the full AS history UI alongside the PerfLocale-native view.

On sites without AS, the runner falls back to `wp_schedule_single_event()`. Same code path, same retry semantics, same UI; just a different scheduler.

## Concurrency safety & self-healing

Workers can run in parallel under Action Scheduler. PerfLocale guarantees correctness under contention without relying on the operator to size cron concurrency:

-   **Atomic per-job + per-type locks.** Backed by raw `INSERT IGNORE INTO wp_options` via the InnoDB UNIQUE constraint — not `add_option()`, which is not atomic across concurrent forks. Token-guarded release prevents a hung holder's late `release()` from deleting another worker's lock row after a TTL takeover. Verified at 1000-way parallelism.
-   **Per-(source, target) translation locks.** Two concurrent `create_translation()` calls for the same post + language serialise at the plugin layer instead of relying on the database's UNIQUE KEY to catch one of two already-inserted `wp_posts` rows, so no orphan post is left behind.
-   **Self-healing worker re-schedule.** When a worker finds the per-job lock already held (typically a leaked row from a crashed sibling), instead of silently returning it **re-queues itself** with exponential backoff + jitter, then marks the job failed after 20 retries with an actionable diagnostic. Cap configurable via [`perflocale/jobs/lock_busy_max_retries`](https://perflocale.com/docs/hooks/#perflocale-jobs-lock-busy-max-retries).
-   **Per-provider circuit breakers.** Every MT provider call is wrapped in a breaker that trips on the first auth error and after five transient errors in a five-minute window, so a bad key or a provider outage can't burn the monthly character budget one retry at a time. Site Health surfaces the breaker state.
-   **Multisite isolation.** Per-blog locks live in `wp_<id>_options` — same lock NAME on two subsites doesn't collide. `switch_to_blog()` mid-request is honoured. Verified at 100-blog scale.

See the [Reliability & Circuit Breakers](https://perflocale.com/docs/hooks/#reliability-breakers) section of the hooks reference for all tuning filters.

## See also

-   [WP-CLI: `wp perflocale jobs` reference](https://perflocale.com/docs/wp-cli/#jobs)
-   [REST API: Jobs endpoints](https://perflocale.com/docs/rest-api/#jobs)
-   [Hooks reference: Background Jobs section](https://perflocale.com/docs/hooks/#background-jobs)
-   [Permissions & Roles](https://perflocale.com/docs/permissions/)
-   [Features: Performance → Background Processing](https://perflocale.com/features/performance/#background-processing)
