chore: sync save state

When a peer save the map, other peers in dirty state should not need
to save the map anymore.

That implementation uses the lastKnownHLC as a reference, but it changes
all dirty states at once. Another impementation could be to have each
object sync its dirty state, but in this case we do not have a HLC per
object as reference, and it also creates more messages.

Co-authored-by: David Larlet <david@larlet.fr>
This commit is contained in:
Yohan Boniface 2025-02-07 16:47:45 +01:00
parent 815ff046ff
commit b8db07a4ce
6 changed files with 80 additions and 2 deletions

View file

@ -10,6 +10,11 @@ export async function save() {
} }
} }
export function clear() {
_queue.clear()
onUpdate()
}
function add(obj) { function add(obj) {
_queue.add(obj) _queue.add(obj)
onUpdate() onUpdate()

View file

@ -2,6 +2,7 @@ import * as Utils from '../utils.js'
import { HybridLogicalClock } from './hlc.js' import { HybridLogicalClock } from './hlc.js'
import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js' import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js'
import { WebSocketTransport } from './websocket.js' import { WebSocketTransport } from './websocket.js'
import * as SaveManager from '../saving.js'
// Start reconnecting after 2 seconds, then double the delay each time // Start reconnecting after 2 seconds, then double the delay each time
// maxing out at 32 seconds. // maxing out at 32 seconds.
@ -125,6 +126,13 @@ export class SyncEngine {
this._send({ verb: 'delete', subject, metadata, key }) this._send({ verb: 'delete', subject, metadata, key })
} }
saved() {
this.transport.send('SavedMessage', {
sender: this.peerId,
lastKnownHLC: this._operations.getLastKnownHLC(),
})
}
_send(inputMessage) { _send(inputMessage) {
const message = this._operations.addLocal(inputMessage) const message = this._operations.addLocal(inputMessage)
@ -168,6 +176,8 @@ export class SyncEngine {
} else if (payload.message.verb === 'ListOperationsResponse') { } else if (payload.message.verb === 'ListOperationsResponse') {
this.onListOperationsResponse(payload) this.onListOperationsResponse(payload)
} }
} else if (kind === 'SavedMessage') {
this.onSavedMessage(payload)
} else { } else {
throw new Error(`Received unknown message from the websocket server: ${kind}`) throw new Error(`Received unknown message from the websocket server: ${kind}`)
} }
@ -280,6 +290,13 @@ export class SyncEngine {
// Else: apply // Else: apply
} }
onSavedMessage({ sender, lastKnownHLC }) {
debug(`received saved message from peer ${sender}`, lastKnownHLC)
if (lastKnownHLC === this._operations.getLastKnownHLC() && SaveManager.isDirty) {
SaveManager.clear()
}
}
/** /**
* Send a message to another peer (via the transport layer) * Send a message to another peer (via the transport layer)
* *
@ -350,7 +367,7 @@ export class Operations {
} }
/** /**
* Tick the clock and add store the passed message in the operations list. * Tick the clock and store the passed message in the operations list.
* *
* @param {*} inputMessage * @param {*} inputMessage
* @returns {*} clock-aware message * @returns {*} clock-aware message

View file

@ -684,6 +684,7 @@ export default class Umap extends ServerStored {
Alert.success(translate('Map has been saved!')) Alert.success(translate('Map has been saved!'))
}) })
} }
this.sync.saved()
this.fire('saved') this.fire('saved')
} }

View file

@ -14,6 +14,7 @@ from .payloads import (
OperationMessage, OperationMessage,
PeerMessage, PeerMessage,
Request, Request,
SavedMessage,
) )
@ -165,6 +166,10 @@ class Peer:
case OperationMessage(): case OperationMessage():
await self.broadcast(text_data) await self.broadcast(text_data)
# Broadcast the new map state to connected peers
case SavedMessage():
await self.broadcast(text_data)
# Send peer messages to the proper peer # Send peer messages to the proper peer
case PeerMessage(): case PeerMessage():
await self.send_to(incoming.root.recipient, text_data) await self.send_to(incoming.root.recipient, text_data)

View file

@ -30,10 +30,17 @@ class PeerMessage(BaseModel):
message: dict message: dict
class SavedMessage(BaseModel):
kind: Literal["SavedMessage"] = "SavedMessage"
lastKnownHLC: str
class Request(RootModel): class Request(RootModel):
"""Any message coming from the websocket should be one of these, and will be rejected otherwise.""" """Any message coming from the websocket should be one of these, and will be rejected otherwise."""
root: Union[PeerMessage, OperationMessage] = Field(discriminator="kind") root: Union[PeerMessage, OperationMessage, SavedMessage] = Field(
discriminator="kind"
)
class JoinResponse(BaseModel): class JoinResponse(BaseModel):

View file

@ -557,3 +557,46 @@ def test_create_and_sync_map(new_page, asgi_live_server, tilelayer, login, user)
peerA.get_by_role("button", name="Edit").click() peerA.get_by_role("button", name="Edit").click()
expect(markersA).to_have_count(2) expect(markersA).to_have_count(2)
expect(markersB).to_have_count(2) expect(markersB).to_have_count(2)
@pytest.mark.xdist_group(name="websockets")
def test_should_sync_saved_status(new_page, asgi_live_server, tilelayer):
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
map.settings["properties"]["syncEnabled"] = True
map.save()
# Create two tabs
peerA = new_page("Page A")
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
peerB = new_page("Page B")
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
# Create a new marker from peerA
peerA.get_by_title("Draw a marker").click()
peerA.locator("#map").click(position={"x": 220, "y": 220})
# Peer A should be in dirty state
expect(peerA.locator("body")).to_have_class(re.compile(".*umap-is-dirty.*"))
# Peer B should not be in dirty state
expect(peerB.locator("body")).not_to_have_class(re.compile(".*umap-is-dirty.*"))
# Create a new marker from peerB
peerB.get_by_title("Draw a marker").click()
peerB.locator("#map").click(position={"x": 200, "y": 250})
# Peer B should be in dirty state
expect(peerB.locator("body")).to_have_class(re.compile(".*umap-is-dirty.*"))
# Peer A should still be in dirty state
expect(peerA.locator("body")).to_have_class(re.compile(".*umap-is-dirty.*"))
# Save layer to the server from peerA
with peerA.expect_response(re.compile(".*/datalayer/create/.*")):
peerA.get_by_role("button", name="Save").click()
# Peer B should not be in dirty state
expect(peerB.locator("body")).not_to_have_class(re.compile(".*umap-is-dirty.*"))
# Peer A should not be in dirty state
expect(peerA.locator("body")).not_to_have_class(re.compile(".*umap-is-dirty.*"))