---
title: "Reliability & Circuit Breakers - PerfLocale - PerfLocale"
description: "Circuit breakers for every external dependency, token-guarded atomic locks, self-healing workers and Site Health checks that name the real cause."
canonical: "https://perflocale.com/docs/reliability/"
source: "https://perflocale.com/docs/reliability/"
format: "markdown"
---
# Reliability & Circuit Breakers

PerfLocale wraps every external dependency — machine-translation providers, webhook receivers, exchange-rate lookups, geo-IP lookups — in a **circuit breaker**. When a dependency starts failing, the breaker detects the pattern in seconds and stops piling retries onto the broken service. Subsequent calls return instantly with a typed exception so callers can route to a graceful-degradation path. Recovery is automatic: after a cooldown the breaker probes once; if the probe succeeds, normal traffic resumes.

This page is an operator's guide. The [hooks reference](https://perflocale.com/docs/hooks/#reliability-breakers) covers every filter; this page covers _when_ to use them, _how_ to interpret what Site Health shows, and _what to do_ when something stays broken.

## How a breaker behaves

Each breaker is a tiny state machine with three states:

-   **CLOSED** — normal operation. Calls go through. Each failure increments a counter in a sliding window (default 5 minutes).
-   **OPEN** — the counter crossed the threshold (default 5 failures in 5 min). Calls short-circuit instantly with `\PerfLocale\Concurrency\BreakerOpenException`. After the cooldown (default 5 min), the breaker promotes itself to HALF\_OPEN.
-   **HALF\_OPEN** — the next call is allowed as a probe. Success closes the breaker; failure re-opens it for another cooldown cycle.

Authentication errors (HTTP 401/403, "invalid API key") trip the breaker on the **first** hit. There's no point retrying a rotated key — it will just keep failing. Rate-limit and transient errors (HTTP 429, 5xx, network timeouts) accumulate toward the normal threshold.

## Breakers that ship by default

| Key | Wraps | Trip threshold |
| --- | --- | --- |
| mt_<provider_id> | Each MT provider (DeepL, Google, Microsoft, LibreTranslate, WP AI Client, custom) | 1 auth error, OR 5 transient errors in 5 min |
| webhook_<uuid> | Each registered webhook receiver | 1 auth error, OR 5 transient errors in 5 min |
| fx_sync | Exchange-rate sync (WooCommerce multi-currency) | 5 empty-fetch failures |
| geo_<provider_id> | Whichever geo-IP source the site wires up through perflocale/geo/providers | 5 failures in 5 min (the shared default) |

PerfLocale ships **no bundled geo-IP or exchange-rate providers**, so the `geo_*` breaker only ever appears once a site registers a provider of its own. The plugin keeps a stricter tuning profile available for geo lookups — 3 failures in a 5-minute window and a 15-minute cooldown, on the theory that a missed country lookup costs the visitor a wrong-language landing — which a custom provider can opt into with the per-key `perflocale/breaker/threshold/geo_<id>`, `perflocale/breaker/window_seconds/geo_<id>`, and `perflocale/breaker/cooldown_seconds/geo_<id>` filters.

## Site Health visibility

Open breakers appear under **Tools → Site Health** in a card titled "PerfLocale circuit breakers". Three states:

-   **Good (green)** — no active breakers. System healthy.
-   **Recommended (yellow)** — one or more breakers are in HALF\_OPEN, probing for recovery. No action needed; the breaker will close itself if the probe succeeds.
-   **Critical (red)** — one or more breakers are OPEN. The card lists each open breaker with its reason, cooldown countdown, and a one-click **Reset now** link.

The "Reset now" link does NOT wait for cooldown — it force-closes the breaker so the next call hits the upstream again. Use this after you've manually verified the upstream is healthy (rotated API key, fixed webhook receiver, etc.) and don't want to wait through the remaining cooldown. Cap-protected: only users with `manage_options` see the link; per-key CSRF nonce on each request.

