Mailspring/packages/client-app/internal_packages/thread-search/lib/search-query-subscription.es6
Mark Hahnenberg 280a5ba6e2 [client-sync] Fetch unknown search results
Summary:
When searching using IMAP/Gmail commands we sometimes get back UIDs for
messages that we have yet to sync. Previously we would just ignore these
results, which would decrease the quality search results for quite some
time during initial sync. This diff enables us to eagerly sync the unknown
messages we get back from the provider by creating a syncback task which
interrupts the sync loop and runs a sync task for the unknown UIDs.

Test Plan: Run locally, verify that we sync unknown messages

Reviewers: spang, evan, juan

Reviewed By: juan

Differential Revision: https://phab.nylas.com/D4101
2017-03-07 11:30:58 -08:00

225 lines
6.8 KiB
JavaScript

import _ from 'underscore'
import {
Actions,
NylasAPI,
Thread,
DatabaseStore,
ComponentRegistry,
FocusedContentStore,
MutableQuerySubscription,
SearchQueryParser,
} from 'nylas-exports'
import SearchActions from './search-actions'
const {LongConnectionStatus} = NylasAPI
class SearchQuerySubscription extends MutableQuerySubscription {
constructor(searchQuery, accountIds) {
super(null, {emitResultSet: true})
this._searchQuery = searchQuery
this._accountIds = accountIds
this.resetData()
this._connections = []
this._unsubscribers = [
FocusedContentStore.listen(this.onFocusedContentChanged),
]
this._extDisposables = []
_.defer(() => this.performSearch())
}
replaceRange = () => {
// TODO
}
resetData() {
this._searchStartedAt = null
this._localResultsReceivedAt = null
this._remoteResultsReceivedAt = null
this._remoteResultsCount = 0
this._localResultsCount = 0
this._firstThreadSelectedAt = null
this._lastFocusedThread = null
this._focusedThreadCount = 0
}
performSearch() {
this._searchStartedAt = Date.now()
this.performLocalSearch()
this.performRemoteSearch()
this.performExtensionSearch()
}
performLocalSearch() {
let dbQuery = DatabaseStore.findAll(Thread).distinct()
if (this._accountIds.length === 1) {
dbQuery = dbQuery.where({accountId: this._accountIds[0]})
}
try {
const parsedQuery = SearchQueryParser.parse(this._searchQuery);
console.info('Successfully parsed and codegened search query', parsedQuery);
dbQuery = dbQuery.structuredSearch(parsedQuery);
} catch (e) {
console.info('Failed to parse local search query, falling back to generic query', e);
dbQuery = dbQuery.search(this._searchQuery);
}
dbQuery = dbQuery
.order(Thread.attributes.lastMessageReceivedTimestamp.descending())
.limit(100)
console.info('dbQuery.sql() =', dbQuery.sql());
dbQuery.then((results) => {
if (!this._localResultsReceivedAt) {
this._localResultsReceivedAt = Date.now()
}
this._localResultsCount += results.length
// Even if we don't have any results now we might sync additional messages
// from the provider which will cause new results to appear later.
this.replaceQuery(dbQuery)
})
}
_addThreadIdsToSearch(ids = []) {
const currentResults = this._set && this._set.ids().length > 0;
let searchIds = ids;
if (currentResults) {
const currentResultIds = this._set.ids()
searchIds = _.uniq(currentResultIds.concat(ids))
}
const dbQuery = (
DatabaseStore.findAll(Thread)
.where({id: searchIds})
.order(Thread.attributes.lastMessageReceivedTimestamp.descending())
)
this.replaceQuery(dbQuery)
}
performRemoteSearch() {
const accountsSearched = new Set()
let resultIds = []
const allAccountsSearched = () => accountsSearched.size === this._accountIds.length
const resultsReturned = () => {
// Don't emit a "result" until we have at least one thread to display.
// Otherwise it will show "No Results Found"
if (resultIds.length > 0 || allAccountsSearched()) {
this._addThreadIdsToSearch(resultIds)
}
}
this._connections = this._accountIds.map((accountId) => {
return NylasAPI.startLongConnection({
accountId,
path: `/threads/search/streaming?q=${encodeURIComponent(this._searchQuery)}`,
onResults: (results) => {
if (!this._remoteResultsReceivedAt) {
this._remoteResultsReceivedAt = Date.now()
}
const threads = results[0]
resultIds = resultIds.concat(_.pluck(threads, 'id'))
this._remoteResultsCount += resultIds.length
resultsReturned()
},
onStatusChanged: (status) => {
const hasClosed = [
LongConnectionStatus.Closed,
LongConnectionStatus.Ended,
].includes(status)
if (hasClosed) {
accountsSearched.add(accountId)
if (allAccountsSearched()) {
SearchActions.searchCompleted()
}
}
},
})
})
}
performExtensionSearch() {
const searchExtensions = ComponentRegistry.findComponentsMatching({
role: "SearchBarResults",
})
this._extDisposables = searchExtensions.map((ext) => {
return ext.observeThreadIdsForQuery(this._searchQuery)
.subscribe((ids = []) => {
const allIds = _.compact(_.flatten(ids))
if (allIds.length === 0) return;
this._addThreadIdsToSearch(allIds)
})
})
}
// We want to keep track of how many threads from the search results were
// focused
onFocusedContentChanged = () => {
const thread = FocusedContentStore.focused('thread')
const shouldRecordChange = (
thread &&
(this._lastFocusedThread || {}).id !== thread.id
)
if (shouldRecordChange) {
if (this._focusedThreadCount === 0) {
this._firstThreadSelectedAt = Date.now()
}
this._focusedThreadCount += 1
this._lastFocusedThread = thread
}
}
reportSearchMetrics() {
if (!this._searchStartedAt) {
return;
}
let timeToLocalResultsMs = null
let timeToFirstRemoteResultsMs = null;
let timeToFirstThreadSelectedMs = null;
const timeInsideSearchMs = Date.now() - this._searchStartedAt
const numThreadsSelected = this._focusedThreadCount
const numLocalResults = this._localResultsCount
const numRemoteResults = this._remoteResultsCount
if (this._firstThreadSelectedAt) {
timeToFirstThreadSelectedMs = this._firstThreadSelectedAt - this._searchStartedAt
}
if (this._localResultsReceivedAt) {
timeToLocalResultsMs = this._localResultsReceivedAt - this._searchStartedAt
}
if (this._remoteResultsReceivedAt) {
timeToFirstRemoteResultsMs = this._remoteResultsReceivedAt - this._searchStartedAt
}
Actions.recordPerfMetric({
action: 'search-performed',
actionTimeMs: timeToLocalResultsMs,
numLocalResults,
numRemoteResults,
numThreadsSelected,
clippedData: [
{key: 'timeToLocalResultsMs', val: timeToLocalResultsMs},
{key: 'timeToFirstThreadSelectedMs', val: timeToFirstThreadSelectedMs},
{key: 'timeInsideSearchMs', val: timeInsideSearchMs, maxValue: 60 * 1000},
{key: 'timeToFirstRemoteResultsMs', val: timeToFirstRemoteResultsMs, maxValue: 10 * 1000},
],
})
this.resetData()
}
// This function is called when the user leaves the SearchPerspective
onLastCallbackRemoved() {
this.reportSearchMetrics();
this._connections.forEach((conn) => conn.end())
this._unsubscribers.forEach((unsub) => unsub())
this._extDisposables.forEach((disposable) => disposable.dispose())
}
}
export default SearchQuerySubscription