perf: sync atomic changes to keys

This commit is contained in:
Richard 2024-10-31 20:57:27 -07:00
parent 13e06dd44c
commit ecf808c706
13 changed files with 368 additions and 402 deletions

View file

@ -19,6 +19,30 @@ return function()
expect(a()).to.equal(2)
end)
it("requires init by default", function()
local a = atom(1)
local client = client({ atoms = { a = a } })
client:sync({
type = "patch",
data = { { path = { "a" }, data = 2 } },
})
expect(a()).to.equal(1)
end)
it("can ignore init requirement", function()
local a = atom(1)
local client = client({ atoms = { a = a }, ignoreUnhydrated = false })
client:sync({
type = "patch",
data = { { path = { "a" }, data = 2 } },
})
expect(a()).to.equal(2)
end)
it("syncs state patch", function()
local a = atom({ b = 1, c = 2 })
local previous = a()
@ -33,7 +57,7 @@ return function()
client:sync({
type = "patch",
data = { a = { b = 3 } },
data = { { path = { "a", "b" }, data = 3 } },
})
expect(a()).to.never.equal(previous)
@ -41,27 +65,6 @@ return function()
expect(a().c).to.equal(2)
end)
it("fixes sparse array patches", function()
local a = atom({ 1 })
local client = client({ atoms = { a = a } })
client:sync({
type = "init",
data = { a = a() },
})
-- This patch would be the result of a diff between { 1 } and { 1, 2 } that
-- was JSON-serialized and deserialized. The client should be able to catch
-- this and convert the string key back to a number.
client:sync({
type = "patch",
data = { a = { ["2"] = 2 } },
})
expect(a()["2" :: any]).to.never.be.ok()
expect(a()[2]).to.equal(2)
end)
it("applies multiple patches", function()
local a = atom(1)
local client = client({ atoms = { a = a } })
@ -78,10 +81,10 @@ return function()
client:sync({
type = "patch",
data = { a = 2 },
data = { { path = { "a" }, data = 2 } },
}, {
type = "patch",
data = { a = 3 },
data = { { path = { "a" }, data = 3 } },
})
expect(a()).to.equal(3)
@ -110,7 +113,10 @@ return function()
client:sync({
type = "patch",
data = { a = 1, b = 2 },
data = {
{ path = { "a" }, data = 1 },
{ path = { "b" }, data = 2 },
},
})
expect(a()).to.equal(1)

View file

@ -0,0 +1,68 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local packets = require(ReplicatedStorage.DevPackages.CharmSync.packets)
return function()
it("creates a packet", function()
local prevState = { foo = "bar", baz = "qux" }
local nextState = { foo = "bar", baz = "quux" }
local results = packets.diff(prevState, nextState)
expect(#results).to.equal(1)
expect(table.concat(results[1].path, "/")).to.equal("baz")
expect(results[1].data).to.equal("quux")
end)
it("applies a packet", function()
local prevState = { foo = "bar", baz = "qux" }
local packet: packets.Packet = { path = { "baz" }, data = "quux" }
local nextState = packets.apply(prevState, { packet })
expect(nextState.baz).to.equal("quux")
expect(nextState.foo).to.equal("bar")
end)
it("applies nested packets", function()
local prevState = { foo = { bar = "baz" } }
local nextState = { foo = { bar = "qux" } }
local results = packets.diff(prevState, nextState)
local applied = packets.apply(prevState, results)
expect(prevState.foo.bar).to.equal("baz")
expect(applied.foo.bar).to.equal("qux")
end)
it("only copies changed tables", function()
local prevState = { foo = { bar = "baz" }, baz = { qux = "quux" } }
local nextState = { foo = { bar = "qux" }, baz = table.clone(prevState.baz) }
local results = packets.diff(prevState, nextState)
local applied = packets.apply(prevState, results)
expect(applied).to.never.equal(prevState)
expect(applied.foo).to.never.equal(prevState.foo)
expect(applied.foo.bar).to.equal("qux")
expect(applied.baz).to.equal(prevState.baz)
end)
it("excludes unchanged tables", function()
local prevState = { foo = { bar = "baz" }, baz = { qux = "quux" } }
local nextState = { foo = { bar = "qux" }, baz = table.clone(prevState.baz) }
local results = packets.diff(prevState, nextState)
expect(#results).to.equal(1)
expect(table.concat(results[1].path, "/")).to.equal("foo/bar")
expect(results[1].data).to.equal("qux")
end)
it("overwrites cleared tables", function()
local prevState = { foo = { 1, 2, 3 } }
local nextState = { foo = {} }
local results = packets.diff(prevState, nextState)
expect(#results).to.equal(1)
expect(table.concat(results[1].path, "/")).to.equal("foo")
expect(#results[1].data).to.equal(0)
end)
end

View file

@ -1,102 +0,0 @@
local HttpService = game:GetService("HttpService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch)
return function()
local function serdes(value: { [any]: any })
return HttpService:JSONDecode(HttpService:JSONEncode(value))
end
it("returns a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local nextState = { foo = "bar", baz = "quux" }
local patches = patch.diff(prevState, nextState)
expect(patches).to.be.ok()
expect(patches.baz).to.equal("quux")
expect(patches.foo).to.never.be.ok()
end)
it("applies a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local patches = { baz = "quux", qaz = "qux" }
local applied = patch.apply(prevState, patches)
expect(applied).to.be.ok()
expect(applied.baz).to.equal("quux")
expect(applied.foo).to.equal("bar")
expect(applied.qaz).to.equal("qux")
end)
it("allows nested patches", function()
local prevState = { foo = { bar = "baz" } }
local nextState = { foo = { bar = "qux" } }
local patches = patch.diff(prevState, nextState)
local applied = patch.apply(prevState, patches)
expect(applied.foo.bar).to.equal("qux")
end)
it("preserves array density when applying", function()
local prevState = { "a", "b", "c", "d" }
local nextState = { ["2"] = "B", ["4"] = "D" }
local applied = patch.apply(prevState, nextState)
expect(applied[1]).to.equal("a")
expect(applied[2]).to.equal("B")
expect(applied[3]).to.equal("c")
expect(applied[4]).to.equal("D")
expect(applied["2"]).to.never.be.ok()
expect(applied["4"]).to.never.be.ok()
end)
itFOCUS("preserves array density after serdes", function()
-- This particular change results in a patch that cannot be serialized
-- without losing information. Charm resolves this issue internally.
local prevState = { "a", "b", "c", "d", "e" }
local nextState = { "A", "b", "c", "D" }
-- FIXME: The [0] index used to fix serialization issues is not preserved
local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
for index in next, prevState do
expect(applied[index]).to.equal(nextState[index])
end
for index in next, applied do
expect(applied[index]).to.equal(nextState[index])
end
end)
it("preserves large gaps between keys", function()
local prevState = table.create(10000, "foo")
local nextState = table.move(prevState, 2, 9998, 2, { [1] = "bar", [9999] = "bar" })
local start = os.clock()
local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
assert(os.clock() - start < 10e-3, `Performance is not acceptable ({(os.clock() - start) * 1e3}ms)`)
for index in next, prevState do
expect(applied[index]).to.equal(nextState[index])
end
end)
it("cannot preserve sparse arrays", function()
local prevState = {}
local nextState = { [2] = "foo" }
local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
-- Not enough information is available to determine whether 'patches'
-- should keep the string keys added during remote event serialization.
expect(applied["2"]).to.be.ok()
expect(applied[2]).to.never.be.ok()
end)
end

View file

@ -5,7 +5,6 @@ local atom = Charm.atom
local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync)
type SyncPayload = CharmSync.SyncPayload
local server = CharmSync.server
local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch)
return function()
local target: Player = {} :: any
@ -22,14 +21,14 @@ return function()
server:hydrate(target)
expect(player).to.equal(target)
expect(payload.type).to.equal("init")
assert(payload.type == "init", "payload.type is not 'init'")
expect(payload.data.a).to.equal(1)
disconnect()
end)
it("sends state patches", function()
local a = atom({ b = 1, c = 2 } :: { [string]: number? })
local a = atom({ 1, 2 })
local server = server({ atoms = { a = a }, interval = -1 })
local player: Player, payload: SyncPayload
@ -37,20 +36,27 @@ return function()
player, payload = ...
end)
a({ b = 1, c = 5 })
a({ 1, 5 })
server:_sendPatch(target)
expect(player).to.equal(target)
expect(payload.type).to.equal("patch")
expect(payload.data.a.b).to.never.be.ok()
expect(payload.data.a.c).to.equal(5)
assert(payload.type == "patch", "payload.type is not 'patch'")
expect(#payload.data).to.equal(1)
a({ b = 1, d = 1 })
expect(table.concat(payload.data[1].path, "/")).to.equal("a/2")
expect(payload.data[1].data).to.equal(5)
a({ 1, nil :: any, 1 })
server:_sendPatch(target)
expect(payload.data.a.b).to.never.be.ok()
expect(payload.data.a.c).to.equal(patch.None)
expect(payload.data.a.d).to.equal(1)
assert(payload.type == "patch", "payload.type is not 'patch'")
expect(#payload.data).to.equal(2)
expect(table.concat(payload.data[1].path, "/")).to.equal("a/2")
expect(payload.data[1].data).to.never.be.ok()
expect(table.concat(payload.data[2].path, "/")).to.equal("a/3")
expect(payload.data[2].data).to.equal(1)
disconnect()
end)
@ -75,9 +81,16 @@ return function()
server:_sendPatch(target)
expect(#payloads).to.equal(1)
expect(payloads[1].type).to.equal("patch")
expect(payloads[1].data.a).to.equal(2)
expect(payloads[1].data.b).to.equal(2)
assert(payloads[1].type == "patch", "payload 1 type is not 'patch'")
expect(#payloads[1].data).to.equal(2)
-- a(2)
expect(table.concat(payloads[1].data[1].path, "/")).to.equal("a")
expect(payloads[1].data[1].data).to.equal(2)
-- b(2)
expect(table.concat(payloads[1].data[2].path, "/")).to.equal("b")
expect(payloads[1].data[2].data).to.equal(2)
a(3)
a(4)
@ -85,12 +98,24 @@ return function()
server:_sendPatch(target)
expect(#payloads).to.equal(2)
expect(payloads[1].type).to.equal("patch")
expect(payloads[1].data.a).to.equal(3)
expect(payloads[1].data.b).to.never.be.ok()
expect(payloads[2].type).to.equal("patch")
expect(payloads[2].data.a).to.equal(4)
expect(payloads[2].data.b).to.equal(3)
assert(payloads[1].type == "patch", "payload 1 type is not 'patch'")
expect(#payloads[1].data).to.equal(1)
-- a(3)
expect(table.concat(payloads[1].data[1].path, "/")).to.equal("a")
expect(payloads[1].data[1].data).to.equal(3)
-- second a() call cannot be collapsed, so it is added to a new payload
assert(payloads[2].type == "patch", "payload 2 type is not 'patch'")
expect(#payloads[2].data).to.equal(2)
-- a(4)
expect(table.concat(payloads[2].data[1].path, "/")).to.equal("a")
expect(payloads[2].data[1].data).to.equal(4)
-- b(3)
expect(table.concat(payloads[2].data[2].path, "/")).to.equal("b")
expect(payloads[2].data[2].data).to.equal(3)
disconnect()
end)