db.ts : réserver le passage hors verrou au handle de la transaction #408
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/Simpl-Resultat#408
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Contexte
Revue de #402 (commentaire 20659, point 1). Pendant le rappel d'un
withTransaction, le drapeau globalinTransactiondesrc/services/db.ts(L30-35) fait passer toutexecute/selecthors du verrou FIFO (serialize, L54-72), d'où qu'il vienne. Une requête lancée ailleurs pendant une transaction s'exécute donc en parallèle d'elle : deux opérations en vol, ce que le commentaire de tête dedb.tsdonne comme cause du « database is locked » de la 0.10.0.onboardingService.ts:128-131le reconnaît déjà en commentaire.Vérifié en analyse
getDb(), directement ou par une fonction appelée. Les 8 sites passent le handle reçu :balance.service.ts:710,:1509,:1597;onboardingService.ts:144;categoryMigrationService.ts:187;categorizationService.ts:227;dataExportService.ts:427(runRestore, avecrestoreCategories,restoreSuppliers,restoreKeywords,restoreImportTemplates,restoreImportSources,attachTransactions, qui prennent tousdben paramètre). Un verrou strict n'interbloque donc aucun chemin actuel.findOrCreateSecurity(balance.service.ts:1006) faitconst db = exec ?? (await getDb()). Un futur appel sansexecdepuis un rappel interbloquerait sous la nouvelle règle.connectToProfile(L163-167),initializeNewProfileDb(L195-204) etcloseDb(L207-212) fermentrawInstancedirectement, sans attendre la file ni une transaction en cours. Un changement de profil (ProfileContext.tsx:97-98,:179-180) peut fermer la connexion au milieu d'une transaction.initializeNewProfileDbexécute aussi son SQL hors verrou.lockTailn'est jamais réinitialisé.db.test.ts:106-117(« lets a nested getDb() call run directly inside a transaction — no deadlock »), à remplacer.Décisions (Max, 2026-10-10)
getDb()depuis un rappel) attend derrière le verrou. Le drapeau global disparaît.finally, un appel sur ce handle lève une erreur nommée, au lieu de passer hors verrou depuis une promesse orpheline.connectToProfile,initializeNewProfileDbetcloseDbferment et rouvrent la connexion viarunExclusive, donc après les opérations déjà en file et après une transaction en cours.Travail à faire
db.ts: supprimerinTransaction;withTransactioncrée un proxy de transaction (exécution directe surrawInstancecapturé) passé au rappel, révoqué dans lefinally.db.ts:connectToProfile,initializeNewProfileDb,closeDbpassent leur fermeture/ouverture (et le SQL d'initialisation) parrunExclusive.getDb()depuis un rappel, utiliser le handle reçu ».balance.service.ts:1006: rendreexecobligatoire surfindOrCreateSecurity(plus deexec ?? getDb()), appelants mis à jour.onboardingService.ts:128-131.docs/architecture.md, section### Accès DB et transactions(à créer) : la règle du verrou, du handle de transaction, de la génération et de l'alerte. Aucun ADR ne portewithTransaction(vérifié) : pas d'ADR.Fichiers concernés
src/services/db.ts,src/services/db.test.tssrc/services/balance.service.ts(L1006)src/services/onboardingService.ts(commentaire L128-131)docs/architecture.mdSurface de test
db.test.tsa unDatabasefactice (L48-64, 2 ms par appel, journal début/fin), réutilisable. Les tests de services mockentdb.ts(withTransaction: fn => fn(await getDb())) et ne voient pas le verrou : ne pas s'y fier.db.test.ts:getDb().selectlancée pendant le rappel (aprèsBEGIN) se termine après leCOMMIT(rouge aujourd'hui) ;connectToProfilelancé pendant une transaction ne ferme la connexion qu'après leCOMMIT;:106-117par un test qui documente la nouvelle règle.Critères d'acceptation
npm testvert, les 8 sites dewithTransactioninchangés fonctionnellement.Complexité estimée
Medium.
Liens
#402 (revue, commentaire 20659), #396 (lectures du fournisseur d'onboarding), v0.10.1 (
withTransaction).Depends on
(aucune)
Critères d'acceptation (planification)
Database, qui exécute directement ;close()sur ce handle lève une erreur nommée ; tout appel après la fin du rappel lève une erreur nommée.connectToProfile,initializeNewProfileDbetcloseDbsont chacun un seulrunExclusive, qui enchaîne fermeture de l'ancien pool → (repair_migrations,prepare_profile_dbpourconnectToProfile) →Database.load→ bascule derawInstance/serializedInstance, via des helpers internes qui ne prennent jamais le verrou (closeUnlocked,loadUnlockedou équivalent).prepare_profile_dbn'est invoqué qu'après la résolution duclose()de l'ancienne connexion.getDb()et le handle de transaction le vérifient au moment où l'opération sort de la file ; une opération mise en file pour une génération fermée lève « connection closed by profile switch » (erreur nommée), au lieu de tourner sur la nouvelle base ou sur un pool fermé.logWarn(une fois par opération) qui nomme ce qui tient le verrou (transaction, requête ou ouverture de profil) et depuis quand.logServicen'écrit qu'en mémoire et ensessionStorage(vérifié), donc l'appel est sûr pendant que le verrou est tenu. Un rattrapage de plus de 15 s produit donc une alerte « ouverture de profil » : attendu, à dire dans le commentaire du code.db.test.ts(Database factice existant, L48-64) : lecture lancée pendant le rappel terminée après leCOMMIT; handle utilisé après le rappel → erreur ;close()sur le handle → erreur ; deuxconnectToProfileconcurrents → un seul pool ouvert, celui du dernier fichier demandé ;withTransactionmis en file derrièreconnectToProfile→ erreur de génération (pas d'exécution sur la nouvelle base) ;prepare_profile_dbinvoqué après leclose()de l'ancienne connexion ;getDb()appelé dans un rappel → alerte écrite après le délai (minuteurs factices de vitest). Remplacer:106-117.Review caveats
execobligatoire.db.ts(withTransaction: fn => fn(await getDb())) : seuldb.test.tsvoit le verrou.Décisions prises en planification
/analyze).execobligatoire ; pas de scan des sources (Max, 2026-10-10).docs/architecture.mdfixées pour toute la milestone :### Accès DB et transactions(sous la section Base de données) et## Tests (vitest sans jsdom)(juste avant CI/CD). La première issue de la pile qui en a besoin crée la section ; les suivantes y ajoutent.planned-2026-10-10-release-deps.Spec source
spec-plan-fiabilite-tests.md(local, ignoré par git)Archive avant réécriture par
/analyze(2026-10-10, lu surmain87920ec). Le corps est réécrit avec les décisions de Max du jour ; le texte d'origine est conservé ci-dessous.Corps d'origine
Constat
Revue de #402 (commentaire 20659, point 1, « hors périmètre »). Pendant le rappel d'un
withTransaction,inTransactionvauttruepour tout le module, et le proxy desrc/services/db.ts(serialize) exécute alors directement toutexecuteouselect, d'où qu'il vienne, pas seulement ceux du rappel. Une requête lancée ailleurs pendant une transaction passe donc à côté du verrou FIFO : deux opérations en vol, ce que le commentaire de tête dedb.tsdonne comme la cause du « database is locked » de la 0.10.0 (unBEGINet sonCOMMITsur deux connexions du pool sqlx).Sonde du relecteur (copie hors dépôt, vrai
db.ts,Databasefactice à 5 ms par appel) : une lecturegetPreferencelancée 2 ms après le début d'une transaction part pendant leBEGIN(« in flight: 2 »).Ce qui a déjà réduit l'exposition
696b83f, en tête de la pile #402 → #407) : la fenêtre ne s'ouvre plus à chaque démarrage ni à chaque changement de profil, seulement à la première ouverture d'un profil.invoke, un minuteur) et qui tombe pendant une transaction.Reste ouvert
Les six autres sites de
withTransactionpartent d'une action de l'utilisateur :categoryMigrationService,categorizationService(mot-clé avec réassignation), trois dansbalance.service,dataExportService.runRestore. Pendant une restauration longue, une navigation déclenche la relecture du fournisseur d'onboarding (#396, quatre lectures parallèles), qui passerait hors verrou ; les pages qui lisent à leur montage le faisaient déjà avant #396.Piste
Réserver le passage direct au handle remis au rappel (un proxy propre à la transaction en cours) et faire attendre derrière le verrou toute requête venue d'ailleurs, au lieu d'un drapeau global. Le drapeau couvre aujourd'hui un cas à recenser avant de changer la règle : un service imbriqué qui appelle
getDb()depuis le rappel attendrait derrière la transaction qui l'attend elle-même (interblocage, décrit dansdb.ts).