Files
flowdeck/app/services/realtime_merge.py
T
bruno f2f2f3209e
FlowDeck CI / lint (push) Successful in 1m25s
FlowDeck CI / test (push) Successful in 10m20s
FlowDeck CI / docker (push) Successful in 1m21s
feat: v6.4.0 Realtime production — merge 3-voix (au-delà du LWW) + broadcast non bloquant
- 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
2026-09-23 23:55:39 -04:00

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