@@ -292,7 +292,7 @@ def search(self, ip: int) -> Optional[int]:
292292 """Find geoname_id for IP address."""
293293 return self ._search (self .root , ip )
294294
295- def _search (self , node : IntervalTreeNode , ip : str ):
295+ def _search (self , node : IntervalTreeNode , ip : int ):
296296 if not node :
297297 return None
298298
@@ -325,6 +325,9 @@ class UltraFastGeoLookup:
325325 def __init__ (self , output_dir : str = "/var/tmp" , license_key : Optional [str ] = None ):
326326 self .output_dir = output_dir
327327 self .license_key = license_key
328+
329+ # Ensure output directory exists
330+ os .makedirs (self .output_dir , exist_ok = True )
328331 self .locations_file = os .path .join (
329332 self .output_dir , "GeoLite2-Country-Locations-en.csv"
330333 )
@@ -335,13 +338,15 @@ def __init__(self, output_dir: str = "/var/tmp", license_key: Optional[str] = No
335338 # Thread-safe data structures
336339 self ._lock = RLock ()
337340 self ._country_map : Dict [int , str ] = {}
338- self ._interval_tree = IntervalTree ( )
341+ self ._ip_ranges = [] # Sorted list of (start_ip, end_ip, geoname_id )
339342
340343 # File monitoring
341344 self ._locations_mtime = 0
342345 self ._blocks_mtime = 0
343346 self ._loaded = False
344347 self ._load_start_time = 0
348+ self ._ready = None # Will be created in start()
349+ self ._initial_load_complete = False
345350
346351 # Performance cache - much larger for metagraph use
347352 self ._cache : Dict [str , str ] = {}
@@ -356,6 +361,8 @@ async def start(self):
356361 """Start the geo lookup service with preloading and background updates."""
357362 btul .logging .info ("🚀 Starting UltraFastGeoLookup service..." )
358363
364+ # Create the ready event in async context
365+ self ._ready = asyncio .Event ()
359366 self ._running = True
360367
361368 # Preload data at startup
@@ -386,19 +393,32 @@ async def _preload_data(self):
386393 self ._load_start_time = start_time
387394
388395 # Check for updates first
396+ btul .logging .debug ("Checking for updates..." )
389397 await self ._updater .check_and_download_updates ()
390398
391399 # Load the data
400+ btul .logging .debug ("Loading data internal..." )
392401 self ._load_data_internal ()
393402
394403 load_time = time .time () - start_time
395404 btul .logging .info (
396405 f"✅ GeoLite2 data preloaded in { load_time :.2f} s: "
397406 f"{ len (self ._country_map )} countries, interval tree ready"
398407 )
408+
409+ # Mark as ready for consumers
410+ self ._initial_load_complete = True
411+ if self ._ready :
412+ self ._ready .set ()
399413
400414 except Exception as e :
401415 btul .logging .error (f"❌ Failed to preload GeoLite2 data: { e } " )
416+ import traceback
417+ btul .logging .error (f"Traceback: { traceback .format_exc ()} " )
418+ # Even on error, mark as "ready" to avoid blocking consumers indefinitely
419+ # They will fall back to API lookups
420+ if self ._ready :
421+ self ._ready .set ()
402422
403423 def _start_background_updates (self ):
404424 """Start background task for periodic CSV updates."""
@@ -424,7 +444,20 @@ async def _background_update_loop(self):
424444
425445 if updated :
426446 btul .logging .info ("🔄 GeoLite2 files updated, reloading data..." )
427- self ._load_data_internal ()
447+ try :
448+ # Temporarily mark as not ready during reload to prevent exceptions
449+ if self ._ready :
450+ self ._ready .clear ()
451+ self ._load_data_internal ()
452+ # Mark as ready again after successful reload
453+ if self ._ready :
454+ self ._ready .set ()
455+ btul .logging .info ("✅ GeoLite2 data reloaded successfully" )
456+ except Exception as e :
457+ btul .logging .error (f"❌ Failed to reload GeoLite2 data: { e } " )
458+ # Re-mark as ready to continue operations (will use cache or fallback)
459+ if self ._ready :
460+ self ._ready .set ()
428461
429462 # Sleep for 6 hours before next check
430463 await asyncio .sleep (6 * 3600 )
@@ -470,14 +503,23 @@ def _check_file_changes(self) -> bool:
470503 def _load_data_internal (self ):
471504 """Internal data loading with maximum performance."""
472505 with self ._lock :
473- if not self ._check_file_changes () and self ._loaded :
474- return
506+ # Always update file times first to prevent infinite recursion
507+ self ._locations_mtime = (
508+ os .path .getmtime (self .locations_file )
509+ if os .path .exists (self .locations_file )
510+ else 0
511+ )
512+ self ._blocks_mtime = (
513+ os .path .getmtime (self .blocks_file )
514+ if os .path .exists (self .blocks_file )
515+ else 0
516+ )
475517
476- btul .logging .debug ("📊 Reloading GeoLite2 data due to file changes ..." )
518+ btul .logging .debug ("📊 Loading GeoLite2 data..." )
477519
478520 # Clear previous data
479521 self ._country_map .clear ()
480- self ._interval_tree = IntervalTree ()
522+ self ._ip_ranges . clear ()
481523 self ._cache .clear ()
482524
483525 # Load country mappings with memory-mapped file for large files
@@ -486,6 +528,7 @@ def _load_data_internal(self):
486528
487529 # Load IP blocks and build interval tree
488530 if os .path .exists (self .blocks_file ):
531+ btul .logging .debug ("Loading blocks file..." )
489532 self ._load_blocks_optimized ()
490533
491534 self ._loaded = True
@@ -503,13 +546,15 @@ def _load_locations_optimized(self):
503546 def _load_blocks_optimized (self ):
504547 """Load IP blocks and build interval tree with progress."""
505548 processed = 0
549+ blocks_data = []
506550
551+ # First pass: collect all data
507552 with open (self .blocks_file , "r" , encoding = "utf-8" ) as f :
508553 reader = csv .DictReader (f )
509554
510555 for row in reader :
511556 network = row .get ("network" )
512- geoname_id = row .get ("geoname_id" )
557+ geoname_id = row .get ("geoname_id" ) or row . get ( "registered_country_geoname_id" )
513558
514559 if network and geoname_id :
515560 try :
@@ -518,40 +563,84 @@ def _load_blocks_optimized(self):
518563 start_ip = int (net .network_address )
519564 end_ip = int (net .broadcast_address )
520565
521- # Insert into interval tree
522- self ._interval_tree .insert (start_ip , end_ip , int (geoname_id ))
566+ blocks_data .append ((start_ip , end_ip , int (geoname_id )))
523567 processed += 1
524568
525569 # Progress indicator for large datasets
526570 if processed % 50000 == 0 :
527571 elapsed = time .time () - self ._load_start_time
528572 btul .logging .debug (
529- f"📈 Processed { processed :,} IP ranges in { elapsed :.1f} s"
573+ f"📈 Collected { processed :,} IP ranges in { elapsed :.1f} s"
530574 )
531575
532576 except (ValueError , ipaddress .AddressValueError ):
533577 continue
534578
579+ # Sort IP ranges for binary search
580+ btul .logging .debug (f"Sorting { len (blocks_data )} IP blocks for binary search..." )
581+ blocks_data .sort (key = lambda x : x [0 ]) # Sort by start IP
582+
583+ # Store sorted ranges
584+ self ._ip_ranges = blocks_data
585+ btul .logging .debug (f"✅ Stored { len (self ._ip_ranges )} IP ranges for ultra-fast lookups" )
586+
587+ async def wait_for_ready (self , timeout : Optional [float ] = 30.0 ):
588+ """Wait for the geo lookup service to be ready for use."""
589+ if self ._ready is None :
590+ btul .logging .warning ("⚠️ GeoLookup service not started yet" )
591+ return
592+
593+ if timeout :
594+ try :
595+ await asyncio .wait_for (self ._ready .wait (), timeout = timeout )
596+ except asyncio .TimeoutError :
597+ btul .logging .warning (f"⚠️ GeoLite2 data loading timed out after { timeout } s - continuing with API fallback" )
598+ else :
599+ await self ._ready .wait ()
600+
601+ def is_ready (self ) -> bool :
602+ """Check if the geo lookup service is ready for use."""
603+ return self ._ready is not None and self ._ready .is_set () and self ._initial_load_complete
604+
535605 def lookup_country (self , ip_str : str ) -> Optional [str ]:
536606 """
537607 Ultra-fast IP to country lookup with automatic reloading.
538608 Returns country code (e.g., 'US', 'DE') or None if not found.
539609 """
610+ # If data is not ready yet, return None to trigger API fallback
611+ if self ._ready is None or not self ._ready .is_set ():
612+ btul .logging .debug (f"GeoLite2 data not ready yet for { ip_str } - will use API fallback" )
613+ return None
614+
540615 # Check cache first (fastest path)
541616 cached = self ._cache .get (ip_str )
542617 if cached is not None :
543618 return cached if cached != "__NOT_FOUND__" else None
544619
545620 # Hot reload if files changed
546621 if self ._check_file_changes ():
547- self ._load_data_internal ()
622+ try :
623+ # Temporarily mark as not ready during reload to prevent race conditions
624+ if self ._ready :
625+ self ._ready .clear ()
626+ self ._load_data_internal ()
627+ # Mark as ready again after successful reload
628+ if self ._ready :
629+ self ._ready .set ()
630+ except Exception as e :
631+ btul .logging .error (f"❌ Failed to hot reload GeoLite2 data: { e } " )
632+ # Re-mark as ready to continue operations
633+ if self ._ready :
634+ self ._ready .set ()
635+ # Return None to trigger API fallback for this lookup
636+ return None
548637
549638 try :
550- # Convert IP to integer for ultra-fast tree search
639+ # Convert IP to integer for binary search
551640 ip_int = int (ipaddress .IPv4Address (ip_str ))
552641
553- # Search interval tree - O(log n)
554- geoname_id = self ._interval_tree . search (ip_int )
642+ # Binary search through sorted IP ranges - O(log n)
643+ geoname_id = self ._binary_search_ip_ranges (ip_int )
555644 if geoname_id is None :
556645 # Cache negative result
557646 self ._cache_result (ip_str , None )
@@ -566,6 +655,26 @@ def lookup_country(self, ip_str: str) -> Optional[str]:
566655 self ._cache_result (ip_str , None )
567656 return None
568657
658+ def _binary_search_ip_ranges (self , ip_int : int ) -> Optional [int ]:
659+ """Binary search to find the geoname_id for an IP address."""
660+ if not self ._ip_ranges :
661+ return None
662+
663+ left , right = 0 , len (self ._ip_ranges ) - 1
664+
665+ while left <= right :
666+ mid = (left + right ) // 2
667+ start_ip , end_ip , geoname_id = self ._ip_ranges [mid ]
668+
669+ if start_ip <= ip_int <= end_ip :
670+ return geoname_id
671+ elif ip_int < start_ip :
672+ right = mid - 1
673+ else :
674+ left = mid + 1
675+
676+ return None
677+
569678 def _cache_result (self , ip_str : str , country : Optional [str ]):
570679 """Cache result with LRU-style eviction."""
571680 if len (self ._cache ) >= self ._cache_max_size :
0 commit comments