### Other Site Health checks

The breaker card above is the headline reliability signal; it is one of 24 PerfLocale checks that register on every Site Health load. Five of those are worth calling out here:

`perflocale_eager_link_map` — Eager-link-map state

Reports the live byte size of the autoloaded `perflocale_eager_links_post` and `perflocale_eager_links_term` options and whether either has flipped to the `too_large` sentinel. Fails **Recommended** when a map exits the cap and link lookups fall back from `alloptions` to the per-key cascade. _What to do:_ install a persistent object cache so the cascade hits L2, or raise the cap via `perflocale/cache/eager_map_row_cap` (the byte-size cap is `perflocale/cache/eager_map_byte_cap`).

`perflocale_cron_schedule` — Background cron schedule

Verifies the watchdog, daily GC, lock-cleanup and machine-translation-usage-cleanup hooks are scheduled (or Action Scheduler is present). `DISABLE_WP_CRON` on its own is _not_ a failure — combined with a system crontab it is the recommended production setup, and PerfLocale cannot see your crontab. Instead the card asks whether the events are actually overdue: with `DISABLE_WP_CRON` set and no Action Scheduler, it fails **Critical** (“PerfLocale background jobs are not running”) only once a recurring event is more than an hour past its scheduled time, which is the observable proof that nothing is consuming the queue. If the events are current it passes and says so explicitly. A schedule that is simply absent is **Recommended**. _What to do:_ on the red verdict, check that your system cron still reaches `wp-cron.php`; otherwise install Action Scheduler (WooCommerce ships it) or set `DISABLE_WP_CRON` back to false. On the missing-schedule verdict, load wp-admin, then save any PerfLocale setting, then deactivate and reactivate.

`perflocale_stuck_translations` — Stuck translations

Counts translations sitting in `in_progress` or `pending` for more than 7 days. Fails **Recommended** when count is above zero — usually a crashed worker that left a row in flight before the lock-cleanup tier swept its lock. _What to do:_ open PerfLocale Jobs admin and retry or mark failed; `wp perflocale jobs list --status=running` surfaces wedged job IDs.

`perflocale_orphan_rows` — Orphan translation rows

Counts `translation_links` rows referencing a `wp_posts` or `wp_terms` row that has been hard-deleted bypassing trash. Fails **Recommended** when count is above zero. _What to do:_ orphans are harmless (lookup paths ignore them); they are not swept automatically — `wp perflocale health-check --fix` clears them on demand.

`perflocale_string_mode_scale` — String storage mode at scale

Only relevant in **database** string-storage mode — files mode always passes, since each language and text domain is served from an opcache-compiled file whose size doesn't touch per-request memory. Counts the translated strings held by the heaviest single language and fails **Recommended** once that count passes a threshold (default 20,000), because database mode loads a language's entire string map on the first translated string of every page request. _What to do:_ switch the string-translation mode to **Files** at _Settings → Performance_ (requires a writable uploads directory), or raise the threshold via `perflocale/site_health/string_blob_threshold`. The heaviest-language row count is cached in a 1-hour transient, so repeated _Tools → Site Health_ loads don't re-scan the `string_translations` table.

**Cost note:** `stuck_translations` and `orphan_rows` each cache their `COUNT` result in a 1-hour transient, so reloading _Tools → Site Health_ (or polling from a monitoring agent) does not repeat the scan against the `translation_links` table. First load after transient expiry pays the count; the next 59 minutes are free.

## Managing breakers from the command line

WP-CLI subcommands for ops automation and dashboards:

```
# Every currently-tracked breaker + its state
wp perflocale breakers list

# Detailed status of one breaker
wp perflocale breakers status mt_deepl

# Force-close one breaker after verifying the upstream
wp perflocale breakers reset mt_deepl

# Force-close every breaker (e.g. after a known-fixed outage)
wp perflocale breakers reset --all

# Machine-readable output for monitoring
wp perflocale breakers list --format=json | jq '.[] | select(.state=="open")'
```

