diff --git a/tests/performance/app/router.js b/tests/performance/app/router.js index e5fadf0fe4c..bda25d3f471 100644 --- a/tests/performance/app/router.js +++ b/tests/performance/app/router.js @@ -21,6 +21,7 @@ Router.map(function () { this.route('destroy'); this.route('unused-relationships'); this.route('update-with-same-state'); + this.route('update-with-same-state-m2m'); }); export default Router; diff --git a/tests/performance/app/routes/update-with-same-state-m2m.js b/tests/performance/app/routes/update-with-same-state-m2m.js new file mode 100644 index 00000000000..ac97ad8c0f1 --- /dev/null +++ b/tests/performance/app/routes/update-with-same-state-m2m.js @@ -0,0 +1,65 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +const REMOVAL_COUNT = 10; + +export default Route.extend({ + store: service(), + + async model() { + performance.mark('start-data-generation'); + + const initialPayload = await fetch('./fixtures/big-many-to-many.json').then((r) => r.json()); + const initialPayload2 = structuredClone(initialPayload); + const payloadWithRemoval = await fetch('./fixtures/big-many-to-many-with-removal.json').then((r) => r.json()); + + performance.mark('start-push-initial-payload'); + this.store.push(initialPayload); + + performance.mark('start-peek-records'); + const peekedCars = this.store.peekAll('car'); + const peekedColors = this.store.peekAll('color'); + + performance.mark('start-record-materialization'); + peekedColors.slice(); + peekedCars.slice(); + + performance.mark('start-relationship-materialization'); + const seen = new Set(); + peekedCars.forEach((car) => iterateCar(car, seen)); + const removedColors = []; + + performance.mark('start-local-removal'); + for (const car of peekedCars) { + const colors = car.colors; + removedColors.push(colors.splice(0, REMOVAL_COUNT)); + } + + performance.mark('start-push-minus-one-payload'); + this.store.push(payloadWithRemoval); + + performance.mark('start-local-addition'); + peekedCars.forEach((car, index) => { + car.colors = removedColors[index].concat(car.colors); + }); + + performance.mark('start-push-plus-one-payload'); + this.store.push(initialPayload2); + + performance.mark('end-push-plus-one-payload'); + }, +}); + +function iterateChild(record, seen) { + if (seen.has(record)) { + return; + } + seen.add(record); + + record.cars; +} + +function iterateCar(record, seen) { + seen.add(record); + record.colors.forEach((color) => iterateChild(color, seen)); +} diff --git a/tests/performance/app/routes/update-with-same-state.js b/tests/performance/app/routes/update-with-same-state.js index 273f766a594..26ada32b96b 100644 --- a/tests/performance/app/routes/update-with-same-state.js +++ b/tests/performance/app/routes/update-with-same-state.js @@ -9,10 +9,7 @@ export default Route.extend({ const initialPayload = await fetch('./fixtures/add-children-initial.json').then((r) => r.json()); const initialPayload2 = structuredClone(initialPayload); - - const minusOnePayload = structuredClone(initialPayload); - minusOnePayload.data.relationships.children.data.pop(); - minusOnePayload.included.pop(); + const payloadWithRemoval = await fetch('./fixtures/add-children-with-removal.json').then((r) => r.json()); performance.mark('start-push-initial-payload'); this.store.push(initialPayload); @@ -32,13 +29,13 @@ export default Route.extend({ const children = await parent.children; performance.mark('start-local-removal'); - const removedChild = children.pop(); + const removedChildren = children.splice(0, 19000); performance.mark('start-push-minus-one-payload'); - this.store.push(minusOnePayload); + this.store.push(payloadWithRemoval); performance.mark('start-local-addition'); - children.push(removedChild); + parent.children = removedChildren.concat(children); performance.mark('start-push-plus-one-payload'); this.store.push(initialPayload2); @@ -53,10 +50,9 @@ function iterateChild(record, seen) { } seen.add(record); - record.bestFriend.get('name'); - record.secondBestFriend.get('name'); - record.friends.forEach((child) => iterateChild(child, seen)); + record.parent; } + function iterateParent(record, seen) { seen.add(record); record.children.forEach((child) => iterateChild(child, seen)); diff --git a/tests/performance/app/templates/application.hbs b/tests/performance/app/templates/application.hbs index 51a416d9eac..f69f5b34018 100644 --- a/tests/performance/app/templates/application.hbs +++ b/tests/performance/app/templates/application.hbs @@ -12,5 +12,6 @@ <li><LinkTo @route='add-children-to-materialized'>Add Children To Materialized</LinkTo></li> <li><LinkTo @route='add-children-then-materialize'>Add Children Then Materialize</LinkTo></li> <li><LinkTo @route='unused-relationships'>Unused Relationships</LinkTo></li> - <li><LinkTo @route='update-with-same-state'>Update With Same State</LinkTo></li> + <li><LinkTo @route='update-with-same-state'>Update With Same State (One to Many)</LinkTo></li> + <li><LinkTo @route='update-with-same-state-m2m'>Update With Same State (Many to Many)</LinkTo></li> </ol> \ No newline at end of file diff --git a/tests/performance/fixtures/create-cars-payload.js b/tests/performance/fixtures/create-cars-payload.js deleted file mode 100644 index 9f42d962a87..00000000000 --- a/tests/performance/fixtures/create-cars-payload.js +++ /dev/null @@ -1,82 +0,0 @@ -const COLORS = ['red', 'white', 'black', 'pink', 'green', 'blue', 'yellow', 'orange', 'green', 'teal']; -const SIZES = ['square', 'rectangle', 'circle', 'oval', 'cube', 'small', 'medium', 'large', 'extra large']; -const MAKES = ['suv', 'sedan', 'minivan', 'electric', 'hybrid', 'truck', 'sport']; - -let FIXTURE_ID = 0; - -function getIndex(index, fixtures) { - const count = fixtures.length; - return index % count; -} - -function assignToMany(resource, id) { - resource.relationships = resource.relationships || {}; - const cars = (resource.relationships.cars = resource.relationships.cars || { data: [] }); - cars.data.push({ - type: 'car', - id, - }); -} - -function getRelatedResource(fixtures, index, id) { - const resource = fixtures[getIndex(index, fixtures)]; - assignToMany(resource, id); - return { id: resource.id, type: resource.type }; -} - -module.exports = function createCarsPayload(n) { - const colors = getColorResources(); - const makes = getMakeResources(); - const sizes = getSizeResources(); - const data = new Array(n); - for (let i = 0; i < n; i++) { - const id = `urn:car:${FIXTURE_ID++}`; - data[i] = { - id, - type: 'car', - attributes: {}, - relationships: { - make: { - data: getRelatedResource(makes, i, id), - }, - size: { - data: getRelatedResource(sizes, i, id), - }, - colors: { - data: [ - getRelatedResource(colors, i, id), - getRelatedResource(colors, i + 1, id), - getRelatedResource(colors, i + 2, id), - ], - }, - }, - }; - } - - const fixture = { - data, - included: [].concat(colors, makes, sizes), - }; - - return fixture; -}; - -function getColorResources() { - return COLORS.map((name) => createJsonApiResource(`urn:color:${FIXTURE_ID++}`, 'color', { name })); -} - -function getSizeResources() { - return SIZES.map((name) => createJsonApiResource(`urn:size:${FIXTURE_ID++}`, 'size', { name })); -} - -function getMakeResources() { - return MAKES.map((name) => createJsonApiResource(`urn:make:${FIXTURE_ID++}`, 'make', { name })); -} - -function createJsonApiResource(id, type, attributes) { - return { - id, - type, - attributes, - }; -} diff --git a/tests/performance/fixtures/create-cars-payload.ts b/tests/performance/fixtures/create-cars-payload.ts new file mode 100644 index 00000000000..7d0e24a8ff3 --- /dev/null +++ b/tests/performance/fixtures/create-cars-payload.ts @@ -0,0 +1,136 @@ +const COLORS = ['red', 'white', 'black', 'pink', 'green', 'blue', 'yellow', 'orange', 'green', 'teal']; +const SIZES = ['square', 'rectangle', 'circle', 'oval', 'cube', 'small', 'medium', 'large', 'extra large']; +const MAKES = ['suv', 'sedan', 'minivan', 'electric', 'hybrid', 'truck', 'sport']; + +let FIXTURE_ID = 0; + +type JSONIdentifier = { id: string; type: string }; + +type JSONAPIResource = { + id: string; + type: string; + attributes: Record<string, string>; + relationships?: Record<string, { data: JSONIdentifier | JSONIdentifier[] }>; +}; + +type JSONAPIPayload = { + data: JSONAPIResource[]; + included: JSONAPIResource[]; +}; + +function getIndex(index: number, fixtures: unknown[]) { + const count = fixtures.length; + return index % count; +} + +function assignToMany(resource: JSONAPIResource, id: string) { + resource.relationships = resource.relationships || {}; + const cars = (resource.relationships.cars = resource.relationships.cars || { data: [] }); + assert('Expected cars.data to be an array', Array.isArray(cars.data)); + cars.data.push({ + type: 'car', + id, + }); +} + +function getRelatedResource(fixtures: JSONAPIResource[], index: number, id: string) { + const resource = fixtures[getIndex(index, fixtures)]; + assignToMany(resource, id); + return { id: resource.id, type: resource.type }; +} + +function createCarsPayload(n: number, c = 1): JSONAPIPayload { + const colors = getColorResources(c); + const makes = getMakeResources(); + const sizes = getSizeResources(); + const data = new Array<JSONAPIResource>(n); + for (let i = 0; i < n; i++) { + const id = `urn:car:${FIXTURE_ID++}`; + data[i] = { + id, + type: 'car', + attributes: {}, + relationships: { + make: { + data: getRelatedResource(makes, i, id), + }, + size: { + data: getRelatedResource(sizes, i, id), + }, + colors: { + data: + c === 1 + ? [ + getRelatedResource(colors, i, id), + getRelatedResource(colors, i + 1, id), + getRelatedResource(colors, i + 2, id), + ] + : new Array(colors.length).fill(null).map((_v, ii) => getRelatedResource(colors, i + ii, id)), + }, + }, + }; + } + + const fixture = { + data, + included: ([] as JSONAPIResource[]).concat(colors, makes, sizes), + }; + + return fixture; +} + +function getColorResources(c: number) { + return COLORS.flatMap((name) => { + if (c > 1) { + return new Array(c) + .fill(null) + .map((_v, i) => createJsonApiResource(`urn:color:${FIXTURE_ID++}`, 'color', { name: `${name}-${i}` })); + } else { + return [createJsonApiResource(`urn:color:${FIXTURE_ID++}`, 'color', { name })]; + } + }); +} + +function getSizeResources() { + return SIZES.map((name) => createJsonApiResource(`urn:size:${FIXTURE_ID++}`, 'size', { name })); +} + +function getMakeResources() { + return MAKES.map((name) => createJsonApiResource(`urn:make:${FIXTURE_ID++}`, 'make', { name })); +} + +function createJsonApiResource(id: string, type: string, attributes: Record<string, string>): JSONAPIResource { + return { + id, + type, + attributes, + }; +} + +function deleteHalfTheColors(payload: JSONAPIPayload) { + const payloadWithRemoval = structuredClone(payload); + + for (const carDatum of payloadWithRemoval.data) { + assert('Expected carDatum to have relationships', carDatum.relationships); + assert('Expected carDatum to have colors array', Array.isArray(carDatum.relationships.colors.data)); + const colorsLength = carDatum.relationships.colors.data.length; + const removedColors = carDatum.relationships.colors.data.splice(0, colorsLength / 2); + for (const removed of removedColors) { + const included = payloadWithRemoval.included.find((r) => r.type === 'color' && r.id === removed.id); + assert('Expected to find color in included', included); + assert('Expected color to have relationships', included.relationships); + assert('Expected color to have cars', Array.isArray(included.relationships.cars.data)); + included.relationships.cars.data = included.relationships.cars.data.filter((car) => car.id !== carDatum.id); + } + } + + return payloadWithRemoval; +} + +function assert(message: string, condition: unknown): asserts condition { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } +} + +module.exports = { createCarsPayload, deleteHalfTheColors }; diff --git a/tests/performance/fixtures/generated/add-children-with-removal.json.br b/tests/performance/fixtures/generated/add-children-with-removal.json.br new file mode 100644 index 00000000000..229a35d20e4 Binary files /dev/null and b/tests/performance/fixtures/generated/add-children-with-removal.json.br differ diff --git a/tests/performance/fixtures/generated/big-many-to-many-with-removal.json.br b/tests/performance/fixtures/generated/big-many-to-many-with-removal.json.br new file mode 100644 index 00000000000..bdbdd422944 Binary files /dev/null and b/tests/performance/fixtures/generated/big-many-to-many-with-removal.json.br differ diff --git a/tests/performance/fixtures/generated/big-many-to-many.json.br b/tests/performance/fixtures/generated/big-many-to-many.json.br new file mode 100644 index 00000000000..c7d9b6c9e4c Binary files /dev/null and b/tests/performance/fixtures/generated/big-many-to-many.json.br differ diff --git a/tests/performance/fixtures/index.js b/tests/performance/fixtures/index.js index f523088d43b..952210f5f77 100644 --- a/tests/performance/fixtures/index.js +++ b/tests/performance/fixtures/index.js @@ -23,13 +23,19 @@ function write(name, json) { } const createParentPayload = require('./create-parent-payload'); -const createCarsPayload = require('./create-cars-payload'); +const { createCarsPayload, deleteHalfTheColors } = require('./create-cars-payload.ts'); const createParentRecords = require('./create-parent-records'); const { createComplexPayload: createComplexRecordsPayload } = require('./create-complex-payload.ts'); async function main() { - write('add-children-initial', createParentPayload(19600)); + const initialChildrenPayload = createParentPayload(19600); + write('add-children-initial', initialChildrenPayload); write('add-children-final', createParentPayload(20000)); + const payloadWithRemoval = structuredClone(initialChildrenPayload); + payloadWithRemoval.data.relationships.children.data.splice(0, 19000); + payloadWithRemoval.included.splice(0, 19000); + write('add-children-with-removal', payloadWithRemoval); + write('destroy', createParentPayload(500, 50)); write('relationship-materialization-simple', createCarsPayload(10000)); write('relationship-materialization-complex', createParentRecords(200, 10, 20)); @@ -40,5 +46,9 @@ async function main() { write('example-parent', createParentPayload(2, 2)); write('basic-record-materialization', createParentRecords(10000, 2, 3)); write('complex-record-materialization', await createComplexRecordsPayload(100)); + + const initialBigM2M = createCarsPayload(100, 100); + write('big-many-to-many', initialBigM2M); + write('big-many-to-many-with-removal', deleteHalfTheColors(initialBigM2M)); } main(); diff --git a/tests/performance/package.json b/tests/performance/package.json index 988db2d730a..f44f01a3186 100644 --- a/tests/performance/package.json +++ b/tests/performance/package.json @@ -17,7 +17,8 @@ "scripts": { "build": "vite build", "start": "bun ./server/index.ts", - "lint": "eslint . --quiet --cache --cache-strategy=content" + "lint": "eslint . --quiet --cache --cache-strategy=content", + "fixtures": "bun run ./fixtures/index.js" }, "devDependencies": { "@babel/core": "^7.26.9",