- app/services/realtime_merge.py : merge à 3 voix diff3-lite, regions disjointes conservees, conflit par champ + drapeau - protocole base (client embarque la base de sa saisie) ; sans base -> LWW historique (retro-compat) - ack renvoie le bloc fusionne + conflict ; adoption cote client + toast ; broadcast du resultat fusionne - broadcast non bloquant : file sortante + tache writer par connexion, coalescence des curseurs - clients trop lents deconnectes (4413), budget ops anti-flood (400/10s) - fix fuite room 4404 + room_state() sur page inexistante - GET /api/realtime/stats (observabilite) - 26 tests test_realtime_v64.py ; suite 725 verte ; ruff + eslint OK ; version 6.4.0
165 lines
6.3 KiB
Python
165 lines
6.3 KiB
Python
"""FlowDeck — v6.4.0 Realtime: résolution de conflits au-delà du last-write-wins.
|
|
|
|
Le LWW par bloc (v5.13.0) écrase intégralement le bloc du dernier arrivé : si deux
|
|
utilisateurs tapent dans le *même* bloc, la saisie du premier est perdue. Ce module
|
|
implémente un vrai *merge à trois versions* (diff3-lite) :
|
|
|
|
base = l'état du bloc dont le client dérive sa saisie (envoyé avec l'op)
|
|
current = l'état actuel du bloc côté serveur (déjà mis à jour par d'autres)
|
|
incoming = la nouvelle proposition du client
|
|
|
|
Règle champ-par-champ :
|
|
* incoming == base → le client n'a pas touché ce champ → on garde current
|
|
* current == base → le serveur n'a pas touché ce champ → on garde incoming
|
|
* current == incoming → les deux ont fait la même chose → sans conflit
|
|
* sinon (conflit)
|
|
- champ texte (str) : merge de caractères. Les régions modifiées qui ne se
|
|
chevauchent pas sont *toutes conservées* (les deux saisies survivent) ;
|
|
chevauchement réel → LWW sur ce champ + drapeau de conflit.
|
|
- autre type (bool, nombre…) : LWW sur ce champ + drapeau de conflit.
|
|
|
|
Toutes les fonctions sont pures et testables sans WebSocket ni base de données.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
__all__ = ["merge_text_3way", "merge_block_3way", "changed_region"]
|
|
|
|
|
|
def changed_region(base: str, other: str) -> tuple[int, int, str] | None:
|
|
"""Région de `base` remplacée par `other` (trim préfixe/suffixe commun).
|
|
|
|
Retourne ``(start, end, replacement)`` tel que ``base[:start] + replacement +
|
|
base[end:] == other``, ou ``None`` si ``other == base`` (aucun changement).
|
|
"""
|
|
if base == other:
|
|
return None
|
|
minlen = min(len(base), len(other))
|
|
prefix = 0
|
|
while prefix < minlen and base[prefix] == other[prefix]:
|
|
prefix += 1
|
|
suffix = 0
|
|
# ne jamais chevaucher le préfixe déjà consommé
|
|
while (suffix < len(base) - prefix and suffix < len(other) - prefix
|
|
and base[len(base) - 1 - suffix] == other[len(other) - 1 - suffix]):
|
|
suffix += 1
|
|
return prefix, len(base) - suffix, other[prefix:len(other) - suffix]
|
|
|
|
|
|
def merge_text_3way(base: str, current: str, incoming: str) -> tuple[str, bool]:
|
|
"""Merge à trois versions d'une chaîne. Retourne ``(texte, conflit)``.
|
|
|
|
Les éditions qui ne se chevauchent pas sont toutes les deux conservées ;
|
|
un chevauchement réel retombe en LWW (``incoming`` gagne) et signale le conflit.
|
|
"""
|
|
if current == incoming:
|
|
return current, False
|
|
if current == base:
|
|
return incoming, False
|
|
if incoming == base:
|
|
return current, False
|
|
|
|
rc = changed_region(base, current)
|
|
ri = changed_region(base, incoming)
|
|
if rc is None:
|
|
return incoming, False
|
|
if ri is None:
|
|
return current, False
|
|
|
|
c_start, c_end, c_text = rc
|
|
i_start, i_end, i_text = ri
|
|
|
|
# Régions disjointes (ou juste adjacentes) → appliquer les deux sur base.
|
|
if c_end <= i_start or i_end <= c_start:
|
|
edits = sorted([(c_start, c_end, c_text), (i_start, i_end, i_text)],
|
|
key=lambda e: e[0])
|
|
out: list[str] = []
|
|
pos = 0
|
|
for start, end, text in edits:
|
|
if start < pos:
|
|
continue # sécurité: ne jamais réappliquer par-dessus
|
|
out.append(base[pos:start])
|
|
out.append(text)
|
|
pos = end
|
|
out.append(base[pos:])
|
|
return "".join(out), False
|
|
|
|
# Chevauchement réel → LWW sur ce champ, conflit signalé.
|
|
return incoming, True
|
|
|
|
|
|
def _scalar_conflict(base: Any, current: Any, incoming: Any) -> tuple[Any, bool]:
|
|
"""Conflit sur un champ non-texte : LWW (incoming gagne)."""
|
|
if current == incoming:
|
|
return current, False
|
|
if current == base:
|
|
return incoming, False
|
|
if incoming == base:
|
|
return current, False
|
|
return incoming, True
|
|
|
|
|
|
def merge_block_3way(base_blk: Any, current_blk: Any,
|
|
incoming_blk: Any) -> tuple[dict, list[str]]:
|
|
"""Merge à trois versions d'un bloc entier.
|
|
|
|
Retourne ``(bloc fusionné, champs en conflit)``. Ne lève jamais d'exception :
|
|
une entrée non-dict retombe en LWW (``incoming``) avec conflit signalé sur
|
|
``__block__`` pour que l'appelant puisse journaliser.
|
|
"""
|
|
if not isinstance(base_blk, dict):
|
|
base_blk = {}
|
|
if not isinstance(current_blk, dict):
|
|
current_blk = {}
|
|
if not isinstance(incoming_blk, dict):
|
|
# proposition invalide → on garde l'état serveur
|
|
return dict(current_blk), ["__block__"]
|
|
|
|
keys = set(base_blk) | set(current_blk) | set(incoming_blk)
|
|
merged: dict[str, Any] = {}
|
|
conflicts: list[str] = []
|
|
|
|
for key in keys:
|
|
b = base_blk.get(key)
|
|
c = current_blk.get(key)
|
|
i = incoming_blk.get(key)
|
|
|
|
if i == b:
|
|
# le client n'a pas modifié ce champ → valeur serveur. Si le serveur
|
|
# a *supprimé* le champ (absent de current) alors la suppression doit
|
|
# gagner : on n'insère pas de clé fantôme value=None.
|
|
if key not in current_blk:
|
|
continue
|
|
merged[key] = c
|
|
elif c == b:
|
|
# le serveur n'a pas modifié ce champ → valeur client
|
|
merged[key] = i
|
|
elif c == i:
|
|
merged[key] = c
|
|
else:
|
|
# les deux ont changé, différemment
|
|
if isinstance(b, str) and isinstance(c, str) and isinstance(i, str):
|
|
text, conflict = merge_text_3way(b, c, i)
|
|
merged[key] = text
|
|
if conflict:
|
|
conflicts.append(key)
|
|
else:
|
|
value, conflict = _scalar_conflict(b, c, i)
|
|
merged[key] = value
|
|
if conflict:
|
|
conflicts.append(key)
|
|
|
|
# Un champ supprimé par le serveur et absent de la proposition client doit
|
|
# rester supprimé (pas de réapparition d'une valeur None fantôme).
|
|
merged = {k: v for k, v in merged.items()
|
|
if not (v is None and k not in incoming_blk)}
|
|
|
|
if "id" not in merged:
|
|
# garantir l'identité du bloc même si base était vide
|
|
ident = incoming_blk.get("id") or current_blk.get("id")
|
|
if ident:
|
|
merged["id"] = ident
|
|
|
|
return merged, conflicts
|