OPEN SOURCE
software/src/orchestration/state.py
Read on this site and download the files you need.
Nichijoyaku was created using AI. A complete review by human biblical-language and biblical-studies specialists has not been established. Read alongside established translations and original-language resources.
← Back to the open-source guide
1"""SQLite-based job state persistence for fault-tolerant translation.23Provides ACID-compliant storage for job state, enabling crash recovery4and resume functionality. Uses SQLite for reliable atomic operations.5"""67from __future__ import annotations89import json10import sqlite311from contextlib import contextmanager12from datetime import datetime13from pathlib import Path14from typing import Any, Generator1516from src.orchestration.models import BibleJob, BookJob, JobStatus, VerseJob171819class JobStateStore:20"""SQLite-based storage for Bible translation job state.2122Provides atomic operations for:23- Creating and managing Bible translation jobs24- Tracking verse-level progress25- Claiming verses for worker processes26- Recovering from crashes2728All operations use SQLite transactions for ACID compliance.29"""3031def __init__(self, db_path: Path | str) -> None:32"""Initialize the job state store.3334Args:35db_path: Path to the SQLite database file.36"""37self.db_path = Path(db_path)38self.db_path.parent.mkdir(parents=True, exist_ok=True)39self._init_schema()4041def _init_schema(self) -> None:42"""Initialize the database schema."""43with self._connection() as conn:44conn.executescript("""45CREATE TABLE IF NOT EXISTS bible_jobs (46job_id TEXT PRIMARY KEY,47books TEXT NOT NULL,48status TEXT NOT NULL DEFAULT 'pending',49config_snapshot TEXT NOT NULL DEFAULT '{}',50created_at TEXT NOT NULL,51started_at TEXT,52completed_at TEXT,53total_verses INTEGER DEFAULT 0,54completed_verses INTEGER DEFAULT 0,55failed_verses INTEGER DEFAULT 056);5758CREATE TABLE IF NOT EXISTS book_jobs (59job_id TEXT NOT NULL,60book TEXT NOT NULL,61status TEXT NOT NULL DEFAULT 'pending',62total_verses INTEGER DEFAULT 0,63completed_verses INTEGER DEFAULT 0,64failed_verses INTEGER DEFAULT 0,65worker_pid INTEGER,66started_at TEXT,67completed_at TEXT,68error_message TEXT,69PRIMARY KEY (job_id, book),70FOREIGN KEY (job_id) REFERENCES bible_jobs(job_id)71);7273CREATE TABLE IF NOT EXISTS verse_jobs (74job_id TEXT NOT NULL,75book TEXT NOT NULL,76chapter INTEGER NOT NULL,77verse INTEGER NOT NULL,78status TEXT NOT NULL DEFAULT 'pending',79attempts INTEGER DEFAULT 0,80last_error TEXT,81worker_pid INTEGER,82started_at TEXT,83completed_at TEXT,84PRIMARY KEY (job_id, book, chapter, verse),85FOREIGN KEY (job_id) REFERENCES bible_jobs(job_id)86);8788CREATE INDEX IF NOT EXISTS idx_verse_pending89ON verse_jobs(job_id, book, status)90WHERE status = 'pending';9192CREATE INDEX IF NOT EXISTS idx_bible_jobs_status93ON bible_jobs(status);9495CREATE INDEX IF NOT EXISTS idx_book_jobs_status96ON book_jobs(job_id, status);97""")9899@contextmanager100def _connection(self) -> Generator[sqlite3.Connection, None, None]:101"""Create a database connection with proper settings.102103Yields:104SQLite connection with row factory configured.105"""106conn = sqlite3.connect(str(self.db_path), timeout=30.0)107conn.row_factory = sqlite3.Row108conn.execute("PRAGMA journal_mode=WAL")109conn.execute("PRAGMA foreign_keys=ON")110try:111yield conn112conn.commit()113except Exception:114conn.rollback()115raise116finally:117conn.close()118119# -------------------------------------------------------------------------120# Bible Job Management121# -------------------------------------------------------------------------122123def create_bible_job(124self,125books: list[str],126config: dict[str, Any],127job_id: str | None = None,128) -> BibleJob:129"""Create a new Bible translation job.130131Args:132books: List of book names to translate.133config: Configuration snapshot for this job.134job_id: Optional specific job ID (generates UUID if not provided).135136Returns:137Created BibleJob instance.138"""139import uuid140141job = BibleJob(142job_id=job_id or str(uuid.uuid4()),143books=books,144status=JobStatus.PENDING,145config_snapshot=config,146created_at=datetime.utcnow(),147)148149with self._connection() as conn:150conn.execute(151"""152INSERT INTO bible_jobs153(job_id, books, status, config_snapshot, created_at,154total_verses, completed_verses, failed_verses)155VALUES (?, ?, ?, ?, ?, ?, ?, ?)156""",157(158job.job_id,159json.dumps(job.books),160job.status.value,161json.dumps(job.config_snapshot),162job.created_at.isoformat(),163job.total_verses,164job.completed_verses,165job.failed_verses,166),167)168169# Create book job entries170for book in books:171conn.execute(172"""173INSERT INTO book_jobs (job_id, book, status)174VALUES (?, ?, ?)175""",176(job.job_id, book, JobStatus.PENDING.value),177)178179return job180181def get_bible_job(self, job_id: str) -> BibleJob | None:182"""Get a Bible job by ID.183184Args:185job_id: Job identifier.186187Returns:188BibleJob if found, None otherwise.189"""190with self._connection() as conn:191row = conn.execute(192"SELECT * FROM bible_jobs WHERE job_id = ?",193(job_id,),194).fetchone()195196if not row:197return None198199return self._row_to_bible_job(row)200201def get_latest_incomplete_job(self) -> BibleJob | None:202"""Get the most recent incomplete job.203204Returns:205Most recent BibleJob with status pending or in_progress.206"""207with self._connection() as conn:208row = conn.execute(209"""210SELECT * FROM bible_jobs211WHERE status IN (?, ?)212ORDER BY created_at DESC213LIMIT 1214""",215(JobStatus.PENDING.value, JobStatus.IN_PROGRESS.value),216).fetchone()217218if not row:219return None220221return self._row_to_bible_job(row)222223def update_job_status(224self,225job_id: str,226status: JobStatus,227started_at: datetime | None = None,228completed_at: datetime | None = None,229) -> None:230"""Update the status of a Bible job.231232Args:233job_id: Job identifier.234status: New status.235started_at: When job started (if transitioning to in_progress).236completed_at: When job completed (if transitioning to completed/failed).237"""238with self._connection() as conn:239updates = ["status = ?"]240params: list[Any] = [status.value]241242if started_at:243updates.append("started_at = ?")244params.append(started_at.isoformat())245246if completed_at:247updates.append("completed_at = ?")248params.append(completed_at.isoformat())249250params.append(job_id)251252conn.execute(253f"UPDATE bible_jobs SET {', '.join(updates)} WHERE job_id = ?",254params,255)256257def update_job_verse_counts(258self,259job_id: str,260total: int | None = None,261completed: int | None = None,262failed: int | None = None,263) -> None:264"""Update verse counts for a Bible job.265266Args:267job_id: Job identifier.268total: New total verse count.269completed: New completed verse count.270failed: New failed verse count.271"""272with self._connection() as conn:273updates = []274params: list[Any] = []275276if total is not None:277updates.append("total_verses = ?")278params.append(total)279280if completed is not None:281updates.append("completed_verses = ?")282params.append(completed)283284if failed is not None:285updates.append("failed_verses = ?")286params.append(failed)287288if not updates:289return290291params.append(job_id)292293conn.execute(294f"UPDATE bible_jobs SET {', '.join(updates)} WHERE job_id = ?",295params,296)297298# -------------------------------------------------------------------------299# Book Job Management300# -------------------------------------------------------------------------301302def get_book_job(self, job_id: str, book: str) -> BookJob | None:303"""Get a book job by job ID and book name.304305Args:306job_id: Parent job identifier.307book: Book name.308309Returns:310BookJob if found, None otherwise.311"""312with self._connection() as conn:313row = conn.execute(314"SELECT * FROM book_jobs WHERE job_id = ? AND book = ?",315(job_id, book),316).fetchone()317318if not row:319return None320321return self._row_to_book_job(row)322323def get_book_jobs(self, job_id: str) -> list[BookJob]:324"""Get all book jobs for a Bible job.325326Args:327job_id: Parent job identifier.328329Returns:330List of BookJob instances.331"""332with self._connection() as conn:333rows = conn.execute(334"SELECT * FROM book_jobs WHERE job_id = ? ORDER BY book",335(job_id,),336).fetchall()337338return [self._row_to_book_job(row) for row in rows]339340def update_book_status(341self,342job_id: str,343book: str,344status: JobStatus,345worker_pid: int | None = None,346started_at: datetime | None = None,347completed_at: datetime | None = None,348error_message: str | None = None,349) -> None:350"""Update the status of a book job.351352Args:353job_id: Parent job identifier.354book: Book name.355status: New status.356worker_pid: Worker process PID.357started_at: When book started.358completed_at: When book completed.359error_message: Error message if failed.360"""361with self._connection() as conn:362updates = ["status = ?"]363params: list[Any] = [status.value]364365if worker_pid is not None:366updates.append("worker_pid = ?")367params.append(worker_pid)368369if started_at:370updates.append("started_at = ?")371params.append(started_at.isoformat())372373if completed_at:374updates.append("completed_at = ?")375params.append(completed_at.isoformat())376377if error_message is not None:378updates.append("error_message = ?")379params.append(error_message)380381params.extend([job_id, book])382383conn.execute(384f"UPDATE book_jobs SET {', '.join(updates)} "385"WHERE job_id = ? AND book = ?",386params,387)388389def update_book_verse_counts(390self,391job_id: str,392book: str,393total: int | None = None,394completed: int | None = None,395failed: int | None = None,396) -> None:397"""Update verse counts for a book job.398399Args:400job_id: Parent job identifier.401book: Book name.402total: New total verse count.403completed: New completed verse count.404failed: New failed verse count.405"""406with self._connection() as conn:407updates = []408params: list[Any] = []409410if total is not None:411updates.append("total_verses = ?")412params.append(total)413414if completed is not None:415updates.append("completed_verses = ?")416params.append(completed)417418if failed is not None:419updates.append("failed_verses = ?")420params.append(failed)421422if not updates:423return424425params.extend([job_id, book])426427conn.execute(428f"UPDATE book_jobs SET {', '.join(updates)} "429"WHERE job_id = ? AND book = ?",430params,431)432433# -------------------------------------------------------------------------434# Verse Job Management435# -------------------------------------------------------------------------436437def initialize_verse_jobs(438self,439job_id: str,440book: str,441verses: list[tuple[int, int]],442completed_verses: list[tuple[int, int]] | None = None,443) -> None:444"""Initialize verse jobs for a book.445446Args:447job_id: Parent job identifier.448book: Book name.449verses: List of (chapter, verse) tuples to mark as pending.450completed_verses: List of (chapter, verse) tuples already451translated on disk, marked COMPLETED immediately.452"""453completed_verses = completed_verses or []454total = len(verses) + len(completed_verses)455456with self._connection() as conn:457# Delete any existing verse jobs for this book458conn.execute(459"DELETE FROM verse_jobs WHERE job_id = ? AND book = ?",460(job_id, book),461)462463# Insert pending verse jobs464if verses:465conn.executemany(466"""467INSERT INTO verse_jobs (job_id, book, chapter, verse, status)468VALUES (?, ?, ?, ?, ?)469""",470[471(job_id, book, chapter, verse, JobStatus.PENDING.value)472for chapter, verse in verses473],474)475476# Insert already-completed verse jobs477if completed_verses:478now = datetime.utcnow().isoformat()479conn.executemany(480"""481INSERT INTO verse_jobs482(job_id, book, chapter, verse, status, completed_at)483VALUES (?, ?, ?, ?, ?, ?)484""",485[486(job_id, book, chapter, verse,487JobStatus.COMPLETED.value, now)488for chapter, verse in completed_verses489],490)491492# Update book totals493conn.execute(494"""495UPDATE book_jobs496SET total_verses = ?, completed_verses = ?497WHERE job_id = ? AND book = ?498""",499(total, len(completed_verses), job_id, book),500)501502def atomic_claim_verse(503self,504job_id: str,505book: str,506chapter: int,507verse: int,508worker_pid: int,509) -> bool:510"""Atomically claim a verse for translation.511512Uses a conditional update to ensure only one worker can claim a verse.513514Args:515job_id: Parent job identifier.516book: Book name.517chapter: Chapter number.518verse: Verse number.519worker_pid: Worker process PID claiming the verse.520521Returns:522True if claim succeeded, False if verse was already claimed.523"""524with self._connection() as conn:525cursor = conn.execute(526"""527UPDATE verse_jobs528SET status = ?, worker_pid = ?, started_at = ?, attempts = attempts + 1529WHERE job_id = ? AND book = ? AND chapter = ? AND verse = ?530AND status = ?531""",532(533JobStatus.IN_PROGRESS.value,534worker_pid,535datetime.utcnow().isoformat(),536job_id,537book,538chapter,539verse,540JobStatus.PENDING.value,541),542)543return cursor.rowcount > 0544545def update_verse_status(546self,547job_id: str,548book: str,549chapter: int,550verse: int,551status: JobStatus,552error: str | None = None,553) -> None:554"""Update the status of a verse job.555556Args:557job_id: Parent job identifier.558book: Book name.559chapter: Chapter number.560verse: Verse number.561status: New status.562error: Error message if failed.563"""564with self._connection() as conn:565updates = ["status = ?"]566params: list[Any] = [status.value]567568if status in (JobStatus.COMPLETED, JobStatus.FAILED):569updates.append("completed_at = ?")570params.append(datetime.utcnow().isoformat())571572if error is not None:573updates.append("last_error = ?")574params.append(error)575576params.extend([job_id, book, chapter, verse])577578conn.execute(579f"UPDATE verse_jobs SET {', '.join(updates)} "580"WHERE job_id = ? AND book = ? AND chapter = ? AND verse = ?",581params,582)583584# Update book counts585if status == JobStatus.COMPLETED:586conn.execute(587"""588UPDATE book_jobs589SET completed_verses = completed_verses + 1590WHERE job_id = ? AND book = ?591""",592(job_id, book),593)594elif status == JobStatus.FAILED:595conn.execute(596"""597UPDATE book_jobs598SET failed_verses = failed_verses + 1599WHERE job_id = ? AND book = ?600""",601(job_id, book),602)603604def get_pending_verses(self, job_id: str, book: str) -> list[VerseJob]:605"""Get all pending verses for a book.606607Args:608job_id: Parent job identifier.609book: Book name.610611Returns:612List of pending VerseJob instances.613"""614with self._connection() as conn:615rows = conn.execute(616"""617SELECT * FROM verse_jobs618WHERE job_id = ? AND book = ? AND status = ?619ORDER BY chapter, verse620""",621(job_id, book, JobStatus.PENDING.value),622).fetchall()623624return [self._row_to_verse_job(row) for row in rows]625626def get_failed_verses(self, job_id: str, book: str) -> list[VerseJob]:627"""Get all failed verses for a book.628629Args:630job_id: Parent job identifier.631book: Book name.632633Returns:634List of failed VerseJob instances.635"""636with self._connection() as conn:637rows = conn.execute(638"""639SELECT * FROM verse_jobs640WHERE job_id = ? AND book = ? AND status = ?641ORDER BY chapter, verse642""",643(job_id, book, JobStatus.FAILED.value),644).fetchall()645646return [self._row_to_verse_job(row) for row in rows]647648def get_book_progress(self, job_id: str, book: str) -> dict[str, Any]:649"""Get progress statistics for a book.650651Args:652job_id: Parent job identifier.653book: Book name.654655Returns:656Dictionary with progress statistics.657"""658with self._connection() as conn:659row = conn.execute(660"""661SELECT662total_verses,663completed_verses,664failed_verses,665status666FROM book_jobs667WHERE job_id = ? AND book = ?668""",669(job_id, book),670).fetchone()671672if not row:673return {}674675total = row["total_verses"]676completed = row["completed_verses"]677failed = row["failed_verses"]678pending = total - completed - failed679680return {681"book": book,682"status": row["status"],683"total_verses": total,684"completed_verses": completed,685"failed_verses": failed,686"pending_verses": pending,687"progress_percentage": (completed / total * 100) if total > 0 else 0,688}689690def reset_orphaned_verses(self, job_id: str) -> int:691"""Reset verses that were in_progress but not completed (orphaned by crash).692693Args:694job_id: Parent job identifier.695696Returns:697Number of verses reset.698"""699with self._connection() as conn:700cursor = conn.execute(701"""702UPDATE verse_jobs703SET status = ?, worker_pid = NULL704WHERE job_id = ? AND status = ?705""",706(JobStatus.PENDING.value, job_id, JobStatus.IN_PROGRESS.value),707)708return cursor.rowcount709710def reset_failed_verses(self, job_id: str, book: str) -> int:711"""Reset failed verses to pending for retry.712713Args:714job_id: Parent job identifier.715book: Book name.716717Returns:718Number of verses reset.719"""720with self._connection() as conn:721cursor = conn.execute(722"""723UPDATE verse_jobs724SET status = ?, worker_pid = NULL, last_error = NULL725WHERE job_id = ? AND book = ? AND status = ?726""",727(JobStatus.PENDING.value, job_id, book, JobStatus.FAILED.value),728)729730# Update book counts731reset_count = cursor.rowcount732if reset_count > 0:733conn.execute(734"""735UPDATE book_jobs736SET failed_verses = failed_verses - ?737WHERE job_id = ? AND book = ?738""",739(reset_count, job_id, book),740)741742return reset_count743744# -------------------------------------------------------------------------745# Helper Methods746# -------------------------------------------------------------------------747748def _row_to_bible_job(self, row: sqlite3.Row) -> BibleJob:749"""Convert a database row to a BibleJob."""750return BibleJob(751job_id=row["job_id"],752books=json.loads(row["books"]),753status=JobStatus(row["status"]),754config_snapshot=json.loads(row["config_snapshot"]),755created_at=datetime.fromisoformat(row["created_at"]),756started_at=(757datetime.fromisoformat(row["started_at"])758if row["started_at"]759else None760),761completed_at=(762datetime.fromisoformat(row["completed_at"])763if row["completed_at"]764else None765),766total_verses=row["total_verses"],767completed_verses=row["completed_verses"],768failed_verses=row["failed_verses"],769)770771def _row_to_book_job(self, row: sqlite3.Row) -> BookJob:772"""Convert a database row to a BookJob."""773return BookJob(774book=row["book"],775status=JobStatus(row["status"]),776total_verses=row["total_verses"],777completed_verses=row["completed_verses"],778failed_verses=row["failed_verses"],779worker_pid=row["worker_pid"],780started_at=(781datetime.fromisoformat(row["started_at"])782if row["started_at"]783else None784),785completed_at=(786datetime.fromisoformat(row["completed_at"])787if row["completed_at"]788else None789),790error_message=row["error_message"],791)792793def _row_to_verse_job(self, row: sqlite3.Row) -> VerseJob:794"""Convert a database row to a VerseJob."""795return VerseJob(796book=row["book"],797chapter=row["chapter"],798verse=row["verse"],799status=JobStatus(row["status"]),800attempts=row["attempts"],801last_error=row["last_error"],802worker_pid=row["worker_pid"],803started_at=(804datetime.fromisoformat(row["started_at"])805if row["started_at"]806else None807),808completed_at=(809datetime.fromisoformat(row["completed_at"])810if row["completed_at"]811else None812),813)814815# -------------------------------------------------------------------------816# Cleanup Methods817# -------------------------------------------------------------------------818819def delete_job(self, job_id: str) -> bool:820"""Delete a job and all its associated data.821822Args:823job_id: Job identifier to delete.824825Returns:826True if job was deleted, False if job not found.827"""828with self._connection() as conn:829# Check if job exists830existing = conn.execute(831"SELECT 1 FROM bible_jobs WHERE job_id = ?",832(job_id,),833).fetchone()834835if not existing:836return False837838# Delete in order: verse_jobs -> book_jobs -> bible_jobs839conn.execute("DELETE FROM verse_jobs WHERE job_id = ?", (job_id,))840conn.execute("DELETE FROM book_jobs WHERE job_id = ?", (job_id,))841conn.execute("DELETE FROM bible_jobs WHERE job_id = ?", (job_id,))842843return True844845def delete_completed_jobs(self, keep_latest: int = 1) -> int:846"""Delete completed jobs, optionally keeping the most recent ones.847848Args:849keep_latest: Number of most recent completed jobs to keep.850851Returns:852Number of jobs deleted.853"""854with self._connection() as conn:855# Get completed job IDs ordered by completion time856rows = conn.execute(857"""858SELECT job_id FROM bible_jobs859WHERE status = ?860ORDER BY completed_at DESC861""",862(JobStatus.COMPLETED.value,),863).fetchall()864865# Skip the ones we want to keep866jobs_to_delete = [row["job_id"] for row in rows[keep_latest:]]867868if not jobs_to_delete:869return 0870871# Delete each job872placeholders = ",".join("?" * len(jobs_to_delete))873conn.execute(874f"DELETE FROM verse_jobs WHERE job_id IN ({placeholders})",875jobs_to_delete,876)877conn.execute(878f"DELETE FROM book_jobs WHERE job_id IN ({placeholders})",879jobs_to_delete,880)881conn.execute(882f"DELETE FROM bible_jobs WHERE job_id IN ({placeholders})",883jobs_to_delete,884)885886return len(jobs_to_delete)887888def delete_jobs_older_than(self, days: int) -> int:889"""Delete jobs older than a specified number of days.890891Args:892days: Delete jobs created more than this many days ago.893894Returns:895Number of jobs deleted.896"""897from datetime import timedelta898899cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()900901with self._connection() as conn:902# Get job IDs older than cutoff903rows = conn.execute(904"""905SELECT job_id FROM bible_jobs906WHERE created_at < ?907""",908(cutoff,),909).fetchall()910911jobs_to_delete = [row["job_id"] for row in rows]912913if not jobs_to_delete:914return 0915916# Delete each job917placeholders = ",".join("?" * len(jobs_to_delete))918conn.execute(919f"DELETE FROM verse_jobs WHERE job_id IN ({placeholders})",920jobs_to_delete,921)922conn.execute(923f"DELETE FROM book_jobs WHERE job_id IN ({placeholders})",924jobs_to_delete,925)926conn.execute(927f"DELETE FROM bible_jobs WHERE job_id IN ({placeholders})",928jobs_to_delete,929)930931return len(jobs_to_delete)932933def list_jobs(self, limit: int = 20) -> list[BibleJob]:934"""List all jobs, most recent first.935936Args:937limit: Maximum number of jobs to return.938939Returns:940List of BibleJob instances.941"""942with self._connection() as conn:943rows = conn.execute(944"""945SELECT * FROM bible_jobs946ORDER BY created_at DESC947LIMIT ?948""",949(limit,),950).fetchall()951952return [self._row_to_bible_job(row) for row in rows]953954def get_database_stats(self) -> dict[str, Any]:955"""Get database statistics.956957Returns:958Dictionary with job counts and database size.959"""960with self._connection() as conn:961total_jobs = conn.execute(962"SELECT COUNT(*) as count FROM bible_jobs"963).fetchone()["count"]964965pending_jobs = conn.execute(966"SELECT COUNT(*) as count FROM bible_jobs WHERE status = ?",967(JobStatus.PENDING.value,),968).fetchone()["count"]969970in_progress_jobs = conn.execute(971"SELECT COUNT(*) as count FROM bible_jobs WHERE status = ?",972(JobStatus.IN_PROGRESS.value,),973).fetchone()["count"]974975completed_jobs = conn.execute(976"SELECT COUNT(*) as count FROM bible_jobs WHERE status = ?",977(JobStatus.COMPLETED.value,),978).fetchone()["count"]979980failed_jobs = conn.execute(981"SELECT COUNT(*) as count FROM bible_jobs WHERE status = ?",982(JobStatus.FAILED.value,),983).fetchone()["count"]984985total_verses = conn.execute(986"SELECT COUNT(*) as count FROM verse_jobs"987).fetchone()["count"]988989# Get file size990db_size_bytes = self.db_path.stat().st_size if self.db_path.exists() else 0991db_size_mb = db_size_bytes / (1024 * 1024)992993return {994"total_jobs": total_jobs,995"pending_jobs": pending_jobs,996"in_progress_jobs": in_progress_jobs,997"completed_jobs": completed_jobs,998"failed_jobs": failed_jobs,999"total_verse_records": total_verses,1000"database_size_mb": round(db_size_mb, 2),1001}1002