Factor out reconciliation into separate module

This commit is contained in:
Lucien Greathouse
2019-01-04 18:34:48 -08:00
parent c57989a790
commit 2724534156
2 changed files with 262 additions and 246 deletions

256
plugin/src/Reconciler.lua Normal file
View File

@@ -0,0 +1,256 @@
local Logging = require(script.Parent.Logging)
local function makeInstanceMap()
local self = {
fromIds = {},
fromInstances = {},
}
function self:insert(id, instance)
self.fromIds[id] = instance
self.fromInstances[instance] = id
end
function self:removeId(id)
local instance = self.fromIds[id]
if instance ~= nil then
self.fromIds[id] = nil
self.fromInstances[instance] = nil
else
Logging.warn("Attempted to remove nonexistant ID %s", tostring(id))
end
end
function self:removeInstance(instance)
local id = self.fromInstances[instance]
if id ~= nil then
self.fromInstances[instance] = nil
self.fromIds[id] = nil
else
Logging.warn("Attempted to remove nonexistant instance %s", tostring(instance))
end
end
function self:destroyId(id)
local instance = self.fromIds[id]
self:removeId(id)
if instance ~= nil then
local descendantsToDestroy = {}
for otherInstance in pairs(self.fromInstances) do
if otherInstance:IsDescendantOf(instance) then
table.insert(descendantsToDestroy, otherInstance)
end
end
for _, otherInstance in ipairs(descendantsToDestroy) do
self:removeInstance(otherInstance)
end
instance:Destroy()
else
Logging.warn("Attempted to destroy nonexistant ID %s", tostring(id))
end
end
return self
end
local function setProperty(instance, key, value)
local ok, err = pcall(function()
if instance[key] ~= value then
instance[key] = value
end
end)
if not ok then
error(("Cannot set property %s on class %s: %s"):format(tostring(key), instance.ClassName, err), 2)
end
end
local Reconciler = {}
Reconciler.__index = Reconciler
function Reconciler.new(instanceMetadataMap)
local self = {
instanceMap = makeInstanceMap(),
instanceMetadataMap = instanceMetadataMap,
}
return setmetatable(self, Reconciler)
end
function Reconciler:applyUpdate(requestedIds, virtualInstancesById)
-- This function may eventually be asynchronous; it will require calls to
-- the server to resolve instances that don't exist yet.
local visitedIds = {}
for _, id in ipairs(requestedIds) do
self:__applyUpdatePiece(id, visitedIds, virtualInstancesById)
end
end
--[[
Update an existing instance, including its properties and children, to match
the given information.
]]
function Reconciler:reconcile(virtualInstancesById, id, instance)
local virtualInstance = virtualInstancesById[id]
-- If an instance changes ClassName, we assume it's very different. That's
-- not always the case!
if virtualInstance.ClassName ~= instance.ClassName then
-- TODO: Preserve existing children instead?
local parent = instance.Parent
self.instanceMap:destroyId(id)
return self:__reify(virtualInstancesById, id, parent)
end
self.instanceMap:insert(id, instance)
-- Some instances don't like being named, even if their name already matches
setProperty(instance, "Name", virtualInstance.Name)
for key, value in pairs(virtualInstance.Properties) do
setProperty(instance, key, value.Value)
end
local existingChildren = instance:GetChildren()
local unvisitedExistingChildren = {}
for _, child in ipairs(existingChildren) do
unvisitedExistingChildren[child] = true
end
for _, childId in ipairs(virtualInstance.Children) do
local childData = virtualInstancesById[childId]
local existingChildInstance
for instance in pairs(unvisitedExistingChildren) do
local ok, name, className = pcall(function()
return instance.Name, instance.ClassName
end)
if ok then
if name == childData.Name and className == childData.ClassName then
existingChildInstance = instance
break
end
end
end
if existingChildInstance ~= nil then
unvisitedExistingChildren[existingChildInstance] = nil
self:reconcile(virtualInstancesById, childId, existingChildInstance)
else
self:__reify(virtualInstancesById, childId, instance)
end
end
if self:__shouldClearUnknownInstances(id) then
for existingChildInstance in pairs(unvisitedExistingChildren) do
self.instanceMap:removeInstance(existingChildInstance)
existingChildInstance:Destroy()
end
end
-- The root instance of a project won't have a parent, like the DataModel,
-- so we need to be careful here.
if virtualInstance.Parent ~= nil then
local parent = self.instanceMap.fromIds[virtualInstance.Parent]
if parent == nil then
Logging.info("Instance %s wanted parent of %s", tostring(id), tostring(virtualInstance.Parent))
error("Rojo bug: During reconciliation, an instance referred to an instance ID as parent that does not exist.")
end
-- Some instances, like services, don't like having their Parent
-- property poked, even if we're setting it to the same value.
setProperty(instance, "Parent", parent)
if instance.Parent ~= parent then
instance.Parent = parent
end
end
return instance
end
function Reconciler:__shouldClearUnknownInstances(id)
if self.instanceMetadataMap[id] then
return not self.instanceMetadataMap[id].ignoreUnknownInstances
else
return true
end
end
function Reconciler:__reify(virtualInstancesById, id, parent)
local virtualInstance = virtualInstancesById[id]
local instance = Instance.new(virtualInstance.ClassName)
for key, value in pairs(virtualInstance.Properties) do
-- TODO: Branch on value.Type
setProperty(instance, key, value.Value)
end
instance.Name = virtualInstance.Name
for _, childId in ipairs(virtualInstance.Children) do
self:__reify(virtualInstancesById, childId, instance)
end
setProperty(instance, "Parent", parent)
self.instanceMap:insert(id, instance)
return instance
end
function Reconciler:__applyUpdatePiece(id, visitedIds, virtualInstancesById)
if visitedIds[id] then
return
end
visitedIds[id] = true
local virtualInstance = virtualInstancesById[id]
local instance = self.instanceMap.fromIds[id]
-- The instance was deleted in this update
if virtualInstance == nil then
self.instanceMap:destroyId(id)
return
end
-- An instance we know about was updated
if instance ~= nil then
self:reconcile(virtualInstancesById, id, instance)
return instance
end
-- If the instance's parent already exists, we can stick it there
local parentInstance = self.instanceMap.fromIds[virtualInstance.Parent]
if parentInstance ~= nil then
self:__reify(virtualInstancesById, id, parentInstance)
return
end
-- Otherwise, we can check if this response payload contained the parent and
-- work from there instead.
local parentData = virtualInstancesById[virtualInstance.Parent]
if parentData ~= nil then
if visitedIds[virtualInstance.Parent] then
error("Rojo bug: An instance was present and marked as visited but its instance was missing")
end
self:__applyUpdatePiece(virtualInstance.Parent, visitedIds, virtualInstancesById)
return
end
Logging.trace("Instance ID %s, parent ID %s", tostring(id), tostring(virtualInstance.Parent))
error("Rojo NYI: Instances with parents that weren't mentioned in an update payload")
end
return Reconciler

