fix(db): keep the lock-free path for the transaction handle only (#408) #433
No reviewers
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#433
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-408-db-strict-lock"
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?
Generated autonomously by /autopilot run of 2026-10-12
First link of the stack #408 → #409 → #389 → #395 (milestone
planned-2026-10-10-fiabilite-tests). Issue #408.What changes
src/services/db.ts:inTransactionflag is gone. AwithTransactioncallback receives its own transaction handle (typedDatabase) that runs directly on the connection; every other call,getDb()from a callback included, waits behind the transaction (strict, non-reentrant lock).finally: a later call rejects withTransactionHandleRevokedError;close()on it always rejects withTransactionHandleCloseError.connectToProfile,initializeNewProfileDbandcloseDbare each onerunExclusivesection, chainingcloseUnlocked()→ (repair_migrations,prepare_profile_db) →loadUnlocked();prepare_profile_dbruns only once the oldclose()resolved. The SQL ofinitializeNewProfileDbruns inside its section.getDb()proxy and the transaction handle check it when the operation leaves the queue and reject withDbConnectionClosedError("connection closed by profile switch").BLOCKED_ALERT_MS(15 s) writes ONElogWarn, naming the holder (transaction,query,profile opening,profile initialization,connection close) and since when (ISO time). The code comment says a catch-up over 15 s yields an expected "profile opening" alert.close()on thegetDb()proxy is routed tocloseDb()(through the lock); no caller uses it today.Other files:
src/services/balance.service.ts:findOrCreateSecuritytakesexecas a mandatory argument (no moreexec ?? getDb()); its only production caller already passes it. Doc comment updated.src/services/onboardingService.ts: the now-wrong parenthesis about queries bypassing the lock is removed (the issue's L128-131, still there on8ceb3de).docs/architecture.md: new### Accès DB et transactionssection at the end of « Base de données », and thedb.tsrow of the services table points to it.src/services/db.test.ts(the old « nested getDb() runs directly » test replaced),src/services/balance.service.test.ts(callers passmockDb; the executor test assertsgetDbis never called).No CHANGELOG entry (internal robustness, nothing visible), no ADR, no migration (v1→v17).
Call sites re-checked on
8ceb3deNone of the 8
withTransactioncallbacks reachesgetDb():balance.servicex3 (proposeStarterAccountsInTransaction, the two snapshot saves throughinsertSnapshotLineWithHoldings→findOrCreateSecurity(…, exec)),onboardingService.initializeGuidedPath,categoryMigrationService.applyMigrationInTransaction,categorizationService.applyKeywordInTransaction,dataExportService.runRestore(allrestore*helpers andattachTransactionstakedb; the file'sgetDb()calls are in thegetExport*readers only). Service tests mockdb.tsand do not see the lock: onlydb.test.tsdoes.Tests added in
db.test.tsgetDb()call from inside a callback waits until after the COMMIT (replaces the old nested-getDb test);TransactionHandleRevokedError;close()on the handle →TransactionHandleCloseError, the pool is not closed;connectToProfilelaunched during a transaction closes only after the COMMIT;connectToProfile→ one pool open, the last file asked for;prepare_profile_dbinvoked after the oldclose()resolved;withTransactionqueued behindconnectToProfile→DbConnectionClosedError, callback never called, nothing run on either database;getDb()handle behind a switch →DbConnectionClosedError;closeDbwaits for the queue, then the old handle refuses to run;initializeNewProfileDbruns its SQL inside the section, before a queued read;Gated tests release their transaction in
finally, so a red assertion leaves no held lock (no cascade into later tests).Mutation pass (one at a time,
db.tsrestored bygit checkoutafter each,vitest run src/services/db.test.ts)inTransactionflag back (proxy runs directly during a callback)active = falsedeleted)closeDboutside the sectionconnectToProfilecloses before entering the sectionclose()not awaited beforeprepare_profile_dblogWarncall dropped)close()passes through to the poolThe « red before » criterion (outside read during a transaction) is the first row: with the pre-#408 flag rule, the read runs between BEGIN and COMMIT.
Proofs
npm run buildgreen;npm test115 files / 1978 tests green (worktree afternpm ci).Decisions (autopilot, logged in the run's decisions log)
close()on thegetDb()proxy routed tocloseDb()(through the lock) rather than straight to the pool or throwing; no caller today.db.ts; adds the getDb()-in-callback hint when a transaction holds the lock.findOrCreateSecuritytests passmockDbexplicitly.For the reviewer
setTimeoutper queued operation (cleared as soon as it starts): the cost of the alert.getDb()handle kept across a profile switch now rejects instead of silently hitting the new profile.ProfileContextreloads the page data after a switch through freshgetDb()calls; a component holding an old handle in a closure across the switch would now seeDbConnectionClosedErrorin its catch.🤖 Generated with Claude Code
Self-review (autopilot worker, not an independent review) — verdict: APPROVE
Diff read in full against
main(6 files). Planning acceptance criteria of #408:Database, runs directly;close()→TransactionHandleCloseError; any call after the callback →TransactionHandleRevokedError(success and failure paths tested).connectToProfile,initializeNewProfileDb,closeDb: onerunExclusiveeach, throughcloseUnlocked/loadUnlocked(never take the lock);prepare_profile_dbafter the oldclose()resolved (tested with a delayed close).getDb()proxy and the transaction handle;DbConnectionClosedError"connection closed by profile switch".logWarnper waiting operation after 15 s, naming the holder and its start time; long catch-up case said in the code comment and tested.findOrCreateSecurityexecmandatory; wrong comment ofonboardingService.tsremoved; architecture section created.Checks: security (no secrets, Journal lines name no paths), correctness vs the 8
withTransactionsites (none reachesgetDb(), re-verified on8ceb3de), no SQL migration touched, no UI text (no i18n impact), no CHANGELOG entry (internal, as the issue says), docs rule applied.npm run buildgreen,npm test115 files / 1978 tests green.Points for the independent reviewer: one
setTimeoutper queued operation (cleared at start); agetDb()handle kept across a profile switch now rejects instead of hitting the new profile;close()on thegetDb()proxy now goes throughcloseDb()(MEDIUM decision, no caller today).CI lue par l’orchestrateur de l’autopilot (2026-10-12) sur la tête
b161d54:PR Check — Frontendrun 529 vert (Nodev24.21.0, build vite,Test Files 115 passed (115)). Pas de runcheck-rustattendu (aucun fichier soussrc-tauri/)./pr-review — PR #433 (#408) — Verdict : APPROVE
Résumé. Le verrou devient strict et non réentrant : le rappel reçoit son propre handle, révoqué au
finally, et les trois fonctions de profil sont chacune une seule section exclusive, avec un contrôle de génération. Le code tient ce que dit la description. J'ai rejoué la suite et mes propres mutations sur la têteb161d54, dans une copie hors dépôt.Ce que j'ai vérifié
git archive, puisnpm ci.npm run buildest vert etvitest runpasse : 115 fichiers, 1978 tests. La CI de la tête est verte (PR Check — Frontend). Rust n'est pas touché, donc pas decheck-rust.getDb(): j'ai tracé les appels de façon statique, par transitivité, à travers toutsrc/services/*.ts. Les racines sont les 5 fonctions*InTransactionetrunRestore, et l'inline deinitializeGuidedPatha été lu à la main. Aucune n'atteintgetDb(). Lesrestore*etattachTransactionsn'appellent quedb.execute,toSqlFlagetnotifyDataChanged.tscconfirme que tous les appelants defindOrCreateSecuritypassentexec.db.ts, une à la fois, fichier restauré et vérifié parcmp) :startedniclearTimeoutinitializeNewProfileDbpasse son SQL par le proxy (interblocage dans la section)closeUnlockedn'incrémente plus la générationwithTransactionrelitrawInstanceet la génération à l'exécution (plus de capture à l'appel)BLOCKED_ALERT_MSà 16 slockHolderjamais remis ànullloadUnlockedn'incrémente plus la générationCes trois survivants ne cachent pas de défaut de comportement (voir suggestions 1 et 2).
Resolves #408figure dans le message du commit08946e6, pas dans le corps de la PR. La timeline de #408 porte seulement despull_refenref_action: none(#433, #434, #435). Un merge en avance rapide de la pile fermera donc #408 par le message de commit, ce qui est le comportement voulu.Suggestions (non bloquantes)
lockHoldern'est épinglé par aucun test du chemin « behind the queue ». Si on ne le remet jamais ànull, l'alerte nomme un détenteur périmé, et aucun test ne rougit. Un test suffit : une opération longue se termine, puis une attente sans détenteur. Ce serait une épingle à ajouter en suivi, pas un tour de plus.transactionHandle: inatteignable aujourd'hui. Seules les sections exclusives touchent à la génération, et aucune ne peut tourner pendant que la transaction tient le verrou. Le garder comme défense est raisonnable, mais il ne faut pas le compter comme couvert. L'incrément dansloadUnlockedest de même redondant avec celui decloseUnlocked.close()sur un ancien proxy degetDb()ferme la connexion courante.serializeroutecloseverscloseDb()sans vérifierhandleGeneration. Un handle gardé avant un changement de profil fermerait donc la base du profil suivant. Aucun appelant aujourd'hui. Il vaudrait mieux rejeter avecDbConnectionClosedErrorquand la génération ne correspond plus, par cohérence avecexecuteetselect.closeUnlockedmet l'état ànullavantawait closing.close(). Siclose()rejette,connectToProfilerejette sans rouvrir, et l'app reste sans connexion (SET_ERRORdansProfileContext). Avant, l'ancienne instance restait référencée. C'est un cas marginal, mais untry/finallyautour duclose()dansconnectToProfile(ouvrir quand même) suivrait la logique « échec ouvert » du reste de la fonction.Actions faites
status:reviewremplacé parstatus:approved.scratchpad/pr433, sauvegardedb.ts.orig, scriptscalls433.pyetmut433.py) supprimée en fin de revue.Suivis de cette revue appliqués en tête de pile, PR #436, commit
74fae14(détail dans le commentaire 23535 de la PR #436, non relu).