ss / memory_db.py
Dooratre's picture
Upload 13 files
a16b959 verified
"""
In-Memory Cards Database v2.0
COMPLETE REWRITE to fix the disappearing cards bug.
Root cause of original bug:
- gevent greenlets switching during dict operations caused partial reads
- Multiple code paths mutated dict values in-place causing corruption
- Shallow copies let external code mutate internal state
Fix approach:
- ALL reads go through a single locked method that returns deep copies
- ALL writes go through a single locked method
- Internal dict is NEVER exposed directly
- Every operation is fully atomic under one lock
"""
import copy
import json
import threading
from datetime import datetime
class CardsMemoryDB:
"""
Thread-safe and greenlet-safe in-memory cards database.
EVERY read and write goes through self._lock.
This is slightly slower but 100% correct.
For our scale (< 10K cards, < 1000 req/s) this is more than fast enough.
"""
def __init__(self):
# The ONLY data store - never expose this directly
self._data = {}
# Single lock for ALL operations - simple and correct
self._lock = threading.RLock()
# Operation counters
self._counters = {
'reads': 0,
'writes': 0,
'redeems': 0,
'redeems_failed': 0,
'generates': 0,
'deletes': 0,
'validates': 0,
'started_at': datetime.now().isoformat(),
'last_backup': None,
'last_restore': None,
'last_backup_error': None,
}
# GitHub storage instance
self._github = None
self._init_github()
# Load initial data
self._load_from_github()
count = len(self._data)
print(f" [DB] Initialized with {count} cards")
def _init_github(self):
"""Initialize GitHub storage."""
try:
from github_storage import GitHubCardsStorage
self._github = GitHubCardsStorage()
except Exception as e:
print(f" [DB] GitHub init error: {e}")
self._github = None
def _load_from_github(self):
"""Load cards from GitHub on startup."""
if not self._github or not self._github.is_configured():
print(" [DB] GitHub not configured - starting empty")
return
try:
print(" [DB] Loading from GitHub...")
data = self._github.pull_cards()
if data and isinstance(data, dict):
with self._lock:
self._data = {}
for code, card in data.items():
# Deep copy each card to ensure isolation
self._data[str(code)] = {
"code": str(card.get("code", code)),
"serial": str(card.get("serial", "")),
"class": str(card.get("class", "0")),
"used": bool(card.get("used", False)),
"used_by": card.get("used_by", None),
"used_at": card.get("used_at", None),
"created_at": str(card.get("created_at", "")),
}
print(f" [DB] Loaded {len(self._data)} cards from GitHub")
else:
print(" [DB] No cards on GitHub (first run)")
except Exception as e:
print(f" [DB] GitHub load error: {e}")
# ═══════════════════════════════════════════════════════════
# CORE READ OPERATIONS
# ═══════════════════════════════════════════════════════════
def get_card(self, code):
"""
Get a card by code. Returns a COPY or None.
100% safe - external code cannot corrupt internal state.
"""
code = str(code).strip()
with self._lock:
card = self._data.get(code)
if card is None:
return None
self._counters['reads'] += 1
# Return a fresh dict copy
return {
"code": card["code"],
"serial": card["serial"],
"class": card["class"],
"used": card["used"],
"used_by": card["used_by"],
"used_at": card["used_at"],
"created_at": card["created_at"],
}
def card_exists(self, code):
"""Check if card exists."""
code = str(code).strip()
with self._lock:
return code in self._data
def get_card_count(self):
"""Get total card count."""
with self._lock:
return len(self._data)
def get_all_cards_copy(self):
"""
Get a complete deep copy of all cards.
Used by admin list and backup.
"""
with self._lock:
self._counters['reads'] += 1
result = {}
for code, card in self._data.items():
result[code] = {
"code": card["code"],
"serial": card["serial"],
"class": card["class"],
"used": card["used"],
"used_by": card["used_by"],
"used_at": card["used_at"],
"created_at": card["created_at"],
}
return result
# ═══════════════════════════════════════════════════════════
# CORE WRITE OPERATIONS
# ═══════════════════════════════════════════════════════════
def redeem_card(self, code, username):
"""
Atomically redeem a card.
Returns (success, card_copy_or_error_string).
"""
code = str(code).strip()
username = str(username).strip()
with self._lock:
card = self._data.get(code)
if card is None:
self._counters['redeems_failed'] += 1
return False, "كود الكرت غير صحيح"
if card["used"]:
self._counters['redeems_failed'] += 1
return False, "هذا الكرت مستخدم مسبقاً"
# Update in place - we're under lock so this is safe
now_str = datetime.now().isoformat()
card["used"] = True
card["used_by"] = username
card["used_at"] = now_str
self._counters['redeems'] += 1
self._counters['writes'] += 1
# Return a copy
return True, {
"code": card["code"],
"serial": card["serial"],
"class": card["class"],
"used": card["used"],
"used_by": card["used_by"],
"used_at": card["used_at"],
"created_at": card["created_at"],
}
def add_cards_bulk(self, cards_dict):
"""
Add multiple cards at once.
cards_dict = {code: card_data, ...}
"""
with self._lock:
for code, card in cards_dict.items():
code = str(code).strip()
self._data[code] = {
"code": str(card.get("code", code)),
"serial": str(card.get("serial", "")),
"class": str(card.get("class", "0")),
"used": bool(card.get("used", False)),
"used_by": card.get("used_by", None),
"used_at": card.get("used_at", None),
"created_at": str(card.get("created_at", "")),
}
added = len(cards_dict)
self._counters['generates'] += added
self._counters['writes'] += added
total = len(self._data)
print(f" [DB] Added {added} cards. Total now: {total}")
return total
def add_card(self, code, card_data):
"""Add a single card."""
return self.add_cards_bulk({code: card_data})
def delete_card(self, code):
"""Delete a card by code."""
code = str(code).strip()
with self._lock:
if code in self._data:
del self._data[code]
self._counters['deletes'] += 1
self._counters['writes'] += 1
print(f" [DB] Deleted card {code}. Total now: {len(self._data)}")
return True
return False
def replace_all(self, new_data):
"""Replace entire database (for restore)."""
with self._lock:
self._data = {}
if isinstance(new_data, dict):
for code, card in new_data.items():
code = str(code).strip()
self._data[code] = {
"code": str(card.get("code", code)),
"serial": str(card.get("serial", "")),
"class": str(card.get("class", "0")),
"used": bool(card.get("used", False)),
"used_by": card.get("used_by", None),
"used_at": card.get("used_at", None),
"created_at": str(card.get("created_at", "")),
}
self._counters['last_restore'] = datetime.now().isoformat()
total = len(self._data)
print(f" [DB] Replaced all data. Total now: {total}")
return total
# ═══════════════════════════════════════════════════════════
# GITHUB OPERATIONS
# ═══════════════════════════════════════════════════════════
def push_to_github(self):
"""Push current data to GitHub."""
if not self._github or not self._github.is_configured():
return False, "GitHub not configured"
# Take snapshot under lock
with self._lock:
snapshot = {}
for code, card in self._data.items():
snapshot[code] = {
"code": card["code"],
"serial": card["serial"],
"class": card["class"],
"used": card["used"],
"used_by": card["used_by"],
"used_at": card["used_at"],
"created_at": card["created_at"],
}
snapshot_count = len(snapshot)
print(f" [DB] Pushing {snapshot_count} cards to GitHub...")
# Push WITHOUT holding lock (network call)
success, error = self._github.push_cards(snapshot)
with self._lock:
if success:
self._counters['last_backup'] = datetime.now().isoformat()
self._counters['last_backup_error'] = None
else:
self._counters['last_backup_error'] = error
return success, error
def pull_from_github(self):
"""Pull from GitHub and replace all data."""
if not self._github or not self._github.is_configured():
return False, "GitHub not configured"
print(" [DB] Pulling from GitHub...")
try:
data = self._github.pull_cards()
if isinstance(data, dict):
total = self.replace_all(data)
print(f" [DB] Restored {total} cards from GitHub")
return True, None
else:
return False, "Invalid data from GitHub"
except Exception as e:
err = f"Pull error: {e}"
print(f" [DB] {err}")
return False, err
# ═══════════════════════════════════════════════════════════
# STATISTICS
# ═══════════════════════════════════════════════════════════
def get_stats(self):
"""Get complete database statistics. All under lock for consistency."""
with self._lock:
total = len(self._data)
used = 0
unused = 0
by_class = {}
for card in self._data.values():
is_used = card["used"]
card_class = card["class"]
if is_used:
used += 1
else:
unused += 1
if card_class not in by_class:
by_class[card_class] = {'total': 0, 'used': 0, 'unused': 0}
by_class[card_class]['total'] += 1
if is_used:
by_class[card_class]['used'] += 1
else:
by_class[card_class]['unused'] += 1
counters_copy = dict(self._counters)
return {
'cards': {
'total': total,
'used': used,
'unused': unused,
'used_percentage': round((used / total * 100), 1) if total > 0 else 0,
},
'by_class': by_class,
'operations': counters_copy,
}
def increment_stat(self, name, count=1):
"""Increment a stat counter."""
with self._lock:
if name in self._counters and isinstance(self._counters[name], int):
self._counters[name] += count
def debug_dump(self):
"""
Debug: dump all card codes and classes.
Use this to verify data integrity.
"""
with self._lock:
total = len(self._data)
classes = {}
for code, card in self._data.items():
c = card["class"]
if c not in classes:
classes[c] = 0
classes[c] += 1
print(f" [DB DEBUG] Total: {total}, By class: {classes}")
return {
"total": total,
"by_class": classes,
"codes": list(self._data.keys()),
}
# ═══════════════════════════════════════════════════════════
# Helper function for blueprints to get DB from app context
# ═══════════════════════════════════════════════════════════
def get_db():
"""
Get DB instance from Flask app config.
This ensures ALL code uses the SAME dict instance.
"""
from flask import current_app
return current_app.config['DB']