Skip to content

Commit e432c23

Browse files
vladarpieh
andauthored
fix(gatsby): fix incorrect intersection of filtered results (#30594)
* add failing test * actually failing test * make test independent of other tests * add invariant for filter intersection assumptions * add failing integration test * fix test 🤦‍ * actually fix this heisenbug * update redux state snapshot * perf: mutate status state directly Co-authored-by: Michal Piechowiak <[email protected]> * only newly created nodes should get a counter * tweak comment Co-authored-by: Michal Piechowiak <[email protected]> Co-authored-by: Michal Piechowiak <[email protected]>
1 parent b0fcb57 commit e432c23

File tree

8 files changed

+122
-7
lines changed

8 files changed

+122
-7
lines changed

integration-tests/artifacts/__tests__/index.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,24 @@ function assertHTMLCorrectness(runNumber) {
232232
})
233233
}
234234

235+
function assertNodeCorrectness(runNumber) {
236+
describe(`node correctness`, () => {
237+
it(`nodes do not have repeating counters`, () => {
238+
const seenCounters = new Map()
239+
const duplicates = []
240+
// Just a convenience step to display node ids with duplicate counters
241+
manifest[runNumber].allNodeCounters.forEach(([id, counter]) => {
242+
if (seenCounters.has(counter)) {
243+
duplicates.push({ counter, nodeIds: [id, seenCounters.get(counter)] })
244+
}
245+
seenCounters.set(counter, id)
246+
})
247+
expect(manifest[runNumber].allNodeCounters.length).toBeGreaterThan(0)
248+
expect(duplicates).toEqual([])
249+
})
250+
})
251+
}
252+
235253
beforeAll(done => {
236254
fs.removeSync(path.join(__dirname, `__debug__`))
237255

@@ -454,6 +472,8 @@ describe(`First run (baseline)`, () => {
454472
assertWebpackBundleChanges({ browser: true, ssr: true, runNumber })
455473

456474
assertHTMLCorrectness(runNumber)
475+
476+
assertNodeCorrectness(runNumber)
457477
})
458478

459479
describe(`Second run (different pages created, data changed)`, () => {
@@ -541,6 +561,8 @@ describe(`Second run (different pages created, data changed)`, () => {
541561
assertWebpackBundleChanges({ browser: false, ssr: false, runNumber })
542562

543563
assertHTMLCorrectness(runNumber)
564+
565+
assertNodeCorrectness(runNumber)
544566
})
545567

546568
describe(`Third run (js change, all pages are recreated)`, () => {
@@ -632,6 +654,8 @@ describe(`Third run (js change, all pages are recreated)`, () => {
632654
assertWebpackBundleChanges({ browser: true, ssr: true, runNumber })
633655

634656
assertHTMLCorrectness(runNumber)
657+
658+
assertNodeCorrectness(runNumber)
635659
})
636660

637661
describe(`Fourth run (gatsby-browser change - cache get invalidated)`, () => {
@@ -718,6 +742,8 @@ describe(`Fourth run (gatsby-browser change - cache get invalidated)`, () => {
718742
assertWebpackBundleChanges({ browser: true, ssr: true, runNumber })
719743

720744
assertHTMLCorrectness(runNumber)
745+
746+
assertNodeCorrectness(runNumber)
721747
})
722748

723749
describe(`Fifth run (.cache is deleted but public isn't)`, () => {
@@ -792,6 +818,8 @@ describe(`Fifth run (.cache is deleted but public isn't)`, () => {
792818
assertWebpackBundleChanges({ browser: true, ssr: true, runNumber })
793819

794820
assertHTMLCorrectness(runNumber)
821+
822+
assertNodeCorrectness(runNumber)
795823
})
796824

797825
describe(`Sixth run (ssr-only change - only ssr compilation hash changes)`, () => {
@@ -882,6 +910,8 @@ describe(`Sixth run (ssr-only change - only ssr compilation hash changes)`, () =
882910
assertWebpackBundleChanges({ browser: false, ssr: true, runNumber })
883911

884912
assertHTMLCorrectness(runNumber)
913+
914+
assertNodeCorrectness(runNumber)
885915
})
886916

887917
describe(`Seventh run (no change in any file that is bundled, we change untracked file, but previous build used unsafe method so all should rebuild)`, () => {
@@ -970,4 +1000,6 @@ describe(`Seventh run (no change in any file that is bundled, we change untracke
9701000
assertWebpackBundleChanges({ browser: false, ssr: false, runNumber })
9711001

9721002
assertHTMLCorrectness(runNumber)
1003+
1004+
assertNodeCorrectness(runNumber)
9731005
})

integration-tests/artifacts/gatsby-node.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ exports.sourceNodes = ({
3838
createContentDigest,
3939
webhookBody,
4040
reporter,
41+
getNode,
4142
}) => {
4243
if (webhookBody && webhookBody.runNumber) {
4344
runNumber = webhookBody.runNumber
@@ -115,6 +116,17 @@ exports.sourceNodes = ({
115116
label: `This is${isFirstRun ? `` : ` not`} a first run`, // this will be queried - we want to invalidate html here
116117
})
117118

119+
for (let prevRun = 1; prevRun < runNumber; prevRun++) {
120+
const node = getNode(`node-created-in-run-${prevRun}`)
121+
if (node) {
122+
actions.touchNode(node)
123+
}
124+
}
125+
createNodeHelper(`NodeCounterTest`, {
126+
id: `node-created-in-run-${runNumber}`,
127+
label: `Node created in run ${runNumber}`,
128+
})
129+
118130
for (const prevNode of previouslyCreatedNodes.values()) {
119131
if (!currentlyCreatedNodes.has(prevNode.id)) {
120132
actions.deleteNode({ node: prevNode })
@@ -186,7 +198,7 @@ exports.onPreBuild = () => {
186198
}
187199

188200
let counter = 1
189-
exports.onPostBuild = async ({ graphql }) => {
201+
exports.onPostBuild = async ({ graphql, getNodes }) => {
190202
console.log(`[test] onPostBuild`)
191203

192204
if (!didRemoveTrailingSlashForTestedPage) {
@@ -212,6 +224,7 @@ exports.onPostBuild = async ({ graphql }) => {
212224
`build-manifest-for-test-${counter++}.json`
213225
),
214226
{
227+
allNodeCounters: getNodes().map(node => [node.id, node.internal.counter]),
215228
allPages: data.allSitePage.nodes.map(node => node.path),
216229
changedBrowserCompilationHash,
217230
changedSsrCompilationHash,

packages/gatsby/src/redux/__tests__/__snapshots__/index.js.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ Object {
9191
"staticQueriesByTemplate": Map {},
9292
"staticQueryComponents": Map {},
9393
"status": Object {
94+
"LAST_NODE_COUNTER": 0,
9495
"PLUGINS_HASH": "",
9596
"plugins": Object {},
9697
},

packages/gatsby/src/redux/__tests__/run-fast-filters.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const {
33
applyFastFilters,
44
} = require(`../run-fast-filters`)
55
const { store } = require(`../index`)
6+
const { getNode } = require(`../nodes`)
67
const { createDbQueriesFromObject } = require(`../../db/common/query`)
78
const { actions } = require(`../actions`)
89
const {
@@ -459,3 +460,54 @@ describe(`applyFastFilters`, () => {
459460
expect(result.length).toBe(2)
460461
})
461462
})
463+
464+
describe(`edge cases (yay)`, () => {
465+
beforeAll(() => {
466+
store.dispatch({ type: `DELETE_CACHE` })
467+
mockNodes().forEach(node =>
468+
actions.createNode(node, { name: `test` })(store.dispatch)
469+
)
470+
})
471+
472+
it(`throws when node counters are messed up`, () => {
473+
const filter = {
474+
slog: { $eq: `def` }, // matches id_2 and id_4
475+
deep: { flat: { search: { chain: { $eq: 500 } } } }, // matches id_2
476+
}
477+
478+
const result = applyFastFilters(
479+
createDbQueriesFromObject(filter),
480+
[typeName],
481+
new Map()
482+
)
483+
484+
// Sanity-check
485+
expect(result.length).toEqual(1)
486+
expect(result[0].id).toEqual(`id_2`)
487+
488+
// After process restart node.internal.counter is reset and conflicts with counters from the previous run
489+
// in some situations this leads to incorrect intersection of filtered results.
490+
// Below we set node.internal.counter to same value that existing node id_4 has and leads
491+
// to bad intersection of filtered results
492+
const badNode = {
493+
id: `bad-node`,
494+
deep: { flat: { search: { chain: 500 } } },
495+
internal: {
496+
type: typeName,
497+
contentDigest: `bad-node`,
498+
counter: getNode(`id_4`).internal.counter,
499+
},
500+
}
501+
store.dispatch({
502+
type: `CREATE_NODE`,
503+
payload: badNode,
504+
})
505+
506+
const run = () =>
507+
applyFastFilters(createDbQueriesFromObject(filter), [typeName], new Map())
508+
509+
expect(run).toThrow(
510+
`Invariant violation: inconsistent node counters detected`
511+
)
512+
})
513+
})

packages/gatsby/src/redux/actions/public.js

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -512,9 +512,17 @@ actions.deleteNode = (node: any, plugin?: Plugin) => {
512512
}
513513
}
514514

515-
// We add a counter to internal to make sure we maintain insertion order for
516-
// backends that don't do that out of the box
517-
let NODE_COUNTER = 0
515+
// We add a counter to node.internal for fast comparisons/intersections
516+
// of various node slices. The counter must increase even across builds.
517+
function getNextNodeCounter() {
518+
const lastNodeCounter = store.getState().status.LAST_NODE_COUNTER ?? 0
519+
if (lastNodeCounter >= Number.MAX_SAFE_INTEGER) {
520+
throw new Error(
521+
`Could not create more nodes. Maximum node count is reached: ${lastNodeCounter}`
522+
)
523+
}
524+
return lastNodeCounter + 1
525+
}
518526

519527
const typeOwners = {}
520528

@@ -614,9 +622,6 @@ const createNode = (
614622
node.internal = {}
615623
}
616624

617-
NODE_COUNTER++
618-
node.internal.counter = NODE_COUNTER
619-
620625
// Ensure the new node has a children array.
621626
if (!node.array && !_.isArray(node.children)) {
622627
node.children = []
@@ -774,6 +779,8 @@ const createNode = (
774779
.map(createDeleteAction)
775780
}
776781

782+
node.internal.counter = getNextNodeCounter()
783+
777784
updateNodeAction = {
778785
...actionOptions,
779786
type: `CREATE_NODE`,

packages/gatsby/src/redux/nodes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,11 @@ export function intersectNodesByCounter(
11041104
} else if (counterA > counterB) {
11051105
pointerB++
11061106
} else {
1107+
if (nodeA !== nodeB) {
1108+
throw new Error(
1109+
`Invariant violation: inconsistent node counters detected`
1110+
)
1111+
}
11071112
// nodeA===nodeB. Make sure we didn't just add this node already.
11081113
// Since input arrays are sorted, the same node should be grouped
11091114
// back to back, so even if both input arrays contained the same node

packages/gatsby/src/redux/reducers/status.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ActionsUnion, IGatsbyState } from "../types"
33

44
const defaultState: IGatsbyState["status"] = {
55
PLUGINS_HASH: ``,
6+
LAST_NODE_COUNTER: 0,
67
plugins: {},
78
}
89

@@ -42,6 +43,9 @@ export const statusReducer = (
4243
),
4344
},
4445
}
46+
case `CREATE_NODE`:
47+
state.LAST_NODE_COUNTER = action.payload.internal.counter
48+
return state
4549
default:
4650
return state
4751
}

packages/gatsby/src/redux/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ export interface IGatsbyState {
228228
status: {
229229
plugins: Record<string, IGatsbyPlugin>
230230
PLUGINS_HASH: Identifier
231+
LAST_NODE_COUNTER: number
231232
}
232233
queries: {
233234
byNode: Map<Identifier, Set<Identifier>>

0 commit comments

Comments
 (0)