Switch the database from LevelDB to SQLite, for greater stability and simpler code. Co-authored-by: Tommy van der Vorst <tommy@pixelspark.nl> Co-authored-by: bt90 <btom1990@googlemail.com>
This commit is contained in:
co-authored by
Tommy van der Vorst
bt90
parent
b1c8f88a44
commit
025905fcdf
@@ -0,0 +1,77 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/syncthing/syncthing/internal/db"
|
||||
"github.com/syncthing/syncthing/lib/protocol"
|
||||
"github.com/thejerf/suture/v4"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
sql *sqlx.DB
|
||||
localDeviceIdx int64
|
||||
updateLock sync.Mutex
|
||||
|
||||
statementsMut sync.RWMutex
|
||||
statements map[string]*sqlx.Stmt
|
||||
tplInput map[string]any
|
||||
}
|
||||
|
||||
var _ db.DB = (*DB)(nil)
|
||||
|
||||
func (s *DB) Close() error {
|
||||
s.updateLock.Lock()
|
||||
s.statementsMut.Lock()
|
||||
defer s.updateLock.Unlock()
|
||||
defer s.statementsMut.Unlock()
|
||||
for _, stmt := range s.statements {
|
||||
stmt.Close()
|
||||
}
|
||||
return wrap(s.sql.Close())
|
||||
}
|
||||
|
||||
func (s *DB) Service(maintenanceInterval time.Duration) suture.Service {
|
||||
return newService(s, maintenanceInterval)
|
||||
}
|
||||
|
||||
func (s *DB) ListFolders() ([]string, error) {
|
||||
var res []string
|
||||
err := s.stmt(`
|
||||
SELECT folder_id FROM folders
|
||||
ORDER BY folder_id
|
||||
`).Select(&res)
|
||||
return res, wrap(err)
|
||||
}
|
||||
|
||||
func (s *DB) ListDevicesForFolder(folder string) ([]protocol.DeviceID, error) {
|
||||
var res []string
|
||||
err := s.stmt(`
|
||||
SELECT d.device_id FROM counts s
|
||||
INNER JOIN folders o ON o.idx = s.folder_idx
|
||||
INNER JOIN devices d ON d.idx = s.device_idx
|
||||
WHERE o.folder_id = ? AND s.count > 0 AND s.device_idx != {{.LocalDeviceIdx}}
|
||||
GROUP BY d.device_id
|
||||
ORDER BY d.device_id
|
||||
`).Select(&res, folder)
|
||||
if err != nil {
|
||||
return nil, wrap(err)
|
||||
}
|
||||
|
||||
devs := make([]protocol.DeviceID, len(res))
|
||||
for i, s := range res {
|
||||
devs[i], err = protocol.DeviceIDFromString(s)
|
||||
if err != nil {
|
||||
return nil, wrap(err)
|
||||
}
|
||||
}
|
||||
return devs, nil
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/syncthing/syncthing/internal/timeutil"
|
||||
"github.com/syncthing/syncthing/lib/config"
|
||||
"github.com/syncthing/syncthing/lib/protocol"
|
||||
"github.com/syncthing/syncthing/lib/rand"
|
||||
)
|
||||
|
||||
var globalFi protocol.FileInfo
|
||||
|
||||
func BenchmarkUpdate(b *testing.B) {
|
||||
db, err := OpenTemp()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
})
|
||||
svc := db.Service(time.Hour).(*Service)
|
||||
|
||||
fs := make([]protocol.FileInfo, 100)
|
||||
seed := 0
|
||||
|
||||
size := 10000
|
||||
for size < 200_000 {
|
||||
t0 := time.Now()
|
||||
if err := svc.periodic(context.Background()); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.Log("garbage collect in", time.Since(t0))
|
||||
|
||||
for {
|
||||
local, err := db.CountLocal(folderID, protocol.LocalDeviceID)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if local.Files >= size {
|
||||
break
|
||||
}
|
||||
fs := make([]protocol.FileInfo, 1000)
|
||||
for i := range fs {
|
||||
fs[i] = genFile(rand.String(24), 64, 0)
|
||||
}
|
||||
if err := db.Update(folderID, protocol.LocalDeviceID, fs); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
b.Run(fmt.Sprintf("Insert100Loc@%d", size), func(b *testing.B) {
|
||||
for range b.N {
|
||||
for i := range fs {
|
||||
fs[i] = genFile(rand.String(24), 64, 0)
|
||||
}
|
||||
if err := db.Update(folderID, protocol.LocalDeviceID, fs); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(b.N)*100.0/b.Elapsed().Seconds(), "files/s")
|
||||
})
|
||||
|
||||
b.Run(fmt.Sprintf("RepBlocks100@%d", size), func(b *testing.B) {
|
||||
for range b.N {
|
||||
for i := range fs {
|
||||
fs[i].Blocks = genBlocks(fs[i].Name, seed, 64)
|
||||
fs[i].Version = fs[i].Version.Update(42)
|
||||
}
|
||||
seed++
|
||||
if err := db.Update(folderID, protocol.LocalDeviceID, fs); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(b.N)*100.0/b.Elapsed().Seconds(), "files/s")
|
||||
})
|
||||
|
||||
b.Run(fmt.Sprintf("RepSame100@%d", size), func(b *testing.B) {
|
||||
for range b.N {
|
||||
for i := range fs {
|
||||
fs[i].Version = fs[i].Version.Update(42)
|
||||
}
|
||||
if err := db.Update(folderID, protocol.LocalDeviceID, fs); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(b.N)*100.0/b.Elapsed().Seconds(), "files/s")
|
||||
})
|
||||
|
||||
b.Run(fmt.Sprintf("Insert100Rem@%d", size), func(b *testing.B) {
|
||||
for range b.N {
|
||||
for i := range fs {
|
||||
fs[i].Blocks = genBlocks(fs[i].Name, seed, 64)
|
||||
fs[i].Version = fs[i].Version.Update(42)
|
||||
fs[i].Sequence = timeutil.StrictlyMonotonicNanos()
|
||||
}
|
||||
if err := db.Update(folderID, protocol.DeviceID{42}, fs); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(b.N)*100.0/b.Elapsed().Seconds(), "files/s")
|
||||
})
|
||||
|
||||
b.Run(fmt.Sprintf("GetGlobal100@%d", size), func(b *testing.B) {
|
||||
for range b.N {
|
||||
for i := range fs {
|
||||
_, ok, err := db.GetGlobalFile(folderID, fs[i].Name)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if !ok {
|
||||
b.Fatal("should exist")
|
||||
}
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(b.N)*100.0/b.Elapsed().Seconds(), "files/s")
|
||||
})
|
||||
|
||||
b.Run(fmt.Sprintf("LocalSequenced@%d", size), func(b *testing.B) {
|
||||
count := 0
|
||||
for range b.N {
|
||||
cur, err := db.GetDeviceSequence(folderID, protocol.LocalDeviceID)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
it, errFn := db.AllLocalFilesBySequence(folderID, protocol.LocalDeviceID, cur-100, 0)
|
||||
for f := range it {
|
||||
count++
|
||||
globalFi = f
|
||||
}
|
||||
if err := errFn(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(count)/b.Elapsed().Seconds(), "files/s")
|
||||
})
|
||||
|
||||
b.Run(fmt.Sprintf("GetDeviceSequenceLoc@%d", size), func(b *testing.B) {
|
||||
for range b.N {
|
||||
_, err := db.GetDeviceSequence(folderID, protocol.LocalDeviceID)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
b.Run(fmt.Sprintf("GetDeviceSequenceRem@%d", size), func(b *testing.B) {
|
||||
for range b.N {
|
||||
_, err := db.GetDeviceSequence(folderID, protocol.DeviceID{42})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
b.Run(fmt.Sprintf("RemoteNeed@%d", size), func(b *testing.B) {
|
||||
count := 0
|
||||
for range b.N {
|
||||
it, errFn := db.AllNeededGlobalFiles(folderID, protocol.DeviceID{42}, config.PullOrderAlphabetic, 0, 0)
|
||||
for f := range it {
|
||||
count++
|
||||
globalFi = f
|
||||
}
|
||||
if err := errFn(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(count)/b.Elapsed().Seconds(), "files/s")
|
||||
})
|
||||
|
||||
b.Run(fmt.Sprintf("LocalNeed100Largest@%d", size), func(b *testing.B) {
|
||||
count := 0
|
||||
for range b.N {
|
||||
it, errFn := db.AllNeededGlobalFiles(folderID, protocol.LocalDeviceID, config.PullOrderLargestFirst, 100, 0)
|
||||
for f := range it {
|
||||
globalFi = f
|
||||
count++
|
||||
}
|
||||
if err := errFn(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(count)/b.Elapsed().Seconds(), "files/s")
|
||||
})
|
||||
|
||||
size <<= 1
|
||||
}
|
||||
}
|
||||
|
||||
func TestBenchmarkDropAllRemote(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("slow test")
|
||||
}
|
||||
|
||||
db, err := OpenTemp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
fs := make([]protocol.FileInfo, 1000)
|
||||
seq := 0
|
||||
for {
|
||||
local, err := db.CountLocal(folderID, protocol.LocalDeviceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if local.Files >= 15_000 {
|
||||
break
|
||||
}
|
||||
for i := range fs {
|
||||
seq++
|
||||
fs[i] = genFile(rand.String(24), 64, seq)
|
||||
}
|
||||
if err := db.Update(folderID, protocol.DeviceID{42}, fs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Update(folderID, protocol.LocalDeviceID, fs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
t0 := time.Now()
|
||||
if err := db.DropAllFiles(folderID, protocol.DeviceID{42}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := time.Since(t0)
|
||||
t.Log("drop all took", d)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"github.com/syncthing/syncthing/internal/db"
|
||||
"github.com/syncthing/syncthing/lib/protocol"
|
||||
)
|
||||
|
||||
type countsRow struct {
|
||||
Type protocol.FileInfoType
|
||||
Count int
|
||||
Size int64
|
||||
Deleted bool
|
||||
LocalFlags int64 `db:"local_flags"`
|
||||
}
|
||||
|
||||
func (s *DB) CountLocal(folder string, device protocol.DeviceID) (db.Counts, error) {
|
||||
var res []countsRow
|
||||
if err := s.stmt(`
|
||||
SELECT s.type, s.count, s.size, s.local_flags, s.deleted FROM counts s
|
||||
INNER JOIN folders o ON o.idx = s.folder_idx
|
||||
INNER JOIN devices d ON d.idx = s.device_idx
|
||||
WHERE o.folder_id = ? AND d.device_id = ? AND s.local_flags & {{.FlagLocalIgnored}} = 0
|
||||
`).Select(&res, folder, device.String()); err != nil {
|
||||
return db.Counts{}, wrap(err)
|
||||
}
|
||||
return summarizeCounts(res), nil
|
||||
}
|
||||
|
||||
func (s *DB) CountNeed(folder string, device protocol.DeviceID) (db.Counts, error) {
|
||||
if device == protocol.LocalDeviceID {
|
||||
return s.needSizeLocal(folder)
|
||||
}
|
||||
return s.needSizeRemote(folder, device)
|
||||
}
|
||||
|
||||
func (s *DB) CountGlobal(folder string) (db.Counts, error) {
|
||||
// Exclude ignored and receive-only changed files from the global count
|
||||
// (legacy expectation? it's a bit weird since those files can in fact
|
||||
// be global and you can get them with GetGlobal etc.)
|
||||
var res []countsRow
|
||||
err := s.stmt(`
|
||||
SELECT s.type, s.count, s.size, s.local_flags, s.deleted FROM counts s
|
||||
INNER JOIN folders o ON o.idx = s.folder_idx
|
||||
WHERE o.folder_id = ? AND s.local_flags & {{.FlagLocalGlobal}} != 0 AND s.local_flags & {{or .FlagLocalReceiveOnly .FlagLocalIgnored}} = 0
|
||||
`).Select(&res, folder)
|
||||
if err != nil {
|
||||
return db.Counts{}, wrap(err)
|
||||
}
|
||||
return summarizeCounts(res), nil
|
||||
}
|
||||
|
||||
func (s *DB) CountReceiveOnlyChanged(folder string) (db.Counts, error) {
|
||||
var res []countsRow
|
||||
err := s.stmt(`
|
||||
SELECT s.type, s.count, s.size, s.local_flags, s.deleted FROM counts s
|
||||
INNER JOIN folders o ON o.idx = s.folder_idx
|
||||
WHERE o.folder_id = ? AND local_flags & {{.FlagLocalReceiveOnly}} != 0
|
||||
`).Select(&res, folder)
|
||||
if err != nil {
|
||||
return db.Counts{}, wrap(err)
|
||||
}
|
||||
return summarizeCounts(res), nil
|
||||
}
|
||||
|
||||
func (s *DB) needSizeLocal(folder string) (db.Counts, error) {
|
||||
// The need size for the local device is the sum of entries with the
|
||||
// need bit set.
|
||||
var res []countsRow
|
||||
err := s.stmt(`
|
||||
SELECT s.type, s.count, s.size, s.local_flags, s.deleted FROM counts s
|
||||
INNER JOIN folders o ON o.idx = s.folder_idx
|
||||
WHERE o.folder_id = ? AND s.local_flags & {{.FlagLocalNeeded}} != 0
|
||||
`).Select(&res, folder)
|
||||
if err != nil {
|
||||
return db.Counts{}, wrap(err)
|
||||
}
|
||||
return summarizeCounts(res), nil
|
||||
}
|
||||
|
||||
func (s *DB) needSizeRemote(folder string, device protocol.DeviceID) (db.Counts, error) {
|
||||
var res []countsRow
|
||||
// See neededGlobalFilesRemote for commentary as that is the same query without summing
|
||||
if err := s.stmt(`
|
||||
SELECT g.type, count(*) as count, sum(g.size) as size, g.local_flags, g.deleted FROM files g
|
||||
INNER JOIN folders o ON o.idx = g.folder_idx
|
||||
WHERE o.folder_id = ? AND g.local_flags & {{.FlagLocalGlobal}} != 0 AND NOT g.deleted AND NOT g.invalid AND NOT EXISTS (
|
||||
SELECT 1 FROM FILES f
|
||||
INNER JOIN devices d ON d.idx = f.device_idx
|
||||
WHERE f.name = g.name AND f.version = g.version AND f.folder_idx = g.folder_idx AND d.device_id = ?
|
||||
)
|
||||
GROUP BY g.type, g.local_flags, g.deleted
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT g.type, count(*) as count, sum(g.size) as size, g.local_flags, g.deleted FROM files g
|
||||
INNER JOIN folders o ON o.idx = g.folder_idx
|
||||
WHERE o.folder_id = ? AND g.local_flags & {{.FlagLocalGlobal}} != 0 AND g.deleted AND NOT g.invalid AND EXISTS (
|
||||
SELECT 1 FROM FILES f
|
||||
INNER JOIN devices d ON d.idx = f.device_idx
|
||||
WHERE f.name = g.name AND f.folder_idx = g.folder_idx AND d.device_id = ? AND NOT f.deleted
|
||||
)
|
||||
GROUP BY g.type, g.local_flags, g.deleted
|
||||
`).Select(&res, folder, device.String(),
|
||||
folder, device.String()); err != nil {
|
||||
return db.Counts{}, wrap(err)
|
||||
}
|
||||
|
||||
return summarizeCounts(res), nil
|
||||
}
|
||||
|
||||
func summarizeCounts(res []countsRow) db.Counts {
|
||||
c := db.Counts{
|
||||
DeviceID: protocol.LocalDeviceID,
|
||||
}
|
||||
for _, r := range res {
|
||||
switch {
|
||||
case r.Deleted:
|
||||
c.Deleted += r.Count
|
||||
case r.Type == protocol.FileInfoTypeFile:
|
||||
c.Files += r.Count
|
||||
c.Bytes += r.Size
|
||||
case r.Type == protocol.FileInfoTypeDirectory:
|
||||
c.Directories += r.Count
|
||||
c.Bytes += r.Size
|
||||
case r.Type == protocol.FileInfoTypeSymlink:
|
||||
c.Symlinks += r.Count
|
||||
c.Bytes += r.Size
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
|
||||
"github.com/syncthing/syncthing/internal/db"
|
||||
"github.com/syncthing/syncthing/internal/itererr"
|
||||
"github.com/syncthing/syncthing/lib/config"
|
||||
"github.com/syncthing/syncthing/lib/osutil"
|
||||
"github.com/syncthing/syncthing/lib/protocol"
|
||||
)
|
||||
|
||||
func (s *DB) GetGlobalFile(folder string, file string) (protocol.FileInfo, bool, error) {
|
||||
file = osutil.NormalizedFilename(file)
|
||||
|
||||
var ind indirectFI
|
||||
err := s.stmt(`
|
||||
SELECT fi.fiprotobuf, bl.blprotobuf FROM fileinfos fi
|
||||
INNER JOIN files f on fi.sequence = f.sequence
|
||||
LEFT JOIN blocklists bl ON bl.blocklist_hash = f.blocklist_hash
|
||||
INNER JOIN folders o ON o.idx = f.folder_idx
|
||||
WHERE o.folder_id = ? AND f.name = ? AND f.local_flags & {{.FlagLocalGlobal}} != 0
|
||||
`).Get(&ind, folder, file)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return protocol.FileInfo{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return protocol.FileInfo{}, false, wrap(err)
|
||||
}
|
||||
fi, err := ind.FileInfo()
|
||||
if err != nil {
|
||||
return protocol.FileInfo{}, false, wrap(err)
|
||||
}
|
||||
return fi, true, nil
|
||||
}
|
||||
|
||||
func (s *DB) GetGlobalAvailability(folder, file string) ([]protocol.DeviceID, error) {
|
||||
file = osutil.NormalizedFilename(file)
|
||||
|
||||
var devStrs []string
|
||||
err := s.stmt(`
|
||||
SELECT d.device_id FROM files f
|
||||
INNER JOIN devices d ON d.idx = f.device_idx
|
||||
INNER JOIN folders o ON o.idx = f.folder_idx
|
||||
INNER JOIN files g ON f.folder_idx = g.folder_idx AND g.version = f.version AND g.name = f.name
|
||||
WHERE o.folder_id = ? AND g.name = ? AND g.local_flags & {{.FlagLocalGlobal}} != 0 AND f.device_idx != {{.LocalDeviceIdx}}
|
||||
ORDER BY d.device_id
|
||||
`).Select(&devStrs, folder, file)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, wrap(err)
|
||||
}
|
||||
|
||||
devs := make([]protocol.DeviceID, 0, len(devStrs))
|
||||
for _, s := range devStrs {
|
||||
d, err := protocol.DeviceIDFromString(s)
|
||||
if err != nil {
|
||||
return nil, wrap(err)
|
||||
}
|
||||
devs = append(devs, d)
|
||||
}
|
||||
|
||||
return devs, nil
|
||||
}
|
||||
|
||||
func (s *DB) AllGlobalFiles(folder string) (iter.Seq[db.FileMetadata], func() error) {
|
||||
it, errFn := iterStructs[db.FileMetadata](s.stmt(`
|
||||
SELECT f.sequence, f.name, f.type, f.modified as modnanos, f.size, f.deleted, f.invalid, f.local_flags as localflags FROM files f
|
||||
INNER JOIN folders o ON o.idx = f.folder_idx
|
||||
WHERE o.folder_id = ? AND f.local_flags & {{.FlagLocalGlobal}} != 0
|
||||
ORDER BY f.name
|
||||
`).Queryx(folder))
|
||||
return itererr.Map(it, errFn, func(m db.FileMetadata) (db.FileMetadata, error) {
|
||||
m.Name = osutil.NativeFilename(m.Name)
|
||||
return m, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *DB) AllGlobalFilesPrefix(folder string, prefix string) (iter.Seq[db.FileMetadata], func() error) {
|
||||
if prefix == "" {
|
||||
return s.AllGlobalFiles(folder)
|
||||
}
|
||||
|
||||
prefix = osutil.NormalizedFilename(prefix)
|
||||
end := prefixEnd(prefix)
|
||||
|
||||
it, errFn := iterStructs[db.FileMetadata](s.stmt(`
|
||||
SELECT f.sequence, f.name, f.type, f.modified as modnanos, f.size, f.deleted, f.invalid, f.local_flags as localflags FROM files f
|
||||
INNER JOIN folders o ON o.idx = f.folder_idx
|
||||
WHERE o.folder_id = ? AND f.name >= ? AND f.name < ? AND f.local_flags & {{.FlagLocalGlobal}} != 0
|
||||
ORDER BY f.name
|
||||
`).Queryx(folder, prefix, end))
|
||||
return itererr.Map(it, errFn, func(m db.FileMetadata) (db.FileMetadata, error) {
|
||||
m.Name = osutil.NativeFilename(m.Name)
|
||||
return m, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *DB) AllNeededGlobalFiles(folder string, device protocol.DeviceID, order config.PullOrder, limit, offset int) (iter.Seq[protocol.FileInfo], func() error) {
|
||||
var selectOpts string
|
||||
switch order {
|
||||
case config.PullOrderRandom:
|
||||
selectOpts = "ORDER BY RANDOM()"
|
||||
case config.PullOrderAlphabetic:
|
||||
selectOpts = "ORDER BY g.name ASC"
|
||||
case config.PullOrderSmallestFirst:
|
||||
selectOpts = "ORDER BY g.size ASC"
|
||||
case config.PullOrderLargestFirst:
|
||||
selectOpts = "ORDER BY g.size DESC"
|
||||
case config.PullOrderOldestFirst:
|
||||
selectOpts = "ORDER BY g.modified ASC"
|
||||
case config.PullOrderNewestFirst:
|
||||
selectOpts = "ORDER BY g.modified DESC"
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
selectOpts += fmt.Sprintf(" LIMIT %d", limit)
|
||||
}
|
||||
if offset > 0 {
|
||||
selectOpts += fmt.Sprintf(" OFFSET %d", offset)
|
||||
}
|
||||
|
||||
if device == protocol.LocalDeviceID {
|
||||
return s.neededGlobalFilesLocal(folder, selectOpts)
|
||||
}
|
||||
|
||||
return s.neededGlobalFilesRemote(folder, device, selectOpts)
|
||||
}
|
||||
|
||||
func (s *DB) neededGlobalFilesLocal(folder, selectOpts string) (iter.Seq[protocol.FileInfo], func() error) {
|
||||
// Select all the non-ignored files with the need bit set.
|
||||
it, errFn := iterStructs[indirectFI](s.stmt(`
|
||||
SELECT fi.fiprotobuf, bl.blprotobuf, g.name, g.size, g.modified FROM fileinfos fi
|
||||
INNER JOIN files g on fi.sequence = g.sequence
|
||||
LEFT JOIN blocklists bl ON bl.blocklist_hash = g.blocklist_hash
|
||||
INNER JOIN folders o ON o.idx = g.folder_idx
|
||||
WHERE o.folder_id = ? AND g.local_flags & {{.FlagLocalIgnored}} = 0 AND g.local_flags & {{.FlagLocalNeeded}} != 0
|
||||
` + selectOpts).Queryx(folder))
|
||||
return itererr.Map(it, errFn, indirectFI.FileInfo)
|
||||
}
|
||||
|
||||
func (s *DB) neededGlobalFilesRemote(folder string, device protocol.DeviceID, selectOpts string) (iter.Seq[protocol.FileInfo], func() error) {
|
||||
// Select:
|
||||
//
|
||||
// - all the valid, non-deleted global files that don't have a corresponding
|
||||
// remote file with the same version.
|
||||
//
|
||||
// - all the valid, deleted global files that have a corresponding non-deleted
|
||||
// remote file (of any version)
|
||||
|
||||
it, errFn := iterStructs[indirectFI](s.stmt(`
|
||||
SELECT fi.fiprotobuf, bl.blprotobuf, g.name, g.size, g.modified FROM fileinfos fi
|
||||
INNER JOIN files g on fi.sequence = g.sequence
|
||||
LEFT JOIN blocklists bl ON bl.blocklist_hash = g.blocklist_hash
|
||||
INNER JOIN folders o ON o.idx = g.folder_idx
|
||||
WHERE o.folder_id = ? AND g.local_flags & {{.FlagLocalGlobal}} != 0 AND NOT g.deleted AND NOT g.invalid AND NOT EXISTS (
|
||||
SELECT 1 FROM FILES f
|
||||
INNER JOIN devices d ON d.idx = f.device_idx
|
||||
WHERE f.name = g.name AND f.version = g.version AND f.folder_idx = g.folder_idx AND d.device_id = ?
|
||||
)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT fi.fiprotobuf, bl.blprotobuf, g.name, g.size, g.modified FROM fileinfos fi
|
||||
INNER JOIN files g on fi.sequence = g.sequence
|
||||
LEFT JOIN blocklists bl ON bl.blocklist_hash = g.blocklist_hash
|
||||
INNER JOIN folders o ON o.idx = g.folder_idx
|
||||
WHERE o.folder_id = ? AND g.local_flags & {{.FlagLocalGlobal}} != 0 AND g.deleted AND NOT g.invalid AND EXISTS (
|
||||
SELECT 1 FROM FILES f
|
||||
INNER JOIN devices d ON d.idx = f.device_idx
|
||||
WHERE f.name = g.name AND f.folder_idx = g.folder_idx AND d.device_id = ? AND NOT f.deleted
|
||||
)
|
||||
`+selectOpts).Queryx(
|
||||
folder, device.String(),
|
||||
folder, device.String(),
|
||||
))
|
||||
return itererr.Map(it, errFn, indirectFI.FileInfo)
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/syncthing/syncthing/lib/config"
|
||||
"github.com/syncthing/syncthing/lib/protocol"
|
||||
)
|
||||
|
||||
func TestNeed(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
db, err := OpenTemp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
// Some local files
|
||||
var v protocol.Vector
|
||||
baseV := v.Update(1)
|
||||
newerV := baseV.Update(42)
|
||||
files := []protocol.FileInfo{
|
||||
genFile("test1", 1, 0), // remote need
|
||||
genFile("test2", 2, 0), // local need
|
||||
genFile("test3", 3, 0), // global
|
||||
}
|
||||
files[0].Version = baseV
|
||||
files[1].Version = baseV
|
||||
files[2].Version = newerV
|
||||
err = db.Update(folderID, protocol.LocalDeviceID, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Some remote files
|
||||
remote := []protocol.FileInfo{
|
||||
genFile("test2", 2, 100), // global
|
||||
genFile("test3", 3, 101), // remote need
|
||||
genFile("test4", 4, 102), // local need
|
||||
}
|
||||
remote[0].Version = newerV
|
||||
remote[1].Version = baseV
|
||||
remote[2].Version = newerV
|
||||
err = db.Update(folderID, protocol.DeviceID{42}, remote)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A couple are needed locally
|
||||
localNeed := fiNames(mustCollect[protocol.FileInfo](t)(db.AllNeededGlobalFiles(folderID, protocol.LocalDeviceID, config.PullOrderAlphabetic, 0, 0)))
|
||||
if !slices.Equal(localNeed, []string{"test2", "test4"}) {
|
||||
t.Log(localNeed)
|
||||
t.Fatal("bad local need")
|
||||
}
|
||||
|
||||
// Another couple are needed remotely
|
||||
remoteNeed := fiNames(mustCollect[protocol.FileInfo](t)(db.AllNeededGlobalFiles(folderID, protocol.DeviceID{42}, config.PullOrderAlphabetic, 0, 0)))
|
||||
if !slices.Equal(remoteNeed, []string{"test1", "test3"}) {
|
||||
t.Log(remoteNeed)
|
||||
t.Fatal("bad remote need")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropRecalcsGlobal(t *testing.T) {
|
||||
// When we drop a device we may get a new global
|
||||
|
||||
t.Parallel()
|
||||
|
||||
t.Run("DropAllFiles", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testDropWithDropper(t, func(t *testing.T, db *DB) {
|
||||
t.Helper()
|
||||
if err := db.DropAllFiles(folderID, protocol.DeviceID{42}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("DropDevice", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testDropWithDropper(t, func(t *testing.T, db *DB) {
|
||||
t.Helper()
|
||||
if err := db.DropDevice(protocol.DeviceID{42}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("DropFilesNamed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testDropWithDropper(t, func(t *testing.T, db *DB) {
|
||||
t.Helper()
|
||||
if err := db.DropFilesNamed(folderID, protocol.DeviceID{42}, []string{"test1", "test42"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func testDropWithDropper(t *testing.T, dropper func(t *testing.T, db *DB)) {
|
||||
t.Helper()
|
||||
|
||||
db, err := OpenTemp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
// Some local files
|
||||
err = db.Update(folderID, protocol.LocalDeviceID, []protocol.FileInfo{
|
||||
genFile("test1", 1, 0),
|
||||
genFile("test2", 2, 0),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Some remote files
|
||||
remote := []protocol.FileInfo{
|
||||
genFile("test1", 3, 0),
|
||||
}
|
||||
remote[0].Version = remote[0].Version.Update(42)
|
||||
err = db.Update(folderID, protocol.DeviceID{42}, remote)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Remote test1 wins as the global, verify.
|
||||
count, err := db.CountGlobal(folderID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count.Bytes != (2+3)*128<<10 {
|
||||
t.Log(count)
|
||||
t.Fatal("bad global size to begin with")
|
||||
}
|
||||
if g, ok, err := db.GetGlobalFile(folderID, "test1"); err != nil || !ok {
|
||||
t.Fatal("missing global to begin with")
|
||||
} else if g.Size != 3*128<<10 {
|
||||
t.Fatal("remote test1 should be the global")
|
||||
}
|
||||
|
||||
// Now remove that remote device
|
||||
dropper(t, db)
|
||||
|
||||
// Our test1 should now be the global
|
||||
count, err = db.CountGlobal(folderID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count.Bytes != (1+2)*128<<10 {
|
||||
t.Log(count)
|
||||
t.Fatal("bad global size after drop")
|
||||
}
|
||||
if g, ok, err := db.GetGlobalFile(folderID, "test1"); err != nil || !ok {
|
||||
t.Fatal("missing global after drop")
|
||||
} else if g.Size != 1*128<<10 {
|
||||
t.Fatal("local test1 should be the global")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedDeleted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, err := OpenTemp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
// Some local files
|
||||
err = db.Update(folderID, protocol.LocalDeviceID, []protocol.FileInfo{
|
||||
genFile("test1", 1, 0),
|
||||
genFile("test2", 2, 0),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A remote deleted file
|
||||
remote := []protocol.FileInfo{
|
||||
genFile("test1", 1, 101),
|
||||
}
|
||||
remote[0].SetDeleted(42)
|
||||
err = db.Update(folderID, protocol.DeviceID{42}, remote)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// We need the one deleted file
|
||||
s, err := db.CountNeed(folderID, protocol.LocalDeviceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Bytes != 0 || s.Deleted != 1 {
|
||||
t.Log(s)
|
||||
t.Error("bad need")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDontNeedIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, err := OpenTemp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
// A remote file
|
||||
files := []protocol.FileInfo{
|
||||
genFile("test1", 1, 103),
|
||||
}
|
||||
err = db.Update(folderID, protocol.DeviceID{42}, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Which we've ignored locally
|
||||
files[0].SetIgnored()
|
||||
err = db.Update(folderID, protocol.LocalDeviceID, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// We don't need it
|
||||
s, err := db.CountNeed(folderID, protocol.LocalDeviceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Bytes != 0 || s.Files != 0 {
|
||||
t.Log(s)
|
||||
t.Error("bad need")
|
||||
}
|
||||
|
||||
// It shouldn't show up in the need list
|
||||
names := mustCollect[protocol.FileInfo](t)(db.AllNeededGlobalFiles(folderID, protocol.LocalDeviceID, config.PullOrderAlphabetic, 0, 0))
|
||||
if len(names) != 0 {
|
||||
t.Log(names)
|
||||
t.Error("need no files")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveDontNeedLocalIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, err := OpenTemp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
// A local ignored file
|
||||
file := genFile("test1", 1, 103)
|
||||
file.SetIgnored()
|
||||
files := []protocol.FileInfo{file}
|
||||
err = db.Update(folderID, protocol.LocalDeviceID, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Which the remote doesn't have (no update)
|
||||
|
||||
// They don't need it
|
||||
s, err := db.CountNeed(folderID, protocol.DeviceID{42})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Bytes != 0 || s.Files != 0 {
|
||||
t.Log(s)
|
||||
t.Error("bad need")
|
||||
}
|
||||
|
||||
// It shouldn't show up in their need list
|
||||
names := mustCollect[protocol.FileInfo](t)(db.AllNeededGlobalFiles(folderID, protocol.DeviceID{42}, config.PullOrderAlphabetic, 0, 0))
|
||||
if len(names) != 0 {
|
||||
t.Log(names)
|
||||
t.Error("need no files")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDontNeedDeletedMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, err := OpenTemp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
// A remote deleted file
|
||||
file := genFile("test1", 1, 103)
|
||||
file.SetDeleted(42)
|
||||
files := []protocol.FileInfo{file}
|
||||
err = db.Update(folderID, protocol.DeviceID{42}, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Which we don't have (no local update)
|
||||
|
||||
// We don't need it
|
||||
s, err := db.CountNeed(folderID, protocol.LocalDeviceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Bytes != 0 || s.Files != 0 || s.Deleted != 0 {
|
||||
t.Log(s)
|
||||
t.Error("bad need")
|
||||
}
|
||||
|
||||
// It shouldn't show up in the need list
|
||||
names := mustCollect[protocol.FileInfo](t)(db.AllNeededGlobalFiles(folderID, protocol.LocalDeviceID, config.PullOrderAlphabetic, 0, 0))
|
||||
if len(names) != 0 {
|
||||
t.Log(names)
|
||||
t.Error("need no files")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteDontNeedDeletedMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, err := OpenTemp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
// A local deleted file
|
||||
file := genFile("test1", 1, 103)
|
||||
file.SetDeleted(42)
|
||||
files := []protocol.FileInfo{file}
|
||||
err = db.Update(folderID, protocol.LocalDeviceID, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Which the remote doesn't have (no local update)
|
||||
|
||||
// They don't need it
|
||||
s, err := db.CountNeed(folderID, protocol.DeviceID{42})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Bytes != 0 || s.Files != 0 || s.Deleted != 0 {
|
||||
t.Log(s)
|
||||
t.Error("bad need")
|
||||
}
|
||||
|
||||
// It shouldn't show up in their need list
|
||||
names := mustCollect[protocol.FileInfo](t)(db.AllNeededGlobalFiles(folderID, protocol.DeviceID{42}, config.PullOrderAlphabetic, 0, 0))
|
||||
if len(names) != 0 {
|
||||
t.Log(names)
|
||||
t.Error("need no files")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedRemoteSymlinkAndDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, err := OpenTemp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
// Two remote "specials", a symlink and a directory
|
||||
var v protocol.Vector
|
||||
v.Update(1)
|
||||
files := []protocol.FileInfo{
|
||||
{Name: "sym", Type: protocol.FileInfoTypeSymlink, Sequence: 100, Version: v, Blocks: genBlocks("symlink", 0, 1)},
|
||||
{Name: "dir", Type: protocol.FileInfoTypeDirectory, Sequence: 101, Version: v},
|
||||
}
|
||||
err = db.Update(folderID, protocol.DeviceID{42}, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// We need them
|
||||
s, err := db.CountNeed(folderID, protocol.LocalDeviceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Directories != 1 || s.Symlinks != 1 {
|
||||
t.Log(s)
|
||||
t.Error("bad need")
|
||||
}
|
||||
|
||||
// They should be in the need list
|
||||
names := mustCollect[protocol.FileInfo](t)(db.AllNeededGlobalFiles(folderID, protocol.LocalDeviceID, config.PullOrderAlphabetic, 0, 0))
|
||||
if len(names) != 2 {
|
||||
t.Log(names)
|
||||
t.Error("bad need")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedPagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, err := OpenTemp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
// Several remote files
|
||||
var v protocol.Vector
|
||||
v.Update(1)
|
||||
files := []protocol.FileInfo{
|
||||
genFile("test0", 1, 100),
|
||||
genFile("test1", 1, 101),
|
||||
genFile("test2", 1, 102),
|
||||
genFile("test3", 1, 103),
|
||||
genFile("test4", 1, 104),
|
||||
genFile("test5", 1, 105),
|
||||
genFile("test6", 1, 106),
|
||||
genFile("test7", 1, 107),
|
||||
genFile("test8", 1, 108),
|
||||
genFile("test9", 1, 109),
|
||||
}
|
||||
err = db.Update(folderID, protocol.DeviceID{42}, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// We should get the first two
|
||||
names := fiNames(mustCollect[protocol.FileInfo](t)(db.AllNeededGlobalFiles(folderID, protocol.LocalDeviceID, config.PullOrderAlphabetic, 2, 0)))
|
||||
if !slices.Equal(names, []string{"test0", "test1"}) {
|
||||
t.Log(names)
|
||||
t.Error("bad need")
|
||||
}
|
||||
|
||||
// We should get the next three
|
||||
names = fiNames(mustCollect[protocol.FileInfo](t)(db.AllNeededGlobalFiles(folderID, protocol.LocalDeviceID, config.PullOrderAlphabetic, 3, 2)))
|
||||
if !slices.Equal(names, []string{"test2", "test3", "test4"}) {
|
||||
t.Log(names)
|
||||
t.Error("bad need")
|
||||
}
|
||||
|
||||
// We should get the last five
|
||||
names = fiNames(mustCollect[protocol.FileInfo](t)(db.AllNeededGlobalFiles(folderID, protocol.LocalDeviceID, config.PullOrderAlphabetic, 5, 5)))
|
||||
if !slices.Equal(names, []string{"test5", "test6", "test7", "test8", "test9"}) {
|
||||
t.Log(names)
|
||||
t.Error("bad need")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/syncthing/syncthing/internal/itererr"
|
||||
"github.com/syncthing/syncthing/lib/protocol"
|
||||
)
|
||||
|
||||
func (s *DB) GetIndexID(folder string, device protocol.DeviceID) (protocol.IndexID, error) {
|
||||
// Try a fast read-only query to begin with. If it does not find the ID
|
||||
// we'll do the full thing under a lock.
|
||||
var indexID string
|
||||
if err := s.stmt(`
|
||||
SELECT i.index_id FROM indexids i
|
||||
INNER JOIN folders o ON o.idx = i.folder_idx
|
||||
INNER JOIN devices d ON d.idx = i.device_idx
|
||||
WHERE o.folder_id = ? AND d.device_id = ?
|
||||
`).Get(&indexID, folder, device.String()); err == nil && indexID != "" {
|
||||
idx, err := indexIDFromHex(indexID)
|
||||
return idx, wrap(err, "select")
|
||||
}
|
||||
if device != protocol.LocalDeviceID {
|
||||
// For non-local devices we do not create the index ID, so return
|
||||
// zero anyway if we don't have one.
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
s.updateLock.Lock()
|
||||
defer s.updateLock.Unlock()
|
||||
|
||||
// We are now operating only for the local device ID
|
||||
|
||||
folderIdx, err := s.folderIdxLocked(folder)
|
||||
if err != nil {
|
||||
return 0, wrap(err)
|
||||
}
|
||||
|
||||
if err := s.stmt(`
|
||||
SELECT index_id FROM indexids WHERE folder_idx = ? AND device_idx = {{.LocalDeviceIdx}}
|
||||
`).Get(&indexID, folderIdx); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, wrap(err, "select local")
|
||||
}
|
||||
|
||||
if indexID == "" {
|
||||
// Generate a new index ID. Some trickiness in the query as we need
|
||||
// to find the max sequence of local files if there already exist
|
||||
// any.
|
||||
id := protocol.NewIndexID()
|
||||
if _, err := s.stmt(`
|
||||
INSERT INTO indexids (folder_idx, device_idx, index_id, sequence)
|
||||
SELECT ?, {{.LocalDeviceIdx}}, ?, COALESCE(MAX(sequence), 0) FROM files
|
||||
WHERE folder_idx = ? AND device_idx = {{.LocalDeviceIdx}}
|
||||
ON CONFLICT DO UPDATE SET index_id = ?
|
||||
`).Exec(folderIdx, indexIDToHex(id), folderIdx, indexIDToHex(id)); err != nil {
|
||||
return 0, wrap(err, "insert")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
return indexIDFromHex(indexID)
|
||||
}
|
||||
|
||||
func (s *DB) SetIndexID(folder string, device protocol.DeviceID, id protocol.IndexID) error {
|
||||
s.updateLock.Lock()
|
||||
defer s.updateLock.Unlock()
|
||||
|
||||
folderIdx, err := s.folderIdxLocked(folder)
|
||||
if err != nil {
|
||||
return wrap(err, "folder idx")
|
||||
}
|
||||
deviceIdx, err := s.deviceIdxLocked(device)
|
||||
if err != nil {
|
||||
return wrap(err, "device idx")
|
||||
}
|
||||
|
||||
if _, err := s.stmt(`
|
||||
INSERT OR REPLACE INTO indexids (folder_idx, device_idx, index_id, sequence) values (?, ?, ?, 0)
|
||||
`).Exec(folderIdx, deviceIdx, indexIDToHex(id)); err != nil {
|
||||
return wrap(err, "insert")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DB) DropAllIndexIDs() error {
|
||||
s.updateLock.Lock()
|
||||
defer s.updateLock.Unlock()
|
||||
_, err := s.stmt(`DELETE FROM indexids`).Exec()
|
||||
return wrap(err)
|
||||
}
|
||||
|
||||
func (s *DB) GetDeviceSequence(folder string, device protocol.DeviceID) (int64, error) {
|
||||
var res sql.NullInt64
|
||||
err := s.stmt(`
|
||||
SELECT sequence FROM indexids i
|
||||
INNER JOIN folders o ON o.idx = i.folder_idx
|
||||
INNER JOIN devices d ON d.idx = i.device_idx
|
||||
WHERE o.folder_id = ? AND d.device_id = ?
|
||||
`).Get(&res, folder, device.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, wrap(err)
|
||||
}
|
||||
if !res.Valid {
|
||||
return 0, nil
|
||||
}
|
||||
return res.Int64, nil
|
||||
}
|
||||
|
||||
func (s *DB) RemoteSequences(folder string) (map[protocol.DeviceID]int64, error) {
|
||||
type row struct {
|
||||
Device string
|
||||
Seq int64
|
||||
}
|
||||
|
||||
it, errFn := iterStructs[row](s.stmt(`
|
||||
SELECT d.device_id AS device, i.sequence AS seq FROM indexids i
|
||||
INNER JOIN folders o ON o.idx = i.folder_idx
|
||||
INNER JOIN devices d ON d.idx = i.device_idx
|
||||
WHERE o.folder_id = ? AND i.device_idx != {{.LocalDeviceIdx}}
|
||||
`).Queryx(folder))
|
||||
|
||||
res := make(map[protocol.DeviceID]int64)
|
||||
for row, err := range itererr.Zip(it, errFn) {
|
||||
if err != nil {
|
||||
return nil, wrap(err)
|
||||
}
|
||||
dev, err := protocol.DeviceIDFromString(row.Device)
|
||||
if err != nil {
|
||||
return nil, wrap(err, "device ID")
|
||||
}
|
||||
res[dev] = row.Seq
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func indexIDFromHex(s string) (protocol.IndexID, error) {
|
||||
bs, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("indexIDFromHex: %q: %w", s, err)
|
||||
}
|
||||
var id protocol.IndexID
|
||||
if err := id.Unmarshal(bs); err != nil {
|
||||
return 0, fmt.Errorf("indexIDFromHex: %q: %w", s, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func indexIDToHex(i protocol.IndexID) string {
|
||||
bs, _ := i.Marshal()
|
||||
return hex.EncodeToString(bs)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/syncthing/syncthing/lib/protocol"
|
||||
)
|
||||
|
||||
func TestIndexIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, err := OpenTemp()
|
||||
if err != nil {
|
||||
t.Fatal()
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("LocalDeviceID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
localID, err := db.GetIndexID("foo", protocol.LocalDeviceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if localID == 0 {
|
||||
t.Fatal("should have been generated")
|
||||
}
|
||||
|
||||
again, err := db.GetIndexID("foo", protocol.LocalDeviceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again != localID {
|
||||
t.Fatal("should get same again")
|
||||
}
|
||||
|
||||
other, err := db.GetIndexID("bar", protocol.LocalDeviceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other == localID {
|
||||
t.Fatal("should not get same for other folder")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("OtherDeviceID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
localID, err := db.GetIndexID("foo", protocol.DeviceID{42})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if localID != 0 {
|
||||
t.Fatal("should have been zero")
|
||||
}
|
||||
|
||||
newID := protocol.NewIndexID()
|
||||
if err := db.SetIndexID("foo", protocol.DeviceID{42}, newID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
again, err := db.GetIndexID("foo", protocol.DeviceID{42})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again != newID {
|
||||
t.Log(again, newID)
|
||||
t.Fatal("should get the ID we set")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"iter"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/syncthing/syncthing/internal/db"
|
||||
)
|
||||
|
||||
func (s *DB) GetKV(key string) ([]byte, error) {
|
||||
var val []byte
|
||||
if err := s.stmt(`
|
||||
SELECT value FROM kv
|
||||
WHERE key = ?
|
||||
`).Get(&val, key); err != nil {
|
||||
return nil, wrap(err)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (s *DB) PutKV(key string, val []byte) error {
|
||||
s.updateLock.Lock()
|
||||
defer s.updateLock.Unlock()
|
||||
_, err := s.stmt(`
|
||||
INSERT OR REPLACE INTO kv (key, value)
|
||||
VALUES (?, ?)
|
||||
`).Exec(key, val)
|
||||
return wrap(err)
|
||||
}
|
||||
|
||||
func (s *DB) DeleteKV(key string) error {
|
||||
s.updateLock.Lock()
|
||||
defer s.updateLock.Unlock()
|
||||
_, err := s.stmt(`
|
||||
DELETE FROM kv WHERE key = ?
|
||||
`).Exec(key)
|
||||
return wrap(err)
|
||||
}
|
||||
|
||||
func (s *DB) PrefixKV(prefix string) (iter.Seq[db.KeyValue], func() error) {
|
||||
var rows *sqlx.Rows
|
||||
var err error
|
||||
if prefix == "" {
|
||||
rows, err = s.stmt(`SELECT key, value FROM kv`).Queryx()
|
||||
} else {
|
||||
end := prefixEnd(prefix)
|
||||
rows, err = s.stmt(`
|
||||
SELECT key, value FROM kv
|
||||
WHERE key >= ? AND key < ?
|
||||
`).Queryx(prefix, end)
|
||||
}
|
||||
if err != nil {
|
||||
return func(_ func(db.KeyValue) bool) {}, func() error { return err }
|
||||
}
|
||||
|
||||
return func(yield func(db.KeyValue) bool) {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var val []byte
|
||||
if err = rows.Scan(&key, &val); err != nil {
|
||||
return
|
||||
}
|
||||
if !yield(db.KeyValue{Key: key, Value: val}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
err = rows.Err()
|
||||
}, func() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
|
||||
"github.com/syncthing/syncthing/internal/db"
|
||||
"github.com/syncthing/syncthing/internal/itererr"
|
||||
"github.com/syncthing/syncthing/lib/osutil"
|
||||
"github.com/syncthing/syncthing/lib/protocol"
|
||||
)
|
||||
|
||||
func (s *DB) GetDeviceFile(folder string, device protocol.DeviceID, file string) (protocol.FileInfo, bool, error) {
|
||||
file = osutil.NormalizedFilename(file)
|
||||
|
||||
var ind indirectFI
|
||||
err := s.stmt(`
|
||||
SELECT fi.fiprotobuf, bl.blprotobuf FROM fileinfos fi
|
||||
INNER JOIN files f on fi.sequence = f.sequence
|
||||
LEFT JOIN blocklists bl ON bl.blocklist_hash = f.blocklist_hash
|
||||
INNER JOIN devices d ON f.device_idx = d.idx
|
||||
INNER JOIN folders o ON f.folder_idx = o.idx
|
||||
WHERE o.folder_id = ? AND d.device_id = ? AND f.name = ?
|
||||
`).Get(&ind, folder, device.String(), file)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return protocol.FileInfo{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return protocol.FileInfo{}, false, wrap(err)
|
||||
}
|
||||
fi, err := ind.FileInfo()
|
||||
if err != nil {
|
||||
return protocol.FileInfo{}, false, wrap(err, "indirect")
|
||||
}
|
||||
return fi, true, nil
|
||||
}
|
||||
|
||||
func (s *DB) AllLocalFiles(folder string, device protocol.DeviceID) (iter.Seq[protocol.FileInfo], func() error) {
|
||||
it, errFn := iterStructs[indirectFI](s.stmt(`
|
||||
SELECT fi.fiprotobuf, bl.blprotobuf FROM fileinfos fi
|
||||
INNER JOIN files f on fi.sequence = f.sequence
|
||||
LEFT JOIN blocklists bl ON bl.blocklist_hash = f.blocklist_hash
|
||||
INNER JOIN folders o ON o.idx = f.folder_idx
|
||||
INNER JOIN devices d ON d.idx = f.device_idx
|
||||
WHERE o.folder_id = ? AND d.device_id = ?
|
||||
`).Queryx(folder, device.String()))
|
||||
return itererr.Map(it, errFn, indirectFI.FileInfo)
|
||||
}
|
||||
|
||||
func (s *DB) AllLocalFilesBySequence(folder string, device protocol.DeviceID, startSeq int64, limit int) (iter.Seq[protocol.FileInfo], func() error) {
|
||||
var limitStr string
|
||||
if limit > 0 {
|
||||
limitStr = fmt.Sprintf(" LIMIT %d", limit)
|
||||
}
|
||||
it, errFn := iterStructs[indirectFI](s.stmt(`
|
||||
SELECT fi.fiprotobuf, bl.blprotobuf FROM fileinfos fi
|
||||
INNER JOIN files f on fi.sequence = f.sequence
|
||||
LEFT JOIN blocklists bl ON bl.blocklist_hash = f.blocklist_hash
|
||||
INNER JOIN folders o ON o.idx = f.folder_idx
|
||||
INNER JOIN devices d ON d.idx = f.device_idx
|
||||
WHERE o.folder_id = ? AND d.device_id = ? AND f.sequence >= ?
|
||||
ORDER BY f.sequence`+limitStr).Queryx(
|
||||
folder, device.String(), startSeq))
|
||||
return itererr.Map(it, errFn, indirectFI.FileInfo)
|
||||
}
|
||||
|
||||
func (s *DB) AllLocalFilesWithPrefix(folder string, device protocol.DeviceID, prefix string) (iter.Seq[protocol.FileInfo], func() error) {
|
||||
if prefix == "" {
|
||||
return s.AllLocalFiles(folder, device)
|
||||
}
|
||||
|
||||
prefix = osutil.NormalizedFilename(prefix)
|
||||
end := prefixEnd(prefix)
|
||||
|
||||
it, errFn := iterStructs[indirectFI](s.sql.Queryx(`
|
||||
SELECT fi.fiprotobuf, bl.blprotobuf FROM fileinfos fi
|
||||
INNER JOIN files f on fi.sequence = f.sequence
|
||||
LEFT JOIN blocklists bl ON bl.blocklist_hash = f.blocklist_hash
|
||||
INNER JOIN folders o ON o.idx = f.folder_idx
|
||||
INNER JOIN devices d ON d.idx = f.device_idx
|
||||
WHERE o.folder_id = ? AND d.device_id = ? AND f.name >= ? AND f.name < ?
|
||||
`, folder, device.String(), prefix, end))
|
||||
return itererr.Map(it, errFn, indirectFI.FileInfo)
|
||||
}
|
||||
|
||||
func (s *DB) AllLocalFilesWithBlocksHash(folder string, h []byte) (iter.Seq[db.FileMetadata], func() error) {
|
||||
return iterStructs[db.FileMetadata](s.stmt(`
|
||||
SELECT f.sequence, f.name, f.type, f.modified as modnanos, f.size, f.deleted, f.invalid, f.local_flags as localflags FROM files f
|
||||
INNER JOIN folders o ON o.idx = f.folder_idx
|
||||
WHERE o.folder_id = ? AND f.device_idx = {{.LocalDeviceIdx}} AND f.blocklist_hash = ?
|
||||
`).Queryx(folder, h))
|
||||
}
|
||||
|
||||
func (s *DB) AllLocalFilesWithBlocksHashAnyFolder(h []byte) (iter.Seq2[string, db.FileMetadata], func() error) {
|
||||
type row struct {
|
||||
FolderID string `db:"folder_id"`
|
||||
db.FileMetadata
|
||||
}
|
||||
it, errFn := iterStructs[row](s.stmt(`
|
||||
SELECT o.folder_id, f.sequence, f.name, f.type, f.modified as modnanos, f.size, f.deleted, f.invalid, f.local_flags as localflags FROM files f
|
||||
INNER JOIN folders o ON o.idx = f.folder_idx
|
||||
WHERE f.device_idx = {{.LocalDeviceIdx}} AND f.blocklist_hash = ?
|
||||
`).Queryx(h))
|
||||
return itererr.Map2(it, errFn, func(r row) (string, db.FileMetadata, error) {
|
||||
return r.FolderID, r.FileMetadata, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *DB) AllLocalBlocksWithHash(hash []byte) (iter.Seq[db.BlockMapEntry], func() error) {
|
||||
// We involve the files table in this select because deletion of blocks
|
||||
// & blocklists is deferred (garbage collected) while the files list is
|
||||
// not. This filters out blocks that are in fact deleted.
|
||||
return iterStructs[db.BlockMapEntry](s.stmt(`
|
||||
SELECT f.blocklist_hash as blocklisthash, b.idx as blockindex, b.offset, b.size FROM files f
|
||||
LEFT JOIN blocks b ON f.blocklist_hash = b.blocklist_hash
|
||||
WHERE f.device_idx = {{.LocalDeviceIdx}} AND b.hash = ?
|
||||
`).Queryx(hash))
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/syncthing/syncthing/internal/db"
|
||||
"github.com/syncthing/syncthing/internal/itererr"
|
||||
"github.com/syncthing/syncthing/lib/protocol"
|
||||
)
|
||||
|
||||
func TestBlocks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, err := OpenTemp()
|
||||
if err != nil {
|
||||
t.Fatal()
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
files := []protocol.FileInfo{
|
||||
{
|
||||
Name: "file1",
|
||||
Blocks: []protocol.BlockInfo{
|
||||
{Hash: []byte{1, 2, 3}, Offset: 0, Size: 42},
|
||||
{Hash: []byte{2, 3, 4}, Offset: 42, Size: 42},
|
||||
{Hash: []byte{3, 4, 5}, Offset: 84, Size: 42},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "file2",
|
||||
Blocks: []protocol.BlockInfo{
|
||||
{Hash: []byte{2, 3, 4}, Offset: 0, Size: 42},
|
||||
{Hash: []byte{3, 4, 5}, Offset: 42, Size: 42},
|
||||
{Hash: []byte{4, 5, 6}, Offset: 84, Size: 42},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := db.Update("test", protocol.LocalDeviceID, files); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Search for blocks
|
||||
|
||||
vals, err := itererr.Collect(db.AllLocalBlocksWithHash([]byte{1, 2, 3}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vals) != 1 {
|
||||
t.Log(vals)
|
||||
t.Fatal("expected one hit")
|
||||
} else if vals[0].BlockIndex != 0 || vals[0].Offset != 0 || vals[0].Size != 42 {
|
||||
t.Log(vals[0])
|
||||
t.Fatal("bad entry")
|
||||
}
|
||||
|
||||
// Get FileInfos for those blocks
|
||||
|
||||
found := 0
|
||||
it, errFn := db.AllLocalFilesWithBlocksHashAnyFolder(vals[0].BlocklistHash)
|
||||
for folder, fileInfo := range it {
|
||||
if folder != folderID {
|
||||
t.Fatal("should be same folder")
|
||||
}
|
||||
if fileInfo.Name != "file1" {
|
||||
t.Fatal("should be file1")
|
||||
}
|
||||
found++
|
||||
}
|
||||
if err := errFn(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if found != 1 {
|
||||
t.Fatal("should find one file")
|
||||
}
|
||||
|
||||
// Get the other blocks
|
||||
|
||||
vals, err = itererr.Collect(db.AllLocalBlocksWithHash([]byte{3, 4, 5}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vals) != 2 {
|
||||
t.Log(vals)
|
||||
t.Fatal("expected two hits")
|
||||
}
|
||||
// if vals[0].Index != 2 || vals[0].Offset != 84 || vals[0].Size != 42 {
|
||||
// t.Log(vals[0])
|
||||
// t.Fatal("bad entry 1")
|
||||
// }
|
||||
// if vals[1].Index != 1 || vals[1].Offset != 42 || vals[1].Size != 42 {
|
||||
// t.Log(vals[1])
|
||||
// t.Fatal("bad entry 2")
|
||||
// }
|
||||
}
|
||||
|
||||
func TestBlocksDeleted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sdb, err := OpenTemp()
|
||||
if err != nil {
|
||||
t.Fatal()
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := sdb.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
// Insert a file
|
||||
file := genFile("foo", 1, 0)
|
||||
if err := sdb.Update(folderID, protocol.LocalDeviceID, []protocol.FileInfo{file}); err != nil {
|
||||
t.Fatal()
|
||||
}
|
||||
|
||||
// We should find one entry for the block hash
|
||||
search := file.Blocks[0].Hash
|
||||
es := mustCollect[db.BlockMapEntry](t)(sdb.AllLocalBlocksWithHash(search))
|
||||
if len(es) != 1 {
|
||||
t.Fatal("expected one hit")
|
||||
}
|
||||
|
||||
// Update the file with a new block hash
|
||||
file.Blocks = genBlocks("foo", 42, 1)
|
||||
if err := sdb.Update(folderID, protocol.LocalDeviceID, []protocol.FileInfo{file}); err != nil {
|
||||
t.Fatal()
|
||||
}
|
||||
|
||||
// Searching for the old hash should yield no hits
|
||||
if hits := mustCollect[db.BlockMapEntry](t)(sdb.AllLocalBlocksWithHash(search)); len(hits) != 0 {
|
||||
t.Log(hits)
|
||||
t.Error("expected no hits")
|
||||
}
|
||||
|
||||
// Searching for the new hash should yield one hits
|
||||
if hits := mustCollect[db.BlockMapEntry](t)(sdb.AllLocalBlocksWithHash(file.Blocks[0].Hash)); len(hits) != 1 {
|
||||
t.Log(hits)
|
||||
t.Error("expected one hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSequence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sdb, err := OpenTemp()
|
||||
if err != nil {
|
||||
t.Fatal()
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := sdb.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
// Insert a local file
|
||||
file := genFile("foo", 1, 0)
|
||||
if err := sdb.Update(folderID, protocol.LocalDeviceID, []protocol.FileInfo{file}); err != nil {
|
||||
t.Fatal()
|
||||
}
|
||||
|
||||
// Insert several remote files
|
||||
file = genFile("foo1", 1, 42)
|
||||
if err := sdb.Update(folderID, protocol.DeviceID{42}, []protocol.FileInfo{file}); err != nil {
|
||||
t.Fatal()
|
||||
}
|
||||
if err := sdb.Update(folderID, protocol.DeviceID{43}, []protocol.FileInfo{file}); err != nil {
|
||||
t.Fatal()
|
||||
}
|
||||
file = genFile("foo2", 1, 43)
|
||||
if err := sdb.Update(folderID, protocol.DeviceID{43}, []protocol.FileInfo{file}); err != nil {
|
||||
t.Fatal()
|
||||
}
|
||||
if err := sdb.Update(folderID, protocol.DeviceID{44}, []protocol.FileInfo{file}); err != nil {
|
||||
t.Fatal()
|
||||
}
|
||||
file = genFile("foo3", 1, 44)
|
||||
if err := sdb.Update(folderID, protocol.DeviceID{44}, []protocol.FileInfo{file}); err != nil {
|
||||
t.Fatal()
|
||||
}
|
||||
|
||||
// Verify remote sequences
|
||||
seqs, err := sdb.RemoteSequences(folderID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(seqs) != 3 || seqs[protocol.DeviceID{42}] != 42 ||
|
||||
seqs[protocol.DeviceID{43}] != 43 ||
|
||||
seqs[protocol.DeviceID{44}] != 44 {
|
||||
t.Log(seqs)
|
||||
t.Error("bad seqs")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *DB) GetMtime(folder, name string) (ondisk, virtual time.Time) {
|
||||
var res struct {
|
||||
Ondisk int64
|
||||
Virtual int64
|
||||
}
|
||||
if err := s.stmt(`
|
||||
SELECT m.ondisk, m.virtual FROM mtimes m
|
||||
INNER JOIN folders o ON o.idx = m.folder_idx
|
||||
WHERE o.folder_id = ? AND m.name = ?
|
||||
`).Get(&res, folder, name); err != nil {
|
||||
return time.Time{}, time.Time{}
|
||||
}
|
||||
return time.Unix(0, res.Ondisk), time.Unix(0, res.Virtual)
|
||||
}
|
||||
|
||||
func (s *DB) PutMtime(folder, name string, ondisk, virtual time.Time) error {
|
||||
s.updateLock.Lock()
|
||||
defer s.updateLock.Unlock()
|
||||
folderIdx, err := s.folderIdxLocked(folder)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
_, err = s.stmt(`
|
||||
INSERT OR REPLACE INTO mtimes (folder_idx, name, ondisk, virtual)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).Exec(folderIdx, name, ondisk.UnixNano(), virtual.UnixNano())
|
||||
return wrap(err)
|
||||
}
|
||||
|
||||
func (s *DB) DeleteMtime(folder, name string) error {
|
||||
s.updateLock.Lock()
|
||||
defer s.updateLock.Unlock()
|
||||
folderIdx, err := s.folderIdxLocked(folder)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
_, err = s.stmt(`
|
||||
DELETE FROM mtimes
|
||||
WHERE folder_idx = ? AND name = ?
|
||||
`).Exec(folderIdx, name)
|
||||
return wrap(err)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMtimePairs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, err := OpenTemp()
|
||||
if err != nil {
|
||||
t.Fatal()
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t0 := time.Now().Truncate(time.Second)
|
||||
t1 := t0.Add(1234567890)
|
||||
|
||||
// Set a pair
|
||||
if err := db.PutMtime("foo", "bar", t0, t1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Check it
|
||||
gt0, gt1 := db.GetMtime("foo", "bar")
|
||||
if !gt0.Equal(t0) || !gt1.Equal(t1) {
|
||||
t.Log(t0, gt0)
|
||||
t.Log(t1, gt1)
|
||||
t.Log("bad times")
|
||||
}
|
||||
|
||||
// Delete it
|
||||
if err := db.DeleteMtime("foo", "bar"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Check it
|
||||
gt0, gt1 = db.GetMtime("foo", "bar")
|
||||
if !gt0.IsZero() || !gt1.IsZero() {
|
||||
t.Log(gt0, gt1)
|
||||
t.Log("bad times")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/syncthing/syncthing/lib/build"
|
||||
"github.com/syncthing/syncthing/lib/protocol"
|
||||
)
|
||||
|
||||
const maxDBConns = 128
|
||||
|
||||
func Open(path string) (*DB, error) {
|
||||
// Open the database with options to enable foreign keys and recursive
|
||||
// triggers (needed for the delete+insert triggers on row replace).
|
||||
sqlDB, err := sqlx.Open(dbDriver, "file:"+path+"?"+commonOptions)
|
||||
if err != nil {
|
||||
return nil, wrap(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(maxDBConns)
|
||||
if _, err := sqlDB.Exec(`PRAGMA journal_mode = WAL`); err != nil {
|
||||
return nil, wrap(err, "PRAGMA journal_mode")
|
||||
}
|
||||
if _, err := sqlDB.Exec(`PRAGMA optimize = 0x10002`); err != nil {
|
||||
// https://www.sqlite.org/pragma.html#pragma_optimize
|
||||
return nil, wrap(err, "PRAGMA optimize")
|
||||
}
|
||||
if _, err := sqlDB.Exec(`PRAGMA journal_size_limit = 6144000`); err != nil {
|
||||
// https://www.powersync.com/blog/sqlite-optimizations-for-ultra-high-performance
|
||||
return nil, wrap(err, "PRAGMA journal_size_limit")
|
||||
}
|
||||
return openCommon(sqlDB)
|
||||
}
|
||||
|
||||
// Open the database with options suitable for the migration inserts. This
|
||||
// is not a safe mode of operation for normal processing, use only for bulk
|
||||
// inserts with a close afterwards.
|
||||
func OpenForMigration(path string) (*DB, error) {
|
||||
sqlDB, err := sqlx.Open(dbDriver, "file:"+path+"?"+commonOptions)
|
||||
if err != nil {
|
||||
return nil, wrap(err, "open")
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if _, err := sqlDB.Exec(`PRAGMA foreign_keys = 0`); err != nil {
|
||||
return nil, wrap(err, "PRAGMA foreign_keys")
|
||||
}
|
||||
if _, err := sqlDB.Exec(`PRAGMA journal_mode = OFF`); err != nil {
|
||||
return nil, wrap(err, "PRAGMA journal_mode")
|
||||
}
|
||||
if _, err := sqlDB.Exec(`PRAGMA synchronous = 0`); err != nil {
|
||||
return nil, wrap(err, "PRAGMA synchronous")
|
||||
}
|
||||
return openCommon(sqlDB)
|
||||
}
|
||||
|
||||
func OpenTemp() (*DB, error) {
|
||||
// SQLite has a memory mode, but it works differently with concurrency
|
||||
// compared to what we need with the WAL mode. So, no memory databases
|
||||
// for now.
|
||||
dir, err := os.MkdirTemp("", "syncthing-db")
|
||||
if err != nil {
|
||||
return nil, wrap(err)
|
||||
}
|
||||
path := filepath.Join(dir, "db")
|
||||
l.Debugln("Test DB in", path)
|
||||
return Open(path)
|
||||
}
|
||||
|
||||
func openCommon(sqlDB *sqlx.DB) (*DB, error) {
|
||||
if _, err := sqlDB.Exec(`PRAGMA auto_vacuum = INCREMENTAL`); err != nil {
|
||||
return nil, wrap(err, "PRAGMA auto_vacuum")
|
||||
}
|
||||
if _, err := sqlDB.Exec(`PRAGMA default_temp_store = MEMORY`); err != nil {
|
||||
return nil, wrap(err, "PRAGMA default_temp_store")
|
||||
}
|
||||
if _, err := sqlDB.Exec(`PRAGMA temp_store = MEMORY`); err != nil {
|
||||
return nil, wrap(err, "PRAGMA temp_store")
|
||||
}
|
||||
|
||||
db := &DB{
|
||||
sql: sqlDB,
|
||||
statements: make(map[string]*sqlx.Stmt),
|
||||
}
|
||||
|
||||
if err := db.runScripts("sql/schema/*"); err != nil {
|
||||
return nil, wrap(err)
|
||||
}
|
||||
|
||||
ver, _ := db.getAppliedSchemaVersion()
|
||||
if ver.SchemaVersion > 0 {
|
||||
filter := func(scr string) bool {
|
||||
scr = filepath.Base(scr)
|
||||
nstr, _, ok := strings.Cut(scr, "-")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
n, err := strconv.ParseInt(nstr, 10, 32)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return int(n) > ver.SchemaVersion
|
||||
}
|
||||
if err := db.runScripts("sql/migrations/*", filter); err != nil {
|
||||
return nil, wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Touch device IDs that should always exist and have a low index
|
||||
// numbers, and will never change
|
||||
db.localDeviceIdx, _ = db.deviceIdxLocked(protocol.LocalDeviceID)
|
||||
|
||||
// Set the current schema version, if not already set
|
||||
if err := db.setAppliedSchemaVersion(currentSchemaVersion); err != nil {
|
||||
return nil, wrap(err)
|
||||
}
|
||||
|
||||
db.tplInput = map[string]any{
|
||||
"FlagLocalUnsupported": protocol.FlagLocalUnsupported,
|
||||
"FlagLocalIgnored": protocol.FlagLocalIgnored,
|
||||
"FlagLocalMustRescan": protocol.FlagLocalMustRescan,
|
||||
"FlagLocalReceiveOnly": protocol.FlagLocalReceiveOnly,
|
||||
"FlagLocalGlobal": protocol.FlagLocalGlobal,
|
||||
"FlagLocalNeeded": protocol.FlagLocalNeeded,
|
||||
"LocalDeviceIdx": db.localDeviceIdx,
|
||||
"SyncthingVersion": build.LongVersion,
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
var tplFuncs = template.FuncMap{
|
||||
"or": func(vs ...int) int {
|
||||
v := vs[0]
|
||||
for _, ov := range vs[1:] {
|
||||
v |= ov
|
||||
}
|
||||
return v
|
||||
},
|
||||
}
|
||||
|
||||
// stmt returns a prepared statement for the given SQL string, after
|
||||
// applying local template expansions. The statement is cached.
|
||||
func (s *DB) stmt(tpl string) stmt {
|
||||
tpl = strings.TrimSpace(tpl)
|
||||
|
||||
// Fast concurrent lookup of cached statement
|
||||
s.statementsMut.RLock()
|
||||
stmt, ok := s.statements[tpl]
|
||||
s.statementsMut.RUnlock()
|
||||
if ok {
|
||||
return stmt
|
||||
}
|
||||
|
||||
// On miss, take the full lock, check again
|
||||
s.statementsMut.Lock()
|
||||
defer s.statementsMut.Unlock()
|
||||
stmt, ok = s.statements[tpl]
|
||||
if ok {
|
||||
return stmt
|
||||
}
|
||||
|
||||
// Apply template expansions
|
||||
var sb strings.Builder
|
||||
compTpl := template.Must(template.New("tpl").Funcs(tplFuncs).Parse(tpl))
|
||||
if err := compTpl.Execute(&sb, s.tplInput); err != nil {
|
||||
panic("bug: bad template: " + err.Error())
|
||||
}
|
||||
|
||||
// Prepare and cache
|
||||
stmt, err := s.sql.Preparex(sb.String())
|
||||
if err != nil {
|
||||
return failedStmt{err}
|
||||
}
|
||||
s.statements[tpl] = stmt
|
||||
return stmt
|
||||
}
|
||||
|
||||
type stmt interface {
|
||||
Exec(args ...any) (sql.Result, error)
|
||||
Get(dest any, args ...any) error
|
||||
Queryx(args ...any) (*sqlx.Rows, error)
|
||||
Select(dest any, args ...any) error
|
||||
}
|
||||
|
||||
type failedStmt struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (f failedStmt) Exec(_ ...any) (sql.Result, error) { return nil, f.err }
|
||||
func (f failedStmt) Get(_ any, _ ...any) error { return f.err }
|
||||
func (f failedStmt) Queryx(_ ...any) (*sqlx.Rows, error) { return nil, f.err }
|
||||
func (f failedStmt) Select(_ any, _ ...any) error { return f.err }
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
//go:build cgo
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
_ "github.com/mattn/go-sqlite3" // register sqlite3 database driver
|
||||
)
|
||||
|
||||
const (
|
||||
dbDriver = "sqlite3"
|
||||
commonOptions = "_fk=true&_rt=true&_cache_size=-65536&_sync=1&_txlock=immediate"
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
//go:build !cgo && !wazero
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"github.com/syncthing/syncthing/lib/build"
|
||||
_ "modernc.org/sqlite" // register sqlite database driver
|
||||
)
|
||||
|
||||
const (
|
||||
dbDriver = "sqlite"
|
||||
commonOptions = "_pragma=foreign_keys(1)&_pragma=recursive_triggers(1)&_pragma=cache_size(-65536)&_pragma=synchronous(1)"
|
||||
)
|
||||
|
||||
func init() {
|
||||
build.AddTag("modernc-sqlite")
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
package sqlite
|
||||
|
||||
import "github.com/jmoiron/sqlx"
|
||||
|
||||
type txPreparedStmts struct {
|
||||
*sqlx.Tx
|
||||
stmts map[string]*sqlx.Stmt
|
||||
}
|
||||
|
||||
func (p *txPreparedStmts) Preparex(query string) (*sqlx.Stmt, error) {
|
||||
if p.stmts == nil {
|
||||
p.stmts = make(map[string]*sqlx.Stmt)
|
||||
}
|
||||
stmt, ok := p.stmts[query]
|
||||
if ok {
|
||||
return stmt, nil
|
||||
}
|
||||
stmt, err := p.Tx.Preparex(query)
|
||||
if err != nil {
|
||||
return nil, wrap(err)
|
||||
}
|
||||
p.stmts[query] = stmt
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
func (p *txPreparedStmts) Commit() error {
|
||||
for _, s := range p.stmts {
|
||||
s.Close()
|
||||
}
|
||||
return p.Tx.Commit()
|
||||
}
|
||||
|
||||
func (p *txPreparedStmts) Rollback() error {
|
||||
for _, s := range p.stmts {
|
||||
s.Close()
|
||||
}
|
||||
return p.Tx.Rollback()
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/syncthing/syncthing/lib/build"
|
||||
)
|
||||
|
||||
const currentSchemaVersion = 1
|
||||
|
||||
//go:embed sql/**
|
||||
var embedded embed.FS
|
||||
|
||||
func (s *DB) runScripts(glob string, filter ...func(s string) bool) error {
|
||||
scripts, err := fs.Glob(embedded, glob)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
|
||||
tx, err := s.sql.Begin()
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
nextScript:
|
||||
for _, scr := range scripts {
|
||||
for _, fn := range filter {
|
||||
if !fn(scr) {
|
||||
l.Debugln("Skipping script", scr)
|
||||
continue nextScript
|
||||
}
|
||||
}
|
||||
l.Debugln("Executing script", scr)
|
||||
bs, err := fs.ReadFile(embedded, scr)
|
||||
if err != nil {
|
||||
return wrap(err, scr)
|
||||
}
|
||||
// SQLite requires one statement per exec, so we split the init
|
||||
// files on lines containing only a semicolon and execute them
|
||||
// separately. We require it on a separate line because there are
|
||||
// also statement-internal semicolons in the triggers.
|
||||
for _, stmt := range strings.Split(string(bs), "\n;") {
|
||||
if _, err := tx.Exec(stmt); err != nil {
|
||||
return wrap(err, stmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return wrap(tx.Commit())
|
||||
}
|
||||
|
||||
type schemaVersion struct {
|
||||
SchemaVersion int
|
||||
AppliedAt int64
|
||||
SyncthingVersion string
|
||||
}
|
||||
|
||||
func (s *schemaVersion) AppliedTime() time.Time {
|
||||
return time.Unix(0, s.AppliedAt)
|
||||
}
|
||||
|
||||
func (s *DB) setAppliedSchemaVersion(ver int) error {
|
||||
_, err := s.stmt(`
|
||||
INSERT OR IGNORE INTO schemamigrations (schema_version, applied_at, syncthing_version)
|
||||
VALUES (?, ?, ?)
|
||||
`).Exec(ver, time.Now().UnixNano(), build.LongVersion)
|
||||
return wrap(err)
|
||||
}
|
||||
|
||||
func (s *DB) getAppliedSchemaVersion() (schemaVersion, error) {
|
||||
var v schemaVersion
|
||||
err := s.stmt(`
|
||||
SELECT schema_version as schemaversion, applied_at as appliedat, syncthing_version as syncthingversion FROM schemamigrations
|
||||
ORDER BY schema_version DESC
|
||||
LIMIT 1
|
||||
`).Get(&v)
|
||||
return v, wrap(err)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/syncthing/syncthing/internal/db"
|
||||
)
|
||||
|
||||
const (
|
||||
internalMetaPrefix = "dbsvc"
|
||||
lastMaintKey = "lastMaint"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
sdb *DB
|
||||
maintenanceInterval time.Duration
|
||||
internalMeta *db.Typed
|
||||
}
|
||||
|
||||
func newService(sdb *DB, maintenanceInterval time.Duration) *Service {
|
||||
return &Service{
|
||||
sdb: sdb,
|
||||
maintenanceInterval: maintenanceInterval,
|
||||
internalMeta: db.NewTyped(sdb, internalMetaPrefix),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Serve(ctx context.Context) error {
|
||||
// Run periodic maintenance
|
||||
|
||||
// Figure out when we last ran maintenance and schedule accordingly. If
|
||||
// it was never, do it now.
|
||||
lastMaint, _, _ := s.internalMeta.Time(lastMaintKey)
|
||||
nextMaint := lastMaint.Add(s.maintenanceInterval)
|
||||
wait := time.Until(nextMaint)
|
||||
if wait < 0 {
|
||||
wait = time.Minute
|
||||
}
|
||||
l.Debugln("Next periodic run in", wait)
|
||||
|
||||
timer := time.NewTimer(wait)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
if err := s.periodic(ctx); err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
|
||||
timer.Reset(s.maintenanceInterval)
|
||||
l.Debugln("Next periodic run in", s.maintenanceInterval)
|
||||
_ = s.internalMeta.PutTime(lastMaintKey, time.Now())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) periodic(ctx context.Context) error {
|
||||
t0 := time.Now()
|
||||
l.Debugln("Periodic start")
|
||||
|
||||
s.sdb.updateLock.Lock()
|
||||
defer s.sdb.updateLock.Unlock()
|
||||
|
||||
t1 := time.Now()
|
||||
defer func() { l.Debugln("Periodic done in", time.Since(t1), "+", t1.Sub(t0)) }()
|
||||
|
||||
if err := s.garbageCollectBlocklistsAndBlocksLocked(ctx); err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
|
||||
_, _ = s.sdb.sql.ExecContext(ctx, `ANALYZE`)
|
||||
_, _ = s.sdb.sql.ExecContext(ctx, `PRAGMA optimize`)
|
||||
_, _ = s.sdb.sql.ExecContext(ctx, `PRAGMA incremental_vacuum`)
|
||||
_, _ = s.sdb.sql.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE)`)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) garbageCollectBlocklistsAndBlocksLocked(ctx context.Context) error {
|
||||
// Remove all blocklists not referred to by any files and, by extension,
|
||||
// any blocks not referred to by a blocklist. This is an expensive
|
||||
// operation when run normally, especially if there are a lot of blocks
|
||||
// to collect.
|
||||
//
|
||||
// We make this orders of magnitude faster by disabling foreign keys for
|
||||
// the transaction and doing the cleanup manually. This requires using
|
||||
// an explicit connection and disabling foreign keys before starting the
|
||||
// transaction. We make sure to clean up on the way out.
|
||||
|
||||
conn, err := s.sdb.sql.Connx(ctx)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if _, err := conn.ExecContext(ctx, `PRAGMA foreign_keys = 0`); err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
defer func() { //nolint:contextcheck
|
||||
_, _ = conn.ExecContext(context.Background(), `PRAGMA foreign_keys = 1`)
|
||||
}()
|
||||
|
||||
tx, err := conn.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
if res, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM blocklists
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM files WHERE files.blocklist_hash = blocklists.blocklist_hash
|
||||
)`); err != nil {
|
||||
return wrap(err, "delete blocklists")
|
||||
} else if shouldDebug() {
|
||||
rows, err := res.RowsAffected()
|
||||
l.Debugln("Blocklist GC:", rows, err)
|
||||
}
|
||||
|
||||
if res, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM blocks
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM blocklists WHERE blocklists.blocklist_hash = blocks.blocklist_hash
|
||||
)`); err != nil {
|
||||
return wrap(err, "delete blocks")
|
||||
} else if shouldDebug() {
|
||||
rows, err := res.RowsAffected()
|
||||
l.Debugln("Blocks GC:", rows, err)
|
||||
}
|
||||
|
||||
return wrap(tx.Commit())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,549 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/syncthing/syncthing/internal/gen/dbproto"
|
||||
"github.com/syncthing/syncthing/internal/itererr"
|
||||
"github.com/syncthing/syncthing/lib/osutil"
|
||||
"github.com/syncthing/syncthing/lib/protocol"
|
||||
"github.com/syncthing/syncthing/lib/sliceutil"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func (s *DB) Update(folder string, device protocol.DeviceID, fs []protocol.FileInfo) error {
|
||||
s.updateLock.Lock()
|
||||
defer s.updateLock.Unlock()
|
||||
|
||||
folderIdx, err := s.folderIdxLocked(folder)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
deviceIdx, err := s.deviceIdxLocked(device)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
|
||||
tx, err := s.sql.BeginTxx(context.Background(), nil)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
txp := &txPreparedStmts{Tx: tx}
|
||||
|
||||
//nolint:sqlclosecheck
|
||||
insertFileStmt, err := txp.Preparex(`
|
||||
INSERT OR REPLACE INTO files (folder_idx, device_idx, remote_sequence, name, type, modified, size, version, deleted, invalid, local_flags, blocklist_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING sequence
|
||||
`)
|
||||
if err != nil {
|
||||
return wrap(err, "prepare insert file")
|
||||
}
|
||||
|
||||
//nolint:sqlclosecheck
|
||||
insertFileInfoStmt, err := txp.Preparex(`
|
||||
INSERT INTO fileinfos (sequence, fiprotobuf)
|
||||
VALUES (?, ?)
|
||||
`)
|
||||
if err != nil {
|
||||
return wrap(err, "prepare insert fileinfo")
|
||||
}
|
||||
|
||||
//nolint:sqlclosecheck
|
||||
insertBlockListStmt, err := txp.Preparex(`
|
||||
INSERT OR IGNORE INTO blocklists (blocklist_hash, blprotobuf)
|
||||
VALUES (?, ?)
|
||||
`)
|
||||
if err != nil {
|
||||
return wrap(err, "prepare insert blocklist")
|
||||
}
|
||||
|
||||
var prevRemoteSeq int64
|
||||
for i, f := range fs {
|
||||
f.Name = osutil.NormalizedFilename(f.Name)
|
||||
|
||||
var blockshash *[]byte
|
||||
if len(f.Blocks) > 0 {
|
||||
f.BlocksHash = protocol.BlocksHash(f.Blocks)
|
||||
blockshash = &f.BlocksHash
|
||||
} else {
|
||||
f.BlocksHash = nil
|
||||
}
|
||||
|
||||
if f.Type == protocol.FileInfoTypeDirectory {
|
||||
f.Size = 128 // synthetic directory size
|
||||
}
|
||||
|
||||
// Insert the file.
|
||||
//
|
||||
// If it is a remote file, set remote_sequence otherwise leave it at
|
||||
// null. Returns the new local sequence.
|
||||
var remoteSeq *int64
|
||||
if device != protocol.LocalDeviceID {
|
||||
if i > 0 && f.Sequence == prevRemoteSeq {
|
||||
return fmt.Errorf("duplicate remote sequence number %d", prevRemoteSeq)
|
||||
}
|
||||
prevRemoteSeq = f.Sequence
|
||||
remoteSeq = &f.Sequence
|
||||
}
|
||||
var localSeq int64
|
||||
if err := insertFileStmt.Get(&localSeq, folderIdx, deviceIdx, remoteSeq, f.Name, f.Type, f.ModTime().UnixNano(), f.Size, f.Version.String(), f.IsDeleted(), f.IsInvalid(), f.LocalFlags, blockshash); err != nil {
|
||||
return wrap(err, "insert file")
|
||||
}
|
||||
|
||||
if len(f.Blocks) > 0 {
|
||||
// Indirect the block list
|
||||
blocks := sliceutil.Map(f.Blocks, protocol.BlockInfo.ToWire)
|
||||
bs, err := proto.Marshal(&dbproto.BlockList{Blocks: blocks})
|
||||
if err != nil {
|
||||
return wrap(err, "marshal blocklist")
|
||||
}
|
||||
if _, err := insertBlockListStmt.Exec(f.BlocksHash, bs); err != nil {
|
||||
return wrap(err, "insert blocklist")
|
||||
}
|
||||
|
||||
if device == protocol.LocalDeviceID {
|
||||
// Insert all blocks
|
||||
if err := s.insertBlocksLocked(txp, f.BlocksHash, f.Blocks); err != nil {
|
||||
return wrap(err, "insert blocks")
|
||||
}
|
||||
}
|
||||
|
||||
f.Blocks = nil
|
||||
}
|
||||
|
||||
// Insert the fileinfo
|
||||
if device == protocol.LocalDeviceID {
|
||||
f.Sequence = localSeq
|
||||
}
|
||||
bs, err := proto.Marshal(f.ToWire(true))
|
||||
if err != nil {
|
||||
return wrap(err, "marshal fileinfo")
|
||||
}
|
||||
if _, err := insertFileInfoStmt.Exec(localSeq, bs); err != nil {
|
||||
return wrap(err, "insert fileinfo")
|
||||
}
|
||||
|
||||
// Update global and need
|
||||
if err := s.recalcGlobalForFile(txp, folderIdx, f.Name); err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
return wrap(tx.Commit())
|
||||
}
|
||||
|
||||
func (s *DB) DropFolder(folder string) error {
|
||||
s.updateLock.Lock()
|
||||
defer s.updateLock.Unlock()
|
||||
_, err := s.stmt(`
|
||||
DELETE FROM folders
|
||||
WHERE folder_id = ?
|
||||
`).Exec(folder)
|
||||
return wrap(err)
|
||||
}
|
||||
|
||||
func (s *DB) DropDevice(device protocol.DeviceID) error {
|
||||
if device == protocol.LocalDeviceID {
|
||||
panic("bug: cannot drop local device")
|
||||
}
|
||||
|
||||
s.updateLock.Lock()
|
||||
defer s.updateLock.Unlock()
|
||||
|
||||
deviceIdx, err := s.deviceIdxLocked(device)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
|
||||
tx, err := s.sql.BeginTxx(context.Background(), nil)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
txp := &txPreparedStmts{Tx: tx}
|
||||
|
||||
// Find all folders where the device is involved
|
||||
var folderIdxs []int64
|
||||
if err := tx.Select(&folderIdxs, `
|
||||
SELECT folder_idx
|
||||
FROM counts
|
||||
WHERE device_idx = ? AND count > 0
|
||||
GROUP BY folder_idx
|
||||
`, deviceIdx); err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
|
||||
// Drop the device, which cascades to delete all files etc for it
|
||||
if _, err := tx.Exec(`DELETE FROM devices WHERE device_id = ?`, device.String()); err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
|
||||
// Recalc the globals for all affected folders
|
||||
for _, idx := range folderIdxs {
|
||||
if err := s.recalcGlobalForFolder(txp, idx); err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
return wrap(tx.Commit())
|
||||
}
|
||||
|
||||
func (s *DB) DropAllFiles(folder string, device protocol.DeviceID) error {
|
||||
s.updateLock.Lock()
|
||||
defer s.updateLock.Unlock()
|
||||
|
||||
// This is a two part operation, first dropping all the files and then
|
||||
// recalculating the global state for the entire folder.
|
||||
|
||||
folderIdx, err := s.folderIdxLocked(folder)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
deviceIdx, err := s.deviceIdxLocked(device)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
|
||||
tx, err := s.sql.BeginTxx(context.Background(), nil)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
txp := &txPreparedStmts{Tx: tx}
|
||||
|
||||
// Drop all the file entries
|
||||
|
||||
result, err := tx.Exec(`
|
||||
DELETE FROM files
|
||||
WHERE folder_idx = ? AND device_idx = ?
|
||||
`, folderIdx, deviceIdx)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
if n, err := result.RowsAffected(); err == nil && n == 0 {
|
||||
// The delete affected no rows, so we don't need to redo the entire
|
||||
// global/need calculation.
|
||||
return wrap(tx.Commit())
|
||||
}
|
||||
|
||||
// Recalc global for the entire folder
|
||||
|
||||
if err := s.recalcGlobalForFolder(txp, folderIdx); err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
return wrap(tx.Commit())
|
||||
}
|
||||
|
||||
func (s *DB) DropFilesNamed(folder string, device protocol.DeviceID, names []string) error {
|
||||
for i := range names {
|
||||
names[i] = osutil.NormalizedFilename(names[i])
|
||||
}
|
||||
|
||||
s.updateLock.Lock()
|
||||
defer s.updateLock.Unlock()
|
||||
|
||||
folderIdx, err := s.folderIdxLocked(folder)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
deviceIdx, err := s.deviceIdxLocked(device)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
|
||||
tx, err := s.sql.BeginTxx(context.Background(), nil)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
txp := &txPreparedStmts{Tx: tx}
|
||||
|
||||
// Drop the named files
|
||||
|
||||
query, args, err := sqlx.In(`
|
||||
DELETE FROM files
|
||||
WHERE folder_idx = ? AND device_idx = ? AND name IN (?)
|
||||
`, folderIdx, deviceIdx, names)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
|
||||
// Recalc globals for the named files
|
||||
|
||||
for _, name := range names {
|
||||
if err := s.recalcGlobalForFile(txp, folderIdx, name); err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
return wrap(tx.Commit())
|
||||
}
|
||||
|
||||
func (*DB) insertBlocksLocked(tx *txPreparedStmts, blocklistHash []byte, blocks []protocol.BlockInfo) error {
|
||||
if len(blocks) == 0 {
|
||||
return nil
|
||||
}
|
||||
bs := make([]map[string]any, len(blocks))
|
||||
for i, b := range blocks {
|
||||
bs[i] = map[string]any{
|
||||
"hash": b.Hash,
|
||||
"blocklist_hash": blocklistHash,
|
||||
"idx": i,
|
||||
"offset": b.Offset,
|
||||
"size": b.Size,
|
||||
}
|
||||
}
|
||||
_, err := tx.NamedExec(`
|
||||
INSERT OR IGNORE INTO blocks (hash, blocklist_hash, idx, offset, size)
|
||||
VALUES (:hash, :blocklist_hash, :idx, :offset, :size)
|
||||
`, bs)
|
||||
return wrap(err)
|
||||
}
|
||||
|
||||
func (s *DB) recalcGlobalForFolder(txp *txPreparedStmts, folderIdx int64) error {
|
||||
// Select files where there is no global, those are the ones we need to
|
||||
// recalculate.
|
||||
//nolint:sqlclosecheck
|
||||
namesStmt, err := txp.Preparex(`
|
||||
SELECT f.name FROM files f
|
||||
WHERE f.folder_idx = ? AND NOT EXISTS (
|
||||
SELECT 1 FROM files g
|
||||
WHERE g.folder_idx = ? AND g.name = f.name AND g.local_flags & ? != 0
|
||||
)
|
||||
GROUP BY name
|
||||
`)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
rows, err := namesStmt.Queryx(folderIdx, folderIdx, protocol.FlagLocalGlobal)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
if err := s.recalcGlobalForFile(txp, folderIdx, name); err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
}
|
||||
return wrap(rows.Err())
|
||||
}
|
||||
|
||||
func (s *DB) recalcGlobalForFile(txp *txPreparedStmts, folderIdx int64, file string) error {
|
||||
//nolint:sqlclosecheck
|
||||
selStmt, err := txp.Preparex(`
|
||||
SELECT name, folder_idx, device_idx, sequence, modified, version, deleted, invalid, local_flags FROM files
|
||||
WHERE folder_idx = ? AND name = ?
|
||||
`)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
es, err := itererr.Collect(iterStructs[fileRow](selStmt.Queryx(folderIdx, file)))
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
if len(es) == 0 {
|
||||
// shouldn't happen
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort the entries; the global entry is at the head of the list
|
||||
slices.SortFunc(es, fileRow.Compare)
|
||||
|
||||
// The global version is the first one in the list that is not invalid,
|
||||
// or just the first one in the list if all are invalid.
|
||||
var global fileRow
|
||||
globIdx := slices.IndexFunc(es, func(e fileRow) bool { return !e.Invalid })
|
||||
if globIdx < 0 {
|
||||
globIdx = 0
|
||||
}
|
||||
global = es[globIdx]
|
||||
|
||||
// We "have" the file if the position in the list of versions is at the
|
||||
// global version or better, or if the version is the same as the global
|
||||
// file (we might be further down the list due to invalid flags), or if
|
||||
// the global is deleted and we don't have it at all...
|
||||
localIdx := slices.IndexFunc(es, func(e fileRow) bool { return e.DeviceIdx == s.localDeviceIdx })
|
||||
hasLocal := localIdx >= 0 && localIdx <= globIdx || // have a better or equal version
|
||||
localIdx >= 0 && es[localIdx].Version.Equal(global.Version.Vector) || // have an equal version but invalid/ignored
|
||||
localIdx < 0 && global.Deleted // missing it, but the global is also deleted
|
||||
|
||||
// Set the global flag on the global entry. Set the need flag if the
|
||||
// local device needs this file, unless it's invalid.
|
||||
global.LocalFlags |= protocol.FlagLocalGlobal
|
||||
if hasLocal || global.Invalid {
|
||||
global.LocalFlags &= ^protocol.FlagLocalNeeded
|
||||
} else {
|
||||
global.LocalFlags |= protocol.FlagLocalNeeded
|
||||
}
|
||||
//nolint:sqlclosecheck
|
||||
upStmt, err := txp.Prepare(`
|
||||
UPDATE files SET local_flags = ?
|
||||
WHERE folder_idx = ? AND device_idx = ? AND sequence = ?
|
||||
`)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
if _, err := upStmt.Exec(global.LocalFlags, global.FolderIdx, global.DeviceIdx, global.Sequence); err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
|
||||
// Clear the need and global flags on all other entries
|
||||
//nolint:sqlclosecheck
|
||||
upStmt, err = txp.Prepare(`
|
||||
UPDATE files SET local_flags = local_flags & ?
|
||||
WHERE folder_idx = ? AND name = ? AND sequence != ? AND local_flags & ? != 0
|
||||
`)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
if _, err := upStmt.Exec(^(protocol.FlagLocalNeeded | protocol.FlagLocalGlobal), folderIdx, global.Name, global.Sequence, protocol.FlagLocalNeeded|protocol.FlagLocalGlobal); err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DB) folderIdxLocked(folderID string) (int64, error) {
|
||||
if _, err := s.stmt(`
|
||||
INSERT OR IGNORE INTO folders(folder_id)
|
||||
VALUES (?)
|
||||
`).Exec(folderID); err != nil {
|
||||
return 0, wrap(err)
|
||||
}
|
||||
var idx int64
|
||||
if err := s.stmt(`
|
||||
SELECT idx FROM folders
|
||||
WHERE folder_id = ?
|
||||
`).Get(&idx, folderID); err != nil {
|
||||
return 0, wrap(err)
|
||||
}
|
||||
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
func (s *DB) deviceIdxLocked(deviceID protocol.DeviceID) (int64, error) {
|
||||
devStr := deviceID.String()
|
||||
if _, err := s.stmt(`
|
||||
INSERT OR IGNORE INTO devices(device_id)
|
||||
VALUES (?)
|
||||
`).Exec(devStr); err != nil {
|
||||
return 0, wrap(err)
|
||||
}
|
||||
var idx int64
|
||||
if err := s.stmt(`
|
||||
SELECT idx FROM devices
|
||||
WHERE device_id = ?
|
||||
`).Get(&idx, devStr); err != nil {
|
||||
return 0, wrap(err)
|
||||
}
|
||||
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// wrap returns the error wrapped with the calling function name and
|
||||
// optional extra context strings as prefix. A nil error wraps to nil.
|
||||
func wrap(err error, context ...string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
prefix := "error"
|
||||
pc, _, _, ok := runtime.Caller(1)
|
||||
details := runtime.FuncForPC(pc)
|
||||
if ok && details != nil {
|
||||
prefix = strings.ToLower(details.Name())
|
||||
if dotIdx := strings.LastIndex(prefix, "."); dotIdx > 0 {
|
||||
prefix = prefix[dotIdx+1:]
|
||||
}
|
||||
}
|
||||
|
||||
if len(context) > 0 {
|
||||
for i := range context {
|
||||
context[i] = strings.TrimSpace(context[i])
|
||||
}
|
||||
extra := strings.Join(context, ", ")
|
||||
return fmt.Errorf("%s (%s): %w", prefix, extra, err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("%s: %w", prefix, err)
|
||||
}
|
||||
|
||||
type fileRow struct {
|
||||
Name string
|
||||
Version dbVector
|
||||
FolderIdx int64 `db:"folder_idx"`
|
||||
DeviceIdx int64 `db:"device_idx"`
|
||||
Sequence int64
|
||||
Modified int64
|
||||
Size int64
|
||||
LocalFlags int64 `db:"local_flags"`
|
||||
Deleted bool
|
||||
Invalid bool
|
||||
}
|
||||
|
||||
func (e fileRow) Compare(other fileRow) int {
|
||||
// From FileInfo.WinsConflict
|
||||
vc := e.Version.Vector.Compare(other.Version.Vector)
|
||||
switch vc {
|
||||
case protocol.Equal:
|
||||
if e.Invalid != other.Invalid {
|
||||
if e.Invalid {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// Compare the device ID index, lower is better. This is only
|
||||
// deterministic to the extent that LocalDeviceID will always be the
|
||||
// lowest one, order between remote devices is random (and
|
||||
// irrelevant).
|
||||
return cmp.Compare(e.DeviceIdx, other.DeviceIdx)
|
||||
case protocol.Greater: // we are newer
|
||||
return -1
|
||||
case protocol.Lesser: // we are older
|
||||
return 1
|
||||
case protocol.ConcurrentGreater, protocol.ConcurrentLesser: // there is a conflict
|
||||
if e.Invalid != other.Invalid {
|
||||
if e.Invalid { // we are invalid, we lose
|
||||
return 1
|
||||
}
|
||||
return -1 // they are invalid, we win
|
||||
}
|
||||
if e.Deleted != other.Deleted {
|
||||
if e.Deleted { // we are deleted, we lose
|
||||
return 1
|
||||
}
|
||||
return -1 // they are deleted, we win
|
||||
}
|
||||
if d := cmp.Compare(e.Modified, other.Modified); d != 0 {
|
||||
return -d // positive d means we were newer, so we win (negative return)
|
||||
}
|
||||
if vc == protocol.ConcurrentGreater {
|
||||
return -1 // we have a better device ID, we win
|
||||
}
|
||||
return 1 // they win
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"github.com/syncthing/syncthing/lib/logger"
|
||||
)
|
||||
|
||||
var l = logger.DefaultLogger.NewFacility("sqlite", "SQLite database")
|
||||
|
||||
func shouldDebug() bool { return l.ShouldDebug("sqlite") }
|
||||
@@ -0,0 +1,8 @@
|
||||
These SQL scripts are embedded in the binary.
|
||||
|
||||
Scripts in `schema/` are run at every startup, in alphanumerical order.
|
||||
|
||||
Scripts in `migrations/` are run when a migration is needed; the must begin
|
||||
with a number that equals the schema version that results from that
|
||||
migration. Migrations are not run on initial database creation, so the
|
||||
scripts in `schema/` should create the latest version.
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Copyright (C) 2025 The Syncthing Authors.
|
||||
--
|
||||
-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
-- License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
-- You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
-- The next migration should be number two.
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Copyright (C) 2025 The Syncthing Authors.
|
||||
--
|
||||
-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
-- License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
-- You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
-- folders map folder IDs as used by Syncthing to database folder indexes
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
idx INTEGER NOT NULL PRIMARY KEY,
|
||||
folder_id TEXT NOT NULL UNIQUE COLLATE BINARY
|
||||
) STRICT
|
||||
;
|
||||
|
||||
-- devices map device IDs as used by Syncthing to database device indexes
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
idx INTEGER NOT NULL PRIMARY KEY,
|
||||
device_id TEXT NOT NULL UNIQUE COLLATE BINARY
|
||||
) STRICT
|
||||
;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Copyright (C) 2025 The Syncthing Authors.
|
||||
--
|
||||
-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
-- License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
-- You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
-- Schema migrations hold the list of historical migrations applied
|
||||
CREATE TABLE IF NOT EXISTS schemamigrations (
|
||||
schema_version INTEGER NOT NULL,
|
||||
applied_at INTEGER NOT NULL, -- unix nanos
|
||||
syncthing_version TEXT NOT NULL COLLATE BINARY,
|
||||
PRIMARY KEY(schema_version)
|
||||
) STRICT
|
||||
;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- Copyright (C) 2025 The Syncthing Authors.
|
||||
--
|
||||
-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
-- License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
-- You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
-- Files
|
||||
--
|
||||
-- The files table contains all files announced by any device. Files present
|
||||
-- on this device are filed under the LocalDeviceID, not the actual current
|
||||
-- device ID, for simplicity, consistency and portability. One announced
|
||||
-- version of each file is considered the "global" version - the latest one,
|
||||
-- that all other devices strive to replicate. This instance gets the Global
|
||||
-- flag bit set. There may be other identical instances of this file
|
||||
-- announced by other devices, but only one onstance gets the Global flag;
|
||||
-- this simplifies accounting. If the current device has the Global version,
|
||||
-- the LocalDeviceID instance of the file is the one that has the Global
|
||||
-- bit.
|
||||
--
|
||||
-- If the current device does not have that version of the file it gets the
|
||||
-- Need bit set. Only Global files announced by another device can have the
|
||||
-- Need bit. This allows for very efficient lookup of files needing handling
|
||||
-- on this device, which is a common query.
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
folder_idx INTEGER NOT NULL,
|
||||
device_idx INTEGER NOT NULL, -- actual device ID or LocalDeviceID
|
||||
sequence INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, -- our local database sequence, for each and every entry
|
||||
remote_sequence INTEGER, -- remote device's sequence number, null for local or synthetic entries
|
||||
name TEXT NOT NULL COLLATE BINARY,
|
||||
type INTEGER NOT NULL, -- protocol.FileInfoType
|
||||
modified INTEGER NOT NULL, -- Unix nanos
|
||||
size INTEGER NOT NULL,
|
||||
version TEXT NOT NULL COLLATE BINARY,
|
||||
deleted INTEGER NOT NULL, -- boolean
|
||||
invalid INTEGER NOT NULL, -- boolean
|
||||
local_flags INTEGER NOT NULL,
|
||||
blocklist_hash BLOB, -- null when there are no blocks
|
||||
FOREIGN KEY(device_idx) REFERENCES devices(idx) ON DELETE CASCADE,
|
||||
FOREIGN KEY(folder_idx) REFERENCES folders(idx) ON DELETE CASCADE
|
||||
) STRICT
|
||||
;
|
||||
-- FileInfos store the actual protobuf object. We do this separately to keep
|
||||
-- the files rows smaller and more efficient.
|
||||
CREATE TABLE IF NOT EXISTS fileinfos (
|
||||
sequence INTEGER NOT NULL PRIMARY KEY, -- our local database sequence from the files table
|
||||
fiprotobuf BLOB NOT NULL,
|
||||
FOREIGN KEY(sequence) REFERENCES files(sequence) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED
|
||||
) STRICT
|
||||
;
|
||||
-- There can be only one file per folder, device, and remote sequence number
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS files_remote_sequence ON files (folder_idx, device_idx, remote_sequence)
|
||||
WHERE remote_sequence IS NOT NULL
|
||||
;
|
||||
-- There can be only one file per folder, device, and name
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS files_device_name ON files (folder_idx, device_idx, name)
|
||||
;
|
||||
-- We want to be able to look up & iterate files based on just folder and name
|
||||
CREATE INDEX IF NOT EXISTS files_name_only ON files (folder_idx, name)
|
||||
;
|
||||
-- We want to be able to look up & iterate files based on blocks hash
|
||||
CREATE INDEX IF NOT EXISTS files_blocklist_hash_only ON files (blocklist_hash, device_idx, folder_idx) WHERE blocklist_hash IS NOT NULL
|
||||
;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Copyright (C) 2025 The Syncthing Authors.
|
||||
--
|
||||
-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
-- License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
-- You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
-- indexids holds the index ID and maximum sequence for a given device and folder
|
||||
CREATE TABLE IF NOT EXISTS indexids (
|
||||
device_idx INTEGER NOT NULL,
|
||||
folder_idx INTEGER NOT NULL,
|
||||
index_id TEXT NOT NULL COLLATE BINARY,
|
||||
sequence INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(device_idx, folder_idx),
|
||||
FOREIGN KEY(folder_idx) REFERENCES folders(idx) ON DELETE CASCADE,
|
||||
FOREIGN KEY(device_idx) REFERENCES devices(idx) ON DELETE CASCADE
|
||||
) STRICT, WITHOUT ROWID
|
||||
;
|
||||
CREATE TRIGGER IF NOT EXISTS indexids_seq AFTER INSERT ON files
|
||||
BEGIN
|
||||
INSERT INTO indexids (folder_idx, device_idx, index_id, sequence)
|
||||
VALUES (NEW.folder_idx, NEW.device_idx, "", COALESCE(NEW.remote_sequence, NEW.sequence))
|
||||
ON CONFLICT DO UPDATE SET sequence = COALESCE(NEW.remote_sequence, NEW.sequence);
|
||||
END
|
||||
;
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Copyright (C) 2025 The Syncthing Authors.
|
||||
--
|
||||
-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
-- License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
-- You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
-- Counts
|
||||
--
|
||||
-- Counts and sizes are maintained for each device, folder, type, flag bits
|
||||
-- combination.
|
||||
CREATE TABLE IF NOT EXISTS counts (
|
||||
folder_idx INTEGER NOT NULL,
|
||||
device_idx INTEGER NOT NULL,
|
||||
type INTEGER NOT NULL,
|
||||
local_flags INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
deleted INTEGER NOT NULL, -- boolean
|
||||
PRIMARY KEY(folder_idx, device_idx, type, local_flags, deleted),
|
||||
FOREIGN KEY(device_idx) REFERENCES devices(idx) ON DELETE CASCADE,
|
||||
FOREIGN KEY(folder_idx) REFERENCES folders(idx) ON DELETE CASCADE
|
||||
) STRICT, WITHOUT ROWID
|
||||
;
|
||||
|
||||
--- Maintain counts when files are added and removed using triggers
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS counts_insert AFTER INSERT ON files
|
||||
BEGIN
|
||||
INSERT INTO counts (folder_idx, device_idx, type, local_flags, count, size, deleted)
|
||||
VALUES (NEW.folder_idx, NEW.device_idx, NEW.type, NEW.local_flags, 1, NEW.size, NEW.deleted)
|
||||
ON CONFLICT DO UPDATE SET count = count + 1, size = size + NEW.size;
|
||||
END
|
||||
;
|
||||
CREATE TRIGGER IF NOT EXISTS counts_delete AFTER DELETE ON files
|
||||
BEGIN
|
||||
UPDATE counts SET count = count - 1, size = size - OLD.size
|
||||
WHERE folder_idx = OLD.folder_idx AND device_idx = OLD.device_idx AND type = OLD.type AND local_flags = OLD.local_flags AND deleted = OLD.deleted;
|
||||
END
|
||||
;
|
||||
CREATE TRIGGER IF NOT EXISTS counts_update AFTER UPDATE OF local_flags ON files
|
||||
WHEN NEW.local_flags != OLD.local_flags
|
||||
BEGIN
|
||||
INSERT INTO counts (folder_idx, device_idx, type, local_flags, count, size, deleted)
|
||||
VALUES (NEW.folder_idx, NEW.device_idx, NEW.type, NEW.local_flags, 1, NEW.size, NEW.deleted)
|
||||
ON CONFLICT DO UPDATE SET count = count + 1, size = size + NEW.size;
|
||||
UPDATE counts SET count = count - 1, size = size - OLD.size
|
||||
WHERE folder_idx = OLD.folder_idx AND device_idx = OLD.device_idx AND type = OLD.type AND local_flags = OLD.local_flags AND deleted = OLD.deleted;
|
||||
END
|
||||
;
|
||||
DROP TRIGGER IF EXISTS counts_update_add -- tmp migration
|
||||
;
|
||||
DROP TRIGGER IF EXISTS counts_update_del -- tmp migration
|
||||
;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Copyright (C) 2025 The Syncthing Authors.
|
||||
--
|
||||
-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
-- License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
-- You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
-- Block lists
|
||||
--
|
||||
-- The block lists are extracted from FileInfos and stored separately. This
|
||||
-- reduces the database size by reusing the same block list entry for all
|
||||
-- devices announcing the same file. Doing it for all block lists instead of
|
||||
-- using a size cutoff simplifies queries. Block lists are garbage collected
|
||||
-- "manually", not using a trigger as that was too performance impacting.
|
||||
CREATE TABLE IF NOT EXISTS blocklists (
|
||||
blocklist_hash BLOB NOT NULL PRIMARY KEY,
|
||||
blprotobuf BLOB NOT NULL
|
||||
) STRICT
|
||||
;
|
||||
|
||||
-- Blocks
|
||||
--
|
||||
-- For all local files we store the blocks individually for quick lookup. A
|
||||
-- given block can exist in multiple blocklists and at multiple offsets in a
|
||||
-- blocklist.
|
||||
CREATE TABLE IF NOT EXISTS blocks (
|
||||
hash BLOB NOT NULL,
|
||||
blocklist_hash BLOB NOT NULL,
|
||||
idx INTEGER NOT NULL,
|
||||
offset INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
PRIMARY KEY (hash, blocklist_hash, idx),
|
||||
FOREIGN KEY(blocklist_hash) REFERENCES blocklists(blocklist_hash) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED
|
||||
) STRICT
|
||||
;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Copyright (C) 2025 The Syncthing Authors.
|
||||
--
|
||||
-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
-- License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
-- You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
--- Backing for the MtimeFS
|
||||
CREATE TABLE IF NOT EXISTS mtimes (
|
||||
folder_idx INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
ondisk INTEGER NOT NULL, -- unix nanos
|
||||
virtual INTEGER NOT NULL, -- unix nanos
|
||||
PRIMARY KEY(folder_idx, name),
|
||||
FOREIGN KEY(folder_idx) REFERENCES folders(idx) ON DELETE CASCADE
|
||||
) STRICT, WITHOUT ROWID
|
||||
;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Copyright (C) 2025 The Syncthing Authors.
|
||||
--
|
||||
-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
-- License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
-- You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
--- Simple KV store. This backs the "miscDB" we use for certain minor pieces
|
||||
-- of data.
|
||||
CREATE TABLE IF NOT EXISTS kv (
|
||||
key TEXT NOT NULL PRIMARY KEY COLLATE BINARY,
|
||||
value BLOB NOT NULL
|
||||
) STRICT
|
||||
;
|
||||
@@ -0,0 +1,117 @@
|
||||
// Copyright (C) 2025 The Syncthing Authors.
|
||||
//
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"iter"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/syncthing/syncthing/internal/gen/bep"
|
||||
"github.com/syncthing/syncthing/internal/gen/dbproto"
|
||||
"github.com/syncthing/syncthing/lib/osutil"
|
||||
"github.com/syncthing/syncthing/lib/protocol"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// iterStructs returns an iterator over the given struct type by scanning
|
||||
// the SQL rows. `rows` is closed when the iterator exits.
|
||||
func iterStructs[T any](rows *sqlx.Rows, err error) (iter.Seq[T], func() error) {
|
||||
if err != nil {
|
||||
return func(_ func(T) bool) {}, func() error { return err }
|
||||
}
|
||||
|
||||
var retErr error
|
||||
return func(yield func(T) bool) {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
v := new(T)
|
||||
if err := rows.StructScan(v); err != nil {
|
||||
retErr = err
|
||||
break
|
||||
}
|
||||
if cleanuper, ok := any(v).(interface{ cleanup() }); ok {
|
||||
cleanuper.cleanup()
|
||||
}
|
||||
if !yield(*v) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil && retErr == nil {
|
||||
retErr = err
|
||||
}
|
||||
}, func() error { return retErr }
|
||||
}
|
||||
|
||||
// dbVector is a wrapper that allows protocol.Vector values to be serialized
|
||||
// to and from the database.
|
||||
type dbVector struct { //nolint:recvcheck
|
||||
protocol.Vector
|
||||
}
|
||||
|
||||
func (v dbVector) Value() (driver.Value, error) {
|
||||
return v.String(), nil
|
||||
}
|
||||
|
||||
func (v *dbVector) Scan(value any) error {
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return errors.New("not a string")
|
||||
}
|
||||
if str == "" {
|
||||
v.Vector = protocol.Vector{}
|
||||
return nil
|
||||
}
|
||||
vec, err := protocol.VectorFromString(str)
|
||||
if err != nil {
|
||||
return wrap(err)
|
||||
}
|
||||
v.Vector = vec
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// indirectFI constructs a FileInfo from separate marshalled FileInfo and
|
||||
// BlockList bytes.
|
||||
type indirectFI struct {
|
||||
Name string // not used, must be present as dest for Need iterator
|
||||
FiProtobuf []byte
|
||||
BlProtobuf []byte
|
||||
Size int64 // not used
|
||||
Modified int64 // not used
|
||||
}
|
||||
|
||||
func (i indirectFI) FileInfo() (protocol.FileInfo, error) {
|
||||
var fi bep.FileInfo
|
||||
if err := proto.Unmarshal(i.FiProtobuf, &fi); err != nil {
|
||||
return protocol.FileInfo{}, wrap(err, "unmarshal fileinfo")
|
||||
}
|
||||
if len(i.BlProtobuf) > 0 {
|
||||
var bl dbproto.BlockList
|
||||
if err := proto.Unmarshal(i.BlProtobuf, &bl); err != nil {
|
||||
return protocol.FileInfo{}, wrap(err, "unmarshal blocklist")
|
||||
}
|
||||
fi.Blocks = bl.Blocks
|
||||
}
|
||||
fi.Name = osutil.NativeFilename(fi.Name)
|
||||
return protocol.FileInfoFromDB(&fi), nil
|
||||
}
|
||||
|
||||
func prefixEnd(s string) string {
|
||||
if s == "" {
|
||||
panic("bug: cannot represent end prefix for empty string")
|
||||
}
|
||||
bs := []byte(s)
|
||||
for i := len(bs) - 1; i >= 0; i-- {
|
||||
if bs[i] < 0xff {
|
||||
bs[i]++
|
||||
break
|
||||
}
|
||||
}
|
||||
return string(bs)
|
||||
}
|
||||
Reference in New Issue
Block a user