Created April 19, 2026. Updated April 20, 2026 to reflect the 3.0.0 pre-release performance pass (widget and Users-list transient caches, Event_Recorder write buffering, batched event-log prune).
WP Sudo's current sudo-session state is authoritative in user meta:
_wp_sudo_token_wp_sudo_expires_wp_sudo_failure_event_wp_sudo_throttle_until_wp_sudo_lockout_until
That remains correct and shippable for v3.0.0. The remaining performance question is not whether the current model works, but whether high-frequency reads should stay on usermeta as dashboard visibility and multi-user operations expand.
Recommendation: for a future post-3.0.0 performance phase, move toward an authoritative session table with usermeta shadow writes. That option gives WP Sudo the best read-path improvement while preserving rollback safety, compatibility with existing session logic, and a gradual migration path.
This is a follow-up architecture decision, not a v3.0.0 release blocker.
Current hot-path session reads/writes live in:
includes/class-sudo-session.phpincludes/class-gate.phpincludes/class-admin-bar.phpincludes/class-dashboard-widget.phpincludes/class-admin.phpuninstall.php- integration and E2E fixtures that directly manipulate
_wp_sudo_expires
| Store | Purpose | Current authority |
|---|---|---|
| user meta | sudo token, expiry, lockout/throttle, failed attempts | Authoritative |
| cookie | browser-bound sudo token transport | Transport/binding only |
events table (wpsudo_events) |
audit visibility and operator telemetry | Authoritative for audit data |
| Area | Current read shape | Why it matters |
|---|---|---|
Sudo_Session::is_active() / token verification |
point reads from user meta | frequent gate-path check |
Sudo_Session::is_within_grace() |
point reads from user meta | frequent near-expiry check |
| admin bar | repeated sudo-state checks while browsing admin | high-request-frequency path |
| dashboard widget active sessions | WP_User_Query meta query on _wp_sudo_expires, cached in a 30 s per-site transient (3.0.0) |
warm path is cheap; cold rebuild still meta-query-bound at large user counts |
Users screen sudo_active=1 count badge |
WP_User_Query::get_total() via _wp_sudo_expires meta query, cached in a 30 s per-site transient (3.0.0) |
warm path is cheap; filtered Users-list render still hits the meta query uncached |
| uninstall cleanup | deletes _wp_sudo_* keys across users/sites |
migration/rollback concern |
Each option below was evaluated against the same criteria:
- hot-path read reduction
- migration complexity
- multisite behavior
- uninstall/cleanup parity
- rollback safety
- test churn
- object-cache interaction
- compatibility with cookie/grace behavior
- impact on audit/event logic
- future usefulness for network dashboards/reporting
- Add a dedicated session table, likely shared-network style like
wpsudo_events, withsite_idretained for local views. - Session truth moves to the table.
- Existing user meta keys continue to be written as compatibility shadows during the migration period.
| Column | Purpose |
|---|---|
id |
row primary key |
site_id |
current site context |
user_id |
user owning the session |
token_hash |
hashed sudo token |
expires_at |
session expiry timestamp |
grace_until or derived grace logic |
grace handling |
lockout_until |
lockout timestamp |
throttle_until |
retry-delay timestamp |
failure_events or separate failure rows |
failed-attempt tracking |
updated_at |
diagnostics / reconciliation |
- Best reduction in high-value read-path cost.
- Clean indexed reads for:
- active session lists
- network dashboards
- cross-site operator reporting
- Safer long-term base for multisite and fleet-level tooling.
- Usermeta shadow keeps rollback simple.
- Existing cookie binding and grace logic can be preserved with minimal UX change.
- Highest write-path complexity of the three options.
- Requires temporary dual-write discipline.
- Needs migration/reconciliation logic and explicit cutover checks.
- Create session table.
- Dual-write: table + existing user meta.
- Shift read paths to prefer table, with optional fallback to meta.
- Run soak period and telemetry validation.
- Remove fallback reads later; keep shadow writes until comfortable.
- Roll back reads to user meta immediately.
- Keep usermeta shadow current during rollout so rollback is low-risk.
- Session table can remain inert without breaking existing users.
| Component | Reads move? | Writes move? | Notes |
|---|---|---|---|
Sudo_Session |
Yes | Yes | primary touchpoint |
Gate |
Indirectly | No direct change | depends on session helpers |
Admin_Bar |
Yes | No | should read through session helper only |
Dashboard_Widget |
Yes | No | active sessions should query table |
| Users filter | Yes | No | avoid meta query |
| uninstall | Yes | Yes | remove table rows + meta shadows |
| test fixtures | Yes | Yes | many fixtures need helpers instead of direct meta |
- Keep user meta authoritative.
- Maintain a dedicated table only for fast reads (active-session lists, counts, dashboards).
- Writes update both user meta and the mirror table.
- Lower behavioral risk than a full authority move.
- Can accelerate widget and users-list paths quickly.
- Leaves
Sudo_Sessioncore logic mostly unchanged at first.
- Two sources of truth in practice, even if one is nominally primary.
- Reconciliation logic is still required.
- Point-read gate paths still hit user meta, so only some hot paths improve.
- Long-term complexity can be worse than Option 1 because the mirror never becomes a complete model.
- Create mirror table.
- Update activation/session writes to keep mirror in sync.
- Move widget/users-list reads to mirror.
- Keep core gate/session logic on user meta.
- Easy to stop reading the mirror table.
- Harder to justify keeping it forever if it does not become authoritative.
| Component | Reads move? | Writes move? | Notes |
|---|---|---|---|
Sudo_Session |
Mostly no | Yes | still meta-centric |
Gate |
No | No | no major change |
Admin_Bar |
No | No | unchanged |
Dashboard_Widget |
Yes | No | main beneficiary |
| Users filter | Yes | No | main beneficiary |
| uninstall | Yes | Yes | remove mirror + meta |
| test fixtures | Some | Some | dual representation still needed |
- Stop using user meta as the session store.
- All token/expiry/lockout/throttle state lives only in the session table.
- Cleanest eventual architecture.
- No dual-write tail once complete.
- Strongest long-term query model.
- Highest migration risk.
- Weakest rollback story.
- Largest test churn.
- Most likely to break assumptions in existing helper code, fixtures, and third-party extensions that inspect
_wp_sudo_expires.
- Create session table.
- Backfill from user meta.
- Cut reads and writes over in one or two tightly coupled releases.
- Remove user meta compatibility path.
- Hardest rollback: requires reverse-sync or acceptance of session invalidation.
- Higher chance of user-visible session churn during rollout.
| Component | Reads move? | Writes move? | Notes |
|---|---|---|---|
Sudo_Session |
Yes | Yes | full rewrite point |
Gate |
Indirectly | No direct change | depends on session helpers |
Admin_Bar |
Yes | No | through helpers |
Dashboard_Widget |
Yes | No | through new query surface |
| Users filter | Yes | No | through new query surface |
| uninstall | Yes | Yes | table-only cleanup |
| test fixtures | Yes | Yes | largest churn |
These alternatives are worth documenting, but are currently lower-ranked than Options 1β3 for WP Sudo's long-term direction.
- Keep session truth in user meta.
- Continue optimizing high-frequency reads with query-shape improvements and short-lived caches.
- Defer any new session table.
- Lowest migration risk.
- Lowest test churn.
- Fastest delivery for near-term performance wins.
- Aggregate reads (active-session counts/lists) remain constrained by usermeta query behavior at larger scale.
- Multisite/network-level reporting remains harder than a table-backed model.
- Defers, rather than resolves, long-term session-read architecture limits.
- Keep user meta as authority.
- Maintain cache-backed active-session indexes/counters for dashboard and users screens.
- Can reduce read latency significantly on deployments with persistent object cache.
- Avoids immediate session-table migration complexity.
- Higher invalidation complexity; correctness depends on cache coherence.
- Uneven portability across hosts without persistent object cache.
- Adds operational fragility compared with a database-backed authoritative model.
A bounded, TTL-based variant of this option shipped in 3.0.0 for the two aggregate-read hot paths most sensitive to user-table size:
Dashboard_Widget::get_active_sessions_payload()β 30 s transientwp_sudo_active_sessions_{blog_id}.Admin::get_sudo_active_user_count()β 30 s transientwp_sudo_active_count_{blog_id}.
These use the WordPress transient API (object-cache-backed when a persistent
cache is configured, wp_options-backed otherwise). The short TTL sidesteps
the invalidation-correctness concerns above: stale reads decay within 30 s of
a session create/expire rather than requiring coherent invalidation on every
session write.
This is interim mitigation, not the long-term design. Gate-path reads
(Sudo_Session::is_active(), is_within_grace()) are untouched and still
read user meta per request. Option 1 remains the recommended direction.
This section documents when the current usermeta-authoritative design starts to strain and where each architectural option becomes necessary. The majority of WP Sudo installs are not expected to reach Tier 2 or Tier 3 β the scaling-architecture work described here is unlikely to be needed in practice. The analysis is retained so the decision is an informed one if a deployment ever approaches those thresholds.
Inflection points below are rough, not hard limits. Real thresholds depend
heavily on the presence of a persistent object cache (Redis / Memcached),
MySQL innodb_buffer_pool_size relative to wp_usermeta size, and admin
traffic shape (burst vs. steady).
| Path | Cost model |
|---|---|
Sudo_Session::is_active() per gated request |
2β5 get_user_meta() reads. WP lazy-loads all meta for the user on first call in a request, so subsequent reads in the same request are free. With a persistent object cache: one DB hit per user per cache TTL. Without: one DB hit per admin request. ~1β5 ms cold, sub-millisecond warm. |
| Admin-bar countdown | Piggybacks on the gate path's meta load β no additional DB cost. |
| Widget active-sessions panel | 30 s per-site transient. Warm hit: ~5 ms. Cold rebuild: one WP_User_Query meta-join; ~10β30 ms at < 500 users carrying _wp_sudo_* meta. |
| Users-list "Sudo Active" badge | Same cache as the widget. Filtered Users-list render on click is uncached but user-initiated, not on every page load. |
| Event log prune | Daily cron, batched 1000 rows per DELETE. No lock contention. At β€ 1 M rows in the 14-day window the full prune completes in well under a second. |
| Audit write per gated action | Buffered in-memory, bulk-inserted on shutdown. One INSERT per request regardless of event count. |
- Up to ~1,000 concurrently sudo-active users per site.
- Admin traffic up to ~200 req/s.
- Event table up to ~10 M rows in the 14-day window.
- Single site or small multisite (< 50 sites).
No architectural change required. The 30 s transient caches absorb aggregate-read cost; gate-path user-meta reads scale per-request, not per-user.
The plugin's overhead is not measurable against baseline WordPress admin at this tier. This is the expected operating envelope for essentially all production deployments.
- 1,000β10,000 sudo-active users per site.
- Admin bursts up to ~1,000 req/s.
- Event table 10 M β 100 M rows.
- Multisite 50β500 sites.
What shows cost at this tier:
wp_usermetafor sudo keys grows proportionally; even with the(meta_key, meta_value)compound index, cold rebuilds of theWP_User_Querystart taking 100+ ms.- 30-second cache-stampede windows: multiple admin users hit a cold cache simultaneously and all run the meta-query at once.
- Without a persistent object cache, every admin request incurs a
get_user_meta()DB round-trip on the gate path.
Mitigations short of Option 1:
- Persistent object cache is effectively mandatory at this tier. Flattens gate-path cost to ~0.1 ms per request.
- Cache-stampede prevention: precompute the widget payload in a cron job rather than on cache miss, so admin requests never rebuild.
- Proactive meta cleanup: a daily cron pass to
Sudo_Session::clear_session_data()for users whose_wp_sudo_expiresis more than a day past expiry. Cleanup currently runs only when the affected user next makes a request, so abandoned sessions accumulate usermeta rows indefinitely. - Longer transient TTL (30 s β 60β120 s) where operators accept coarser "active now" granularity.
- 10,000+ sudo-active users per site.
- Admin traffic β₯ 1,000 req/s sustained.
- Multisite 500+ sites with network-admin aggregate dashboards.
- Event table > 100 M rows.
What breaks at this tier:
- Gate-path reads on user meta are fundamentally per-request Γ per-user-meta-row-hydrate. Even with object cache, cold-cache reconstructions on eviction become visible as p99 latency spikes.
- Network-admin aggregation (roadmap Β§11.1) cannot be built from transient caches. A super-admin dashboard querying sudo state across every subsite's user set requires a single indexed query, not a fan-out over N usermeta scans.
- At 100 M+ event rows, prune durations grow; retention policy may need tightening or partitioning.
At this point docs/session-store-evaluation.md Option 1 (authoritative session table + usermeta shadow) stops being "the right long-term direction" and becomes load-bearing. Schema shape for that phase is sketched under "Required Code Touchpoints for a Future Session-Table Phase" below and in Option 1's Candidate schema section above. Indexing targets:
UNIQUE KEY (user_id, site_id)for upsert-on-activation (no read-modify-write window).KEY (site_id, expires_at)for the widget / Users-list active-count path (index-only scan).KEY (user_id, token_hash)for gate-path verification (single point-select).
Keep the 30 s transient pattern layered on top of the table even after Option 1 lands β table reads are fast, cached reads are free.
- Event store architecture. The only plausible future change is partitioning
wpsudo_eventsbycreated_at(monthly partitions) if a single site sustains > 500 M rows, and only if prune becomes slow. Current batched-delete is index-friendly and avoids long locks. - Cookie binding + grace window. The existing token model (token in cookie, SHA-256 hash in DB, timing-safe compare) transfers unchanged to a session table.
- Audit-event buffering. One bulk INSERT per request on
shutdownis already write-optimal for high-volume gated traffic.
| Criterion | Option 1: authoritative table + shadow | Option 2: mirror table | Option 3: full cutover |
|---|---|---|---|
| Hot-path read reduction | High | Medium | High |
| Migration complexity | Medium-high | Medium | High |
| Multisite fit | Strong | Medium | Strong |
| Uninstall parity | Strong | Strong | Medium |
| Rollback safety | Strong | Strong | Weak |
| Test churn | Medium | Low-medium | High |
| Object-cache dependence reduction | High | Medium | High |
| Cookie/grace compatibility | Strong | Strong | Medium-high |
| Audit/event integration fit | Strong | Medium | Strong |
| Network dashboard/reporting value | Strong | Medium | Strong |
Recommendation: implement authoritative session table + usermeta shadow in a future dedicated phase.
Why:
- It meaningfully improves the right reads, not just the widget.
- It keeps rollback practical.
- It supports future multisite/network operator tooling better than a mirror-only model.
- It avoids the operational risk of a hard cutover.
The 3.0.0 widget/Users-list transient caches (see Option 5 β Current partial
adoption) mitigate aggregate-read cost but do not touch the per-request
gate-path meta reads on _wp_sudo_token / _wp_sudo_expires that fire on
every gated admin request. Option 1 is still required to retire those reads
and to support network-admin cross-site session views.
- It solves only part of the problem.
- It keeps the most important gate-path reads on user meta.
- It adds reconciliation complexity without yielding a complete long-term model.
- It is too abrupt for the current plugin maturity and release cadence.
- It would force the most invasive migration and fixture churn.
- It does not buy enough additional value over Option 1 to justify the rollback risk.
- It is a valid short-term posture, but it does not materially change the long-term scaling characteristics of aggregate session reads.
- It delays, rather than solves, the architecture problem this evaluation was intended to address.
- It can be useful as an optimization layer, but is not a strong primary design because behavior varies widely by hosting/cache configuration.
- Cache invalidation complexity creates additional failure modes for security visibility features.
includes/class-sudo-session.php- token activation
- token verification
- expiry / grace reads
- lockout/throttle storage
includes/class-gate.php- indirect session-state lookups via
Sudo_Session
- indirect session-state lookups via
includes/class-admin-bar.php- active-session state display
includes/class-dashboard-widget.php- active-session listing/counts
includes/class-admin.php- Users screen
sudo_activecount and filtered list
- Users screen
includes/class-plugin.php- activation/deactivation hooks and cron/setup helpers
includes/class-upgrader.php- schema creation and migration sequencing
uninstall.php- table cleanup plus compatibility meta cleanup
- integration tests that seed
_wp_sudo_expiresdirectly - unit tests that assert direct
get_user_meta()/update_user_meta()behavior - Playwright/E2E helpers that assume usermeta-backed active sessions
- Preserve the current cookie-bound sudo behavior exactly during the first phase.
- Prefer helper methods over direct meta access in tests before the storage move.
- Keep multisite shared-table semantics aligned with
wpsudo_events. - Treat shadow writes as temporary compatibility plumbing, not permanent architecture.
For post-v3.0.0 performance work, WP Sudo should plan around Option 1: authoritative session table with usermeta shadow writes.