mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-08 15:41:18 +02:00
added experimental backup optimizations
This commit is contained in:
+13
@@ -1262,6 +1262,19 @@ type App interface {
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerRecordOTPSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Filesystem event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnFilesystemNewWriter is a low level hook for app.NewFilesystem()
|
||||
// instances that is triggered on every storage filesystem writer initialization
|
||||
// (aka. whenever attempting to create a new file).
|
||||
OnFilesystemNewWriter() *hook.Hook[*FilesystemNewWriterEvent]
|
||||
|
||||
// OnFilesystemDelete is a low level hook for app.NewFilesystem()
|
||||
// instances that is triggered for every storage file delete call.
|
||||
OnFilesystemDelete() *hook.Hook[*FilesystemDeleteEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Realtime API event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
)
|
||||
|
||||
const (
|
||||
StoreKeyActiveBackup = "@activeBackup"
|
||||
)
|
||||
|
||||
// generateBackupName generates a new backup name based on the app name and current date.
|
||||
func generateBackupName(app App, prefix string) string {
|
||||
appName := inflector.Snakecase(app.Settings().Meta.AppName)
|
||||
if len(appName) > 50 {
|
||||
appName = appName[:50]
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%s%s_%s.zip",
|
||||
prefix,
|
||||
appName,
|
||||
time.Now().UTC().Format("20060102150405"),
|
||||
)
|
||||
}
|
||||
|
||||
// registerAutobackupHooks registers the autobackup app serve hooks.
|
||||
func (app *BaseApp) registerAutobackupHooks() {
|
||||
const jobId = "__pbAutoBackup__"
|
||||
|
||||
loadJob := func() {
|
||||
rawSchedule := app.Settings().Backups.Cron
|
||||
if rawSchedule == "" {
|
||||
app.Cron().Remove(jobId)
|
||||
return
|
||||
}
|
||||
|
||||
app.Cron().Add(jobId, rawSchedule, func() {
|
||||
const autoPrefix = "@auto_pb_backup_"
|
||||
|
||||
name := generateBackupName(app, autoPrefix)
|
||||
|
||||
if err := app.CreateBackup(context.Background(), name); err != nil {
|
||||
app.Logger().Error(
|
||||
"[Backup cron] Failed to create backup",
|
||||
slog.String("name", name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
|
||||
alertError := sendSystemAlertToAllSuperusers(
|
||||
app,
|
||||
"Autobackup failure",
|
||||
"Failed to create/upload automated backup. Raw error:\n"+err.Error(),
|
||||
)
|
||||
if alertError != nil {
|
||||
app.Logger().Warn(
|
||||
"[Backup cron] Failed to send backup error alerts",
|
||||
slog.String("name", name),
|
||||
slog.String("error", alertError.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
maxKeep := app.Settings().Backups.CronMaxKeep
|
||||
|
||||
if maxKeep == 0 {
|
||||
return // no explicit limit
|
||||
}
|
||||
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
app.Logger().Error(
|
||||
"[Backup cron] Failed to initialize the backup filesystem",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
return
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
files, err := fsys.List(autoPrefix)
|
||||
if err != nil {
|
||||
app.Logger().Error(
|
||||
"[Backup cron] Failed to list autogenerated backups",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if maxKeep >= len(files) {
|
||||
return // nothing to remove
|
||||
}
|
||||
|
||||
// sort desc
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].ModTime.After(files[j].ModTime)
|
||||
})
|
||||
|
||||
// keep only the most recent n auto backup files
|
||||
toRemove := files[maxKeep:]
|
||||
|
||||
for _, f := range toRemove {
|
||||
if err := fsys.Delete(f.Key); err != nil {
|
||||
app.Logger().Error(
|
||||
"[Backup cron] Failed to remove old autogenerated backup",
|
||||
slog.String("key", f.Key),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
app.OnBootstrap().BindFunc(func(e *BootstrapEvent) error {
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
loadJob()
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
app.OnSettingsReload().BindFunc(func(e *SettingsReloadEvent) error {
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
loadJob()
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"compress/flate"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
)
|
||||
|
||||
// CreateBackup creates a new backup of the current app pb_data directory.
|
||||
//
|
||||
// If name is empty, it will be autogenerated.
|
||||
// If backup with the same name exists, the new backup file will replace it.
|
||||
//
|
||||
// To safely perform the backup, it is recommended to have free disk space
|
||||
// for at least 2x the size of the pb_data directory.
|
||||
//
|
||||
// By default backups are stored in pb_data/backups
|
||||
// (the backups directory itself is excluded from the generated backup).
|
||||
//
|
||||
// Backups can be stored on S3 if it is configured in app.Settings().Backups.
|
||||
// When using S3 storage for the uploaded collection files, you have to
|
||||
// take care manually to backup those since they are not part of the pb_data.
|
||||
//
|
||||
// DB write locks are minimal and isolated only for the duration of the
|
||||
// VACUUM INTO statement that creates a live copy of the app database.
|
||||
//
|
||||
// The backup works as follow:
|
||||
//
|
||||
// 1. Start listening for DELETED storage files.
|
||||
// In case a file is being deleted while the backup is still ongoing,
|
||||
// we directly copy it in the zip before the deletion and mark it as "excluded".
|
||||
//
|
||||
// 2. Copy the main database with VACUUM INTO, write it in the zip and mark it as "excluded".
|
||||
//
|
||||
// 3. Stop listening for DELETED files.
|
||||
//
|
||||
// 4. Start listening for NEW storage files and mark all new files as excluded.
|
||||
//
|
||||
// 5. Copy the logs database with VACUUM INTO, write it in the zip and mark it as "excluded".
|
||||
//
|
||||
// 6. Copy the rest of the pb_data files in the zip while ignoring the "excluded" list (it should be concurrent safe).
|
||||
//
|
||||
// 7. Stop listening for NEW storage files.
|
||||
//
|
||||
// While there is a risk for a race condition between steps 1, 2 and 3, it is an
|
||||
// acceptable trade-off between performance and correctness because in
|
||||
// the worst case there will be some unused storage files in the backup that don't do any harm.
|
||||
func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
if app.Store().Has(StoreKeyActiveBackup) {
|
||||
return errors.New("try again later - another backup/restore operation has already been started")
|
||||
}
|
||||
|
||||
app.Store().Set(StoreKeyActiveBackup, name)
|
||||
defer app.Store().Remove(StoreKeyActiveBackup)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
event := new(BackupEvent)
|
||||
event.App = app
|
||||
event.Context = ctx
|
||||
event.Name = name
|
||||
// default root dir entries to exclude from the backup generation
|
||||
event.Exclude = []string{
|
||||
LocalBackupsDirName,
|
||||
LocalTempDirName,
|
||||
LocalNotifyDirName,
|
||||
LocalAutocertCacheDirName,
|
||||
lostFoundDirName,
|
||||
}
|
||||
|
||||
return app.OnBackupCreate().Trigger(event, func(e *BackupEvent) error {
|
||||
if e.Name == "" {
|
||||
e.Name = generateBackupName(e.App, "pb_backup_")
|
||||
}
|
||||
|
||||
// create backup zip
|
||||
// (it needs to be inside the current pb_data to avoid "cross-device link" errors)
|
||||
// -----------------------------------------------------------
|
||||
tempZipPath := filepath.Join(app.DataDir(), LocalTempDirName, "pb_backup_"+security.PseudorandomString(6))
|
||||
err := createZip(e, tempZipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tempZipPath)
|
||||
|
||||
// persist the backup in the backups filesystem
|
||||
// -----------------------------------------------------------
|
||||
fsys, err := e.App.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
fsys.SetContext(e.Context)
|
||||
|
||||
file, err := filesystem.NewFileFromPath(tempZipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file.OriginalName = e.Name
|
||||
file.Name = file.OriginalName
|
||||
|
||||
err = fsys.UploadFile(file, file.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app.Logger().Debug(
|
||||
"["+e.Name+"] zip archive completed",
|
||||
slog.Float64("execTime", float64(time.Since(startTime))/float64(time.Millisecond)),
|
||||
)
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func createZip(be *BackupEvent, tempZipPath string) error {
|
||||
logPrefix := "[" + be.Name + "] "
|
||||
|
||||
// make sure that the special temp directory exists
|
||||
localTempDir := filepath.Dir(tempZipPath)
|
||||
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf(logPrefix+"failed to create temp dir: %w", err)
|
||||
}
|
||||
|
||||
const tempFilesHookId = "__pbTempBackupFilesystemWatcher__"
|
||||
defer func() {
|
||||
// unbind again in cacase of an error
|
||||
be.App.OnFilesystemDelete().Unbind(tempFilesHookId)
|
||||
be.App.OnFilesystemNewWriter().Unbind(tempFilesHookId)
|
||||
}()
|
||||
|
||||
zf, err := os.Create(tempZipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
zw := zip.NewWriter(zf)
|
||||
zw.RegisterCompressor(zip.Deflate, func(out io.Writer) (io.WriteCloser, error) {
|
||||
return flate.NewWriter(out, flate.BestSpeed)
|
||||
})
|
||||
|
||||
closeZip := func() error {
|
||||
return errors.Join(zw.Close(), zf.Close())
|
||||
}
|
||||
// call defer even though zf.Close will error if invoked multiple times
|
||||
// because otherwise the code become too brittle
|
||||
defer closeZip()
|
||||
|
||||
excluded := store.New[string, struct{}](nil)
|
||||
for _, name := range be.Exclude {
|
||||
excluded.Set(normalizePathExclude(name), struct{}{})
|
||||
}
|
||||
|
||||
// init deleted files tracker
|
||||
// ---------------------------------------------------------------
|
||||
be.App.OnFilesystemDelete().Bind(&hook.Handler[*FilesystemDeleteEvent]{
|
||||
Id: tempFilesHookId,
|
||||
Func: func(e *FilesystemDeleteEvent) error {
|
||||
// note: the zip header name allow only forward slashes
|
||||
zipPath := path.Join(LocalStorageDirName, e.FileKey)
|
||||
|
||||
if excluded.Has(normalizePathExclude(zipPath)) || be.App.Settings().S3.Enabled {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
localPath := filepath.Join(
|
||||
be.App.DataDir(),
|
||||
LocalStorageDirName,
|
||||
e.FileKey,
|
||||
)
|
||||
|
||||
// copy to zip before delete
|
||||
err := copyFileToZip(zw, localPath, zipPath)
|
||||
if err != nil {
|
||||
be.App.Logger().Warn(
|
||||
logPrefix+"failed to copy file in backup zip before delete",
|
||||
slog.Any("error", err),
|
||||
slog.String("file", e.FileKey),
|
||||
)
|
||||
} else {
|
||||
// mark that it was already copied
|
||||
excluded.Set(normalizePathExclude(zipPath), struct{}{})
|
||||
}
|
||||
|
||||
// proceed with the normal deletion
|
||||
return e.Next()
|
||||
},
|
||||
})
|
||||
|
||||
// copy data.db
|
||||
// ---------------------------------------------------------------
|
||||
dataStartTime := time.Now()
|
||||
tempDataDBPath := filepath.Join(localTempDir, dataDBFilename)
|
||||
|
||||
_, err = be.App.NonconcurrentDB().NewQuery("VACUUM INTO {:path}").Bind(dbx.Params{"path": tempDataDBPath}).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// eageerly stop listenining for deleted files since we already have what we needed
|
||||
be.App.OnFilesystemDelete().Unbind(tempFilesHookId)
|
||||
|
||||
be.App.Logger().Debug(
|
||||
logPrefix+dataDBFilename+" copy completed",
|
||||
slog.Float64("execTime", float64(time.Since(dataStartTime))/float64(time.Millisecond)),
|
||||
)
|
||||
|
||||
err = copyFileToZip(zw, tempDataDBPath, dataDBFilename)
|
||||
if err != nil {
|
||||
_ = os.Remove(tempDataDBPath)
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(tempDataDBPath)
|
||||
|
||||
excluded.Set(normalizePathExclude(dataDBFilename), struct{}{})
|
||||
excluded.Set(normalizePathExclude(dataDBFilename+"-wal"), struct{}{})
|
||||
excluded.Set(normalizePathExclude(dataDBFilename+"-shm"), struct{}{})
|
||||
|
||||
// init to-be-created files tracker
|
||||
// ---------------------------------------------------------------
|
||||
be.App.OnFilesystemNewWriter().Bind(&hook.Handler[*FilesystemNewWriterEvent]{
|
||||
Id: tempFilesHookId,
|
||||
Func: func(e *FilesystemNewWriterEvent) error {
|
||||
if !be.App.Settings().S3.Enabled {
|
||||
// mark for exclude even if the writer eventually fails
|
||||
// (all record files have random name so collusions are unlikely)
|
||||
name := normalizePathExclude(filepath.Join(LocalStorageDirName, e.FileKey))
|
||||
excluded.Set(name, struct{}{})
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
})
|
||||
|
||||
// copy auxiliary.db
|
||||
// ---------------------------------------------------------------
|
||||
auxStartTime := time.Now()
|
||||
tempAuxDBPath := filepath.Join(localTempDir, auxDBFilename)
|
||||
|
||||
_, err = be.App.AuxNonconcurrentDB().NewQuery("VACUUM INTO {:path}").Bind(dbx.Params{"path": tempAuxDBPath}).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
be.App.Logger().Debug(
|
||||
logPrefix+auxDBFilename+" copy completed",
|
||||
slog.Float64("execTime", float64(time.Since(auxStartTime))/float64(time.Millisecond)),
|
||||
)
|
||||
|
||||
err = copyFileToZip(zw, tempAuxDBPath, auxDBFilename)
|
||||
if err != nil {
|
||||
_ = os.Remove(tempAuxDBPath)
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(tempAuxDBPath)
|
||||
|
||||
excluded.Set(normalizePathExclude(auxDBFilename), struct{}{})
|
||||
excluded.Set(normalizePathExclude(auxDBFilename+"-wal"), struct{}{})
|
||||
excluded.Set(normalizePathExclude(auxDBFilename+"-shm"), struct{}{})
|
||||
|
||||
// copy the rest of the pb_data
|
||||
// ---------------------------------------------------------------
|
||||
err = copyDirToZip(zw, os.DirFS(be.App.DataDir()), excluded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return closeZip()
|
||||
}
|
||||
|
||||
// normalize the provided file path to always use and end with forward slash
|
||||
func normalizePathExclude(filePath string) string {
|
||||
return path.Clean(filePath) + "/"
|
||||
}
|
||||
|
||||
func copyFileToZip(w *zip.Writer, localPath string, zipPath string) error {
|
||||
info, err := os.Stat(localPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.Name = zipPath
|
||||
h.Method = zip.Deflate
|
||||
|
||||
fw, err := w.CreateHeader(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = io.Copy(fw, f)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func copyDirToZip(w *zip.Writer, fsys fs.FS, excludedPrefixes *store.Store[string, struct{}]) error {
|
||||
return fs.WalkDir(fsys, ".", func(name string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// skip excluded prefixes
|
||||
if excludedPrefixes != nil {
|
||||
check := normalizePathExclude(name)
|
||||
prefixes := excludedPrefixes.Keys() // refetch in case to avoid races
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(check, prefix) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.Name = name
|
||||
h.Method = zip.Deflate
|
||||
|
||||
fw, err := w.CreateHeader(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := fsys.Open(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = io.Copy(fw, f)
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -9,117 +9,12 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/archive"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
const (
|
||||
StoreKeyActiveBackup = "@activeBackup"
|
||||
)
|
||||
|
||||
// CreateBackup creates a new backup of the current app pb_data directory.
|
||||
//
|
||||
// If name is empty, it will be autogenerated.
|
||||
// If backup with the same name exists, the new backup file will replace it.
|
||||
//
|
||||
// The backup is executed within a transaction, meaning that new writes
|
||||
// will be temporary "blocked" until the backup file is generated.
|
||||
//
|
||||
// To safely perform the backup, it is recommended to have free disk space
|
||||
// for at least 2x the size of the pb_data directory.
|
||||
//
|
||||
// By default backups are stored in pb_data/backups
|
||||
// (the backups directory itself is excluded from the generated backup).
|
||||
//
|
||||
// When using S3 storage for the uploaded collection files, you have to
|
||||
// take care manually to backup those since they are not part of the pb_data.
|
||||
//
|
||||
// Backups can be stored on S3 if it is configured in app.Settings().Backups.
|
||||
func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
if app.Store().Has(StoreKeyActiveBackup) {
|
||||
return errors.New("try again later - another backup/restore operation has already been started")
|
||||
}
|
||||
|
||||
app.Store().Set(StoreKeyActiveBackup, name)
|
||||
defer app.Store().Remove(StoreKeyActiveBackup)
|
||||
|
||||
event := new(BackupEvent)
|
||||
event.App = app
|
||||
event.Context = ctx
|
||||
event.Name = name
|
||||
// default root dir entries to exclude from the backup generation
|
||||
event.Exclude = []string{
|
||||
LocalBackupsDirName,
|
||||
LocalTempDirName,
|
||||
LocalNotifyDirName,
|
||||
LocalAutocertCacheDirName,
|
||||
lostFoundDirName,
|
||||
}
|
||||
|
||||
return app.OnBackupCreate().Trigger(event, func(e *BackupEvent) error {
|
||||
// generate a default name if missing
|
||||
if e.Name == "" {
|
||||
e.Name = generateBackupName(e.App, "pb_backup_")
|
||||
}
|
||||
|
||||
// make sure that the special temp directory exists
|
||||
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
|
||||
localTempDir := filepath.Join(e.App.DataDir(), LocalTempDirName)
|
||||
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("failed to create a temp dir: %w", err)
|
||||
}
|
||||
|
||||
// archive pb_data in a temp directory, excluding the "backups" and the temp dirs
|
||||
//
|
||||
// run in transaction to temporary block other writes (transactions uses the NonconcurrentDB connection)
|
||||
// ---
|
||||
tempPath := filepath.Join(localTempDir, "pb_backup_"+security.PseudorandomString(6))
|
||||
createErr := e.App.RunInTransaction(func(txApp App) error {
|
||||
return txApp.AuxRunInTransaction(func(txApp App) error {
|
||||
// run manual checkpoint and truncate the WAL files
|
||||
// (errors are ignored because it is not that important and the PRAGMA may not be supported by the used driver)
|
||||
txApp.DB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
|
||||
txApp.AuxDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
|
||||
|
||||
return archive.Create(txApp.DataDir(), tempPath, e.Exclude...)
|
||||
})
|
||||
})
|
||||
if createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
// persist the backup in the backups filesystem
|
||||
// ---
|
||||
fsys, err := e.App.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
fsys.SetContext(e.Context)
|
||||
|
||||
file, err := filesystem.NewFileFromPath(tempPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file.OriginalName = e.Name
|
||||
file.Name = file.OriginalName
|
||||
|
||||
if err := fsys.UploadFile(file, file.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RestoreBackup restores the backup with the specified name and restarts
|
||||
// the current running application process.
|
||||
//
|
||||
@@ -299,124 +194,3 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// registerAutobackupHooks registers the autobackup app serve hooks.
|
||||
func (app *BaseApp) registerAutobackupHooks() {
|
||||
const jobId = "__pbAutoBackup__"
|
||||
|
||||
loadJob := func() {
|
||||
rawSchedule := app.Settings().Backups.Cron
|
||||
if rawSchedule == "" {
|
||||
app.Cron().Remove(jobId)
|
||||
return
|
||||
}
|
||||
|
||||
app.Cron().Add(jobId, rawSchedule, func() {
|
||||
const autoPrefix = "@auto_pb_backup_"
|
||||
|
||||
name := generateBackupName(app, autoPrefix)
|
||||
|
||||
if err := app.CreateBackup(context.Background(), name); err != nil {
|
||||
app.Logger().Error(
|
||||
"[Backup cron] Failed to create backup",
|
||||
slog.String("name", name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
|
||||
alertError := sendSystemAlertToAllSuperusers(
|
||||
app,
|
||||
"Autobackup failure",
|
||||
"Failed to create/upload automated backup. Raw error:\n"+err.Error(),
|
||||
)
|
||||
if alertError != nil {
|
||||
app.Logger().Warn(
|
||||
"[Backup cron] Failed to send backup error alerts",
|
||||
slog.String("name", name),
|
||||
slog.String("error", alertError.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
maxKeep := app.Settings().Backups.CronMaxKeep
|
||||
|
||||
if maxKeep == 0 {
|
||||
return // no explicit limit
|
||||
}
|
||||
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
app.Logger().Error(
|
||||
"[Backup cron] Failed to initialize the backup filesystem",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
return
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
files, err := fsys.List(autoPrefix)
|
||||
if err != nil {
|
||||
app.Logger().Error(
|
||||
"[Backup cron] Failed to list autogenerated backups",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if maxKeep >= len(files) {
|
||||
return // nothing to remove
|
||||
}
|
||||
|
||||
// sort desc
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].ModTime.After(files[j].ModTime)
|
||||
})
|
||||
|
||||
// keep only the most recent n auto backup files
|
||||
toRemove := files[maxKeep:]
|
||||
|
||||
for _, f := range toRemove {
|
||||
if err := fsys.Delete(f.Key); err != nil {
|
||||
app.Logger().Error(
|
||||
"[Backup cron] Failed to remove old autogenerated backup",
|
||||
slog.String("key", f.Key),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
app.OnBootstrap().BindFunc(func(e *BootstrapEvent) error {
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
loadJob()
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
app.OnSettingsReload().BindFunc(func(e *SettingsReloadEvent) error {
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
loadJob()
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func generateBackupName(app App, prefix string) string {
|
||||
appName := inflector.Snakecase(app.Settings().Meta.AppName)
|
||||
if len(appName) > 50 {
|
||||
appName = appName[:50]
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%s%s_%s.zip",
|
||||
prefix,
|
||||
appName,
|
||||
time.Now().UTC().Format("20060102150405"),
|
||||
)
|
||||
}
|
||||
@@ -126,11 +126,7 @@ func verifyBackupContent(app core.App, path string) error {
|
||||
expectedRootEntries := []string{
|
||||
"storage",
|
||||
"data.db",
|
||||
"data.db-shm",
|
||||
"data.db-wal",
|
||||
"auxiliary.db",
|
||||
"auxiliary.db-shm",
|
||||
"auxiliary.db-wal",
|
||||
".gitignore",
|
||||
}
|
||||
|
||||
+61
-6
@@ -44,6 +44,9 @@ const (
|
||||
|
||||
// @todo consider removing after backups refactoring
|
||||
lostFoundDirName string = "lost+found"
|
||||
|
||||
dataDBFilename string = "data.db"
|
||||
auxDBFilename string = "auxiliary.db"
|
||||
)
|
||||
|
||||
// FilesManager defines an interface with common methods that files manager models should implement.
|
||||
@@ -146,6 +149,10 @@ type BaseApp struct {
|
||||
onMailerRecordOTPSend *hook.Hook[*MailerRecordEvent]
|
||||
onMailerRecordAuthAlertSend *hook.Hook[*MailerRecordEvent]
|
||||
|
||||
// filesystem event hooks
|
||||
onFilesystemNewWriter *hook.Hook[*FilesystemNewWriterEvent]
|
||||
onFilesystemDelete *hook.Hook[*FilesystemDeleteEvent]
|
||||
|
||||
// realtime api event hooks
|
||||
onRealtimeConnectRequest *hook.Hook[*RealtimeConnectRequestEvent]
|
||||
onRealtimeMessageSend *hook.Hook[*RealtimeMessageEvent]
|
||||
@@ -294,6 +301,10 @@ func (app *BaseApp) initHooks() {
|
||||
app.onMailerRecordOTPSend = &hook.Hook[*MailerRecordEvent]{}
|
||||
app.onMailerRecordAuthAlertSend = &hook.Hook[*MailerRecordEvent]{}
|
||||
|
||||
// filesystem event hooks
|
||||
app.onFilesystemNewWriter = &hook.Hook[*FilesystemNewWriterEvent]{}
|
||||
app.onFilesystemDelete = &hook.Hook[*FilesystemDeleteEvent]{}
|
||||
|
||||
// realtime API event hooks
|
||||
app.onRealtimeConnectRequest = &hook.Hook[*RealtimeConnectRequestEvent]{}
|
||||
app.onRealtimeMessageSend = &hook.Hook[*RealtimeMessageEvent]{}
|
||||
@@ -712,9 +723,10 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
|
||||
//
|
||||
// NB! Make sure to call Close() on the returned result
|
||||
// after you are done working with it.
|
||||
func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
|
||||
func (app *BaseApp) NewFilesystem() (fsys *filesystem.System, err error) {
|
||||
if app.settings != nil && app.settings.S3.Enabled {
|
||||
return filesystem.NewS3(
|
||||
// S3
|
||||
fsys, err = filesystem.NewS3(
|
||||
app.settings.S3.Bucket,
|
||||
app.settings.S3.Region,
|
||||
app.settings.S3.Endpoint,
|
||||
@@ -722,10 +734,41 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
|
||||
app.settings.S3.Secret,
|
||||
app.settings.S3.ForcePathStyle,
|
||||
)
|
||||
} else {
|
||||
// local filesystem
|
||||
fsys, err = filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// fallback to local filesystem
|
||||
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName))
|
||||
// attach delete hook
|
||||
if app.onFilesystemDelete.Length() > 0 {
|
||||
fsys.OnDelete().BindFunc(func(originalEvent *filesystem.DeleteEvent) error {
|
||||
appEvent := new(FilesystemDeleteEvent)
|
||||
appEvent.DeleteEvent = originalEvent
|
||||
appEvent.App = app
|
||||
|
||||
return app.onFilesystemDelete.Trigger(appEvent, func(fde *FilesystemDeleteEvent) error {
|
||||
return originalEvent.Next()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// attach write hook
|
||||
if app.onFilesystemNewWriter.Length() > 0 {
|
||||
fsys.OnNewWriter().BindFunc(func(originalEvent *filesystem.NewWriterEvent) error {
|
||||
appEvent := new(FilesystemNewWriterEvent)
|
||||
appEvent.NewWriterEvent = originalEvent
|
||||
appEvent.App = app
|
||||
|
||||
return app.onFilesystemNewWriter.Trigger(appEvent, func(fwe *FilesystemNewWriterEvent) error {
|
||||
return originalEvent.Next()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return fsys, nil
|
||||
}
|
||||
|
||||
// NewBackupsFilesystem creates a new local or S3 filesystem instance
|
||||
@@ -1016,6 +1059,18 @@ func (app *BaseApp) OnMailerRecordAuthAlertSend(tags ...string) *hook.TaggedHook
|
||||
return hook.NewTaggedHook(app.onMailerRecordAuthAlertSend, tags...)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Filesystem event hooks
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (app *BaseApp) OnFilesystemNewWriter() *hook.Hook[*FilesystemNewWriterEvent] {
|
||||
return app.onFilesystemNewWriter
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnFilesystemDelete() *hook.Hook[*FilesystemDeleteEvent] {
|
||||
return app.onFilesystemDelete
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Realtime API event hooks
|
||||
// -------------------------------------------------------------------
|
||||
@@ -1173,7 +1228,7 @@ func (app *BaseApp) OnBatchRequest() *hook.Hook[*BatchRequestEvent] {
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (app *BaseApp) initDataDB() error {
|
||||
dbPath := filepath.Join(app.DataDir(), "data.db")
|
||||
dbPath := filepath.Join(app.DataDir(), dataDBFilename)
|
||||
|
||||
concurrentDB, err := app.config.DBConnect(dbPath)
|
||||
if err != nil {
|
||||
@@ -1235,7 +1290,7 @@ func normalizeSQLLog(sql string) string {
|
||||
func (app *BaseApp) initAuxDB() error {
|
||||
// note: renamed to "auxiliary" because "aux" is a reserved Windows filename
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/5607)
|
||||
dbPath := filepath.Join(app.DataDir(), "auxiliary.db")
|
||||
dbPath := filepath.Join(app.DataDir(), auxDBFilename)
|
||||
|
||||
concurrentDB, err := app.config.DBConnect(dbPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
@@ -188,6 +189,24 @@ type MailerRecordEvent struct {
|
||||
Meta map[string]any
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Filesystem events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type FilesystemNewWriterEvent struct {
|
||||
hook.Event
|
||||
*filesystem.NewWriterEvent
|
||||
|
||||
App App
|
||||
}
|
||||
|
||||
type FilesystemDeleteEvent struct {
|
||||
hook.Event
|
||||
*filesystem.DeleteEvent
|
||||
|
||||
App App
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Model events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user