fix(config): Store cursors with sync state, avoid constant config.cson writes

Summary:
Previously we were storing sync cursors in config.cson. They were by
far the most frequently updated piece of data in config. To make things worse,
all the writing was happening in the worker window - the main window was just
seeing the changes on disk and reloading.

I believe there's an edge case which causes the main window to read the config
file when it's mid-write and empty. This causes the accounts array to become
empty in the AccountStore and lots of downstream issues. It's also then possible
for the main window to write a config change of it's own and empty the file
permanently.

Test Plan: A few new tests to make sure this is backwards compatible.

Reviewers: juan, evan

Reviewed By: juan, evan

Differential Revision: https://phab.nylas.com/D2642
This commit is contained in:
Ben Gotow 2016-02-26 13:52:19 -08:00
parent 114a7b2740
commit 18677e51a6
3 changed files with 58 additions and 15 deletions

View file

@ -11,10 +11,10 @@ class NylasLongConnection
Connected: 'connected' Connected: 'connected'
Retrying: 'retrying' Retrying: 'retrying'
constructor: (api, accountId) -> constructor: (api, accountId, config) ->
@_api = api @_api = api
@_accountId = accountId @_accountId = accountId
@_cursorKey = "nylas.#{@_accountId}.cursor" @_config = config
@_emitter = new Emitter @_emitter = new Emitter
@_state = 'idle' @_state = 'idle'
@_req = null @_req = null
@ -28,10 +28,9 @@ class NylasLongConnection
last = @_deltas[@_deltas.length - 1] last = @_deltas[@_deltas.length - 1]
@_emitter.emit('deltas-stopped-arriving', @_deltas) @_emitter.emit('deltas-stopped-arriving', @_deltas)
@_config.setCursor(last.cursor)
@_deltas = [] @_deltas = []
# Note: setCursor is slow and saves to disk, so we do it once at the end
@setCursor(last.cursor)
, 1000 , 1000
@ @
@ -40,10 +39,10 @@ class NylasLongConnection
@_accountId @_accountId
hasCursor: -> hasCursor: ->
!!NylasEnv.config.get(@_cursorKey) !!@_config.getCursor()
withCursor: (callback) -> withCursor: (callback) ->
cursor = NylasEnv.config.get(@_cursorKey) cursor = @_config.getCursor()
return callback(cursor) if cursor return callback(cursor) if cursor
@_api.makeRequest @_api.makeRequest
@ -52,12 +51,9 @@ class NylasLongConnection
method: 'POST' method: 'POST'
success: ({cursor}) => success: ({cursor}) =>
console.log("Obtained stream cursor #{cursor}.") console.log("Obtained stream cursor #{cursor}.")
@setCursor(cursor) @_config.setCursor(cursor)
callback(cursor) callback(cursor)
setCursor: (cursor) ->
NylasEnv.config.set(@_cursorKey, cursor)
state: -> state: ->
@state @state
@ -92,6 +88,8 @@ class NylasLongConnection
@_flushDeltasDebounced() @_flushDeltasDebounced()
start: -> start: ->
return unless @_config.ready()
token = @_api.accessTokenForAccountId(@_accountId) token = @_api.accessTokenForAccountId(@_accountId)
return if not token? return if not token?
return if @_state is NylasLongConnection.State.Ended return if @_state is NylasLongConnection.State.Ended

View file

@ -42,7 +42,16 @@ class NylasSyncWorker
@_account = account @_account = account
@_terminated = false @_terminated = false
@_connection = new NylasLongConnection(api, account.id) @_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)] @_refreshingCaches = [new ContactRankingsCache(account.id)]
@_resumeTimer = new BackoffTimer => @_resumeTimer = new BackoffTimer =>
# indirection needed so resumeFetches can be spied on # indirection needed so resumeFetches can be spied on
@ -53,9 +62,10 @@ class NylasSyncWorker
@_state = null @_state = null
DatabaseStore.findJSONBlob("NylasSyncWorker:#{@_account.id}").then (json) => DatabaseStore.findJSONBlob("NylasSyncWorker:#{@_account.id}").then (json) =>
@_state = json ? {} @_state = json ? {}
for model, modelState of @_state for key in ['threads', 'labels', 'folders', 'drafts', 'contacts', 'calendars', 'events']
modelState.busy = false @_state[key].busy = false if @_state[key]
@resumeFetches() @resumeFetches()
@_connection.start()
@ @

