smodusermc commited on
Commit
4ad9c44
·
verified ·
1 Parent(s): 23fdfc9

Update storage_handler.py

Browse files
Files changed (1) hide show
  1. storage_handler.py +323 -39
storage_handler.py CHANGED
@@ -43,6 +43,14 @@ except Exception as e:
43
 
44
  assert len(FILE_ENCRYPTION_KEY) == 32, "FILE_ENCRYPTION_KEY must decode to exactly 32 bytes for AES-256"
45
 
 
 
 
 
 
 
 
 
46
  # ------------------------------------------------------------------------
47
  # Storage availability
48
  # ------------------------------------------------------------------------
@@ -198,15 +206,53 @@ def _validate_path(remote_path: str) -> None:
198
  if len(remote_path) > 1024:
199
  raise ValueError("Path too long (max 1024 characters)")
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  # ------------------------------------------------------------------------
202
  # Core File Operations
203
  # ------------------------------------------------------------------------
204
  def store_file(remote_path: str, data: bytes, encrypt: bool = True) -> str:
205
  _validate_path(remote_path)
206
- _require_available()
207
- fs = _get_fs()
208
  try:
209
  payload = encrypt_bytes(data, remote_path.encode('utf-8')) if encrypt else data
 
 
 
 
 
 
 
 
 
 
210
  full_uri = _bucket_path(remote_path)
211
  with fs.open(full_uri, "wb") as f:
212
  f.write(payload)
@@ -221,14 +267,20 @@ def store_file(remote_path: str, data: bytes, encrypt: bool = True) -> str:
221
 
222
  def retrieve_file(remote_path: str, decrypt: bool = True) -> bytes:
223
  _validate_path(remote_path)
224
- _require_available()
225
- fs = _get_fs()
226
- full_uri = _bucket_path(remote_path)
227
  try:
228
- if not fs.exists(full_uri):
229
- raise FileNotFoundError(f"File not found: {remote_path}")
230
- with fs.open(full_uri, "rb") as f:
231
- payload = f.read()
 
 
 
 
 
 
 
 
 
232
  if not payload:
233
  raise IOError(f"Empty file: {remote_path}")
234
  if decrypt:
@@ -247,11 +299,17 @@ def retrieve_file(remote_path: str, decrypt: bool = True) -> bytes:
247
 
248
  def delete_file(remote_path: str) -> bool:
249
  _validate_path(remote_path)
250
- if _OFFLINE:
251
- return False
252
- fs = _get_fs()
253
- full_uri = _bucket_path(remote_path)
254
  try:
 
 
 
 
 
 
 
 
 
 
255
  if fs.exists(full_uri):
256
  fs.rm(full_uri)
257
  logger.debug(f"🗑️ Deleted: {remote_path}")
@@ -264,19 +322,19 @@ def delete_file(remote_path: str) -> bool:
264
 
265
 
266
  def file_exists(remote_path: str) -> bool:
267
- if _OFFLINE:
268
- return False
269
  try:
270
  _validate_path(remote_path)
 
 
271
  return _get_fs().exists(_bucket_path(remote_path))
272
  except Exception:
273
  return False
274
 
275
 
276
  def list_files(prefix: str = "", recursive: bool = True) -> List[str]:
277
- if _OFFLINE:
278
- return []
279
  try:
 
 
280
  fs = _get_fs()
281
  search_path = _bucket_path(prefix) if prefix else BUCKET_URI
282
  items = fs.ls(search_path, detail=False, recursive=recursive)
@@ -290,10 +348,11 @@ def list_files(prefix: str = "", recursive: bool = True) -> List[str]:
290
 
291
 
292
  def get_file_size(remote_path: str) -> Optional[int]:
293
- if _OFFLINE:
294
- return None
295
  try:
296
  _validate_path(remote_path)
 
 
 
297
  info = _get_fs().info(_bucket_path(remote_path))
298
  return info.get("size")
299
  except Exception:
@@ -301,10 +360,20 @@ def get_file_size(remote_path: str) -> Optional[int]:
301
 
302
 
303
  def get_file_info(remote_path: str) -> Optional[Dict[str, Any]]:
304
- if _OFFLINE:
305
- return None
306
  try:
307
  _validate_path(remote_path)
 
 
 
 
 
 
 
 
 
 
 
 
