فایل اصلی به‌تنهایی همیشه نسخهٔ پشتیبان نیست

وقتی SQLite در حالت WAL کار می‌کند، بخشی از تغییرات ثبت‌شده می‌تواند هنوز در فایل WAL باشد. کپی‌کردن تنها فایل اصلی در زمان فعالیت برنامه، قرارداد روشنی برای یک نسخهٔ قابل بازیابی ایجاد نمی‌کند. پیش از انتخاب روش، مشخص کنید چه داده‌ای باید قابل بازیابی باشد، برنامه چه مدت می‌تواند منتظر بماند و آخرین تمرین بازیابی چه زمانی انجام شده است. وجود یک فایل با پسوند db به‌تنهایی نشانهٔ موفقیت نیست.

Backup API امکان کپی‌کردن محتویات یک اتصال به پایگاه مقصد را فراهم می‌کند. در نمونهٔ پایتون از Connection.backup استفاده می‌کنیم و منبع را با URI فقط‌خواندنی باز می‌کنیم تا نبودن فایل، پایگاه خالی جدید نسازد. مقصد ابتدا نام موقت دارد. پس از پایان کپی، quick_check اجرا می‌شود و سپس نسخهٔ کامل با نام نهایی منتشر می‌شود. این مراحل، کپی فایل خام و تغییر هم‌زمان فایل‌های WAL را به کد برنامه واگذار نمی‌کنند.

ساخت snapshot، محدودیت زمان و انتشار نتیجه

برنامهٔ نمونه فایل موجود در مقصد را بازنویسی نمی‌کند. نام موقت در همان پوشه قرار دارد و hard link پس از موفقیت به آن اشاره می‌کند؛ در صورت وجود نام نهایی، انتشار شکست می‌خورد. این قرارداد به پشتیبانی فایل‌سیستم از hard link نیاز دارد. محدودیت زمانی با callback پیشرفت بررسی می‌شود، بنابراین مهلت در مرز callbackها کنترل می‌شود و یک تضمین زمان‌بندی سخت برای هر فراخوانی سیستم‌عامل نیست.

آزمون WAL یک اتصال منبع را باز نگه می‌دارد، داده را commit می‌کند و سپس نسخهٔ پشتیبان را از اتصال جداگانه می‌سازد. شمارش رکوردها و integrity_check روی پایگاه کپی‌شده انجام می‌شود. دو آزمون دیگر نبودن منبع و حفظ فایل مقصد موجود را بررسی می‌کنند. اتصال sqlite3 با contextlib.closing بسته می‌شود؛ محدودهٔ with خود اتصال به‌تنهایی مسئول بستن فایل نیست و این تفاوت در ویندوز اهمیت عملی دارد.

برای استفادهٔ عملی، نسخهٔ کتابخانهٔ داخلی SQLite را از sqlite3.sqlite_version بخوانید. یادداشت رسمی WAL-reset در سال ۲۰۲۶ اصلاح مرتبط با نوشتن و checkpoint هم‌زمان را توضیح می‌دهد؛ اصلاح در 3.51.3 و جدیدتر و نیز بعضی شاخه‌های پشتیبانی‌شده ارائه شده است. نسخهٔ پایتون به‌تنهایی نسخهٔ SQLite را مشخص نمی‌کند. تمرین این مقاله جای بررسی patch کتابخانه، ظرفیت ذخیره‌سازی و مجوزهای فایل‌سیستم در محیط واقعی را نمی‌گیرد.

فایل‌های کامل، روش اجرا و آزمون

Python 3.11+، ماژول sqlite3 و فایل‌سیستم محلی دارای hard link؛ برای تولید نسخهٔ اصلاح‌شدهٔ SQLite انتخاب شود. چهار فایل زیر را با همین نام‌ها در یک پوشه ذخیره کنید؛ هیچ بخش کد با سه‌نقطه جایگزین نشده است.

1. پشتیبان‌گیری با نام موقت و مهلت مشخص

