mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 15:08:16 +02:00
Base ouverte a la demande, pas gardee ouverte
bbolt pose un verrou exclusif sur son fichier : le service de lien, qui tourne en permanence, empechait toute commande CLI de s'executer a cote. La base est desormais ouverte pour la duree d'une operation puis refermee, et les buckets ne sont crees qu'a l'ecriture (une lecture ne coute plus un fsync : 3,8 ms -> 0,55 ms). Test de concurrence a l'appui.
This commit is contained in:
1 parent
687ec81023
commit
47c27499ae
3 files changed
+104
-86
No files matched your search
+68
-81
@@ -33,77 +33,75 @@ const (
|
|||||||
bkChat = "chat" // conversation partagée
|
bkChat = "chat" // conversation partagée
|
||||||
)
|
)
|
||||||
|
|
||||||
// Les connexions sont mises en cache PAR CHEMIN, et non dans un singleton.
|
// La base n'est PAS gardée ouverte entre deux opérations, et c'est délibéré.
|
||||||
// $AJEAN_HOME peut désigner un autre dossier d'un appel à l'autre — c'est le cas
|
//
|
||||||
// dans les tests, qui donnent à chacun son dossier temporaire. Un singleton
|
// bbolt pose un verrou EXCLUSIF sur son fichier tant qu'il est ouvert. Or une
|
||||||
// figerait la toute première base et tous les appels suivants écriraient dans le
|
// machine installée fait tourner en permanence le service de lien, qui sert le
|
||||||
// mauvais fichier, sans erreur et sans bruit.
|
// tunnel et l'UI : s'il gardait la base ouverte, plus une seule commande ne
|
||||||
var (
|
// fonctionnerait à côté — « ajean status », « ajean switch », « ajean edit »
|
||||||
dbMu sync.Mutex
|
// échoueraient toutes sur un délai d'attente, sur la machine même où tout est
|
||||||
dbConns = map[string]*bolt.DB{}
|
// censé marcher. On ouvre donc pour la durée d'une opération, puis on referme.
|
||||||
)
|
//
|
||||||
|
// Le coût est celui d'un open+close sur un fichier de quelques dizaines de Ko,
|
||||||
|
// négligeable devant le moindre appel au modèle. Le délai d'attente absorbe la
|
||||||
|
// contention entre process ; dbMu la sérialise à l'intérieur du process.
|
||||||
|
var dbMu sync.Mutex
|
||||||
|
|
||||||
func dbPath() string { return filepath.Join(AjeanHome(), "ajean.db") }
|
func dbPath() string { return filepath.Join(AjeanHome(), "ajean.db") }
|
||||||
|
|
||||||
// db ouvre la base (ou renvoie celle déjà ouverte pour ce chemin) et la garde
|
// withDB ouvre la base, exécute fn, puis referme — toujours, même en erreur.
|
||||||
// ouverte pour la durée du process. bbolt prend un verrou exclusif sur le
|
func withDB(fn func(*bolt.DB) error) error {
|
||||||
// fichier : le délai d'attente évite qu'un second process (l'app lancée deux
|
|
||||||
// fois) se bloque indéfiniment.
|
|
||||||
func db() (*bolt.DB, error) {
|
|
||||||
path := dbPath()
|
path := dbPath()
|
||||||
|
|
||||||
dbMu.Lock()
|
dbMu.Lock()
|
||||||
defer dbMu.Unlock()
|
defer dbMu.Unlock()
|
||||||
if d, ok := dbConns[path]; ok {
|
|
||||||
return d, nil
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
d, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 3 * time.Second})
|
d, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 5 * time.Second})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("base %s inaccessible : %w", path, err)
|
return fmt.Errorf("base %s inaccessible : %w", path, err)
|
||||||
}
|
}
|
||||||
err = d.Update(func(tx *bolt.Tx) error {
|
defer d.Close()
|
||||||
for _, b := range []string{bkConfig, bkPrefs, bkState, bkChat} {
|
return fn(d)
|
||||||
if _, err := tx.CreateBucketIfNotExists([]byte(b)); err != nil {
|
}
|
||||||
|
|
||||||
|
// view exécute une lecture. Un bucket encore absent (base neuve) est traité
|
||||||
|
// comme vide : les buckets ne sont créés QU'À l'écriture, pour qu'une simple
|
||||||
|
// lecture n'ouvre jamais de transaction d'écriture — elle coûterait un fsync,
|
||||||
|
// et les lectures sont de loin les plus fréquentes.
|
||||||
|
func view(bucket string, fn func(b *bolt.Bucket) error) error {
|
||||||
|
return withDB(func(d *bolt.DB) error {
|
||||||
|
return d.View(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte(bucket))
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fn(b)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// update exécute une écriture, en créant le bucket au besoin.
|
||||||
|
func update(bucket string, fn func(b *bolt.Bucket) error) error {
|
||||||
|
return withDB(func(d *bolt.DB) error {
|
||||||
|
return d.Update(func(tx *bolt.Tx) error {
|
||||||
|
b, err := tx.CreateBucketIfNotExists([]byte(bucket))
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
return fn(b)
|
||||||
return nil
|
})
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
d.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
dbConns[path] = d
|
|
||||||
return d, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getBytes lit une valeur. Une base inaccessible se comporte comme une base
|
// getBytes lit une valeur. Une base inaccessible se comporte comme une base
|
||||||
// vide : les appelants sont des lecteurs de réglages, aucun n'a de recours utile
|
// vide : les appelants sont des lecteurs de réglages, aucun n'a de recours utile
|
||||||
// face à une erreur d'E/S, et tous ont déjà un défaut.
|
// face à une erreur d'E/S, et tous ont déjà un défaut.
|
||||||
// closeDB ferme la base ouverte pour le dossier courant, s'il y en a une. bbolt
|
|
||||||
// garde un verrou exclusif sur son fichier tant qu'il est ouvert : sans ça,
|
|
||||||
// impossible de supprimer ou déplacer le dossier de données sous Windows.
|
|
||||||
func closeDB() {
|
|
||||||
path := dbPath()
|
|
||||||
dbMu.Lock()
|
|
||||||
defer dbMu.Unlock()
|
|
||||||
if d, ok := dbConns[path]; ok {
|
|
||||||
_ = d.Close()
|
|
||||||
delete(dbConns, path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getBytes(bucket, key string) []byte {
|
func getBytes(bucket, key string) []byte {
|
||||||
d, err := db()
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []byte
|
var out []byte
|
||||||
_ = d.View(func(tx *bolt.Tx) error {
|
_ = view(bucket, func(b *bolt.Bucket) error {
|
||||||
if v := tx.Bucket([]byte(bucket)).Get([]byte(key)); v != nil {
|
if v := b.Get([]byte(key)); v != nil {
|
||||||
out = append([]byte(nil), v...) // la valeur ne survit pas à la transaction
|
out = append([]byte(nil), v...) // la valeur ne survit pas à la transaction
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -113,12 +111,7 @@ func getBytes(bucket, key string) []byte {
|
|||||||
|
|
||||||
// putBytes écrit une valeur. Une valeur nil supprime la clé.
|
// putBytes écrit une valeur. Une valeur nil supprime la clé.
|
||||||
func putBytes(bucket, key string, val []byte) error {
|
func putBytes(bucket, key string, val []byte) error {
|
||||||
d, err := db()
|
return update(bucket, func(b *bolt.Bucket) error {
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return d.Update(func(tx *bolt.Tx) error {
|
|
||||||
b := tx.Bucket([]byte(bucket))
|
|
||||||
if val == nil {
|
if val == nil {
|
||||||
return b.Delete([]byte(key))
|
return b.Delete([]byte(key))
|
||||||
}
|
}
|
||||||
@@ -168,12 +161,8 @@ func putJSON(bucket, key string, v any) error {
|
|||||||
// les clés ne sont pas connues à l'avance (EXTRA_ARGS et consorts).
|
// les clés ne sont pas connues à l'avance (EXTRA_ARGS et consorts).
|
||||||
func allKV(bucket string) map[string]string {
|
func allKV(bucket string) map[string]string {
|
||||||
m := map[string]string{}
|
m := map[string]string{}
|
||||||
d, err := db()
|
_ = view(bucket, func(b *bolt.Bucket) error {
|
||||||
if err != nil {
|
return b.ForEach(func(k, v []byte) error {
|
||||||
return m
|
|
||||||
}
|
|
||||||
_ = d.View(func(tx *bolt.Tx) error {
|
|
||||||
return tx.Bucket([]byte(bucket)).ForEach(func(k, v []byte) error {
|
|
||||||
m[string(k)] = string(v)
|
m[string(k)] = string(v)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -185,26 +174,24 @@ func allKV(bucket string) map[string]string {
|
|||||||
// C'est ce qu'exige l'application d'un preset : à aucun instant la config ne
|
// C'est ce qu'exige l'application d'un preset : à aucun instant la config ne
|
||||||
// doit être un mélange de l'ancienne et de la nouvelle.
|
// doit être un mélange de l'ancienne et de la nouvelle.
|
||||||
func replaceKV(bucket string, m map[string]string) error {
|
func replaceKV(bucket string, m map[string]string) error {
|
||||||
d, err := db()
|
return withDB(func(d *bolt.DB) error {
|
||||||
if err != nil {
|
return d.Update(func(tx *bolt.Tx) error {
|
||||||
return err
|
if err := tx.DeleteBucket([]byte(bucket)); err != nil && err != bolt.ErrBucketNotFound {
|
||||||
}
|
|
||||||
return d.Update(func(tx *bolt.Tx) error {
|
|
||||||
if err := tx.DeleteBucket([]byte(bucket)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
b, err := tx.CreateBucket([]byte(bucket))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for k, v := range m {
|
|
||||||
if v == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := b.Put([]byte(k), []byte(v)); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
b, err := tx.CreateBucket([]byte(bucket))
|
||||||
return nil
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for k, v := range m {
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := b.Put([]byte(k), []byte(v)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package ajean
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Deux « process » logiques qui martèlent la base en parallèle ne doivent ni
|
||||||
|
// s'exclure ni perdre d'écriture : c'est le scénario du serveur, où le service
|
||||||
|
// de lien tourne pendant qu'on tape des commandes.
|
||||||
|
func TestAccesConcurrent(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(n int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < 20; j++ {
|
||||||
|
_ = SetConfigKey("K", "v")
|
||||||
|
_ = ReadConfig()
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { wg.Wait(); close(done) }()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
t.Fatal("blocage sous accès concurrent")
|
||||||
|
}
|
||||||
|
if ReadConfig()["K"] != "v" {
|
||||||
|
t.Fatal("écriture perdue")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,11 @@ package ajean
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
// testHome donne au test un $AJEAN_HOME à lui et ferme la base à la fin.
|
// testHome donne au test un $AJEAN_HOME à lui.
|
||||||
//
|
|
||||||
// La fermeture n'est pas une politesse : bbolt garde un verrou exclusif sur son
|
|
||||||
// fichier, et sous Windows le ménage de t.TempDir() échoue tant qu'il est ouvert.
|
|
||||||
func testHome(t *testing.T) string {
|
func testHome(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
home := t.TempDir()
|
home := t.TempDir()
|
||||||
t.Setenv("AJEAN_HOME", home)
|
t.Setenv("AJEAN_HOME", home)
|
||||||
t.Cleanup(closeDB)
|
|
||||||
return home
|
return home
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user