The `list` command is safe to run on a per-minute cron from a monitoring host; it is a single transient/option read per breaker and touches nothing else.

## Catching `BreakerOpenException` in your own code

When you call PerfLocale's MT service from your own code (a custom translation flow, a Gutenberg block, a cron handler), the breaker can interrupt the call by throwing `\PerfLocale\Concurrency\BreakerOpenException`. The exception is a typed companion to `\RuntimeException` — catch it specifically to route to a graceful fallback, instead of conflating with a genuine downstream error.

```php
use PerfLocale\Concurrency\BreakerOpenException;

try {
	$translated = $mt_service->translate_text( $text, 'en', 'de', 'deepl' );
} catch ( BreakerOpenException $e ) {
	// Provider is currently in cooldown. The exception carries:
	//   - $e->get_breaker_key()       — e.g. "mt_deepl"
	//   - $e->getMessage()            — human-readable description (includes the retry-in seconds)
	//
	// Degrade gracefully: your own cache, a human fallback, or
	// just return the source text unchanged.
	$translated = my_cached_translation( $text ) ?? $text;
} catch ( \RuntimeException $e ) {
	// Genuine downstream error (not a breaker pre-emption).
	// Log + skip; the breaker will catch the next one if it persists.
	error_log( '[my-plugin] translation failed: ' . $e->getMessage() );
	$translated = $text;
}
```

The same pattern works for any custom code calling through `AbstractProvider::make_request()`.

## When to tune cooldowns & thresholds

Defaults are sized for the median use case. You should tune when:

-   **Your MT provider has unusually slow recovery** (4+ hour outages are common during quota resets). Raise `perflocale/breaker/cooldown_seconds/mt_<provider>` to `HOUR_IN_SECONDS` or higher so you're not probing every 5 minutes during a sustained outage.
-   **You run dozens of webhooks against the same receiver**. The default 5-failure trip might bounce too often if the receiver flaps. Either consolidate the webhooks (one PerfLocale webhook, one fan-out endpoint on your side) or raise `perflocale/breaker/threshold/webhook_<uuid>`.
-   **Your operator dashboard wants stricter breakers**. Tighten `perflocale/breaker/threshold/...` to 2 or 3 so ops gets alerted faster, paired with a monitoring agent that watches `wp perflocale breakers list`.
-   **You're debugging a flaky upstream** and want the breaker out of the way temporarily. `add_filter( 'perflocale/breaker/disabled', '__return_true' )` is the global kill-switch. Re-enable as soon as debugging is done.

## Where breaker state lives

Each breaker's state lives in a single transient: `perflocale_breaker_<key>`. When you have a persistent object cache (Redis, Memcached) configured, that's where the state actually lives — transients bypass `wp_options`. Without an object cache, transients fall back to `wp_options` rows.

A small non-autoloaded index option `perflocale_breakers_index` tracks every breaker key ever touched so the Site Health card + `wp perflocale breakers list` can enumerate them regardless of where the transient state actually lives. The index entries are cleaned up by `Breaker::reset()` and the full uninstall sweep.

**Cache eviction:** if Redis flushes under memory pressure, the breaker "forgets" its state. That's intentional — better one extra failed call than a stuck-open breaker. The breaker will re-trip on the next round of failures.

## Concurrency locks (the other half of reliability)

Breakers protect external dependencies; **locks** protect internal critical sections. PerfLocale uses two flavors:

-   `\PerfLocale\Concurrency\Lock` — general-purpose advisory lock backed by atomic `INSERT IGNORE INTO wp_options`. Used everywhere a critical section can't tolerate concurrent execution: content sync, WooCommerce inventory sync, auto-translate on save, FX sync, settings and addon-settings writes, job resume.
-   `\PerfLocale\Background\JobLock` — per-job and per-type locks for the background-jobs worker pool. Same atomic primitive; different option-key namespace.