نام فایل: backup_sqlite.py؛ این فایل را کامل و بدون حذف سطرها ذخیره کنید.

from contextlib import closing
import os
from pathlib import Path
import sqlite3
import time
import uuid


def snapshot(source, destination, *, budget_seconds=10):
    source, destination = Path(source).resolve(), Path(destination).resolve()
    if source == destination or budget_seconds <= 0:
        raise ValueError('Use different paths and a positive time budget')
    if not source.is_file():
        raise FileNotFoundError(source)
    temporary = destination.with_name(destination.name + '.pending-' + uuid.uuid4().hex)
    deadline = time.monotonic() + budget_seconds
    def progress(status, remaining, total):
        if time.monotonic() > deadline:
            raise TimeoutError('Backup exceeded its time budget')
    try:
        with closing(sqlite3.connect(source.as_uri() + '?mode=ro', uri=True, timeout=.25)) as live:
            with closing(sqlite3.connect(temporary)) as backup:
                live.backup(backup, pages=32, progress=progress, sleep=.05)
                if backup.execute('PRAGMA quick_check').fetchall() != [('ok',)]:
                    raise RuntimeError('Backup integrity check failed')
        # Both paths are in one directory: atomic publication, no overwrite.
        os.link(temporary, destination)
    finally:
        temporary.unlink(missing_ok=True)
    return destination

snapshot را با مسیر منبع و مقصد متفاوت فراخوانی کنید. منبع فقط‌خواندنی است، اتصال‌ها بسته می‌شوند و مقصد موجود با hard link بازنویسی نمی‌شود.

2. نمونهٔ کامل با WAL فعال

نام فایل: seed_wal.py؛ این فایل را کامل و بدون حذف سطرها ذخیره کنید.

from contextlib import closing
from pathlib import Path
import sqlite3
import tempfile
from backup_sqlite import snapshot


with tempfile.TemporaryDirectory(prefix='liyan-wal-demo-') as folder:
    source = Path(folder) / 'live.db'
    destination = Path(folder) / 'backup.db'
    live = sqlite3.connect(source)
    try:
        assert live.execute('PRAGMA journal_mode=WAL').fetchone()[0] == 'wal'
        live.execute('CREATE TABLE documents(id INTEGER PRIMARY KEY, title TEXT NOT NULL)')
        live.executemany('INSERT INTO documents(title) VALUES (?)',
                         [(f'document-{i}',) for i in range(100)])
        live.commit()
        assert Path(str(source) + '-wal').is_file()
        snapshot(source, destination)
        with closing(sqlite3.connect(destination)) as restored:
            assert restored.execute('SELECT COUNT(*) FROM documents').fetchone()[0] == 100
            print('restored rows:', 100)
    finally:
        live.close()

اجرای python seed_wal.py باید restored rows: 100 بدهد. اتصال منبع تا پایان بررسی نسخهٔ پشتیبان باز می‌ماند و داده پیش از کپی commit شده است.

3. آزمون بازیابی و حفظ مقصد موجود

نام فایل: test_backup_sqlite.py؛ این فایل را کامل و بدون حذف سطرها ذخیره کنید.

from contextlib import closing
from pathlib import Path
import sqlite3
import tempfile
import unittest
from backup_sqlite import snapshot


