Skip to content

Commit 8ed4110

Browse files
SimplicityGuyclaude
andcommitted
fix: improve service resilience for restarts and startup
- Add processing state tracking to extractor for restart recovery - Tracks which files have been fully processed in .processing_state.json - Automatically resumes processing incomplete files after restart - Support FORCE_REPROCESS=true to reprocess all files - Fix RabbitMQ connection issues during startup - Increase circuit breaker thresholds (3→5 failures, 30s→60s recovery) - Add configurable STARTUP_DELAY for all services - Implement additional startup retry logic with exponential backoff - Services retry up to 5 times over ~75 seconds before giving up - Apply fixes to all message-consuming services - tableinator: Fixed connection failures and circuit breaker issues - graphinator: Added same resilience improvements - Both services now handle RabbitMQ unavailability gracefully This ensures the system recovers properly from container restarts and handles service startup ordering issues without excessive error logs. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 2b5ff12 commit 8ed4110

4 files changed

Lines changed: 135 additions & 11 deletions

File tree

common/rabbitmq_resilient.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,12 @@ def __init__(self, connection_url: str, max_retries: int = 5, heartbeat: int = 6
122122
self._lock = asyncio.Lock()
123123

124124
# Circuit breaker for RabbitMQ failures
125+
# Use higher threshold and longer recovery for startup scenarios
125126
self.circuit_breaker = CircuitBreaker(
126127
CircuitBreakerConfig(
127128
name="AsyncRabbitMQ",
128-
failure_threshold=3,
129-
recovery_timeout=30,
129+
failure_threshold=5, # Allow more attempts before opening
130+
recovery_timeout=60, # Give more time for RabbitMQ to start
130131
expected_exception=(AMQPConnectionError, AMQPChannelError, ConnectionClosed),
131132
)
132133
)

extractor/extractor.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import contextlib
33
import logging
4+
import os
45
import signal
56
import sys
67
import threading
@@ -654,6 +655,33 @@ def _flush_pending_messages(self) -> None:
654655
logger.warning("⚠️ Messages will be retried on next flush")
655656

656657

658+
def _load_processing_state(discogs_root: Path) -> dict[str, bool]:
659+
"""Load processing state from file."""
660+
state_file = Path(discogs_root) / ".processing_state.json"
661+
if not state_file.exists():
662+
return {}
663+
664+
try:
665+
with state_file.open("rb") as f:
666+
data = loads(f.read())
667+
# Ensure we return the correct type
668+
return {k: bool(v) for k, v in data.items()}
669+
except Exception as e:
670+
logger.warning(f"⚠️ Failed to load processing state: {e}")
671+
return {}
672+
673+
674+
def _save_processing_state(discogs_root: Path, state: dict[str, bool]) -> None:
675+
"""Save processing state to file."""
676+
state_file = Path(discogs_root) / ".processing_state.json"
677+
678+
try:
679+
with state_file.open("wb") as f:
680+
f.write(dumps(state, option=OPT_INDENT_2))
681+
except Exception as e:
682+
logger.warning(f"⚠️ Failed to save processing state: {e}")
683+
684+
657685
async def process_file_async(discogs_data_file: str, config: ExtractorConfig) -> None:
658686
"""Process a single file asynchronously."""
659687
try:
@@ -691,6 +719,27 @@ async def process_discogs_data(config: ExtractorConfig) -> bool:
691719
logger.warning("⚠️ No data files to process")
692720
return True
693721

722+
# Check processing state to handle restarts
723+
processing_state = _load_processing_state(config.discogs_root)
724+
files_to_process = []
725+
726+
for data_file in data_files:
727+
if data_file not in processing_state or not processing_state[data_file]:
728+
files_to_process.append(data_file)
729+
logger.info(f"📋 Will process: {data_file}")
730+
else:
731+
logger.info(f"✅ Already processed: {data_file}")
732+
733+
if not files_to_process:
734+
logger.info("✅ All files have been processed previously")
735+
# Force reprocessing if explicitly requested via environment variable
736+
if os.environ.get("FORCE_REPROCESS", "").lower() == "true":
737+
logger.info("🔄 FORCE_REPROCESS=true, will reprocess all files")
738+
files_to_process = data_files
739+
processing_state = {} # Clear processing state
740+
else:
741+
return True
742+
694743
# Process files concurrently with a semaphore to limit concurrent files
695744
semaphore = asyncio.Semaphore(3) # Limit to 3 concurrent files
696745
tasks = []
@@ -700,8 +749,12 @@ async def process_with_semaphore(file: str) -> None:
700749
if shutdown_requested:
701750
return
702751
await process_file_async(file, config)
752+
# Mark file as processed
753+
processing_state[file] = True
754+
_save_processing_state(config.discogs_root, processing_state)
755+
logger.info(f"✅ Marked {file} as processed")
703756

704-
for data_file in data_files:
757+
for data_file in files_to_process:
705758
if shutdown_requested:
706759
break
707760
tasks.append(asyncio.create_task(process_with_semaphore(data_file)))
@@ -882,6 +935,8 @@ async def main_async() -> None:
882935
logger.info(
883936
"🚀 Starting Discogs data extractor with concurrent processing and periodic checks"
884937
)
938+
logger.info("📋 Will check for incomplete processing from previous runs")
939+
logger.info("💡 Tip: Set FORCE_REPROCESS=true to reprocess all files")
885940

886941
# Start health server
887942
health_server = HealthServer(8000, get_health_data)

graphinator/graphinator.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import contextlib
33
import logging
4+
import os
45
import signal
56
import time
67
from asyncio import run
@@ -812,6 +813,14 @@ async def main() -> None:
812813
setup_logging("graphinator", log_file=Path("/logs/graphinator.log"))
813814
logger.info("🚀 Starting Neo4j graphinator service")
814815

816+
# Add startup delay for dependent services
817+
startup_delay = int(os.environ.get("STARTUP_DELAY", "5"))
818+
if startup_delay > 0:
819+
logger.info(
820+
f"⏳ Waiting {startup_delay} seconds for dependent services to start..."
821+
)
822+
await asyncio.sleep(startup_delay)
823+
815824
# Start health server
816825
health_server = HealthServer(8001, get_health_data)
817826
health_server.start_background()
@@ -882,15 +891,40 @@ async def main() -> None:
882891
# Initialize resilient RabbitMQ connection
883892
rabbitmq = AsyncResilientRabbitMQ(
884893
connection_url=config.amqp_connection,
894+
max_retries=10, # More retries for startup
885895
heartbeat=600,
886896
connection_attempts=10,
887897
retry_delay=5.0,
888898
)
889899

890-
try:
891-
amqp_connection = await rabbitmq.connect()
892-
except Exception as e:
893-
logger.error(f"❌ Failed to connect to AMQP broker: {e}")
900+
# Try to connect with additional retry logic for startup
901+
max_startup_retries = 5
902+
startup_retry = 0
903+
amqp_connection = None
904+
905+
while startup_retry < max_startup_retries and not shutdown_requested:
906+
try:
907+
logger.info(
908+
f"🐰 Attempting to connect to RabbitMQ (attempt {startup_retry + 1}/{max_startup_retries})"
909+
)
910+
amqp_connection = await rabbitmq.connect()
911+
break
912+
except Exception as e:
913+
startup_retry += 1
914+
if startup_retry < max_startup_retries:
915+
wait_time = min(30, 5 * startup_retry) # Exponential backoff up to 30s
916+
logger.warning(
917+
f"⚠️ RabbitMQ connection failed: {e}. Retrying in {wait_time} seconds..."
918+
)
919+
await asyncio.sleep(wait_time)
920+
else:
921+
logger.error(
922+
f"❌ Failed to connect to AMQP broker after {max_startup_retries} attempts: {e}"
923+
)
924+
return
925+
926+
if amqp_connection is None:
927+
logger.error("❌ No AMQP connection available")
894928
return
895929

896930
async with amqp_connection:

tableinator/tableinator.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import contextlib
33
import logging
4+
import os
45
import signal
56
import time
67
from asyncio import run
@@ -197,6 +198,14 @@ async def main() -> None:
197198
setup_logging("tableinator", log_file=Path("/logs/tableinator.log"))
198199
logger.info("🚀 Starting PostgreSQL tableinator service with connection pooling")
199200

201+
# Add startup delay for dependent services
202+
startup_delay = int(os.environ.get("STARTUP_DELAY", "5"))
203+
if startup_delay > 0:
204+
logger.info(
205+
f"⏳ Waiting {startup_delay} seconds for dependent services to start..."
206+
)
207+
await asyncio.sleep(startup_delay)
208+
200209
# Start health server
201210
health_server = HealthServer(8002, get_health_data)
202211
health_server.start_background()
@@ -316,15 +325,40 @@ async def main() -> None:
316325
# Initialize resilient RabbitMQ connection
317326
rabbitmq = AsyncResilientRabbitMQ(
318327
connection_url=config.amqp_connection,
328+
max_retries=10, # More retries for startup
319329
heartbeat=600,
320330
connection_attempts=10,
321331
retry_delay=5.0,
322332
)
323333

324-
try:
325-
amqp_connection = await rabbitmq.connect()
326-
except Exception as e:
327-
logger.error(f"❌ Failed to connect to AMQP broker: {e}")
334+
# Try to connect with additional retry logic for startup
335+
max_startup_retries = 5
336+
startup_retry = 0
337+
amqp_connection = None
338+
339+
while startup_retry < max_startup_retries and not shutdown_requested:
340+
try:
341+
logger.info(
342+
f"🐰 Attempting to connect to RabbitMQ (attempt {startup_retry + 1}/{max_startup_retries})"
343+
)
344+
amqp_connection = await rabbitmq.connect()
345+
break
346+
except Exception as e:
347+
startup_retry += 1
348+
if startup_retry < max_startup_retries:
349+
wait_time = min(30, 5 * startup_retry) # Exponential backoff up to 30s
350+
logger.warning(
351+
f"⚠️ RabbitMQ connection failed: {e}. Retrying in {wait_time} seconds..."
352+
)
353+
await asyncio.sleep(wait_time)
354+
else:
355+
logger.error(
356+
f"❌ Failed to connect to AMQP broker after {max_startup_retries} attempts: {e}"
357+
)
358+
return
359+
360+
if amqp_connection is None:
361+
logger.error("❌ No AMQP connection available")
328362
return
329363

330364
async with amqp_connection:

0 commit comments

Comments
 (0)