perf: fix race conditions with file locking and optimize backend latency

This commit is contained in:
a2nr 2026-05-05 22:57:19 +07:00
parent 7a3a2ecc13
commit 2b6ec21633
3 changed files with 153 additions and 92 deletions

View File

@ -25,7 +25,6 @@ def login():
token = (data.get('token') or '').strip() token = (data.get('token') or '').strip()
if not token: if not token:
time.sleep(1.5) # Tarpitting for empty tokens
return jsonify({'success': False, 'message': 'Token is required'}) return jsonify({'success': False, 'message': 'Token is required'})
student_info = validate_token(token) student_info = validate_token(token)
@ -42,7 +41,6 @@ def login():
) )
return response return response
else: else:
time.sleep(1.5) # Tarpitting for invalid tokens
return jsonify({'success': False, 'message': 'Invalid token'}) return jsonify({'success': False, 'message': 'Invalid token'})
except Exception as e: except Exception as e:

View File

@ -53,6 +53,9 @@ def track_progress():
def api_progress_report(): def api_progress_report():
"""Return progress report data as JSON.""" """Return progress report data as JSON."""
token = request.args.get('token', '').strip() token = request.args.get('token', '').strip()
if not token:
token = request.cookies.get('student_token', '').strip()
if not token or not validate_token(token): if not token or not validate_token(token):
return jsonify({'success': False, 'message': 'Unauthorized'}), 401 return jsonify({'success': False, 'message': 'Unauthorized'}), 401
@ -70,6 +73,9 @@ def api_progress_report():
def export_progress_csv(): def export_progress_csv():
"""Export the progress report as CSV.""" """Export the progress report as CSV."""
token = request.args.get('token', '').strip() token = request.args.get('token', '').strip()
if not token:
token = request.cookies.get('student_token', '').strip()
if not token or not validate_token(token): if not token or not validate_token(token):
return jsonify({'success': False, 'message': 'Unauthorized'}), 401 return jsonify({'success': False, 'message': 'Unauthorized'}), 401

View File

