Persistance : le fsync de la discussion ne retarde plus le premier token

StartTurn persistait AVANT de lancer la génération : un marshal, puis deux
commits bbolt (contenu, puis index), soit deux fsync et plusieurs ouvertures
de base — 26-30 ms mesurés sur un NVMe Windows, bien plus sur un /mnt/user
d'Unraid ou un disque de parité, payés à chaque message avant que la requête
ne parte vers llama-server. Même facture au milieu d'un tour (vérification du
mode Code) et pour chaque résultat d'outil coupé (« voir plus »).

- chat_persist.go : un écrivain unique et ordonné. L'instantané reste pris
  sous c.mu (images → références, marshal, identifiant de la discussion) ;
  seule l'E/S part en différé. Le dernier instantané de chaque discussion
  l'emporte (numéro pris sous c.mu), les lots sont fusionnés en UNE
  transaction : contenu + index ensemble, résultats d'outils compris.
- Seuls StartTurn, la persistance en cours de tour et les résultats d'outils
  sont asynchrones. Fin de tour, reset, bascules, compactage manuel restent
  synchrones et attendent tout ce qui précède ; la suppression écarte puis
  attend les écritures qui la visent (plus de discussion ressuscitée).
- Discussion active gardée en RAM, créée sous verrou (plus de double
  identifiant sur base neuve) et changée sous c.mu avec le contenu : un
  instantané ne peut plus écrire l'ancien fil sous le nouvel identifiant.
- « Voir plus » servi depuis la mémoire tant que le résultat n'est pas écrit ;
  verrouillage vérifié avant de rendre un id.
- En plein tour, la base reçoit le journal sous sa forme de fin de tour
  (compactLog, version pure de compactLogLocked) ; la mémoire n'est pas touchée.
- Vidage de l'écrivain avant redémarrage, « Quitter », exec et (dé)chiffrement.

Le contexte vu par le modèle ne change pas : il lit c.Messages en mémoire, les
résultats complets d'outils et la compaction du journal ne servent qu'à l'UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 02:19:03 +02:00
1 parent 9b2e974942
commit c3005f5b56
14 files changed
+1082 -80

No files matched your search

