Contact ranking now built into sync, remove separate cache

This commit is contained in:
Ben Gotow 2017-06-25 17:52:29 -07:00
parent 38c6ecea00
commit ade70d32bd
9 changed files with 19 additions and 358 deletions

View file

@ -1,112 +0,0 @@
import _ from 'underscore'
import moment from 'moment-timezone'
import {
IdentityStore,
AccountStore,
NylasAPI,
NylasAPIRequest,
FolderSyncProgressStore,
} from 'nylas-exports'
import RefreshingJSONCache from './refreshing-json-cache'
// Stores contact rankings
class ContactRankingsCache extends RefreshingJSONCache {
constructor(accountId) {
super({
key: `ContactRankingsFor${accountId}`,
version: 1,
refreshInterval: moment.duration(60, 'seconds').asMilliseconds(),
maxRefreshInterval: moment.duration(24, 'hours').asMilliseconds(),
})
this._accountId = accountId
}
_nextRefreshInterval() {
// For the first 15 minutes, refresh roughly once every minute so that the
// experience of composing drafts during initial is less annoying.
const initialLimit = (60 * 1000) + 15;
if (this.refreshInterval < initialLimit) {
return this.refreshInterval + 1;
}
// After the first 15 minutes, refresh twice as long each time up to the max.
return Math.min(this.refreshInterval * 2, this.maxRefreshInterval);
}
fetchData = (callback) => {
if (NylasEnv.inSpecMode()) { return }
if (!IdentityStore.identity()) { return }
const request = new NylasAPIRequest({
api: NylasAPI,
options: {
accountId: this._accountId,
path: "/contacts/rankings",
},
})
request.run()
.then((json) => {
if (!json || !(json instanceof Array)) return
// Convert rankings into the format needed for quick lookup
const rankings = {}
for (const [email, rank] of json) {
rankings[email.toLowerCase()] = rank
}
callback(rankings)
this.refreshInterval = this._nextRefreshInterval();
})
.catch((err) => {
console.warn(`Request for Contact Rankings failed for
account ${this._accountId}. ${err}`)
})
}
}
class ContactRankingsCacheManager {
constructor() {
this.accountCaches = {};
this.unsubscribers = [];
this.onAccountsChanged = _.debounce(this.onAccountsChanged, 100);
}
activate() {
this.onAccountsChanged();
this.unsubscribers = [AccountStore.listen(this.onAccountsChanged)];
}
deactivate() {
this.unsubscribers.forEach(unsub => unsub());
}
onAccountsChanged = async () => {
const previousIDs = Object.keys(this.accountCaches);
const latestIDs = AccountStore.accounts().map(a => a.id);
if (_.isEqual(previousIDs, latestIDs)) {
return;
}
const newIDs = _.difference(latestIDs, previousIDs);
const removedIDs = _.difference(previousIDs, latestIDs);
console.log(`ContactRankingsCache: Updating contact rankings; added = ${latestIDs}, removed = ${removedIDs}`);
for (const newID of newIDs) {
// Wait until the account has started syncing before trying to fetch
// contact rankings
await FolderSyncProgressStore.whenCategoryListSynced(newID)
this.accountCaches[newID] = new ContactRankingsCache(newID);
this.accountCaches[newID].start();
}
for (const removedID of removedIDs) {
if (this.accountCaches[removedID]) {
this.accountCaches[removedID].end();
this.accountCaches[removedID] = null;
}
}
}
}
export default new ContactRankingsCacheManager();

View file

@ -1,10 +0,0 @@
import ContactRankingsCache from './contact-rankings-cache'
export function activate() {
ContactRankingsCache.activate();
}
export function deactivate() {
ContactRankingsCache.deactivate();
}

View file