@ -1,107 +1,166 @@
""" """
Token and student progress operations backed by CSV file. Token and student progress operations backed by CSV file.
Optimized with in-memory caching and cross-process file locking.
""" """
import csv import csv
import logging import logging
import os import os
import fcntl
import threading
from typing import Dict, List, Optional, Tuple
from config import TOKENS_FILE from config import TOKENS_FILE
# Global cache and synchronization for the current process
_cache_lock = threading.Lock()
_cached_tokens: Dict[str, dict] = {}
_cached_mtime: float = 0.0
_cached_fieldnames: List[str] = []
def _load_tokens_safely() -> Tuple[Dict[str, dict], List[str]]:
"""Load tokens from CSV with file locking and return (tokens_dict, fieldnames)."""
if not os.path.exists(TOKENS_FILE):
return {}, []
try:
with open(TOKENS_FILE, 'r', newline='', encoding='utf-8') as f:
# Acquire shared lock for reading (blocks if an exclusive lock is held)
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
try:
reader = csv.DictReader(f, delimiter=';')
fieldnames = reader.fieldnames or []
# Use a dictionary for O(1) lookups by token
tokens = {row['token']: row for row in reader if 'token' in row}
return tokens, fieldnames
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
except Exception as e:
logging.error(f"Error loading tokens file: {e}")
return {}, []
def _get_tokens() -> Tuple[Dict[str, dict], List[str]]:
"""Get tokens from cache or reload if file has changed on disk."""
global _cached_tokens, _cached_mtime, _cached_fieldnames
if not os.path.exists(TOKENS_FILE):
return {}, []
try:
current_mtime = os.path.getmtime(TOKENS_FILE)
except OSError:
return {}, []
with _cache_lock:
if current_mtime > _cached_mtime:
# File has been modified by this or another process
new_tokens, new_fieldnames = _load_tokens_safely()
if new_tokens: # Only update if we successfully read something
_cached_tokens = new_tokens
_cached_fieldnames = new_fieldnames
_cached_mtime = current_mtime
logging.debug(f"Reloaded {len(_cached_tokens)} tokens from {TOKENS_FILE}")
return _cached_tokens, _cached_fieldnames
def get_teacher_token(): def get_teacher_token():
"""Return the teacher token (first data row in CSV).""" """Return the teacher token (first data row in CSV)."""
if not os.path.exists(TOKENS_FILE): tokens, _ = _get_tokens()
if not tokens:
return None return None
# Python 3.7+ preserves insertion order in dicts
with open(TOKENS_FILE, 'r', newline='', encoding='utf-8') as csvfile: return next(iter(tokens.keys()))
reader = csv.DictReader(csvfile, delimiter=';')
for row in reader:
return row['token']
return None
def is_teacher_token(token): def is_teacher_token(token):
"""Check if the given token belongs to the teacher (first row).""" """Check if the given token belongs to the teacher."""
return token == get_teacher_token() return token == get_teacher_token()
def validate_token(token): def validate_token(token):
"""Validate if a token exists in the CSV file and return student info.""" """Validate if a token exists and return student info."""
if not os.path.exists(TOKENS_FILE): if not token:
return None return None
tokens, _ = _get_tokens()
with open(TOKENS_FILE, 'r', newline='', encoding='utf-8') as csvfile: row = tokens.get(token)
reader = csv.DictReader(csvfile, delimiter=';') if row:
for row in reader: return {
if row['token'] == token: 'token': row['token'],
return { 'student_name': row['nama_siswa'],
'token': row['token'], 'is_teacher': is_teacher_token(token),
'student_name': row['nama_siswa'], }
'is_teacher': is_teacher_token(token),
}
return None return None
def get_student_progress(token): def get_student_progress(token):
"""Get the progress of a student based on their token.""" """Get the progress of a student based on their token."""
if not os.path.exists(TOKENS_FILE): if not token:
return None return None
tokens, _ = _get_tokens()
with open(TOKENS_FILE, 'r', newline='', encoding='utf-8') as csvfile: return tokens.get(token)
reader = csv.DictReader(csvfile, delimiter=';')
for row in reader:
if row['token'] == token:
return row
return None
def update_student_progress(token, lesson_name, status="completed"): def update_student_progress(token, lesson_name, status="completed"):
"""Update the progress of a student for a specific lesson.""" """Update the progress of a student for a specific lesson with cross-process locking."""
if not os.path.exists(TOKENS_FILE): if not os.path.exists(TOKENS_FILE):
logging.warning(f"Tokens file {TOKENS_FILE} does not exist") logging.warning(f"Tokens file {TOKENS_FILE} does not exist")
return False return False
rows = [] # Use 'r+' to allow reading and writing in the same file handle with one lock
with open(TOKENS_FILE, 'r', newline='', encoding='utf-8') as csvfile: try:
reader = csv.DictReader(csvfile, delimiter=';') with open(TOKENS_FILE, 'r+', newline='', encoding='utf-8') as f:
fieldnames = reader.fieldnames # Acquire exclusive lock for the whole read-modify-write cycle
rows = list(reader) fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
updated = False reader = csv.DictReader(f, delimiter=';')
for row in rows: fieldnames = reader.fieldnames
if row['token'] == token: rows = list(reader)
if lesson_name in fieldnames:
row[lesson_name] = status updated = False
updated = True for row in rows:
logging.info(f"Updating progress for token {token}, lesson {lesson_name}, status {status}") if row['token'] == token:
else: if lesson_name in fieldnames:
logging.warning(f"Lesson '{lesson_name}' not found in CSV columns: {fieldnames}") row[lesson_name] = status
break updated = True
logging.info(f"Updating progress for token {token}, lesson {lesson_name}, status {status}")
if updated: else:
with open(TOKENS_FILE, 'w', newline='', encoding='utf-8') as csvfile: logging.warning(f"Lesson '{lesson_name}' not found in CSV columns")
writer = csv.DictWriter(csvfile, fieldnames=fieldnames, delimiter=';') break
writer.writeheader()
writer.writerows(rows) if updated:
logging.info(f"Updated progress for token {token}, lesson {lesson_name}, status {status}") # Clear and write back
else: f.seek(0)
logging.warning(f"Failed to update progress for token {token}, lesson {lesson_name}") f.truncate()
writer = csv.DictWriter(f, fieldnames=fieldnames, delimiter=';')
return updated writer.writeheader()
writer.writerows(rows)
f.flush()
os.fsync(f.fileno()) # Guarantee write to disk
return True
else:
return False
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
except Exception as e:
logging.error(f"Error updating student progress: {e}")
return False
def initialize_tokens_file(lesson_names): def initialize_tokens_file(lesson_names):
"""Initialize the tokens CSV file with headers and lesson columns.""" """Initialize the tokens CSV file with headers and lesson columns."""
if not os.path.exists(TOKENS_FILE): if not os.path.exists(TOKENS_FILE):
headers = ['token', 'nama_siswa'] + lesson_names headers = ['token', 'nama_siswa'] + lesson_names
with open(TOKENS_FILE, 'w', newline='', encoding='utf-8') as csvfile: try:
writer = csv.writer(csvfile, delimiter=';') with open(TOKENS_FILE, 'w', newline='', encoding='utf-8') as csvfile:
writer.writerow(headers) # No lock needed for initial creation
print(f"Created new tokens file: {TOKENS_FILE} with headers: {headers}") writer = csv.writer(csvfile, delimiter=';')
writer.writerow(headers)
print(f"Created new tokens file: {TOKENS_FILE} with headers: {headers}")
except Exception as e:
logging.error(f"Error initializing tokens file: {e}")
def calculate_student_completion(student_data, all_lessons): def calculate_student_completion(student_data, all_lessons):
@ -119,40 +178,38 @@ def calculate_student_completion(student_data, all_lessons):
def get_all_students_progress(all_lessons_func): def get_all_students_progress(all_lessons_func):
"""Get all students' progress data for the progress report. """Get all students' progress data for the progress report."""
Returns (all_students_progress, ordered_lessons).
"""
all_students_progress = [] all_students_progress = []
ordered_lessons = [] ordered_lessons = []
lesson_headers = []
if not os.path.exists(TOKENS_FILE): tokens, fieldnames = _get_tokens()
if not tokens:
return all_students_progress, ordered_lessons return all_students_progress, ordered_lessons
with open(TOKENS_FILE, 'r', newline='', encoding='utf-8') as csvfile: lesson_headers = [field for field in fieldnames if field not in ['token', 'nama_siswa']]
reader = csv.DictReader(csvfile, delimiter=';')
lesson_headers = [field for field in reader.fieldnames if field not in ['token', 'nama_siswa']]
all_lessons_dict = {} all_lessons_dict = {}
for lesson in all_lessons_func(): for lesson in all_lessons_func():
lesson_key = lesson['filename'].replace('.md', '') lesson_key = lesson['filename'].replace('.md', '')
all_lessons_dict[lesson_key] = lesson all_lessons_dict[lesson_key] = lesson
for lesson_header in lesson_headers: for lesson_header in lesson_headers:
if lesson_header in all_lessons_dict: if lesson_header in all_lessons_dict:
ordered_lessons.append(all_lessons_dict[lesson_header]) ordered_lessons.append(all_lessons_dict[lesson_header])
else: else:
ordered_lessons.append({ ordered_lessons.append({
'filename': f"{lesson_header}.md", 'filename': f"{lesson_header}.md",
'title': lesson_header.replace('_', ' ').title(), 'title': lesson_header.replace('_', ' ').title(),
'description': 'Lesson information not available', 'description': 'Lesson information not available',
}) })
for row in reader: for row in tokens.values():
student_data = dict(row) student_data = dict(row)
del student_data['token'] # Don't delete 'token' from the original dict in cache!
student_data['completed_count'] = calculate_student_completion(student_data, ordered_lessons) student_data_copy = student_data.copy()
all_students_progress.append(student_data) if 'token' in student_data_copy:
del student_data_copy['token']
student_data_copy['completed_count'] = calculate_student_completion(student_data_copy, ordered_lessons)
all_students_progress.append(student_data_copy)
return all_students_progress, ordered_lessons return all_students_progress, ordered_lessons