Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf: improve perf inside search #2663

Closed
wants to merge 9 commits into from
2 changes: 1 addition & 1 deletion .bundlemonrc
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
},
{
"path": "services/qualificationMigration/drive.js",
"maxSize": "242 KB"
"maxSize": "244 KB"
},
{
"path": "vendors/drive.<hash>.<hash>.min.css",
Expand Down
11 changes: 9 additions & 2 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"no-console": 1,
"no-param-reassign": "error",
"react-hooks/exhaustive-deps": "error"

},
"globals": {
"fixture": false
Expand All @@ -13,5 +12,13 @@
"react": {
"version": "detect"
}
}
},
"overrides": [
{
"files": ["*.spec.js[x]"],
"rules": {
"react/display-name": "off"
}
}
]
}
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## ✨ Features

* Upgrade [email protected] to be able to findAll in FileCollection
* Upgrade [email protected] to be able to call onSelect function
* Improve speed of search suggestion by querying fewer data
* Update cozy-stack-client and cozy-pouch-link to sync with cozy-client version
* Update cozy-ui
- Modify Viewers to handle [68.0.0 BC](https://github.com/cozy/cozy-ui/releases/tag/v68.0.0)
Expand All @@ -12,7 +15,7 @@

* Improve cozy-bar implementation to fix UI bugs in Amirale
* Fix navigation through mobile Flagship on Note creation and opening
* Remove unused contacts permissions on Photos
* Remove unused contacts permissions on Photos

## 🔧 Tech

Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@
"classnames": "2.3.1",
"copy-text-to-clipboard": "1.0.4",
"cozy-authentication": "2.8.3",
"cozy-bar": "7.19.1",
"cozy-bar": "7.20.0",
"cozy-ci": "0.4.1",
"cozy-client": "^32.2.8",
"cozy-client": "^32.7.3",
"cozy-client-js": "0.19.0",
"cozy-device-helper": "^2.1.0",
"cozy-doctypes": "1.82.2",
Expand All @@ -125,12 +125,12 @@
"cozy-intent": "^1.17.1",
"cozy-keys-lib": "4.1.9",
"cozy-logger": "1.9.0",
"cozy-pouch-link": "^32.2.8",
"cozy-pouch-link": "^32.7.3",
"cozy-realtime": "3.14.4",
"cozy-scanner": "2.0.2",
"cozy-scripts": "^6.3.8",
"cozy-sharing": "4.1.5",
"cozy-stack-client": "^32.2.5",
"cozy-stack-client": "^32.7.3",
"cozy-ui": "^68.4.0",
"date-fns": "1.30.1",
"diacritics": "1.3.0",
Expand Down
32 changes: 32 additions & 0 deletions src/drive/web/modules/queries.js
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,38 @@ export const buildEncryptionByIdQuery = id => ({
}
})

/**
* Provide options to fetch files
*
* @returns {MangoQueryOptions} A minimalist file list
*/
export const prepareSuggestionQuery = () => ({
partialFilter: {
_id: {
$ne: TRASH_DIR_ID
},
path: {
// this predicate is necessary until the trashed attribute is more reliable
$or: [{ $exists: false }, { $regex: '^(?!/.cozy_trash)' }]
},
trashed: {
$or: [{ $exists: false }, { $eq: false }]
}
},
fields: [
'_id',
'trashed',
'dir_id',
'name',
'path',
'type',
'mime',
'metadata'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Une chose est sûre, c'est qu'on peut pas garder metadata au complet. Soit on arrive à aller lire quelques champs particuliers, soit on change la façon de checker si c'est une note. Car metadata sur les notes, ça peut vite peser des centaines de ko par note.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

j'ai recréé un helper isNote, car les metadata peuvent être volumineuses.

Copy link
Contributor

@Crash-- Crash-- Aug 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if I like this idea.

You can add to the select(['metadata.title', 'metadata.version']) . title + version are not so big. So the helper can be if(metata.title && metadata.version && file.endsWith... && ...) .

See a test query with metadata.datetime to see the result.

Capture d’écran 2022-08-22 à 11 31 33

What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have only one note in my local Drive.
When adding:

    'metadata.content',
    'metadata.schema',
    'metadata.title',
    'metadata.version'

as fields, the size of request increase
from 650ko / 39,87 ko transferred in 538ms
to 657,97 ko / 41,61ko transferred in 578ms.

Capture d’écran 2022-08-22 à 12 03 37
Capture d’écran 2022-08-22 à 12 05 08

That is quite huge, I think.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not what I suggested. I suggested to only add version + title. Not schema + content.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perfect
Capture d’écran 2022-08-22 à 14 11 58
PR updated

],
indexedFields: ['_id'],
limit: 1000
})

