perf(db): Lazily deserialize models on the other side of the action bridge

Summary:
We send database `trigger()` events through the ActionBrige to all windows of the app. This means that during initial sync, we're serializing, IPCing and unserializing thousands of models a minute x "N" windows.

This diff converts the payload of the trigger method into an actual class that implements a custom toJSON. It converts the impacted `objects` into a string, and doesn't deserialize them until it's asked.

Bottom line: this means that in many scenarios, we can avoid creating Contact models, etc. in composer windows only to broadcast them and then gc them.

Test Plan: No new tests yet, but this should definitel be tested. #willfix.

Reviewers: juan, evan

Reviewed By: evan

Differential Revision: https://phab.nylas.com/D2236
This commit is contained in:
Ben Gotow 2015-11-06 11:15:20 -08:00
parent d838610290
commit 118761d79e
6 changed files with 71 additions and 26 deletions

View file

@ -16,7 +16,7 @@ describe "DatabaseStore", ->
beforeEach ->
TestModel.configureBasic()
spyOn(ModelQuery.prototype, 'where').andCallThrough()
spyOn(DatabaseStore, '_triggerSoon').andCallFake -> Promise.resolve()
spyOn(DatabaseStore, '_accumulateAndTrigger').andCallFake -> Promise.resolve()
@performed = []
@ -122,9 +122,9 @@ describe "DatabaseStore", ->
it "should cause the DatabaseStore to trigger with a change that contains the model", ->
waitsForPromise ->
DatabaseStore.persistModel(testModelInstance).then ->
expect(DatabaseStore._triggerSoon).toHaveBeenCalled()
expect(DatabaseStore._accumulateAndTrigger).toHaveBeenCalled()
change = DatabaseStore._triggerSoon.mostRecentCall.args[0]
change = DatabaseStore._accumulateAndTrigger.mostRecentCall.args[0]
expect(change).toEqual({objectClass: TestModel.name, objects: [testModelInstance], type:'persist'})
.catch (err) ->
console.log err
@ -141,9 +141,9 @@ describe "DatabaseStore", ->
it "should cause the DatabaseStore to trigger with a change that contains the models", ->
waitsForPromise ->
DatabaseStore.persistModels([testModelInstanceA, testModelInstanceB]).then ->
expect(DatabaseStore._triggerSoon).toHaveBeenCalled()
expect(DatabaseStore._accumulateAndTrigger).toHaveBeenCalled()
change = DatabaseStore._triggerSoon.mostRecentCall.args[0]
change = DatabaseStore._accumulateAndTrigger.mostRecentCall.args[0]
expect(change).toEqual
objectClass: TestModel.name,
objects: [testModelInstanceA, testModelInstanceB]
@ -171,9 +171,9 @@ describe "DatabaseStore", ->
it "should cause the DatabaseStore to trigger() with a change that contains the model", ->
waitsForPromise ->
DatabaseStore.unpersistModel(testModelInstance).then ->
expect(DatabaseStore._triggerSoon).toHaveBeenCalled()
expect(DatabaseStore._accumulateAndTrigger).toHaveBeenCalled()
change = DatabaseStore._triggerSoon.mostRecentCall.args[0]
change = DatabaseStore._accumulateAndTrigger.mostRecentCall.args[0]
expect(change).toEqual({objectClass: TestModel.name, objects: [testModelInstance], type:'unpersist'})
describe "when the model provides additional sqlite config", ->
@ -447,4 +447,4 @@ describe "DatabaseStore", ->
expect(@performed[4].query).toBe "BEGIN EXCLUSIVE TRANSACTION"
expect(@performed[5].query).toBe "COMMIT"
describe "DatabaseStore::_triggerSoon", ->
describe "DatabaseStore::_accumulateAndTrigger", ->

View file

@ -80,7 +80,7 @@ class ActionBridge
if name == Message.DATABASE_STORE_TRIGGER
DatabaseStore.triggeringFromActionBridge = true
DatabaseStore.trigger(args...)
DatabaseStore.trigger(new DatabaseStore.ChangeRecord(args...))
DatabaseStore.triggeringFromActionBridge = false
else if Actions[name]

View file

@ -54,13 +54,17 @@ class AttributeCollection extends Attribute
return [] unless json && json instanceof Array
objs = []
for objJSON in json
obj = new @itemClass(objJSON)
# Important: if no ids are in the JSON, don't make them up
# randomly. This causes an object to be "different" each time it's
# de-serialized even if it's actually the same, makes React
# components re-render!
obj.clientId = undefined
obj.fromJSON(objJSON) if obj.fromJSON?
if @itemClass.prototype.fromJSON?
obj = new @itemClass
# Important: if no ids are in the JSON, don't make them up
# randomly. This causes an object to be "different" each time it's
# de-serialized even if it's actually the same, makes React
# components re-render!
obj.clientId = undefined
obj.fromJSON(objJSON)
else
obj = new @itemClass(objJSON)
obj.clientId = undefined
objs.push(obj)
objs

View file