**Token-guarded release.** Both lock types stamp a per-acquire random token in the stored value. `release()` only deletes the row when the stored value still matches what THIS request stamped — so a lock holder that hangs past TTL and gets its lock taken over by another worker can't accidentally delete the new owner's row when it finally finishes.

**Verified at scale.** The concurrency test harness (in `tools/concurrency-tests/` in the GitHub repo, excluded from the wp.org zip) runs 22+ scenarios across 3 WP installs (single-site, subdir multisite, subdomain multisite). 1000-way parallel acquire produces exactly one winner. 100-blog network activation completes cleanly with all per-blog crons scheduled. 10-way and 20-way concurrent `create_translation()` calls for the same post + language produce exactly one new `wp_posts` row, never an orphan. A caller that loses that race waits a bounded time for the winner and returns the winner’s translation instead of reporting a failure.

## Disaster-recovery idempotency (WPML / Polylang / TranslatePress importers)

The three migration importers all guarantee idempotent re-runs — including the disaster-recovery scenario where an operator restores a database backup to a pre-import state and re-runs the importer. Without that guarantee, every re-import after a restore would allocate fresh `translation_groups` rows for content that already had groups in the prior run, duplicating every linkable post and term.

The mechanism is a dedicated `perflocale_migration_source_map` table that pins a stable identifier from the source plugin to the `translation_groups` row PerfLocale created for it. Strings get the same guarantee from the `REPLACE INTO` shape of `StringTranslationRepository::set()`; the source map extends the same shape to posts and terms.

