Skip to content

Commit

Permalink
Full query response cache plugin
Browse files Browse the repository at this point in the history
- A new plugin package implementing a response cache, based on
  apollo-cache-control hints.

- GraphQLRequestContext new fields:
  - overallCachePolicy
  - documentText
  - metrics

- New plugin hook responseForOperation.

- new GraphQLExtension hook didResolveOperation, identical to the same hook in
  the Plugin API.  Change apollo-engine-reporting to use this hook instead of
  executionDidStart, because executionDidStart doesn't run if the cache
  short-circuits execution.

- apollo-engine-reporting: report whether the request was a cache hit. Also use
  the new requestContext.metrics object to report persisted query hit/register
  instead of specific extension options (though those extension options still
  work).

- cacheControl constructor option semantic change: include the cacheControl
  GraphQL extension in the output with `cacheControl: true` and `cacheControl:
  {stripFormattedExtensions: true}` (as before), but not for `cacheControl:
  {otherOptions: ...}`.
  • Loading branch information
glasser committed Mar 19, 2019
1 parent dcd572f commit c9e2f6f
Show file tree
Hide file tree
Showing 24 changed files with 862 additions and 49 deletions.
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"apollo-server-lambda": "file:packages/apollo-server-lambda",
"apollo-server-micro": "file:packages/apollo-server-micro",
"apollo-server-plugin-base": "file:packages/apollo-server-plugin-base",
"apollo-server-plugin-response-cache": "file:packages/apollo-server-plugin-response-cache",
"apollo-server-testing": "file:packages/apollo-server-testing",
"apollo-tracing": "file:packages/apollo-tracing",
"graphql-extensions": "file:packages/graphql-extensions"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ export async function collectCacheControlHints(
): Promise<CacheHint[]> {
enableGraphQLExtensions(schema);

const cacheControlExtension = new CacheControlExtension(options);
// Because this test helper looks at the formatted extensions, we always want
// to include them.
const cacheControlExtension = new CacheControlExtension({
...options,
stripFormattedExtensions: false,
});

const response = await graphql({
schema,
Expand Down
25 changes: 24 additions & 1 deletion packages/apollo-cache-control/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ declare module 'graphql/type/definition' {
}
}

declare module 'apollo-server-core/dist/requestPipelineAPI' {
interface GraphQLRequestContext<TContext> {
// Not readonly: plugins can set it.
overallCachePolicy?: Required<CacheHint> | undefined;
}
}

export class CacheControlExtension<TContext = any>
implements GraphQLExtension<TContext> {
private defaultMaxAge: number;
Expand All @@ -51,6 +58,7 @@ export class CacheControlExtension<TContext = any>
}

private hints: Map<ResponsePath, CacheHint> = new Map();
private overallCachePolicyOverride?: Required<CacheHint>;

willResolveField(
_source: any,
Expand Down Expand Up @@ -123,7 +131,14 @@ export class CacheControlExtension<TContext = any>
}

format(): [string, CacheControlFormat] | undefined {
if (this.options.stripFormattedExtensions) return;
// We should have to explicitly ask leave the formatted extension in, or
// pass the old-school `cacheControl: true` (as interpreted by
// apollo-server-core/ApolloServer), in order to include the
// engineproxy-aimed extensions. Specifically, we want users of
// apollo-server-plugin-response-cache to be able to specify
// `cacheControl: {defaultMaxAge: 600}` without accidentally turning on the
// extension formatting.
if (this.options.stripFormattedExtensions !== false) return;

return [
'cacheControl',
Expand Down Expand Up @@ -152,7 +167,15 @@ export class CacheControlExtension<TContext = any>
}
}

public overrideOverallCachePolicy(overallCachePolicy: Required<CacheHint>) {
this.overallCachePolicyOverride = overallCachePolicy;
}

computeOverallCachePolicy(): Required<CacheHint> | undefined {
if (this.overallCachePolicyOverride) {
return this.overallCachePolicyOverride;
}

let lowestMaxAge: number | undefined = undefined;
let scope: CacheScope = CacheScope.Public;

Expand Down
39 changes: 13 additions & 26 deletions packages/apollo-engine-reporting/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
responsePathAsArray,
ResponsePath,
DocumentNode,
ExecutionArgs,
GraphQLError,
} from 'graphql';
import {
Expand Down Expand Up @@ -34,7 +33,7 @@ export class EngineReportingExtension<TContext = any>
public trace = new Trace();
private nodes = new Map<string, Trace.Node>();
private startHrTime!: [number, number];
private operationName?: string;
private operationName?: string | null;
private queryString?: string;
private documentAST?: DocumentNode;
private options: EngineReportingOptions<TContext>;
Expand Down Expand Up @@ -92,8 +91,6 @@ export class EngineReportingExtension<TContext = any>
queryString?: string;
parsedQuery?: DocumentNode;
variables?: Record<string, any>;
persistedQueryHit?: boolean;
persistedQueryRegister?: boolean;
context: TContext;
extensions?: Record<string, any>;
requestContext: GraphQLRequestContext<TContext>;
Expand Down Expand Up @@ -149,10 +146,10 @@ export class EngineReportingExtension<TContext = any>
}
}

if (o.persistedQueryHit) {
if (o.requestContext.metrics!.persistedQueryHit) {
this.trace.persistedQueryHit = true;
}
if (o.persistedQueryRegister) {
if (o.requestContext.metrics!.persistedQueryRegister) {
this.trace.persistedQueryRegister = true;
}
}
Expand Down Expand Up @@ -213,6 +210,9 @@ export class EngineReportingExtension<TContext = any>
);
this.trace.endTime = dateToTimestamp(new Date());

this.trace.fullQueryCacheHit = !!o.requestContext.metrics!
.responseCacheHit;

const operationName = this.operationName || '';
let signature;
if (this.documentAST) {
Expand All @@ -237,21 +237,13 @@ export class EngineReportingExtension<TContext = any>
};
}

