Commit fedbc15d5c8 for woocommerce
commit fedbc15d5c8f8bf4a1f0ced99838fb5fefc67f8c
Author: Alba Rincón <albarin@users.noreply.github.com>
Date: Wed May 13 15:42:27 2026 +0200
Add OPcache-backed caching for parsed GraphQL queries (#64596)
* Add OPcache-backed caching for parsed GraphQL queries
* Add changefile(s) from automation for the following project(s): woocommerce
* Address lint warnings in QueryCacheTest
* Replace error suppression with WP_Filesystem in QueryCache
* Use WP_Filesystem methods for is_writable and file deletion
* Reject stream wrappers in OPcache cache directory filter
* Make OPcache fallback test deterministic across runners
* Always write APQ registrations to the object cache
* Silence opcache_get_status() warning under opcache.restrict_api
* Treat malformed object-cache entries as a cache miss
* Fix catch binding and test fixture alignment
* Reject malformed APQ hashes to prevent path traversal
* Fix alignment of test fixture assignments
* Add scheduled cleanup for expired OPcache cache files
* Fix lint warnings in OpcacheFileExpiry and its test
* Reuse QueryCache::get_cache_ttl() for OPcache file expiry
* Extract duplicated wp_filesystem helper into Utils
* Cache ensure_scheduled() result to avoid per-write DB lookup
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/64596-64166-graphql-opcache-cache b/plugins/woocommerce/changelog/64596-64166-graphql-opcache-cache
new file mode 100644
index 00000000000..104bec7754b
--- /dev/null
+++ b/plugins/woocommerce/changelog/64596-64166-graphql-opcache-cache
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add OPcache-backed caching for parsed GraphQL queries, with fallback to the WP object cache.
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/Main.php b/plugins/woocommerce/src/Internal/Api/Main.php
index 92303abdbd5..8a249e7253c 100644
--- a/plugins/woocommerce/src/Internal/Api/Main.php
+++ b/plugins/woocommerce/src/Internal/Api/Main.php
@@ -57,6 +57,14 @@ class Main {
*/
public const OPTION_MAX_QUERY_COMPLEXITY = 'woocommerce_graphql_max_query_complexity';
+ /**
+ * Option name for the "OPcache-based caching" setting.
+ *
+ * When enabled, parsed query ASTs are written to disk as PHP files so
+ * that OPcache serves them from shared memory on subsequent requests.
+ */
+ public const OPTION_OPCACHE_ENABLED = 'woocommerce_graphql_opcache_enabled';
+
/**
* Option name for the "ObjectCache-based caching" setting.
*/
@@ -103,6 +111,17 @@ class Main {
return wc_string_to_bool( get_option( self::OPTION_APQ_ENABLED, 'yes' ) );
}
+ /**
+ * Whether the OPcache-backed query cache is enabled.
+ *
+ * Defaults to true. Activation also depends on OPcache being loaded and
+ * the cache directory being writable; see {@see QueryCache} for the
+ * runtime capability check.
+ */
+ public static function is_opcache_enabled(): bool {
+ return wc_string_to_bool( get_option( self::OPTION_OPCACHE_ENABLED, 'yes' ) );
+ }
+
/**
* Whether the ObjectCache-backed query cache is enabled.
*
@@ -154,6 +173,10 @@ class Main {
$settings = wc_get_container()->get( Settings::class );
$settings->register();
+
+ if ( self::is_enabled() ) {
+ add_action( OpcacheFileExpiry::ACTION_HOOK, array( OpcacheFileExpiry::class, 'handle_cleanup_action' ) );
+ }
}
/**
diff --git a/plugins/woocommerce/src/Internal/Api/OpcacheFileExpiry.php b/plugins/woocommerce/src/Internal/Api/OpcacheFileExpiry.php
new file mode 100644
index 00000000000..228933fbaa8
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/OpcacheFileExpiry.php
@@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api;
+
+/**
+ * Deletes expired OPcache cache files via Action Scheduler.
+ */
+class OpcacheFileExpiry {
+
+ /**
+ * Action Scheduler hook name for the cleanup job.
+ */
+ public const ACTION_HOOK = 'woocommerce_graphql_opcache_cleanup';
+
+ /**
+ * Action Scheduler group for the cleanup job.
+ */
+ public const ACTION_GROUP = 'woocommerce-graphql';
+
+ /**
+ * Object-cache key used to short-circuit {@see self::ensure_scheduled()}.
+ */
+ private const SCHEDULED_CACHE_KEY = 'graphql_opcache_cleanup_scheduled';
+
+ /**
+ * Delete OPcache cache files older than {@see QueryCache::get_cache_ttl()}.
+ *
+ * AST contents are a pure function of the query, so this is a disk-usage
+ * bound, not a correctness concern. Returns the count.
+ */
+ public static function delete_expired_files(): int {
+ $dir = QueryCache::get_opcache_cache_dir();
+ if ( '' === $dir || ! is_dir( $dir ) ) {
+ return 0;
+ }
+
+ $fs = Utils::wp_filesystem();
+ if ( ! $fs ) {
+ return 0;
+ }
+
+ $files = glob( $dir . '/*.php' );
+ if ( false === $files ) {
+ return 0;
+ }
+
+ $cutoff = time() - QueryCache::get_cache_ttl();
+ $count = 0;
+ foreach ( $files as $path ) {
+ $mtime = $fs->mtime( $path );
+ if ( false !== $mtime && $mtime < $cutoff && $fs->delete( $path ) ) {
+ ++$count;
+ }
+ }
+
+ return $count;
+ }
+
+ /**
+ * Action Scheduler callback: delete expired files and reschedule.
+ *
+ * Immediate reschedule when files were deleted (drain the backlog), 24h
+ * otherwise.
+ *
+ * @internal
+ */
+ public static function handle_cleanup_action(): void {
+ $interval = self::delete_expired_files() > 0 ? 1 : DAY_IN_SECONDS;
+
+ if ( function_exists( 'as_schedule_single_action' ) ) {
+ as_schedule_single_action( time() + $interval, self::ACTION_HOOK, array(), self::ACTION_GROUP );
+ }
+ }
+
+ /**
+ * Schedule the cleanup if it isn't already scheduled.
+ *
+ * Called from {@see QueryCache::write_to_opcache()} so the first run is
+ * triggered by the first write — no separate bootstrap step.
+ */
+ public static function ensure_scheduled(): void {
+ if ( wp_cache_get( self::SCHEDULED_CACHE_KEY, QueryCache::CACHE_GROUP ) ) {
+ return;
+ }
+
+ if ( ! function_exists( 'as_has_scheduled_action' ) || ! function_exists( 'as_schedule_single_action' ) ) {
+ return;
+ }
+
+ if ( ! as_has_scheduled_action( self::ACTION_HOOK, array(), self::ACTION_GROUP ) ) {
+ as_schedule_single_action( time() + DAY_IN_SECONDS, self::ACTION_HOOK, array(), self::ACTION_GROUP );
+ }
+
+ wp_cache_set( self::SCHEDULED_CACHE_KEY, true, QueryCache::CACHE_GROUP, HOUR_IN_SECONDS );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Api/QueryCache.php b/plugins/woocommerce/src/Internal/Api/QueryCache.php
index f92c8fd22df..72805547aad 100644
--- a/plugins/woocommerce/src/Internal/Api/QueryCache.php
+++ b/plugins/woocommerce/src/Internal/Api/QueryCache.php
@@ -9,14 +9,19 @@ use Automattic\WooCommerce\Vendor\GraphQL\Language\Parser;
use Automattic\WooCommerce\Vendor\GraphQL\Utils\AST;
/**
- * Caches parsed GraphQL ASTs in the WP object cache and implements the
- * Apollo Automatic Persisted Queries (APQ) protocol.
+ * Caches parsed GraphQL ASTs and implements the Apollo Automatic Persisted
+ * Queries (APQ) protocol.
+ *
+ * Two backends are supported. OPcache (filesystem) is preferred: parsed ASTs
+ * are written as PHP files so that OPcache serves them from shared memory.
+ * The WP object cache is used as a fallback when OPcache isn't available or
+ * the cache directory isn't writable.
*/
class QueryCache {
/**
* WP object-cache group.
*/
- private const CACHE_GROUP = 'wc-graphql';
+ public const CACHE_GROUP = 'wc-graphql';
/**
* Cache key prefix. Includes the library major version so that upgrading
@@ -26,6 +31,20 @@ class QueryCache {
*/
private const CACHE_KEY_PREFIX = 'graphql_ast_v15_';
+ /**
+ * Subdirectory (under wp-uploads) for the OPcache-backed file cache.
+ * The version segment matches {@see self::CACHE_KEY_PREFIX} so a major
+ * webonyx upgrade naturally orphans the previous version's files.
+ */
+ private const OPCACHE_DIR_RELATIVE = 'wc-graphql-cache/v15';
+
+ /**
+ * Cached result of {@see self::is_opcache_usable()} for the current request.
+ *
+ * @var ?bool
+ */
+ private ?bool $opcache_usable = null;
+
/**
* Default time-to-live (in seconds) applied when the option is unset or non-positive.
*
@@ -62,7 +81,7 @@ class QueryCache {
&& is_array( $apq )
&& 1 === ( $apq['version'] ?? null )
&& is_string( $apq_hash )
- && '' !== $apq_hash ) {
+ && 1 === preg_match( '/^[a-f0-9]{64}$/', $apq_hash ) ) {
return $this->resolve_apq( $query, $apq_hash );
}
@@ -72,7 +91,7 @@ class QueryCache {
}
// APQ keeps using the cache; it has its own settings toggle.
- if ( ! Main::is_object_cache_enabled() ) {
+ if ( ! $this->is_caching_enabled() ) {
return $this->parse( $query );
}
@@ -102,16 +121,16 @@ class QueryCache {
);
}
- $doc = $this->get_cached_document( $apq_hash );
+ $doc = $this->get_cached_document( $apq_hash, true );
if ( false !== $doc ) {
return $doc;
}
- return $this->parse_and_cache( $query, $apq_hash );
+ return $this->parse_and_cache( $query, $apq_hash, true );
}
// Hash-only lookup.
- $doc = $this->get_cached_document( $apq_hash );
+ $doc = $this->get_cached_document( $apq_hash, true );
if ( false !== $doc ) {
return $doc;
}
@@ -119,19 +138,49 @@ class QueryCache {
return $this->error_response( 'PersistedQueryNotFound', 'PERSISTED_QUERY_NOT_FOUND' );
}
+ /**
+ * Whether at least one cache backend is enabled (and, for OPcache, usable).
+ *
+ * Used to short-circuit the standard-query path when neither backend is
+ * available, so the request is parsed once with no cache lookup overhead.
+ */
+ private function is_caching_enabled(): bool {
+ return ( Main::is_opcache_enabled() && $this->is_opcache_usable() )
+ || Main::is_object_cache_enabled();
+ }
+
/**
* Retrieve a cached DocumentNode by hash.
*
- * @param string $hash The SHA-256 hash.
+ * Tries OPcache first when enabled and usable, then falls back to the
+ * WP object cache. APQ requests pass $for_apq=true so the object cache
+ * is consulted regardless of the standard-query toggle, matching the
+ * pre-OPcache behaviour where APQ always persisted via the object cache.
+ *
+ * @param string $hash The SHA-256 hash.
+ * @param bool $for_apq Whether the lookup is for an APQ request.
* @return DocumentNode|false
*/
- private function get_cached_document( string $hash ) {
- $cached = wp_cache_get( $this->build_cache_key( $hash ), self::CACHE_GROUP );
- if ( false === $cached || ! is_array( $cached ) ) {
- return false;
+ private function get_cached_document( string $hash, bool $for_apq = false ) {
+ if ( Main::is_opcache_enabled() && $this->is_opcache_usable() ) {
+ $doc = $this->read_from_opcache( $hash );
+ if ( false !== $doc ) {
+ return $doc;
+ }
+ }
+
+ if ( $for_apq || Main::is_object_cache_enabled() ) {
+ $cached = wp_cache_get( $this->build_cache_key( $hash ), self::CACHE_GROUP );
+ if ( is_array( $cached ) ) {
+ try {
+ return AST::fromArray( $cached );
+ } catch ( \Throwable $e ) {
+ return false;
+ }
+ }
}
- return AST::fromArray( $cached );
+ return false;
}
/**
@@ -152,19 +201,32 @@ class QueryCache {
/**
* Parse a query, cache the resulting AST, and return the DocumentNode.
*
+ * Writes to OPcache when enabled and usable. APQ registrations always
+ * also write to the object cache so hash-only lookups still resolve if
+ * OPcache later becomes unavailable (toggle off, dir unwritable, files
+ * cleaned up, or a silent write_to_opcache failure).
+ *
* Returns an error array if the query has a syntax error.
*
- * @param string $query The GraphQL query string.
- * @param string $hash The SHA-256 hash to cache under.
+ * @param string $query The GraphQL query string.
+ * @param string $hash The SHA-256 hash to cache under.
+ * @param bool $for_apq Whether the request is an APQ registration.
* @return DocumentNode|array
*/
- private function parse_and_cache( string $query, string $hash ) {
+ private function parse_and_cache( string $query, string $hash, bool $for_apq = false ) {
$document = $this->parse( $query );
if ( ! $document instanceof DocumentNode ) {
return $document;
}
- wp_cache_set( $this->build_cache_key( $hash ), $document->toArray(), self::CACHE_GROUP, self::get_cache_ttl() );
+ $used_opcache = Main::is_opcache_enabled() && $this->is_opcache_usable();
+ if ( $used_opcache ) {
+ $this->write_to_opcache( $hash, $document );
+ }
+
+ if ( $for_apq || ( Main::is_object_cache_enabled() && ! $used_opcache ) ) {
+ wp_cache_set( $this->build_cache_key( $hash ), $document->toArray(), self::CACHE_GROUP, self::get_cache_ttl() );
+ }
return $document;
}
@@ -179,6 +241,184 @@ class QueryCache {
return self::CACHE_KEY_PREFIX . $hash;
}
+ /**
+ * Whether the OPcache file backend can be used for this request.
+ *
+ * Memoised per request: the underlying checks (opcache_get_status,
+ * filesystem writability) don't change mid-request and are wasteful
+ * to repeat across the read and write paths.
+ */
+ private function is_opcache_usable(): bool {
+ if ( null !== $this->opcache_usable ) {
+ return $this->opcache_usable;
+ }
+
+ $this->opcache_usable = $this->compute_is_opcache_usable();
+ return $this->opcache_usable;
+ }
+
+ /**
+ * Underlying capability check for the OPcache file backend.
+ *
+ * Requires the OPcache extension to be loaded and enabled, and the cache
+ * directory to exist (or be creatable) and be writable.
+ */
+ private function compute_is_opcache_usable(): bool {
+ if ( ! function_exists( 'opcache_get_status' ) || ! ini_get( 'opcache.enable' ) ) {
+ return false;
+ }
+
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- opcache.restrict_api raises E_WARNING when the calling path is disallowed; the false return is handled below.
+ $status = @opcache_get_status( false );
+ if ( ! is_array( $status ) || empty( $status['opcache_enabled'] ) ) {
+ return false;
+ }
+
+ return $this->ensure_opcache_dir_writable();
+ }
+
+ /**
+ * Resolve the directory where OPcache cache files are written.
+ *
+ * Defaults to a versioned subdirectory under wp-uploads so it inherits
+ * the writability guarantees WordPress places on uploads. Filterable
+ * for tests and unusual hosting layouts.
+ *
+ * @internal Public for {@see OpcacheFileExpiry}; not part of the plugin's external API.
+ */
+ public static function get_opcache_cache_dir(): string {
+ $upload_dir = wp_get_upload_dir();
+ $default = trailingslashit( $upload_dir['basedir'] ) . self::OPCACHE_DIR_RELATIVE;
+
+ /**
+ * Filters the directory where parsed GraphQL ASTs are written for OPcache.
+ *
+ * @since 10.9.0
+ *
+ * @param string $dir Default cache directory under wp-uploads.
+ */
+ $dir = (string) apply_filters( 'woocommerce_graphql_opcache_cache_dir', $default );
+
+ // Reject stream wrappers (e.g. phar://, http://) to keep file_put_contents,
+ // rename, and include constrained to local filesystem paths.
+ if ( '' === $dir || wp_is_stream( $dir ) ) {
+ return '';
+ }
+
+ return $dir;
+ }
+
+ /**
+ * Ensure the OPcache cache directory exists and is writable.
+ *
+ * Creates the directory on first use and drops a deny-all .htaccess and
+ * an empty index.html alongside it. Returns false if creation fails or
+ * the directory ends up non-writable.
+ */
+ private function ensure_opcache_dir_writable(): bool {
+ $dir = self::get_opcache_cache_dir();
+
+ if ( '' === $dir ) {
+ return false;
+ }
+
+ if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
+ return false;
+ }
+
+ $fs = Utils::wp_filesystem();
+ if ( ! $fs || ! $fs->is_writable( $dir ) ) {
+ return false;
+ }
+
+ // Best-effort hardening; ignore failures (e.g. read-only permissions).
+ $htaccess = $dir . '/.htaccess';
+ $index = $dir . '/index.html';
+ if ( ! file_exists( $htaccess ) ) {
+ $fs->put_contents( $htaccess, "Deny from all\n" );
+ }
+ if ( ! file_exists( $index ) ) {
+ $fs->put_contents( $index, '' );
+ }
+
+ return true;
+ }
+
+ /**
+ * Read a cached DocumentNode from the OPcache file backend.
+ *
+ * @param string $hash The SHA-256 hash.
+ * @return DocumentNode|false
+ */
+ private function read_from_opcache( string $hash ) {
+ $path = self::get_opcache_cache_dir() . '/' . $hash . '.php';
+
+ if ( ! is_file( $path ) ) {
+ return false;
+ }
+
+ // File contents are produced by self::write_to_opcache() and only
+ // ever return a primitive array. The caller falls back to parsing
+ // when the include returns a non-array.
+ $data = include $path;
+
+ if ( ! is_array( $data ) ) {
+ return false;
+ }
+
+ try {
+ return AST::fromArray( $data );
+ } catch ( \Throwable $e ) {
+ return false;
+ }
+ }
+
+ /**
+ * Persist a parsed AST to the OPcache file backend.
+ *
+ * Writes atomically (temp file + rename) so concurrent readers never see
+ * a partial file, and explicitly invalidates OPcache for the destination
+ * path so installs running with opcache.validate_timestamps=0 still see
+ * the new version.
+ *
+ * Failures are intentionally silent: the caller already holds a valid
+ * DocumentNode, and a failed cache write only forfeits the optimisation
+ * for one request.
+ *
+ * @param string $hash The SHA-256 hash to cache under.
+ * @param DocumentNode $document The parsed AST.
+ */
+ private function write_to_opcache( string $hash, DocumentNode $document ): void {
+ $dir = self::get_opcache_cache_dir();
+ $path = $dir . '/' . $hash . '.php';
+ $tmp = $path . '.' . bin2hex( random_bytes( 8 ) ) . '.tmp';
+
+ $contents = "<?php\nreturn " . var_export( $document->toArray(), true ) . ";\n"; // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ if ( false === file_put_contents( $tmp, $contents, LOCK_EX ) ) {
+ return;
+ }
+
+ $fs = Utils::wp_filesystem();
+ if ( ! $fs || ! $fs->move( $tmp, $path, true ) ) {
+ if ( $fs ) {
+ $fs->delete( $tmp );
+ }
+ return;
+ }
+
+ if ( function_exists( 'opcache_invalidate' ) ) {
+ opcache_invalidate( $path, true );
+ }
+ if ( function_exists( 'opcache_compile_file' ) ) {
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+ @opcache_compile_file( $path );
+ }
+
+ OpcacheFileExpiry::ensure_scheduled();
+ }
+
/**
* Build a GraphQL-shaped error response array.
*
diff --git a/plugins/woocommerce/src/Internal/Api/Settings.php b/plugins/woocommerce/src/Internal/Api/Settings.php
index 812ad644b9e..30f4a597089 100644
--- a/plugins/woocommerce/src/Internal/Api/Settings.php
+++ b/plugins/woocommerce/src/Internal/Api/Settings.php
@@ -94,6 +94,13 @@ class Settings {
'type' => 'number',
'custom_attributes' => array( 'min' => '1' ),
),
+ array(
+ 'title' => __( 'Enable OPcache-based caching', 'woocommerce' ),
+ 'desc' => __( 'Cache parsed queries on disk as PHP files so OPcache can serve them from shared memory. Falls back to the object cache when the filesystem is not writable.', 'woocommerce' ),
+ 'id' => Main::OPTION_OPCACHE_ENABLED,
+ 'default' => 'yes',
+ 'type' => 'checkbox',
+ ),
array(
'title' => __( 'Enable ObjectCache-based caching', 'woocommerce' ),
'desc' => __( 'Cache parsed queries in the WP object cache', 'woocommerce' ),
diff --git a/plugins/woocommerce/src/Internal/Api/Utils.php b/plugins/woocommerce/src/Internal/Api/Utils.php
index 734a16692f2..57e6e8c05c4 100644
--- a/plugins/woocommerce/src/Internal/Api/Utils.php
+++ b/plugins/woocommerce/src/Internal/Api/Utils.php
@@ -323,4 +323,19 @@ class Utils {
}//end try
// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
+
+ /**
+ * Lazy-initialize and return the WP_Filesystem global, or null when the
+ * direct method isn't available (e.g. credentials prompt would be needed).
+ */
+ public static function wp_filesystem(): ?\WP_Filesystem_Base {
+ global $wp_filesystem;
+ if ( ! $wp_filesystem ) {
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ if ( ! WP_Filesystem() ) {
+ return null;
+ }
+ }
+ return $wp_filesystem;
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/OpcacheFileExpiryTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/OpcacheFileExpiryTest.php
new file mode 100644
index 00000000000..797dcf66d76
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/OpcacheFileExpiryTest.php
@@ -0,0 +1,125 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Internal\Api\OpcacheFileExpiry;
+use Automattic\WooCommerce\Internal\Api\QueryCache;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for {@see OpcacheFileExpiry} — TTL-based deletion of cached files.
+ */
+class OpcacheFileExpiryTest extends WC_Unit_Test_Case {
+
+ /**
+ * Track temp dirs for removal in tearDown.
+ *
+ * @var string[]
+ */
+ private array $temp_dirs_to_clean = array();
+
+ /**
+ * Skip on PHP < 8.1 because OpcacheFileExpiry imports from the GraphQL
+ * stack autoloaded only on PHP 8.1+.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ if ( PHP_VERSION_ID < 80100 ) {
+ $this->markTestSkipped( 'OpcacheFileExpiry tests require PHP 8.1+.' );
+ }
+ }
+
+ /**
+ * Clean up filters, temp dirs, and scheduled actions between tests.
+ */
+ public function tearDown(): void {
+ remove_all_filters( 'woocommerce_graphql_opcache_cache_dir' );
+ foreach ( $this->temp_dirs_to_clean as $dir ) {
+ $this->rrmdir( $dir );
+ }
+ $this->temp_dirs_to_clean = array();
+ if ( function_exists( 'as_unschedule_all_actions' ) ) {
+ as_unschedule_all_actions( OpcacheFileExpiry::ACTION_HOOK );
+ }
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox delete_expired_files removes only files whose mtime is older than the TTL.
+ */
+ public function test_delete_expired_files_removes_only_expired(): void {
+ $dir = $this->register_temp_cache_dir();
+
+ $fresh = $dir . '/' . str_repeat( 'a', 64 ) . '.php';
+ $expired = $dir . '/' . str_repeat( 'b', 64 ) . '.php';
+ file_put_contents( $fresh, '<?php return array();' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ file_put_contents( $expired, '<?php return array();' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ touch( $expired, time() - QueryCache::get_cache_ttl() - 1 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch
+
+ $deleted = OpcacheFileExpiry::delete_expired_files();
+
+ $this->assertSame( 1, $deleted );
+ $this->assertFileExists( $fresh );
+ $this->assertFileDoesNotExist( $expired );
+ }
+
+ /**
+ * @testdox delete_expired_files returns 0 when the cache directory does not exist.
+ */
+ public function test_delete_expired_files_returns_zero_when_dir_missing(): void {
+ add_filter(
+ 'woocommerce_graphql_opcache_cache_dir',
+ static function () {
+ return '/nonexistent/path/that/does/not/exist';
+ }
+ );
+
+ $this->assertSame( 0, OpcacheFileExpiry::delete_expired_files() );
+ }
+
+ /**
+ * Create a per-test cache directory, point the OPcache filter at it, and
+ * register it for cleanup in tearDown.
+ */
+ private function register_temp_cache_dir(): string {
+ $dir = sys_get_temp_dir() . '/wc-graphql-cleanup-test-' . bin2hex( random_bytes( 6 ) );
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
+ mkdir( $dir, 0700, true );
+
+ add_filter(
+ 'woocommerce_graphql_opcache_cache_dir',
+ static function () use ( $dir ) {
+ return $dir;
+ }
+ );
+
+ $this->temp_dirs_to_clean[] = $dir;
+ return $dir;
+ }
+
+ /**
+ * Recursively remove a directory tree.
+ *
+ * @param string $dir Path to remove.
+ */
+ private function rrmdir( string $dir ): void {
+ if ( ! is_dir( $dir ) ) {
+ return;
+ }
+ // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
+ foreach ( scandir( $dir ) as $entry ) {
+ if ( '.' === $entry || '..' === $entry ) {
+ continue;
+ }
+ $path = $dir . '/' . $entry;
+ if ( is_dir( $path ) ) {
+ $this->rrmdir( $path );
+ } else {
+ wp_delete_file( $path );
+ }
+ }
+ rmdir( $dir );
+ // phpcs:enable
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/QueryCacheTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/QueryCacheTest.php
index b3ba2b912a9..51fb1d8c681 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/QueryCacheTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/QueryCacheTest.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Api;
use Automattic\WooCommerce\Internal\Api\Main;
use Automattic\WooCommerce\Internal\Api\QueryCache;
+use Automattic\WooCommerce\Internal\Api\OpcacheFileExpiry;
use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\DocumentNode;
use WC_Unit_Test_Case;
@@ -36,6 +37,11 @@ class QueryCacheTest extends WC_Unit_Test_Case {
$this->markTestSkipped( 'QueryCache tests require PHP 8.1+.' );
}
+ // OPcache caching defaults to 'yes'; turn it off so the existing
+ // object-cache assertions aren't bypassed by a writable filesystem.
+ // Individual tests may opt back in.
+ update_option( Main::OPTION_OPCACHE_ENABLED, 'no' );
+
wp_cache_flush();
$this->sut = new QueryCache();
}
@@ -45,6 +51,21 @@ class QueryCacheTest extends WC_Unit_Test_Case {
*/
public function tearDown(): void {
delete_option( Main::OPTION_OBJECT_CACHE_ENABLED );
+ delete_option( Main::OPTION_OPCACHE_ENABLED );
+ remove_all_filters( 'woocommerce_graphql_opcache_cache_dir' );
+ foreach ( $this->temp_dirs_to_clean as $dir ) {
+ $this->rrmdir( $dir );
+ }
+ $this->temp_dirs_to_clean = array();
+ foreach ( $this->temp_files_to_clean as $file ) {
+ if ( file_exists( $file ) ) {
+ wp_delete_file( $file );
+ }
+ }
+ $this->temp_files_to_clean = array();
+ if ( function_exists( 'as_unschedule_all_actions' ) ) {
+ as_unschedule_all_actions( OpcacheFileExpiry::ACTION_HOOK );
+ }
wp_cache_flush();
parent::tearDown();
}
@@ -164,6 +185,26 @@ class QueryCacheTest extends WC_Unit_Test_Case {
$this->assertInstanceOf( DocumentNode::class, $result );
}
+ /**
+ * @testdox apq is ignored when the hash is not 64-char lowercase hex — guards the OPcache include path against traversal.
+ */
+ public function test_apq_rejects_malformed_hash(): void {
+ $extensions = array(
+ 'persistedQuery' => array(
+ 'version' => 1,
+ 'sha256Hash' => 'not-a-sha256-hash',
+ ),
+ );
+
+ $result = $this->sut->resolve( '{ widget { id } }', $extensions );
+
+ $this->assertInstanceOf(
+ DocumentNode::class,
+ $result,
+ 'A malformed APQ hash must bypass APQ dispatch so it never reaches the OPcache include path.'
+ );
+ }
+
/**
* @testdox get_cache_ttl exposes the configured TTL.
*/
@@ -201,6 +242,247 @@ class QueryCacheTest extends WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox resolve treats a malformed object-cache entry as a cache miss and reparses.
+ */
+ public function test_resolve_treats_malformed_object_cache_entry_as_miss(): void {
+ update_option( Main::OPTION_OBJECT_CACHE_ENABLED, 'yes' );
+
+ $query = '{ __typename }';
+ wp_cache_set( $this->cache_key_for( $query ), array( 'not' => 'a valid AST' ), 'wc-graphql' );
+
+ $result = $this->sut->resolve( $query, array() );
+
+ $this->assertInstanceOf(
+ DocumentNode::class,
+ $result,
+ 'A corrupted cache payload must be treated as a miss and the query reparsed.'
+ );
+ }
+
+ /**
+ * @testdox resolve writes a parsed AST as a PHP file when OPcache is enabled and the dir is writable.
+ */
+ public function test_resolve_writes_to_opcache_file_when_toggle_on(): void {
+ $dir = $this->use_temp_opcache_dir();
+ update_option( Main::OPTION_OPCACHE_ENABLED, 'yes' );
+
+ $query = '{ widget { id } }';
+ $result = $this->sut->resolve( $query, array() );
+
+ $this->assertInstanceOf( DocumentNode::class, $result );
+ $this->assertFileExists( $dir . '/' . hash( 'sha256', $query ) . '.php' );
+ }
+
+ /**
+ * @testdox resolve does not write to the OPcache dir when the toggle is off.
+ */
+ public function test_resolve_does_not_write_to_opcache_file_when_toggle_off(): void {
+ $dir = $this->use_temp_opcache_dir();
+ update_option( Main::OPTION_OPCACHE_ENABLED, 'no' );
+
+ $query = '{ widget { id } }';
+ $this->sut->resolve( $query, array() );
+
+ $this->assertFileDoesNotExist( $dir . '/' . hash( 'sha256', $query ) . '.php' );
+ }
+
+ /**
+ * @testdox resolve returns the AST from the OPcache file on the second call.
+ */
+ public function test_resolve_returns_document_from_opcache_on_second_call(): void {
+ $this->use_temp_opcache_dir();
+ update_option( Main::OPTION_OPCACHE_ENABLED, 'yes' );
+
+ $query = '{ widget { id } }';
+ $first = $this->sut->resolve( $query, array() );
+ $second = $this->sut->resolve( $query, array() );
+
+ $this->assertInstanceOf( DocumentNode::class, $first );
+ $this->assertInstanceOf( DocumentNode::class, $second );
+ $this->assertEquals( $first->toArray(), $second->toArray() );
+ }
+
+ /**
+ * @testdox apq registration persists across the OPcache file backend.
+ */
+ public function test_apq_round_trip_via_opcache(): void {
+ $this->use_temp_opcache_dir();
+ update_option( Main::OPTION_OPCACHE_ENABLED, 'yes' );
+
+ $query = '{ widget { id } }';
+ $hash = hash( 'sha256', $query );
+ $extensions = array(
+ 'persistedQuery' => array(
+ 'version' => 1,
+ 'sha256Hash' => $hash,
+ ),
+ );
+
+ $register = $this->sut->resolve( $query, $extensions );
+ $this->assertInstanceOf( DocumentNode::class, $register );
+
+ $lookup = $this->sut->resolve( null, $extensions );
+ $this->assertInstanceOf( DocumentNode::class, $lookup );
+ }
+
+ /**
+ * @testdox apq hash-only lookup resolves from the object cache when OPcache becomes unavailable after registration.
+ */
+ public function test_apq_lookup_falls_back_to_object_cache_when_opcache_disabled(): void {
+ $this->use_temp_opcache_dir();
+ update_option( Main::OPTION_OPCACHE_ENABLED, 'yes' );
+
+ $query = '{ widget { id } }';
+ $hash = hash( 'sha256', $query );
+ $extensions = array(
+ 'persistedQuery' => array(
+ 'version' => 1,
+ 'sha256Hash' => $hash,
+ ),
+ );
+
+ $register = $this->sut->resolve( $query, $extensions );
+ $this->assertInstanceOf( DocumentNode::class, $register );
+
+ update_option( Main::OPTION_OPCACHE_ENABLED, 'no' );
+ $this->sut = new QueryCache();
+
+ $lookup = $this->sut->resolve( null, $extensions );
+ $this->assertInstanceOf(
+ DocumentNode::class,
+ $lookup,
+ 'APQ hash-only lookup must still resolve from the object cache after OPcache is disabled.'
+ );
+ }
+
+ /**
+ * @testdox resolve falls back to the object cache when the OPcache dir is not writable.
+ */
+ public function test_resolve_falls_back_to_object_cache_when_opcache_dir_unwritable(): void {
+ $this->skip_if_opcache_disabled();
+
+ $not_a_dir = tempnam( sys_get_temp_dir(), 'wc-graphql-cache-' );
+ $this->temp_files_to_clean[] = $not_a_dir;
+
+ add_filter(
+ 'woocommerce_graphql_opcache_cache_dir',
+ static function () use ( $not_a_dir ) {
+ return $not_a_dir;
+ }
+ );
+
+ update_option( Main::OPTION_OPCACHE_ENABLED, 'yes' );
+ update_option( Main::OPTION_OBJECT_CACHE_ENABLED, 'yes' );
+
+ $result = $this->sut->resolve( '{ __typename }', array() );
+
+ $this->assertInstanceOf( DocumentNode::class, $result );
+ $this->assertNotFalse(
+ wp_cache_get( $this->cache_key_for( '{ __typename }' ), 'wc-graphql' ),
+ 'Should have fallen back to the object cache when the OPcache dir is unwritable.'
+ );
+ }
+
+ /**
+ * @testdox writing to the OPcache backend schedules the cleanup sweep on first write.
+ */
+ public function test_first_write_schedules_cleanup(): void {
+ $this->use_temp_opcache_dir();
+ update_option( Main::OPTION_OPCACHE_ENABLED, 'yes' );
+
+ $this->assertFalse(
+ as_has_scheduled_action( OpcacheFileExpiry::ACTION_HOOK ),
+ 'Pre-condition: no cleanup action should be scheduled before the first write.'
+ );
+
+ $this->sut->resolve( '{ __typename }', array() );
+
+ $this->assertTrue(
+ as_has_scheduled_action( OpcacheFileExpiry::ACTION_HOOK ),
+ 'A successful OPcache write must schedule the cleanup sweep.'
+ );
+ }
+
+ /**
+ * Skip the calling test if OPcache is not enabled in this environment.
+ *
+ * Typical for PHP CLI without opcache.enable_cli=1 — the file-backend
+ * capability check requires opcache_get_status to report enabled, so
+ * tests that exercise that path are not meaningful without it.
+ */
+ private function skip_if_opcache_disabled(): void {
+ if ( ! function_exists( 'opcache_get_status' ) || ! ini_get( 'opcache.enable' ) ) {
+ $this->markTestSkipped( 'OPcache is not enabled in this environment.' );
+ }
+ $status = opcache_get_status( false );
+ if ( ! is_array( $status ) || empty( $status['opcache_enabled'] ) ) {
+ $this->markTestSkipped( 'OPcache is not enabled in this environment.' );
+ }
+ }
+
+ /**
+ * Point the OPcache backend at a per-test temp dir, return the path, and
+ * register a teardown hook to remove it.
+ */
+ private function use_temp_opcache_dir(): string {
+ $this->skip_if_opcache_disabled();
+
+ $dir = sys_get_temp_dir() . '/wc-graphql-test-' . bin2hex( random_bytes( 6 ) );
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
+ mkdir( $dir, 0700, true );
+
+ add_filter(
+ 'woocommerce_graphql_opcache_cache_dir',
+ static function () use ( $dir ) {
+ return $dir;
+ }
+ );
+
+ $this->temp_dirs_to_clean[] = $dir;
+
+ return $dir;
+ }
+
+ /**
+ * Track temp dirs for removal in tearDown.
+ *
+ * @var string[]
+ */
+ private array $temp_dirs_to_clean = array();
+
+ /**
+ * Track temp files for removal in tearDown.
+ *
+ * @var string[]
+ */
+ private array $temp_files_to_clean = array();
+
+ /**
+ * Recursively remove a directory tree.
+ *
+ * @param string $dir Path to remove.
+ */
+ private function rrmdir( string $dir ): void {
+ if ( ! is_dir( $dir ) ) {
+ return;
+ }
+ // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
+ foreach ( scandir( $dir ) as $entry ) {
+ if ( '.' === $entry || '..' === $entry ) {
+ continue;
+ }
+ $path = $dir . '/' . $entry;
+ if ( is_dir( $path ) ) {
+ $this->rrmdir( $path );
+ } else {
+ wp_delete_file( $path );
+ }
+ }
+ rmdir( $dir );
+ // phpcs:enable
+ }
+
/**
* Build the QueryCache cache key for a query string. Prefix kept in sync
* with QueryCache::CACHE_KEY_PREFIX.
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
index 8864d59a5f8..de1d856a787 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
@@ -221,6 +221,18 @@ class SettingsTest extends WC_Unit_Test_Case {
$this->assertSame( 'yes', $by_id[ Main::OPTION_OBJECT_CACHE_ENABLED ]['default'] );
}
+ /**
+ * @testdox add_settings defines the OPcache checkbox with a 'yes' default.
+ */
+ public function test_add_settings_defines_opcache_checkbox(): void {
+ $fields = $this->sut->add_settings( array(), Settings::SECTION_ID );
+ $by_id = array_column( $fields, null, 'id' );
+
+ $this->assertArrayHasKey( Main::OPTION_OPCACHE_ENABLED, $by_id );
+ $this->assertSame( 'checkbox', $by_id[ Main::OPTION_OPCACHE_ENABLED ]['type'] );
+ $this->assertSame( 'yes', $by_id[ Main::OPTION_OPCACHE_ENABLED ]['default'] );
+ }
+
/**
* @testdox add_settings defines the max query depth field with min=1 and the default constant as default.
*/