@ -0,0 +1,39 @@
Utils = require '../models/utils'
###
DatabaseChangeRecord is the object emitted from the DatabaseStore when it triggers.
The DatabaseChangeRecord contains information about what type of model changed,
and references to the new model values. All mutations to the database produce these
change records.
###
class DatabaseChangeRecord
constructor: (options) ->
@options = options
# When DatabaseChangeRecords are sent over IPC to other windows, their object
# payload is sub-serialized into a JSON string. This means that we can wait
# to deserialize models until someone in the window asks for `change.objects`
@_objects = options.objects
@_objectsString = options.objectsString
Object.defineProperty(@, 'type', {
get: -> options.type
})
Object.defineProperty(@, 'objectClass', {
get: -> options.objectClass
})
Object.defineProperty(@, 'objects', {
get: ->
@_objects ?= Utils.deserializeRegisteredObjects(@_objectsString)
@_objects
})
toJSON: =>
@_objectsString ?= Utils.serializeRegisteredObjects(@_objects)
type: @type
objectClass: @objectClass
objectsString: @_objectsString
module.exports = DatabaseChangeRecord

View file

@ -10,6 +10,7 @@ ModelQuery = require '../models/query'
NylasStore = require '../../global/nylas-store'
PromiseQueue = require 'promise-queue'
DatabaseSetupQueryBuilder = require './database-setup-query-builder'
DatabaseChangeRecord = require './database-change-record'
PriorityUICoordinator = require '../../priority-ui-coordinator'
{AttributeCollection, AttributeJoinedData} = require '../attributes'
@ -414,7 +415,7 @@ class DatabaseStore extends NylasStore
throw new Error("DatabaseStore::persistModel - You must pass an instance of the Model class.")
@_writeModels([model]).then =>
@_triggerSoon({objectClass: model.constructor.name, objects: [model], type: 'persist'})
@_accumulateAndTrigger({objectClass: model.constructor.name, objects: [model], type: 'persist'})
# Public: Asynchronously writes `models` to the cache and triggers a single change
# event. Note: Models must be of the same class to be persisted in a batch operation.
@ -442,7 +443,7 @@ class DatabaseStore extends NylasStore
ids[model.id] = true
@_writeModels(models).then =>
@_triggerSoon({objectClass: models[0].constructor.name, objects: models, type: 'persist'})
@_accumulateAndTrigger({objectClass: models[0].constructor.name, objects: models, type: 'persist'})
# Public: Asynchronously removes `model` from the cache and triggers a change event.
#
@ -455,12 +456,12 @@ class DatabaseStore extends NylasStore
# callbacks failed
unpersistModel: (model) =>
@_deleteModel(model).then =>
@_triggerSoon({objectClass: model.constructor.name, objects: [model], type: 'unpersist'})
@_accumulateAndTrigger({objectClass: model.constructor.name, objects: [model], type: 'unpersist'})
persistJSONObject: (key, json) ->
jsonString = serializeRegisteredObjects(json)
@_query("REPLACE INTO `JSONObject` (`key`,`data`) VALUES (?,?)", [key, jsonString]).then =>
@trigger({objectClass: 'JSONObject', objects: [{key: key, json: json}], type: 'persist'})
@trigger(new DatabaseChangeRecord({objectClass: 'JSONObject', objects: [{key: key, json: json}], type: 'persist'}))
findJSONObject: (key) ->
@_query("SELECT `data` FROM `JSONObject` WHERE key = ? LIMIT 1", [key]).then (results) =>
@ -486,19 +487,19 @@ class DatabaseStore extends NylasStore
########################### PRIVATE METHODS ############################
########################################################################
# _TriggerSoon is a guarded version of trigger that can accumulate changes.
# _accumulateAndTrigger is a guarded version of trigger that can accumulate changes.
# This means that even if you're a bad person and call `persistModel` 100 times
# from 100 task objects queued at the same time, it will only create one
# `trigger` event. This is important since the database triggering impacts
# the entire application.
_triggerSoon: (change) =>
_accumulateAndTrigger: (change) =>
@_triggerPromise ?= new Promise (resolve, reject) =>
@_resolve = resolve
flush = =>
return unless @_changeAccumulated
clearTimeout(@_changeFireTimer) if @_changeFireTimer
@trigger(@_changeAccumulated)
@trigger(new DatabaseChangeRecord(@_changeAccumulated))
@_changeAccumulated = null
@_changeFireTimer = null
@_resolve?()
@ -662,3 +663,4 @@ class DatabaseStore extends NylasStore
module.exports = new DatabaseStore()
module.exports.ChangeRecord = DatabaseChangeRecord

View file

@ -20,7 +20,7 @@ class SectionConfig
class PreferencesSectionStore extends NylasStore
constructor: ->
@_sectionConfigs = []
@_triggerSoon ?= _.debounce(( => @trigger()), 20)
@_accumulateAndTrigger ?= _.debounce(( => @trigger()), 20)
@Section = {}
@SectionConfig = SectionConfig
@ -64,12 +64,12 @@ class PreferencesSectionStore extends NylasStore
@Section[sectionConfig.sectionId] = sectionConfig.sectionId
@_sectionConfigs.push(sectionConfig)
@_sectionConfigs = _.sortBy(@_sectionConfigs, "order")
@_triggerSoon()
@_accumulateAndTrigger()
unregisterPreferenceSection: (sectionId) ->
delete @Section[sectionId]
@_sectionConfigs = _.reject @_sectionConfigs, (sectionConfig) ->
sectionConfig.sectionId is sectionId
@_triggerSoon()
@_accumulateAndTrigger()
module.exports = new PreferencesSectionStore()