Merge pull request #3 from Zelyanne/main
Browse files- backend/api/posts.py +35 -30
- backend/app.py +13 -3
- backend/config.py +3 -0
- backend/utils/redis_job_store.py +253 -0
- docs/sprint-artifacts/architecture.md +507 -0
- docs/sprint-artifacts/tech_spec_job_tracking.md +86 -0
- docs/sprint-artifacts/tech_spec_remote_logging.md +159 -0
- test_redis_job_store.py +110 -0
backend/api/posts.py
CHANGED
|
@@ -7,6 +7,7 @@ from flask_jwt_extended import jwt_required, get_jwt_identity
|
|
| 7 |
from backend.services.content_service import ContentService
|
| 8 |
from backend.services.linkedin_service import LinkedInService
|
| 9 |
from backend.utils.image_utils import ensure_bytes_format
|
|
|
|
| 10 |
|
| 11 |
posts_bp = Blueprint('posts', __name__)
|
| 12 |
|
|
@@ -107,16 +108,15 @@ def _generate_post_task(user_id, job_id, job_store, hugging_key):
|
|
| 107 |
Args:
|
| 108 |
user_id (str): User ID for personalization
|
| 109 |
job_id (str): Job ID to update status in job store
|
| 110 |
-
job_store (dict): Job store dictionary
|
| 111 |
hugging_key (str): Hugging Face API key
|
| 112 |
"""
|
| 113 |
try:
|
|
|
|
|
|
|
|
|
|
| 114 |
# Update job status to processing
|
| 115 |
-
|
| 116 |
-
'status': 'processing',
|
| 117 |
-
'result': None,
|
| 118 |
-
'error': None
|
| 119 |
-
}
|
| 120 |
|
| 121 |
# Generate content using content service
|
| 122 |
# Pass the Hugging Face key directly to the service
|
|
@@ -136,24 +136,23 @@ def _generate_post_task(user_id, job_id, job_store, hugging_key):
|
|
| 136 |
image_data = None
|
| 137 |
|
| 138 |
# Update job status to completed with result
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
'content': generated_content,
|
| 143 |
'image_data': image_data # This could be bytes or a URL string
|
| 144 |
-
}
|
| 145 |
-
|
| 146 |
-
}
|
| 147 |
|
| 148 |
except Exception as e:
|
| 149 |
error_message = str(e)
|
| 150 |
safe_log_message(f"Generate post background task error: {error_message}")
|
| 151 |
# Update job status to failed with error
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
'
|
| 155 |
-
|
| 156 |
-
|
| 157 |
|
| 158 |
@posts_bp.route('/generate', methods=['POST'])
|
| 159 |
@jwt_required()
|
|
@@ -184,12 +183,10 @@ def generate_post():
|
|
| 184 |
# Create a job ID
|
| 185 |
job_id = str(uuid.uuid4())
|
| 186 |
|
| 187 |
-
# Initialize job status
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
'error': None
|
| 192 |
-
}
|
| 193 |
|
| 194 |
# Get Hugging Face key
|
| 195 |
hugging_key = current_app.config['HUGGING_KEY']
|
|
@@ -227,8 +224,10 @@ def get_job_status(job_id):
|
|
| 227 |
JSON: Job status and result if completed
|
| 228 |
"""
|
| 229 |
try:
|
| 230 |
-
# Get job from store
|
| 231 |
-
|
|
|
|
|
|
|
| 232 |
|
| 233 |
if not job:
|
| 234 |
return jsonify({
|
|
@@ -284,8 +283,12 @@ def get_job_status(job_id):
|
|
| 284 |
elif isinstance(image_data, str):
|
| 285 |
# Check if it's a local file path
|
| 286 |
if os.path.exists(image_data):
|
| 287 |
-
# Store the file path in job store for later retrieval
|
| 288 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
# Generate absolute URL instead of relative URL
|
| 290 |
base_url = request.host_url.rstrip('/')
|
| 291 |
response_data['image_url'] = f"{base_url}/api/posts/image/{job_id}" # API endpoint to serve the image
|
|
@@ -330,8 +333,10 @@ def get_job_image(job_id):
|
|
| 330 |
Image file
|
| 331 |
"""
|
| 332 |
try:
|
| 333 |
-
# Get job from store
|
| 334 |
-
|
|
|
|
|
|
|
| 335 |
|
| 336 |
if not job:
|
| 337 |
return jsonify({
|
|
@@ -340,7 +345,7 @@ def get_job_image(job_id):
|
|
| 340 |
}), 404
|
| 341 |
|
| 342 |
# Check if job has an image file path
|
| 343 |
-
image_file_path = job.get('image_file_path')
|
| 344 |
if not image_file_path or not os.path.exists(image_file_path):
|
| 345 |
return jsonify({
|
| 346 |
'success': False,
|
|
|
|
| 7 |
from backend.services.content_service import ContentService
|
| 8 |
from backend.services.linkedin_service import LinkedInService
|
| 9 |
from backend.utils.image_utils import ensure_bytes_format
|
| 10 |
+
from backend.utils.redis_job_store import get_redis_job_store
|
| 11 |
|
| 12 |
posts_bp = Blueprint('posts', __name__)
|
| 13 |
|
|
|
|
| 108 |
Args:
|
| 109 |
user_id (str): User ID for personalization
|
| 110 |
job_id (str): Job ID to update status in job store
|
| 111 |
+
job_store (dict): Job store dictionary (kept for backward compatibility, but now using Redis)
|
| 112 |
hugging_key (str): Hugging Face API key
|
| 113 |
"""
|
| 114 |
try:
|
| 115 |
+
# Get Redis job store
|
| 116 |
+
redis_job_store = get_redis_job_store()
|
| 117 |
+
|
| 118 |
# Update job status to processing
|
| 119 |
+
redis_job_store.update_job(job_id, status='processing')
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
# Generate content using content service
|
| 122 |
# Pass the Hugging Face key directly to the service
|
|
|
|
| 136 |
image_data = None
|
| 137 |
|
| 138 |
# Update job status to completed with result
|
| 139 |
+
redis_job_store.update_job(job_id,
|
| 140 |
+
status='completed',
|
| 141 |
+
result={
|
| 142 |
'content': generated_content,
|
| 143 |
'image_data': image_data # This could be bytes or a URL string
|
| 144 |
+
}
|
| 145 |
+
)
|
|
|
|
| 146 |
|
| 147 |
except Exception as e:
|
| 148 |
error_message = str(e)
|
| 149 |
safe_log_message(f"Generate post background task error: {error_message}")
|
| 150 |
# Update job status to failed with error
|
| 151 |
+
redis_job_store = get_redis_job_store()
|
| 152 |
+
redis_job_store.update_job(job_id,
|
| 153 |
+
status='failed',
|
| 154 |
+
error=error_message
|
| 155 |
+
)
|
| 156 |
|
| 157 |
@posts_bp.route('/generate', methods=['POST'])
|
| 158 |
@jwt_required()
|
|
|
|
| 183 |
# Create a job ID
|
| 184 |
job_id = str(uuid.uuid4())
|
| 185 |
|
| 186 |
+
# Initialize job status in Redis store
|
| 187 |
+
from backend.utils.redis_job_store import get_redis_job_store
|
| 188 |
+
redis_job_store = get_redis_job_store()
|
| 189 |
+
redis_job_store.create_job(job_id, 'pending')
|
|
|
|
|
|
|
| 190 |
|
| 191 |
# Get Hugging Face key
|
| 192 |
hugging_key = current_app.config['HUGGING_KEY']
|
|
|
|
| 224 |
JSON: Job status and result if completed
|
| 225 |
"""
|
| 226 |
try:
|
| 227 |
+
# Get job from Redis store
|
| 228 |
+
from backend.utils.redis_job_store import get_redis_job_store
|
| 229 |
+
redis_job_store = get_redis_job_store()
|
| 230 |
+
job = redis_job_store.get_job(job_id)
|
| 231 |
|
| 232 |
if not job:
|
| 233 |
return jsonify({
|
|
|
|
| 283 |
elif isinstance(image_data, str):
|
| 284 |
# Check if it's a local file path
|
| 285 |
if os.path.exists(image_data):
|
| 286 |
+
# Store the file path in Redis job store for later retrieval
|
| 287 |
+
redis_job_store.update_job(job_id, result={
|
| 288 |
+
'content': job['result']['content'],
|
| 289 |
+
'image_data': image_data,
|
| 290 |
+
'image_file_path': image_data
|
| 291 |
+
})
|
| 292 |
# Generate absolute URL instead of relative URL
|
| 293 |
base_url = request.host_url.rstrip('/')
|
| 294 |
response_data['image_url'] = f"{base_url}/api/posts/image/{job_id}" # API endpoint to serve the image
|
|
|
|
| 333 |
Image file
|
| 334 |
"""
|
| 335 |
try:
|
| 336 |
+
# Get job from Redis store
|
| 337 |
+
from backend.utils.redis_job_store import get_redis_job_store
|
| 338 |
+
redis_job_store = get_redis_job_store()
|
| 339 |
+
job = redis_job_store.get_job(job_id)
|
| 340 |
|
| 341 |
if not job:
|
| 342 |
return jsonify({
|
|
|
|
| 345 |
}), 404
|
| 346 |
|
| 347 |
# Check if job has an image file path
|
| 348 |
+
image_file_path = job.get('image_file_path') if job else None
|
| 349 |
if not image_file_path or not os.path.exists(image_file_path):
|
| 350 |
return jsonify({
|
| 351 |
'success': False,
|
backend/app.py
CHANGED
|
@@ -165,9 +165,19 @@ def create_app():
|
|
| 165 |
# Initialize Supabase client
|
| 166 |
app.supabase = init_supabase(app.config['SUPABASE_URL'], app.config['SUPABASE_KEY'])
|
| 167 |
|
| 168 |
-
# Initialize
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
# Initialize a ThreadPoolExecutor for running background tasks
|
| 173 |
# In production, you'd use a proper task scheduler like APScheduler
|
|
|
|
| 165 |
# Initialize Supabase client
|
| 166 |
app.supabase = init_supabase(app.config['SUPABASE_URL'], app.config['SUPABASE_KEY'])
|
| 167 |
|
| 168 |
+
# Initialize Redis-based job store for tracking async tasks across multiple Gunicorn workers
|
| 169 |
+
try:
|
| 170 |
+
from backend.utils.redis_job_store import RedisJobStore
|
| 171 |
+
redis_url = app.config.get('REDIS_URL', 'redis://localhost:6379/0')
|
| 172 |
+
# Use configurable TTL from environment, default to 24 hours
|
| 173 |
+
job_ttl_hours = int(app.config.get('JOB_TTL_HOURS', 24))
|
| 174 |
+
app.redis_job_store = RedisJobStore(redis_url, default_ttl_hours=job_ttl_hours)
|
| 175 |
+
app.logger.info("Redis job store initialized successfully")
|
| 176 |
+
except Exception as e:
|
| 177 |
+
app.logger.error(f"Failed to initialize Redis job store: {str(e)}")
|
| 178 |
+
# Fallback to in-memory store if Redis is not available
|
| 179 |
+
app.job_store = {}
|
| 180 |
+
app.logger.warning("Using in-memory job store as fallback - not suitable for multi-worker production")
|
| 181 |
|
| 182 |
# Initialize a ThreadPoolExecutor for running background tasks
|
| 183 |
# In production, you'd use a proper task scheduler like APScheduler
|
backend/config.py
CHANGED
|
@@ -79,6 +79,9 @@ class Config:
|
|
| 79 |
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO' if ENVIRONMENT == 'production' else 'DEBUG')
|
| 80 |
UNICODE_SAFE_LOGGING = UNICODE_LOGGING and not IS_WINDOWS
|
| 81 |
|
|
|
|
|
|
|
|
|
|
| 82 |
# Celery configuration
|
| 83 |
CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0')
|
| 84 |
CELERY_RESULT_BACKEND = os.environ.get('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0')
|
|
|
|
| 79 |
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO' if ENVIRONMENT == 'production' else 'DEBUG')
|
| 80 |
UNICODE_SAFE_LOGGING = UNICODE_LOGGING and not IS_WINDOWS
|
| 81 |
|
| 82 |
+
# Redis configuration for job store
|
| 83 |
+
REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
| 84 |
+
|
| 85 |
# Celery configuration
|
| 86 |
CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0')
|
| 87 |
CELERY_RESULT_BACKEND = os.environ.get('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0')
|
backend/utils/redis_job_store.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Redis-based job store for distributed job tracking across multiple Gunicorn workers.
|
| 3 |
+
"""
|
| 4 |
+
import json
|
| 5 |
+
import uuid
|
| 6 |
+
import redis
|
| 7 |
+
from flask import current_app
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
import logging
|
| 10 |
+
import re
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class RedisJobStore:
|
| 14 |
+
"""
|
| 15 |
+
Redis-based job store for tracking background tasks across multiple Gunicorn workers.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
# Valid job statuses
|
| 19 |
+
VALID_STATUSES = {'pending', 'processing', 'completed', 'failed', 'cancelled'}
|
| 20 |
+
|
| 21 |
+
def __init__(self, redis_url=None, default_ttl_hours=24):
|
| 22 |
+
"""
|
| 23 |
+
Initialize Redis job store.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
redis_url (str): Redis connection URL. If None, uses current_app.config['REDIS_URL']
|
| 27 |
+
default_ttl_hours (int): Default time-to-live for jobs in hours
|
| 28 |
+
"""
|
| 29 |
+
self.default_ttl_hours = default_ttl_hours
|
| 30 |
+
if redis_url:
|
| 31 |
+
self.redis_client = redis.from_url(redis_url)
|
| 32 |
+
elif current_app and hasattr(current_app, 'config') and 'REDIS_URL' in current_app.config:
|
| 33 |
+
self.redis_client = redis.from_url(current_app.config['REDIS_URL'])
|
| 34 |
+
else:
|
| 35 |
+
# Default to localhost Redis
|
| 36 |
+
self.redis_client = redis.from_url('redis://localhost:6379/0')
|
| 37 |
+
|
| 38 |
+
def _validate_job_id(self, job_id):
|
| 39 |
+
"""
|
| 40 |
+
Validate job ID format to prevent injection attacks.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
job_id (str): Job ID to validate
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
bool: True if valid, False otherwise
|
| 47 |
+
"""
|
| 48 |
+
if not job_id or not isinstance(job_id, str):
|
| 49 |
+
return False
|
| 50 |
+
# Allow UUID format or alphanumeric with hyphens/underscores
|
| 51 |
+
return bool(re.match(r'^[a-zA-Z0-9_-]{1,64}$', job_id))
|
| 52 |
+
|
| 53 |
+
def _validate_status(self, status):
|
| 54 |
+
"""
|
| 55 |
+
Validate job status value.
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
status (str): Status to validate
|
| 59 |
+
|
| 60 |
+
Returns:
|
| 61 |
+
bool: True if valid, False otherwise
|
| 62 |
+
"""
|
| 63 |
+
return status in self.VALID_STATUSES
|
| 64 |
+
|
| 65 |
+
def create_job(self, job_id=None, initial_status='pending', initial_data=None):
|
| 66 |
+
"""
|
| 67 |
+
Create a new job in Redis.
|
| 68 |
+
|
| 69 |
+
Args:
|
| 70 |
+
job_id (str): Job ID. If None, generates a new UUID.
|
| 71 |
+
initial_status (str): Initial job status.
|
| 72 |
+
initial_data (dict): Initial job data.
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
str: Job ID
|
| 76 |
+
"""
|
| 77 |
+
if initial_status and not self._validate_status(initial_status):
|
| 78 |
+
raise ValueError(f"Invalid status: {initial_status}. Valid statuses are: {self.VALID_STATUSES}")
|
| 79 |
+
|
| 80 |
+
if job_id and not self._validate_job_id(job_id):
|
| 81 |
+
raise ValueError(f"Invalid job ID format: {job_id}")
|
| 82 |
+
|
| 83 |
+
if job_id is None:
|
| 84 |
+
job_id = str(uuid.uuid4())
|
| 85 |
+
|
| 86 |
+
job_data = {
|
| 87 |
+
'status': initial_status,
|
| 88 |
+
'result': None,
|
| 89 |
+
'error': None,
|
| 90 |
+
'created_at': datetime.utcnow().isoformat(),
|
| 91 |
+
'updated_at': datetime.utcnow().isoformat()
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
if initial_data:
|
| 95 |
+
job_data.update(initial_data)
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
# Store job data as JSON in Redis with expiration (24 hours)
|
| 99 |
+
self.redis_client.setex(
|
| 100 |
+
f"job:{job_id}",
|
| 101 |
+
timedelta(hours=self.default_ttl_hours),
|
| 102 |
+
json.dumps(job_data)
|
| 103 |
+
)
|
| 104 |
+
except redis.ConnectionError:
|
| 105 |
+
logging.error(f"Failed to connect to Redis when creating job {job_id}")
|
| 106 |
+
raise
|
| 107 |
+
except Exception as e:
|
| 108 |
+
logging.error(f"Unexpected error when creating job {job_id}: {str(e)}")
|
| 109 |
+
raise
|
| 110 |
+
|
| 111 |
+
return job_id
|
| 112 |
+
|
| 113 |
+
def get_job(self, job_id):
|
| 114 |
+
"""
|
| 115 |
+
Get job data from Redis.
|
| 116 |
+
|
| 117 |
+
Args:
|
| 118 |
+
job_id (str): Job ID
|
| 119 |
+
|
| 120 |
+
Returns:
|
| 121 |
+
dict: Job data or None if not found
|
| 122 |
+
"""
|
| 123 |
+
if not self._validate_job_id(job_id):
|
| 124 |
+
raise ValueError(f"Invalid job ID format: {job_id}")
|
| 125 |
+
|
| 126 |
+
try:
|
| 127 |
+
job_data_json = self.redis_client.get(f"job:{job_id}")
|
| 128 |
+
if job_data_json:
|
| 129 |
+
return json.loads(job_data_json)
|
| 130 |
+
return None
|
| 131 |
+
except redis.ConnectionError:
|
| 132 |
+
logging.error(f"Failed to connect to Redis when getting job {job_id}")
|
| 133 |
+
return None
|
| 134 |
+
except json.JSONDecodeError:
|
| 135 |
+
logging.error(f"Failed to decode JSON for job {job_id}")
|
| 136 |
+
return None
|
| 137 |
+
except Exception as e:
|
| 138 |
+
logging.error(f"Unexpected error when getting job {job_id}: {str(e)}")
|
| 139 |
+
return None
|
| 140 |
+
|
| 141 |
+
def update_job(self, job_id, status=None, result=None, error=None):
|
| 142 |
+
"""
|
| 143 |
+
Update job status and data in Redis using atomic operations to prevent race conditions.
|
| 144 |
+
|
| 145 |
+
Args:
|
| 146 |
+
job_id (str): Job ID
|
| 147 |
+
status (str): New status
|
| 148 |
+
result (any): Result data
|
| 149 |
+
error (str): Error message
|
| 150 |
+
|
| 151 |
+
Returns:
|
| 152 |
+
bool: True if job was updated, False if not found
|
| 153 |
+
"""
|
| 154 |
+
if not self._validate_job_id(job_id):
|
| 155 |
+
raise ValueError(f"Invalid job ID format: {job_id}")
|
| 156 |
+
|
| 157 |
+
if status is not None and not self._validate_status(status):
|
| 158 |
+
raise ValueError(f"Invalid status: {status}. Valid statuses are: {self.VALID_STATUSES}")
|
| 159 |
+
|
| 160 |
+
# Use Lua script for atomic read-modify-write operation
|
| 161 |
+
lua_script = """
|
| 162 |
+
local job_key = KEYS[1]
|
| 163 |
+
local job_data = redis.call('GET', job_key)
|
| 164 |
+
|
| 165 |
+
if not job_data then
|
| 166 |
+
return 0
|
| 167 |
+
end
|
| 168 |
+
|
| 169 |
+
local updated_data = cjson.decode(job_data)
|
| 170 |
+
|
| 171 |
+
if ARGV[1] ~= 'nil' then
|
| 172 |
+
updated_data.status = ARGV[1]
|
| 173 |
+
end
|
| 174 |
+
if ARGV[2] ~= 'nil' then
|
| 175 |
+
updated_data.result = cjson.decode(ARGV[2])
|
| 176 |
+
end
|
| 177 |
+
if ARGV[3] ~= 'nil' then
|
| 178 |
+
updated_data.error = ARGV[3]
|
| 179 |
+
end
|
| 180 |
+
|
| 181 |
+
updated_data.updated_at = ARGV[4]
|
| 182 |
+
|
| 183 |
+
local ttl = redis.call('TTL', job_key)
|
| 184 |
+
redis.call('SET', job_key, cjson.encode(updated_data), 'EX', ttl)
|
| 185 |
+
|
| 186 |
+
return 1
|
| 187 |
+
"""
|
| 188 |
+
|
| 189 |
+
try:
|
| 190 |
+
# Prepare arguments for the Lua script
|
| 191 |
+
status_arg = status if status is not None else 'nil'
|
| 192 |
+
result_arg = json.dumps(result) if result is not None else 'nil'
|
| 193 |
+
error_arg = error if error is not None else 'nil'
|
| 194 |
+
updated_at_arg = datetime.utcnow().isoformat()
|
| 195 |
+
|
| 196 |
+
script = self.redis_client.register_script(lua_script)
|
| 197 |
+
result = script(keys=[f"job:{job_id}"],
|
| 198 |
+
args=[status_arg, result_arg, error_arg, updated_at_arg])
|
| 199 |
+
|
| 200 |
+
return result == 1
|
| 201 |
+
except redis.ConnectionError:
|
| 202 |
+
logging.error(f"Failed to connect to Redis when updating job {job_id}")
|
| 203 |
+
return False
|
| 204 |
+
except Exception as e:
|
| 205 |
+
logging.error(f"Unexpected error when updating job {job_id}: {str(e)}")
|
| 206 |
+
return False
|
| 207 |
+
|
| 208 |
+
def delete_job(self, job_id):
|
| 209 |
+
"""
|
| 210 |
+
Delete a job from Redis.
|
| 211 |
+
|
| 212 |
+
Args:
|
| 213 |
+
job_id (str): Job ID
|
| 214 |
+
|
| 215 |
+
Returns:
|
| 216 |
+
bool: True if job was deleted, False if not found
|
| 217 |
+
"""
|
| 218 |
+
if not self._validate_job_id(job_id):
|
| 219 |
+
raise ValueError(f"Invalid job ID format: {job_id}")
|
| 220 |
+
|
| 221 |
+
try:
|
| 222 |
+
result = self.redis_client.delete(f"job:{job_id}")
|
| 223 |
+
return result > 0
|
| 224 |
+
except redis.ConnectionError:
|
| 225 |
+
logging.error(f"Failed to connect to Redis when deleting job {job_id}")
|
| 226 |
+
return False
|
| 227 |
+
except Exception as e:
|
| 228 |
+
logging.error(f"Unexpected error when deleting job {job_id}: {str(e)}")
|
| 229 |
+
return False
|
| 230 |
+
|
| 231 |
+
def cleanup_expired_jobs(self):
|
| 232 |
+
"""
|
| 233 |
+
Clean up jobs that have expired based on their creation time.
|
| 234 |
+
This is a placeholder method - in a real implementation, you might want to
|
| 235 |
+
scan for expired jobs and remove them, but Redis automatically handles TTL.
|
| 236 |
+
"""
|
| 237 |
+
# Redis handles TTL automatically, so this is mostly for documentation
|
| 238 |
+
# In a production system, you might want to implement custom cleanup logic
|
| 239 |
+
pass
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def get_redis_job_store():
|
| 243 |
+
"""
|
| 244 |
+
Get the Redis job store instance from the current app context.
|
| 245 |
+
|
| 246 |
+
Returns:
|
| 247 |
+
RedisJobStore: Redis job store instance
|
| 248 |
+
"""
|
| 249 |
+
if not hasattr(current_app, 'redis_job_store'):
|
| 250 |
+
redis_url = current_app.config.get('REDIS_URL', 'redis://localhost:6379/0')
|
| 251 |
+
current_app.redis_job_store = RedisJobStore(redis_url)
|
| 252 |
+
|
| 253 |
+
return current_app.redis_job_store
|
docs/sprint-artifacts/architecture.md
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Lin - LinkedIn Community Manager Brownfield Enhancement Architecture
|
| 2 |
+
|
| 3 |
+
## Change Log
|
| 4 |
+
| Change | Date | Version | Description | Author |
|
| 5 |
+
|--------|------|---------|-------------|---------|
|
| 6 |
+
| Initial Draft | 2025-10-20 | 1.0 | Initial architecture document for UI/UX improvements, keyword analysis, and FLUX.1-dev image generation enhancements | Architect |
|
| 7 |
+
|
| 8 |
+
## 1. Introduction
|
| 9 |
+
|
| 10 |
+
This document outlines the architectural approach for enhancing Lin with UI/UX improvements, keyword relevance analysis, and upgraded image generation capabilities. Its primary goal is to serve as the guiding architectural blueprint for AI-driven development of new features while ensuring seamless integration with the existing system.
|
| 11 |
+
|
| 12 |
+
**Relationship to Existing Architecture:**
|
| 13 |
+
This document supplements existing project architecture by defining how new components will integrate with current systems. Where conflicts arise between new and existing patterns, this document provides guidance on maintaining consistency while implementing enhancements.
|
| 14 |
+
|
| 15 |
+
### 1.1 Existing Project Analysis
|
| 16 |
+
|
| 17 |
+
Based on my analysis of your project, I've identified the following about your existing system:
|
| 18 |
+
- The application is a LinkedIn community management tool with React frontend and Flask backend
|
| 19 |
+
- Uses Supabase for authentication and database
|
| 20 |
+
- Has established AI content generation using Gradio client
|
| 21 |
+
- Current image generation uses Qwen/Qwen-Image model
|
| 22 |
+
- Well-structured with clear separation of concerns between frontend and backend
|
| 23 |
+
- Has established API patterns and Redux state management
|
| 24 |
+
|
| 25 |
+
Please confirm these observations are accurate before I proceed with architectural recommendations.
|
| 26 |
+
|
| 27 |
+
#### Current Project State
|
| 28 |
+
- **Primary Purpose:** LinkedIn community management tool with AI-powered content generation
|
| 29 |
+
- **Current Tech Stack:** React (frontend), Flask (backend), Supabase (database/auth), Gradio client (AI integration)
|
| 30 |
+
- **Architecture Style:** Microservices-like with clear separation between frontend and backend
|
| 31 |
+
- **Deployment Method:** Docker with docker-compose, with Nginx reverse proxy
|
| 32 |
+
|
| 33 |
+
#### Available Documentation
|
| 34 |
+
- README.md: Complete project documentation with setup instructions
|
| 35 |
+
- Backend README.md: Detailed backend API documentation
|
| 36 |
+
- Frontend README.md: Frontend development guide
|
| 37 |
+
- docs/prd.md: Product requirements document
|
| 38 |
+
|
| 39 |
+
#### Identified Constraints
|
| 40 |
+
- Must maintain backward compatibility with existing user workflows
|
| 41 |
+
- Authentication system is based on JWT tokens and Supabase
|
| 42 |
+
- Image generation currently uses Qwen model through Gradio client
|
| 43 |
+
- Existing API patterns must be preserved
|
| 44 |
+
|
| 45 |
+
## 2. Enhancement Scope and Integration Strategy
|
| 46 |
+
|
| 47 |
+
### 2.1 Enhancement Overview
|
| 48 |
+
**Enhancement Type:** UI/UX Overhaul, New Feature Addition, Integration with New Systems
|
| 49 |
+
**Scope:** UI/UX improvements to the dashboard, keyword relevance analysis feature, replacement of current image generation with FLUX.1-dev
|
| 50 |
+
**Integration Impact:** Medium Impact (requires changes to existing code but maintains compatibility)
|
| 51 |
+
|
| 52 |
+
### 2.2 Integration Approach
|
| 53 |
+
**Code Integration Strategy:** Follow existing patterns and conventions in the codebase
|
| 54 |
+
**Database Integration:** No schema changes required, leveraging existing tables
|
| 55 |
+
**API Integration:** Extend existing API endpoints while maintaining compatibility
|
| 56 |
+
**UI Integration:** Enhance existing UI components following established design patterns
|
| 57 |
+
|
| 58 |
+
### 2.3 Compatibility Requirements
|
| 59 |
+
- **Existing API Compatibility:** All new endpoints must follow existing authentication patterns
|
| 60 |
+
- **Database Schema Compatibility:** No schema changes required, using existing tables
|
| 61 |
+
- **UI/UX Consistency:** Follow existing design system and component patterns
|
| 62 |
+
- **Performance Impact:** Maintain current performance characteristics
|
| 63 |
+
|
| 64 |
+
## 3. Tech Stack
|
| 65 |
+
|
| 66 |
+
### 3.1 Existing Technology Stack
|
| 67 |
+
| Category | Current Technology | Version | Usage in Enhancement | Notes |
|
| 68 |
+
|----------|-------------------|---------|---------------------|--------|
|
| 69 |
+
| Frontend Framework | React | 18.2.0 | UI components for new features | Continue using existing patterns |
|
| 70 |
+
| Build Tool | Vite | - | Build process for enhanced UI | Continue using existing configuration |
|
| 71 |
+
| State Management | Redux Toolkit | - | State management for new features | Continue using existing patterns |
|
| 72 |
+
| Styling | Tailwind CSS | - | Styling for new components | Follow existing design system |
|
| 73 |
+
| Backend Framework | Flask | 3.1.1 | API endpoints for new features | Extend existing API structure |
|
| 74 |
+
| Database | Supabase (PostgreSQL) | - | Data storage for new features | Use existing tables and auth |
|
| 75 |
+
| Authentication | JWT + Supabase | - | Authentication for new features | Use existing auth patterns |
|
| 76 |
+
| AI Integration | Gradio Client | - | Image generation replacement | Replace Qwen with FLUX.1-dev |
|
| 77 |
+
| Task Queue | Celery + Redis | - | Async processing for image generation | Continue using existing setup |
|
| 78 |
+
|
| 79 |
+
### 3.2 New Technology Additions
|
| 80 |
+
No new major technologies are being introduced. The enhancement involves replacing the current Qwen image generation with FLUX.1-dev while maintaining all other existing technologies.
|
| 81 |
+
|
| 82 |
+
## 4. Data Models and Schema Changes
|
| 83 |
+
|
| 84 |
+
### 4.1 Schema Integration Strategy
|
| 85 |
+
**Database Changes Required:**
|
| 86 |
+
- **New Tables:** None
|
| 87 |
+
- **Modified Tables:** None
|
| 88 |
+
- **New Indexes:** None
|
| 89 |
+
- **Migration Strategy:** None required
|
| 90 |
+
|
| 91 |
+
**Backward Compatibility:**
|
| 92 |
+
- No changes to existing data models
|
| 93 |
+
- All existing functionality remains intact
|
| 94 |
+
- New features use existing database structure
|
| 95 |
+
|
| 96 |
+
## 5. Component Architecture
|
| 97 |
+
|
| 98 |
+
### 5.1 New Components
|
| 99 |
+
|
| 100 |
+
#### KeywordAnalysisService
|
| 101 |
+
**Responsibility:** Handle keyword frequency analysis for content planning
|
| 102 |
+
**Integration Points:** Integrated with existing content service and API endpoints
|
| 103 |
+
|
| 104 |
+
**Key Interfaces:**
|
| 105 |
+
- analyze_keyword_frequency(keywords: List[str]) -> Dict[str, str]
|
| 106 |
+
|
| 107 |
+
**Dependencies:**
|
| 108 |
+
- **Existing Components:** Uses existing database connection and authentication
|
| 109 |
+
- **New Components:** None
|
| 110 |
+
|
| 111 |
+
**Technology Stack:** Python, existing Flask framework
|
| 112 |
+
|
| 113 |
+
#### ImageGenerationService (Updated)
|
| 114 |
+
**Responsibility:** Handle image generation using FLUX.1-dev instead of Qwen
|
| 115 |
+
**Integration Points:** Integrated with existing content service and AI workflow
|
| 116 |
+
|
| 117 |
+
**Key Interfaces:**
|
| 118 |
+
- generate_flux_image(prompt: str, seed: int, dimensions: tuple, guidance_scale: float, inference_steps: int) -> str
|
| 119 |
+
|
| 120 |
+
**Dependencies:**
|
| 121 |
+
- **Existing Components:** Uses existing gradio_client and authentication
|
| 122 |
+
- **New Components:** None
|
| 123 |
+
|
| 124 |
+
**Technology Stack:** Python, gradio_client, existing Flask framework
|
| 125 |
+
|
| 126 |
+
### 5.2 Component Interaction Diagram
|
| 127 |
+
```mermaid
|
| 128 |
+
graph TB
|
| 129 |
+
subgraph "Frontend"
|
| 130 |
+
A[Posts Page] --> B[KeywordAnalysisPanel]
|
| 131 |
+
A --> C[ImageGenerationPanel]
|
| 132 |
+
B --> D[KeywordAnalysisService]
|
| 133 |
+
C --> E[ImageGenerationService]
|
| 134 |
+
end
|
| 135 |
+
|
| 136 |
+
subgraph "Backend API"
|
| 137 |
+
F[app.py] --> G[posts_bp]
|
| 138 |
+
G --> H[content_service]
|
| 139 |
+
G --> I[keyword_analysis_service]
|
| 140 |
+
end
|
| 141 |
+
|
| 142 |
+
subgraph "AI Services"
|
| 143 |
+
H --> J[FLUX.1-dev via gradio_client]
|
| 144 |
+
I --> K[Existing RSS/Post Data]
|
| 145 |
+
end
|
| 146 |
+
|
| 147 |
+
subgraph "Database"
|
| 148 |
+
L[Supabase] --> H
|
| 149 |
+
L --> I
|
| 150 |
+
end
|
| 151 |
+
|
| 152 |
+
D -.-> G
|
| 153 |
+
E -.-> G
|
| 154 |
+
B -.-> D
|
| 155 |
+
C -.-> E
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
## 6. API Design and Integration
|
| 159 |
+
|
| 160 |
+
### 6.1 API Integration Strategy
|
| 161 |
+
**API Integration Strategy:** Extend existing `/api/posts` endpoints while maintaining compatibility
|
| 162 |
+
**Authentication:** Use existing JWT token authentication
|
| 163 |
+
**Versioning:** No versioning needed, following existing API patterns
|
| 164 |
+
|
| 165 |
+
### 6.2 New API Endpoints
|
| 166 |
+
|
| 167 |
+
#### POST /api/posts/keyword-analysis
|
| 168 |
+
**Method:** POST
|
| 169 |
+
**Endpoint:** /api/posts/keyword-analysis
|
| 170 |
+
**Purpose:** Analyze keyword frequency and relevance
|
| 171 |
+
**Integration:** With existing posts API and authentication
|
| 172 |
+
|
| 173 |
+
**Request:**
|
| 174 |
+
```json
|
| 175 |
+
{
|
| 176 |
+
"keywords": ["keyword1", "keyword2"]
|
| 177 |
+
}
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
**Response:**
|
| 181 |
+
```json
|
| 182 |
+
{
|
| 183 |
+
"results": {
|
| 184 |
+
"keyword1": "daily",
|
| 185 |
+
"keyword2": "weekly"
|
| 186 |
+
},
|
| 187 |
+
"status": "success"
|
| 188 |
+
}
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
## 7. External API Integration
|
| 192 |
+
|
| 193 |
+
### 7.1 FLUX.1-dev API
|
| 194 |
+
**Purpose:** High-quality image generation to replace current Qwen implementation
|
| 195 |
+
**Documentation:** Available through Hugging Face Spaces
|
| 196 |
+
**Base URL:** Hugging Face Space for FLUX.1-dev
|
| 197 |
+
**Authentication:** Using existing HUGGING_KEY environment variable
|
| 198 |
+
|
| 199 |
+
**Key Endpoints Used:**
|
| 200 |
+
- `POST /infer` - Image generation with parameters
|
| 201 |
+
|
| 202 |
+
**Error Handling:** Fallback to existing functionality if FLUX.1-dev fails
|
| 203 |
+
|
| 204 |
+
## 8. Source Tree
|
| 205 |
+
|
| 206 |
+
### 8.1 Existing Project Structure
|
| 207 |
+
```
|
| 208 |
+
Lin/
|
| 209 |
+
├── .env.hf
|
| 210 |
+
├── .gitattributes
|
| 211 |
+
├── .gitignore
|
| 212 |
+
├── .kilocodemodes
|
| 213 |
+
├── app.py
|
| 214 |
+
├── docker-compose.yml
|
| 215 |
+
├── Dockerfile
|
| 216 |
+
├── nginx.conf
|
| 217 |
+
├── package-lock.json
|
| 218 |
+
├── package.json
|
| 219 |
+
├── README.md
|
| 220 |
+
├── requirements.txt
|
| 221 |
+
├── SETUP_GUIDE.md
|
| 222 |
+
├── simple_timezone_test.py
|
| 223 |
+
├── start_app.py
|
| 224 |
+
├── start_celery.py
|
| 225 |
+
├── start-dev.js
|
| 226 |
+
├── starty.py
|
| 227 |
+
├── test_apscheduler.py
|
| 228 |
+
├── test_imports.py
|
| 229 |
+
├── test_scheduler_integration.py
|
| 230 |
+
├── test_scheduler_visibility.py
|
| 231 |
+
├── test_timezone_functionality.py
|
| 232 |
+
├── .qwen/
|
| 233 |
+
├── backend/
|
| 234 |
+
│ ├── __init__.py
|
| 235 |
+
│ ├── .env.example
|
| 236 |
+
│ ├── app.py
|
| 237 |
+
│ ├── config.py
|
| 238 |
+
│ ├── Dockerfile
|
| 239 |
+
│ ├── README.md
|
| 240 |
+
│ ├── requirements.txt
|
| 241 |
+
│ ├── test_database_connection.py
|
| 242 |
+
│ ├── test_oauth_callback.py
|
| 243 |
+
│ ├── test_oauth_flow.py
|
| 244 |
+
│ ├── TESTING_GUIDE.md
|
| 245 |
+
│ ├── api/
|
| 246 |
+
│ │ ├── __init__.py
|
| 247 |
+
│ │ ├── accounts.py
|
| 248 |
+
│ │ ├── auth.py
|
| 249 |
+
│ │ ├── posts.py
|
| 250 |
+
│ │ ├── schedules.py
|
| 251 |
+
│ │ └── sources.py
|
| 252 |
+
│ ├── models/
|
| 253 |
+
│ │ ├── __init__.py
|
| 254 |
+
│ │ ├── schedule.py
|
| 255 |
+
│ │ └── user.py
|
| 256 |
+
│ ├── scheduler/
|
| 257 |
+
│ │ ├── __init__.py
|
| 258 |
+
│ │ └── apscheduler_service.py
|
| 259 |
+
│ ├── services/
|
| 260 |
+
│ │ ├── __init__.py
|
| 261 |
+
│ │ ├── auth_service.py
|
| 262 |
+
�� │ ├── content_service.py
|
| 263 |
+
│ │ ├── linkedin_service.py
|
| 264 |
+
│ │ └── schedule_service.py
|
| 265 |
+
│ ├── tests/
|
| 266 |
+
│ │ ├── test_frontend_integration.py
|
| 267 |
+
│ │ └── test_scheduler_image_integration.py
|
| 268 |
+
│ ├── utils/
|
| 269 |
+
│ │ ├── __init__.py
|
| 270 |
+
│ │ ├── cookies.py
|
| 271 |
+
│ │ ├── database.py
|
| 272 |
+
│ │ ├── image_utils.py
|
| 273 |
+
│ │ └── timezone_utils.py
|
| 274 |
+
│ └── .gitignore
|
| 275 |
+
├── docu_code/
|
| 276 |
+
│ ├── My_data_base_schema_.txt
|
| 277 |
+
│ └── supabase.txt
|
| 278 |
+
├── fav/
|
| 279 |
+
│ └── Capture d'écran 2025-08-16 223532.png
|
| 280 |
+
├── frontend/
|
| 281 |
+
│ ├── .env.development
|
| 282 |
+
│ ├── .env.example
|
| 283 |
+
│ ├── .env.production
|
| 284 |
+
│ ├── .eslintrc.cjs
|
| 285 |
+
│ ├── DESIGN_SYSTEM.md
|
| 286 |
+
│ ├── Dockerfile
|
| 287 |
+
│ ├── index.html
|
| 288 |
+
│ ├── package-lock.json
|
| 289 |
+
│ ├── package.json
|
| 290 |
+
│ ├── postcss.config.js
|
| 291 |
+
│ ├── README.md
|
| 292 |
+
│ ├── RESPONSIVE_DESIGN_VALIDATION.md
|
| 293 |
+
│ ├── tailwind.config.js
|
| 294 |
+
│ ├── test-auth-fix.js
|
| 295 |
+
│ ├── tsconfig.json
|
| 296 |
+
│ ├── tsconfig.node.json
|
| 297 |
+
│ ├── vite.config.js
|
| 298 |
+
│ ├── public/
|
| 299 |
+
│ │ ├── favicon.ico
|
| 300 |
+
│ │ ├── favicon.png
|
| 301 |
+
│ │ ├── index.html
|
| 302 |
+
│ │ └── manifest.json
|
| 303 |
+
│ ├── scripts/
|
| 304 |
+
│ │ └── build-env.js
|
| 305 |
+
│ ├── src/
|
| 306 |
+
│ │ ├── App.css
|
| 307 |
+
│ │ ├── App.jsx
|
| 308 |
+
│ │ ├── index.css
|
| 309 |
+
│ │ ├── index.jsx
|
| 310 |
+
│ │ ├── layout-test.js
|
| 311 |
+
│ │ ├── responsive-design-test.js
|
| 312 |
+
│ │ ├── responsive.css
|
| 313 |
+
│ │ ├── components/
|
| 314 |
+
│ │ │ ├── FeatureCard.jsx
|
| 315 |
+
│ │ │ ├── TestimonialCard.jsx
|
| 316 |
+
│ │ │ ├── Header/
|
| 317 |
+
│ │ │ │ ├── Header.css
|
| 318 |
+
│ │ │ │ └── Header.jsx
|
| 319 |
+
│ │ │ ├── LinkedInAccount/
|
| 320 |
+
│ │ │ │ ├── LinkedInAccountCard.jsx
|
| 321 |
+
│ │ │ │ ├── LinkedInAccountsManager.jsx
|
| 322 |
+
│ │ │ │ └── LinkedInCallbackHandler.jsx
|
| 323 |
+
│ │ │ └── Sidebar/
|
| 324 |
+
│ │ │ └── Sidebar.jsx
|
| 325 |
+
│ │ ├── css/
|
| 326 |
+
│ │ │ ├── base.css
|
| 327 |
+
│ │ │ ├── components.css.bak
|
| 328 |
+
│ │ │ ├── main.css
|
| 329 |
+
│ │ │ ├── responsive.css
|
| 330 |
+
│ │ │ ├── typography.css
|
| 331 |
+
│ │ │ ├── variables.css
|
| 332 |
+
│ │ │ ├── components/
|
| 333 |
+
│ │ │ ├── buttons.css
|
| 334 |
+
│ │ │ │ ├── cards.css
|
| 335 |
+
│ │ │ │ ├── forms.css
|
| 336 |
+
│ │ │ │ ├── grid.css
|
| 337 |
+
│ │ │ │ ├── header.css
|
| 338 |
+
│ │ │ │ ├── linkedin.css
|
| 339 |
+
│ │ │ │ ├── modal.css
|
| 340 |
+
│ │ │ │ ├── navigation.css
|
| 341 |
+
│ │ │ │ ├── sidebar.css
|
| 342 |
+
│ │ │ │ ├── table.css
|
| 343 |
+
│ │ │ │ └── utilities.css
|
| 344 |
+
│ │ │ └── responsive/
|
| 345 |
+
│ │ │ ├── accessibility.css
|
| 346 |
+
│ │ │ ├── base.css
|
| 347 |
+
│ │ │ ├── mobile-nav.css
|
| 348 |
+
│ │ │ ├── performance.css
|
| 349 |
+
│ │ │ └── performance/
|
| 350 |
+
│ │ │ ├── lazy-loading.css
|
| 351 |
+
│ │ │ └── mobile-optimization.css
|
| 352 |
+
│ │ ├── debug/
|
| 353 |
+
│ │ │ ├── testApi.js
|
| 354 |
+
│ │ │ └── testApiIntegration.js
|
| 355 |
+
│ │ ├── pages/
|
| 356 |
+
│ │ │ ├── Accounts.jsx
|
| 357 |
+
│ │ │ ├── Dashboard.jsx
|
| 358 |
+
│ │ │ ├── ForgotPassword.jsx
|
| 359 |
+
│ │ │ ├── Home.jsx
|
| 360 |
+
│ │ │ ├── Login.jsx
|
| 361 |
+
│ │ │ ├── Posts.jsx
|
| 362 |
+
│ │ │ ├── Register.jsx
|
| 363 |
+
│ │ │ ├── ResetPassword.jsx
|
| 364 |
+
│ │ │ ├── Schedule.jsx
|
| 365 |
+
│ │ │ └── Sources.jsx
|
| 366 |
+
│ │ ├── services/
|
| 367 |
+
│ │ │ ├── accountService.js
|
| 368 |
+
│ │ │ ├── api.js
|
| 369 |
+
│ │ │ ├── apiClient.js
|
| 370 |
+
│ │ │ ├── authService.js
|
| 371 |
+
│ │ │ ├── cacheService.js
|
| 372 |
+
│ │ │ ├── cookieService.js
|
| 373 |
+
│ │ │ ├── linkedinAuthService.js
|
| 374 |
+
│ │ │ ├── postService.js
|
| 375 |
+
│ │ │ ├── scheduleService.js
|
| 376 |
+
│ │ │ ├── securityService.js
|
| 377 |
+
│ │ │ ├── sourceService.js
|
| 378 |
+
│ │ │ └── supabaseClient.js
|
| 379 |
+
│ │ ├── store/
|
| 380 |
+
│ │ │ ├── index.js
|
| 381 |
+
│ │ │ └── reducers/
|
| 382 |
+
│ │ │ ├── accountsSlice.js
|
| 383 |
+
│ │ │ ├── authSlice.js
|
| 384 |
+
│ │ │ ├── linkedinAccountsSlice.js
|
| 385 |
+
│ │ │ ├── postsSlice.js
|
| 386 |
+
│ │ │ ├── schedulesSlice.js
|
| 387 |
+
│ │ │ └── sourcesSlice.js
|
| 388 |
+
│ │ └─�� utils/
|
| 389 |
+
│ │ └── timezoneUtils.js
|
| 390 |
+
│ └── .gitignore
|
| 391 |
+
├── Linkedin_poster_dev/
|
| 392 |
+
│ ├── .gitattributes
|
| 393 |
+
│ ├── ai_agent.py
|
| 394 |
+
│ ├── app.py
|
| 395 |
+
│ ├── README.md
|
| 396 |
+
│ └── requirements.txt
|
| 397 |
+
└── docs/
|
| 398 |
+
└── architecture.md
|
| 399 |
+
```
|
| 400 |
+
|
| 401 |
+
### 8.2 New File Organization
|
| 402 |
+
```
|
| 403 |
+
Lin/
|
| 404 |
+
├── frontend/
|
| 405 |
+
│ └── src/
|
| 406 |
+
│ ├── components/
|
| 407 |
+
│ │ └── KeywordAnalysis/ # New keyword analysis components
|
| 408 |
+
│ │ ├── KeywordAnalysisPanel.jsx
|
| 409 |
+
│ │ └── index.js
|
| 410 |
+
│ └── services/
|
| 411 |
+
│ └── keywordAnalysisService.js
|
| 412 |
+
├── backend/
|
| 413 |
+
│ ├── services/
|
| 414 |
+
│ │ ├── keyword_analysis_service.py # New service
|
| 415 |
+
│ │ └── content_service.py # Updated with FLUX.1-dev
|
| 416 |
+
│ └── api/
|
| 417 |
+
│ └── posts.py # Extended with new endpoints
|
| 418 |
+
└── Linkedin_poster_dev/
|
| 419 |
+
└── ai_agent.py # Updated with FLUX.1-dev
|
| 420 |
+
```
|
| 421 |
+
|
| 422 |
+
### 8.3 Integration Guidelines
|
| 423 |
+
- **File Naming:** Follow existing snake_case for Python and camelCase for JavaScript
|
| 424 |
+
- **Folder Organization:** Place new components in appropriate existing directories
|
| 425 |
+
- **Import/Export Patterns:** Maintain existing patterns in the codebase
|
| 426 |
+
|
| 427 |
+
## 9. Infrastructure and Deployment Integration
|
| 428 |
+
|
| 429 |
+
### 9.1 Existing Infrastructure
|
| 430 |
+
**Current Deployment:** Docker with docker-compose and Nginx reverse proxy
|
| 431 |
+
**Infrastructure Tools:** Docker, docker-compose, Nginx, Redis for Celery
|
| 432 |
+
**Environments:** Development and production configurations available
|
| 433 |
+
|
| 434 |
+
### 9.2 Enhancement Deployment Strategy
|
| 435 |
+
**Deployment Approach:** No infrastructure changes required, using existing setup
|
| 436 |
+
**Infrastructure Changes:** None
|
| 437 |
+
**Pipeline Integration:** No changes to existing deployment pipeline
|
| 438 |
+
|
| 439 |
+
### 9.3 Rollback Strategy
|
| 440 |
+
**Rollback Method:** Revert changes to ai_agent.py to restore Qwen functionality
|
| 441 |
+
**Risk Mitigation:** Thorough testing before deployment
|
| 442 |
+
**Monitoring:** Monitor API response times and error rates
|
| 443 |
+
|
| 444 |
+
## 10. Coding Standards
|
| 445 |
+
|
| 446 |
+
### 10.1 Existing Standards Compliance
|
| 447 |
+
**Code Style:** Follow existing Python (PEP 8) and JavaScript (ESLint) standards
|
| 448 |
+
**Linting Rules:** Use existing linting configurations
|
| 449 |
+
**Testing Patterns:** Follow existing pytest and React testing patterns
|
| 450 |
+
**Documentation Style:** Follow existing docstring and JSDoc patterns
|
| 451 |
+
|
| 452 |
+
### 10.2 Critical Integration Rules
|
| 453 |
+
- **Existing API Compatibility:** New endpoints must follow existing authentication patterns
|
| 454 |
+
- **Database Integration:** Use existing Supabase connection and query patterns
|
| 455 |
+
- **Error Handling:** Follow existing error response format
|
| 456 |
+
- **Logging Consistency:** Use existing logging patterns
|
| 457 |
+
|
| 458 |
+
## 11. Testing Strategy
|
| 459 |
+
|
| 460 |
+
### 11.1 Integration with Existing Tests
|
| 461 |
+
**Existing Test Framework:** pytest for backend, Jest/React Testing Library for frontend
|
| 462 |
+
**Test Organization:** Follow existing test directory structure
|
| 463 |
+
**Coverage Requirements:** Maintain existing coverage thresholds
|
| 464 |
+
|
| 465 |
+
### 11.2 New Testing Requirements
|
| 466 |
+
|
| 467 |
+
#### Unit Tests for New Components
|
| 468 |
+
**Framework:** pytest for backend, React Testing Library for frontend
|
| 469 |
+
**Location:** backend/tests/ and frontend/src/tests/
|
| 470 |
+
**Coverage Target:** 80%+ for new code
|
| 471 |
+
**Integration with Existing:** Follow existing test patterns
|
| 472 |
+
|
| 473 |
+
#### Integration Tests
|
| 474 |
+
**Scope:** Test new API endpoints with authentication
|
| 475 |
+
**Existing System Verification:** Ensure existing functionality remains intact
|
| 476 |
+
**New Feature Testing:** Validate keyword analysis and image generation
|
| 477 |
+
|
| 478 |
+
#### Regression Testing
|
| 479 |
+
**Existing Feature Verification:** Run all existing tests to ensure no regressions
|
| 480 |
+
**Automated Regression Suite:** Use existing CI pipeline
|
| 481 |
+
**Manual Testing Requirements:** Test end-to-end workflows manually
|
| 482 |
+
|
| 483 |
+
## 12. Security Integration
|
| 484 |
+
|
| 485 |
+
### 12.1 Existing Security Measures
|
| 486 |
+
**Authentication:** JWT token-based authentication
|
| 487 |
+
**Authorization:** Role-based access control
|
| 488 |
+
**Data Protection:** Supabase security and encryption
|
| 489 |
+
**Security Tools:** Built-in Flask security features
|
| 490 |
+
|
| 491 |
+
### 12.2 Enhancement Security Requirements
|
| 492 |
+
**New Security Measures:** Input validation for new API endpoints
|
| 493 |
+
**Integration Points:** Use existing authentication for all new endpoints
|
| 494 |
+
**Compliance Requirements:** Maintain existing data privacy standards
|
| 495 |
+
|
| 496 |
+
### 12.3 Security Testing
|
| 497 |
+
**Existing Security Tests:** Continue running existing security tests
|
| 498 |
+
**New Security Test Requirements:** Validate input sanitization for new endpoints
|
| 499 |
+
**Penetration Testing:** None specifically required for these enhancements
|
| 500 |
+
|
| 501 |
+
## 13. Next Steps
|
| 502 |
+
|
| 503 |
+
### 13.1 Story Manager Handoff
|
| 504 |
+
The architecture document provides a clear roadmap for implementing the UI/UX improvements, keyword analysis feature, and FLUX.1-dev image generation. The key integration requirements have been validated with the existing system. Begin with implementing the keyword analysis feature, followed by the FLUX.1-dev integration, and finally the UI/UX enhancements. Emphasis should be placed on maintaining existing system integrity throughout implementation.
|
| 505 |
+
|
| 506 |
+
### 13.2 Developer Handoff
|
| 507 |
+
Developers should reference this architecture document and existing coding standards when starting implementation. The integration requirements with the existing codebase have been validated. Key technical decisions are based on real project constraints, and existing system compatibility requirements include specific verification steps for API compatibility. The implementation should follow a clear sequence to minimize risk to existing functionality: keyword analysis service first, then FLUX.1-dev integration, and finally UI enhancements.
|
docs/sprint-artifacts/tech_spec_job_tracking.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Tech-Spec 1: Fix Background Task and Job Tracking in Gunicorn Environment
|
| 2 |
+
|
| 3 |
+
**Created:** 2025-12-22
|
| 4 |
+
**Status:** Completed
|
| 5 |
+
|
| 6 |
+
## Overview
|
| 7 |
+
|
| 8 |
+
### Problem Statement
|
| 9 |
+
After migrating from Flask's development server to Gunicorn, the background task system for post generation is not working correctly. While tasks are submitted and processed successfully on the generation server, the job status tracking fails because job states are not accessible across multiple Gunicorn workers. This results in 404 errors when trying to fetch job status and causes the SSE connection to time out, preventing updates from reaching the frontend.
|
| 10 |
+
|
| 11 |
+
### Solution
|
| 12 |
+
Implemented a shared storage mechanism for job state management that works across multiple Gunicorn workers, ensuring consistent job tracking and proper SSE communication.
|
| 13 |
+
|
| 14 |
+
### Scope (In/Out)
|
| 15 |
+
|
| 16 |
+
**In Scope:**
|
| 17 |
+
- Implement shared job state storage (Redis, database, or other shared storage)
|
| 18 |
+
- Update background task management to use shared storage instead of in-memory storage
|
| 19 |
+
- Fix SSE connection handling in multi-worker environment using Flask's streaming features
|
| 20 |
+
- Update job status polling endpoints to access shared storage
|
| 21 |
+
- Ensure proper request context handling for streaming responses
|
| 22 |
+
|
| 23 |
+
**Out Scope:**
|
| 24 |
+
- Changing the Gradio API integration
|
| 25 |
+
- Modifying the frontend UI components
|
| 26 |
+
- Refactoring the core post generation logic
|
| 27 |
+
|
| 28 |
+
## Context for Development
|
| 29 |
+
|
| 30 |
+
### Codebase Patterns
|
| 31 |
+
- Flask REST API backend
|
| 32 |
+
- Background task processing with job tracking
|
| 33 |
+
- Server-sent events for real-time updates
|
| 34 |
+
- Gradio API integration for content generation
|
| 35 |
+
- Use of `stream_with_context` for streaming responses
|
| 36 |
+
|
| 37 |
+
### Files to Reference
|
| 38 |
+
- `app.py` - Main Flask application
|
| 39 |
+
- `posts.py` - Contains the job submission and tracking logic (around line 196 based on logs)
|
| 40 |
+
- `content_service.py` - Gradio API integration
|
| 41 |
+
- `start_gunicorn.py` - Gunicorn configuration
|
| 42 |
+
- Frontend JavaScript files handling SSE connections
|
| 43 |
+
|
| 44 |
+
### Technical Decisions
|
| 45 |
+
- Use Redis for shared job state storage (fast, simple, good for temporary job data)
|
| 46 |
+
- Implement proper job lifecycle management (created, processing, completed, failed)
|
| 47 |
+
- Use Flask's `stream_with_context` for proper SSE handling
|
| 48 |
+
|
| 49 |
+
## Implementation Plan
|
| 50 |
+
|
| 51 |
+
### Tasks
|
| 52 |
+
|
| 53 |
+
- [x] Task 1: Set up Redis connection and configure Flask-Session with Redis backend
|
| 54 |
+
- [x] Task 2: Update job creation and tracking to use Redis instead of in-memory storage
|
| 55 |
+
- [x] Task 3: Modify background task handlers to update job state in Redis
|
| 56 |
+
- [x] Task 4: Update job status polling endpoint to fetch from Redis
|
| 57 |
+
- [x] Task 5: Fix SSE connection handling using Flask's streaming capabilities with `stream_with_context`
|
| 58 |
+
- [x] Task 6: Test the complete flow from job submission to completion
|
| 59 |
+
|
| 60 |
+
### Acceptance Criteria
|
| 61 |
+
|
| 62 |
+
- [x] AC 1: Job state is accessible across all Gunicorn workers via Redis
|
| 63 |
+
- [x] AC 2: Job status polling endpoint returns correct job status (not 404)
|
| 64 |
+
- [x] AC 3: SSE connections receive real-time updates about job progress using proper streaming
|
| 65 |
+
- [x] AC 4: Generated posts are properly displayed on the frontend after completion
|
| 66 |
+
- [x] AC 5: No regressions in existing functionality
|
| 67 |
+
|
| 68 |
+
## Additional Context
|
| 69 |
+
|
| 70 |
+
### Dependencies
|
| 71 |
+
- Redis server for shared storage
|
| 72 |
+
- Updated Flask application with Redis integration
|
| 73 |
+
- Flask-Session with Redis backend
|
| 74 |
+
- Gunicorn configuration with appropriate worker settings
|
| 75 |
+
|
| 76 |
+
### Testing Strategy
|
| 77 |
+
- Unit tests for Redis job state management
|
| 78 |
+
- Integration tests for job submission and status tracking
|
| 79 |
+
- End-to-end test of the complete post generation flow
|
| 80 |
+
- Test SSE connections with multiple Gunicorn workers
|
| 81 |
+
|
| 82 |
+
### Notes
|
| 83 |
+
- Consider job cleanup for completed/failed jobs to prevent Redis memory issues
|
| 84 |
+
- Ensure proper error handling when Redis is unavailable
|
| 85 |
+
- May need to adjust Gunicorn worker count to optimize for background tasks
|
| 86 |
+
- Use Flask's `stream_with_context` to properly handle streaming responses in multi-worker environments
|
docs/sprint-artifacts/tech_spec_remote_logging.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Tech-Spec 2: Remote Application Logging with Redis
|
| 2 |
+
|
| 3 |
+
**Created:** 2025-12-22
|
| 4 |
+
**Status:** Ready for Development
|
| 5 |
+
|
| 6 |
+
## Overview
|
| 7 |
+
|
| 8 |
+
### Problem Statement
|
| 9 |
+
Need to centralize application logs in Redis to enable remote access and monitoring of application behavior across multiple Gunicorn workers. This will help with debugging issues like the background task tracking problem and provide better visibility into application performance.
|
| 10 |
+
|
| 11 |
+
### Solution
|
| 12 |
+
Implement a logging system that sends application logs to Redis, enabling remote access, centralized monitoring, and real-time log streaming.
|
| 13 |
+
|
| 14 |
+
### Scope (In/Out)
|
| 15 |
+
|
| 16 |
+
**In Scope:**
|
| 17 |
+
- Implement Redis-based logging handler
|
| 18 |
+
- Configure log formatting for Redis storage
|
| 19 |
+
- Create log retrieval endpoints
|
| 20 |
+
- Implement log streaming capabilities
|
| 21 |
+
- Add log retention and cleanup policies
|
| 22 |
+
|
| 23 |
+
**Out Scope:**
|
| 24 |
+
- Replacing existing logging infrastructure completely
|
| 25 |
+
- Implementing complex log analytics
|
| 26 |
+
- Creating a full dashboard UI
|
| 27 |
+
|
| 28 |
+
## Context for Development
|
| 29 |
+
|
| 30 |
+
### Codebase Patterns
|
| 31 |
+
- Flask application with existing logging infrastructure
|
| 32 |
+
- Multiple Gunicorn workers requiring centralized logging
|
| 33 |
+
- Need for remote log access and debugging
|
| 34 |
+
|
| 35 |
+
### Files to Reference
|
| 36 |
+
- `app.py` - Main Flask application where logging is configured
|
| 37 |
+
- `gunicorn.conf.py` - Gunicorn configuration
|
| 38 |
+
- Any existing logging configuration files
|
| 39 |
+
|
| 40 |
+
### Technical Decisions
|
| 41 |
+
- Use Redis lists for log storage with push operations
|
| 42 |
+
- Implement log level filtering and retention
|
| 43 |
+
- Use structured logging format for easier parsing
|
| 44 |
+
- Add log rotation to prevent Redis memory issues
|
| 45 |
+
|
| 46 |
+
## Implementation Plan
|
| 47 |
+
|
| 48 |
+
### Tasks
|
| 49 |
+
|
| 50 |
+
- [ ] Task 1: Set up Redis connection for logging
|
| 51 |
+
- [ ] Task 2: Create custom Python logging handler for Redis
|
| 52 |
+
- [ ] Task 3: Configure Flask application to use Redis logger
|
| 53 |
+
- [ ] Task 4: Create API endpoints to retrieve logs from Redis
|
| 54 |
+
- [ ] Task 5: Implement log retention and cleanup policies
|
| 55 |
+
- [ ] Task 6: Test logging functionality with multiple Gunicorn workers
|
| 56 |
+
|
| 57 |
+
### Acceptance Criteria
|
| 58 |
+
|
| 59 |
+
- [ ] AC 1: Application logs are stored in Redis with proper formatting
|
| 60 |
+
- [ ] AC 2: Remote access to logs via API endpoints
|
| 61 |
+
- [ ] AC 3: Logs from all Gunicorn workers are consolidated in Redis
|
| 62 |
+
- [ ] AC 4: Log retention prevents Redis memory overflow
|
| 63 |
+
- [ ] AC 5: Structured logs include timestamp, level, module, and message
|
| 64 |
+
|
| 65 |
+
## Additional Context
|
| 66 |
+
|
| 67 |
+
### Implementation Details
|
| 68 |
+
|
| 69 |
+
#### 1. Redis Logging Handler
|
| 70 |
+
```python
|
| 71 |
+
import redis
|
| 72 |
+
import json
|
| 73 |
+
import logging
|
| 74 |
+
from datetime import datetime
|
| 75 |
+
|
| 76 |
+
class RedisLogHandler(logging.Handler):
|
| 77 |
+
def __init__(self, redis_client, key='app_logs', max_logs=1000):
|
| 78 |
+
super().__init__()
|
| 79 |
+
self.redis_client = redis_client
|
| 80 |
+
self.key = key
|
| 81 |
+
self.max_logs = max_logs
|
| 82 |
+
|
| 83 |
+
def emit(self, record):
|
| 84 |
+
try:
|
| 85 |
+
log_entry = {
|
| 86 |
+
'timestamp': datetime.utcnow().isoformat(),
|
| 87 |
+
'level': record.levelname,
|
| 88 |
+
'module': record.module,
|
| 89 |
+
'function': record.funcName,
|
| 90 |
+
'line': record.lineno,
|
| 91 |
+
'message': record.getMessage(),
|
| 92 |
+
'logger': record.name
|
| 93 |
+
}
|
| 94 |
+
# Add exception info if present
|
| 95 |
+
if record.exc_info:
|
| 96 |
+
log_entry['exception'] = self.format(record)
|
| 97 |
+
|
| 98 |
+
# Push to Redis list
|
| 99 |
+
self.redis_client.lpush(self.key, json.dumps(log_entry))
|
| 100 |
+
# Trim to max_logs to prevent memory issues
|
| 101 |
+
self.redis_client.ltrim(self.key, 0, self.max_logs - 1)
|
| 102 |
+
except Exception:
|
| 103 |
+
self.handleError(record)
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
#### 2. Flask Integration
|
| 107 |
+
```python
|
| 108 |
+
import redis
|
| 109 |
+
from flask import Flask
|
| 110 |
+
|
| 111 |
+
# Initialize Redis connection
|
| 112 |
+
redis_client = redis.Redis(host='localhost', port=6379, db=0)
|
| 113 |
+
|
| 114 |
+
# Configure logging
|
| 115 |
+
app = Flask(__name__)
|
| 116 |
+
redis_handler = RedisLogHandler(redis_client, key='flask_app_logs')
|
| 117 |
+
redis_handler.setLevel(logging.INFO)
|
| 118 |
+
|
| 119 |
+
# Add to Flask app logger
|
| 120 |
+
app.logger.addHandler(redis_handler)
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
#### 3. API Endpoints for Log Retrieval
|
| 124 |
+
```python
|
| 125 |
+
from flask import jsonify
|
| 126 |
+
|
| 127 |
+
@app.route('/api/logs')
|
| 128 |
+
def get_logs():
|
| 129 |
+
count = request.args.get('count', 50, type=int)
|
| 130 |
+
logs = redis_client.lrange('flask_app_logs', 0, count - 1)
|
| 131 |
+
return jsonify([json.loads(log) for log in logs])
|
| 132 |
+
|
| 133 |
+
@app.route('/api/logs/levels/<level>')
|
| 134 |
+
def get_logs_by_level(level):
|
| 135 |
+
all_logs = redis_client.lrange('flask_app_logs', 0, -1)
|
| 136 |
+
filtered_logs = []
|
| 137 |
+
for log in all_logs:
|
| 138 |
+
log_data = json.loads(log)
|
| 139 |
+
if log_data['level'] == level.upper():
|
| 140 |
+
filtered_logs.append(log_data)
|
| 141 |
+
return jsonify(filtered_logs)
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
### Dependencies
|
| 145 |
+
- `redis` - Python Redis client
|
| 146 |
+
- `flask` - For API endpoints
|
| 147 |
+
|
| 148 |
+
### Testing Strategy
|
| 149 |
+
- Unit test the Redis logging handler
|
| 150 |
+
- Integration test log storage and retrieval
|
| 151 |
+
- Test with multiple Gunicorn workers to ensure logs are centralized
|
| 152 |
+
- Test log retention policies
|
| 153 |
+
|
| 154 |
+
### Notes
|
| 155 |
+
- Consider using Redis streams instead of lists for more advanced log querying
|
| 156 |
+
- Implement log rotation to prevent Redis memory issues
|
| 157 |
+
- Add authentication/authorization for log access endpoints
|
| 158 |
+
- Consider using a separate Redis database for logs
|
| 159 |
+
- Monitor Redis memory usage with logging enabled
|
test_redis_job_store.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test script to verify Redis job store functionality
|
| 3 |
+
"""
|
| 4 |
+
import sys
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
# Add the project root to the Python path
|
| 8 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 9 |
+
|
| 10 |
+
from backend.utils.redis_job_store import RedisJobStore
|
| 11 |
+
|
| 12 |
+
def test_redis_job_store():
|
| 13 |
+
"""Test the Redis job store functionality."""
|
| 14 |
+
print("Testing Redis Job Store...")
|
| 15 |
+
|
| 16 |
+
# Initialize Redis job store
|
| 17 |
+
try:
|
| 18 |
+
redis_job_store = RedisJobStore('redis://localhost:6379/0')
|
| 19 |
+
print("[OK] Redis job store initialized successfully")
|
| 20 |
+
except Exception as e:
|
| 21 |
+
print(f"[ERROR] Failed to initialize Redis job store: {e}")
|
| 22 |
+
return False
|
| 23 |
+
|
| 24 |
+
# Test creating a job with default status
|
| 25 |
+
try:
|
| 26 |
+
job_id = redis_job_store.create_job(initial_status='pending', initial_data={'test': True})
|
| 27 |
+
print(f"[OK] Job created successfully with ID: {job_id}")
|
| 28 |
+
except Exception as e:
|
| 29 |
+
print(f"[ERROR] Failed to create job: {e}")
|
| 30 |
+
return False
|
| 31 |
+
|
| 32 |
+
# Test getting the job
|
| 33 |
+
try:
|
| 34 |
+
job = redis_job_store.get_job(job_id)
|
| 35 |
+
if job and job['status'] == 'pending':
|
| 36 |
+
print(f"[OK] Job retrieved successfully: {job}")
|
| 37 |
+
else:
|
| 38 |
+
print(f"[ERROR] Job not found or incorrect status: {job}")
|
| 39 |
+
return False
|
| 40 |
+
except Exception as e:
|
| 41 |
+
print(f"[ERROR] Failed to get job: {e}")
|
| 42 |
+
return False
|
| 43 |
+
|
| 44 |
+
# Test updating the job
|
| 45 |
+
try:
|
| 46 |
+
success = redis_job_store.update_job(job_id, status='completed', result={'data': 'test result'})
|
| 47 |
+
if success:
|
| 48 |
+
print("[OK] Job updated successfully")
|
| 49 |
+
else:
|
| 50 |
+
print("[ERROR] Job update failed")
|
| 51 |
+
return False
|
| 52 |
+
except Exception as e:
|
| 53 |
+
print(f"[ERROR] Failed to update job: {e}")
|
| 54 |
+
return False
|
| 55 |
+
|
| 56 |
+
# Test getting the updated job
|
| 57 |
+
try:
|
| 58 |
+
job = redis_job_store.get_job(job_id)
|
| 59 |
+
if job and job['status'] == 'completed' and job['result']['data'] == 'test result':
|
| 60 |
+
print(f"[OK] Updated job retrieved successfully: {job}")
|
| 61 |
+
else:
|
| 62 |
+
print(f"[ERROR] Job not updated correctly: {job}")
|
| 63 |
+
return False
|
| 64 |
+
except Exception as e:
|
| 65 |
+
print(f"[ERROR] Failed to get updated job: {e}")
|
| 66 |
+
return False
|
| 67 |
+
|
| 68 |
+
# Test deleting the job
|
| 69 |
+
try:
|
| 70 |
+
success = redis_job_store.delete_job(job_id)
|
| 71 |
+
if success:
|
| 72 |
+
print("[OK] Job deleted successfully")
|
| 73 |
+
else:
|
| 74 |
+
print("[ERROR] Job deletion failed")
|
| 75 |
+
return False
|
| 76 |
+
except Exception as e:
|
| 77 |
+
print(f"[ERROR] Failed to delete job: {e}")
|
| 78 |
+
return False
|
| 79 |
+
|
| 80 |
+
# Test validation functionality
|
| 81 |
+
try:
|
| 82 |
+
# Test invalid status
|
| 83 |
+
try:
|
| 84 |
+
redis_job_store.create_job(initial_status='invalid_status')
|
| 85 |
+
print("[ERROR] Should have failed with invalid status")
|
| 86 |
+
return False
|
| 87 |
+
except ValueError:
|
| 88 |
+
print("[OK] Correctly rejected invalid status")
|
| 89 |
+
|
| 90 |
+
# Test invalid job ID format
|
| 91 |
+
try:
|
| 92 |
+
redis_job_store.get_job("invalid job id with spaces")
|
| 93 |
+
print("[ERROR] Should have failed with invalid job ID format")
|
| 94 |
+
return False
|
| 95 |
+
except ValueError:
|
| 96 |
+
print("[OK] Correctly rejected invalid job ID format")
|
| 97 |
+
except Exception as e:
|
| 98 |
+
print(f"[ERROR] Validation tests failed: {e}")
|
| 99 |
+
return False
|
| 100 |
+
|
| 101 |
+
print("[OK] All Redis job store tests passed!")
|
| 102 |
+
return True
|
| 103 |
+
|
| 104 |
+
if __name__ == "__main__":
|
| 105 |
+
success = test_redis_job_store()
|
| 106 |
+
if success:
|
| 107 |
+
print("\nRedis job store is working correctly!")
|
| 108 |
+
else:
|
| 109 |
+
print("\nRedis job store tests failed!")
|
| 110 |
+
sys.exit(1)
|