rojo/plugin/src/Reconciler/matching.lua
Joe 5b38648ead Update dependencies and improve syncback handling
- Updated versions of several dependencies in `Cargo.lock`, including `async-compression` (0.4.40), `aws-lc-rs` (1.16.0), `bumpalo` (3.20.1), and `compression-codecs` (0.4.37), ensuring compatibility and access to the latest features and fixes.
- Refactored the `fire` method in `SyncbackSignal` to return a boolean indicating whether a syncback was successfully initiated, preventing concurrent syncbacks.
- Enhanced error handling in the API syncback process to return a conflict response if a syncback is already in progress.
- Improved the `serve.rs` file by replacing `forget` with `drop` for better memory management and added checks to ensure service chunk integrity during live syncback operations.
- Added new tests for edge cases and resilience in live syncback functionality, ensuring robustness against malformed data and rapid sequential requests.

These changes aim to enhance the reliability and performance of the syncback feature while keeping dependencies up to date.
2026-02-19 00:17:43 -06:00

656 lines
16 KiB
Lua

--!strict
--!native
--!optimize 2
--[[
Instance matching algorithm for the plugin.
Pairs server virtual instances to Studio instances during hydration
by minimizing total reconciler changes. matchChildren is fundamentally
computeSubtreeCost -- it finds the cost-minimizing pairing of children
and returns the total cost. The match assignments are a bonus output.
Two-function mutual recursion:
matchChildren: given two child sets, find optimal pairing + totalCost
computePairCost: given one virtual + one studio instance, compute
total cost including subtree (calls matchChildren for children)
A MatchingSession holds caches keyed by instance identity so that
recursive scoring results are reused when hydrate recurses into the
same children that were already scored.
]]
local Packages = script.Parent.Parent.Parent.Packages
local RbxDom = require(Packages.RbxDom)
local trueEquals = require(script.Parent.trueEquals)
local UNMATCHED_PENALTY = 10000
local MAX_SCORING_DEPTH = 3
-- ================================================================
-- Types
-- ================================================================
type MatchPair = {
virtualId: string,
studioInstance: Instance,
}
type MatchResult = {
matched: { MatchPair },
unmatchedVirtual: { string },
unmatchedStudio: { Instance },
totalCost: number,
}
type MatchingSession = {
matchCache: { [string]: { [Instance]: MatchResult } },
costCache: { [string]: { [Instance]: number } },
vCacheStore: { [string]: VCache },
}
type RefIdentity = {
name: string,
className: string,
}
type VCache = {
props: { [string]: any },
extraProps: { string },
tags: { [string]: boolean },
attrs: { [string]: any },
refs: { [string]: RefIdentity },
childCount: number,
validChildren: { string },
}
type SCache = {
instance: Instance,
props: { [string]: any },
tags: { [string]: boolean },
attrs: { [string]: any },
refs: { [string]: RefIdentity },
children: { Instance },
childCount: number,
}
type VirtualInstance = {
Name: string,
ClassName: string,
Children: { string },
Properties: { [string]: any }?,
}
type VirtualInstances = { [string]: VirtualInstance }
type ClassComparisonKeys = {
propNames: { string },
propNameSet: { [string]: boolean },
defaults: { [string]: any },
}
type ScoredPair = {
vi: number,
si: number,
cost: number,
idx: number,
}
-- ================================================================
-- Session constructor
-- ================================================================
local function newSession(): MatchingSession
return {
matchCache = {},
costCache = {},
vCacheStore = {},
}
end
-- ================================================================
-- Utilities
-- ================================================================
local function rawIndex(inst: any, key: string): any
return inst[key]
end
local function safeGet(inst: Instance, key: string): (boolean, any)
return pcall(rawIndex, inst :: any, key)
end
-- ================================================================
-- Pre-computation helpers (called once per instance per group)
-- ================================================================
local function cacheVirtual(
vInst: VirtualInstance,
classKeys: ClassComparisonKeys,
virtualInstances: VirtualInstances
): VCache
local decoded: { [string]: any } = {}
local extraProps: { string } = {}
local refs: { [string]: RefIdentity } = {}
local vProps = vInst.Properties
if vProps then
for propName, encodedValue in pairs(vProps) do
if propName == "Tags" or propName == "Attributes" then
continue
end
local ty = next(encodedValue)
if ty == "Ref" then
local targetId = encodedValue.Ref
if targetId then
local target = virtualInstances[targetId]
if target then
refs[propName] = { name = target.Name, className = target.ClassName }
end
end
continue
end
local pcallOk, decodeOk, value = pcall(RbxDom.EncodedValue.decode, encodedValue)
if pcallOk and decodeOk and value ~= nil then
decoded[propName] = value
if not classKeys.propNameSet[propName] then
table.insert(extraProps, propName)
end
end
end
end
local decodedTags: { [string]: boolean } = {}
if vProps and vProps.Tags then
local ok, tags = RbxDom.EncodedValue.decode(vProps.Tags)
if ok and type(tags) == "table" then
for _, tag in ipairs(tags) do
decodedTags[tag] = true
end
end
end
local decodedAttrs: { [string]: any } = {}
if vProps and vProps.Attributes then
local ok, attrs = RbxDom.EncodedValue.decode(vProps.Attributes)
if ok and type(attrs) == "table" then
decodedAttrs = attrs
end
end
local validChildren: { string } = {}
if vInst.Children then
for _, childId in ipairs(vInst.Children) do
if virtualInstances[childId] then
table.insert(validChildren, childId)
end
end
end
return {
props = decoded,
extraProps = extraProps,
tags = decodedTags,
attrs = decodedAttrs,
refs = refs,
childCount = if vInst.Children then #vInst.Children else 0,
validChildren = validChildren,
}
end
local function cacheStudio(
studioInstance: Instance,
classKeys: ClassComparisonKeys,
extraPropNames: { string },
refPropNames: { string }
): SCache
local props: { [string]: any } = {}
for _, propName in ipairs(classKeys.propNames) do
local ok, value = safeGet(studioInstance, propName)
if ok then
props[propName] = value
end
end
for _, propName in ipairs(extraPropNames) do
if props[propName] ~= nil then
continue
end
local ok, value = safeGet(studioInstance, propName)
if ok then
props[propName] = value
end
end
local refs: { [string]: RefIdentity } = {}
for _, propName in ipairs(refPropNames) do
local ok, value = safeGet(studioInstance, propName)
if ok and typeof(value) == "Instance" then
refs[propName] = { name = (value :: Instance).Name, className = (value :: Instance).ClassName }
end
end
local tags: { [string]: boolean } = {}
for _, tag in ipairs(studioInstance:GetTags()) do
tags[tag] = true
end
local children = studioInstance:GetChildren()
return {
instance = studioInstance,
props = props,
tags = tags,
attrs = studioInstance:GetAttributes(),
refs = refs,
children = children,
childCount = #children,
}
end
-- ================================================================
-- Hot-path scoring (ZERO decode, ZERO reflection lookup)
-- ================================================================
local function countOwnDiffs(vCache: VCache, sCache: SCache, classKeys: ClassComparisonKeys): number
local cost = 0
local vProps = vCache.props
local sProps = sCache.props
local defaults = classKeys.defaults
for _, propName in ipairs(classKeys.propNames) do
local vVal = vProps[propName]
if vVal == nil then
vVal = defaults[propName]
end
local sVal = sProps[propName]
if vVal ~= sVal and not trueEquals(vVal, sVal) then
cost += 1
end
end
for _, propName in ipairs(vCache.extraProps) do
local vVal = vProps[propName]
local sVal = sProps[propName]
if vVal ~= sVal and not trueEquals(vVal, sVal) then
cost += 1
end
end
local vTags = vCache.tags
local sTags = sCache.tags
for tag, _ in pairs(vTags) do
if not sTags[tag] then
cost += 1
end
end
for tag, _ in pairs(sTags) do
if not vTags[tag] then
cost += 1
end
end
local vAttrs = vCache.attrs
local sAttrs = sCache.attrs
for key, vVal in pairs(vAttrs) do
local sVal = sAttrs[key]
if vVal ~= sVal and not trueEquals(vVal, sVal) then
cost += 1
end
end
for key, _ in pairs(sAttrs) do
if vAttrs[key] == nil then
cost += 1
end
end
local vRefs = vCache.refs
local sRefs = sCache.refs
for propName, vRef in pairs(vRefs) do
local sRef = sRefs[propName]
if sRef then
if vRef.name ~= sRef.name or vRef.className ~= sRef.className then
cost += UNMATCHED_PENALTY
end
else
cost += UNMATCHED_PENALTY
end
end
for propName, _ in pairs(sRefs) do
if not vRefs[propName] then
cost += UNMATCHED_PENALTY
end
end
if vCache.childCount ~= sCache.childCount then
cost += 1
end
return cost
end
-- ================================================================
-- Core: 2-function mutual recursion
-- ================================================================
local matchChildren -- forward declare
local function computePairCost(
session: MatchingSession,
virtualId: string,
studioInstance: Instance,
virtualInstances: VirtualInstances,
bestSoFar: number,
depth: number
): number
local vc = session.costCache[virtualId]
if vc then
local cached = vc[studioInstance]
if cached ~= nil then
return cached
end
end
local vInst = virtualInstances[virtualId]
if not vInst then
return UNMATCHED_PENALTY
end
local classKeys = RbxDom.getClassComparisonKeys(vInst.ClassName)
local vCache = session.vCacheStore[virtualId]
if not vCache then
vCache = cacheVirtual(vInst, classKeys, virtualInstances)
session.vCacheStore[virtualId] = vCache
end
local refPropNames: { string } = {}
for propName, _ in pairs(vCache.refs) do
table.insert(refPropNames, propName)
end
local sCache = cacheStudio(studioInstance, classKeys, vCache.extraProps, refPropNames)
local cost = countOwnDiffs(vCache, sCache, classKeys)
if cost >= bestSoFar or depth >= MAX_SCORING_DEPTH then
return cost
end
local validVChildren = vCache.validChildren
local studioKids = sCache.children
if #validVChildren > 0 or #studioKids > 0 then
if #validVChildren == 0 then
cost += #studioKids * UNMATCHED_PENALTY
elseif #studioKids == 0 then
cost += #validVChildren * UNMATCHED_PENALTY
else
local childResult = matchChildren(
session,
validVChildren,
studioKids,
virtualInstances,
virtualId,
studioInstance,
depth + 1
)
cost += childResult.totalCost
end
end
if cost < bestSoFar then
if not session.costCache[virtualId] then
session.costCache[virtualId] = {}
end
session.costCache[virtualId][studioInstance] = cost
end
return cost
end
matchChildren = function(
session: MatchingSession,
virtualChildren: { string },
studioChildren: { Instance },
virtualInstances: VirtualInstances,
parentVirtualId: string?,
parentStudioInstance: Instance?,
_depth: number?
): MatchResult
local depth = _depth or 0
if parentVirtualId and parentStudioInstance then
local pc = session.matchCache[parentVirtualId]
if pc then
local cached = pc[parentStudioInstance]
if cached then
return cached
end
end
end
local matched: { MatchPair } = {}
local matchedCosts: { number? } = {}
local matchedV: { [number]: boolean } = {}
local matchedS: { [number]: boolean } = {}
-- ============================================================
-- Group by (Name, ClassName) once
-- ============================================================
local vByKey: { [string]: { number } } = {}
for i, id in ipairs(virtualChildren) do
local vInst = virtualInstances[id]
if vInst then
local key = vInst.Name .. "\0" .. vInst.ClassName
local group = vByKey[key]
if not group then
group = {}
vByKey[key] = group
end
table.insert(group, i)
end
end
local sByKey: { [string]: { number } } = {}
for i, inst in ipairs(studioChildren) do
local key = inst.Name .. "\0" .. inst.ClassName
local group = sByKey[key]
if not group then
group = {}
sByKey[key] = group
end
table.insert(group, i)
end
-- ============================================================
-- 1:1 instant match + ambiguous scoring in single pass
-- ============================================================
for key, vIndices in pairs(vByKey) do
local sIndices = sByKey[key]
if not sIndices then
continue
end
if #vIndices == 1 and #sIndices == 1 then
local vi, si = vIndices[1], sIndices[1]
table.insert(matched, {
virtualId = virtualChildren[vi],
studioInstance = studioChildren[si],
})
table.insert(matchedCosts, nil)
matchedV[vi] = true
matchedS[si] = true
continue
end
local firstVInst = virtualInstances[virtualChildren[vIndices[1]]]
if not firstVInst then
continue
end
local classKeys = RbxDom.getClassComparisonKeys(firstVInst.ClassName)
local vCaches: { [number]: VCache } = {}
local allExtraProps: { [string]: boolean } = {}
local allRefProps: { [string]: boolean } = {}
for _, vi in ipairs(vIndices) do
local vid = virtualChildren[vi]
local vInst = virtualInstances[vid]
if not vInst then
continue
end
local vCache = session.vCacheStore[vid]
if not vCache then
vCache = cacheVirtual(vInst, classKeys, virtualInstances)
session.vCacheStore[vid] = vCache
end
vCaches[vi] = vCache
for _, propName in ipairs(vCache.extraProps) do
allExtraProps[propName] = true
end
for propName, _ in pairs(vCache.refs) do
allRefProps[propName] = true
end
end
local extraPropNamesArray: { string } = {}
for propName, _ in pairs(allExtraProps) do
table.insert(extraPropNamesArray, propName)
end
local refPropNamesArray: { string } = {}
for propName, _ in pairs(allRefProps) do
table.insert(refPropNamesArray, propName)
end
local sCaches: { [number]: SCache } = {}
for _, si in ipairs(sIndices) do
sCaches[si] = cacheStudio(studioChildren[si], classKeys, extraPropNamesArray, refPropNamesArray)
end
local scoredPairs: { ScoredPair } = {}
local pairIdx = 0
local bestSoFar = math.huge
for _, vi in ipairs(vIndices) do
local vCache = vCaches[vi]
if not vCache then
continue
end
for _, si in ipairs(sIndices) do
local sCache = sCaches[si]
if not sCache then
continue
end
pairIdx += 1
local cost = countOwnDiffs(vCache, sCache, classKeys)
if cost < bestSoFar and depth < MAX_SCORING_DEPTH then
local validVChildren = vCache.validChildren
local studioKids = sCache.children
if #validVChildren > 0 or #studioKids > 0 then
if #validVChildren == 0 then
cost += #studioKids * UNMATCHED_PENALTY
elseif #studioKids == 0 then
cost += #validVChildren * UNMATCHED_PENALTY
else
local childResult = matchChildren(
session,
validVChildren,
studioKids,
virtualInstances,
virtualChildren[vi],
sCache.instance,
depth + 1
)
cost += childResult.totalCost
end
end
end
table.insert(scoredPairs, { vi = vi, si = si, cost = cost, idx = pairIdx })
if cost < bestSoFar then
bestSoFar = cost
end
end
end
table.sort(scoredPairs, function(a: ScoredPair, b: ScoredPair): boolean
if a.cost ~= b.cost then
return a.cost < b.cost
end
return a.idx < b.idx
end)
for _, pair in ipairs(scoredPairs) do
if matchedV[pair.vi] or matchedS[pair.si] then
continue
end
table.insert(matched, {
virtualId = virtualChildren[pair.vi],
studioInstance = studioChildren[pair.si],
})
table.insert(matchedCosts, pair.cost)
matchedV[pair.vi] = true
matchedS[pair.si] = true
end
end
-- ============================================================
-- Build unmatched lists + compute totalCost
-- ============================================================
local unmatchedVirtual: { string } = {}
for i, id in ipairs(virtualChildren) do
if not matchedV[i] then
table.insert(unmatchedVirtual, id)
end
end
local unmatchedStudio: { Instance } = {}
for i, inst in ipairs(studioChildren) do
if not matchedS[i] then
table.insert(unmatchedStudio, inst)
end
end
local totalCost = 0
for i, pair in ipairs(matched) do
local precomputed = matchedCosts[i]
if precomputed ~= nil then
totalCost += precomputed
else
totalCost += computePairCost(
session,
pair.virtualId,
pair.studioInstance,
virtualInstances,
math.huge,
depth
)
end
end
totalCost += (#unmatchedVirtual + #unmatchedStudio) * UNMATCHED_PENALTY
local result = {
matched = matched,
unmatchedVirtual = unmatchedVirtual,
unmatchedStudio = unmatchedStudio,
totalCost = totalCost,
}
if parentVirtualId and parentStudioInstance then
if not session.matchCache[parentVirtualId] then
session.matchCache[parentVirtualId] = {}
end
session.matchCache[parentVirtualId][parentStudioInstance] = result
end
return result
end
return {
newSession = newSession,
matchChildren = matchChildren,
}