Skip to content

Add in-memory caching to QueryRunner #475

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -1123,7 +1123,7 @@ By default, the `deserialize_response` assumes a JSON string and deserializes it

The `get_request_details` method extracts and validates the request details provided by the query. The input variables for the current request are provided as an associative array (`[ $var_name => $value ]`). The return value is an associative array that provides the HTTP method, request options, origin, and URI.

### get_raw_response_data( HttpQueryInterface $query, array $input_variables ): array|WP_Error
### get_raw_response_data( array $request_details, array $input_variables ): array|WP_Error

The `get_raw_response_data` method dispatches the HTTP request and assembles the raw (pre-processed) response data. The input variables for the current request are provided as an associative array (`[ $var_name => $value ]`). The return value is an associative array that provides the response metadata and the raw response data.

Expand Down
2 changes: 1 addition & 1 deletion docs/extending/query-runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ By default, the `deserialize_response` assumes a JSON string and deserializes it

The `get_request_details` method extracts and validates the request details provided by the query. The input variables for the current request are provided as an associative array (`[ $var_name => $value ]`). The return value is an associative array that provides the HTTP method, request options, origin, and URI.

### get_raw_response_data( HttpQueryInterface $query, array $input_variables ): array|WP_Error
### get_raw_response_data( array $request_details, array $input_variables ): array|WP_Error

The `get_raw_response_data` method dispatches the HTTP request and assembles the raw (pre-processed) response data. The input variables for the current request are provided as an associative array (`[ $var_name => $value ]`). The return value is an associative array that provides the response metadata and the raw response data.

