112 lines
3.9 KiB
Python
112 lines
3.9 KiB
Python
"""Sync state persistence for Jira Project Knowledge.
|
|
|
|
A ``SyncManifest`` records the operational state of one project's Jira sync:
|
|
when it last succeeded, how many issues were processed or failed, and the
|
|
incremental checkpoint (Jira ``updated > timestamp``) for the next run.
|
|
|
|
Persistence uses atomic JSON writes so a crash mid-sync cannot corrupt the
|
|
manifest and cause duplicate or lost work on recovery.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import asdict, dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
|
|
@dataclass
|
|
class SyncManifest:
|
|
"""Operational state of one project's Jira knowledge sync.
|
|
|
|
All timestamps are ISO-8601 UTC strings. ``sync_cursor`` is the Jira
|
|
``updated`` timestamp watermark; the next incremental sync fetches issues
|
|
with ``updated >= sync_cursor``.
|
|
"""
|
|
project_id: str
|
|
jira_project_key: str = ""
|
|
last_successful_sync: str = ""
|
|
last_attempted_sync: str = ""
|
|
sync_cursor: str = ""
|
|
processed_count: int = 0
|
|
failed_count: int = 0
|
|
error_category: str = ""
|
|
total_issues_indexed: int = 0
|
|
sync_duration_seconds: float = 0.0
|
|
extra: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
def mark_attempt(self) -> None:
|
|
"""Record that a sync attempt has started."""
|
|
self.last_attempted_sync = datetime.now(timezone.utc).isoformat()
|
|
|
|
def mark_success(
|
|
self,
|
|
*,
|
|
processed: int,
|
|
failed: int,
|
|
cursor: str,
|
|
duration: float,
|
|
total_indexed: int,
|
|
) -> None:
|
|
"""Record a completed sync with its outcomes."""
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
self.last_successful_sync = now
|
|
self.last_attempted_sync = now
|
|
self.processed_count = processed
|
|
self.failed_count = failed
|
|
self.sync_cursor = cursor
|
|
self.sync_duration_seconds = round(duration, 2)
|
|
self.total_issues_indexed = total_indexed
|
|
self.error_category = ""
|
|
|
|
def mark_failure(self, category: str, failed: int = 0) -> None:
|
|
"""Record a failed sync attempt without losing the previous cursor."""
|
|
self.last_attempted_sync = datetime.now(timezone.utc).isoformat()
|
|
self.error_category = category
|
|
if failed:
|
|
self.failed_count = failed
|
|
|
|
|
|
def _manifest_path(index_root: Path, project_id: str) -> Path:
|
|
"""Deterministic manifest path for one project."""
|
|
safe = "".join(c if (c.isalnum() or c in "-_") else "_" for c in project_id)
|
|
return index_root / safe / "manifest.json"
|
|
|
|
|
|
def load_manifest(index_root: Path, project_id: str) -> SyncManifest:
|
|
"""Load the manifest for ``project_id``, returning a fresh one if absent.
|
|
|
|
Never raises on missing or corrupt files — a missing manifest simply means
|
|
"first sync", and a corrupt one is treated the same way (the operator can
|
|
inspect the file manually if needed).
|
|
"""
|
|
path = _manifest_path(index_root, project_id)
|
|
if not path.exists():
|
|
return SyncManifest(project_id=project_id)
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
known = {f.name for f in SyncManifest.__dataclass_fields__.values()}
|
|
return SyncManifest(**{k: v for k, v in data.items() if k in known})
|
|
except (OSError, json.JSONDecodeError, TypeError):
|
|
return SyncManifest(project_id=project_id)
|
|
|
|
|
|
def save_manifest(index_root: Path, manifest: SyncManifest) -> None:
|
|
"""Atomically persist ``manifest`` to disk.
|
|
|
|
Creates the project directory if it does not exist. Uses the shared
|
|
atomic-write helper so a crash between truncate and write cannot leave
|
|
a half-written manifest.
|
|
"""
|
|
path = _manifest_path(index_root, manifest.project_id)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
from ...infrastructure.persistence.json.atomic_write import write_json
|
|
write_json(path, asdict(manifest))
|
|
|
|
|
|
__all__ = [
|
|
"SyncManifest",
|
|
"load_manifest",
|
|
"save_manifest",
|
|
] |