Skip to content

Commit 4d99e2b

Browse files
jsbattigclaude
andcommitted
fix: Add SQLite backend support to BackgroundJobManager (Story #702)
- Added background_jobs table schema to database_manager.py - Created BackgroundJobsSqliteBackend class in sqlite_backends.py - Updated BackgroundJobManager with use_sqlite and db_path parameters - Updated app.py to use SQLite for BackgroundJobManager - Added 16 unit tests for the new SQLite backend This fixes the Dashboard jobs display issue where jobs were stored in the sync_jobs table but the web UI used BackgroundJobManager which was still using JSON file storage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 57f2862 commit 4d99e2b

File tree

5 files changed

+766
-9
lines changed

5 files changed

+766
-9
lines changed

src/code_indexer/server/app.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2551,10 +2551,11 @@ async def validation_exception_handler(
25512551
use_sqlite=True,
25522552
db_path=db_path,
25532553
)
2554-
# Initialize BackgroundJobManager with persistence enabled (Story #541 - AC4)
2555-
jobs_storage_path = str(Path(server_data_dir) / "jobs.json")
2554+
# Initialize BackgroundJobManager with SQLite persistence (Bug fix: Jobs not showing in Dashboard)
25562555
background_job_manager = BackgroundJobManager(
2557-
storage_path=jobs_storage_path, resource_config=server_config.resource_config
2556+
resource_config=server_config.resource_config,
2557+
use_sqlite=True,
2558+
db_path=db_path,
25582559
)
25592560
# Inject BackgroundJobManager into GoldenRepoManager for async operations
25602561
golden_repo_manager.background_job_manager = background_job_manager

src/code_indexer/server/repositories/background_jobs.py

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
if TYPE_CHECKING:
2121
from code_indexer.server.utils.config_manager import ServerResourceConfig
22+
from code_indexer.server.storage.sqlite_backends import BackgroundJobsSqliteBackend
2223

2324

2425
class JobStatus(str, Enum):
@@ -72,12 +73,16 @@ def __init__(
7273
self,
7374
storage_path: Optional[str] = None,
7475
resource_config: Optional["ServerResourceConfig"] = None,
76+
use_sqlite: bool = False,
77+
db_path: Optional[str] = None,
7578
):
7679
"""Initialize enhanced background job manager.
7780
7881
Args:
79-
storage_path: Path for persistent job storage (optional)
82+
storage_path: Path for persistent job storage (JSON file, optional)
8083
resource_config: Resource configuration (limits, timeouts)
84+
use_sqlite: Whether to use SQLite backend instead of JSON file
85+
db_path: Path to SQLite database file (required if use_sqlite=True)
8186
"""
8287
self.jobs: Dict[str, BackgroundJob] = {}
8388
self._lock = threading.Lock()
@@ -87,6 +92,18 @@ def __init__(
8792

8893
# Persistence settings
8994
self.storage_path = storage_path
95+
self.use_sqlite = use_sqlite
96+
self.db_path = db_path
97+
self._sqlite_backend: Optional["BackgroundJobsSqliteBackend"] = None
98+
99+
# Initialize SQLite backend if enabled
100+
if self.use_sqlite and self.db_path:
101+
from code_indexer.server.storage.sqlite_backends import (
102+
BackgroundJobsSqliteBackend,
103+
)
104+
105+
self._sqlite_backend = BackgroundJobsSqliteBackend(self.db_path)
106+
logging.info("BackgroundJobManager using SQLite backend")
90107

91108
# Resource configuration (import here to avoid circular dependency)
92109
if resource_config is None:
@@ -95,9 +112,8 @@ def __init__(
95112
resource_config = ServerResourceConfig()
96113
self.resource_config = resource_config
97114

98-
# Load persisted jobs if storage path provided
99-
if self.storage_path:
100-
self._load_jobs()
115+
# Load persisted jobs
116+
self._load_jobs()
101117

102118
# Background job manager initialized silently
103119

@@ -642,10 +658,16 @@ def get_jobs_by_operation_and_params(
642658

643659
def _persist_jobs(self) -> None:
644660
"""
645-
Persist jobs to storage file.
661+
Persist jobs to storage (SQLite or JSON file).
646662
647663
Note: This method should be called within a lock.
648664
"""
665+
# Use SQLite backend if enabled
666+
if self._sqlite_backend:
667+
self._persist_jobs_sqlite()
668+
return
669+
670+
# Fall back to JSON file storage
649671
if not self.storage_path:
650672
return
651673

@@ -678,10 +700,74 @@ def _persist_jobs(self) -> None:
678700
logging.error(f"Failed to persist jobs: {e}")
679701
# TODO: Consider implementing retry logic for failed persistence attempts
680702

703+
def _persist_jobs_sqlite(self) -> None:
704+
"""
705+
Persist all in-memory jobs to SQLite.
706+
707+
Note: This method should be called within a lock.
708+
"""
709+
if not self._sqlite_backend:
710+
return
711+
712+
try:
713+
for job_id, job in self.jobs.items():
714+
# Check if job exists in database
715+
existing = self._sqlite_backend.get_job(job_id)
716+
if existing:
717+
# Update existing job
718+
self._sqlite_backend.update_job(
719+
job_id=job_id,
720+
status=job.status.value,
721+
started_at=job.started_at.isoformat() if job.started_at else None,
722+
completed_at=job.completed_at.isoformat() if job.completed_at else None,
723+
result=job.result,
724+
error=job.error,
725+
progress=job.progress,
726+
cancelled=job.cancelled,
727+
resolution_attempts=job.resolution_attempts,
728+
claude_actions=job.claude_actions,
729+
failure_reason=job.failure_reason,
730+
extended_error=job.extended_error,
731+
language_resolution_status=job.language_resolution_status,
732+
)
733+
else:
734+
# Insert new job
735+
self._sqlite_backend.save_job(
736+
job_id=job_id,
737+
operation_type=job.operation_type,
738+
status=job.status.value,
739+
created_at=job.created_at.isoformat(),
740+
started_at=job.started_at.isoformat() if job.started_at else None,
741+
completed_at=job.completed_at.isoformat() if job.completed_at else None,
742+
result=job.result,
743+
error=job.error,
744+
progress=job.progress,
745+
username=job.username,
746+
is_admin=job.is_admin,
747+
cancelled=job.cancelled,
748+
repo_alias=job.repo_alias,
749+
resolution_attempts=job.resolution_attempts,
750+
claude_actions=job.claude_actions,
751+
failure_reason=job.failure_reason,
752+
extended_error=job.extended_error,
753+
language_resolution_status=job.language_resolution_status,
754+
)
755+
except Exception as e:
756+
logging.error(f"Failed to persist jobs to SQLite: {e}")
757+
758+
# Maximum number of jobs to load from SQLite into memory at startup
759+
MAX_JOBS_TO_LOAD = 10000
760+
681761
def _load_jobs(self) -> None:
682762
"""
683-
Load jobs from storage file.
763+
Load jobs from storage (SQLite or JSON file).
684764
"""
765+
# Use SQLite backend if enabled
766+
if self._sqlite_backend:
767+
self._load_jobs_sqlite()
768+
return
769+
770+
# Fall back to JSON file storage
685771
if not self.storage_path:
686772
return
687773

@@ -711,6 +797,34 @@ def _load_jobs(self) -> None:
711797
except Exception as e:
712798
logging.error(f"Failed to load jobs from storage: {e}")
713799

800+
def _load_jobs_sqlite(self) -> None:
801+
"""
802+
Load jobs from SQLite database into memory.
803+
"""
804+
if not self._sqlite_backend:
805+
return
806+
807+
try:
808+
stored_jobs = self._sqlite_backend.list_jobs(limit=self.MAX_JOBS_TO_LOAD)
809+
810+
for job_dict in stored_jobs:
811+
# Convert ISO strings back to datetime objects
812+
for field in ["created_at", "started_at", "completed_at"]:
813+
if job_dict.get(field) is not None:
814+
job_dict[field] = datetime.fromisoformat(job_dict[field])
815+
816+
# Convert string status back to enum
817+
job_dict["status"] = JobStatus(job_dict["status"])
818+
819+
# Create job object
820+
job = BackgroundJob(**job_dict)
821+
self.jobs[job_dict["job_id"]] = job
822+
823+
logging.info(f"Loaded {len(stored_jobs)} jobs from SQLite")
824+
825+
except Exception as e:
826+
logging.error(f"Failed to load jobs from SQLite: {e}")
827+
714828
def _calculate_cutoff(self, time_filter: str) -> datetime:
715829
"""
716830
Calculate cutoff datetime based on time filter.

src/code_indexer/server/storage/database_manager.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,30 @@ class DatabaseSchema:
169169
)
170170
"""
171171

172+
# Background Jobs table (Bug fix: BackgroundJobManager SQLite migration)
173+
CREATE_BACKGROUND_JOBS_TABLE = """
174+
CREATE TABLE IF NOT EXISTS background_jobs (
175+
job_id TEXT PRIMARY KEY NOT NULL,
176+
operation_type TEXT NOT NULL,
177+
status TEXT NOT NULL,
178+
created_at TEXT NOT NULL,
179+
started_at TEXT,
180+
completed_at TEXT,
181+
result TEXT,
182+
error TEXT,
183+
progress INTEGER NOT NULL DEFAULT 0,
184+
username TEXT NOT NULL,
185+
is_admin INTEGER NOT NULL DEFAULT 0,
186+
cancelled INTEGER NOT NULL DEFAULT 0,
187+
repo_alias TEXT,
188+
resolution_attempts INTEGER NOT NULL DEFAULT 0,
189+
claude_actions TEXT,
190+
failure_reason TEXT,
191+
extended_error TEXT,
192+
language_resolution_status TEXT
193+
)
194+
"""
195+
172196
def __init__(self, db_path: Optional[str] = None) -> None:
173197
"""
174198
Initialize DatabaseSchema.
@@ -224,6 +248,7 @@ def initialize_database(self) -> None:
224248
conn.execute(self.CREATE_SSH_KEYS_TABLE)
225249
conn.execute(self.CREATE_SSH_KEY_HOSTS_TABLE)
226250
conn.execute(self.CREATE_GOLDEN_REPOS_METADATA_TABLE)
251+
conn.execute(self.CREATE_BACKGROUND_JOBS_TABLE)
227252

228253
conn.commit()
229254
logger.info(f"Database initialized at {db_path}")

0 commit comments

Comments
 (0)