-   **Schema.** `perflocale_migration_source_map` has `UNIQUE (migration_type, source_key)` backing an `ON DUPLICATE KEY UPDATE` upsert. `migration_type` is one of `wpml`, `polylang`, or `trp`; `source_key` is a per-importer natural key (e.g. WPML uses `"<trid>|post"` / `"<trid>|term"`; Polylang uses the translation-term id; TranslatePress uses `"<post_id>|<lang_id>"`).
-   **Atomic with group creation.** The map insert lives _inside_ the same `START TRANSACTION` as the `translation_groups` insert in `TranslationGroupRepository::create_group()`. A crash here rolls back both rows together — never leaving a stale map entry pointing at a non-existent group, and never leaving an orphan group with no map entry.
-   **WPML and Polylang importers** look up `get_group_id(type, key)` _before_ calling `create_group()`. If a mapping exists, the existing group\_id is reused; otherwise `create_group()` writes both rows in one transaction.
-   **TranslatePress importer** uses a different dedup pattern because it `wp_insert_post()`s new translation posts on the destination side. The pre-check there is `get_translation_in_language()` against the `translation_links` table — if the source post already has a translation in the target language, the importer skips the `wp_insert_post()` and re-records the source-map row for cross-restart consistency.
-   **Operator escape hatch.** [wp perflocale migrate <source> --force-restart](https://perflocale.com/docs/wp-cli/#migrate) clears the source-map for one importer when the operator has deliberately restored a clean pre-migration backup and wants fresh allocations. The CLI logs the row-count cleared before the new import begins. The default (omit the flag) is the safer choice for almost every scenario.
-   **Concurrency.** The `UNIQUE (migration_type, source_key)` constraint serializes concurrent imports via MySQL’s InnoDB locking on the unique index. Two parallel importers hitting the same source key converge to one row via `ON DUPLICATE KEY UPDATE`; verified live with the regression harness at `tools/regression-tests/idempotency.php`.

Both `get_group_id()` and set/upsert are single indexed lookups, so the source map does not become the bottleneck of a large migration, and a re-run costs less than the first pass because every lookup hits. Storage footprint is about 235 bytes per row — roughly 11 MB at 50,000 rows and 112 MB at 500,000.

Migration jobs (`WpmlMigrationJob`, `PolylangMigrationJob`, `TranslatePressMigrationJob`, `DataImportJob`) all call `MigrationCacheHelper::flush_post_migration_caches()` after a successful import. This flushes the L1 static memos on `TranslationGroupRepository`, deletes the autoloaded `perflocale_eager_links_*` + `perflocale_has_any_groups` options, and flushes the L2 cache, so long-running CLI / cron workers do not serve pre-import group memos for the rest of their process lifetime.

## Transactional FK cascades

Operations that touch multiple tables in lock-step run inside a single SQL transaction so a partial failure rolls the whole change back rather than committing an inconsistent state. Two load-bearing paths use this:

-   **Language delete** — `LanguageRepository::delete()` wraps every dependent-table DELETE (translation\_links, orphan-group GC, orphaned migration\_source\_map rows, string\_translations, slug\_translations) in one `START TRANSACTION` with `ROLLBACK` on any per-table failure. A script timeout, dropped DB connection, or unrecoverable error mid-cleanup cannot leave the site with the `languages` row gone but tens of thousands of FK-orphan rows still referencing its `language_id`.
-   **Translation group linking** — `TranslationGroupRepository::link_object()` is the primitive every `create_translation()` call runs through (post, term, string, and migration paths). Its three internal `DELETE`s enforce the documented one-object-one-group invariant; on any DELETE failure the method rolls back when it owns the transaction and returns `false`. The TranslatePress importer’s batch transaction checks this return and throws so a failed link rolls the whole batch back rather than inflating the imported counter past the actual link count on disk.

Helpers that participate in those transactions (e.g. `StringTranslationRepository::delete_for_language()`) throw on DB failure rather than returning silent `0` values, so the outer cascade catches the failure and rolls back. `wp_insert_post()` callers across the migration and translation paths pass `$wp_error=true` so the `is_wp_error()` guard surfaces real DB errors instead of conflating them with the legitimate `int 0` “couldn’t insert” return.

## Uninstall and deactivation cleanup

By default, **uninstalling PerfLocale keeps your data**. The uninstall routine removes the Translator role and every `perflocale_*` capability (including direct per-user grants), and clears all scheduled WP-Cron / Action Scheduler events — but the nine `perflocale_*` tables, the settings, and every translation stay in the database, so a later re-install picks up exactly where you left off.

Full deletion is opt-in: tick **Delete all plugin data when uninstalling** under _PerfLocale → Settings → Advanced_ (the `delete_data_on_uninstall` setting, off by default) _before_ deleting the plugin. Only then does `SiteCleanup::full_purge()` run, and it cleans up everything the plugin ever wrote, including non-obvious surfaces:

-   **L2 object cache flush** — `SiteCleanup::full_purge()` iterates the canonical `CacheManager::GROUPS` list of plugin-owned cache groups and calls `wp_cache_flush_group()` on each. Without this, persistent Redis or Memcached entries can linger for up to 12 hours past uninstall and be served as ghost reads if the plugin is reinstalled in the same window.
-   **Direct-grant capability orphans** — `SiteCleanup::sweep_orphan_user_caps()` strips every `perflocale_*` key from per-user `wp_capabilities` meta. It’s called from BOTH plugin deactivation and full uninstall so direct-grant caps do not accumulate across deactivation/reactivation cycles.
-   **Per-blog isolation on multisite** — `uninstall.php` hands the network to `SiteCleanup::purge_network()`, which walks every site of every network in batches, purges each one switched in, and catches a failure per site so one broken subsite cannot strand the rest. Each site’s own `delete_data_on_uninstall` choice is read inside the sweep, and the row carrying that choice is the last thing deleted, so an interrupted purge is finished in the same mode rather than silently turning into “keep my data”. The sweep is bounded by the request’s execution budget: it stops between sites and records where it got to, and the next delete carries on from there.
-   **Importer checkpoints** — per-importer resume-checkpoint options (e.g. `perflocale_trp_import_post_checkpoint`) are included in the uninstall sweep even when an interrupted import left them behind.

## MT rate-limit: fail-CLOSED + site-wide cap

The per-user hourly ceiling on machine-translation requests is enforced inside an atomic lock so concurrent `POST /perflocale/v1/machine-translate` requests can’t both read the pre-increment count, both pass the `< limit` check, and each write `count+1` — stampeding past the cap. If a request can’t acquire the lock, it **fails CLOSED** with HTTP 429 + `Retry-After` rather than open: a request that can’t even take the lock cannot have its count incremented, so treating the miss as “allow” would defeat the cap.

The [`perflocale/mt/rate_limit_site`](https://perflocale.com/docs/hooks/#perflocale-mt-rate-limit-site) filter (default 5000) caps total MT requests per hour across every user summed together. Per-user and site counters share a single global lock so a hostile editor with the `perflocale_use_mt` capability can’t fan out parallel requests to drain the site-wide budget faster than the rate-limit check can register them.

## Data retention & garbage collection

Every plugin-owned data store has a clear bound. There are no tables, options, or meta keys that can grow forever — either the schema invariant (UNIQUE keys + cascade-on-delete from posts / terms / languages) keeps the row count proportional to content, or a layered GC system trims it on a schedule. Every retention knob is filterable.

### Strings mark-and-sweep (`perflocale_strings` & `perflocale_string_translations`)

The `perflocale_strings` table holds every translatable gettext call the string scanner has discovered across the active plugins and themes. Disable a plugin (or update one that drops a string), and those rows would otherwise linger forever — carrying with them an arbitrary number of `perflocale_string_translations` rows per active language.

The scanner stamps a `last_seen_at` timestamp on every row it re-discovers. `register_setting_string()` does the same for manually-registered strings (settings labels, addon-registered templates). A daily GC fires inside the existing `perflocale_jobs_gc` cron and deletes rows whose `last_seen_at` is older than [`perflocale/strings/stale_retention_days`](https://perflocale.com/docs/hooks/#perflocale-strings-stale-retention-days) (default 90), cascading to `perflocale_string_translations` in the same DELETE.

Two safety nets prevent over-eager eviction:

-   **90-day retention** — tolerates rare-but-real code paths (settings pages, seldom-rendered admin screens) that may only run a few times a year. Tune via the filter above.
-   **Context whitelist** — [`perflocale/strings/manual_contexts`](https://perflocale.com/docs/hooks/#perflocale-strings-manual-contexts) lists context values that the GC never deletes, no matter how stale. The default is an empty list; add your own `register_setting_string()` contexts as a hedge against the retention window expiring before the code path that registers them runs.

A separate **orphan-sweep** runs in the same daily window: a single `DELETE … LEFT JOIN` over `perflocale_string_translations` drops any row whose parent `strings.id` has been deleted by a path that bypassed the cascade (manual SQL, partial-failure recovery, future code paths that forget the cascade). Cheap when nothing to delete; emits [`perflocale/string_translations/orphans_swept`](https://perflocale.com/docs/hooks/#perflocale-string-translations-orphans-swept) when it finds work.

### Other bounded data stores

For completeness, every other plugin-owned data store and its bound:

`perflocale_translation_links` + `perflocale_translation_groups`

Bounded by content. The `before_delete_post` hook unlinks the deleted post's row; orphan groups (groups left with zero links) get swept in the same daily `perflocale_jobs_gc` via `TranslationGroupRepository::gc_empty_groups()` with a 1,000-row cap per tick.

`perflocale_slug_translations`

Bounded by content. One row per `(object_type, object_id, language_id)` — cleaned up when the post / term is deleted (cascading hooks) or the language is deleted (transactional cascade in `LanguageRepository::delete`).

`perflocale_content_hashes`

Bounded by content. One row per `(object_type, object_id)` — rehashing UPDATEs the row in place; new objects INSERT; deleted objects cascade.

`perflocale_migration_source_map`

Bounded by the source plugin's data. One row per `(migration_type, source_key)`; orphan rows whose `translation_groups` parent is gone are swept in the language-delete transaction.

`perflocale_jobs`

Status-based TTL. `JobState::gc()` deletes completed / failed / canceled jobs older than 24 h in the daily `perflocale_jobs_gc`; live jobs (`queued`, `running`) stay until they finish.

`perflocale_mt_usage_YYYY_MM` options

One per blog per month. Weekly `perflocale_mt_usage_gc` drops anything older than 13 months (only the current month and 12 prior are read by the admin UI).

`perflocale_breaker_*`, `perflocale_*_lock_*`, `perflocale_addon_*` options

Breaker rows are bounded by the small set of named breakers (5–10 typically). Lock rows are TTL-based with daily `Lock::reap_expired()`. Addon settings + disabled-addon lists are byte-capped (16 KiB and 4 KiB respectively).

## Self-healing worker re-schedule

When a background worker finds its per-job lock already held (typically a leaked row from a crashed sibling worker that never released), it **re-queues itself** with exponential backoff + jitter instead of silently returning. The retry chain is capped at 20 attempts; on cap exhaustion, the job is marked failed with an actionable diagnostic message pointing at the wedged `perflocale_job_lock_<id>` row.

Configure via [`perflocale/jobs/lock_busy_max_retries`](https://perflocale.com/docs/hooks/#perflocale-jobs-lock-busy-max-retries) (default 20) and [`perflocale/jobs/lock_busy_max_seconds`](https://perflocale.com/docs/hooks/#perflocale-jobs-lock-busy-max-seconds) (default 600s).

## Troubleshooting

The Site Health card shows a breaker as OPEN and it won't close

1.  Click the "Reset now" link — this force-closes the breaker without waiting for cooldown.
2.  If the breaker re-trips immediately, the upstream is genuinely failing. Check the PHP error log for lines tagged with the breaker's reason (`[auth]`, `[rate_limit]`, `[transient]`).
3.  For MT providers: verify the API key is valid and the monthly quota isn't exhausted. For webhooks: hit the URL with `curl` from the WP host; if it doesn't respond, the receiver is the problem. For FX/geo: check the provider's status page.

I never see any breakers tripped — is the system working?

That's the desired steady state. Run `wp perflocale breakers list` to confirm: an empty list means every external call is succeeding. To verify the subsystem is actually loaded, plant a test trip: `wp eval '\PerfLocale\Concurrency\Breaker::record_failure( "test", "auth", 1 );'` then check `wp perflocale breakers list` — you should see a row.

My background job is stuck. How do I diagnose?

The _PerfLocale → Jobs_ admin page shows current status + a per-job log. Look for entries like "_Per-job lock held by another worker; deferred by Ns (retry K/20)_" — that means a sibling worker is stuck. After 20 retries the job will auto-mark-failed with a pointer to the wedged lock row. Manually clear it with: `wp eval 'global $wpdb; $wpdb->delete( $wpdb->options, ["option_name" => "perflocale_job_lock_<the-job-id>"] );'`

Can I disable the entire reliability layer?

`add_filter( 'perflocale/breaker/disabled', '__return_true' )` kills all breakers (calls go straight through to the upstream, no short-circuit). Locks can't be disabled — they're load-bearing for correctness, not optional safety. If you're hitting lock contention, the right answer is to raise the type-busy or job-busy retry cap, not disable.

## Related

-   [Hooks reference: Reliability & Circuit Breakers](https://perflocale.com/docs/hooks/#reliability-breakers) — every filter
-   [Background Jobs: Concurrency safety](https://perflocale.com/docs/background-jobs/#concurrency-safety)
-   [Machine Translation: Reliability under provider failure](https://perflocale.com/docs/machine-translation/#reliability)
-   [WP-CLI commands](https://perflocale.com/docs/wp-cli/)

[← Back to Docs](https://perflocale.com/docs/)
