Skip to content

Commit 2516b8f

Browse files
committed
Add automated database backup and retention
1 parent 9b84136 commit 2516b8f

20 files changed

Lines changed: 1748 additions & 11 deletions

CMakeLists.txt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ qt_add_executable(BrickSuite
6666
src/services/ReferenceDataSeeder.h
6767
src/services/database/DatabaseStatusService.h
6868
src/services/database/DatabaseStatusService.cpp
69+
src/services/database/AutomaticBackupPolicy.h
70+
src/services/database/AutomaticBackupPolicy.cpp
71+
src/services/database/AutomaticBackupService.h
72+
src/services/database/AutomaticBackupService.cpp
73+
src/services/database/AutomaticBackupRetention.h
74+
src/services/database/AutomaticBackupRetention.cpp
6975
src/services/Logger.h
7076
src/services/Updater.h
7177
src/services/Updater.cpp
@@ -353,6 +359,34 @@ target_link_libraries(BrickSuite PRIVATE
353359
include(CTest)
354360

355361
if(BUILD_TESTING)
362+
qt_add_executable(AutomaticBackupPolicyTest EXCLUDE_FROM_ALL
363+
tests/AutomaticBackupPolicyTest.cpp
364+
src/services/database/AutomaticBackupPolicy.h
365+
src/services/database/AutomaticBackupPolicy.cpp
366+
src/services/database/AutomaticBackupRetention.h
367+
src/services/database/AutomaticBackupRetention.cpp
368+
)
369+
target_link_libraries(AutomaticBackupPolicyTest PRIVATE Qt6::Core)
370+
add_test(NAME AutomaticBackupPolicy COMMAND AutomaticBackupPolicyTest)
371+
372+
qt_add_executable(AutomaticBackupServiceTest EXCLUDE_FROM_ALL
373+
tests/AutomaticBackupServiceTest.cpp
374+
src/database/DatabaseManager.h src/database/DatabaseManager.cpp
375+
src/database/DatabaseSchema.h src/database/DatabaseSchema.cpp
376+
src/settings/UserSettings.h src/settings/UserSettings.cpp
377+
src/services/CredentialStore.h src/services/CredentialStore.cpp
378+
src/services/database/AutomaticBackupPolicy.h
379+
src/services/database/AutomaticBackupPolicy.cpp
380+
src/services/database/AutomaticBackupService.h
381+
src/services/database/AutomaticBackupService.cpp
382+
src/services/database/DatabaseStatusService.h
383+
src/services/database/DatabaseStatusService.cpp
384+
src/services/database/AutomaticBackupRetention.h
385+
src/services/database/AutomaticBackupRetention.cpp
386+
)
387+
target_link_libraries(AutomaticBackupServiceTest PRIVATE Qt6::Core Qt6::Sql)
388+
add_test(NAME AutomaticBackupService COMMAND AutomaticBackupServiceTest)
389+
356390
qt_add_executable(DatabaseStatusServiceTest EXCLUDE_FROM_ALL
357391
tests/DatabaseStatusServiceTest.cpp
358392
src/services/database/DatabaseStatusService.h

resources/help/settings.html

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ <h1>Settings</h1>
2222
Application Settings
2323
Startup preferences
2424
Appearance
25+
Automatic database backup policy and status
2526
API provider configuration
2627
</pre>
2728

@@ -52,6 +53,51 @@ <h2>Appearance</h2>
5253
data. Most screenshots in this Help system use Dark theme, so colors may differ when another
5354
theme is selected.</p>
5455

56+
<h2>Database Backup</h2>
57+
58+
<p>The <b>Database Backup</b> page configures BrickSuite-managed automatic backups. Automatic
59+
backups are disabled by default. Choose a <b>Backup root</b>, a supported hourly/daily frequency, and how many
60+
automatic backups to retain, then enable scheduling when you are ready.</p>
61+
62+
<p>BrickSuite appends a folder for the current database schema automatically. For example, a
63+
backup root ending in <code>BrickSuite/database</code> currently stores automatic backups under
64+
<code>BrickSuite/database/v29</code>. When a later BrickSuite release uses schema 30, new backups
65+
will go to <code>v30</code>; BrickSuite does not move or delete the previous <code>v29</code>
66+
folder.</p>
67+
68+
<p>Automatic filenames use a UTC timestamp, such as
69+
<code>BrickSuite_AutoBackup_v29_2026-09-03_142530.db</code>. Settings displays backup and due
70+
times in your local time.</p>
71+
72+
<p><b>Backup Now</b> runs the same automatic pipeline immediately, even when scheduling is
73+
disabled or a failed scheduled attempt is still within its retry interval. BrickSuite creates a
74+
read-only health check of the live database first. The automatic pipeline proceeds only when the
75+
full integrity check is healthy and the foreign-key check has no violations. It then creates a
76+
SQLite snapshot, verifies database integrity and the current schema version, publishes the final
77+
automatic filename, and only then applies retention.</p>
78+
79+
<p>If the live database fails either pre-backup health check, BrickSuite skips the automatic
80+
backup and preserves every existing backup. If a check cannot complete because the database is
81+
busy, locked, or unavailable, the attempt is deferred without treating the database as corrupt.
82+
Use <b>Tools → Database Status &amp; Integrity</b> for detailed diagnostics. The manual
83+
<b>File → Backup Database</b> command remains available as an explicit preservation workflow.</p>
84+
85+
<p>Frequency choices are 1, 4, 8, or 16 hours; 1 or 2 days; and 7 days. The default is
86+
<b>Every 1 day</b>. After a failed scheduled attempt, retry eligibility is the shorter of the
87+
configured interval or six hours, with a one-hour minimum. Backup Now bypasses that retry gate.</p>
88+
89+
<div class="note"><b>Retention is deliberately narrow.</b> It applies only to recognized
90+
BrickSuite automatic backups in the current schema-version folder. It never removes manual
91+
backups, pre-restore safety backups, unrecognized files, or backups from earlier schema versions.</div>
92+
93+
<p>If an explicitly configured destination is unavailable or not writable, BrickSuite reports
94+
the failure and keeps that destination unchanged. Scheduled failures do not interrupt normal use,
95+
and another scheduled attempt is bounded to avoid repeated retry and log noise. Existing verified
96+
backups remain untouched when creation or verification fails.</p>
97+
98+
<p><b>File → Backup Database</b> remains a separate manual workflow: you choose its filename and
99+
destination, and automatic retention never applies to it.</p>
100+
55101
<h2>APIs</h2>
56102

57103
<img class="screenshot" src="images/settings_apis_overview.png"

src/app/Application.cpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
#include "../database/DatabaseManager.h"
2727
#include "../services/ReferenceDataSeeder.h"
28+
#include "../services/database/AutomaticBackupService.h"
2829
#include "../ui/MainWindow.h"
2930

3031
#include <QDebug>
@@ -61,8 +62,12 @@ bool Application::initialize()
6162
m_workspaceContext = std::make_unique<WorkspaceContext>();
6263

6364
m_mainWindow = std::make_unique<MainWindow>(*m_workspaceContext);
65+
m_automaticBackupService = std::make_unique<AutomaticBackupService>();
66+
m_mainWindow->setAutomaticBackupService(m_automaticBackupService.get());
6467
m_mainWindow->show();
6568

69+
m_automaticBackupService->start();
70+
6671
return true;
6772
}
6873

src/app/Application.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
class MainWindow;
2828
class QWidget;
2929
class WorkspaceContext;
30+
class AutomaticBackupService;
3031

3132
class Application
3233
{
@@ -40,4 +41,5 @@ class Application
4041
private:
4142
std::unique_ptr<WorkspaceContext> m_workspaceContext;
4243
std::unique_ptr<MainWindow> m_mainWindow;
44+
std::unique_ptr<AutomaticBackupService> m_automaticBackupService;
4345
};

src/database/DatabaseManager.cpp

Lines changed: 119 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,28 @@
2929
#include <QFile>
3030
#include <QFileInfo>
3131
#include <QSqlError>
32+
#include <QRecursiveMutex>
3233
#include <QStandardPaths>
3334
#include <QUuid>
3435
#include <qsqlquery.h>
3536

37+
namespace {
38+
QRecursiveMutex& maintenanceMutex()
39+
{
40+
static QRecursiveMutex mutex;
41+
return mutex;
42+
}
43+
44+
class AdoptedMaintenanceLock
45+
{
46+
public:
47+
explicit AdoptedMaintenanceLock(QRecursiveMutex& mutex) : m_mutex(mutex) {}
48+
~AdoptedMaintenanceLock() { m_mutex.unlock(); }
49+
private:
50+
QRecursiveMutex& m_mutex;
51+
};
52+
}
53+
3654
DatabaseManager& DatabaseManager::instance()
3755
{
3856
static DatabaseManager instance;
@@ -108,17 +126,39 @@ QSqlDatabase DatabaseManager::database() const
108126
}
109127

110128
bool DatabaseManager::backupDatabase(const QString& backupPath, QString* errorMessage)
129+
{
130+
if (!maintenanceMutex().tryLock()) {
131+
const QString message = "Another database maintenance operation is already running.";
132+
if (errorMessage) *errorMessage = message;
133+
qWarning() << "Database backup rejected:" << message;
134+
return false;
135+
}
136+
AdoptedMaintenanceLock lock(maintenanceMutex());
137+
return backupDatabaseUnlocked(backupPath, errorMessage);
138+
}
139+
140+
bool DatabaseManager::backupDatabaseUnlocked(const QString& backupPath, QString* errorMessage)
141+
{
142+
return createSnapshot(m_database, backupPath, errorMessage);
143+
}
144+
145+
bool DatabaseManager::createSnapshot(QSqlDatabase database, const QString& backupPath,
146+
QString* errorMessage, BackupFailure* failure)
111147
{
112148
auto setError = [errorMessage](const QString& message) {
113149
if (errorMessage)
114150
*errorMessage = message;
115151
};
116152

117-
if (!m_database.isOpen()) {
153+
if (failure)
154+
*failure = BackupFailure::None;
155+
156+
if (!database.isOpen()) {
118157
const QString message = "The BrickSuite database is not open.";
119158
setError(message);
120159
qWarning() << "Database backup rejected:" << message;
121160

161+
if (failure) *failure = BackupFailure::Source;
122162
return false;
123163
}
124164

@@ -128,7 +168,7 @@ bool DatabaseManager::backupDatabase(const QString& backupPath, QString* errorMe
128168

129169
if (trimmedPath.isEmpty()) {
130170
setError("No backup file was selected.");
131-
171+
if (failure) *failure = BackupFailure::Destination;
132172
return false;
133173
}
134174

@@ -145,6 +185,7 @@ bool DatabaseManager::backupDatabase(const QString& backupPath, QString* errorMe
145185
.arg(backupDirectory.absolutePath()));
146186
qCritical() << "Database backup failed:" << message;
147187

188+
if (failure) *failure = BackupFailure::Destination;
148189
return false;
149190
}
150191
}
@@ -165,6 +206,7 @@ bool DatabaseManager::backupDatabase(const QString& backupPath, QString* errorMe
165206
"backup file:\n%1")
166207
.arg(temporaryPath));
167208

209+
if (failure) *failure = BackupFailure::Destination;
168210
return false;
169211
}
170212
}
@@ -176,7 +218,7 @@ bool DatabaseManager::backupDatabase(const QString& backupPath, QString* errorMe
176218
//
177219
escapedTemporaryPath.replace("'", "''");
178220

179-
QSqlQuery query(m_database);
221+
QSqlQuery query(database);
180222

181223
const QString sql = QString("VACUUM INTO '%1'").arg(escapedTemporaryPath);
182224

@@ -188,6 +230,11 @@ bool DatabaseManager::backupDatabase(const QString& backupPath, QString* errorMe
188230
QString("Unable to create the database backup.\n\n%1").arg(message));
189231
qCritical() << "Database backup VACUUM INTO failed:" << message;
190232

233+
if (failure) {
234+
const QString lower = message.toLower();
235+
*failure = lower.contains("busy") || lower.contains("locked")
236+
? BackupFailure::Busy : BackupFailure::Snapshot;
237+
}
191238
return false;
192239
}
193240

