From daf62f7de7250f7811afc28735c807687050df66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Fri, 12 Jun 2026 19:43:40 +0200 Subject: [PATCH] =?UTF-8?q?fix(backup):=20bezbedna=20zamena=20baze=20pri?= =?UTF-8?q?=20obnovi=20(sync.RWMutex=20+=20drain)=20Obnova=20backupa=20(Vr?= =?UTF-8?q?atiBackup)=20je=20menjala=20Handler.DB=20i=20repozitorijume=20b?= =?UTF-8?q?ez=20zaklju=C4=8Davanja=20dok=20drugi=20zahtevi=20rade=20?= =?UTF-8?q?=E2=80=94=20data=20race=20i=20mogu=C4=87=20upit=20nad=20zatvore?= =?UTF-8?q?nom=20konekcijom.=20Uveden=20Handler.mu=20(sync.RWMutex):=20zah?= =?UTF-8?q?tevi=20dr=C5=BEe=20deljeno=20zaklju=C4=8Davanje=20preko=20middl?= =?UTF-8?q?eware-a=20ZakljucajCitanje,=20a=20sama=20zamena=20(close+copy+r?= =?UTF-8?q?eopen+reinit)=20uzima=20ekskluzivno=20zaklju=C4=8Davanje=20u=20?= =?UTF-8?q?zasebnoj=20gorutini,=20pa=20sa=C4=8Deka=20da=20svi=20teku=C4=87?= =?UTF-8?q?i=20zahtevi=20zavr=C5=A1e=20(drain)=20pre=20zamene.=20Zasebna?= =?UTF-8?q?=20gorutina=20je=20nu=C5=BEna=20jer=20zahtev=20jo=C5=A1=20dr?= =?UTF-8?q?=C5=BEi=20deljeno=20zaklju=C4=8Davanje=20(ina=C4=8De=20deadlock?= =?UTF-8?q?).=20Poznato=20ograni=C4=8Denje:=20pozadinske=20gorutine=20i=20?= =?UTF-8?q?dalje=20koriste=20stari=20db=20handle=20iz=20main.go=20?= =?UTF-8?q?=E2=80=94=20zaseban=20slu=C4=8Daj=20za=20kasnije.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/ntech/main.go | 3 ++ internal/handler/handler.go | 23 ++++++++++++++- internal/handler/podesavanja.go | 52 ++++++++++++++++++--------------- 3 files changed, 53 insertions(+), 25 deletions(-) diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index ae6bdbe..874ca5f 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -155,6 +155,9 @@ func main() { r.Use(ntechmw.BezbednostHeaders()) r.Use(ntechmw.CsrfMiddleware) r.Use(middleware.Compress(5)) + // deljeno zaključavanje baze za vreme zahteva — obnova backupa (VratiBackup) + // čeka da svi zahtevi završe pre zamene konekcije (vidi handler.ZakljucajCitanje) + r.Use(h.ZakljucajCitanje) // uploads su uvek na disku — korisnički fajlovi, ne ugrađuju se r.Handle("/static/uploads/*", http.StripPrefix("/static/uploads/", diff --git a/internal/handler/handler.go b/internal/handler/handler.go index f74c0f9..85e7d4f 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -5,6 +5,7 @@ import ( "html/template" "io/fs" "net/http" + "sync" "ntech/internal/db" "ntech/internal/db/sqlite" @@ -36,6 +37,24 @@ type Handler struct { Templates map[string]*template.Template TemplatesFS fs.FS totpKljuc []byte // ključ za šifrovanje TOTP tajni (prosleđuje se KorisniciRepo) + + // mu štiti DB i sve repozitorijume od zamene u letu (obnova backupa). + // Zahtevi drže deljeno (read) zaključavanje preko ZakljucajCitanje, a obnova + // uzima ekskluzivno (write) zaključavanje pre zamene konekcije. + mu sync.RWMutex +} + +// ZakljucajCitanje je middleware koji za vreme obrade zahteva drži deljeno +// zaključavanje baze. Više zahteva se izvršava paralelno (RLock ne blokira druge +// čitaoce), ali obnova baze (VratiBackup), koja traži ekskluzivno zaključavanje, +// sačeka da svi tekući zahtevi završe pre zamene konekcije. Time se sprečava +// data race nad Handler.DB / repozitorijumima i upit nad zatvorenom konekcijom. +func (h *Handler) ZakljucajCitanje(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.mu.RLock() + defer h.mu.RUnlock() + next.ServeHTTP(w, r) + }) } // Novi kreira novi Handler sa datom bazom. @@ -62,7 +81,9 @@ func Novi(baza *sql.DB, totpKljuc []byte) *Handler { } } -// reinicijalizujRepozitorijume zamenjuje sve repozitorijume posle obnove baze +// reinicijalizujRepozitorijume zamenjuje sve repozitorijume posle obnove baze. +// MORA se pozivati dok je h.mu ekskluzivno zaključan (vidi VratiBackup), inače +// nastaje data race sa zahtevima koji čitaju repozitorijume. func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) { h.DB = novaDB h.Artikli = sqlite.NoviArtikalRepo(novaDB) diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go index 3b96788..61f37fb 100644 --- a/internal/handler/podesavanja.go +++ b/internal/handler/podesavanja.go @@ -165,32 +165,36 @@ func (h *Handler) VratiBackup(w http.ResponseWriter, r *http.Request) { log.Printf("vrati backup: wal_checkpoint greška: %v", err) } - // zatvori sve konekcije - if err := h.DB.Close(); err != nil { - log.Printf("vrati backup: greška pri zatvaranju baze: %v", err) - } + // Sama zamena baze (zatvaranje stare, kopiranje, otvaranje nove) radi se u + // zasebnoj gorutini pod EKSKLUZIVNIM zaključavanjem (h.mu.Lock). Razlog: ovaj + // zahtev još drži deljeno zaključavanje (ZakljucajCitanje middleware), pa bi + // uzimanje ekskluzivnog u istoj gorutini izazvalo deadlock. Ekskluzivno + // zaključavanje sačeka da svi tekući zahtevi (uključujući ovaj, čim vrati + // odgovor) završe, pa tek onda menja konekciju — bez data race-a i bez upita + // nad zatvorenom bazom. Sledeći zahtev (redirect na /podesavanja) prirodno + // sačeka da zamena završi jer čeka na deljeno zaključavanje. + go func() { + h.mu.Lock() + defer h.mu.Unlock() - // kopiraj backup fajl na mesto trenutne baze - if err := kopiraFajl(putanjaBackupa, h.PutanjaBaze); err != nil { - log.Printf("vrati backup: greška pri kopiranju: %v", err) - http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+obnovi+baze", http.StatusSeeOther) - return - } + if err := h.DB.Close(); err != nil { + log.Printf("vrati backup: greška pri zatvaranju baze: %v", err) + } + if err := kopiraFajl(putanjaBackupa, h.PutanjaBaze); err != nil { + log.Printf("vrati backup: greška pri kopiranju (baza je zatvorena, potreban restart): %v", err) + return + } + os.Remove(h.PutanjaBaze + "-wal") + os.Remove(h.PutanjaBaze + "-shm") - // ukloni WAL i SHM fajlove stare baze - os.Remove(h.PutanjaBaze + "-wal") - os.Remove(h.PutanjaBaze + "-shm") - - // otvori novu konekciju - novaDB, err := ntechsqlite.OtvoriDB(h.PutanjaBaze) - if err != nil { - log.Printf("vrati backup: greška pri otvaranju nove baze: %v", err) - http.Redirect(w, r, "/podesavanja?backup_greska=Baza+obnovljena+ali+je+potreban+restart", http.StatusSeeOther) - return - } - - h.reinicijalizujRepozitorijume(novaDB) - log.Printf("Baza uspešno obnovljena iz: %s", ime) + novaDB, err := ntechsqlite.OtvoriDB(h.PutanjaBaze) + if err != nil { + log.Printf("vrati backup: greška pri otvaranju nove baze (potreban restart): %v", err) + return + } + h.reinicijalizujRepozitorijume(novaDB) + log.Printf("Baza uspešno obnovljena iz: %s", ime) + }() http.Redirect(w, r, "/podesavanja?sacuvano=vraceno", http.StatusSeeOther) }