308
  fs = _get_fs()
309
  info = fs.info(_bucket_path(remote_path))
310
  return {
@@ -332,10 +401,14 @@ def retrieve_file_stream(remote_path: str, decrypt: bool = True) -> BinaryIO:
332
 
333
  def get_storage_stats() -> Dict[str, Any]:
334
  if _OFFLINE:
335
- return {
336
- "bucket": None, "file_count": 0, "total_size": 0,
337
- "total_size_mb": 0, "error": "Storage offline (HF_TOKEN not configured)"
338
- }
 
 
 
 
339
  try:
340
  fs = _get_fs()
341
  items = fs.ls(BUCKET_URI, detail=True, recursive=True)
@@ -391,23 +464,26 @@ DB_SYNC_INTERVAL = 30 # seconds
391
 
392
  def download_database(local_path: str) -> bool:
393
  """
394
- Download database from bucket to local path.
395
- Returns True if database was found and downloaded.
 
 
396
  """
397
- if _OFFLINE:
398
- logger.info("📭 Storage offline - skipping database download")
399
  return False
400
  try:
401
  if file_exists(DB_BUCKET_PATH):
402
- logger.info("📥 Downloading database from bucket...")
403
  data = retrieve_file(DB_BUCKET_PATH)
404
- os.makedirs(os.path.dirname(local_path), exist_ok=True)
 
405
  with open(local_path, 'wb') as f:
406
  f.write(data)
407
  logger.info(f"✅ Database downloaded ({len(data):,} bytes)")
408
  return True
409
  else:
410
- logger.info("📭 No existing database in bucket - will create fresh")
411
  return False
412
  except Exception as e:
413
  logger.error(f"❌ Failed to download database: {e}")
@@ -425,8 +501,6 @@ def upload_database(local_path: str) -> bool:
425
  Returns True on success.
426
  """
427
  global _last_db_sync
428
- if _OFFLINE:
429
- return False
430
  with _db_sync_lock:
431
  tmp_path = None
432
  try:
@@ -467,14 +541,223 @@ def upload_database(local_path: str) -> bool:
467
  pass
468
 
469
 
470
- def start_db_sync(local_path: str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  """
472
- Start background thread that periodically uploads the database to bucket.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
473
  """
474
- if _OFFLINE:
475
- logger.info("🔄 Database auto-sync skipped (storage offline)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  return
477
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  def _sync_loop():
479
  while True:
480
  time.sleep(DB_SYNC_INTERVAL)
@@ -504,5 +787,6 @@ __all__ = [
504
  'store_file_stream', 'retrieve_file_stream',
505
  'get_storage_stats', 'create_backup', 'close',
506
  'download_database', 'upload_database', 'start_db_sync',
507
- 'DB_BUCKET_PATH', 'StorageUnavailableError'
 
508
  ]
 
43
 
44
  assert len(FILE_ENCRYPTION_KEY) == 32, "FILE_ENCRYPTION_KEY must decode to exactly 32 bytes for AES-256"
45
 
46
+ # Local filesystem fallback used when HF_TOKEN is not configured. This lets
47
+ # previews/dev machines upload files, run backups, and keep data between
48
+ # restarts without a Hugging Face account.
49
+ LOCAL_STORAGE_DIR = os.environ.get(
50
+ "LOCAL_STORAGE_DIR",
51
+ os.path.join(os.path.expanduser("~"), ".infinitychat_storage"),
52
+ )
53
+
54
  # ------------------------------------------------------------------------
55
  # Storage availability
56
  # ------------------------------------------------------------------------
 
206
  if len(remote_path) > 1024:
207
  raise ValueError("Path too long (max 1024 characters)")
208
 
209
+
210
+
211
+ def _local_root() -> str:
212
+ os.makedirs(LOCAL_STORAGE_DIR, exist_ok=True)
213
+ return LOCAL_STORAGE_DIR
214
+
215
+
216
+ def _local_path(remote_path: str) -> str:
217
+ _validate_path(remote_path)
218
+ return os.path.join(_local_root(), *remote_path.split("/"))
219
+
220
+
221
+ def _local_list(prefix: str = "", recursive: bool = True) -> List[str]:
222
+ root = _local_root()
223
+ base = os.path.join(root, *prefix.split("/")) if prefix else root
224
+ if not os.path.isdir(base):
225
+ return []
226
+ out = []
227
+ if recursive:
228
+ for dirpath, _dirnames, filenames in os.walk(base):
229
+ for name in filenames:
230
+ full = os.path.join(dirpath, name)
231
+ out.append(os.path.relpath(full, root).replace(os.sep, "/"))
232
+ else:
233
+ for name in os.listdir(base):
234
+ full = os.path.join(base, name)
235
+ if os.path.isfile(full):
236
+ out.append(os.path.relpath(full, root).replace(os.sep, "/"))
237
+ return sorted(out)
238
+
239
  # ------------------------------------------------------------------------
240
  # Core File Operations
241
  # ------------------------------------------------------------------------
242
  def store_file(remote_path: str, data: bytes, encrypt: bool = True) -> str:
243
  _validate_path(remote_path)
 
 
244
  try:
245
  payload = encrypt_bytes(data, remote_path.encode('utf-8')) if encrypt else data
246
+ if _OFFLINE:
247
+ path = _local_path(remote_path)
248
+ os.makedirs(os.path.dirname(path), exist_ok=True)
249
+ tmp = path + ".tmp"
250
+ with open(tmp, "wb") as f:
251
+ f.write(payload)
252
+ os.replace(tmp, path)
253
+ logger.debug(f"💾 Stored (local): {remote_path} ({len(data)} bytes)")
254
+ return remote_path
255
+ fs = _get_fs()
256
  full_uri = _bucket_path(remote_path)
257
  with fs.open(full_uri, "wb") as f:
258
  f.write(payload)
 
267
 
268
  def retrieve_file(remote_path: str, decrypt: bool = True) -> bytes:
269
  _validate_path(remote_path)
 
 
 
270
  try:
271
+ if _OFFLINE:
272
+ path = _local_path(remote_path)
273
+ if not os.path.exists(path):
274
+ raise FileNotFoundError(f"File not found: {remote_path}")
275
+ with open(path, "rb") as f:
276
+ payload = f.read()
277
+ else:
278
+ fs = _get_fs()
279
+ full_uri = _bucket_path(remote_path)
280
+ if not fs.exists(full_uri):
281
+ raise FileNotFoundError(f"File not found: {remote_path}")
282
+ with fs.open(full_uri, "rb") as f:
283
+ payload = f.read()
284
  if not payload:
285
  raise IOError(f"Empty file: {remote_path}")
286
  if decrypt:
 
299
 
300
  def delete_file(remote_path: str) -> bool:
301
  _validate_path(remote_path)
 
 
 
 
302
  try:
303
+ if _OFFLINE:
304
+ path = _local_path(remote_path)
305
+ if os.path.exists(path):
306
+ os.unlink(path)
307
+ logger.debug(f"🗑️ Deleted (local): {remote_path}")
308
+ return True
309
+ logger.warning(f"⚠️ Not found for deletion: {remote_path}")
310
+ return False
311
+ fs = _get_fs()
312
+ full_uri = _bucket_path(remote_path)
313
  if fs.exists(full_uri):
314
  fs.rm(full_uri)
315
  logger.debug(f"🗑️ Deleted: {remote_path}")
 
322
 
323
 
324
  def file_exists(remote_path: str) -> bool:
 
 
325
  try:
326
  _validate_path(remote_path)
327
+ if _OFFLINE:
328
+ return os.path.exists(_local_path(remote_path))
329
  return _get_fs().exists(_bucket_path(remote_path))
330
  except Exception:
331
  return False
332
 
333
 
334
  def list_files(prefix: str = "", recursive: bool = True) -> List[str]:
 
 
335
  try:
336
+ if _OFFLINE:
337
+ return _local_list(prefix, recursive)
338
  fs = _get_fs()
339
  search_path = _bucket_path(prefix) if prefix else BUCKET_URI
340
  items = fs.ls(search_path, detail=False, recursive=recursive)
 
348
 
349
 
350
  def get_file_size(remote_path: str) -> Optional[int]:
 
 
351
  try:
352
  _validate_path(remote_path)
353
+ if _OFFLINE:
354
+ path = _local_path(remote_path)
355
+ return os.path.getsize(path) if os.path.exists(path) else None
356
  info = _get_fs().info(_bucket_path(remote_path))
357
  return info.get("size")
358
  except Exception:
 
360
 
361
 
362
  def get_file_info(remote_path: str) -> Optional[Dict[str, Any]]:
 
 
363
  try:
364
  _validate_path(remote_path)
365
+ if _OFFLINE:
366
+ path = _local_path(remote_path)
367
+ if not os.path.exists(path):
368
+ return None
369
+ st = os.stat(path)
370
+ return {
371
+ "name": remote_path,
372
+ "size": st.st_size,
373
+ "created": st.st_ctime,
374
+ "modified": st.st_mtime,
375
+ "type": "file",
376
+ }
377
  fs = _get_fs()
378
  info = fs.info(_bucket_path(remote_path))
379
  return {
 
401
 
402
  def get_storage_stats() -> Dict[str, Any]:
403
  if _OFFLINE:
404
+ try:
405
+ files = _local_list()
406
+ total = sum(os.path.getsize(_local_path(p)) for p in files)
407
+ return {"bucket": "local", "file_count": len(files), "total_size": total,
408
+ "total_size_mb": round(total / (1024 * 1024), 2)}
409
+ except Exception as e:
410
+ return {"bucket": "local", "file_count": 0, "total_size": 0,
411
+ "total_size_mb": 0, "error": str(e)}
412
  try:
413
  fs = _get_fs()
414
  items = fs.ls(BUCKET_URI, detail=True, recursive=True)
 
464
 
465
  def download_database(local_path: str) -> bool:
466
  """
467
+ Download database from storage to the local path when no local database
468
+ exists yet. The local SQLite file is the working copy; overwriting it on
469
+ every restart would discard newer local changes (DMs/groups/social posts)
470
+ that haven't been synced back yet.
471
  """
472
+ if os.path.exists(local_path):
473
+ logger.info("📁 Local database already exists - keeping it")
474
  return False
475
  try:
476
  if file_exists(DB_BUCKET_PATH):
477
+ logger.info("📥 Downloading database from storage...")
478
  data = retrieve_file(DB_BUCKET_PATH)
479
+ dname = os.path.dirname(os.path.abspath(local_path))
480
+ os.makedirs(dname, exist_ok=True)
481
  with open(local_path, 'wb') as f:
482
  f.write(data)
483
  logger.info(f"✅ Database downloaded ({len(data):,} bytes)")
484
  return True
485
  else:
486
+ logger.info("📭 No existing database in storage - will create fresh")
487
  return False
488
  except Exception as e:
489
  logger.error(f"❌ Failed to download database: {e}")
 
501
  Returns True on success.
502
  """
503
  global _last_db_sync
 
 
504
  with _db_sync_lock:
505
  tmp_path = None
506
  try:
 
541
  pass
542
 
543
 
544
+ # ------------------------------------------------------------------------
545
+ # Backups (database + all data files) to a separate folder in the bucket
546
+ # ------------------------------------------------------------------------
547
+ BACKUP_DIR = "backups"
548
+ BACKUP_RETENTION_HOURS = int(os.environ.get("BACKUP_RETENTION_HOURS", "48"))
549
+ BACKUP_RETENTION_DAILY = int(os.environ.get("BACKUP_RETENTION_DAILY", "7"))
550
+
551
+
552
+ def _utc_slug() -> str:
553
+ return time.strftime("%Y-%m-%d_%H-%M-%S", time.gmtime())
554
+
555
+
556
+ def _snapshot_db_bytes(local_path: str) -> bytes:
557
+ """Take a consistent SQLite snapshot and return its bytes.
558
+
559
+ Uses the sqlite online backup API so WAL-mode writes are not lost and the
560
+ copied file is never a torn/corrupt snapshot.
561
  """
562
+ if not os.path.exists(local_path):
563
+ raise FileNotFoundError(f"Database not found at {local_path}")
564
+ fd, tmp = tempfile.mkstemp(suffix=".db", dir=os.path.dirname(os.path.abspath(local_path)) or ".")
565
+ os.close(fd)
566
+ os.unlink(tmp)
567
+ src = sqlite3.connect(f"file:{local_path}?mode=ro", uri=True)
568
+ try:
569
+ dst = sqlite3.connect(tmp)
570
+ try:
571
+ src.backup(dst)
572
+ finally:
573
+ dst.close()
574
+ finally:
575
+ src.close()
576
+ with open(tmp, "rb") as f:
577
+ data = f.read()
578
+ try:
579
+ os.unlink(tmp)
580
+ except Exception:
581
+ pass
582
+ if not data:
583
+ raise IOError("Database snapshot was empty")
584
+ return data
585
+
586
+
587
+ def _copy_live_files_to_backup(backup_prefix: str) -> int:
588
+ """Copy current data files (uploads/avatars/database) into a backup folder.
589
+
590
+ Existing backups under backups/ are excluded so hourly backups don't grow
591
+ exponentially by copying previous backups.
592
  """
593
+ copied = 0
594
+ for path in list_files(""):
595
+ if path == DB_BUCKET_PATH or path == "database/infinitychat.db":
596
+ continue # database is saved separately from the snapshot
597
+ if path.startswith(BACKUP_DIR + "/"):
598
+ continue
599
+ try:
600
+ data = retrieve_file(path) # decrypted to plaintext
601
+ store_file(f"{backup_prefix}/{path}", data, encrypt=True)
602
+ copied += 1
603
+ except Exception as e:
604
+ logger.warning(f"⚠️ Backup skipped {path}: {e}")
605
+ return copied
606
+
607
+
608
+ def list_backups() -> List[Dict[str, Any]]:
609
+ """Return metadata for all timestamped backups (newest first)."""
610
+ out: Dict[str, Dict[str, Any]] = {}
611
+ try:
612
+ for path in list_files(BACKUP_DIR):
613
+ parts = path.split("/")
614
+ if len(parts) < 4:
615
+ continue
616
+ category, stamp = parts[1], parts[2]
617
+ key = f"{category}/{stamp}"
618
+ if key not in out:
619
+ out[key] = {
620
+ "path": f"{BACKUP_DIR}/{category}/{stamp}",
621
+ "category": category,
622
+ "timestamp": stamp,
623
+ "file_count": 0,
624
+ "size": 0,
625
+ "created": None,
626
+ "has_database": False,
627
+ }
628
+ info = get_file_info(path)
629
+ out[key]["file_count"] += 1
630
+ out[key]["size"] += int(info.get("size") or 0) if info else 0
631
+ out[key]["created"] = info.get("created") if info else None
632
+ if path.endswith("infinitychat.db"):
633
+ out[key]["has_database"] = True
634
+ except Exception as e:
635
+ logger.error(f"Failed to list backups: {e}")
636
+ return sorted(out.values(), key=lambda x: x["timestamp"], reverse=True)
637
+
638
+
639
+ def create_timestamped_backup(local_path: str, category: str = "hourly") -> Optional[str]:
640
+ """Create a full point-in-time backup of the DB + data files.
641
+
642
+ Returns the backup prefix (e.g. backups/hourly/2026-01-01_00-00-00) or None
643
+ when the storage backend is unavailable.
644
+ """
645
+ with _db_sync_lock:
646
+ prefix = f"{BACKUP_DIR}/{category}/{_utc_slug()}"
647
+ try:
648
+ db_bytes = _snapshot_db_bytes(local_path)
649
+ store_file(f"{prefix}/database/infinitychat.db", db_bytes, encrypt=True)
650
+ copied = _copy_live_files_to_backup(prefix)
651
+ logger.info(f"💾 Backup created: {prefix} (db + {copied} files)")
652
+ prune_backups()
653
+ return prefix
654
+ except Exception as e:
655
+ logger.error(f"❌ Backup failed: {e}")
656
+ return None
657
+
658
+
659
+ def prune_backups():
660
+ """Keep the newest N hourly backups and the last daily marker per day."""
661
+ try:
662
+ hourly = sorted([b for b in list_backups() if b["category"] == "hourly"],
663
+ key=lambda b: b["timestamp"], reverse=True)
664
+ for b in hourly[BACKUP_RETENTION_HOURS:]:
665
+ _delete_tree(b["path"])
666
+ # Keep at most one backup per calendar day (the newest of that day),
667
+ # for a longer daily retention window.
668
+ seen_days = set()
669
+ for b in hourly[:BACKUP_RETENTION_HOURS]:
670
+ day = b["timestamp"][:10]
671
+ if day not in seen_days:
672
+ seen_days.add(day)
673
+ daily = sorted([b for b in hourly if b["timestamp"][:10] not in seen_days],
674
+ key=lambda b: b["timestamp"], reverse=True)
675
+ for b in daily[BACKUP_RETENTION_DAILY:]:
676
+ _delete_tree(b["path"])
677
+ except Exception as e:
678
+ logger.error(f"Failed to prune backups: {e}")
679
+
680
+
681
+ def _delete_tree(prefix: str):
682
+ try:
683
+ for path in list_files(prefix):
684
+ try:
685
+ delete_file(path)
686
+ except Exception:
687
+ pass
688
+ except Exception as e:
689
+ logger.warning(f"Failed to delete backup tree {prefix}: {e}")
690
+
691
+
692
+ def restore_backup(local_db_path: str, backup_prefix: str) -> int:
693
+ """Restore a database + data files from a backup prefix.
694
+
695
+ Returns the number of files restored. WAL/SHM sidecars are removed first so
696
+ the server reads the restored snapshot on the next request.
697
+ """
698
+ if not backup_prefix or backup_prefix.startswith("/") or ".." in backup_prefix.split("/"):
699
+ raise ValueError("Invalid backup prefix")
700
+ files = list_files(backup_prefix)
701
+ if not files:
702
+ raise FileNotFoundError("Backup not found")
703
+ restored = 0
704
+ for path in files:
705
+ data = retrieve_file(path) # decrypt
706
+ rel = path[len(backup_prefix) + 1:]
707
+ if rel == "database/infinitychat.db":
708
+ os.makedirs(os.path.dirname(local_db_path) or ".", exist_ok=True)
709
+ with open(local_db_path, "wb") as f:
710
+ f.write(data)
711
+ for suffix in ("-wal", "-shm"):
712
+ try:
713
+ if os.path.exists(local_db_path + suffix):
714
+ os.unlink(local_db_path + suffix)
715
+ except Exception:
716
+ pass
717
+ else:
718
+ store_file(rel, data, encrypt=True) # re-encrypt with current key
719
+ restored += 1
720
+ logger.info(f"♻️ Restored {restored} file(s) from {backup_prefix}")
721
+ return restored
722
+
723
+
724
+ def delete_backup(backup_prefix: str) -> None:
725
+ """Delete a timestamped backup tree. Only backups/... paths are allowed."""
726
+ if not backup_prefix or backup_prefix.startswith("/") or ".." in backup_prefix.split("/"):
727
+ raise ValueError("Invalid backup prefix")
728
+ bucket_prefix = f"{BACKUP_DIR}/"
729
+ if not backup_prefix.startswith(bucket_prefix):
730
+ raise ValueError("Only backups from the backup bucket can be deleted")
731
+ _delete_tree(backup_prefix)
732
+ logger.info(f"🗑 Deleted backup tree: {backup_prefix}")
733
+
734
+
735
+ _backup_loop_started = False
736
+
737
+
738
+ def start_backup_loop(local_path: str, interval: int = 3600):
739
+ """Background thread that creates an hourly full backup."""
740
+ global _backup_loop_started
741
+ if _backup_loop_started:
742
  return
743
 
744
+ def _loop():
745
+ while True:
746
+ time.sleep(interval)
747
+ try:
748
+ create_timestamped_backup(local_path, "hourly")
749
+ except Exception as e:
750
+ logger.warning(f"⚠️ Hourly backup failed: {e}")
751
+
752
+ _backup_loop_started = True
753
+ t = threading.Thread(target=_loop, daemon=True)
754
+ t.start()
755
+ logger.info(f"🕐 Hourly backup thread started (every {interval}s)")
756
+
757
+ def start_db_sync(local_path: str):
758
+ """
759
+ Start background thread that periodically uploads the database to bucket.
760
+ """
761
  def _sync_loop():
762
  while True:
763
  time.sleep(DB_SYNC_INTERVAL)
 
787
  'store_file_stream', 'retrieve_file_stream',
788
  'get_storage_stats', 'create_backup', 'close',
789
  'download_database', 'upload_database', 'start_db_sync',
790
+ 'list_backups', 'create_timestamped_backup', 'restore_backup',
791
+ 'start_backup_loop', 'DB_BUCKET_PATH', 'StorageUnavailableError'
792
  ]