View file

@ -16,10 +16,12 @@ describe "NylasSyncWorker", ->
getThreads: (account, params, requestOptions) => getThreads: (account, params, requestOptions) =>
@apiRequests.push({account, model:'threads', params, requestOptions}) @apiRequests.push({account, model:'threads', params, requestOptions})
@apiCursorStub = undefined
spyOn(DatabaseTransaction.prototype, 'persistJSONBlob').andReturn(Promise.resolve()) spyOn(DatabaseTransaction.prototype, 'persistJSONBlob').andReturn(Promise.resolve())
spyOn(DatabaseStore, 'findJSONBlob').andCallFake (key) => spyOn(DatabaseStore, 'findJSONBlob').andCallFake (key) =>
if key is "NylasSyncWorker:#{TEST_ACCOUNT_ID}" if key is "NylasSyncWorker:#{TEST_ACCOUNT_ID}"
return Promise.resolve _.extend {}, { return Promise.resolve _.extend {}, {
"cursor": @apiCursorStub
"contacts": "contacts":
busy: true busy: true
complete: false complete: false
@ -36,6 +38,7 @@ describe "NylasSyncWorker", ->
@account = new Account(clientId: TEST_ACCOUNT_CLIENT_ID, serverId: TEST_ACCOUNT_ID, organizationUnit: 'label') @account = new Account(clientId: TEST_ACCOUNT_CLIENT_ID, serverId: TEST_ACCOUNT_ID, organizationUnit: 'label')
@worker = new NylasSyncWorker(@api, @account) @worker = new NylasSyncWorker(@api, @account)
@connection = @worker.connection() @connection = @worker.connection()
spyOn(@connection, 'start')
advanceClock() advanceClock()
it "should reset `busy` to false when reading state from disk", -> it "should reset `busy` to false when reading state from disk", ->
@ -46,13 +49,11 @@ describe "NylasSyncWorker", ->
describe "start", -> describe "start", ->
it "should open the delta connection", -> it "should open the delta connection", ->
spyOn(@connection, 'start')
@worker.start() @worker.start()
advanceClock() advanceClock()
expect(@connection.start).toHaveBeenCalled() expect(@connection.start).toHaveBeenCalled()
it "should start querying for model collections and counts that haven't been fully cached", -> it "should start querying for model collections and counts that haven't been fully cached", ->
spyOn(@connection, 'start')
@worker.start() @worker.start()
advanceClock() advanceClock()
expect(@apiRequests.length).toBe(10) expect(@apiRequests.length).toBe(10)
@ -129,6 +130,40 @@ describe "NylasSyncWorker", ->
advanceClock(30000) advanceClock(30000)
expect(@worker.resumeFetches.callCount).toBe(1) expect(@worker.resumeFetches.callCount).toBe(1)
describe "delta streaming cursor", ->
it "should read the cursor from the database, and the old config format", ->
spyOn(NylasLongConnection.prototype, 'withCursor').andCallFake =>
@apiCursorStub = undefined
# no cursor present
worker = new NylasSyncWorker(@api, @account)
connection = worker.connection()
expect(connection.hasCursor()).toBe(false)
advanceClock()
expect(connection.hasCursor()).toBe(false)
# cursor present in config
spyOn(NylasEnv.config, 'get').andCallFake (key) =>
return 'old-school' if key is "nylas.#{@account.id}.cursor"
return undefined
worker = new NylasSyncWorker(@api, @account)
connection = worker.connection()
advanceClock()
expect(connection.hasCursor()).toBe(true)
expect(connection._config.getCursor()).toEqual('old-school')
# cursor present in database, overrides cursor in config
@apiCursorStub = "new-school"
worker = new NylasSyncWorker(@api, @account)
connection = worker.connection()
expect(connection.hasCursor()).toBe(false)
advanceClock()
expect(connection.hasCursor()).toBe(true)
expect(connection._config.getCursor()).toEqual('new-school')
describe "when a count request completes", -> describe "when a count request completes", ->
beforeEach -> beforeEach ->
@worker.start() @worker.start()