Skip to content

Commit 355f533

Browse files
author
Josh Bendson
committed
feat: Add NOT_INITIALIZED status for lazy-loaded databases
Add graceful fallback for lazy-loaded databases that don't exist yet. Changes: - Add DatabaseHealthStatus.NOT_INITIALIZED status for optional databases - Identify search_config.db and file_content_limits.db as lazy-loaded - Update health service to not treat missing lazy-loaded DBs as errors - Add UI styling and display for NOT_INITIALIZED state - Add comprehensive tests for lazy-loaded database handling Database files using singleton pattern (get_instance()) are only created when their features are first accessed. Health checks now properly handle these optional databases with NOT_INITIALIZED status instead of ERROR.
1 parent f77d097 commit 355f533

5 files changed

Lines changed: 192 additions & 17 deletions

File tree

src/code_indexer/server/services/database_health_service.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ class DatabaseHealthStatus(str, Enum):
2828
HEALTHY = "healthy" # Green - All 5 checks pass
2929
WARNING = "warning" # Yellow - Some checks pass, some fail
3030
ERROR = "error" # Red - Critical checks fail
31+
NOT_INITIALIZED = (
32+
"not_initialized" # Blue/Gray - Lazy-loaded database not yet created
33+
)
3134

3235

3336
@dataclass
@@ -54,13 +57,18 @@ def get_tooltip(self) -> str:
5457
5558
Always shows display name and path.
5659
Unhealthy databases also show the failed condition.
60+
NOT_INITIALIZED databases show they're optional and not yet created.
5761
"""
5862
# Always include path in tooltip
5963
base_tooltip = f"{self.display_name}\n{self.db_path}"
6064

6165
if self.status == DatabaseHealthStatus.HEALTHY:
6266
return base_tooltip
6367

68+
# NOT_INITIALIZED databases get special message
69+
if self.status == DatabaseHealthStatus.NOT_INITIALIZED:
70+
return f"{base_tooltip}\nNot initialized (optional)"
71+
6472
# Find first failed check to include in tooltip
6573
for check_name, result in self.checks.items():
6674
if not result.passed:
@@ -83,6 +91,10 @@ def get_tooltip(self) -> str:
8391
"payload_cache.db": "Payload Cache",
8492
}
8593

94+
# Lazy-loaded databases (singleton pattern with get_instance())
95+
# These databases are only created when their features are first accessed
96+
LAZY_LOADED_DATABASES = {"search_config.db", "file_content_limits.db"}
97+
8698

8799
class DatabaseHealthService:
88100
"""
@@ -203,6 +215,12 @@ def _check_connect(db_path: str) -> CheckResult:
203215
try:
204216
# Check if file exists first
205217
if not Path(db_path).exists():
218+
# Check if this is a lazy-loaded database
219+
file_name = Path(db_path).name
220+
if file_name in LAZY_LOADED_DATABASES:
221+
return CheckResult(
222+
passed=False, error_message="Not initialized (optional)"
223+
)
206224
return CheckResult(
207225
passed=False, error_message="Connection failed: file not found"
208226
)
@@ -314,7 +332,13 @@ def _determine_status(checks: Dict[str, CheckResult]) -> DatabaseHealthStatus:
314332
- GREEN (HEALTHY): All 5 checks pass
315333
- YELLOW (WARNING): Some checks pass, some fail (degraded but operational)
316334
- RED (ERROR): Critical checks fail (connect/read)
335+
- BLUE/GRAY (NOT_INITIALIZED): Lazy-loaded database not yet created
317336
"""
337+
# Check for lazy-loaded database not yet initialized
338+
if "connect" in checks and not checks["connect"].passed:
339+
if checks["connect"].error_message == "Not initialized (optional)":
340+
return DatabaseHealthStatus.NOT_INITIALIZED
341+
318342
# Critical checks - if these fail, status is ERROR
319343
critical_checks = ["connect", "read"]
320344
for check_name in critical_checks:

src/code_indexer/server/services/health_service.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ def __init__(self):
8080
# CPU history for sustained threshold detection (Story #727 AC4)
8181
# List of (timestamp, cpu_percent) tuples for rolling 60s window
8282
self._cpu_history: List[Tuple[float, float]] = []
83-
self._cpu_history_lock = threading.Lock() # Thread safety for concurrent requests
83+
self._cpu_history_lock = (
84+
threading.Lock()
85+
) # Thread safety for concurrent requests
8486

8587
except Exception as e:
8688
logger.error(
@@ -379,6 +381,9 @@ def _collect_database_failures(
379381
f"{db_result.display_name} DB: {check.error_message or check_name}"
380382
)
381383
break
384+
# NOT_INITIALIZED and HEALTHY statuses don't affect overall health
385+
# NOT_INITIALIZED databases are lazy-loaded and optional (not yet created)
386+
# HEALTHY databases are fully operational
382387

383388
return has_warning, has_error, reasons
384389

@@ -491,15 +496,13 @@ def _check_cpu_sustained(self, current_cpu: float) -> Tuple[bool, bool]:
491496
readings_60s = [c for t, c in history_snapshot]
492497

493498
# Degraded: CPU >95% sustained for 30+ seconds
494-
is_degraded = (
495-
len(readings_30s) >= MIN_CPU_READINGS_FOR_DEGRADED
496-
and all(c > CPU_SUSTAINED_THRESHOLD for c in readings_30s)
499+
is_degraded = len(readings_30s) >= MIN_CPU_READINGS_FOR_DEGRADED and all(
500+
c > CPU_SUSTAINED_THRESHOLD for c in readings_30s
497501
)
498502

499503
# Unhealthy: CPU >95% sustained for 60+ seconds
500-
is_unhealthy = (
501-
len(readings_60s) >= MIN_CPU_READINGS_FOR_UNHEALTHY
502-
and all(c > CPU_SUSTAINED_THRESHOLD for c in readings_60s)
504+
is_unhealthy = len(readings_60s) >= MIN_CPU_READINGS_FOR_UNHEALTHY and all(
505+
c > CPU_SUSTAINED_THRESHOLD for c in readings_60s
503506
)
504507

505508
return is_degraded, is_unhealthy
@@ -532,7 +535,9 @@ def _calculate_overall_status(
532535
failure_reasons.extend(db_reasons)
533536

534537
# AC2: Volume health
535-
vol_warn, vol_err, vol_reasons = self._collect_volume_failures(system_info.volumes)
538+
vol_warn, vol_err, vol_reasons = self._collect_volume_failures(
539+
system_info.volumes
540+
)
536541
has_warning = has_warning or vol_warn
537542
has_error = has_error or vol_err
538543
failure_reasons.extend(vol_reasons)
@@ -568,7 +573,9 @@ def _calculate_overall_status(
568573
# AC5: Limit to MAX_FAILURE_REASONS with "+N more" indicator
569574
if len(failure_reasons) > MAX_FAILURE_REASONS:
570575
extra_count = len(failure_reasons) - MAX_FAILURE_REASONS
571-
failure_reasons = failure_reasons[:MAX_FAILURE_REASONS] + [f"+{extra_count} more"]
576+
failure_reasons = failure_reasons[:MAX_FAILURE_REASONS] + [
577+
f"+{extra_count} more"
578+
]
572579

573580
return status, failure_reasons
574581

src/code_indexer/server/web/static/admin.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -837,6 +837,10 @@ button.danger, .button.danger {
837837
fill: #ef4444;
838838
}
839839

840+
.hexagon.hex-not-initialized {
841+
fill: #94a3b8;
842+
}
843+
840844
/* AC2/AC3: Hover effect for tooltips (title attribute provides native tooltip) */
841845
.honeycomb-svg polygon.hexagon:hover {
842846
stroke-width: 2;
@@ -887,6 +891,10 @@ button.danger, .button.danger {
887891
background-color: #ef4444;
888892
}
889893

894+
.legend-dot.not-initialized {
895+
background-color: #94a3b8;
896+
}
897+
890898
/* ==========================================================================
891899
Story #712 AC5: Disk Metrics Complete Display
892900
========================================================================== */

src/code_indexer/server/web/templates/partials/dashboard_health.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ <h3>Database Health</h3>
3939
{% set col = i % 4 %}
4040
{% set x = 35 + col * 55 %}
4141
{% set y = 40 + row * 65 %}
42-
{% set status_class = 'healthy' if db.status.value == 'healthy' else ('warning' if db.status.value == 'warning' else 'error') %}
42+
{% set status_class = 'healthy' if db.status.value == 'healthy' else ('warning' if db.status.value == 'warning' else ('not-initialized' if db.status.value == 'not_initialized' else 'error')) %}
4343
<g class="hexagon-group" transform="translate({{ x }}, {{ y }})">
4444
<polygon class="hexagon hex-{{ status_class }}"
4545
points="25,0 50,15 50,45 25,60 0,45 0,15">
@@ -55,6 +55,7 @@ <h3>Database Health</h3>
5555
<span class="legend-item"><span class="legend-dot healthy"></span>Healthy</span>
5656
<span class="legend-item"><span class="legend-dot warning"></span>Warning</span>
5757
<span class="legend-item"><span class="legend-dot error"></span>Error</span>
58+
<span class="legend-item"><span class="legend-dot not-initialized"></span>Not Initialized</span>
5859
</div>
5960
{% else %}
6061
<div class="status-content">

tests/server/services/test_database_health_service.py

Lines changed: 142 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def temp_server_dir(self) -> Generator[Path, None, None]:
3737
"logs.db": "CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY)",
3838
"search_config.db": "CREATE TABLE IF NOT EXISTS config (id INTEGER PRIMARY KEY)",
3939
"file_content_limits.db": "CREATE TABLE IF NOT EXISTS limits (id INTEGER PRIMARY KEY)",
40-
"scip_audit.db": "CREATE TABLE IF NOT EXISTS audit (id INTEGER PRIMARY KEY)",
40+
"groups.db": "CREATE TABLE IF NOT EXISTS groups (id INTEGER PRIMARY KEY)",
4141
"payload_cache.db": "CREATE TABLE IF NOT EXISTS cache (id INTEGER PRIMARY KEY)",
4242
}
4343

@@ -80,7 +80,7 @@ def test_health_service_checks_all_8_databases(self, temp_server_dir: Path):
8080
"logs.db",
8181
"search_config.db",
8282
"file_content_limits.db",
83-
"scip_audit.db",
83+
"groups.db",
8484
"payload_cache.db",
8585
}
8686
actual_files = {result.file_name for result in health_results}
@@ -110,7 +110,7 @@ def test_health_service_provides_display_names(self, temp_server_dir: Path):
110110
"logs.db": "Logs",
111111
"search_config.db": "Search Config",
112112
"file_content_limits.db": "File Limits",
113-
"scip_audit.db": "SCIP Audit",
113+
"groups.db": "Groups",
114114
"payload_cache.db": "Payload Cache",
115115
}
116116

@@ -352,7 +352,7 @@ def healthy_db_path(self) -> Generator[Path, None, None]:
352352
yield db_path
353353

354354
def test_healthy_database_tooltip_shows_only_name(self, healthy_db_path: Path):
355-
"""AC2: Healthy database tooltip shows only database name."""
355+
"""AC2: Healthy database tooltip shows database name and path."""
356356
from code_indexer.server.services.database_health_service import (
357357
DatabaseHealthService,
358358
DatabaseHealthStatus,
@@ -364,10 +364,15 @@ def test_healthy_database_tooltip_shows_only_name(self, healthy_db_path: Path):
364364

365365
assert result.status == DatabaseHealthStatus.HEALTHY
366366
tooltip = result.get_tooltip()
367-
assert tooltip == "Main Server"
367+
# Tooltip should contain display name and path (no error info for healthy DB)
368+
assert "Main Server" in tooltip
369+
assert str(healthy_db_path) in tooltip
370+
# Should not contain error information for healthy database
371+
assert "Connect:" not in tooltip
372+
assert "failed" not in tooltip
368373

369374
def test_unhealthy_database_tooltip_shows_failure(self, healthy_db_path: Path):
370-
"""AC3: Unhealthy database tooltip shows name AND failed condition."""
375+
"""AC3: Unhealthy database tooltip shows name, path, AND failed condition."""
371376
from code_indexer.server.services.database_health_service import (
372377
DatabaseHealthService,
373378
DatabaseHealthStatus,
@@ -380,8 +385,11 @@ def test_unhealthy_database_tooltip_shows_failure(self, healthy_db_path: Path):
380385

381386
assert result.status == DatabaseHealthStatus.ERROR
382387
tooltip = result.get_tooltip()
388+
# Tooltip should contain display name, path, and error info
383389
assert "OAuth" in tooltip
384-
assert " - " in tooltip
390+
assert str(healthy_db_path) in tooltip
391+
# Should contain error information (check name + error message)
392+
assert "Connect:" in tooltip or "failed" in tooltip
385393

386394

387395
# =============================================================================
@@ -455,3 +463,130 @@ def test_get_stats_partial_passes_user_role_to_repo_counts(self):
455463
assert (
456464
"_get_repo_counts" in source and "user_role" in source
457465
), "get_stats_partial must pass user_role to _get_repo_counts"
466+
467+
468+
# =============================================================================
469+
# Lazy-Loaded Database Tests
470+
# =============================================================================
471+
472+
473+
class TestLazyLoadedDatabases:
474+
"""Tests for graceful handling of lazy-loaded databases."""
475+
476+
def test_lazy_loaded_database_not_initialized_status(self):
477+
"""
478+
Lazy-loaded database that doesn't exist yet gets NOT_INITIALIZED status.
479+
480+
Given a lazy-loaded database file (search_config.db or file_content_limits.db)
481+
When the database file doesn't exist yet
482+
Then the health check returns NOT_INITIALIZED status instead of ERROR
483+
"""
484+
from code_indexer.server.services.database_health_service import (
485+
DatabaseHealthService,
486+
DatabaseHealthStatus,
487+
)
488+
489+
with tempfile.TemporaryDirectory(prefix="cidx_lazy_test_") as tmp:
490+
# Create non-existent path for lazy-loaded database
491+
db_path = Path(tmp) / "search_config.db"
492+
493+
result = DatabaseHealthService.check_database_health(
494+
str(db_path), display_name="Search Config"
495+
)
496+
497+
assert result.status == DatabaseHealthStatus.NOT_INITIALIZED
498+
assert result.checks["connect"].passed is False
499+
assert (
500+
result.checks["connect"].error_message == "Not initialized (optional)"
501+
)
502+
503+
def test_lazy_loaded_database_initialized_is_healthy(self):
504+
"""
505+
Lazy-loaded database that exists and is healthy gets HEALTHY status.
506+
507+
Given a lazy-loaded database file (search_config.db)
508+
When the database file exists and all checks pass
509+
Then the health check returns HEALTHY status
510+
"""
511+
from code_indexer.server.services.database_health_service import (
512+
DatabaseHealthService,
513+
DatabaseHealthStatus,
514+
)
515+
516+
with tempfile.TemporaryDirectory(prefix="cidx_lazy_test_") as tmp:
517+
# Create lazy-loaded database
518+
db_path = Path(tmp) / "search_config.db"
519+
with sqlite3.connect(str(db_path)) as conn:
520+
conn.execute("CREATE TABLE config (id INTEGER PRIMARY KEY)")
521+
conn.commit()
522+
523+
result = DatabaseHealthService.check_database_health(
524+
str(db_path), display_name="Search Config"
525+
)
526+
527+
assert result.status == DatabaseHealthStatus.HEALTHY
528+
assert result.checks["connect"].passed is True
529+
530+
def test_non_lazy_database_missing_is_error(self):
531+
"""
532+
Non-lazy-loaded database that doesn't exist gets ERROR status.
533+
534+
Given a non-lazy-loaded database (e.g., oauth.db)
535+
When the database file doesn't exist
536+
Then the health check returns ERROR status (not NOT_INITIALIZED)
537+
"""
538+
from code_indexer.server.services.database_health_service import (
539+
DatabaseHealthService,
540+
DatabaseHealthStatus,
541+
)
542+
543+
with tempfile.TemporaryDirectory(prefix="cidx_lazy_test_") as tmp:
544+
# Create non-existent path for non-lazy database
545+
db_path = Path(tmp) / "oauth.db"
546+
547+
result = DatabaseHealthService.check_database_health(
548+
str(db_path), display_name="OAuth"
549+
)
550+
551+
assert result.status == DatabaseHealthStatus.ERROR
552+
assert result.checks["connect"].passed is False
553+
assert "file not found" in result.checks["connect"].error_message
554+
555+
def test_lazy_loaded_database_tooltip(self):
556+
"""
557+
Lazy-loaded database tooltip shows 'Not initialized (optional)'.
558+
559+
Given a lazy-loaded database that doesn't exist yet
560+
When get_tooltip() is called
561+
Then it shows the display name, path, and 'Not initialized (optional)'
562+
"""
563+
from code_indexer.server.services.database_health_service import (
564+
DatabaseHealthService,
565+
)
566+
567+
with tempfile.TemporaryDirectory(prefix="cidx_lazy_test_") as tmp:
568+
db_path = Path(tmp) / "file_content_limits.db"
569+
570+
result = DatabaseHealthService.check_database_health(
571+
str(db_path), display_name="File Limits"
572+
)
573+
574+
tooltip = result.get_tooltip()
575+
assert "File Limits" in tooltip
576+
assert str(db_path) in tooltip
577+
assert "Not initialized (optional)" in tooltip
578+
579+
def test_both_lazy_databases_defined(self):
580+
"""
581+
Verify both lazy-loaded databases are defined in LAZY_LOADED_DATABASES.
582+
583+
This test documents which databases are lazy-loaded and ensures
584+
they're properly configured in the constant.
585+
"""
586+
from code_indexer.server.services.database_health_service import (
587+
LAZY_LOADED_DATABASES,
588+
)
589+
590+
assert "search_config.db" in LAZY_LOADED_DATABASES
591+
assert "file_content_limits.db" in LAZY_LOADED_DATABASES
592+
assert len(LAZY_LOADED_DATABASES) == 2

0 commit comments

Comments
 (0)