@@ -198,6 +245,7 @@ bool DatabaseManager::backupDatabase(const QString& backupPath, QString* errorMe
198245
"the temporary backup file was not created."
199246
<< "TemporaryPath:" << temporaryPath;
200247

248+
if (failure) *failure = BackupFailure::Snapshot;
201249
return false;
202250
}
203251

@@ -213,6 +261,7 @@ bool DatabaseManager::backupDatabase(const QString& backupPath, QString* errorMe
213261
"existing destination file could not be replaced:\n%1")
214262
.arg(trimmedPath));
215263

264+
if (failure) *failure = BackupFailure::Destination;
216265
return false;
217266
}
218267
}
@@ -224,6 +273,7 @@ bool DatabaseManager::backupDatabase(const QString& backupPath, QString* errorMe
224273
"but it could not be renamed to:\n%1")
225274
.arg(trimmedPath));
226275

276+
if (failure) *failure = BackupFailure::Destination;
227277
return false;
228278
}
229279

@@ -233,6 +283,11 @@ bool DatabaseManager::backupDatabase(const QString& backupPath, QString* errorMe
233283
}
234284

235285
bool DatabaseManager::verifyDatabaseBackup(const QString& backupPath, QString* errorMessage) const
286+
{
287+
return verifyBackup(backupPath, errorMessage);
288+
}
289+
290+
bool DatabaseManager::verifyBackup(const QString& backupPath, QString* errorMessage)
236291
{
237292
auto setError = [errorMessage](const QString& message) {
238293
if (errorMessage)
@@ -336,6 +391,58 @@ bool DatabaseManager::verifyDatabaseBackup(const QString& backupPath, QString* e
336391
return true;
337392
}
338393

394+
DatabaseManager::VerifiedBackupResult DatabaseManager::createVerifiedBackup(
395+
const QString& sourceDatabasePath, const QString& backupPath)
396+
{
397+
VerifiedBackupResult result;
398+
result.backupPath = backupPath;
399+
400+
if (!maintenanceMutex().tryLock()) {
401+
result.failure = BackupFailure::Busy;
402+
result.errorMessage = "Another database maintenance operation is already running.";
403+
return result;
404+
}
405+
AdoptedMaintenanceLock lock(maintenanceMutex());
406+
407+
const QString connectionName = QString("BrickSuiteAutomaticBackup_%1")
408+
.arg(QUuid::createUuid().toString(QUuid::WithoutBraces));
409+
const QString pendingPath = backupPath + ".pending."
410+
+ QUuid::createUuid().toString(QUuid::WithoutBraces);
411+
{
412+
QSqlDatabase source = QSqlDatabase::addDatabase("QSQLITE", connectionName);
413+
source.setDatabaseName(sourceDatabasePath);
414+
source.setConnectOptions("QSQLITE_OPEN_READONLY");
415+
if (!source.open()) {
416+
result.failure = BackupFailure::Source;
417+
result.errorMessage = QString("Unable to open the BrickSuite database for backup.\n\n%1")
418+
.arg(source.lastError().text());
419+
} else if (createSnapshot(source, pendingPath, &result.errorMessage, &result.failure)) {
420+
source.close();
421+
QString verificationError;
422+
if (verifyBackup(pendingPath, &verificationError)) {
423+
if (QFile::rename(pendingPath, backupPath)) {
424+
result.success = true;
425+
result.failure = BackupFailure::None;
426+
} else {
427+
result.failure = BackupFailure::Destination;
428+
result.errorMessage = QString("The verified backup could not be published as: %1")
429+
.arg(backupPath);
430+
QFile::remove(pendingPath);
431+
}
432+
} else {
433+
result.failure = BackupFailure::Verification;
434+
result.errorMessage = verificationError;
435+
if (!QFile::remove(pendingPath))
436+
QFile::rename(pendingPath, pendingPath + ".unverified");
437+
}
438+
}
439+
source.close();
440+
}
441+
QSqlDatabase::removeDatabase(connectionName);
442+
QFile::remove(pendingPath + ".tmp");
443+
return result;
444+
}
445+
339446
QString DatabaseManager::databasePath() const
340447
{
341448
const QString dataPath = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation);
@@ -350,6 +457,14 @@ bool DatabaseManager::restoreDatabase(const QString& backupPath, QString* errorM
350457
*errorMessage = message;
351458
};
352459

460+
if (!maintenanceMutex().tryLock()) {
461+
const QString message = "Another database maintenance operation is already running.";
462+
setError(message);
463+
qWarning() << "Database restore rejected:" << message;
464+
return false;
465+
}
466+
AdoptedMaintenanceLock lock(maintenanceMutex());
467+
353468
const QString trimmedBackupPath = backupPath.trimmed();
354469

355470
qInfo() << "Database restore requested:" << trimmedBackupPath;
@@ -546,4 +661,4 @@ bool DatabaseManager::restoreDatabase(const QString& backupPath, QString* errorM
546661
qInfo() << "Pre-restore safety backup:" << safetyBackupPath;
547662

548663
return true;
549-
}
664+
}

0 commit comments

Comments
 (0)