Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,3 +459,46 @@ seed idempotent למשתמש היחיד מ-`ADMIN_EMAIL`/`ADMIN_PASSWORD`, bcryp
עריכה משותפת בזמן אמת, הרשאות ברמת המסמך, ייצוא ל-PDF, תגובות, וסנכרון דו-כיווני עם Git.

כל אלה סבירים בהמשך. אף אחד מהם לא נדרש כדי שהמערכת תהיה שימושית.

---

## שחזור

גיבוי שלא ניסית לשחזר ממנו הוא לא גיבוי — הוא תקווה. לכן `scripts/restore.py`
הוא חלק מהמנגנון ולא נספח לו, ויש בדיקה שסוגרת את המעגל: יצירה → גיבוי →
מחיקת הכול → שחזור → השוואה. **היא מצאה שני באגים אמיתיים ברגע שנכתבה**,
ושניהם היו שקטים: הפרויקט והמסמכים נוצרו מחדש תחת slug שנגזר מהשם ומהכותרת
במקום זה שנשמר. השחזור היה "מצליח", וכל כתובת שנשלחה למישהו הייתה נשברת.

### שלושת מצבי הכתיבה

| מצב | מה קורה | מתי |
|---|---|---|
| `skip` (ברירת מחדל) | כותב רק מה שלא קיים | מסד ריק, מיגרציה, סביבת בדיקה. לא יכול להזיק |
| `upsert` | דורס את מה שבגיבוי, משאיר מה שנוצר אחריו | שחזור נקודתי — "מחקתי בטעות מסמכים אתמול" |
| `replace` | מוחק כל פרויקט שבגיבוי ובונה מחדש | שחזור מלא אחרי אובדן. **מוחק מה שנוצר אחרי הגיבוי** |

לפני שנוגעים בכלום מוצג בדיוק מה עומד לקרות, והאישור נשאל עם ברירת מחדל
`N` — Enter בטעות לא משחזר כלום. `--dry-run` מציג בלי לכתוב.

### תרגיל שחזור

חמש דקות ברבעון. הפעם היחידה שבה תגלה שהגיבוי שבור היא הפעם שבה זה כבר לא
משנה.

1. הורד גיבוי מהכפתור בסרגל.
2. הרם עותק מקומי של המערכת מול בסיס נתונים ריק.
3. `python3 scripts/restore.py backup-XXX.zip --url http://127.0.0.1:8000`
4. פתח את האתר וּודא שהתוכן נראה נכון.
5. מחק את בסיס הנתונים המקומי.

### נראות

`GET /api/backup` מחזיר את שלוש השאלות שחשובות — האם המנגנון פעיל, מתי רץ
לאחרונה, ומתי הבא — ואת רשימת הקבצים. בממשק יש פאנל שמציג אותן ולחצן
"גיבוי עכשיו". `POST /api/backup/run` מוגן בנעילה, והבדיקה `is_running()`
נעשית **לפני** התפיסה: בקשה שנייה חוזרת מיד עם 409 במקום להמתין לסיום ואז
להריץ גיבוי נוסף. תפוס אינו שבור, והמשתמש צריך לדעת מה מהשניים.

הנעילה היא בתוך התהליך. השירות מוגבל למופע יחיד ממילא בגלל הדיסק, ולכן זה
מספיק — ריצה בכמה workers הייתה דורשת נעילה מבוזרת.
68 changes: 65 additions & 3 deletions app/routers/backup.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""הורדת גיבוי מלא."""
"""הורדת גיבוי מלא, ומצב מנגנון הגיבוי."""

from __future__ import annotations

from datetime import UTC, datetime

from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import JSONResponse, StreamingResponse

from app import scheduler
from app.backup import stream_archive
from app.config import get_settings
from app.deps import require_user
from app.models import User

Expand All @@ -28,3 +30,63 @@ async def download_backup(user: User = Depends(require_user)) -> StreamingRespon
# ASCII בלבד בשם: כותרת עם תווים עבריים שוברת חלק מהלקוחות.
headers={"Content-Disposition": f'attachment; filename="{name}"'},
)


@router.get("")
async def backup_status(user: User = Depends(require_user)) -> JSONResponse:
"""מצב מנגנון הגיבוי.