@ -1,57 +0,0 @@
_ = require 'underscore'
{NylasStore, DatabaseStore} = require 'nylas-exports'
class RefreshingJSONCache
constructor: ({@key, @version, @refreshInterval, @maxRefreshInterval}) ->
@_timeoutId = null
start: ->
# Clear any scheduled actions
@end()
# Look up existing data from db
DatabaseStore.findJSONBlob(@key).then (json) =>
if json? and json.refreshInterval
@refreshInterval = json.refreshInterval
# Refresh immediately if json is missing or version is outdated. Otherwise,
# compute next refresh time and schedule
timeUntilRefresh = 0
if json? and json.version is @version
timeUntilRefresh = Math.max(0, @refreshInterval - (Date.now() - json.time))
@_timeoutId = setTimeout(@refresh, timeUntilRefresh)
reset: ->
# Clear db value, turn off any scheduled actions
DatabaseStore.inTransaction (t) => t.persistJSONBlob(@key, {})
@end()
end: ->
# Turn off any scheduled actions
clearInterval(@_timeoutId) if @_timeoutId
@_timeoutId = null
refresh: =>
# Set up next refresh call
clearTimeout(@_timeoutId) if @_timeoutId
@_timeoutId = setTimeout(@refresh, @refreshInterval)
# Call fetch data function, save it to the database
@fetchData (newValue) =>
DatabaseStore.inTransaction (t) =>
t.persistJSONBlob(@key, {
version: @version
time: Date.now()
value: newValue
refreshInterval: @refreshInterval
})
fetchData: (callback) =>
throw new Error("Subclasses should override this method.")
module.exports = RefreshingJSONCache

View file

@ -1,14 +0,0 @@
{
"name": "deltas",
"version": "0.1.0",
"main": "./lib/main",
"description": "Delta connection handling in Nylas Mail",
"license": "GPL-3.0",
"private": true,
"engines": {
"nylas": "*"
},
"windowTypes": {
"work": true
}
}

View file

@ -5,7 +5,6 @@ Contact = require('../../src/flux/models/contact').default
NylasAPI = require('../../src/flux/nylas-api').default
NylasAPIRequest = require('../../src/flux/nylas-api-request').default
ContactStore = require '../../src/flux/stores/contact-store'
ContactRankingStore = require '../../src/flux/stores/contact-ranking-store'
DatabaseStore = require('../../src/flux/stores/database-store').default
AccountStore = require('../../src/flux/stores/account-store').default
@ -15,65 +14,23 @@ xdescribe "ContactStore", ->
beforeEach ->
spyOn(NylasEnv, "isMainWindow").andReturn true
@rankings = [
["evanA@nylas.com", 10]
["evanB@nylas.com", 1]
["evanC@nylas.com", 0.1]
]
spyOn(NylasAPIRequest.prototype, "run").andCallFake (options) =>
if options.path is "/contacts/rankings"
return Promise.resolve(@rankings)
else
throw new Error("Invalid request path!")
NylasEnv.testOrganizationUnit = "folder"
ContactStore._contactCache = []
ContactStore._fetchOffset = 0
ContactStore._accountId = null
ContactRankingStore.reset()
afterEach ->
NylasEnv.testOrganizationUnit = null
describe "ranking contacts", ->
beforeEach ->
@accountId = TEST_ACCOUNT_ID
@c1 = new Contact({name: "Evan A", email: "evanA@nylas.com", @accountId})
@c2 = new Contact({name: "Evan B", email: "evanB@nylas.com", @accountId})
@c3 = new Contact({name: "Evan C", email: "evanC@nylas.com", @accountId})
@c4 = new Contact({name: "Ben", email: "ben@nylas.com"})
@contacts = [@c3, @c1, @c2, @c4]
it "queries for, and sorts, contacts present in the rankings", ->
spyOn(ContactRankingStore, 'valuesForAllAccounts').andReturn
"evana@nylas.com": 10
"evanb@nylas.com": 1
"evanc@nylas.com": 0.1
spyOn(DatabaseStore, 'findAll').andCallFake =>
return {background: => Promise.resolve([@c3, @c1, @c2, @c4])}
waitsForPromise =>
ContactStore._updateRankedContactCache().then =>
expect(ContactStore._rankedContacts).toEqual [@c1, @c2, @c3, @c4]
describe "when ContactRankings change", ->
it "re-generates the ranked contact cache", ->
spyOn(ContactStore, "_updateRankedContactCache")
ContactRankingStore.trigger()
expect(ContactStore._updateRankedContactCache).toHaveBeenCalled()
describe "when searching for a contact", ->
beforeEach ->
@c1 = new Contact(name: "", email: "1test@nylas.com")
@c2 = new Contact(name: "First", email: "2test@nylas.com")
@c3 = new Contact(name: "First Last", email: "3test@nylas.com")
@c4 = new Contact(name: "Fit", email: "fit@nylas.com")
@c5 = new Contact(name: "Fins", email: "fins@nylas.com")
@c6 = new Contact(name: "Fill", email: "fill@nylas.com")
@c7 = new Contact(name: "Fin", email: "fin@nylas.com")
ContactStore._rankedContacts = [@c1,@c2,@c3,@c4,@c5,@c6,@c7]
@c1 = new Contact(name: "", email: "1test@nylas.com", refs: 7)
@c2 = new Contact(name: "First", email: "2test@nylas.com", refs: 6)
@c3 = new Contact(name: "First Last", email: "3test@nylas.com", refs: 5)
@c4 = new Contact(name: "Fit", email: "fit@nylas.com", refs: 4)
@c5 = new Contact(name: "Fins", email: "fins@nylas.com", refs: 3)
@c6 = new Contact(name: "Fill", email: "fill@nylas.com", refs: 2)
@c7 = new Contact(name: "Fin", email: "fin@nylas.com", refs: 1)
it "can find by first name", ->
waitsForPromise =>