class BackupTests(unittest.TestCase):
    def test_wal_snapshot_with_open_writer_connection(self):
        with tempfile.TemporaryDirectory(prefix='liyan-backup-test-') as folder:
            source, target = Path(folder) / 'live.db', Path(folder) / 'backup.db'
            live = sqlite3.connect(source)
            try:
                live.execute('PRAGMA journal_mode=WAL')
                live.execute('CREATE TABLE documents(id INTEGER PRIMARY KEY, title TEXT)')
                live.execute('INSERT INTO documents VALUES (?, ?)', (1, 'approved'))
                live.commit()
                snapshot(source, target)
                with closing(sqlite3.connect(target)) as copied:
                    self.assertEqual(copied.execute('SELECT title FROM documents').fetchall(), [('approved',)])
                    self.assertEqual(copied.execute('PRAGMA integrity_check').fetchone(), ('ok',))
            finally:
                live.close()

    def test_missing_source_is_not_created(self):
        with tempfile.TemporaryDirectory(prefix='liyan-backup-missing-') as folder:
            source = Path(folder) / 'missing.db'
            with self.assertRaises(FileNotFoundError):
                snapshot(source, Path(folder) / 'backup.db')
            self.assertFalse(source.exists())

    def test_existing_target_survives(self):
        with tempfile.TemporaryDirectory(prefix='liyan-backup-existing-') as folder:
            source, target = Path(folder) / 'live.db', Path(folder) / 'existing.db'
            with closing(sqlite3.connect(source)) as live:
                live.execute('CREATE TABLE documents(id INTEGER PRIMARY KEY)')
            target.write_bytes(b'keep this file')
            with self.assertRaises(FileExistsError):
                snapshot(source, target)
            self.assertEqual(target.read_bytes(), b'keep this file')
            self.assertEqual(sorted(p.name for p in Path(folder).iterdir()), ['existing.db', 'live.db'])


if __name__ == '__main__':
    unittest.main()

با python -m unittest test_backup_sqlite.py سه آزمون اجرا می‌شود. آزمون فایل مفقود و فایل مقصد موجود کمک می‌کند خطاها به موفقیت ظاهری تبدیل نشوند.

4. بررسی فایل بازیابی‌شده از خط فرمان

نام فایل: restore_check.py؛ این فایل را کامل و بدون حذف سطرها ذخیره کنید.

from contextlib import closing
import argparse
import json
from pathlib import Path
import sqlite3


def verify_restore(path):
    path = Path(path).resolve()
    with closing(sqlite3.connect(path.as_uri() + '?mode=ro', uri=True)) as database:
        integrity = database.execute('PRAGMA integrity_check').fetchall()
        foreign_keys = database.execute('PRAGMA foreign_key_check').fetchall()
        rows = database.execute('SELECT COUNT(*) FROM documents').fetchone()[0]
    if integrity != [('ok',)] or foreign_keys:
        raise RuntimeError('The restored database failed validation')
    return {'integrity': 'ok', 'foreign_key_errors': 0, 'documents': rows}


if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('database')
    args = parser.parse_args()
    print(json.dumps(verify_restore(args.database)))

برای نسخهٔ نمونه، python restore_check.py backup.db را اجرا کنید. نام جدول documents مخصوص این مثال است؛ برای پروژهٔ واقعی کوئری‌های پذیرش خود را تعریف کنید.

بازیابی آزمایشی بخشی از پشتیبان‌گیری است

ابتدا python -m unittest test_backup_sqlite.py و سپس python seed_wal.py را اجرا کنید؛ نمونهٔ دوم بازیابی صد رکورد را نشان می‌دهد. فایل restore_check.py برای بررسی یک نسخهٔ دارای جدول documents آماده است و integrity، کلیدهای خارجی و تعداد رکورد را گزارش می‌کند. برنامهٔ بازیابی واقعی باید شروع سرویس، کوئری‌های مهم و دسترسی‌ها را نیز امتحان کند. مدت نگهداری، رمزگذاری و نگهداری نسخه در محل مستقل را به سیاست عملیاتی اضافه کنید.

چک‌لیست اجرای عملی

  • نسخهٔ SQLite و وضعیت اصلاح WAL-reset را بررسی کنید.
  • منبع مفقود نباید پایگاه خالی ایجاد کند.
  • بازیابی و integrity_check را واقعاً اجرا کنید.
  • فایل مقصد موجود باید حفظ شود؛ فضای ذخیره‌سازی و مجوزها را بررسی کنید.

منابع

تدوین و راهنمای عملی: دانشنامه لیان.