Expand Down
76 changes: 61 additions & 15 deletions inc/Config/QueryRunner/QueryRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,20 @@
* Class that executes queries.
*/
class QueryRunner implements QueryRunnerInterface {
/**
* @var array<string, array|WP_Error>
*/
protected static array $in_memory_cache;

protected HttpClient $http_client;

/**
* @param HttpClient|null $http_client The HTTP client used to make HTTP requests.
* @param array|null $in_memory_cache The in-memory cache used to store query results.
*/
public function __construct( ?HttpClient $http_client = null ) {
public function __construct( ?HttpClient $http_client = null, ?array $in_memory_cache = null ) {
$this->http_client = $http_client ?? HttpClient::instance();
self::$in_memory_cache = $in_memory_cache ?? self::$in_memory_cache ?? [];
}

/**
Expand Down Expand Up @@ -121,20 +128,14 @@ protected function get_request_details( HttpQueryInterface $query, array $input_
/**
* Dispatch the HTTP request and assemble the raw (pre-processed) response data.
*
* @param HttpQueryInterface $query The query being executed.
* @param array<string, mixed> $request_details The parsed request details for the current request.
* @param array<string, mixed> $input_variables The input variables for the current request.
* @return WP_Error|array{
* @return array{
* metadata: array<string, string|int|null>,
* response_data: string|array|object|null,
* response_data: string|array|object|null|WP_Error,
* }
*/
protected function get_raw_response_data( HttpQueryInterface $query, array $input_variables ): array|WP_Error {
$request_details = $this->get_request_details( $query, $input_variables );

if ( is_wp_error( $request_details ) ) {
return $request_details;
}

protected function get_raw_response_data( array $request_details, array $input_variables ): array|WP_Error {
try {
$response = $this->http_client->request( $request_details['method'], $request_details['uri'], $request_details['options'] );
} catch ( Exception $e ) {
Expand All @@ -152,7 +153,7 @@ protected function get_raw_response_data( HttpQueryInterface $query, array $inpu

return [
'metadata' => [
'age' => intval( $response->getHeaderLine( 'Age' ) ),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that this is still being referenced in QueryRunner#get_response_metadata, just need to remove that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opted to restore it for now in 64549e9

'age' => $response->getHeaderLine( RdbCacheStrategy::CACHE_AGE_RESPONSE_HEADER ),
'status_code' => $response_code,
],
'response_data' => $this->deserialize_response( $raw_response_string, $input_variables ),
Expand Down Expand Up @@ -222,10 +223,31 @@ public function execute( HttpQueryInterface $query, array $input_variables ): ar
}
}

$raw_response_data = $this->get_raw_response_data( $query, $input_variables );
$request_details = $this->get_request_details( $query, $input_variables );

if ( is_wp_error( $request_details ) ) {
return $request_details;
}

// Try to resolve the request from the in-memory cache using a hash of
// the query and input. This is not 1:1 with the object caching that is
// implemented by HttpClient, but it is good enough to de-duplicate
// requests that occur in the same process.
//
// This avoids redundant post-processing (QueryResponseParser) and
// object cache requests.
$in_memory_cache_key = sprintf( 'query-runner:%s', md5( wp_json_encode( $request_details ) ) );
$cached_result = $this->cache_get( $in_memory_cache_key );

if ( null !== $cached_result ) {
return $cached_result;
}

$raw_response_data = $this->get_raw_response_data( $request_details, $input_variables );

// Errors are also cached in-memory to prevent repeating failed requests.
if ( is_wp_error( $raw_response_data ) ) {
return $raw_response_data;
return $this->cache_save( $in_memory_cache_key, $raw_response_data );
}

// Preprocess the response data.
Expand Down Expand Up @@ -258,13 +280,15 @@ public function execute( HttpQueryInterface $query, array $input_variables ): ar
}
}

return [
$result = [
'metadata' => $metadata,
'pagination' => Pagination::format_pagination_data_for_query_response( $pagination, $input_schema, $input_variables ),
'results' => $results,
'query_id' => $query->get_id(),
'query_inputs' => [ $input_variables ],
];

return $this->cache_save( $in_memory_cache_key, $result );
}

/**
Expand Down Expand Up @@ -344,4 +368,26 @@ protected function deserialize_response( string $raw_response_data, array $input
protected function preprocess_response( HttpQueryInterface $query, mixed $response_data, array $input_variables ): mixed {
return $query->preprocess_response( $response_data, $input_variables );
}

/**
* Cache the response data in memory.
*
* @param string $cache_key The cache key.
* @return array|WP_Error|null The cached data or null if not found.
*/
protected function cache_get( string $cache_key ): array|WP_Error|null {
return self::$in_memory_cache[ $cache_key ] ?? null;
}

/**
* Cache the response data in memory.
*
* @param string $cache_key The cache key.
* @param array|WP_Error $data The data to cache.
* @return array|WP_Error The cached data.
*/
protected function cache_save( string $cache_key, array|WP_Error $data ): array|WP_Error {
self::$in_memory_cache[ $cache_key ] = $data;
return $data;
}
}
4 changes: 1 addition & 3 deletions inc/ExampleApi/Queries/ExampleApiQueryRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@

namespace RemoteDataBlocks\ExampleApi\Queries;

use RemoteDataBlocks\Config\Query\HttpQueryInterface;
use RemoteDataBlocks\Config\QueryRunner\QueryRunner;
use RemoteDataBlocks\ExampleApi\Data\ExampleApiData;
use WP_Error;

defined( 'ABSPATH' ) || exit();

Expand All @@ -19,7 +17,7 @@
*
*/
class ExampleApiQueryRunner extends QueryRunner {
protected function get_raw_response_data( HttpQueryInterface $query, array $input_variables ): array|WP_Error {
protected function get_raw_response_data( array $request_details, array $input_variables ): array {
if ( isset( $input_variables['record_id'] ) ) {
return [
'metadata' => [],
Expand Down
1 change: 1 addition & 0 deletions inc/HttpClient/RdbCacheStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use RemoteDataBlocks\Logging\LoggerManager;

class RdbCacheStrategy extends GreedyCacheStrategy {
public const CACHE_AGE_RESPONSE_HEADER = 'Age';
public const CACHE_TTL_REQUEST_HEADER = GreedyCacheStrategy::HEADER_TTL;

private const CACHE_INVALIDATING_REQUEST_HEADERS = [ 'Authorization', 'Cache-Control' ];
Expand Down
54 changes: 53 additions & 1 deletion tests/inc/Config/QueryRunnerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ protected function setUp(): void {

$this->query = MockQuery::create( [
'data_source' => $this->http_data_source,
'query_runner' => new QueryRunner( $this->http_client ),
'query_runner' => new QueryRunner( $this->http_client, [] ),
] );
}

Expand Down Expand Up @@ -383,4 +383,56 @@ public function testQueryRunnerAppliesDefaultInputVariables(): void {
$this->assertArrayHasKey( 'metadata', $result );
$this->assertArrayHasKey( 'results', $result );
}

public function testSubsequentRequestsResolveFromInMemoryCache(): void {
$response_body = wp_json_encode( [
'data' => [
'id' => 1,
'name' => 'Test',
],
] );
$response = new Response( 200, [], $response_body );

$this->query->set_output_schema( [
'is_collection' => false,
'path' => '$.data',
'type' => [
'id' => [
'name' => 'ID',
'type' => 'id',
],
'name' => [
'name' => 'Name',
'type' => 'string',
],
],
] );

$this->http_client->method( 'request' )->willReturn( $response );

$result = $this->query->execute( [] );

$this->assertIsArray( $result );
$this->assertArrayHasKey( 'results', $result );
$this->assertEquals( 1, $result['results'][0]['result']['id']['value'] );
$this->assertEquals( 'Test', $result['results'][0]['result']['name']['value'] );

$updated_response_body = wp_json_encode( [
'data' => [
'id' => 2,
'name' => 'Test 2',
],
] );
$updated_response = new Response( 200, [], $updated_response_body );

$this->http_client->method( 'request' )->willReturn( $updated_response );

$result = $this->query->execute( [] );

// Returns original response from in-memory cache.
$this->assertIsArray( $result );
$this->assertArrayHasKey( 'results', $result );
$this->assertEquals( 1, $result['results'][0]['result']['id']['value'] );
$this->assertEquals( 'Test', $result['results'][0]['result']['name']['value'] );
}
}
7 changes: 4 additions & 3 deletions tests/inc/stubs.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,14 @@ function is_wp_error( mixed $thing ): bool {
}

function wp_parse_url( string $url ): array|false {
// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url
// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url
return parse_url( $url );
}

function wp_json_encode( mixed $data ): string {
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
return json_encode( $data );
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
$string = json_encode( $data );
return $string ?: '';
}

function wp_cache_get(): bool {
Expand Down
5 changes: 3 additions & 2 deletions tests/integration/RDBTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
use WP_UnitTestCase;
use RemoteDataBlocks\Config\DataSource\HttpDataSource;
use RemoteDataBlocks\Config\Query\HttpQuery;
use RemoteDataBlocks\Config\Query\HttpQueryInterface;
use RemoteDataBlocks\Config\QueryRunner\QueryRunner;
use RemoteDataBlocks\Editor\BlockManagement\BlockRegistration;
use RemoteDataBlocks\Editor\BlockManagement\ConfigStore;
Expand Down Expand Up @@ -102,11 +101,13 @@ protected function get_query_runner_with_response( array $response_data, int $st
private $status_code;

public function __construct( array $response_data, int $status_code ) {
parent::__construct( null, [] );

$this->response_data = $response_data;
$this->status_code = $status_code;
}

protected function get_raw_response_data( HttpQueryInterface $query, array $input_variables ): array|WP_Error {
protected function get_raw_response_data( array $request_details, array $input_variables ): array|WP_Error {
return [
'metadata' => [
'age' => 100,
Expand Down
Loading