From 84977d598161ed5c0e058c552a93a26640079bd4 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Mon, 17 Aug 2026 07:06:12 +0800 Subject: [PATCH] add configurable Grafast cache limits --- .../__tests__/grafast-cache-limits.test.ts | 106 +++++++++++++++ .../src/grafast-cache-limits.ts | 47 +++++++ graphile/graphile-settings/src/index.ts | 6 + graphql/env/README.md | 8 ++ .../__tests__/grafast-cache-limits.test.ts | 124 ++++++++++++++++++ graphql/env/src/env.ts | 29 ++++ graphql/env/src/grafast-cache-limits.ts | 54 ++++++++ graphql/env/src/index.ts | 1 + graphql/env/src/merge.ts | 11 +- graphql/server/src/middleware/graphile.ts | 28 +++- graphql/types/src/graphile.ts | 12 ++ graphql/types/src/index.ts | 1 + 12 files changed, 422 insertions(+), 5 deletions(-) create mode 100644 graphile/graphile-settings/__tests__/grafast-cache-limits.test.ts create mode 100644 graphile/graphile-settings/src/grafast-cache-limits.ts create mode 100644 graphql/env/__tests__/grafast-cache-limits.test.ts create mode 100644 graphql/env/src/grafast-cache-limits.ts diff --git a/graphile/graphile-settings/__tests__/grafast-cache-limits.test.ts b/graphile/graphile-settings/__tests__/grafast-cache-limits.test.ts new file mode 100644 index 0000000000..5657dd110f --- /dev/null +++ b/graphile/graphile-settings/__tests__/grafast-cache-limits.test.ts @@ -0,0 +1,106 @@ +import { grafastSync } from 'grafast'; +import { buildSchema, GraphQLSchema, type GraphQLSchemaConfig } from 'graphql'; + +import { + applyGrafastCacheLimits, + createGrafastCacheLimitsPlugin, + createGrafastCacheLimitsPreset, +} from '../src/grafast-cache-limits'; + +const schemaConfig = (): GraphQLSchemaConfig => ({ + extensions: { + existing: true, + grafast: { queryCacheMaxLength: 99 }, + }, +}); + +describe('Grafast schema-local cache limits', () => { + it('preserves unrelated schema and Grafast extensions', () => { + const result = applyGrafastCacheLimits(schemaConfig(), { + operationsCacheMaxLength: 16, + operationOperationPlansCacheMaxLength: 8, + }); + + expect(result.extensions).toMatchObject({ + existing: true, + grafast: { + queryCacheMaxLength: 99, + operationsCacheMaxLength: 16, + operationOperationPlansCacheMaxLength: 8, + }, + }); + }); + + it('installs an immutable limit snapshot through the GraphQLSchema hook', () => { + const limits = { + queryCacheMaxLength: 16, + operationsCacheMaxLength: 8, + operationOperationPlansCacheMaxLength: 4, + }; + const plugin = createGrafastCacheLimitsPlugin(limits); + limits.operationsCacheMaxLength = 64; + + expect(plugin.name).toBe('GrafastCacheLimitsPlugin'); + const hook = plugin.schema?.hooks?.GraphQLSchema; + expect(typeof hook).toBe('function'); + + const result = (hook as Function)(schemaConfig(), {}, {}); + expect(result.extensions?.grafast).toMatchObject({ + queryCacheMaxLength: 16, + operationsCacheMaxLength: 8, + operationOperationPlansCacheMaxLength: 4, + }); + }); + + it('is inert when no limits are configured', () => { + expect(createGrafastCacheLimitsPreset()).toEqual({}); + expect(createGrafastCacheLimitsPreset({})).toEqual({}); + }); + + it('returns one plugin preset when at least one limit is configured', () => { + const preset = createGrafastCacheLimitsPreset({ queryCacheMaxLength: 8 }); + + expect(preset.plugins).toHaveLength(1); + expect(preset.plugins?.[0].name).toBe('GrafastCacheLimitsPlugin'); + }); + + it('bounds Grafast query and operation caches under query diversity', () => { + const config = applyGrafastCacheLimits( + buildSchema('type Query { hello: String }').toConfig(), + { + queryCacheMaxLength: 2, + operationsCacheMaxLength: 2, + } + ); + const schema = new GraphQLSchema(config); + + for (let index = 0; index < 4; index += 1) { + const result = grafastSync({ + schema, + source: `query CacheCase${index} { hello }`, + rootValue: { hello: 'world' }, + }); + expect(result.errors).toBeUndefined(); + } + + const grafastExtensions = schema.extensions.grafast as unknown as Record< + symbol, + { length?: number } + >; + const cacheLengths = Object.fromEntries( + Object.getOwnPropertySymbols(grafastExtensions).map((symbol) => [ + symbol.description, + grafastExtensions[symbol].length, + ]) + ); + expect(cacheLengths).toMatchObject({ queryCache: 2, cacheByOperation: 2 }); + }); + + it('rejects invalid limits before Graphile schema construction', () => { + expect(() => + createGrafastCacheLimitsPreset({ operationsCacheMaxLength: 1 }) + ).toThrow( + 'graphile.grafastCache.operationsCacheMaxLength must be a safe integer of at least 2' + ); + }); +}); diff --git a/graphile/graphile-settings/src/grafast-cache-limits.ts b/graphile/graphile-settings/src/grafast-cache-limits.ts new file mode 100644 index 0000000000..11a67dedc9 --- /dev/null +++ b/graphile/graphile-settings/src/grafast-cache-limits.ts @@ -0,0 +1,47 @@ +import { normalizeGrafastCacheLimits } from '@constructive-io/graphql-env'; +import type { GrafastCacheLimits } from '@constructive-io/graphql-types'; +import type { GraphileConfig } from 'graphile-config'; +import type { GraphQLSchemaConfig } from 'graphql'; + +/** Apply authoritative per-schema cache limits without disturbing extensions. */ +export const applyGrafastCacheLimits = ( + config: GraphQLSchemaConfig, + limits: Readonly +): GraphQLSchemaConfig => ({ + ...config, + extensions: { + ...(config.extensions ?? {}), + grafast: { + ...(config.extensions?.grafast ?? {}), + ...limits, + }, + }, +}); + +/** Reusable plugin for bounding Grafast's schema-local runtime caches. */ +export const createGrafastCacheLimitsPlugin = ( + limits: GrafastCacheLimits +): GraphileConfig.Plugin => { + const normalized = normalizeGrafastCacheLimits(limits) ?? {}; + return { + name: 'GrafastCacheLimitsPlugin', + version: '1.0.0', + description: 'Bounds schema-local Grafast parse and operation-plan caches', + schema: { + hooks: { + GraphQLSchema(config) { + return applyGrafastCacheLimits(config, normalized); + }, + }, + }, + }; +}; + +export const createGrafastCacheLimitsPreset = ( + limits?: GrafastCacheLimits +): GraphileConfig.Preset => { + const normalized = normalizeGrafastCacheLimits(limits); + return normalized === undefined || Object.keys(normalized).length === 0 + ? {} + : { plugins: [createGrafastCacheLimitsPlugin(normalized)] }; +}; diff --git a/graphile/graphile-settings/src/index.ts b/graphile/graphile-settings/src/index.ts index afa9154a82..654facb65e 100644 --- a/graphile/graphile-settings/src/index.ts +++ b/graphile/graphile-settings/src/index.ts @@ -37,6 +37,12 @@ import 'graphile-build'; import { makePgService } from 'postgraphile/adaptors/pg'; +export { + applyGrafastCacheLimits, + createGrafastCacheLimitsPlugin, + createGrafastCacheLimitsPreset +} from './grafast-cache-limits'; + // ============================================================================ // Re-export all plugins and presets // ============================================================================ diff --git a/graphql/env/README.md b/graphql/env/README.md index e5084a59d8..d66164e7fa 100644 --- a/graphql/env/README.md +++ b/graphql/env/README.md @@ -44,6 +44,14 @@ In addition to all environment variables supported by `@pgpmjs/env`, this packag ### GraphQL Schema - `GRAPHILE_SCHEMA` - Comma-separated list of PostgreSQL schemas to expose +### Grafast Cache Limits +- `GRAPHILE_QUERY_CACHE_MAX_LENGTH` - Maximum parsed and validated queries retained per schema +- `GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH` - Maximum operations retained for plan lookup per schema +- `GRAPHILE_OPERATION_PLANS_CACHE_MAX_LENGTH` - Maximum context/variable-specific plans retained per operation + +Each cache limit must be a safe integer of at least `2`. When omitted, Grafast's +upstream default for that cache remains in effect. + ### Feature Flags - `FEATURES_SIMPLE_INFLECTION` - Enable simple inflection plugin - `FEATURES_OPPOSITE_BASE_NAMES` - Enable opposite base names diff --git a/graphql/env/__tests__/grafast-cache-limits.test.ts b/graphql/env/__tests__/grafast-cache-limits.test.ts new file mode 100644 index 0000000000..1df62027e5 --- /dev/null +++ b/graphql/env/__tests__/grafast-cache-limits.test.ts @@ -0,0 +1,124 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { getGraphQLEnvVars } from '../src/env'; +import { normalizeGrafastCacheLimits } from '../src/grafast-cache-limits'; +import { getEnvOptions } from '../src/merge'; + +describe('Grafast cache-limit configuration', () => { + it('normalizes a partial configuration into an immutable copy', () => { + const input = { + queryCacheMaxLength: 64, + operationOperationPlansCacheMaxLength: 8, + }; + + const normalized = normalizeGrafastCacheLimits(input); + input.queryCacheMaxLength = 128; + + expect(normalized).toEqual({ + queryCacheMaxLength: 64, + operationOperationPlansCacheMaxLength: 8, + }); + expect(Object.isFrozen(normalized)).toBe(true); + }); + + it.each([0, 1, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Infinity, NaN])( + 'rejects an unsafe bound %s', + (value) => { + expect(() => + normalizeGrafastCacheLimits({ operationsCacheMaxLength: value }) + ).toThrow( + 'graphile.grafastCache.operationsCacheMaxLength must be a safe integer of at least 2' + ); + } + ); + + it('rejects malformed objects and unknown settings', () => { + expect(() => normalizeGrafastCacheLimits([] as unknown as {})).toThrow( + 'graphile.grafastCache must be an object' + ); + expect(() => + normalizeGrafastCacheLimits({ + queryCacheMaximum: 8, + } as unknown as {}) + ).toThrow("contains unsupported setting 'queryCacheMaximum'"); + }); + + it('maps all three environment variables', () => { + expect( + getGraphQLEnvVars({ + GRAPHILE_QUERY_CACHE_MAX_LENGTH: '64', + GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH: '32', + GRAPHILE_OPERATION_PLANS_CACHE_MAX_LENGTH: '8', + }).graphile?.grafastCache + ).toEqual({ + queryCacheMaxLength: 64, + operationsCacheMaxLength: 32, + operationOperationPlansCacheMaxLength: 8, + }); + }); + + it.each(['', '0', '1', '-1', '1.5', '12entries'])( + 'rejects an invalid environment bound %s', + (value) => { + expect(() => + getGraphQLEnvVars({ GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH: value }) + ).toThrow( + 'GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH must be a safe integer of at least 2' + ); + } + ); + + it('keeps the feature absent when no limit is configured', () => { + expect(getGraphQLEnvVars({}).graphile?.grafastCache).toBeUndefined(); + }); + + it('validates the final config, environment, and override merge', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'grafast-cache-')); + try { + fs.writeFileSync( + path.join(tempDir, 'pgpm.json'), + JSON.stringify({ + graphile: { + grafastCache: { + queryCacheMaxLength: 64, + operationsCacheMaxLength: 64, + }, + }, + }) + ); + + const options = getEnvOptions( + { + graphile: { + grafastCache: { operationOperationPlansCacheMaxLength: 8 }, + }, + }, + tempDir, + { GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH: '32' } + ); + + expect(options.graphile?.grafastCache).toEqual({ + queryCacheMaxLength: 64, + operationsCacheMaxLength: 32, + operationOperationPlansCacheMaxLength: 8, + }); + expect(Object.isFrozen(options.graphile?.grafastCache)).toBe(true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('rejects an invalid runtime override during final validation', () => { + expect(() => + getEnvOptions( + { graphile: { grafastCache: { queryCacheMaxLength: 1 } } }, + process.cwd(), + {} + ) + ).toThrow( + 'graphile.grafastCache.queryCacheMaxLength must be a safe integer of at least 2' + ); + }); +}); diff --git a/graphql/env/src/env.ts b/graphql/env/src/env.ts index 014924ef24..00ddfd2f34 100644 --- a/graphql/env/src/env.ts +++ b/graphql/env/src/env.ts @@ -1,12 +1,20 @@ import { ConstructiveOptions } from '@constructive-io/graphql-types'; import { parseEnvBoolean, parseEnvNumber } from '12factor-env'; +import { + normalizeGrafastCacheLimits, + parseGrafastCacheLimitEnv +} from './grafast-cache-limits'; + /** * @param env - Environment object to read from (defaults to process.env for backwards compatibility) */ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial => { const { GRAPHILE_SCHEMA, + GRAPHILE_QUERY_CACHE_MAX_LENGTH, + GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH, + GRAPHILE_OPERATION_PLANS_CACHE_MAX_LENGTH, FEATURES_SIMPLE_INFLECTION, FEATURES_OPPOSITE_BASE_NAMES, @@ -38,6 +46,11 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial // let an absent env var overwrite pgpm.json or consumer-specific values. const smsRequestTimeoutMs = parseEnvNumber(SMS_REQUEST_TIMEOUT_MS); const smsDryRun = parseEnvBoolean(SEND_SMS_DRY_RUN); + const hasGrafastCacheLimits = [ + GRAPHILE_QUERY_CACHE_MAX_LENGTH, + GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH, + GRAPHILE_OPERATION_PLANS_CACHE_MAX_LENGTH + ].some(value => value !== undefined); const hasSmsEnvOverrides = Boolean( SMS_PROVIDER || SMS_SENDER_ID || @@ -48,6 +61,22 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial return { graphile: { + ...(hasGrafastCacheLimits && { + grafastCache: normalizeGrafastCacheLimits({ + queryCacheMaxLength: parseGrafastCacheLimitEnv( + GRAPHILE_QUERY_CACHE_MAX_LENGTH, + 'GRAPHILE_QUERY_CACHE_MAX_LENGTH' + ), + operationsCacheMaxLength: parseGrafastCacheLimitEnv( + GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH, + 'GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH' + ), + operationOperationPlansCacheMaxLength: parseGrafastCacheLimitEnv( + GRAPHILE_OPERATION_PLANS_CACHE_MAX_LENGTH, + 'GRAPHILE_OPERATION_PLANS_CACHE_MAX_LENGTH' + ) + }) + }), ...(GRAPHILE_SCHEMA && { schema: GRAPHILE_SCHEMA.includes(',') ? GRAPHILE_SCHEMA.split(',').map(s => s.trim()) diff --git a/graphql/env/src/grafast-cache-limits.ts b/graphql/env/src/grafast-cache-limits.ts new file mode 100644 index 0000000000..01f5404de7 --- /dev/null +++ b/graphql/env/src/grafast-cache-limits.ts @@ -0,0 +1,54 @@ +import type { GrafastCacheLimits } from '@constructive-io/graphql-types'; +import { parseEnvNumber } from '12factor-env'; + +const LIMIT_KEYS = [ + 'queryCacheMaxLength', + 'operationsCacheMaxLength', + 'operationOperationPlansCacheMaxLength', +] as const; + +const assertGrafastCacheLimit = (value: unknown, label: string): number => { + if (!Number.isSafeInteger(value) || (value as number) < 2) { + throw new Error(`${label} must be a safe integer of at least 2`); + } + return value as number; +}; + +export const parseGrafastCacheLimitEnv = ( + value: string | undefined, + envName: string +): number | undefined => { + if (value === undefined) return undefined; + return assertGrafastCacheLimit(parseEnvNumber(value), envName); +}; + +/** Validate every configuration source before cache bounds reach Grafast. */ +export const normalizeGrafastCacheLimits = ( + limits: GrafastCacheLimits | undefined +): Readonly | undefined => { + if (limits === undefined) return undefined; + if (typeof limits !== 'object' || limits === null || Array.isArray(limits)) { + throw new Error('graphile.grafastCache must be an object'); + } + + const allowedKeys = new Set(LIMIT_KEYS); + for (const key of Object.keys(limits)) { + if (!allowedKeys.has(key)) { + throw new Error( + `graphile.grafastCache contains unsupported setting '${key}'` + ); + } + } + + const normalized: GrafastCacheLimits = {}; + for (const key of LIMIT_KEYS) { + const value = limits[key]; + if (value !== undefined) { + normalized[key] = assertGrafastCacheLimit( + value, + `graphile.grafastCache.${key}` + ); + } + } + return Object.freeze(normalized); +}; diff --git a/graphql/env/src/index.ts b/graphql/env/src/index.ts index 50627b7d54..1cde42a418 100644 --- a/graphql/env/src/index.ts +++ b/graphql/env/src/index.ts @@ -1,4 +1,5 @@ // Export Constructive-specific env functions export { getGraphQLEnvVars } from './env'; +export { normalizeGrafastCacheLimits } from './grafast-cache-limits'; export { getConstructiveEnvOptions,getEnvOptions } from './merge'; export type { DevSmsOptions, SmsOptions } from '@constructive-io/graphql-types'; diff --git a/graphql/env/src/merge.ts b/graphql/env/src/merge.ts index 15f1402c53..fcf155461e 100644 --- a/graphql/env/src/merge.ts +++ b/graphql/env/src/merge.ts @@ -3,6 +3,7 @@ import { getEnvOptions as getPgpmEnvOptions, loadConfigSync, replaceArrays } fro import deepmerge from 'deepmerge'; import { getGraphQLEnvVars } from './env'; +import { normalizeGrafastCacheLimits } from './grafast-cache-limits'; /** * Get Constructive environment options by merging: @@ -36,7 +37,7 @@ export const getEnvOptions = ( const configOptions = loadConfigSync(cwd) as Partial; // Merge in order: core -> graphql defaults -> config (for graphql keys) -> graphql env -> overrides - return deepmerge.all([ + const options = deepmerge.all([ coreOptions, constructiveGraphqlDefaults, // Only merge graphql-related keys from config (if present) @@ -51,6 +52,14 @@ export const getEnvOptions = ( ], { arrayMerge: replaceArrays }) as ConstructiveOptions; + + const grafastCache = normalizeGrafastCacheLimits( + options.graphile?.grafastCache + ); + if (grafastCache !== undefined && options.graphile) { + options.graphile = { ...options.graphile, grafastCache }; + } + return options; }; /** diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index e6de98f7ad..f2fd9a3a89 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -12,7 +12,11 @@ import type { GraphQLError, GraphQLFormattedError } from 'grafast/graphql'; import { createGraphileInstance, graphileCache,type GraphileCacheEntry } from 'graphile-cache'; import type { GraphileConfig } from 'graphile-config'; import { createFunctionBindingsPlugin } from 'graphile-function-bindings'; -import { createConstructivePreset, makePgService } from 'graphile-settings'; +import { + createConstructivePreset, + createGrafastCacheLimitsPreset, + makePgService +} from 'graphile-settings'; import { getPgPool } from 'pg-cache'; import { getPgEnvOptions } from 'pg-env'; @@ -167,10 +171,17 @@ const buildPreset = ( roleName: string, databaseSettings?: DatabaseSettings, apiId?: string, - compute?: ComputeConfig + compute?: ComputeConfig, + grafastCache?: NonNullable['grafastCache'] ): GraphileConfig.Preset => { + const grafastCachePreset = createGrafastCacheLimitsPreset(grafastCache); return { - extends: [createConstructivePreset(databaseSettings)], + extends: [ + createConstructivePreset(databaseSettings), + ...(Object.keys(grafastCachePreset).length > 0 + ? [grafastCachePreset] + : []) + ], plugins: [ AuthCookiePlugin, // Only registered when the compute module is provisioned for this @@ -403,7 +414,16 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => { // Create promise and store in in-flight map BEFORE try block const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined; - const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings, api.apiId, compute); + const preset = buildPreset( + pool, + schema || [], + anonRole, + roleName, + api.databaseSettings, + api.apiId, + compute, + opts.graphile?.grafastCache + ); const creationPromise = observeGraphileBuild( { cacheKey: key, diff --git a/graphql/types/src/graphile.ts b/graphql/types/src/graphile.ts index 72fff4c739..df9a07f09d 100644 --- a/graphql/types/src/graphile.ts +++ b/graphql/types/src/graphile.ts @@ -1,5 +1,15 @@ import type { GraphileConfig } from 'graphile-config'; +/** Per-schema Grafast parse, operation, and operation-plan cache bounds. */ +export interface GrafastCacheLimits { + /** Maximum parsed and validated GraphQL documents retained by one schema. */ + queryCacheMaxLength?: number; + /** Maximum GraphQL operations with retained plan lookup state per schema. */ + operationsCacheMaxLength?: number; + /** Maximum context/variable-specific plans retained for one operation. */ + operationOperationPlansCacheMaxLength?: number; +} + /** * PostGraphile/Graphile v5 configuration */ @@ -10,6 +20,8 @@ export interface GraphileOptions { extends?: GraphileConfig.Preset[]; /** Preset overrides */ preset?: Partial; + /** Explicit per-schema Grafast cache bounds used for tenant-density control. */ + grafastCache?: GrafastCacheLimits; } /** diff --git a/graphql/types/src/index.ts b/graphql/types/src/index.ts index 895604e137..c6c9884ef8 100644 --- a/graphql/types/src/index.ts +++ b/graphql/types/src/index.ts @@ -2,6 +2,7 @@ export { apiDefaults, ApiOptions, + GrafastCacheLimits, graphileDefaults, graphileFeatureDefaults, GraphileFeatureOptions,