Merge pull request #2470 from Human-Connection/improve_notification_query_performance

Improve notification query performance by reducing db calls
This commit is contained in:
mattwr18 2019-12-10 19:26:05 +01:00 committed by GitHub
commit 950f33f637
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 147 additions and 78 deletions

View File

@ -1,9 +1,9 @@
import { getNeode } from '../../../bootstrap/neo4j'
import log from './databaseLogger'
export const undefinedToNullResolver = list => {
const resolvers = {}
list.forEach(key => {
resolvers[key] = async (parent, params, context, resolveInfo) => {
resolvers[key] = async parent => {
return typeof parent[key] === 'undefined' ? null : parent[key]
}
})
@ -11,7 +11,6 @@ export const undefinedToNullResolver = list => {
}
export default function Resolver(type, options = {}) {
const instance = getNeode()
const {
idAttribute = 'id',
undefinedToNull = [],
@ -22,32 +21,49 @@ export default function Resolver(type, options = {}) {
} = options
const _hasResolver = (resolvers, { key, connection }, { returnType }) => {
return async (parent, params, context, resolveInfo) => {
return async (parent, params, { driver, cypherParams }, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const id = parent[idAttribute]
const statement = `MATCH(:${type} {${idAttribute}: {id}})${connection} RETURN related`
const result = await instance.cypher(statement, { id })
let response = result.records.map(r => r.get('related').properties)
if (returnType === 'object') response = response[0] || null
return response
const session = driver.session()
const readTxResultPromise = session.readTransaction(async txc => {
const cypher = `
MATCH(:${type} {${idAttribute}: $id})${connection}
RETURN related {.*} as related
`
const result = await txc.run(cypher, { id, cypherParams })
log(result)
return result.records.map(r => r.get('related'))
})
try {
let response = await readTxResultPromise
if (returnType === 'object') response = response[0] || null
return response
} finally {
session.close()
}
}
}
const booleanResolver = obj => {
const resolvers = {}
for (const [key, condition] of Object.entries(obj)) {
resolvers[key] = async (parent, params, { cypherParams }, resolveInfo) => {
resolvers[key] = async (parent, params, { cypherParams, driver }, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const result = await instance.cypher(
`
${condition.replace('this', 'this {id: $parent.id}')} as ${key}`,
{
parent,
cypherParams,
},
)
const [record] = result.records
return record.get(key)
const id = parent[idAttribute]
const session = driver.session()
const readTxResultPromise = session.readTransaction(async txc => {
const nodeCondition = condition.replace('this', 'this {id: $id}')
const cypher = `${nodeCondition} as ${key}`
const result = await txc.run(cypher, { id, cypherParams })
log(result)
const [response] = result.records.map(r => r.get(key))
return response
})
try {
return await readTxResultPromise
} finally {
session.close()
}
}
}
return resolvers
@ -56,16 +72,25 @@ export default function Resolver(type, options = {}) {
const countResolver = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = async (parent, params, context, resolveInfo) => {
resolvers[key] = async (parent, params, { driver, cypherParams }, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const id = parent[idAttribute]
const statement = `
MATCH(u:${type} {${idAttribute}: {id}})${connection}
RETURN COUNT(DISTINCT(related)) as count
`
const result = await instance.cypher(statement, { id })
const [response] = result.records.map(r => r.get('count').toNumber())
return response
const session = driver.session()
const readTxResultPromise = session.readTransaction(async txc => {
const id = parent[idAttribute]
const cypher = `
MATCH(u:${type} {${idAttribute}: $id})${connection}
RETURN COUNT(DISTINCT(related)) as count
`
const result = await txc.run(cypher, { id, cypherParams })
log(result)
const [response] = result.records.map(r => r.get('count').toNumber())
return response
})
try {
return await readTxResultPromise
} finally {
session.close()
}
}
}
return resolvers

View File

@ -0,0 +1,15 @@
import Debug from 'debug'
const debugCypher = Debug('human-connection:neo4j:cypher')
const debugStats = Debug('human-connection:neo4j:stats')
export default function log(response) {
const { statement, counters, resultConsumedAfter, resultAvailableAfter } = response.summary
const { text, parameters } = statement
debugCypher('%s', text)
debugCypher('%o', parameters)
debugStats('%o', counters)
debugStats('%o', {
resultConsumedAfter: resultConsumedAfter.toNumber(),
resultAvailableAfter: resultAvailableAfter.toNumber(),
})
}

View File

@ -1,3 +1,5 @@
import log from './helpers/databaseLogger'
const resourceTypes = ['Post', 'Comment']
const transformReturnType = record => {
@ -42,16 +44,29 @@ export default {
}
const offset = args.offset && typeof args.offset === 'number' ? `SKIP ${args.offset}` : ''
const limit = args.first && typeof args.first === 'number' ? `LIMIT ${args.first}` : ''
const cypher = `
MATCH (resource {deleted: false, disabled: false})-[notification:NOTIFIED]->(user:User {id:$id})
${whereClause}
RETURN resource, notification, user
${orderByClause}
${offset} ${limit}
`
const readTxResultPromise = session.readTransaction(async transaction => {
const notificationsTransactionResponse = await transaction.run(
`
MATCH (resource {deleted: false, disabled: false})-[notification:NOTIFIED]->(user:User {id:$id})
${whereClause}
WITH user, notification, resource,
[(resource)<-[:WROTE]-(author:User) | author {.*}] as authors,
[(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post{.*, author: properties(author)} ] as posts
WITH resource, user, notification, authors, posts,
resource {.*, __typename: labels(resource)[0], author: authors[0], post: posts[0]} as finalResource
RETURN notification {.*, from: finalResource, to: properties(user)}
${orderByClause}
${offset} ${limit}
`,
{ id: currentUser.id },
)
log(notificationsTransactionResponse)
return notificationsTransactionResponse.records.map(record => record.get('notification'))
})
try {
const result = await session.run(cypher, { id: currentUser.id })
return result.records.map(transformReturnType)
const notifications = await readTxResultPromise
return notifications
} finally {
session.close()
}
@ -68,6 +83,7 @@ export default {
RETURN resource, notification, user
`
const result = await session.run(cypher, { resourceId: args.id, id: currentUser.id })
log(result)
const notifications = await result.records.map(transformReturnType)
return notifications[0]
} finally {

View File

@ -184,6 +184,7 @@ describe('given some notifications', () => {
data: {
notifications: expect.arrayContaining(expected),
},
errors: undefined,
})
})
})
@ -233,7 +234,10 @@ describe('given some notifications', () => {
`
await expect(
mutate({ mutation: deletePostMutation, variables: { id: 'p3' } }),
).resolves.toMatchObject({ data: { DeletePost: { id: 'p3', deleted: true } } })
).resolves.toMatchObject({
data: { DeletePost: { id: 'p3', deleted: true } },
errors: undefined,
})
authenticatedUser = await user.toJson()
}
@ -242,11 +246,12 @@ describe('given some notifications', () => {
query({ query: notificationQuery, variables: { ...variables, read: false } }),
).resolves.toMatchObject({
data: { notifications: [expect.any(Object), expect.any(Object)] },
errors: undefined,
})
await deletePostAction()
await expect(
query({ query: notificationQuery, variables: { ...variables, read: false } }),
).resolves.toMatchObject({ data: { notifications: [] } })
).resolves.toMatchObject({ data: { notifications: [] }, errors: undefined })
})
})
})

View File

@ -1,3 +1,5 @@
import log from './helpers/databaseLogger'
const transformReturnType = record => {
return {
...record.get('report').properties,
@ -11,12 +13,11 @@ const transformReturnType = record => {
export default {
Mutation: {
fileReport: async (_parent, params, context, _resolveInfo) => {
let createdRelationshipWithNestedAttributes
const { resourceId, reasonCategory, reasonDescription } = params
const { driver, user } = context
const session = driver.session()
const reportWriteTxResultPromise = session.writeTransaction(async txc => {
const reportTransactionResponse = await txc.run(
const reportWriteTxResultPromise = session.writeTransaction(async transaction => {
const reportTransactionResponse = await transaction.run(
`
MATCH (submitter:User {id: $submitterId})
MATCH (resource {id: $resourceId})
@ -36,23 +37,23 @@ export default {
reasonDescription,
},
)
log(reportTransactionResponse)
return reportTransactionResponse.records.map(transformReturnType)
})
try {
const txResult = await reportWriteTxResultPromise
if (!txResult[0]) return null
createdRelationshipWithNestedAttributes = txResult[0]
const [createdRelationshipWithNestedAttributes] = await reportWriteTxResultPromise
if (!createdRelationshipWithNestedAttributes) return null
return createdRelationshipWithNestedAttributes
} finally {
session.close()
}
return createdRelationshipWithNestedAttributes
},
},
Query: {
reports: async (_parent, params, context, _resolveInfo) => {
const { driver } = context
const session = driver.session()
let reports, orderByClause, filterClause
let orderByClause, filterClause
switch (params.orderBy) {
case 'createdAt_asc':
orderByClause = 'ORDER BY report.createdAt ASC'
@ -81,8 +82,8 @@ export default {
params.offset && typeof params.offset === 'number' ? `SKIP ${params.offset}` : ''
const limit = params.first && typeof params.first === 'number' ? `LIMIT ${params.first}` : ''
const reportReadTxPromise = session.readTransaction(async tx => {
const allReportsTransactionResponse = await tx.run(
const reportReadTxPromise = session.readTransaction(async transaction => {
const allReportsTransactionResponse = await transaction.run(
`
MATCH (report:Report)-[:BELONGS_TO]->(resource)
WHERE (resource:User OR resource:Post OR resource:Comment)
@ -100,16 +101,15 @@ export default {
${offset} ${limit}
`,
)
log(allReportsTransactionResponse)
return allReportsTransactionResponse.records.map(record => record.get('report'))
})
try {
const txResult = await reportReadTxPromise
if (!txResult[0]) return null
reports = txResult
const reports = await reportReadTxPromise
return reports
} finally {
session.close()
}
return reports
},
},
Report: {
@ -118,23 +118,23 @@ export default {
const session = context.driver.session()
const { id } = parent
let filed
const readTxPromise = session.readTransaction(async tx => {
const allReportsTransactionResponse = await tx.run(
const readTxPromise = session.readTransaction(async transaction => {
const filedReportsTransactionResponse = await transaction.run(
`
MATCH (submitter:User)-[filed:FILED]->(report:Report {id: $id})
RETURN filed, submitter
MATCH (submitter:User)-[filed:FILED]->(report:Report {id: $id})
RETURN filed, submitter
`,
{ id },
)
return allReportsTransactionResponse.records.map(record => ({
log(filedReportsTransactionResponse)
return filedReportsTransactionResponse.records.map(record => ({
submitter: record.get('submitter').properties,
filed: record.get('filed').properties,
}))
})
try {
const txResult = await readTxPromise
if (!txResult[0]) return null
filed = txResult.map(reportedRecord => {
const filedReports = await readTxPromise
filed = filedReports.map(reportedRecord => {
const { submitter, filed } = reportedRecord
const relationshipWithNestedAttributes = {
...filed,
@ -152,8 +152,8 @@ export default {
const session = context.driver.session()
const { id } = parent
let reviewed
const readTxPromise = session.readTransaction(async tx => {
const allReportsTransactionResponse = await tx.run(
const readTxPromise = session.readTransaction(async transaction => {
const reviewedReportsTransactionResponse = await transaction.run(
`
MATCH (resource)<-[:BELONGS_TO]-(report:Report {id: $id})<-[review:REVIEWED]-(moderator:User)
RETURN moderator, review
@ -161,14 +161,15 @@ export default {
`,
{ id },
)
return allReportsTransactionResponse.records.map(record => ({
log(reviewedReportsTransactionResponse)
return reviewedReportsTransactionResponse.records.map(record => ({
review: record.get('review').properties,
moderator: record.get('moderator').properties,
}))
})
try {
const txResult = await readTxPromise
reviewed = txResult.map(reportedRecord => {
const reviewedReports = await readTxPromise
reviewed = reviewedReports.map(reportedRecord => {
const { review, moderator } = reportedRecord
const relationshipWithNestedAttributes = {
...review,

View File

@ -21,7 +21,6 @@ describe('file a report on a resource', () => {
id
createdAt
updatedAt
disable
closed
rule
resource {
@ -489,7 +488,6 @@ describe('file a report on a resource', () => {
id
createdAt
updatedAt
disable
closed
resource {
__typename
@ -624,7 +622,6 @@ describe('file a report on a resource', () => {
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
disable: false,
closed: false,
resource: {
__typename: 'User',
@ -645,7 +642,6 @@ describe('file a report on a resource', () => {
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
disable: false,
closed: false,
resource: {
__typename: 'Post',
@ -666,7 +662,6 @@ describe('file a report on a resource', () => {
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
disable: false,
closed: false,
resource: {
__typename: 'Comment',

View File

@ -1,5 +1,15 @@
import gql from 'graphql-tag'
export const linkableUserFragment = lang => gql`
fragment user on User {
id
slug
name
avatar
disabled
deleted
}
`
export const userFragment = lang => gql`
fragment user on User {
id
@ -32,8 +42,6 @@ export const postCountsFragment = gql`
}
`
export const postFragment = lang => gql`
${userFragment(lang)}
fragment post on Post {
id
title
@ -68,8 +76,6 @@ export const postFragment = lang => gql`
}
`
export const commentFragment = lang => gql`
${userFragment(lang)}
fragment comment on Comment {
id
createdAt

View File

@ -1,9 +1,10 @@
import gql from 'graphql-tag'
import { postFragment, commentFragment, postCountsFragment } from './Fragments'
import { userFragment, postFragment, commentFragment, postCountsFragment } from './Fragments'
export default i18n => {
const lang = i18n.locale().toUpperCase()
return gql`
${userFragment(lang)}
${postFragment(lang)}
${postCountsFragment}
${commentFragment(lang)}
@ -23,6 +24,7 @@ export default i18n => {
export const filterPosts = i18n => {
const lang = i18n.locale().toUpperCase()
return gql`
${userFragment(lang)}
${postFragment(lang)}
${postCountsFragment}
@ -38,6 +40,7 @@ export const filterPosts = i18n => {
export const profilePagePosts = i18n => {
const lang = i18n.locale().toUpperCase()
return gql`
${userFragment(lang)}
${postFragment(lang)}
${postCountsFragment}
@ -66,6 +69,7 @@ export const PostsEmotionsByCurrentUser = () => {
export const relatedContributions = i18n => {
const lang = i18n.locale().toUpperCase()
return gql`
${userFragment(lang)}
${postFragment(lang)}
${postCountsFragment}

View File

@ -1,5 +1,5 @@
import gql from 'graphql-tag'
import { userFragment, postFragment, commentFragment } from './Fragments'
import { linkableUserFragment, userFragment, postFragment, commentFragment } from './Fragments'
export default i18n => {
const lang = i18n.locale().toUpperCase()
@ -49,6 +49,7 @@ export const minimisedUserQuery = () => {
export const notificationQuery = i18n => {
const lang = i18n.locale().toUpperCase()
return gql`
${linkableUserFragment()}
${commentFragment(lang)}
${postFragment(lang)}
@ -78,6 +79,7 @@ export const notificationQuery = i18n => {
export const markAsReadMutation = i18n => {
const lang = i18n.locale().toUpperCase()
return gql`
${linkableUserFragment()}
${commentFragment(lang)}
${postFragment(lang)}