View file

@ -54,6 +54,11 @@ export default class Contact extends Model {
modelKey: 'email',
}),
refs: Attributes.Number({
queryable: true,
modelKey: 'refs',
}),
// Contains the raw thirdPartyData (keyed by the vendor name) about
// this contact.
thirdPartyData: Attributes.Object({

View file

@ -42,7 +42,7 @@ Section: Models
class Thread extends ModelWithMetadata {
static attributes = _.extend({}, ModelWithMetadata.attributes, {
snippet: Attributes.String({
snippet: Attributes.String({ // TODO NONFUNCTIONAL
modelKey: 'snippet',
}),

View file

@ -1,63 +0,0 @@
Rx = require 'rx-lite'
NylasStore = require 'nylas-store'
DatabaseStore = require('./database-store').default
AccountStore = require('./account-store').default
class ContactRankingStore extends NylasStore
constructor: ->
@_values = {}
@_valuesAllAccounts = null
@_disposables = {}
@listenTo AccountStore, @_onAccountsChanged
@_registerObservables(AccountStore.accounts())
_registerObservables: (accounts) =>
nextDisposables = {}
# Create new observables, reusing existing ones when possible
# (so they don't trigger with initial state unnecesarily)
for acct in accounts
if @_disposables[acct.id]
nextDisposables[acct.id] = @_disposables[acct.id]
delete @_disposables[acct.id]
else
query = DatabaseStore.findJSONBlob("ContactRankingsFor#{acct.id}")
callback = @_onRankingsChanged.bind(@, acct.id)
nextDisposables[acct.id] = Rx.Observable.fromQuery(query).subscribe(callback)
# Remove unused observables in the old set
for key, disposable of @_disposables
disposable.dispose()
@_disposables = nextDisposables
_onRankingsChanged: (accountId, json) =>
@_values[accountId] = if json then json.value else null
@_valuesAllAccounts = null
@trigger()
_onAccountsChanged: =>
@_registerObservables(AccountStore.accounts())
valueFor: (accountId) =>
@_values[accountId]
valuesForAllAccounts: =>
unless @_valuesAllAccounts
combined = {}
for acctId, values of @_values
for email, score of values
if combined[email]
combined[email] = Math.max(combined[email], score)
else
combined[email] = score
@_valuesAllAccounts = combined
return @_valuesAllAccounts
reset: =>
@_valuesAllAccounts = null
@_values = {}
module.exports = new ContactRankingStore()

View file

@ -10,7 +10,6 @@ RegExpUtils = require '../../regexp-utils'
DatabaseStore = require('./database-store').default
AccountStore = require('./account-store').default
ComponentRegistry = require('../../registries/component-registry')
ContactRankingStore = require './contact-ranking-store'
_ = require 'underscore'
WindowBridge = require '../../window-bridge'
@ -24,11 +23,6 @@ Section: Stores
###
class ContactStore extends NylasStore
constructor: ->
@_rankedContacts = []
@listenTo ContactRankingStore, => @_updateRankedContactCache()
@_updateRankedContactCache()
# Public: Search the user's contact list for the given search term.
# This method compares the `search` string against each Contact's
# `name` and `email`.
@ -47,19 +41,13 @@ class ContactStore extends NylasStore
search = search.toLowerCase()
accountCount = AccountStore.accounts().length
extensions = ComponentRegistry.findComponentsMatching({
role: "ContactSearchResults"
})
if not search or search.length is 0
return Promise.resolve([])
# Search ranked contacts which are stored in order in memory
results = []
for contact in @_rankedContacts
if (contact.email.toLowerCase().indexOf(search) isnt -1 or
contact.name.toLowerCase().indexOf(search) isnt -1)
results.push(contact)
if results.length is limit
break
# If we haven't found enough items in memory, query for more from the
# database. Note that we ask for LIMIT * accountCount because we want to
# return contacts with distinct email addresses, and the same contact
@ -68,18 +56,10 @@ class ContactStore extends NylasStore
query = DatabaseStore.findAll(Contact)
.search(search)
.limit(limit * accountCount)
query.then (queryResults) =>
existingEmails = _.pluck(results, 'email')
.order(Contact.attributes.refs.descending())
query.then (results) =>
# remove query results that were already found in ranked contacts
queryResults = _.reject queryResults, (c) -> c.email in existingEmails
queryResults = @_distinctByEmail(queryResults)
results = results.concat(queryResults)
extensions = ComponentRegistry.findComponentsMatching({
role: "ContactSearchResults"
})
results = @_distinctByEmail(results)
return Promise.each extensions, (ext) =>
return ext.findAdditionalContacts(search, results).then (contacts) =>
results = contacts
@ -141,26 +121,6 @@ class ContactStore extends NylasStore
return match if match and match.email is contact.email
return contact
_updateRankedContactCache: =>
rankings = ContactRankingStore.valuesForAllAccounts()
emails = Object.keys(rankings)
if emails.length is 0
@_rankedContacts = []
return
# Sort the emails by rank and then clip to 400 so that our ranked cache
# has a bounded size.
emails = _.sortBy emails, (email) ->
(- (rankings[email.toLowerCase()] ? 0) / 1)
emails.length = 400 if emails.length > 400
DatabaseStore.findAll(Contact, {email: emails}).background().then (contacts) =>
contacts = @_distinctByEmail(contacts)
for contact in contacts
contact._rank = (- (rankings[contact.email.toLowerCase()] ? 0) / 1)
@_rankedContacts = _.sortBy contacts, (contact) -> contact._rank
_distinctByEmail: (contacts) =>
# remove query results that are duplicates, prefering ones that have names
uniq = {}
@ -172,9 +132,4 @@ class ContactStore extends NylasStore
uniq[key] = contact
_.values(uniq)
_resetCache: =>
@_rankedContacts = []
ContactRankingStore.reset()
@trigger(@)
module.exports = new ContactStore()