export {
buildDriveQuery,
buildRecentQuery,
Expand Down
84 changes: 49 additions & 35 deletions src/drive/web/modules/services/components/SuggestionProvider.jsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
/* global cozy */
import React from 'react'
import FuzzyPathSearch from '../FuzzyPathSearch'
import { withClient } from 'cozy-client'

import { TYPE_DIRECTORY, makeNormalizedFile } from './helpers'
import { getIconUrl } from './iconContext'
import { DOCTYPE_FILES } from 'drive/lib/doctypes'
import { prepareSuggestionQuery } from '../../queries'

class SuggestionProvider extends React.Component {
componentDidMount() {
const { intent } = this.props
this.hasIndexedFiles = false
this.hasIndexFilesBeenLaunched = false

// re-attach the message listener for the intent to receive the suggestion requests
window.addEventListener('message', event => {
Expand All @@ -22,8 +23,25 @@ class SuggestionProvider extends React.Component {
})
}

/**
* Provide Suggestions to calling Intent
*
* This method called when intent provide query will indexFiles once
* to fill FuzzyPathSearch. Then will re-post message to the intent
* with updated search results containing `files` as `suggestions`
* for SearchBar need.
*
* ⚠️ For note file, we don't provide url to open, but onSelect method
* to be called on click. Less API calls expected. But a note will be opened
* slower. See helpers.js
*
* @param query - Query to find file
* @param id
* @param intent - Intent calling
* @returns {Promise<void>} nothing
*/
async provideSuggestions(query, id, intent) {
if (!this.hasIndexedFiles) {
if (!this.hasIndexFilesBeenLaunched) {
await this.indexFiles()
}

Expand All @@ -38,52 +56,48 @@ class SuggestionProvider extends React.Component {
title: result.name,
subtitle: result.path,
term: result.name,
onSelect: 'open:' + result.url,
onSelect: result.onSelect || 'open:' + result.url,
icon: result.icon
}))
},
intent.attributes.client
)
}

// fetches pretty much all the files and preloads FuzzyPathSearch
/**
* Fetches all files without trashed and preloads FuzzyPathSearch
*
* Using _find route (from findAll) improves performance:
* - using partial index to reduce amount of data fetched
* - removing trashed data directly
*
* Also, this method:
* - set first the `hasIndexFilesBeenLaunched` to prevent multiple calls
* - removes orphan file
* - normalize file to match <SearchBar> expectation
* - preloads FuzzyPathSearch
*
* @returns {Promise<void>} nothing
*/
async indexFiles() {
const { client } = this.props
// TODO: fix me
// eslint-disable-next-line no-async-promise-executor
return new Promise(async resolve => {
const resp = await cozy.client.fetchJSON(
'GET',
`/data/io.cozy.files/_all_docs?include_docs=true`
)
const files = resp.rows
// TODO: fix me
// eslint-disable-next-line no-prototype-builtins
.filter(row => !row.doc.hasOwnProperty('views'))
.map(row => ({ id: row.id, ...row.doc }))
this.hasIndexFilesBeenLaunched = true

const folders = files.filter(file => file.type === TYPE_DIRECTORY)
const options = prepareSuggestionQuery()
const files = await client.collection(DOCTYPE_FILES).findAll(null, options)

const notInTrash = file =>
!file.trashed && !/^\/\.cozy_trash/.test(file.path)
const notOrphans = file =>
folders.find(folder => folder._id === file.dir_id) !== undefined
const folders = files.filter(file => file.type === TYPE_DIRECTORY)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we can benefits from http2 multiplexing, I'm wondering if we should not make two requests:

  • one for the files
  • one for the folders

We'll have the almost the same number of round trips (1 or 2) but we'll have a few of them parallelized. @paultranvan what do you think?

Copy link
Contributor

@paultranvan paultranvan Aug 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume that, most of the time, the number of files will be order of magnitudes higher than the number of folders. So, the folder query might end up sooner than the files one, which is good, as we want to display the folders first. The downside is that we have now 2 indexes to compute instead of one, which might slow down the search when we make a query with non-indexed docs.
I think what you propose is still relevant either way, as it should allow to get partial (i.e. folders) results faster and I think faster user response is more important than the overall performance.
That being said, I also think paginated mango queries is sub-optimal for this use-case :/ I look forward for view-base approach and/or pouchdb exploration


const normalizedFilesPrevious = files
.filter(notInTrash)
.filter(notOrphans)
const notOrphans = file =>
folders.find(folder => folder._id === file.dir_id) !== undefined

const normalizedFiles = await Promise.all(
normalizedFilesPrevious.map(
async file =>
await makeNormalizedFile(client, folders, file, getIconUrl)
)
)
const normalizedFilesPrevious = files.filter(notOrphans)

this.fuzzyPathSearch = new FuzzyPathSearch(normalizedFiles)
this.hasIndexedFiles = true
resolve()
})
const normalizedFiles = normalizedFilesPrevious.map(file =>
makeNormalizedFile(client, folders, file, getIconUrl)
)

this.fuzzyPathSearch = new FuzzyPathSearch(normalizedFiles)
}

render() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import React from 'react'
import { render, waitFor } from '@testing-library/react'
import SuggestionProvider from './SuggestionProvider'
import { dummyFile, dummyNote } from 'test/dummies/dummyFile'

const parentFolder = dummyFile({ _id: 'id-file' })
const folder = dummyFile({ dir_id: 'id-file' })
const note = dummyNote({
dir_id: 'id-file',
name: 'name.cozy-note'
})
const mockFindAll = jest.fn().mockReturnValue([parentFolder, folder, note])
const mockClient = {
collection: jest.fn().mockReturnValue({ findAll: mockFindAll })
}
const mockIntentAttributesClient = 'intent-attributes-client'

jest.mock('cozy-client', () => ({
...jest.requireActual('cozy-client'),
withClient: Component => () => {
const intent = {
_id: 'id_intent',
attributes: { client: mockIntentAttributesClient }
}
return <Component client={mockClient} intent={intent}></Component>
}
}))
jest.mock('./iconContext', () => ({ getIconUrl: () => 'iconUrl' }))

describe('SuggestionProvider', () => {
let events = {}
let event

beforeEach(() => {
window.addEventListener = jest.fn((event, callback) => {
events[event] = callback
})
window.parent.postMessage = jest.fn()
event = {
origin: mockIntentAttributesClient,
data: { query: 'name', id: 'id' }
}
})

it('should query all files to display fuzzy suggestion', () => {
// Given
render(<SuggestionProvider />)

// When
events.message(event)

// Then
expect(mockClient.collection).toHaveBeenCalledWith('io.cozy.files')
expect(mockFindAll).toHaveBeenCalledWith(null, {
fields: [
'_id',
'trashed',
'dir_id',
'name',
'path',
'type',
'mime',
'metadata'
],
indexedFields: ['_id'],
limit: 1000,
partialFilter: {
_id: { $ne: 'io.cozy.files.trash-dir' },
path: { $or: [{ $exists: false }, { $regex: '^(?!/.cozy_trash)' }] },
trashed: { $or: [{ $exists: false }, { $eq: false }] }
}
})
})

it('should provide onSelect with open url when file is not a note + and function when it is a note', async () => {
// Given
render(<SuggestionProvider />)

// When
events.message(event)

// Then
await waitFor(() => {
expect(window.parent.postMessage).toHaveBeenCalledWith(
{
id: 'id',
suggestions: [
{
icon: 'iconUrl',
id: 'id-file',
onSelect: 'open:http://localhost/#/folder/id-file',
subtitle: '/path',
term: 'name',
title: 'name'
},
{
icon: 'iconUrl',
id: 'id-file',
onSelect: expect.any(Function),
subtitle: '/path',
term: 'name.cozy-note',
title: 'name.cozy-note'
}
],
type: 'intent-id_intent:data'
},
'intent-attributes-client'
)
})
})
})
Loading