Bon, alors comme avec la peinture monocouche, il est bon d'en repasser un cht'it coup...
SQLite n'est pas un moteur SQL de type client/serveur, où le serveur est un machin opaque qui coûte bonbon, demande une salle entière climatisée et fait des miracles l'air de rien. De plus, SQLite est "lite" donc taillé pour être embarqué dans un GPS, une télé, une box xDSL, un routeur, tous les smartphones existants, bref dans des centaines de milliards de bidules qui n'ont pas la richesse matérielle et fonctionnelle de serveurs dédiés. SQLite est non seulement portable sur à peu près toutes les plates-formes disposant d'un compilateur C mais les BdD SQLite sont portables sans aucun changement sur toutes ces plates-formes.
Ce n'est pas pour autant qu'il est forcément très limité en termes de capacités SQL, seulement qu'il faut bien être conscient de ses possibilités mais aussi de ce qu'on ne peut pas raisonnablement attendre de lui. Ainsi, j'ai eu l'occasion de travailler sur un serveur web utilisant une base SQLite dépassant 127 To pour servir jusqu'à 50 requêtes par seconde. Un serveur dédié signé Oracle aurait coûté plus du million de USD alors que ce machin tourne sur un serveur lambda (certes gavé de mémoire) coûtant 50 fois moins cher et sans licence annuelle. Juste pour dire que ce n'est pas exactement ni uniquement un jouet pour pré-ados. On peut donc vraiment faire des choses "velues" avec.
Revenons à nos transactions. SQLite n'utilise qu'un seul moyen pour s'assurer que deux transactions vont respecter l'isolation et l'intégrité des données : c'est l'emploi de verrouillage du fichier contenant la BdD (file locking). Etant donné qu'il n'existe aucun protocole de "file locking" fiable sur aucun réseau existant à ce jour, l'emploi de BdD SQLite déportées est à proscrire absolument.
Ce point étant acquis, on peut progresser. SQLite propose 3 types de transactions :
begin [transaction]
begin immediate [transaction]
begin exclusive [transaction]
On va y revenir. Pendant ce temps, une BdD peut être dans l'un quelconque de ces états de verrouillage :
unlocked = aucun lock
shared = une ou plusieurs transactions peuvent lire la BdD
reserved = une seule transaction a posé un verrou "shared" mais elle a signifié qu'elle prévoit d'écrire par la suite ;
dans cet état, d'autre processus peuvent acquérir un verrou "shared" pour lire
pending = la transaction veut écrire et attend que tous les verrous "shared" soient levés
exclusive = la transaction est la seule qui puisse écrire et cela empêche l'octroi de tout autre type de verrou
tant qu'elle n'en n'a pas fini.
Pour info, un requête "toute seule" (non incluse dans une transaction explicite) est en interne délimitée elle-même par une transaction générée par le moteur lui-même. Ainsi, il existe une transaction explicite ou implicite à chaque instant où SQLite est solicité pour une requête SQL.
On comprend assez vite que deux ou plusieurs transactions peuvent s'imbriquer de façon mutuellement bloquante ; ces situations provoquent une erreur SQLITE_BUSY pour toutes les transactions impliquées.
Lorsqu'une transaction ne peut se voir octroyé le type de verrouillage dont elle a besoin SQLite la fait "patienter" un certain temps avec plusieurs tentatives et si l'opération n'aboutit pas au bout d'un "certain temps" (assez court en fait), une erreur SQLITE_BUSY est retournée. La gestion de ce cas de figure par l'application en complique lourdement le code et on peut contourner cette difficulté autrement.
Il suffit de définir avec _SQLite_SetTimeout pour chaque connexion à une BdB un timeout suffisament long et d'utiliser BEGIN IMMEDIATE pour toute transaction prévoyant de lire puis d'écrire au cours de son déroulement.
Lorsqu'on ne fait qu'écrire massivement (INSERT, UPDATE ou DELETE en boucle), un BEGIN EXCLUSIVE permet de le faire en toute certitude et en toute sécurité.
Si on ne fait que consulter (SELECT) alors un simple BEGIN fait l'affaire.
Quelle valeur de timeout utiliser ? La bonne réponse est "la durée maximum de la séquence la plus longue de toutes les transactions qui pourraient s'exécuter avant que notre transaction ne le fasse". J'avoue que ça semble cryptique comme ça, mais c'est exactement cela. Pourquoi en suis-je arrivé à formuler ceci de cette façon ? Parce que lorsqu'il fait "patienter" une transaction, SQLite la met en sommeil dans une liste et donne sa chance à la transaction suivante dans la liste. Ce schéma n'est pas déterministe et il se peut qu'une transaction "patiente" longtemps car d'autres peuvent lui passer devant à moulte reprises. Perso, je mets facilement un timeout de 20 minutes sans état d'âme, car ainsi je n'ai absolument pas à me poser de questions (ni, même je dois l'avouer, à tester de code erreur dans la grande majorité des cas où je sais qu'on ne va pas me débrancher le disque dur en plein milieu). Si on excepte les cas de figure tordus où ce genre de risque existe, on peut faire confiance au moteur qui fera le boulot qu'on lui a confié, à un moment ou un autre mais peu importe quand.
Pour que ce paradigme fonctionne en pratique, on doit quand même tenir compte de la réalité pour éviter les blocages idiots.
Prenons le cas évoqué plus haut dans ce fil : je lis ma base (SELECT ceci, cela from ...) et le résultat part dans une listview. Un autre process (de la même machine) modifie ce que je vient de lire dès que j'ai le dos tourné et quand je reviens de la caféteria, rien ne m'indique ma listview est obsolète. On pourrait être tenté de faire un "begin immediate" avant le select, mais ça bloquerait toute écriture dans la base pour tous les autres processus. Dans ce cas, la chose à faire est de prévoir une colonne "réservé" contenant un identificateur permettant de savoir que c'est tel process qui est temporairement seul détenteur de droit de modification sur ces éléments. On peut aussi faire un rafraîchissement en mode "immediate" des données concernées dès que l'utilisateur tente de les modifier, lui indiquant que ce qu'il voit n'est plus d'actualité.
Bref, il existe des millions de cas de figure et heureusement toujours une solution disponible, à condition d'y penser avant de se retrouver coincé dans un piège (qu'on a préparé soi-même, comme toujours en programmation).
Oui, je sais mon mur de texte est long et ennuyeux mais voici que point la bonne nouvelle : SQLite est encore plus malin que ça !
Toute modification apportée à l'intérieur d'une transaction explicite n'est physiquement répercuté dans la BdD qu'à l'issue de cette transaction. Comme les données peuvent être massives, SQLite les inscrit dans un fichier "journal" qu'il est interdit de bidouiller si on en trouve un. Outre le mode par défaut de journalisation, qui n'autorise qu'un "writer" OU plusieurs "readers", SQLite offre maintenant le mode WAL, qui permet un "writer" ET plusieurs "readers" concurremment. Il suffit de faire une seule fois un
_SQLite_Exec($hDB, "pragma journal_mode=WAL") et hop, la base restera en mode WAL tant qu'on n'annulera pas ce mode. Il est clair que WAL augmente considérablement la concurrence d'applications sur une base donnée et minimisant les temps de latence d'attente de levée de verrous.
Dans tous les cas, il est primordial de faire en sorte que les transactions soient les plus courtes possibles et cette règle s'applique à tous les moteurs.
Pour les amoureux de détails plus croustillants, la doc est à votre disposition :
https://www.sqlite.org/lockingv3.html
La cryptographie d'aujourd'hui c'est le taquin plus l'électricité.