+28 -41
View File
@@ -191,11 +191,15 @@ func LoadConversation() {
// loadFrom remplace l'état en mémoire par une autre discussion (b vide = fil
// neuf) et invalide les abonnés : l'epoch incrémenté leur fait vider l'écran et
// rejouer depuis zéro, exactement comme un reset. L'appelant NE doit PAS
// détenir mu.
func (c *Conversation) loadFrom(b []byte) {
// rejouer depuis zéro, exactement comme un reset. id = discussion chargée : le
// cache de la discussion active change sous mu, avec le contenu (voir
// convActivate). L'appelant NE doit PAS détenir mu.
func (c *Conversation) loadFrom(id string, b []byte) {
c.Stop()
c.mu.Lock()
if c == conv {
convActiveRemember(id)
}
c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0
c.ctxUsedLen, c.genPeak = 0, 0
c.queued = nil // file de l'ancienne discussion : elle ne suit pas la bascule
@@ -224,34 +228,6 @@ func reloadEncryptedStores() {
}
}
// persist enregistre l'état (appelé en fin de tour et sur reset, pas à chaque
// delta) sous la discussion active, et rafraîchit ses métadonnées (titre déduit
// du premier message, date, nombre d'échanges). L'appelant NE doit PAS détenir mu.
func (c *Conversation) persist() {
c.mu.Lock()
// Images en base64 → références (chat_images.go) avant d'écrire : une photo
// jointe pesait des Mo, réécrits en entier à CHAQUE fin de tour.
refImagesInMessages(c.Messages)
b, err := json.Marshal(c)
title := convSummary(c.Messages)
turns := 0
for _, ev := range c.Log {
if _, ok := ev.Delta["user"]; ok {
turns++
}
}
c.mu.Unlock()
if err != nil {
return
}
id := convEnsureActive()
// Chiffré si la mémoire l'est et qu'elle est déverrouillée. Verrouillée,
// l'écriture est REFUSÉE plutôt que de remplacer un blob chiffré par du
// clair — le fil de ce tour reste en RAM, rien n'est perdu sur disque.
_ = putStoreBytes(bkChat, convKey(id), b)
convTouchMeta(id, title, turns)
}
// appendDelta journalise un événement d'affichage et réveille les abonnés.
// epoch est celui capturé au début du tour : si un Reset est passé entre-temps,
// l'événement appartient à l'ancienne conversation et est jeté (sinon il
@@ -303,8 +279,16 @@ func evTS0(d map[string]any, fallback int64) int64 {
// On préserve `toks` (somme) et `ts0` (premier) pour que le compteur de vitesse
// (tok/s) reste correct au replay. Verrou détenu par l'appelant.
func (c *Conversation) compactLogLocked() {
if len(c.Log) < 2 {
return
c.Log = compactLog(c.Log)
}
// compactLog est la compaction de compactLogLocked sous forme PURE : elle rend
// un nouveau journal sans toucher à celui qu'on lui passe (les événements non
// fusionnés sont partagés, pas recopiés). snapshot s'en sert pour écrire, en
// plein tour, la forme qu'aura le journal à la fin du tour.
func compactLog(log []LogEvent) []LogEvent {
if len(log) < 2 {
return log
}
textKey := func(d map[string]any) string {
if _, ok := d["content"].(string); ok {
@@ -315,7 +299,7 @@ func (c *Conversation) compactLogLocked() {
}
return ""
}
out := make([]LogEvent, 0, len(c.Log))
out := make([]LogEvent, 0, len(log))
var buf strings.Builder
bufKey := ""
var cur LogEvent
@@ -332,7 +316,7 @@ func (c *Conversation) compactLogLocked() {
buf.Reset()
bufKey, toks, ts0, seq0 = "", 0, 0, 0
}
for _, ev := range c.Log {
for _, ev := range log {
// Outils : un appel s'écrit en de nombreux événements (annonce done=false,
// frappe du corps, streaming des arguments) jusqu'au done=true, qui porte
// déjà l'état FINAL (résultat + diff). Les intermédiaires ne servent qu'à
@@ -368,7 +352,7 @@ func (c *Conversation) compactLogLocked() {
toks += evToks(ev.Delta)
}
flush()
c.Log = out
return out
}
// compactAndPublish exécute UNE compaction et en publie tout le cycle de vie :
@@ -511,9 +495,10 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
epoch := c.epoch
c.mu.Unlock()
// Borne de tour + bulle utilisateur (rejouables). Persistée tout de suite :
// si le process meurt en pleine génération (crash, restart après MAJ), le
// message de l'utilisateur survit au lieu de disparaître avec le tour.
// Borne de tour + bulle utilisateur (rejouables). Persistée tout de suite
// (sur disque quelques ms plus tard, voir persistAsync) : si le process
// meurt en pleine génération (crash, restart après MAJ), le message de
// l'utilisateur survit au lieu de disparaître avec le tour.
delta := map[string]any{"user": text}
// Nom du modèle qui va produire la réponse : journalisé avec la borne de
// tour, donc rejoué au chargement — la carte de réponse garde son modèle
@@ -531,7 +516,9 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
if !caps.Code {
maybeCodeHint(c, epoch, text)
}
c.persist()
// Instantané pris ici, écriture laissée à l'écrivain (chat_persist.go) : le
// fsync ne retarde plus le départ de la requête vers le modèle.
c.persistAsync()
if temperature == 0 {
temperature = 0.7
}
@@ -750,7 +737,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
}()
// Télémétrie : tout ce que ce tour envoie au moteur (étapes, compaction,
// sous-agents, vérification) est rattaché à la discussion active.
ctx = withPerf(ctx, perfMain, getStr(bkChat, ckActive))
ctx = withPerf(ctx, perfMain, convEnsureActive())
// llama-server local seulement : le preset externe garde le seul seuil, et
// ses complétions ne nourrissent pas la garde de marge (compactNeeded).