## Kanban Real-time Sync (NEW-002)
- Backend:
- WebSocket endpoint: /ws/projects/{project_id}
- Project room management in ConnectionManager
- Redis Pub/Sub: project:{project_id}:tasks channel
- Task CRUD event publishing (5 event types)
- Redis connection retry with exponential backoff
- Race condition fix in broadcast_to_project
- Frontend:
- ProjectSyncContext for WebSocket management
- Reconnection with exponential backoff (max 5 attempts)
- Multi-tab event deduplication via event_id
- Live/Offline connection indicator
- Optimistic updates with rollback
- Spec:
- collaboration spec: +1 requirement (Project Real-time Sync)
- 7 new scenarios for real-time sync
## Workload Cache Fix (NEW-001)
- Added cache invalidation to all task endpoints:
- create_task, update_task, update_task_status
- delete_task, restore_task, assign_task
- Extended to clear heatmap cache as well
## OpenSpec Archive
- 2026-01-05-add-kanban-realtime-sync
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
174 lines
5.1 KiB
Python
174 lines
5.1 KiB
Python
"""Workload cache service using Redis.
|
|
|
|
Provides caching for workload calculations to improve API response times.
|
|
"""
|
|
import json
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
from typing import Optional, List
|
|
|
|
from app.core.redis import redis_client
|
|
from app.schemas.workload import UserWorkloadSummary, LoadLevel
|
|
|
|
# Cache TTL in seconds (1 hour)
|
|
WORKLOAD_CACHE_TTL = 3600
|
|
|
|
|
|
def _make_heatmap_cache_key(
|
|
week_start: date,
|
|
department_id: Optional[str] = None,
|
|
user_ids: Optional[List[str]] = None,
|
|
) -> str:
|
|
"""Generate cache key for heatmap query."""
|
|
parts = ["workload", "heatmap", str(week_start)]
|
|
if department_id:
|
|
parts.append(f"dept:{department_id}")
|
|
if user_ids:
|
|
parts.append(f"users:{','.join(sorted(user_ids))}")
|
|
return ":".join(parts)
|
|
|
|
|
|
def _make_user_cache_key(user_id: str, week_start: date) -> str:
|
|
"""Generate cache key for user workload."""
|
|
return f"workload:user:{user_id}:{week_start}"
|
|
|
|
|
|
def _serialize_workload_summary(summary: UserWorkloadSummary) -> dict:
|
|
"""Serialize UserWorkloadSummary for JSON storage."""
|
|
return {
|
|
"user_id": summary.user_id,
|
|
"user_name": summary.user_name,
|
|
"department_id": summary.department_id,
|
|
"department_name": summary.department_name,
|
|
"capacity_hours": str(summary.capacity_hours),
|
|
"allocated_hours": str(summary.allocated_hours),
|
|
"load_percentage": str(summary.load_percentage) if summary.load_percentage else None,
|
|
"load_level": summary.load_level.value,
|
|
"task_count": summary.task_count,
|
|
}
|
|
|
|
|
|
def _deserialize_workload_summary(data: dict) -> UserWorkloadSummary:
|
|
"""Deserialize UserWorkloadSummary from JSON."""
|
|
return UserWorkloadSummary(
|
|
user_id=data["user_id"],
|
|
user_name=data["user_name"],
|
|
department_id=data["department_id"],
|
|
department_name=data["department_name"],
|
|
capacity_hours=Decimal(data["capacity_hours"]),
|
|
allocated_hours=Decimal(data["allocated_hours"]),
|
|
load_percentage=Decimal(data["load_percentage"]) if data["load_percentage"] else None,
|
|
load_level=LoadLevel(data["load_level"]),
|
|
task_count=data["task_count"],
|
|
)
|
|
|
|
|
|
def get_cached_heatmap(
|
|
week_start: date,
|
|
department_id: Optional[str] = None,
|
|
user_ids: Optional[List[str]] = None,
|
|
) -> Optional[List[UserWorkloadSummary]]:
|
|
"""
|
|
Get cached heatmap data.
|
|
|
|
Args:
|
|
week_start: Start of week
|
|
department_id: Department filter
|
|
user_ids: User IDs filter
|
|
|
|
Returns:
|
|
List of UserWorkloadSummary or None if not cached
|
|
"""
|
|
cache_key = _make_heatmap_cache_key(week_start, department_id, user_ids)
|
|
cached = redis_client.get(cache_key)
|
|
|
|
if cached:
|
|
data = json.loads(cached)
|
|
return [_deserialize_workload_summary(item) for item in data]
|
|
|
|
return None
|
|
|
|
|
|
def set_cached_heatmap(
|
|
week_start: date,
|
|
summaries: List[UserWorkloadSummary],
|
|
department_id: Optional[str] = None,
|
|
user_ids: Optional[List[str]] = None,
|
|
) -> None:
|
|
"""
|
|
Cache heatmap data.
|
|
|
|
Args:
|
|
week_start: Start of week
|
|
summaries: List of workload summaries
|
|
department_id: Department filter
|
|
user_ids: User IDs filter
|
|
"""
|
|
cache_key = _make_heatmap_cache_key(week_start, department_id, user_ids)
|
|
data = [_serialize_workload_summary(s) for s in summaries]
|
|
redis_client.setex(cache_key, WORKLOAD_CACHE_TTL, json.dumps(data))
|
|
|
|
|
|
def get_cached_user_workload(
|
|
user_id: str,
|
|
week_start: date,
|
|
) -> Optional[UserWorkloadSummary]:
|
|
"""
|
|
Get cached user workload.
|
|
|
|
Args:
|
|
user_id: User ID
|
|
week_start: Start of week
|
|
|
|
Returns:
|
|
UserWorkloadSummary or None if not cached
|
|
"""
|
|
cache_key = _make_user_cache_key(user_id, week_start)
|
|
cached = redis_client.get(cache_key)
|
|
|
|
if cached:
|
|
data = json.loads(cached)
|
|
return _deserialize_workload_summary(data)
|
|
|
|
return None
|
|
|
|
|
|
def set_cached_user_workload(
|
|
user_id: str,
|
|
week_start: date,
|
|
summary: UserWorkloadSummary,
|
|
) -> None:
|
|
"""
|
|
Cache user workload.
|
|
|
|
Args:
|
|
user_id: User ID
|
|
week_start: Start of week
|
|
summary: Workload summary
|
|
"""
|
|
cache_key = _make_user_cache_key(user_id, week_start)
|
|
data = _serialize_workload_summary(summary)
|
|
redis_client.setex(cache_key, WORKLOAD_CACHE_TTL, json.dumps(data))
|
|
|
|
|
|
def invalidate_user_workload_cache(user_id: str) -> None:
|
|
"""
|
|
Invalidate all cached workload data for a user.
|
|
|
|
This clears:
|
|
1. User-specific workload cache (workload:user:{user_id}:*)
|
|
2. All heatmap caches (workload:heatmap:*) since heatmap aggregates may include this user
|
|
|
|
Note: This uses pattern matching which may be slow for large datasets.
|
|
For Phase 1, we rely on TTL expiration instead of active invalidation.
|
|
"""
|
|
# Clear user-specific workload cache
|
|
user_pattern = f"workload:user:{user_id}:*"
|
|
for key in redis_client.scan_iter(match=user_pattern):
|
|
redis_client.delete(key)
|
|
|
|
# Clear all heatmap caches since they aggregate user data
|
|
heatmap_pattern = "workload:heatmap:*"
|
|
for key in redis_client.scan_iter(match=heatmap_pattern):
|
|
redis_client.delete(key)
|