שלוש שאלות, ובכוונה בראש התשובה: האם המנגנון פעיל, מתי רץ לאחרונה,
ומתי הבא. גיבוי שאף אחד לא רואה הוא גיבוי שאף אחד לא מגלה שהפסיק
לעבוד — וה-log של Render אינו מקום שבודקים בו כל יום.
"""
settings = get_settings()

# מעבר אחד ו-stat אחד לכל קובץ. שני מעברים הרחיבו את החלון שבו קובץ
# נמחק בין אחד לשני — גיזום, שחזור, או ניקוי ידני — ואז הקריאה
# השנייה זורקת והמסך שאמור לדווח על מצב הגיבוי מחזיר 500.
entries = []
total = 0
for path in scheduler.existing_backups():
try:
size = path.stat().st_size
except OSError:
continue
total += size
stamp = scheduler.stamp_of(path)
entries.append(
{"name": path.name, "bytes": size, "at": stamp.isoformat() if stamp else None}
)

return JSONResponse(
{
"enabled": settings.backup_enabled,
"running": scheduler.is_running(),
"every_hours": settings.backup_every_hours,
"keep": settings.backup_keep,
"offsite_enabled": settings.backup_telegram_enabled,
"last_run": scheduler.last_run(),
"next_run_at": scheduler.next_run_at(),
"total_bytes": total,
"backups": entries,
}
)


@router.post("/run", status_code=status.HTTP_200_OK)
async def run_backup_now(user: User = Depends(require_user)) -> JSONResponse:
"""מפעיל גיבוי עכשיו, בלי להמתין לתזמון.

force=True מדלג על בדיקת "האם עברו 24 שעות" — מי שלחץ על הכפתור
התכוון לגבות עכשיו. הוא אינו מדלג על הנעילה.
"""
result = await scheduler.run_guarded(force=True)

if not result["ok"]:
# 409 ולא 500: תפוס אינו שבור. בלי ההבחנה הזאת שני המצבים
# נראים למשתמש אותו דבר, והוא לא יודע אם לנסות שוב או לחקור.
if result.get("error") == scheduler.ALREADY_RUNNING:
raise HTTPException(status.HTTP_409_CONFLICT, scheduler.ALREADY_RUNNING)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "הגיבוי נכשל")

return JSONResponse({"file": result["file"], "skipped": result["skipped"]})
124 changes: 115 additions & 9 deletions app/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,36 @@
PREFIX = "backup-"
STAMP_FORMAT = "%Y%m%d-%H%M%S"

# הודעה קבועה, כי ה-router מבדיל לפיה בין "תפוס" לבין "נשבר".
ALREADY_RUNNING = "גיבוי כבר רץ כרגע"

# נעילה בתוך התהליך. שני מקורות יכולים להפעיל גיבוי — המתזמן והכפתור
# הידני — ובלעדיה שניהם שולפים במקביל את כל התוכן ומתנגשים על אותו שם
# קובץ.
#
# מגבלה שכדאי שתהיה כתובה: הנעילה היא לתהליך אחד. השירות מוגבל למופע
# יחיד ממילא בגלל הדיסק, ולכן זה מספיק כאן; ריצה בכמה workers הייתה
# דורשת נעילה מבוזרת.
_lock = asyncio.Lock()

# תוצאת הריצה האחרונה, לתצוגה. בזיכרון בלבד — אחרי הפעלה מחדש היא ריקה
# עד הגיבוי הבא, וזה מקובל: מה שקובע באמת הוא הקבצים על הדיסק.
_last_run: dict | None = None


def last_run() -> dict | None:
return _last_run


def is_running() -> bool:
return _lock.locked()


def backup_dir() -> Path:
return Path(get_settings().backup_dir)


def _stamp_of(path: Path) -> datetime | None:
def stamp_of(path: Path) -> datetime | None:
"""קורא את הזמן מתוך שם הקובץ.

מ-mtime ולא משם הקובץ היה נשבר בהעתקה או בשחזור של הדיסק, ששניהם
Expand All @@ -44,8 +68,11 @@ def _stamp_of(path: Path) -> datetime | None:
if not name.startswith(PREFIX) or not name.endswith(SUFFIX):
return None
core = name[len(PREFIX) : -len(SUFFIX)]
# שני גיבויים באותה שנייה מקבלים סיומת -2, -3. החותמת היא החלק
# שלפניה.
stamp = core.split("-dup", 1)[0]
try:
return datetime.strptime(core, STAMP_FORMAT).replace(tzinfo=UTC)
return datetime.strptime(stamp, STAMP_FORMAT).replace(tzinfo=UTC)
except ValueError:
return None

Expand All @@ -55,7 +82,7 @@ def existing_backups(directory: Path | None = None) -> list[Path]:
target = directory or backup_dir()
if not target.is_dir():
return []
dated = [(stamp, path) for path in target.iterdir() if (stamp := _stamp_of(path)) is not None]
dated = [(stamp, path) for path in target.iterdir() if (stamp := stamp_of(path)) is not None]
dated.sort(key=lambda pair: (pair[0], pair[1].name), reverse=True)
return [path for _, path in dated]

Expand Down Expand Up @@ -88,6 +115,14 @@ async def write_backup(directory: Path | None = None) -> Path:
stamp = datetime.now(UTC).strftime(STAMP_FORMAT)
path = target / f"{PREFIX}{stamp}{SUFFIX}"

# החותמת היא ברזולוציית שנייה. עם הגיבוי היומי זה לא נתקל, אבל
# לחיצה כפולה על "גיבוי עכשיו" נופלת בדיוק לכאן — והקובץ השני היה
# דורס את הראשון בשקט, כלומר גיבוי שנעלם.
n = 2
while path.exists():
path = target / f"{PREFIX}{stamp}-dup{n}{SUFFIX}"
n += 1

