Mailspring/internal_packages/worker-sync/lib/nylas-sync-worker.coffee

270 lines
7.3 KiB
CoffeeScript
Raw Normal View History

_ = require 'underscore'
{Actions, DatabaseStore} = require 'nylas-exports'
2015-05-16 01:53:00 +08:00
NylasLongConnection = require './nylas-long-connection'
ContactRankingsCache = require './contact-rankings-cache'
INITIAL_PAGE_SIZE = 30
feat(transactions): Explicit (and faster) database transactions Summary: Until now, we've been hiding transactions beneath the surface. When you call persistModel, you're implicitly creating a transaction. You could explicitly create them with `atomically`..., but there were several critical problems that are fixed in this diff: - Calling persistModel / unpersistModel within a transaction could cause the DatabaseStore to trigger. This could result in other parts of the app making queries /during/ the transaction, potentially before the COMMIT occurred and saved the changes. The new, explicit inTransaction syntax holds all changes until after COMMIT and then triggers. - Calling atomically and then calling persistModel inside that resulted in us having to check whether a transaction was present and was gross. - Many parts of the code ran extensive logic inside a promise chained within `atomically`: BAD: ``` DatabaseStore.atomically => DatabaseStore.persistModel(draft) => GoMakeANetworkRequestThatReturnsAPromise ``` OVERWHELMINGLY BETTER: ``` DatabaseStore.inTransaction (t) => t.persistModel(draft) .then => GoMakeANetworkRequestThatReturnsAPromise ``` Having explicit transactions also puts us on equal footing with Sequelize and other ORMs. Note that you /have/ to call DatabaseStore.inTransaction (t) =>. There is no other way to access the methods that let you alter the database. :-) Other changes: - This diff removes Message.labels and the Message-Labels table. We weren't using Message-level labels anywhere, and the table could grow very large. - This diff changes the page size during initial sync from 250 => 200 in an effort to make transactions a bit faster. Test Plan: Run tests! Reviewers: juan, evan Reviewed By: juan, evan Differential Revision: https://phab.nylas.com/D2353
2015-12-18 03:46:05 +08:00
MAX_PAGE_SIZE = 200
# BackoffTimer is a small helper class that wraps setTimeout. It fires the function
# you provide at a regular interval, but backs off each time you call `backoff`.
#
class BackoffTimer
constructor: (@fn) ->
@reset()
cancel: =>
clearTimeout(@_timeout) if @_timeout
@_timeout = null
reset: =>
@cancel()
@_delay = 20 * 1000
backoff: =>
@_delay = Math.min(@_delay * 1.4, 5 * 1000 * 60) # Cap at 5 minutes
refactor(env): new NylasEnv global Converted all references of global atom to NylasEnv Temporary rename atom.io find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.io/temporaryAtomIoReplacement/g' atom.config to NylasEnv.config find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.config/NylasEnv.config/g' atom.packages -> NylasEnv.packages atom.commands -> NylasEnv.commands atom.getLoadSettings find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.commands/NylasEnv.commands/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.getLoadSettings/NylasEnv.getLoadSettings/g' More common atom methods find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.styles/NylasEnv.styles/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.emitError/NylasEnv.emitError/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.inSpecMode/NylasEnv.inSpecMode/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.inDevMode/NylasEnv.inDevMode/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.getWindowType/NylasEnv.getWindowType/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.displayWindow/NylasEnv.displayWindow/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.quit/NylasEnv.quit/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.close/NylasEnv.close/g' More atom method changes find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.keymaps/NylasEnv.keymaps/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.hide/NylasEnv.hide/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.getCurrentWindow/NylasEnv.getCurrentWindow/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.menu/NylasEnv.menu/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.getConfigDirPath/NylasEnv.getConfigDirPath/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.isMainWindow/NylasEnv.isMainWindow/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.finishUnload/NylasEnv.finishUnload/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.isWorkWindow/NylasEnv.isWorkWindow/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.showSaveDialog/NylasEnv.showSaveDialog/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.append/NylasEnv.append/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.confirm/NylasEnv.confirm/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.clipboard/NylasEnv.clipboard/g' find -E . -regex ".*\.(coffee|cjsx|js|md|cmd|es6)" -print0 | xargs -0 sed -i "" 's/atom.getVersion/NylasEnv.getVersion/g' More atom renaming Rename atom methods More atom methods Fix grunt config variable Change atom.cmd to N1.cmd Rename atom.coffee and atom.js to nylas-env.coffee nylas-env.js Fix atom global reference in specs manually Fix atom requires Change engine from atom to nylas got rid of global/nylas-env rename to nylas-win-bootup Fix onWindowPropsChanged to onWindowPropsReceived fix nylas-workspace atom-text-editor to nylas-theme-wrap atom-text-editor -> nylas-theme-wrap Replacing atom keyword AtomWindow -> NylasWindow Replace Atom -> N1 Rename atom items nylas.asar -> atom.asar Remove more atom references Remove 6to5 references Remove license exception for atom
2015-11-12 02:25:11 +08:00
if not NylasEnv.inSpecMode()
console.log("Backing off after sync failure. Will retry in #{Math.floor(@_delay / 1000)} seconds.")
start: =>
clearTimeout(@_timeout) if @_timeout
@_timeout = setTimeout =>
@_timeout = null
@fn()
, @_delay
module.exports =
2015-05-16 01:53:00 +08:00
class NylasSyncWorker
constructor: (api, account) ->
2015-05-16 01:53:00 +08:00
@_api = api
@_account = account
@_terminated = false
@_connection = new NylasLongConnection(api, account.id, {
ready: => @_state isnt null
getCursor: =>
return null if @_state is null
@_state['cursor'] || NylasEnv.config.get("nylas.#{@_account.id}.cursor")
setCursor: (val) =>
@_state['cursor'] = val
@writeState()
})
@_refreshingCaches = [new ContactRankingsCache(account.id)]
@_resumeTimer = new BackoffTimer =>
# indirection needed so resumeFetches can be spied on
@resumeFetches()
@_unlisten = Actions.retryInitialSync.listen(@_onRetryInitialSync, @)
@_state = null
DatabaseStore.findJSONBlob("NylasSyncWorker:#{@_account.id}").then (json) =>
@_state = json ? {}
for key in ['threads', 'labels', 'folders', 'drafts', 'contacts', 'calendars', 'events']
@_state[key].busy = false if @_state[key]
@resumeFetches()
@_connection.start()
@
2015-05-16 01:53:00 +08:00
account: ->
@_account
connection: ->
@_connection
state: ->
@_state
busy: ->
return false unless @_state
for key, state of @_state
if state.busy
return true
false
start: ->
@_resumeTimer.start()
@_connection.start()
@_refreshingCaches.map (c) -> c.start()
@resumeFetches()
cleanup: ->
@_unlisten?()
@_resumeTimer.cancel()
@_connection.end()
@_refreshingCaches.map (c) -> c.end()
@_terminated = true
@
resumeFetches: =>
return unless @_state
# Stop the timer. If one or more network requests fails during the fetch process
# we'll backoff and restart the timer.
@_resumeTimer.cancel()
needed = [
{model: 'threads'},
{model: "#{@_account.organizationUnit}s", initialPageSize: 1000}
{model: 'drafts'},
{model: 'contacts'},
{model: 'calendars'},
{model: 'events'},
].filter ({model}) =>
@shouldFetchCollection(model)
return if needed.length is 0
@fetchAllMetadata =>
needed.forEach ({model, initialPageSize}) =>
@fetchCollection(model, initialPageSize)
fetchAllMetadata: (finished) ->
@_metadata = {}
makeMetadataRequest = (offset) =>
limit = 200
@_fetchWithErrorHandling
path: "/metadata"
qs: {limit, offset}
success: (data) =>
for metadatum in data
@_metadata[metadatum.object_id] ?= []
@_metadata[metadatum.object_id].push(metadatum)
if data.length is limit
makeMetadataRequest(offset + limit)
else
console.log("Retrieved #{offset + data.length} metadata objects")
finished()
makeMetadataRequest(0)
shouldFetchCollection: (model) ->
return false unless @_state
state = @_state[model] ? {}
return false if state.complete
return false if state.busy
return true
fetchCollection: (model, initialPageSize = INITIAL_PAGE_SIZE) ->
state = @_state[model] ? {}
state.complete = false
state.error = null
state.busy = true
state.fetched ?= 0
if not state.count
state.count = 0
@fetchCollectionCount(model)
if state.errorRequestRange
{limit, offset} = state.errorRequestRange
state.errorRequestRange = null
@fetchCollectionPage(model, {limit, offset})
else
@fetchCollectionPage(model, {
limit: initialPageSize,
offset: 0
})
@_state[model] = state
@writeState()
fetchCollectionCount: (model) ->
@_fetchWithErrorHandling
path: "/#{model}"
qs: {view: 'count'}
success: (response) =>
@updateTransferState(model, count: response.count)
fetchCollectionPage: (model, params = {}) ->
requestStartTime = Date.now()
requestOptions =
metadataToAttach: @_metadata
error: (err) =>
return if @_terminated
@_fetchCollectionPageError(model, params, err)
success: (json) =>
return if @_terminated
if model in ["labels", "folders"] and @_hasNoInbox(json)
@_fetchCollectionPageError(model, params, "No inbox in #{model}")
return
lastReceivedIndex = params.offset + json.length
moreToFetch = json.length is params.limit
if moreToFetch
nextParams = _.extend({}, params, {offset: lastReceivedIndex})
nextParams.limit = Math.min(Math.round(params.limit * 1.5), MAX_PAGE_SIZE)
nextDelay = Math.max(0, 1500 - (Date.now() - requestStartTime))
setTimeout(( => @fetchCollectionPage(model, nextParams)), nextDelay)
@updateTransferState(model, {
fetched: lastReceivedIndex,
busy: moreToFetch,
complete: !moreToFetch,
error: null,
errorRequestRange: null
})
if model is 'threads'
@_api.getThreads(@_account.id, params, requestOptions)
else
@_api.getCollection(@_account.id, model, params, requestOptions)
2015-05-16 01:53:00 +08:00
# It's occasionally possible for the NylasAPI's labels or folders
# endpoint to not return an "inbox" label. Since that's a core part of
# the app and it doesn't function without it, keep retrying until we see
# it.
_hasNoInbox: (json) ->
return not _.any(json, (obj) -> obj.name is "inbox")
_fetchWithErrorHandling: ({path, qs, success, error}) ->
@_api.makeRequest
accountId: @_account.id
returnsModel: false
path: path
qs: qs
success: (response) =>
return if @_terminated
success(response) if success
error: (err) =>
return if @_terminated
@_resumeTimer.backoff()
@_resumeTimer.start()
error(err) if error
_fetchCollectionPageError: (model, params, err) ->
@_resumeTimer.backoff()
@_resumeTimer.start()
@updateTransferState(model, {
busy: false,
complete: false,
error: err.toString()
errorRequestRange: {offset: params.offset, limit: params.limit}
})
updateTransferState: (model, updatedKeys) ->
@_state[model] = _.extend(@_state[model], updatedKeys)
@writeState()
writeState: ->
@_writeState ?= _.debounce =>
feat(transactions): Explicit (and faster) database transactions Summary: Until now, we've been hiding transactions beneath the surface. When you call persistModel, you're implicitly creating a transaction. You could explicitly create them with `atomically`..., but there were several critical problems that are fixed in this diff: - Calling persistModel / unpersistModel within a transaction could cause the DatabaseStore to trigger. This could result in other parts of the app making queries /during/ the transaction, potentially before the COMMIT occurred and saved the changes. The new, explicit inTransaction syntax holds all changes until after COMMIT and then triggers. - Calling atomically and then calling persistModel inside that resulted in us having to check whether a transaction was present and was gross. - Many parts of the code ran extensive logic inside a promise chained within `atomically`: BAD: ``` DatabaseStore.atomically => DatabaseStore.persistModel(draft) => GoMakeANetworkRequestThatReturnsAPromise ``` OVERWHELMINGLY BETTER: ``` DatabaseStore.inTransaction (t) => t.persistModel(draft) .then => GoMakeANetworkRequestThatReturnsAPromise ``` Having explicit transactions also puts us on equal footing with Sequelize and other ORMs. Note that you /have/ to call DatabaseStore.inTransaction (t) =>. There is no other way to access the methods that let you alter the database. :-) Other changes: - This diff removes Message.labels and the Message-Labels table. We weren't using Message-level labels anywhere, and the table could grow very large. - This diff changes the page size during initial sync from 250 => 200 in an effort to make transactions a bit faster. Test Plan: Run tests! Reviewers: juan, evan Reviewed By: juan, evan Differential Revision: https://phab.nylas.com/D2353
2015-12-18 03:46:05 +08:00
DatabaseStore.inTransaction (t) =>
t.persistJSONBlob("NylasSyncWorker:#{@_account.id}", @_state)
,100
@_writeState()
_onRetryInitialSync: =>
@resumeFetches()
NylasSyncWorker.BackoffTimer = BackoffTimer
NylasSyncWorker.INITIAL_PAGE_SIZE = INITIAL_PAGE_SIZE
NylasSyncWorker.MAX_PAGE_SIZE = MAX_PAGE_SIZE