|
18 | 18 |
|
19 | 19 | #include "database/db_core.h" |
20 | 20 | #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); |
21 | 26 | #include "core/config.h" |
22 | 27 | #include "core/logger.h" |
23 | 28 | #include "core/shutdown_coordinator.h" |
@@ -652,6 +657,172 @@ static int test_backup_aborts_during_verification_when_shutdown_requested(void) |
652 | 657 | return result; |
653 | 658 | } |
654 | 659 |
|
| 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 | + |
655 | 826 | static int count_timestamped_backups(const char *db_path) { |
656 | 827 | char backup_dir[PATH_MAX]; |
657 | 828 | snprintf(backup_dir, sizeof(backup_dir), "%s.backups", db_path); |
@@ -876,6 +1047,16 @@ int main(void) { |
876 | 1047 | return 1; |
877 | 1048 | } |
878 | 1049 |
|
| 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 | + |
879 | 1060 | if (test_shutdown_skips_backup_when_recent_backup_exists() != 0) { |
880 | 1061 | printf("Test failed: shutdown did not skip a redundant backup\n"); |
881 | 1062 | return 1; |
|
0 commit comments