public executionDidStart(o: { executionArgs: ExecutionArgs }) {
// If the operationName is explicitly provided, save it. If there's just one
// named operation, the client doesn't have to provide it, but we still want
// to know the operation name so that the server can identify the query by
// it without having to parse a signature.
//
// Fortunately, in the non-error case, we can just pull this out of
// the first call to willResolveField's `info` argument. In an
// error case (eg, the operationName isn't found, or there are more
// than one operation and no specified operationName) it's OK to continue
// to file this trace under the empty operationName.
if (o.executionArgs.operationName) {
this.operationName = o.executionArgs.operationName;
}
this.documentAST = o.executionArgs.document;
public didResolveOperation(o: {
requestContext: GraphQLRequestContext<TContext>;
}) {
const { requestContext } = o;

this.operationName = requestContext.operationName;
this.documentAST = requestContext.document;
}

public willResolveField(
Expand All @@ -260,11 +252,6 @@ export class EngineReportingExtension<TContext = any>
_context: TContext,
info: GraphQLResolveInfo,
): ((error: Error | null, result: any) => void) | void {
if (this.operationName === undefined) {
this.operationName =
(info.operation.name && info.operation.name.value) || '';
}

const path = info.path;
const node = this.newNode(path);
node.type = info.returnType.toString();
Expand Down
1 change: 1 addition & 0 deletions packages/apollo-server-caching/src/KeyValueCache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export interface KeyValueCache<V = string> {
get(key: string): Promise<V | undefined>;
// ttl is measured in seconds.
set(key: string, value: V, options?: { ttl?: number }): Promise<void>;
delete(key: string): Promise<boolean | void>;
}
Expand Down
68 changes: 47 additions & 21 deletions packages/apollo-server-core/src/requestPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,19 @@ export async function processGraphQLRequest<TContext>(

initializeDataSources();

if (!requestContext.metrics) {
requestContext.metrics = {};
}

const request = requestContext.request;

let { query, extensions } = request;

let queryHash: string;

let persistedQueryCache: KeyValueCache | undefined;
let persistedQueryHit = false;
let persistedQueryRegister = false;
requestContext.metrics.persistedQueryHit = false;
requestContext.metrics.persistedQueryRegister = false;

if (extensions && extensions.persistedQuery) {
// It looks like we've received a persisted query. Check if we
Expand Down Expand Up @@ -150,7 +154,7 @@ export async function processGraphQLRequest<TContext>(
if (query === undefined) {
query = await persistedQueryCache.get(queryHash);
if (query) {
persistedQueryHit = true;
requestContext.metrics.persistedQueryHit = true;
} else {
throw new PersistedQueryNotFoundError();
}
Expand All @@ -167,7 +171,7 @@ export async function processGraphQLRequest<TContext>(
// Defering the writing gives plugins the ability to "win" from use of
// the cache, but also have their say in whether or not the cache is
// written to (by interrupting the request with an error).
persistedQueryRegister = true;
requestContext.metrics.persistedQueryRegister = true;
}
} else if (query) {
// FIXME: We'll compute the APQ query hash to use as our cache key for
Expand All @@ -178,16 +182,17 @@ export async function processGraphQLRequest<TContext>(
}

requestContext.queryHash = queryHash;
requestContext.documentText = query;

const requestDidEnd = extensionStack.requestDidStart({
request: request.http!,
queryString: request.query,
operationName: request.operationName,
variables: request.variables,
extensions: request.extensions,
persistedQueryHit,
persistedQueryRegister,
context: requestContext.context,
persistedQueryHit: requestContext.metrics.persistedQueryHit,
persistedQueryRegister: requestContext.metrics.persistedQueryRegister,
requestContext,
});

Expand Down Expand Up @@ -284,32 +289,53 @@ export async function processGraphQLRequest<TContext>(
// pipeline, and given plugins appropriate ability to object (by throwing
// an error) and not actually write, we'll write to the cache if it was
// determined earlier in the request pipeline that we should do so.
if (persistedQueryRegister && persistedQueryCache) {
if (requestContext.metrics.persistedQueryRegister && persistedQueryCache) {
Promise.resolve(persistedQueryCache.set(queryHash, query)).catch(
console.warn,
);
}

const executionDidEnd = await dispatcher.invokeDidStartHook(
'executionDidStart',
let response: GraphQLResponse | null = await dispatcher.invokeHooksUntilNonNull(
'responseForOperation',
requestContext as WithRequired<
typeof requestContext,
'document' | 'operation' | 'operationName'
>,
);
if (response == null) {
const executionDidEnd = await dispatcher.invokeDidStartHook(
'executionDidStart',
requestContext as WithRequired<
typeof requestContext,
'document' | 'operation' | 'operationName'
>,
);

let response: GraphQLResponse;
try {
response = (await execute(
requestContext.document,
request.operationName,
request.variables,
)) as GraphQLResponse;
executionDidEnd();
} catch (executionError) {
executionDidEnd(executionError);
return sendErrorResponse(executionError);
}
}

try {
response = (await execute(
requestContext.document,
request.operationName,
request.variables,
)) as GraphQLResponse;
executionDidEnd();
} catch (executionError) {
executionDidEnd(executionError);
return sendErrorResponse(executionError);
if (cacheControlExtension) {
if (requestContext.overallCachePolicy) {
// If we read this response from a cache and it already has its own
// policy, teach that to cacheControlExtension so that it'll use the
// saved policy for HTTP headers. (If cacheControlExtension was a
// plugin, it could just read from the requestContext, but it isn't.)
cacheControlExtension.overrideOverallCachePolicy(
requestContext.overallCachePolicy,
);
} else {
requestContext.overallCachePolicy = cacheControlExtension.computeOverallCachePolicy();
}
}

const formattedExtensions = extensionStack.format();
Expand All @@ -323,7 +349,7 @@ export async function processGraphQLRequest<TContext>(
});
}

return sendResponse(response);
return sendResponse(response!!);
} finally {
requestDidEnd();
}
Expand Down
13 changes: 13 additions & 0 deletions packages/apollo-server-core/src/requestPipelineAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ export interface GraphQLResponse {
http?: Pick<Response, 'headers'>;
}

export interface GraphQLRequestMetrics {
persistedQueryHit?: boolean;
persistedQueryRegister?: boolean;
// XXX I thought about making this an augmentation either from
// apollo-engine-reporting or apollo-server-plugin-response-cache but that
// seemed to mean that one of those packages would have to depend on the
// other, which seemed wrong. Happy to hear there's a better way.
responseCacheHit?: boolean;
}

export interface GraphQLRequestContext<TContext = Record<string, any>> {
readonly request: GraphQLRequest;
readonly response?: GraphQLResponse;
Expand All @@ -50,13 +60,16 @@ export interface GraphQLRequestContext<TContext = Record<string, any>> {
readonly queryHash?: string;

readonly document?: DocumentNode;
readonly documentText?: string;

// `operationName` is set based on the operation AST, so it is defined
// even if no `request.operationName` was passed in.
// It will be set to `null` for an anonymous operation.
readonly operationName?: string | null;
readonly operation?: OperationDefinitionNode;

readonly metrics?: GraphQLRequestMetrics;

debug?: boolean;
}

Expand Down
19 changes: 19 additions & 0 deletions packages/apollo-server-core/src/utils/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,25 @@ export class Dispatcher<T> {
);
}

public async invokeHooksUntilNonNull<
TMethodName extends FunctionPropertyNames<Required<T>>
>(
methodName: TMethodName,
...args: Args<T[TMethodName]>
): Promise<UnwrapPromise<ReturnType<AsFunction<T[TMethodName]>>> | null> {
for (const target of this.targets) {
const method = target[methodName];
if (!(method && typeof method === 'function')) {
continue;
}
const value = await method.apply(target, args);
if (value !== null) {
return value;
}
}
return null;
}

public invokeDidStartHook<
TMethodName extends FunctionPropertyNames<
Required<T>,
Expand Down
Loading

0 comments on commit c9e2f6f

Please sign in to comment.