# כתיבה לקובץ זמני והחלפה אטומית. קריסה באמצע כתיבה ישירה משאירה
# ארכיון חתוך ששום דבר לא מסמן אותו כפגום.
temp = path.with_suffix(".part")
Expand Down Expand Up @@ -143,14 +178,69 @@ def _due(now: datetime, last: datetime | None, every_hours: int) -> bool:
return last is None or now - last >= timedelta(hours=every_hours)


async def run_once() -> Path | None:
"""סבב אחד: כותב אם הגיע הזמן, ושולח החוצה אם הגיע הזמן לזה."""
async def run_guarded(force: bool = False) -> dict:
"""נקודת הכניסה היחידה שמריצה גיבוי, ולעולם לא זורקת.

התפיסה אינה חוסמת. `async with _lock` לבדו היה גורם לקריאה שנייה
להמתין לסיום הראשונה ואז להריץ גיבוי נוסף — בקשת HTTP שתקועה שתי
דקות ומייצרת עבודה כפולה. כאן היא חוזרת מיד, וה-router מתרגם את זה
ל-409 ולא ל-500: "תפוס, נסה עוד רגע" הוא מצב אחר לגמרי מ"משהו נשבר".
"""
global _last_run

# בדיקה ואז תפיסה, וזה נכון כאן למרות שכלל 2 מזהיר מהצירוף הזה.
#
# ל-asyncio.Lock אין try-acquire, ו-`wait_for(acquire(), timeout=0)`
# אינו תחליף: timeout=0 מבטל את המשימה לפני שהיא רצה, ולכן הוא נכשל
# *תמיד* — גם כשהנעילה פנויה. כלומר שום גיבוי לא היה רץ יותר.
#
# מה שהופך את הצירוף לבטוח הוא שאין כאן נקודת השהיה: acquire של
# asyncio.Lock חוזר בלי להשתהות כשהנעילה פנויה, ולולאת האירועים היא
# חד-חוטית. מה שמאמת את זה הוא בדיקה שיורה 20 קריאות במקביל ומוודאת
# שרצה בדיוק אחת — לא הנימוק הזה.
if _lock.locked():
return {"ok": False, "error": ALREADY_RUNNING}

await _lock.acquire()
try:
started = datetime.now(UTC)
try:
path = await run_once(force=force)
except Exception as exc:
# המתזמן קורא לכאן. חריגה שיוצאת החוצה מפילה את הלולאה
# ומשאירה את המערכת בלי גיבויים — בשקט.
logger.exception("הגיבוי נכשל")
_last_run = {
"ok": False,
"at": started.isoformat(),
"error": type(exc).__name__,
}
return {"ok": False, "error": "הגיבוי נכשל"}

_last_run = {
"ok": True,
"at": started.isoformat(),
"file": path.name if path else None,
"skipped": path is None,
}
return {"ok": True, "file": path.name if path else None, "skipped": path is None}
finally:
# ב-finally ולא בסוף הבלוק: גם כישלון וגם ביטול המשימה חייבים
# לשחרר, אחרת כל ניסיון עתידי יקבל "תפוס" לנצח.
_lock.release()


async def run_once(force: bool = False) -> Path | None:
"""סבב אחד: כותב אם הגיע הזמן, ושולח החוצה אם הגיע הזמן לזה.

force מדלג על בדיקת התזמון בלבד — הוא לא מדלג על הנעילה.
"""
settings = get_settings()
now = datetime.now(UTC)

latest = existing_backups()
last = _stamp_of(latest[0]) if latest else None
if not _due(now, last, settings.backup_every_hours):
last = stamp_of(latest[0]) if latest else None
if not force and not _due(now, last, settings.backup_every_hours):
return None

path = await write_backup()
Expand Down Expand Up @@ -182,10 +272,26 @@ async def loop() -> None:
)
while True:
try:
await run_once()
# run_guarded בולעת שגיאות בעצמה ומחזירה תוצאה, ולכן הסבב
# הבא תמיד מגיע גם אחרי כישלון.
await run_guarded()
except asyncio.CancelledError:
raise
except Exception:
# תקלה בגיבוי לא מפילה את השרת ולא עוצרת את הסבב הבא.
logger.exception("סבב הגיבוי נכשל")
await asyncio.sleep(TICK_SECONDS)


def next_run_at() -> str | None:
"""מתי הגיבוי הבא צפוי, לפי הגיבוי האחרון שעל הדיסק.

נגזר ולא נשמר: המתזמן לא מחזיק לוח זמנים משלו, אלא שואל בכל דופק
"האם עברו מספיק שעות". לכן זו התשובה הנכונה גם אחרי הפעלה מחדש.
"""
latest = existing_backups()
if not latest:
return None
last = stamp_of(latest[0])
if last is None:
return None
return (last + timedelta(hours=get_settings().backup_every_hours)).isoformat()
Loading