collection-n-plus-one.e2e-spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. /* eslint-disable @typescript-eslint/no-non-null-assertion, no-console, @typescript-eslint/restrict-template-expressions */
  2. import {
  3. DefaultJobQueuePlugin,
  4. facetValueCollectionFilter,
  5. mergeConfig,
  6. } from '@vendure/core';
  7. import { createTestEnvironment, registerInitializer, SqljsInitializer } from '@vendure/testing';
  8. import gql from 'graphql-tag';
  9. import path from 'path';
  10. import { Logger as TypeOrmLogger, QueryRunner } from 'typeorm';
  11. import { afterAll, beforeAll, describe, expect, it } from 'vitest';
  12. import { initialData } from '../../../e2e-common/e2e-initial-data';
  13. import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
  14. import * as Codegen from './graphql/generated-e2e-admin-types';
  15. import { LanguageCode } from './graphql/generated-e2e-admin-types';
  16. import { CREATE_FACET, CREATE_FACET_VALUE } from './graphql/shared-definitions';
  17. import { awaitRunningJobs } from './utils/await-running-jobs';
  18. /**
  19. * A custom TypeORM logger that records all queries for analysis.
  20. * Used to benchmark and detect N+1 query issues.
  21. */
  22. class QueryRecordingLogger implements TypeOrmLogger {
  23. private queries: Array<{ query: string; parameters?: any[] }> = [];
  24. private recording = false;
  25. startRecording(): void {
  26. this.queries = [];
  27. this.recording = true;
  28. }
  29. stopRecording(): Array<{ query: string; parameters?: any[] }> {
  30. this.recording = false;
  31. return [...this.queries];
  32. }
  33. getQueryCount(): number {
  34. return this.queries.length;
  35. }
  36. getQueries(): Array<{ query: string; parameters?: any[] }> {
  37. return [...this.queries];
  38. }
  39. clearQueries(): void {
  40. this.queries = [];
  41. }
  42. logQuery(query: string, parameters?: any[], _queryRunner?: QueryRunner): void {
  43. if (this.recording) {
  44. this.queries.push({ query, parameters });
  45. }
  46. }
  47. logQueryError(_error: string | Error, _query: string, _parameters?: any[], _queryRunner?: QueryRunner): void {
  48. // no-op for benchmark purposes
  49. }
  50. logQuerySlow(_time: number, _query: string, _parameters?: any[], _queryRunner?: QueryRunner): void {
  51. // no-op for benchmark purposes
  52. }
  53. logSchemaBuild(_message: string, _queryRunner?: QueryRunner): void {
  54. // no-op for benchmark purposes
  55. }
  56. logMigration(_message: string, _queryRunner?: QueryRunner): void {
  57. // no-op for benchmark purposes
  58. }
  59. log(_level: 'log' | 'info' | 'warn', _message: any, _queryRunner?: QueryRunner): void {
  60. // no-op for benchmark purposes
  61. }
  62. }
  63. // Create a shared logger instance
  64. const queryLogger = new QueryRecordingLogger();
  65. registerInitializer('sqljs', new SqljsInitializer(path.join(__dirname, '__data__'), 1000));
  66. const NUM_COLLECTIONS = 15;
  67. const { server, adminClient } = createTestEnvironment(
  68. mergeConfig(testConfig(), {
  69. plugins: [DefaultJobQueuePlugin],
  70. dbConnectionOptions: {
  71. logging: true,
  72. logger: queryLogger,
  73. },
  74. }),
  75. );
  76. /**
  77. * GraphQL query to fetch collections with productVariants { totalItems }
  78. * This is the query we want to benchmark for N+1 issues.
  79. * Note: We use take: 0 on productVariants to indicate we only want the count,
  80. * which allows the resolver to use the pre-fetched cached count.
  81. */
  82. const GET_COLLECTIONS_WITH_PRODUCT_VARIANT_COUNT = gql`
  83. query GetCollectionsWithProductVariantCount($options: CollectionListOptions) {
  84. collections(options: $options) {
  85. items {
  86. id
  87. name
  88. productVariants(options: { take: 0 }) {
  89. totalItems
  90. }
  91. }
  92. totalItems
  93. }
  94. }
  95. `;
  96. const CREATE_COLLECTION = gql`
  97. mutation CreateCollection($input: CreateCollectionInput!) {
  98. createCollection(input: $input) {
  99. id
  100. name
  101. }
  102. }
  103. `;
  104. describe('Collection N+1 Query Benchmark', () => {
  105. const facetValueIds: string[] = [];
  106. beforeAll(async () => {
  107. await server.init({
  108. initialData,
  109. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
  110. customerCount: 1,
  111. });
  112. await adminClient.asSuperAdmin();
  113. await awaitRunningJobs(adminClient);
  114. // Create a facet with multiple values to use for collection filters
  115. const { createFacet } = await adminClient.query<
  116. Codegen.CreateFacetMutation,
  117. Codegen.CreateFacetMutationVariables
  118. >(CREATE_FACET, {
  119. input: {
  120. code: 'benchmark-facet',
  121. isPrivate: false,
  122. translations: [{ languageCode: LanguageCode.en as any, name: 'Benchmark Facet' }],
  123. },
  124. });
  125. // Create facet values for each collection
  126. for (let i = 0; i < NUM_COLLECTIONS; i++) {
  127. const { createFacetValue } = await adminClient.query<
  128. Codegen.CreateFacetValueMutation,
  129. Codegen.CreateFacetValueMutationVariables
  130. >(CREATE_FACET_VALUE, {
  131. input: {
  132. facetId: createFacet.id,
  133. code: `benchmark-value-${i}`,
  134. translations: [{ languageCode: LanguageCode.en, name: `Benchmark Value ${i}` }],
  135. },
  136. });
  137. facetValueIds.push(createFacetValue.id);
  138. }
  139. // Create collections - each with a different facet value filter
  140. for (let i = 0; i < NUM_COLLECTIONS; i++) {
  141. await adminClient.query<
  142. Codegen.CreateCollectionMutation,
  143. Codegen.CreateCollectionMutationVariables
  144. >(CREATE_COLLECTION, {
  145. input: {
  146. translations: [
  147. {
  148. languageCode: LanguageCode.en,
  149. name: `Benchmark Collection ${i}`,
  150. slug: `benchmark-collection-${i}`,
  151. description: `Collection ${i} for N+1 benchmark`,
  152. },
  153. ],
  154. filters: [
  155. {
  156. code: facetValueCollectionFilter.code,
  157. arguments: [
  158. { name: 'facetValueIds', value: `["${facetValueIds[i]}"]` },
  159. { name: 'containsAny', value: 'false' },
  160. ],
  161. },
  162. ],
  163. },
  164. });
  165. await awaitRunningJobs(adminClient);
  166. }
  167. }, TEST_SETUP_TIMEOUT_MS);
  168. afterAll(async () => {
  169. await server.destroy();
  170. });
  171. it('should not have N+1 queries when fetching collections with productVariants totalItems', async () => {
  172. // Clear any previous query recordings
  173. queryLogger.clearQueries();
  174. // Start recording
  175. queryLogger.startRecording();
  176. // Execute the query that fetches collections with productVariants { totalItems }
  177. const result = await adminClient.query(GET_COLLECTIONS_WITH_PRODUCT_VARIANT_COUNT, {
  178. options: {
  179. take: NUM_COLLECTIONS + 1, // +1 for root collection
  180. },
  181. });
  182. // Stop recording
  183. const recordedQueries = queryLogger.stopRecording();
  184. // Basic assertions
  185. expect(result.collections.items.length).toBeGreaterThan(NUM_COLLECTIONS);
  186. expect(result.collections.totalItems).toBeGreaterThan(NUM_COLLECTIONS);
  187. // Analyze query patterns
  188. const selectQueries = recordedQueries.filter(q =>
  189. q.query.startsWith('SELECT') && !q.query.includes('PRAGMA'),
  190. );
  191. // Log results for analysis
  192. console.log('\n=== N+1 Query Benchmark Results ===');
  193. console.log(`Total collections in response: ${result.collections.items.length}`);
  194. console.log(`Total SQL queries executed: ${recordedQueries.length}`);
  195. console.log(`SELECT queries executed: ${selectQueries.length}`);
  196. // Group queries by pattern (normalize parameters)
  197. const queryPatterns = new Map<string, number>();
  198. for (const { query } of selectQueries) {
  199. // Normalize the query by removing specific parameter values
  200. const normalizedQuery = query
  201. .replace(/= \?/g, '= ?')
  202. .replace(/IN \([^)]+\)/g, 'IN (?)')
  203. .replace(/\d+/g, 'N');
  204. queryPatterns.set(normalizedQuery, (queryPatterns.get(normalizedQuery) || 0) + 1);
  205. }
  206. console.log('\nQuery patterns (normalized):');
  207. for (const [pattern, count] of queryPatterns.entries()) {
  208. console.log(` [${count}x] ${pattern.substring(0, 100)}...`);
  209. }
  210. // Log full queries grouped by pattern for detailed analysis
  211. console.log('\n=== FULL QUERIES BY PATTERN ===');
  212. const seenPatterns = new Set<string>();
  213. for (const { query, parameters } of selectQueries) {
  214. const normalizedQuery = query
  215. .replace(/= \?/g, '= ?')
  216. .replace(/IN \([^)]+\)/g, 'IN (?)')
  217. .replace(/\d+/g, 'N');
  218. if (!seenPatterns.has(normalizedQuery)) {
  219. seenPatterns.add(normalizedQuery);
  220. const count = queryPatterns.get(normalizedQuery) || 1;
  221. console.log(`\n--- Pattern (${count}x) ---`);
  222. console.log('FULL SQL:');
  223. console.log(query);
  224. if (parameters && parameters.length > 0) {
  225. console.log('PARAMS:', JSON.stringify(parameters));
  226. }
  227. }
  228. }
  229. // Detect potential N+1 issues
  230. const potentialN1Issues: string[] = [];
  231. for (const [pattern, count] of queryPatterns.entries()) {
  232. if (count > 2 && count >= NUM_COLLECTIONS * 0.5) {
  233. potentialN1Issues.push(`Pattern executed ${count} times (likely N+1): ${pattern.substring(0, 80)}...`);
  234. }
  235. }
  236. if (potentialN1Issues.length > 0) {
  237. console.log('\n!!! POTENTIAL N+1 ISSUES DETECTED !!!');
  238. for (const issue of potentialN1Issues) {
  239. console.log(` - ${issue}`);
  240. }
  241. }
  242. // Calculate expected vs actual
  243. // Optimized: ~4 queries (collections list + ids + count + batch variant counts)
  244. // N+1 (bad): 1 query for collections list + N queries for productVariant counts per collection
  245. const optimizedQueryCount = 4;
  246. const n1QueryCount = NUM_COLLECTIONS + 3;
  247. console.log(`\nExpected query count (optimized): ~${optimizedQueryCount}`);
  248. console.log(`Expected query count (N+1): ~${n1QueryCount}`);
  249. console.log(`Actual query count: ${selectQueries.length}`);
  250. // Determine if we have an N+1 issue
  251. const hasN1Issue = selectQueries.length > optimizedQueryCount + 5; // some tolerance
  252. console.log(`\nN+1 issue detected: ${hasN1Issue ? 'YES' : 'NO'}`);
  253. // Calculate N+1 ratio: queries per collection
  254. const queriesPerCollection = selectQueries.length / result.collections.items.length;
  255. console.log(`Queries per collection: ${queriesPerCollection.toFixed(2)}`);
  256. console.log(`(Ideal is close to 0.2-0.5 with proper batching)\n`);
  257. // Assert that we don't have an N+1 issue
  258. expect(hasN1Issue).toBe(false);
  259. // Assert a reasonable query count - should be ~4 queries with proper batching
  260. // Allow some headroom for future changes but catch N+1 regressions
  261. expect(selectQueries.length).toBeLessThan(10);
  262. // The queries per collection ratio should be well below 1 (no N+1)
  263. expect(queriesPerCollection).toBeLessThan(1);
  264. });
  265. it('should not batch-fetch variant counts when productVariants.totalItems is not requested', async () => {
  266. // Test 1: No productVariants at all
  267. queryLogger.clearQueries();
  268. queryLogger.startRecording();
  269. const GET_COLLECTIONS_WITHOUT_VARIANTS = gql`
  270. query GetCollectionsWithoutVariants($options: CollectionListOptions) {
  271. collections(options: $options) {
  272. items {
  273. id
  274. name
  275. }
  276. totalItems
  277. }
  278. }
  279. `;
  280. await adminClient.query(GET_COLLECTIONS_WITHOUT_VARIANTS, {
  281. options: { take: NUM_COLLECTIONS + 1 },
  282. });
  283. let recordedQueries = queryLogger.stopRecording();
  284. let selectQueries = recordedQueries.filter(q =>
  285. q.query.startsWith('SELECT') && !q.query.includes('PRAGMA'),
  286. );
  287. let batchCountQueries = selectQueries.filter(q =>
  288. q.query.includes('GROUP BY') && q.query.includes('product_variant'),
  289. );
  290. console.log(`\nQueries without productVariants: ${selectQueries.length}`);
  291. expect(batchCountQueries.length).toBe(0);
  292. // Test 2: productVariants.items requested but NOT totalItems
  293. queryLogger.clearQueries();
  294. queryLogger.startRecording();
  295. const GET_COLLECTIONS_WITH_VARIANT_ITEMS_ONLY = gql`
  296. query GetCollectionsWithVariantItemsOnly($options: CollectionListOptions) {
  297. collections(options: $options) {
  298. items {
  299. id
  300. name
  301. productVariants(options: { take: 1 }) {
  302. items {
  303. id
  304. }
  305. }
  306. }
  307. totalItems
  308. }
  309. }
  310. `;
  311. await adminClient.query(GET_COLLECTIONS_WITH_VARIANT_ITEMS_ONLY, {
  312. options: { take: 5 },
  313. });
  314. recordedQueries = queryLogger.stopRecording();
  315. selectQueries = recordedQueries.filter(q =>
  316. q.query.startsWith('SELECT') && !q.query.includes('PRAGMA'),
  317. );
  318. batchCountQueries = selectQueries.filter(q =>
  319. q.query.includes('GROUP BY') && q.query.includes('product_variant'),
  320. );
  321. console.log(`Queries with productVariants.items only: ${selectQueries.length}`);
  322. console.log(`Batch count queries found: ${batchCountQueries.length}`);
  323. // Should NOT have batch count query since totalItems wasn't requested
  324. expect(batchCountQueries.length).toBe(0);
  325. });
  326. it.skip('logs individual queries for detailed analysis', async () => {
  327. queryLogger.clearQueries();
  328. queryLogger.startRecording();
  329. await adminClient.query(GET_COLLECTIONS_WITH_PRODUCT_VARIANT_COUNT, {
  330. options: {
  331. take: 5, // Smaller set for detailed logging
  332. },
  333. });
  334. const recordedQueries = queryLogger.stopRecording();
  335. console.log('\n=== Detailed Query Log (first 5 collections) ===');
  336. const selectQueries = recordedQueries.filter(q =>
  337. q.query.startsWith('SELECT') && !q.query.includes('PRAGMA'),
  338. );
  339. for (let i = 0; i < Math.min(selectQueries.length, 30); i++) {
  340. const { query, parameters } = selectQueries[i];
  341. console.log(`\n--- Query ${i + 1} ---`);
  342. console.log(`SQL: ${query.substring(0, 200)}${query.length > 200 ? '...' : ''}`);
  343. if (parameters && parameters.length > 0) {
  344. console.log(`Params: ${JSON.stringify(parameters).substring(0, 100)}`);
  345. }
  346. }
  347. if (selectQueries.length > 30) {
  348. console.log(`\n... and ${selectQueries.length - 30} more queries`);
  349. }
  350. // Just a basic sanity check
  351. expect(selectQueries.length).toBeGreaterThan(0);
  352. });
  353. });