View File

@@ -1,247 +1,6 @@
local ApiContext = require(script.Parent.ApiContext)
local Logging = require(script.Parent.Logging)
local function makeInstanceMap()
local self = {
fromIds = {},
fromInstances = {},
}
function self:insert(id, instance)
self.fromIds[id] = instance
self.fromInstances[instance] = id
end
function self:removeId(id)
local instance = self.fromIds[id]
if instance ~= nil then
self.fromIds[id] = nil
self.fromInstances[instance] = nil
else
Logging.warn("Attempted to remove nonexistant ID %s", tostring(id))
end
end
function self:removeInstance(instance)
local id = self.fromInstances[instance]
if id ~= nil then
self.fromInstances[instance] = nil
self.fromIds[id] = nil
else
Logging.warn("Attempted to remove nonexistant instance %s", tostring(instance))
end
end
function self:destroyId(id)
local instance = self.fromIds[id]
self:removeId(id)
if instance ~= nil then
local descendantsToDestroy = {}
for otherInstance in pairs(self.fromInstances) do
if otherInstance:IsDescendantOf(instance) then
table.insert(descendantsToDestroy, otherInstance)
end
end
for _, otherInstance in ipairs(descendantsToDestroy) do
self:removeInstance(otherInstance)
end
instance:Destroy()
else
Logging.warn("Attempted to destroy nonexistant ID %s", tostring(id))
end
end
return self
end
local function setProperty(instance, key, value)
local ok, err = pcall(function()
if instance[key] ~= value then
instance[key] = value
end
end)
if not ok then
error(("Cannot set property %s on class %s: %s"):format(tostring(key), instance.ClassName, err), 2)
end
end
local function shouldClearUnknown(id, instanceMetadataMap)
if instanceMetadataMap[id] then
return not instanceMetadataMap[id].ignoreUnknownInstances
else
return true
end
end
local function reify(virtualInstancesById, instanceMap, instanceMetadataMap, id, parent)
local virtualInstance = virtualInstancesById[id]
local instance = Instance.new(virtualInstance.ClassName)
for key, value in pairs(virtualInstance.Properties) do
-- TODO: Branch on value.Type
setProperty(instance, key, value.Value)
end
instance.Name = virtualInstance.Name
for _, childId in ipairs(virtualInstance.Children) do
reify(virtualInstancesById, instanceMap, instanceMetadataMap, childId, instance)
end
setProperty(instance, "Parent", parent)
instanceMap:insert(id, instance)
return instance
end
--[[
Update an existing instance, including its properties and children, to match
the given information.
]]
local function reconcile(virtualInstancesById, instanceMap, instanceMetadataMap, id, existingInstance)
local virtualInstance = virtualInstancesById[id]
-- If an instance changes ClassName, we assume it's very different. That's
-- not always the case!
if virtualInstance.ClassName ~= existingInstance.ClassName then
-- TODO: Preserve existing children instead?
local parent = existingInstance.Parent
instanceMap:destroyId(id)
reify(virtualInstancesById, instanceMap, instanceMetadataMap, id, parent)
return
end
instanceMap:insert(id, existingInstance)
-- Some instances don't like being named, even if their name already matches
setProperty(existingInstance, "Name", virtualInstance.Name)
for key, value in pairs(virtualInstance.Properties) do
setProperty(existingInstance, key, value.Value)
end
local existingChildren = existingInstance:GetChildren()
local unvisitedExistingChildren = {}
for _, child in ipairs(existingChildren) do
unvisitedExistingChildren[child] = true
end
for _, childId in ipairs(virtualInstance.Children) do
local childData = virtualInstancesById[childId]
local existingChildInstance
for instance in pairs(unvisitedExistingChildren) do
local ok, name, className = pcall(function()
return instance.Name, instance.ClassName
end)
if ok then
if name == childData.Name and className == childData.ClassName then
existingChildInstance = instance
break
end
end
end
if existingChildInstance ~= nil then
unvisitedExistingChildren[existingChildInstance] = nil
reconcile(virtualInstancesById, instanceMap, instanceMetadataMap, childId, existingChildInstance)
else
reify(virtualInstancesById, instanceMap, instanceMetadataMap, childId, existingInstance)
end
end
if shouldClearUnknown(id, instanceMetadataMap) then
for existingChildInstance in pairs(unvisitedExistingChildren) do
instanceMap:removeInstance(existingChildInstance)
existingChildInstance:Destroy()
end
end
-- The root instance of a project won't have a parent, like the DataModel,
-- so we need to be careful here.
if virtualInstance.Parent ~= nil then
local parent = instanceMap.fromIds[virtualInstance.Parent]
if parent == nil then
Logging.info("Instance %s wanted parent of %s", tostring(id), tostring(virtualInstance.Parent))
error("Rojo bug: During reconciliation, an instance referred to an instance ID as parent that does not exist.")
end
-- Some instances, like services, don't like having their Parent
-- property poked, even if we're setting it to the same value.
setProperty(existingInstance, "Parent", parent)
if existingInstance.Parent ~= parent then
existingInstance.Parent = parent
end
end
return existingInstance
end
local function applyUpdatePiece(id, visitedIds, virtualInstancesById, instanceMap, instanceMetadataMap)
if visitedIds[id] then
return
end
visitedIds[id] = true
local virtualInstance = virtualInstancesById[id]
local instance = instanceMap.fromIds[id]
-- The instance was deleted in this update
if virtualInstance == nil then
instanceMap:destroyId(id)
return
end
-- An instance we know about was updated
if instance ~= nil then
reconcile(virtualInstancesById, instanceMap, instanceMetadataMap, id, instance)
return instance
end
-- If the instance's parent already exists, we can stick it there
local parentInstance = instanceMap.fromIds[virtualInstance.Parent]
if parentInstance ~= nil then
reify(virtualInstancesById, instanceMap, instanceMetadataMap, id, parentInstance)
return
end
-- Otherwise, we can check if this response payload contained the parent and
-- work from there instead.
local parentData = virtualInstancesById[virtualInstance.Parent]
if parentData ~= nil then
if visitedIds[virtualInstance.Parent] then
error("Rojo bug: An instance was present and marked as visited but its instance was missing")
end
applyUpdatePiece(virtualInstance.Parent, visitedIds, virtualInstancesById, instanceMap, instanceMetadataMap)
return
end
Logging.trace("Instance ID %s, parent ID %s", tostring(id), tostring(virtualInstance.Parent))
error("Rojo NYI: Instances with parents that weren't mentioned in an update payload")
end
local function applyUpdate(requestedIds, virtualInstancesById, instanceMap, instanceMetadataMap)
-- This function may eventually be asynchronous; it will require calls to
-- the server to resolve instances that don't exist yet.
local visitedIds = {}
for _, id in ipairs(requestedIds) do
applyUpdatePiece(id, visitedIds, virtualInstancesById, instanceMap, instanceMetadataMap)
end
end
local Reconciler = require(script.Parent.Reconciler)
local Session = {}
Session.__index = Session
@@ -251,7 +10,7 @@ function Session.new(config)
self.onError = config.onError
local instanceMap = makeInstanceMap()
local reconciler
local remoteUrl = ("http://%s:%s"):format(config.address, config.port)
@@ -274,7 +33,7 @@ function Session.new(config)
return api:read(requestedIds)
:andThen(function(response)
return applyUpdate(requestedIds, response.instances, instanceMap, api.instanceMetadataMap)
return reconciler:applyUpdate(requestedIds, response.instances)
end)
:catch(function(message)
Logging.warn("%s", tostring(message))
@@ -284,11 +43,12 @@ function Session.new(config)
api:connect()
:andThen(function()
reconciler = Reconciler.new(api.instanceMetadataMap)
return api:read({api.rootInstanceId})
end)
:andThen(function(response)
reconcile(response.instances, instanceMap, api.instanceMetadataMap, api.rootInstanceId, game)
-- reify(response.instances, instanceMap, instanceMetadataMap, api.rootInstanceId, game.ReplicatedStorage)
reconciler:reconcile(response.instances, api.rootInstanceId, game)
return api:retrieveMessages()
end)
:catch(function(message)