Skip to content

Commit 2f4b75c

Browse files
committed
Merge branch 'main' of github.com:opensensor/lightNVR
2 parents c9e0c5e + 7491375 commit 2f4b75c

3 files changed

Lines changed: 268 additions & 9 deletions

File tree

‎include/database/db_backup.h‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@
2121
*/
2222
int backup_database(const char *source_path, const char *dest_path, bool abortable);
2323

24+
/** Default maximum duration (seconds) an abortable backup may run before
25+
* self-aborting as a stuck-backup safety valve. Not test-only (unlike
26+
* db_backup_set_max_duration_seconds_for_testing(), declared separately in
27+
* the test file itself rather than here), since a test needs this value to
28+
* restore the default after overriding it. */
29+
#define DB_BACKUP_MAX_DURATION_SECONDS_DEFAULT (30 * 60)
30+
2431
/**
2532
* Restore database from backup
2633
*

‎src/database/db_backup.c‎

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,52 @@ static pthread_mutex_t backup_mutex = PTHREAD_MUTEX_INITIALIZER;
3535
#define BACKUP_BUSY_RETRY_US 100000
3636
#define BACKUP_VERIFY_PROGRESS_OPS 100000
3737

38+
/* Safety valve for a scheduled backup that never finishes.
39+
* maybe_run_scheduled_database_backup() runs synchronously on the main loop
40+
* thread, so a hung copy or verification step blocks all other periodic
41+
* maintenance (service self-healing, further backup scheduling) indefinitely
42+
* -- the only existing abort point is an explicit restart/shutdown request,
43+
* which may never come. 30 minutes gives wide margin above the largest
44+
* normal backup+verify cycle observed in production (~12 minutes for a 3GB
45+
* database) while still bounding a truly stuck run instead of it silently
46+
* blocking every subsequent scheduled backup for hours. */
47+
static int g_backup_max_duration_seconds = DB_BACKUP_MAX_DURATION_SECONDS_DEFAULT;
48+
49+
/* Test-only: not declared in the public header (would otherwise let
50+
* production code mutate a global timeout at runtime), reached via an
51+
* `extern` declaration in the test file instead. */
52+
void db_backup_set_max_duration_seconds_for_testing(int seconds) {
53+
g_backup_max_duration_seconds = seconds;
54+
}
55+
56+
typedef struct {
57+
struct timespec start;
58+
int max_duration_seconds;
59+
} backup_deadline_t;
60+
61+
static void backup_deadline_start(backup_deadline_t *deadline, bool abortable) {
62+
deadline->max_duration_seconds = abortable ? g_backup_max_duration_seconds : 0;
63+
/* CLOCK_MONOTONIC rather than time(NULL): a wall-clock jump backward
64+
* (NTP sync, manual clock change) during a long-running backup would
65+
* otherwise delay or defeat this safety valve entirely. */
66+
clock_gettime(CLOCK_MONOTONIC, &deadline->start);
67+
}
68+
69+
static bool backup_deadline_exceeded(const backup_deadline_t *deadline) {
70+
/* A non-positive duration (production default is always positive; only
71+
* reachable via db_backup_set_max_duration_seconds_for_testing()) means
72+
* "already expired" -- lets tests force an expired deadline without any
73+
* clock arithmetic that could underflow. */
74+
if (!deadline || deadline->max_duration_seconds <= 0) {
75+
return true;
76+
}
77+
struct timespec now;
78+
clock_gettime(CLOCK_MONOTONIC, &now);
79+
double elapsed_seconds = (double)(now.tv_sec - deadline->start.tv_sec) +
80+
(double)(now.tv_nsec - deadline->start.tv_nsec) / 1e9;
81+
return elapsed_seconds >= (double)deadline->max_duration_seconds;
82+
}
83+
3884
static void release_file_cache(int fd, const char *path) {
3985
#ifdef POSIX_FADV_DONTNEED
4086
int advise_rc = posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
@@ -66,6 +112,8 @@ typedef struct {
66112
int fd;
67113
const char *path;
68114
bool abortable;
115+
const backup_deadline_t *deadline;
116+
bool deadline_hit;
69117
} cache_release_progress_t;
70118

71119
static int progress_during_verification(void *opaque) {
@@ -75,9 +123,16 @@ static int progress_during_verification(void *opaque) {
75123
* it's verifying, with no other abort point once sqlite3_backup_finish()
76124
* has run. A non-zero return here interrupts the running statement
77125
* (like sqlite3_interrupt()), so this is the only way to make that scan
78-
* itself responsive to a pending restart/shutdown. */
79-
if (progress && progress->abortable && is_background_abort_requested()) {
80-
return 1;
126+
* itself responsive to a pending restart/shutdown or a stuck-backup
127+
* timeout. */
128+
if (progress && progress->abortable) {
129+
if (is_background_abort_requested()) {
130+
return 1;
131+
}
132+
if (backup_deadline_exceeded(progress->deadline)) {
133+
progress->deadline_hit = true;
134+
return 1;
135+
}
81136
}
82137
if (progress && progress->fd >= 0) {
83138
release_file_cache(progress->fd, progress->path);
@@ -205,11 +260,13 @@ static int run_integrity_check(sqlite3 *db_handle, const char *path_label) {
205260
// (progress_during_verification(), registered by the caller for
206261
// an abortable backup) surfaces here as SQLITE_INTERRUPT, not a
207262
// real failure -- the caller already logs its own, more specific
208-
// "aborting during verification: restart/shutdown requested"
209-
// warning right after this returns. Logging this as an error too
210-
// would make every ordinary abort during a routine restart look
211-
// like a corruption/failure event in the logs.
212-
log_warn("Integrity check for %s interrupted (restart/shutdown requested)", path_label);
263+
// "restart/shutdown requested" or "exceeded maximum duration"
264+
// warning right after this returns, so this message is
265+
// deliberately reason-neutral rather than claiming a specific
266+
// cause. Logging this as an error too would make every ordinary
267+
// abort during a routine restart look like a corruption/failure
268+
// event in the logs.
269+
log_warn("Integrity check for %s interrupted (abort requested)", path_label);
213270
} else {
214271
log_error("Failed to execute integrity check for %s: %s",
215272
path_label, sqlite3_errmsg(db_handle));
@@ -254,6 +311,9 @@ int backup_database(const char *source_path, const char *dest_path, bool abortab
254311
backup_in_progress = true;
255312
pthread_mutex_unlock(&backup_mutex);
256313

314+
backup_deadline_t deadline;
315+
backup_deadline_start(&deadline, abortable);
316+
257317
if (snprintf(temp_path, sizeof(temp_path), "%s.tmp", dest_path) >= (int)sizeof(temp_path)) {
258318
log_error("Destination path is too long for temporary backup file: %s", dest_path);
259319
temp_path[0] = '\0';
@@ -370,6 +430,12 @@ int backup_database(const char *source_path, const char *dest_path, bool abortab
370430
rc = SQLITE_ABORT;
371431
goto cleanup;
372432
}
433+
if (abortable && backup_deadline_exceeded(&deadline)) {
434+
log_error("Database backup aborting early: exceeded maximum duration of %d seconds (stuck-backup safety valve)",
435+
g_backup_max_duration_seconds);
436+
rc = SQLITE_ABORT;
437+
goto cleanup;
438+
}
373439
}
374440

375441
// Finish the backup
@@ -413,6 +479,7 @@ int backup_database(const char *source_path, const char *dest_path, bool abortab
413479
.fd = dest_cache_fd,
414480
.path = temp_path,
415481
.abortable = abortable,
482+
.deadline = &deadline,
416483
};
417484
if (dest_cache_fd >= 0 || abortable) {
418485
sqlite3_progress_handler(dest_db, BACKUP_VERIFY_PROGRESS_OPS,
@@ -422,7 +489,11 @@ int backup_database(const char *source_path, const char *dest_path, bool abortab
422489
int verification_rc = run_integrity_check(dest_db, temp_path);
423490
sqlite3_progress_handler(dest_db, 0, NULL, NULL);
424491
if (verification_rc != 0) {
425-
if (abortable && is_background_abort_requested()) {
492+
if (abortable && verification_progress.deadline_hit) {
493+
log_error("Database backup aborting during verification: exceeded maximum duration of %d seconds (stuck-backup safety valve)",
494+
g_backup_max_duration_seconds);
495+
rc = SQLITE_ABORT;
496+
} else if (abortable && is_background_abort_requested()) {
426497
log_warn("Database backup aborting during verification: restart/shutdown requested");
427498
rc = SQLITE_ABORT;
428499
} else {

‎tests/database/db_backup_test.c‎

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818

1919
#include "database/db_core.h"
2020
#include "database/db_backup.h"
21+
22+
// Test-only hook into db_backup.c's internal duration safety valve. Not
23+
// declared in the public header (production code has no business mutating
24+
// a global backup timeout at runtime), so it's declared here instead.
25+
extern void db_backup_set_max_duration_seconds_for_testing(int seconds);
2126
#include "core/config.h"
2227
#include "core/logger.h"
2328
#include "core/shutdown_coordinator.h"
@@ -652,6 +657,172 @@ static int test_backup_aborts_during_verification_when_shutdown_requested(void)
652657
return result;
653658
}
654659

660+
// Regression test for a bug found live in production: a scheduled backup
661+
// that hung during its copy phase blocked the main loop (which runs
662+
// maybe_run_scheduled_database_backup() synchronously) for almost 12 hours
663+
// straight, silently skipping every other scheduled backup in that window,
664+
// with no way to recover short of an operator happening to trigger a
665+
// restart. Verifies the new stuck-backup safety valve: an abortable backup
666+
// whose duration budget is already exhausted aborts on the very next
667+
// between-batches check instead of running unbounded.
668+
static int test_backup_aborts_early_when_duration_exceeded(void) {
669+
sqlite3 *source = NULL;
670+
sqlite3_stmt *stmt = NULL;
671+
int result = -1;
672+
int rc;
673+
char temp_path[PATH_MAX];
674+
struct stat st;
675+
676+
unlink(TEST_ABORT_DB_PATH);
677+
unlink(TEST_ABORT_BACKUP_PATH);
678+
snprintf(temp_path, sizeof(temp_path), "%s.tmp", TEST_ABORT_BACKUP_PATH);
679+
unlink(temp_path);
680+
681+
rc = sqlite3_open(TEST_ABORT_DB_PATH, &source);
682+
if (rc != SQLITE_OK) {
683+
printf("Failed to create duration-test fixture: %s\n", sqlite3_errmsg(source));
684+
goto cleanup;
685+
}
686+
rc = sqlite3_exec(source, "PRAGMA page_size=4096;", NULL, NULL, NULL);
687+
if (rc != SQLITE_OK) {
688+
printf("Failed to set duration-test fixture page size: %s\n", sqlite3_errmsg(source));
689+
goto cleanup;
690+
}
691+
rc = sqlite3_exec(source,
692+
"CREATE TABLE payload (id INTEGER PRIMARY KEY, data BLOB);",
693+
NULL, NULL, NULL);
694+
if (rc != SQLITE_OK) {
695+
printf("Failed to create duration-test table: %s\n", sqlite3_errmsg(source));
696+
goto cleanup;
697+
}
698+
rc = sqlite3_prepare_v2(source,
699+
"INSERT INTO payload(data) VALUES(zeroblob(?));", -1, &stmt, NULL);
700+
if (rc != SQLITE_OK) {
701+
printf("Failed to prepare duration-test fixture: %s\n", sqlite3_errmsg(source));
702+
goto cleanup;
703+
}
704+
// Larger than one BACKUP_STEP_PAGES batch (16MB) so the between-batches
705+
// deadline check actually gets exercised before the copy would finish.
706+
sqlite3_bind_int(stmt, 1, 20 * 1024 * 1024);
707+
if (sqlite3_step(stmt) != SQLITE_DONE) {
708+
printf("Failed to populate duration-test fixture: %s\n", sqlite3_errmsg(source));
709+
goto cleanup;
710+
}
711+
sqlite3_finalize(stmt);
712+
stmt = NULL;
713+
sqlite3_close(source);
714+
source = NULL;
715+
716+
// Force an already-expired deadline deterministically, rather than
717+
// waiting out a real timeout.
718+
db_backup_set_max_duration_seconds_for_testing(-60);
719+
720+
rc = backup_database(TEST_ABORT_DB_PATH, TEST_ABORT_BACKUP_PATH, true);
721+
db_backup_set_max_duration_seconds_for_testing(DB_BACKUP_MAX_DURATION_SECONDS_DEFAULT);
722+
if (rc == 0) {
723+
printf("Backup should have aborted early on an exhausted duration budget but reported success\n");
724+
goto cleanup;
725+
}
726+
727+
if (stat(temp_path, &st) == 0) {
728+
printf("Duration-aborted backup left behind a temp file: %s\n", temp_path);
729+
goto cleanup;
730+
}
731+
if (stat(TEST_ABORT_BACKUP_PATH, &st) == 0) {
732+
printf("Duration-aborted backup should not have produced a final backup file\n");
733+
goto cleanup;
734+
}
735+
736+
printf("Backup aborted early on an exhausted duration budget, as expected\n");
737+
result = 0;
738+
739+
cleanup:
740+
if (stmt) sqlite3_finalize(stmt);
741+
if (source) sqlite3_close(source);
742+
unlink(TEST_ABORT_DB_PATH);
743+
unlink(TEST_ABORT_BACKUP_PATH);
744+
unlink(temp_path);
745+
return result;
746+
}
747+
748+
// Same production bug as above, but for the verification phase: once the
749+
// copy loop reaches SQLITE_DONE it isn't re-checked, so a stuck
750+
// PRAGMA integrity_check needs its own deadline check (progress_during_
751+
// verification) to ever be interrupted. Uses the same cell-dense,
752+
// single-batch fixture as the shutdown-request verification-abort test so
753+
// the copy loop finishes without consulting the deadline, forcing this test
754+
// to exercise the verification-phase check specifically.
755+
static int test_backup_aborts_during_verification_when_duration_exceeded(void) {
756+
sqlite3 *source = NULL;
757+
sqlite3_stmt *stmt = NULL;
758+
int result = -1;
759+
int rc;
760+
char temp_path[PATH_MAX];
761+
struct stat st;
762+
763+
unlink(TEST_ABORT_DB_PATH);
764+
unlink(TEST_ABORT_BACKUP_PATH);
765+
snprintf(temp_path, sizeof(temp_path), "%s.tmp", TEST_ABORT_BACKUP_PATH);
766+
unlink(temp_path);
767+
768+
rc = sqlite3_open(TEST_ABORT_DB_PATH, &source);
769+
if (rc != SQLITE_OK) {
770+
printf("Failed to create verification-duration-test fixture: %s\n", sqlite3_errmsg(source));
771+
goto cleanup;
772+
}
773+
rc = sqlite3_exec(source, "PRAGMA page_size=4096;", NULL, NULL, NULL);
774+
if (rc != SQLITE_OK) {
775+
printf("Failed to set verification-duration-test fixture page size: %s\n", sqlite3_errmsg(source));
776+
goto cleanup;
777+
}
778+
rc = sqlite3_exec(source,
779+
"CREATE TABLE payload (id INTEGER PRIMARY KEY, data BLOB);",
780+
NULL, NULL, NULL);
781+
if (rc != SQLITE_OK) {
782+
printf("Failed to create verification-duration-test table: %s\n", sqlite3_errmsg(source));
783+
goto cleanup;
784+
}
785+
rc = sqlite3_exec(source,
786+
"WITH RECURSIVE seq(x) AS ("
787+
" SELECT 1 UNION ALL SELECT x+1 FROM seq WHERE x < 300000"
788+
") INSERT INTO payload(data) SELECT randomblob(16) FROM seq;",
789+
NULL, NULL, NULL);
790+
if (rc != SQLITE_OK) {
791+
printf("Failed to populate verification-duration-test fixture: %s\n", sqlite3_errmsg(source));
792+
goto cleanup;
793+
}
794+
sqlite3_close(source);
795+
source = NULL;
796+
797+
db_backup_set_max_duration_seconds_for_testing(-60);
798+
799+
rc = backup_database(TEST_ABORT_DB_PATH, TEST_ABORT_BACKUP_PATH, true);
800+
db_backup_set_max_duration_seconds_for_testing(DB_BACKUP_MAX_DURATION_SECONDS_DEFAULT);
801+
if (rc == 0) {
802+
printf("Backup should have aborted during verification on an exhausted duration budget but reported success\n");
803+
goto cleanup;
804+
}
805+
if (stat(temp_path, &st) == 0) {
806+
printf("Backup aborted during verification (duration) left behind a temp file: %s\n", temp_path);
807+
goto cleanup;
808+
}
809+
if (stat(TEST_ABORT_BACKUP_PATH, &st) == 0) {
810+
printf("Backup aborted during verification (duration) should not have produced a final backup file\n");
811+
goto cleanup;
812+
}
813+
814+
printf("Backup aborted during post-copy verification on an exhausted duration budget, as expected\n");
815+
result = 0;
816+
817+
cleanup:
818+
if (stmt) sqlite3_finalize(stmt);
819+
if (source) sqlite3_close(source);
820+
unlink(TEST_ABORT_DB_PATH);
821+
unlink(TEST_ABORT_BACKUP_PATH);
822+
unlink(temp_path);
823+
return result;
824+
}
825+
655826
static int count_timestamped_backups(const char *db_path) {
656827
char backup_dir[PATH_MAX];
657828
snprintf(backup_dir, sizeof(backup_dir), "%s.backups", db_path);
@@ -876,6 +1047,16 @@ int main(void) {
8761047
return 1;
8771048
}
8781049

1050+
if (test_backup_aborts_early_when_duration_exceeded() != 0) {
1051+
printf("Test failed: Backup did not abort early on an exhausted duration budget\n");
1052+
return 1;
1053+
}
1054+
1055+
if (test_backup_aborts_during_verification_when_duration_exceeded() != 0) {
1056+
printf("Test failed: Backup did not abort during post-copy verification on an exhausted duration budget\n");
1057+
return 1;
1058+
}
1059+
8791060
if (test_shutdown_skips_backup_when_recent_backup_exists() != 0) {
8801061
printf("Test failed: shutdown did not skip a redundant backup\n");
8811062
return 1;

0 commit comments

Comments
 (0)